From 6ede0e525649845d94cb0e07a893bd854cc322ef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?=
Date: Tue, 27 Oct 2026 20:28:45 +0900
Subject: [PATCH 1/7] =?UTF-8?q?=E9=9B=9B=E5=BD=A2=E3=81=AE=E3=83=95?=
=?UTF-8?q?=E3=83=83=E3=82=AF:=20=E7=B4=A0=E3=81=AE=20URL=20=E3=81=AE?=
=?UTF-8?q?=E4=B8=AD=E3=81=AE=20Q&A=20=E3=81=AE=E7=95=AA=E5=8F=B7=E3=82=92?=
=?UTF-8?q?=E6=9B=B8=E3=81=8D=E6=8F=9B=E3=81=88=E3=81=AA=E3=81=84=E3=83=BB?=
=?UTF-8?q?decision=5Ftable=20=E3=81=AE=E5=8D=B0=E3=81=A7=E6=B1=BA?=
=?UTF-8?q?=E5=AE=9A=E8=A1=A8=E3=82=92=E6=8F=8F=E3=81=8F=EF=BC=88#376?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
汎用の CSV の印は足さない(仕様の穴として推奨で決めた。確認は #480)。
Fixes #376
Fixes #341
Fixes #369
Co-Authored-By: Claude Opus 5.5
---
.claude-plugin/marketplace.json | 2 +-
docs/plans/issue-376.md | 102 ++++++++++++++++++
plugins/spec-loop/.claude-plugin/plugin.json | 2 +-
plugins/spec-loop/references/tree.md | 9 +-
plugins/spec-loop/skills/feasibility/SKILL.md | 4 +-
plugins/spec-loop/skills/proposal/SKILL.md | 4 +-
plugins/spec-loop/skills/req-loop/SKILL.md | 4 +-
plugins/spec-loop/skills/scaffold/SKILL.md | 4 +-
plugins/spec-loop/skills/spec-review/SKILL.md | 4 +-
.../templates/mkdocs/docs/decision-table.md | 6 ++
.../templates/mkdocs/hooks/spec_loop_pages.py | 48 ++++++++-
scripts/test-init-mkdocs.py | 71 +++++++++++-
12 files changed, 244 insertions(+), 16 deletions(-)
create mode 100644 docs/plans/issue-376.md
create mode 100644 plugins/spec-loop/templates/mkdocs/docs/decision-table.md
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 448012d5..5c4bd103 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -41,7 +41,7 @@
{
"name": "spec-loop",
"source": "./plugins/spec-loop",
- "version": "0.26.0",
+ "version": "0.28.0",
"author": { "name": "hdknr" },
"description": "要求側のループ(要求確定・仕様レビュー・実現性とコスト・提案書)と、dev-loop への引き渡しを担うプラグイン。現在のスキルは req-loop(Q&A 表への起票(推奨と理由つき)・回答の反映・レビュー会用 PPTX の生成で、利用者の回答待ちで止まる)と spec-review(開発者向けの仕様を書き下し、書き込みツールを持たない 3 つの判定役に別ティアでレビューさせ、利用者に見える結果が変わる指摘は共有の Q&A 表に差し戻し、費用・運用だけが変わる選択は内部の Q&A 表に記録し、範囲の外の場面と実現の手段の指摘は仕様に足さない。抜ける条件を満たさなければ抜けたと報告しない)と feasibility(規模から見積りとスケジュールを更新し、ガントチャートをスクリプトで生成し、費用付きの質問を Q&A 表に起票する。予算責任者の合意待ちで止まる)と proposal(同じ版の 2 つのサインオフのタグが同じ共有の範囲(docs/v/shared/ ほか)を指すかをスクリプトで照合し、一致し、かつ暫定の回答が残っていないときだけ提案書と仕様書(PPTX も選べる)を生成し、利用者にまだ確かめていない暫定の決定を合意と分けて載せる。v2 以降は前の版との比較を載せた変更の提案書になる。決裁者の承認待ちで止まる)と scaffold(確定した仕様を中間表現 spec_ir.json に書き起こし、OpenAPI・型・実装が無いと失敗する Red テストと dev-loop に渡す Issue の本文をスクリプトで生成して、1 機能 = Issue 1 件で登録する。v2 以降は前の版と比べて機能を追加・変更・削除に分け、変わった機能だけを Issue にする。Issue の登録で止まる)の 5 本"
}
diff --git a/docs/plans/issue-376.md b/docs/plans/issue-376.md
new file mode 100644
index 00000000..cdfd3056
--- /dev/null
+++ b/docs/plans/issue-376.md
@@ -0,0 +1,102 @@
+# Issue #376 — 雛形のフックで素の URL を壊さず、決定表を描く(#341・#369 のまとめ)
+
+雛形のフック(`templates/mkdocs/hooks/spec_loop_pages.py`)の 2 つの穴を 1 周で直す——素の本文の URL の中の Q&A の番号を
+リンクに書き換えて URL を壊す(#341)、決定表(`decision_table.csv`)を描く印が無く CSV への直リンクが開けない(#369)。
+
+**不在の周**である——出所は `/dev-loop:cruise 474 --absent`(2026-10-27、利用者が会話で「-absent でまわします」と指示)。
+台帳 `issue-474` の確かめた線: `仕様の穴 = 推奨で決めて起票`・`直す線 = 既定`・`マージ = 関門を通ればマージ`・`周の途中の課題 = 台帳に足して直す`
+(出所: cruise の確かめた線(台帳 `issue-474`))。**このリポジトリのマージはサイトを再デプロイするので `マージ` の線は効かない**(#476)——PR まで作って止まる。
+
+## 変更範囲
+
+触る:
+
+- `plugins/spec-loop/templates/mkdocs/hooks/spec_loop_pages.py` — 素の URL を除いて番号をリンクにする・`decision_table` の描画関数・docstring
+- `plugins/spec-loop/templates/mkdocs/docs/decision-table.md` — 決定表の表示用ページ(新規。`init-mkdocs.py` は雛形を全部写すので本体は変えない)
+- `scripts/test-init-mkdocs.py` — 素の URL・決定表の試料と変異、`tree.md` の照合の語
+- `plugins/spec-loop/references/tree.md` — ツリー・写すもの・`decision_table` の印・CSV へ直接リンクしない決まり・0.28.0 の取り込み
+- `plugins/spec-loop/.claude-plugin/plugin.json` — 版(0.26.0 → 0.28.0。機能の追加で minor。#477 が 0.26.1・#479 が 0.27.0 に上げるので重ならない値)
+- `.claude-plugin/marketplace.json` — 同じ版
+- `plugins/spec-loop/skills/*/SKILL.md` — 同じ版の 2 行
+- `docs/plans/issue-376.md` — この計画
+
+触らない:
+
+- `init-mkdocs.py`(雛形のディレクトリを全部写す——新しいページも写る)
+- 汎用の CSV の印(下の「仕様の穴」で足さないと決めた)
+- `qa-csv.md` の「本文での番号」(URL の中は元々番号として書く場所ではない)
+
+### 同じことを述べている箇所の数
+
+「フックが描くもの」: フックの docstring の表・`tree.md` の「ドキュメントのツール」の 2 箇所。「URL の中は変えない」: フックの docstring だけ。分けない——同じ決定の写し。
+
+## 仕様の穴(不在の線で推奨により決めた)
+
+Issue の受入基準 B の「版の中の任意の CSV を描く汎用の印にするかを決める」。**線は `仕様の穴 = 推奨で決めて起票`**。確認の Issue を起票する。
+
+- 選択肢: (a) `decision_table` の印だけ足す / (b) 汎用の印(``)も足す
+- **決めた値: (a)**
+- 理由: 決定表は spec-loop の決まりで `shared/` に置く標準の表で、どの正本リポジトリにもある。汎用の印は引数(パス)を取る新しい印の形になり、
+ 版をまたぐ扱い(重ねない・どの版のパスか)と、`shared/` の外(`spec/`)の表を利用者のサイトに描いてよいかを決める必要がある——
+ 実データ(#369)で描いた仕様の CSV は `spec/data/features.csv` などで、置き場所の決まりが spec-loop に無い。足すなら別の Issue で決める。
+
+URL の守り方(受入基準 A の「`/` を見る小さな修正か、URL 全体を除く形か」)は**実現の手段**なので、開発者として URL 全体を除く形に決めた(`/` 以外の前置き `?id=`・`#` も守れる)。
+
+## 乗るデプロイ経路
+
+プラグイン(`plugins/spec-loop/`)の版を上げてマージ。マージで main へ push されるとサイトが再デプロイされる(`docs/plans/` を足す)。
+
+## 周の在り処
+
+- ブランチ: `issue/376-hook-url-decision-table`
+- worktree: `/Users/hdknr/Projects/hdknr/claude-code-setup/.claude/worktrees/issue-376`
+- 起点: `eab5e13`
+- base: 参照 `origin/main`(`refs/remotes/origin/HEAD`)→ `eab5e1387a3fa7587aed615754fcb7bc35cd75e0`
+
+## verify の受入基準
+
+Issue 本文は検査対象であって要件ではない。
+
+不変条件:
+
+- **I1(#341)**: 素の本文の URL(`https://ex.com/Q-001`・`http://…/docs/Q-001.md`・`…?id=Q-001`・`…#Q-001`・`www.…`・日本語の直後の URL)の中の番号を書き換えない。
+- **I2(陽性対照)**: 地の文の番号(`(Q-001)`)は引き続きリンクになる。見出し・コード・リンクの中を変えない今までの決まりも変わらない。
+- **I3(#369)**: 印 `decision_table` が、各版の `docs/v/shared/decision_table.csv` を見出しの行どおりに版ごとの節に描く(セルはエスケープ・短い行は埋める・BOM を読む・空白だけの行は飛ばす)。無い版は飛ばし、1 つも無ければそう書く。読めない・見出しの無い表は警告。
+- **I4**: `init-mkdocs.py` が `docs/decision-table.md` を写す(空の根に雛形をすべて写す試料)。
+- **I5**: `tree.md` に、CSV へ直接リンクしない決まりと、写し済みのフックの取り込み(#339 の手順 4 に乗せる)がある。
+
+経路:
+
+| 経路 | 守り方 | 確かめ方 |
+| --- | --- | --- |
+| `on_page_content` → `link_numbers` の文字の書き換え | 文字の中の URL を先に除いてから番号を置き換える | 試料: 素の URL 6 形と地の文の番号(`hook_links_bare_url`)。URL を見ない・URL の中も書き換える・URL の後ろを書き換えない・`www.` を見ない変異 |
+| `on_page_markdown` → `RENDERERS` の `decision_table` | 描画関数 | 試料: 版ごと・見出し・エスケープ・短い行・長い行・BOM・空白だけの行・無い版・無い・読めない・見出しが無い |
+| `init-mkdocs.py` の写し | 雛形のディレクトリを全部写す(変えない) | 既存の「空の根には雛形をすべて写す」が新しいページも見る |
+
+各経路の守りを外す変異で赤になる(陽性対照つき)。
+
+生成物: 図の書き出し・`skill-metrics.py` の生成ブロックは該当しない——**生成物なし**。
+
+未証明: **実際の MkDocs(Python-Markdown と Material)で描いた HTML** に当てていない——試料は描いた HTML に見立てた文字列(今までの #324 の試料と同じ形。CI に MkDocs と Material の組が無い)。人間のレビューで、正本リポジトリで `mkdocs build` して URL と決定表を目で見ることをお願いする。
+
+## 未解決の判断
+
+上の「仕様の穴」を確認の Issue #480 で利用者に渡す(台帳の `decisions` に記録)。
+
+## 関門の進捗(再開点)
+
+- 直す線: `既定`(出所: cruise の確かめた線(台帳 `issue-474`))
+- 割り目(手順 4 の完了時): 不在のため訊かず続けた
+- 実験: 試料(`test-init-mkdocs.py` 51 件・変異 100 件)
+- Verifier 1 パス目: 未
+- `/code-review` 1 パス目: 未
+
+## 起票
+
+- この周の起票: 1 件(仕様の穴の確認の Issue。起票の承認は不在のため訊いていない)
+
+## 既知の限界・決着済みの論点
+
+- 汎用の CSV の印は足さない(仕様の穴として推奨で決めた。確認の Issue)。
+- URL は `<スキーム>://` か `www.` で始まり、空白・`<`・`>`・引用符の手前までとみなす——**URL の直後に空白を置かずに続けた文字(`https://ex.com/aでQ-001を見る`)は URL の一部と読む**ので、その中の番号はリンクにならない
+(壊すより残すほうに倒している。URL の終わりを全角の文字で切ると、日本語を含む URL を壊す)。
diff --git a/plugins/spec-loop/.claude-plugin/plugin.json b/plugins/spec-loop/.claude-plugin/plugin.json
index cc8d7cf6..94343814 100644
--- a/plugins/spec-loop/.claude-plugin/plugin.json
+++ b/plugins/spec-loop/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "spec-loop",
"description": "要求側のループ(要求確定・仕様レビュー・実現性とコスト・提案書)と、dev-loop への引き渡しを担うプラグイン。現在のスキルは req-loop(Q&A 表への起票(推奨と理由つき)・回答の反映・レビュー会用 PPTX の生成で、利用者の回答待ちで止まる)と spec-review(開発者向けの仕様を書き下し、書き込みツールを持たない 3 つの判定役に別ティアでレビューさせ、利用者に見える結果が変わる指摘は共有の Q&A 表に差し戻し、費用・運用だけが変わる選択は内部の Q&A 表に記録し、範囲の外の場面と実現の手段の指摘は仕様に足さない。抜ける条件を満たさなければ抜けたと報告しない)と feasibility(規模から見積りとスケジュールを更新し、ガントチャートをスクリプトで生成し、費用付きの質問を Q&A 表に起票する。予算責任者の合意待ちで止まる)と proposal(同じ版の 2 つのサインオフのタグが同じ共有の範囲(docs/v/shared/ ほか)を指すかをスクリプトで照合し、一致し、かつ暫定の回答が残っていないときだけ提案書と仕様書(PPTX も選べる)を生成し、利用者にまだ確かめていない暫定の決定を合意と分けて載せる。v2 以降は前の版との比較を載せた変更の提案書になる。決裁者の承認待ちで止まる)と scaffold(確定した仕様を中間表現 spec_ir.json に書き起こし、OpenAPI・型・実装が無いと失敗する Red テストと dev-loop に渡す Issue の本文をスクリプトで生成して、1 機能 = Issue 1 件で登録する。v2 以降は前の版と比べて機能を追加・変更・削除に分け、変わった機能だけを Issue にする。Issue の登録で止まる)の 5 本",
- "version": "0.26.0",
+ "version": "0.28.0",
"author": { "name": "hdknr" }
}
diff --git a/plugins/spec-loop/references/tree.md b/plugins/spec-loop/references/tree.md
index 0acdb851..cec76284 100644
--- a/plugins/spec-loop/references/tree.md
+++ b/plugins/spec-loop/references/tree.md
@@ -33,6 +33,7 @@ build/merged/ # 版を重ね合わせた全体(生成物。comm
spec-review/v.csv # 仕様レビューの実行の記録(版ごと。commit する。docs/ の外——列と理由は spec-exit.md の「実行の上限と記録」)
docs/index.md # 表紙(下の「ドキュメントのツール」。版の外)
docs/qa.md # Q&A 表の表示用ページ(同上。正本は各版の shared/qa.csv)
+docs/decision-table.md # 決定表の表示用ページ(同上。正本は各版の shared/decision_table.csv)
mkdocs.yml # MkDocs の設定(同上)
hooks/spec_loop_pages.py # 表示用ページを CSV から作るフック(同上)
```
@@ -155,11 +156,14 @@ python3 /scripts/init-mkdocs.py [<正本
在れば触らず、足りない依存・行を示すので、足すかどうかは利用者が決める。
- 写すもの: `mkdocs.yml`(Material・`use_directory_urls: false`・nav は書かず自動)・`pyproject.toml`(uv。
**MkDocs は 2 未満に固定**——2.0 は非互換の変更が予告されている)・`.gitignore`(`build/`・`site/`・`.venv/`・`__pycache__/`)・
- `hooks/spec_loop_pages.py`・`docs/index.md`・`docs/qa.md`。閲覧は `uv run mkdocs serve`。
+ `hooks/spec_loop_pages.py`・`docs/index.md`・`docs/qa.md`・`docs/decision-table.md`。閲覧は `uv run mkdocs serve`。
- **CSV は Markdown に写さない。** 表示用のページに印(``)を書き、フックが CSV から描く。
`qa` の印は、各版の `docs/v/shared/qa.csv` を**版ごとの節**に描く(重ね合わせない。アンカーは `v-q-001`)。
+ `decision_table` の印は、各版の `docs/v/shared/decision_table.csv` を**見出しの行どおり**に版ごとの節に描く(#369)。
CSV の文字は表のセルにだけ置く(段落に置くと、行頭の `!!! ` や字下げがブロックの記法に読まれる)。
ほかの CSV を描くときは、フックに描画関数を足す。
+- **要求・仕様から CSV へ直接リンクしない**——ブラウザでは CSV のページが開けない(#369)。リンクは表示用のページ
+ (`docs/qa.md`・`docs/decision-table.md`。版の節は `#v1` のようなアンカー)へ張る。
- **本文の Q&A の番号(`Q-008`)は、フックが Q&A 表(`docs/qa.md`)のアンカーへのリンクにする**(描画関数が描いた表のセルを含む。
Q&A 表のページそのものは除く)。リンクする版・置き換えないところはフックの docstring を正とする。
**本文の書き方の決まりは `qa-csv.md` の「本文での番号」を正とする。**
@@ -168,6 +172,9 @@ python3 /scripts/init-mkdocs.py [<正本
**印つきの番号(`Q-S-008`。spec-loop 0.24.0 から)をリンクにするのは 0.24.0 以降のフックだけ**——それより前に写したフックは
印つきの番号を素通しし、リンクにも「表に無い番号」の警告にもしない(黙って素の文字のまま出る)。0.24.0 に上げたら、
印つきの番号を起票する前に、同じ手順 4 の形でフックを取り込む。
+ **spec-loop 0.28.0 で、フックは素の本文の URL の中の番号を書き換えなくなり(#341)、`decision_table` の印を描くようになった(#369)**——
+ 同じ手順 4 の形でフックを取り込み、雛形の `docs/decision-table.md` を写す(在れば変えない)。それまで CSV へ直接張っていたリンクは
+ `docs/decision-table.md` へ張り替える。
- **表示用のページは `shared/` に置かない**(`docs/qa.md` のように版の外に置く)。`shared/` は 2 つのサインオフが比べる範囲
(`signoff-tags.md`)なので、表示用のファイルを入れると比べる中身が増える。版の外の名前は重ね合わせにも入らない(`versions.md`)。
- **フックはサーバーの起動時にしか読まれない**——直したら `mkdocs serve` を起動し直す。
diff --git a/plugins/spec-loop/skills/feasibility/SKILL.md b/plugins/spec-loop/skills/feasibility/SKILL.md
index 0b40556a..a799d0c3 100644
--- a/plugins/spec-loop/skills/feasibility/SKILL.md
+++ b/plugins/spec-loop/skills/feasibility/SKILL.md
@@ -5,8 +5,8 @@ description: "実現性・コストのループを 1 回分進める。要求・
# feasibility スキル
-
-> **このスキルの版: 0.26.0**(プラグイン `spec-loop`)。
+
+> **このスキルの版: 0.28.0**(プラグイン `spec-loop`)。
> 手元で読まれている版がリポジトリの最新より古いなら、
> `/plugin marketplace update` → `/plugin update spec-loop@claude-code-setup` の順に実行し、
> Claude Code を再起動する([#63](https://github.com/hdknr/claude-code-setup/issues/63))。
diff --git a/plugins/spec-loop/skills/proposal/SKILL.md b/plugins/spec-loop/skills/proposal/SKILL.md
index b44b6709..fee029c6 100644
--- a/plugins/spec-loop/skills/proposal/SKILL.md
+++ b/plugins/spec-loop/skills/proposal/SKILL.md
@@ -5,8 +5,8 @@ description: "提案書のループを 1 回分進める。利用者の要求確
# proposal スキル
-
-> **このスキルの版: 0.26.0**(プラグイン `spec-loop`)。
+
+> **このスキルの版: 0.28.0**(プラグイン `spec-loop`)。
> 手元で読まれている版がリポジトリの最新より古いなら、
> `/plugin marketplace update` → `/plugin update spec-loop@claude-code-setup` の順に実行し、
> Claude Code を再起動する([#63](https://github.com/hdknr/claude-code-setup/issues/63))。
diff --git a/plugins/spec-loop/skills/req-loop/SKILL.md b/plugins/spec-loop/skills/req-loop/SKILL.md
index 8aacf123..a85f2d0c 100644
--- a/plugins/spec-loop/skills/req-loop/SKILL.md
+++ b/plugins/spec-loop/skills/req-loop/SKILL.md
@@ -5,8 +5,8 @@ description: "要求確定のループを 1 周(= レビュー会 1 回分)
# req-loop スキル
-
-> **このスキルの版: 0.26.0**(プラグイン `spec-loop`)。
+
+> **このスキルの版: 0.28.0**(プラグイン `spec-loop`)。
> 手元で読まれている版がリポジトリの最新より古いなら、
> `/plugin marketplace update` → `/plugin update spec-loop@claude-code-setup` の順に実行し、
> Claude Code を再起動する([#63](https://github.com/hdknr/claude-code-setup/issues/63))。
diff --git a/plugins/spec-loop/skills/scaffold/SKILL.md b/plugins/spec-loop/skills/scaffold/SKILL.md
index 32e29739..fb40d87c 100644
--- a/plugins/spec-loop/skills/scaffold/SKILL.md
+++ b/plugins/spec-loop/skills/scaffold/SKILL.md
@@ -5,8 +5,8 @@ description: "確定した仕様を dev-loop の入力(Issue)に変える引
# scaffold スキル
-
-> **このスキルの版: 0.26.0**(プラグイン `spec-loop`)。
+
+> **このスキルの版: 0.28.0**(プラグイン `spec-loop`)。
> 手元で読まれている版がリポジトリの最新より古いなら、
> `/plugin marketplace update` → `/plugin update spec-loop@claude-code-setup` の順に実行し、
> Claude Code を再起動する([#63](https://github.com/hdknr/claude-code-setup/issues/63))。
diff --git a/plugins/spec-loop/skills/spec-review/SKILL.md b/plugins/spec-loop/skills/spec-review/SKILL.md
index 4246fd1d..dd1d45da 100644
--- a/plugins/spec-loop/skills/spec-review/SKILL.md
+++ b/plugins/spec-loop/skills/spec-review/SKILL.md
@@ -5,8 +5,8 @@ description: "仕様レビューのループを 1 回分進める。確定した
# spec-review スキル
-
-> **このスキルの版: 0.26.0**(プラグイン `spec-loop`)。
+
+> **このスキルの版: 0.28.0**(プラグイン `spec-loop`)。
> 手元で読まれている版がリポジトリの最新より古いなら、
> `/plugin marketplace update` → `/plugin update spec-loop@claude-code-setup` の順に実行し、
> Claude Code を再起動する([#63](https://github.com/hdknr/claude-code-setup/issues/63))。
diff --git a/plugins/spec-loop/templates/mkdocs/docs/decision-table.md b/plugins/spec-loop/templates/mkdocs/docs/decision-table.md
new file mode 100644
index 00000000..4b7c0fb0
--- /dev/null
+++ b/plugins/spec-loop/templates/mkdocs/docs/decision-table.md
@@ -0,0 +1,6 @@
+# 決定表
+
+正本は各版の `shared/decision_table.csv` で、このページはそこから作る(このページに表を書き写さない)。
+要求・仕様から決定表を指すときは、CSV ではなくこのページへリンクする。
+
+
diff --git a/plugins/spec-loop/templates/mkdocs/hooks/spec_loop_pages.py b/plugins/spec-loop/templates/mkdocs/hooks/spec_loop_pages.py
index 6d66c04c..598d4623 100644
--- a/plugins/spec-loop/templates/mkdocs/hooks/spec_loop_pages.py
+++ b/plugins/spec-loop/templates/mkdocs/hooks/spec_loop_pages.py
@@ -6,6 +6,7 @@
| 印 | 描くもの |
| --- | --- |
| `qa` | 各版の `docs/v/shared/qa.csv` を、版ごとの節に(件数・一覧・質問ごとの詳細。推奨の選択肢と理由も) |
+| `decision_table` | 各版の `docs/v/shared/decision_table.csv` を、版ごとの節に(見出しの行どおりの表。#369) |
**描くのは共有の層の表だけ**——内部の層の表(`docs/v/spec/qa-dev.csv`)は描かない(開発者が決めて記録するもの。
spec-loop の `references/qa-csv.md` の「層」)。
@@ -21,6 +22,8 @@
- セルの文字は HTML と Markdown の記号をエスケープし、改行は `
` にする(表を壊さない)。
**CSV の文字は表のセルにだけ置く**——段落や箇条書きに置くと、行頭の `!!! `・字下げ・`1. ` がブロックの記法に読まれる。
- 描くものを足すときは、描画関数を書いて `RENDERERS` に足す。
+- **決定表(`decision_table`)は列を決めない**——見出しの行をそのまま表の見出しにする(列の決まりは正本リポジトリが持つ)。
+ 見出しの行が空・空白だけなら警告する。版は重ね合わせない(Q&A 表と同じ)。
**本文の Q&A の番号(`Q-008`)は、Q&A 表のページ(`qa.md`)のアンカーへのリンクにする**(正本は番号のまま。
決まりは spec-loop の `references/qa-csv.md` の「本文での番号」)。`on_page_content` が**描いた後の HTML** に掛けるので、
@@ -36,6 +39,9 @@
- **リンクにする版の Q&A 表に無い番号はリンクにせず警告する**(打ち間違いを拾う)。版の下のページなら、
その版より新しい版にだけある番号もここに入る。読めない表の番号は数えない(表の警告は Q&A 表のページで出る)。
- Q&A 表のページ(`qa` の印を持つページ)と、`qa.md` の無いサイトは変えない。
+- **素の本文に書いた URL(`https://ex.com/Q-001`・`…?id=Q-001`・`…#Q-001`)の中の番号は変えない**(#341)——
+ `` の中は上の決まりで変えないが、素の URL は MkDocs が `` にしないので、文字の中から URL を先に除く。
+ URL は `<スキーム>://` か `www.` で始まり、空白・`<`・`>`・引用符の手前までとみなす。
"""
from __future__ import annotations
@@ -61,6 +67,9 @@
QA_OPTION_SEPARATOR = " / "
QA_PAGE = "qa.md"
+DECISION_TABLE = "decision_table.csv"
+# 素の本文の URL(#341)。番号のリンク化はこの中を変えない
+URL = re.compile(r"(?:[A-Za-z][A-Za-z0-9+.-]*://|www\.)[^\s<>\"']+")
# 起票元ごとの番号の印(`Q-S-001`。#278)。印の無い前の形の番号(`Q-001`)もリンクにする
QA_MARKS = "RSFD"
QA_NUMBER = re.compile(rf"(? str:
return "\n".join(out)
+def render_decision_table(docs_dir: Path) -> str:
+ """各版の `shared/decision_table.csv` を、見出しの行どおりの表にする(#369)。"""
+ out: list[str] = []
+ for version, root in versions(docs_dir):
+ path = root / "shared" / DECISION_TABLE
+ if not path.is_file(): # 決定表の無い版は飛ばす
+ continue
+ try:
+ with path.open(newline="", encoding="utf-8-sig") as f:
+ rows = [row for row in csv.reader(f) if any(cell.strip() for cell in row)]
+ if not rows: # 空白だけの行は読み飛ばしたので、残った最初の行が見出し
+ raise ValueError("見出しの行が無い")
+ except (OSError, ValueError, csv.Error) as e: # UnicodeDecodeError は ValueError の子
+ out += [problem(f"{path.relative_to(docs_dir)}: {e}"), ""]
+ continue
+ header, body = rows[0], rows[1:]
+ out += [f"## v{version}", "", "| " + " | ".join(text(cell) for cell in header) + " |",
+ "| " + " | ".join("---" for _ in header) + " |"]
+ out += ["| " + " | ".join(text(cell) for cell in (row + [""] * (len(header) - len(row)))[:len(header)]) + " |"
+ for row in body]
+ out.append("")
+ if not out:
+ return "決定表(`docs/v/shared/decision_table.csv`)はまだ無い。\n"
+ return "\n".join(out)
+
+
RENDERERS = {
"qa": render_qa,
+ "decision_table": render_decision_table,
}
@@ -201,17 +237,25 @@ def link(match: re.Match) -> str:
return number
return f'{number}'
+ def link_text(between: str) -> str:
+ """文字の中の番号をリンクにする。素の URL の中は変えない(#341)。"""
+ parts, start = [], 0
+ for url in URL.finditer(between):
+ parts += [QA_NUMBER.sub(link, between[start:url.start()]), url.group(0)]
+ start = url.end()
+ return "".join(parts + [QA_NUMBER.sub(link, between[start:])])
+
out, last = [], 0
inside = {name: 0 for name in UNLINKED} # 開いている要素の数(入れ子・閉じ忘れの多い HTML でも数で追う)
for match in MARKUP.finditer(content):
between = content[last:match.start()]
- out += [between if any(inside.values()) else QA_NUMBER.sub(link, between), match.group(0)]
+ out += [between if any(inside.values()) else link_text(between), match.group(0)]
last = match.end()
name = (match.group(2) or "").lower()
if name in inside and not match.group(0).endswith("/>"):
inside[name] = max(inside[name] - 1, 0) if match.group(1) else inside[name] + 1
between = content[last:]
- out.append(between if any(inside.values()) else QA_NUMBER.sub(link, between))
+ out.append(between if any(inside.values()) else link_text(between))
for number in unknown:
log.warning("spec-loop: %s: %s は %sにも無いので、リンクにしない(読めない表は数えない)",
src_path, number, scope)
diff --git a/scripts/test-init-mkdocs.py b/scripts/test-init-mkdocs.py
index 5db1e163..340c4972 100644
--- a/scripts/test-init-mkdocs.py
+++ b/scripts/test-init-mkdocs.py
@@ -692,6 +692,49 @@ def merge_ignores_pages(plugin: Path, work: Path) -> bool:
name.endswith(("qa.md", "index.md")) for name in merged)
+
+# 素の本文の URL の中の番号は変えない(#341)。`/`・`?id=`・`#` の前置き、日本語の直後の URL も。地の文の番号は変える(陽性対照)
+BARE_URLS = ("https://ex.com/Q-001 と http://h/docs/Q-001.md と https://ex.com/?id=Q-001 と https://ex.com/#Q-001 と "
+ "www.ex.com/Q-002 と 参照https://ex.com/a/Q-002 と")
+
+
+def hook_links_bare_url(plugin: Path, work: Path) -> bool:
+ docs = link_site(work)
+ out, warnings = link(plugin, docs, f"{BARE_URLS}(Q-001)
\n", "index.md")
+ return not warnings and out == f"{BARE_URLS}({a('Q-001', 'qa.html#v1-q-001')})
\n"
+
+
+def hook_decision_table(plugin: Path, work: Path) -> bool:
+ docs = work / "docs"
+ write(docs, {"v1/shared/decision_table.csv": "\ufeff条件,結果\nA|x,y\n\n , \n",
+ "v2/requirements/a.md": "# a\n",
+ "v3/shared/decision_table.csv": "列1,列2,列3\n1,2\n4,5,6,7\n"})
+ out, warnings = render(plugin, docs, "# 決定表\n\n\n", src="decision-table.md")
+ v1, v3 = out.index("## v1"), out.index("## v3")
+ return (not warnings and v1 < v3 and "## v2" not in out
+ and "| 条件 | 結果 |\n| --- | --- |\n| A\\|x | <b>y</b> |\n\n## v3" in out
+ and "| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| 1 | 2 | |\n| 4 | 5 | 6 |\n" in out[v3:]
+ and "")
+ return not warnings and "決定表(`docs/v/shared/decision_table.csv`)はまだ無い" in out
+
+
+def hook_decision_table_unreadable(plugin: Path, work: Path) -> bool:
+ docs = work / "docs"
+ (docs / "v1" / "shared").mkdir(parents=True)
+ (docs / "v1" / "shared" / "decision_table.csv").write_bytes(b"\xff\xfe")
+ (docs / "v2" / "shared").mkdir(parents=True)
+ (docs / "v2" / "shared" / "decision_table.csv").write_text(" , \n", encoding="utf-8")
+ out, warnings = render(plugin, docs, "")
+ return (len(warnings) == 2 and "v1/shared/decision_table.csv" in warnings[0]
+ and "v2/shared/decision_table.csv: 見出しの行が無い" in warnings[1] and out.count("表示できない") == 2)
+
+
CASES = {
"空の根には雛形をすべて写す": init_empty,
"mkdocs.yml が在れば何も写さない": init_config_exists,
@@ -739,11 +782,36 @@ def merge_ignores_pages(plugin: Path, work: Path) -> bool:
"Q&A 表のページは変えない": hook_links_not_qa_page,
"描画した表のセルも置き換える": hook_links_rendered,
"qa.md が無ければ変えない": hook_links_no_qa_page,
+ "素の URL の中の番号は変えない": hook_links_bare_url,
+ "決定表を版ごとに見出しの行どおりに描く": hook_decision_table,
+ "決定表が無ければそう書く": hook_decision_table_none,
+ "読めない・見出しの無い決定表は警告": hook_decision_table_unreadable,
"重ね合わせに表示用のファイルが混ざらない": merge_ignores_pages,
}
# (名前, 壊す対象, 壊す前, 壊した後, 殺すはずの試料の名前)。2 箇所を壊すときは (壊す前, 壊した後) の列を渡す
MUTATIONS: list[tuple] = [
+ ("素の URL の中も書き換える", HOOK, " parts += [QA_NUMBER.sub(link, between[start:url.start()]), url.group(0)]",
+ " parts += [QA_NUMBER.sub(link, between[start:url.start()]), QA_NUMBER.sub(link, url.group(0))]",
+ "素の URL の中の番号は変えない"),
+ ("URL を見ない", HOOK, " for url in URL.finditer(between):", " for url in []:", "素の URL の中の番号は変えない"),
+ ("www. の URL を見ない", HOOK, 'URL = re.compile(r"(?:[A-Za-z][A-Za-z0-9+.-]*://|www\\.)', 'URL = re.compile(r"(?:[A-Za-z][A-Za-z0-9+.-]*://)',
+ "素の URL の中の番号は変えない"),
+ ("URL の後ろを書き換えない", HOOK, 'return "".join(parts + [QA_NUMBER.sub(link, between[start:])])',
+ 'return "".join(parts + [between[start:]])', "素の URL の中の番号は変えない"),
+ ("決定表を描かない", HOOK, ' "decision_table": render_decision_table,\n', "", "決定表を版ごとに見出しの行どおりに描く"),
+ ("決定表の見出しを描かない", HOOK, '"| " + " | ".join(text(cell) for cell in header) + " |",',
+ '"| " + " | ".join("" for cell in header) + " |",', "決定表を版ごとに見出しの行どおりに描く"),
+ ("決定表をエスケープしない", HOOK, '" | ".join(text(cell) for cell in (row + [""]', '" | ".join(cell for cell in (row + [""]',
+ "決定表を版ごとに見出しの行どおりに描く"),
+ ("決定表の短い行を埋めない", HOOK, "(row + [\"\"] * (len(header) - len(row)))[:len(header)]", "row",
+ "決定表を版ごとに見出しの行どおりに描く"),
+ ("決定表の BOM を読まない", HOOK, 'with path.open(newline="", encoding="utf-8-sig") as f:',
+ 'with path.open(newline="", encoding="utf-8") as f:', "決定表を版ごとに見出しの行どおりに描く"),
+ ("決定表の空白だけの行も描く", HOOK, "rows = [row for row in csv.reader(f) if any(cell.strip() for cell in row)]",
+ "rows = [row for row in csv.reader(f) if row]", "決定表を版ごとに見出しの行どおりに描く"),
+ ("見出しの無い決定表を黙る", HOOK, ' if not rows: # 空白だけの行は', ' if False: # 空白だけの行は',
+ "読めない・見出しの無い決定表は警告"),
("mkdocs.yml が在っても写す", INIT, " if os.path.lexists(root / name):\n", " if False:\n",
"mkdocs.yml が在れば何も写さない"),
("mkdocs.yaml を設定とみなさない", INIT, 'CONFIG_NAMES = ("mkdocs.yml", "mkdocs.yaml")', 'CONFIG_NAMES = ("mkdocs.yml",)',
@@ -979,7 +1047,8 @@ def check_reference() -> None:
text = REFERENCE.read_text(encoding="utf-8")
check("references/tree.md に「ドキュメントのツール」の節が無い", "\n## ドキュメントのツール\n" in text)
section = text.split("\n## ドキュメントのツール\n", 1)[-1].split("\n## ", 1)[0]
- for word in ("scripts/init-mkdocs.py", "mkdocs.yml", "docs/qa.md", "shared/", "`qa-csv.md` の「本文での番号」"):
+ for word in ("scripts/init-mkdocs.py", "mkdocs.yml", "docs/qa.md", "docs/decision-table.md", "shared/",
+ "`qa-csv.md` の「本文での番号」", "CSV へ直接リンクしない"):
check(f"references/tree.md の「ドキュメントのツール」に {word} が無い", word in section)
qa = (PLUGIN / "references" / "qa-csv.md").read_text(encoding="utf-8")
check("references/qa-csv.md に「本文での番号」の節が無い", "\n## 本文での番号\n" in qa)
From 7b446138dc17206109ec2c5d7a34cf35fa7d978b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?=
Date: Tue, 27 Oct 2026 20:29:02 +0900
Subject: [PATCH 2/7] =?UTF-8?q?issue-376:=20=E5=86=8D=E9=96=8B=E7=82=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
docs/plans/issue-376.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/plans/issue-376.md b/docs/plans/issue-376.md
index cdfd3056..70ef5268 100644
--- a/docs/plans/issue-376.md
+++ b/docs/plans/issue-376.md
@@ -88,8 +88,9 @@ Issue 本文は検査対象であって要件ではない。
- 直す線: `既定`(出所: cruise の確かめた線(台帳 `issue-474`))
- 割り目(手順 4 の完了時): 不在のため訊かず続けた
- 実験: 試料(`test-init-mkdocs.py` 51 件・変異 100 件)
-- Verifier 1 パス目: 未
-- `/code-review` 1 パス目: 未
+- 1 パス目の起点: `6ede0e5`(この後のコミットは計画ファイルだけ)
+- Verifier 1 パス目: 起動(sonnet)
+- `/code-review` 1 パス目: 起動(high)
## 起票
From 9184f8d4f6e2d62cb03c77376b274d6a7d6fdf2d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?=
Date: Tue, 27 Oct 2026 20:34:13 +0900
Subject: [PATCH 3/7] =?UTF-8?q?=E9=9B=9B=E5=BD=A2=E3=81=AE=E3=83=95?=
=?UTF-8?q?=E3=83=83=E3=82=AF:=20=E6=B1=BA=E5=AE=9A=E8=A1=A8=E3=81=AE?=
=?UTF-8?q?=E9=95=B7=E3=81=84=E8=A1=8C=E3=82=92=E5=88=87=E3=82=89=E3=81=AA?=
=?UTF-8?q?=E3=81=84=E3=83=BB=E8=A6=8B=E5=87=BA=E3=81=97=E3=81=AE=E7=A9=BA?=
=?UTF-8?q?=E3=82=92=E8=AD=A6=E5=91=8A=E3=83=BBURL=20=E3=82=92=E5=85=A8?=
=?UTF-8?q?=E8=A7=92=E3=81=AE=E5=8F=A5=E8=AA=AD=E7=82=B9=E3=81=A8=E5=AE=9F?=
=?UTF-8?q?=E4=BD=93=E5=8F=82=E7=85=A7=E3=81=A7=E7=B5=82=E3=81=88=E3=82=8B?=
=?UTF-8?q?=EF=BC=88#376=20=E3=81=AE=201=20=E3=83=91=E3=82=B9=E7=9B=AE?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
docs/plans/issue-376.md | 27 ++++++++-
.../templates/mkdocs/hooks/spec_loop_pages.py | 22 +++++---
scripts/test-init-mkdocs.py | 56 +++++++++++++++----
3 files changed, 84 insertions(+), 21 deletions(-)
diff --git a/docs/plans/issue-376.md b/docs/plans/issue-376.md
index 70ef5268..7a219c9a 100644
--- a/docs/plans/issue-376.md
+++ b/docs/plans/issue-376.md
@@ -89,8 +89,10 @@ Issue 本文は検査対象であって要件ではない。
- 割り目(手順 4 の完了時): 不在のため訊かず続けた
- 実験: 試料(`test-init-mkdocs.py` 51 件・変異 100 件)
- 1 パス目の起点: `6ede0e5`(この後のコミットは計画ファイルだけ)
-- Verifier 1 パス目: 起動(sonnet)
-- `/code-review` 1 パス目: 起動(high)
+- Verifier 1 パス目: 通した——反証 2 件(V1・V2)
+- `/code-review` 1 パス目: 通した——指摘 8 件(CR1〜CR8)
+- 1 パス目の指摘対応: V1・CR2・CR5・V2・CR4・CR1・CR3 を直した。差分は 0 行でない → 2 パス目を回す
+- 割り目(2 パス目の前): 不在のため訊かず続けた
## 起票
@@ -99,5 +101,26 @@ Issue 本文は検査対象であって要件ではない。
## 既知の限界・決着済みの論点
- 汎用の CSV の印は足さない(仕様の穴として推奨で決めた。確認の Issue)。
+- URL の終わり: 1 パス目の指摘で、空白・`<`・`>`・引用符に加えて、描いた HTML の実体参照(`<` など)と全角の括弧・句読点で終えるようにした。
+ **半角の記号(`.`・`)`)は URL の中にも現れるので区切りにしない**——`https://ex.com/a.Q-001`・`(https://ex.com/a)Q-001` の番号はリンクにならない。
+
+### 1 パス目の指摘の採否
+
+Verifier(sonnet): 反証 2 件。`/code-review`(high): 8 件。どちらも `git status --porcelain` は前後とも空。MkDocs は手元に無く、実際の `mkdocs build` には当てていない(未証明のまま)。
+
+| # | 指摘 | 実物で失敗を示せるか | 破る受入基準 | 採否 |
+| --- | --- | --- | --- | --- |
+| V1・CR2 | 決定表の見出しより長い行の余りを黙って切る(`build-proposal.py` の仕様書は埋める) | 示せる(Verifier・`/code-review` の直接実行: `1,2,3` → `| 1 | 2 |`) | I3(表を描く——セルが消える) | 直す(見出しを空で埋めて全部の列を描く) |
+| CR5 | URL の後ろの全角の句読点・閉じ括弧(`(https://…)。Q-001`)で、次の番号がリンクにならない(直す前はなっていた) | 示せる(`/code-review` の試料) | I2 | 直す(全角の括弧・句読点で URL を終える) |
+| V2 | URL の直前に空白なしの番号(`Q-001https://…`)がスキームに飲まれる | 示せる(Verifier の直接実行) | I2 | 直す(スキームを英字だけに) |
+| CR4 | 描いた HTML の `<`・`>`・`"` で URL が終わらない | 示せる(`/code-review` の試料) | I2 | 直す(実体参照で URL を終える) |
+| CR1 | 見出しの行が空だと、次の行を見出しに繰り上げて黙る(docstring は警告すると言う) | 示せる(`/code-review` の試料) | I3(見出しの無い表は警告) | 直す(最初の行を見出しとし、空なら警告) |
+| CR3 | 「空の根に雛形をすべて写す」は 6 件以上しか見ないので、`decision-table.md` が無くても通る | 示せる(試料の条件 `len(expected) >= 6`) | I4 | 直す(ページの在る・印・写ることを見る試料と変異) |
+| CR6 | 決定表の読み方が `read_qa`・`build-proposal.py` の写しで、突き合わせが無い | 示せない(ずれは V1 で直した分だけ) | 無し | 残課題 |
+| CR7 | `mailto:Q-001@ex.com` のように `//` の無いスキームの中の番号を書き換える | 示せる(`/code-review` の試料) | 無し(I1 の列挙の外) | 残課題 |
+| CR8 | `qa.csv` を定数にしていない(`DECISION_TABLE` だけ定数) | 示せない(保守の手間) | 無し | 残課題 |
+
+1 パス目の対応で URL の正規表現を書き直したとき、変異 3 件が生き残った——否定の文字クラスと先読みが重なり、片方を外しても振る舞いが変わらなかった(等価変異)。
+先読みだけで終える形(`\S+?(?=…)`)に縮め、振る舞いに効かない条件(終わりの半角の句読点を外す)は落とした。
- URL は `<スキーム>://` か `www.` で始まり、空白・`<`・`>`・引用符の手前までとみなす——**URL の直後に空白を置かずに続けた文字(`https://ex.com/aでQ-001を見る`)は URL の一部と読む**ので、その中の番号はリンクにならない
(壊すより残すほうに倒している。URL の終わりを全角の文字で切ると、日本語を含む URL を壊す)。
diff --git a/plugins/spec-loop/templates/mkdocs/hooks/spec_loop_pages.py b/plugins/spec-loop/templates/mkdocs/hooks/spec_loop_pages.py
index 598d4623..c377b2f4 100644
--- a/plugins/spec-loop/templates/mkdocs/hooks/spec_loop_pages.py
+++ b/plugins/spec-loop/templates/mkdocs/hooks/spec_loop_pages.py
@@ -23,7 +23,8 @@
**CSV の文字は表のセルにだけ置く**——段落や箇条書きに置くと、行頭の `!!! `・字下げ・`1. ` がブロックの記法に読まれる。
- 描くものを足すときは、描画関数を書いて `RENDERERS` に足す。
- **決定表(`decision_table`)は列を決めない**——見出しの行をそのまま表の見出しにする(列の決まりは正本リポジトリが持つ)。
- 見出しの行が空・空白だけなら警告する。版は重ね合わせない(Q&A 表と同じ)。
+ **最初の行が見出し**で、空・空白だけなら警告する(次の行を見出しに繰り上げない)。見出しより長い行があれば、
+ 見出しの右を空で埋めて**全部の列を描く**(黙って切らない。`build-proposal.py` の仕様書と同じ)。版は重ね合わせない(Q&A 表と同じ)。
**本文の Q&A の番号(`Q-008`)は、Q&A 表のページ(`qa.md`)のアンカーへのリンクにする**(正本は番号のまま。
決まりは spec-loop の `references/qa-csv.md` の「本文での番号」)。`on_page_content` が**描いた後の HTML** に掛けるので、
@@ -41,7 +42,9 @@
- Q&A 表のページ(`qa` の印を持つページ)と、`qa.md` の無いサイトは変えない。
- **素の本文に書いた URL(`https://ex.com/Q-001`・`…?id=Q-001`・`…#Q-001`)の中の番号は変えない**(#341)——
`` の中は上の決まりで変えないが、素の URL は MkDocs が `` にしないので、文字の中から URL を先に除く。
- URL は `<スキーム>://` か `www.` で始まり、空白・`<`・`>`・引用符の手前までとみなす。
+ URL は `<英字のスキーム>://` か `www.` で始まり、空白・`<`・`>`・引用符(描いた HTML の `<`・`>`・`"`・`'` も)・
+ 全角の括弧と句読点(`()「」『』。、`)の手前までとみなす(文の区切りの後ろの番号をリンクにするため)。
+ 半角の記号(`.`・`)` など)は URL の中にも現れるので区切りにしない——`https://ex.com/a.Q-001` の番号はリンクにならない。
"""
from __future__ import annotations
@@ -69,7 +72,7 @@
QA_PAGE = "qa.md"
DECISION_TABLE = "decision_table.csv"
# 素の本文の URL(#341)。番号のリンク化はこの中を変えない
-URL = re.compile(r"(?:[A-Za-z][A-Za-z0-9+.-]*://|www\.)[^\s<>\"']+")
+URL = re.compile(r"(?:[A-Za-z]+://|www\.)\S+?(?=[\s<>\"'()「」『』。、]|&(?:lt|gt|quot|#39|#x27);|$)")
# 起票元ごとの番号の印(`Q-S-001`。#278)。印の無い前の形の番号(`Q-001`)もリンクにする
QA_MARKS = "RSFD"
QA_NUMBER = re.compile(rf"(? str:
continue
try:
with path.open(newline="", encoding="utf-8-sig") as f:
- rows = [row for row in csv.reader(f) if any(cell.strip() for cell in row)]
- if not rows: # 空白だけの行は読み飛ばしたので、残った最初の行が見出し
- raise ValueError("見出しの行が無い")
+ reader = csv.reader(f)
+ header = next(reader, [])
+ body = [row for row in reader if "".join(row).strip()]
+ if not any(cell.strip() for cell in header): # 最初の行が見出し——空なら次の行を繰り上げずに警告
+ raise ValueError("見出しの行が無い(最初の行が空)")
except (OSError, ValueError, csv.Error) as e: # UnicodeDecodeError は ValueError の子
out += [problem(f"{path.relative_to(docs_dir)}: {e}"), ""]
continue
- header, body = rows[0], rows[1:]
+ width = max([len(header)] + [len(row) for row in body]) # 見出しより長い行も切らない
+ header = header + [""] * (width - len(header))
out += [f"## v{version}", "", "| " + " | ".join(text(cell) for cell in header) + " |",
"| " + " | ".join("---" for _ in header) + " |"]
- out += ["| " + " | ".join(text(cell) for cell in (row + [""] * (len(header) - len(row)))[:len(header)]) + " |"
+ out += ["| " + " | ".join(text(cell) for cell in row + [""] * (width - len(row))) + " |"
for row in body]
out.append("")
if not out:
diff --git a/scripts/test-init-mkdocs.py b/scripts/test-init-mkdocs.py
index 340c4972..fbd9f81b 100644
--- a/scripts/test-init-mkdocs.py
+++ b/scripts/test-init-mkdocs.py
@@ -698,10 +698,17 @@ def merge_ignores_pages(plugin: Path, work: Path) -> bool:
"www.ex.com/Q-002 と 参照https://ex.com/a/Q-002 と")
+# URL の後ろの全角の句読点・閉じ括弧・描いた HTML の実体参照で URL を終える。URL の直前の番号も URL に入れない(#376 の 1 パス目)
+AFTER_URLS = ("(https://ex.com/a)。{q}で 、https://ex.com/b、{q} <https://ex.com/c>{q} https://ex.com/d"{q} "
+ "{q}https://ex.com/e https://ex.com/f. {q}")
+
+
def hook_links_bare_url(plugin: Path, work: Path) -> bool:
docs = link_site(work)
out, warnings = link(plugin, docs, f"{BARE_URLS}(Q-001)
\n", "index.md")
- return not warnings and out == f"{BARE_URLS}({a('Q-001', 'qa.html#v1-q-001')})
\n"
+ after, warned = link(plugin, docs, f"{AFTER_URLS.format(q='Q-001')}
\n", "index.md")
+ return (not warnings and out == f"{BARE_URLS}({a('Q-001', 'qa.html#v1-q-001')})
\n"
+ and not warned and after == f"{AFTER_URLS.format(q=a('Q-001', 'qa.html#v1-q-001'))}
\n")
def hook_decision_table(plugin: Path, work: Path) -> bool:
@@ -713,7 +720,7 @@ def hook_decision_table(plugin: Path, work: Path) -> bool:
v1, v3 = out.index("## v1"), out.index("## v3")
return (not warnings and v1 < v3 and "## v2" not in out
and "| 条件 | 結果 |\n| --- | --- |\n| A\\|x | <b>y</b> |\n\n## v3" in out
- and "| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| 1 | 2 | |\n| 4 | 5 | 6 |\n" in out[v3:]
+ and "| 列1 | 列2 | 列3 | |\n| --- | --- | --- | --- |\n| 1 | 2 | | |\n| 4 | 5 | 6 | 7 |\n" in out[v3:]
and "")
- return (len(warnings) == 2 and "v1/shared/decision_table.csv" in warnings[0]
- and "v2/shared/decision_table.csv: 見出しの行が無い" in warnings[1] and out.count("表示できない") == 2)
+ return (len(warnings) == 3 and "v1/shared/decision_table.csv" in warnings[0]
+ and "v2/shared/decision_table.csv: 見出しの行が無い" in warnings[1]
+ and "v3/shared/decision_table.csv: 見出しの行が無い" in warnings[2]
+ and out.count("表示できない") == 3 and "| 条件 | 結果 |" not in out)
+
+
+# 雛形の決定表のページ(I4)——在って、印を持ち、空の根に写る(#376 の 1 パス目)
+def init_decision_table_page(plugin: Path, work: Path) -> bool:
+ page = plugin / "templates" / "mkdocs" / "docs" / "decision-table.md"
+ if not page.is_file() or "" not in page.read_text(encoding="utf-8"):
+ return False
+ root = work / "root"
+ root.mkdir(parents=True)
+ code, _ = run_init(plugin, root)
+ copied = root / "docs" / "decision-table.md"
+ return code == 0 and copied.is_file() and copied.read_bytes() == page.read_bytes()
CASES = {
@@ -786,6 +809,7 @@ def hook_decision_table_unreadable(plugin: Path, work: Path) -> bool:
"決定表を版ごとに見出しの行どおりに描く": hook_decision_table,
"決定表が無ければそう書く": hook_decision_table_none,
"読めない・見出しの無い決定表は警告": hook_decision_table_unreadable,
+ "雛形の決定表のページは印を持ち、空の根に写る": init_decision_table_page,
"重ね合わせに表示用のファイルが混ざらない": merge_ignores_pages,
}
@@ -795,23 +819,33 @@ def hook_decision_table_unreadable(plugin: Path, work: Path) -> bool:
" parts += [QA_NUMBER.sub(link, between[start:url.start()]), QA_NUMBER.sub(link, url.group(0))]",
"素の URL の中の番号は変えない"),
("URL を見ない", HOOK, " for url in URL.finditer(between):", " for url in []:", "素の URL の中の番号は変えない"),
- ("www. の URL を見ない", HOOK, 'URL = re.compile(r"(?:[A-Za-z][A-Za-z0-9+.-]*://|www\\.)', 'URL = re.compile(r"(?:[A-Za-z][A-Za-z0-9+.-]*://)',
+ ("www. の URL を見ない", HOOK, 'URL = re.compile(r"(?:[A-Za-z]+://|www\\.)', 'URL = re.compile(r"(?:[A-Za-z]+://)',
"素の URL の中の番号は変えない"),
("URL の後ろを書き換えない", HOOK, 'return "".join(parts + [QA_NUMBER.sub(link, between[start:])])',
'return "".join(parts + [between[start:]])', "素の URL の中の番号は変えない"),
("決定表を描かない", HOOK, ' "decision_table": render_decision_table,\n', "", "決定表を版ごとに見出しの行どおりに描く"),
("決定表の見出しを描かない", HOOK, '"| " + " | ".join(text(cell) for cell in header) + " |",',
'"| " + " | ".join("" for cell in header) + " |",', "決定表を版ごとに見出しの行どおりに描く"),
- ("決定表をエスケープしない", HOOK, '" | ".join(text(cell) for cell in (row + [""]', '" | ".join(cell for cell in (row + [""]',
+ ("決定表をエスケープしない", HOOK, '" | ".join(text(cell) for cell in row + [""]', '" | ".join(cell for cell in row + [""]',
+ "決定表を版ごとに見出しの行どおりに描く"),
+ ("決定表の短い行を埋めない", HOOK, "for cell in row + [\"\"] * (width - len(row))) + \" |\"", "for cell in row) + \" |\"",
"決定表を版ごとに見出しの行どおりに描く"),
- ("決定表の短い行を埋めない", HOOK, "(row + [\"\"] * (len(header) - len(row)))[:len(header)]", "row",
+ ("決定表の長い行を切る", HOOK, " width = max([len(header)] + [len(row) for row in body])", " width = len(header)",
"決定表を版ごとに見出しの行どおりに描く"),
("決定表の BOM を読まない", HOOK, 'with path.open(newline="", encoding="utf-8-sig") as f:',
'with path.open(newline="", encoding="utf-8") as f:', "決定表を版ごとに見出しの行どおりに描く"),
- ("決定表の空白だけの行も描く", HOOK, "rows = [row for row in csv.reader(f) if any(cell.strip() for cell in row)]",
- "rows = [row for row in csv.reader(f) if row]", "決定表を版ごとに見出しの行どおりに描く"),
- ("見出しの無い決定表を黙る", HOOK, ' if not rows: # 空白だけの行は', ' if False: # 空白だけの行は',
+ ("決定表の空白だけの行も描く", HOOK, 'body = [row for row in reader if "".join(row).strip()]',
+ "body = [row for row in reader if row]", "決定表を版ごとに見出しの行どおりに描く"),
+ ("見出しの無い決定表を黙る", HOOK, " if not any(cell.strip() for cell in header): # 最初の行が見出し",
+ " if not header: # 最初の行が見出し", "読めない・見出しの無い決定表は警告"),
+ ("空の見出しの次の行を繰り上げる", HOOK, " header = next(reader, [])\n",
+ " header = next((row for row in reader if any(cell.strip() for cell in row)), [])\n",
"読めない・見出しの無い決定表は警告"),
+ ("URL を全角の句読点で終えない", HOOK, "(?=[\\s<>\\\"'()「」『』。、]|", "(?=[\\s<>\\\"']|", "素の URL の中の番号は変えない"),
+ ("URL を実体参照で終えない", HOOK, "|&(?:lt|gt|quot|#39|#x27);|$)", "|$)", "素の URL の中の番号は変えない"),
+ ("スキームに数字と記号を許す", HOOK, '(?:[A-Za-z]+://|www', '(?:[A-Za-z][A-Za-z0-9+.-]*://|www', "素の URL の中の番号は変えない"),
+ ("雛形の決定表のページに印が無い", "templates/mkdocs/docs/decision-table.md", "", "",
+ "雛形の決定表のページは印を持ち、空の根に写る"),
("mkdocs.yml が在っても写す", INIT, " if os.path.lexists(root / name):\n", " if False:\n",
"mkdocs.yml が在れば何も写さない"),
("mkdocs.yaml を設定とみなさない", INIT, 'CONFIG_NAMES = ("mkdocs.yml", "mkdocs.yaml")', 'CONFIG_NAMES = ("mkdocs.yml",)',
From dcac64cc4438292955a157f069c8bb5885c38fbe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?=
Date: Tue, 27 Oct 2026 20:38:16 +0900
Subject: [PATCH 4/7] =?UTF-8?q?issue-376:=202=20=E3=83=91=E3=82=B9?=
=?UTF-8?q?=E7=9B=AE=E3=81=AE=E5=86=8D=E9=96=8B=E7=82=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
docs/plans/issue-376.md | 3 +++
1 file changed, 3 insertions(+)
diff --git a/docs/plans/issue-376.md b/docs/plans/issue-376.md
index 7a219c9a..bbc624e7 100644
--- a/docs/plans/issue-376.md
+++ b/docs/plans/issue-376.md
@@ -93,6 +93,9 @@ Issue 本文は検査対象であって要件ではない。
- `/code-review` 1 パス目: 通した——指摘 8 件(CR1〜CR8)
- 1 パス目の指摘対応: V1・CR2・CR5・V2・CR4・CR1・CR3 を直した。差分は 0 行でない → 2 パス目を回す
- 割り目(2 パス目の前): 不在のため訊かず続けた
+- 2 パス目の起点: `9184f8d`(この後のコミットは計画ファイルだけ)
+- Verifier 2 パス目: 起動(sonnet)
+- `/code-review` 2 パス目: 起動(high)
## 起票
From 3e252e5f086277145433ad715216251ebc9ef1a3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?=
Date: Tue, 27 Oct 2026 20:45:27 +0900
Subject: [PATCH 5/7] =?UTF-8?q?=E9=9B=9B=E5=BD=A2=E3=81=AE=E3=83=95?=
=?UTF-8?q?=E3=83=83=E3=82=AF:=20URL=20=E3=81=AE=E5=85=A8=E8=A7=92?=
=?UTF-8?q?=E3=81=AE=E4=B8=B8=E6=8B=AC=E5=BC=A7=E3=82=92=E5=AF=BE=E3=81=A7?=
=?UTF-8?q?=E6=95=B0=E3=81=88=E3=83=BB=E5=85=A8=E8=A7=92=E3=81=AE=E5=8F=A5?=
=?UTF-8?q?=E8=AA=AD=E7=82=B9=E3=81=A7=E7=B5=82=E3=81=88=E3=83=BB=E6=AE=8B?=
=?UTF-8?q?=E3=82=8A=E3=81=AE=E6=AC=A1=E3=81=AE=20URL=20=E3=82=82=E8=A6=8B?=
=?UTF-8?q?=E3=82=8B=E3=80=81=E8=A9=A6=E6=96=99=E3=82=92=E8=B6=B3=E3=81=99?=
=?UTF-8?q?=EF=BC=88#376=20=E3=81=AE=202=20=E3=83=91=E3=82=B9=E7=9B=AE?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
docs/plans/issue-376.md | 28 +++++++++++++++--
.../templates/mkdocs/hooks/spec_loop_pages.py | 31 ++++++++++++++++---
scripts/test-init-mkdocs.py | 29 ++++++++++++-----
3 files changed, 73 insertions(+), 15 deletions(-)
diff --git a/docs/plans/issue-376.md b/docs/plans/issue-376.md
index bbc624e7..c8d3c2ff 100644
--- a/docs/plans/issue-376.md
+++ b/docs/plans/issue-376.md
@@ -77,7 +77,10 @@ Issue 本文は検査対象であって要件ではない。
生成物: 図の書き出し・`skill-metrics.py` の生成ブロックは該当しない——**生成物なし**。
-未証明: **実際の MkDocs(Python-Markdown と Material)で描いた HTML** に当てていない——試料は描いた HTML に見立てた文字列(今までの #324 の試料と同じ形。CI に MkDocs と Material の組が無い)。人間のレビューで、正本リポジトリで `mkdocs build` して URL と決定表を目で見ることをお願いする。
+未証明: ~~実際の MkDocs に当てていない~~——**2 パス目の指摘(P4)で、main の作業ツリーの `.venv` に mkdocs 1.6.1・Markdown 3.10.2・Material があると分かり、
+当てた**(`scratchpad/mkdocs376.py`。雛形を一時ディレクトリに写し、素の URL・末尾の URL・全角の丸括弧を含む URL・`(URL)。番号`・引用符・箇条書きの末尾・決定表の長い行を描いた。
+URL の中の番号は変わらず、地の文の番号はリンクになり、決定表は全部の列が出た)。**CI では当てていない**(CI に MkDocs と Material の組が無い)。
+**Markdown の強調が URL を割る形(`https://ex.com/__init__/Q-001`)は直していない**(P3。残課題)。
## 未解決の判断
@@ -94,8 +97,10 @@ Issue 本文は検査対象であって要件ではない。
- 1 パス目の指摘対応: V1・CR2・CR5・V2・CR4・CR1・CR3 を直した。差分は 0 行でない → 2 パス目を回す
- 割り目(2 パス目の前): 不在のため訊かず続けた
- 2 パス目の起点: `9184f8d`(この後のコミットは計画ファイルだけ)
-- Verifier 2 パス目: 起動(sonnet)
-- `/code-review` 2 パス目: 起動(high)
+- Verifier 2 パス目: 通した——反証 0 件
+- `/code-review` 2 パス目: 通した——指摘 10 件(P1〜P10)
+- 2 パス目の指摘対応: P1・P2・P4(計画の文言と実際の MkDocs での確認)・P5・P6・P7 を直した。終点は下の狭いパスに渡すコミット
+- 狭いパス: 起動(sonnet)
## 起票
@@ -125,5 +130,22 @@ Verifier(sonnet): 反証 2 件。`/code-review`(high): 8 件。どちら
1 パス目の対応で URL の正規表現を書き直したとき、変異 3 件が生き残った——否定の文字クラスと先読みが重なり、片方を外しても振る舞いが変わらなかった(等価変異)。
先読みだけで終える形(`\S+?(?=…)`)に縮め、振る舞いに効かない条件(終わりの半角の句読点を外す)は落とした。
+
+### 2 パス目の指摘の採否
+
+Verifier(sonnet): 反証 0 件。`/code-review`(high): 10 件。どちらも `git status --porcelain` は前後とも空。
+
+| # | 指摘 | 実物で失敗を示せるか | 破る受入基準 | 採否 |
+| --- | --- | --- | --- | --- |
+| P1 | 文字の区間の終わり(`
` の直前)の URL の試料が無く、`|$` を外す変異が生き残る | 示せる(`/code-review` の変異と再現) | I1 | 直す(試料と変異) |
+| P2 | 全角の丸括弧で URL を切るので、パスに `(…)` を含む URL が割れて番号がリンクになる | 示せる(直接実行で再現) | I1 | 直す(全角の丸括弧は対で数える) |
+| P3 | Markdown の強調が URL を割る(`https://ex.com/__init__/Q-001`)と、後ろの区間の番号がリンクになる | 示せる(実際の Python-Markdown で再現) | 無し(I1 の列挙の外。直すには区間をまたいで URL を見る設計の変更で、2 パス目では関門を通らない) | 残課題 |
+| P4 | 未証明の理由「手元に MkDocs が無い」は誤り(main の `.venv` にある) | 示せる(`.venv` で import と描画) | — (計画ファイル) | 直す(実際の MkDocs で当て、未証明を測った範囲まで狭めた) |
+| P5 | 生の `"`・`'`・`'`・かぎ括弧で URL を終えることを試料が見ない(外す変異が生き残る) | 示せる(`/code-review` の変異) | I2 | 直す(試料と変異) |
+| P6 | `tree.md` の 0.28.0 の取り込みの段落を消しても照合が通る | 示せる(`check_reference` の語) | I5 | 直す(照合の語を足した) |
+| P7 | 全角の `,.:;!?【】〈〉《》` で URL が終わらず、後ろの番号がリンクにならない(直す前はなっていた) | 示せる(`/code-review` の再現) | I2 | 直す(全角の句読点・かぎ括弧の集合を広げた) |
+| P8 | `path.is_file()` が `try` の外——3.12 では権限の誤りで例外、3.14 では黙って飛ばす | 示せない(走らせていない) | 無し | 残課題(`qa.csv` も同じ形) |
+| P9 | `[A-Za-z]+://` に左の錨が無く、長い英字の並びで 2 乗の時間 | 示せる(`/code-review` の計測: 16,000 字で 0.37 秒) | 無し(性能) | 残課題 |
+| P10 | 読めない・見出しの無い決定表は警告だけで `## v` の見出しを出さない(前の版の節に入り、`#v` が無くなる) | 示せない(走らせていない) | 無し | 残課題 |
- URL は `<スキーム>://` か `www.` で始まり、空白・`<`・`>`・引用符の手前までとみなす——**URL の直後に空白を置かずに続けた文字(`https://ex.com/aでQ-001を見る`)は URL の一部と読む**ので、その中の番号はリンクにならない
(壊すより残すほうに倒している。URL の終わりを全角の文字で切ると、日本語を含む URL を壊す)。
diff --git a/plugins/spec-loop/templates/mkdocs/hooks/spec_loop_pages.py b/plugins/spec-loop/templates/mkdocs/hooks/spec_loop_pages.py
index c377b2f4..6c37b19b 100644
--- a/plugins/spec-loop/templates/mkdocs/hooks/spec_loop_pages.py
+++ b/plugins/spec-loop/templates/mkdocs/hooks/spec_loop_pages.py
@@ -43,7 +43,9 @@
- **素の本文に書いた URL(`https://ex.com/Q-001`・`…?id=Q-001`・`…#Q-001`)の中の番号は変えない**(#341)——
`` の中は上の決まりで変えないが、素の URL は MkDocs が `` にしないので、文字の中から URL を先に除く。
URL は `<英字のスキーム>://` か `www.` で始まり、空白・`<`・`>`・引用符(描いた HTML の `<`・`>`・`"`・`'` も)・
- 全角の括弧と句読点(`()「」『』。、`)の手前までとみなす(文の区切りの後ろの番号をリンクにするため)。
+ 全角の句読点・かぎ括弧(`。、,.:;!?「」『』【】〈〉《》`)の手前までとみなす(文の区切りの後ろの番号をリンクにするため)。
+ **全角の丸括弧は対で数える**——URL の中で開いた `(` に対応する `)` は URL の中、対の無い `)` で終える
+ (`https://ja.wikipedia.org/wiki/仕様(改訂)/Q-001` を割らず、`(https://ex.com/a)。Q-001` の番号はリンクにする)。
半角の記号(`.`・`)` など)は URL の中にも現れるので区切りにしない——`https://ex.com/a.Q-001` の番号はリンクにならない。
"""
@@ -72,7 +74,24 @@
QA_PAGE = "qa.md"
DECISION_TABLE = "decision_table.csv"
# 素の本文の URL(#341)。番号のリンク化はこの中を変えない
-URL = re.compile(r"(?:[A-Za-z]+://|www\.)\S+?(?=[\s<>\"'()「」『』。、]|&(?:lt|gt|quot|#39|#x27);|$)")
+URL = re.compile(r"(?:[A-Za-z]+://|www\.)\S+?(?=[\s<>\"']|&(?:lt|gt|quot|#39|#x27);|$)")
+# URL を終える全角の文字(丸括弧は対で数えるので url_end が見る)
+URL_STOP = set("。、,.:;!?「」『』【】〈〉《》")
+
+
+def url_end(candidate: str) -> int:
+ """URL の候補のうち、URL とみなす長さ。全角の句読点・かぎ括弧と、対の無い全角の閉じ丸括弧の手前で終える。"""
+ depth = 0
+ for index, char in enumerate(candidate):
+ if char in URL_STOP:
+ return index
+ if char == "(":
+ depth += 1
+ elif char == ")":
+ if depth == 0:
+ return index
+ depth -= 1
+ return len(candidate)
# 起票元ごとの番号の印(`Q-S-001`。#278)。印の無い前の形の番号(`Q-001`)もリンクにする
QA_MARKS = "RSFD"
QA_NUMBER = re.compile(rf"(? str:
def link_text(between: str) -> str:
"""文字の中の番号をリンクにする。素の URL の中は変えない(#341)。"""
parts, start = [], 0
- for url in URL.finditer(between):
- parts += [QA_NUMBER.sub(link, between[start:url.start()]), url.group(0)]
- start = url.end()
+ # 全角の文字で終えた URL の残りから探し直す(残りの中の次の URL も見る)
+ while (url := URL.search(between, start)) is not None:
+ end = url.start() + url_end(url.group(0))
+ parts += [QA_NUMBER.sub(link, between[start:url.start()]), between[url.start():end]]
+ start = end
return "".join(parts + [QA_NUMBER.sub(link, between[start:])])
out, last = [], 0
diff --git a/scripts/test-init-mkdocs.py b/scripts/test-init-mkdocs.py
index fbd9f81b..53818fd8 100644
--- a/scripts/test-init-mkdocs.py
+++ b/scripts/test-init-mkdocs.py
@@ -700,15 +700,21 @@ def merge_ignores_pages(plugin: Path, work: Path) -> bool:
# URL の後ろの全角の句読点・閉じ括弧・描いた HTML の実体参照で URL を終える。URL の直前の番号も URL に入れない(#376 の 1 パス目)
AFTER_URLS = ("(https://ex.com/a)。{q}で 、https://ex.com/b、{q} <https://ex.com/c>{q} https://ex.com/d"{q} "
- "{q}https://ex.com/e https://ex.com/f. {q}")
+ "{q}https://ex.com/e https://ex.com/f. {q} https://ex.com/g\"{q} https://ex.com/h'{q} https://ex.com/i'{q} "
+ "「https://ex.com/j」{q} https://ex.com/k,{q} https://ex.com/l:{q} 【https://ex.com/m】{q} "
+ "(https://ex.com/n)、https://ex.com/o/Q-002 と (https://ex.com/r){q}")
+# URL の中の全角の丸括弧(対)と、文字の区間の終わりの URL(#376 の 2 パス目)
+INSIDE_URLS = ("https://ja.wikipedia.org/wiki/仕様(改訂)/Q-001 と https://ex.com/p/Q-001")
def hook_links_bare_url(plugin: Path, work: Path) -> bool:
docs = link_site(work)
out, warnings = link(plugin, docs, f"{BARE_URLS}(Q-001)
\n", "index.md")
after, warned = link(plugin, docs, f"{AFTER_URLS.format(q='Q-001')}
\n", "index.md")
+ inside, warned2 = link(plugin, docs, f"{INSIDE_URLS}
\nhttps://ex.com/q/Q-001\n", "index.md")
return (not warnings and out == f"{BARE_URLS}({a('Q-001', 'qa.html#v1-q-001')})
\n"
- and not warned and after == f"{AFTER_URLS.format(q=a('Q-001', 'qa.html#v1-q-001'))}
\n")
+ and not warned and after == f"{AFTER_URLS.format(q=a('Q-001', 'qa.html#v1-q-001'))}
\n"
+ and not warned2 and inside == f"{INSIDE_URLS}
\nhttps://ex.com/q/Q-001\n")
def hook_decision_table(plugin: Path, work: Path) -> bool:
@@ -815,10 +821,11 @@ def init_decision_table_page(plugin: Path, work: Path) -> bool:
# (名前, 壊す対象, 壊す前, 壊した後, 殺すはずの試料の名前)。2 箇所を壊すときは (壊す前, 壊した後) の列を渡す
MUTATIONS: list[tuple] = [
- ("素の URL の中も書き換える", HOOK, " parts += [QA_NUMBER.sub(link, between[start:url.start()]), url.group(0)]",
- " parts += [QA_NUMBER.sub(link, between[start:url.start()]), QA_NUMBER.sub(link, url.group(0))]",
+ ("素の URL の中も書き換える", HOOK, " parts += [QA_NUMBER.sub(link, between[start:url.start()]), between[url.start():end]]",
+ " parts += [QA_NUMBER.sub(link, between[start:url.start()]), QA_NUMBER.sub(link, between[url.start():end])]",
+ "素の URL の中の番号は変えない"),
+ ("URL を見ない", HOOK, " while (url := URL.search(between, start)) is not None:", " while (url := None) is not None:",
"素の URL の中の番号は変えない"),
- ("URL を見ない", HOOK, " for url in URL.finditer(between):", " for url in []:", "素の URL の中の番号は変えない"),
("www. の URL を見ない", HOOK, 'URL = re.compile(r"(?:[A-Za-z]+://|www\\.)', 'URL = re.compile(r"(?:[A-Za-z]+://)',
"素の URL の中の番号は変えない"),
("URL の後ろを書き換えない", HOOK, 'return "".join(parts + [QA_NUMBER.sub(link, between[start:])])',
@@ -841,7 +848,14 @@ def init_decision_table_page(plugin: Path, work: Path) -> bool:
("空の見出しの次の行を繰り上げる", HOOK, " header = next(reader, [])\n",
" header = next((row for row in reader if any(cell.strip() for cell in row)), [])\n",
"読めない・見出しの無い決定表は警告"),
- ("URL を全角の句読点で終えない", HOOK, "(?=[\\s<>\\\"'()「」『』。、]|", "(?=[\\s<>\\\"']|", "素の URL の中の番号は変えない"),
+ ("URL を全角の句読点で終えない", HOOK, 'URL_STOP = set("。、,.:;!?「」『』【】〈〉《》")', 'URL_STOP = set()',
+ "素の URL の中の番号は変えない"),
+ ("URL の全角の丸括弧を対で数えない", HOOK, " depth += 1\n", " pass\n", "素の URL の中の番号は変えない"),
+ ("URL を対の無い閉じ丸括弧で終えない", HOOK, " if depth == 0:\n return index\n", " if depth == 0:\n continue\n",
+ "素の URL の中の番号は変えない"),
+ ("URL を引用符で終えない", HOOK, "(?=[\\s<>\\\"']|", "(?=[\\s<>]|", "素の URL の中の番号は変えない"),
+ ("URL を文字の区間の終わりで終えない", HOOK, "#x27);|$)", "#x27);)", "素の URL の中の番号は変えない"),
+ ("URL の残りの次の URL を見ない", HOOK, " start = end\n", " start = url.end()\n", "素の URL の中の番号は変えない"),
("URL を実体参照で終えない", HOOK, "|&(?:lt|gt|quot|#39|#x27);|$)", "|$)", "素の URL の中の番号は変えない"),
("スキームに数字と記号を許す", HOOK, '(?:[A-Za-z]+://|www', '(?:[A-Za-z][A-Za-z0-9+.-]*://|www', "素の URL の中の番号は変えない"),
("雛形の決定表のページに印が無い", "templates/mkdocs/docs/decision-table.md", "", "",
@@ -1082,7 +1096,8 @@ def check_reference() -> None:
check("references/tree.md に「ドキュメントのツール」の節が無い", "\n## ドキュメントのツール\n" in text)
section = text.split("\n## ドキュメントのツール\n", 1)[-1].split("\n## ", 1)[0]
for word in ("scripts/init-mkdocs.py", "mkdocs.yml", "docs/qa.md", "docs/decision-table.md", "shared/",
- "`qa-csv.md` の「本文での番号」", "CSV へ直接リンクしない"):
+ "`qa-csv.md` の「本文での番号」", "CSV へ直接リンクしない",
+ "spec-loop 0.28.0 で、フックは素の本文の URL の中の番号を書き換えなくなり"):
check(f"references/tree.md の「ドキュメントのツール」に {word} が無い", word in section)
qa = (PLUGIN / "references" / "qa-csv.md").read_text(encoding="utf-8")
check("references/qa-csv.md に「本文での番号」の節が無い", "\n## 本文での番号\n" in qa)
From b8cd04ffb84bd58685bf5bd29a7d310adfe6d7d1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?=
Date: Tue, 27 Oct 2026 20:49:56 +0900
Subject: [PATCH 6/7] =?UTF-8?q?issue-376:=20=E7=8B=AD=E3=81=84=E3=83=91?=
=?UTF-8?q?=E3=82=B9=E3=81=AE=E7=B5=90=E6=9E=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
docs/plans/issue-376.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/plans/issue-376.md b/docs/plans/issue-376.md
index c8d3c2ff..c1ec4f13 100644
--- a/docs/plans/issue-376.md
+++ b/docs/plans/issue-376.md
@@ -99,8 +99,9 @@ URL の中の番号は変わらず、地の文の番号はリンクになり、
- 2 パス目の起点: `9184f8d`(この後のコミットは計画ファイルだけ)
- Verifier 2 パス目: 通した——反証 0 件
- `/code-review` 2 パス目: 通した——指摘 10 件(P1〜P10)
-- 2 パス目の指摘対応: P1・P2・P4(計画の文言と実際の MkDocs での確認)・P5・P6・P7 を直した。終点は下の狭いパスに渡すコミット
-- 狭いパス: 起動(sonnet)
+- 2 パス目の指摘対応: P1・P2・P4(計画の文言と実際の MkDocs での確認)・P5・P6・P7 を直した。起点 `9184f8d` → 終点 `3e252e5`
+- 狭いパス: 通した(sonnet)——指摘 3 件(人間が決める): (1) 入れ子の全角の丸括弧(`a(b)c)Q-001`)の試料が無く `depth -= 1` を外す変異が生き残る見込み (2) 全角の `[]{}<>` で URL を終えない (3) 実体参照 `"`・`'` で URL を終えない。あわせて「対の無い全角の `)` で URL を終える」は I1 と緊張する(仕様どおり)
+- PR: 作る(マージは止まる。#476)
## 起票
From 66180645864920541064679045149797420a973a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?=
Date: Wed, 28 Oct 2026 18:00:02 +0900
Subject: [PATCH 7/7] =?UTF-8?q?docs/plans/issue-376.md:=20=E5=86=8D?=
=?UTF-8?q?=E9=96=8B=E7=82=B9=E3=81=AB=20#500=20=E3=81=AE=E5=8F=96?=
=?UTF-8?q?=E3=82=8A=E8=BE=BC=E3=81=BF=E3=82=92=E8=A8=98=E3=81=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
docs/plans/issue-376.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/docs/plans/issue-376.md b/docs/plans/issue-376.md
index 604d34cc..65680fe7 100644
--- a/docs/plans/issue-376.md
+++ b/docs/plans/issue-376.md
@@ -106,6 +106,7 @@ URL の中の番号は変わらず、地の文の番号はリンクになり、
- 続けて main が #485(spec-loop 0.26.2)で進んだので再度 merge。衝突は同じく版の行(7 ファイル)だけで、0.28.0 のまま(0.26.2 より上)
- さらに main が #483(spec-loop 0.29.0)で進んだので再度 merge。衝突は版の行(7 ファイル)だけ。版を 0.31.0 に上げ直し、`tree.md` の「spec-loop 0.28.0 で」とその照合(`test-init-mkdocs.py`)も 0.31.0 に直した
- さらに main が #479(spec-loop 0.30.0)で進んだので再度 merge。衝突は版の行(7 ファイル)だけで、0.31.0 のまま。`tree.md` とその照合の「0.31.0 で」もそのまま
+- push の直前に main が #500(dev-loop のみ)で進んだので再度 merge。衝突なし
## 起票