Compare commits
5
Commits
a80f5ea215
...
54f292b08e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54f292b08e | ||
|
|
2fa910ed74 | ||
|
|
bc2d33f856 | ||
|
|
142357a440 | ||
|
|
26df6edc06 |
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: decide
|
||||||
|
description: 設計上の決定を記録する。メインドキュメントの更新・検討済み案の記録・journal作成・コミットを一度に行う。
|
||||||
|
disable-model-invocation: true
|
||||||
|
argument-hint: [決定の要旨]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 決定を記録する
|
||||||
|
|
||||||
|
共通の運用ルールは、AGENTS.mdの参照先であるCLAUDE.mdを正とする。
|
||||||
|
現在の仕様はメインドキュメント、判断の経緯はjournal、変更の要旨はコミットに記録する。
|
||||||
|
「検討済み・非採用」には、将来の再提案を防ぐために必要な案を選んで残す。
|
||||||
|
|
||||||
|
## 手順
|
||||||
|
|
||||||
|
### 0. 決定内容を確認する
|
||||||
|
|
||||||
|
以下が揃っているか確認し、足りなければユーザーに聞く。推測で埋めない。
|
||||||
|
|
||||||
|
- 採用した案は何か
|
||||||
|
- それによって**今後効く制約**は何か(却下理由ではなく、現在の状態として書ける文)
|
||||||
|
- 落とした案があるか
|
||||||
|
- この決定が未確認の推測に依存していないか
|
||||||
|
|
||||||
|
### 1. メインドキュメントを更新する
|
||||||
|
|
||||||
|
該当する直下の `*.md` を更新する。
|
||||||
|
|
||||||
|
- 採用した案・現在の設定を書く。「なぜAなのか」は1〜3行まで。
|
||||||
|
- 「なぜBではないのか」は書かない。代わりに**制約文**として書く。
|
||||||
|
- ✗ `B案はKLEパーサーが壊れるので却下した`
|
||||||
|
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
|
||||||
|
- 比較表・検討過程・ベンチマーク結果をここに書かない。
|
||||||
|
- 他の担当の詳細を外へ移したあとも、この文書だけで担当範囲が分かるか確認する。
|
||||||
|
境界の理解に必要な一文と参照は残す。参照先の仕様は複製しない。
|
||||||
|
|
||||||
|
### 2. 非採用一覧に残す案を選ぶ
|
||||||
|
|
||||||
|
各案について、この記録が将来のどんな再提案を防ぐかを判断する。
|
||||||
|
本文の制約だけで十分なら一覧へ重複記載しない。実際に検討した別方針で、
|
||||||
|
再提案されうるものは、経緯への入口として末尾の `## 検討済み・非採用` に1案1行で残す。
|
||||||
|
|
||||||
|
```
|
||||||
|
- labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 理由は書かない。journalファイル名だけ。
|
||||||
|
- 目安は5行まで。件数や古さだけで機械的に削除せず、再提案を防ぐ必要性で掲載を見直す。
|
||||||
|
- 掲載する案がなければセクションを設けない。一覧から外しても経緯はjournalに残す。
|
||||||
|
|
||||||
|
### 3. journal を書く
|
||||||
|
|
||||||
|
`journal/YYYY-MM-DD-<topic>.md` を作る。トピックを詰め込まない(1決定1ファイル)。
|
||||||
|
|
||||||
|
構成は上から順に:
|
||||||
|
|
||||||
|
1. **結論** — 何を採用したか
|
||||||
|
2. **案の比較** — 簡潔な表。メリット/デメリットを1行ずつ
|
||||||
|
3. 検証ログ・根拠・捨てた案の詳細
|
||||||
|
|
||||||
|
冒頭20行で結論と選択肢が分かること。整形は最低限でよい。
|
||||||
|
他ファイルへのリンクも、他ファイルからのリンクも張らない。
|
||||||
|
|
||||||
|
### 4. 推測と未決の問いを扱う
|
||||||
|
|
||||||
|
この決定が未確認の推測に依存する場合:
|
||||||
|
|
||||||
|
- メインドキュメント本文に `[推測:P-n]` を付ける
|
||||||
|
- 末尾の `## 未確認事項` に項目を足し、**何を確認できれば事実に格上げできるか**を書く
|
||||||
|
|
||||||
|
逆に、この決定で検証が済んだ推測があれば、タグを外して `## 未確認事項` から削除する。
|
||||||
|
本文タグと末尾項目は必ず対で操作する(片方だけ残さない)。
|
||||||
|
|
||||||
|
本文の未決記述は、読者が現在の対応範囲を理解するために必要かを判断する。
|
||||||
|
対応する問いがあればCLAUDE.mdのルール4に従ってリンクし、問いの詳細は複製しない。
|
||||||
|
必要な問いが未登録なら未確認事項を追加し、実装にも現在の説明にも不要な言及なら本文から外す。
|
||||||
|
今は答えに依存せず進められる課題はルール5に従う。「未決」という単語だけで分類しない。
|
||||||
|
|
||||||
|
### 5. コミットする
|
||||||
|
|
||||||
|
メインドキュメントと journal を**同じコミット**に入れる。1コミット1決定。
|
||||||
|
|
||||||
|
メッセージ:
|
||||||
|
|
||||||
|
```
|
||||||
|
<何を変えたか(1行)>
|
||||||
|
|
||||||
|
<なぜそうしたか 数行>
|
||||||
|
|
||||||
|
Journal: journal/2026-09-16-cluster-metadata.md
|
||||||
|
```
|
||||||
|
|
||||||
|
「なぜ」の詳細はコミットメッセージに書ききらない。journal 側にある。
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- 決定が固まっていない段階では使わない。調査中は journal だけ書けばよい。
|
||||||
|
- 複数の決定をまとめてコミットしない。分ける。
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
name: doc-lint
|
||||||
|
description: ドキュメントがAGENTS.mdの運用ルール1〜5に沿っているか検査する。推測タグと未確認事項の対応漏れ、保留課題の書式・参照・必須欄、journalへのリンク、比較表の混入などを検出する。
|
||||||
|
argument-hint: [ファイル...]
|
||||||
|
---
|
||||||
|
|
||||||
|
# ドキュメントのルール違反を検査する
|
||||||
|
|
||||||
|
機械的に判定できる項目はスクリプトで検出し、判断が要る項目だけ目視で見る。
|
||||||
|
共通の運用ルールは、AGENTS.mdの参照先であるCLAUDE.mdを正とする。
|
||||||
|
**検出して報告するだけで、勝手に直さない。** 修正は内容を確認してもらってから。
|
||||||
|
|
||||||
|
## 1. スクリプトを走らせる
|
||||||
|
|
||||||
|
```
|
||||||
|
python .agents/skills/doc-lint/doc-lint.py [ファイル...]
|
||||||
|
```
|
||||||
|
|
||||||
|
Python 3.9以上で実行する。引数なしでリポジトリ直下の `*.md`、`journal/*.md`、`backlog/**/*.md` を全部見る。
|
||||||
|
実行場所にかかわらずリポジトリを基準に分類し、明示したパスは実行ディレクトリから解決する。
|
||||||
|
ERROR があれば終了コード 1。存在しない引数のファイルもERRORにする。
|
||||||
|
|
||||||
|
検出する項目:
|
||||||
|
|
||||||
|
| 種別 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| ERROR | 本文の `[推測:P-n]` に対応する `## 未確認事項` の項目がない |
|
||||||
|
| ERROR | 本文の推測タグに対応する項目に `- 格上げ条件:` の内容がない |
|
||||||
|
| ERROR | タグのない未確認事項に `- 格上げ条件:` または `- 完了条件:` の内容がない |
|
||||||
|
| ERROR | `## 未確認事項` のIDが重複している |
|
||||||
|
| ERROR | 未確認事項へのローカルリンクの参照先ファイル・見出しがない、または個別項目へのリンクが未確認事項の項目を指していない |
|
||||||
|
| ERROR | リンクラベルの課題番号と参照先の課題番号が一致しない |
|
||||||
|
| ERROR | 仕様書の `## 保留課題` がリンク箇条書き以外を含む、空である、参照先ファイル・見出しがない |
|
||||||
|
| ERROR | 末尾の管理セクションが重複する、保留課題 → 検討済み・非採用 → 未確認事項の順でない |
|
||||||
|
| ERROR | backlogの項目IDが不正・検査対象内で重複する、必須5欄がない・空である |
|
||||||
|
| ERROR | backlogに推測タグがある、参照先ファイルが存在しない |
|
||||||
|
| ERROR | journal へのリンク / journal から他ファイルへのリンク |
|
||||||
|
| ERROR | `## 検討済み・非採用` の項目が journal ファイル名を参照していない |
|
||||||
|
| ERROR | journal のファイル名が `YYYY-MM-DD-<topic>.md` 形式でない |
|
||||||
|
| WARN | `## 検討済み・非採用` が5件を超えた |
|
||||||
|
| WARN | 本文の段落・箇条書き項目に、リンクのない課題番号や、課題への参照を伴わない「未決」「未定」がある |
|
||||||
|
| WARN | journal の冒頭20行に結論が見当たらない |
|
||||||
|
| WARN | メインドキュメントに比較表・優先度評価・却下案の記述らしきもの |
|
||||||
|
|
||||||
|
**本文タグのない `## 未確認事項` の項目はエラーにしない。** 実装前に判断が必要な未解決の問いとして正当。
|
||||||
|
設計判断は「完了条件」でよい。タグなしの項目が事実確認なのか設計判断なのかは、目視で確認する。
|
||||||
|
|
||||||
|
backlogでは `### PHY-P-14 題名` のように区分付きIDを使い、関連仕様・未決事項・保留できる理由・再開条件・完了条件を必須とする。
|
||||||
|
仕様書側の書式は `- [PHYの保留課題](backlog/README.md#phy)`。
|
||||||
|
backlogは短い候補・制約を保持できるため、メインドキュメント向けの散文ヒューリスティックは適用しない。
|
||||||
|
|
||||||
|
WARN の散文検出は当たりが粗い。取りこぼしも誤検出もあるので、出た/出ないを結論にしない。
|
||||||
|
未確認事項リンクの検査対象は `[P-3](#p-3-...)`・`[MAPのP-3](MAPレイヤー.md#p-3-...)` 等の
|
||||||
|
インラインリンク。Markdown全般のリンク検査ではない。
|
||||||
|
「未決」「未定」のWARNは、次行の参照も同じ段落として扱う。コード例、末尾の管理セクション、
|
||||||
|
journal、backlog、ルールブック自体はこのWARNの対象外とする。
|
||||||
|
WARNから課題の追加や本文の削除を自動決定しない。
|
||||||
|
|
||||||
|
## 2. スクリプトで見られない項目を確認する
|
||||||
|
|
||||||
|
こちらが本体。ファイルを読んで判断する。
|
||||||
|
|
||||||
|
- **「なぜBではないのか」が理由として残っていないか。**
|
||||||
|
却下理由ではなく、現在効いている制約として書かれているか。
|
||||||
|
- ✗ `階層構造はKLEが非対応なので諦めた`
|
||||||
|
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
|
||||||
|
- **本文が前提にする未確認の記述にタグが付いているか。** 検査項目の中で最も重要で、
|
||||||
|
機械では判定できない。「〜できるはず」「〜と考えられる」「実現可能」で実際に試していない
|
||||||
|
記述を探し、`[推測:P-n]` が付いているか確認する。
|
||||||
|
- **同じ事実が複数ドキュメントに複製されていないか。** 片方だけ古くなる。
|
||||||
|
- **他の担当の説明を移した結果、責務の境界まで消えていないか。**
|
||||||
|
この文書だけで担当範囲が分かるための一文と参照は残し、他の担当の詳細仕様は参照先で管理する。
|
||||||
|
- **非採用一覧の各項目は、将来のどんな再提案を防ぐか。**
|
||||||
|
本文の制約で十分か、実際に検討した別方針として経緯への入口が必要かを判断する。
|
||||||
|
件数・古さ・重複だけで一律に追加・削除しない。
|
||||||
|
- **本文の未決記述は、読者に何を伝えるためにあるか。**
|
||||||
|
現在の対応範囲は本文に残し、問いの詳細は未確認事項へ集約する。
|
||||||
|
対応項目へのリンク、必要な課題の追加、不要な言及の削除のいずれかを判断する。
|
||||||
|
リンクが存在しても、その問いが本文の内容に対応するかは目視で確認する。
|
||||||
|
- **保留中の対応範囲が仕様本文にあるか。** 現在の実装が答えに依存する前提をbacklogへ隠していないか。
|
||||||
|
- **再開条件・完了条件が具体的か。** 仕様書の保留課題リンクは該当区分に向き、解決済みの課題が残っていないか。
|
||||||
|
- **journal の冒頭20行で、結論と選択肢が分かるか。** 見出しの有無ではなく中身を読む。
|
||||||
|
- **コミット規約**(必要なら `git log --oneline` を見る)
|
||||||
|
- 1コミット1決定になっているか
|
||||||
|
- journal を伴う決定のメッセージに `Journal:` 行があるか
|
||||||
|
|
||||||
|
## 3. 報告する
|
||||||
|
|
||||||
|
機械検査のERROR / WARNと、目視で確認した内容・未確認の範囲を分けて報告する。
|
||||||
|
機械検査が0件でも、内容の分類・責務の境界・記録の必要性が適切だった証明にはしない。
|
||||||
|
目視確認を省いた場合はその旨を明記する。
|
||||||
|
修正案は出してよいが、適用は確認を取ってから。事実関係の誤りを見つけた場合も、直さず指摘する。
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- 既存ドキュメントへのルール適用は段階的に進めている。
|
||||||
|
WARN が大量に出るのは想定内で、再編(`/restructure-doc`)のタイミングで解消する。
|
||||||
|
再編前のファイルの WARN を個別に直そうとしない。
|
||||||
|
|
||||||
|
スクリプトを変更した場合の回帰検査:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -B -m unittest discover -s .agents/skills/doc-lint -p test_doc_lint.py
|
||||||
|
```
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
#!/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))
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""文書の分類・参照・完了条件の回帰検査。標準ライブラリだけで実行する。"""
|
||||||
|
import contextlib
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("doc_lint", Path(__file__).with_name("doc-lint.py"))
|
||||||
|
lint = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(lint)
|
||||||
|
|
||||||
|
|
||||||
|
def backlog_item(pid="PHY-P-14"):
|
||||||
|
return (
|
||||||
|
f"### {pid} 原点の吸着\n\n"
|
||||||
|
"- 関連仕様: [PHY](../PHY.md)\n"
|
||||||
|
"- 未決事項: 吸着が必要か\n"
|
||||||
|
"- 保留できる理由: 数値入力で編集できる\n"
|
||||||
|
"- 再開条件: 原点移動を試作するとき\n"
|
||||||
|
"- 完了条件: 吸着の要否を決める\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def issue_section():
|
||||||
|
return (
|
||||||
|
"## 未確認事項\n\n"
|
||||||
|
"### P-1 動作の範囲\n\n- 完了条件: 範囲を決める\n\n"
|
||||||
|
"### P-3 対応・状態管理\n\n- 完了条件: 管理方法を決める\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocLintTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
self.root = Path(temporary.name).resolve()
|
||||||
|
root_patch = patch.object(lint, "ROOT", self.root)
|
||||||
|
root_patch.start()
|
||||||
|
self.addCleanup(root_patch.stop)
|
||||||
|
self.write("PHY.md", "# PHY\n")
|
||||||
|
|
||||||
|
def write(self, relative, content):
|
||||||
|
path = self.root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def run_lint(self, *paths):
|
||||||
|
output = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(output):
|
||||||
|
status = lint.main(["doc-lint.py", *(str(path) for path in paths)])
|
||||||
|
return status, output.getvalue()
|
||||||
|
|
||||||
|
def test_default_scan_includes_backlog_and_accepts_fixed_links(self):
|
||||||
|
self.write("PHY.md", "# PHY\n\n## 保留課題\n\n- [PHYの保留課題](backlog/README.md#phy)\n")
|
||||||
|
self.write("backlog/README.md", "# 保留課題\n\n## PHY\n\n" + backlog_item())
|
||||||
|
self.write("journal/2026-09-22-decision.md", "# 決定\n\n## 結論\n\n採用した。\n")
|
||||||
|
status, output = self.run_lint()
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("3 files / ERROR 0", output)
|
||||||
|
|
||||||
|
def test_backlog_fields_must_have_values(self):
|
||||||
|
for field in lint.BACKLOG_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
text = backlog_item()
|
||||||
|
text = "\n".join(f"- {field}: " if line.startswith(f"- {field}:") else line
|
||||||
|
for line in text.splitlines())
|
||||||
|
path = self.write("backlog/README.md", "## PHY\n" + text)
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn(f"- {field}:", output)
|
||||||
|
|
||||||
|
def test_duplicate_backlog_ids_across_nested_files(self):
|
||||||
|
self.write("backlog/one.md", "## PHY\n" + backlog_item())
|
||||||
|
self.write("backlog/sub/two.md", "## PHY\n" + backlog_item().replace("../PHY.md", "../../PHY.md"))
|
||||||
|
status, output = self.run_lint()
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("保留課題のID PHY-P-14 が重複", output)
|
||||||
|
|
||||||
|
def test_incomplete_last_item_cannot_borrow_fields_from_next_section(self):
|
||||||
|
self.write("backlog/README.md", "## PHY\n### PHY-P-14 吸着\n\n## ELC\n" + backlog_item("ELC-P-1"))
|
||||||
|
status, output = self.run_lint()
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("PHY-P-14 に「- 完了条件:」", output)
|
||||||
|
|
||||||
|
def test_invalid_backlog_heading_and_missing_reference(self):
|
||||||
|
path = self.write("backlog/README.md", "## PHY\n### P-14 吸着\n" + backlog_item().replace("../PHY.md", "../missing.md"))
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("保留課題の見出し", output)
|
||||||
|
self.assertIn("参照先ファイルがない", output)
|
||||||
|
|
||||||
|
def test_spec_backlog_section_rejects_prose_empty_and_broken_targets(self):
|
||||||
|
self.write("backlog/README.md", "## PHY\n" + backlog_item())
|
||||||
|
cases = {
|
||||||
|
"内容をここにも要約する。": "だけを書く",
|
||||||
|
"": "空の ## 保留課題",
|
||||||
|
"- [PHYの保留課題](backlog/missing.md#phy)": "参照先ファイルがない",
|
||||||
|
"- [PHYの保留課題](backlog/README.md#missing)": "参照先見出しがない",
|
||||||
|
"- [PHYの保留課題](backlog/README.md)": "見出しへリンク",
|
||||||
|
"- [PHYの保留課題](PHY.md#phy)": "backlog内",
|
||||||
|
}
|
||||||
|
for content, expected in cases.items():
|
||||||
|
with self.subTest(content=content):
|
||||||
|
path = self.write("PHY.md", "# PHY\n\n## 保留課題\n\n" + content + "\n")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn(expected, output)
|
||||||
|
|
||||||
|
def test_management_sections_have_one_order(self):
|
||||||
|
path = self.write("PHY.md", "## 未確認事項\n\n(なし)\n\n## 検討済み・非採用\n\n## 未確認事項\n")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("順に置く", output)
|
||||||
|
self.assertIn("## 未確認事項 が重複", output)
|
||||||
|
|
||||||
|
def test_decisions_accept_completion_but_tagged_assumptions_require_evidence(self):
|
||||||
|
text = "## 未確認事項\n\n### P-1 操作方式\n- 完了条件: 操作方式を決める\n"
|
||||||
|
path = self.write("PHY.md", text)
|
||||||
|
self.assertEqual(self.run_lint(path)[0], 0)
|
||||||
|
path.write_text("本文 [推測:P-1]\n\n" + text, encoding="utf-8")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("格上げ条件", output)
|
||||||
|
path.write_text("本文 [推測:P-1]\n\n" + text.replace("完了条件", "格上げ条件"), encoding="utf-8")
|
||||||
|
self.assertEqual(self.run_lint(path)[0], 0)
|
||||||
|
|
||||||
|
def test_absolute_journal_paths_keep_journal_checks(self):
|
||||||
|
path = self.write("journal/bad.md", "# 調査\n\n[仕様](../PHY.md)\n")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("journal のファイル名", output)
|
||||||
|
self.assertIn("journal から他ファイルへのリンク", output)
|
||||||
|
|
||||||
|
def test_backlog_is_not_prose_linted_but_cannot_link_to_journal(self):
|
||||||
|
self.write("journal/2026-09-22-decision.md", "## 結論\n")
|
||||||
|
path = self.write("backlog/README.md", "## PHY\n" + backlog_item() + "\n候補はA案。\n[履歴](../journal/2026-09-22-decision.md)\n")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("journal へのリンク", output)
|
||||||
|
self.assertIn("WARN 0", output)
|
||||||
|
|
||||||
|
def test_code_examples_do_not_create_real_sections_or_tags(self):
|
||||||
|
path = self.write("PHY.md", "```markdown\n## 保留課題\n[推測:P-9]\n```\n")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
|
||||||
|
def test_missing_explicit_file_is_an_error(self):
|
||||||
|
status, output = self.run_lint(self.root / "missing.md")
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertIn("対象ファイルが見つからない", output)
|
||||||
|
|
||||||
|
def test_issue_links_accept_same_file_cross_file_and_encoded_anchors(self):
|
||||||
|
self.write("MAPレイヤー.md", "# MAP\n\n" + issue_section())
|
||||||
|
encoded = quote("MAPレイヤー.md") + "#" + quote("p-3-対応状態管理")
|
||||||
|
path = self.write(
|
||||||
|
"PHY.md",
|
||||||
|
"# PHY\n\n範囲は未決。\n[P-1](#p-1-動作の範囲)で扱う。\n\n"
|
||||||
|
"[MAPのP-3](MAPレイヤー.md#p-3-対応状態管理)へ。\n\n"
|
||||||
|
f"[MAPのP-3]({encoded})へ。\n\n" + issue_section(),
|
||||||
|
)
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("ERROR 0 / WARN 0", output)
|
||||||
|
|
||||||
|
def test_issue_links_reject_missing_file_heading_and_fragment(self):
|
||||||
|
self.write("MAP.md", "# MAP\n\n" + issue_section())
|
||||||
|
for target, expected in (
|
||||||
|
("missing.md#p-3-対応状態管理", "参照先ファイルがない"),
|
||||||
|
("MAP.md#p-3-古い題名", "参照先見出しがない"),
|
||||||
|
("MAP.md", "参照先見出しがない"),
|
||||||
|
):
|
||||||
|
with self.subTest(target=target):
|
||||||
|
path = self.write("PHY.md", f"[P-3]({target})\n")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1, output)
|
||||||
|
self.assertIn(expected, output)
|
||||||
|
|
||||||
|
def test_issue_label_must_match_real_entry_in_unconfirmed_section(self):
|
||||||
|
self.write("MAP.md", "# MAP\n\n### P-2 本文の見出し\n\n" + issue_section())
|
||||||
|
for link, expected in (
|
||||||
|
("[P-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"),
|
||||||
|
("[MAPのP-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"),
|
||||||
|
("[P-2](MAP.md#p-2-本文の見出し)", "個別項目を指していない"),
|
||||||
|
("[P-3](MAP.md#未確認事項)", "個別項目を指していない"),
|
||||||
|
):
|
||||||
|
with self.subTest(link=link):
|
||||||
|
path = self.write("PHY.md", link + "\n")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1, output)
|
||||||
|
self.assertIn(expected, output)
|
||||||
|
|
||||||
|
def test_duplicate_heading_slug_resolves_actual_target(self):
|
||||||
|
self.write("MAP.md", "# MAP\n\n### P-1 動作の範囲\n\n" + issue_section())
|
||||||
|
path = self.write("PHY.md", "[P-1](MAP.md#p-1-動作の範囲-1)\n")
|
||||||
|
self.assertEqual(self.run_lint(path)[0], 0)
|
||||||
|
path.write_text("[P-1](MAP.md#p-1-動作の範囲)\n", encoding="utf-8")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1, output)
|
||||||
|
self.assertIn("個別項目を指していない", output)
|
||||||
|
|
||||||
|
def test_unresolved_prose_warns_without_failing_or_modifying_documents(self):
|
||||||
|
text = "# PHY\n\n方式は未定。P-3で検討する。\n\n" + issue_section()
|
||||||
|
path = self.write("PHY.md", text)
|
||||||
|
before = path.read_bytes()
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("リンクのない課題番号", output)
|
||||||
|
self.assertIn("未決・未定に課題への参照がない", output)
|
||||||
|
self.assertEqual(path.read_bytes(), before)
|
||||||
|
|
||||||
|
def test_another_issue_link_does_not_hide_bare_id(self):
|
||||||
|
path = self.write(
|
||||||
|
"PHY.md", "P-3と[P-1](#p-1-動作の範囲)を参照。\n\n" + issue_section()
|
||||||
|
)
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("リンクのない課題番号がある(要確認): P-3", output)
|
||||||
|
|
||||||
|
def test_unrelated_links_and_section_links_do_not_hide_unresolved_prose(self):
|
||||||
|
for link in ("[仕様](PHY.md)", "[一覧](#未確認事項)", "[外部](https://example.com/)"):
|
||||||
|
with self.subTest(link=link):
|
||||||
|
path = self.write("PHY.md", f"範囲は未決。{link}\n\n" + issue_section())
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("未決・未定に課題への参照がない", output)
|
||||||
|
|
||||||
|
def test_broken_link_does_not_count_as_resolved_reference(self):
|
||||||
|
path = self.write("PHY.md", "範囲は未決。\n[P-1](#p-1-missing)\n")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1, output)
|
||||||
|
self.assertIn("参照先見出しがない", output)
|
||||||
|
self.assertIn("未決・未定に課題への参照がない", output)
|
||||||
|
|
||||||
|
def test_paragraph_and_list_boundaries_do_not_borrow_links(self):
|
||||||
|
for body in (
|
||||||
|
"範囲は未決。\n\n[P-1](#p-1-動作の範囲)\n",
|
||||||
|
"- 範囲は未決。\n- [P-1](#p-1-動作の範囲)\n",
|
||||||
|
"1. 範囲は未決。\n2. [P-1](#p-1-動作の範囲)\n",
|
||||||
|
"範囲は未決。\n## 次の話\n[P-1](#p-1-動作の範囲)\n",
|
||||||
|
):
|
||||||
|
with self.subTest(body=body):
|
||||||
|
path = self.write("PHY.md", body + "\n" + issue_section())
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("未決・未定に課題への参照がない", output)
|
||||||
|
path = self.write(
|
||||||
|
"PHY.md", "- 範囲は未決。\n [P-1](#p-1-動作の範囲)\n\n" + issue_section()
|
||||||
|
)
|
||||||
|
self.assertIn("ERROR 0 / WARN 0", self.run_lint(path)[1])
|
||||||
|
|
||||||
|
def test_code_examples_external_links_and_assumptions_are_not_prose_warnings(self):
|
||||||
|
path = self.write(
|
||||||
|
"PHY.md",
|
||||||
|
"\x60未決 P-8 [P-8](#missing)\x60\n\n"
|
||||||
|
"\x60\x60\x60markdown\n未定 P-8 [P-8](#missing)\n\x60\x60\x60\n\n"
|
||||||
|
"~~~markdown\n未定 P-8 [P-8](#missing)\n~~~\n\n"
|
||||||
|
"[P-99](https://example.com/#p-99)\n\n"
|
||||||
|
"範囲は未定 [推測:P-1]。\n\n"
|
||||||
|
"## 未確認事項\n\n### P-1 調査\n\n- 格上げ条件: 検証する\n",
|
||||||
|
)
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("ERROR 0 / WARN 0", output)
|
||||||
|
|
||||||
|
def test_management_sections_and_non_spec_files_are_exempt_from_prose_warnings(self):
|
||||||
|
self.write("CLAUDE.md", "未決 P-8 [P-8](#missing)\n")
|
||||||
|
self.write("journal/2026-09-22-decision.md", "## 結論\n\n未定 P-8\n")
|
||||||
|
self.write("backlog/README.md", "## PHY\n\n" + backlog_item() + "\n未定 P-8\n")
|
||||||
|
self.write("PHY.md", "# PHY\n\n" + issue_section() + "\n未定 P-8\n")
|
||||||
|
status, output = self.run_lint()
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("WARN 0", output)
|
||||||
|
|
||||||
|
def test_links_inside_unconfirmed_items_are_still_checked(self):
|
||||||
|
path = self.write("PHY.md", issue_section() + "\n[P-8](#missing)\n")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1, output)
|
||||||
|
self.assertIn("参照先見出しがない", output)
|
||||||
|
|
||||||
|
def test_rejected_count_warning_does_not_select_entries_for_deletion(self):
|
||||||
|
path = self.write(
|
||||||
|
"PHY.md", "## 検討済み・非採用\n\n"
|
||||||
|
+ "".join(f"- 案{i} (journal/2026-09-22-decision.md)\n" for i in range(6))
|
||||||
|
)
|
||||||
|
before = path.read_bytes()
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("6 件(目安5件)", output)
|
||||||
|
self.assertIn("再提案を防ぐ必要性", output)
|
||||||
|
self.assertNotIn("古いものから落とす", output)
|
||||||
|
self.assertEqual(path.read_bytes(), before)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
name: restructure-doc
|
||||||
|
description: 既存ドキュメント1ファイルをAGENTS.mdのルール1〜4に沿って仕分ける。事実・推測・採用案を残し、調査過程をjournalへ移す。
|
||||||
|
disable-model-invocation: true
|
||||||
|
argument-hint: <ファイル名>
|
||||||
|
---
|
||||||
|
|
||||||
|
# ドキュメントを再編する
|
||||||
|
|
||||||
|
運用ルール策定前に書かれたドキュメントを、AGENTS.md のルール1〜4に沿った形に作り直す。
|
||||||
|
**1回の実行で1ファイルだけ**扱う。
|
||||||
|
|
||||||
|
## 最重要の制約
|
||||||
|
|
||||||
|
**情報を失わせない。** この作業は「移動と再配置」であって、要約でも削除でもない。
|
||||||
|
|
||||||
|
- 内容を短くしない。判断に迷った記述は、消さずに残してユーザーに聞く。
|
||||||
|
- メインドキュメントから外す記述は、必ず journal に移す。捨てない。
|
||||||
|
- 元の主張を勝手に言い換えない。特に断定度を上げ下げしない。
|
||||||
|
|
||||||
|
## 手順
|
||||||
|
|
||||||
|
### 1. 全体を読む
|
||||||
|
|
||||||
|
対象ファイルを最初から最後まで読む。部分的に読んで判断しない。
|
||||||
|
|
||||||
|
### 2. 4分類する
|
||||||
|
|
||||||
|
各ブロック(見出し単位、または段落単位)を次のどれかに分ける。
|
||||||
|
|
||||||
|
| 分類 | 行き先 |
|
||||||
|
|---|---|
|
||||||
|
| a. 採用した案 / 現在の設定 / 設計に影響する制約 | メインドキュメントに残す |
|
||||||
|
| b. 裏取りの済んだ事実 | メインドキュメントに残す(文中のどこでもよい) |
|
||||||
|
| c. 未確認の推測・未解決の問い | メインドキュメント末尾の `## 未確認事項` |
|
||||||
|
| d. 調査過程 / 案の比較 / 却下案 / 優先度評価 | `journal/` へ移す |
|
||||||
|
|
||||||
|
判定のヒント:
|
||||||
|
|
||||||
|
- メリット/デメリット表、優先度リスト、「〜も検討したが」 → d
|
||||||
|
- 「〜できるはず」「〜と考えられる」「実現可能」で、実際に試していない → c
|
||||||
|
- 外部仕様の記述(KLEのプロパティ定義など)は、出典を確認できていれば b、していなければ c
|
||||||
|
|
||||||
|
### 3. 分類結果を提示して承認を得る
|
||||||
|
|
||||||
|
**ここで必ず止まる。** ファイルを書き換える前に、分類の一覧をユーザーに見せて確認する。
|
||||||
|
特に b と c の境目(裏取りできているか)は自分で判断せず聞く。
|
||||||
|
|
||||||
|
### 4. メインドキュメントを書き直す
|
||||||
|
|
||||||
|
- a を中心に再構成する。b は文中に自然に置く。
|
||||||
|
- 「なぜBではないのか」の記述が残っていたら、**制約文**に書き換える。
|
||||||
|
- ✗ `階層構造はKLEが非対応なので諦めた`
|
||||||
|
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
|
||||||
|
- 落とした案があれば末尾 `## 検討済み・非採用` に1案1行(理由なし、journalファイル名を参照)。
|
||||||
|
|
||||||
|
### 5. 未確認事項を立てる
|
||||||
|
|
||||||
|
c に分類したものを末尾の `## 未確認事項` にまとめる。
|
||||||
|
|
||||||
|
- 本文が前提にしている推測 → 本文に `[推測:P-n]` を付け、対応する項目を置く
|
||||||
|
- まだ本文に書けない未解決の問い → 本文タグなしで項目だけ置く
|
||||||
|
- 各項目に**格上げ条件**を必ず書く。思いつかないものはユーザーに聞く(空欄で残さない)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### P-1 KLEはlabels[9]の内容を保存時に保持する
|
||||||
|
- 位置づけ: 本文「クラスター管理」が前提にしている
|
||||||
|
- 格上げ条件: KLE本家でインポート→エクスポートし、labels[9]が残ることを確認する
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. journal へ移す
|
||||||
|
|
||||||
|
d を `journal/YYYY-MM-DD-<元ファイル名>.md` に移す。
|
||||||
|
|
||||||
|
- 冒頭に**結論**(このファイルの再編で何が確定し、何を落としたか)
|
||||||
|
- 次に案の比較(元ファイルの表をそのまま持ってきてよい)
|
||||||
|
- 以下に元の調査記述
|
||||||
|
- 整形は最低限でよい
|
||||||
|
|
||||||
|
### 7. コミットする
|
||||||
|
|
||||||
|
1ファイル1コミット。メインドキュメントと journal を同じコミットに入れる。
|
||||||
|
|
||||||
|
```
|
||||||
|
<ファイル名> を運用ルールに沿って再編
|
||||||
|
|
||||||
|
<何を残し、何をjournalへ移したか 数行>
|
||||||
|
|
||||||
|
Journal: journal/2026-09-16-<元ファイル名>.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- 複数ファイルを一度に触らない。ファイル間で内容が重複していると気づいた場合は、
|
||||||
|
勝手に統合せず、指摘だけしてそのファイルの再編を終える。
|
||||||
|
- 再編の途中で事実関係の誤りを見つけても、勝手に直さない。指摘する。
|
||||||
@@ -7,8 +7,9 @@ argument-hint: [決定の要旨]
|
|||||||
|
|
||||||
# 決定を記録する
|
# 決定を記録する
|
||||||
|
|
||||||
CLAUDE.md のルール1〜4に従い、決定を4箇所に分散して記録する。
|
共通の運用ルールは CLAUDE.md を正とする。
|
||||||
どれか1つでも欠けると後から追えなくなるので、全部やる。
|
現在の仕様はメインドキュメント、判断の経緯はjournal、変更の要旨はコミットに記録する。
|
||||||
|
「検討済み・非採用」には、将来の再提案を防ぐために必要な案を選んで残す。
|
||||||
|
|
||||||
## 手順
|
## 手順
|
||||||
|
|
||||||
@@ -30,17 +31,22 @@ CLAUDE.md のルール1〜4に従い、決定を4箇所に分散して記録す
|
|||||||
- ✗ `B案はKLEパーサーが壊れるので却下した`
|
- ✗ `B案はKLEパーサーが壊れるので却下した`
|
||||||
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
|
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
|
||||||
- 比較表・検討過程・ベンチマーク結果をここに書かない。
|
- 比較表・検討過程・ベンチマーク結果をここに書かない。
|
||||||
|
- 他の担当の詳細を外へ移したあとも、この文書だけで担当範囲が分かるか確認する。
|
||||||
|
境界の理解に必要な一文と参照は残す。参照先の仕様は複製しない。
|
||||||
|
|
||||||
### 2. 落とした案を1行残す
|
### 2. 非採用一覧に残す案を選ぶ
|
||||||
|
|
||||||
メインドキュメント末尾の `## 検討済み・非採用` に追記する。
|
各案について、この記録が将来のどんな再提案を防ぐかを判断する。
|
||||||
|
本文の制約だけで十分なら一覧へ重複記載しない。実際に検討した別方針で、
|
||||||
|
再提案されうるものは、経緯への入口として末尾の `## 検討済み・非採用` に1案1行で残す。
|
||||||
|
|
||||||
```
|
```
|
||||||
- labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md)
|
- labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
- 理由は書かない。journalファイル名だけ。
|
- 理由は書かない。journalファイル名だけ。
|
||||||
- セクションが5行を超えたら、古いものから落とす。長くなるのは書きすぎのサイン。
|
- 目安は5行まで。件数や古さだけで機械的に削除せず、再提案を防ぐ必要性で掲載を見直す。
|
||||||
|
- 掲載する案がなければセクションを設けない。一覧から外しても経緯はjournalに残す。
|
||||||
|
|
||||||
### 3. journal を書く
|
### 3. journal を書く
|
||||||
|
|
||||||
@@ -55,7 +61,7 @@ CLAUDE.md のルール1〜4に従い、決定を4箇所に分散して記録す
|
|||||||
冒頭20行で結論と選択肢が分かること。整形は最低限でよい。
|
冒頭20行で結論と選択肢が分かること。整形は最低限でよい。
|
||||||
他ファイルへのリンクも、他ファイルからのリンクも張らない。
|
他ファイルへのリンクも、他ファイルからのリンクも張らない。
|
||||||
|
|
||||||
### 4. 推測を扱う
|
### 4. 推測と未決の問いを扱う
|
||||||
|
|
||||||
この決定が未確認の推測に依存する場合:
|
この決定が未確認の推測に依存する場合:
|
||||||
|
|
||||||
@@ -65,6 +71,11 @@ CLAUDE.md のルール1〜4に従い、決定を4箇所に分散して記録す
|
|||||||
逆に、この決定で検証が済んだ推測があれば、タグを外して `## 未確認事項` から削除する。
|
逆に、この決定で検証が済んだ推測があれば、タグを外して `## 未確認事項` から削除する。
|
||||||
本文タグと末尾項目は必ず対で操作する(片方だけ残さない)。
|
本文タグと末尾項目は必ず対で操作する(片方だけ残さない)。
|
||||||
|
|
||||||
|
本文の未決記述は、読者が現在の対応範囲を理解するために必要かを判断する。
|
||||||
|
対応する問いがあればCLAUDE.mdのルール4に従ってリンクし、問いの詳細は複製しない。
|
||||||
|
必要な問いが未登録なら未確認事項を追加し、実装にも現在の説明にも不要な言及なら本文から外す。
|
||||||
|
今は答えに依存せず進められる課題はルール5に従う。「未決」という単語だけで分類しない。
|
||||||
|
|
||||||
### 5. コミットする
|
### 5. コミットする
|
||||||
|
|
||||||
メインドキュメントと journal を**同じコミット**に入れる。1コミット1決定。
|
メインドキュメントと journal を**同じコミット**に入れる。1コミット1決定。
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ argument-hint: [ファイル...]
|
|||||||
# ドキュメントのルール違反を検査する
|
# ドキュメントのルール違反を検査する
|
||||||
|
|
||||||
機械的に判定できる項目はスクリプトで検出し、判断が要る項目だけ目視で見る。
|
機械的に判定できる項目はスクリプトで検出し、判断が要る項目だけ目視で見る。
|
||||||
|
共通の運用ルールは CLAUDE.md を正とする。
|
||||||
**検出して報告するだけで、勝手に直さない。** 修正は内容を確認してもらってから。
|
**検出して報告するだけで、勝手に直さない。** 修正は内容を確認してもらってから。
|
||||||
|
|
||||||
## 1. スクリプトを走らせる
|
## 1. スクリプトを走らせる
|
||||||
@@ -27,6 +28,8 @@ ERROR があれば終了コード 1。存在しない引数のファイルもERR
|
|||||||
| ERROR | 本文の推測タグに対応する項目に `- 格上げ条件:` の内容がない |
|
| ERROR | 本文の推測タグに対応する項目に `- 格上げ条件:` の内容がない |
|
||||||
| ERROR | タグのない未確認事項に `- 格上げ条件:` または `- 完了条件:` の内容がない |
|
| ERROR | タグのない未確認事項に `- 格上げ条件:` または `- 完了条件:` の内容がない |
|
||||||
| ERROR | `## 未確認事項` のIDが重複している |
|
| ERROR | `## 未確認事項` のIDが重複している |
|
||||||
|
| ERROR | 未確認事項へのローカルリンクの参照先ファイル・見出しがない、または個別項目へのリンクが未確認事項の項目を指していない |
|
||||||
|
| ERROR | リンクラベルの課題番号と参照先の課題番号が一致しない |
|
||||||
| ERROR | 仕様書の `## 保留課題` がリンク箇条書き以外を含む、空である、参照先ファイル・見出しがない |
|
| ERROR | 仕様書の `## 保留課題` がリンク箇条書き以外を含む、空である、参照先ファイル・見出しがない |
|
||||||
| ERROR | 末尾の管理セクションが重複する、保留課題 → 検討済み・非採用 → 未確認事項の順でない |
|
| ERROR | 末尾の管理セクションが重複する、保留課題 → 検討済み・非採用 → 未確認事項の順でない |
|
||||||
| ERROR | backlogの項目IDが不正・検査対象内で重複する、必須5欄がない・空である |
|
| ERROR | backlogの項目IDが不正・検査対象内で重複する、必須5欄がない・空である |
|
||||||
@@ -35,6 +38,7 @@ ERROR があれば終了コード 1。存在しない引数のファイルもERR
|
|||||||
| ERROR | `## 検討済み・非採用` の項目が journal ファイル名を参照していない |
|
| ERROR | `## 検討済み・非採用` の項目が journal ファイル名を参照していない |
|
||||||
| ERROR | journal のファイル名が `YYYY-MM-DD-<topic>.md` 形式でない |
|
| ERROR | journal のファイル名が `YYYY-MM-DD-<topic>.md` 形式でない |
|
||||||
| WARN | `## 検討済み・非採用` が5件を超えた |
|
| WARN | `## 検討済み・非採用` が5件を超えた |
|
||||||
|
| WARN | 本文の段落・箇条書き項目に、リンクのない課題番号や、課題への参照を伴わない「未決」「未定」がある |
|
||||||
| WARN | journal の冒頭20行に結論が見当たらない |
|
| WARN | journal の冒頭20行に結論が見当たらない |
|
||||||
| WARN | メインドキュメントに比較表・優先度評価・却下案の記述らしきもの |
|
| WARN | メインドキュメントに比較表・優先度評価・却下案の記述らしきもの |
|
||||||
|
|
||||||
@@ -46,6 +50,11 @@ backlogでは `### PHY-P-14 題名` のように区分付きIDを使い、関連
|
|||||||
backlogは短い候補・制約を保持できるため、メインドキュメント向けの散文ヒューリスティックは適用しない。
|
backlogは短い候補・制約を保持できるため、メインドキュメント向けの散文ヒューリスティックは適用しない。
|
||||||
|
|
||||||
WARN の散文検出は当たりが粗い。取りこぼしも誤検出もあるので、出た/出ないを結論にしない。
|
WARN の散文検出は当たりが粗い。取りこぼしも誤検出もあるので、出た/出ないを結論にしない。
|
||||||
|
未確認事項リンクの検査対象は `[P-3](#p-3-...)`・`[MAPのP-3](MAPレイヤー.md#p-3-...)` 等の
|
||||||
|
インラインリンク。Markdown全般のリンク検査ではない。
|
||||||
|
「未決」「未定」のWARNは、次行の参照も同じ段落として扱う。コード例、末尾の管理セクション、
|
||||||
|
journal、backlog、ルールブック自体はこのWARNの対象外とする。
|
||||||
|
WARNから課題の追加や本文の削除を自動決定しない。
|
||||||
|
|
||||||
## 2. スクリプトで見られない項目を確認する
|
## 2. スクリプトで見られない項目を確認する
|
||||||
|
|
||||||
@@ -59,6 +68,15 @@ WARN の散文検出は当たりが粗い。取りこぼしも誤検出もある
|
|||||||
機械では判定できない。「〜できるはず」「〜と考えられる」「実現可能」で実際に試していない
|
機械では判定できない。「〜できるはず」「〜と考えられる」「実現可能」で実際に試していない
|
||||||
記述を探し、`[推測:P-n]` が付いているか確認する。
|
記述を探し、`[推測:P-n]` が付いているか確認する。
|
||||||
- **同じ事実が複数ドキュメントに複製されていないか。** 片方だけ古くなる。
|
- **同じ事実が複数ドキュメントに複製されていないか。** 片方だけ古くなる。
|
||||||
|
- **他の担当の説明を移した結果、責務の境界まで消えていないか。**
|
||||||
|
この文書だけで担当範囲が分かるための一文と参照は残し、他の担当の詳細仕様は参照先で管理する。
|
||||||
|
- **非採用一覧の各項目は、将来のどんな再提案を防ぐか。**
|
||||||
|
本文の制約で十分か、実際に検討した別方針として経緯への入口が必要かを判断する。
|
||||||
|
件数・古さ・重複だけで一律に追加・削除しない。
|
||||||
|
- **本文の未決記述は、読者に何を伝えるためにあるか。**
|
||||||
|
現在の対応範囲は本文に残し、問いの詳細は未確認事項へ集約する。
|
||||||
|
対応項目へのリンク、必要な課題の追加、不要な言及の削除のいずれかを判断する。
|
||||||
|
リンクが存在しても、その問いが本文の内容に対応するかは目視で確認する。
|
||||||
- **保留中の対応範囲が仕様本文にあるか。** 現在の実装が答えに依存する前提をbacklogへ隠していないか。
|
- **保留中の対応範囲が仕様本文にあるか。** 現在の実装が答えに依存する前提をbacklogへ隠していないか。
|
||||||
- **再開条件・完了条件が具体的か。** 仕様書の保留課題リンクは該当区分に向き、解決済みの課題が残っていないか。
|
- **再開条件・完了条件が具体的か。** 仕様書の保留課題リンクは該当区分に向き、解決済みの課題が残っていないか。
|
||||||
- **journal の冒頭20行で、結論と選択肢が分かるか。** 見出しの有無ではなく中身を読む。
|
- **journal の冒頭20行で、結論と選択肢が分かるか。** 見出しの有無ではなく中身を読む。
|
||||||
@@ -68,7 +86,9 @@ WARN の散文検出は当たりが粗い。取りこぼしも誤検出もある
|
|||||||
|
|
||||||
## 3. 報告する
|
## 3. 報告する
|
||||||
|
|
||||||
ERROR / WARN / 目視で見つけたもの を分けて出す。
|
機械検査のERROR / WARNと、目視で確認した内容・未確認の範囲を分けて報告する。
|
||||||
|
機械検査が0件でも、内容の分類・責務の境界・記録の必要性が適切だった証明にはしない。
|
||||||
|
目視確認を省いた場合はその旨を明記する。
|
||||||
修正案は出してよいが、適用は確認を取ってから。事実関係の誤りを見つけた場合も、直さず指摘する。
|
修正案は出してよいが、適用は確認を取ってから。事実関係の誤りを見つけた場合も、直さず指摘する。
|
||||||
|
|
||||||
## 注意
|
## 注意
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ TAG = re.compile(r"\[推測:(P-\d+)\]")
|
|||||||
ENTRY = re.compile(r"^###\s+(P-\d+)\b")
|
ENTRY = re.compile(r"^###\s+(P-\d+)\b")
|
||||||
H2 = re.compile(r"^##\s+(.*?)\s*$")
|
H2 = re.compile(r"^##\s+(.*?)\s*$")
|
||||||
MDLINK = re.compile(r"\]\(([^)]+\.md[^)]*)\)")
|
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$")
|
JOURNALFN= re.compile(r"^\d{4}-\d{2}-\d{2}-.+\.md$")
|
||||||
PROMOTE = re.compile(r"^\s*[-*]\s*格上げ条件\s*[::]\s*\S")
|
PROMOTE = re.compile(r"^\s*[-*]\s*格上げ条件\s*[::]\s*\S")
|
||||||
COMPLETE = re.compile(r"^\s*[-*]\s*完了条件\s*[::]\s*\S")
|
COMPLETE = re.compile(r"^\s*[-*]\s*完了条件\s*[::]\s*\S")
|
||||||
@@ -36,13 +41,20 @@ def add(sev, path, line, msg):
|
|||||||
def load(path):
|
def load(path):
|
||||||
"""コードフェンス内を空行に潰した (行番号, 本文) の列を返す。"""
|
"""コードフェンス内を空行に潰した (行番号, 本文) の列を返す。"""
|
||||||
raw = Path(path).read_text(encoding="utf-8").splitlines()
|
raw = Path(path).read_text(encoding="utf-8").splitlines()
|
||||||
out, fence = [], False
|
out, fence = [], None
|
||||||
for i, l in enumerate(raw, 1):
|
for i, l in enumerate(raw, 1):
|
||||||
if l.lstrip().startswith("```"):
|
marker = re.match(r"^ {0,3}(\x60{3,}|~{3,})(.*)$", l)
|
||||||
fence = not fence
|
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, ""))
|
out.append((i, ""))
|
||||||
continue
|
continue
|
||||||
out.append((i, "" if fence else l))
|
out.append((i, l))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def section(lines, title):
|
def section(lines, title):
|
||||||
@@ -71,6 +83,108 @@ def local_target(path, target):
|
|||||||
return linked, unquote(parsed.fragment)
|
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):
|
def check_backlog(path, rel, lines, seen_ids):
|
||||||
blocks = []
|
blocks = []
|
||||||
current = None
|
current = None
|
||||||
@@ -138,12 +252,7 @@ def check_backlog_section(path, rel, lines):
|
|||||||
if not linked.is_file():
|
if not linked.is_file():
|
||||||
add("ERROR", rel, n, "保留課題の参照先ファイルがない: %s" % target)
|
add("ERROR", rel, n, "保留課題の参照先ファイルがない: %s" % target)
|
||||||
continue
|
continue
|
||||||
headings = set()
|
headings = heading_index(load(linked))
|
||||||
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:
|
if anchor not in headings:
|
||||||
add("ERROR", rel, n, "保留課題の参照先見出しがない: %s" % target)
|
add("ERROR", rel, n, "保留課題の参照先見出しがない: %s" % target)
|
||||||
|
|
||||||
@@ -160,6 +269,8 @@ def check(path, seen_backlog_ids=None):
|
|||||||
check_backlog(path, rel, lines, seen_backlog_ids if seen_backlog_ids is not None else {})
|
check_backlog(path, rel, lines, seen_backlog_ids if seen_backlog_ids is not None else {})
|
||||||
elif not is_journal:
|
elif not is_journal:
|
||||||
check_backlog_section(path, rel, lines)
|
check_backlog_section(path, rel, lines)
|
||||||
|
if base not in PROSE_EXEMPT:
|
||||||
|
check_issue_references(path, rel, lines)
|
||||||
|
|
||||||
unconf = section(lines, "未確認事項")
|
unconf = section(lines, "未確認事項")
|
||||||
rejected = section(lines, "検討済み・非採用")
|
rejected = section(lines, "検討済み・非採用")
|
||||||
@@ -217,7 +328,7 @@ def check(path, seen_backlog_ids=None):
|
|||||||
"検討済み・非採用の項目が journal ファイル名を参照していない")
|
"検討済み・非採用の項目が journal ファイル名を参照していない")
|
||||||
if len(items) > 5:
|
if len(items) > 5:
|
||||||
add("WARN", rel, items[5][0],
|
add("WARN", rel, items[5][0],
|
||||||
"検討済み・非採用が %d 件(目安5件)。古いものから落とす" % len(items))
|
"検討済み・非採用が %d 件(目安5件)。再提案を防ぐ必要性で掲載を見直す" % len(items))
|
||||||
|
|
||||||
# --- journal 固有 ---
|
# --- journal 固有 ---
|
||||||
if is_journal:
|
if is_journal:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import io
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +25,14 @@ def backlog_item(pid="PHY-P-14"):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def issue_section():
|
||||||
|
return (
|
||||||
|
"## 未確認事項\n\n"
|
||||||
|
"### P-1 動作の範囲\n\n- 完了条件: 範囲を決める\n\n"
|
||||||
|
"### P-3 対応・状態管理\n\n- 完了条件: 管理方法を決める\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DocLintTests(unittest.TestCase):
|
class DocLintTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
temporary = tempfile.TemporaryDirectory()
|
temporary = tempfile.TemporaryDirectory()
|
||||||
@@ -145,6 +154,147 @@ class DocLintTests(unittest.TestCase):
|
|||||||
self.assertEqual(status, 1)
|
self.assertEqual(status, 1)
|
||||||
self.assertIn("対象ファイルが見つからない", output)
|
self.assertIn("対象ファイルが見つからない", output)
|
||||||
|
|
||||||
|
def test_issue_links_accept_same_file_cross_file_and_encoded_anchors(self):
|
||||||
|
self.write("MAPレイヤー.md", "# MAP\n\n" + issue_section())
|
||||||
|
encoded = quote("MAPレイヤー.md") + "#" + quote("p-3-対応状態管理")
|
||||||
|
path = self.write(
|
||||||
|
"PHY.md",
|
||||||
|
"# PHY\n\n範囲は未決。\n[P-1](#p-1-動作の範囲)で扱う。\n\n"
|
||||||
|
"[MAPのP-3](MAPレイヤー.md#p-3-対応状態管理)へ。\n\n"
|
||||||
|
f"[MAPのP-3]({encoded})へ。\n\n" + issue_section(),
|
||||||
|
)
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("ERROR 0 / WARN 0", output)
|
||||||
|
|
||||||
|
def test_issue_links_reject_missing_file_heading_and_fragment(self):
|
||||||
|
self.write("MAP.md", "# MAP\n\n" + issue_section())
|
||||||
|
for target, expected in (
|
||||||
|
("missing.md#p-3-対応状態管理", "参照先ファイルがない"),
|
||||||
|
("MAP.md#p-3-古い題名", "参照先見出しがない"),
|
||||||
|
("MAP.md", "参照先見出しがない"),
|
||||||
|
):
|
||||||
|
with self.subTest(target=target):
|
||||||
|
path = self.write("PHY.md", f"[P-3]({target})\n")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1, output)
|
||||||
|
self.assertIn(expected, output)
|
||||||
|
|
||||||
|
def test_issue_label_must_match_real_entry_in_unconfirmed_section(self):
|
||||||
|
self.write("MAP.md", "# MAP\n\n### P-2 本文の見出し\n\n" + issue_section())
|
||||||
|
for link, expected in (
|
||||||
|
("[P-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"),
|
||||||
|
("[MAPのP-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"),
|
||||||
|
("[P-2](MAP.md#p-2-本文の見出し)", "個別項目を指していない"),
|
||||||
|
("[P-3](MAP.md#未確認事項)", "個別項目を指していない"),
|
||||||
|
):
|
||||||
|
with self.subTest(link=link):
|
||||||
|
path = self.write("PHY.md", link + "\n")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1, output)
|
||||||
|
self.assertIn(expected, output)
|
||||||
|
|
||||||
|
def test_duplicate_heading_slug_resolves_actual_target(self):
|
||||||
|
self.write("MAP.md", "# MAP\n\n### P-1 動作の範囲\n\n" + issue_section())
|
||||||
|
path = self.write("PHY.md", "[P-1](MAP.md#p-1-動作の範囲-1)\n")
|
||||||
|
self.assertEqual(self.run_lint(path)[0], 0)
|
||||||
|
path.write_text("[P-1](MAP.md#p-1-動作の範囲)\n", encoding="utf-8")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1, output)
|
||||||
|
self.assertIn("個別項目を指していない", output)
|
||||||
|
|
||||||
|
def test_unresolved_prose_warns_without_failing_or_modifying_documents(self):
|
||||||
|
text = "# PHY\n\n方式は未定。P-3で検討する。\n\n" + issue_section()
|
||||||
|
path = self.write("PHY.md", text)
|
||||||
|
before = path.read_bytes()
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("リンクのない課題番号", output)
|
||||||
|
self.assertIn("未決・未定に課題への参照がない", output)
|
||||||
|
self.assertEqual(path.read_bytes(), before)
|
||||||
|
|
||||||
|
def test_another_issue_link_does_not_hide_bare_id(self):
|
||||||
|
path = self.write(
|
||||||
|
"PHY.md", "P-3と[P-1](#p-1-動作の範囲)を参照。\n\n" + issue_section()
|
||||||
|
)
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("リンクのない課題番号がある(要確認): P-3", output)
|
||||||
|
|
||||||
|
def test_unrelated_links_and_section_links_do_not_hide_unresolved_prose(self):
|
||||||
|
for link in ("[仕様](PHY.md)", "[一覧](#未確認事項)", "[外部](https://example.com/)"):
|
||||||
|
with self.subTest(link=link):
|
||||||
|
path = self.write("PHY.md", f"範囲は未決。{link}\n\n" + issue_section())
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("未決・未定に課題への参照がない", output)
|
||||||
|
|
||||||
|
def test_broken_link_does_not_count_as_resolved_reference(self):
|
||||||
|
path = self.write("PHY.md", "範囲は未決。\n[P-1](#p-1-missing)\n")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1, output)
|
||||||
|
self.assertIn("参照先見出しがない", output)
|
||||||
|
self.assertIn("未決・未定に課題への参照がない", output)
|
||||||
|
|
||||||
|
def test_paragraph_and_list_boundaries_do_not_borrow_links(self):
|
||||||
|
for body in (
|
||||||
|
"範囲は未決。\n\n[P-1](#p-1-動作の範囲)\n",
|
||||||
|
"- 範囲は未決。\n- [P-1](#p-1-動作の範囲)\n",
|
||||||
|
"1. 範囲は未決。\n2. [P-1](#p-1-動作の範囲)\n",
|
||||||
|
"範囲は未決。\n## 次の話\n[P-1](#p-1-動作の範囲)\n",
|
||||||
|
):
|
||||||
|
with self.subTest(body=body):
|
||||||
|
path = self.write("PHY.md", body + "\n" + issue_section())
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("未決・未定に課題への参照がない", output)
|
||||||
|
path = self.write(
|
||||||
|
"PHY.md", "- 範囲は未決。\n [P-1](#p-1-動作の範囲)\n\n" + issue_section()
|
||||||
|
)
|
||||||
|
self.assertIn("ERROR 0 / WARN 0", self.run_lint(path)[1])
|
||||||
|
|
||||||
|
def test_code_examples_external_links_and_assumptions_are_not_prose_warnings(self):
|
||||||
|
path = self.write(
|
||||||
|
"PHY.md",
|
||||||
|
"\x60未決 P-8 [P-8](#missing)\x60\n\n"
|
||||||
|
"\x60\x60\x60markdown\n未定 P-8 [P-8](#missing)\n\x60\x60\x60\n\n"
|
||||||
|
"~~~markdown\n未定 P-8 [P-8](#missing)\n~~~\n\n"
|
||||||
|
"[P-99](https://example.com/#p-99)\n\n"
|
||||||
|
"範囲は未定 [推測:P-1]。\n\n"
|
||||||
|
"## 未確認事項\n\n### P-1 調査\n\n- 格上げ条件: 検証する\n",
|
||||||
|
)
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("ERROR 0 / WARN 0", output)
|
||||||
|
|
||||||
|
def test_management_sections_and_non_spec_files_are_exempt_from_prose_warnings(self):
|
||||||
|
self.write("CLAUDE.md", "未決 P-8 [P-8](#missing)\n")
|
||||||
|
self.write("journal/2026-09-22-decision.md", "## 結論\n\n未定 P-8\n")
|
||||||
|
self.write("backlog/README.md", "## PHY\n\n" + backlog_item() + "\n未定 P-8\n")
|
||||||
|
self.write("PHY.md", "# PHY\n\n" + issue_section() + "\n未定 P-8\n")
|
||||||
|
status, output = self.run_lint()
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("WARN 0", output)
|
||||||
|
|
||||||
|
def test_links_inside_unconfirmed_items_are_still_checked(self):
|
||||||
|
path = self.write("PHY.md", issue_section() + "\n[P-8](#missing)\n")
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 1, output)
|
||||||
|
self.assertIn("参照先見出しがない", output)
|
||||||
|
|
||||||
|
def test_rejected_count_warning_does_not_select_entries_for_deletion(self):
|
||||||
|
path = self.write(
|
||||||
|
"PHY.md", "## 検討済み・非採用\n\n"
|
||||||
|
+ "".join(f"- 案{i} (journal/2026-09-22-decision.md)\n" for i in range(6))
|
||||||
|
)
|
||||||
|
before = path.read_bytes()
|
||||||
|
status, output = self.run_lint(path)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn("6 件(目安5件)", output)
|
||||||
|
self.assertIn("再提案を防ぐ必要性", output)
|
||||||
|
self.assertNotIn("古いものから落とす", output)
|
||||||
|
self.assertEqual(path.read_bytes(), before)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
|
|||||||
| `PHYレイヤー.md` | PHY層の仕様 |
|
| `PHYレイヤー.md` | PHY層の仕様 |
|
||||||
| `ELCレイヤー.md` | ELC層の仕様 |
|
| `ELCレイヤー.md` | ELC層の仕様 |
|
||||||
| `IDXレイヤー.md` | IDX層の仕様 |
|
| `IDXレイヤー.md` | IDX層の仕様 |
|
||||||
|
| `MAPレイヤー.md` | MAP層とエディタの責務・実機との読み書き方針 |
|
||||||
| `phase1-json-schema.md` | 各層のJSON Schema |
|
| `phase1-json-schema.md` | 各層のJSON Schema |
|
||||||
| `keyboard-making-workflow.md` | 既存の自作キーボード制作フローとの対応 |
|
| `keyboard-making-workflow.md` | 既存の自作キーボード制作フローとの対応 |
|
||||||
| `implementation-plan.md` | 実装方針の決定事項・検証用サンプル |
|
| `implementation-plan.md` | 実装方針の決定事項・検証用サンプル |
|
||||||
@@ -39,10 +40,11 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
|
|||||||
- 却下案の再提案を防ぐ情報は、比較ではなく**制約文**として書く。
|
- 却下案の再提案を防ぐ情報は、比較ではなく**制約文**として書く。
|
||||||
- ✗ `B案はKLEパーサーが壊れるので却下した`
|
- ✗ `B案はKLEパーサーが壊れるので却下した`
|
||||||
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
|
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
|
||||||
- 末尾に `## 検討済み・非採用` を置き、落とした案を**1案1行**で残す。理由は書かず、journalファイル名を参照する。
|
- 落とした案があれば、末尾に `## 検討済み・非採用` を置き、**1案1行**で残す。理由は書かず、journalファイル名を参照する。
|
||||||
- 例: `- labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md)`
|
- 例: `- labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md)`
|
||||||
- コミットは `git log --oneline -- journal/2026-09-16-cluster-metadata.md` で引ける(journal削除後も追跡可)
|
- コミットは `git log --oneline -- journal/2026-09-16-cluster-metadata.md` で引ける(journal削除後も追跡可)
|
||||||
- 目安5行まで。増えたら古いものから落とす。ここが長くなるのは書きすぎのサイン。
|
- 載せるのは、実際に検討した別方針のうち再提案されうるものに限る。本文の制約文で足りるなら重複させない。
|
||||||
|
- 目安5行まで。増えたら、再提案を防ぐ必要性が低いものから落とす。長くなるのは書きすぎのサイン。
|
||||||
|
|
||||||
### 2. 履歴と根拠
|
### 2. 履歴と根拠
|
||||||
|
|
||||||
@@ -68,6 +70,9 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
|
|||||||
- **現在の仕様・実装が答えに依存する未確認事項**は、仕様書の末尾に残す。2種類ある:
|
- **現在の仕様・実装が答えに依存する未確認事項**は、仕様書の末尾に残す。2種類ある:
|
||||||
- 本文が前提にしている推測 → 本文に `[推測:P-n]` を付け、末尾の `## 未確認事項` に対応項目を置く
|
- 本文が前提にしている推測 → 本文に `[推測:P-n]` を付け、末尾の `## 未確認事項` に対応項目を置く
|
||||||
- 実装前に判断が必要な未解決の問い → 本文タグなしで `## 未確認事項` に直接置く
|
- 実装前に判断が必要な未解決の問い → 本文タグなしで `## 未確認事項` に直接置く
|
||||||
|
- 本文が未決の問いに触れるときは、「未決」と書いて終わらせず該当項目へリンクする。
|
||||||
|
同一ファイル内は `[P-3](#p-3-...)`、他ファイルからは `[MAPのP-3](MAPレイヤー.md#p-3-...)` の形とする。
|
||||||
|
問いの内容は `## 未確認事項` にだけ書き、本文へ写さない(二重管理を避ける)。
|
||||||
- 未確認の推測には、**何を確認できれば事実に格上げできるか**を「格上げ条件」として書く。
|
- 未確認の推測には、**何を確認できれば事実に格上げできるか**を「格上げ条件」として書く。
|
||||||
設計判断には、**何を決めれば解決するか**を「完了条件」として書く。意思決定を事実確認と混同しない。
|
設計判断には、**何を決めれば解決するか**を「完了条件」として書く。意思決定を事実確認と混同しない。
|
||||||
- 答えに依存せず進められる課題は `backlog/` に置く(ルール5)。保留中の対応範囲・暫定仕様は仕様書に残す。
|
- 答えに依存せず進められる課題は `backlog/` に置く(ルール5)。保留中の対応範囲・暫定仕様は仕様書に残す。
|
||||||
|
|||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
# MAPレイヤー
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
MAPレイヤーは、キーマップレイヤーごとのキーへの割り当てを持つ。
|
||||||
|
MAPエディタは、その設計・編集と、実機との読み書きを提供する。
|
||||||
|
ここでいうキーマップレイヤーは、PHY・ELC等の設計レイヤーとは区別する。
|
||||||
|
|
||||||
|
主眼は、新しく作成するキーボードのワークフローにある。
|
||||||
|
キーマップを設計し、実機へ反映し、使って調整した結果をプロジェクトへ戻せるようにする。
|
||||||
|
既存キーボード全般への接続・取り込みを、初期対応の前提にはしない。
|
||||||
|
|
||||||
|
## MAPデータと他レイヤーの関係
|
||||||
|
|
||||||
|
キー参照と出力時の並べ替えは、[IDXレイヤー「他レイヤーとの関係」](IDXレイヤー.md#他レイヤーとの関係)に従う。
|
||||||
|
MAPのキー参照にはPHYのキーIDを使い、実機との通信に使うアドレスを正本のキー参照にはしない。
|
||||||
|
|
||||||
|
現行の保存形式は、[JSON SchemaのMAP節](phase1-json-schema.md#4-mapレイヤー-json-schema)にある
|
||||||
|
レイヤー別のキーコード割り当てを出発点とする。
|
||||||
|
動作定義と保存形式の拡張範囲は、[P-1](#p-1-割り当てる動作と保存形式の範囲)で扱う。
|
||||||
|
|
||||||
|
## MAPエディタの責務
|
||||||
|
|
||||||
|
- PHYの配置を参照し、キーマップレイヤーごとの割り当てを編集・保存する。
|
||||||
|
- 対応する実機へ割り当てを書き込み、実機の現在の割り当てを読み出す。
|
||||||
|
- 実機で使いながら調整し、その結果をMAPへ取り込むワークフローを提供する。
|
||||||
|
|
||||||
|
読み書きは一組として対応する。読み出しには、現在値の確認に加え、Remap等で調整した結果を
|
||||||
|
プロジェクトへ戻す用途も含む。保存と実機への反映の操作、差分表示、自動同期の有無は、
|
||||||
|
[P-3](#p-3-プロジェクトと実機の対応状態管理)で扱う。
|
||||||
|
|
||||||
|
## DynamicとStatic
|
||||||
|
|
||||||
|
Dynamicは、対応ファームウェアが公開している通信プロトコルを使って、再ビルドせずに設定を読み書きする経路。
|
||||||
|
Staticは、設定をファームウェアへ組み込むために出力する経路とする。
|
||||||
|
Staticの出力は[全体方針のFirmware Generator](keyboard-design-layers.md#firmware-generator)が担い、MAPエディタは持たない。
|
||||||
|
|
||||||
|
MAPエディタはDynamicの読み書きを提供する。Dynamicで変更できる範囲は、
|
||||||
|
対象ファームウェアと既存プロトコルが公開する範囲に従う。
|
||||||
|
Staticで扱う動作定義の範囲と、Dynamicで変更できない設定の扱いは、
|
||||||
|
[P-1](#p-1-割り当てる動作と保存形式の範囲)で扱う。
|
||||||
|
|
||||||
|
## 対応プロトコル
|
||||||
|
|
||||||
|
最初に対応する通信プロトコルはVIAとし、対応プロトコルを絞って段階的に増やす。
|
||||||
|
ユーザーが好むRemapと同じ通信経路を使い、新規キーボードの設計から実機での調整へつなぐため。
|
||||||
|
|
||||||
|
- QMKやVial側にBeyond KLE独自のプロトコルを追加しない。そのためのファームウェアのフォークも行わない。
|
||||||
|
- VIA対応では、対象ファームウェアの既存のVIA機能を使って読み書きする。
|
||||||
|
- VIAへの対応は、Remapというアプリ全体の機能を再現することを意味しない。
|
||||||
|
- 初期の実装対象はVIAに限る。
|
||||||
|
|
||||||
|
対応するVIAプロトコルのバージョン、キーコード体系、編集項目の範囲は、
|
||||||
|
[P-2](#p-2-viaの初期対応範囲)で扱う。
|
||||||
|
|
||||||
|
## 持たないもの
|
||||||
|
|
||||||
|
物理配置、配線、キーの出力番号は、それぞれPHY・ELC・IDXで扱う。
|
||||||
|
OS配列やIMEの入力変換はIME側の責務とし、外部ツールで編集できる照明等の設定も、
|
||||||
|
VIAで通信できるという理由だけでMAPの所有データには含めない。
|
||||||
|
|
||||||
|
アナログ入力の解釈の所属は、[全体方針のP-1](keyboard-design-layers.md#p-1-アナログ値の解釈をどの層が持つか)で扱う。
|
||||||
|
|
||||||
|
## 検討済み・非採用
|
||||||
|
|
||||||
|
- MAPエディタをStaticに閉じる (journal/2026-09-22-map-via-workflow.md)
|
||||||
|
|
||||||
|
## 未確認事項
|
||||||
|
|
||||||
|
### P-1 割り当てる動作と保存形式の範囲
|
||||||
|
|
||||||
|
- 位置づけ: MAPのデータモデルを定めるための設計判断。
|
||||||
|
- 未決事項: キーコード割り当てに加え、タップ/ホールド、コンボ、マクロ等の定義・設定をどこまで扱うか。Staticで扱う範囲、ファームウェア共通表現と固有表現の境界も未定。
|
||||||
|
- 完了条件: 初期対応する動作と設定、その保存形式、Static出力とDynamic読み書きでの扱いをユーザーが決める。
|
||||||
|
|
||||||
|
### P-2 VIAの初期対応範囲
|
||||||
|
|
||||||
|
- 位置づけ: VIA通信と編集UIの実装に必要な対応範囲の判断。
|
||||||
|
- 未決事項: 対応プロトコルのバージョン、キーコード体系、レイヤー別割り当て以外に読み書きする項目、対象ファームウェアの機能・容量制約の確認方法。
|
||||||
|
- 完了条件: 対象バージョンと読み書きする項目を定め、対応する新規キーボードの構成で必要なコマンドと制約を確認する。
|
||||||
|
|
||||||
|
### P-3 プロジェクトと実機の対応・状態管理
|
||||||
|
|
||||||
|
- 位置づけ: 実機への読み書きをMAPへ結び付けるための設計判断。
|
||||||
|
- 未決事項: 出力した構成とPHYキーID・通信上のアドレスの対応をどこに保持し、接続した実機とどう照合するか。バリアントや配線の変更後の扱い、プロジェクトと実機の差分・保存・反映の操作も未定。
|
||||||
|
- 完了条件: 対応情報の生成・保存・照合方法と状態管理を定め、設計から実機へ反映し、Remap等での変更を元のPHYキーIDへの割り当てとして取り込む手順を確認する。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# MAPエディタの実機との往復とVIA対応
|
||||||
|
|
||||||
|
## 結論
|
||||||
|
|
||||||
|
新規作成するキーボードのワークフローを主眼とし、MAPエディタは実機の読み書きを一組で提供する。
|
||||||
|
最初の通信プロトコルはVIA。対応プロトコルを絞り、段階的に増やす。
|
||||||
|
独自プロトコルの追加や、そのためのQMK・Vial等のフォークは行わない。
|
||||||
|
高度な動作定義、Staticの対応範囲、同期方式はまだ決めていない。
|
||||||
|
|
||||||
|
## 案の比較
|
||||||
|
|
||||||
|
| 案 | 扱い |
|
||||||
|
| --- | --- |
|
||||||
|
| 既存プロトコルで実機の読み書きを提供し、VIAから始める | 採用 |
|
||||||
|
| MAPエディタをStaticに閉じる | 採用しない |
|
||||||
|
| Dynamicの対応範囲を読み出しだけに限定する | ユーザーが読み書きの両対応を指定 |
|
||||||
|
| 独自プロトコルのためにファームウェアをフォークする | ユーザーが意図しないと明示 |
|
||||||
|
|
||||||
|
## 判断の根拠
|
||||||
|
|
||||||
|
ユーザーは、既存キーボード全般への対応よりも、新しく作るキーボードのワークフローを重視している。
|
||||||
|
VIAを話すなら読み書きの両方を提供し、利用者が好むRemapと同じ通信経路から始める方針となった。
|
||||||
|
当初のアシスタントによる「互換性」の説明はファイル変換に寄りすぎており、実機との通信を中心に修正した。
|
||||||
|
VIA向けのキーボード定義生成と、MAPの割り当ての読み書きは別の責務として整理した。
|
||||||
|
|
||||||
|
## 調査で確認した範囲
|
||||||
|
|
||||||
|
公式文書・実装を参照した。実機による読み書きの検証は行っていない。
|
||||||
|
|
||||||
|
- RemapはWebHIDを使い、QMKのVIAクライアント向け機能へアクセスする。Workbenchにはソース編集・ビルド・書き込みもある。
|
||||||
|
参照: https://github.com/remap-keys/remap#references
|
||||||
|
参照: https://remap-keys.app/docs/workbench
|
||||||
|
- VIAのキーボード定義には配置とmatrix座標の対応がある。QMKのVIA実装はキー単位の読み書きにlayer・row・colを使う。
|
||||||
|
参照: https://caniusevia.com/docs/specification/
|
||||||
|
参照: https://github.com/qmk/qmk_firmware/blob/master/quantum/via.c
|
||||||
|
- Vialではタップダンスやコンボを実機へ保存できるが、枠数等にはビルド時の制約がある。
|
||||||
|
参照: https://get.vial.today/manual/tap-dance.html
|
||||||
|
参照: https://get.vial.today/manual/combos.html
|
||||||
|
- ZMK Studioは既存behaviorの割り当てを変更できるが、新しいbehavior定義等は対象外。実機に保存した変更とソース側の設定の扱いにも注意が必要。
|
||||||
|
参照: https://zmk.dev/docs/features/studio
|
||||||
|
|
||||||
|
## 未採用の提案との区別
|
||||||
|
|
||||||
|
共通の動作モデル、ファームウェア固有表現の保持、Staticを先に実装する順序、差分を選んで反映するUIは、
|
||||||
|
アシスタントの提案段階にとどまる。今回の合意として固定しない。
|
||||||
|
Vial・ZMK Studioを次にどの順序で実装するかも決めていない。
|
||||||
@@ -50,11 +50,12 @@ ELCはIDXを参照せず、IDXもELCを参照しない。MAPもIDXを参照し
|
|||||||
|
|
||||||
### 4. MAPレイヤー (キーマップ)
|
### 4. MAPレイヤー (キーマップ)
|
||||||
|
|
||||||
- **役割**: キーとキーコードの対応
|
- **役割**: キーマップレイヤーごとのキーへの割り当て
|
||||||
- **入力**: PHYのキーID
|
- **入力**: PHYのキーID
|
||||||
- **対応ツール**: ファームウェア、VIA、Remap
|
- **対応ツール**: MAPエディタ (Keymap Designer)
|
||||||
- **出力**: レイヤー別キーコード
|
- **出力**: MAP JSON (現行形式はレイヤー別キーコード割り当て)
|
||||||
- **備考**: キーの参照にPHYのキーIDを使う。配列としての並べ替えは、出力時にバリアント選択後のIDXの並び順で行う
|
- **備考**: キーの参照にPHYのキーIDを使う。配列としての並べ替えは、出力時にバリアント選択後のIDXの並び順で行う
|
||||||
|
- **詳細**: [MAPレイヤー](MAPレイヤー.md)。動作定義と保存形式の拡張範囲は、[MAPのP-1](MAPレイヤー.md#p-1-割り当てる動作と保存形式の範囲)で扱う
|
||||||
|
|
||||||
### 5. IMEレイヤー (入力変換)
|
### 5. IMEレイヤー (入力変換)
|
||||||
|
|
||||||
@@ -85,19 +86,22 @@ ELCはIDXを参照せず、IDXもELCを参照しない。MAPもIDXを参照し
|
|||||||
- 視覚的に配線を確認
|
- 視覚的に配線を確認
|
||||||
- 出力: ELC JSON
|
- 出力: ELC JSON
|
||||||
|
|
||||||
#### Keymap Designer
|
#### Keymap Designer (MAPエディタ)
|
||||||
|
|
||||||
- PHY JSONを入力
|
- PHYの配置を参照し、キーマップレイヤーごとの割り当てを編集・保存する
|
||||||
- レイヤー別キーコード割り当て
|
- 新規キーボードの設計から実機での調整までを扱い、割り当ての読み書きを一組で提供する
|
||||||
- VIA/Remapとの互換性
|
- 実機との通信は既存のVIAプロトコルから対応し、対象を段階的に増やす
|
||||||
- 出力: MAP JSON
|
- 出力: MAP JSON
|
||||||
|
|
||||||
|
具体的な責務と通信上の制約は、[MAPレイヤー](MAPレイヤー.md#mapエディタの責務)に従う。
|
||||||
|
|
||||||
#### Firmware Generator
|
#### Firmware Generator
|
||||||
|
|
||||||
- 全レイヤーの情報を統合
|
- 全レイヤーの情報を統合
|
||||||
- バリアントを選択し、IDXから対象外のキーを除いた並びで、キー番号の対応表とMAPの配列を生成
|
- バリアントを選択し、IDXから対象外のキーを除いた並びで、キー番号の対応表とMAPの配列を生成
|
||||||
- row / col など、ターゲットが要求する派生情報をここで割り当てる
|
- row / col など、ターゲットが要求する派生情報をここで割り当てる
|
||||||
- QMK/VIA/KMK等へ変換
|
- QMK/VIA/KMK等へ変換
|
||||||
|
- VIA向けキーボード定義は、配置と接続先の対応などを統合して生成する
|
||||||
- 出力: ターゲット固有の設定ファイル
|
- 出力: ターゲット固有の設定ファイル
|
||||||
|
|
||||||
### 統合環境 (IDE)
|
### 統合環境 (IDE)
|
||||||
@@ -107,6 +111,9 @@ ELCはIDXを参照せず、IDXもELCを参照しない。MAPもIDXを参照し
|
|||||||
- **バージョン管理統合**: テキストベース定義でgit管理
|
- **バージョン管理統合**: テキストベース定義でgit管理
|
||||||
- **ワークフローサポート**: ガイド付きウィザード、テンプレート
|
- **ワークフローサポート**: ガイド付きウィザード、テンプレート
|
||||||
|
|
||||||
|
ビルド・ファームウェアの書き込みは、MAP編集とは別の実行機能として扱う。
|
||||||
|
同じ画面から各ツールを利用できることと、各レイヤーがそのデータや処理を所有することは区別する。
|
||||||
|
|
||||||
## プロトコル設計
|
## プロトコル設計
|
||||||
|
|
||||||
### レイヤー間インターフェース
|
### レイヤー間インターフェース
|
||||||
@@ -122,7 +129,7 @@ PHY → IDX
|
|||||||
|
|
||||||
PHY → MAP
|
PHY → MAP
|
||||||
Input: キーID
|
Input: キーID
|
||||||
Output: レイヤー別キーコード
|
Output: MAP JSON (現行形式はレイヤー別キーコード割り当て)
|
||||||
|
|
||||||
All → Firmware
|
All → Firmware
|
||||||
Input: 各レイヤーのデータ + バリアント選択
|
Input: 各レイヤーのデータ + バリアント選択
|
||||||
@@ -158,7 +165,7 @@ All → Firmware
|
|||||||
## 既存ツールとの関係
|
## 既存ツールとの関係
|
||||||
|
|
||||||
- **KLE**: PHYデータを表示できる既存のビュワー。KLE JSONの読み書きで連携する
|
- **KLE**: PHYデータを表示できる既存のビュワー。KLE JSONの読み書きで連携する
|
||||||
- **VIA/Remap**: MAP Layerクライアントとして連携
|
- **VIA/Remap**: 同じVIA対応実機の割り当てを編集する外部クライアント。外部で調整した結果もMAPエディタで読み出してプロジェクトへ戻す。対応範囲は[MAPのP-2](MAPレイヤー.md#p-2-viaの初期対応範囲)、実機との対応・状態管理は[MAPのP-3](MAPレイヤー.md#p-3-プロジェクトと実機の対応状態管理)で扱う
|
||||||
- **QMK Configurator**: Firmware Generatorのターゲット
|
- **QMK Configurator**: Firmware Generatorのターゲット
|
||||||
|
|
||||||
## 配慮事項
|
## 配慮事項
|
||||||
|
|||||||
Reference in New Issue
Block a user