Files
Beyond-KLE/.agents/skills/doc-lint/doc-lint.py
T
ewdernaandClaude Opus 5 54f292b08e agent: decide/doc-lintを更新し、.agents と .claude を同期
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
2026-09-22 22:32:18 +09:00

383 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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*(<[^>\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))