docs: 保留課題をbacklogへ分離し、運用ルール5を追加

未確認事項が「現在の仕様が答えに依存するもの」と「答えが出なくても進められる
もの」を混ぜて抱えていた。後者を backlog/README.md へ移し、仕様書の未確認事項を
前者だけに絞る。移す際は保留中の対応範囲・暫定仕様を仕様書本文に明記し、
backlogへの移動が仮説の採用を意味しないようにする。

PHY・ELC・IDXの10件を移し、3ファイルとも未確認事項が空になった。
ルール4では、事実確認の「格上げ条件」と意思決定の「完了条件」を区別する。
仕様書側の保留課題はbacklogの区分へのリンク箇条書きだけに固定し、
末尾は保留課題 → 検討済み・非採用 → 未確認事項の順に置く。

doc-lintをルール5に対応させ、backlogを検査対象に加えた。回帰テストを追加。

Journal: journal/2026-09-22-document-backlog.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKW58HEg8zAQoLrkWByPfD
This commit is contained in:
2026-09-22 19:52:39 +09:00
co-authored by Claude Opus 5
parent 66a594a378
commit 5b671976d5
10 changed files with 535 additions and 102 deletions
+135 -21
View File
@@ -1,16 +1,17 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""CLAUDE.md の運用ルール1〜4のうち、機械的に判定できる項目を検査する。
"""CLAUDE.md の運用ルール1〜5のうち、機械的に判定できる項目を検査する。
使い方:
python .claude/skills/doc-lint/doc-lint.py [ファイル...]
引数なしなら リポジトリ直下の *.md と journal/*.md を対象にする。
引数なしならリポジトリ直下の *.md、journal/*.md、backlog/**/*.md が対象。
明示したパスは実行ディレクトリから解決する。
ERROR が1件でもあれば終了コード 1。
"""
import sys, re, os, io, glob
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
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")
@@ -18,6 +19,12 @@ 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")
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"}
@@ -28,7 +35,7 @@ def add(sev, path, line, msg):
def load(path):
"""コードフェンス内を空行に潰した (行番号, 本文) の列を返す。"""
raw = io.open(path, encoding="utf-8").read().splitlines()
raw = Path(path).read_text(encoding="utf-8").splitlines()
out, fence = [], False
for i, l in enumerate(raw, 1):
if l.lstrip().startswith("```"):
@@ -55,15 +62,109 @@ def section(lines, title):
body.append((n, l))
return body
def check(path):
rel = path.replace(os.sep, "/")
base = os.path.basename(rel)
is_journal = rel.startswith("journal/")
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 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 = set()
for _, linked_line in load(linked):
heading = re.match(r"^#{1,6}\s+(.+?)\s*#*\s*$", linked_line)
if heading:
slug = re.sub(r"[^\w\s-]", "", heading.group(1)).lower().replace(" ", "-")
headings.add(slug)
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)
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()
@@ -80,9 +181,14 @@ def check(path):
elif blocks:
blocks[-1][2].append(l)
for pid, ln, body in blocks:
if not any(PROMOTE.match(x) for x in body):
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:
@@ -96,9 +202,10 @@ def check(path):
# --- ルール3: journal への/からのリンク禁止 ---
for n, l in lines:
for target in MDLINK.findall(l):
if target.lstrip("./").startswith("journal/"):
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:
elif is_journal and linked is not None:
add("ERROR", rel, n, "journal から他ファイルへのリンクが張られている: %s" % target)
# --- ルール1: 検討済み・非採用 ---
@@ -121,7 +228,7 @@ def check(path):
add("WARN", rel, 1, "冒頭20行に結論が見当たらない(結論を先頭に置く)")
# --- ルール1: 調査・比較の混入(要目視確認) ---
if not is_journal and base not in PROSE_EXEMPT:
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:
@@ -135,16 +242,21 @@ def check(path):
add("WARN", rel, n, "優先度評価は journal 行きでは?")
def main(argv):
targets = argv[1:]
issues.clear()
targets = [Path(arg).resolve() for arg in argv[1:]]
if not targets:
targets = sorted(glob.glob("*.md")) + sorted(glob.glob("journal/*.md"))
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
print("対象ファイルなし")
return 0
seen_backlog_ids = {}
for t in targets:
if os.path.isfile(t):
check(t)
if t.is_file():
check(t, seen_backlog_ids)
else:
print("見つからない: %s" % t)
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"]
@@ -154,4 +266,6 @@ def main(argv):
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))