From c7f2df929ba5e6e54f1b0007868d050099b16ae1 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 10:34:52 +0900 Subject: [PATCH 1/7] =?UTF-8?q?spec-loop:=20=E6=83=B3=E5=AE=9A=E5=A4=96?= =?UTF-8?q?=E3=81=AE=E4=BE=8B=E5=A4=96=E3=82=92=202=EF=BC=88=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=81=97=E3=81=A6=E3=81=84=E3=81=AA=E3=81=84=EF=BC=89?= =?UTF-8?q?=E3=81=AB=E5=AF=84=E3=81=9B=E3=80=81=E8=AA=AD=E3=82=81=E3=81=AA?= =?UTF-8?q?=E3=81=95=E3=81=AE=E5=8E=9F=E5=9B=A0=E3=82=92=E5=8F=96=E3=82=8A?= =?UTF-8?q?=E9=81=95=E3=81=88=E3=81=AA=E3=81=84=EF=BC=88#286=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 12 本の CLI の入口で、想定外の例外(csv.Error・RecursionError など)を 2 にする。 SystemExit・KeyboardInterrupt は寄せない。読み込まれた関数は例外をそのまま返す - -OO(docstring が無い)でも使い方・argparse の説明でトレースバックにしない - 2 で前の出力を消すと約束しているもの(merge-versions・schedule-to-gantt・build-proposal)は、 想定外の例外でも消す/書きかけを残さない。scaffold は判定の外では本文を消さないと docstring に名指し - build-proposal: git の起動・手書き部分の読み・出力の書き込みを別の文言で、strerror とパスつきで報告 - check-signoff: removed.csv の読めなさを常に「UTF-8 として読めない」と言っていたのを、Unreadable の文言に - 試料(128KB 超のセル・深い入れ子・読めない手書き部分・-OO の引数なし)と、捕捉を外す変異を各テストに - spec-loop 0.30.0 Fixes #286 Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 2 +- plugins/spec-loop/.claude-plugin/plugin.json | 2 +- plugins/spec-loop/scripts/build-proposal.py | 47 +++++--- .../scripts/check-feasibility-csv.py | 11 +- plugins/spec-loop/scripts/check-frozen.py | 10 +- plugins/spec-loop/scripts/check-qa-csv.py | 11 +- plugins/spec-loop/scripts/check-signoff.py | 16 ++- plugins/spec-loop/scripts/check-spec-exit.py | 12 +- plugins/spec-loop/scripts/diff-versions.py | 12 +- plugins/spec-loop/scripts/init-mkdocs.py | 9 +- plugins/spec-loop/scripts/merge-versions.py | 24 +++- plugins/spec-loop/scripts/qa-to-pptx.py | 12 +- plugins/spec-loop/scripts/scaffold.py | 9 +- .../spec-loop/scripts/schedule-to-gantt.py | 28 ++++- plugins/spec-loop/skills/feasibility/SKILL.md | 4 +- plugins/spec-loop/skills/proposal/SKILL.md | 6 +- 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 +- scripts/test-build-proposal.py | 114 +++++++++++++++--- scripts/test-check-feasibility-csv.py | 31 ++++- scripts/test-check-frozen.py | 49 +++++++- scripts/test-check-qa-csv.py | 49 +++++++- scripts/test-check-signoff.py | 68 ++++++++++- scripts/test-check-spec-exit.py | 31 ++++- scripts/test-diff-versions.py | 17 +++ scripts/test-init-mkdocs.py | 34 +++++- scripts/test-merge-versions.py | 76 +++++++++++- scripts/test-qa-to-pptx.py | 43 +++++++ scripts/test-scaffold.py | 44 +++++++ scripts/test-schedule-to-gantt.py | 69 ++++++++++- 31 files changed, 751 insertions(+), 101 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0e7fb967..57b33365 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.1", + "version": "0.30.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/plugins/spec-loop/.claude-plugin/plugin.json b/plugins/spec-loop/.claude-plugin/plugin.json index eb9c22ad..a811bd49 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.1", + "version": "0.30.0", "author": { "name": "hdknr" } } diff --git a/plugins/spec-loop/scripts/build-proposal.py b/plugins/spec-loop/scripts/build-proposal.py index ec079167..2876dde3 100644 --- a/plugins/spec-loop/scripts/build-proposal.py +++ b/plugins/spec-loop/scripts/build-proposal.py @@ -45,7 +45,9 @@ - 0: 書いた - 1: 照合が一致しない・表が決まりに反する・材料(表・手書き部分)が無い——書いていない - 2: 照合していない・表が読めない(`check-signoff.py` の 2 と同じ)・出力先の名前が無い・空白・改行を含むか `#` で始まる・`\\` を含む・UTF-8 で書けない・確かめられない(辿れない)・ディレクトリ・仕様書・ガントチャート・PPTX・図・印と重なる・ - 印に無いファイルが出力の名前で在る・書けない・印が名指しする前の出力を消せない(消せなかったものを名指しし、印は残す。#440)——書いていない + 印に無いファイルが出力の名前で在る・書けない・印が名指しする前の出力を消せない(消せなかったものを名指しし、印は残す。#440)・ + 想定外の例外(128KB を超えるセルの `csv.Error` など。#286。前の出力は作り始める前に消してあり、書いている途中なら書きかけを消す)——書いていない。 + **git を起動できない・手書き部分を読めない・出力を書けない・一時ファイルを読み書きできないは、別の文言で strerror とパスを出す** - 3: 要求確定のタグの時点に暫定の回答(`暫定反映`)が残る(`check-signoff.py` の 3 と同じ)——書いていない **守らないもの**: 手書き部分の中身(判断であって一意に決まらない)・見た目(Markdown を表示して目で見る)・ @@ -115,8 +117,16 @@ def __init__(self, code: int, lines: list[str]): self.code, self.lines = code, lines +def run_git(top: Path, *args: str, **kwargs) -> subprocess.CompletedProcess: + """`git` を起動する。起動できなければ Stop(2)——手書き部分の読み・出力の書き込みの失敗と文言を分ける(#286)。""" + try: + return subprocess.run(["git", "-C", str(top), *args], capture_output=True, **kwargs) + except OSError as e: + raise Stop(2, [f"git を起動できない({e.strerror}: {e.filename})。提案書の材料を読んでいない"]) + + def show(top: Path, commit: str, tag: str, path: str) -> str: - result = subprocess.run(["git", "-C", str(top), "show", f"{commit}:{path}"], capture_output=True) + result = run_git(top, "show", f"{commit}:{path}") if result.returncode != 0: raise Stop(1, [f"{tag}:{path}: 承認の時点に無い。提案書の材料が無い"]) try: @@ -126,8 +136,7 @@ def show(top: Path, commit: str, tag: str, path: str) -> str: def exists(top: Path, commit: str, path: str) -> bool: - result = subprocess.run(["git", "-C", str(top), "ls-tree", "--name-only", commit, "--", path], - capture_output=True, text=True) + result = run_git(top, "ls-tree", "--name-only", commit, "--", path, text=True) if result.returncode != 0: raise Stop(2, [f"{commit[:7]}:{path}: 在るかを確かめられない: {result.stderr.strip()}"]) return bool(result.stdout.strip()) @@ -153,8 +162,7 @@ def copy_docs(top: Path, commit: str, tag: str, dest: Path) -> None: (macOS の既定)では同じ場所に書かれ、片方が黙って消えるか、先に置いたリンクを辿って一時ディレクトリの外に 書く。どのファイルシステムでも同じ答えにするため、写す前に一覧で断る。書く先が既にあるときも断る。 """ - listing = subprocess.run(["git", "-C", str(top), "ls-tree", "-r", "-z", "--full-tree", commit, "--", signoff.DOCS], - capture_output=True) + listing = run_git(top, "ls-tree", "-r", "-z", "--full-tree", commit, "--", signoff.DOCS) if listing.returncode != 0: raise Stop(2, [f"{tag}: {signoff.DOCS}/ を一覧できない: {listing.stderr.decode('utf-8', 'replace').strip()}"]) entries, versions = [], set() @@ -193,8 +201,7 @@ def copy_docs(top: Path, commit: str, tag: str, dest: Path) -> None: clashes.append(f"{tag}: ディレクトリ {other} と {parent} が大文字小文字・Unicode の正規化だけ違う(同じ場所に書かれうる)") if clashes: raise Stop(1, sorted(set(clashes)) + ["仕様書・前の版との比較を作れない(写すと同じ場所に書かれる)"]) - batch = subprocess.run(["git", "-C", str(top), "cat-file", "--batch"], - input=b"".join(sha.encode() + b"\n" for _, sha, _ in entries), capture_output=True) + batch = run_git(top, "cat-file", "--batch", input=b"".join(sha.encode() + b"\n" for _, sha, _ in entries)) if batch.returncode != 0: raise Stop(2, [f"{tag}: {signoff.DOCS}/ を読めない: {batch.stderr.decode('utf-8', 'replace').strip()}"]) data, at, root = batch.stdout, 0, dest.resolve() @@ -393,7 +400,9 @@ def read_hand(directory: Path) -> list[tuple[str, str]]: for path in sorted(directory.glob("*.md")) if directory.is_dir() else []: try: hand.append((path.name, path.read_text(encoding="utf-8-sig"))) - except (OSError, UnicodeDecodeError): + except OSError as e: # 権限などを「UTF-8 として読めない」「git を起動できない」と言わない(#286) + raise Stop(2, [f"{path}: 手書き部分を読めない({e.strerror})。中身を検査していない"]) + except UnicodeDecodeError: raise Stop(2, [f"{path}: UTF-8 として読めない。中身を検査していない"]) return hand @@ -681,15 +690,17 @@ def write_outputs(outputs: dict[Path, bytes | list], directory: Path) -> None: pptx.write(content, path) else: path.write_bytes(content) - except OSError as e: + except Exception as e: # 想定外の例外でも書きかけを残さない(#286)。印に載っているので消せる + reason = (f"{e.filename or directory}: 出力を書けない({e.strerror})" if isinstance(e, OSError) + else f"{directory}: 書いている途中に想定外の例外({type(e).__name__}: {e})") left = remove_owned(directory) if left: - raise Stop(2, [f"{directory}: 書けない({e.strerror})。書きかけを消せなかった", *left_lines(left)]) - raise Stop(2, [f"{directory}: 書けない({e.strerror})。書きかけを消した"]) + raise Stop(2, [f"{reason}。書きかけを消せなかった", *left_lines(left)]) + raise Stop(2, [f"{reason}。書きかけを消した"]) def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None) parser.add_argument("--repo", default=".", type=Path) parser.add_argument("--version", type=signoff.positive) parser.add_argument("--req") @@ -721,8 +732,8 @@ def main(argv: list[str]) -> int: outputs = build(args, out) except signoff.Unjudged as e: raise Stop(2, [str(e)]) - except OSError as e: - raise Stop(2, [f"git を起動できない({e.strerror})。提案書の材料を読んでいない"]) + except OSError as e: # git の起動(`run_git`)と手書き部分(`read_hand`)は先に捕まえる。残るのは一時ファイルの読み書き + raise Stop(2, [f"{e.filename or '(パス不明)'}: 読み書きできない({e.strerror})。提案書の材料を読み終えていない"]) write_outputs(outputs, out.parent) except Stop as stop: for line in stop.lines: @@ -734,4 +745,8 @@ def main(argv: list[str]) -> int: if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + try: + sys.exit(main(sys.argv[1:])) + except Exception as e: # 想定外の例外も「判定していない」(2)。トレースバックの 1(違反)に化けさせない(#286) + print(f"{Path(__file__).name}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない", file=sys.stderr) + sys.exit(2) diff --git a/plugins/spec-loop/scripts/check-feasibility-csv.py b/plugins/spec-loop/scripts/check-feasibility-csv.py index d825ddb0..83a82c60 100644 --- a/plugins/spec-loop/scripts/check-feasibility-csv.py +++ b/plugins/spec-loop/scripts/check-feasibility-csv.py @@ -16,7 +16,7 @@ - 0: 2 つとも決まりどおり - 1: 決まりに反する箇所がある(**全部を 1 行ずつ出す**。最初の 1 件で止めない) -- 2: 中身を検査していない——引数の誤り・読めない・UTF-8 でない・空。 +- 2: 中身を検査していない——引数の誤り・読めない・UTF-8 でない・空・想定外の例外(128KB を超えるセルの `csv.Error` など。#286)。 **反する箇所が別のファイルに見つかっても 2 を優先する**(見ていないファイルを「決まりどおり」と言わない) **この検査が守らないもの**: @@ -180,7 +180,8 @@ def check_estimate(path: Path, text: str | None = None) -> tuple[list[str], list def main(argv: list[str]) -> int: if len(argv) != 2: - print(__doc__.splitlines()[2], file=sys.stderr) + print(__doc__.splitlines()[2] if __doc__ else + f"usage: python3 {Path(__file__).name} …(-OO で docstring が無く、使い方を出せない)", file=sys.stderr) return 2 problems, unreadable, counts = [], [], [] for path, checker in ((Path(argv[0]), check_schedule), (Path(argv[1]), check_estimate)): @@ -202,4 +203,8 @@ def main(argv: list[str]) -> int: if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + try: + sys.exit(main(sys.argv[1:])) + except Exception as e: # 想定外の例外も「判定していない」(2)。トレースバックの 1(違反)に化けさせない(#286) + print(f"{Path(__file__).name}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない", file=sys.stderr) + sys.exit(2) diff --git a/plugins/spec-loop/scripts/check-frozen.py b/plugins/spec-loop/scripts/check-frozen.py index 78847d69..dbe7eddf 100644 --- a/plugins/spec-loop/scripts/check-frozen.py +++ b/plugins/spec-loop/scripts/check-frozen.py @@ -21,7 +21,7 @@ - 0: 凍結した版は直されていない(凍結した版が無いときも 0。出力で区別する) - 1: 直されている(違うファイルを版ごとに全部出す) -- 2: 判定していない——git でない・git が無い・HEAD が無い・浅い clone・タグの時点に `docs/v/` が無い +- 2: 判定していない——git でない・git が無い・HEAD が無い・浅い clone・タグの時点に `docs/v/` が無い・想定外の例外(#286) 対象リポジトリ(正本リポジトリ)で単体で動くよう、**標準ライブラリだけ**を使う(`git` は要る)。 CI で走らせるなら、タグを取得できるよう履歴をすべて取る(GitHub Actions なら `actions/checkout` に `fetch-depth: 0`)。 @@ -121,7 +121,7 @@ def check(repo: Path, only: int | None = None) -> tuple[int, list[str]]: def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None) parser.add_argument("--repo", default=".", type=Path) args = parser.parse_args(argv) code, lines = check(args.repo) @@ -131,4 +131,8 @@ def main(argv: list[str]) -> int: if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + try: + sys.exit(main(sys.argv[1:])) + except Exception as e: # 想定外の例外も「判定していない」(2)。トレースバックの 1(違反)に化けさせない(#286) + print(f"{Path(__file__).name}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない", file=sys.stderr) + sys.exit(2) diff --git a/plugins/spec-loop/scripts/check-qa-csv.py b/plugins/spec-loop/scripts/check-qa-csv.py index 1c927cfe..cf9de416 100644 --- a/plugins/spec-loop/scripts/check-qa-csv.py +++ b/plugins/spec-loop/scripts/check-qa-csv.py @@ -20,6 +20,8 @@ - 決まりに反する箇所があれば、**全部を 1 行ずつ出して**非ゼロ(1)で終わる(最初の 1 件で止めない)。 - ファイルが読めない・UTF-8 でない・空のときは 2 で終わる(**中身を検査していない**)。 + **想定外の例外(128KB を超えるセルの `csv.Error` など)も 2**——トレースバックの 1(違反)にしない(#286)。 + 2 に寄せるのは CLI の入口だけで、読み込んだ `check` は例外をそのまま呼び出し元へ返す。 - 反する箇所が無ければ 0 で終わり、検査した行数を出す。 **この検査が守らないもの**: @@ -236,7 +238,8 @@ def check(path: Path) -> tuple[int, list[str]]: def main(argv: list[str]) -> int: if len(argv) != 1: - print(__doc__.splitlines()[2], file=sys.stderr) + print(__doc__.splitlines()[2] if __doc__ else + f"usage: python3 {Path(__file__).name} …(-OO で docstring が無く、使い方を出せない)", file=sys.stderr) return 2 code, lines = check(Path(argv[0])) for line in lines: @@ -245,4 +248,8 @@ def main(argv: list[str]) -> int: if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + try: + sys.exit(main(sys.argv[1:])) + except Exception as e: # 想定外の例外も「判定していない」(2)。トレースバックの 1(違反)に化けさせない(#286) + print(f"{Path(__file__).name}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない", file=sys.stderr) + sys.exit(2) diff --git a/plugins/spec-loop/scripts/check-signoff.py b/plugins/spec-loop/scripts/check-signoff.py index afc1efba..86a56290 100644 --- a/plugins/spec-loop/scripts/check-signoff.py +++ b/plugins/spec-loop/scripts/check-signoff.py @@ -44,7 +44,8 @@ どちらかの時点で比べる場所にファイルが無い(旧い構成のタグを含む)・どちらかの時点に `docs/v/` が無い・ `removed.csv` が読めない/決まり(`merge-versions.py` の読み方)に反する・要求確定のタグの時点の `removed.csv` の 配置を `merge` が拒否する(判定する形は `provisional_rows` の docstring)・ - 要求確定のタグの時点の `qa.csv` が読めない/列検査(`check-qa-csv.py`)を通らない + 要求確定のタグの時点の `qa.csv` が読めない/列検査(`check-qa-csv.py`)を通らない・ + 想定外の例外(128KB を超えるセルの `csv.Error` など。#286) 対象リポジトリ(正本リポジトリ)で単体で動くよう、**標準ライブラリだけ**を使う(`git` は要る)。 @@ -155,8 +156,9 @@ def read_removed_at(repo: Path, commit: str, tag: str, path: str) -> list[tuple[ copy.write_bytes(result.stdout) try: parsed = versions.read_removed(copy, problems) - except versions.Unreadable: - raise Unjudged(f"{tag} の {path} が UTF-8 として読めない。照合していない") + except versions.Unreadable as e: + # 文言は `Unreadable` のものを出す——権限などの読めなさを「UTF-8 として読めない」と言わない(#286) + raise Unjudged(f"{tag} の {path} を読めない: {str(e).replace(str(copy), path)}。照合していない") problems = [line.replace(str(copy), path) for line in problems] if problems: raise Unjudged(refused(tag, problems)) @@ -400,7 +402,7 @@ def positive(text: str) -> int: def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None) parser.add_argument("--repo", default=".", type=Path) parser.add_argument("--version", type=positive) parser.add_argument("--req") @@ -413,4 +415,8 @@ def main(argv: list[str]) -> int: if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + try: + sys.exit(main(sys.argv[1:])) + except Exception as e: # 想定外の例外も「判定していない」(2)。トレースバックの 1(違反)に化けさせない(#286) + print(f"{Path(__file__).name}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない", file=sys.stderr) + sys.exit(2) diff --git a/plugins/spec-loop/scripts/check-spec-exit.py b/plugins/spec-loop/scripts/check-spec-exit.py index daab10ff..6c174c7e 100644 --- a/plugins/spec-loop/scripts/check-spec-exit.py +++ b/plugins/spec-loop/scripts/check-spec-exit.py @@ -26,7 +26,8 @@ - 0: 条件 1 を満たす - 1: 条件 1 を満たさない(反映した状態でない行と、印のある箇所を**全部** 1 行ずつ出す)。 **Q&A 表が列検査を通らないときもここ**(決まりに反する表は、確定していない) -- 2: 判定していない——引数の誤り・Q&A 表が読めない・仕様のディレクトリが無い・ +- 2: 判定していない——引数の誤り・Q&A 表が読めない・想定外の例外(128KB を超えるセルの `csv.Error` など。#286)・ + 仕様のディレクトリが無い・ 仕様のファイルが 1 つも無い(**書いていない仕様は、確定していない**ので、0 にはしない)・ **図(`FIGURE_SUFFIXES`)以外に読めないファイルがある**(UTF-16・`.xlsx` など。見ていないものを 「印 0 件」と言わない)・**読めた仕様のファイルが 1 つも無い**(図だけ)。 @@ -183,7 +184,8 @@ def check(qa_path: Path, spec_dir: Path) -> tuple[int, list[str]]: def main(argv: list[str]) -> int: if len(argv) != 2: - print(__doc__.splitlines()[2], file=sys.stderr) + print(__doc__.splitlines()[2] if __doc__ else + f"usage: python3 {Path(__file__).name} …(-OO で docstring が無く、使い方を出せない)", file=sys.stderr) return 2 code, lines = check(Path(argv[0]), Path(argv[1])) for line in lines: @@ -192,4 +194,8 @@ def main(argv: list[str]) -> int: if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + try: + sys.exit(main(sys.argv[1:])) + except Exception as e: # 想定外の例外も「判定していない」(2)。トレースバックの 1(違反)に化けさせない(#286) + print(f"{Path(__file__).name}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない", file=sys.stderr) + sys.exit(2) diff --git a/plugins/spec-loop/scripts/diff-versions.py b/plugins/spec-loop/scripts/diff-versions.py index 319473db..060e99d6 100644 --- a/plugins/spec-loop/scripts/diff-versions.py +++ b/plugins/spec-loop/scripts/diff-versions.py @@ -19,7 +19,8 @@ - 0: 出した - 1: 入力が決まりに反する(重ね合わせが作れない)——比べていない -- 2: 引数の誤り・入力が無い/読めない・その版が無い——比べていない +- 2: 引数の誤り・入力が無い/読めない・その版が無い・想定外の例外(深い入れ子の `RecursionError`・128KB を超えるセルの + `csv.Error` など。#286)——比べていない **守らないもの**: @@ -189,7 +190,8 @@ def main(argv: list[str]) -> int: version = int(args[at + 1]) del args[at:at + 2] if len(args) != 1: - print(__doc__.splitlines()[2], file=sys.stderr) + print(__doc__.splitlines()[2] if __doc__ else + f"usage: python3 {Path(__file__).name} …(-OO で docstring が無く、使い方を出せない)", file=sys.stderr) return 2 docs = Path(args[0]) if not docs.is_dir(): @@ -211,4 +213,8 @@ def main(argv: list[str]) -> int: if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + try: + sys.exit(main(sys.argv[1:])) + except Exception as e: # 想定外の例外も「判定していない」(2)。トレースバックの 1(違反)に化けさせない(#286) + print(f"{Path(__file__).name}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない", file=sys.stderr) + sys.exit(2) diff --git a/plugins/spec-loop/scripts/init-mkdocs.py b/plugins/spec-loop/scripts/init-mkdocs.py index 92cd5e4d..4cf1d349 100644 --- a/plugins/spec-loop/scripts/init-mkdocs.py +++ b/plugins/spec-loop/scripts/init-mkdocs.py @@ -16,7 +16,8 @@ - `--dry-run` は何を写すかを示すだけで、何も書かない。 - 雛形の `gitignore` は `.gitignore` として写す(プラグインの中で本物の `.gitignore` として効かないように名前を変えてある)。 -終了コード: 0 写した・写すものが無い / 2 根がディレクトリでない・雛形が読めない・書けない(行き先の親がファイルのときを含む)。 +終了コード: 0 写した・写すものが無い / 2 根がディレクトリでない・雛形が読めない・書けない(行き先の親がファイルのときを含む)・ +想定外の例外(#286。書いている途中でも一時ファイルは消し、退路で作りかけたファイルも消す——上の「書けない」と同じ)。 """ from __future__ import annotations @@ -175,4 +176,8 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": - sys.exit(main()) + try: + sys.exit(main(sys.argv[1:])) + except Exception as e: # 想定外の例外も「判定していない」(2)。トレースバックの 1(違反)に化けさせない(#286) + print(f"{Path(__file__).name}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない", file=sys.stderr) + sys.exit(2) diff --git a/plugins/spec-loop/scripts/merge-versions.py b/plugins/spec-loop/scripts/merge-versions.py index 8928a426..9f1eec5b 100644 --- a/plugins/spec-loop/scripts/merge-versions.py +++ b/plugins/spec-loop/scripts/merge-versions.py @@ -19,8 +19,9 @@ - 0: 書いた - 1: 入力が決まりに反する(重ねた結果が書けない形になる・UTF-8 で書けない文字を含む、も含む)——**書かない。前に作った出力があれば消す**(問題は 1 件で止めず、 最初に問題のあった版の中は全部出す。それより後の版は前の版の全体に依るので見ない) -- 2: 引数の誤り・入力が無い/読めない・出力先に書けない——**書かない**。 - **入力が無い・読めないときは**、出力先が印の在るディレクトリなら前の出力を消す。 +- 2: 引数の誤り・入力が無い/読めない・出力先に書けない・想定外の例外(巨大なセルの `csv.Error`・深い入れ子の + `RecursionError` など。#286)——**書かない**(書いている途中なら書きかけを消す)。 + **入力が無い・読めないときは**(重ねている途中の想定外の例外を含む)、出力先が印の在るディレクトリなら前の出力を消す。 **出力先そのものを断ったとき**(印の無い空でない場所・シンボリックリンク・`docs/` の中・`docs/` を含む場所)は**消さない** **守らないもの**: @@ -390,7 +391,8 @@ def main(argv: list[str]) -> int: upto = int(args[at + 1]) del args[at:at + 2] if len(args) != 2: - print(__doc__.splitlines()[2], file=sys.stderr) + print(__doc__.splitlines()[2] if __doc__ else + f"usage: python3 {Path(__file__).name} …(-OO で docstring が無く、使い方を出せない)", file=sys.stderr) return 2 docs, out = Path(args[0]), Path(args[1]) @@ -424,6 +426,11 @@ def main(argv: list[str]) -> int: print("重ね合わせは作っていない", file=sys.stderr) remove_stale(out) return 2 + except Exception as e: # 想定外の例外(巨大なセルの csv.Error・深い入れ子の RecursionError など)も読めない入力と同じに扱う(#286) + print(f"{docs}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない。重ね合わせは作っていない", + file=sys.stderr) + remove_stale(out) + return 2 if problems: for line in problems: print(line, file=sys.stderr) @@ -439,8 +446,9 @@ def main(argv: list[str]) -> int: put(out / path, rendered[path]) # 印は最後に書く——途中で失敗した出力を、出来上がったものと取り違えないため put(out / MARKER, "merge-versions.py が作った。手で直さない\n".encode("utf-8")) - except OSError as e: - print(f"{out}: 書けない({e.strerror})。書きかけを消した", file=sys.stderr) + except Exception as e: # 想定外の例外でも書きかけを残さない——印の無い書きかけは、次の実行が断る(#286) + reason = e.strerror if isinstance(e, OSError) else f"想定外の例外 {type(e).__name__}: {e}" + print(f"{out}: 書けない({reason})。書きかけを消した", file=sys.stderr) if not existed: shutil.rmtree(out, ignore_errors=True) elif out.is_dir(): @@ -456,4 +464,8 @@ def main(argv: list[str]) -> int: if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + try: + sys.exit(main(sys.argv[1:])) + except Exception as e: # 想定外の例外も「判定していない」(2)。トレースバックの 1(違反)に化けさせない(#286) + print(f"{Path(__file__).name}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない", file=sys.stderr) + sys.exit(2) diff --git a/plugins/spec-loop/scripts/qa-to-pptx.py b/plugins/spec-loop/scripts/qa-to-pptx.py index 731a8155..0c439df7 100644 --- a/plugins/spec-loop/scripts/qa-to-pptx.py +++ b/plugins/spec-loop/scripts/qa-to-pptx.py @@ -31,7 +31,8 @@ - 0: 作った(載せた質問の数を、回答待ち・未反映と暫定に分けて出す) - 1: 列検査を通らない(**出力は作らない。既存の出力にも触らない**) -- 2: 引数の誤り・表が読めない・**出力先が入力の表と同じファイル**・**内部の層の表**(**同上**) +- 2: 引数の誤り・表が読めない・**出力先が入力の表と同じファイル**・**内部の層の表**・想定外の例外(128KB を超えるセルの + `csv.Error` など。書いている途中でも一時ファイルを消すので既存の出力は残る。#286)(**同上**) - 3: 出力を書けなかった(**既存の出力は残る**——一時ファイルに書いてから置き換える) 出力先の親ディレクトリが無ければ作る(既定の出力先 `build/req-loop/` は、 @@ -457,7 +458,8 @@ def parse(argv: list[str]) -> tuple[Path, Path, str, str] | None: def main(argv: list[str]) -> int: parsed = parse(argv) if parsed is None: - print(__doc__.splitlines()[2], file=sys.stderr) + print(__doc__.splitlines()[2] if __doc__ else + f"usage: python3 {Path(__file__).name} …(-OO で docstring が無く、使い方を出せない)", file=sys.stderr) return 2 src, out, title, subtitle = parsed # **出力先が入力の表そのものなら書かない**——置き換えで正本の表が PPTX になり、戻せない。 @@ -484,4 +486,8 @@ def main(argv: list[str]) -> int: if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + try: + sys.exit(main(sys.argv[1:])) + except Exception as e: # 想定外の例外も「判定していない」(2)。トレースバックの 1(違反)に化けさせない(#286) + print(f"{Path(__file__).name}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない", file=sys.stderr) + sys.exit(2) diff --git a/plugins/spec-loop/scripts/scaffold.py b/plugins/spec-loop/scripts/scaffold.py index e4392e25..a18539e3 100644 --- a/plugins/spec-loop/scripts/scaffold.py +++ b/plugins/spec-loop/scripts/scaffold.py @@ -76,7 +76,8 @@ - 2: 判定していない——引数の誤り(書く生成・`--red-check` に `--upto`、`--red-check` の無い `--in-progress`、無い版の `--upto` を含む)・`docs/` が無い/版が 1 つも無い/ 版が読めない・関門が判定していない・中間表現が無い・Red テストを走らせられない・実装が無く回帰を判定できない・ 前の版の時点が崩れていて凍結しているかを判定できない(`git` を起動できない・浅い clone・`check-frozen.py` が 2)・ - 予期しない失敗(書き込みの失敗を含む)。書く生成では前の Issue の本文は消す + 予期しない失敗(書き込みの失敗・128KB を超えるセルの `csv.Error` を含む)。書く生成では前の Issue の本文は消す。 + **ただし引数を解釈する前・判定の外で上がった想定外の例外は、入口が 2 にするだけで本文を消さない**(書く生成かが決まっていない。#286) **`scaffold/` は失敗しても消さない**——commit 済みの契約で、古ければ `--check` が言う。 @@ -1498,4 +1499,8 @@ def run(repo: Path, args, writing: bool) -> int: if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + try: + sys.exit(main(sys.argv[1:])) + except Exception as e: # 想定外の例外も「判定していない」(2)。トレースバックの 1(違反)に化けさせない(#286) + print(f"{Path(__file__).name}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない", file=sys.stderr) + sys.exit(2) diff --git a/plugins/spec-loop/scripts/schedule-to-gantt.py b/plugins/spec-loop/scripts/schedule-to-gantt.py index a01b5d4e..f810ac93 100644 --- a/plugins/spec-loop/scripts/schedule-to-gantt.py +++ b/plugins/spec-loop/scripts/schedule-to-gantt.py @@ -16,7 +16,9 @@ - 0: 書いた - 1: 表が決まりに反する・描く工程が 1 行も無い——**書かない。前に作った出力があれば消す** (古いガントチャートを今の表のものと取り違えないため) -- 2: 引数の誤り・表が読めない(中身を検査していない)——同じく書かず、前の出力を消す +- 2: 引数の誤り・表が読めない(中身を検査していない)・書けない・想定外の例外(巨大なセルの `csv.Error` など。#286) + ——同じく書かず、前の出力を消す(引数の誤りでは出力先が分からないので消さない。 + 前の出力そのものを消せないときは、消せなかった理由を出して 2) **守らないもの**: 見た目(文字が枠からはみ出すか・読みやすいか)。ブラウザで開いて目で見る。 """ @@ -113,15 +115,22 @@ def remove_stale(out: Path) -> None: def main(argv: list[str]) -> int: if len(argv) != 2: - print(__doc__.splitlines()[2], file=sys.stderr) + print(__doc__.splitlines()[2] if __doc__ else + f"usage: python3 {Path(__file__).name} …(-OO で docstring が無く、使い方を出せない)", file=sys.stderr) return 2 source, out = Path(argv[0]), Path(argv[1]) try: problems, rows = feasibility.check_schedule(source) + if not problems and rows: + svg = render(rows).encode("utf-8") except feasibility.Unreadable as e: print(e, file=sys.stderr) remove_stale(out) return 2 + except Exception as e: # 想定外の例外(巨大なセルの csv.Error など)でも、前の出力を残さない(#286) + print(f"{source}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない", file=sys.stderr) + remove_stale(out) + return 2 if not problems and not rows: problems = [f"{source}: 描く工程が 1 行も無い"] if problems: @@ -130,11 +139,20 @@ def main(argv: list[str]) -> int: print("ガントチャートは作っていない", file=sys.stderr) remove_stale(out) return 1 - out.parent.mkdir(parents=True, exist_ok=True) - out.write_bytes(render(rows).encode("utf-8")) + try: + out.parent.mkdir(parents=True, exist_ok=True) + out.write_bytes(svg) + except OSError as e: # 書きかけを今の表のものと取り違えないよう消す + print(f"{out}: 書けない({e.strerror})", file=sys.stderr) + remove_stale(out) + return 2 print(f"{out}: {len(rows)} 工程のガントチャートを書いた") return 0 if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + try: + sys.exit(main(sys.argv[1:])) + except Exception as e: # 想定外の例外も「判定していない」(2)。トレースバックの 1(違反)に化けさせない(#286) + print(f"{Path(__file__).name}: 想定外の例外で止まった({type(e).__name__}: {e})。判定していない", file=sys.stderr) + sys.exit(2) diff --git a/plugins/spec-loop/skills/feasibility/SKILL.md b/plugins/spec-loop/skills/feasibility/SKILL.md index dbbda657..075bfb74 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.1**(プラグイン `spec-loop`)。 + +> **このスキルの版: 0.30.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 8bd2e07c..24ed7525 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.1**(プラグイン `spec-loop`)。 + +> **このスキルの版: 0.30.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))。 @@ -69,7 +69,7 @@ python3 <このスキルの base ディレクトリ>/../../scripts/check-signoff | --- | --- | | **0**(一致する) | 手順 2 へ | | **1**(一致しない) | 提案書を作らない。**出力の差分(どのファイルが違うか)を示し**、どちらの承認を取り直すかを利用者に訊いて止まる | -| **2**(照合していない) | 提案書を作らない。**何が無いのか(タグ・比べる場所の `shared/`)を示して止まる**。タグが無いなら、まだ承認がそろっていない——`/spec-loop:req-loop`・`/spec-loop:feasibility` の止まる地点を案内する | +| **2**(照合していない) | 提案書を作らない。**何が無いのか(タグ・比べる場所の `shared/`)を示して止まる**。タグが無いなら、まだ承認がそろっていない——`/spec-loop:req-loop`・`/spec-loop:feasibility` の止まる地点を案内する。**想定外の例外(`想定外の例外で止まった` と出る)も 2**——違反(1)と読まず、出た型と文言を示して止まる | | **3**(要求確定のタグの時点に暫定の回答が残る) | 提案書を作らない。**出力の暫定の行を示し**、利用者に確かめてもらってから、**要求確定と予算の合意の両方**のサインオフを取り直すよう案内して止まる(確かめると `qa.csv` が変わり、今の予算の合意とも一致しなくなる。`/spec-loop:req-loop` のレビュー会。`../../references/signoff-tags.md`) | **1・2・3 で、それ以上先へ進まない。** diff --git a/plugins/spec-loop/skills/req-loop/SKILL.md b/plugins/spec-loop/skills/req-loop/SKILL.md index 2537f5ab..822d7a46 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.1**(プラグイン `spec-loop`)。 + +> **このスキルの版: 0.30.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 2204748e..18a76d2d 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.1**(プラグイン `spec-loop`)。 + +> **このスキルの版: 0.30.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 58439cb0..74c6dc47 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.1**(プラグイン `spec-loop`)。 + +> **このスキルの版: 0.30.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/scripts/test-build-proposal.py b/scripts/test-build-proposal.py index b9d79e95..2018a3e0 100644 --- a/scripts/test-build-proposal.py +++ b/scripts/test-build-proposal.py @@ -27,6 +27,10 @@ 8. **前の出力を消せない場所(書けないディレクトリ)では、traceback・1 ではなく 2 で、残ったものを名指しし、印を残す**(#440) ——`--out` の親・`--out` に渡したディレクトリ・書きかけの片付けの 3 つの入口。 +9. **想定外の例外(128KB 超のセルの `csv.Error`)も 2 で、前の出力を消し、トレースバックの 1 にしない**。読めなさの原因を + 取り違えない——git を起動できない・手書き部分を読めない・出力を書けないを別の文言で、strerror とパスを添えて出す。 + 書いている途中の想定外の例外でも書きかけを残さない。`-OO`(docstring が無い)で引数なしに呼んでも想定外の例外にならない(#286)。 + **このテストは実環境を触らない。** 試料の git リポジトリはすべて一時ディレクトリに作り、 `HOME` と `GIT_CONFIG_GLOBAL` を差し替える。 @@ -448,10 +452,24 @@ def pptx_ok(parts: dict) -> bool: and "(spec.md で見る)" in pptx_text(r.pptx["spec.pptx"]) and "(proposal.md で見る)" in pptx_text(r.pptx["proposal.pptx"]) and r.pptx["spec.pptx:bytes"] == r.again_pptx.get("spec.pptx:bytes") and r.pptx["proposal.pptx:bytes"] == r.again_pptx.get("proposal.pptx:bytes")), + # ---- 想定外の例外・読めなさの原因(#286) + # 128KB 超のセル(csv.Error)は照合の中で上がる。2 で、前の出力を消し、トレースバックの 1 にしない + ("Q&A 表に巨大なセル", [("w", QA, QA_HEAD + QA_ROWS.replace("割引は?", "x" * 200_000))] + BASE[1:] + [("t", R1), ("t", B1)], + True, lambda r: r.code == 2 and r.text is None and not r.svg and r.spec is None and not r.names + and "想定外の例外で止まった(Error: field larger" in r.output and "前に作った出力を消した" in r.output + and "Traceback" not in r.output), + # 権限で読めない手書き部分を「UTF-8 として読めない」「git を起動できない」と言わない + ("手書き部分が読めない", SIGNED + [("chmod", "docs/v1/proposal/01-background.md", 0)], False, + lambda r: not_made(2)(r) and "01-background.md: 手書き部分を読めない(" in r.output + and "UTF-8" not in r.output and "git を起動" not in r.output), ] # (名前, 壊す前, 壊した後, 殺すはずの試料の名前) MUTATIONS: list[tuple[str, str, str, str]] = [ + ("想定外の例外を 2 に寄せない", " except Exception as e: # 想定外の例外も「判定していない」(2)", + " except ImportError as e: # 想定外の例外も「判定していない」(2)", "Q&A 表に巨大なセル"), + ("手書き部分の読めなさを捕まえない", " except OSError as e: # 権限などを", " except ImportError as e: # 権限などを", + "手書き部分が読めない"), # 外さなくても `Q&A(内部)` の節は描かない(共有の種類だけ描く。#344)——外すことが効くのは内部の表の違反のほう ("内部の Q&A も比較に載せる", " drop_internal(Path(tmp) / signoff.DOCS)\n", "", "内部の表の違反でも比較を作る"), ("removed.csv の D- の行を外さない", "if len(kept) != len(rows):", "if False:", "内部の表の違反でも比較を作る"), @@ -635,6 +653,8 @@ def compose_problems(scripts: Path) -> list[str]: # 書いている途中の失敗で殺す変異。(名前, 壊す前, 壊した後) WRITE_MUTATIONS: list[tuple[str, str, str]] = [ + ("想定外の例外で書きかけを残す", " except Exception as e: # 想定外の例外でも書きかけを残さない(#286)", + " except OSError as e: # 想定外の例外でも書きかけを残さない(#286)"), ("書きかけを消さない", " left = remove_owned(directory)\n", " left = []\n"), ("印を書かない(途中の失敗)", " (directory / MANIFEST).write_text(", " (directory / MANIFEST).with_name('unused').write_text("), ] @@ -652,23 +672,69 @@ def write_failure_problems(scripts: Path) -> list[str]: module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - def fail(*_args) -> None: - raise OSError(errno.EACCES, "書けない(試料)") + problems = [] + # OSError は書けなかったパスと strerror を出す。想定外の例外(ValueError)でも書きかけを残さず 2(#286) + for error, words in ((OSError(errno.EACCES, "書けない(試料)", "proposal.pptx"), ["proposal.pptx: 出力を書けない(書けない(試料))"]), + (ValueError("試料"), ["想定外の例外(ValueError: 試料)"])): + def fail(*_args, error=error) -> None: + raise error + + module.pptx.write = fail + with tempfile.TemporaryDirectory(prefix="proposal-write-") as tmp: + directory = Path(tmp) / "out" + try: + with contextlib.redirect_stderr(io.StringIO()): # 消したことの通知は親の出力に混ぜない + module.write_outputs({directory / "proposal.md": b"x", directory / "proposal.pptx": []}, directory) + problems.append(f"{type(error).__name__}: 書けないのに止まらない") + except module.Stop as stop: + if (stop.code != 2 or not any("書きかけを消した" in line for line in stop.lines) + or not all(any(w in line for line in stop.lines) for w in words)): + problems.append(f"{type(error).__name__}: 止まり方が違う: {stop.code} {stop.lines}") + except Exception as e: # noqa: BLE001 — 変異の結果を観測するため + problems.append(f"{type(error).__name__}: 捕まえない: {type(e).__name__}: {e}") + left = sorted(p.name for p in directory.iterdir()) if directory.is_dir() else [] + if left: + problems.append(f"{type(error).__name__}: 書きかけが残る: {left}") + return problems + +# 想定外の例外・git の起動・-OO(#286)。(名前, 壊す前, 壊した後) +ENTRY_MUTATIONS: list[tuple[str, str, str]] = [ + ("git を起動できないのを捕まえない", ' except OSError as e:\n raise Stop(2, [f"git を起動できない', + ' except ImportError as e:\n raise Stop(2, [f"git を起動できない'), + ("-OO で docstring が無いと落ちる", "description=__doc__.splitlines()[0] if __doc__ else None", + "description=__doc__.splitlines()[0] if True else None"), +] + - module.pptx.write = fail +def entry_problems(scripts: Path, root: Path, env: dict[str, str]) -> list[str]: + """CASES の外の 2 つ(#286): + 材料を読んでいる途中で git を起動できなくなったら、「git を起動できない」と strerror とパスを出して 2——照合(`check-signoff.py`) + が先に git を起動するので CLI からは作れず、`show` を `PATH` に git の無い環境で呼ぶ。 + `-OO`(docstring が無い)で引数なしに呼んでも、想定外の例外にもトレースバックにもならない(git でない場所なので 2)。""" + assert_in_tmp(root) + root.mkdir(parents=True) problems = [] - with tempfile.TemporaryDirectory(prefix="proposal-write-") as tmp: - directory = Path(tmp) / "out" - 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): - problems.append(f"止まり方が違う: {stop.code} {stop.lines}") - left = sorted(p.name for p in directory.iterdir()) if directory.is_dir() else [] - if left: - problems.append(f"書きかけが残る: {left}") + spec = importlib.util.spec_from_file_location(f"build_proposal_{id(root)}", scripts / TARGET) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + (root / "empty-path").mkdir() + saved = os.environ.get("PATH") + os.environ["PATH"] = str(root / "empty-path") + try: + module.show(root, "HEAD", "t", "x") + problems.append("git を起動できないのに止まらない") + except module.Stop as stop: + if stop.code != 2 or not any("git を起動できない(" in line and ": git)" in line for line in stop.lines): + problems.append(f"git を起動できない: {stop.code} {stop.lines}") + except Exception as e: # noqa: BLE001 — 変異の結果を観測するため + problems.append(f"git を起動できない: {type(e).__name__}: {e}") + finally: + os.environ["PATH"] = saved if saved is not None else "" + proc = subprocess.run([sys.executable, "-I", "-OO", str(scripts / TARGET)], capture_output=True, text=True, + cwd=root, env=env) + output = proc.stdout + proc.stderr + if proc.returncode != 2 or "想定外の例外" in output or "Traceback" in output: + problems.append(f"-OO の引数なし: 終了 {proc.returncode}: {output[-300:]!r}") return problems # 読めない印・辿れない出力先で殺す変異。(名前, 壊す前, 壊した後) @@ -726,8 +792,8 @@ def ownership_problems(scripts: Path) -> list[str]: " 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}: 書けない"), + ("書きかけを消せなくても消したと言う", " if left:\n raise Stop(2, [f\"{reason}。書きかけを消せなかった", + " if False:\n raise Stop(2, [f\"{reason}。書きかけを消せなかった"), ] @@ -909,6 +975,8 @@ def build_repo(root: Path, steps: list[tuple[str, ...]], env: dict[str, str]) -> run_git(root, env, "commit", "-q", "--allow-empty", "-m", "step") elif step[0] == "t": run_git(root, env, "tag", step[1]) + elif step[0] == "chmod": # 作業ツリーのファイルの権限(commit の後。手書き部分は作業ツリーから読む) + (root / step[1]).chmod(step[2]) def generate(scripts: Path, root: Path, out: Path | str, env: dict[str, str], extra: list[str] = ()) -> tuple[int, str]: @@ -1177,6 +1245,16 @@ def main() -> int: (mutated / TARGET).write_text(source.replace(before, after), encoding="utf-8") check(f"変異が生き残った: {name}(write_outputs を直接呼んで殺せるはず)", bool(write_failure_problems(mutated))) + problems = entry_problems(SCRIPTS, tmp_path / "entry", env) + check("git を起動できない・-OO の引数なしの扱いが違う", not problems, "; ".join(problems)) + for index, (name, before, after) in enumerate(ENTRY_MUTATIONS): + if source.count(before) != 1: + check(f"変異が届いていない: {name}", False, f"壊す箇所が {source.count(before)} 回現れる") + continue + mutated = copy_scripts(tmp_path / f"mutentry{index}") + (mutated / TARGET).write_text(source.replace(before, after), encoding="utf-8") + check(f"変異が生き残った: {name}", bool(entry_problems(mutated, tmp_path / f"mutentrywork{index}", env))) + problems = ownership_problems(SCRIPTS) check("読めない印か辿れない出力先の扱いが違う", not problems, "; ".join(problems)) for index, (name, before, after) in enumerate(OWN_MUTATIONS): @@ -1208,7 +1286,7 @@ def main() -> int: for message in failures: print(f" - {message}") return 1 - 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)} 件" + 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(ENTRY_MUTATIONS) - len(skipped)} 件" + (f"(python {sys.version_info.major}.{sys.version_info.minor} では当てていない {len(skipped)} 件: {'・'.join(skipped)})" if skipped else "")) return 0 diff --git a/scripts/test-check-feasibility-csv.py b/scripts/test-check-feasibility-csv.py index e9d6fe1d..d4ac7853 100644 --- a/scripts/test-check-feasibility-csv.py +++ b/scripts/test-check-feasibility-csv.py @@ -12,6 +12,8 @@ 2. **決まりどおりの形を通す**(陽性対照。`references/feasibility-csv.md` の「例」も通ること)。 3. **中身を検査していない形を 0 にしない(2)**——ファイルが無い・UTF-8 でない・空。 4. **本体の定数が `references/feasibility-csv.md` の表と一致する**(写しのずれ)。 +3'. **想定外の例外(128KB 超のセルの `csv.Error`)も 2 で、トレースバックの 1 にしない**。`-OO`(docstring が無い)で + 引数なしに呼んでも使い方の誤りとして 2(#286)。 5. **リポジトリの外で単体で動く**——プラグインの `scripts/` を一時ディレクトリに写し、`python3 -I` で走らせる。 **このテストは実環境を触らない。** 試料はすべて一時ディレクトリに書く。 @@ -103,6 +105,9 @@ ("見積りが空", S_HEAD + S_GOOD, "", 2, "空のファイル"), # 片方に問題があっても、もう片方を読んでいなければ 2 ("問題と読めないファイル", S_HEAD + "T-1,a,2026-11-02,2026-11-02,\n", "", 2, "`T-001` の形でない"), + # 想定外の例外(128KB 超のセルの csv.Error)も 2 で、トレースバックの 1 にしない(#286) + ("巨大なセル", S_HEAD + "T-001," + "x" * 200_000 + ",2026-11-02,2026-11-02,\n", E_HEAD + E_GOOD, 2, + "想定外の例外で止まった(Error: field larger"), ] # (名前, 壊す前, 壊した後, 殺すはずの試料の名前) @@ -139,8 +144,20 @@ ("BOM を剥がさない", 'path.read_text(encoding="utf-8-sig")', 'path.read_text(encoding="utf-8")', "BOM 付き"), ("空のファイルを 2 にしない", " if not text.strip():\n raise Unreadable", " if False:\n raise Unreadable", "見積りが空"), + ("想定外の例外を 2 に寄せない", " except Exception as e: # 想定外の例外も「判定していない」(2)", + " except ImportError as e: # 想定外の例外も「判定していない」(2)", "巨大なセル"), ] +# -OO(docstring が無い)で引数なしに呼んでも、使い方の誤りとして 2(#286)。(名前, 壊す前, 壊した後) +OO_MUTATIONS = [("-OO で docstring が無いと落ちる", "__doc__.splitlines()[2] if __doc__ else", "__doc__.splitlines()[2] if True else")] + + +def oo_problems(scripts: Path, cwd: Path) -> list[str]: + proc = subprocess.run([sys.executable, "-I", "-OO", str(scripts / TARGET)], capture_output=True, text=True, cwd=cwd) + if proc.returncode != 2 or "usage:" not in proc.stderr or "Traceback" in proc.stderr: + return [f"-OO の引数なし: 終了 {proc.returncode}: {proc.stderr[-300:]!r}"] + return [] + failures: list[str] = [] @@ -177,7 +194,7 @@ def run_all(scripts: Path, work: Path) -> dict[str, tuple[int, str]]: def as_expected(name: str, result: tuple[int, str]) -> bool: expected = next(case for case in CASES if case[0] == name) - return result[0] == expected[3] and expected[4] in result[1] + return result[0] == expected[3] and expected[4] in result[1] and "Traceback" not in result[1] def section(markdown: str, heading: str) -> str: @@ -289,12 +306,22 @@ def main() -> int: check(f"変異が生き残った: {name}({killer} で殺せるはず)", not as_expected(killer, mutated_results[killer]), mutated_results[killer][1][-300:]) + problems = oo_problems(scripts, tmp_path) + check("-OO の引数なしで使い方の誤り(2)にならない", not problems, "; ".join(problems)) + for index, (name, before, after) in enumerate(OO_MUTATIONS): + if source.count(before) != 1: + check(f"変異が届いていない: {name}", False, f"壊す箇所が {source.count(before)} 回現れる") + continue + mutated = copy_scripts(tmp_path / f"oo-mut{index}") + (mutated / TARGET).write_text(source.replace(before, after), encoding="utf-8") + check(f"変異が生き残った: {name}", bool(oo_problems(mutated, tmp_path))) + if failures: print(f"{len(failures)} 件の失敗:\n") for message in failures: print(f" - {message}") return 1 - print(f"OK: 試料 {len(CASES)} 件・変異 {len(MUTATIONS)} 件") + print(f"OK: 試料 {len(CASES)} 件・変異 {len(MUTATIONS) + len(OO_MUTATIONS)} 件") return 0 diff --git a/scripts/test-check-frozen.py b/scripts/test-check-frozen.py index de1e21d1..df7d685c 100644 --- a/scripts/test-check-frozen.py +++ b/scripts/test-check-frozen.py @@ -14,6 +14,8 @@ 4. **判定できない形を 0 にしない(2)**——タグの時点に `docs/v/` が無い・タグがコミットを指さない・ HEAD が無い・git でない・**浅い clone**(`actions/checkout` の既定。タグが見えない—— 同じ元からの深い clone では判定できることを陽性対照にする)。 +4'. **想定外の例外も 2 で、トレースバックの 1 にしない。`-OO`(docstring が無い)で引数なしに呼んでも想定外の例外にならない**(#286)。 + **想定外の例外を作る入力は無い**——読むのは git の出力だけで、CSV も JSON も読まない。`subprocess.run` を差し替えて作る。 5. **本体の定数が `references/versions.md` と一致する**(写しのずれ)。 6. **リポジトリの外で単体で動く**——プラグインの `scripts/` を一時ディレクトリに写し、`python3 -I` で走らせる。 @@ -196,6 +198,40 @@ def reference_mismatches(source: str, markdown: str) -> list[str]: ] +# 想定外の例外は 2・-OO(docstring が無い)で引数なしに呼んでも想定外の例外にならない(#286)。(名前, 壊す前, 壊した後) +OO_MUTATIONS = [ + ("想定外の例外を 2 に寄せない", " except Exception as e: # 想定外の例外も「判定していない」(2)", + " except ImportError as e: # 想定外の例外も「判定していない」(2)"), + ("-OO で docstring が無いと落ちる", "description=__doc__.splitlines()[0] if __doc__ else None", + "description=__doc__.splitlines()[0] if True else None"), +] +# 想定外の例外を作る入力はこの本体に無い(読むのは git の出力だけで、CSV も JSON も読まない)。 +# だから `subprocess.run` を差し替えて ValueError を投げさせ、入口(`__main__`)から走らせる +UNEXPECTED_RUNNER = """ +import runpy, subprocess, sys +def run(*args, **kwargs): + raise ValueError("試料") +subprocess.run = run +sys.argv = sys.argv[1:] +runpy.run_path(sys.argv[0], run_name="__main__") +""" + + +def oo_problems(scripts: Path, cwd: Path, env: dict[str, str]) -> list[str]: + problems = [] + proc = subprocess.run([sys.executable, "-I", "-OO", str(scripts / TARGET)], capture_output=True, text=True, + cwd=cwd, env=env) + output = proc.stdout + proc.stderr + if proc.returncode != 2 or "想定外の例外" in output or "Traceback" in output: + problems.append(f"-OO の引数なし: 終了 {proc.returncode}: {output[-300:]!r}") + proc = subprocess.run([sys.executable, "-I", "-c", UNEXPECTED_RUNNER, str(scripts / TARGET)], capture_output=True, + text=True, cwd=cwd, env=env) + output = proc.stdout + proc.stderr + if proc.returncode != 2 or "想定外の例外で止まった(ValueError: 試料)" not in output or "Traceback" in output: + problems.append(f"想定外の例外: 終了 {proc.returncode}: {output[-300:]!r}") + return problems + + def copy_scripts(dest: Path) -> Path: assert_in_tmp(dest) shutil.copytree(SCRIPTS, dest, ignore=shutil.ignore_patterns("__pycache__")) @@ -237,12 +273,23 @@ def main() -> int: check(f"変異が生き残った: {name}({killer} で殺せるはず)", not as_expected(killer, mutated_results[killer]), mutated_results[killer][1][-300:]) + (tmp_path / "oo").mkdir() # git でない場所——引数なしは今のディレクトリを見るので 2 + problems = oo_problems(scripts, tmp_path / "oo", env) + check("想定外の例外・-OO の引数なしで 2 にならない", not problems, "; ".join(problems)) + for index, (name, before, after) in enumerate(OO_MUTATIONS): + if source.count(before) != 1: + check(f"変異が届いていない: {name}", False, f"壊す箇所が {source.count(before)} 回現れる") + continue + mutated = copy_scripts(tmp_path / f"oo-mut{index}") + (mutated / TARGET).write_text(source.replace(before, after), encoding="utf-8") + check(f"変異が生き残った: {name}", bool(oo_problems(mutated, tmp_path / "oo", env))) + if failures: print(f"{len(failures)} 件の失敗:\n") for message in failures: print(f" - {message}") return 1 - print(f"OK: 試料 {len(CASES)} 件・変異 {len(MUTATIONS)} 件") + print(f"OK: 試料 {len(CASES)} 件・変異 {len(MUTATIONS) + len(OO_MUTATIONS)} 件") return 0 diff --git a/scripts/test-check-qa-csv.py b/scripts/test-check-qa-csv.py index e808c5ac..d413d123 100644 --- a/scripts/test-check-qa-csv.py +++ b/scripts/test-check-qa-csv.py @@ -15,6 +15,8 @@ 本体の定数はその写しである。**写しがずれたらここで落ちる。** `references/qa-csv.md` の 「例」のブロックも本体に通す(決まりの文書が、自分の例で決まりを破っていないか)。 4. **リポジトリの外で単体で動く**——本体だけを一時ディレクトリに写し、`python3 -I` で走らせる。 +4a. **想定外の例外(128KB 超のセルの `csv.Error`)も CLI では 2 で、トレースバックの 1 にしない**。`-OO`(docstring が無い)で + 引数なしに呼んでも使い方の誤りとして 2。**読み込んだ `check` は例外をそのまま返す**(2 に寄せるのは入口だけ。#286)。 **このテストは実環境を触らない。** 試料はすべて一時ディレクトリに書く。 @@ -717,6 +719,30 @@ def reference_example(markdown: str, heading: str = "例") -> str | None: return m.group(1) if m else None +# 想定外の例外は 2(#286)。入口の捕捉と、-OO で docstring が無いときの使い方を壊す。(名前, 壊す前, 壊した後) +ENTRY_GUARD = " except Exception as e: # 想定外の例外も「判定していない」(2)" +ENTRY_MUTATIONS = [ + ("想定外の例外を 2 に寄せない", ENTRY_GUARD, ENTRY_GUARD.replace("Exception", "ImportError")), + ("-OO で docstring が無いと落ちる", "__doc__.splitlines()[2] if __doc__ else", "__doc__.splitlines()[2] if True else"), +] + + +def unexpected_problems(script: Path, root: Path) -> list[str]: + """巨大なセル(128KB 超。`csv.Error`)の表を CLI に渡すと 2 で、トレースバックの 1 にならないか。 + `-OO`(docstring が無い)で引数なしに呼ぶと、使い方の誤りとして 2 か(#286)。""" + problems = [] + big = root / "big.csv" + assert_in_tmp(big) + big.write_text(HEADER + row(質問="x" * 200_000), encoding="utf-8") + r = subprocess.run([sys.executable, "-I", str(script), str(big)], capture_output=True, text=True, cwd=root) + if r.returncode != 2 or "想定外の例外" not in r.stderr or "field larger" not in r.stderr or "Traceback" in r.stderr: + problems.append(f"巨大なセル: 終了 {r.returncode}: {r.stderr[-300:]!r}") + r = subprocess.run([sys.executable, "-I", "-OO", str(script)], capture_output=True, text=True, cwd=root) + if r.returncode != 2 or "usage:" not in r.stderr or "Traceback" in r.stderr: + problems.append(f"-OO の引数なし: 終了 {r.returncode}: {r.stderr[-300:]!r}") + return problems + + def main() -> int: mod = load(SCRIPT) reference = REFERENCE.read_text(encoding="utf-8") @@ -767,6 +793,27 @@ def main() -> int: capture_output=True, text=True) check("引数が無ければ 2 で使い方を出す", usage.returncode == 2 and "usage:" in usage.stderr, usage.stderr) + print("4a. 想定外の例外は 2(#286)") + problems = unexpected_problems(SCRIPT, root) + check("巨大なセルで 2・-OO の引数なしで 2(トレースバックにならない)", not problems, "; ".join(problems)) + big = root / "big.csv" + try: + mod.check(big) + check("読み込んだ check は例外を呼び出し元へ返す(2 に寄せるのは CLI の入口だけ)", False, "例外が出ない") + except Exception as e: # noqa: BLE001 + check("読み込んだ check は例外を呼び出し元へ返す(2 に寄せるのは CLI の入口だけ)", + "field larger" in str(e), f"{type(e).__name__}: {e}") + source = SCRIPT.read_text(encoding="utf-8") + for index, (name, before, after) in enumerate(ENTRY_MUTATIONS): + if source.count(before) != 1: + check(f"変異が届く: {name}", False, f"壊す箇所が {source.count(before)} 回現れる") + continue + mutant = root / f"entry-mutant-{index}" / SCRIPT.name + mutant.parent.mkdir() + mutant.write_text(source.replace(before, after), encoding="utf-8") + assert_in_tmp(mutant) + check(f"変異を殺せる: {name}", bool(unexpected_problems(mutant, root))) + print("4b. 番号から層を決める(is_internal_number)") problems = number_layer_mismatches(mod) check("番号の層が期待どおり", not problems, "; ".join(problems)) @@ -858,7 +905,7 @@ def main() -> int: if failures: print(f"FAIL: {len(failures)} 件") return 1 - print(f"OK: 試料 {len(CASES)} 件・変異 {len(MUTATIONS) + len(NUMBER_MUTATIONS) + len(ORDER_MUTATIONS) + len(PROVISIONAL_MUTATIONS) + len(READER_MUTATIONS) + 1} 件") + print(f"OK: 試料 {len(CASES)} 件・変異 {len(MUTATIONS) + len(NUMBER_MUTATIONS) + len(ORDER_MUTATIONS) + len(PROVISIONAL_MUTATIONS) + len(READER_MUTATIONS) + len(ENTRY_MUTATIONS) + 1} 件") return 0 diff --git a/scripts/test-check-signoff.py b/scripts/test-check-signoff.py index 9b0c0677..025148f7 100644 --- a/scripts/test-check-signoff.py +++ b/scripts/test-check-signoff.py @@ -31,6 +31,8 @@ **要求確定側と予算側の `removed.csv` を入れ替えると、要求確定側だけが拒否する試料で捕まる**(#349。その試料と 「removed.csv だけ違う」の作業ツリー=予算側は `merge` が受け付ける)・**3 の案内が「要求確定と予算の合意の両方」を取り直させる**(#336。 出力は試料と変異で、docstring の終了コード 3 の項は文字列で見る)。 +5'. **想定外の例外(128KB 超のセルの `csv.Error`)も 2 で、トレースバックの 1 にしない**。`-OO`(docstring が無い)で + 引数なしに呼んでも想定外の例外にならない。**`removed.csv` が権限などで読めないとき「UTF-8 として読めない」と言わない**(#286)。 6. **本体の定数が `references/signoff-tags.md` と一致する**(写しのずれ)。 7. **リポジトリの外で単体で動く**——プラグインの `scripts/` を一時ディレクトリに写し、`python3 -I` で走らせる。 @@ -309,6 +311,10 @@ [], 2, "docs/v1/shared/qa.csv が決まりに反する(references/qa-csv.md)"), ("要求確定の時点の Q&A 表が空", [("w", SHARED, ""), ("c",), ("t", R1), ("t", B1)], [], 2, "docs/v1/shared/qa.csv を読めない"), + # 想定外の例外(128KB 超のセルの csv.Error)も 2 で、トレースバックの 1 にしない(#286) + ("要求確定の時点の Q&A 表に巨大なセル", [("w", SHARED, QA_HEAD + QA_2.replace("上限は?", "x" * 200_000)), ("c",), + ("t", R1), ("t", B1)], + [], 2, "想定外の例外で止まった(Error: field larger"), ] # 暫定の行の重ね方は `references/versions.md` の「重ねるもの」の写し——本体は Q&A の行だけを自前で重ねる。 @@ -347,6 +353,8 @@ # (名前, 壊す前, 壊した後, 殺すはずの試料の名前) MUTATIONS: list[tuple[str, str, str, str]] = [ + ("想定外の例外を 2 に寄せない", " except Exception as e: # 想定外の例外も「判定していない」(2)", + " except ImportError as e: # 想定外の例外も「判定していない」(2)", "要求確定の時点の Q&A 表に巨大なセル"), ("最新を文字列で選ぶ", "numbered = [(int(m.group(1)), name)", "numbered = [(m.group(1), name)", "最新を数で選ぶ"), ("命名を前方一致で見る", "if (m := pattern.fullmatch(name))", "if (m := pattern.match(name))", "命名に合わないタグを数えない"), @@ -615,7 +623,53 @@ def merge_refusing(root: Path) -> list[str]: def as_expected(name: str, result: tuple[int | None, str]) -> bool: expected = next(case for case in CASES if case[0] == name) once = name not in ONCE or result[1].count(ONCE[name]) == 1 - return result[0] == expected[3] and expected[4] in result[1] and once + return result[0] == expected[3] and expected[4] in result[1] and once and "Traceback" not in result[1] + + +# 想定外の例外・読めなさの原因(#286)。(名前, 壊す前, 壊した後) +ENTRY_MUTATIONS: list[tuple[str, str, str]] = [ + ("-OO で docstring が無いと落ちる", "description=__doc__.splitlines()[0] if __doc__ else None", + "description=__doc__.splitlines()[0] if True else None"), + ("removed.csv の読めなさを UTF-8 と言う", + ' raise Unjudged(f"{tag} の {path} を読めない: {str(e).replace(str(copy), path)}。照合していない")\n problems = [line', + ' raise Unjudged(f"{tag} の {path} が UTF-8 として読めない。照合していない")\n problems = [line'), +] +UNREADABLE_REMOVED = "removed.csv で消した暫定は数えない" # v2 の removed.csv を持つ試料 + + +def entry_problems(scripts: Path, work: Path, env: dict[str, str]) -> list[str]: + """CASES の外の 2 つ(#286): + `-OO`(docstring が無い)で引数なしに呼んでも、想定外の例外にもトレースバックにもならない(git でない場所なので 2)。 + `removed.csv` が権限などで読めないとき、`Unreadable` の文言を出し「UTF-8 として読めない」と言わない—— + 写しは本体が一時ディレクトリに書くので CLI からは作れず、`read_removed` を差し替えて `read_removed_at` を呼ぶ。""" + problems = [] + proc = subprocess.run([sys.executable, "-I", "-OO", str(scripts / TARGET)], capture_output=True, text=True, + cwd=work.parent, env=env) + output = proc.stdout + proc.stderr + if proc.returncode != 2 or "想定外の例外" in output or "Traceback" in output: + problems.append(f"-OO の引数なし: 終了 {proc.returncode}: {output[-300:]!r}") + spec = importlib.util.spec_from_file_location(f"check_signoff_{id(scripts)}", scripts / TARGET) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + def denied(path, _problems): + raise module.versions.Unreadable(f"{path}: 読めない(Permission denied)") + + module.versions.read_removed = denied + root = work / f"case{[c[0] for c in CASES].index(UNREADABLE_REMOVED)}" + saved = dict(os.environ) + os.environ.clear() + os.environ.update(env) # 本体の git も利用者の設定を読まない + try: + module.read_removed_at(root, R2, R2, REMOVED2) + problems.append("読めない removed.csv で止まらない") + except module.Unjudged as e: + if "Permission denied" not in str(e) or "UTF-8" in str(e) or f"{REMOVED2}: 読めない" not in str(e): + problems.append(f"読めない removed.csv の文言: {e}") + finally: + os.environ.clear() + os.environ.update(saved) + return problems NUMBER_WORDS = {"1 から始まる整数": r"([1-9][0-9]*)"} @@ -738,6 +792,16 @@ def main() -> int: killed = run_case(mutated, work, env, broken, killer) check(f"変異が生き残った: {name}({killer} で殺せるはず)", not as_expected(killer, killed), killed[1][-300:]) + problems = entry_problems(scripts, work, env) + check("-OO の引数なし・読めない removed.csv の文言", not problems, "; ".join(problems)) + for index, (name, before, after) in enumerate(ENTRY_MUTATIONS): + if source.count(before) != 1: + check(f"変異が届いていない: {name}", False, f"壊す箇所が {source.count(before)} 回現れる") + continue + mutated = copy_scripts(tmp_path / f"entry-mut{index}") + (mutated / TARGET).write_text(source.replace(before, after), encoding="utf-8") + check(f"変異が生き残った: {name}", bool(entry_problems(mutated, work, env))) + # 判別の本体(`check-qa-csv.py` の `is_provisional`)を壊す——照合は写しを持たずにそれを読む(#404) checker = (SCRIPTS / CHECKER).read_text(encoding="utf-8") for index, (name, before, after, killer) in enumerate(CHECKER_MUTATIONS): @@ -755,7 +819,7 @@ def main() -> int: for message in failures: print(f" - {message}") return 1 - print(f"OK: 試料 {len(CASES)} 件(うち {len(COEXIST)} 件は #260 の名前でも当てた)・変異 {len(MUTATIONS) + len(CHECKER_MUTATIONS)} 件") + print(f"OK: 試料 {len(CASES)} 件(うち {len(COEXIST)} 件は #260 の名前でも当てた)・変異 {len(MUTATIONS) + len(CHECKER_MUTATIONS) + len(ENTRY_MUTATIONS)} 件") return 0 diff --git a/scripts/test-check-spec-exit.py b/scripts/test-check-spec-exit.py index eddb3b4d..20a17948 100644 --- a/scripts/test-check-spec-exit.py +++ b/scripts/test-check-spec-exit.py @@ -13,6 +13,8 @@ 3. **判定できない形を 0 にしない**(仕様のディレクトリが無い・空・Q&A 表が読めない)。 4. **0 で終わっても「抜けた」とは言わない**——出力に「条件 2〜5 は判定していない」が出る。 5. **本体の定数が `references/spec-exit.md` の「未決定の印」の表と一致する**(写しのずれ)。 +3'. **想定外の例外(128KB 超のセルの `csv.Error`)も 2 で、トレースバックの 1 にしない**。`-OO`(docstring が無い)で + 引数なしに呼んでも使い方の誤りとして 2(#286)。 6. **リポジトリの外で単体で動く**——プラグインの `scripts/` を一時ディレクトリに写し、`python3 -I` で走らせる。 **このテストは実環境を触らない。** 試料はすべて一時ディレクトリに書く。 @@ -105,6 +107,9 @@ ("ドット始まりしか無い", HEADER, {".keep": ""}, 2, "ファイルが 1 つも無い"), ("Q&A 表が無い", None, {"order.md": CLEAN_SPEC}, 2, "Q&A 表を読めない"), ("Q&A 表が UTF-8 でない", b"\xff\xfe\x00", {"order.md": CLEAN_SPEC}, 2, "Q&A 表を読めない"), + # 想定外の例外(128KB 超のセルの csv.Error)も 2 で、トレースバックの 1 にしない(#286) + ("巨大なセル", HEADER + OPEN_ROW.replace("端数は?", "x" * 200_000), {"order.md": CLEAN_SPEC}, 2, + "想定外の例外で止まった(Error: field larger"), ] # (名前, 壊す前, 壊した後, 殺すはずの試料の名前) @@ -145,8 +150,20 @@ ("TBD を印から外す", 'MARKERS = ("TBD", "TODO", "未定", "要確認")', 'MARKERS = ("TODO", "未定", "要確認")', "TBD"), ("満たしても但し書きを出さない", ' "**これだけでは抜けていない**——条件 2〜5 は判定していない(references/spec-exit.md)",\n', "", "満たす"), + ("想定外の例外を 2 に寄せない", " except Exception as e: # 想定外の例外も「判定していない」(2)", + " except ImportError as e: # 想定外の例外も「判定していない」(2)", "巨大なセル"), ] +# -OO(docstring が無い)で引数なしに呼んでも、使い方の誤りとして 2(#286)。(名前, 壊す前, 壊した後) +OO_MUTATIONS = [("-OO で docstring が無いと落ちる", "__doc__.splitlines()[2] if __doc__ else", "__doc__.splitlines()[2] if True else")] + + +def oo_problems(scripts: Path, cwd: Path) -> list[str]: + proc = subprocess.run([sys.executable, "-I", "-OO", str(scripts / TARGET)], capture_output=True, text=True, cwd=cwd) + if proc.returncode != 2 or "usage:" not in proc.stderr or "Traceback" in proc.stderr: + return [f"-OO の引数なし: 終了 {proc.returncode}: {proc.stderr[-300:]!r}"] + return [] + failures: list[str] = [] @@ -195,7 +212,7 @@ def run_all(scripts: Path, work: Path) -> dict[str, tuple[int, str]]: def as_expected(name: str, result: tuple[int, str]) -> bool: expected = next(case for case in CASES if case[0] == name) - return result[0] == expected[3] and expected[4] in result[1] + return result[0] == expected[3] and expected[4] in result[1] and "Traceback" not in result[1] def reference_markers(markdown: str) -> tuple[list[str], list[str]]: @@ -273,12 +290,22 @@ def main() -> int: check(f"変異が生き残った: {name}({killer} で殺せるはず)", not as_expected(killer, mutated_results[killer]), mutated_results[killer][1][-300:]) + problems = oo_problems(scripts, tmp_path) + check("-OO の引数なしで使い方の誤り(2)にならない", not problems, "; ".join(problems)) + for index, (name, before, after) in enumerate(OO_MUTATIONS): + if source.count(before) != 1: + check(f"変異が届いていない: {name}", False, f"壊す箇所が {source.count(before)} 回現れる") + continue + mutated = copy_scripts(tmp_path / f"oo-mut{index}") + (mutated / TARGET).write_text(source.replace(before, after), encoding="utf-8") + check(f"変異が生き残った: {name}", bool(oo_problems(mutated, tmp_path))) + if failures: print(f"{len(failures)} 件の失敗:\n") for message in failures: print(f" - {message}") return 1 - print(f"OK: 試料 {len(CASES)} 件・変異 {len(MUTATIONS) + len(CHECKER_MUTATIONS)} 件") + print(f"OK: 試料 {len(CASES)} 件・変異 {len(MUTATIONS) + len(CHECKER_MUTATIONS) + len(OO_MUTATIONS)} 件") return 0 diff --git a/scripts/test-diff-versions.py b/scripts/test-diff-versions.py index 007300bf..8b086b42 100644 --- a/scripts/test-diff-versions.py +++ b/scripts/test-diff-versions.py @@ -15,6 +15,8 @@ 6. **比べられないときは比べない**——その版が無い(2。文言は `--version`)・決まりに反する(1)・`docs` が無い(2)。 7. **同じ入力から同じ出力。** **1 変更は 1 行**——セルの改行は `\\n` と書き、偽の変更行を作らない(件数と一覧の行数が食い違わない)。 +6'. **想定外の例外(深い入れ子の `RecursionError`・128KB 超のセルの `csv.Error`)も 2 で、トレースバックの 1 にしない**。 + `-OO`(docstring が無い)で引数なしに呼んでも使い方の誤りとして 2(#286)。 8. **リポジトリの外で単体で動く**——プラグインの `scripts/` を一時ディレクトリに写し、`python3 -I` で走らせる。 9. **決まりの原本(`references/versions.md`)に「版の差分」の節があり、3 つの変化の名前とコマンドが載っている。** @@ -95,6 +97,10 @@ def __init__(self, code: int, output: str, again: str): def run(scripts: Path, docs: Path, extra: list[str]) -> tuple[int, str]: + if extra == ["-OO"]: # docstring の無い実行で、引数なし(#286) + proc = subprocess.run([sys.executable, "-I", "-OO", str(scripts / TARGET)], capture_output=True, text=True, + cwd=docs.parent) + return proc.returncode, proc.stdout + proc.stderr proc = subprocess.run([sys.executable, "-I", str(scripts / TARGET), str(docs), *extra], capture_output=True, text=True, cwd=docs.parent) return proc.returncode, proc.stdout + proc.stderr @@ -141,6 +147,13 @@ def run(scripts: Path, docs: Path, extra: list[str]) -> tuple[int, str]: and "Q&A: 追加 1・変更 0・削除 0\n 追加 Q-004 質問4\n" "Q&A(内部): 追加 1・変更 1・削除 1\n 変更 D-001 内部1\n 追加 D-002 内部2\n 削除 D-003 内部3(理由: 共有へ移した)\n機能:" in r.output), + # 想定外の例外(深い入れ子の RecursionError・128KB 超のセルの csv.Error)も 2 で、トレースバックの 1 にしない(#286) + ("深い入れ子", {**V1, "v2/spec/spec_ir.json": "[" * 200_000 + "]" * 200_000}, [], + lambda r: r.code == 2 and "想定外の例外で止まった(RecursionError" in r.output and "Traceback" not in r.output), + ("巨大なセル", {**V1, "v2/shared/qa.csv": QA_HEAD + q("Q-004", "x" * 200_000)}, [], + lambda r: r.code == 2 and "想定外の例外で止まった(Error: field larger" in r.output and "Traceback" not in r.output), + ("-OO の引数なし", None, ["-OO"], + lambda r: r.code == 2 and "usage:" in r.output and "Traceback" not in r.output), ] # (名前, 壊す前, 壊した後, 殺すはずの試料の名前) @@ -170,6 +183,10 @@ def run(scripts: Path, docs: Path, extra: list[str]) -> tuple[int, str]: ("内部の削除の理由を引き当てない", "REMOVED_KIND.get(kind, kind)", "kind", "内部の表の行も比べる"), ("決まりに反しても比べる", " if problems:\n return problems, None\n number", " if False:\n return problems, None\n number", "決まりに反すると比べない"), + ("想定外の例外を 2 に寄せない", " except Exception as e: # 想定外の例外も「判定していない」(2)", + " except ImportError as e: # 想定外の例外も「判定していない」(2)", "深い入れ子"), + ("-OO で docstring が無いと落ちる", "__doc__.splitlines()[2] if __doc__ else", "__doc__.splitlines()[2] if True else", + "-OO の引数なし"), ] failures: list[str] = [] diff --git a/scripts/test-init-mkdocs.py b/scripts/test-init-mkdocs.py index 5db1e163..73f9e161 100644 --- a/scripts/test-init-mkdocs.py +++ b/scripts/test-init-mkdocs.py @@ -16,6 +16,8 @@ (`Path.unlink` を差し替える)そう出力し、書けなかった理由のほうで 2。リンクと関係ない失敗(`EACCES`)は 2。 写したファイルの権限は umask に従う(リンクの経路でも退路でも)。 雛形の中の `__pycache__` は写さない。 + **想定外の例外**(`os.link` を差し替えて `ValueError`)も 2 で、トレースバックの 1 にせず何も残さない。`-OO`(docstring が無い)で + 引数なしに呼んでも写す(#286)。 2. **フックの描き方**——版ごとの節を番号の順(`v10` は `v2` の後)に、表の行をすべて、版をまたいで一意なアンカーで描く。 セルの HTML と Markdown の記号(`&`・`\\`・`{` `}` を含む)・`|`・改行(`\\r` を含む)をエスケープする。 アンカーに使えない文字(空白・`=`・`(`)は `-` にする(`No` の文字が見出しの属性にならない)。`qa.csv` の無い版は飛ばして次の版を描く。CSV の文字は表のセルにだけ置き、 @@ -133,7 +135,8 @@ def run_init(plugin: Path, root: Path, *extra: str) -> tuple[int, str]: # os.link を差し替えて本体を走らせる。{err} は errno の名前、{race} は真なら失敗の前に行き先を作る(確かめた後に作られた形)、 # {cut} は真なら、512 バイトを超えるファイルで失敗の前に大きさの上限を 512 バイトにする(一時ファイルは書けたあと、退路の書き込みで切れる。 # 小さいファイルでは上限を掛けない——掛けると次のファイルの一時ファイルが先に切れて、退路まで届かない)。 -# {stuck} は真なら Path.unlink を EROFS で失敗させる(書きかけを消せない形。一時ファイルは os.unlink で消すので当たらない) +# {stuck} は真なら Path.unlink を EROFS で失敗させる(書きかけを消せない形。一時ファイルは os.unlink で消すので当たらない)。 +# {unexpected} は真なら OSError でなく ValueError を投げる(想定外の例外。#286) NO_LINK_RUNNER = """ import errno, os, pathlib, resource, runpy, sys def unlink(self, missing_ok=False): @@ -146,6 +149,8 @@ def link(src, dst): f.write("他人\\n") if {cut} and os.path.getsize(src) > 512: resource.setrlimit(resource.RLIMIT_FSIZE, (512, resource.RLIM_INFINITY)) + if {unexpected}: + raise ValueError("試料") raise OSError(errno.{err}, os.strerror(errno.{err})) os.link = link sys.argv = sys.argv[1:] @@ -154,9 +159,9 @@ def link(src, dst): def run_init_no_link(plugin: Path, root: Path, err: str = "ENOTSUP", race: bool = False, cut: bool = False, - stuck: bool = False) -> tuple[int, str]: + stuck: bool = False, unexpected: bool = False) -> tuple[int, str]: assert_in_tmp(root) - code = NO_LINK_RUNNER.format(err=err, race=race, cut=cut, stuck=stuck) + code = NO_LINK_RUNNER.format(err=err, race=race, cut=cut, stuck=stuck, unexpected=unexpected) proc = subprocess.run([sys.executable, "-I", "-c", code, str(plugin / INIT), str(root)], capture_output=True, text=True, check=False) return proc.returncode, proc.stdout + proc.stderr @@ -297,6 +302,25 @@ def init_link_other_error(plugin: Path, work: Path) -> bool: return code == 2 and snapshot(root) == {} and "書けない" in out +def init_unexpected(plugin: Path, work: Path) -> bool: + """想定外の例外(`os.link` が ValueError)も 2 で、トレースバックの 1 にせず、切れたファイルも一時ファイルも残さない(#286)。""" + root = work / "repo" + root.mkdir(parents=True) + code, out = run_init_no_link(plugin, root, unexpected=True) + return code == 2 and snapshot(root) == {} and "想定外の例外で止まった(ValueError: 試料)" in out and "Traceback" not in out + + +def init_optimized(plugin: Path, work: Path) -> bool: + """`-OO`(docstring が無い)で引数なしに呼んでも、トレースバックにならず今のディレクトリに写す(#286)。""" + root = work / "repo" + root.mkdir(parents=True) + assert_in_tmp(root) + proc = subprocess.run([sys.executable, "-I", "-OO", str(plugin / INIT)], capture_output=True, text=True, check=False, + cwd=root) + output = proc.stdout + proc.stderr + return proc.returncode == 0 and "Traceback" not in output and (root / "mkdocs.yml").is_file() + + def init_keeps_existing(plugin: Path, work: Path) -> bool: root = work / "repo" write(root, {"pyproject.toml": '[project]\nname = "x"\ndependencies = ["requests"]\n', @@ -705,6 +729,8 @@ def merge_ignores_pages(plugin: Path, work: Path) -> bool: "EPERM(Linux の vfat)でも退路に入る": init_no_link_eperm, "書きかけを消せなければそう言い、書けなかった理由で 2": init_no_link_stuck, "リンクと関係ない失敗は 2": init_link_other_error, + "想定外の例外は 2 で、切れたファイルを残さない": init_unexpected, + "-OO の引数なしでも写す": init_optimized, "在るファイルは変えず、足りないものを示す": init_keeps_existing, "壊れたリンクも在るとみなす": init_symlink, "--dry-run は書かない": init_dry_run, @@ -744,6 +770,8 @@ def merge_ignores_pages(plugin: Path, work: Path) -> bool: # (名前, 壊す対象, 壊す前, 壊した後, 殺すはずの試料の名前)。2 箇所を壊すときは (壊す前, 壊した後) の列を渡す MUTATIONS: list[tuple] = [ + ("想定外の例外を 2 に寄せない", INIT, " except Exception as e: # 想定外の例外も「判定していない」(2)", + " except ImportError as e: # 想定外の例外も「判定していない」(2)", "想定外の例外は 2 で、切れたファイルを残さない"), ("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",)', diff --git a/scripts/test-merge-versions.py b/scripts/test-merge-versions.py index f6fd6f3b..7aeeda9a 100644 --- a/scripts/test-merge-versions.py +++ b/scripts/test-merge-versions.py @@ -20,6 +20,9 @@ **2 回続けて作るだけでは、日付のように粒の粗い時刻を捕まえない**ので、出力の全バイト(印を含む)を期待値と突き合わせる。 8. **書いている途中で失敗したら、書きかけを残さない**——書き込みの口(`put`)を差し替えて 2 つ目で失敗させる。 用意された空の出力先は消さない(印を書きかけて失敗しても、直下に書きかけを残さない)。**途中で落ちても(後始末が走らなくても)印は無い。** +9. **想定外の例外(128KB 超のセルの `csv.Error`・深い入れ子の `RecursionError`)も 2 で、前の出力を消す**。 + 書いている途中の想定外の例外でも書きかけを残さない。前の出力を消そうとして上がった例外も入口が 2 にする + (トレースバックの 1 にしない)。`-OO`(docstring が無い)で引数なしに呼んでも使い方の誤りとして 2(#286)。 6. **リポジトリの外で単体で動く**——プラグインの `scripts/` を一時ディレクトリに写し、`python3 -I` で走らせる。 7. **決まりの写しがずれていない**——本体の定数が `references/versions.md` と一致する。 @@ -68,7 +71,10 @@ REMOVED_HEAD = "種類,対象,理由\n" # 入力の木の特別な値: シンボリックリンク(版の外のファイル/ディレクトリへ)と、読めないファイル/ディレクトリ LINK, LINK_DIR, UNREADABLE, UNREADABLE_DIR = None, "\0link-dir", "\0unreadable", "\0unreadable-dir" -UNREADABLE_CASES = {"読めないファイル", "読めないディレクトリ"} +UNREADABLE_CASES = {"読めないファイル", "読めないディレクトリ", "読めない中間表現", "前の出力を消せないときの想定外の例外"} +# 128KB を超えるセル(csv.Error)と深い入れ子(RecursionError)——どちらも想定外の例外(#286) +BIG_QA = QA_HEAD + Q3.replace("質問3", "x" * 200_000) +DEEP_JSON = "[" * 200_000 + "]" * 200_000 def js(data) -> str: @@ -219,6 +225,11 @@ def main_case(r: Result) -> bool: lambda r: r.code == 2 and r.tree is None and "読めない" in r.output and "前に作った出力を消した" in r.output), ("読めないディレクトリ", {**MAIN, "v2/requirements/sub": UNREADABLE_DIR}, "stale", [], lambda r: r.code == 2 and r.tree is None and "読めない" in r.output and "前に作った出力を消した" in r.output), + # 権限で読めないものを「UTF-8 として読めない」と言わない。UTF-8 でないものは UTF-8 と言う(#286) + ("読めない中間表現", {**MAIN, "v2/spec/spec_ir.json": UNREADABLE}, "stale", [], + lambda r: r.code == 2 and r.tree is None and "spec_ir.json: 読めない(" in r.output and "UTF-8" not in r.output), + ("UTF-8 でない中間表現", {**MAIN, "v2/spec/spec_ir.json": b"\xff\xfe{}"}, "stale", [], + lambda r: r.code == 2 and r.tree is None and "spec_ir.json: UTF-8 として読めない" in r.output), ("ファイルの形の版名", {**MAIN, "v3.md": "x\n"}, "none", [], lambda r: refused(r, "版の名前の形")), ("版がシンボリックリンク", {**MAIN, "v3": LINK_DIR}, "none", [], lambda r: refused(r, "版はディレクトリで置く")), ("置き場所がシンボリックリンク", {**V1, "v2/requirements": LINK_DIR}, "none", [], @@ -267,6 +278,18 @@ def main_case(r: Result) -> bool: ("大文字の V は版でない", {**MAIN, "V3/requirements/a.md": "x\n"}, "none", [], main_case), ("途中で落ちても印は無い", MAIN, "crash", [], lambda r: r.code != 0 and r.leftover is not None and ".merged-versions" not in r.leftover), + # 想定外の例外も 2(判定していない)で、トレースバックの 1 にしない。前の出力は読めない入力と同じに消す(#286) + ("巨大なセル", {**MAIN, "v2/shared/qa.csv": BIG_QA}, "stale", [], + lambda r: r.code == 2 and r.tree is None and "想定外の例外で止まった(Error: field larger" in r.output + and "前に作った出力を消した" in r.output and "Traceback" not in r.output), + ("深い入れ子", {**MAIN, "v2/spec/spec_ir.json": DEEP_JSON}, "stale", [], + lambda r: r.code == 2 and r.tree is None and "想定外の例外で止まった(RecursionError" in r.output + and "前に作った出力を消した" in r.output and "Traceback" not in r.output), + ("書いている途中の想定外の例外", MAIN, "failwrite-unexpected", [], + lambda r: r.code == 2 and "想定外の例外 ValueError" in r.output and "書きかけを消した" in r.output and r.leftover is None), + # 前の出力を消そうとして PermissionError——内側の捕捉の中で上がった例外は、入口が 2 にする + ("前の出力を消せないときの想定外の例外", {**MAIN, "v2/shared/qa.csv": BIG_QA}, "stale-locked", [], + lambda r: r.code == 2 and "想定外の例外で止まった(PermissionError" in r.output and "Traceback" not in r.output), ] # (名前, 壊す前, 壊した後, 殺すはずの試料の名前) @@ -363,8 +386,29 @@ def main_case(r: Result) -> bool: "(self.internal if target.startswith('D-') and target[2].isdigit() else self.qa)", "印つきの内部の行を removed.csv で消す"), ("行が 0 なら qa.csv を書かない", "(self.has_qa, QA_PATH, self.qa)", "(bool(self.qa), QA_PATH, self.qa)", "Q&A が全部消えても qa.csv を書く"), + # 読めなさの原因を取り違える・想定外の例外(#286) + ("権限で読めないのを UTF-8 と言う", ' raise Unreadable(f"{path}: 読めない({e.strerror})") from None\n except UnicodeDecodeError:', + ' raise Unreadable(f"{path}: UTF-8 として読めない") from None\n except UnicodeDecodeError:', "読めない中間表現"), + ("想定外の例外で前の出力を消さない", " except Exception as e: # 想定外の例外(巨大なセルの csv.Error", + " except ImportError as e: # 想定外の例外(巨大なセルの csv.Error", "巨大なセル"), + ("深い入れ子で前の出力を消さない", " except Exception as e: # 想定外の例外(巨大なセルの csv.Error", + " except csv.Error as e: # 想定外の例外(巨大なセルの csv.Error", "深い入れ子"), + ("書いている途中の想定外の例外で書きかけを残す", " except Exception as e: # 想定外の例外でも書きかけを残さない", + " except OSError as e: # 想定外の例外でも書きかけを残さない", "書いている途中の想定外の例外"), + ("入口で想定外の例外を 2 に寄せない", " except Exception as e: # 想定外の例外も「判定していない」(2)", + " except ImportError as e: # 想定外の例外も「判定していない」(2)", "前の出力を消せないときの想定外の例外"), ] +# -OO(docstring が無い)で引数なしに呼んでも、使い方の誤りとして 2(#286)。(名前, 壊す前, 壊した後) +OO_MUTATIONS = [("-OO で docstring が無いと落ちる", "__doc__.splitlines()[2] if __doc__ else", "__doc__.splitlines()[2] if True else")] + + +def oo_problems(scripts: Path, cwd: Path) -> list[str]: + proc = subprocess.run([sys.executable, "-I", "-OO", str(scripts / TARGET)], capture_output=True, text=True, cwd=cwd) + if proc.returncode != 2 or "usage:" not in proc.stderr or "Traceback" in proc.stderr: + return [f"-OO の引数なし: 終了 {proc.returncode}: {proc.stderr[-300:]!r}"] + return [] + failures: list[str] = [] @@ -404,6 +448,8 @@ def build(docs: Path, tree: dict | None) -> list[Path]: path.mkdir() (path / "e.md").write_text("読めない\n", encoding="utf-8") locked.append(path) + elif isinstance(content, bytes): + path.write_bytes(content) else: path.write_text(content, encoding="utf-8") for path in locked: @@ -434,6 +480,8 @@ def put(dest, content): elif len(calls) == 2: if mode == "crash": raise KeyboardInterrupt + if mode == "unexpected": + raise ValueError("試料") raise OSError(28, "No space left on device") real(dest, content) module.put = put @@ -460,7 +508,7 @@ def run_case(scripts: Path, root: Path, tree: dict, prepare: str, extra: list[st (root / "real").mkdir() out.parent.mkdir(parents=True) os.symlink(root / "real", out) - elif prepare == "stale": + elif prepare in ("stale", "stale-locked"): out.mkdir(parents=True) (out / ".merged-versions").write_text("前の出力\n", encoding="utf-8") (out / "old.md").write_text("古い\n", encoding="utf-8") @@ -469,8 +517,16 @@ def run_case(scripts: Path, root: Path, tree: dict, prepare: str, extra: list[st (out / "keep.md").write_text("正本\n", encoding="utf-8") elif prepare in ("failwrite-empty", "marker-empty"): out.mkdir(parents=True) - fail = {"failwrite": "oserror", "failwrite-empty": "oserror", "crash": "crash", "marker-empty": "marker"}.get(prepare) - code, output = merge(scripts, docs, out, extra, fail) + fail = {"failwrite": "oserror", "failwrite-empty": "oserror", "crash": "crash", "marker-empty": "marker", + "failwrite-unexpected": "unexpected"}.get(prepare) + if prepare == "stale-locked": + out.parent.chmod(0o555) # 前の出力(の中身)は消せても、出力先そのものは消せない + try: + code, output = merge(scripts, docs, out, extra, fail) + finally: + out.parent.chmod(0o755) + else: + code, output = merge(scripts, docs, out, extra, fail) leftover = (sorted(p.relative_to(out).as_posix() for p in out.rglob("*")) if out.is_dir() and not out.is_symlink() else None) for path in locked: @@ -563,6 +619,16 @@ def main() -> int: result = run_case(mutated, tmp_path / f"mutwork{index}", *killer_case[1:4]) check(f"変異が生き残った: {name}({killer} で殺せるはず)", not as_expected(killer, result), result.output[-300:]) + problems = oo_problems(scripts, tmp_path) + check("-OO の引数なしで使い方の誤り(2)にならない", not problems, "; ".join(problems)) + for index, (name, before, after) in enumerate(OO_MUTATIONS): + if source.count(before) != 1: + check(f"変異が届いていない: {name}", False, f"壊す箇所が {source.count(before)} 回現れる") + continue + mutated = copy_scripts(tmp_path / f"oo-mut{index}") + (mutated / TARGET).write_text(source.replace(before, after), encoding="utf-8") + check(f"変異が生き残った: {name}", bool(oo_problems(mutated, tmp_path))) + if failures: print(f"{len(failures)} 件の失敗:\n") for message in failures: @@ -571,7 +637,7 @@ def main() -> int: skip = [name for name, *_ in CASES if skipped(name)] if skip: print(f"飛ばした(root では読めない入力を作れない): 試料 {'・'.join(skip)} と、それを名指しした変異") - print(f"OK: 試料 {len(CASES) - len(skip)} 件・変異 {sum(not skipped(m[3]) for m in MUTATIONS)} 件") + print(f"OK: 試料 {len(CASES) - len(skip)} 件・変異 {sum(not skipped(m[3]) for m in MUTATIONS) + len(OO_MUTATIONS)} 件") return 0 diff --git a/scripts/test-qa-to-pptx.py b/scripts/test-qa-to-pptx.py index 268484ec..92158b53 100644 --- a/scripts/test-qa-to-pptx.py +++ b/scripts/test-qa-to-pptx.py @@ -17,6 +17,8 @@ 書けない出力先は 3 で、**一時ファイルを残さない。** 5. **出力が入力と引数だけで決まる**——時刻をまたいで 2 回作り、バイトが一致する。 6. **リポジトリの外で単体で動く**——2 本のスクリプトだけを一時ディレクトリに写し、`python3 -I` で走らせる。 +7. **想定外の例外(128KB 超のセルの `csv.Error`)も 2 で、既存の出力に触らず、トレースバックの 1 にしない**。 + `-OO`(docstring が無い)で引数なしに呼んでも使い方の誤りとして 2(#286)。 **見ないもの**: PowerPoint・LibreOffice で開けること(CI に無い。周の検証で手で当てる)、 文字がはみ出さないこと(本体の docstring の「守らないもの」)。 @@ -290,6 +292,33 @@ def observe(mod, base: Path) -> dict: ] +# 想定外の例外は 2(#286)。入口の捕捉と、-OO で docstring が無いときの使い方を壊す。(名前, 壊す前, 壊した後) +ENTRY_GUARD = " except Exception as e: # 想定外の例外も「判定していない」(2)" +ENTRY_MUTATIONS = [ + ("想定外の例外を 2 に寄せない", ENTRY_GUARD, ENTRY_GUARD.replace("Exception", "ImportError")), + ("-OO で docstring が無いと落ちる", "__doc__.splitlines()[2] if __doc__ else", "__doc__.splitlines()[2] if True else"), +] + + +def unexpected_problems(directory: Path, base: Path) -> list[str]: + """巨大なセル(128KB 超。`csv.Error`)の表を CLI に渡すと 2 で、既存の出力に触らず、トレースバックの 1 にならないか。 + `-OO`(docstring が無い)で引数なしに呼ぶと、使い方の誤りとして 2 か(#286)。`directory` に本体と検査の 2 本を置く。""" + problems = [] + big = write_csv(base / f"big-{directory.name}.csv", [ROWS[1][:2] + ["x" * 200_000] + ROWS[1][3:]]) + out = base / f"big-{directory.name}.pptx" + out.write_bytes(b"old") + r = subprocess.run([sys.executable, "-I", str(directory / SCRIPT.name), str(big), str(out)], + capture_output=True, text=True, cwd=base) + if r.returncode != 2 or "想定外の例外" not in r.stderr or "field larger" not in r.stderr or "Traceback" in r.stderr: + problems.append(f"巨大なセル: 終了 {r.returncode}: {r.stderr[-300:]!r}") + if out.read_bytes() != b"old": + problems.append("巨大なセル: 既存の出力が変わった") + r = subprocess.run([sys.executable, "-I", "-OO", str(directory / SCRIPT.name)], capture_output=True, text=True, cwd=base) + if r.returncode != 2 or "usage:" not in r.stderr or "Traceback" in r.stderr: + problems.append(f"-OO の引数なし: 終了 {r.returncode}: {r.stderr[-300:]!r}") + return problems + + def main() -> int: with tempfile.TemporaryDirectory() as tmp: base = Path(tmp) @@ -329,6 +358,20 @@ def main() -> int: capture_output=True, text=True, cwd=alone) check("python3 -I で 0 で終わり、出力がある", r.returncode == 0 and (alone / "o.pptx").exists()) + print("\n想定外の例外は 2(#286)") + problems = unexpected_problems(alone, base) + check(f"巨大なセルで 2・既存の出力に触らない・-OO の引数なしで 2({'; '.join(problems) or '問題なし'})", not problems) + source = SCRIPT.read_text(encoding="utf-8") + for n, (name, needle, replacement) in enumerate(ENTRY_MUTATIONS): + if source.count(needle) != 1: + check(f"変異を当てる先が 1 箇所ある: {name}({source.count(needle)} 箇所)", False) + continue + d = base / f"entry-mut-{n}" + d.mkdir() + shutil.copy(CHECKER, d / CHECKER.name) + (d / SCRIPT.name).write_text(source.replace(needle, replacement, 1), encoding="utf-8") + check(f"変異を殺せる: {name}", bool(unexpected_problems(d, base))) + print("\n変異テスト(壊したのに期待どおりなら失格)") source = SCRIPT.read_text(encoding="utf-8") for n, (name, needle, replacement) in enumerate(MUTATIONS): diff --git a/scripts/test-scaffold.py b/scripts/test-scaffold.py index 3c17ad01..ea7bc9a0 100644 --- a/scripts/test-scaffold.py +++ b/scripts/test-scaffold.py @@ -30,6 +30,9 @@ 同名の型と突き合わせる(OpenAPI の中で一緒に落ちる・変わるのを見るため)。exit 0 の全試料に当てる。 6. **本体の定数が `references/` の表と一致する**(メソッド・型・Issue の印と節)。**dev-loop の `references/spec-gap.md` も同じ印と節を持ち、dev-loop の `SKILL.md` も同じ印を持つ**(2 つのプラグインの境界の写し)。 +6'. **想定外の例外(128KB 超のセルの `csv.Error`)も 2 で、古い Issue の本文を残さない**。`-OO`(docstring が無い)で + 引数なしに呼んでもトレースバックにならない。判定(`run`)の外——引数の解釈——で上がった想定外の例外も入口が 2 にする + (`argparse` を差し替えて作る。#286)。 7. **リポジトリの外で単体で動く**——プラグインの `scripts/` を一時ディレクトリに写し、`python3 -I` で走らせる。 **このテストは実環境を触らない。** 試料はすべて一時ディレクトリに書く。 @@ -1191,6 +1194,9 @@ def remedy(repo: Path) -> None: Case("メソッドが配列", 1, "operation.method: 決まりに無い", pre=plant_old_issues, post=issues_gone, ir=edit(lambda ir: f1(ir)["operation"].update(method=["POST"]))), Case("深い入れ子", 2, "中間表現を読めない", raw="[" * 200000 + "]" * 200000, pre=plant_old_issues, post=issues_gone), + # 128KB 超のセル(csv.Error)も予期しない失敗として 2 で、古い Issue の本文を残さない(#286) + Case("巨大なセル", 2, "予期しない失敗(Error: field larger", pre=plant_old_issues, post=issues_gone, absent="Traceback", + qa=HEADER + QA_DONE.replace("シルバーで 10000 円未満の割引は?", "x" * 200_000) + QA_DONE2), Case("仕様のパスに NUL", 2, "予期しない失敗", pre=plant_old_issues, post=issues_gone, ir=edit(lambda ir: f1(ir).update(source=["spec/a\u0000b.md"]))), Case("桁の多いケースの id", 0, "作った", @@ -1531,6 +1537,8 @@ def remedy(repo: Path) -> None: " if False:\n try:\n clear_issues(repo)", "仕様のパスに NUL"), ("予期しない失敗を捕まえない", " except Exception as exc: # 予期しない失敗", " except ZeroDivisionError as exc: # 予期しない失敗", "書き込みの途中で失敗する"), + ("巨大なセルの予期しない失敗を捕まえない", " except Exception as exc: # 予期しない失敗", + " except ZeroDivisionError as exc: # 予期しない失敗", "巨大なセル"), ("メソッドが決まりに無くても入口の重複を見る", " elif method in METHODS and (method, path) in routes:", " elif (method, path) in routes:", "メソッドが配列"), # パスの変数(#289) @@ -1823,6 +1831,24 @@ def constant(source: str, name: str): return ast.literal_eval(found.group(1)) if found else None +# 入口の捕捉(#286)。`argparse` の解釈で ValueError を投げさせ、`__main__` から走らせる +ENTRY_GUARD = " except Exception as e: # 想定外の例外も「判定していない」(2)" +UNEXPECTED_RUNNER = """ +import argparse, runpy, sys +def parse_args(self, *args, **kwargs): + raise ValueError("試料") +argparse.ArgumentParser.parse_args = parse_args +sys.argv = sys.argv[1:] +runpy.run_path(sys.argv[0], run_name="__main__") +""" + + +def entry_caught(script: Path, cwd: Path) -> tuple[bool, str]: + proc = subprocess.run([sys.executable, "-I", "-c", UNEXPECTED_RUNNER, str(script)], capture_output=True, text=True, cwd=cwd) + output = proc.stdout + proc.stderr + return (proc.returncode == 2 and "想定外の例外で止まった(ValueError: 試料)" in output and "Traceback" not in output), output + + CHECKER = "check-qa-csv.py" CHECKER_MUTATIONS: list[tuple[str, str, str, str]] = [ ("判別の本体で暫定を見ない", 'return row["状態"].strip() == PROVISIONAL', "return False", "暫定反映の Q&A を指さない"), @@ -1966,6 +1992,24 @@ def main() -> int: ok, detail = run_case(mutated, tmp_path / f"mutwork{index}", by_name[killer]) check(f"変異が生き残った: {name}({killer} で殺せるはず)", not ok, detail) + # -OO(docstring が無い)で引数なしに呼んでも、想定外の例外にもトレースバックにもならない(#286)。 + # 引数なしは今のディレクトリを正本として読むので、空のディレクトリでは docs/ が無くて 2 + (tmp_path / "oo").mkdir() + proc = subprocess.run([sys.executable, "-I", "-OO", str(SCRIPTS / TARGET)], capture_output=True, text=True, + cwd=tmp_path / "oo") + output = proc.stdout + proc.stderr + check("-OO の引数なしで 2 にならない", proc.returncode == 2 and "予期しない失敗" not in output + and "想定外の例外" not in output and "Traceback" not in output, output[-300:]) + # 予期しない失敗は `run` の中なら本体が捕まえる。その外(引数の解釈)で上がった例外も入口が 2 にする(#286) + caught, output = entry_caught(SCRIPTS / TARGET, tmp_path / "oo") + check("引数の解釈で上がった想定外の例外を入口が 2 にしない", caught, output[-300:]) + if source.count(ENTRY_GUARD) != 1: + check("変異が届いていない: 入口で想定外の例外を 2 に寄せない", False, f"壊す箇所が {source.count(ENTRY_GUARD)} 回現れる") + else: + mutated = copy_scripts(tmp_path / "entry-mut", source.replace(ENTRY_GUARD, ENTRY_GUARD.replace("Exception", "ImportError"))) + caught, output = entry_caught(mutated / TARGET, tmp_path / "oo") + check("変異が生き残った: 入口で想定外の例外を 2 に寄せない", not caught, output[-300:]) + if failures: print(f"{len(failures)} 件の失敗:\n") for message in failures: diff --git a/scripts/test-schedule-to-gantt.py b/scripts/test-schedule-to-gantt.py index 933471b7..7e8e3042 100644 --- a/scripts/test-schedule-to-gantt.py +++ b/scripts/test-schedule-to-gantt.py @@ -12,6 +12,8 @@ 2. **表の中身が図に出る**——工程の数だけ棒があり、表の順に並び、棒の位置と幅が日付から決まる。 3. **整形式の XML**——工程の名前に `&`・`<`・制御文字が入っても。 4. **決まりに反する表・読めない表からは描かない**(1・2)。**前に作った出力は消す。** +4'. **想定外の例外(128KB 超のセルの `csv.Error`)も 2 で、前の出力を消す。** 前の出力を消せない・出力先に書けないときも + トレースバックの 1 にせず 2。`-OO`(docstring が無い)で引数なしに呼んでも使い方の誤りとして 2(#286)。 5. **リポジトリの外で単体で動く**——プラグインの `scripts/` を一時ディレクトリに写し、`python3 -I` で走らせる。 **守らないもの**: 見た目(文字が枠からはみ出すか)。ブラウザで開いて目で見る。 @@ -85,6 +87,10 @@ def well_formed(r: Result) -> bool: lambda r: r.code == 1 and r.svg is None and "前に作った出力を消した" in r.output), ("工程が無い", HEAD, False, False, lambda r: r.code == 1 and r.svg is None and "描く工程が 1 行も無い" in r.output), ("読めない表", None, True, False, lambda r: r.code == 2 and r.svg is None), + # 想定外の例外(128KB 超のセルの csv.Error)も 2 で、前の出力を消す(#286) + ("巨大なセル", HEAD + "T-001," + "x" * 200_000 + ",2026-11-02,2026-11-03,\n", True, False, + lambda r: r.code == 2 and r.svg is None and "想定外の例外で止まった(Error: field larger" in r.output + and "前に作った出力を消した" in r.output and "Traceback" not in r.output), ] # (名前, 壊す前, 壊した後, 殺すはずの試料の名前) @@ -106,8 +112,59 @@ def well_formed(r: Result) -> bool: "終了が開始より前は描かない"), ("前の出力を消さない", " out.unlink()\n", " pass\n", "前の出力を消す"), ("空の表を通す", "if not problems and not rows:", "if False:", "工程が無い"), + ("想定外の例外で前の出力を消さない", " except Exception as e: # 想定外の例外(巨大なセルの csv.Error など)でも", + " except ImportError as e: # 想定外の例外(巨大なセルの csv.Error など)でも", "巨大なセル"), ] +# 想定外の例外は 2(#286)。前の出力を消せないとき・書けないとき・-OO の引数なしは CASES の外で見る。(名前, 壊す前, 壊した後) +ENTRY_MUTATIONS: list[tuple[str, str, str]] = [ + ("入口で想定外の例外を 2 に寄せない", " except Exception as e: # 想定外の例外も「判定していない」(2)", + " except ImportError as e: # 想定外の例外も「判定していない」(2)"), + ("書けないときを捕まえない", " except OSError as e: # 書きかけを", " except ImportError as e: # 書きかけを"), + ("-OO で docstring が無いと落ちる", "__doc__.splitlines()[2] if __doc__ else", "__doc__.splitlines()[2] if True else"), +] + + +def entry_problems(scripts: Path, root: Path) -> list[str]: + """CASES の外の 3 つ(#286): + 前の出力を消せない場所(書けないディレクトリ)で巨大なセル——内側の捕捉が消そうとして `PermissionError` を投げても、 + 入口が 2 にする(トレースバックの 1 にしない)。書けない出力先は 2 で「書けない」と言う。`-OO` の引数なしは使い方の誤りで 2。""" + assert_in_tmp(root) + root.mkdir(parents=True) + problems = [] + big, good = root / "big.csv", root / "good.csv" + big.write_text(HEAD + "T-001," + "x" * 200_000 + ",2026-11-02,2026-11-03,\n", encoding="utf-8") + good.write_text(HEAD + GOOD, encoding="utf-8") + locked = root / "locked" + locked.mkdir() + (locked / "gantt.svg").write_text("古い", encoding="utf-8") + locked.chmod(0o555) + try: + if _writable(locked): + return ["書けないディレクトリに書ける(root で走っている?)——試料が判別しない"] + code, output = generate(scripts, big, locked / "gantt.svg") + if code != 2 or "想定外の例外" not in output or "Traceback" in output: + problems.append(f"消せない前の出力: 終了 {code}: {output[-300:]!r}") + code, output = generate(scripts, good, locked / "new.svg") + if code != 2 or "書けない(" not in output or "Traceback" in output: + problems.append(f"書けない出力先: 終了 {code}: {output[-300:]!r}") + finally: + locked.chmod(0o755) + proc = subprocess.run([sys.executable, "-I", "-OO", str(scripts / TARGET)], capture_output=True, text=True, cwd=root) + if proc.returncode != 2 or "usage:" not in proc.stderr or "Traceback" in proc.stderr: + problems.append(f"-OO の引数なし: 終了 {proc.returncode}: {proc.stderr[-300:]!r}") + return problems + + +def _writable(directory: Path) -> bool: + try: + (directory / "probe").write_text("", encoding="utf-8") + except OSError: + return False + (directory / "probe").unlink() + return True + + failures: list[str] = [] @@ -192,12 +249,22 @@ def main() -> int: check(f"変異が生き残った: {name}({killer} で殺せるはず)", not as_expected(killer, mutated_results[killer]), mutated_results[killer].output[-300:]) + problems = entry_problems(scripts, tmp_path / "entry") + check("消せない前の出力・書けない出力先・-OO の引数なしで 2 にならない", not problems, "; ".join(problems)) + for index, (name, before, after) in enumerate(ENTRY_MUTATIONS): + if source.count(before) != 1: + check(f"変異が届いていない: {name}", False, f"壊す箇所が {source.count(before)} 回現れる") + continue + mutated = copy_scripts(tmp_path / f"entry-mut{index}") + (mutated / TARGET).write_text(source.replace(before, after), encoding="utf-8") + check(f"変異が生き残った: {name}", bool(entry_problems(mutated, tmp_path / f"entry-mutwork{index}"))) + if failures: print(f"{len(failures)} 件の失敗:\n") for message in failures: print(f" - {message}") return 1 - print(f"OK: 試料 {len(CASES)} 件・変異 {len(MUTATIONS)} 件") + print(f"OK: 試料 {len(CASES)} 件・変異 {len(MUTATIONS) + len(ENTRY_MUTATIONS)} 件") return 0 From f63bd10e865319aa6669a0828a6c5df32f0292a0 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 10:39:41 +0900 Subject: [PATCH 2/7] =?UTF-8?q?issue-286:=20=E8=A8=88=E7=94=BB=E3=81=A8?= =?UTF-8?q?=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-286.md | 100 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/plans/issue-286.md diff --git a/docs/plans/issue-286.md b/docs/plans/issue-286.md new file mode 100644 index 00000000..8ef5f227 --- /dev/null +++ b/docs/plans/issue-286.md @@ -0,0 +1,100 @@ +# Issue #286 — spec-loop: 想定外の例外がトレースバックで 1 になり、1(違反)と 2(検査していない)の区別が崩れる + +spec-loop のスクリプトは、終了コード 1(違反・決まりに反する)と 2(判定していない・書いていない)を分けると約束している。 +ところが想定外の例外(`csv.Error`・`RecursionError` など)はトレースバックになり、1 で終わる。 +さらに、例外を捕まえてはいても原因を取り違えて報告する箇所がある。`build-proposal.py` の `OSError` →「git を起動できない」と、`merge-versions.py` の `Unreadable` →「UTF-8 として読めない」の 2 つ。 + +**不在の周**である——出所は `/dev-loop:cruise 474 --absent`(2026-10-28 に利用者が同じ引数で起動し直した)。 +台帳 `issue-474` の確かめた線: `仕様の穴 = 推奨で決めて起票`・`直す線 = 既定`・`マージ = 関門を通ればマージ`・`周の途中の課題 = 台帳に足して直す` +(出所: cruise の確かめた線(台帳 `issue-474`))。**このリポジトリのマージはサイトを再デプロイするので `マージ` の線は効かない**(#476)——PR まで作って止まる。 + +## 変更範囲 + +触る: + +- `plugins/spec-loop/scripts/*.py` — 12 本の入口(想定外の例外を 2 に寄せる)・docstring の終了コードの表・`build-proposal.py` の `OSError` の切り分け・`merge-versions.py` の `Unreadable` の文言。共通の関数を置くなら同じディレクトリの新しいファイル +- `scripts/test-*.py` のうち spec-loop の 12 本に対応するもの — 試料と変異 +- `plugins/spec-loop/references/*.md`・`plugins/spec-loop/skills/*/SKILL.md` — 終了コードの意味を述べている箇所があれば、想定外の例外も 2 だと揃える(無ければ触らない) +- `plugins/spec-loop/.claude-plugin/plugin.json` — 版(0.26.1 → 0.30.0。終了コードの振る舞いが変わるので minor。PR #479・#481・#483・#485 の 0.26.2〜0.29.0 と重ならない値) +- `.claude-plugin/marketplace.json` — 同じ版 +- `plugins/spec-loop/skills/*/SKILL.md` — 同じ版の 2 行 +- `scripts/check-all.py`・`.github/workflows/plugins.yml` — 新しいテストファイルを足した場合だけ +- `docs/plans/issue-286.md` — この計画 + +触らない: + +- `plugins/spec-loop/templates/mkdocs/` のフック(MkDocs から読まれるもので、終了コードを持たない) +- 想定内の誤りの終了コード(既存の 0/1/2/3 の割り当て) +- 他プラグイン(dev-loop など)のスクリプト + +### 同じことを述べている箇所の数 + +「終了コード 2 の意味」は 12 本の docstring にそれぞれある(12 箇所)。加えて `references/`・`SKILL.md` に終了コードを述べる箇所があれば、それも数に入る。 +分けない——同じ規則(想定外の例外は 2)を全部の入口に当てる周なので、一部だけ直すと「入口によって 1 と 2 が混ざる」状態が残る。数の多さは手戻りの危険なので、共通の関数に寄せて写しを減らすかを実装で決める。 + +## 乗るデプロイ経路 + +プラグイン(`plugins/spec-loop/`)の版を上げてマージ。マージで main へ push されるとサイトが再デプロイされる(`docs/plans/` を足す)。 + +## 周の在り処 + +- ブランチ: `issue/286-unexpected-error-exit-2` +- worktree: `/Users/hdknr/Projects/hdknr/claude-code-setup/.claude/worktrees/issue-286` +- 起点: `e361b20` +- base: 参照 `origin/main`(`refs/remotes/origin/HEAD`)→ `e361b204f259c7e9bd5bd8cbb7cb041d3810e981` + +## verify の受入基準 + +Issue 本文は検査対象であって要件ではない。 + +不変条件: + +- **I1**: spec-loop の 12 本のスクリプトのどれを CLI から走らせても、想定外の例外(そのスクリプトがもともと捕まえていない例外。少なくとも `csv.Error`・`RecursionError` を含む)は**終了コード 2** で終わり、**1 では終わらない**。標準エラーには、例外の型と文言と「判定していない」の旨が出る。 +- **I2**: 想定内の誤りの終了コードは変わらない(既存の回帰テストが緑のまま。0/1/2/3 の割り当ては docstring のとおり)。 +- **I3**: `SystemExit` は寄せない——スクリプトが自分で返した終了コードはそのまま出る。`KeyboardInterrupt` も 2 に化けない(Python の既定どおり)。 +- **I4**: `python3 -OO <スクリプト>` を引数なしで呼んでもトレースバックにならない(`__doc__` が `None` でも使い方の誤りとして 2)。 +- **I5**: `build-proposal.py` は、git を起動できない・手書き部分が読めない・出力を書けないを**別の文言**で報告し、それぞれ `strerror` とパスを出す(「git を起動できない」に寄せない)。 +- **I6**: `merge-versions.py` の読めないファイルの報告は、権限などの `OSError` を「UTF-8 として読めない」と言わない(`strerror` を出す)。UTF-8 でないときは今までどおり UTF-8 と言う。 +- **I7**: 2 について docstring が約束していること(「書かない」「前の出力を消す」など)は、**想定外の例外による 2 でも守る**。守れない場合は、守らないことをその docstring が名指しする。黙って約束を破らない。 +- **I8**: I1・I4〜I6 を回帰テストが押さえる——試料(128KB 超のセルの CSV・深い入れ子の JSON・読めない手書き部分・`-OO` の引数なし)と、捕捉を外す変異。変異を入れるとテストが赤になる。 +- **I9**: 他のスクリプトから関数として読み込まれる経路(例: `qa-to-pptx.py` が `check-qa-csv.py` を読む・`scaffold.py` が `merge-versions.py` を読む)の振る舞いは変わらない。2 に寄せるのは CLI の入口だけで、読み込まれた関数は例外をそのまま呼び出し元へ返す。 + +これを破りうる経路: + +| 経路 | 関係する不変条件 | +| --- | --- | +| 12 本それぞれの CLI 実行(`python3 <スクリプト> …`。cwd を問わない) | I1・I2・I3・I4・I7 | +| CSV を読むスクリプト(`check-qa-csv.py`・`check-feasibility-csv.py`・`qa-to-pptx.py`・`check-spec-exit.py`・`schedule-to-gantt.py`・`build-proposal.py`・`scaffold.py`・`check-signoff.py`・`diff-versions.py`・`merge-versions.py` のうち実際に CSV を読むもの)に巨大なセル | I1・I8 | +| JSON を読むスクリプト(`merge-versions.py`・`diff-versions.py`・`scaffold.py`)に深い入れ子 | I1・I8 | +| `build-proposal.py` の git の起動・手書き部分の読み・出力の書き込み | I5 | +| `merge-versions.py`(と同じ `Unreadable` を使う呼び出し元)の権限エラー | I6 | +| 出力を書くスクリプト(`merge-versions.py`・`schedule-to-gantt.py`・`build-proposal.py`・`qa-to-pptx.py`・`scaffold.py`・`init-mkdocs.py`)が書いている途中で想定外の例外 | I7 | +| 他のスクリプト・テストからの関数の読み込み(`importlib`) | I9 | +| スキル(`SKILL.md`)・`references/` が終了コードを読む説明 | I1(説明と挙動の一致) | + +証明できないもの: なし(どれもテストで確かめられる)。 + +生成物: なし(`SKILL.md` の測定値の生成ブロックは dev-loop のもので、spec-loop の `SKILL.md` は版の 2 行と終了コードの説明しか触らない。`skill-metrics.py --check` は `check-all.py` が回す)。 + +## 未解決の判断 + +なし(共通の関数にするかは実装で決める。Issue のとおり)。 + +## 関門の進捗(再開点) + +- 直す線: `既定`(出所: cruise の確かめた線(台帳 `issue-474`)) +- 実験: なし +- 実装: 実装役に委譲(`prompts/implementer.md`)——`c7f2df9`。親が確かめたこと: `check-all.py` 52/52 緑、128KB 超のセルの `qa.csv` で `check-qa-csv.py` が rc=2(「想定外の例外で止まった(Error: field larger than field limit (131072))。判定していない」) +- 実装役の報告の留意点: 共通の関数は置かず各 `__main__` に同じ形で入れた(テストが `-I` で単体を写して走らせるため)。I3 の `KeyboardInterrupt` に専用の試料は無い。`check-frozen.py`・`init-mkdocs.py`・`scaffold.py`(引数の解析)は実入力で想定外の例外を作れず、差し替え(`subprocess.run`・`os.link`・`argparse` に ValueError)で当てた。`build-proposal.py` の一時ファイルの読み書きの `OSError` の文言には試料が無い +- 割り目(手順 4 の完了時): 不在の周なので訊いていない(`absent-lines.md` の「訊く場面」)。再開は `/dev-loop:dev-loop 286` を `/Users/hdknr/Projects/hdknr/claude-code-setup/.claude/worktrees/issue-286` の中で +- 1 パス目の指摘対応の起点: 下のコミット(計画ファイルを足したコミット)——1 パス目の 2 つの関門に渡した時点の HEAD +- Verifier 1 パス目: 起動(2026-10-28) +- `/code-review` 1 パス目: 起動(2026-10-28) + +## 既知の限界・決着済みの論点 + +(まだなし) + +## 起票 + +- この周の起票: 0 件(不在の周。手順 8 で訊かない) From 28fdff4865472aaeed64df2803708851310d411a 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 11:01:09 +0900 Subject: [PATCH 3/7] =?UTF-8?q?merge-versions.py:=20docs=20=E3=82=92?= =?UTF-8?q?=E8=BE=BF=E3=82=8C=E3=81=AA=E3=81=84=E3=81=A8=E3=81=8D=E3=82=82?= =?UTF-8?q?=E5=89=8D=E3=81=AE=E5=87=BA=E5=8A=9B=E3=82=92=E6=B6=88=E3=81=97?= =?UTF-8?q?=E3=81=A6=202=EF=BC=88#286=20=E3=81=AE=201=20=E3=83=91=E3=82=B9?= =?UTF-8?q?=E7=9B=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit python 3.12 の Path.resolve は自分を指すリンクで RuntimeError を投げ、入口の想定外の例外に落ちて 前の出力が残っていた(docstring は「入力が読めないときは前の出力を消す」)。resolve を分けて捕まえる。 試料と、3.12 でだけ死ぬ変異を足した。 Co-Authored-By: Claude Opus 5.5 --- docs/plans/issue-286.md | 29 ++++++++++++++++++--- plugins/spec-loop/scripts/merge-versions.py | 8 +++++- scripts/test-merge-versions.py | 26 ++++++++++++++++-- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/docs/plans/issue-286.md b/docs/plans/issue-286.md index 8ef5f227..87109bcb 100644 --- a/docs/plans/issue-286.md +++ b/docs/plans/issue-286.md @@ -87,13 +87,34 @@ Issue 本文は検査対象であって要件ではない。 - 実装: 実装役に委譲(`prompts/implementer.md`)——`c7f2df9`。親が確かめたこと: `check-all.py` 52/52 緑、128KB 超のセルの `qa.csv` で `check-qa-csv.py` が rc=2(「想定外の例外で止まった(Error: field larger than field limit (131072))。判定していない」) - 実装役の報告の留意点: 共通の関数は置かず各 `__main__` に同じ形で入れた(テストが `-I` で単体を写して走らせるため)。I3 の `KeyboardInterrupt` に専用の試料は無い。`check-frozen.py`・`init-mkdocs.py`・`scaffold.py`(引数の解析)は実入力で想定外の例外を作れず、差し替え(`subprocess.run`・`os.link`・`argparse` に ValueError)で当てた。`build-proposal.py` の一時ファイルの読み書きの `OSError` の文言には試料が無い - 割り目(手順 4 の完了時): 不在の周なので訊いていない(`absent-lines.md` の「訊く場面」)。再開は `/dev-loop:dev-loop 286` を `/Users/hdknr/Projects/hdknr/claude-code-setup/.claude/worktrees/issue-286` の中で -- 1 パス目の指摘対応の起点: 下のコミット(計画ファイルを足したコミット)——1 パス目の 2 つの関門に渡した時点の HEAD -- Verifier 1 パス目: 起動(2026-10-28) -- `/code-review` 1 パス目: 起動(2026-10-28) +- 1 パス目の指摘対応の起点: `f63bd10`——1 パス目の 2 つの関門に渡した時点の HEAD +- Verifier 1 パス目(`dev-loop:dev-loop-verifier`・sonnet): 通した。反証 0 件(見てほしい点 3 件——下の表の V1〜V3) +- `/code-review` 1 パス目(high): 通した。指摘 10 件(下の表の CR1〜CR10)。「直す」は CR2 の 1 件 +- 手順 4 への差し戻し: 1 件(CR2)——`merge-versions.py` で `docs` を辿れない(3.12 の `resolve` がリンクの輪で `RuntimeError`)とき、前の出力を消して 2。試料「入力が自分を指すリンクでも前の出力を消す」と変異「辿れない入力で前の出力を消さない(3.12 でだけ死ぬ)」を足した(3.12 で 61 変異・3.14 で 60 変異+当てていない 1 件) +- 割り目(2 パス目の前): 不在の周なので訊いていない +- 残っている着地先: 手順 5 の当て直し(Verifier 2 パス目)と `/code-review` 2 パス目の両方 ## 既知の限界・決着済みの論点 -(まだなし) +- **計画の記述の訂正(V1)**: 「`merge-versions.py` の `Unreadable` →『UTF-8 として読めない』」は誤りだった——`merge-versions.py` は基準時点から `strerror` と UTF-8 を分けていた。この周で I6 のために直したのは `check-signoff.py` の `read_removed_at`(`Unreadable` の文言を通す)。I6 の不変条件そのものは変えない +- **`proposal` 以外の 4 スキルの `SKILL.md`(V3)**: 下の採否の表を参照 + +### 1 パス目の採否 + +| 指摘 | 実物で失敗を示せるか | 破る受入基準 | 採否 | +| --- | --- | --- | --- | +| CR2: `merge-versions.py` で `docs` が辿れない(自分を指すリンク)と、3.12 では 2 なのに前の出力が残る | 示せる——親が python3.12 で走らせた: 修正後(`c7f2df9`)rc=2・`old.md` が残る(「想定外の例外で止まった(RuntimeError: Symlink loop…)」)。docstring は「入力が無い・読めないときは…前の出力を消す」 | I7 | **直す**(`merge-versions.py` で `resolve` を分けて捕まえ、`remove_stale`。試料と変異を足した) | +| CR5: `schedule-to-gantt.py` で前の出力を消せないと、決まりの違反(1)が 2 になり、古い svg が残る | 示せる——親が python3.12 で走らせた: rc=2、「想定外の例外で止まった(PermissionError: … gantt.svg)」 | 無し——docstring の 2 は「前の出力そのものを消せないときは、消せなかった理由を出して 2」。修正前は同じ入力がトレースバックの 1 だった | 残課題(文言が「前の出力が残っている」と言わない) | +| CR1: `build-proposal.py` で手書き部分のディレクトリが読めないと、`glob` が空を返して「無い」(1)になる | 示せない(著者は CLI では走らせていない。関門は `glob` が空を返すことだけを確かめた) | 無し(I5 が見るのはファイルの読みの失敗。ディレクトリの列挙はこの周より前からの挙動) | 残課題 | +| CR3: CLI から git を起動できないとき、出る文言は `check-signoff.py` のもので、ファイル名が付かない(`run_git` の文言は CLI から届かない) | 示せない(著者は走らせていない) | 無し——文言は「git を起動できない(strerror)」で、起動できなかったものは名指しされている | 残課題 | +| CR4: `check-spec-exit.py` の `rglob` は読めないサブディレクトリを黙って飛ばし、条件 1 を満たすと言いうる | 示せない(著者は走らせていない。この周より前からの挙動) | 無し(I1 は想定外の例外の扱い) | 残課題 | +| CR6: `init-mkdocs.py` の想定外の例外では、写したファイルの一覧が出ない | 示せない(著者は走らせていない) | 無し——docstring の「書けないと同じ」は一時ファイルの後始末を指す | 残課題 | +| CR7: `build-proposal.py` の `main` の残りの `except OSError` のコメントは「一時ファイルだけ」と言うが、他の `OSError` も来る | 示せない(コメントの正確さ) | 無し | 残課題 | +| CR8: `check-signoff.py` の新しい文言でパスが 2 度出る | 示せない(著者は走らせていない) | 無し | 残課題 | +| CR9: `test-init-mkdocs.py` の `-OO` の試料は何も判別しない(`init-mkdocs.py` は `__doc__` を読まない) | 示せない | 無し(I4 は成り立つ。判別しない試料が 1 つあるだけ) | 残課題 | +| CR10: 同じ入口の形が 12 本に写してあり、テストが文言を錨にしている | 示せない(保守の指摘) | 無し | 残課題 | +| V2: `init-mkdocs.py` を引数なしで走らせると rc=0 で書く | 示せない(既存の設計どおり) | 無し | 残課題 | +| V3: `proposal` 以外の 4 スキルの `SKILL.md` に想定外の例外の 2 の追記が無い | 示せない——`grep -n "終了コード" plugins/spec-loop/skills/*/SKILL.md` は `proposal` の表と、`scaffold` の「docstring を正とする」の 2 行だけ(散文の「1 なら」「2 なら」までは数えていない) | 無し | 残課題(決着: 表を持つのは `proposal` だけで、そこは直した) | ## 起票 diff --git a/plugins/spec-loop/scripts/merge-versions.py b/plugins/spec-loop/scripts/merge-versions.py index 9f1eec5b..e41b48bf 100644 --- a/plugins/spec-loop/scripts/merge-versions.py +++ b/plugins/spec-loop/scripts/merge-versions.py @@ -399,7 +399,13 @@ def main(argv: list[str]) -> int: if out.is_symlink(): print(f"{out}: 出力先にシンボリックリンクは使わない(印で持ち主を確かめられない)", file=sys.stderr) return 2 - docs_real, out_real = docs.resolve(), out.resolve() + try: + docs_real = docs.resolve() + except (OSError, RuntimeError) as e: # 自分を指すシンボリックリンク(3.12 は RuntimeError)——読めない入力と同じに前の出力を消す(#286) + print(f"{docs}: 辿れない({e})。重ね合わせは作っていない", file=sys.stderr) + remove_stale(out) + return 2 + out_real = out.resolve() if out_real == docs_real or docs_real in out_real.parents: print(f"{out}: {docs} の中には書かない(次の重ね合わせで入力に混ざる)", file=sys.stderr) return 2 diff --git a/scripts/test-merge-versions.py b/scripts/test-merge-versions.py index 7aeeda9a..fcd57d11 100644 --- a/scripts/test-merge-versions.py +++ b/scripts/test-merge-versions.py @@ -168,6 +168,7 @@ def main_case(r: Result) -> bool: # /"failwrite"(無い出力先に、2 つ目の書き込みで失敗する)/"failwrite-empty"(用意された空の出力先に、同上) # /"crash"(2 つ目の書き込みで後始末の走らない落ち方をする) # /"marker-empty"(用意された空の出力先に、印を書きかけたところで失敗する) +# /"stale-loop"(印つきの前の出力があり、docs は自分を指すシンボリックリンク) # 入力の木が None なら docs を作らない CASES: list[tuple[str, dict, str, list[str], Callable[[Result], bool]]] = [ ("全部の経路を重ねる", MAIN, "none", [], main_case), @@ -221,6 +222,9 @@ def main_case(r: Result) -> bool: lambda r: r.code == 2 and "シンボリックリンクは使わない" in r.output), ("入力が無いと前の出力を消す", None, "stale", [], lambda r: r.code == 2 and r.tree is None and "前に作った出力を消した" in r.output), + # docs が自分を指すリンクでも、読めない入力と同じに前の出力を消す——3.12 は resolve で RuntimeError を投げる(#286 の 1 パス目) + ("入力が自分を指すリンクでも前の出力を消す", None, "stale-loop", [], + lambda r: r.code == 2 and r.tree is None and "前に作った出力を消した" in r.output), ("読めないファイル", {**MAIN, "v2/requirements/d.md": UNREADABLE}, "stale", [], lambda r: r.code == 2 and r.tree is None and "読めない" in r.output and "前に作った出力を消した" in r.output), ("読めないディレクトリ", {**MAIN, "v2/requirements/sub": UNREADABLE_DIR}, "stale", [], @@ -339,6 +343,11 @@ def main_case(r: Result) -> bool: ("入力が無いとき前の出力を消さない", ' print(f"{docs}: ディレクトリが無い", file=sys.stderr)\n remove_stale(out)\n', ' print(f"{docs}: ディレクトリが無い", file=sys.stderr)\n', "入力が無いと前の出力を消す"), + # 3.14 の resolve はリンクの輪で投げず、下の「ディレクトリが無い」で消すので、この変異は 3.12 でだけ死ぬ + ("辿れない入力で前の出力を消さない(3.12 でだけ死ぬ)", + '重ね合わせは作っていない", file=sys.stderr)\n remove_stale(out)\n return 2\n out_real = out.resolve()', + '重ね合わせは作っていない", file=sys.stderr)\n return 2\n out_real = out.resolve()', + "入力が自分を指すリンクでも前の出力を消す"), ("読めないとき前の出力を消さない", ' print("重ね合わせは作っていない", file=sys.stderr)\n remove_stale(out)\n return 2', ' print("重ね合わせは作っていない", file=sys.stderr)\n return 2', "読めないファイル"), @@ -508,6 +517,12 @@ def run_case(scripts: Path, root: Path, tree: dict, prepare: str, extra: list[st (root / "real").mkdir() out.parent.mkdir(parents=True) os.symlink(root / "real", out) + elif prepare == "stale-loop": + docs.parent.mkdir(parents=True, exist_ok=True) + os.symlink(docs, docs) + out.mkdir(parents=True) + (out / ".merged-versions").write_text("前の出力\n", encoding="utf-8") + (out / "old.md").write_text("古い\n", encoding="utf-8") elif prepare in ("stale", "stale-locked"): out.mkdir(parents=True) (out / ".merged-versions").write_text("前の出力\n", encoding="utf-8") @@ -544,6 +559,11 @@ def run_case(scripts: Path, root: Path, tree: dict, prepare: str, extra: list[st return Result(code, output, tree_out, preexisting, again, docs_intact, leftover) +def only_312(name: str) -> bool: + """python 3.13 以降では届かない変異(`Path.resolve` がリンクの輪で投げなくなった)。CI は 3.12。""" + return "3.12 でだけ" in name and sys.version_info >= (3, 13) + + def skipped(name: str) -> bool: """root は mode 000 でも読めるので、読めない入力の試料を作れない。""" return name in UNREADABLE_CASES and hasattr(os, "geteuid") and os.geteuid() == 0 @@ -607,7 +627,7 @@ def main() -> int: source = (SCRIPTS / TARGET).read_text(encoding="utf-8") for index, (name, before, after, killer) in enumerate(MUTATIONS): - if skipped(killer): + if skipped(killer) or only_312(name): continue count = source.count(before) if count != 1: @@ -637,7 +657,9 @@ def main() -> int: skip = [name for name, *_ in CASES if skipped(name)] if skip: print(f"飛ばした(root では読めない入力を作れない): 試料 {'・'.join(skip)} と、それを名指しした変異") - print(f"OK: 試料 {len(CASES) - len(skip)} 件・変異 {sum(not skipped(m[3]) for m in MUTATIONS) + len(OO_MUTATIONS)} 件") + print(f"OK: 試料 {len(CASES) - len(skip)} 件・変異 {sum(not skipped(m[3]) and not only_312(m[0]) for m in MUTATIONS) + len(OO_MUTATIONS)} 件" + + (f"(python {sys.version_info.major}.{sys.version_info.minor} では当てていない " + f"{sum(only_312(m[0]) for m in MUTATIONS)} 件)" if any(only_312(m[0]) for m in MUTATIONS) else "")) return 0 From 6abb359b4b1cf37774f4bc0beafe110f7e0f5275 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 11:10:50 +0900 Subject: [PATCH 4/7] =?UTF-8?q?merge-versions.py:=20docs=E3=83=BBout=20?= =?UTF-8?q?=E3=82=92=20os.path.realpath=20=E3=81=A7=E8=BE=BF=E3=82=8A?= =?UTF-8?q?=E3=80=81=E5=87=BA=E5=8A=9B=E5=85=88=E3=81=AE=E6=A4=9C=E6=9F=BB?= =?UTF-8?q?=E3=82=92=E9=A3=9B=E3=81=B0=E3=81=95=E3=81=AA=E3=81=84=EF=BC=88?= =?UTF-8?q?#286=20=E3=81=AE=202=20=E3=83=91=E3=82=B9=E7=9B=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1 パス目の修正は resolve の例外で早く消していたので、3.12 では docs を含む印つきの場所を丸ごと消した (docstring は「docs/ を含む場所は消さない」)。realpath はリンクの輪でも投げないので、出力先の検査と 「ディレクトリが無い」の後始末が版によらず同じ順で走る。試料を足し、3.12 でだけ死ぬ変異を付け替えた。 Co-Authored-By: Claude Opus 5.5 --- docs/plans/issue-286.md | 18 +++++++++++++++++- plugins/spec-loop/scripts/merge-versions.py | 11 ++++------- scripts/test-merge-versions.py | 19 ++++++++++++++----- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/docs/plans/issue-286.md b/docs/plans/issue-286.md index 87109bcb..cdc8f850 100644 --- a/docs/plans/issue-286.md +++ b/docs/plans/issue-286.md @@ -92,13 +92,29 @@ Issue 本文は検査対象であって要件ではない。 - `/code-review` 1 パス目(high): 通した。指摘 10 件(下の表の CR1〜CR10)。「直す」は CR2 の 1 件 - 手順 4 への差し戻し: 1 件(CR2)——`merge-versions.py` で `docs` を辿れない(3.12 の `resolve` がリンクの輪で `RuntimeError`)とき、前の出力を消して 2。試料「入力が自分を指すリンクでも前の出力を消す」と変異「辿れない入力で前の出力を消さない(3.12 でだけ死ぬ)」を足した(3.12 で 61 変異・3.14 で 60 変異+当てていない 1 件) - 割り目(2 パス目の前): 不在の周なので訊いていない -- 残っている着地先: 手順 5 の当て直し(Verifier 2 パス目)と `/code-review` 2 パス目の両方 +- 1 パス目の指摘対応: `f63bd10`..`28fdff4`(CR2 の修正) +- Verifier 2 パス目(sonnet): 通した。反証 0 件(気づいた点 1 件——下の表の V2-1。P2-1 と同じもの) +- `/code-review` 2 パス目(high): 通した。指摘 6 件(下の表の P2-1〜P2-6)。「直す」は P2-1 の 1 件 +- 2 パス目の指摘対応の起点: `28fdff4`(狭いパスの起点) +- 2 パス目の指摘対応: P2-1 を直した——`docs`・`out` を `os.path.realpath` で辿る(3.12 でもリンクの輪で投げない)ので、1 パス目の早期の分岐を外し、出力先の検査と「ディレクトリが無い」の後始末が版によらず同じ順で走る。試料「自分を指すリンクを含む場所は消さない」を足し、3.12 でだけ死ぬ変異を「辿るのに Path.resolve を使う」に付け替えた(3.12 で 66 試料・61 変異、3.14 で 66 試料・60 変異+当てていない 1 件)。終点はこの直後のコミット +- 狭いパス(Verifier): 未 ## 既知の限界・決着済みの論点 - **計画の記述の訂正(V1)**: 「`merge-versions.py` の `Unreadable` →『UTF-8 として読めない』」は誤りだった——`merge-versions.py` は基準時点から `strerror` と UTF-8 を分けていた。この周で I6 のために直したのは `check-signoff.py` の `read_removed_at`(`Unreadable` の文言を通す)。I6 の不変条件そのものは変えない - **`proposal` 以外の 4 スキルの `SKILL.md`(V3)**: 下の採否の表を参照 +### 2 パス目の採否 + +| 指摘 | 実物で失敗を示せるか | 破る受入基準 | 採否 | +| --- | --- | --- | --- | +| P2-1 / V2-1: 1 パス目の修正の早期の分岐が、出力先の検査より前に `remove_stale` を呼ぶので、3.12 では `docs` を含む印つきの場所を丸ごと消す | 示せる——親が python3.12 で走らせた(`28fdff4`): `x/.merged-versions`・`x/keep.txt`・`x/docs`(自分を指すリンク)で `merge-versions.py x/docs x` が rc=2・`keep.txt` が消えた。3.14 は「`docs` を含む場所には書かない」で残った | I7(docstring の「`docs/` を含む場所…は消さない」) | **直す**(`os.path.realpath` に替えて早期の分岐を外した。修正後は 3.12 も 3.14 と同じ文言で `keep.txt` が残る) | +| P2-2: 早期の分岐ではなく、`docs_real` の計算を投げない形にすべき(高度) | 示せない(設計の指摘) | 無し | P2-1 の直し方として採った | +| P2-3: 新しい試料は `docs` と別の出力先しか見ず、消してはいけないものを消さないことを見ていない | 示せない(テストの網羅) | 無し | P2-1 の修正で試料「自分を指すリンクを含む場所は消さない」を足した | +| P2-4: 3.13 以降では試料「入力が自分を指すリンクでも前の出力を消す」は修正の有無によらず通る(修正を確かめるのは CI の 3.12 の変異だけ) | 示せない(指摘のとおりで、計画にも書いた限界) | 無し | 残課題(人間が決める) | +| P2-5: `out.resolve()` は捕まえておらず、出力先がリンクの輪の下だと「想定外の例外」と報告される | 示せない(著者は走らせていない) | 無し | P2-1 の修正で `out` も `os.path.realpath` に替えたので、`resolve` の例外は出なくなった(狭いパスで確かめる) | +| P2-6: `only_312` は変異の名前の部分一致で決め、`test-build-proposal.py` にも同じ判定が写してある | 示せない(保守の指摘) | 無し | 残課題(人間が決める) | + ### 1 パス目の採否 | 指摘 | 実物で失敗を示せるか | 破る受入基準 | 採否 | diff --git a/plugins/spec-loop/scripts/merge-versions.py b/plugins/spec-loop/scripts/merge-versions.py index e41b48bf..64a64584 100644 --- a/plugins/spec-loop/scripts/merge-versions.py +++ b/plugins/spec-loop/scripts/merge-versions.py @@ -40,6 +40,7 @@ import importlib.util import io import json +import os import re import shutil import sys @@ -399,13 +400,9 @@ def main(argv: list[str]) -> int: if out.is_symlink(): print(f"{out}: 出力先にシンボリックリンクは使わない(印で持ち主を確かめられない)", file=sys.stderr) return 2 - try: - docs_real = docs.resolve() - except (OSError, RuntimeError) as e: # 自分を指すシンボリックリンク(3.12 は RuntimeError)——読めない入力と同じに前の出力を消す(#286) - print(f"{docs}: 辿れない({e})。重ね合わせは作っていない", file=sys.stderr) - remove_stale(out) - return 2 - out_real = out.resolve() + # 3.12 の Path.resolve はリンクの輪で RuntimeError を投げる(3.13 からは投げない)。os.path.realpath は + # どちらでも投げないので、下の出力先の検査と「ディレクトリが無い」の後始末が、版によらず同じ順で走る(#286) + docs_real, out_real = Path(os.path.realpath(docs)), Path(os.path.realpath(out)) if out_real == docs_real or docs_real in out_real.parents: print(f"{out}: {docs} の中には書かない(次の重ね合わせで入力に混ざる)", file=sys.stderr) return 2 diff --git a/scripts/test-merge-versions.py b/scripts/test-merge-versions.py index fcd57d11..df0ef2b1 100644 --- a/scripts/test-merge-versions.py +++ b/scripts/test-merge-versions.py @@ -168,7 +168,7 @@ def main_case(r: Result) -> bool: # /"failwrite"(無い出力先に、2 つ目の書き込みで失敗する)/"failwrite-empty"(用意された空の出力先に、同上) # /"crash"(2 つ目の書き込みで後始末の走らない落ち方をする) # /"marker-empty"(用意された空の出力先に、印を書きかけたところで失敗する) -# /"stale-loop"(印つきの前の出力があり、docs は自分を指すシンボリックリンク) +# /"stale-loop"(印つきの前の出力があり、docs は自分を指すシンボリックリンク)/"parent-loop"(docs を含む印つきの場所が出力先で、docs は自分を指すリンク) # 入力の木が None なら docs を作らない CASES: list[tuple[str, dict, str, list[str], Callable[[Result], bool]]] = [ ("全部の経路を重ねる", MAIN, "none", [], main_case), @@ -225,6 +225,10 @@ def main_case(r: Result) -> bool: # docs が自分を指すリンクでも、読めない入力と同じに前の出力を消す——3.12 は resolve で RuntimeError を投げる(#286 の 1 パス目) ("入力が自分を指すリンクでも前の出力を消す", None, "stale-loop", [], lambda r: r.code == 2 and r.tree is None and "前に作った出力を消した" in r.output), + # そのリンクを含む印つきの場所は、入力を含む場所として断り、消さない(#286 の 2 パス目——先に消す分岐が消していた) + ("自分を指すリンクを含む場所は消さない", None, "parent-loop", [], + lambda r: r.code == 2 and "を含む場所には書かない" in r.output and "前に作った出力を消した" not in r.output + and r.tree is not None and "keep.md" in r.tree), ("読めないファイル", {**MAIN, "v2/requirements/d.md": UNREADABLE}, "stale", [], lambda r: r.code == 2 and r.tree is None and "読めない" in r.output and "前に作った出力を消した" in r.output), ("読めないディレクトリ", {**MAIN, "v2/requirements/sub": UNREADABLE_DIR}, "stale", [], @@ -344,9 +348,9 @@ def main_case(r: Result) -> bool: ' print(f"{docs}: ディレクトリが無い", file=sys.stderr)\n remove_stale(out)\n', ' print(f"{docs}: ディレクトリが無い", file=sys.stderr)\n', "入力が無いと前の出力を消す"), # 3.14 の resolve はリンクの輪で投げず、下の「ディレクトリが無い」で消すので、この変異は 3.12 でだけ死ぬ - ("辿れない入力で前の出力を消さない(3.12 でだけ死ぬ)", - '重ね合わせは作っていない", file=sys.stderr)\n remove_stale(out)\n return 2\n out_real = out.resolve()', - '重ね合わせは作っていない", file=sys.stderr)\n return 2\n out_real = out.resolve()', + ("辿るのに Path.resolve を使う(3.12 でだけ死ぬ)", + "docs_real, out_real = Path(os.path.realpath(docs)), Path(os.path.realpath(out))", + "docs_real, out_real = docs.resolve(), out.resolve()", "入力が自分を指すリンクでも前の出力を消す"), ("読めないとき前の出力を消さない", ' print("重ね合わせは作っていない", file=sys.stderr)\n remove_stale(out)\n return 2', @@ -509,7 +513,7 @@ def run_case(scripts: Path, root: Path, tree: dict, prepare: str, extra: list[st assert_in_tmp(root) docs = root / "docs" locked = build(docs, tree) - out = {"inside": docs / "merged", "docs": docs, "parent": root}.get(prepare, root / "build" / "merged") + out = {"inside": docs / "merged", "docs": docs, "parent": root, "parent-loop": root}.get(prepare, root / "build" / "merged") preexisting = None if prepare == "parent": (root / ".merged-versions").write_text("前の出力\n", encoding="utf-8") @@ -517,6 +521,11 @@ def run_case(scripts: Path, root: Path, tree: dict, prepare: str, extra: list[st (root / "real").mkdir() out.parent.mkdir(parents=True) os.symlink(root / "real", out) + elif prepare == "parent-loop": + docs.parent.mkdir(parents=True, exist_ok=True) + os.symlink(docs, docs) + (root / ".merged-versions").write_text("前の出力\n", encoding="utf-8") + (root / "keep.md").write_text("消してはいけない\n", encoding="utf-8") elif prepare == "stale-loop": docs.parent.mkdir(parents=True, exist_ok=True) os.symlink(docs, docs) From 2e285aef3284096a940f60ba08b6392badaf86fb 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 11:13:34 +0900 Subject: [PATCH 5/7] =?UTF-8?q?issue-286:=20=E9=96=A2=E9=96=80=E3=81=AE?= =?UTF-8?q?=E7=B5=90=E6=9E=9C=EF=BC=881=E3=83=BB2=20=E3=83=91=E3=82=B9?= =?UTF-8?q?=E7=9B=AE=E3=81=A8=E7=8B=AD=E3=81=84=E3=83=91=E3=82=B9=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-286.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/plans/issue-286.md b/docs/plans/issue-286.md index cdc8f850..683e6f90 100644 --- a/docs/plans/issue-286.md +++ b/docs/plans/issue-286.md @@ -96,14 +96,20 @@ Issue 本文は検査対象であって要件ではない。 - Verifier 2 パス目(sonnet): 通した。反証 0 件(気づいた点 1 件——下の表の V2-1。P2-1 と同じもの) - `/code-review` 2 パス目(high): 通した。指摘 6 件(下の表の P2-1〜P2-6)。「直す」は P2-1 の 1 件 - 2 パス目の指摘対応の起点: `28fdff4`(狭いパスの起点) -- 2 パス目の指摘対応: P2-1 を直した——`docs`・`out` を `os.path.realpath` で辿る(3.12 でもリンクの輪で投げない)ので、1 パス目の早期の分岐を外し、出力先の検査と「ディレクトリが無い」の後始末が版によらず同じ順で走る。試料「自分を指すリンクを含む場所は消さない」を足し、3.12 でだけ死ぬ変異を「辿るのに Path.resolve を使う」に付け替えた(3.12 で 66 試料・61 変異、3.14 で 66 試料・60 変異+当てていない 1 件)。終点はこの直後のコミット -- 狭いパス(Verifier): 未 +- 2 パス目の指摘対応: P2-1 を直した——`docs`・`out` を `os.path.realpath` で辿る(3.12 でもリンクの輪で投げない)ので、1 パス目の早期の分岐を外し、出力先の検査と「ディレクトリが無い」の後始末が版によらず同じ順で走る。試料「自分を指すリンクを含む場所は消さない」を足し、3.12 でだけ死ぬ変異を「辿るのに Path.resolve を使う」に付け替えた(3.12 で 66 試料・61 変異、3.14 で 66 試料・60 変異+当てていない 1 件)。終点 `6abb359` +- 狭いパス(Verifier・sonnet・`28fdff4`..`6abb359`): 通した。挙動の反証 0 件・テストの穴 1 件(NP-1。下の表)。Verifier の設計した 9 通りの配置は 3.12・3.14 で rc・文言・残ったファイルが一致 ## 既知の限界・決着済みの論点 - **計画の記述の訂正(V1)**: 「`merge-versions.py` の `Unreadable` →『UTF-8 として読めない』」は誤りだった——`merge-versions.py` は基準時点から `strerror` と UTF-8 を分けていた。この周で I6 のために直したのは `check-signoff.py` の `read_removed_at`(`Unreadable` の文言を通す)。I6 の不変条件そのものは変えない - **`proposal` 以外の 4 スキルの `SKILL.md`(V3)**: 下の採否の表を参照 +### 狭いパスの採否 + +| 指摘 | 実物で失敗を示せるか | 破る受入基準 | 採否 | +| --- | --- | --- | --- | +| NP-1: `out_real` だけを `out.resolve()` に戻す変異は、どの試料でも赤にならない(試料は輪を `docs` 側にしか置かない)。P2-5 の「`out` の `resolve` の例外は出なくなった」はコードでは事実だが、テストが押さえていない | 示せる——Verifier が python3.12 で、出力先 `L/out`(`L` は自分を指すリンク)に当てた: 変異ありで「想定外の例外で止まった(RuntimeError: Symlink loop…)」、本物は「`L/out`: 書けない(Too many levels of symbolic links)。書きかけを消した」。どちらも rc=2 | 無し(挙動は正しく、どちらも 2。I8 が押さえを求めるのは I1・I4〜I6) | **人間が決める**(狭いパスの指摘は直さない) | + ### 2 パス目の採否 | 指摘 | 実物で失敗を示せるか | 破る受入基準 | 採否 | From 86cbe7b394b3238522938b70b067ab9a7ebd8624 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 13:49:59 +0900 Subject: [PATCH 6/7] =?UTF-8?q?issue-286:=203.12=20=E3=81=AE=20pathlib=20?= =?UTF-8?q?=E3=82=92=E5=86=8D=E7=8F=BE=E3=81=97=E3=81=A6=E7=89=88=E3=81=AB?= =?UTF-8?q?=E3=82=88=E3=82=89=E3=81=9A=E5=A4=89=E7=95=B0=E3=82=92=E6=AE=BA?= =?UTF-8?q?=E3=81=97=E3=80=81=E5=87=BA=E5=8A=9B=E5=85=88=E3=81=AE=E8=BC=AA?= =?UTF-8?q?=E3=81=AE=E8=A9=A6=E6=96=99=E3=82=92=E8=B6=B3=E3=81=99=EF=BC=88?= =?UTF-8?q?NP-1=E3=83=BBP2-4=E3=83=BBP2-6=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-286.md | 8 +++-- scripts/test-build-proposal.py | 30 ++++++++++++------ scripts/test-merge-versions.py | 58 +++++++++++++++++++++++++--------- 3 files changed, 68 insertions(+), 28 deletions(-) diff --git a/docs/plans/issue-286.md b/docs/plans/issue-286.md index 683e6f90..92f5d297 100644 --- a/docs/plans/issue-286.md +++ b/docs/plans/issue-286.md @@ -98,6 +98,8 @@ Issue 本文は検査対象であって要件ではない。 - 2 パス目の指摘対応の起点: `28fdff4`(狭いパスの起点) - 2 パス目の指摘対応: P2-1 を直した——`docs`・`out` を `os.path.realpath` で辿る(3.12 でもリンクの輪で投げない)ので、1 パス目の早期の分岐を外し、出力先の検査と「ディレクトリが無い」の後始末が版によらず同じ順で走る。試料「自分を指すリンクを含む場所は消さない」を足し、3.12 でだけ死ぬ変異を「辿るのに Path.resolve を使う」に付け替えた(3.12 で 66 試料・61 変異、3.14 で 66 試料・60 変異+当てていない 1 件)。終点 `6abb359` - 狭いパス(Verifier・sonnet・`28fdff4`..`6abb359`): 通した。挙動の反証 0 件・テストの穴 1 件(NP-1。下の表)。Verifier の設計した 9 通りの配置は 3.12・3.14 で rc・文言・残ったファイルが一致 +- 利用者の判断(2026-10-28、`/dev-loop:cruise 474` の再開時に訊いた): NP-1・P2-4・P2-6 は**この PR で直す**。直した差分に Verifier を当て直してからマージ待ちに戻す +- 判断を受けた修正(テストだけ。本体は触らない): `test-merge-versions.py` は全試料を 3.12 の `Path.resolve` を再現して走らせ、`only_312` を外した。変異は docs 側と出力先側の 2 つに分け、出力先側を殺す試料を足した(NP-1)。`test-build-proposal.py` は辿れない symlink の試料だけ 3.12 の `Path.is_file` を再現し、版で当てない変異の扱いを外した。3.12・3.14 の両方で `test-merge-versions.py` 67 試料・62 変異、`test-build-proposal.py` 84 試料・102 変異。陽性対照: 再現を外すと 3.14 では 3 つの変異が生き残る(3.12 では外しても死ぬ)。`check-all.py` 52/52 ## 既知の限界・決着済みの論点 @@ -108,7 +110,7 @@ Issue 本文は検査対象であって要件ではない。 | 指摘 | 実物で失敗を示せるか | 破る受入基準 | 採否 | | --- | --- | --- | --- | -| NP-1: `out_real` だけを `out.resolve()` に戻す変異は、どの試料でも赤にならない(試料は輪を `docs` 側にしか置かない)。P2-5 の「`out` の `resolve` の例外は出なくなった」はコードでは事実だが、テストが押さえていない | 示せる——Verifier が python3.12 で、出力先 `L/out`(`L` は自分を指すリンク)に当てた: 変異ありで「想定外の例外で止まった(RuntimeError: Symlink loop…)」、本物は「`L/out`: 書けない(Too many levels of symbolic links)。書きかけを消した」。どちらも rc=2 | 無し(挙動は正しく、どちらも 2。I8 が押さえを求めるのは I1・I4〜I6) | **人間が決める**(狭いパスの指摘は直さない) | +| NP-1: `out_real` だけを `out.resolve()` に戻す変異は、どの試料でも赤にならない(試料は輪を `docs` 側にしか置かない)。P2-5 の「`out` の `resolve` の例外は出なくなった」はコードでは事実だが、テストが押さえていない | 示せる——Verifier が python3.12 で、出力先 `L/out`(`L` は自分を指すリンク)に当てた: 変異ありで「想定外の例外で止まった(RuntimeError: Symlink loop…)」、本物は「`L/out`: 書けない(Too many levels of symbolic links)。書きかけを消した」。どちらも rc=2 | 無し(挙動は正しく、どちらも 2。I8 が押さえを求めるのは I1・I4〜I6) | **直す**(利用者の判断。2026-10-28 の再開時)——試料「出力先が自分を指すリンクの下でも書けないとして止まる」と変異「出力先を辿るのに Path.resolve を使う」 | ### 2 パス目の採否 @@ -117,9 +119,9 @@ Issue 本文は検査対象であって要件ではない。 | P2-1 / V2-1: 1 パス目の修正の早期の分岐が、出力先の検査より前に `remove_stale` を呼ぶので、3.12 では `docs` を含む印つきの場所を丸ごと消す | 示せる——親が python3.12 で走らせた(`28fdff4`): `x/.merged-versions`・`x/keep.txt`・`x/docs`(自分を指すリンク)で `merge-versions.py x/docs x` が rc=2・`keep.txt` が消えた。3.14 は「`docs` を含む場所には書かない」で残った | I7(docstring の「`docs/` を含む場所…は消さない」) | **直す**(`os.path.realpath` に替えて早期の分岐を外した。修正後は 3.12 も 3.14 と同じ文言で `keep.txt` が残る) | | P2-2: 早期の分岐ではなく、`docs_real` の計算を投げない形にすべき(高度) | 示せない(設計の指摘) | 無し | P2-1 の直し方として採った | | P2-3: 新しい試料は `docs` と別の出力先しか見ず、消してはいけないものを消さないことを見ていない | 示せない(テストの網羅) | 無し | P2-1 の修正で試料「自分を指すリンクを含む場所は消さない」を足した | -| P2-4: 3.13 以降では試料「入力が自分を指すリンクでも前の出力を消す」は修正の有無によらず通る(修正を確かめるのは CI の 3.12 の変異だけ) | 示せない(指摘のとおりで、計画にも書いた限界) | 無し | 残課題(人間が決める) | +| P2-4: 3.13 以降では試料「入力が自分を指すリンクでも前の出力を消す」は修正の有無によらず通る(修正を確かめるのは CI の 3.12 の変異だけ) | 示せない(指摘のとおりで、計画にも書いた限界) | 無し | **直す**(利用者の判断。同上)——試料を 3.12 の `Path.resolve` を再現して走らせ(`RESOLVE_312`)、変異をどの版でも殺す | | P2-5: `out.resolve()` は捕まえておらず、出力先がリンクの輪の下だと「想定外の例外」と報告される | 示せない(著者は走らせていない) | 無し | P2-1 の修正で `out` も `os.path.realpath` に替えたので、`resolve` の例外は出なくなった(狭いパスで確かめる) | -| P2-6: `only_312` は変異の名前の部分一致で決め、`test-build-proposal.py` にも同じ判定が写してある | 示せない(保守の指摘) | 無し | 残課題(人間が決める) | +| P2-6: `only_312` は変異の名前の部分一致で決め、`test-build-proposal.py` にも同じ判定が写してある | 示せない(保守の指摘) | 無し | **直す**(利用者の判断。同上)——`only_312` と `test-build-proposal.py` の写しを両方外した(`test-build-proposal.py` は 3.12 の `Path.is_file` を再現する) | ### 1 パス目の採否 diff --git a/scripts/test-build-proposal.py b/scripts/test-build-proposal.py index 2018a3e0..fabcf933 100644 --- a/scripts/test-build-proposal.py +++ b/scripts/test-build-proposal.py @@ -790,7 +790,7 @@ def ownership_problems(scripts: Path) -> list[str]: ("消せないのに作っていないと言わない", " 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():", + ("symlink より先に is_file を見る", " if stale.is_symlink() or stale.is_file():", " if stale.is_file() or stale.is_symlink():"), ("書きかけを消せなくても消したと言う", " if left:\n raise Stop(2, [f\"{reason}。書きかけを消せなかった", " if False:\n raise Stop(2, [f\"{reason}。書きかけを消せなかった"), @@ -808,6 +808,7 @@ def locked_problems(scripts: Path) -> list[str]: import contextlib import errno import io + import stat spec = importlib.util.spec_from_file_location("build_proposal_locked", scripts / TARGET) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) @@ -896,7 +897,8 @@ def unlink(self, *args, **kwargs): 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 でだけ死ぬ) + # 辿れない先を指す symlink は消せる——python3.12 の `is_file` はここで例外を投げる(3.13 からは投げない)。 + # 3.12 の `is_file` を再現して走らせ、「symlink より先に is_file を見る」変異を走らせた版によらず殺す(#286 の P2-4・P2-6) unreachable = Path(tmp) / "unreachable" unreachable.mkdir() (unreachable / "target").write_text("x", encoding="utf-8") @@ -905,12 +907,27 @@ def unlink(self, *args, **kwargs): (directory / MANIFEST).write_text("# 印\nproposal.md\n", encoding="utf-8") (directory / "proposal.md").symlink_to(unreachable / "target") unreachable.chmod(0) + original_is_file = Path.is_file + + def is_file_312(self: Path, **kwargs) -> bool: + # 3.12 の `Path.is_file` は、この 4 つ以外の errno(EACCES など)を投げる + try: + return stat.S_ISREG(self.stat().st_mode) + except OSError as e: + if e.errno not in (errno.ENOENT, errno.ENOTDIR, errno.EBADF, errno.ELOOP): + raise + return False + except ValueError: + return False + + Path.is_file = is_file_312 try: with contextlib.redirect_stderr(io.StringIO()): left = module.remove_owned(directory) except OSError as e: left = [f"例外で逃げる: {e!r}"] finally: + Path.is_file = original_is_file unreachable.chmod(0o755) if left or list(directory.iterdir()): problems.append(f"辿れない先の symlink を消さない: {left} {sorted(p.name for p in directory.iterdir())}") @@ -922,8 +939,6 @@ def unlink(self, *args, **kwargs): COEXIST = ["素の v1 タグと共存する"] failures: list[str] = [] -# この python で当てなかった変異の名前——OK の件数に数えず、名指しする(#440 の 2 パス目) -skipped: list[str] = [] def check(name: str, cond: bool, detail: str = "") -> None: @@ -1269,10 +1284,6 @@ def main() -> int: 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} 回現れる") @@ -1286,8 +1297,7 @@ def main() -> int: for message in failures: print(f" - {message}") return 1 - 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(ENTRY_MUTATIONS) - len(skipped)} 件" - + (f"(python {sys.version_info.major}.{sys.version_info.minor} では当てていない {len(skipped)} 件: {'・'.join(skipped)})" if skipped else "")) + 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(ENTRY_MUTATIONS)} 件") return 0 diff --git a/scripts/test-merge-versions.py b/scripts/test-merge-versions.py index df0ef2b1..4da53dbb 100644 --- a/scripts/test-merge-versions.py +++ b/scripts/test-merge-versions.py @@ -169,6 +169,7 @@ def main_case(r: Result) -> bool: # /"crash"(2 つ目の書き込みで後始末の走らない落ち方をする) # /"marker-empty"(用意された空の出力先に、印を書きかけたところで失敗する) # /"stale-loop"(印つきの前の出力があり、docs は自分を指すシンボリックリンク)/"parent-loop"(docs を含む印つきの場所が出力先で、docs は自分を指すリンク) +# /"out-loop"(出力先が、自分を指すリンクの下にある) # 入力の木が None なら docs を作らない CASES: list[tuple[str, dict, str, list[str], Callable[[Result], bool]]] = [ ("全部の経路を重ねる", MAIN, "none", [], main_case), @@ -229,6 +230,9 @@ def main_case(r: Result) -> bool: ("自分を指すリンクを含む場所は消さない", None, "parent-loop", [], lambda r: r.code == 2 and "を含む場所には書かない" in r.output and "前に作った出力を消した" not in r.output and r.tree is not None and "keep.md" in r.tree), + # 出力先が輪の下でも、辿るところでは止まらず、書けないとして 2(#286 の狭いパスの NP-1——輪を out 側に置く試料が無かった) + ("出力先が自分を指すリンクの下でも書けないとして止まる", MAIN, "out-loop", [], + lambda r: r.code == 2 and "書けない" in r.output and "想定外の例外" not in r.output), ("読めないファイル", {**MAIN, "v2/requirements/d.md": UNREADABLE}, "stale", [], lambda r: r.code == 2 and r.tree is None and "読めない" in r.output and "前に作った出力を消した" in r.output), ("読めないディレクトリ", {**MAIN, "v2/requirements/sub": UNREADABLE_DIR}, "stale", [], @@ -347,11 +351,15 @@ def main_case(r: Result) -> bool: ("入力が無いとき前の出力を消さない", ' print(f"{docs}: ディレクトリが無い", file=sys.stderr)\n remove_stale(out)\n', ' print(f"{docs}: ディレクトリが無い", file=sys.stderr)\n', "入力が無いと前の出力を消す"), - # 3.14 の resolve はリンクの輪で投げず、下の「ディレクトリが無い」で消すので、この変異は 3.12 でだけ死ぬ - ("辿るのに Path.resolve を使う(3.12 でだけ死ぬ)", + # 試料は 3.12 の Path.resolve を再現して走らせる(下の RESOLVE_312)ので、どちらの変異もどの版でも死ぬ + ("docs を辿るのに Path.resolve を使う", "docs_real, out_real = Path(os.path.realpath(docs)), Path(os.path.realpath(out))", - "docs_real, out_real = docs.resolve(), out.resolve()", + "docs_real, out_real = docs.resolve(), Path(os.path.realpath(out))", "入力が自分を指すリンクでも前の出力を消す"), + ("出力先を辿るのに Path.resolve を使う", + "docs_real, out_real = Path(os.path.realpath(docs)), Path(os.path.realpath(out))", + "docs_real, out_real = Path(os.path.realpath(docs)), out.resolve()", + "出力先が自分を指すリンクの下でも書けないとして止まる"), ("読めないとき前の出力を消さない", ' print("重ね合わせは作っていない", file=sys.stderr)\n remove_stale(out)\n return 2', ' print("重ね合わせは作っていない", file=sys.stderr)\n return 2', "読めないファイル"), @@ -476,9 +484,33 @@ def snapshot(out: Path) -> dict[str, bytes] | None: return {p.relative_to(out).as_posix(): p.read_bytes() for p in sorted(out.rglob("*")) if p.is_file()} +# python 3.13 から `Path.resolve` はリンクの輪で投げなくなった(3.12 は RuntimeError)。試料はすべて 3.12 の振る舞いを +# 再現して走らせる——「辿るのに Path.resolve を使う」変異を、走らせた版によらず殺すため(#286 の P2-4・P2-6)。 +# 3.12 では再現しなくても同じに投げる。輪かどうかは os.path.realpath(strict=True) の ELOOP で見る +RESOLVE_312 = ''' +import errno, os, pathlib +_resolve = pathlib.Path.resolve +def _resolve_312(self, strict=False): + try: + os.path.realpath(self, strict=True) + except OSError as e: + if e.errno == errno.ELOOP: + raise RuntimeError(f"Symlink loop from {str(self)!r}") + return _resolve(self, strict) +pathlib.Path.resolve = _resolve_312 +''' + +# 本体を `__main__` として走らせる(入口の想定外の例外の捕捉もそのまま通る) +MAIN_DRIVER = RESOLVE_312 + ''' +import runpy, sys +script = sys.argv[1] +sys.argv = [script, *sys.argv[2:]] +runpy.run_path(script, run_name="__main__") +''' + # 書き込みの口(`put`)を差し替えて、2 つ目の書き込みで失敗させる。"crash" は後始末の走らない落ち方(OSError でない)。 # "marker" は印を書きかけたところで失敗させる——用意された出力先の直下に書きかけのファイルが残る形を作る -FAIL_DRIVER = ''' +FAIL_DRIVER = RESOLVE_312 + ''' import importlib.util, sys spec = importlib.util.spec_from_file_location("merge_versions", sys.argv[1]) module = importlib.util.module_from_spec(spec) @@ -503,7 +535,7 @@ def put(dest, content): def merge(scripts: Path, docs: Path, out: Path, extra: list[str], fail: str | None = None) -> tuple[int, str]: - head = ["-c", FAIL_DRIVER, str(scripts / TARGET), fail] if fail else [str(scripts / TARGET)] + head = ["-c", FAIL_DRIVER, str(scripts / TARGET), fail] if fail else ["-c", MAIN_DRIVER, str(scripts / TARGET)] proc = subprocess.run([sys.executable, "-I", *head, str(docs), str(out), *extra], capture_output=True, text=True, cwd=docs.parent) return proc.returncode, proc.stdout + proc.stderr @@ -513,7 +545,8 @@ def run_case(scripts: Path, root: Path, tree: dict, prepare: str, extra: list[st assert_in_tmp(root) docs = root / "docs" locked = build(docs, tree) - out = {"inside": docs / "merged", "docs": docs, "parent": root, "parent-loop": root}.get(prepare, root / "build" / "merged") + out = {"inside": docs / "merged", "docs": docs, "parent": root, "parent-loop": root, + "out-loop": root / "loop" / "merged"}.get(prepare, root / "build" / "merged") preexisting = None if prepare == "parent": (root / ".merged-versions").write_text("前の出力\n", encoding="utf-8") @@ -526,6 +559,8 @@ def run_case(scripts: Path, root: Path, tree: dict, prepare: str, extra: list[st os.symlink(docs, docs) (root / ".merged-versions").write_text("前の出力\n", encoding="utf-8") (root / "keep.md").write_text("消してはいけない\n", encoding="utf-8") + elif prepare == "out-loop": + os.symlink(out.parent, out.parent) elif prepare == "stale-loop": docs.parent.mkdir(parents=True, exist_ok=True) os.symlink(docs, docs) @@ -568,11 +603,6 @@ def run_case(scripts: Path, root: Path, tree: dict, prepare: str, extra: list[st return Result(code, output, tree_out, preexisting, again, docs_intact, leftover) -def only_312(name: str) -> bool: - """python 3.13 以降では届かない変異(`Path.resolve` がリンクの輪で投げなくなった)。CI は 3.12。""" - return "3.12 でだけ" in name and sys.version_info >= (3, 13) - - def skipped(name: str) -> bool: """root は mode 000 でも読めるので、読めない入力の試料を作れない。""" return name in UNREADABLE_CASES and hasattr(os, "geteuid") and os.geteuid() == 0 @@ -636,7 +666,7 @@ def main() -> int: source = (SCRIPTS / TARGET).read_text(encoding="utf-8") for index, (name, before, after, killer) in enumerate(MUTATIONS): - if skipped(killer) or only_312(name): + if skipped(killer): continue count = source.count(before) if count != 1: @@ -666,9 +696,7 @@ def main() -> int: skip = [name for name, *_ in CASES if skipped(name)] if skip: print(f"飛ばした(root では読めない入力を作れない): 試料 {'・'.join(skip)} と、それを名指しした変異") - print(f"OK: 試料 {len(CASES) - len(skip)} 件・変異 {sum(not skipped(m[3]) and not only_312(m[0]) for m in MUTATIONS) + len(OO_MUTATIONS)} 件" - + (f"(python {sys.version_info.major}.{sys.version_info.minor} では当てていない " - f"{sum(only_312(m[0]) for m in MUTATIONS)} 件)" if any(only_312(m[0]) for m in MUTATIONS) else "")) + print(f"OK: 試料 {len(CASES) - len(skip)} 件・変異 {sum(not skipped(m[3]) for m in MUTATIONS) + len(OO_MUTATIONS)} 件") return 0 From f4c0cdeb67ea2b4eda1ece145c60490ee3eab32d 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 13:59:14 +0900 Subject: [PATCH 7/7] =?UTF-8?q?issue-286:=20=E5=88=A4=E6=96=AD=E3=82=92?= =?UTF-8?q?=E5=8F=97=E3=81=91=E3=81=9F=E4=BF=AE=E6=AD=A3=E3=81=AE=20Verifi?= =?UTF-8?q?er=20=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-286.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/issue-286.md b/docs/plans/issue-286.md index 92f5d297..b57d294c 100644 --- a/docs/plans/issue-286.md +++ b/docs/plans/issue-286.md @@ -100,6 +100,7 @@ Issue 本文は検査対象であって要件ではない。 - 狭いパス(Verifier・sonnet・`28fdff4`..`6abb359`): 通した。挙動の反証 0 件・テストの穴 1 件(NP-1。下の表)。Verifier の設計した 9 通りの配置は 3.12・3.14 で rc・文言・残ったファイルが一致 - 利用者の判断(2026-10-28、`/dev-loop:cruise 474` の再開時に訊いた): NP-1・P2-4・P2-6 は**この PR で直す**。直した差分に Verifier を当て直してからマージ待ちに戻す - 判断を受けた修正(テストだけ。本体は触らない): `test-merge-versions.py` は全試料を 3.12 の `Path.resolve` を再現して走らせ、`only_312` を外した。変異は docs 側と出力先側の 2 つに分け、出力先側を殺す試料を足した(NP-1)。`test-build-proposal.py` は辿れない symlink の試料だけ 3.12 の `Path.is_file` を再現し、版で当てない変異の扱いを外した。3.12・3.14 の両方で `test-merge-versions.py` 67 試料・62 変異、`test-build-proposal.py` 84 試料・102 変異。陽性対照: 再現を外すと 3.14 では 3 つの変異が生き残る(3.12 では外しても死ぬ)。`check-all.py` 52/52 +- 判断を受けた修正の Verifier(sonnet・`2e285ae`..`86cbe7b`): 通した。主張 6 件すべて成り立つ・反証 0 件。再現は 3.12 の素の `Path.resolve` と 24 通りで一致。気づいた点: OS の ELOOP 上限を超える輪でない長いリンクの連鎖では 3.12 と再現が食い違いうる(45 段では観測されず、変異の判定には効かない) ## 既知の限界・決着済みの論点