Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
{
"name": "spec-loop",
"source": "./plugins/spec-loop",
"version": "0.31.0",
"version": "0.32.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<n>/shared/ ほか)を指すかをスクリプトで照合し、一致し、かつ暫定の回答が残っていないときだけ提案書と仕様書(PPTX も選べる)を生成し、利用者にまだ確かめていない暫定の決定を合意と分けて載せる。v2 以降は前の版との比較を載せた変更の提案書になる。決裁者の承認待ちで止まる)と scaffold(確定した仕様を中間表現 spec_ir.json に書き起こし、OpenAPI・型・実装が無いと失敗する Red テストと dev-loop に渡す Issue の本文をスクリプトで生成して、1 機能 = Issue 1 件で登録する。v2 以降は前の版と比べて機能を追加・変更・削除に分け、変わった機能だけを Issue にする。Issue の登録で止まる)の 5 本"
}
Expand Down
150 changes: 150 additions & 0 deletions docs/plans/issue-286.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion plugins/spec-loop/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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<n>/shared/ ほか)を指すかをスクリプトで照合し、一致し、かつ暫定の回答が残っていないときだけ提案書と仕様書(PPTX も選べる)を生成し、利用者にまだ確かめていない暫定の決定を合意と分けて載せる。v2 以降は前の版との比較を載せた変更の提案書になる。決裁者の承認待ちで止まる)と scaffold(確定した仕様を中間表現 spec_ir.json に書き起こし、OpenAPI・型・実装が無いと失敗する Red テストと dev-loop に渡す Issue の本文をスクリプトで生成して、1 機能 = Issue 1 件で登録する。v2 以降は前の版と比べて機能を追加・変更・削除に分け、変わった機能だけを Issue にする。Issue の登録で止まる)の 5 本",
"version": "0.31.0",
"version": "0.32.0",
"author": { "name": "hdknr" }
}
47 changes: 31 additions & 16 deletions plugins/spec-loop/scripts/build-proposal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 を表示して目で見る)・
Expand Down Expand Up @@ -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:
Expand All @@ -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())
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -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)
11 changes: 8 additions & 3 deletions plugins/spec-loop/scripts/check-feasibility-csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

- 0: 2 つとも決まりどおり
- 1: 決まりに反する箇所がある(**全部を 1 行ずつ出す**。最初の 1 件で止めない)
- 2: 中身を検査していない——引数の誤り・読めない・UTF-8 でない・空。
- 2: 中身を検査していない——引数の誤り・読めない・UTF-8 でない・空・想定外の例外(128KB を超えるセルの `csv.Error` など。#286)。
**反する箇所が別のファイルに見つかっても 2 を優先する**(見ていないファイルを「決まりどおり」と言わない)

**この検査が守らないもの**:
Expand Down Expand Up @@ -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)):
Expand All @@ -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)
10 changes: 7 additions & 3 deletions plugins/spec-loop/scripts/check-frozen.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

- 0: 凍結した版は直されていない(凍結した版が無いときも 0。出力で区別する)
- 1: 直されている(違うファイルを版ごとに全部出す)
- 2: 判定していない——git でない・git が無い・HEAD が無い・浅い clone・タグの時点に `docs/v<n>/` が無い
- 2: 判定していない——git でない・git が無い・HEAD が無い・浅い clone・タグの時点に `docs/v<n>/` が無い・想定外の例外(#286)

対象リポジトリ(正本リポジトリ)で単体で動くよう、**標準ライブラリだけ**を使う(`git` は要る)。
CI で走らせるなら、タグを取得できるよう履歴をすべて取る(GitHub Actions なら `actions/checkout` に `fetch-depth: 0`)。
Expand Down Expand Up @@ -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)
Expand All @@ -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)
11 changes: 9 additions & 2 deletions plugins/spec-loop/scripts/check-qa-csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

- 決まりに反する箇所があれば、**全部を 1 行ずつ出して**非ゼロ(1)で終わる(最初の 1 件で止めない)。
- ファイルが読めない・UTF-8 でない・空のときは 2 で終わる(**中身を検査していない**)。
**想定外の例外(128KB を超えるセルの `csv.Error` など)も 2**——トレースバックの 1(違反)にしない(#286)。
2 に寄せるのは CLI の入口だけで、読み込んだ `check` は例外をそのまま呼び出し元へ返す。
- 反する箇所が無ければ 0 で終わり、検査した行数を出す。

**この検査が守らないもの**:
Expand Down Expand Up @@ -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:
Expand All @@ -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)
16 changes: 11 additions & 5 deletions plugins/spec-loop/scripts/check-signoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
どちらかの時点で比べる場所にファイルが無い(旧い構成のタグを含む)・どちらかの時点に `docs/v<n>/` が無い・
`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` は要る)。

Expand Down Expand Up @@ -173,8 +174,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))
Expand Down Expand Up @@ -419,7 +421,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")
Expand All @@ -432,4 +434,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)
Loading
Loading