diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 448012d5..0e7fb967 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.26.1", "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-440.md b/docs/plans/issue-440.md new file mode 100644 index 00000000..ccd68d61 --- /dev/null +++ b/docs/plans/issue-440.md @@ -0,0 +1,133 @@ +# Issue #440 — build-proposal.py の remove_owned が unlink の失敗で traceback・1 になり、古い出力が残る + +`remove_owned` が `unlink` の失敗を受けないので、出力先のディレクトリが書けないと traceback・終了コード 1(照合の不一致と同じ値)になる。 +消せなかったものを名指しして **2** で止め、印は残す(次の実行が消せるように)。 + +**不在の周**である——出所は `/dev-loop:cruise 474 --absent`(2026-10-27、利用者が会話で「-absent でまわします」と指示)。 +台帳 `issue-474` の確かめた線: `仕様の穴 = 推奨で決めて起票`・`直す線 = 既定`・`マージ = 関門を通ればマージ`・`周の途中の課題 = 台帳に足して直す` +(出所: cruise の確かめた線(台帳 `issue-474`))。**このリポジトリは main への push でサイトをデプロイするので、`マージ` の線は効かない** +(`references/absent-lines.md` の `本番反映`)——PR まで作って止まる。 + +## 変更範囲 + +触る: + +- `plugins/spec-loop/scripts/build-proposal.py` — `remove_owned` が消せなかった名前を返す・`main` と `write_outputs` が 2 で止まり名指しする・docstring の終了コード 2 +- `scripts/test-build-proposal.py` — 書けない出力先(mode 555)の試料と、捕捉を外す変異 +- `plugins/spec-loop/references/tree.md` — 「前の出力の印」の決まりに、消せなければ 2 で止まることを 1 文 +- `plugins/spec-loop/.claude-plugin/plugin.json` — 版(0.26.0 → 0.26.1) +- `.claude-plugin/marketplace.json` — 同じ版 +- `plugins/spec-loop/skills/*/SKILL.md` — 同じ版の 2 行 +- `docs/plans/issue-440.md` — この計画 + +触らない: + +- proposal の `SKILL.md` 手順 3 の「0 以外で終わったら、提案書は無い」と `tree.md` の移行の節(#441 の範囲) +- `bad_out` の文言(#442 の範囲) +- 想定外の例外を 2 に寄せる全体の作り(#286 の範囲) + +### 同じことを述べている箇所の数 + +「消せなかったら」: 本体の docstring(終了コード 2)・`tree.md` の生成物の置き場所の段落の 2 箇所。分けない——同じ決定の写し。 + +### 決めたこと + +- **消せなかったら 2 で止まり、残ったファイルを名指しする**——書けない場所では、どのみち新しい出力は書けない。照合(1)と取り違えない。 +- **消せなかったものがあれば印は消さない**——印を消すと、残った出力をこの道具のものと確かめる跡が無くなり、次の実行が「印に無い」で 2 になる。 +- `write_outputs` の書きかけの片付けで消せなければ、`書けない` の 2 に「書きかけを消せなかった」と名指しを添える(2 つ目の例外を逃がさない)。 +- これは開発者が決めてよい範囲(失敗したあとの状態・終了コード)で、利用者に見える結果は変わらない——仕様の穴として起票しない。 + +## 乗るデプロイ経路 + +プラグイン(`plugins/spec-loop/`)の版を上げてマージ。マージで main へ push されるとサイトが再デプロイされる(`docs/plans/` を足す)。 + +## 周の在り処 + +- ブランチ: `issue/440-remove-owned-unlink` +- worktree: `/Users/hdknr/Projects/hdknr/claude-code-setup/.claude/worktrees/issue-440` +- 起点: `9b562d0` +- base: 参照 `origin/main`(`refs/remotes/origin/HEAD`)→ `9b562d0f72947fe5f0f140ec5a835ca564ecafa5` + +## verify の受入基準 + +Issue 本文は検査対象であって要件ではない。 + +不変条件: + +- **I1**: `build-proposal.py` は消せない古い出力を理由に traceback で終わらず、終了コード 1 にならない(2 で終わる)。 +- **I2**: 消せなかった古い出力を名指しする(「提案書は作っていない」と併せて)——黙って残さない。 +- **I3**: 消せなかったものがあれば、印 `.build-proposal` を残す(名指ししていた出力の跡を消さない)。 +- **I4**: 消せる場所では今までどおり消す(陽性対照。既存の試料が緑)。 +- **I5**: 書きかけの片付けで消せなくても、2 つ目の例外を逃がさず 2 で終わる。 + +経路(古い出力を消す入口。`remove_owned` の呼び出し 3 箇所): + +| 経路 | 守り方 | 確かめ方 | +| --- | --- | --- | +| `main` の `remove_owned(out.parent)`(引数が正しい・誤り両方の前) | 消せなければ 2・名指し | CLI に mode 555 のディレクトリと有効な印(I1・I2・I3) | +| `main` の `remove_owned(out)`(`--out` にディレクトリ) | 同じ | CLI に `--out <書けないディレクトリ>` | +| `write_outputs` の `except OSError` の中 | 消せなければ名指しを足して 2 | `write_outputs` を直接呼び、片付けの `unlink` を失敗させる(I5) | + +各経路の守りを外す変異で赤になる(陽性対照: 書ける場所では既存の試料が緑)。python3.12 と 3.14 の両方で回す(`Path.is_file` の例外の差。メモリの記録)。 + +生成物: `skill-metrics.py` の生成ブロックは dev-loop の `SKILL.md` だけを測るので該当しない。図の書き出しも無い——**生成物なし**。 + +未証明: root で走らせると mode 555 でも消せるので、試料が判別しない(CI は root でない前提)——試料の中で「消せないこと」を先に確かめ、消せたら試料を飛ばしたと言う。 + +## 未解決の判断 + +なし。 + +## 関門の進捗(再開点) + +- 直す線: `既定`(出所: cruise の確かめた線(台帳 `issue-474`)) +- 割り目(手順 4 の完了時): 不在のため訊かず続けた +- 実験: 再現手順(mode 555 の出力先・有効な印)を修正前後で当てた——修正前 exit=1、修正後 exit=2 で 2 つの出力を名指し・印と出力が残る(python3.12)。 + 交絡: 修正前のファイルを同じ `scripts/` に置いて同じ手順で走らせた(違いは本体だけ) +- 1 パス目の起点(関門に渡した時点の HEAD): `ceaa89f`(`0432f2b` との違いは計画ファイルだけ) +- Verifier 1 パス目: 通した——反証 1 件(V1。採否の表) +- `/code-review` 1 パス目: 通した——指摘 7 件(CR1〜CR7。採否の表) +- 1 パス目の指摘対応: CR1・CR5 を直した(`88faf38` ほか)。差分は 0 行でない → 2 パス目を回す +- 割り目(2 パス目の前): 不在のため訊かず続けた +- 2 パス目の起点(2 パス目の指摘対応を始める直前の HEAD): `2de165f` +- Verifier 2 パス目: 通した——反証 0 件(所見 1 件) +- `/code-review` 2 パス目: 通した——指摘 7 件(P2-1〜P2-7。採否の表) +- 2 パス目の指摘対応: P2-2 を直した。終点 `5feae08`(起点 `2de165f` → 終点 `5feae08` の 2 点) +- 狭いパス: 通した(sonnet)——反証 0 件。所見 1 件(OK の行の件数の表示を壊しても赤にならない——表示を判別する試料が無い)。人間が決める +- PR: 作る(マージは止まる——このリポジトリのマージはサイトを再デプロイするので、`マージ` の線が効かない。#476) + +## 起票 + +- この周の起票: 0 件(仕様の穴は無かった。周の途中の課題も無い) + +## 既知の限界・決着済みの論点 + +- #441(文面)・#442(`bad_out` の文言)・#286(例外の全体)はこの周で触らない(別の周として台帳にある)。 + +### 1 パス目の指摘の採否 + +Verifier(sonnet): 反証 1 件。`/code-review`(high): 7 件。どちらも `git status --porcelain` は前後とも空。 + +| # | 指摘 | 実物で失敗を示せるか | 破る受入基準 | 採否 | +| --- | --- | --- | --- | --- | +| CR1 | python3.12 で、辿れない先を指す symlink を `is_file()` が例外にし、消せるのに「消せない」と 2 | 示せる——`probe_cr1.py`: 3.12 は `left=['…/proposal.md(Permission denied)']`・残る、3.14 は `left=[]`・消える | I4 | 直す | +| CR2・V1 | 印が読めない(mode 000)・辿れないディレクトリでは、残った前の出力を名指ししない | 示せる(Verifier・`/code-review` の試料。rc=2 で名指し無し) | 無し——#405 で決めた「読めない印は消さない」の延長で、消そうとしていない出力。I2 は消せなかった出力の名指し。文面は #441 | 残課題 | +| CR3 | 書きかけの片付けで印が読めず何も消さなかったのに「書きかけを消した」と言う | 示せる(`/code-review` の試料 f) | 無し(I5 の「2 で終わる」は守られる。文言の誤り) | 残課題 | +| CR4 | 印だけが消せないとき、印を「前に作った出力」として名指しする | 示せる(試料 d) | 無し(名指しの文言) | 残課題 | +| CR5 | `locked_problems` が「提案書は作っていない」を見ない——消す変異が生き残る | 示せる——`probe_cr5.py`: 変異の前後とも `[]` | I2(「併せて」の部分を試料が判別しない) | 直す | +| CR6 | CLI の試料は I3 を判別しない(555 では印も消せない)。I3 は `Path.unlink` の差し替えの試料だけが見る | 示せる(変異は差し替えの試料だけで死ぬ) | 無し(I3 は差し替えの試料で判別している) | 残課題 | +| CR7 | `main` の `if left:` が `Stop` の報告の経路を写している | 示せない(掃除) | 無し | 残課題 | + +### 2 パス目の指摘の採否 + +Verifier(sonnet): 反証 0 件(所見 1 件 = P2-2 と同じ)。`/code-review`(high): 7 件。どちらも `git status --porcelain` は前後とも空。 + +| # | 指摘 | 実物で失敗を示せるか | 破る受入基準 | 採否 | +| --- | --- | --- | --- | --- | +| P2-1 | `write_outputs` の `path.exists()` が python3.12 で辿れない先の symlink(印に無い手置きのもの)に例外を投げ、traceback・1 | 示せる(`/code-review` の `probe_exists.py`) | 無し——消そうとする前の出力ではなく、書く前の「印に無いファイル」の検査。想定外の例外を 2 に寄せるのは #286 | 残課題 | +| P2-2 | 3.13 以降で当てない変異を、`OK` の変異の件数に数える | 示せる(3.14 の出力 `OK: … 変異 97 件` と「当てていない」の行。Verifier も同じ所見) | 経路の表の「各経路の守りを外す変異で赤になる」——当てていない変異を当てたと数える | 直す | +| P2-3 | `try` が成功の `print` も包み、stderr が閉じていると消したものを「消せない」にする | 示せない(走らせていない) | 無し | 残課題 | +| P2-4 | 確かめてから消すまでの間に消えたファイルを「消せない」にする | 示せない(競合。走らせていない) | 無し | 残課題 | +| P2-5 | 書きかけの片付けで、今回書いたものを「前に作った出力」と名指しする | 示せる(試料 half の出力) | 無し(文言。CR4 と同じ型) | 残課題 | +| P2-6 | 一部だけ消せない試料が、残りが消えたこと・名指しの中身を見ない | 示せない(変異を走らせていない) | 無し | 残課題 | +| P2-7 | 3.12 の `lstat` が ENAMETOOLONG を投げ、印の名前が長すぎると以後ずっと 2 | 示せない(走らせていない) | 無し | 残課題 | diff --git a/plugins/spec-loop/.claude-plugin/plugin.json b/plugins/spec-loop/.claude-plugin/plugin.json index cc8d7cf6..eb9c22ad 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.26.1", "author": { "name": "hdknr" } } diff --git a/plugins/spec-loop/references/tree.md b/plugins/spec-loop/references/tree.md index 0acdb851..db707bf7 100644 --- a/plugins/spec-loop/references/tree.md +++ b/plugins/spec-loop/references/tree.md @@ -92,6 +92,7 @@ hooks/spec_loop_pages.py # 表示用ページを CSV から作るフック 次の実行は、作る前に**印が名指しするものだけ**を消す——**印に無いファイルは、名前が `spec.md` でも消さず、上書きもしない** (上書きになるなら 2 で止まる)。だから手書きの `spec.md` の在る場所に `--out` を向けても消えない。**印を手で直さない。** `--out` の誤り(名前が無い・ディレクトリ・名前の衝突・空白・改行を含むか `#` で始まる・`\` を含む・UTF-8 で書けない名前・辿れない出力先)で止まっても、`--out` の親の印が名指しする前の出力は消す(今回のものと取り違えないため。ディレクトリを渡したなら、その中の印が名指しするものも消す)。 +**消せなければ(出力先のディレクトリが書けないなど)作らずに 2 で止まり、消せなかった出力を名指しして印を残す**(#440。次の実行が消せるように)。 Issue の本文は**登録すれば GitHub に残る**ので、commit しなくてよい。**作れなかったときは前の本文を消す** (古い本文を登録させない)。 diff --git a/plugins/spec-loop/scripts/build-proposal.py b/plugins/spec-loop/scripts/build-proposal.py index 65a27739..ec079167 100644 --- a/plugins/spec-loop/scripts/build-proposal.py +++ b/plugins/spec-loop/scripts/build-proposal.py @@ -45,7 +45,7 @@ - 0: 書いた - 1: 照合が一致しない・表が決まりに反する・材料(表・手書き部分)が無い——書いていない - 2: 照合していない・表が読めない(`check-signoff.py` の 2 と同じ)・出力先の名前が無い・空白・改行を含むか `#` で始まる・`\\` を含む・UTF-8 で書けない・確かめられない(辿れない)・ディレクトリ・仕様書・ガントチャート・PPTX・図・印と重なる・ - 印に無いファイルが出力の名前で在る・書けない——書いていない + 印に無いファイルが出力の名前で在る・書けない・印が名指しする前の出力を消せない(消せなかったものを名指しし、印は残す。#440)——書いていない - 3: 要求確定のタグの時点に暫定の回答(`暫定反映`)が残る(`check-signoff.py` の 3 と同じ)——書いていない **守らないもの**: 手書き部分の中身(判断であって一意に決まらない)・見た目(Markdown を表示して目で見る)・ @@ -606,19 +606,35 @@ def owned_names(directory: Path) -> list[str] | None: and name not in (".", "..", MANIFEST)] -def remove_owned(directory: Path) -> None: - """印に載る出力と印を消す。載っていないもの(手書きのファイル)は、名前が出力と同じでも消さない。 - 読めない印は消さない——消すと、名指ししていた出力がこの道具のものだった跡が無くなる。""" +def remove_owned(directory: Path) -> list[str]: + """印に載る出力と印を消し、消せなかったものを(理由つきで)返す。載っていないもの(手書きのファイル)は、名前が出力と同じでも消さない。 + 読めない印は消さない——消すと、名指ししていた出力がこの道具のものだった跡が無くなる。 + 消せなかった出力があれば印も残す(#440)——次の実行が、残った出力をこの道具のものと確かめて消せるように。""" names = owned_names(directory) if names is None: - return + return [] + left = [] for name in names: stale = directory / name - if stale.is_file() or stale.is_symlink(): - stale.unlink() - print(f"{stale}: 前に作った出力を消した", file=sys.stderr) - if (directory / MANIFEST).is_file(): - (directory / MANIFEST).unlink() + try: + # symlink を先に見る——python3.12 の `is_file` は辿れない先を指す symlink で例外を投げる + if stale.is_symlink() or stale.is_file(): + stale.unlink() + print(f"{stale}: 前に作った出力を消した", file=sys.stderr) + except OSError as e: + left.append(f"{stale}({e.strerror})") + manifest = directory / MANIFEST + if not left: + try: + if manifest.is_file(): + manifest.unlink() + except OSError as e: + left.append(f"{manifest}({e.strerror})") + return left + + +def left_lines(left: list[str]) -> list[str]: + return [f"前に作った出力を消せない: {name}" for name in left] def bad_out(out: Path) -> str | None: @@ -666,7 +682,9 @@ def write_outputs(outputs: dict[Path, bytes | list], directory: Path) -> None: else: path.write_bytes(content) except OSError as e: - remove_owned(directory) + left = remove_owned(directory) + if left: + raise Stop(2, [f"{directory}: 書けない({e.strerror})。書きかけを消せなかった", *left_lines(left)]) raise Stop(2, [f"{directory}: 書けない({e.strerror})。書きかけを消した"]) @@ -686,9 +704,14 @@ def main(argv: list[str]) -> int: out = args.out if args.out else repo_top(args.repo) / DEFAULT_OUT # 引数の誤りは消す前に見る。誤りで止まっても、印が名指しする前の出力は消す(残すと今回のものと取り違える。#398) problem = bad_out(out) - remove_owned(out.parent) + left = remove_owned(out.parent) if is_dir(out) and out.resolve() != out.parent.resolve(): - remove_owned(out) # ディレクトリを渡したなら、指したつもりの場所の前の出力も消す + left += remove_owned(out) # ディレクトリを渡したなら、指したつもりの場所の前の出力も消す + if left: # 消せない場所には書けもしない。残ったものを今回のものと取り違えないよう名指しする(#440) + for line in left_lines(left) + ([problem] if problem else []): + print(line, file=sys.stderr) + print("提案書は作っていない", file=sys.stderr) + return 2 if problem: print(f"{problem}。提案書は作っていない", file=sys.stderr) return 2 diff --git a/plugins/spec-loop/skills/feasibility/SKILL.md b/plugins/spec-loop/skills/feasibility/SKILL.md index 0b40556a..dbbda657 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.26.1**(プラグイン `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..8bd2e07c 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.26.1**(プラグイン `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..2537f5ab 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.26.1**(プラグイン `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..2204748e 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.26.1**(プラグイン `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..58439cb0 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.26.1**(プラグイン `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/scripts/test-build-proposal.py b/scripts/test-build-proposal.py index 5d6f0533..b9d79e95 100644 --- a/scripts/test-build-proposal.py +++ b/scripts/test-build-proposal.py @@ -24,6 +24,8 @@ 失敗の経路でも消さず、作れる経路でも上書きしない(2)。印が同じ場所の外を名指ししても外は消さない。 `--out` の誤り(`.`・`/`・`..`・ディレクトリ・名前の衝突)は traceback ではなく 2 で、それでも印の名指しする前の出力は消す。 **陽性対照は本物の前の実行**——試料の前の出力は印を手で書くので、本体が印に何を載せるかはそこでしか見えない。 +8. **前の出力を消せない場所(書けないディレクトリ)では、traceback・1 ではなく 2 で、残ったものを名指しし、印を残す**(#440) + ——`--out` の親・`--out` に渡したディレクトリ・書きかけの片付けの 3 つの入口。 **このテストは実環境を触らない。** 試料の git リポジトリはすべて一時ディレクトリに作り、 `HOME` と `GIT_CONFIG_GLOBAL` を差し替える。 @@ -542,10 +544,10 @@ def pptx_ok(parts: dict) -> bool: " if False:\n raise Stop(2", "手で置いたファイルを上書きしない"), ("印の名前の外へ出る", 'if name and not name.startswith("#") and "/" not in name and "\\\\" not in name\n', 'if name and not name.startswith("#")\n', "印が外を名指ししても消さない"), - ("引数の誤りで前の出力を消さない", " problem = bad_out(out)\n remove_owned(out.parent)\n", - " problem = bad_out(out)\n if problem:\n return 2\n remove_owned(out.parent)\n", + ("引数の誤りで前の出力を消さない", " problem = bad_out(out)\n left = remove_owned(out.parent)\n", + " problem = bad_out(out)\n if problem:\n return 2\n left = remove_owned(out.parent)\n", "名前の衝突で止まっても前の出力を消す"), - ("ディレクトリの出力先の中を消さない", " remove_owned(out) # ディレクトリを", " pass # ディレクトリを", + ("ディレクトリの出力先の中を消さない", " left += remove_owned(out) # ディレクトリを", " pass # ディレクトリを", "出力先がディレクトリならその中の前の出力を消す"), ("前後に空白のある名前を見ない", " if out.name != out.name.strip() or ", " if ", "出力の名前が空白で始まると 2"), ("改行を含む名前を見ない", "len(out.name.splitlines()) != 1 or ", "", "出力の名前が改行を含むと 2"), @@ -633,8 +635,7 @@ def compose_problems(scripts: Path) -> list[str]: # 書いている途中の失敗で殺す変異。(名前, 壊す前, 壊した後) WRITE_MUTATIONS: list[tuple[str, str, str]] = [ - ("書きかけを消さない", " remove_owned(directory)\n raise Stop(2, [f\"{directory}: 書けない", - " raise Stop(2, [f\"{directory}: 書けない"), + ("書きかけを消さない", " left = remove_owned(directory)\n", " left = []\n"), ("印を書かない(途中の失敗)", " (directory / MANIFEST).write_text(", " (directory / MANIFEST).with_name('unused').write_text("), ] @@ -672,7 +673,7 @@ def fail(*_args) -> None: # 読めない印・辿れない出力先で殺す変異。(名前, 壊す前, 壊した後) OWN_MUTATIONS: list[tuple[str, str, str]] = [ - ("読めない印を消す", " if names is None:\n return\n", " names = names or []\n"), + ("読めない印を消す", " if names is None:\n return []\n", " names = names or []\n"), ("辿れない出力先を見ない", ' return f"{out}: 出力先を確かめられない({e.strerror})"\n', " is_directory = False\n"), ] @@ -712,12 +713,151 @@ def ownership_problems(scripts: Path) -> list[str]: problems.append(f"辿れない出力先: {proc.returncode} {output[-300:]}") return problems +# 前の出力を消せない場所で殺す変異(#440)。(名前, 壊す前, 壊した後) +LOCKED_MUTATIONS: list[tuple[str, str, str]] = [ + ("消せない出力で traceback", " except OSError as e:\n left.append(f\"{stale}", + " except FileNotFoundError as e:\n left.append(f\"{stale}"), + ("消せなかったのに印を消す", " if not left:\n try:\n if manifest.is_file():", + " if True:\n try:\n if manifest.is_file():"), + ("消せなくても続ける", " if left: # 消せない場所には", " if False: # 消せない場所には"), + ("ディレクトリの出力先で消せないことを見ない", " left += remove_owned(out) # ディレクトリを", " remove_owned(out) # ディレクトリを"), + ("消せないのに作っていないと言わない", + " print(line, file=sys.stderr)\n print(\"提案書は作っていない\", file=sys.stderr)\n return 2\n if problem:", + " print(line, file=sys.stderr)\n return 2\n if problem:"), + ("symlink より先に is_file を見る(3.12 でだけ死ぬ)", " if stale.is_symlink() or stale.is_file():", + " if stale.is_file() or stale.is_symlink():"), + ("書きかけを消せなくても消したと言う", " if left:\n raise Stop(2, [f\"{directory}: 書けない", + " if False:\n raise Stop(2, [f\"{directory}: 書けない"), +] + + +def locked_problems(scripts: Path) -> list[str]: + """前の出力を消せない(出力先のディレクトリが書けない)とき、traceback・1 ではなく 2 で、残ったものを名指しし、印を残す(#440)。 + + 入口は `remove_owned` の呼び出しの 3 箇所——`--out` の親・`--out` に渡したディレクトリ(CLI)と、 + `write_outputs` の書きかけの片付け(直接呼ぶ。CLI からは途中で書けなくする手が無い)。 + **消せない場所で unlink が通る環境(root)では判別しないので、問題として返す**——「当てられなかった」は緑ではない。 + 印だけを残すこと(I3)はファイルシステムでは作れない(消せるかはディレクトリで決まる)ので、`Path.unlink` を差し替えて見る。 + """ + import contextlib + import errno + import io + spec = importlib.util.spec_from_file_location("build_proposal_locked", scripts / TARGET) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + problems = [] + with tempfile.TemporaryDirectory(prefix="proposal-locked-") as tmp: + probe = Path(tmp) / "probe" + probe.mkdir() + (probe / "x").write_text("x", encoding="utf-8") + probe.chmod(0o555) + try: + (probe / "x").unlink() + return ["書けないディレクトリでも消せる(root で走っている?)——試料が判別しない"] + except OSError: + pass + finally: + probe.chmod(0o755) + + def locked_dir(path: Path) -> Path: + path.mkdir() + (path / MANIFEST).write_text("# 印\nproposal.md\nspec.md\n", encoding="utf-8") + for name in ("proposal.md", "spec.md"): + (path / name).write_text("古い出力\n", encoding="utf-8") + path.chmod(0o555) + return path + + for label, directory, out in (("--out の親", Path(tmp) / "ro", Path(tmp) / "ro" / "proposal.md"), + ("--out のディレクトリ", Path(tmp) / "rodir", Path(tmp) / "rodir")): + locked_dir(directory) + try: + proc = subprocess.run([sys.executable, "-I", str(scripts / TARGET), "--repo", tmp, "--out", str(out)], + capture_output=True, text=True, cwd=tmp) + finally: + directory.chmod(0o755) + output = proc.stdout + proc.stderr + if proc.returncode != 2 or "Traceback" in output: + problems.append(f"{label}: 2 で止まらない: {proc.returncode} {output[-300:]}") + if not all(f"消せない: {directory / name}" in output for name in ("proposal.md", "spec.md")): + problems.append(f"{label}: 残ったものを名指ししない: {output[-300:]}") + if "提案書は作っていない" not in output: + problems.append(f"{label}: 「提案書は作っていない」と言わない: {output[-300:]}") + left = sorted(p.name for p in directory.iterdir()) + if left != sorted([MANIFEST, "proposal.md", "spec.md"]): + problems.append(f"{label}: 残るものが違う: {left}") + + directory = Path(tmp) / "half" + directory.mkdir() + + def fail(*_args) -> None: + directory.chmod(0o555) # 1 つ目を書いた後で書けなくする——書きかけを消せない + raise OSError(errno.EACCES, "書けない(試料)") + + module.pptx.write = fail + try: + with contextlib.redirect_stderr(io.StringIO()): + module.write_outputs({directory / "proposal.md": b"x", directory / "proposal.pptx": []}, directory) + problems.append("書きかけ: 書けないのに止まらない") + except module.Stop as stop: + if stop.code != 2 or not any("書きかけを消せなかった" in line for line in stop.lines) \ + or not any(f"消せない: {directory / 'proposal.md'}" in line for line in stop.lines): + problems.append(f"書きかけ: 止まり方が違う: {stop.code} {stop.lines}") + except OSError as e: + problems.append(f"書きかけ: 片付けの失敗が例外で逃げる: {e!r}") + finally: + directory.chmod(0o755) + + directory = Path(tmp) / "partial" + directory.mkdir() + (directory / MANIFEST).write_text("# 印\nproposal.md\nspec.md\n", encoding="utf-8") + for name in ("proposal.md", "spec.md"): + (directory / name).write_text("古い出力\n", encoding="utf-8") + original = Path.unlink + + def unlink(self, *args, **kwargs): + if self.name == "proposal.md": + raise OSError(errno.EACCES, "消せない(試料)") + return original(self, *args, **kwargs) + + Path.unlink = unlink + try: + with contextlib.redirect_stderr(io.StringIO()): + left = module.remove_owned(directory) + except OSError as e: + return problems + [f"1 つ消せない: 例外で逃げる: {e!r}"] + finally: + Path.unlink = original + if not left or (directory / MANIFEST).exists() is False: + problems.append(f"1 つ消せないのに印を消すか、名指ししない: {left} {sorted(p.name for p in directory.iterdir())}") + + # 辿れない先を指す symlink は消せる——python3.12 の `is_file` はここで例外を投げる(3.14 は投げないので、この変異は 3.12 でだけ死ぬ) + unreachable = Path(tmp) / "unreachable" + unreachable.mkdir() + (unreachable / "target").write_text("x", encoding="utf-8") + directory = Path(tmp) / "symlink" + directory.mkdir() + (directory / MANIFEST).write_text("# 印\nproposal.md\n", encoding="utf-8") + (directory / "proposal.md").symlink_to(unreachable / "target") + unreachable.chmod(0) + try: + with contextlib.redirect_stderr(io.StringIO()): + left = module.remove_owned(directory) + except OSError as e: + left = [f"例外で逃げる: {e!r}"] + finally: + unreachable.chmod(0o755) + if left or list(directory.iterdir()): + problems.append(f"辿れない先の symlink を消さない: {left} {sorted(p.name for p in directory.iterdir())}") + return problems + # #260 の名前(版を根にした名前)。素の `v1` と共存する試料を、承認のタグだけこの名前にして作ると、 # タグの手順で作れない(`refs/tags/v1` と `refs/tags/v1/...` は共存できない)——試料が衝突を判別している確かめ OLD_NAMES = {R1: "v1/req-signoff-1", B1: "v1/budget-signoff-1"} COEXIST = ["素の v1 タグと共存する"] failures: list[str] = [] +# この python で当てなかった変異の名前——OK の件数に数えず、名指しする(#440 の 2 パス目) +skipped: list[str] = [] def check(name: str, cond: bool, detail: str = "") -> None: @@ -1048,12 +1188,28 @@ def main() -> int: (mutated / TARGET).write_text(source.replace(before, after), encoding="utf-8") check(f"変異が生き残った: {name}(読めない印か辿れない出力先で殺せるはず)", bool(ownership_problems(mutated))) + problems = locked_problems(SCRIPTS) + check("前の出力を消せない場所の扱いが違う", not problems, "; ".join(problems)) + for index, (name, before, after) in enumerate(LOCKED_MUTATIONS): + if "3.12 でだけ" in name and sys.version_info >= (3, 13): + # 3.13 以降の `is_file` は辿れない symlink で例外を投げないので、この変異は判別できない(CI の 3.12 で当てる) + skipped.append(name) + continue + count = source.count(before) + if count != 1: + check(f"変異が届いていない: {name}", False, f"壊す箇所が {count} 回現れる") + continue + mutated = copy_scripts(tmp_path / f"mutlocked{index}") + (mutated / TARGET).write_text(source.replace(before, after), encoding="utf-8") + check(f"変異が生き残った: {name}(消せない出力先で殺せるはず)", bool(locked_problems(mutated))) + if failures: print(f"{len(failures)} 件の失敗:\n") for message in failures: print(f" - {message}") return 1 - print(f"OK: 試料 {len(CASES) + 6} 件・変異 {len(MUTATIONS) + len(NO_GIT_MUTATIONS) + len(PREVIOUS_MUTATIONS) + len(QA_MUTATIONS) + len(COMPOSE_MUTATIONS) + len(WRITE_MUTATIONS) + len(OWN_MUTATIONS)} 件") + print(f"OK: 試料 {len(CASES) + 7} 件・変異 {len(MUTATIONS) + len(NO_GIT_MUTATIONS) + len(PREVIOUS_MUTATIONS) + len(QA_MUTATIONS) + len(COMPOSE_MUTATIONS) + len(WRITE_MUTATIONS) + len(OWN_MUTATIONS) + len(LOCKED_MUTATIONS) - len(skipped)} 件" + + (f"(python {sys.version_info.major}.{sys.version_info.minor} では当てていない {len(skipped)} 件: {'・'.join(skipped)})" if skipped else "")) return 0