codexが .agents 側で更新した内容を .claude へ取り込み、名前依存の箇所だけ 置換した。両ツリーの差分はCLAUDE.md/AGENTS.mdとパスの表記のみ。 - 非採用一覧の掲載基準をルール1の変更に合わせる - 本文の「未決」はリンク・課題追加・言及削除のいずれかを判断する(ルール4) - 責務を外へ移したあと、担当範囲が分かる一文と参照が残っているか確認する - doc-lintに未確認事項リンクの参照先・ラベル整合の検査(ERROR)と、 リンクのない課題番号・参照なしの「未決」「未定」の検出(WARN)を追加 - load() のコードフェンス処理が ~~~ や長さ違いを扱えていなかったのを修正 - .Codex という実在しないパスの参照を .agents に修正 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtVk8W4yK6j3eCqQpEQBLS
383 lines
17 KiB
Python
383 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""CLAUDE.md の運用ルール1〜5のうち、機械的に判定できる項目を検査する。
|
||
|
||
使い方:
|
||
python .claude/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*(<[^>\n]+>|[^\s)]+)\s*\)")
|
||
INLINE_CODE = re.compile(r"(\x60+).*?\1", re.S)
|
||
ISSUE_ID = re.compile(r"(?<![A-Za-z0-9_-])P-\d+(?![A-Za-z0-9_-])")
|
||
ISSUE_ANCHOR = re.compile(r"^p-\d+(?:-|$)")
|
||
HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*#*\s*$")
|
||
JOURNALFN= re.compile(r"^\d{4}-\d{2}-\d{2}-.+\.md$")
|
||
PROMOTE = re.compile(r"^\s*[-*]\s*格上げ条件\s*[::]\s*\S")
|
||
COMPLETE = re.compile(r"^\s*[-*]\s*完了条件\s*[::]\s*\S")
|
||
BACKLOG_ENTRY = re.compile(r"^###\s+([A-Za-z][A-Za-z0-9_-]*-P-\d+)\s+\S")
|
||
BACKLOG_LINK = re.compile(r"^- \[[^\]]+の保留課題\]\(([^)]+)\)$")
|
||
BACKLOG_FIELDS = ("関連仕様", "未決事項", "保留できる理由", "再開条件", "完了条件")
|
||
MANAGEMENT_SECTIONS = ("保留課題", "検討済み・非採用", "未確認事項")
|
||
ROOT = Path(__file__).resolve().parents[3]
|
||
|
||
# ルールブック自身は、アンチパターンを例として引用するため散文ヒューリスティックの対象外
|
||
PROSE_EXEMPT = {"CLAUDE.md"}
|
||
|
||
issues = []
|
||
def add(sev, path, line, msg):
|
||
issues.append((sev, path, line, msg))
|
||
|
||
def load(path):
|
||
"""コードフェンス内を空行に潰した (行番号, 本文) の列を返す。"""
|
||
raw = Path(path).read_text(encoding="utf-8").splitlines()
|
||
out, fence = [], None
|
||
for i, l in enumerate(raw, 1):
|
||
marker = re.match(r"^ {0,3}(\x60{3,}|~{3,})(.*)$", l)
|
||
if fence is not None:
|
||
out.append((i, ""))
|
||
if (marker and marker.group(1)[0] == fence[0]
|
||
and len(marker.group(1)) >= 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):
|
||
"""## <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))
|