#!/usr/bin/env python3 # -*- coding: utf-8 -*- """CLAUDE.md の運用ルール1〜5のうち、機械的に判定できる項目を検査する。 使い方: python .agents/skills/doc-lint/doc-lint.py [ファイル...] 引数なしならリポジトリ直下の *.md、journal/*.md、backlog/**/*.md が対象。 明示したパスは実行ディレクトリから解決する。 ERROR が1件でもあれば終了コード 1。 """ import re import sys from pathlib import Path from urllib.parse import unquote, urlsplit 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[^)]*)\)") INLINE_LINK = re.compile(r"(?\n]+>|[^\s)]+)\s*\)") INLINE_CODE = re.compile(r"(\x60+).*?\1", re.S) ISSUE_ID = re.compile(r"(?= fence[1] and not marker.group(2).strip()): fence = None continue if marker: fence = (marker.group(1)[0], len(marker.group(1))) out.append((i, "")) continue out.append((i, 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 local_target(path, target): """相対リンクをリンク元基準で解決する。外部URLは対象外。""" parsed = urlsplit(target) if parsed.scheme or parsed.netloc: return None, "" linked = (path.parent / unquote(parsed.path)).resolve() if parsed.path else path return linked, unquote(parsed.fragment) def heading_index(lines): """ATX見出しのアンカーを作る。同じ見出しの連番も扱う。""" headings = {} for n, line in lines: match = HEADING.match(line) if not match: continue title = match.group(1) slug = re.sub(r"[^\w\s-]", "", title).lower().replace(" ", "-") anchor, suffix = slug, 0 while anchor in headings: suffix += 1 anchor = f"{slug}-{suffix}" headings[anchor] = (n, title) return headings def body_paragraphs(lines): """改行をまたぐ本文段落を返す。見出し・空行・次の箇条書きで区切る。""" paragraph = [] in_management = False for n, line in lines: if HEADING.match(line): if paragraph: yield paragraph paragraph = [] h2 = H2.match(line) if h2: in_management = h2.group(1) in MANAGEMENT_SECTIONS elif line.startswith("# "): in_management = False continue if in_management: continue if not line.strip() or re.match(r"^\s*(?:[-*+]|\d+[.)])\s+", line): if paragraph: yield paragraph paragraph = [] if line.strip(): paragraph.append((n, line)) if paragraph: yield paragraph def check_issue_references(path, rel, lines): """課題リンクの構造はERROR、本文の参照漏れの候補はWARNにする。""" targets = {} valid_link_lines = set() # インラインコードも位置を変えずにマスクする。 text = "\n".join(line for _, line in lines) masked = INLINE_CODE.sub(lambda m: re.sub(r"[^\n]", " ", m.group()), text).split("\n") prose_lines = [(n, line) for (n, _), line in zip(lines, masked)] for n, line in prose_lines: for match in INLINE_LINK.finditer(line): label, target = match.groups() target = target.strip("<>") linked, anchor = local_target(path, target) if linked is None: continue label_ids = ISSUE_ID.findall(label) if not (label_ids or ISSUE_ANCHOR.match(anchor) or anchor == "未確認事項"): continue if not linked.is_file(): add("ERROR", rel, n, "未確認事項リンクの参照先ファイルがない: %s" % target) continue if linked not in targets: target_lines = load(linked) entries = { ln: ENTRY.match(value).group(1) for ln, value in (section(target_lines, "未確認事項") or []) if ENTRY.match(value) } targets[linked] = (heading_index(target_lines), entries) headings, entries = targets[linked] if not anchor or anchor not in headings: add("ERROR", rel, n, "未確認事項リンクの参照先見出しがない: %s" % target) continue target_line, _ = headings[anchor] if anchor == "未確認事項" and not label_ids: continue target_id = entries.get(target_line) if target_id is None: add("ERROR", rel, n, "課題リンクが未確認事項の個別項目を指していない: %s" % target) elif any(pid != target_id for pid in label_ids): add("ERROR", rel, n, "リンクラベルの課題番号と参照先が一致しない: %s → %s" % (label, target_id)) else: valid_link_lines.add(n) for paragraph in body_paragraphs(prose_lines): n = paragraph[0][0] text = "\n".join(line for _, line in paragraph) # リンクと推測タグには別の構造検査がある。裸の課題番号だけを探す。 plain = TAG.sub("", INLINE_LINK.sub("", text)) bare_ids = sorted(set(ISSUE_ID.findall(plain))) if bare_ids: add("WARN", rel, n, "本文にリンクのない課題番号がある(要確認): %s" % ", ".join(bare_ids)) if (re.search(r"未決|未定", plain) and not any(ln in valid_link_lines for ln, _ in paragraph) and not TAG.search(text)): add("WARN", rel, n, "本文の未決・未定に課題への参照がない(要確認)。リンク・課題追加・言及削除を判断する") def check_backlog(path, rel, lines, seen_ids): blocks = [] current = None for n, line in lines: if H2.match(line): current = None elif re.match(r"^###\s", line): match = BACKLOG_ENTRY.match(line) current = None if not match: add("ERROR", rel, n, "保留課題の見出しは ### <区分>-P-<番号> <題名> とする") continue pid = match.group(1) if pid in seen_ids: add("ERROR", rel, n, "保留課題のID %s が重複している(初出: %s)" % (pid, seen_ids[pid])) seen_ids[pid] = "%s:%d" % (rel, n) current = (pid, n, []) blocks.append(current) elif current is not None: current[2].append(line) for pid, n, body in blocks: for field in BACKLOG_FIELDS: pattern = re.compile(r"^[-*]\s+" + re.escape(field) + r"\s*[::]\s*\S") if not any(pattern.match(line) for line in body): add("ERROR", rel, n, "%s に「- %s:」の内容がない" % (pid, field)) for n, line in lines: if TAG.search(line): add("ERROR", rel, n, "本文の前提となる推測タグはbacklogではなく仕様書に置く") for target in MDLINK.findall(line): linked, _ = local_target(path, target) if linked is not None and not linked.is_file(): add("ERROR", rel, n, "backlogの参照先ファイルがない: %s" % target) def check_backlog_section(path, rel, lines): positions = [] for title in MANAGEMENT_SECTIONS: headings = [n for n, line in lines if H2.match(line) and H2.match(line).group(1) == title] if len(headings) > 1: add("ERROR", rel, headings[1], "## %s が重複している" % title) if headings: positions.append(headings[0]) if positions != sorted(positions): add("ERROR", rel, min(positions), "末尾の管理セクションは保留課題 → 検討済み・非採用 → 未確認事項の順に置く") deferred = section(lines, "保留課題") if deferred is None: return contents = [(n, line) for n, line in deferred if line.strip()] if not contents: add("ERROR", rel, 1, "空の ## 保留課題 はセクションごと削除する") for n, line in contents: match = BACKLOG_LINK.fullmatch(line) if not match: add("ERROR", rel, n, "保留課題には - [<区分>の保留課題](backlogのファイル#見出し) だけを書く") continue target = match.group(1) linked, anchor = local_target(path, target) if (linked is None or not linked.is_relative_to(ROOT / "backlog") or linked.suffix != ".md" or not anchor): add("ERROR", rel, n, "保留課題はbacklog内のMarkdownファイルの見出しへリンクする: %s" % target) continue if not linked.is_file(): add("ERROR", rel, n, "保留課題の参照先ファイルがない: %s" % target) continue headings = heading_index(load(linked)) if anchor not in headings: add("ERROR", rel, n, "保留課題の参照先見出しがない: %s" % target) def check(path, seen_backlog_ids=None): path = Path(path).resolve() rel = path.relative_to(ROOT).as_posix() if path.is_relative_to(ROOT) else path.as_posix() base = path.name is_journal = path.is_relative_to(ROOT / "journal") is_backlog = path.is_relative_to(ROOT / "backlog") lines = load(path) if is_backlog: check_backlog(path, rel, lines, seen_backlog_ids if seen_backlog_ids is not None else {}) elif not is_journal: check_backlog_section(path, rel, lines) if base not in PROSE_EXEMPT: check_issue_references(path, rel, lines) unconf = section(lines, "未確認事項") rejected = section(lines, "検討済み・非採用") unconf_span = set(n for n, _ in unconf) if unconf else set() tagged = {pid for n, line in lines if n not in unconf_span for pid in TAG.findall(line)} # --- ルール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: has_promote = any(PROMOTE.match(x) for x in body) has_complete = any(COMPLETE.match(x) for x in body) if pid in tagged and not has_promote: add("ERROR", rel, ln, "%s に「- 格上げ条件:」の行がない(何を確認すれば事実に格上げできるか)" % pid) elif not has_promote and not has_complete: 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): linked, _ = local_target(path, target) if linked is not None and linked.is_relative_to(ROOT / "journal"): add("ERROR", rel, n, "journal へのリンクが張られている: %s" % target) elif is_journal and linked is not None: 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 not is_backlog 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): issues.clear() targets = [Path(arg).resolve() for arg in argv[1:]] if not targets: targets = (sorted(ROOT.glob("*.md")) + sorted((ROOT / "journal").glob("*.md")) + sorted((ROOT / "backlog").rglob("*.md"))) targets = list(dict.fromkeys(targets)) if not targets: print("対象ファイルなし") return 0 seen_backlog_ids = {} for t in targets: if t.is_file(): check(t, seen_backlog_ids) else: add("ERROR", t.as_posix(), 1, "対象ファイルが見つからない") 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__": if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") sys.exit(main(sys.argv))