diff --git a/AGENTS.md b/AGENTS.md index ada9fec29..55c9b0d04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,6 +165,7 @@ ai-plugins/ - ドキュメントをスキップ - 機密情報をコミット - テストをスキップ +- `.md` の文言を照合するテストを書く(文言を固定しても振る舞いは保証されない。参照切れは検査スクリプトが見る) - ユーザーの許可なくPRを承認 ## Git運用フロー diff --git a/docs/specifications/cross-refactoring-apply-intake.md b/docs/specifications/cross-refactoring-apply-intake.md index 2e611a2d5..8c5c60b4f 100644 --- a/docs/specifications/cross-refactoring-apply-intake.md +++ b/docs/specifications/cross-refactoring-apply-intake.md @@ -390,7 +390,7 @@ graph TD | 修正の結果を適用ラウンドの担当から読み、結果なしで修正ラウンドが進んで見送りへ至ること | 同 `tests/test_abandon_items.py` | | 最終ゲートの修正の結果なしで取り消し、次の最終ゲートが取り消し後の地点でテストすること | 同 `tests/test_final_fix.py` | | 帰属の段落が後ろに付いたコミットから必須の記名を読み、散文の中の記名の形の行を読まないこと | 同 `tests/test_commit_trailers_git.py`(一時リポジトリで git を実行する) | -| 無進捗の許容が起動の出力に入り、骨組みの 3 つの監視の呼び出しへ渡ること | 同 `tests/test_init.py` / `tests/test_skill_terms.py` | +| 無進捗の許容が起動の出力に入り、骨組みの 3 つの監視の呼び出しへ渡ること | 同 `tests/test_init.py`(文書の語の照合は #885 で削除) | | 結果ファイルがあり検証を通る適用ラウンドが、変更の前と同じく 1 回目で取り込まれること | 同 `tests/test_merge_apply.py` | | 文書の分量が分割の基準を超えないこと | `python3 scripts/check-doc-line-limit.py` | | 参照のリンクが解決できること | `python3 scripts/check-markdown-links.py` | diff --git a/docs/specifications/cross-refactoring-participants.md b/docs/specifications/cross-refactoring-participants.md index bbe3ab264..b7d328b30 100644 --- a/docs/specifications/cross-refactoring-participants.md +++ b/docs/specifications/cross-refactoring-participants.md @@ -240,7 +240,7 @@ | 完了報告と改修計画にレビュー担当の列が無く、母集合を 1 行で出すこと | `cross-refactoring/tests/test_rounds.py` / `test_plan_comment.py` | | この変更の前の状態ファイルをラウンドの開始と完了報告が読めること | `cross-refactoring/tests/test_rounds.py` / `test_start_round_emits_runtimes.py` | | 結果なしの試行の交代先が参加者の数の範囲で選ばれること | `cross-refactoring/tests/test_apply_attempts.py` | -| 手順書と指示書の語が新しい参加者と輪番を書くこと | `cross-refactoring/tests/test_skill_terms.py` | +| 手順書と指示書の語が新しい参加者と輪番を書くこと | 文書を読んで確かめる(照合していたテストは #885 で削除) | | 旧関数 4 つが共通層と両 Skill の部品に残らないこと | `scripts/tests/test_shared_lib_layout.py` | ## 関連リンク diff --git a/docs/specifications/cross-review-writes-to-conductor.md b/docs/specifications/cross-review-writes-to-conductor.md index 5a0a89add..effd41f60 100644 --- a/docs/specifications/cross-review-writes-to-conductor.md +++ b/docs/specifications/cross-review-writes-to-conductor.md @@ -398,7 +398,7 @@ FINDINGS=8 | 修正を現在の頭で送り、報告されたコミットが送り先に無ければ止まること | 同 `test_result_posts.py` と `plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py` | | 単独の修正の口が同じ層を使い、送り先のブランチを決められないとき返信へ進まないこと | `plugins/ndf/scripts/tests/test_result_posts.py` | | 骨組みの起動し直しが繰り返しの先頭へ戻り、8 の枝を 7 より先に見ること | `plugins/ndf/skills/cross-review/tests/test_skill_layout.py` | -| 文書が投稿の担い手と種別ごとの契約を書いていること | `plugins/ndf/skills/cross-review/tests/test_writes_by_conductor_docs.py` | +| 文書が投稿の担い手と種別ごとの契約を書いていること | 文書を読んで確かめる(照合していたテストは #885 で削除) | ## 関連リンク diff --git a/docs/specifications/ndf-agent-layers-unattended-run.md b/docs/specifications/ndf-agent-layers-unattended-run.md index 2b2e8b80a..8f99a52eb 100644 --- a/docs/specifications/ndf-agent-layers-unattended-run.md +++ b/docs/specifications/ndf-agent-layers-unattended-run.md @@ -260,13 +260,13 @@ supervisor と直接起動した worker を一覧し、`resets_passed` が真の | 観点 | 確かめ方 | | --- | --- | -| 3 層の責務の表、持ち場 5 つと関門の列、報告の 10 項目 / 5 項目、続けさせる回数 3、モデルの基準 2 つ、委譲しない 5 つ、並行の単位が supervisor、止まるときに持ち場の一覧を出す 3 つの時点 | `plugins/ndf/skills/development-workflow/tests/test_agent_layers_doc.py` | +| 3 層の責務の表、持ち場 5 つと関門の列、報告の 10 項目 / 5 項目、続けさせる回数 3、モデルの基準 2 つ、委譲しない 5 つ、並行の単位が supervisor、止まるときに持ち場の一覧を出す 3 つの時点 | 文書を読んで確かめる(照合していたテストは #885 で削除) | | 中断の契機 4 つ、落ちた層ごとの 3 通り、解除を待つ手段 3 段、点検の手順、`--max-sleep` を付けない規約 | 同上 | -| 「メイン」の定義が supervisor を指す | 同上(`test_cross_review_defines_main_as_the_driving_supervisor`) | +| 「メイン」の定義が supervisor を指す | 同上 | | `context-window.md` にモデルに依る目安とリポジトリに依る固定費の区別があり、規約の文書に固定費の実測値が無い | 同上 | -| 3 つの規約の文書に「窓」「親」が無い | 同上(`test_the_three_layer_docs_drop_the_old_words`) | +| 3 つの規約の文書に「窓」「親」が無い | 同上 | | 3 層へ出すのは `/goal` だけ、関門以外は提示して進める | 同上 | -| 関門の数・承認の形が変わらない | `test_approval_gates.py` / `test_workflow_guard.py` が無改変で通る | +| 関門の数・承認の形が変わらない | `test_workflow_guard.py` が無改変で通る(`test_approval_gates.py` は #885 で削除) | | 実機での無人の通過(人の入力の数、conductor の報告の持ち場の一覧と記録の `SendMessage` の数の一致) | リリース後テスト(`light` の課題 1 件を `/goal` で通し、次のまとまりで `standard` を含む複数の課題を関門 2 つと振り返りまで通す) | | 上限からの再開そのもの | リリース後テスト(上限を意図して起こせないため、発生した実行の記録を読む) | diff --git a/docs/specifications/ndf-cleanup-and-bundle-closing.md b/docs/specifications/ndf-cleanup-and-bundle-closing.md index bc7a6e614..2f4a9b28f 100644 --- a/docs/specifications/ndf-cleanup-and-bundle-closing.md +++ b/docs/specifications/ndf-cleanup-and-bundle-closing.md @@ -246,10 +246,10 @@ Pull Request を選ぶため、別のまとまりの課題を閉じうる。 | 観点 | 確かめ方 | | --- | --- | -| `gh issue close` と `status "Done"` を持つ `SKILL.md` が `progress-tracking` だけ、関門が 2 つのまま | `plugins/ndf/skills/development-workflow/tests/test_workflow_hooks.py`(`test_only_progress_tracking_closes_issues` / `test_the_gates_stay_two`) | +| `gh issue close` と `status "Done"` を持つ `SKILL.md` が `progress-tracking` だけ、関門が 2 つのまま | 文書を読んで確かめる(照合していたテストは #885 で削除) | | 3 つの終わりの工程で「まとまりを閉じる」が `issue-upkeep` より前にある | 同上 | -| 配布の記録の読み取りと閉じる手順が手順書のコード例のとおりに動く(本番の版のブロックの選択、`配布なし` の後のブロック、`まとまり:` の行の取り出し、盤面 → close → 読み直しの順、一覧が空なら止まる、最初の読みに失敗したら盤面を触らない) | 同上(`closing_step` / `record_reader` / `release_verification_template` を名前に持つテスト。疑似 `gh` で呼び出しの順を固定する) | -| `release/SKILL.md` の行数が上限(365 行)に収まる | `plugins/ndf/skills/release/tests/test_completion_check.py` | +| 配布の記録の読み取りと閉じる手順が手順書のコード例のとおりに動く(本番の版のブロックの選択、`配布なし` の後のブロック、`まとまり:` の行の取り出し、盤面 → close → 読み直しの順、一覧が空なら止まる、最初の読みに失敗したら盤面を触らない) | `plugins/ndf/skills/development-workflow/tests/test_workflow_hooks.py`(`closing_step` / `record_reader` を名前に持つテスト。疑似 `gh` で呼び出しの順を固定する) | +| `release/SKILL.md` の行数が上限(365 行)に収まる | `plugins/ndf/skills/release/tests/test_completion_check.py`(`test_the_skill_md_stays_within_its_budget`) | | frontmatter の `description` から "after listing them for approval" が消えている | `python3 scripts/check-skill-frontmatter.py` | | 退避のループが空白・改行・引用符・非 ASCII を含むパスと、追跡されたディレクトリの配下の無視されたパスを同じ相対パスで移す | 一時リポジトリでの実測(PR #747 の本文) | | 書けない退避先では 1 件目を退避した後に止まり、`git worktree remove` へ進まず作業ツリーが残る | 同上 | diff --git a/docs/specifications/ndf-context-window-metrics.md b/docs/specifications/ndf-context-window-metrics.md index e1411e23a..96aedb74b 100644 --- a/docs/specifications/ndf-context-window-metrics.md +++ b/docs/specifications/ndf-context-window-metrics.md @@ -205,7 +205,7 @@ worker(読解)は深さ 1 に現れるため、`description` の先頭語が | `wait-reset` が過ぎていれば直ちに終わり、未来なら差の秒数だけ眠り、2 件では早い方まで眠る。直接起動した worker だけでも眠る。`--max-sleep` の終了コード 3。負の秒数で 2 | 同上(眠る関数と時刻を差し替える) | | `socket` を塞いだ実行で終了コード 0 | 同上 | | conductor の行、複数セッション、2 つの supervisor の worker が起動元ごとに分かれる、層ごとの合計、束ねる候補が supervisor の行にだけ付く、worker を使いすぎの印、`--agents` を付けない既定の出力が変わらない、`--window-limit` の既定が `context-window.md` と一致 | `plugins/ndf/skills/skill-stats/tests/test_agents_report.py` | -| 手順 2 の観点に context window の行、雛形に 3 つの表があり、パス・本文・`agent_id` の列を持たない | `plugins/ndf/skills/retrospective/tests/test_context_window_section.py` | +| 手順 2 の観点に context window の行、雛形に 3 つの表があり、パス・本文・`agent_id` の列を持たない | 文書を読んで確かめる(照合していたテストは #885 で削除) | ## 運用 diff --git a/docs/specifications/ndf-design-phase.md b/docs/specifications/ndf-design-phase.md index 038a2f16f..9e0777063 100644 --- a/docs/specifications/ndf-design-phase.md +++ b/docs/specifications/ndf-design-phase.md @@ -133,7 +133,7 @@ AI ごとに変わり、文書の分割単位・記載項目・粒度をその | 説明文書の Skill 数が実体と一致する | `python3 scripts/check-doc-staleness.py` | | 参照のリンクが解決できる | `python3 scripts/check-markdown-links.py` | | 4 ランタイムで Skill が読み込める | `bash scripts/runtime-smoke-test.sh` | -| 工程表の「設計」の行と `WF_STAGE_MATRIX` が一致する | `test_workflow_stage_matrix.py` | +| 工程表の「設計」の行と `WF_STAGE_MATRIX` が一致する | 文書を読んで確かめる(照合していたテストは #885 で削除) | | 条件付きの工程は、記録が無くても欠落として並ばない | `test_workflow_guard.py::test_a_conditional_stage_without_a_record_is_not_a_gap` | ## 運用 diff --git a/docs/specifications/ndf-documentation-mode.md b/docs/specifications/ndf-documentation-mode.md index df496d6be..114c5226a 100644 --- a/docs/specifications/ndf-documentation-mode.md +++ b/docs/specifications/ndf-documentation-mode.md @@ -211,12 +211,12 @@ Confluence と SharePoint の実装は別のリポジトリにあり、自リポ | 観点 | 確かめ方 | | --- | --- | -| 工程表が 18 行 × 5 列で、`SKILL.md` と定数が並びまで一致する | `test_workflow_stage_matrix.py` | -| 工程名の並びが 5 箇所で一致する | `test_stage_values.py` / `test_approval_gates.py` | +| 工程表が 18 行 × 5 列で、`SKILL.md` と定数が並びまで一致する | 文書を読んで確かめる(照合していたテストは #885 で削除) | +| 工程名の並びが 5 箇所で一致する | 文書を読んで確かめる(照合していたテストは #885 で削除) | | `documentation` の高さが 5 で、混在時にそちらで検査する | `test_documentation_mode.py` | | 5 列目を読み、既存 4 列がずれない | 同上 | -| 承認の関門が 2 つのままである | `test_approval_gates.py` | -| 形が 9 つあり、索引が実ファイルと一致する | `test_completion_check.py` | +| 承認の関門が 2 つのままである | 文書を読んで確かめる(照合していたテストは #885 で削除) | +| 形が 9 つあり、索引が実ファイルと一致する | 索引が実在する `form-*.md` をすべて指すことは `plugins/ndf/skills/release/tests/test_completion_check.py`(`test_the_form_index_links_to_every_form_file`)で見る。形の数は文書を読んで確かめる(固定の一覧と照合していたテストは #885 で削除) | | 新設 4 個が 4 つの manifest すべてに載る | `test_agy_distribution.py` / `validate-runtime-plugins.sh` | | 自リポジトリ前提を持たない | `check-skill-repo-assumptions.py` | | 説明文書の数が実体と一致する | `check-doc-staleness.py` | diff --git a/docs/specifications/ndf-execution-plan-and-parallel-capacity.md b/docs/specifications/ndf-execution-plan-and-parallel-capacity.md index 09b52f774..fd85bf8dd 100644 --- a/docs/specifications/ndf-execution-plan-and-parallel-capacity.md +++ b/docs/specifications/ndf-execution-plan-and-parallel-capacity.md @@ -232,8 +232,8 @@ oom_kill_increased = yes なら allowed = max(0, min(allowed, running − 1)) | 測れない環境で本数を出さず終了コード 3。`memory.events` が無ければ `oom_kill=unknown` で 0 | 同上 | | スワップの空きが少ないと 1 減る。`oom_kill` が増えていれば「今の本数 − 1」以下で、`--running 0` / `1` では 0 になり `floor` が付かない | 同上 | | `concurrency` が重なり・並行度・最大同時本数を出し、端が接するだけの組は重ならない。偽の `gh` が受けた引数が `pr view` だけ | 同上 | -| 下限 4・5・6 の文言、下限 6 の理由に「メモリ」と「21GiB」、コンテナを起動する担当は 1 本、重なりの目安の 3 区分と列 | `plugins/ndf/skills/development-workflow/tests/test_parallel_work_bounds.py` / `test_approval_gates.py` | -| `execution-plan.md` に置き場所・コミットしないこと・行の列・確度と状態の値・契機 5 つ・閉じる手順・組を読むこと・`capacity` を起動の前に実行することがあり、初期値の数値(2048・25%)が無い | `plugins/ndf/skills/issue-plan-strategy/tests/test_execution_plan_doc.py` | +| 下限 4・5・6 の文言、下限 6 の理由に「メモリ」と「21GiB」、コンテナを起動する担当は 1 本、重なりの目安の 3 区分と列 | 文書を読んで確かめる(照合していたテストは #885 で削除) | +| `execution-plan.md` に置き場所・コミットしないこと・行の列・確度と状態の値・契機 5 つ・閉じる手順・組を読むこと・`capacity` を起動の前に実行することがあり、初期値の数値(2048・25%)が無い | 文書を読んで確かめる(照合していたテストは #885 で削除) | | `milestones.md` に「並列の組(見込み)」の節と列、段 2A から写す文、見込みであり確定は実行計画が持つ文があり、段 2A の控える項目が 6 行のまま、既存の名前と連番のテストが通る。0 件になった組の行削除で番号を詰めない | `plugins/ndf/skills/issue-upkeep/tests/test_issue_upkeep_layout.py` | | 閉じた実行計画のコメントと振り返りが読める | リリース後テスト(次に複数の束を持つまとまりを進めたとき) | diff --git a/docs/specifications/ndf-instruction-files-check.md b/docs/specifications/ndf-instruction-files-check.md index 37f2e299a..054cf295f 100644 --- a/docs/specifications/ndf-instruction-files-check.md +++ b/docs/specifications/ndf-instruction-files-check.md @@ -312,7 +312,7 @@ semver の形(数字 3 つと任意の接尾辞)でなければ終了コー | 許可の判定(未指定と `{}`・効かない許可)、出た版の段落(段落の先頭・箇条書きの 2 件目・折り返し行・プロジェクトのスコープに限る)、変更履歴とタグからの最大値(`10.9.0` と `10.10.0`)、名前付きの捕捉、`released.path` の境界、印、上限、値の範囲、構造の不正 | 同上(`--criteria` で観点のデータを差し替える) | | 指摘の 1 行の形(扱いの印と位置の 3 通り)、指摘が無いときの標準出力、知らない引数で 3 | 同上 | | 4 ランタイムの導入先(claude の複製 / codex の複製 / agy の symlink / kiro が読む `scripts/`)から起動し、0・1・2・3 の終了コードが検査の値と一致する | 手順(PR #746 の本文。16 通り) | -| `release/SKILL.md` の行数が上限に収まる | `plugins/ndf/skills/release/tests/test_completion_check.py` | +| `release/SKILL.md` の行数が上限に収まる | `plugins/ndf/skills/release/tests/test_completion_check.py`(`test_the_skill_md_stays_within_its_budget`) | ## 運用 diff --git a/docs/specifications/ndf-workflow-unit-and-gates.md b/docs/specifications/ndf-workflow-unit-and-gates.md index 598cfecd0..6878db02d 100644 --- a/docs/specifications/ndf-workflow-unit-and-gates.md +++ b/docs/specifications/ndf-workflow-unit-and-gates.md @@ -250,11 +250,11 @@ symlink を含む)`absent`、存在すれば(ディレクトリを含む)` | 観点 | 確かめ方 | | --- | --- | -| 工程表と分類表の分類が一致する | `plugins/ndf/skills/development-workflow/tests/test_workflow_stage_matrix.py` | -| 工程の並びが 4 か所で一致する | 同 `tests/test_stage_values.py` | +| 工程表と分類表の分類が一致する | 文書を読んで確かめる(照合していたテストは #885 で削除) | +| 工程の並びが 4 か所で一致する | 文書を読んで確かめる(照合していたテストは #885 で削除) | | 判定の単位・工程の順序・`light` の工程が本文と表で揃う | 同 `tests/test_workflow_units.py` | | gate が案内を出し、拒否しない | 同 `tests/test_workflow_evidence.py` | -| 承認の関門と並行開発の下限が書かれている | 同 `tests/test_approval_gates.py` | +| 承認の関門と並行開発の下限が書かれている | 文書を読んで確かめる(照合していたテストは #885 で削除) | | 本番のチャネルを宣言から読める | `plugins/ndf/skills/worktree/tests/test_production_branch.py` | | `check` が状態ごとの終了コードを返し、ファイルを作らず、`status` と同じ 1 行目を出す | `plugins/ndf/skills/worktree/tests/test_setup.py` | | 手順 0 が手順 1 より前にあり、分岐・拒否しない理由・判定の実体が 1 か所であることが本文に揃う | `plugins/ndf/skills/development-workflow/tests/test_declaration_check.py` | diff --git a/issues/issue-885-plan.md b/issues/issue-885-plan.md new file mode 100644 index 000000000..1a7a6a34c --- /dev/null +++ b/issues/issue-885-plan.md @@ -0,0 +1,105 @@ +# #885: `.md` の文言固定テストを削り、置かない方針を規約にする — 実装計画 + +## 関連リンク + +- 課題: #885 +- 要求と受け入れ条件: [issue-880-885-requirements.md](issue-880-885-requirements.md)(AC19a・AC19b・AC19c・AC24) +- 設計: [issue-880-885-design.md](issue-880-885-design.md)(「テストと規約(F10・F11)」「#885 の棚卸」「実装の分け方と順序」の PR 1 の行) +- 決定の理由: [issue-880-885-design-decisions.md](issue-880-885-design-decisions.md)(決定 11・決定 13) + +## モード + +standard(設計 PR #911 で関門 1 を通過済み。このまとまりの 2 本のうち PR 1) + +## 目的と非目的 + +達成したい状態: + +- リポジトリで追跡している `.md` の文言を照合する pytest が残っていない +- 文書が指す参照の実在は、検査スクリプトが失敗として出す +- テストを書く規約(`tdd-cycle`・`quality-gates`・`AGENTS.md`)に、`.md` の文言を照合するテストを書かない方針がある + +やらないこと: + +- cross-refactoring の是正(#880 #883 #494 #723)。PR 2 が扱う。`refactor_lib`・cross-refactoring の scripts・`workflow-modes.md`・`stage-notes.md` に触れない +- 残すテストの書き換え(削る関数を外すだけで、残る関数の中身は変えない) +- 新たな文言固定テスト(利用者の方針) + +## 前提 + +- 前提 1: 設計の棚卸(develop `dae35582`)は概数で、関数ごとの分類はこの PR で行い直す +- 前提 2: 分類は設計の「分類の規則」に従う。判定の問いは「その `.md` をスクリプトが実行時に読むか、テストがその中のコードを実行するか」 + +## 受け入れ条件 + +- [ ] AC19a: 削除の後に洗い出し(実行時の読み取りの記録と AST の走査の和)を回し、出た関数がすべて「分類の規則」の残す行に当たる。対応を PR 本文の表に書く +- [ ] AC19b: 寄せる関数ごとに、見ていた参照を一時的に壊し、検査スクリプトが失敗を返すことを確かめ、記録を PR 本文に残す +- [ ] AC19c: `tdd-cycle/references/test-quality.md`・`quality-gates/SKILL.md`・`AGENTS.md` に方針がある(文書を読んで確かめる。文言を固定するテストは書かない) +- [ ] AC24: `uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q -n 4` が通る + +## 代替案と採否 + +| 案 | 内容 | 採否 | 理由 | +| --- | --- | --- | --- | +| A | 実行時の読み取りの記録(pytest プラグイン)と AST の走査の和で洗い出す | 採用 | 実行時の記録はキャッシュ経由の読み取りを取りこぼし、AST は一時ファイルの同名を誤検知する。和を取って人が分類する | +| B | AST の走査だけで洗い出す | 不採用 | 一時ファイルの `README.md` などを拾い、950 関数が候補に出る | + +## 修正対象 + +- 削る: 設計の「ファイルごとの概数」の表のファイルと、洗い出しで追加に見つかったファイルの該当関数 +- 規約: `plugins/ndf/skills/tdd-cycle/references/test-quality.md`・`plugins/ndf/skills/quality-gates/SKILL.md`・`AGENTS.md` +- 検査スクリプト: 寄せる参照が既存の `scripts/check-markdown-links.py` で失敗にならないときだけ判定を足す + +## タスク分解 + +### Task 1: ファイルごと消すテストを削る + +- **対象ファイル:** 設計の表で「消す」のファイル(12 本)と、洗い出しで全関数が削る対象と分かったファイル +- **変更内容:** ファイルを削除する。中に残す形(P1〜P3・配線)の関数があれば、そのファイルは Task 2 へ回す +- **満たす受け入れ条件:** AC19a・AC24 +- **進め方:** テスト駆動は適用しない(テストを消す変更で、振る舞いの変更が無い)。削除の後に全体テストを通す + +### Task 2: 混在するファイルから削る関数を外す + +- **対象ファイル:** 設計の表で「残す」のファイルと、洗い出しで追加に見つかったファイル +- **変更内容:** 削る関数を外し、使われなくなった補助の関数・定数・import を消す +- **満たす受け入れ条件:** AC19a・AC24 +- **進め方:** Task 1 と同じ + +### Task 3: 参照の実在を検査スクリプトへ寄せる + +- **対象ファイル:** 寄せる関数と `scripts/check-markdown-links.py`(必要なときだけ) +- **変更内容:** 見ていた参照を一時的に壊し、検査スクリプトが失敗を返すことを確かめてから関数を削る。返さないものは件数を見て、判定を足すか、削って理由を本文に書くかを決める +- **満たす受け入れ条件:** AC19b +- **進め方:** 判定を足す場合は、検査スクリプトのテスト(一時ファイルの `.md` を入力に渡す)を先に書く + +### Task 4: テストを書く規約へ方針を書く + +- **対象ファイル:** `tdd-cycle/references/test-quality.md`(`### 8. .md の文言を照合する` の新設と「削除してよいテスト」の 1 行)・`quality-gates/SKILL.md`・`AGENTS.md` の DON'T +- **満たす受け入れ条件:** AC19c +- **進め方:** 文書の変更。テストは書かない + +### Task 5: 洗い出しを回し直して分類表を作る + +- **変更内容:** 削除の後に洗い出しを回し、残った関数をすべて「分類の規則」の残す行へ対応させ、PR 本文の表にする +- **満たす受け入れ条件:** AC19a + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| PR 2 が cross-refactoring の `tests/` を触り、削除と衝突する | 実装の後の構造改善で足りる。PR 1 が cross-refactoring で触るのは `test_skill_terms.py` などの文言照合の関数だけ。衝突したら後から入れる側が削除で解く(設計の「実装の分け方と順序」) | +| 残す形(P1 など)の関数を誤って削る | 関数ごとに、スクリプトを起動するか・文書の中のコードを実行するかを見てから削る | +| 共有の補助モジュールの関数を消して、残す関数が壊れる | 補助の関数は、参照が 0 件になったものだけを消す。全体テストで確かめる | + +## 構造改善 + +本番コードの差分が無い(テストの削除・文書・必要なら検査スクリプトのみ)。設計の「実装の分け方と順序」のとおり、`assess` が無いため、この条件に当たることを PR 本文に書いて飛ばす。検査スクリプトへ判定を足した場合は、その行数を本文に書く。 + +## 切り戻し手順 + +- テストの削除と文書の追記だけで、データの移行は無い。Pull Request の revert で戻る + +## 完了の定義 + +- [ ] AC19a〜AC19c・AC24 をすべて満たし、条件ごとに検証手段と結果が PR 本文で対応している diff --git a/plugins/ndf/scripts/tests/test_instructions_check.py b/plugins/ndf/scripts/tests/test_instructions_check.py index 1a78c0ef2..6b9fe274c 100644 --- a/plugins/ndf/scripts/tests/test_instructions_check.py +++ b/plugins/ndf/scripts/tests/test_instructions_check.py @@ -938,14 +938,20 @@ def test_ac44_refresh_writes_nothing(tmp_path): root = make_repo(tmp_path, {"CLAUDE.md": "# c\n"}) path = criteria_file(tmp_path, sources=[ {"id": "a", "name": "A", "url": "http://127.0.0.1:1/a", "claim": "c"}]) - before = {p: p.stat().st_mtime_ns for p in root.rglob("*") if p.is_file()} + + # .git 配下は git の背景の保守が一時ファイルを作って消すため、比べる対象から外す + def snapshot(): + return {p: p.stat().st_mtime_ns for p in root.rglob("*") + if ".git" not in p.relative_to(root).parts and p.is_file()} + + before = snapshot() before[path] = path.stat().st_mtime_ns subprocess.run( [sys.executable, str(SCRIPT), "--root", str(root), "--refresh", "--criteria", str(path), "--refresh-timeout", "1"], capture_output=True, text=True, ) - after = {p: p.stat().st_mtime_ns for p in root.rglob("*") if p.is_file()} + after = snapshot() after[path] = path.stat().st_mtime_ns assert before == after diff --git a/plugins/ndf/scripts/tests/test_run_metrics_docs.py b/plugins/ndf/scripts/tests/test_run_metrics_docs.py deleted file mode 100644 index b96054161..000000000 --- a/plugins/ndf/scripts/tests/test_run_metrics_docs.py +++ /dev/null @@ -1,30 +0,0 @@ -"""実行の要約を指す文書の文言(#662 の AC24)。 - -利用者が作業ツリーを消す手順と、測定の説明の 2 か所が、要約が残ることを書いているかを見る。 -""" -from __future__ import annotations - -import pathlib -import re - -SKILLS = pathlib.Path(__file__).resolve().parents[2] / "skills" - - -def _step(text: str, number: int) -> str: - """番号付きの手順 1 つ(次の番号の行の手前まで)を取り出す。""" - m = re.search(rf"^{number}\. .*?(?=^{number + 1}\. )", text, re.MULTILINE | re.DOTALL) - assert m, f"手順 {number} が見つかりません" - return m.group(0) - - -def test_merged_step4_says_summaries_survive_the_review_worktree() -> None: - step = _step((SKILLS / "merged" / "SKILL.md").read_text(encoding="utf-8"), 4) - assert "レビュー用を消しても" in step - assert "実行の要約は残る" in step - assert "run_metrics.py aggregate" in step - - -def test_cross_review_skill_says_measure_runs_on_every_save() -> None: - lines = (SKILLS / "cross-review" / "SKILL.md").read_text(encoding="utf-8").splitlines() - [line] = [l for l in lines if l.startswith("- [scripts/measure.py]")] - assert "状態を保存するたびに実行の要約として呼ばれ" in line diff --git a/plugins/ndf/scripts/tests/test_token_guard.py b/plugins/ndf/scripts/tests/test_token_guard.py index 74ec30918..6239ae9c2 100644 --- a/plugins/ndf/scripts/tests/test_token_guard.py +++ b/plugins/ndf/scripts/tests/test_token_guard.py @@ -24,11 +24,6 @@ SCRIPT = ROOT / "scripts" / "token-guard.sh" STAGES = ROOT / "scripts" / "lib" / "token-guard-stages.txt" WF_DOCS = ROOT / "skills" / "development-workflow" -WAITING = WF_DOCS / "references" / "waiting.md" -LAYERS = WF_DOCS / "references" / "agent-layers.md" -CONTEXT = WF_DOCS / "references" / "context-window.md" -WF_SKILL = WF_DOCS / "SKILL.md" -README = ROOT / "README.md" HOOKS = ROOT / "hooks" / "claude.json" MANIFESTS = ROOT / "manifests" @@ -659,53 +654,6 @@ def test_hook_not_registered_for_other_runtimes(): # ---------------------------------------------------------------- 文書(AC1〜AC4 / AC18〜AC23) -def test_waiting_doc_lists_methods(): - text = WAITING.read_text() - for word in ("run_in_background", "Monitor", "token-guard.sh", "NDF_SLEEP_GUARD", - "NDF_READ_REPEAT_GUARD", "tasks/*.output"): - assert word in text, word - - -def _code_blocks(text): - import re - return re.findall(r"```[a-z]*\n(.*?)```", text, re.S) - - -def test_no_foreground_sleep_loop_examples(): - import re - for doc in (WAITING, LAYERS): - for block in _code_blocks(doc.read_text()): - if "run_in_background" in block: - continue - assert not re.search(r"\b(while|until)\b.*\bdo\b[^`]*\bsleep\b", block, re.S), doc - - -def test_agent_layers_refers_waiting(): - text = LAYERS.read_text() - sup = text.split("supervisor が守る規則:")[1].split("\n\n")[1] - wrk = text.split("worker が守る規則:")[1].split("\n\n")[1] - assert "waiting.md" in sup - assert "waiting.md" in wrk - - -def test_foreground_loop_docs_point_to_waiting(): - skills = ROOT / "skills" - for rel in ("external-ai/references/cli-codex.md", "external-ai/references/cli-agy.md", - "qa-security-scan/03-report-template.md", - "release/references/completion-check.md"): - assert "development-workflow/references/waiting.md" in (skills / rel).read_text(), rel - - -def test_context_window_doc(): - text = CONTEXT.read_text() - assert "実測ではない" not in text - assert "#827" in text - assert "NDF_CONTEXT_LIMIT" in text - assert "200,000" in text or "200000" in text - assert "/ndf:development-workflow #" in text - assert "stage-check.sh report" in text - assert "closedByPullRequestsReferences" in text - def test_context_limit_default_matches_doc(tmp_path, state): tp = transcript(tmp_path, 200_001) @@ -714,21 +662,6 @@ def test_context_limit_default_matches_doc(tmp_path, state): assert denied(run(skill(tp2, session="s2"), state)) is None -def test_workflow_skill_handover_rule(): - text = WF_SKILL.read_text() - assert "引き継ぎの 1 行" in text - assert "## 持ち場の報告" in text - assert "結果: 関門" in text - assert "context-window.md" in text - - -def test_readme_runtime_table(): - text = README.read_text() - assert "token-guard.sh" in text - for rt in ("Claude Code", "Codex", "Kiro", "agy"): - assert rt in text - - # ---------------------------------------------------------------- 中継の下の conductor(#895 AC23) import fcntl # noqa: E402 diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_assert_changes.py b/plugins/ndf/skills/cross-refactoring/tests/test_assert_changes.py index 923e0bc0f..ac75359b1 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_assert_changes.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_assert_changes.py @@ -192,7 +192,6 @@ def test_the_judging_prompt_asks_for_three_verdicts() -> None: / "prompts" / "judge-test-changes.md").read_text(encoding="utf-8") for verdict in ("unchanged", "changed", "undecidable"): assert verdict in prompt - assert "リポジトリを編集しない" in prompt # ---------- 判定の穴(レビューの指摘) ---------- diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_commit_trailers_git.py b/plugins/ndf/skills/cross-refactoring/tests/test_commit_trailers_git.py index 946d6f4f4..7e640bd03 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_commit_trailers_git.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_commit_trailers_git.py @@ -151,17 +151,3 @@ def test_a_commit_with_an_attribution_paragraph_passes_the_apply_check( ) assert problem is None - - -def test_the_commit_convention_asks_for_the_last_paragraph(prompts_dir): - """AC39: 適用と修正の雛形が、必須の記名を最後の段落へ置くことを書く。""" - for name in ("apply.md", "fix.md"): - text = (prompts_dir / name).read_text(encoding="utf-8") - assert "最後の段落" in text, name - assert "空行を挟まず" in text, name - - -@pytest.fixture -def prompts_dir(): - import pathlib - return pathlib.Path(__file__).resolve().parents[1] / "prompts" diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_intake.py b/plugins/ndf/skills/cross-refactoring/tests/test_intake.py index 18fda83f0..f1a04ce8d 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_intake.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_intake.py @@ -65,17 +65,12 @@ def test_the_monitor_reason_decides_whether_the_same_agent_can_be_relaunched( def test_the_stem_matches_the_template_the_orchestrator_passes_to_the_monitor(paths): """AC3: 名前の幹は、骨組みが監視へ渡す雛形を担当名で埋めた値と一致する。""" - skill = ( - paths.pathlib.Path(__file__).resolve().parents[1] / "SKILL.md" - ).read_text(encoding="utf-8") - expected = { "apply": "{agent}-apply-r$ROUND", "fix": "{agent}-fix-r$ROUND", "final-fix": "{agent}-final-fix", } for phase, template in expected.items(): - assert f'--stem-template "{template}"' in skill, phase built = template.replace("{agent}", "codex").replace("$ROUND", "2") assert paths.stem_for("codex", phase, 130, 2) == built diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_monitor_phase_calls.py b/plugins/ndf/skills/cross-refactoring/tests/test_monitor_phase_calls.py deleted file mode 100644 index 15d8599fe..000000000 --- a/plugins/ndf/skills/cross-refactoring/tests/test_monitor_phase_calls.py +++ /dev/null @@ -1,87 +0,0 @@ -"""cross-refactoring の監視の呼び出しは `--phase` を渡し、`--timeout` を渡さない(#598 / #537 の AC37 / AC38)。 - -監視の上限は上限の表(`lib/limits.py`)が工程で決める。骨組みに秒数を書くと、表だけを -直したときに食い違う。修正(`fix`)と最終ゲートの修正(`final-fix`)は `--timeout` を -持たず、変更前は既定の 420 秒で打ち切られていた。 -""" -from __future__ import annotations - -import pathlib -import re -import shutil -import subprocess - -import pytest - -SKILL = pathlib.Path(__file__).resolve().parents[1] -REPO = SKILL.parents[3] - -# 呼び出しの置き場所と数(AC37)。 -EXPECTED_CALLS = { - "SKILL.md": 4, - "docs/01-state-and-propose.md": 1, - "docs/02-apply-and-review.md": 2, - "docs/04-fix-and-report.md": 2, -} - -# 監視の雛形(stem)から、渡すべき工程を決める。 -STEM_TO_PHASE = [ - (re.compile(r"\{agent\}-propose-rf"), "propose"), - (re.compile(r"\{agent\}-apply-r"), "apply"), - (re.compile(r"\{agent\}-fix-r"), "fix"), - (re.compile(r"\{agent\}-judge-test-changes-r"), "judge-test-changes"), - (re.compile(r"\{agent\}-final-fix"), "final-fix"), -] - - -def _calls(rel: str) -> list[str]: - """`"$LIB/monitor.py"` から始まり、行末の `\\` で続く 1 つのコマンドを返す。""" - lines = (SKILL / rel).read_text(encoding="utf-8").splitlines() - calls = [] - for i, line in enumerate(lines): - if '"$LIB/monitor.py"' not in line: - continue - parts = [line] - j = i - while parts[-1].rstrip().endswith("\\"): - j += 1 - parts.append(lines[j]) - calls.append(" ".join(p.rstrip().rstrip("\\") for p in parts)) - return calls - - -@pytest.mark.parametrize(("rel", "count"), sorted(EXPECTED_CALLS.items())) -def test_every_call_site_is_found(rel: str, count: int) -> None: - assert len(_calls(rel)) == count - - -def _all_calls() -> list[tuple[str, str]]: - return [(rel, call) for rel in EXPECTED_CALLS for call in _calls(rel)] - - -@pytest.mark.parametrize(("rel", "call"), _all_calls()) -def test_the_call_passes_the_phase_of_its_stem(rel: str, call: str) -> None: - stem = re.search(r'--stem-template\s+"([^"]+)"', call) - assert stem, call - expected = [phase for pat, phase in STEM_TO_PHASE if pat.search(stem.group(1))] - assert len(expected) == 1, stem.group(1) - assert re.search(rf"--phase\s+{re.escape(expected[0])}(\s|$)", call), call - - -@pytest.mark.parametrize(("rel", "call"), _all_calls()) -def test_the_call_does_not_pass_a_timeout(rel: str, call: str) -> None: - assert not re.search(r"--timeout\b", call), call - - -@pytest.mark.skipif(shutil.which("grep") is None, reason="grep が無い") -def test_the_acceptance_grep_returns_9() -> None: - """要求の文書の AC38 のコマンドをそのまま実行する。 - - 最終ゲートの修正の監視を修正の説明へも書いたため、9 件になる(#728)。 - """ - command = ( - 'grep -rn -A2 "monitor.py" plugins/ndf/skills/cross-refactoring/SKILL.md ' - 'plugins/ndf/skills/cross-refactoring/docs | grep -c -- "--phase"' - ) - r = subprocess.run(["bash", "-c", command], cwd=REPO, capture_output=True, text=True) - assert r.stdout.strip() == "9", r.stdout + r.stderr diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_removed_review_phase.py b/plugins/ndf/skills/cross-refactoring/tests/test_removed_review_phase.py index 9581ba0f8..50974e721 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_removed_review_phase.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_removed_review_phase.py @@ -36,8 +36,3 @@ def test_the_launcher_does_not_name_the_removed_prompt() -> None: assert "review.md" not in text assert "RF_POST_EVENT_NOTE" not in text - -def test_the_review_prompt_is_gone() -> None: - """レビューの指示そのものが残っていない。""" - prompts = LAUNCH.resolve().parents[1] / "prompts" - assert not (prompts / "review.md").exists() diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py b/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py deleted file mode 100644 index 8277a2a3b..000000000 --- a/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py +++ /dev/null @@ -1,220 +0,0 @@ -"""`SKILL.md` の用語表と引数の表が 4 つのラウンドを並べること(A1 / A6)。 - -**階層はすべて「ラウンド」で表す**(#436)。4 つは同じ形を持つため読み手が覚える -形は 1 つで済むが、**何の単位か・どの上限が掛かるか**は 4 つとも違う。書いていないと、 -上限の名前からどのラウンドが切られるのかを読み解けない。 -""" -from __future__ import annotations - -import pathlib - -import pytest - -SKILL = pathlib.Path(__file__).resolve().parent.parent / "SKILL.md" - -ROUNDS = ("テスト整備ラウンド", "提案ラウンド", "適用ラウンド", "修正ラウンド") -CAPS = ("--max-test-rounds", "--max-outer-rounds", "--max-fix-rounds", - "--max-items-per-round") - - -@pytest.fixture(scope="module") -def skill() -> str: - return SKILL.read_text(encoding="utf-8") - - -def _terms_table(text: str) -> list[str]: - """用語表の行だけを取り出す。""" - lines = text.splitlines() - start = next(i for i, l in enumerate(lines) if l.startswith("## この Skill で使う語")) - rows = [] - for line in lines[start:]: - if line.startswith("## ") and rows: - break - if line.startswith("| "): - rows.append(line) - return rows - - -def test_the_four_rounds_are_listed_in_order(skill): - rows = "\n".join(_terms_table(skill)) - positions = [rows.find(name) for name in ROUNDS] - assert all(p >= 0 for p in positions), f"用語表に無いラウンドがある: {positions}" - assert positions == sorted(positions), "並びは実行の順にする" - - -def test_each_round_states_its_unit_and_cap(skill): - """それぞれ何の単位かと、上限を決めるものは何かを書く。""" - rows = _terms_table(skill) - header = rows[0] - assert "何の単位か" in header and "上限" in header - for name in ROUNDS: - row = next(r for r in rows if name in r) - cells = [c.strip() for c in row.strip("|").split("|")] - assert all(cells), f"{name} の行に空の欄がある: {row}" - - -def test_every_cap_appears_in_the_argument_table(skill): - for cap in CAPS: - assert f"`{cap} N`" in skill, f"引数の表に {cap} が無い" - - -def test_the_defaults_match_the_implementation(cmd_setup, vocabulary, skill): - """既定値は 1 か所(新規の初期化が置き換える表)が持ち、手順書の表はそれを写す。""" - assert "| `--max-test-rounds N` | " in skill - for cap in CAPS: - default = cmd_setup.NEW_RUN_DEFAULTS[cap[2:].replace("-", "_")] - row = next(l for l in skill.splitlines() if l.startswith(f"| `{cap} N`")) - assert f"`{default}`" in row, f"{cap} の既定が表と実装で食い違う" - assert vocabulary.DEFAULT_MAX_TEST_ROUNDS == 2 - - -# ---------- 参加者と担当の決め方(#727 の AC42 / AC43) ---------- - -PARTICIPANT_ARGS = ("--exclude", "--include", "--require-all") -DOC01 = SKILL.parent / "docs" / "01-state-and-propose.md" -CLAUDE_MD = SKILL.parents[4] / "CLAUDE.md" - - -def test_the_participant_arguments_are_documented(skill): - """AC43 — 引数の表と `argument-hint` に 3 つの引数がある。""" - hint = next(l for l in skill.splitlines() if l.startswith("argument-hint:")) - rows = [l for l in skill.splitlines() if l.startswith("| `--")] - for arg in PARTICIPANT_ARGS: - assert arg in hint, f"argument-hint に {arg} が無い" - assert any(r.startswith(f"| `{arg}") for r in rows), f"引数の表に {arg} が無い" - - -def test_the_assignment_section_has_one_cohort(skill): - """AC43 — 担当の決め方は母集合を 1 つの表で書き、適用専用の母集合を持たない。""" - lines = skill.splitlines() - start = lines.index("## 担当の決め方") - end = next(i for i, l in enumerate(lines[start + 1:], start + 1) if l.startswith("## ")) - section = "\n".join(lines[start:end]) - assert "impl_capable" not in section - assert sum(1 for l in lines[start:end] if l.startswith("| ---")) == 1 - assert "codex / kiro" in section and "--include agy" in section - - -def test_the_prerequisites_no_longer_demand_every_cli(skill): - """AC43 — 前提から「すべてログイン済み」が消え、要る CLI は codex / kiro-cli になる。""" - lines = skill.splitlines() - start = lines.index("## 前提") - end = next(i for i, l in enumerate(lines[start + 1:], start + 1) if l.startswith("## ")) - section = "\n".join(lines[start:end]) - assert "すべてログイン済み" not in section - assert "| Claude Code | `codex` / `kiro-cli` |" in section - - -def test_init_variables_do_not_list_the_implementation_cohort(): - """AC43 — `init` が返す変数の表に適用専用の母集合が無い。""" - assert "IMPL_POOL" not in DOC01.read_text(encoding="utf-8") - - -def test_claude_md_describes_the_participants_and_the_rotation(): - """AC42 — 指示書の cross-refactoring の節が新しい母集合と輪番を書く(#736 を含む)。""" - text = CLAUDE_MD.read_text(encoding="utf-8") - lines = text.splitlines() - start = lines.index("## cross-refactoring") - end = next(i for i, l in enumerate(lines[start + 1:], start + 1) if l.startswith("## ")) - section = "\n".join(lines[start:end]) - assert "codex / kiro とホスト(ホストが codex / kiro なら 2 者)" in section - assert "適用担当は参加者の数のラウンドで 1 周する" in section - for stale in ("ホストを除く 3 者", "参加する 4 者", "codex / agy の両方", - "既定が 4"): - assert stale not in text, f"CLAUDE.md に {stale} が残っている" - - -# ---------- 実行のコマンド列(A1 / B6) ---------- - -def _run_block(text: str) -> str: - """「## 実行」の節にある bash のコード塊を返す。""" - lines = text.splitlines() - start = next(i for i, l in enumerate(lines) if l.startswith("## 実行")) - end = next((i for i, l in enumerate(lines[start + 1:], start + 1) - if l.startswith("## ")), len(lines)) - return "\n".join(lines[start:end]) - - -def test_the_command_sequence_passes_the_propose_phase(skill): - """テスト整備ラウンドは `propose-tests` を起動する。 - - `propose` を直に書くと、テスト整備ラウンドでも構造改善のプロンプトが渡り、 - ラウンドの種類が実行に反映されない。 - """ - block = _run_block(skill) - assert '"$PROPOSE_PHASE"' in block - assert 'launch-cli.sh" "$a" propose ' not in block - - -def test_the_command_sequence_runs_the_final_gate(skill): - """B5 / B6 — Step 7 の分岐は `final-gate` が決める。""" - block = _run_block(skill) - assert "final-gate" in block - assert "FINAL_GATE" in block - - -def test_the_command_sequence_passes_the_new_caps(skill): - block = _run_block(skill) - for flag in ("--max-test-rounds", "--ci-check", "--workflow-step"): - assert flag in block, f"実行のコマンド列に {flag} が無い" - - -def test_the_command_sequence_does_not_launch_reviewers(skill): - """Step 5 はテストで判定する。**レビュー CLI は起動しない。**""" - block = _run_block(skill) - assert " review " not in block - assert "verify-round" in block - - -# ---------- 結果なしと無進捗の許容(#728 / #647 / #553) ---------- - -DOCS = SKILL.parent / "docs" - - -def test_the_apply_round_row_states_the_attempt_cap(skill): - """AC43: 適用ラウンドの行が、同じ群を開き直す上限(2 回)を書く。""" - row = next(r for r in _terms_table(skill) if "適用ラウンド" in r) - - assert "2 回" in row - - -def test_the_single_cap_paragraph_is_gone(skill): - """AC43: 上限を 1 つに保つとしていた段落が残っていないこと。""" - assert "別の上限を置かない" not in skill - assert "別に置かない" not in skill - - -def test_every_implementer_phase_passes_the_stall_timeout(skill): - """AC41: 適用・修正・最終ゲートの修正の監視が、無進捗の許容だけを受け取る。""" - for phase in ("apply", "fix", "final-fix"): - block = skill.split(f"--phase {phase}", 1)[1].split("\n\n", 1)[0] - assert '--stall-timeout "$IMPL_STALL_TIMEOUT"' in block, phase - assert "--timeout " not in block, phase - - -def test_the_apply_document_describes_a_missing_result(skill): - """AC44: 適用の説明が、結果なしのときの取り消し・記録・終了コードを書く。""" - text = (DOCS / "02-apply-and-review.md").read_text(encoding="utf-8") - - assert "実装担当が結果を残さなかったとき" in text - assert "failed_attempts" in text - assert "終了コード" in text - assert '--stall-timeout "$IMPL_STALL_TIMEOUT"' in text - - -def test_the_fix_document_describes_a_missing_result(skill): - """AC44: 修正と最終ゲートの説明が、同じ 2 つを書く。""" - text = (DOCS / "04-fix-and-report.md").read_text(encoding="utf-8") - - assert text.count('--stall-timeout "$IMPL_STALL_TIMEOUT"') == 2 - assert "修正の担当が結果を残さなかったときも、修正ラウンドは進める" in text - assert "修正の担当が結果を残さなかったときも同じ手順を通る" in text - - -def test_the_trailer_section_states_both_ways_of_reading(skill): - """AC45: 記名の節が、人の集計と進行側の検証の 2 つの読み方を書く。""" - text = (DOCS / "02-apply-and-review.md").read_text(encoding="utf-8") - section = text.split("### コミットトレーラーの形式", 1)[1].split("\n### ", 1)[0] - - assert "最後の段落だけ" in section - assert "git interpret-trailers --parse" in section diff --git a/plugins/ndf/skills/cross-review/tests/test_agy_naming.py b/plugins/ndf/skills/cross-review/tests/test_agy_naming.py deleted file mode 100644 index ed5a34b96..000000000 --- a/plugins/ndf/skills/cross-review/tests/test_agy_naming.py +++ /dev/null @@ -1,175 +0,0 @@ -"""委譲先の名前が手順書と参照で揃っていること(#214)。 - -`cross-review` は、レビュー担当の名前で一時ファイルの骨格・状態ファイルの鍵・ -監視の対象名を揃えている。名前がひとつでも残ると、どの担当を起動するのかが -読み取れなくなる。 - -過去のレビューの出所を示すコメント(`gemini round N 指摘`)は残す。実行時の -呼び出し先ではなく、その行がそう書かれている理由だからである。 -""" -from __future__ import annotations - -import pathlib -import re - -import pytest - -ROOT = pathlib.Path(__file__).resolve().parents[5] -SKILLS = ROOT / "plugins/ndf/skills" - -# 担当 A が書き換える範囲。説明文書とプラグイン定義は含まない(担当 B が持つ)。 -SCOPE = ( - "plugins/ndf/skills/cross-review", - "plugins/ndf/skills/cross-refactoring", - "plugins/ndf/skills/external-ai", - "plugins/ndf/skills/fix/SKILL.md", - "plugins/ndf/skills/issue-plan-strategy/SKILL.md", - "plugins/ndf/skills/pr-review/SKILL.md", - "plugins/ndf/skills/worktree/SKILL.md", - "plugins/ndf/skills/worktree/references", - "plugins/ndf/scripts/lib", - "plugins/ndf/scripts/worktree-guard.sh", - ".ndf/worktree.json", -) - -# 残してよい形。過去のレビューの出所を指す注記だけ。 -ALLOWED = re.compile(r"gemini\s+(?:round\s+\d+\s+)?(?:指摘|#\d+)") - - -# 走査から外すディレクトリ。tests は出所のコメントを持つため、残りは実行時に作られる -# 作業領域である。追跡していないファイルを検査へ入れると、実行した回数で結果が変わる。 -_IGNORED_DIRS = {"tests", "__pycache__", ".pytest_cache"} - - -def _scope_files() -> list[pathlib.Path]: - found: list[pathlib.Path] = [] - for entry in SCOPE: - path = ROOT / entry - if path.is_file(): - found.append(path) - continue - found.extend( - p for p in path.rglob("*") - if p.is_file() - and not _IGNORED_DIRS & set(p.relative_to(path).parts) - ) - return sorted(found) - - -# ---------- 受け入れ条件 23(起動スクリプトの名前) ---------- - -def test_the_review_launcher_is_named_after_the_reviewer() -> None: - assert (SKILLS / "cross-review/scripts/launch-agy.sh").is_file() - assert not (SKILLS / "cross-review/scripts/launch-gemini.sh").exists() - - -def test_the_procedure_points_at_the_renamed_launcher() -> None: - body = (SKILLS / "cross-review/SKILL.md").read_text(encoding="utf-8") - assert "launch-agy.sh" in body - assert "launch-gemini.sh" not in body - - -# ---------- 受け入れ条件 24(委譲先の参照) ---------- - -def test_the_reference_for_the_delegate_exists() -> None: - assert (SKILLS / "external-ai/references/cli-agy.md").is_file() - assert not (SKILLS / "external-ai/references/cli-gemini.md").exists() - - -def test_the_delegation_skill_points_at_the_reference() -> None: - body = (SKILLS / "external-ai/SKILL.md").read_text(encoding="utf-8") - assert "references/cli-agy.md" in body - - -# ---------- 受け入れ条件 25(委譲先の指定) ---------- - -def test_the_review_skill_offers_codex_and_agy() -> None: - body = (SKILLS / "pr-review/SKILL.md").read_text(encoding="utf-8") - assert "codex|agy" in body - - -# ---------- 受け入れ条件 26(残る語) ---------- - -@pytest.mark.parametrize( - "path", _scope_files(), ids=lambda p: str(p.relative_to(ROOT)) -) -def test_only_the_provenance_comments_keep_the_old_name(path: pathlib.Path) -> None: - left = [ - f"{path.relative_to(ROOT)}:{n}: {line.strip()}" - for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1) - if "gemini" in line.lower() and not ALLOWED.search(line.lower()) - ] - assert left == [] - - -# ---------- 受け入れ条件 14(共通層の削除) ---------- - -def _files_left_under(root: pathlib.Path) -> list[str]: - """`root` に残っている、読み込みうるファイルの相対パスを返す。 - - 走査から外すのは実行時に作られる作業領域(`_IGNORED_DIRS`)だけである。 - """ - return sorted( - str(p.relative_to(root)) - for p in root.rglob("*") - if p.is_file() and not _IGNORED_DIRS & set(p.relative_to(root).parts) - ) - - -def test_the_gemini_environment_helper_is_gone() -> None: - """移設した先から共通層を読み込めないこと(#280 / #285)。 - - 判定するのは**そこから読み込めるファイルが無いこと**であって、ディレクトリの - 有無ではない。移設より前に作られた `__pycache__/*.pyc` は `.gitignore` の - 対象であるため作業ディレクトリに残り、**ディレクトリだけが存在し続ける** - (#388)。存在で判定すると、v10.2.0 より前にこのリポジトリでテストを動かした - 作業ディレクトリでだけ落ちる。 - """ - assert not (SKILLS.parent / "scripts/lib/_gemini-env.sh").exists() - assert _files_left_under(SKILLS / "cross-review/scripts/lib") == [] - - -def test_leftover_caches_do_not_count_as_a_remaining_layer( - tmp_path: pathlib.Path, -) -> None: - """`__pycache__` だけが残った状態を「共通層が残っている」と読まない(#388)。""" - cache = tmp_path / "__pycache__" - cache.mkdir() - (cache / "models.cpython-313.pyc").write_bytes(b"") - - assert _files_left_under(tmp_path) == [] - - (tmp_path / "models.py").write_text("", encoding="utf-8") - assert _files_left_under(tmp_path) == ["models.py"] - - -def test_no_script_sources_the_removed_helper() -> None: - hits = [ - p for p in SKILLS.glob("**/*.sh") - if "_gemini-env" in p.read_text(encoding="utf-8") - ] - assert hits == [] - - -# ---------- 走査の対象(実行の回数で結果が変わらないこと) ---------- - -def test_the_scan_skips_directories_made_while_running(tmp_path: pathlib.Path) -> None: - """実行時に作られる作業領域を検査へ入れない。 - - `pytest` は実行のたびに `.pytest_cache/` を作り、その中の `nodeids` には - テスト関数の名前がそのまま残る。走査へ入れると、`gemini` を含む名前の - テストを 1 度でも実行した環境で結果が変わる。 - """ - for name in _IGNORED_DIRS: - target = tmp_path / name / "nested" - target.mkdir(parents=True) - (target / "artifact.txt").write_text("gemini\n", encoding="utf-8") - (tmp_path / "real.txt").write_text("agy\n", encoding="utf-8") - - scanned = [ - p for p in tmp_path.rglob("*") - if p.is_file() - and not _IGNORED_DIRS & set(p.relative_to(tmp_path).parts) - ] - - assert scanned == [tmp_path / "real.txt"] diff --git a/plugins/ndf/skills/cross-review/tests/test_critiques.py b/plugins/ndf/skills/cross-review/tests/test_critiques.py index 7c262dd01..f8e21d42a 100644 --- a/plugins/ndf/skills/cross-review/tests/test_critiques.py +++ b/plugins/ndf/skills/cross-review/tests/test_critiques.py @@ -8,6 +8,7 @@ import argparse import json import pathlib +import re import shutil import pytest @@ -857,3 +858,9 @@ def test_normal_path_builds_prompt_and_launches_the_cli(tmp_dir, tmp_path, kiro_ time.sleep(0.05) assert f"cwd={work}" in recorded assert "codex-r1-0" in recorded + + +def test_critique_round_monitors_with_the_critique_phase() -> None: + body = (SCRIPTS / "critique-round.sh").read_text(encoding="utf-8") + call = re.search(r'"\$SCRIPT_DIR/monitor\.py".*?(? None: - found = _DIRECT_EVAL.findall(path.read_text(encoding="utf-8")) - - assert not found, ( - f"{path.name}: `eval \"$(...)\"` は終了コードを 0 に潰す。" - " 変数で受けてから eval すること" - ) - - -def test_start_round_propagates_the_exit_code() -> None: - """`start-round` の呼び出しは、exit 1 で抜けそれ以外は止める形で書く。""" - text = (_SKILL_DIR / "SKILL.md").read_text(encoding="utf-8") - - assert 'ROUND_VARS=$("$SCRIPTS/state.py" start-round "$STATE_PR")' in text - assert 'exit "$RC"' in text - - -def test_unresolved_threads_propagates_the_exit_code() -> None: - """`fix` の `unresolved-threads` も変数で受けてから eval する形で書く。""" - text = (_SKILL_DIR.parent / "fix" / "SKILL.md").read_text(encoding="utf-8") - - assert 'UNRESOLVED_VARS=$(' in text - assert 'eval "$UNRESOLVED_VARS"' in text - - -# `verify-sweep` は exit 6(残件あり)だけを続行として扱い、それ以外の終了コードは -# 完了報告へ進ませない。`|| [ $? -eq 6 ]` の形は失敗のステータスを返すだけで、 -# `set -e` の無いこの手順では次の `report` が実行される。 -_SWEEP_FILES = [ - _SKILL_DIR / "SKILL.md", - _SKILL_DIR / "docs" / "02-fix-and-rotation.md", -] -_SWALLOWED_SWEEP = re.compile(r'verify-sweep[^\n]*\|\|\s*\[\s*\$\?\s+-eq\s+6\s*\]\s*$', re.MULTILINE) - - -@pytest.mark.parametrize("path", _SWEEP_FILES, ids=lambda p: f"{p.parent.name}/{p.name}") -def test_verify_sweep_does_not_swallow_an_unexpected_failure(path: pathlib.Path) -> None: - text = path.read_text(encoding="utf-8") - - assert not _SWALLOWED_SWEEP.search(text), ( - f"{path.name}: `verify-sweep ... || [ $? -eq 6 ]` は exit 6 以外の失敗も飲む。" - " 終了コードを変数で受け、6 以外は exit すること" - ) - assert 'exit "$RC"' in text diff --git a/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py b/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py index 822a81972..b1db84676 100644 --- a/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py +++ b/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py @@ -235,7 +235,6 @@ def test_non_dict_items_in_rejected_list_are_filtered_out(tmp_dir, state_mod): assert st["rounds"][-1]["fix"]["rejected"] == 5 - # ---------------- 報告 ---------------- def test_the_report_lists_the_rejected_findings(tmp_dir, state_mod, capsys): @@ -277,25 +276,3 @@ def test_the_none_line_survives_a_rejected_finding(tmp_dir, state_mod, capsys): out = capsys.readouterr().out assert "## 残 deferred nit: なし" in out assert "## 却下した指摘 (1 件)" in out - - -# ---------------- 手順書と契約 ---------------- - -SKILLS = pathlib.Path(__file__).resolve().parents[2] - - -def test_the_fix_document_asks_for_the_location(): - """`fix` が返す `rejected[]` の例が 6 項目を持つこと。""" - text = (SKILLS / "fix/SKILL.md").read_text(encoding="utf-8") - start = text.index('"rejected": [') - block = text[start:start + 400] - for key in ("path", "line", "severity", "comment_id", "summary", - "reason_for_rejection"): - assert f'"{key}"' in block, key - - -def test_the_contract_documents_the_accumulated_records(): - """状態ファイルの契約が `rejected_findings` の形を持つこと。""" - text = (SKILLS / "cross-review/docs/04-contracts.md").read_text(encoding="utf-8") - assert '"rejected_findings"' in text - assert "reason_for_rejection" in text diff --git a/plugins/ndf/skills/cross-review/tests/test_skill_bg_wait.py b/plugins/ndf/skills/cross-review/tests/test_skill_bg_wait.py deleted file mode 100644 index a9f2c066d..000000000 --- a/plugins/ndf/skills/cross-review/tests/test_skill_bg_wait.py +++ /dev/null @@ -1,125 +0,0 @@ -"""cross-review の骨組みは 600 秒を超える監視を区切って待つ(#598 / #537 の AC41 / AC42)。 - -レビューの監視の上限は 1200 秒で、Claude Code の Bash ツールの 1 回(600 秒)に収まらない。 -骨組みはレビューの監視(起動し直しも同じ行を通る、#583)と `critique-round.sh` を `bg-wait.sh run` で -背景に回し、`bg-wait.sh wait`(1 回 540 秒以内)を 124 のあいだ繰り返す。 -""" -from __future__ import annotations - -import pathlib -import re - -import pytest - -HERE = pathlib.Path(__file__).resolve().parents[1] -SKILL = HERE / "SKILL.md" -SCRIPTS = HERE / "scripts" -DOCS = HERE / "docs" - - -def _skeleton() -> list[str]: - """`SKILL.md` の収束ループの骨組み(`while :; do` を含む bash ブロック)の行。""" - blocks = re.findall(r"```bash\n(.*?)```", SKILL.read_text(encoding="utf-8"), re.DOTALL) - loops = [b for b in blocks if "while :; do" in b and "state.py\" judge" in b] - assert len(loops) == 1 - return loops[0].splitlines() - - -def _code(line: str) -> str: - return line.split("#", 1)[0] if line.lstrip().startswith("#") else line - - -LONG_RUNNERS = ('"$SCRIPTS/monitor.py"', '"$SCRIPTS/critique-round.sh"') - - -def _long_runner_lines() -> list[tuple[int, str]]: - lines = _skeleton() - return [(i, line) for i, line in enumerate(lines) - if not line.lstrip().startswith("#") and any(r in line for r in LONG_RUNNERS)] - - -def test_the_skeleton_has_the_two_long_runners() -> None: - """レビューの監視と反証の 1 ラウンド。起動し直しは同じ監視の行を通る(#583)。""" - found = _long_runner_lines() - assert len(found) == 2 - assert sum('"$SCRIPTS/monitor.py"' in line for _, line in found) == 1 - assert sum('"$SCRIPTS/critique-round.sh"' in line for _, line in found) == 1 - - -@pytest.mark.parametrize("index", range(2)) -def test_each_long_runner_is_started_by_bg_wait_run(index: int) -> None: - _, line = _long_runner_lines()[index] - runner = next(r for r in LONG_RUNNERS if r in line) - head = line.split(runner, 1)[0] - assert re.search(r'"\$SCRIPTS/bg-wait\.sh" run "[^"]+" -- $', head), line - - -@pytest.mark.parametrize("index", range(2)) -def test_each_run_is_followed_by_one_wait_on_the_same_rc(index: int) -> None: - """**待ちは 1 回の呼び出しに 1 つだけ書く。** 繰り返しを 1 回の Bash へ書くと、 - 2 回目の待ちに入った時点で合計が 600 秒を超え、ホストに打ち切られる(#683 round 2)。""" - lines = _skeleton() - i, line = _long_runner_lines()[index] - rc = re.search(r'"\$SCRIPTS/bg-wait\.sh" run ("[^"]+")', line).group(1) - following = next(l for l in lines[i + 1:] if l.strip() and not l.lstrip().startswith("#")) - assert re.search( - rf'^\s*"\$SCRIPTS/bg-wait\.sh" wait {re.escape(rc)}( --max-wait (\d+))?\s*(#|$)', - following), following - assert not re.search(r"\b(while|until|for)\b", following), following - max_wait = re.search(r"--max-wait (\d+)", following) - assert max_wait is None or int(max_wait.group(1)) <= 540 - # 呼び直すことが、その場(行内か直前の注記)に書かれている - context = "\n".join(lines[max(0, i - 6):i + 2]) - assert "124" in context and "呼び直す" in context, context - - -def test_the_skeleton_has_no_wait_loop_in_a_single_call() -> None: - for line in _skeleton(): - if "bg-wait.sh\" wait" in line or 'bg-wait.sh" wait' in line: - assert not line.lstrip().startswith(("while", "until")), line - - -def test_the_review_monitor_passes_the_review_phase() -> None: - for _, line in _long_runner_lines(): - if '"$SCRIPTS/monitor.py"' in line: - assert "--phase review" in line, line - - -def test_critique_round_monitors_with_the_critique_phase() -> None: - body = (SCRIPTS / "critique-round.sh").read_text(encoding="utf-8") - call = re.search(r'"\$SCRIPT_DIR/monitor\.py".*?(? None: - """ホストは wait を **別の Bash の呼び出し** として呼び直す。1 回に並べると 600 秒を超える。""" - text = "\n".join(_skeleton()) - assert "600 秒" in text and "別の Bash" in text - - -# ---------- AC42 ---------- - -def test_wait_review_header_states_the_table_values() -> None: - head = (SCRIPTS / "wait-review.sh").read_text(encoding="utf-8").split("set -euo pipefail")[0] - assert "1200" in head - assert "1800s" not in head and "600s" not in head - - -def test_the_skeleton_monitor_comment_states_the_table_values() -> None: - lines = _skeleton() - i, _ = _long_runner_lines()[0] - comments = "\n".join(l for l in lines[max(0, i - 6):i] if l.lstrip().startswith("#")) - assert "1200" in comments - assert "7 分" not in comments and "3 分" not in comments - - -def test_the_review_output_antipattern_states_the_table_values() -> None: - body = (DOCS / "03-review-output.md").read_text(encoding="utf-8") - line = next(l for l in body.splitlines() if "タイムアウトなしで wait" in l) - assert "1200" in line - assert "30 分既定" not in line and "10 分既定" not in line - - -def test_the_procedure_no_longer_says_seven_minutes() -> None: - body = (DOCS / "01-state-and-review.md").read_text(encoding="utf-8") - assert "既定 **7 分**" not in body diff --git a/plugins/ndf/skills/cross-review/tests/test_skill_layout.py b/plugins/ndf/skills/cross-review/tests/test_skill_layout.py index e5ed590d3..8e09282e2 100644 --- a/plugins/ndf/skills/cross-review/tests/test_skill_layout.py +++ b/plugins/ndf/skills/cross-review/tests/test_skill_layout.py @@ -1,217 +1,12 @@ -"""手順書の構成を固定する(#245)。 - -`SKILL.md` は 500 行の上限にちょうど張り付いていた。判定に関わらない説明を削らないと -1 行も足せない状態だったため、実行の途中で読まなくてよい 4 節を参照へ移した。 - -移した内容が失われていないこと、移した先への案内が手順書に残っていること、そして -上限に対する余白があることを固定する。 -""" +"""反証のプロンプトの語を固定する(#732)。""" from __future__ import annotations import pathlib -import pytest - HERE = pathlib.Path(__file__).resolve().parent.parent -SKILL = HERE / "SKILL.md" -REVIEW_OUTPUT = HERE / "docs/03-review-output.md" - -# 上限は `scripts/check-skill-frontmatter.py` の 500 行。余白を 80 行以上残す。 -SKILL_MD_MAX_LINES = 420 - -MOVED_HEADINGS = ( - "## レビュー出力の制約", - "## CI failure の分類(誤中断防止)", - "## アンチパターン", - "## monitor.py が誤って kill する場合の手順", -) - - -def test_the_skill_has_room_under_the_line_limit() -> None: - lines = len(SKILL.read_text(encoding="utf-8").splitlines()) - assert lines <= SKILL_MD_MAX_LINES, f"SKILL.md が {lines} 行" - - -def test_the_moved_document_exists() -> None: - assert REVIEW_OUTPUT.is_file() - - -@pytest.mark.parametrize("heading", MOVED_HEADINGS) -def test_the_moved_sections_are_kept(heading: str) -> None: - """移した内容が失われていないことを、見出しの有無で確かめる。""" - body = REVIEW_OUTPUT.read_text(encoding="utf-8") - assert any(line.strip() == heading for line in body.splitlines()), heading - - -@pytest.mark.parametrize("heading", MOVED_HEADINGS) -def test_the_moved_sections_are_not_left_behind(heading: str) -> None: - """同じ内容が手順書にも残っていると、片方だけが古くなる。""" - body = SKILL.read_text(encoding="utf-8") - assert not any(line.strip() == heading for line in body.splitlines()), heading - - -def test_the_skill_points_at_the_moved_document() -> None: - body = SKILL.read_text(encoding="utf-8") - assert "docs/03-review-output.md" in body - - -# ---- 手順書の分割(#330) ---- -# -# `docs/01-state-and-review.md` は 508 行で、分割の基準(501 行以上)を超えていた。 -# 契約(状態ファイルの形式と入出力の取り決め)を `docs/04-contracts.md` へ移し、 -# 手順だけを残した。 -# -# **行数の上限は `cross-review` の中だけで固定する。** 検査を -# `scripts/check-skill-frontmatter.py` へ入れると、このバッチのどの担当の範囲にも -# 入っていない文書で落ちる(#354)。 - -DOCS = HERE / "docs" -PROCEDURE = DOCS / "01-state-and-review.md" -CONTRACTS = DOCS / "04-contracts.md" - -# 分割の基準は 501 行以上。`SKILL.md` の上限と同じ値を文書にも当てる。 -# -# **手順書だけをさらに厳しく縛らない。** 分割の直後の `docs/01` には 500 行まで余白があり、 -# 次に節を足す担当(#350 の待ち行列)はその余白を使う前提で設計されている。ここで先に -# 上限を下げると、余白を使い切る前に検査が落ち、足す側がこのファイルを直すことになる。 -# 余白を超える節を足すときは、次の切れ目(Step 3〜4)を切り出す。 -DOC_MAX_LINES = 500 - -MOVED_CONTRACT_HEADINGS = ( - "## 状態ファイル", - "### 重要なフィールド", - "## AI への入出力契約(両 launcher 共通)", - "## AI が書き出すファイル契約", -) - - -def _line_count(path: pathlib.Path) -> int: - return len(path.read_text(encoding="utf-8").splitlines()) - - -@pytest.mark.parametrize( - "doc", sorted(DOCS.glob("*.md")), ids=lambda p: p.name -) -def test_the_review_docs_stay_under_the_split_threshold(doc: pathlib.Path) -> None: - lines = _line_count(doc) - assert lines <= DOC_MAX_LINES, f"{doc.name} が {lines} 行" - - -def test_the_contract_document_exists() -> None: - assert CONTRACTS.is_file() - - -@pytest.mark.parametrize("heading", MOVED_CONTRACT_HEADINGS) -def test_the_contract_sections_are_kept(heading: str) -> None: - """移した内容が失われていないことを、見出しの有無で確かめる。""" - body = CONTRACTS.read_text(encoding="utf-8") - assert any(line.strip() == heading for line in body.splitlines()), heading - - -@pytest.mark.parametrize("heading", MOVED_CONTRACT_HEADINGS) -def test_the_contract_sections_are_not_left_behind(heading: str) -> None: - """同じ内容が手順書にも残っていると、片方だけが古くなる。""" - body = PROCEDURE.read_text(encoding="utf-8") - assert not any(line.strip() == heading for line in body.splitlines()), heading - - -def test_the_procedure_points_at_the_contract_document() -> None: - assert "04-contracts.md" in PROCEDURE.read_text(encoding="utf-8") - - -def test_the_skill_points_at_the_contract_document() -> None: - assert "docs/04-contracts.md" in SKILL.read_text(encoding="utf-8") - - -# ---- 1 者指定のシェル変数で絞らない(#727 の AC30) ---- -# -# ラウンドの開始が返す担当の一覧は、1 者指定と席の埋め合わせを反映済みである。 -# シェル変数でもう一度絞ると、状態ファイルとシェル変数がずれたときに起動も監視も -# 誰にも当たらない(設計の決定 17)。1 者指定のシェル変数は初期化へ渡す 1 行にだけ残す。 - -ONLY_DOCS = (SKILL, PROCEDURE) - - -@pytest.mark.parametrize("doc", ONLY_DOCS, ids=lambda p: p.name) -def test_the_only_variable_appears_only_where_it_is_passed_to_init(doc: pathlib.Path) -> None: - offenders = [ - f"{doc.name}:{no}: {line.strip()}" - for no, line in enumerate(doc.read_text(encoding="utf-8").splitlines(), 1) - if "ONLY" in line and "--only" not in line - ] - assert offenders == [], offenders - - -@pytest.mark.parametrize("doc", ONLY_DOCS, ids=lambda p: p.name) -def test_the_reviewers_returned_by_the_round_are_used(doc: pathlib.Path) -> None: - """起動・監視・取り込み・反証は、ラウンドの開始が返す一覧を使う。""" - body = doc.read_text(encoding="utf-8") - assert "$REVIEWERS_CSV" in body - assert "${ONLY:-" not in body - - -# ---- 区分の 6 つ目「未反証」(#732) ---- -# -# 誤りを示されていない `major` を `unrefuted` として数える。規約 3 文書・反証のプロンプト・ -# 確定仕様が同じ語で書いていることを固定する(AC18〜AC20)。 - CRITIQUE_SH = HERE / "scripts/critique.sh" -EVIDENCE = DOCS / "06-evidence.md" -POOL = DOCS / "05-pool-and-convergence.md" -SPEC = HERE.parents[3] / "docs/specifications/cross-review-evidence-based.md" def test_the_critique_prompt_says_insufficient_evidence_does_not_drop_the_finding() -> None: """「立証できない」を返しても指摘は数から落ちないことを、プロンプトが担当へ言う(AC19)。""" assert "数から落ち" in CRITIQUE_SH.read_text(encoding="utf-8") - - -@pytest.mark.parametrize("doc", (EVIDENCE, CONTRACTS, POOL), ids=lambda p: p.name) -def test_the_review_docs_name_the_unrefuted_classification(doc: pathlib.Path) -> None: - assert "unrefuted" in doc.read_text(encoding="utf-8"), doc.name - - -def test_the_evidence_doc_says_the_mark_is_removed_when_critiques_are_incomplete() -> None: - assert "印を外す" in EVIDENCE.read_text(encoding="utf-8") - - -def test_the_specification_holds_the_same_six_classifications() -> None: - """区分の表と行き先の表の両方が `unrefuted` を持つ(AC20)。""" - assert SPEC.read_text(encoding="utf-8").count("unrefuted") >= 2 - - -# ---------- 起動し直しは初回と同じ経路を通る(#583 #730) ---------- - - -def _loop() -> str: - """骨組みの繰り返し(`while :; do` から `done` まで)。""" - text = SKILL.read_text(encoding="utf-8") - block = text.split("## 実行ステップ概要(メインの bash 骨組み)", 1)[1] - block = block.split("```bash", 1)[1].split("\n```", 1)[0] - return block.split("while :; do", 1)[1] - - -@pytest.mark.parametrize("step", [ - '"$SCRIPTS/launch-reviewer.sh"', - '"$SCRIPTS/state.py" read-result', - '"$SCRIPTS/state.py" verify-findings', - '"$SCRIPTS/critique-round.sh"', -]) -def test_each_step_of_a_review_is_written_once(step: str) -> None: - """起動し直しの枝が自分の起動・取り込みを持たない。経路は 1 本である(AC18)。 - - 枝が別に持つと、根拠の検証と反証を飛ばして 2 度目の判定へ進む。 - """ - assert _loop().count(step) == 1, step - - -def test_the_relaunch_goes_back_to_the_head_of_the_review() -> None: - loop = _loop() - relaunch = loop.index('"$JUDGE_RC" -eq 7') - assert "continue" in loop[relaunch:loop.index("done", relaunch)] - - -def test_the_queue_branch_is_seen_before_the_relaunch() -> None: - """待ち行列に残りがあるときの枝は、各判定の直後、7 より先に見る(AC19)。""" - loop = _loop() - assert loop.index('"$JUDGE_RC" -eq 8') < loop.index('"$JUDGE_RC" -eq 7') diff --git a/plugins/ndf/skills/cross-review/tests/test_state_rotation_head_branch.py b/plugins/ndf/skills/cross-review/tests/test_state_rotation_head_branch.py index f14bd341b..9fbbe52c6 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_rotation_head_branch.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_rotation_head_branch.py @@ -152,14 +152,3 @@ def test_only_the_current_pr_entry_is_closed_when_history_has_past_prs( assert history[2]["closed_at"] is None assert history[2]["rounds"] == 0 assert len(history) == 3 - - -def test_the_skeleton_passes_the_new_branch(state_mod) -> None: - """手順書と参照の骨組みが `--head-branch` を渡していることを固定する。""" - here = pathlib.Path(__file__).resolve().parent.parent - for name in ("SKILL.md", "docs/02-fix-and-rotation.md"): - body = (here / name).read_text(encoding="utf-8") - calls = [line for line in body.splitlines() if "set-current-pr" in line and "$NEW_PR" in line] - assert calls, f"{name} に set-current-pr の呼び出しが無い" - for line in calls: - assert "--head-branch" in line, f"{name}: {line}" diff --git a/plugins/ndf/skills/cross-review/tests/test_writes_by_conductor_docs.py b/plugins/ndf/skills/cross-review/tests/test_writes_by_conductor_docs.py deleted file mode 100644 index b90065905..000000000 --- a/plugins/ndf/skills/cross-review/tests/test_writes_by_conductor_docs.py +++ /dev/null @@ -1,52 +0,0 @@ -"""GitHub と git へ書くのをレビューを回す側だけにした決定が、文書に反映されている(#730)。 - -| 受け入れ条件 | 文書 | 消える記述 | 入る記述 | -| --- | --- | --- | --- | -| AC25 | `SKILL.md` の設計方針の表 | 担当が `gh api` で直接投稿する | 投稿の担い手とその理由 | -| AC26 | `docs/03-review-output.md` | 担当の直接投稿の決定 | 待ち行列を通す形 | -| AC27 | `docs/02-fix-and-rotation.md` | 担当の送信の行 | 取り込む側の送信 | -| AC28 | `references/context-budget.md` | 中間ペイロードがメインを通らない | 本文がプロセスの中だけを通る | -| AC29 | `docs/04-contracts.md` | — | 投稿の種別ごとの契約 | -""" -from __future__ import annotations - -import pathlib - -import pytest - -HERE = pathlib.Path(__file__).resolve().parent.parent - - -def _read(rel: str) -> str: - return (HERE / rel).read_text(encoding="utf-8") - - -def test_the_policy_table_names_who_posts() -> None: - text = _read("SKILL.md") - assert "AI 自身が `gh api` で PR に直接投稿" not in text - assert "| 投稿の担い手 |" in text - - -def test_the_review_output_goes_through_the_queue() -> None: - text = _read("docs/03-review-output.md") - assert "AI 直接投稿" not in text - assert "待ち行列" in text - - -def test_the_fix_procedure_does_not_push() -> None: - text = _read("docs/02-fix-and-rotation.md") - assert "git push origin {HEAD_BRANCH}" not in text - assert "HEAD:<ブランチ名>" in text - - -def test_the_context_budget_keeps_bodies_in_the_process() -> None: - text = _read("references/context-budget.md") - assert "中間ペイロードがメインを通らない" not in text - assert "プロセスの中だけを通り" in text - - -@pytest.mark.parametrize("kind", ["review-post", "review-reply", "thread-resolve", "pr-comment"]) -def test_the_contract_lists_each_kind_of_post(kind: str) -> None: - text = _read("docs/04-contracts.md") - section = text.split("## 投稿の種別ごとの契約", 1)[1].split("\n## ", 1)[0] - assert f"| `{kind}` |" in section diff --git a/plugins/ndf/skills/design/tests/test_design_definition_ownership.py b/plugins/ndf/skills/design/tests/test_design_definition_ownership.py deleted file mode 100644 index c2f21f3f6..000000000 --- a/plugins/ndf/skills/design/tests/test_design_definition_ownership.py +++ /dev/null @@ -1,56 +0,0 @@ -"""設計の雛形と規約の定義元を固定する(#526 / R4-002・R4-004)。""" -from __future__ import annotations - -import pathlib - - -DESIGN = pathlib.Path(__file__).resolve().parents[1] -SKILL = DESIGN / "SKILL.md" -TEMPLATE = DESIGN / "references" / "design-template.md" - - -def _section(path: pathlib.Path, heading: str) -> str: - """見出しから、同じ水準の次の見出しの手前までを返す。""" - level = heading.split(" ", 1)[0] - body = path.read_text(encoding="utf-8") - rest = body[body.index(heading) + len(heading):] - end = rest.find(f"\n{level} ") - return rest if end < 0 else rest[:end] - - -def test_all_sections_keep_their_implementation_plan_usage() -> None: - """全 10 節と実装計画での使われ方の対応を固定する。""" - section = _section(TEMPLATE, "## 各節が実装計画のどこへつながるか") - expected_rows = { - "機能一覧": "タスクを機能の単位で割る根拠になる", - "構成要素": "タスクの単位になる", - "構造": "変更の範囲を読む", - "データ構造": "移行タスクと検査タスクになる", - "入出力の契約": "契約テストのタスクになる", - "処理の流れ": "順序の依存を読む", - "非機能の実現方式": "検査タスクになる", - "決定の記録": "代替案の再検討を止める", - "テスト設計": "テストのタスクになる", - "未確認のまま残ること": "リスクとして計画へ移す", - } - - for heading, usage in expected_rows.items(): - assert f"| {heading} | {usage} |" in section - - -def test_deliverables_owns_what_each_section_contains() -> None: - """雛形は内容を再掲せず、成果物規約の定義元を指す。""" - section = _section(TEMPLATE, "## 各節が実装計画のどこへつながるか") - assert "[deliverables.md](deliverables.md) の「成果物ごとの中身」" in section - assert "| 節 | 書く内容 |" not in section - - -def test_consistency_rationales_live_only_in_the_template() -> None: - """4 モード適用と結果を残さない根拠は雛形だけが持つ。""" - template_section = _section(TEMPLATE, "## 進む前に突き合わせる対") - skill_step = _section(SKILL, "### 4. 進む前に文書の内部整合を突き合わせる") - - for rule in ("4 モードすべてで行う", "確かめた結果は残さない"): - assert rule in template_section - assert rule not in skill_step - assert "[references/design-template.md](references/design-template.md)" in skill_step diff --git a/plugins/ndf/skills/design/tests/test_documentation_read_order.py b/plugins/ndf/skills/design/tests/test_documentation_read_order.py deleted file mode 100644 index 93d7cf0b4..000000000 --- a/plugins/ndf/skills/design/tests/test_documentation_read_order.py +++ /dev/null @@ -1,52 +0,0 @@ -"""`design` SKILL の `documentation` の読む順を固定する(#526 / R3-005)。 - -**読む順(共通参照 → 出力の形の参照 1 つ)は、触る領域の表のセルが唯一の定義である。** -直後の段落が読む順を言い直していたが、同じ規則を 2 箇所に持つと、形態の追加や参照の変更の -たびに両方を直す必要がある。段落には根拠(形態固有の参照を決めるのは出力の形)だけを残す。 - -この現状固定テストは、書き換えの前後で次の 2 つが保たれることを示す。 - -1. `documentation` 行のセルが「共通参照を先に、その後に出力の形の参照を 1 つ」という読む順を持つ -2. 直後の段落が根拠だけを述べ、読む順を言い直さない -""" -from __future__ import annotations - -import pathlib - -SKILL = pathlib.Path(__file__).resolve().parents[1] / "SKILL.md" - - -def _documentation_row() -> str: - body = SKILL.read_text(encoding="utf-8") - return next( - line for line in body.split("\n") - if line.startswith("| **読み手へ渡す文書を作る**") - ) - - -def _paragraph_after_the_table() -> str: - """触る領域の表の直後、「触らない領域の参照は読まない。」の次の段落を返す。""" - body = SKILL.read_text(encoding="utf-8") - marker = "触らない領域の参照は読まない。" - rest = body[body.index(marker) + len(marker):].lstrip("\n") - return rest.split("\n\n", 1)[0] - - -def test_the_table_cell_keeps_the_read_order() -> None: - """読む順は `documentation` 行のセルが持つ。共通が先で、形の参照は 1 つ。""" - row = _documentation_row() - assert "共通の" in row - assert "出力の形に当たる参照 1 つ" in row - - -def test_the_paragraph_states_only_the_rationale() -> None: - """段落は根拠(形態固有の参照を決めるのは出力の形)だけを残す。""" - paragraph = _paragraph_after_the_table() - assert "形態固有の参照を決めるのは、触る領域ではなく出力の形である" in paragraph - - -def test_the_paragraph_does_not_restate_the_read_order() -> None: - """段落は読む順を言い直さない。読む順の定義は表のセルに一元化する。""" - paragraph = _paragraph_after_the_table() - assert "共通参照を先に読み" not in paragraph - assert "その後に対象の形のファイル" not in paragraph diff --git a/plugins/ndf/skills/development-workflow/scripts/lib/workflow-common.sh b/plugins/ndf/skills/development-workflow/scripts/lib/workflow-common.sh index 6f84cc84e..15f7a18ef 100644 --- a/plugins/ndf/skills/development-workflow/scripts/lib/workflow-common.sh +++ b/plugins/ndf/skills/development-workflow/scripts/lib/workflow-common.sh @@ -14,7 +14,7 @@ # 並びと分類は SKILL.md の「モードごとに起動する Skill」の表から導ける。 # R = 必須 / C = 条件付き / - = 対象外 # 表のセルが `—` なら対象外、`任意` か丸括弧で条件を添えたものなら条件付き、 -# それ以外は必須である。食い違いは tests/test_workflow_stage_matrix.py が拾う。 +# それ以外は必須である。表と食い違わないことはレビューで見る(#885)。 WF_MODES=$'light\toperation\tlegacy-refactor\tstandard\tdocumentation' WF_STAGE_MATRIX=$'要求と受け入れ条件\tR\tR\t-\tR\tR 作業場所の用意\tC\tC\tR\tR\tR diff --git a/plugins/ndf/skills/development-workflow/tests/test_agent_layers_doc.py b/plugins/ndf/skills/development-workflow/tests/test_agent_layers_doc.py deleted file mode 100644 index be14805e1..000000000 --- a/plugins/ndf/skills/development-workflow/tests/test_agent_layers_doc.py +++ /dev/null @@ -1,656 +0,0 @@ -"""3 層(conductor / supervisor / worker)の規約の文書を固定する(#550 の 2 本目)。 - -要求は `issues/old/milestone-18-unattended/issue-550-657-requirements.md` の AC3・AC4・AC4b・AC9〜AC19・AC64、 -形は `docs/specifications/ndf-agent-layers-unattended-run.md` が持つ。 - -**規約は文書にしか無い。** 運転そのものは Claude Code の `/goal` と `Agent` が行うため、 -このテストが確かめられるのは「規約が形を持って書かれていること」だけである。実機での -無人の通過(AC1・AC2・AC60・AC65)はリリース後テストが見る。 - -**値ではなく形を見る。** 固定費の実測値のようにリポジトリとモデルで変わる値は規約へ -書かない(AC12)ため、そこだけは「無いこと」を確かめる。 -""" -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -from workflow_helpers import SKILL_DIR - -PLUGIN_ROOT = SKILL_DIR.parents[1] -SKILL = SKILL_DIR / "SKILL.md" -REFERENCES = SKILL_DIR / "references" -LAYERS_REF = REFERENCES / "agent-layers.md" -WINDOW_REF = REFERENCES / "context-window.md" -CROSS_REVIEW = PLUGIN_ROOT / "skills" / "cross-review" / "SKILL.md" -CROSS_REVIEW_BUDGET = (PLUGIN_ROOT / "skills" / "cross-review" / "references" - / "context-budget.md") - -# 語を揃える対象(AC16)。ファイル名は変えない。 -TERM_TARGETS = (SKILL, WINDOW_REF, LAYERS_REF) - -# 分割の基準(`scripts/check-doc-line-limit.py` の LIMIT と同じ)。 -LINE_LIMIT = 500 - - -@pytest.fixture(scope="module") -def layers() -> str: - return LAYERS_REF.read_text(encoding="utf-8") - - -@pytest.fixture(scope="module") -def window() -> str: - return WINDOW_REF.read_text(encoding="utf-8") - - -# ---------- AC16: 語が 3 層と context window に揃っている ---------- - -@pytest.mark.parametrize("path", TERM_TARGETS, ids=lambda p: p.name) -def test_the_three_layer_docs_drop_the_old_words(path: Path) -> None: - """「窓」と「親」(実行の主体の意味)が残っていない。""" - body = path.read_text(encoding="utf-8") - assert "窓" not in body, f"{path.name} に「窓」が残っている" - assert "親" not in body, f"{path.name} に「親」が残っている" - - -@pytest.mark.parametrize("path", TERM_TARGETS, ids=lambda p: p.name) -def test_the_three_layer_docs_use_the_layer_words(path: Path) -> None: - body = path.read_text(encoding="utf-8") - assert "context window" in body - assert "conductor" in body and "supervisor" in body and "worker" in body - - -@pytest.mark.parametrize("path", (SKILL, *sorted(REFERENCES.glob("*.md"))), ids=lambda p: p.name) -def test_the_workflow_docs_carry_no_measured_fixed_cost(path: Path) -> None: - """規約の文書に固定費の実測値が書かれていない(AC12)。""" - for line in path.read_text(encoding="utf-8").splitlines(): - if "固定費" not in line: - continue - assert not re.search(r"[0-90-9][0-90-9,]*\s*(万|k|K)?\s*トークン", line), line - - -def test_the_skill_stays_within_the_split_limit() -> None: - """`/goal` の節を移した後も分割の基準を超えない(決定 23)。""" - assert len(SKILL.read_text(encoding="utf-8").splitlines()) <= LINE_LIMIT - - -def test_the_layers_doc_stays_within_the_split_limit() -> None: - """3 本目(中断と再開)が節を足せる余りを残す。""" - assert len(LAYERS_REF.read_text(encoding="utf-8").splitlines()) <= LINE_LIMIT - - -# ---------- AC9: 3 層の責務と持ち場の表 ---------- - -def test_the_layers_doc_tells_who_may_ask_the_human(layers: str) -> None: - """人間へ問えるのは conductor だけである(AC2 の前提・AC17)。""" - assert "AskUserQuestion" in layers - row = next(line for line in layers.splitlines() - if line.startswith("| conductor ") and "AskUserQuestion" in line) - assert "問える" in row - for layer in ("supervisor", "worker"): - other = next(line for line in layers.splitlines() if line.startswith(f"| {layer} ")) - assert "問えない" in other - - -@pytest.mark.parametrize("post", ("設計", "実装", "検査", "取り込み", "仕上げ")) -def test_the_post_table_lists_the_five_posts(layers: str, post: str) -> None: - assert re.search(rf"^\| {post} \|", layers, re.MULTILINE), post - - -def test_the_post_table_has_a_gate_column(layers: str) -> None: - header = next(line for line in layers.splitlines() - if line.startswith("| 持ち場 |") and "返す関門" in line) - for column in ("通す工程", "単位", "始まりの条件", "終わりの条件", "返す関門"): - assert column in header, column - - -def test_the_post_table_maps_onto_the_four_cut_points(layers: str) -> None: - """切れ目 4 点(`context-window.md`)と関門 2 つとの対応が読み取れる(AC9)。""" - assert "context-window.md" in layers - for cut in ("ドキュメントレビューのマージの後", "構造改善と実装レビューの前後", - "Pull Request を出した後", "配布の後"): - assert cut in layers, cut - assert "設計 Pull Request のマージ" in layers - assert "本番の系へ届く操作" in layers - - -def test_the_layers_doc_keeps_the_gate_count_at_two(layers: str) -> None: - """関門を増やさない(AC62)。""" - assert "関門は 2 つ" in layers - - -# ---------- モードごとの持ち場の組み方 ---------- - -MODE_POST_COMPOSITIONS = ( - ("standard", "上の表のまま"), - ("documentation", "設計に素材の収集と出典の確定、仕上げに体裁レビューが入る。" - "実装は執筆、検査は構造改善を含まない"), - ("legacy-refactor", "設計 Pull Request を分けないときは設計の持ち場を作らない。" - "実装は現状固定テストと段階的改善、実装レビューは `pr-review`"), - ("light", "設計の持ち場を作らない。実装は計画を含まず、検査は構造改善を含まない。" - "本番の承認が要らなければ仕上げも作らない"), - ("operation", "設計が**本番の系へ届く実行の前で関門を返す**。" - "実装は実行([operation-run.md](operation-run.md))"), -) - - -@pytest.mark.parametrize(("mode", "composition"), MODE_POST_COMPOSITIONS) -def test_each_mode_keeps_its_current_post_composition( - layers: str, mode: str, composition: str) -> None: - """各モードで省略・追加する持ち場、工程、関門の現状を固定する。""" - assert f"| `{mode}` | {composition} |" in layers - - -def test_omitted_post_work_and_start_condition_move_together(layers: str) -> None: - """作らない持ち場の工程と開始条件は、次の持ち場へ一緒に移る。""" - assert ("作らない持ち場の工程は、次の持ち場の先頭へ入る。" - "始まりの条件も一緒に移る。") in layers - assert "関門を返す\n持ち場は、中身が少なくても作る。" in layers - - -# ---------- AC4: supervisor の報告の 10 項目 ---------- - -SUPERVISOR_REPORT = ( - "持ち場", "課題", "結果", "関門", "次の持ち場", "Pull Request", - "最後に記録した工程", "使った worker", "提示物", "理由", -) - - -@pytest.mark.parametrize("item", SUPERVISOR_REPORT) -def test_the_supervisor_report_carries_all_ten_items(layers: str, item: str) -> None: - block = layers.split("## 持ち場の報告", 1) - assert len(block) > 1, "`## 持ち場の報告` の見出しが無い" - assert f"- {item}: " in block[1], item - - -def test_the_supervisor_report_names_the_next_post(layers: str) -> None: - """報告の中に次に起動する持ち場の名前が入る(AC4)。""" - assert "次の持ち場" in layers - assert "工程表を読まずに" in layers - - -# ---------- AC17: worker の報告の 5 項目 ---------- - -WORKER_REPORT = ("作業", "結果", "見つけたもの", "置き場所", "次にすること") - - -@pytest.mark.parametrize("item", WORKER_REPORT) -def test_the_worker_report_carries_all_five_items(layers: str, item: str) -> None: - block = layers.split("## 作業の報告", 1) - assert len(block) > 1, "`## 作業の報告` の見出しが無い" - assert f"- {item}: " in block[1], item - - -def test_the_worker_is_a_leaf(layers: str) -> None: - """worker は人間へ問わず、別のサブエージェントを起動しない(AC17)。""" - assert "別のサブエージェントを起動しない" in layers - - -# ---------- AC18: worker の報告を conductor へ転送しない ---------- - -def test_the_worker_report_is_not_forwarded(layers: str) -> None: - assert "worker の報告をそのまま conductor へ渡さない" in layers - assert "conductor が 1 つの持ち場について読む報告は 1 件である" in layers - - -# ---------- AC3: supervisor は承認を求めず、取り消せない操作をしない ---------- - -def test_the_supervisor_returns_the_gate_without_asking(layers: str) -> None: - assert "承認を求めずに" in layers - assert "結果: 関門" in layers - - -def test_the_supervisor_never_merges_the_design_pull_request(layers: str) -> None: - assert "設計 Pull Request をマージしない" in layers - assert re.search(r"承認[^\n]*無いまま行わない", layers) - - -# ---------- AC15: 関門以外の確認は提示して進める ---------- - -def test_the_lower_layers_present_and_proceed(layers: str) -> None: - assert "関門以外の Skill の確認は、提示して進める" in layers - assert "確認待ちで止まらない" in layers - - -# ---------- AC5: 報告の形を持たない終わりは 3 回まで続けさせる ---------- - -def test_the_upper_layer_resends_three_times_at_most(layers: str) -> None: - assert "SendMessage" in layers - assert "3 回" in layers - assert "4 回目は送らず" in layers - - -def test_the_conductor_counts_the_resends_per_post(layers: str) -> None: - assert "報告なしで続けさせた回数" in layers - - -# ---------- AC4b: 止まるときに持ち場の一覧を 1 回出す ---------- - -def test_the_conductor_prints_the_post_list_when_it_stops(layers: str) -> None: - assert "## 持ち場の一覧" in layers - for moment in ("`AskUserQuestion` を出す前", "失敗を報告する前", - "背景の待ちを起動して応答を終える前"): - assert moment in layers, moment - - -# ---------- AC10: モデルの既定と落とす基準 2 つ ---------- - -def test_the_model_default_follows_the_upper_layer(layers: str) -> None: - assert re.search(r"`model`[^\n]*省", layers) - - -def test_the_model_may_be_lowered_only_on_two_conditions(layers: str) -> None: - block = layers.split("軽いモデルへ落として", 1) - assert len(block) > 1, "軽いモデルへ落とす基準が無い" - tail = block[1][:800] - assert "1." in tail and "2." in tail, tail - assert "調査" in tail and "集計" in tail - - -# ---------- AC19: 委譲しない 5 つ ---------- - -NOT_DELEGATED = ( - "モード判定", "関門の判断", "収束の判定", - "設計の決定と理由の記録", "受け入れ条件の書き換え", -) - - -@pytest.mark.parametrize("item", NOT_DELEGATED) -def test_the_five_undelegated_judgements_stay_above_the_worker(layers: str, item: str) -> None: - assert item in layers, item - - -def test_the_undelegated_judgements_belong_to_the_upper_layers(layers: str) -> None: - assert re.search(r"(supervisor か conductor|supervisor(または conductor))", layers) - - -# ---------- 起動の指示の description の形(測定が持ち場を読めるようにする) ---------- - -def test_the_launch_instruction_fixes_the_description_head(layers: str) -> None: - """測定は `description` の先頭語から持ち場と作業の種類を読む(決定 14)。""" - assert "<持ち場>: <課題番号" in layers - assert "<作業の種類>: <一言>" in layers - assert "その他" in layers - - -@pytest.mark.parametrize("word", ("設計", "実装", "検査", "取り込み", "仕上げ", - "調査", "修正", "検証", "集計")) -def test_the_launch_vocabulary_matches_the_measurement(layers: str, word: str) -> None: - assert word in layers, word - - -# ---------- AC64: 並行の本数を数える単位 ---------- - -def test_the_parallel_unit_is_the_supervisor(layers: str) -> None: - assert "supervisor 1 つ" in layers - assert "1 つの作業ツリー" in layers - assert re.search(r"同時に(動かす|走る)\s*worker[^\n]*既定", layers) - - -# ---------- AC14: `/goal` でない呼び出しでは 3 層へ出さない ---------- - -def test_the_three_layers_apply_only_to_goal(layers: str, ) -> None: - assert "`/goal` の引数として呼ばれたときだけ" in layers - skill = SKILL.read_text(encoding="utf-8") - assert "agent-layers.md" in skill - - -def test_the_goal_section_keeps_only_the_entrance() -> None: - """細部は参照文書が持つ(決定 23)。""" - skill = SKILL.read_text(encoding="utf-8") - section = skill.split("## `/goal` の引数として呼ばれたとき", 1)[1].split("\n## ", 1)[0] - assert "agent-layers.md" in section - assert len(section.splitlines()) <= 40, "節が長い。細部は参照文書へ移す" - assert "他者の承認が要るときは、到達点を置き直す" not in section - - -def test_the_goal_section_keeps_the_two_gates() -> None: - skill = SKILL.read_text(encoding="utf-8") - section = skill.split("## `/goal` の引数として呼ばれたとき", 1)[1].split("\n## ", 1)[0] - assert "AskUserQuestion" in section - assert "設計 Pull Request のマージ" in section - assert "本番の系へ届く操作" in section - - -def test_the_goal_target_reset_moved_to_the_layers_doc(layers: str) -> None: - """到達点の置き直しは参照文書が持つ。""" - assert "到達点を置き直す" in layers - assert "gh api" in layers - - -def test_the_target_reset_table_maps_results_to_targets(layers: str) -> None: - """他者承認が要る・要らない・判定できないの 3 結果と到達点の対応を固定する。""" - section = layers.split("## 到達点を置き直す", 1)[1].split("\n## ", 1)[0] - # 1. 到達点の判定表を行単位で読み取る - rows = { - parts[0].strip(): parts[1].strip() - for line in section.splitlines() - if line.startswith("|") - for parts in [line.split("|")[1:-1]] - if len(parts) == 2 and parts[0].strip() not in ("結果", "---") - } - assert set(rows.keys()) == {"承認が要る", "要らない", "判定できない"} - - # 2. 承認が要る場合は Pull Request の提出とレビュー収束へ置き直す現状を比較する - assert rows["承認が要る"] == "**Pull Request の提出とレビューの収束**へ置き直す" - - # 3. 承認不要の場合と判定不能の場合は元のゴールまで進み、判定不能ではマージ拒否時に置き直す現状を比較する - assert rows["要らない"] == "ゴール条件が指す工程まで" - assert rows["判定できない"] == "ゴール条件が指す工程まで進み、マージが拒否された時点で置き直す" - - -# ---------- 報告から conductor の次動作への対応表(AC4・AC5) ---------- - -def _conductor_action_rows(layers: str) -> dict[tuple[str, str], str]: - """「conductor が見るのは、…」の表を (見出しの有無, 結果) → 動き で読み取る。""" - section = layers.split("conductor が見るのは、見出しの有無と", 1)[1] - section = section.split("\n**supervisor が worker", 1)[0] - rows: dict[tuple[str, str], str] = {} - for line in section.splitlines(): - if not line.startswith("|"): - continue - parts = [cell.strip() for cell in line.split("|")[1:-1]] - if len(parts) != 3: - continue - if parts[0] in ("見出しの有無", "---"): - continue - rows[(parts[0], parts[1])] = parts[2] - return rows - - -def test_the_conductor_action_table_covers_the_four_results(layers: str) -> None: - """見出し無し・完了・関門・止まったの 4 入力が表にそろっている。""" - rows = _conductor_action_rows(layers) - assert set(rows.keys()) == { - ("無い", "—"), - ("ある", "`完了`"), - ("ある", "`関門`"), - ("ある", "`止まった`"), - } - - -def test_the_conductor_resends_when_the_heading_is_missing(layers: str) -> None: - """見出しが無いときは SendMessage で続けさせる現状を固定する。""" - rows = _conductor_action_rows(layers) - assert rows[("無い", "—")] == ( - "`SendMessage` で続けさせる(下の「報告が無いまま終わったとき」)") - - -def test_the_conductor_launches_the_next_post_on_done(layers: str) -> None: - """完了では次の持ち場を起動し、無しなら到達の報告に至る現状を固定する。""" - rows = _conductor_action_rows(layers) - assert rows[("ある", "`完了`")] == ( - "`次の持ち場` を起動する。`無し` なら到達の報告") - - -def test_the_conductor_reports_and_ends_when_stopped(layers: str) -> None: - """止まったでは理由を添えて利用者へ報告し終える現状を固定する。""" - rows = _conductor_action_rows(layers) - assert rows[("ある", "`止まった`")] == "`理由` を添えて利用者へ報告し、終える" - - -def test_the_gate_branches_on_the_gate_value_not_the_mode(layers: str) -> None: - """関門では設計 Pull Request のマージと本番操作の二経路が関門値で分かれる。""" - action = _conductor_action_rows(layers)[("ある", "`関門`")] - assert "`AskUserQuestion` で承認を求める" in action - # 設計 Pull Request のマージは conductor がマージしてから次の持ち場を起動する - assert ("`設計 Pull Request のマージ` なら conductor がマージしてから " - "`次の持ち場` を起動する") in action - # 本番の系へ届く操作はマージせずに次の持ち場を起動する - assert ("`本番の系へ届く操作` ならマージせずに `次の持ち場` を起動する") in action - # 分岐の基準はモード名でも持ち場の名前でもなく、関門値である - assert "モードや持ち場の名前から分岐しない" in action - - -# ---------- AC13: モデルに依る目安とリポジトリに依る固定費 ---------- - -def test_the_window_doc_separates_the_model_guideline_from_the_repository_cost(window: str) -> None: - assert "モデルに依る" in window - assert "リポジトリに依る" in window - - -def test_the_window_doc_ties_its_guideline_to_the_measurement_default(window: str) -> None: - """`skill-stats --window-limit` の既定(200000)と「遅くとも N 万で切る」が一致する。""" - match = re.search(r"遅くとも\s*([0-9]+)\s*万で切る", window) - assert match, "「遅くとも N 万で切る」が無い" - assert int(match.group(1)) * 10000 == 200000 - - -# ---------- AC12: 粒度の基準は比で、supervisor に当てる ---------- - -def test_the_grain_rule_is_a_ratio_applied_to_the_supervisor(window: str) -> None: - assert "実作業が固定費を上回る" in window - assert "束ね" in window - block = window.split("実作業が固定費を上回る", 1)[0][-600:] - assert "supervisor" in block, "比の基準を当てる層が supervisor だと書かれていない" - - -def test_the_window_doc_points_at_the_three_layer_operation(window: str) -> None: - assert "agent-layers.md" in window - - -# ---------- AC11: cross-review の「メイン」の定義 ---------- - -def test_cross_review_defines_main_as_the_driving_supervisor() -> None: - body = CROSS_REVIEW_BUDGET.read_text(encoding="utf-8") - block = body.split("「メイン」", 1) - assert len(block) > 1, "「メイン」の定義が無い" - tail = block[1][:500] - assert "収束ループを駆動している supervisor" in tail - assert "conductor" in tail - - -def test_cross_review_keeps_the_diff_out_of_the_supervisor_window() -> None: - body = CROSS_REVIEW_BUDGET.read_text(encoding="utf-8") - assert "worker" in body - assert re.search(r"supervisor の context window に diff を載せない", body) - - -def test_cross_review_points_at_the_definition() -> None: - """手順書は 420 行の余白を保つため、定義は参照文書が持つ。""" - skill = CROSS_REVIEW.read_text(encoding="utf-8") - assert "「メイン」が何を指すか" in skill - assert "references/context-budget.md" in skill - - -# ========== #657: 中断と再開(AC40〜AC49) ========== - -def _interruption_section(layers: str) -> str: - block = layers.split("## 中断と再開", 1) - assert len(block) > 1, "`## 中断と再開` の節が無い" - return block[1] - - -def _conductor_interruption_check_section(layers: str) -> str: - section = _interruption_section(layers) - block = section.split("### conductor の中断の点検", 1) - assert len(block) > 1, "`### conductor の中断の点検` の節が無い" - return block[1].split("\n### ", 1)[0] - - -# ---------- AC40: 通知ではなく記録で見分け、契機は 4 つ ---------- - -def test_the_interruption_is_told_apart_by_the_record_not_the_notification( - layers: str) -> None: - section = _interruption_section(layers) - assert "通知ではなく記録で見分ける" in section - assert "apiErrorStatus" in section - assert "429" in section - - -@pytest.mark.parametrize("trigger", ( - "failed", "自動の継続", "人の入力", "背景の待ちの終わり", -)) -def test_the_check_has_four_triggers(layers: str, trigger: str) -> None: - assert trigger in _interruption_section(layers), trigger - - -def test_the_other_api_errors_are_kept_apart_from_the_rate_limit(layers: str) -> None: - section = _interruption_section(layers) - assert "server_error" in section - assert "authentication_failed" in section - - -# ---------- AC41: 解除時刻は記録から取る。固定の間隔で待たない ---------- - -def test_the_reset_time_comes_from_the_record(layers: str) -> None: - section = _interruption_section(layers) - assert "quotaLimits.resetsAt" in section or "resets_at" in section - assert "固定の間隔で待たない" in section - - -def test_the_conductor_normally_waits_without_a_max_sleep(layers: str) -> None: - section = _conductor_interruption_check_section(layers) - assert "手順 4 は `--max-sleep` を付けない" in section - - -def test_the_conductor_rechecks_only_steps_one_and_four_when_waiting_is_cut( - layers: str) -> None: - section = _conductor_interruption_check_section(layers) - exception = section.split("背景の待ちを数時間続けられないと分かったときだけ", 1) - assert len(exception) > 1, "長時間待機できない場合の例外が無い" - rule = exception[1].split("。", 1)[0] - assert "`--max-sleep 540`" in rule - assert "終了コード 3" in rule - assert "手順 1 と手順 4 だけ" in rule - assert "手順 2" not in rule and "手順 3" not in rule - - -# ---------- AC49: 落ちた層ごとの 3 通りの表 ---------- - -def _fallen_layer_rows(layers: str) -> dict[str, list[str]]: - """「落ちた層」の表を 落ちた層 → 残りの列 で読み取る。""" - section = _interruption_section(layers) - rows: dict[str, list[str]] = {} - for line in section.splitlines(): - if not line.startswith("| "): - continue - parts = [cell.strip() for cell in line.split("|")[1:-1]] - if len(parts) != 4 or parts[0] in ("落ちた層", "---"): - continue - if parts[0] in ("worker", "supervisor", "conductor"): - rows[parts[0]] = parts[1:] - return rows - - -def test_the_three_fallen_layers_have_a_row_each(layers: str) -> None: - assert set(_fallen_layer_rows(layers)) == {"worker", "supervisor", "conductor"} - - -def test_the_fallen_layer_table_names_its_four_columns(layers: str) -> None: - section = _interruption_section(layers) - header = next(line for line in section.splitlines() - if line.startswith("| 落ちた層 |")) - for column in ("落ちた層", "検知する側", "再開する側", "起こす手段"): - assert column in header, column - - -def test_the_supervisor_is_resumed_by_the_conductor(layers: str) -> None: - detects, resumes, how = _fallen_layer_rows(layers)["supervisor"] - assert "conductor" in detects and "conductor" in resumes - assert "SendMessage" in how - - -def test_the_worker_is_resumed_by_its_launcher(layers: str) -> None: - _, resumes, _ = _fallen_layer_rows(layers)["worker"] - assert "supervisor" in resumes - - -def test_the_conductor_is_woken_from_outside(layers: str) -> None: - detects, _, how = _fallen_layer_rows(layers)["conductor"] - assert "人" in detects or "Claude Code" in detects - assert "自動の継続" in how - - -# ---------- AC42: 再開は直下だけ。続けられないときの後段は層で分かれる ---------- - -def test_the_upper_layer_resumes_only_its_direct_partners(layers: str) -> None: - section = _interruption_section(layers) - assert "直下だけ" in section - assert "--depth 1" in section - - -def test_the_conductor_does_not_reach_the_workers_of_a_supervisor(layers: str) -> None: - assert "conductor が supervisor の下の worker を直接再開しない" in ( - _interruption_section(layers)) - - -def test_the_fallback_differs_by_layer(layers: str) -> None: - section = _interruption_section(layers) - # supervisor は進行の記録が指す工程の頭から起動し直す - assert "最後に記録した工程" in section or "進行の記録が指す工程の頭" in section - # worker は同じ作業でもう一度起動する - assert "同じ作業" in section - # 失敗の判定は SendMessage の結果である - assert '"success": true' in section - - -# ---------- AC44: 解除まで再開しない。見回りに頼らない ---------- - -def test_the_upper_layer_waits_until_the_reset_time(layers: str) -> None: - section = _interruption_section(layers) - assert "wait-reset" in section - assert "見回り" in section - - -# ---------- 決定 20: 解除を待つ手段が 3 段 ---------- - -def _waiting_steps(layers: str) -> dict[str, str]: - block = _interruption_section(layers).split("### 解除を待つ手段", 1) - assert len(block) > 1, "「解除を待つ手段」の小節が無い" - section = block[1].split("\n### ", 1)[0] - rows: dict[str, str] = {} - for line in section.splitlines(): - if not line.startswith("| "): - continue - parts = [cell.strip() for cell in line.split("|")[1:-1]] - if len(parts) == 3 and parts[0] in ("1", "2", "3"): - rows[parts[0]] = parts[1] - return rows - - -def test_the_wait_has_three_steps(layers: str) -> None: - steps = _waiting_steps(layers) - assert set(steps) == {"1", "2", "3"} - assert "自動の継続" in steps["1"] - assert "wait-reset" in steps["2"] or "背景" in steps["2"] - assert "1 通" in steps["3"] - - -# ---------- AC45: 人の 1 通は承認ではない ---------- - -def test_the_single_message_from_the_human_is_not_an_approval(layers: str) -> None: - section = _interruption_section(layers) - assert "承認ではなく" in section - assert "関門の数に数えない" in section or "関門に数えない" in section - - -# ---------- AC48: 済んだ外部への書き込みを重ねない ---------- - -def test_the_resumed_partner_does_not_write_twice(layers: str) -> None: - section = _interruption_section(layers) - assert "既に書いたもの" in section - for written in ("Pull Request", "コメント", "進行の記録"): - assert written in section, written - - -# ---------- 決定 21: StopFailure フックを使わない ---------- - -def test_the_stop_failure_hook_is_not_used(layers: str) -> None: - assert "StopFailure" in _interruption_section(layers) - - -# ---------- conductor の報告に「上限の中断から再開した回数」の列が入る ---------- - -def test_the_post_list_counts_the_resumptions_after_a_rate_limit(layers: str) -> None: - header = next(line for line in layers.splitlines() - if line.startswith("| 持ち場 |") and "報告なしで続けさせた回数" in line) - assert "上限の中断から再開した回数" in header - - -# ---------- この規約は無人でない進行にも効く(決定 22) ---------- - -def test_the_interruption_rule_applies_to_any_launched_partner(layers: str) -> None: - assert "上の層が起動した相手" in _interruption_section(layers) diff --git a/plugins/ndf/skills/development-workflow/tests/test_approval_gates.py b/plugins/ndf/skills/development-workflow/tests/test_approval_gates.py deleted file mode 100644 index cf492e2ae..000000000 --- a/plugins/ndf/skills/development-workflow/tests/test_approval_gates.py +++ /dev/null @@ -1,229 +0,0 @@ -"""承認の関門と、並行開発の下限が書かれていることを固定する(#424 の E / F)。 - -**関門は 2 つで、増やさない。** 増やすほど「承認したこと」の意味が薄れる。通過の -回数が増えると、内容を読まずに通す動きが入る。 - -**2 つは要否の決まり方が違う。** 設計 Pull Request のマージはマージ先のチャネルに -よらず要り、本番の系へ届く操作だけが**届く先が本番の系かどうか**で決まる。表と規則を -並べて書くと片方に掛かる規則が両方に掛かって読めるため、**関門ごとに節を分ける**。 - -**2 つ目の関門は配布と運用モードの実行の両方を含む(#423)。** 性質が同じもの -(反映した瞬間に効き、取り消しても「その状態を見た人」は戻らない)を別の関門として -数えると、数だけが増える。 -""" -from __future__ import annotations - -import re - -from workflow_helpers import SKILL_DIR - -SKILL = SKILL_DIR / "SKILL.md" -APPROVAL = SKILL_DIR / "references" / "approval-request.md" -PARALLEL = SKILL_DIR / "references" / "parallel-work.md" -PLUGIN_ROOT = SKILL_DIR.parents[1] -PR_SKILL = PLUGIN_ROOT / "skills" / "pr" / "SKILL.md" -MERGED_SKILL = PLUGIN_ROOT / "skills" / "merged" / "SKILL.md" - - -def skill() -> str: - return SKILL.read_text(encoding="utf-8") - - -def approval() -> str: - return APPROVAL.read_text(encoding="utf-8") - - -def parallel() -> str: - return PARALLEL.read_text(encoding="utf-8") - - -# --- E1 / E2 / E2-b: 関門は 2 つ -------------------------------------------- - - -def test_there_are_exactly_two_gates() -> None: - body = skill() - assert re.search(r"関門は\s*\*{0,2}2 つ", body) - assert "設計 Pull Request のマージ" in body - assert "本番の系へ届く操作" in body - - -def test_the_design_gate_does_not_depend_on_the_channel() -> None: - """承認するのは配布の可否ではなく、この設計で実装へ進んでよいかである。""" - body = skill() - assert "マージ先のチャネルによらず" in body - - -def test_the_release_gate_depends_on_the_destination() -> None: - """要否は「届く先が本番の系かどうか」で決まる。`operation` はマージ経路を持たない。""" - body = skill() - assert re.search(r"届く先が本番の系かどうかで決まる", body) - assert "チャネルは系の一種である" in body - - -def test_the_two_gates_have_their_own_sections() -> None: - """規則を並べて書くと、片方に掛かる規則が両方に掛かって読める。""" - body = skill() + approval() - assert body.count("### 設計 Pull Request のマージ") >= 1 - assert body.count("### 本番の系へ届く操作") >= 1 - - -def test_the_implementation_merge_is_not_a_gate() -> None: - body = skill() - assert "実装 Pull Request のマージは、それ自体では関門にならない" in body - - -def test_merged_and_pr_point_at_the_rule() -> None: - """規則は `release` が持つ。読む側は `merged` と `pr` から辿れる。""" - for path in (PR_SKILL, MERGED_SKILL): - body = path.read_text(encoding="utf-8") - assert "チャネル" in body, path - assert "release" in body, path - - -# --- E3 / E4: 本番のチャネルの宣言 ------------------------------------------ - - -def test_the_production_channel_is_declared_by_the_repository() -> None: - body = skill() - assert "production_branch" in body - assert ".ndf/worktree.json" in body - - -def test_the_base_branch_is_not_borrowed() -> None: - body = skill() - assert re.search(r"`base_branch`\s*は.{0,30}流用しない", body, re.DOTALL) - - -def test_without_a_declaration_the_default_branch_is_used() -> None: - body = skill() - assert re.search(r"宣言が無.{0,40}既定ブランチ", body, re.DOTALL) - - -# --- E5〜E8: 提示物 --------------------------------------------------------- - - -def test_parallel_pull_requests_are_approved_in_one_go() -> None: - body = skill() + approval() - assert re.search(r"まとめて\s*\*{0,2}1 回", body) - - -def test_every_url_is_listed_raw() -> None: - body = approval() - assert "生の URL" in body - assert re.search(r"すべて|全て", body) - - -def test_the_order_of_multiple_pull_requests_is_defined() -> None: - body = approval() - assert "並べ方" in body - - -def test_a_dependency_order_is_shown_as_a_merge_order() -> None: - body = approval() - assert "マージの順序" in body - - -# --- E9: `/goal` の止まり方 ------------------------------------------------- - - -def test_the_goal_stop_matches_the_two_gates() -> None: - body = skill() - section = body[body.index("## `/goal` の引数として呼ばれたとき") :] - assert "AskUserQuestion" in section - assert "本番" in section - - -# --- E10 / E11 / F: 並行開発 ------------------------------------------------ - - -def test_the_method_is_left_to_the_person_in_charge() -> None: - body = parallel() - assert "担当の判断に任せる" in body - - -def test_multiple_milestones_are_sequential_by_default() -> None: - body = parallel() - assert re.search(r"マイルストーン.{0,80}順次", body, re.DOTALL) - - -def test_more_than_one_issue_can_be_handed_over() -> None: - body = parallel() - assert "複数の課題" in body - - -def test_the_four_shapes_are_listed() -> None: - body = parallel() - for shape in ("1 : 1", "1 : N", "N : 1", "N : M"): - assert shape in body, shape - - -def test_the_boundary_with_issue_plan_strategy_is_written() -> None: - body = parallel() - assert "issue-plan-strategy" in body - assert "工程の単位" in body - - -def test_every_stage_says_which_unit_it_moves_in() -> None: - """工程表の各行が、どの単位で動くかを持つ。 - - **工程の一覧をここへ写さない。** 写すと工程を足したときに片方だけが古くなる。 - `SKILL.md` の工程表を読み、その行がすべて `parallel-work.md` にあることを見る。 - """ - body = parallel() - stages = _stages_from_the_workflow_table() - assert len(stages) >= 15, stages - for stage in stages: - assert re.search(rf"^\|\s*{re.escape(stage)}\s*\|", body, re.MULTILINE), stage - - -def _stages_from_the_workflow_table() -> list[str]: - """`SKILL.md` の「モードごとに起動する Skill」の表から工程名を読む。""" - lines = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8").splitlines() - start = next(i for i, ln in enumerate(lines) - if ln.strip() == "## モードごとに起動する Skill") - stages: list[str] = [] - header_seen = False - for line in lines[start + 1:]: - stripped = line.strip() - if stripped.startswith("## "): - break - if not stripped.startswith("|"): - if stages: - break - continue - cells = [c.strip() for c in stripped.strip("|").split("|")] - if not header_seen: - header_seen = True - continue - if set("".join(cells)) <= set("-: "): - continue - stages.append(cells[0]) - return stages - - -LOWER_BOUNDS = ( - ("F5", "モードの違う課題を 1 本の Pull Request へ混ぜない"), - ("F6", "Pull Request ごとにモードを判定して記録する"), - ("F7", "判定したモードの必須の工程を飛ばさない"), - ("F8", "依存する工程が終わる前に、それを入力にする工程を始めない"), - ("F9", "競合を解いた差分を、レビューを通さずにマージしない"), - ("F10", "収束レビューが回る範囲"), -) - - -def test_the_six_lower_bounds_are_written() -> None: - body = parallel() - for name, text in LOWER_BOUNDS: - assert text in body, f"{name}: {text}" - - -def test_it_says_which_bounds_the_machine_watches() -> None: - """振り分けを書かないと、手順の側にある下限が「機械が見ているはず」と読まれる。""" - body = parallel() - assert "機械" in body - assert "手順" in body - assert re.search(r"^\|.*機械.*\|", body, re.MULTILINE) - - -def test_the_skill_points_at_the_reference() -> None: - assert "references/parallel-work.md" in skill() diff --git a/plugins/ndf/skills/development-workflow/tests/test_declaration_check.py b/plugins/ndf/skills/development-workflow/tests/test_declaration_check.py index 0c6ed4cf2..8f3c1bd24 100644 --- a/plugins/ndf/skills/development-workflow/tests/test_declaration_check.py +++ b/plugins/ndf/skills/development-workflow/tests/test_declaration_check.py @@ -19,9 +19,7 @@ SKILL = SKILL_DIR / "SKILL.md" PLUGIN_ROOT = SKILL_DIR.parents[1] SCRIPTS_DIR = PLUGIN_ROOT / "scripts" -WORKTREE_SKILL = PLUGIN_ROOT / "skills" / "worktree" / "SKILL.md" COMMON_LIB = SCRIPTS_DIR / "lib" / "worktree-common.sh" -WORKFLOW_GUARD = SKILL_DIR / "scripts" / "workflow-guard.sh" JUDGE_HEADING = "## 判定の手順" STEP0_HEADING = "### 0. " @@ -46,15 +44,6 @@ def step0_bash() -> str: return block.group(1) -def table_row(code: str) -> str: - """手順 0 の表から、1 列目が終了コード `code` で始まる行を返す。""" - for line in step0().splitlines(): - cells = [c.strip() for c in line.split("|")] - if len(cells) > 2 and cells[1].startswith(code): - return line - raise AssertionError(f"手順 0 の表に終了コード {code} の行が無い") - - def run_step0(cwd: Path, scripts: str | None) -> subprocess.CompletedProcess: env = os.environ.copy() env["LC_ALL"] = "C.UTF-8" @@ -67,69 +56,9 @@ def run_step0(cwd: Path, scripts: str | None) -> subprocess.CompletedProcess: ) -# --- 条件 4: 手順 1 より前にあり、2 のときは worktree の手順 0 を通す --------- - - -def test_step0_comes_before_step1() -> None: - body = skill() - judge = body.index(JUDGE_HEADING) - step0_at = body.index(STEP0_HEADING, judge) - assert judge < step0_at < body.index(STEP1_HEADING, judge) - assert "worktree-setup.sh\" check" in step0_bash() - - -def test_received_values_declare_scripts() -> None: - """`$SCRIPTS` を使う以上、決め方を「この文書が受け取る値」で示す。""" - body = skill() - head = body.index("## この文書が受け取る値") - assert head < body.index(JUDGE_HEADING) - section = body[head: body.index(JUDGE_HEADING)] - assert "`$SCRIPTS`" in section - assert "references/scripts-lookup.md" in section - - -def test_missing_declaration_goes_through_worktree_step0() -> None: - row = table_row("2") - assert "worktree" in row and "0. 宣言ファイルを用意する" in row - assert "0 を返してから" in row - - -def test_the_output_example_carries_the_declaration_line() -> None: - body = skill() - output = body[body.index("### 3. 判定結果を出力する"):] - example = re.search(r"^```text\n(.*?)^```$", output, re.S | re.M) - assert example is not None - lines = example.group(1).splitlines() - assert lines[0].startswith("mode:") and lines[1].startswith("根拠:") - assert lines[2].startswith("宣言: "), lines[:3] - - -def test_the_stage_order_line_is_unchanged() -> None: - """手順 0 は工程ではない。工程の並びへ書き足さない(実装で決めた B)。""" - body = skill() - order = next(line for line in body.splitlines() if "要求と受け入れ条件 → モード判定" in line) - assert "宣言" not in order, order - - -# --- 条件 9: 拒否しない理由 ------------------------------------------------------ - - -def test_step0_does_not_refuse_and_says_why() -> None: - section = step0() - assert "拒否しない" in section - assert "引き金" in section, "起動の時点に止める引き金が無いこと" - assert "4 ランタイム" in section - assert "#565" in section - - # --- 条件 10: 判定の実体は 1 か所 -------------------------------------------------- -def test_step0_and_workflow_guard_do_not_inspect_the_file() -> None: - assert "worktree.json" not in step0() - assert "worktree.json" not in WORKFLOW_GUARD.read_text(encoding="utf-8") - - def test_unreadable_is_classified_only_in_the_common_lib() -> None: hits = sorted( p.relative_to(PLUGIN_ROOT).as_posix() @@ -139,31 +68,9 @@ def test_unreadable_is_classified_only_in_the_common_lib() -> None: assert hits == [COMMON_LIB.relative_to(PLUGIN_ROOT).as_posix()], hits -# --- 条件 11: 読めない宣言では進まない --------------------------------------------- - - -def test_unreadable_declaration_stops_without_force() -> None: - row = table_row("3") - assert "進まない" in row - assert "--force" not in row.replace("`init --force` を実行せず", "") - assert "実行せず" in row - - # --- 条件 12: 判定できないときは止めない ------------------------------------------- -def test_undecidable_does_not_stop_and_is_reported() -> None: - row = table_row("1") - assert "止めずに" in row - assert "宣言: 判定できない" in row - commands = "\n".join( - line for line in step0_bash().splitlines() if not line.lstrip().startswith("#") - ) - assert not re.search(r"(^|[;&|{]\s*|\bthen\s+|\belse\s+)exit\b", commands, re.M), ( - "手順 0 は exit しない" - ) - - def test_step0_without_scripts_reports_undecidable(tmp_path: Path) -> None: repo = init_repo(tmp_path / "main") got = run_step0(repo, scripts=None) @@ -178,12 +85,3 @@ def test_step0_reports_the_exit_code_of_check(tmp_path: Path) -> None: assert got.returncode == 0, got.stderr assert got.stdout.splitlines()[-1] == "exit=2", got.stdout assert not (repo / ".ndf").exists(), "確認は宣言を作らない" - - -# --- worktree から check へ辿れる -------------------------------------------------- - - -def test_worktree_step0_points_to_check() -> None: - body = WORKTREE_SKILL.read_text(encoding="utf-8") - section = body[body.index("## 0. 宣言ファイルを用意する"): body.index("## 1. 現在地を確かめる")] - assert "worktree-setup.sh check" in section diff --git a/plugins/ndf/skills/development-workflow/tests/test_document_restructuring_stage.py b/plugins/ndf/skills/development-workflow/tests/test_document_restructuring_stage.py index fe342e710..d64c2de14 100644 --- a/plugins/ndf/skills/development-workflow/tests/test_document_restructuring_stage.py +++ b/plugins/ndf/skills/development-workflow/tests/test_document_restructuring_stage.py @@ -1,7 +1,6 @@ """工程「ドキュメント再構成」の追加と、工程名の改名 2 件を固定する(#391)。 -工程名の並びそのものは `test_stage_values.py` と `test_workflow_stage_matrix.py` が 4 か所で -突き合わせる。ここが見るのは、その並びの外にある 3 つである。 +ここが見るのは、工程名の並びの外にある 3 つである。 | 見るもの | なぜ並びの検査に載らないか | | --- | --- | @@ -15,7 +14,6 @@ from __future__ import annotations import re -from pathlib import Path import pytest @@ -126,12 +124,3 @@ def test_the_skill_directory_exists() -> None: # --- C11: 言語ごとの数え方 -------------------------------------------------- - - -def test_the_skill_does_not_enumerate_the_language_files() -> None: - """言語を 1 つ足すときに他のファイルを変更しない。一覧を持つと SKILL.md が動く。""" - body = (SKILL_DIR.parent / "document-restructuring" / "SKILL.md").read_text(encoding="utf-8") - references = Path(SKILL_DIR.parent / "document-restructuring" / "references") - assert list(references.glob("lang-*.md")), "言語ごとの参照が 1 本も無い" - for path in references.glob("lang-*.md"): - assert path.name not in body, f"SKILL.md が {path.name} を名指ししている" diff --git a/plugins/ndf/skills/development-workflow/tests/test_documentation_mode.py b/plugins/ndf/skills/development-workflow/tests/test_documentation_mode.py index 2283b2a6c..76235f9ff 100644 --- a/plugins/ndf/skills/development-workflow/tests/test_documentation_mode.py +++ b/plugins/ndf/skills/development-workflow/tests/test_documentation_mode.py @@ -3,8 +3,6 @@ **足すのは 5 番目のモードと 2 つの工程である。** 既存の 4 モードの判定と、16 個の工程の 並びは変わらない。この検査は新しい値だけを見て、既存の値が動いていないことも同時に見る。 -工程表と分類表の突き合わせは `test_workflow_stage_matrix.py` が、工程名の並びの 4 箇所の -一致は `test_stage_values.py` が持つ。ここでは重ねない。 """ from __future__ import annotations diff --git a/plugins/ndf/skills/development-workflow/tests/test_operation_mode.py b/plugins/ndf/skills/development-workflow/tests/test_operation_mode.py index 2f8316fdd..d9d6e1cc6 100644 --- a/plugins/ndf/skills/development-workflow/tests/test_operation_mode.py +++ b/plugins/ndf/skills/development-workflow/tests/test_operation_mode.py @@ -11,22 +11,12 @@ from __future__ import annotations import re -from pathlib import Path from workflow_helpers import LIB, SKILL_DIR, run_lib -SKILL = SKILL_DIR / "SKILL.md" -REFS = SKILL_DIR / "references" -MODES_REF = REFS / "workflow-modes.md" -OPERATION_REF = REFS / "operation-run.md" -APPROVAL_REF = REFS / "approval-request.md" -STAGE_NOTES_REF = REFS / "stage-notes.md" PROJECTS_COMMON = SKILL_DIR.parents[1] / "scripts" / "lib" / "projects-common.sh" -SPEC = SKILL_DIR.parents[3] / "docs" / "specifications" / "ndf-workflow-unit-and-gates.md" EXPECTED_MODES = ["light", "operation", "legacy-refactor", "standard", "documentation"] -JUDGEMENT_HEADING = "### 2. 上から順に条件を判定する" -WORKFLOW_TABLE_HEADING = "## モードごとに起動する Skill" # `operation` 列の期待値。工程表の全行に値が入る(受け入れ条件 B3)。 # **行数を書かない。** 工程を足すたびに書き換える記述を残さない。 @@ -56,10 +46,6 @@ def lib() -> str: return LIB.read_text(encoding="utf-8") -def skill() -> str: - return SKILL.read_text(encoding="utf-8") - - def declared_modes() -> list[str]: found = re.search(r"WF_MODES=\$'([^']*)'", lib()) assert found, f"モードの一覧を読み取れない: {LIB}" @@ -73,35 +59,6 @@ def declared_heights() -> list[tuple[str, int]]: return [(name, int(value)) for name, value in rows] -def _table_rows(body: str, heading: str) -> tuple[list[str], list[list[str]]]: - lines = body.splitlines() - try: - start = next(i for i, line in enumerate(lines) if line.strip() == heading) - except StopIteration: - raise AssertionError(f"見出しが見つからない: {heading}") - header: list[str] = [] - rows: list[list[str]] = [] - for line in lines[start + 1 :]: - stripped = line.strip() - if stripped.startswith("#"): - if rows: - break - continue - if not stripped.startswith("|"): - if rows: - break - continue - cells = [c.strip() for c in stripped.strip("|").split("|")] - if not header: - header = cells - continue - if set("".join(cells)) <= set("-: "): - continue - rows.append(cells) - assert header and rows, f"表を読み取れない: {heading}" - return header, rows - - # --- B1: 一覧と判定の基準の表 ------------------------------------------------ @@ -109,40 +66,6 @@ def test_the_mode_is_declared() -> None: assert "operation" in declared_modes() -def test_the_judgement_table_gained_a_row() -> None: - """判定の基準の表に 1 行加わる。""" - _, rows = _table_rows(skill(), JUDGEMENT_HEADING) - modes = [row[1].strip("`") for row in rows] - assert "operation" in modes, modes - - -def test_the_mode_is_judged_first() -> None: - """アクセス権の設定は条件だけ見れば `standard` に当たる。工程は当たらない。""" - _, rows = _table_rows(skill(), JUDGEMENT_HEADING) - assert rows[0][1].strip("`") == "operation", rows[0] - assert rows[0][0] == "1", rows[0] - - -def test_the_judgement_order_is_numbered_from_one() -> None: - _, rows = _table_rows(skill(), JUDGEMENT_HEADING) - assert [row[0] for row in rows] == ["1", "2", "3", "4", "5"] - - -def test_the_condition_is_the_state_of_an_external_system() -> None: - _, rows = _table_rows(skill(), JUDGEMENT_HEADING) - condition = next(row[2] for row in rows if row[1].strip("`") == "operation") - assert "外部の系" in condition, condition - assert "本番コードも文書も変えず" in condition, condition - - -def test_the_judgement_does_not_name_a_hosting_feature() -> None: - """判定の根拠に、その機能を持たない対象では成り立たない名前を使わない。""" - _, rows = _table_rows(skill(), JUDGEMENT_HEADING) - condition = next(row[2] for row in rows if row[1].strip("`") == "operation") - for name in ("GitHub", "ruleset", "GitLab", "AWS", "Slack"): - assert name not in condition, name - - # --- A9 / 決定 10: 並びと高さ ------------------------------------------------ @@ -205,11 +128,6 @@ def test_the_mode_is_accepted_by_the_library() -> None: # --- B3: 工程表の列 ---------------------------------------------------------- -def test_the_workflow_table_gained_a_column_in_height_order() -> None: - header, _ = _table_rows(skill(), WORKFLOW_TABLE_HEADING) - assert [c.strip("`") for c in header[1:]] == EXPECTED_MODES - - def test_every_stage_has_a_value_for_the_new_column() -> None: """工程表の全行に必須・条件付き・対象外のいずれかが入る。""" for stage, expected in EXPECTED_OPERATION_COLUMN.items(): @@ -232,20 +150,6 @@ def test_stage_class_rejects_an_unknown_stage_without_output() -> None: assert result.stdout == "" -def test_the_column_covers_the_whole_workflow_table() -> None: - """期待値の側が工程表と同じ行を持つ。行が増えたら、この検査が先に落ちる。""" - _, rows = _table_rows(skill(), WORKFLOW_TABLE_HEADING) - assert [row[0] for row in rows] == list(EXPECTED_OPERATION_COLUMN) - - -def test_the_implementation_stage_points_at_the_run_reference() -> None: - """`operation` の「実装」は実行そのものである。呼ぶ手順は参照が持つ。""" - header, rows = _table_rows(skill(), WORKFLOW_TABLE_HEADING) - column = header.index("`operation`") - cell = next(row[column] for row in rows if row[0] == "実装") - assert "operation-run.md" in cell, cell - - # --- 盤面の値 ---------------------------------------------------------------- @@ -254,144 +158,3 @@ def test_the_board_accepts_the_new_mode() -> None: found = re.search(r"PJ_MODES=\$'([^']*)'", body) assert found, f"モードの一覧を読み取れない: {PROJECTS_COMMON}" assert found.group(1).split("\\n") == EXPECTED_MODES - - -# --- B2: 境界事例 ------------------------------------------------------------ - - -def test_the_boundary_cases_include_the_new_mode() -> None: - body = MODES_REF.read_text(encoding="utf-8") - rows = [line for line in body.splitlines() if line.startswith("|")] - hits = [line for line in rows if "`operation`" in line] - assert len(hits) >= 3, hits - - -def test_the_boundary_with_standard_is_written() -> None: - """本番コードを触るなら `operation` の条件を外れる。""" - body = MODES_REF.read_text(encoding="utf-8") - assert re.search(r"本番コードを変えるため.{0,20}`operation`", body) - - -def test_the_boundary_with_light_is_written() -> None: - body = MODES_REF.read_text(encoding="utf-8") - assert "外部の系を触らない" in body - - -def test_the_reference_has_a_section_for_the_mode() -> None: - body = MODES_REF.read_text(encoding="utf-8") - assert "### `operation`" in body - - -# --- B4: 関門 ---------------------------------------------------------------- - - -def test_the_gate_covers_both_release_and_the_run() -> None: - """関門は 2 つのまま。2 つ目が配布と運用モードの実行の両方を含む。""" - body = skill() - assert "本番の系へ届く操作" in body - assert re.search(r"配布の工程、および\s*`operation`\s*の実装の工程", body) - - -def test_the_gate_is_decided_by_the_destination() -> None: - body = skill() - assert "届く先が本番の系かどうかで決まる" in body - - -def test_the_gate_count_did_not_change() -> None: - body = skill() - assert re.search(r"関門は\s*\*{0,2}2 つ", body) - - -def test_the_approval_reference_lists_what_to_show_for_the_run() -> None: - body = APPROVAL_REF.read_text(encoding="utf-8") - assert "operation" in body - assert "取り消せない単位" in body - - -def test_the_specification_follows_the_same_criterion() -> None: - """Skill の本文と確定仕様が同じ基準を述べる(受け入れ条件 D8)。""" - body = SPEC.read_text(encoding="utf-8") - assert "本番の系へ届く操作" in body - assert "届く先が本番の系かどうかで決まる" in body - assert "本番の系" in body.split("## 用語")[1].split("## 背景")[0] - - -# --- B5 / B6 / B7 / B8: 実行の手順 ------------------------------------------- - - -def operation_ref() -> str: - return OPERATION_REF.read_text(encoding="utf-8") - - -def test_the_run_reference_exists_and_is_linked() -> None: - assert OPERATION_REF.is_file() - assert "references/operation-run.md" in skill() - - -def test_the_run_is_split_into_units() -> None: - body = operation_ref() - assert "単位" in body - assert re.search(r"##\s*1\.\s*実行の範囲", body) - - -def test_each_unit_keeps_the_command_output_and_exit_code() -> None: - """B5. 保全先と書式が定まり、コマンド・出力・終了コードを残す。""" - body = operation_ref() - for token in ("コマンド", "出力", "終了コード"): - assert token in body, token - assert "終了コード: 0" in body - - -def test_a_failure_stops_the_run( ) -> None: - """B6. 失敗したらその時点で止め、部分適用の範囲を確定させる。""" - body = operation_ref() - assert "その時点で止める" in body - assert "部分適用の範囲" in body - assert "戻したかどうか" in body - - -def test_an_irreversible_unit_is_declared_before_the_run() -> None: - """B7. 取り消せない操作を含むときの扱い。""" - body = operation_ref() - assert "取り消せない単位は、実行の前にそのことを示す" in body - assert "影響が及ぶ範囲" in body - - -def test_the_record_location_is_searched_not_assumed() -> None: - """B8. 対象リポジトリを仮定しない。候補は順序ではなく一覧である。""" - body = operation_ref() - assert "候補は順序ではなく一覧" in body - assert "見つからないときは" in body - assert "issues/" not in body - assert "docs/" not in body - - -def test_the_verification_method_is_searched_not_named() -> None: - body = operation_ref() - assert "照会の手段を名指ししない" in body - assert "確かめられない" in body - - -def test_secrets_are_kept_out_of_the_record() -> None: - body = operation_ref() - assert "機微情報" in body - assert "値を書かず" in body - - -def test_the_run_reference_stays_within_the_line_limit() -> None: - """分割の基準は 501 行以上(`markdown-writing`)。""" - assert len(operation_ref().splitlines()) <= 500 - - -def test_the_stage_notes_list_the_conditional_stages() -> None: - """条件付きの 4 つは、発動する条件を持つ。""" - body = STAGE_NOTES_REF.read_text(encoding="utf-8") - section = body[body.index("## `operation` の工程") :] - for stage in ("作業場所の用意", "確定仕様化", "リリース後テスト", "振り返り"): - assert re.search(rf"^\|\s*{re.escape(stage)}\s*\|", section, re.MULTILINE), stage - - -def test_the_quality_gate_has_a_definition_of_done() -> None: - dod = Path(SKILL_DIR.parents[0] / "quality-gates" / "references" / "definition-of-done.md") - body = dod.read_text(encoding="utf-8") - assert "## `operation`" in body diff --git a/plugins/ndf/skills/development-workflow/tests/test_parallel_work_bounds.py b/plugins/ndf/skills/development-workflow/tests/test_parallel_work_bounds.py deleted file mode 100644 index 5c0ea2a8f..000000000 --- a/plugins/ndf/skills/development-workflow/tests/test_parallel_work_bounds.py +++ /dev/null @@ -1,265 +0,0 @@ -"""並行の本数と重なりの下限(`parallel-work.md`、#621 / #540)。 - -**この文書は本数の初期値を持たない。** 予備・1 本の見込み・上限・スワップの閾値を -決めるのは `plugins/ndf/scripts/parallel-measure.py` の定数だけで、文書が写すと -2 か所が同じ事実を持つ(設計の決定 7)。 - -`test_approval_gates.py` は下限の**存在**(F5〜F10)を見る。こちらは下限 4〜6 の -**理由と目安**を見る。同じファイルの別の節に当たるため、束が分かれても衝突しない。 -""" -from __future__ import annotations - -import re - -from workflow_helpers import SKILL_DIR - -PARALLEL = SKILL_DIR / "references" / "parallel-work.md" -MEASURE = SKILL_DIR.parents[1] / "scripts" / "parallel-measure.py" - - -def parallel() -> str: - return PARALLEL.read_text(encoding="utf-8") - - -def flat(text: str) -> str: - """折り返しの改行を除く。日本語の文は改行の位置で語が割れる。""" - return text.replace("\n", "").replace("**", "") - - -# --- 下限 6: ホストのメモリ(AC30 / AC36) ---------------------------------- - - -def test_the_sixth_bound_names_the_host_memory() -> None: - """CLI の共有だけを理由にすると、1 本ずつ回しても落ちる事故を説明できない。""" - body = parallel() - assert "ホストのメモリ" in body - assert "並行の本数は、測った本数と収束レビューが回る範囲の小さい方に抑える" in body - - -def test_the_sixth_bound_keeps_the_measured_incident() -> None: - """21GiB のホストで 5〜6 本のとき本体が 2 回落ち、3 本では落ちなかった(#621)。""" - body = parallel() - assert "21GiB" in body - assert "5〜6 本" in body - assert "3 本では落ちなかった" in body - assert "#621" in body - - -def test_the_bounds_name_both_releases_they_came_from() -> None: - """下限 6 の理由は v10.11.0 の事故(5〜6 本で本体が落ちた)から来ている。""" - body = parallel() - assert "いずれも v10.5.0 と v10.11.0 の事故か、その予防に当たる" in body - - -def test_a_lane_is_one_worktree() -> None: - """数えるのは作業ツリー 1 つ分の担当で、その中の補助の実行主体は数えない(決定 26)。""" - body = parallel() - assert "作業ツリー 1 つ分の担当" in body - - -def test_only_one_lane_runs_container_checks() -> None: - """コンテナは担当の cgroup の外で動き、1 本の見込みの外側で空きを食う(決定 9)。""" - assert "コンテナを起動する検査を持つ担当は同時に 1 本にする" in parallel() - - -# --- 工程が動く単位 ---------------------------------------------------------- - - -def test_stage_units_have_the_three_current_values() -> None: - rows = _stage_unit_rows() - assert {row[1] for row in rows} == {"課題", "Pull Request", "まとまり"} - - -def test_the_release_group_stages_move_as_one_group() -> None: - rows = _stage_unit_rows() - assert {row[0] for row in rows if row[1] == "まとまり"} == { - "確定仕様化", "配布", "体裁レビュー", "リリース後テスト", "振り返り", - } - - -def test_requirements_and_acceptance_criteria_move_per_issue() -> None: - rows = _stage_unit_rows() - assert {row[0] for row in rows if row[1] == "課題"} == {"要求と受け入れ条件"} - - -def _stage_unit_rows() -> list[list[str]]: - lines = parallel().splitlines() - start = next(i for i, line in enumerate(lines) - if line.strip() == "## 工程が動く単位") - rows: list[list[str]] = [] - for line in lines[start + 1:]: - stripped = line.strip() - if stripped.startswith("## "): - break - if not stripped.startswith("|"): - continue - cells = [cell.strip() for cell in stripped.strip("|").split("|")] - if cells == ["工程", "単位", "理由"] or set("".join(cells)) <= set("-: "): - continue - rows.append(cells) - return rows - - -# --- 振り分け: 測定は機械、判断は手順(決定 8) ------------------------------ - - -def test_the_measurement_is_machine_and_the_judgement_is_procedure() -> None: - body = parallel() - assert "測定は機械、判断は手順" in body - assert "parallel-measure.py capacity" in body - - -def test_the_measurement_does_not_refuse_a_launch() -> None: - """担当は Agent ツールで起動され、フックで数えられない(決定 8)。""" - assert "起動は拒否しない" in parallel() - - -def test_bounds_1_to_3_are_watched_by_machine() -> None: - """下限 1〜3 は Pull Request 作成時に機械が案内する。""" - by_bound = {row[0]: row[2] for row in _watcher_rows()} - for bound in ("1", "2", "3"): - assert "機械" in by_bound[bound] - - -def test_bounds_4_and_5_are_watched_by_procedure() -> None: - """下限 4・5 は手順(実行計画・後からマージする側の実装レビュー)が見る。""" - by_bound = {row[0]: row[2] for row in _watcher_rows()} - for bound in ("4", "5"): - assert "手順" in by_bound[bound] - - -def test_bound_6_splits_measurement_and_judgement() -> None: - """下限 6 は測定をスクリプトが行い、判断を担当自身が下す。""" - by_bound = {row[0]: row[2] for row in _watcher_rows()} - assert "測定は機械、判断は手順" in by_bound["6"] - - -def _watcher_rows() -> list[list[str]]: - lines = parallel().splitlines() - start = next(i for i, line in enumerate(lines) - if line.strip() == "## 機械が見るものと、手順として書くもの") - rows: list[list[str]] = [] - for line in lines[start + 1:]: - stripped = line.strip() - if stripped.startswith("## "): - break - if not stripped.startswith("|"): - continue - cells = [cell.strip() for cell in stripped.strip("|").split("|")] - if cells == ["#", "下限", "誰が見るか", "どこで"] or set("".join(cells)) <= set("-: "): - continue - rows.append(cells) - return rows - - -# --- 初期値を持つのは 1 か所だけ(AC33 / 決定 7) --------------------------- - - -def test_the_document_does_not_copy_the_defaults() -> None: - body = parallel() - for value in ("2048", "25%"): - assert value not in body, f"初期値 {value} は parallel-measure.py だけが持つ" - - -def test_the_script_holds_the_defaults() -> None: - source = MEASURE.read_text(encoding="utf-8") - for name in ("DEFAULT_RESERVE_MIB", "DEFAULT_PER_LANE_MIB", "DEFAULT_MAX_LANES", - "DEFAULT_SWAP_FREE_MIN_PCT"): - assert re.search(rf"^{name} = \d+$", source, re.MULTILINE), name - - -# --- 下限 4: 依存は工程の対で見る(AC5) ----------------------------------- - - -def test_the_fourth_bound_is_a_pair_of_stages() -> None: - """依存は「B のどの工程が、A のどの時点を入力にするか」で決まる(決定 2)。""" - body = flat(parallel()) - assert "依存する工程が終わる前に、それを入力にする工程を始めない" in body - assert "B の設計が A の決定を読むなら A の設計の収束を" in body - assert "B の実装が A の実装を前提にするなら A の実装のマージを待つ" in body - - -def test_an_implementation_order_does_not_block_the_next_design() -> None: - """v10.11.0 の後半 3 回は、この 2 つを区別しなかったために設計が実装を待った。""" - assert "実装の順序の依存は、B の設計を止めない" in flat(parallel()) - - -# --- 下限 5 と重なりの目安(AC6 / AC7 / AC8) ------------------------------- - - -def test_the_fifth_bound_is_about_the_resolved_diff() -> None: - """守るものは「レビューした差分と入る差分が食い違わない」ことである(決定 3)。""" - body = flat(parallel()) - assert "競合を解いた差分を、レビューを通さずにマージしない" in body - assert "同じファイルを触るものを並行させない" not in body - - -def test_the_later_merge_resolves_the_conflict() -> None: - """先にマージした側は、解く時点で既に閉じている(決定 3)。""" - body = flat(parallel()) - assert "後からマージする側が解き、解いた後の head でレビューを収束させる" in body - - -def test_there_is_a_section_for_the_overlap_guide() -> None: - assert "## 重なりの目安" in parallel() - - -def test_the_overlap_guide_has_three_kinds() -> None: - """区分は設計の時点で分かる最も細かい単位(節)で切る(決定 4)。""" - table = _overlap_table() - assert [row[0] for row in table] == ["別の節", "足すだけ", "書き換え"], table - - -def test_the_overlap_guide_says_whether_each_kind_runs_in_parallel() -> None: - """区分ごとに、並行してよいかと、並行しないときに何を待つかが一意に決まる。""" - header, *rows = _overlap_rows() - assert header == ["区分", "何が当たるか", "並行", "待つもの"], header - verdicts = {row[0]: (row[2], row[3]) for row in rows} - assert verdicts["別の節"] == ("してよい", "なし") - assert verdicts["足すだけ"][0] == "してよい" - assert "実装はしない" in verdicts["書き換え"][0] - assert "先の Pull Request のマージ" in verdicts["書き換え"][1] - - -def test_designs_run_in_parallel_whatever_the_kind() -> None: - """設計文書は束ごとに別のファイルへ書く(AC8)。""" - body = flat(parallel()) - assert "設計の工程どうしは、どの区分でも並行してよい" in body - - -def test_the_kind_is_fixed_after_the_design() -> None: - """着手の時点の触る場所は見込みで、確定の値は実行計画が持つ(決定 4)。""" - body = flat(parallel()) - assert "区分は設計の後に確定する" in body - assert "実行計画" in body - - -def _overlap_rows() -> list[list[str]]: - lines = parallel().splitlines() - start = next(i for i, ln in enumerate(lines) if ln.strip() == "## 重なりの目安") - rows: list[list[str]] = [] - for line in lines[start + 1:]: - stripped = line.strip() - if stripped.startswith("## "): - break - if not stripped.startswith("|"): - continue - cells = [c.strip() for c in stripped.strip("|").split("|")] - if set("".join(cells)) <= set("-: "): - continue - rows.append(cells) - return rows - - -def _overlap_table() -> list[list[str]]: - return _overlap_rows()[1:] - - -# --- `issue-plan-strategy` との境界(決定 5) ------------------------------- - - -def test_the_execution_plan_belongs_to_issue_plan_strategy() -> None: - """置き場所・形・見直す時点・閉じ方は `issue-plan-strategy` が持つ。""" - body = flat(parallel()) - assert "実行計画" in body - assert "issue-plan-strategy" in body diff --git a/plugins/ndf/skills/development-workflow/tests/test_stage_values.py b/plugins/ndf/skills/development-workflow/tests/test_stage_values.py deleted file mode 100644 index 451acf385..000000000 --- a/plugins/ndf/skills/development-workflow/tests/test_stage_values.py +++ /dev/null @@ -1,118 +0,0 @@ -"""工程の並びが 4 か所で一致していることを固定する(#231)。 - -`development-workflow` の工程表が判定の基準を持つ唯一の場所である。対応表も盤面の値の -一覧も、その表から作られる。片方だけが更新されると、工程を盤面へ記録できなくなる。 -実際に「ドキュメントレビュー」が値の一覧から抜けており、その工程にいることを記録できなかった。 - -突き合わせる先は 4 つある。 - -| 突き合わせる先 | 何を読むか | -| --- | --- | -| 工程表 | `SKILL.md` の「モードごとに起動する Skill」の表の 1 列目 | -| 対応表の工程の列 | `references/projects-tracking.md` の「工程と値の対応」の表の 1 列目 | -| 対応表の値の列 | 同じ表の 3 列目 | -| 値の一覧 | `plugins/ndf/scripts/lib/projects-common.sh` の並び | - -**並びまで見る。** 盤面の単一選択は並びを持ち、その並びが工程の順序を表す。順序が -入れ替わっても集合は一致するため、集合だけでは食い違いを拾えない。 - -読み取れないこと自体も失敗として扱う。素通りさせると、表の書き方を変えるだけでこの検査を -無効にできる。 -""" -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -SKILL_DIR = Path(__file__).resolve().parents[1] -SKILL = SKILL_DIR / "SKILL.md" -TRACKING = SKILL_DIR / "references/projects-tracking.md" -COMMON = SKILL_DIR.parents[1] / "scripts/lib/projects-common.sh" - -WORKFLOW_TABLE_HEADING = "## モードごとに起動する Skill" -TRACKING_TABLE_HEADING = "## 工程と値の対応" - - -def _table_rows(body: str, heading: str) -> list[list[str]]: - """見出しの直後に来る表の本文の行を、セルの並びとして返す。""" - lines = body.splitlines() - try: - start = next(i for i, line in enumerate(lines) if line.strip() == heading) - except StopIteration: - raise AssertionError(f"見出しが見つからない: {heading}") - rows: list[list[str]] = [] - seen_header = False - for line in lines[start + 1 :]: - stripped = line.strip() - if stripped.startswith("## "): - break - if not stripped.startswith("|"): - if rows: - break - continue - cells = [c.strip() for c in stripped.strip("|").split("|")] - if not seen_header: - seen_header = True - continue - if set("".join(cells)) <= set("-: "): - continue - rows.append(cells) - assert rows, f"表を読み取れない: {heading}" - return rows - - -def workflow_stages() -> list[str]: - return [row[0] for row in _table_rows(SKILL.read_text(encoding="utf-8"), WORKFLOW_TABLE_HEADING)] - - -def tracking_rows() -> list[list[str]]: - return _table_rows(TRACKING.read_text(encoding="utf-8"), TRACKING_TABLE_HEADING) - - -def declared_stages() -> list[str]: - body = COMMON.read_text(encoding="utf-8") - found = re.search(r"PJ_STAGES=\$'([^']*)'", body) - assert found, f"値の一覧を読み取れない: {COMMON}" - return found.group(1).split("\\n") - - -def test_the_workflow_table_is_readable() -> None: - assert len(workflow_stages()) >= 10 - - -def test_the_tracking_table_lists_the_same_stages_in_the_same_order() -> None: - assert [row[0] for row in tracking_rows()] == workflow_stages() - - -def test_the_tracking_values_match_the_stage_names() -> None: - """対応表の値の列は、工程表の行名をそのまま書く。""" - assert [row[2].strip("`") for row in tracking_rows()] == workflow_stages() - - -def test_the_declared_values_match_the_workflow_table() -> None: - assert declared_stages() == workflow_stages() - - -def test_the_design_review_stage_is_declared() -> None: - """ドキュメントレビューの工程を盤面へ記録できること(#231 そのもの)。""" - assert "ドキュメントレビュー" in declared_stages() - - -@pytest.mark.parametrize("heading", [WORKFLOW_TABLE_HEADING, TRACKING_TABLE_HEADING]) -def test_an_unreadable_table_fails(heading: str) -> None: - """表を見つけられないことを、素通りさせない。""" - with pytest.raises(AssertionError): - _table_rows("# 見出しのない文書\n", heading) - - -def test_a_missing_value_is_detected() -> None: - stages = declared_stages() - assert stages[:-1] != workflow_stages() - - -def test_a_reordered_list_is_detected() -> None: - stages = declared_stages() - swapped = [stages[1], stages[0], *stages[2:]] - assert swapped != workflow_stages() diff --git a/plugins/ndf/skills/development-workflow/tests/test_workflow_hooks.py b/plugins/ndf/skills/development-workflow/tests/test_workflow_hooks.py index 506a69372..c0b57ae21 100644 --- a/plugins/ndf/skills/development-workflow/tests/test_workflow_hooks.py +++ b/plugins/ndf/skills/development-workflow/tests/test_workflow_hooks.py @@ -18,16 +18,8 @@ from workflow_helpers import GUARD, SKILL_DIR, STAGE_CHECK SKILL = SKILL_DIR / "SKILL.md" -TRACKING = SKILL_DIR / "references/projects-tracking.md" -LOOKUP = SKILL_DIR / "references/scripts-lookup.md" -COMPLETENESS = SKILL_DIR / "references/stage-completeness.md" -MERGED = SKILL_DIR.parent / "merged/SKILL.md" SKILLS_DIR = SKILL_DIR.parent PROGRESS_TRACKING = SKILLS_DIR / "progress-tracking/SKILL.md" -RELEASE_VERIFICATION = SKILLS_DIR / "release-verification/SKILL.md" - -# まとまりを閉じる手順を呼ぶ、終わりの工程の Skill(#623 の決定 8)。 -CLOSING_CALLERS = ("release", "release-verification", "retrospective") # プラグインの根。`${CLAUDE_PLUGIN_ROOT}` が指す先で、Skill の実体はこの下の # `skills/<名前>/` にある。 @@ -195,105 +187,6 @@ def test_the_scripts_are_executable_shell(script) -> None: assert script.read_text(encoding="utf-8").startswith("#!/usr/bin/env bash") -def test_the_skill_names_the_design_branch_prefix() -> None: - """設計 Pull Request の見分けは head のブランチ名で行う(決定 4)。規約を本文へ書く。""" - body = SKILL.read_text(encoding="utf-8") - section = body.split("**ドキュメントレビューは")[1].split("\n## ")[0] - - assert "design/" in section - - -def test_the_reference_is_linked_from_the_skill() -> None: - assert "references/stage-completeness.md" in SKILL.read_text(encoding="utf-8") - assert COMPLETENESS.is_file() - - -def test_the_scripts_lookup_section_is_unchanged() -> None: - """#221-6: 進行の記録を書く手順は変わらない。 - - `$SCRIPTS` の解決は `scripts-lookup.md` へ移した(#282)。盤面の記録だけが使う値では - ないためである。呼び方の側は `projects-tracking.md` に残る。 - """ - assert "## 候補の並び" in LOOKUP.read_text(encoding="utf-8") - body = TRACKING.read_text(encoding="utf-8") - assert 'bash "$SCRIPTS/projects-sync.sh" <キー> "<値>"' in body - - -def test_the_merged_report_carries_the_stage_report() -> None: - """#221-2: 後片付けの完了報告に、スクリプトの出力として載る。""" - body = MERGED.read_text(encoding="utf-8") - - assert "stage-check.sh" in body - assert "report" in body - - -def test_the_closing_step_closes_issues_with_their_repository() -> None: - """#229-2: 取り出した 2 つの値を `--repo` へ渡す書き方であること。 - - 閉じる手順は `merged` から `progress-tracking` の「まとまりを閉じる」へ移した - (#623 の決定 7)。読む先だけを移し、確かめる書き方は変えない。 - """ - body = PROGRESS_TRACKING.read_text(encoding="utf-8") - - assert "gh issue close <番号> --repo <所有者>/<リポジトリ>" in body - - -def test_only_progress_tracking_closes_issues() -> None: - """課題を閉じる手順を持つ `SKILL.md` は 1 つだけにする(#623 の C2 / C6 / C13)。 - - 3 つの終わりの工程(`release` / `release-verification` / `retrospective`)へ写しを - 置くと、どれを読んだかで閉じる時点が変わる。**正本は `progress-tracking` の - 「まとまりを閉じる」だけで、ほかはそこを指す。** - - `references/` の下は対象にしない。`issue-upkeep` の「やらない」と `out-of-scope` の - 起票先は、まとまりの工程とは別の契機で閉じる手順である。 - - 盤面の `Done` も同じ 1 か所に寄せる(C13)。工程の入口の進行の記録で `Done` を書くと、 - `Auto-close issue` が先に閉じて reopen の手段が報告から落ちる。 - """ - closes = sorted( - path.relative_to(SKILLS_DIR).as_posix() - for path in SKILLS_DIR.glob("*/SKILL.md") - if "gh issue close" in path.read_text(encoding="utf-8") - ) - assert closes == ["progress-tracking/SKILL.md"], closes - - done = sorted( - path.relative_to(SKILLS_DIR).as_posix() - for path in SKILLS_DIR.glob("*/SKILL.md") - if 'status "Done"' in path.read_text(encoding="utf-8") - ) - assert done == ["progress-tracking/SKILL.md"], done - - # 呼ぶ側は「まとまりを閉じる」を `issue-upkeep` より前に置く(C6)。後に置くと、 - # `issue-upkeep` の段 1 が読む「このまとまりで閉じた課題」がまだ閉じていない。 - for name in CLOSING_CALLERS: - body = (SKILLS_DIR / name / "SKILL.md").read_text(encoding="utf-8") - closing = body.find("まとまりを閉じる") - upkeep = body.find("/ndf:issue-upkeep") - assert closing >= 0, f"{name}: 「まとまりを閉じる」への参照が無い" - assert upkeep >= 0, f"{name}: `issue-upkeep` の呼び出しが無い" - assert closing < upkeep, f"{name}: 閉じる手順が `issue-upkeep` より後にある" - - -def test_the_gates_stay_two() -> None: - """関門は 2 つのままで、工程の側が関門の外へ実行前確認を足さない(#561 の B5 / B6)。 - - 実行前確認の要否は `AUTHORING.md` の基準が決める。関門の節はその原則を指すだけで、 - 基準の中身を写さない(決定 11。このファイルは 500 行の上限に達している)。 - """ - body = SKILL.read_text(encoding="utf-8") - section = body.split("## 人手の承認を求める関門")[1].split("\n### ")[0] - - assert "**関門は 2 つで、増やさない。**" in section - assert "関門の外で工程の側が実行前確認を足さない" in section - assert "AUTHORING.md" in section - - rows = [line for line in section.splitlines() if line.startswith("| ") and " | " in line] - # 見出しの行と区切りの行を除いた残りが関門そのものである。 - assert len(rows) - 2 == 2, rows - - # 疑似の `gh`。呼び出しを 1 行ずつ控え、課題の状態をファイルで持つ。通信しない。 FAKE_GH = r"""#!/usr/bin/env bash printf 'gh %s\n' "$*" >>"$FAKE_DIR/calls" @@ -504,14 +397,6 @@ def test_the_closing_step_closes_an_open_issue_after_the_board_is_done(tmp_path) assert "--repo devbasex/ai-plugins" in calls[close], calls[close] assert "before=OPEN" in out and "now=OPEN" in out and "after=CLOSED" in out, out - # 結果の報告: before が OPEN で after が CLOSED なら `閉じた`。戻し方を載せる。 - report = closing_section("結果の報告") - closed = [line for line in report.splitlines() if line.startswith("| `閉じた` |")] - assert len(closed) == 1, report - condition, carried = [cell.strip() for cell in closed[0].strip("|").split("|")][1:3] - assert "`before` が OPEN" in condition and "`after` が CLOSED" in condition, condition - assert "`gh issue reopen <番号> --repo <所有者>/<リポジトリ>`" in carried, carried - def closing_fake(tmp_path, *, pr_body: str, state_12: str, record: str) -> tuple[Path, dict]: """「手順」のコード例を疑似の `gh` / `projects-sync.sh` へ向けて実行する土台を作る。 @@ -599,89 +484,3 @@ def test_the_closing_step_does_not_touch_the_board_when_the_first_read_fails(tmp # 黙って次の課題へ飛ばさない。`失敗` が 1 件でもあれば `issue-upkeep` を呼ばずに # 止まるため、控えないとその規則が働かない。 assert "failed= 12" in done.stdout, done.stdout + done.stderr - - # 結果の報告: 読み取りが 0 以外で終わった課題は `失敗`。やり直すコマンドを載せる。 - report = closing_section("結果の報告") - failed = [line for line in report.splitlines() if line.startswith("| `失敗(理由)` |")] - assert len(failed) == 1, report - condition, carried = [cell.strip() for cell in failed[0].strip("|").split("|")][1:3] - assert "読み取りが 0 以外で終わった" in condition, condition - assert "`gh issue close <番号> --repo <所有者>/<リポジトリ>`" in carried, carried - - -def release_verification_output_template() -> str: - """`release-verification` の「出力物」節にある markdown 雛形を返す。 - - 節は `## 出力物` から次の実在の節見出しまで。**節の中の ```markdown``` の柵で - 囲まれた雛形だけを取り出す。** 柵の中の `## リリース後テスト` は雛形の一部であり、 - 節の境目ではない。柵で切ると、その混同を避けられる。 - """ - body = RELEASE_VERIFICATION.read_text(encoding="utf-8") - section = body.split("\n## 出力物\n", maxsplit=1)[1] - found = re.search(r"^```markdown\n(.*?)^```$", section, re.MULTILINE | re.DOTALL) - assert found, f"出力物の markdown 雛形を読み取れない: {section[:200]}" - return found.group(1) - - -def test_the_release_verification_template_carries_the_target_version_once() -> None: - """現状固定: 雛形は `対象の版:` の行を 1 つ持つ。 - - 「まとまりを閉じる」の「配布の記録」の読み取りは、この行で本番の版のブロックを - 選ぶ。行が無い・複数あると、どの版を確かめたのかが決まらない。 - """ - template = release_verification_output_template() - targets = [line for line in template.splitlines() if line.startswith("対象の版:")] - - assert len(targets) == 1, template - assert "<配布した版>" in targets[0], targets[0] - - -def test_the_release_verification_template_starts_the_table_with_the_issue_column() -> None: - """現状固定: 表の先頭の列が `課題` で、`結果` の列も持つ。 - - 課題ごとに閉じる判定を読むため、行がどの課題の受け入れ条件だったかを先頭の列で - 引く。結果の列が合否を持つ。どちらが欠けても課題別の閉じる判定へ渡せない。 - """ - template = release_verification_output_template() - rows = [line for line in template.splitlines() if line.startswith("| ")] - # 見出しの行・区切りの行・各データ行。 - assert len(rows) >= 3, rows - - header = [cell.strip() for cell in rows[0].strip("|").split("|")] - assert header[0] == "課題", header - assert header[-1] == "結果", header - assert "受け入れ条件" in header, header - - -def test_the_release_verification_template_maps_each_condition_to_an_issue() -> None: - """現状固定: 各受け入れ条件の行が、課題の列と結果の列を対応付けて読める。 - - 区切りの行を除いた各データ行で、先頭の課題の列が `#<番号>` の形を持ち、結果の列が - 合格・保留のいずれかの語を持つ。これがまとまりの課題別の閉じる判定へ渡す形である。 - """ - template = release_verification_output_template() - rows = [line for line in template.splitlines() if line.startswith("| ")] - header = [cell.strip() for cell in rows[0].strip("|").split("|")] - issue_at = header.index("課題") - result_at = header.index("結果") - - # 見出しの行と区切りの行(各セルが `---`)を除いた残りがデータ行。 - def is_separator(row: str) -> bool: - cells = [cell.strip() for cell in row.strip("|").split("|")] - return all(set(cell) == {"-"} for cell in cells) - - data = [ - [cell.strip() for cell in row.strip("|").split("|")] - for row in rows[1:] - if not is_separator(row) - ] - assert data, rows - - issues = set() - for cells in data: - assert re.match(r"^#\d+$", cells[issue_at]), cells - assert re.search(r"合格|保留", cells[result_at]), cells - issues.add(cells[issue_at]) - - # 雛形は複数の課題(#561 / #623)を、それぞれの受け入れ条件の行へ対応付けて示す。 - assert issues == {"#561", "#623"}, issues diff --git a/plugins/ndf/skills/development-workflow/tests/test_workflow_stage_matrix.py b/plugins/ndf/skills/development-workflow/tests/test_workflow_stage_matrix.py deleted file mode 100644 index 27eea14b5..000000000 --- a/plugins/ndf/skills/development-workflow/tests/test_workflow_stage_matrix.py +++ /dev/null @@ -1,107 +0,0 @@ -"""必須の工程の一覧が工程表から導けることを固定する(#221-5)。 - -判定は `scripts/lib/workflow-common.sh` の分類表が持つ。**その表は工程表から機械で -導ける。** 工程表の側だけを変えると、必須と判定する工程が古いまま残る。この検査は -工程表を読み直して分類し、ライブラリの表と突き合わせる。 - -分類の規則は 3 つである。 - -| 工程表のセル | 分類 | -| --- | --- | -| `—` | 対象外 | -| `任意`、または丸括弧で条件を添えたもの | 条件付き | -| それ以外 | 必須 | -""" -from __future__ import annotations - -import re - -import pytest - -from workflow_helpers import LIB, SKILL_DIR - -SKILL = SKILL_DIR / "SKILL.md" -WORKFLOW_TABLE_HEADING = "## モードごとに起動する Skill" -CLASS_OF = {"必須": "R", "条件付き": "C", "対象外": "-"} - - -def _table(heading: str) -> tuple[list[str], list[list[str]]]: - lines = SKILL.read_text(encoding="utf-8").splitlines() - try: - start = next(i for i, line in enumerate(lines) if line.strip() == heading) - except StopIteration: - raise AssertionError(f"見出しが見つからない: {heading}") - header: list[str] = [] - rows: list[list[str]] = [] - for line in lines[start + 1 :]: - stripped = line.strip() - if stripped.startswith("## "): - break - if not stripped.startswith("|"): - if rows: - break - continue - cells = [c.strip() for c in stripped.strip("|").split("|")] - if not header: - header = cells - continue - if set("".join(cells)) <= set("-: "): - continue - rows.append(cells) - assert header and rows, f"表を読み取れない: {heading}" - return header, rows - - -def classify(cell: str) -> str: - """工程表のセル 1 つを分類する。""" - body = cell.strip() - if body in ("—", "-", ""): - return "-" - if body == "任意" or "(" in body: - return "C" - return "R" - - -def derived() -> tuple[list[str], list[list[str]]]: - header, rows = _table(WORKFLOW_TABLE_HEADING) - modes = [c.strip("`") for c in header[1:]] - matrix = [[row[0], *[classify(c) for c in row[1:]]] for row in rows] - return modes, matrix - - -def declared() -> tuple[list[str], list[list[str]]]: - body = LIB.read_text(encoding="utf-8") - found_modes = re.search(r"WF_MODES=\$'([^']*)'", body) - assert found_modes, f"モードの一覧を読み取れない: {LIB}" - modes = found_modes.group(1).split("\\t") - found = re.search(r"WF_STAGE_MATRIX=\$'(.*?)'\n", body, re.DOTALL) - assert found, f"分類表を読み取れない: {LIB}" - matrix = [line.split("\\t") for line in found.group(1).split("\n") if line] - return modes, matrix - - -def test_the_modes_match_the_workflow_table() -> None: - assert declared()[0] == derived()[0] - - -def test_the_matrix_matches_the_workflow_table() -> None: - assert declared()[1] == derived()[1] - - -def test_the_matrix_covers_every_stage_in_order() -> None: - """並びまで見る。工程の順序が報告の並びになる。""" - assert [row[0] for row in declared()[1]] == [row[0] for row in derived()[1]] - - -def test_an_unreadable_table_fails() -> None: - """表を見つけられないことを、素通りさせない。""" - with pytest.raises(AssertionError): - _table("## 存在しない見出し") - - -def test_the_classification_rule() -> None: - assert classify("—") == "-" - assert classify("任意") == "C" - assert classify("`plan-to-spec`(仕様が変わった場合)") == "C" - assert classify("`quality-gates`") == "R" - assert classify("直接編集") == "R" diff --git a/plugins/ndf/skills/development-workflow/tests/test_workflow_units.py b/plugins/ndf/skills/development-workflow/tests/test_workflow_units.py index 428639dd5..a215b4384 100644 --- a/plugins/ndf/skills/development-workflow/tests/test_workflow_units.py +++ b/plugins/ndf/skills/development-workflow/tests/test_workflow_units.py @@ -9,13 +9,11 @@ """ from __future__ import annotations -import re import shlex import pytest from workflow_helpers import ( - SKILL_DIR, base_env, init_repo, run_lib, @@ -23,11 +21,6 @@ state_file, ) -SKILL = SKILL_DIR / "SKILL.md" -MODES_REF = SKILL_DIR / "references" / "workflow-modes.md" -COMPLETENESS_REF = SKILL_DIR / "references" / "stage-completeness.md" -WORKFLOW_TABLE_HEADING = "## モードごとに起動する Skill" - @pytest.mark.parametrize( ("mode", "expected"), @@ -98,168 +91,6 @@ def test_json_escape_replaces_all_five_special_characters_together() -> None: assert result.stdout == expected -def workflow_table() -> tuple[list[str], list[list[str]]]: - """工程表の見出しと本文の行を返す。読み取れないことは失敗として扱う。""" - lines = SKILL.read_text(encoding="utf-8").splitlines() - start = next( - (i for i, line in enumerate(lines) if line.strip() == WORKFLOW_TABLE_HEADING), - None, - ) - assert start is not None, f"見出しが見つからない: {WORKFLOW_TABLE_HEADING}" - header: list[str] = [] - rows: list[list[str]] = [] - for line in lines[start + 1 :]: - stripped = line.strip() - if stripped.startswith("## "): - break - if not stripped.startswith("|"): - if rows: - break - continue - cells = [c.strip() for c in stripped.strip("|").split("|")] - if not header: - header = cells - continue - if set("".join(cells)) <= set("-: "): - continue - rows.append(cells) - assert header and rows, "工程表を読み取れない" - return header, rows - - -def cell(stage: str, mode: str) -> str: - header, rows = workflow_table() - column = header.index(f"`{mode}`") - row = next((r for r in rows if r[0] == stage), None) - assert row is not None, f"工程が見つからない: {stage}" - return row[column] - - -def stages() -> list[str]: - return [row[0] for row in workflow_table()[1]] - - -# --- A1 / B3: `light` が通る工程 ------------------------------------------- - - -def test_light_is_reviewed_by_the_converging_loop() -> None: - """レビューの深さをモードで変える根拠が無い。差分の小ささはラウンド数が吸収する。""" - assert "cross-review" in cell("実装レビュー", "light") - - -def test_light_carries_requirements_too() -> None: - """判定の入力になるため、モードによらず通る。""" - assert cell("要求と受け入れ条件", "light") not in ("—", "-", "") - - -# --- B2: 工程の順序 --------------------------------------------------------- - - -def test_requirements_come_before_preparing_the_workspace() -> None: - """**Pull Request に何が入るかが決まらないと判定できない。**""" - order = stages() - assert order.index("要求と受け入れ条件") < order.index("作業場所の用意") - - -def test_the_judgement_is_not_a_stage_of_its_own() -> None: - """盤面の値を増やさない。順序は本文が書く。""" - assert "モード判定" not in stages() - - -def test_the_body_places_the_judgement_between_the_two() -> None: - body = SKILL.read_text(encoding="utf-8") - assert re.search(r"要求と受け入れ条件.*モード判定.*作業場所の用意", body, re.DOTALL) - - -# --- B1 / B4 / B5: 判定の単位と、分ける基準 --------------------------------- - - -def test_the_unit_of_judgement_is_the_pull_request() -> None: - body = SKILL.read_text(encoding="utf-8") - assert "判定する単位" in body - assert re.search(r"判定する単位は\*{0,2}\s*Pull Request", body) - - -def test_splitting_is_decided_by_the_files_that_are_touched() -> None: - """**束ねる理由は競合の回避であって、モードを混ぜたいからではない。**""" - body = SKILL.read_text(encoding="utf-8") - assert "触るファイルが重ならない" in body - - -def test_one_pull_request_carries_one_mode() -> None: - body = SKILL.read_text(encoding="utf-8") - assert re.search(r"1 つの Pull Request .{0,20}モードは 1 つ", body, re.DOTALL) - - -# --- B6: 控えの契約 --------------------------------------------------------- - - -def test_the_note_keeps_its_key_on_the_issue() -> None: - """判定の時点で Pull Request の番号は無い。鍵ではなく値の一致で表す。""" - body = COMPLETENESS_REF.read_text(encoding="utf-8") - assert "課題番号" in body - assert "食い違" in body - - -# --- A4 / A5: 図と参照 ------------------------------------------------------ - - -def flow_diagram() -> str: - body = SKILL.read_text(encoding="utf-8") - found = re.search(r"## 標準フロー.*?```mermaid\n(.*?)```", body, re.DOTALL) - assert found, "標準フローの図を読み取れない" - return found.group(1) - - -def test_the_flow_routes_light_through_a_review() -> None: - """`light` の経路がレビューのノードへ入る。""" - diagram = flow_diagram() - review_ids = { - found.group(1) - for found in re.finditer(r"(\w+)\[[^\]]*レビュー[^\]]*\]", diagram) - } - assert review_ids, "レビューのノードが図に無い" - light_targets = { - found.group(1) - for found in re.finditer(r"-\.->\|[^|]*light[^|]*\|\s*(\w+)", diagram) - } - assert light_targets & review_ids, ( - f"`light` の破線がレビューへ入っていない: {sorted(light_targets)}" - ) - - -def test_the_reference_does_not_justify_by_the_absence_of_a_review() -> None: - """`light` もレビューを通るようになるため、その根拠は使えない。""" - body = MODES_REF.read_text(encoding="utf-8") - assert "レビューの工程を通らないため" not in body - - -def test_the_reference_says_light_is_reviewed() -> None: - body = MODES_REF.read_text(encoding="utf-8") - section = body[body.index("### `light`") : body.index("### `standard`")] - assert "cross-review" in section - - -# --- #392: 重複した段落 ----------------------------------------------------- - - -def openings(body: str) -> list[str]: - """段落の書き出し。**ほぼ同一**の段落は、末尾の 1 文だけが違う形で並ぶ。""" - found: list[str] = [] - for block in body.split("\n\n"): - flat = " ".join(block.split()) - if len(flat) < 40 or flat.startswith(("|", "-", "```", "#")): - continue - found.append(flat[:40]) - return found - - -def test_no_paragraph_is_repeated() -> None: - found = openings(SKILL.read_text(encoding="utf-8")) - duplicates = sorted({p for p in found if found.count(p) > 1}) - assert duplicates == [], f"ほぼ同一の段落が 2 つ並んでいる: {duplicates}" - - # --- A6: 報告が `light` のレビューを必須として扱う --------------------------- diff --git a/plugins/ndf/skills/issue-plan-strategy/tests/test_execution_plan_doc.py b/plugins/ndf/skills/issue-plan-strategy/tests/test_execution_plan_doc.py deleted file mode 100644 index abeed6afe..000000000 --- a/plugins/ndf/skills/issue-plan-strategy/tests/test_execution_plan_doc.py +++ /dev/null @@ -1,285 +0,0 @@ -"""実行計画の規約(`issue-plan-strategy`、#540)。 - -**実行計画は進行側が持つ 1 つのファイルで、開いている間はコミットしない。** 会話が -失われても同じ内容を読み直せることが、置き場所を決めた理由である(設計の決定 5)。 - -**契約の列名と状態の値は、書く側(この文書)と読む側(テスト)が同じ表から写す。** -列名が片方だけ変わると、実行計画を読む進行側が知らない列を受け取る。 -""" -from __future__ import annotations - -import pathlib - -SKILL_DIR = pathlib.Path(__file__).resolve().parents[1] -SKILL = SKILL_DIR / "SKILL.md" -PLAN = SKILL_DIR / "references" / "execution-plan.md" - - -def skill() -> str: - return SKILL.read_text(encoding="utf-8") - - -def plan() -> str: - return PLAN.read_text(encoding="utf-8") - - -def flat(text: str) -> str: - """折り返しの改行と強調の印を除く。日本語の文は改行の位置で語が割れる。""" - return text.replace("\n", "").replace("**", "") - - -# --- AC1: 作る時点と、作らないとき ------------------------------------------ - - -def test_the_plan_is_written_before_the_first_lane_starts() -> None: - """長い待ちを契機にしないため、起動の前に依存と本数を置いておく。""" - body = flat(plan()) - assert "複数の課題" in body - assert "最初の担当を起動する前に" in body - - -def test_a_single_pull_request_needs_no_plan() -> None: - """依存も重なりも本数も、比べる相手がいない。""" - assert "課題が 1 件で、Pull Request も 1 本" in flat(plan()) - assert "書かない" in flat(plan()) - - -def test_a_single_issue_split_into_pull_requests_needs_a_plan_per_pull_request() -> None: - rows = _section_table_rows(plan(), "## 作る時点と、作らないとき") - row = next(row for row in rows if row[0] == "1 件の課題を複数の Pull Request に分ける") - assert row[1] == "書く。行は Pull Request の単位である" - - -# --- AC2: 行の形 ------------------------------------------------------------- - -ROW_COLUMNS = [ - "行", "束", "種類", "課題", "モード", "触るファイルと節", "確度", - "設計の依存", "実装の依存", "状態", "Pull Request", -] -CONFIDENCE = ["見込み", "確定"] -STATES = ["待ち", "着手できる", "動いている", "承認待ち", "止まった", "マージ済み"] - - -def test_the_row_table_has_every_column() -> None: - assert ROW_COLUMNS in _tables(plan()), _tables(plan()) - - -def test_the_row_table_distinguishes_a_guess_from_a_fixed_value() -> None: - """触るファイルと節は、設計のレビューが収束するまで見込みである。""" - body = flat(plan()) - for value in CONFIDENCE: - assert f"`{value}`" in body, value - assert "設計のレビューが収束した見直しで、その束の行を `確定` にする" in body - - -def test_the_row_table_has_six_states() -> None: - body = flat(plan()) - for state in STATES: - assert f"`{state}`" in body, state - - -def test_only_the_running_rows_are_counted() -> None: - """承認待ちの担当は報告を返して終わっており、CLI もテストも動かしていない。""" - assert "本数に数えるのは `動いている` だけである" in flat(plan()) - - -def test_the_dependency_is_a_pair_of_stages() -> None: - """設計の行に実装の依存を書くと、実装の順序が次の設計を止める(決定 2)。""" - body = flat(plan()) - assert "`A-設計:収束`" in body - assert "`A-実装:マージ`" in body - assert "B の設計の行に、B の実装の依存を書かない" in body - - -# --- AC3: 置き場所 ----------------------------------------------------------- - - -def test_the_plan_has_one_place_and_survives_a_restart() -> None: - body = flat(plan()) - assert "issues/execution-plan-" in body - assert "主ディレクトリ" in body - assert "本体が再起動しても" in body - - -def test_the_plan_is_not_committed_while_it_is_open() -> None: - """見直しのたびに Pull Request を通すと、計画の更新がマージを待つ。""" - assert "開いている間はコミットしない" in flat(plan()) - - -# --- AC4 / AC37: 測った値 ---------------------------------------------------- - -MEASURE_COLUMNS = [ - "時刻", "空き(MiB)", "cgroup の残り(MiB)", "スワップの空き(MiB)", - "oom_kill", "動いている本数", "起動してよい本数", "決めた条件", "起動した行", -] - - -def test_the_measured_table_has_every_column() -> None: - assert MEASURE_COLUMNS in _tables(plan()), _tables(plan()) - - -def test_the_capacity_is_measured_before_every_launch() -> None: - body = flat(plan()) - assert "担当を起動する前に、毎回これを実行し" in body - assert "parallel-measure.py" in body - assert "capacity" in body - - -def test_an_unmeasurable_host_runs_one_lane() -> None: - """終了コード 3 のときは、上限ではなく 1 本で進める。""" - body = flat(plan()) - assert "測れない" in body - assert "起動してよい本数を 1 として扱う" in body - - -def test_the_plan_does_not_copy_the_defaults() -> None: - """予備・1 本の見込み・上限・スワップの閾値は `parallel-measure.py` だけが持つ。""" - body = plan() - for value in ("2048", "25%"): - assert value not in body, f"初期値 {value} は parallel-measure.py だけが持つ" - - -def test_only_one_lane_runs_container_checks() -> None: - assert "コンテナを起動する検査を持つ行" in flat(plan()) - - -# --- AC9 / AC10 / AC11: 見直し ---------------------------------------------- - -TRIGGERS = [ - "担当の報告を受け取った", - "Pull Request がマージされた", - "担当が止まった", - "OOM Killer の回数", - "進行側が再起動して", -] -REVIEW_COLUMNS = ["時刻", "契機", "変えたこと", "理由"] - - -def test_the_five_triggers_are_listed() -> None: - body = flat(plan()) - assert "契機は 5 つである" in body - for trigger in TRIGGERS: - assert trigger in body, trigger - - -def test_a_row_starts_when_its_dependencies_are_done_and_there_is_room() -> None: - """supervisor の中の待ちは進行側から見えないため、待ちを契機にしない。""" - body = flat(plan()) - assert "着手できるかは依存と本数だけで決まる" in body - assert "長い待ち" in body - assert "知らなくても" in body - - -def test_the_review_table_records_the_reason() -> None: - assert REVIEW_COLUMNS in _tables(plan()), _tables(plan()) - assert "1 行足して理由を残す" in flat(plan()) - - -def test_an_increased_oom_kill_stops_the_launch_and_moves_the_baseline() -> None: - """起点を更新しないと、以後の見直しがすべて「増えた」になる(決定 7)。""" - body = flat(plan()) - assert "oom_kill_increased=yes" in body - assert "その見直しでは新しい担当を起動しない" in body - assert "いま測った値へ更新する" in body - - -# --- AC23: 組を束の初期値として読む ------------------------------------------ - - -def test_the_plan_reads_the_milestone_groups() -> None: - body = flat(plan()) - assert "### 並列の組(見込み)" in body - assert "束の初期値として読む" in body - - -def test_the_group_columns_are_mapped_to_the_row_columns() -> None: - """書く側の手順が入る前でも、読む側だけで組を行へ移せる。""" - assert ["組の列", "読み方"] in _tables(plan()), _tables(plan()) - body = flat(plan()) - for column in ("組", "課題", "触る場所の見込み", "依存"): - assert column in body, column - assert "書く側の手順が入る前でも動く" in body - - -def test_there_is_a_way_to_start_without_groups() -> None: - """マイルストーンを使わないリポジトリでも実行計画を作れる。""" - body = flat(plan()) - assert "組が無いとき" in body - assert "マイルストーンを使わないリポジトリ" in body - assert "書き戻さない" in body - - -# --- AC7: 競合の解き方 -------------------------------------------------------- - - -def test_the_later_merge_resolves_and_reconverges() -> None: - body = flat(plan()) - assert "後からマージする側が解く" in body - assert "解いた後の head で実装レビューを収束させてからマージする" in body - - -# --- AC41: 閉じる ------------------------------------------------------------- - -CLOSING_COLUMNS = [ - "対象の Pull Request", "期間(分)", "重なり(分)", "並行度", "最大同時", - "oom_kill の増分", -] - - -def test_the_closing_table_has_every_column() -> None: - assert CLOSING_COLUMNS in _tables(plan()), _tables(plan()) - - -def test_closing_measures_posts_and_deletes() -> None: - body = flat(plan()) - assert "すべての行が `マージ済み` になったら閉じる" in body - assert "concurrency" in body - assert "gh pr comment" in body - assert "ファイルを消す" in body - - -# --- 配線: `SKILL.md` から辿れる --------------------------------------------- - - -def test_the_skill_points_at_the_reference() -> None: - assert "references/execution-plan.md" in skill() - - -def test_the_skill_has_a_section_for_the_execution_plan() -> None: - assert "## 実行計画" in skill() - - -def _tables(text: str) -> list[list[str]]: - """表の見出し行だけを列の一覧として返す。""" - headers: list[list[str]] = [] - lines = text.splitlines() - for i, line in enumerate(lines[:-1]): - stripped = line.strip() - if not stripped.startswith("|"): - continue - following = lines[i + 1].strip() - if not following.startswith("|"): - continue - cells = [c.strip() for c in following.strip("|").split("|")] - if not cells or set("".join(cells)) > set("-: "): - continue - headers.append([c.strip() for c in stripped.strip("|").split("|")]) - return headers - - -def _section_table_rows(text: str, heading: str) -> list[list[str]]: - """指定した節にある表の本文を、行ごとのセルとして返す。""" - lines = text.splitlines() - start = next(i for i, line in enumerate(lines) if line.strip() == heading) - rows: list[list[str]] = [] - for line in lines[start + 1:]: - stripped = line.strip() - if stripped.startswith("## "): - break - if not stripped.startswith("|"): - continue - cells = [cell.strip() for cell in stripped.strip("|").split("|")] - if cells == ["状況", "実行計画"] or set("".join(cells)) <= set("-: "): - continue - rows.append(cells) - return rows diff --git a/plugins/ndf/skills/issue-upkeep/tests/test_issue_upkeep_layout.py b/plugins/ndf/skills/issue-upkeep/tests/test_issue_upkeep_layout.py index 11938bb36..8e5a8f688 100644 --- a/plugins/ndf/skills/issue-upkeep/tests/test_issue_upkeep_layout.py +++ b/plugins/ndf/skills/issue-upkeep/tests/test_issue_upkeep_layout.py @@ -1,8 +1,4 @@ -"""`issue-upkeep` の構成と配線(#331 / #712 / #713)。 - -**判定の値は 3 箇所(手順の表・自動で反映してよい変更の表・報告)で同じ並びを持つ。** -片方だけが増えると、反映の側が知らない判定を受け取る。 -""" +"""`issue-upkeep` の構成と配線(#331 / #712 / #713)。""" from __future__ import annotations import json @@ -17,22 +13,7 @@ ROOT = SKILLS.parents[2] SKILL = SKILL_DIR / "SKILL.md" -NO_WORK = SKILL_DIR / "references" / "no-work.md" -MILESTONES = SKILL_DIR / "references" / "milestones.md" GROUPING = SKILL_DIR / "references" / "grouping.md" -VOCABULARY = SKILLS / "refactoring" / "references" / "vocabulary.md" - -# 手順の表が持つ 8 つの判定。**この並びが基準である。** -VERDICTS = [ - "そのまま", "追記が要る", "書き直しが要る", "閉じてよい", - "やらない", "重複", "ルートコーズ", "要判断", -] -VERDICT_HEADER = "| 判定 | 選ぶ条件 | 段 3 での対応 |" - - -def flat(text: str) -> str: - """折り返しの改行を除く。日本語の文は改行の位置で語が割れるため、照合の前に繋ぐ。""" - return text.replace("\n", "") def plain(text: str) -> str: @@ -40,31 +21,11 @@ def plain(text: str) -> str: return text.replace("\n", "").replace("**", "") -def contains(text: str, fragment: str) -> bool: - """強調の印と改行の位置によらず、文が本文にあるか。""" - return plain(fragment) in plain(text) - - def locate(text: str, fragment: str) -> int: """強調の印を除いた文の、本文での位置。切り出しの起点に使う。""" return text.index(plain(fragment)) -def row_starting(text: str, header: str, first_cell: str) -> str: - """見出し行で始まる表から、先頭のセルで行を探す。先頭のセルの太字の有無は問わない。 - - 探すのは見出し行より後だけである。同じ語を先頭に持つ別の表(用語の表など)の行を拾わない。 - """ - return next(line for line in text[text.index(header):].split("\n") - if plain(line).startswith(plain(first_cell))) - - -def links_to(text: str, target: str, base: pathlib.Path = SKILL_DIR) -> bool: - """本文のリンクが `target` を指し、その先のファイルが実在するか。""" - found = [link.split("#", 1)[0] for link in re.findall(r"\]\(([^)]+)\)", text)] - return target in found and (base / target).is_file() - - def section(text: str, heading: str) -> str: """見出しから、同じ深さか浅い次の見出しまでを返す。""" level = heading.split(" ", 1)[0] @@ -75,208 +36,6 @@ def section(text: str, heading: str) -> str: return heading + (rest[:ends[0]] if ends else rest) -def table(text: str, header: str) -> list[list[str]]: - """見出し行で始まる表の、データ行のセルを返す。太字の印は外す。""" - block = text[text.index(header):] - block = block[:block.index("\n\n")] if "\n\n" in block else block - rows = [line for line in block.split("\n")[2:] if line.startswith("|")] - return [[cell.strip().strip("*").strip() for cell in row.strip("|").split("|")] - for row in rows] - - -def test_the_references_exist() -> None: - """「やらない」とマイルストーンは参照へ分ける。 - - 段 1 と段 2A では、どちらの判断も起きない。手順の本体から外すと、対象を選んで調べる - 段では読み込まれない。 - """ - assert NO_WORK.is_file() - assert MILESTONES.is_file() - assert GROUPING.is_file() - - -def test_the_verdict_table_lists_eight_in_order() -> None: - """判定は 8 つに限られ、手順の表がその並びを持つ。 - - 「ルートコーズ」は課題をまたいで決まるため「重複」の隣に置く。「要判断」は受け皿として末尾に残す。 - """ - body = SKILL.read_text(encoding="utf-8") - table = body[body.index("| 判定 | 選ぶ条件 | 段 3 での対応 |"):] - table = table[:table.index("\n\n")] - found = [re.match(r"\| \*?\*?([^|*]+?)\*?\*?(?: \|| ).*", line).group(1).strip() - for line in table.split("\n")[2:] if line.startswith("|")] - assert found == VERDICTS, found - - -def test_no_work_is_reachable_from_the_verdict_table() -> None: - """「やらない」の行から参照を指す。判断の基準はそちらにある。""" - body = SKILL.read_text(encoding="utf-8") - row = row_starting(body, VERDICT_HEADER, "| **やらない**") - assert links_to(row, "references/no-work.md"), row - - -def test_no_work_has_two_necessary_conditions() -> None: - """2 つの必要条件と、欠けたときの行き先が表で示されている。 - - 条件 2 の行き先を「重複」に固定すると、同じ修正レイヤーを指す別の課題が重複として閉じられる。 - 行き先は 3 つに分け、「要判断」へ倒す文は見積りである条件 1 だけに掛ける。 - """ - body = NO_WORK.read_text(encoding="utf-8") - assert "## 2 つの必要条件" in body - assert "欠けたときの行き先" in body - assert "抱える費用が、直す費用を下回る" in body - assert "同じ原因の他の課題へ寄せられない" in body - rows = table(body, "| # | 条件 | 確かめ方 | 欠けたときの行き先 |") - second = next(row for row in rows if row[0] == "2") - assert "重複とルートコーズの両方の突き合わせの結果" in plain(second[2]) - assert second[2].index("重複") < second[2].index("ルートコーズ") - for outcome in ("重複", "ルートコーズ", "寄せ先が無く、条件は欠けていない"): - assert outcome in second[3], outcome - assert "どちらも見積りである" not in flat(body) - assert "条件 1 は見積りである" in plain(body) - - -def test_no_work_shows_both_kinds_of_reactivation_condition() -> None: - """再燃の条件は観測できる形で書く。観測できない書き方の例も示す。""" - body = NO_WORK.read_text(encoding="utf-8") - assert contains(body, "**観測できる**") - assert "観測できない" in body - - -def test_closing_and_not_planned_are_separate_verdicts() -> None: - """「やらない」と「閉じてよい」を別の判定として区別する。""" - body = SKILL.read_text(encoding="utf-8") - assert contains(NO_WORK.read_text(encoding="utf-8"), "**「閉じてよい」とは\n別である。**") - assert contains(body, "| 閉じてよい |") - assert contains(body, "| **やらない** |") - - -@pytest.mark.parametrize(("priority", "holding_cost", "candidate"), [ - ("高い(実害・安全機構の欠落)", "大きい", "原則ならない。 候補にするなら、実害が稀であることを実測で示す"), - ("中くらい(保守性・設計一貫性)", "中くらい", "直す費用が大きければ候補になる"), - ("低い(余力があれば)", "小さい", "候補になりやすい"), -]) -def test_no_work_branches_by_priority( - priority: str, holding_cost: str, candidate: str) -> None: - """重要度の 3 区分に応じた「やらない」候補判定の分岐を固定する。""" - part = section(NO_WORK.read_text(encoding="utf-8"), - "## 重要度が、抱える費用の目安になる") - rows = table(part, "| 重要度 | 抱える費用 | 候補になるか |") - normalized = [[plain(cell) for cell in row] for row in rows] - row = next(r for r in normalized if r[0] == priority) - assert row[1] == holding_cost - assert row[2] == candidate - - -def test_no_work_high_priority_requires_frequency_measurement_or_needs_judgement() -> None: - """重要度「高い」を候補にするには頻度の実測が必要で、示せなければ要判断へ倒す規則を固定する。""" - part = plain(section(NO_WORK.read_text(encoding="utf-8"), - "## 重要度が、抱える費用の目安になる")) - assert "高いものを候補にするときは、頻度を実測で示す" in part - assert "示せなければ要判断へ倒す" in part - - -def test_milestones_reflect_only_the_earlier_direction() -> None: - """早める方向だけを自動で反映する。""" - body = MILESTONES.read_text(encoding="utf-8") - assert contains(body, "**早める方向だけを自動で反映する。**") - assert contains(body, "**要判断**") - - -@pytest.mark.parametrize(("situation", "response"), [ - ("重要度が高いのに、直近でないマイルストーンにある", "直近へ移す"), - ("重要度が低いのに、直近のマイルストーンにある", "要判断。 後ろへ移すと着手が遅れる"), - ("重要度と位置が合っている", "何もしない"), -]) -def test_existing_milestones_branch_by_priority_and_position( - situation: str, response: str) -> None: - """重要度と現在位置の食い違いに応じた 3 つの対応を固定する。""" - part = section(MILESTONES.read_text(encoding="utf-8"), - "## 既に設定されているものを振り直す") - rows = table(part, "| 状況 | 対応 |") - row = next(row for row in rows if row[0] == situation) - assert plain(row[1]) == response - - -def test_existing_milestones_explain_why_only_earlier_moves_are_automatic() -> None: - """早める判断には実害の根拠があり、遅らせる判断は価値判断なので自動化しない。""" - part = section(MILESTONES.read_text(encoding="utf-8"), - "## 既に設定されているものを振り直す") - assert contains(part, "早める判断は実害の記述が根拠になる") - assert contains(part, "遅らせる判断は\n「今やらなくてよい」という価値の判断") - - -def test_milestones_are_not_created_for_a_single_issue() -> None: - """1 件しか残らないときは作らない。""" - assert contains(MILESTONES.read_text(encoding="utf-8"), "**1 件しか残らないときは作らない。**") - - -def test_new_milestone_is_appended_with_the_next_number() -> None: - """通常の追加は既存の順序を動かさず、過去最大の着手順序の次を採る。""" - part = section(MILESTONES.read_text(encoding="utf-8"), - "## 新しく作るとき") - assert contains(part, "**末尾に置く。**") - assert contains(part, "連番は現在の最大の次にする") - - -def test_inserting_a_new_milestone_between_existing_ones_needs_judgement() -> None: - """既存の間への差し込みは他の課題の着手順序を変えるため、要判断へ倒す。""" - part = section(MILESTONES.read_text(encoding="utf-8"), - "## 新しく作るとき") - assert contains(part, "既存の間へ差し込むことは、他の課題の着手の順序を早めるか遅らせるかの判断") - assert contains(part, "**要判断**へ倒す") - - -def test_new_milestone_number_is_not_reused_after_closing() -> None: - """閉じたマイルストーンを含む過去最大の着手順序から、再利用しない連番を決める。""" - part = section(MILESTONES.read_text(encoding="utf-8"), - "## 新しく作るとき") - rows = table(part, "| 決めること | 決め方 |") - name = next(row[1] for row in rows if row[0] == "名前") - assert "これまでに付けた最大の次" in name - assert "再利用しない" in name - assert "閉じたマイルストーンは版数へ改名される" in name - assert "着手の順序: N 番目" in name - - -@pytest.mark.parametrize(("priority", "when_no_matching_subject"), [ - ("高い(実害・安全機構の欠落)", "直近のマイルストーンへ、主題によらず入れる"), - ("中くらい(保守性・設計一貫性)", "末尾に新しく作る(下記)"), - ("低い(余力があれば)", "未設定のまま残す"), -]) -def test_unassigned_issues_branch_by_priority_when_no_subject_matches( - priority: str, when_no_matching_subject: str) -> None: - """主題の合うものが無いときの割り当て先を、重要度の 3 区分ごとに固定する。""" - part = section(MILESTONES.read_text(encoding="utf-8"), - "## 設定されていない課題を割り当てる") - rows = table(part, "| 重要度 | 入れる先 | 主題の合うものが無いとき |") - normalized = [[cell.replace("**", "") for cell in row] for row in rows] - row = next(row for row in normalized if row[0] == priority) - assert row[2] == when_no_matching_subject - - -def test_unassigned_issue_with_multiple_matching_subjects_needs_judgement() -> None: - """主題が複数に当てはまるときは、自動で割り当てず要判断へ倒す。""" - part = section(MILESTONES.read_text(encoding="utf-8"), - "## 設定されていない課題を割り当てる") - assert contains(part, "主題が複数に当てはまるときは**要判断**へ倒す") - - -@pytest.mark.parametrize("caller,marker", [ - ("retrospective", "issue-upkeep"), - ("release", "issue-upkeep"), - ("out-of-scope", "issue-upkeep"), - ("problem-solving", "issue-upkeep"), -]) -def test_the_callers_point_here(caller: str, marker: str) -> None: - """振り返り・配布・起票・根本原因の調査の 4 つがこの Skill を指す。 - - 構造の判断の担い手はこの Skill であり、どこから読み始めてもここへたどれるようにする。 - """ - body = (SKILLS / caller / "SKILL.md").read_text(encoding="utf-8") - assert marker in body, f"{caller} が {marker} を指していない" - - @pytest.mark.parametrize("runtime", ["claude", "codex", "kiro", "agy"]) def test_the_skill_is_distributed(runtime: str) -> None: """4 つの manifest すべてに載る。""" @@ -288,41 +47,6 @@ def test_the_skill_is_distributed(runtime: str) -> None: # ---------- 対象の範囲 ---------- -def test_zero_targets_skip_to_reporting_with_the_reason() -> None: - """4 経路で集めた対象が 0 件なら、判定・反映をせず報告へ理由を残す。""" - body = SKILL.read_text(encoding="utf-8") - procedure = section(body, "## 手順") - target_rows = table(procedure, "| 経路 | 取り方 | 拾えるもの |") - report_rows = table(body, "| 項目 | 何を書くか |") - - assert [row[0] for row in target_rows] == [ - "機械の候補", "担当が足す", "マイルストーン", "親 issue", - ] - assert "段 1 で対象が 0 件なら、そこで飛ばす" in plain(procedure) - target_report = next(row for row in report_rows if row[0] == "対象") - assert "0 件なら飛ばしたことと理由" in target_report[1] - - -def test_the_target_is_decided_only_by_the_milestone() -> None: - """段 1 の 3 つ目の経路が、マイルストーンの有無だけで対象を決めることを明記している。 - - 絞ると、未設定のまま溜まる課題を誰も見ないことになる。 - """ - body = SKILL.read_text(encoding="utf-8") - assert contains(body, "マイルストーンの付いていない open の課題すべて") - assert contains(body, "**起票者は問わない。**") - - -def test_the_target_query_filters_only_by_milestone() -> None: - """未設定の課題を拾う例が、マイルストーンの有無だけで絞っていること。""" - body = SKILL.read_text(encoding="utf-8") - block = body[locate(body, "**起票者は問わない。**"):] - start = block.index("```bash") - block = block[start:block.index("```", start + len("```bash")) + 3] - assert "--author" not in block, "投稿者で絞る例になっている" - assert "created:" not in block, "起票の時期で絞る例になっている" - assert "select(.milestone == null)" in block - def test_the_target_query_keeps_only_null_milestone_issues() -> None: """段 1 の jq を実際に流し、milestone が null の課題だけを残すことを固定する。 @@ -353,654 +77,6 @@ def test_the_target_query_keeps_only_null_milestone_issues() -> None: assert line == f"#{issue['number']} {issue['title']}" -def test_stage_1_expands_to_all_open_issues_on_pervasive_changes() -> None: - """全体に影響する変更における open 全件への拡張(--all)の 4 条件を固定する。 - - ディレクトリの移動・統合、対応する実行環境の増減、ブランチ戦略の変更、 - 識別子の一括改名のいずれかに当たるときは、対象を open 全件(--all)へ広げる。 - """ - body = SKILL.read_text(encoding="utf-8") - part = body[body.index("### 段 1: 対象を選ぶ"):body.index("### 段 2A: 課題ごとに調べる")] - rows = table(part, "| 全体に影響する変更 | 例 |") - - assert [row[0] for row in rows] == [ - "ディレクトリの移動・統合", - "対応する実行環境の増減", - "ブランチ戦略の変更", - "識別子の一括改名", - ] - text = plain(part) - assert "全体に影響する変更では、触った領域では足りない" in text - assert "次のいずれかに当たるときは対象をopen の全件へ広げる(`--all`)" in text - - -# ---------- ルートコーズの判定(#712) ---------- - -def test_cluster_is_reachable_from_the_verdict_table() -> None: - """「ルートコーズ」の行から参照を指す。条件と書き方はそちらにある。""" - body = SKILL.read_text(encoding="utf-8") - row = row_starting(body, VERDICT_HEADER, "| **ルートコーズ**") - assert links_to(row, "references/grouping.md"), row - - -def test_creating_the_parent_needs_approval() -> None: - """親 issue の起票と子 issue への書き込みは、一括で提示して承認を得てから行う。 - - どちらも外部から見える場所への書き込みである。 - """ - body = SKILL.read_text(encoding="utf-8") - rows = table(body, "| 変更 | 自動で反映してよいか |") - create = [row for row in rows if "親 issue を作る" in row[0]] - link = [row for row in rows if "本文へ 1 行を足す" in row[0]] - assert create and link - for row in create + link: - assert "一括で提示し、承認を得てから行う" in row[1], row - - -def test_every_upkeep_change_has_one_of_the_three_dispositions() -> None: - """変更操作は、自動反映・承認後に反映・人へ返す、の 3 区分だけを持つ。""" - part = section(SKILL.read_text(encoding="utf-8"), - "## 自動で反映してよい変更と、返す変更") - rows = table(part, "| 変更 | 自動で反映してよいか |") - dispositions = {row[1] for row in rows} - assert dispositions == { - "よい", - "一括で提示し、承認を得てから行う", - "返す", - } - - -@pytest.mark.parametrize("change", [ - "起票の意図が現在も要るかを判断できない", - "正しい読み方が複数ある", -]) -def test_upkeep_returns_ambiguous_changes_to_a_human(change: str) -> None: - """本文の外に判断材料が要る 2 つの安全弁は、自動反映せず人へ返す。""" - part = section(SKILL.read_text(encoding="utf-8"), - "## 自動で反映してよい変更と、返す変更") - rows = table(part, "| 変更 | 自動で反映してよいか |") - row = next(row for row in rows if row[0] == change) - assert row[1] == "返す" - assert "判断の材料が本文の外にあるかどうか" in plain(part) - assert "決まらないものだけを返す" in plain(part) - - -def test_upkeep_handles_four_things() -> None: - """扱うことが 4 つになり、4 つ目が根本原因の場所で直す判断である。""" - body = SKILL.read_text(encoding="utf-8") - assert "## 扱う 3 つ" not in body - part = section(body, "## 扱う 4 つ") - rows = table(part, "| # | 扱うこと | 決めること |") - assert [row[0] for row in rows] == ["1", "2", "3", "4"] - assert "根本原因の場所で直す" in rows[3][2] - assert links_to(part, "references/grouping.md") - terms = table(body, "| 語 | この文書での意味 |") - assert "8 つの区分" in next(row for row in terms if row[0] == "判定")[1] - - -def test_stage_1_picks_up_children_of_a_closed_parent() -> None: - """閉じた親 issue の子 issue を拾う経路がある。 - - 子 issue は閉じないため親が閉じた後も open のまま残り、親と別のマイルストーンにいると - ほかの 3 経路では拾えない。 - """ - part = section(SKILL.read_text(encoding="utf-8"), "### 段 1: 対象を選ぶ") - assert "4 つの経路" in part - rows = table(part, "| 経路 | 取り方 | 拾えるもの |") - assert len(rows) == 4 - assert "閉じた親 issue の子 issue" in rows[3][1] - assert "閉じない" in plain(part) - - -def test_stage_2a_records_the_shared_cause() -> None: - """段 2A は現象レイヤーと修正レイヤーを控え、クラスタは決めない。""" - part = section(SKILL.read_text(encoding="utf-8"), "### 段 2A: 課題ごとに調べる") - text = plain(part) - assert "確かめるのは 6 点である" in text - assert "修正レイヤーが現象レイヤーと違うか" in text - rows = table(part, "| 控える項目 | 何に使うか |") - names = [row[0] for row in rows] - assert "現象レイヤー" in names and "修正レイヤー" in names - assert "段 2A ではクラスタを決めない" in text - assert "他の課題の控えが見えない" in text - - -def test_stage_2b_matches_issues_by_shared_cause() -> None: - """段 2B は同じ原因を持つクラスタを突き合わせ、書き換えを揃える行とは分ける。""" - part = section(SKILL.read_text(encoding="utf-8"), "### 段 2B: 全体で突き合わせて判定を確定する") - names = [row[0] for row in table(part, "| 突き合わせるもの | 何を決めるか |")] - assert "同じ原因を持つクラスタ" in names - assert "同じ前提の変化を受けた課題群" in names - assert names.index("重複と判定された組") < names.index("同じ原因を持つクラスタ") - assert "言い回しを揃える" in plain(part) - - -def test_stage_2b_note_branches_to_cluster() -> None: - """起点が同じ組は、直した後に個別の作業が残るかで重複と親 issue へ分かれる。""" - body = SKILL.read_text(encoding="utf-8") - note = plain(body[locate(body, "**重複の判定では、起点が同じことと同じ課題であることを分ける。**"): - locate(body, "**「やらない」の候補は")]) - assert "個別の作業が残る" in note - assert "ルートコーズ" in note and "親 issue" in note - - -def test_cluster_separates_itself_from_duplication() -> None: - """重複との境目は、原因を直した後に個別の作業が残るかである。""" - text = plain(GROUPING.read_text(encoding="utf-8")) - assert "原因を直しても各課題に個別の作業が残るならルートコーズ、残らないなら重複である" in text - - -def test_root_cause_has_one_condition() -> None: - """条件は 1 つで、件数は条件ではない。""" - body = GROUPING.read_text(encoding="utf-8") - rows = table(body, "| 条件 | 確かめ方 | 欠けたときの行き先 |") - assert len(rows) == 1 - assert "修正レイヤーが現象レイヤーと違う" in rows[0][0] - assert "既存の判定のまま" in rows[0][2] - text = plain(body) - assert "件数は条件ではない" in text - assert "件数が決めるのは" in text - - -def test_stage_3_has_three_outcomes() -> None: - """段 3 の対応は、件数と個別の作業の有無で 3 つに分かれ、3 行目が重複との境目である。""" - body = GROUPING.read_text(encoding="utf-8") - rows = table(body, "| 同じ修正レイヤーを指す件数 | 直しても個別の作業が残るか | 対応 |") - assert len(rows) == 3 - assert "本文へ" in rows[0][2] - assert "親 issue" in rows[1][2] - assert "重複" in rows[2][2] - assert "3 行目が重複との境目である" in plain(body) - - -def test_cluster_keeps_the_child_issues_open() -> None: - """子 issue を閉じない。結び付けはサブイシュー関係を既定にし、例外だけ本文の 1 行にする。""" - body = GROUPING.read_text(encoding="utf-8") - text = plain(body) - assert "子 issue を閉じない" in text - assert "固有の再現手順" in text and "個別の適用が残る" in text - assert "サブイシュー関係" in text - assert "sub_issue_id" in body and "データベースの ID" in text - assert "子 1 件が持てる親は 1 件だけ" in text - assert "サブイシューの API を持たないリポジトリ" in text - - -def test_cluster_shows_the_body_of_the_parent_issue() -> None: - """親 issue に書く 4 つを挙げ、子 issue の中身は写さない。""" - text = plain(GROUPING.read_text(encoding="utf-8")) - for item in ("修正レイヤー", "採る手", "現象レイヤーと観測", "完了条件"): - assert item in text, item - assert "見出しの形は決めない" in text - assert "子 issue の中身を写さない" in text - assert "片方だけが古くなる" in text - - -def test_an_issue_may_join_two_clusters() -> None: - """1 件が複数のクラスタへ属してよく、やり直しの見分けは親 issue の側から引く。""" - text = plain(GROUPING.read_text(encoding="utf-8")) - assert "1 件が複数のクラスタへ属してよい" in text - assert "親 issue どうしが同じ箇所を直すなら、それは 1 つのクラスタである" in text - assert "親 issue の側から引く" in text - - -def test_cluster_does_not_gate_on_size() -> None: - """層がまたがっても判定は変わらず、採る手は判定の条件ではない。""" - text = plain(GROUPING.read_text(encoding="utf-8")) - assert "修正レイヤーが 2 つ以上にまたがっても、判定は変わらない" in text - assert "マイルストーンの割り当てと実装計画" in text - assert "手は判定の条件ではない" in text - assert "当てはまるものを全部書く" in text - - -def test_parent_takes_the_highest_priority_in_the_cluster() -> None: - """親 issue の重要度はクラスタの最高値を採り、重要度の規則を先に効かせてから最も早いものを採る。""" - assert "クラスタの中で最も高いもの" in plain(GROUPING.read_text(encoding="utf-8")) - assert links_to(GROUPING.read_text(encoding="utf-8"), "milestones.md", GROUPING.parent) - part = plain(section(MILESTONES.read_text(encoding="utf-8"), "## 親 issue を割り当てる")) - assert "重要度が「高い」" in part - assert part.index("重要度が「高い」") < part.index("最も早いもの") - assert "子 issue のマイルストーンは動かさない" in part - - -def test_upkeep_files_only_the_parent_issue() -> None: - """「手入れ」は起票を含まず、例外は親 issue 1 つだけである。""" - body = SKILL.read_text(encoding="utf-8") - text = plain(body) - assert "「手入れ」は起票を含まない" in text - assert "例外は親 issue の起票 1 つだけである" in text - assert "発見の瞬間しか見ない" in text - terms = table(body, "| 語 | この文書での意味 |") - assert "親 issue" in next(row for row in terms if row[0] == "手入れ")[1] - - -def test_the_report_counts_the_clusters() -> None: - """完了報告は 8 つの判定の内訳と、処理したクラスタの数を持つ。""" - rows = table(SKILL.read_text(encoding="utf-8"), "| 項目 | 何を書くか |") - assert "8 つの判定" in next(row for row in rows if row[0] == "判定の内訳")[1] - assert any("処理したクラスタの数" in row[1] for row in rows) - - -# ---------- 判断の担い手(#713) ---------- - -def test_the_boundary_table_covers_both_judgements() -> None: - """価値と構造の判断 × 発見の瞬間と溜まった課題の表を、この Skill が正本として持つ。""" - body = SKILL.read_text(encoding="utf-8") - rows = table(body, "| 判断 | 発見の瞬間 | 溜まった課題 |") - value = next(row for row in rows if row[0].startswith("価値")) - structure = next(row for row in rows if row[0].startswith("構造")) - assert "「やらない」" in value[2] - assert "「ルートコーズ」" in structure[2] - part = plain(section((SKILLS / "out-of-scope" / "SKILL.md").read_text(encoding="utf-8"), - "## 蓄積した課題との境界")) - assert "`issue-upkeep` の「やらない」" in part - assert "`issue-upkeep` の「ルートコーズ」" in part - - -def test_problem_solving_separates_upstream_from_cluster() -> None: - """「上流で直す」と棚卸の「ルートコーズ」の違いを、件数ではなく見る対象で書く。""" - body = (SKILLS / "problem-solving" / "SKILL.md").read_text(encoding="utf-8") - paragraph = next(p for p in body.split("\n\n") if "ルートコーズ" in p) - assert links_to(paragraph, "../issue-upkeep/SKILL.md", SKILLS / "problem-solving"), paragraph - assert "件数" not in paragraph and "複数" not in paragraph - - -def test_retrospective_declines_cluster_discovery() -> None: - """振り返りはクラスタの発見を担わない。1 回の変更を見るためクラスタが見えない。""" - text = plain((SKILLS / "retrospective" / "SKILL.md").read_text(encoding="utf-8")) - assert "クラスタの発見を担わない" in text - assert "1 回の変更を見る" in text - - -# ---------- 用語 ---------- - -def test_the_terms_are_loanwords() -> None: - """判定・クラスタ・着手の時期を一般語で呼ばない。4 語は用語の表が定める。""" - body = SKILL.read_text(encoding="utf-8") - names = [row[0] for row in table(body, "| 語 | この文書での意味 |")] - for term in ("ルートコーズ", "クラスタ", "親 issue", "子 issue", "まとまり"): - assert term in names, term - grouping = GROUPING.read_text(encoding="utf-8") - for text in (body, grouping): - assert "束ねる" not in text - assert "かたまり" not in text - assert "群" not in text.replace("同じ前提の変化を受けた課題群", "") - assert "まとまり" not in grouping - for phrase in ("着手のまとまり", "どのまとまりで着手", "どのまとまりへも"): - assert phrase not in flat(body), phrase - - -def test_milestones_use_one_word_for_the_timing() -> None: - """着手の時期は「マイルストーン」で呼び、「まとまり」を使わない。""" - assert "まとまり" not in MILESTONES.read_text(encoding="utf-8") - - -def test_milestones_pick_the_nearest_by_sequence() -> None: - """直近と順序は名前の先頭の連番で決める。open のマイルストーンは版数を持たない。""" - text = plain(MILESTONES.read_text(encoding="utf-8")) - assert "版数が最も小さい" not in text - assert "版数の順序" not in text - assert "連番が最も小さい" in text - assert "連番の順序と食い違うとき" in text - assert "後の連番にあるなら" in text - assert "`<2 桁の連番> <主題>`" in text - assert "説明が連番と別の着手の順序を書いているときは、説明を採る" in text - - -# ---------- 上位の分解 ---------- - -def test_grouping_separates_the_two_layers() -> None: - """現象レイヤーと修正レイヤーを分けて書き、さかのぼる段数を決めない。""" - body = GROUPING.read_text(encoding="utf-8") - rows = table(body, "| 現象レイヤーの例 | 修正レイヤーの例 |") - phenomena = [row[0] for row in rows] - for example in ("各コントローラ", "各サブクラス", "移譲している側", "各スクリプト"): - assert example in phenomena, example - text = plain(body) - assert "さかのぼる段数を決めない" in text - assert "そこを直せば、現象レイヤーの各所が同じ形で直るか" in text - assert "修正レイヤーが現象レイヤーと同じこともある" in text - assert "名指しできない" in text - - -def test_grouping_lists_five_moves() -> None: - """採る手は 5 つに限り、呼び名は `refactoring` の語彙から採る。""" - body = GROUPING.read_text(encoding="utf-8") - rows = table(body, "| 手 | いつ選ぶか | 対応する手法(`refactoring` の呼び名) |") - assert [row[0] for row in rows] == ["移動", "統合", "新設", "向きの修正", "分離"] - names = re.findall(r"`([a-z_]+)`", " ".join(row[2] for row in rows)) - assert len(names) >= 6 - vocabulary = VOCABULARY.read_text(encoding="utf-8") - for name in names: - assert f"| `{name}` |" in vocabulary, name - assert "references/vocabulary.md" in body - - -# ---------- 判断を人へ返さない・書式を持たない ---------- - -def test_cluster_never_defers_to_a_human() -> None: - """ルートコーズの分岐は段 2A の控えだけで決まるため、「要判断」へ倒さない。""" - body = GROUPING.read_text(encoding="utf-8") - assert "要判断" not in body - text = plain(body) - for outcome in ("ルートコーズ", "既存の判定のまま", "本文へ", "親 issue を 1 件つくり", "重複として正本へ寄せる"): - assert outcome in text, outcome - - -def test_the_skill_carries_only_what_it_must() -> None: - """その場で決めれば済む書式の雛形を置かない。実測でしか分からないことは書く。""" - body = GROUPING.read_text(encoding="utf-8") - assert "```markdown" not in body - assert "```text" not in body - assert "sub_issue_id" in body - - -def test_grouping_rereads_the_stated_fix() -> None: - """課題が書いている直し方をそのまま採らず、手段の語で終わる本文は何が直るのかを書き出す。""" - body = GROUPING.read_text(encoding="utf-8") - part = section(body, "## 課題が書いている直し方をそのまま採らない") - assert table(part, "| 本文が書いていること | 棚卸が確かめること |") - text = plain(part) - assert "起票は現象を見て書かれる" in text - for word in ("まとめる", "整理する", "統一する", "揃える"): - assert f"「{word}」" in text, word - assert "何が直るのか" in text - assert "まだ原因に届いていない" in text - - -# ---------- 段 3 の反映直前の照合 ---------- - -STAGE_3_GATE = "**`updated_at` が変わっていれば本文の要約値を見る。**" - - -@pytest.mark.parametrize(("path", "outcome"), [ - ("updated_at が不変", STAGE_3_GATE), - ("updated_at は変わったが要約値は同じ", "同じならそのまま反映する"), - ("要約値も変わった", "要約値も変わっていればその課題だけを段 2A へ戻し"), -]) -def test_stage_3_reconciles_right_before_reflecting(path: str, outcome: str) -> None: - """反映直前の照合を、`updated_at` と本文の要約値の 3 経路ごとに固定する。 - - 要約値を見るのは `updated_at` が変わったときだけで、不変ならそのまま反映へ進む。 - 要約値が同じなら反映を続け、変わっていればその課題だけを段 2A へ戻す。 - """ - part = plain(section(SKILL.read_text(encoding="utf-8"), "### 段 3: 反映する")) - assert "段 3 は反映の直前に照合する" in part - gate = part.index(plain(STAGE_3_GATE)) - assert "要約値" not in part[:gate], "updated_at より先に要約値を見ている" - assert plain(outcome) in part, path - assert part.index(plain(outcome)) >= gate, path - - -# ---------- 外部への書き込みの制限 ---------- - - -@pytest.mark.parametrize(("situation", "handling"), [ - ("応答が `Retry-After` を返す", "その秒数だけ待つ"), - ("上限の回復時刻を返す", "その時刻まで待つ"), - ("どちらも無い(作成の二次的な制限)", "間隔を倍にしながら待つ"), - ("一定の回数を超えた", - "部分的に終わった状態として止め、何が済んだかを報告へ残す"), -]) -def test_rate_limit_branches_by_response(situation: str, handling: str) -> None: - """GitHub のレート制限に当たったときの 4 つの状況ごとの待ち方・停止を固定する。 - - 待つ長さは応答から取り、固定の間隔で待たない。`Retry-After` は秒数、回復時刻は時刻、 - どちらも無ければ倍にしながら待ち、一定の回数を超えたら部分終了として止める。 - """ - part = section(SKILL.read_text(encoding="utf-8"), "## 外部への書き込みの制限") - rows = table(part, "| 状況 | 待ち方 |") - mapping = {flat(row[0]): flat(row[1]) for row in rows} - assert flat(situation) in mapping, situation - assert flat(handling) == mapping[flat(situation)] - - -def test_rate_limit_takes_the_wait_from_the_response() -> None: - """待つ長さは応答から取り、固定の間隔で待たないことを明記している。""" - part = plain(section(SKILL.read_text(encoding="utf-8"), "## 外部への書き込みの制限")) - assert "待つ長さは応答から取る" in part - assert "固定の間隔で待たない" in part - - -def test_rate_limit_reports_the_wait_count() -> None: - """完了報告に、制限へ当たった回数と待った長さを残す行がある。""" - rows = table(SKILL.read_text(encoding="utf-8"), "| 項目 | 何を書くか |") - row = next(row for row in rows if row[0] == "待った回数") - assert "外部への書き込みの制限に当たった回数" in row[1] - assert "待った長さ" in row[1] - - -# ---------- 他のリポジトリで動くこと ---------- - - -def test_other_repositories_define_fallback_behaviors() -> None: - """マイルストーン・サブイシュー API・重要度ラベル・wontfix ラベル欠落時の振る舞いを固定する。 - - 前提にしてよいのは gh と issue だけであり、4 つの機能欠落時における - フォールバック動作の分岐が定義されていることを検証する。 - """ - body = SKILL.read_text(encoding="utf-8") - part = section(body, "## 他のリポジトリで動くこと") - rows = table(part, "| 無いもの | 振る舞い |") - mapping = {row[0].replace("`", ""): row[1] for row in rows} - - assert "マイルストーン" in mapping - assert "段 1 の 3 つ目の経路と、扱うこと 2 を飛ばす" in mapping["マイルストーン"] - - assert "サブイシューの API" in mapping - assert "子 issue の結び付けを、すべて子 issue の本文の 1 行で行う" in mapping["サブイシューの API"] - - assert "重要度ラベル" in mapping - assert "gh label list" in mapping["重要度ラベル"] - assert "説明が無ければ付け替えない" in mapping["重要度ラベル"] - - assert "wontfix にあたるラベル" in mapping - assert "ラベルを付けずに閉じる" in mapping["wontfix にあたるラベル"] - assert "理由と再燃の条件は本文に残る" in mapping["wontfix にあたるラベル"] - - -# ---------- 重要度と問いの形を変えるときの基準 ---------- - - -def test_priority_change_branches_by_harm_observation() -> None: - """重要度変更の 3 経路(実害観測追記で上げる/実害なし判明で下げる/説明なしラベルは維持)を固定する。 - - 重要度はラベルの説明の区分へ当てはめ、実害観測が本文へ追記されたら上げ、 - 実害が起きない経路だと分かったら下げ、説明のないラベルへは付け替えない。 - """ - part = section(SKILL.read_text(encoding="utf-8"), - "### 重要度と問いの形を変えるときの基準") - rows = table(part, "| 変えるもの | 基準 | 材料の在り処 |") - row = next(row for row in rows if row[0] == "重要度") - assert "実害の観測が変わったか" in row[1] - assert "ラベルの説明" in row[2] - - text = plain(part) - assert "重要度は、ラベルの説明が持つ区分へ当てはめる" in text - assert "実害の観測が本文へ追記された課題は上げ" in text - assert "実害が起きない経路だと分かった課題は下げ" in text - assert "説明の無いラベルへは付け替えない" in text - - -def test_question_restructuring_branches_by_premise_and_gist() -> None: - """問いの形変更の 2 経路(前提反転時のみ立て直す/主旨変更時は人へ返す)を固定する。 - - 問いを立て直すのは前提反転時のみで主旨や困りごとは変えず、 - 主旨まで変わる場合は新起票判断として人へ返す。 - """ - part = section(SKILL.read_text(encoding="utf-8"), - "### 重要度と問いの形を変えるときの基準") - rows = table(part, "| 変えるもの | 基準 | 材料の在り処 |") - row = next(row for row in rows if row[0] == "問いの形") - assert "前提が反転したか" in row[1] - assert "本文が指す実物の状態" in row[2] - - text = plain(part) - assert "問いを立て直すのは、前提が反転したときに限る" in text - assert "立て直すのは問いの形であって、主旨ではない" in text - assert "何が困るかは変えない" in text - assert "主旨まで変わるなら、それは別の課題である" in text - assert "元の課題を閉じて新しく起票する判断になるため、返す" in text - - -# ---------- 発見の瞬間の 3 択(out-of-scope の段 2) ---------- - -OUT_OF_SCOPE = SKILLS / "out-of-scope" / "SKILL.md" - - -def test_out_of_scope_stage_2_offers_exactly_three_choices() -> None: - """段 2 の判断が {起票する, 範囲内へ入れる, 起票しない} の 3 分岐だけであることを固定する。 - - 表示文字列の完全一致ではなく、判断の列を集合として比較する。この 3 分岐は発見の瞬間の - 判断の中心で、後の構造改善で表を触る対象になる。 - """ - part = section(OUT_OF_SCOPE.read_text(encoding="utf-8"), "### 2. 3 択で決める") - rows = table(part, "| 判断 | 選ぶ条件 | 残すもの |") - assert {row[0] for row in rows} == {"起票する", "範囲内へ入れる", "起票しない"} - - -def test_out_of_scope_issue_target_needed_only_when_filing() -> None: - """「起票先を決める」段が『起票する』を選んだときだけ要ることを固定する。 - - 起票先が要る分岐は 3 択のうち 1 つだけである。 - """ - text = plain(section(OUT_OF_SCOPE.read_text(encoding="utf-8"), "### 3. 起票先を決める")) - assert "「起票する」を選んだときだけ行う" in text - assert "残る 2 つの判断には起票先が要らない" in text - - -def test_table_helper_does_not_pass_over_a_missing_heading() -> None: - """見出し(表)が無い本文では table 補助が投げ、素通りしないことを確かめる。 - - 段 2 の表を消したときに固定テストが黙って通ってしまわないための番犬。 - """ - with pytest.raises(ValueError): - table("見出しの無い本文\n", "| 判断 | 選ぶ条件 | 残すもの |") - - -# --- 並列の組(#541) -------------------------------------------------------- -# -# **組は、マイルストーンへ課題を入れる時点で書く見込みである。** 確定した触る場所は -# 実行計画(`issue-plan-strategy`)が持ち、説明へ書き戻さない(設計の決定 10)。 - -GROUP_SECTION = "## 並列の組を説明へ書く" -GROUP_TABLE_HEADER = "| 組 | 課題 | 触る場所の見込み | 依存 |" - - -def group_section() -> str: - return section(MILESTONES.read_text(encoding="utf-8"), GROUP_SECTION) - - -def test_milestones_have_a_section_for_the_parallel_groups() -> None: - """AC20: マイルストーンへ課題を入れるときに、その課題の組を説明へ書く。""" - part = group_section() - assert contains(part, "課題をマイルストーンへ入れるときに、その課題の組を説明へ書く") - assert "### 並列の組(見込み)" in part, "説明へ置く見出しの形が無い" - assert GROUP_TABLE_HEADER in part - - -def test_the_group_row_is_written_when_an_issue_enters_a_milestone() -> None: - """AC20: 新しく作る・既存へ足す・直近へ移すの 3 つが同じ時点として扱われる。""" - rows = table(group_section(), "| 時点 | 行うこと |") - moments = [row[0] for row in rows] - assert any("入れる" in m and "新しく作る" in m and "足す" in m and "移す" in m - for m in moments), moments - assert any("別のマイルストーンへ移す" in m for m in moments), moments - assert any("閉じた" in m for m in moments), moments - - -def test_the_group_is_copied_from_what_stage_2a_already_records() -> None: - """AC21: 触る場所は段 2A の修正レイヤーから、依存は依存する課題の番号から写す。""" - part = plain(group_section()) - assert "修正レイヤー" in part - assert "依存する課題の番号" in part - assert "新しく調べる項目を増やさない" in part - - -def test_stage_2a_records_the_same_six_items() -> None: - """AC21: 段 2A の控える項目は増えない。 - - 組のために段 2A へ項目を足すと、棚卸の 1 課題あたりの費用が上がる。 - """ - rows = table(SKILL.read_text(encoding="utf-8"), "| 控える項目 | 何に使うか |") - assert len(rows) == 6, [row[0] for row in rows] - assert rows[-1][0] == "修正レイヤー" - - -def test_groups_are_parallel_between_and_sequential_within() -> None: - """AC22: 組の間は並列、組の中は 1 本へ束ねるか順に進める見込みである。""" - part = plain(group_section()) - assert "組の間は並列にできる見込み" in part - assert "組の中は同じ Pull Request へ束ねるか順に進める見込み" in part - - -def test_the_group_is_only_an_estimate() -> None: - """AC22: 確定は設計の後に実行計画が持ち、説明へ書き戻さない。""" - part = plain(group_section()) - assert "見込みであり、確定は設計の後に実行計画が持つ" in part - assert "書き戻さない" in part - - -def test_the_group_does_not_change_the_milestone_structure() -> None: - """AC24: 組のために既存のマイルストーンを分けたり束ね直したりしない。""" - part = plain(group_section()) - assert "既存のマイルストーンを分けたり束ね直したりしない" in part - assert "組の番号は詰めない" in part - - -def test_an_empty_group_row_is_removed_without_renumbering_the_remaining_groups() -> None: - """現状固定: 移動で 0 件になった組は消すが、残る組の番号は詰めない。""" - part = group_section() - column_rows = table(part, "| 列 | 値 | 写す元 |") - group_column = next(row for row in column_rows if row[0] == "組") - assert group_column[1] == "1 から始まる連番。説明の中で一意" - - timing_rows = table(part, "| 時点 | 行うこと |") - moving = next(row for row in timing_rows if row[0] == "課題を別のマイルストーンへ移す") - assert plain(moving[1]) == ( - "元の説明の表から番号を消す。課題が 0 件になった組は行を消し、" - "組の番号は詰めない" - ) - - -def test_milestones_without_a_group_table_are_not_rewritten_at_once() -> None: - """決定 11: 既存の説明を一括で書き直さず、足した課題の行だけを載せる。""" - part = plain(group_section()) - assert "一括で書き直さない" in part - assert "表が無ければ見出しと表を作る" in part - - -def test_group_partition_rule_by_fix_layer() -> None: - """現状固定: 同じ修正レイヤーの課題は同じ組、違えば別組にする規則を固定する。""" - part = plain(group_section()) - assert "同じ修正レイヤーの課題は同じ組、違えば別の組にする" in part - - -@pytest.mark.parametrize(("timing_pattern", "expected_actions"), [ - ("課題をマイルストーンへ入れる", [ - "修正レイヤーが既存の組と同じなら、その組の「課題」へ番号を足す", - "違えば組を 1 つ足す", - "表が無ければ見出しと表を作る", - ]), - ("別のマイルストーンへ移す", [ - "元の説明の表から番号を消す", - "課題が 0 件になった組は行を消し", - "組の番号は詰めない", - ]), - ("課題が閉じた", [ - "何もしない", - "実行計画が状態を持つ", - ]), -]) -def test_group_timing_branches_and_actions( - timing_pattern: str, expected_actions: list[str]) -> None: - """現状固定: マイルストーンへの追加(一致・不一致)、移動、完了の各分岐における動作対応を固定する。""" - rows = table(group_section(), "| 時点 | 行うこと |") - row = next(r for r in rows if timing_pattern in r[0]) - action = plain(row[1]) - for expected in expected_actions: - assert expected in action, f"{timing_pattern} の行うことに '{expected}' が含まれていない" - - # ---------- やり直しで 2 度行わない(grouping.md) ---------- REDO_SECTION = "## やり直しで 2 度行わない" @@ -1015,42 +91,6 @@ def redo_commands() -> list[str]: return re.findall(r"```bash\n(.*?)```", redo_section(), re.DOTALL) -def test_redo_takes_its_materials_from_two_lookups() -> None: - """現状固定: 起票と結び付けを分けて見分け、材料は親の側と子の側の 2 つの取り方で引く。 - - まとめて扱うと、書き込みの制限で途中で止まったときに、起票済みを理由として残りの - 結び付けが飛ぶ。親の側からは子の一覧を、子の側からは親の番号を引く。 - """ - text = plain(redo_section()) - assert "起票と結び付けを分けて見分ける" in text - assert "起票はクラスタに 1 度、結び付けは課題ごとに行う" in text - assert "起票済みを理由として残りの結び付けが飛ぶ" in text - - from_parent, from_child = redo_commands() - assert "/issues/<親の番号>/sub_issues" in from_parent - assert "--jq '.[].number'" in from_parent - assert "gh api graphql" in from_child - assert "issue(number:<子の番号>){parent{number}}" in from_child - - -@pytest.mark.parametrize(("decision", "material"), [ - ("起票の要否", "この修正レイヤーを指す親 issue を探して起票の要否を決め"), - ("結び付けの要否", "その親 issue の子の一覧で結び付けの要否を決める"), -]) -def test_redo_decides_filing_and_linking_from_the_parent_side( - decision: str, material: str) -> None: - """現状固定: 起票の要否は親 issue の有無で、結び付けの要否は親の子の一覧で決める。 - - 親が作成済みなら起票を飛ばし、子の一覧に無い子だけを結び付ける経路になる。 - 子の本文の 1 行だけでは決めない。複数のクラスタへ属する課題では別の親を指す。 - """ - text = plain(redo_section()) - assert "見分けは親 issue の側から引く" in text - assert material in text, decision - assert "子の本文の 1 行だけでは決めない" in text - assert "1 行が別の親 issue を指していることがある" in text - - def test_the_parent_side_lookup_lists_the_linked_children() -> None: """現状固定: 親の側の jq を実際に流し、結び付け済みの子の番号だけが並ぶことを固定する。 @@ -1071,20 +111,3 @@ def test_the_parent_side_lookup_lists_the_linked_children() -> None: done = subprocess.run(["jq", "-r", expression], input="[]", capture_output=True, text=True, check=True) assert done.stdout.splitlines() == [] - - -def test_a_child_with_another_parent_is_linked_by_a_body_line() -> None: - """現状固定: 子が既に別の親を持つかは子の側から引き、値があれば本文の 1 行で指す。 - - 親は単数で返るため、2 つ目のクラスタはサブイシュー関係では結べない。 - 結び付け方の節が、その例外を本文の 1 行として定める。 - """ - body = GROUPING.read_text(encoding="utf-8") - text = plain(redo_section()) - assert "子 issue が既に別の親を持つかは、子の側から引く" in text - assert "親は単数で返る" in text - assert "値があれば 2 つ目のクラスタであり、本文の 1 行で指す" in text - - linking = plain(section(body, "### 結び付け方")) - assert ("2 つ目以降のクラスタと、サブイシューの API を持たないリポジトリでは、" - "子 issue の本文へ親 issue を指す 1 行を足す") in linking diff --git a/plugins/ndf/skills/issue-upkeep/tests/test_related_skills_characterization.py b/plugins/ndf/skills/issue-upkeep/tests/test_related_skills_characterization.py index 231f8f6e4..a63a2842c 100644 --- a/plugins/ndf/skills/issue-upkeep/tests/test_related_skills_characterization.py +++ b/plugins/ndf/skills/issue-upkeep/tests/test_related_skills_characterization.py @@ -1,4 +1,4 @@ -"""`issue-upkeep` とつながる 3 つの Skill の、構造改善で守る契約(#717 の構造改善)。 +"""`issue-upkeep` とつながる Skill の、構造改善で守る契約(#717 の構造改善)。 NOTE: 現状固定。期待値の根拠は仕様ではなく、構造改善の直前の手順が返す結果である。 節の分け方や言い回しを変えても、ここに書いた分岐と値は変わらないことを確かめる。 @@ -6,8 +6,6 @@ | Skill | 固定するもの | | --- | --- | | `retrospective` | Pull Request の番号を引く 3 段(開発の起点 → 基準ブランチ → マージ済みの Pull Request) | -| `problem-solving` | 型不一致の危険な組み合わせと対策、それを指す 2 か所 | -| `out-of-scope` | 由来の形、由来を書く場所、由来での検索 | """ from __future__ import annotations @@ -21,19 +19,12 @@ SKILLS = pathlib.Path(__file__).resolve().parents[2] RETROSPECTIVE = SKILLS / "retrospective" / "SKILL.md" -PROBLEM_SOLVING = SKILLS / "problem-solving" / "SKILL.md" -OUT_OF_SCOPE = SKILLS / "out-of-scope" / "SKILL.md" def read(path: pathlib.Path) -> str: return path.read_text(encoding="utf-8") -def plain(text: str) -> str: - """強調の印と折り返しの改行を除く。言い回しの位置ではなく文を照合する。""" - return text.replace("\n", "").replace("**", "") - - def section(text: str, heading: str) -> str: """見出しから、同じ深さか浅い次の見出しまでを返す。囲みの中の `#` は見出しとして数えない。""" depth = len(heading.split(" ", 1)[0]) @@ -51,15 +42,6 @@ def section(text: str, heading: str) -> str: return "\n".join(lines[start:]) -def rows(text: str, header: str) -> list[list[str]]: - """見出し行で始まる表の、データ行のセルを返す。太字の印は外す。""" - block = text[text.index(header):] - block = block[:block.index("\n\n")] if "\n\n" in block else block - lines = [line for line in block.split("\n")[2:] if line.startswith("|")] - return [[cell.strip().replace("**", "") for cell in line.strip().strip("|").split("|")] - for line in lines] - - def bash_blocks(text: str) -> list[str]: return re.findall(r"^```bash\n(.*?)^```$", text, re.S | re.M) @@ -128,13 +110,6 @@ def run_stages(work: pathlib.Path, case: str) -> subprocess.CompletedProcess: capture_output=True, text=True) -def test_retrospective_stages_run_in_order() -> None: - """開発の起点 → 基準ブランチ → Pull Request の順に並ぶ。""" - blocks = pr_blocks() - assert blocks["dev_base"][0] < blocks["no_issue"][0] < blocks["pulls"][0] - assert blocks["dev_base"][0] < blocks["group"][0] < blocks["pulls"][0] - - @pytest.mark.parametrize(("case", "branch"), [ ("no_issue", "develop"), ("group", "main"), @@ -172,7 +147,6 @@ def test_retrospective_group_stops_without_the_distribution_branch(clone: pathli def test_retrospective_keeps_only_merged_pull_requests() -> None: """基準コミットに付く Pull Request のうち、マージ済みだけを番号として出す。""" block = pr_blocks()["pulls"][1] - assert 'gh api "/repos/$RECORD_REPO/commits/$(git rev-parse "origin/$record_base")/pulls"' in block expression = re.search(r"--jq '([^']*)'", block).group(1) pulls = [ {"number": 1, "merged_at": None, "base": {"ref": "main"}, "head": {"ref": "topic"}}, @@ -182,101 +156,3 @@ def test_retrospective_keeps_only_merged_pull_requests() -> None: done = subprocess.run(["jq", "-r", expression], input=json.dumps(pulls), capture_output=True, text=True, check=True) assert done.stdout.splitlines() == ["#2 main <- develop"] - - -def test_retrospective_asks_when_not_exactly_one_pull_request() -> None: - """絞った結果が 1 件でないときは、推測で投稿せず番号を利用者に聞く。""" - text = plain(section(read(RETROSPECTIVE), PR_SECTION)) - assert "起点が 1 件の issue なら、番号はそのまま使える" in text - assert "絞った結果が 1 件でないときは、推測で投稿せず番号を利用者に聞く" in text - cases = {row[0]: row[1] for row in rows(section(read(RETROSPECTIVE), PR_SECTION), - "| 場合 | 起点にするコミット |")} - assert set(cases) == {"起点の issue を持たない変更", "まとまり"} - assert "`base_branch`" in cases["起点の issue を持たない変更"] - assert "正式版のチャネル" in cases["まとまり"] - - -# ---------- problem-solving: 型不一致 ---------- - -TYPE_SECTION = "### 型不一致の検出パターン" - - -def test_problem_solving_lists_the_risky_type_pairs() -> None: - """危険な組み合わせ 3 つと、そのリスクと対策。""" - part = section(read(PROBLEM_SOLVING), TYPE_SECTION) - pairs = rows(part, "| ローカルキー型 | 外部キー型 | リスク |") - assert [(row[0], row[1]) for row in pairs] == [ - ("VARCHAR", "INT"), ("INT", "VARCHAR"), ("string", "integer"), - ] - assert "Eager Load マッチングでずれる" in pairs[0][2] - assert "strict comparison で不一致" in pairs[2][2] - assert "対策: 型キャストで揃えるか、リレーション定義時に明示的に型変換する。" in plain(part) - - -def test_problem_solving_points_at_type_mismatch_from_two_places() -> None: - """見逃しパターンの 2WD/4WD の行と、チェックリストの型の行が型不一致を扱う。""" - body = read(PROBLEM_SOLVING) - missed = {row[0]: row for row in rows(body, "| 症状 | 表層の「原因」 | 真の根本原因 |")} - assert list(missed) == ["料金が異常値", "レコードの2WD/4WD逆転", "一部ユーザーで通知が届かない"] - cause = missed["レコードの2WD/4WD逆転"][2] - assert "リレーション型不一致" in cause and "Eager Load" in cause - checks = {row[0]: row[1] for row in rows(body, "| チェック項目 | 方法 |")} - assert "DB定義" in checks["型が一致するか"] - assert "`$casts`" in checks["型が一致するか"] - - -def test_problem_solving_keeps_the_type_pairs_in_one_table() -> None: - """型の組み合わせは検出パターンの表だけが持ち、2 か所はそこを指す。""" - body = read(PROBLEM_SOLVING) - part = section(body, TYPE_SECTION) - outside = body.replace(part, "") - assert not re.search(r"(? None: - """由来は起票の本文に節として残り、重複のときは既存の課題へのコメントに残る。""" - body = read(OUT_OF_SCOPE) - assert "## 由来\n" in body - comment = [block for block in bash_blocks(body) if "gh issue comment" in block] - assert comment and "<由来>" in comment[0] - report = [block for block in bash_blocks(body) if "gh pr comment" in block] - assert report and "#<起票した番号>" in report[0] - assert "Pull Request がまだ無い段階では、起点の issue へ同じ 1 行を足す" in plain(body) - - -def test_out_of_scope_origin_has_two_forms() -> None: - """由来は `PR #<番号>` か `issue #<番号>` で、Pull Request を作る前は issue の番号で書く。""" - text = plain(read(OUT_OF_SCOPE)) - assert "`PR #<番号>` か `issue #<番号>`" in text - assert "Pull Request を作る前に見つけた課題は、起点の issue の番号で書く" in text - - -def test_out_of_scope_origin_rule_lives_in_the_trace_section() -> None: - """由来の形の規則は辿り方の節だけが持ち、手順 4〜6 はそこを指す。""" - body = read(OUT_OF_SCOPE) - trace = section(body, TRACE_SECTION) - rule = "Pull Request を作る前に見つけた課題は、起点の issue の番号で書く" - assert plain(body).count(rule) == 1 and rule in plain(trace) - for step in ("### 4. 重複を確かめる", "### 5. 起票する", "### 6. 由来を残す"): - assert "「起票した課題の辿り方」" in section(body, step), step - - -def test_out_of_scope_searches_by_origin_in_every_repository() -> None: - """由来で検索し、本文とコメントの両方を対象にし、両方のリポジトリを見る。""" - part = section(read(OUT_OF_SCOPE), TRACE_SECTION) - search = [block for block in bash_blocks(part) if "gh issue list" in block] - assert search and '--state all --search "<由来>"' in search[0] - assert '"PR #177" / "issue #175"' in search[0] - text = plain(part) - assert "in:body で絞らない" in text.replace("`", "") - assert "両方のリポジトリを検索する" in text - assert "label は増やさない" in text diff --git a/plugins/ndf/skills/out-of-scope/tests/issue_target_helpers.py b/plugins/ndf/skills/out-of-scope/tests/issue_target_helpers.py index 509ba6022..55e9b7d42 100644 --- a/plugins/ndf/skills/out-of-scope/tests/issue_target_helpers.py +++ b/plugins/ndf/skills/out-of-scope/tests/issue_target_helpers.py @@ -3,28 +3,20 @@ `conftest.py` ではなく固有名のモジュールへ置く。pytest は収集したテストのあるディレクトリを `sys.path` の先頭へ足すため、束を同時に実行すると同名のファイルが互いを覆う。**束ごとに 違う名前を付ければ、どの束から実行しても同じものが読まれる。** - -表の読み取りは `retrospective` の束にも同じものがある。**束は単独でも実行できる必要があり、 -別の Skill のテストのディレクトリは、その束を収集しない限り `sys.path` に載らない。** -共有するには置き場所をリポジトリの根へ上げることになるため、束ごとに持つ。 """ from __future__ import annotations import os -import re import subprocess from pathlib import Path SKILL_DIR = Path(__file__).resolve().parents[1] -SKILL = SKILL_DIR / "SKILL.md" REFERENCE = SKILL_DIR / "references" / "issue-target.md" REMOTE = "https://github.com/devbasex/ai-plugins.git" SLUG = "devbasex/ai-plugins" -DECISION_TABLE_HEADING = "## 判断表" RESOLUTION_TABLE_HEADING = "## 起票先のリポジトリを決める" -CROSS_REPOSITORY_HEADING = "## 両方にまたがる課題" def read(path: Path) -> str: @@ -32,38 +24,6 @@ def read(path: Path) -> str: return path.read_text(encoding="utf-8") -def table(body: str, heading: str) -> tuple[list[str], list[list[str]]]: - """見出しの直後に来る表の見出し行と本文の行を返す。 - - 読み取れないこと自体を失敗として扱う。素通りさせると、表の書き方を変えるだけで - この検査を無効にできる。 - """ - lines = body.splitlines() - try: - start = next(i for i, line in enumerate(lines) if line.strip() == heading) - except StopIteration: - raise AssertionError(f"見出しが見つからない: {heading}") - header: list[str] = [] - rows: list[list[str]] = [] - for line in lines[start + 1 :]: - stripped = line.strip() - if stripped.startswith("#"): - break - if not stripped.startswith("|"): - if rows: - break - continue - cells = [c.strip() for c in stripped.strip("|").split("|")] - if not header: - header = cells - continue - if set("".join(cells)) <= set("-: "): - continue - rows.append(cells) - assert header and rows, f"表を読み取れない: {heading}" - return header, rows - - def fenced_blocks(body: str) -> list[str]: """囲みの中身を、本文に現れる順で返す。""" blocks: list[str] = [] @@ -96,37 +56,6 @@ def resolution_snippet(body: str) -> str: return found[0] -def headings(body: str, prefix: str) -> list[str]: - """その深さの見出しを、本文に現れる順で返す。囲みの中は数えない。""" - found: list[str] = [] - fenced = False - for line in body.splitlines(): - if line.lstrip().startswith("```"): - fenced = not fenced - continue - if fenced: - continue - if line.startswith(prefix): - found.append(line[len(prefix) :].strip()) - return found - - -def command_lines(body: str) -> list[str]: - """囲みの中の行だけを返す。 - - 実行できる呼び出しかどうかを見るため、本文の説明に出てくる同じ語は数えない。 - """ - found: list[str] = [] - fenced = False - for line in body.splitlines(): - if line.lstrip().startswith("```"): - fenced = not fenced - continue - if fenced: - found.append(line) - return found - - def section(body: str, heading: str) -> list[str]: """見出しから、次の同じ深さ以上の見出しまでの行を返す。 @@ -156,47 +85,6 @@ def section(body: str, heading: str) -> list[str]: return found -def ordered_steps(body: str, heading: str) -> list[str]: - """節の中の番号付きの手順を、本文に現れる順で返す。囲みの中は数えない。""" - steps: list[str] = [] - fenced = False - for line in section(body, heading): - if line.lstrip().startswith("```"): - fenced = not fenced - continue - if fenced: - continue - matched = re.match(r"^(\d+)\.\s+(.*)$", line.strip()) - if matched: - steps.append(matched.group(2).strip()) - assert steps, f"番号付きの手順を読み取れない: {heading}" - return steps - - -def gh_issue_commands(body: str) -> list[str]: - """囲みの中の `gh issue` で始まる呼び出しを、行の継続をつないで返す。 - - `\\` で折り返した呼び出しは 1 つにまとめる。行ごとに見ると、次の行へ回した - `--repo` を渡していないものとして読んでしまう。 - """ - joined: list[str] = [] - buffer = "" - for line in command_lines(body): - stripped = line.strip() - if not buffer and not stripped.startswith("gh issue"): - continue - continues = stripped.endswith("\\") - if continues: - stripped = stripped[:-1].strip() - buffer = f"{buffer} {stripped}".strip() - if not continues: - joined.append(buffer) - buffer = "" - if buffer: - joined.append(buffer) - return joined - - # 段 2 が見る位置を、ランタイムごとに作る。**手順書の表が挙げる位置と同じものを作る。** # 手順は「1 つに絞れたときだけ採る」ため、どのランタイムでも配置は 1 つにする。 RUNTIME_LAYOUTS = { diff --git a/plugins/ndf/skills/out-of-scope/tests/test_issue_target.py b/plugins/ndf/skills/out-of-scope/tests/test_issue_target.py index 4e9ed51f9..2fe656c93 100644 --- a/plugins/ndf/skills/out-of-scope/tests/test_issue_target.py +++ b/plugins/ndf/skills/out-of-scope/tests/test_issue_target.py @@ -13,76 +13,17 @@ import pytest from issue_target_helpers import ( - CROSS_REPOSITORY_HEADING, - DECISION_TABLE_HEADING, REFERENCE, RESOLUTION_TABLE_HEADING, RUNTIME_LAYOUTS, - SKILL, SLUG, - gh_issue_commands, - headings, make_clone, - ordered_steps, read, resolution_snippet, run_resolution, runtime_layout, - section, - table, ) -# 手順の見出しの並び。**起票先を決める段は 3 択の直後に来る。** 起票先が要るのは -# 「起票する」を選んだときだけで、重複の確認もその起票先に対して行う。 -EXPECTED_STEPS = [ - "1. 範囲かを照合する", - "2. 3 択で決める", - "3. 起票先を決める", - "4. 重複を確かめる", - "5. 起票する", - "6. 由来を残す", -] - - -def test_the_decision_table_lists_three_kinds_and_their_target() -> None: - """判断表は 3 つの性質と、それぞれの起票先を持つ。""" - header, rows = table(read(REFERENCE), DECISION_TABLE_HEADING) - assert header[1] == "起票先", f"2 列目が起票先ではない: {header}" - assert len(rows) == 3, f"判断表の行数が 3 ではない: {rows}" - assert all(row[0] for row in rows), f"性質が空の行がある: {rows}" - assert all("リポジトリ" in row[1] for row in rows), f"起票先がリポジトリを指していない: {rows}" - - -def test_the_resolution_table_has_three_stages() -> None: - """起票先のリポジトリの解決は 3 段で、段 3 は止まる側に倒す。""" - _, rows = table(read(REFERENCE), RESOLUTION_TABLE_HEADING) - assert [row[0] for row in rows] == ["1", "2", "3"], f"段の並びが違う: {rows}" - assert "NDF_SKILL_REPO" in rows[0][1], f"段 1 が環境変数を見ていない: {rows[0]}" - assert "remote.origin.url" in rows[1][1], f"段 2 が取得元の clone を見ていない: {rows[1]}" - - -def test_the_second_stage_only_looks_at_clones_that_carry_ndf() -> None: - """段 2 は、NDF の実体を持つ clone だけを候補にする。 - - 取得元の位置には登録したすべての clone が並ぶ。GitHub の取得元であることだけを条件に - すると、Slack など配布元ではないリポジトリが候補に入る。 - """ - _, rows = table(read(REFERENCE), RESOLUTION_TABLE_HEADING) - assert "plugins/ndf" in rows[1][1], f"段 2 が配布元へ絞っていない: {rows[1]}" - assert "plugins/ndf" in resolution_snippet(read(REFERENCE)), "解決が配布元へ絞っていない" - - -def test_the_second_stage_does_not_adopt_inside_the_loop() -> None: - """候補の採用を、走査の途中で行わない。 - - ループの中で決めると、先頭から見て最初に条件へ合った取得元が採られる。並びは名前順で - あって、配布元が先に来る保証は無い。複数残るときは段 3 へ倒す。 - """ - code = resolution_snippet(read(REFERENCE)) - assert "for clone" in code and "done" in code, f"取得元の走査が読み取れない: {code}" - loop = code[code.index("for clone") : code.index("done")] - assert "SKILL_REPO=" not in loop, f"走査の途中で採用している: {loop}" - @pytest.mark.parametrize("runtime", sorted(RUNTIME_LAYOUTS)) def test_the_second_stage_resolves_on_every_runtime(runtime: str, tmp_path: Path) -> None: @@ -142,88 +83,3 @@ def test_an_unreadable_resolution_snippet_fails() -> None: """解決の囲みを読み取れないことは、素通りではなく失敗になる。""" with pytest.raises(AssertionError): resolution_snippet(f"{RESOLUTION_TABLE_HEADING}\n\n本文だけの節\n") - - -def test_the_reference_covers_the_duplicate_check_and_the_creation() -> None: - """重複の確認と起票と、既存の issue への追記が呼び出しとして載っている。""" - commands = gh_issue_commands(read(REFERENCE)) - for command in ("gh issue list", "gh issue create", "gh issue comment"): - assert any(line.startswith(command) for line in commands), f"呼び出しが無い: {command}" - - -@pytest.mark.parametrize("path", [REFERENCE, SKILL], ids=["reference", "skill"]) -def test_every_gh_issue_call_passes_the_target(path: Path) -> None: - """`gh issue` の呼び出しは、どのファイルのどの下位コマンドでも起票先を受け取る。 - - `--repo` を省くと、いま作業しているリポジトリへ向かう。片方のファイルだけを見ると、 - もう片方から `--repo` が抜けたことに気づけない。 - """ - commands = gh_issue_commands(read(path)) - assert commands, f"`gh issue` の呼び出しを読み取れない: {path}" - missing = [line for line in commands if "--repo" not in line] - assert not missing, f"起票先が渡されていない: {missing}" - - -def test_the_steps_keep_their_order() -> None: - """起票先を決める段が 3 択の直後にある。""" - assert headings(read(SKILL), "### ") == EXPECTED_STEPS - - -def test_the_consent_shows_the_target() -> None: - """起票の前に取る同意の提示に、起票先が含まれる。""" - lines = [line for line in read(SKILL).splitlines() if "同意を取る" in line] - assert lines, "同意を取る提示が見つからない" - assert all("起票先" in line for line in lines), f"提示に起票先が無い: {lines}" - - -def test_the_skill_points_at_the_reference() -> None: - """手順が判断表の置き場所を指している。""" - assert "references/issue-target.md" in read(SKILL) - - -@pytest.mark.parametrize("heading", [DECISION_TABLE_HEADING, RESOLUTION_TABLE_HEADING]) -def test_an_unreadable_table_fails(heading: str) -> None: - """表を読み取れないことは、素通りではなく失敗になる。""" - with pytest.raises(AssertionError): - table("見出しの無い本文", heading) - - -def test_the_cross_repository_steps_start_from_the_skill_repository() -> None: - """両方にまたがる課題は、配布元を先に起票する。 - - 番号が先に決まれば、開発対象の側の本文へその番号を書ける。逆順にすると、どちらの - 本文にも相手の番号が無い状態が一度できる。 - """ - steps = ordered_steps(read(REFERENCE), CROSS_REPOSITORY_HEADING) - assert len(steps) == 3, f"手順の数が 3 ではない: {steps}" - assert "配布元" in steps[0] and "起票" in steps[0], f"最初が配布元への起票ではない: {steps[0]}" - assert "開発対象" in steps[1], f"2 番目が開発対象への起票ではない: {steps[1]}" - - -def test_the_cross_repository_steps_link_both_numbers() -> None: - """双方の番号が互いから辿れる。開発対象の本文に配布元の番号、配布元にコメントで開発対象の番号。""" - steps = ordered_steps(read(REFERENCE), CROSS_REPOSITORY_HEADING) - assert "本文" in steps[1] and "配布元の番号" in steps[1], f"本文へ配布元の番号を書いていない: {steps[1]}" - assert "配布元" in steps[2], f"3 番目が配布元への追記ではない: {steps[2]}" - assert "開発対象の番号" in steps[2] and "コメント" in steps[2], f"開発対象の番号を残していない: {steps[2]}" - - -def test_the_cross_repository_comment_is_executable() -> None: - """配布元へ番号を戻す追記が、起票先を受け取る呼び出しとして載っている。""" - body = "\n".join(section(read(REFERENCE), CROSS_REPOSITORY_HEADING)) - commands = gh_issue_commands(body) - found = [line for line in commands if line.startswith("gh issue comment")] - assert found, f"追記の呼び出しが無い: {commands}" - assert all("--repo" in line for line in found), f"起票先が渡されていない: {found}" - - -def test_the_skill_points_at_the_cross_repository_section() -> None: - """手順が、両方にまたがる場合の節を名前で指している。""" - assert CROSS_REPOSITORY_HEADING.lstrip("# ") in read(SKILL) - - -@pytest.mark.parametrize("heading", [CROSS_REPOSITORY_HEADING]) -def test_an_unreadable_section_fails(heading: str) -> None: - """節を読み取れないことは、素通りではなく失敗になる。""" - with pytest.raises(AssertionError): - ordered_steps("見出しの無い本文", heading) diff --git a/plugins/ndf/skills/quality-gates/SKILL.md b/plugins/ndf/skills/quality-gates/SKILL.md index 41439ef74..ebd285da2 100644 --- a/plugins/ndf/skills/quality-gates/SKILL.md +++ b/plugins/ndf/skills/quality-gates/SKILL.md @@ -124,6 +124,10 @@ flowchart LR 条件ごとに検証手段を書く。手段を書けない条件は満たしたと言えない。 +**文書の文言を照合するテストを、受け入れ条件の検証手段にしない。** 文書(`.md`)の変更を +求める条件は、該当の節を読んで確かめ、その節を証跡に書く。文言を固定しても振る舞いは +保証されない(`tdd-cycle` の `references/test-quality.md` の例 8)。 + ## カバレッジの扱い **閾値をこの Skill に持たない。** 対象プロジェクトが持つカバレッジツールの設定を唯一の diff --git a/plugins/ndf/skills/release/tests/test_completion_check.py b/plugins/ndf/skills/release/tests/test_completion_check.py index 0d51588d4..2c43c40b9 100644 --- a/plugins/ndf/skills/release/tests/test_completion_check.py +++ b/plugins/ndf/skills/release/tests/test_completion_check.py @@ -5,14 +5,6 @@ ログに出ると見込んだ語を待つループになった。公開の操作は成功していたが、その語はログに 出ないため、待ちだけが残った。 -検査の対象は 3 つに分かれる。 - -| 何を | どこを見るか | -| --- | --- | -| 形ごとに違う値 | `references/form-*.md` の「完了の事実」の項目 | -| 形をまたぐ決まり | `references/completion-check.md` の 4 つの節 | -| 工程への結び付け | `SKILL.md` の手順 4・完了の判定・出力物・形ごとに変わるものの列挙 | - **記載を消したときも落ちる。** 位置を決める語が見つからなければ、読み取れないこととして 失敗させる(`scripts/check-doc-staleness.py` と同じ扱い)。読み取りの関数を文字列に対して 呼べる形にしてあるのは、記載を取り除いた文字列で落ちることを同じ検査で確かめるためである。 @@ -37,7 +29,6 @@ REFERENCES = SKILL_DIR / "references" DISTRIBUTION_FORMS = REFERENCES / "distribution-forms.md" COMPLETION_CHECK = REFERENCES / "completion-check.md" -FORM_FILES = sorted(REFERENCES.glob("form-*.md")) # `completion-check.md` が持つ 4 つの節。並びも手順の順序に合わせる。 COMPLETION_SECTIONS = [ @@ -47,21 +38,16 @@ "## 上限に達したとき", ] -FORM_INDEX = "## 形ごとのファイル" - -STEP_FOUR = "### 4. 公開する" -CRITERIA = "## 完了の判定" -OUTPUT = "## 出力物" -RULES_SECTION = COMPLETION_SECTIONS[0] TEMPLATE_SECTION = COMPLETION_SECTIONS[1] LIMIT_SECTION = COMPLETION_SECTIONS[2] +FORM_INDEX = "## 形ごとのファイル" + # #554 が退避の手順へ指示書の検査を足した分(4 行。**詳細は # `references/instruction-files.md` が持ち、本文には呼び出しと参照への案内だけを置く**)と、 # #623 が足した「配布の記録」(別の実行の終わりの工程が読むブロック)と「まとまりを閉じる」を # 含む。300 → 320 のときと同じく実測(353 行)へ余地を足して上げる。 SKILL_MD_MAX_LINES = 365 -MARKDOWN_MAX_LINES = 500 def read(path: Path) -> str: @@ -94,59 +80,6 @@ def section(body: str, heading: str) -> str: return text -def enumeration(body: str) -> list[str]: - """「形ごとに変わるもの」の列挙を、書かれた並びのまま返す。""" - flat = body.replace("\n", "").replace("**", "") - found = re.search(r"形ごとに変わるのは、?(.+?)の\s*(\d+)\s*つ", flat) - assert found, "「形ごとに変わるもの」の列挙を読み取れない" - items = [item.strip() for item in found.group(1).split("・")] - assert all(items), "列挙に空の項目がある" - assert len(items) == int(found.group(2)), ( - f"列挙の数と書かれた個数が食い違う: {len(items)} 個に対して {found.group(2)} と書かれている" - ) - return items - - -def completion_facts(body: str) -> list[str]: - """「完了の事実」の項目の本文を返す。続きの行も本文に含める。""" - lines = body.splitlines() - found: list[str] = [] - for index, line in enumerate(lines): - head = re.match(r"- \*\*完了の事実\*\*[::](.*)$", line.strip()) - if not head: - continue - parts = [head.group(1)] - for follow in lines[index + 1 :]: - if not follow.startswith(" ") or not follow.strip(): - break - parts.append(follow.strip()) - found.append(" ".join(parts).strip()) - assert found, "「完了の事実」の項目が見つからない" - assert all(found), "「完了の事実」の項目に本文が無い" - return found - - -def table_rows(body: str, heading: str) -> list[list[str]]: - """節の中の最初の表の本文の行を、セルの並びとして返す。""" - rows: list[list[str]] = [] - seen_header = False - for line in section(body, heading).splitlines(): - stripped = line.strip() - if not stripped.startswith("|"): - if rows: - break - continue - cells = [cell.strip() for cell in stripped.strip("|").split("|")] - if not seen_header: - seen_header = True - continue - if set("".join(cells)) <= set("-: "): - continue - rows.append(cells) - assert rows, f"表を読み取れない: {heading}" - return rows - - def fenced_blocks(body: str, language: str) -> list[str]: blocks: list[str] = [] current: list[str] | None = None @@ -163,99 +96,30 @@ def fenced_blocks(body: str, language: str) -> list[str]: return blocks + def link_targets(body: str) -> list[str]: return re.findall(r"\[[^\]]+\]\(([^)]+)\)", body) -# --- 条件 1: 形ごとの「完了の事実」 ------------------------------------------------- - - -def test_the_form_files_are_the_known_forms() -> None: - """形は 9 つある。上の 5 つが配布物、下の 4 つが `documentation` の出力の形である。""" - assert [path.name for path in FORM_FILES] == [ - "form-desktop.md", - "form-document.md", - "form-mobile.md", - "form-package-plugin.md", - "form-page.md", - "form-procedure.md", - "form-service.md", - "form-slide.md", - "form-spreadsheet.md", - ] +# --- 条件 1: 形ごとのファイルの索引 --------------------------------------------------- def test_the_form_index_links_to_every_form_file() -> None: - """索引の表が 9 形すべてを指す。 + """索引の表が実在する `form-*.md` をすべて指す。 実ファイルが増えても表へ足し忘れると、`SKILL.md` から辿れる先はそのままである。 - ファイルの有無ではなく、**索引から辿り着けるか**を見る。 + ファイルの有無ではなく、**索引から辿り着けるか**を見る。形の数は固定しない。 """ listed = [ target for target in link_targets(section(read(DISTRIBUTION_FORMS), FORM_INDEX)) if target.startswith("form-") ] - assert sorted(listed) == [path.name for path in FORM_FILES] - - -@pytest.mark.parametrize("path", FORM_FILES, ids=lambda path: path.name) -def test_every_form_file_states_one_completion_fact(path: Path) -> None: - facts = completion_facts(read(path)) - assert len(facts) == 1, f"{path.name} の「完了の事実」が {len(facts)} 個ある" - - -def test_the_number_of_completion_facts_matches_the_number_of_form_files() -> None: - total = sum(len(completion_facts(read(path))) for path in FORM_FILES) - assert total == len(FORM_FILES) - - -def test_the_service_form_covers_the_image_registry() -> None: - """公開の操作の途中で作られる配布物にも同じ考え方を当てる(#228 の事例)。""" - body = read(REFERENCES / "form-service.md") - assert "イメージ" in body - - -# --- 条件 2: 「形ごとに変わるもの」の列挙 ------------------------------------------ - - -def test_the_two_enumerations_are_the_same_five_items_in_the_same_order() -> None: - assert enumeration(read(SKILL)) == enumeration(read(DISTRIBUTION_FORMS)) - - -def test_the_enumeration_has_five_items_including_the_completion_check() -> None: - items = enumeration(read(SKILL)) - assert len(items) == 5 - assert "完了の確かめ方" in items - + assert sorted(listed) == sorted(p.name for p in REFERENCES.glob("form-*.md")) # --- 条件 3: 形をまたぐ決まり ------------------------------------------------------ -@pytest.mark.parametrize("heading", COMPLETION_SECTIONS) -def test_the_completion_check_has_the_section(heading: str) -> None: - assert section(read(COMPLETION_CHECK), heading) - - -def test_the_sections_are_written_in_this_order() -> None: - body = read(COMPLETION_CHECK) - positions = [body.index(heading) for heading in COMPLETION_SECTIONS] - assert positions == sorted(positions) - - -def test_the_rules_table_separates_required_from_recommended() -> None: - handling = [row[1] for row in table_rows(read(COMPLETION_CHECK), RULES_SECTION)] - assert handling.count("必須") == 2, f"必須の決まりが 2 つでない: {handling}" - assert handling.count("推奨") == 1, f"推奨の決まりが 1 つでない: {handling}" - - -def test_the_limit_section_states_the_minimum_interval() -> None: - """照会のループに待機を必須とし、既定の秒数を書く。""" - body = section(read(COMPLETION_CHECK), LIMIT_SECTION) - assert "5 秒" in body - assert "必須" in body - - def template() -> str: """雛形の bash を返す。""" blocks = fenced_blocks(section(read(COMPLETION_CHECK), TEMPLATE_SECTION), "bash") @@ -300,7 +164,6 @@ def watch(work: str, log_body: str | None, **overrides: str) -> subprocess.Compl def test_the_template_is_valid_bash() -> None: - assert "sleep" in template(), "照会のループに待機が無い" with tempfile.NamedTemporaryFile("w", suffix=".sh", encoding="utf-8") as handle: handle.write(template()) handle.flush() @@ -387,47 +250,6 @@ def test_an_unset_variable_stops_before_the_loop() -> None: assert not done.stdout.split(), done.stdout -# --- 条件 4〜6: 工程への結び付け ---------------------------------------------------- - - -def test_step_four_points_to_the_shared_rules_and_the_form_files() -> None: - body = section(read(SKILL), STEP_FOUR) - assert "references/completion-check.md" in link_targets(body) - assert "form-" in body, "形ごとのファイルを指していない" - assert "完了の事実" in body - - -def test_the_completion_criteria_requires_the_completion_fact() -> None: - bullets = [ - line for line in section(read(SKILL), CRITERIA).splitlines() if line.startswith("- ") - ] - assert any("完了の事実" in line for line in bullets), "完了の事実を求める項目が無い" - - -def test_the_report_template_has_a_line_for_the_checked_value() -> None: - blocks = fenced_blocks(section(read(SKILL), OUTPUT), "markdown") - assert blocks, "出力物の雛形が見つからない" - lines = [line for line in blocks[0].splitlines() if line.startswith("完了の確認:")] - assert len(lines) == 1, "出力物の雛形に「完了の確認」の行が無い" - assert "<" in lines[0] and ">" in lines[0], "確かめた値を書く欄になっていない" - - -# --- 条件 7: 分量 ------------------------------------------------------------------ - - -def test_the_skill_md_stays_within_its_budget() -> None: - lines = len(read(SKILL).splitlines()) - assert lines <= SKILL_MD_MAX_LINES, f"SKILL.md が {lines} 行" - - -@pytest.mark.parametrize( - "path", sorted(SKILL_DIR.rglob("*.md")), ids=lambda path: path.name -) -def test_every_markdown_stays_within_the_file_budget(path: Path) -> None: - lines = len(read(path).splitlines()) - assert lines <= MARKDOWN_MAX_LINES, f"{path.name} が {lines} 行" - - # --- 条件 10: 記載を消したときも落ちる --------------------------------------------- @@ -463,86 +285,9 @@ def test_an_empty_section_is_not_passed_over() -> None: section(f"{LIMIT_SECTION}\n\n## 次の節\n", LIMIT_SECTION) -def test_a_missing_enumeration_is_not_passed_over() -> None: - with pytest.raises(AssertionError): - enumeration("形ごとに変わるものは書かれていない。\n") - - -def test_a_miscounted_enumeration_is_not_passed_over() -> None: - with pytest.raises(AssertionError): - enumeration("形ごとに変わるのは、起点の決め方・公開の仕方の 5 つである。\n") - - -def test_a_missing_completion_fact_is_not_passed_over() -> None: - with pytest.raises(AssertionError): - completion_facts("- **起点**: 直前の版のタグ\n") - - -def test_an_empty_completion_fact_is_not_passed_over() -> None: - with pytest.raises(AssertionError): - completion_facts("- **完了の事実**:\n") - - -def test_a_missing_table_is_not_passed_over() -> None: - with pytest.raises(AssertionError): - table_rows(f"{RULES_SECTION}\n\n表の無い本文。\n", RULES_SECTION) - - -# --- 配布の記録: 配布を飛ばした経路 -------------------------------------------------- - - -RECORD_HEADING = "## 配布の記録" - - -def record_fields(block: str) -> dict[str, str]: - """「配布の記録」のブロックを、見出しの直後に並ぶ `<項目>: <値>` の対応として返す。""" - lines = [line for line in block.splitlines() if line.strip()] - assert lines and lines[0] == RECORD_HEADING, f"見出しで始まらない: {lines[:1]}" - fields: dict[str, str] = {} - for line in lines[1:]: - found = re.match(r"(段階|版|まとまり): (.+)$", line) - assert found, f"項目の行として読めない: {line}" - fields[found.group(1)] = found.group(2) - return fields - - -def test_the_production_distribution_record_exposes_the_three_reader_fields_in_order() -> None: - """現状固定: 通常の本番配布は段階・版・まとまりを見出し直後に各 1 行置く。""" - output = section(read(SKILL), OUTPUT) - examples = [ - block for block in fenced_blocks(output, "markdown") if block.startswith(RECORD_HEADING) - ] - assert len(examples) == 1, f"配布の記録の例が 1 つでない: {len(examples)} 個" - - fields = record_fields(examples[0]) - - assert list(fields.items()) == [ - ("段階", "本番(2026-09-18 10:00 に承認)"), - ("版", "10.14.0 → 10.15.0(MINOR: 機能追加)"), - ("まとまり", "PR #717 / #718 / #720"), - ] - +# --- 条件 7: 分量 ------------------------------------------------------------------ -def test_the_skipped_distribution_record_starts_without_distribution_and_has_no_version() -> None: - """現状固定: 配布を飛ばしたときは `段階: 配布なし` と `まとまり:` の 2 行だけを置く。 - 規則は出力物の節の本文から取り、通常の例と同じ読み取りへ通して項目ごとに比べる。 - """ - output = section(read(SKILL), OUTPUT) - examples = [ - block for block in fenced_blocks(output, "markdown") if block.startswith(RECORD_HEADING) - ] - assert len(examples) == 1, f"配布の記録の例が 1 つでない: {len(examples)} 個" - normal = record_fields(examples[0]) - assert list(normal) == ["段階", "版", "まとまり"], normal - assert normal["段階"].startswith("本番"), normal - - flat = output.replace("\n", "") - stage = re.search(r"`(段階: 配布なし([^`]+))`", flat) - assert stage, "配布を飛ばしたときの `段階:` の書き方が無い" - assert "`まとまり:` の 2 行だけを書き(`版:` は書かない)" in flat - - skipped = record_fields(f"{RECORD_HEADING}\n\n{stage.group(1)}\nまとまり: {normal['まとまり']}\n") - assert skipped["段階"].startswith("配布なし"), skipped - assert "版" not in skipped, skipped - assert skipped["まとまり"] == normal["まとまり"], skipped +def test_the_skill_md_stays_within_its_budget() -> None: + lines = len(read(SKILL).splitlines()) + assert lines <= SKILL_MD_MAX_LINES, f"SKILL.md が {lines} 行" diff --git a/plugins/ndf/skills/retrospective/tests/retrospective_helpers.py b/plugins/ndf/skills/retrospective/tests/retrospective_helpers.py deleted file mode 100644 index 984369184..000000000 --- a/plugins/ndf/skills/retrospective/tests/retrospective_helpers.py +++ /dev/null @@ -1,142 +0,0 @@ -"""振り返りの記録先を読み取る補助。 - -`conftest.py` ではなく固有名のモジュールへ置く。pytest は収集したテストのあるディレクトリを -`sys.path` の先頭へ足すため、束を同時に実行すると同名のファイルが互いを覆う。 - -表の読み取りは `out-of-scope` の束にも同じものがある。**束は単独でも実行できる必要があり、 -別の Skill のテストのディレクトリは、その束を収集しない限り `sys.path` に載らない。** -起票先の判断表は import ではなくファイルとして読む。 -""" -from __future__ import annotations - -import re -from pathlib import Path - -SKILL_DIR = Path(__file__).resolve().parents[1] -SKILL = SKILL_DIR / "SKILL.md" -ISSUE_TARGET = SKILL_DIR.parent / "out-of-scope" / "references" / "issue-target.md" - -POST_TARGET_HEADING = "#### 投稿先を決める" -CHANGE_TABLE_HEADING = "### 3. 次に変えることを決める" -DECISION_TABLE_HEADING = "## 判断表" - -# 記録の投稿先のリポジトリを持つ変数。**起票先とは別のものである。** 起票先は課題の性質が -# 決めるため配布元になることがあるが、記録はその変更を行ったリポジトリに残る。 -RECORD_REPO = "RECORD_REPO" - -LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)") - - -def read(path: Path) -> str: - assert path.is_file(), f"ファイルが無い: {path}" - return path.read_text(encoding="utf-8") - - -def _table_span(lines: list[str], heading: str) -> tuple[int, int]: - """見出しの直後に来る表の、最初の行と終端の次の行を返す。""" - try: - start = next(i for i, line in enumerate(lines) if line.strip() == heading) - except StopIteration: - raise AssertionError(f"見出しが見つからない: {heading}") - first = -1 - for index in range(start + 1, len(lines)): - stripped = lines[index].strip() - if stripped.startswith("#"): - break - if stripped.startswith("|"): - if first < 0: - first = index - continue - if first >= 0: - return first, index - assert first >= 0, f"表を読み取れない: {heading}" - return first, len(lines) - - -def table(body: str, heading: str) -> tuple[list[str], list[list[str]]]: - """見出しの直後に来る表の見出し行と本文の行を返す。""" - lines = body.splitlines() - first, end = _table_span(lines, heading) - header: list[str] = [] - rows: list[list[str]] = [] - for line in lines[first:end]: - cells = [c.strip() for c in line.strip().strip("|").split("|")] - if not header: - header = cells - continue - if set("".join(cells)) <= set("-: "): - continue - rows.append(cells) - assert header and rows, f"表を読み取れない: {heading}" - return header, rows - - -def line_after_table(body: str, heading: str) -> str: - """見出しの直後に来る表の、次に現れる本文の 1 行を返す。""" - lines = body.splitlines() - _, end = _table_span(lines, heading) - for line in lines[end:]: - if line.strip(): - return line.strip() - raise AssertionError(f"表の後に本文が無い: {heading}") - - -def fenced_blocks(body: str) -> list[str]: - """囲みの中身を、本文に現れる順で返す。""" - blocks: list[str] = [] - current: list[str] | None = None - for line in body.splitlines(): - if line.lstrip().startswith("```"): - if current is None: - current = [] - else: - blocks.append("\n".join(current)) - current = None - continue - if current is not None: - current.append(line) - return blocks - - -def command_lines(body: str) -> list[str]: - """囲みの中の行だけを返す。 - - 実行できる呼び出しかどうかを見るため、本文の説明に出てくる同じ語は数えない。 - """ - found: list[str] = [] - fenced = False - for line in body.splitlines(): - if line.lstrip().startswith("```"): - fenced = not fenced - continue - if fenced: - found.append(line) - return found - - -def gh_commands(body: str) -> list[str]: - """囲みの中の `gh` で始まる呼び出しを、行の継続をつないで返す。 - - `\\` で折り返した呼び出しは 1 つにまとめる。行ごとに見ると、次の行へ回した - `--repo` を渡していないものとして読んでしまう。 - """ - joined: list[str] = [] - buffer = "" - for line in command_lines(body): - stripped = line.strip() - if not buffer and not stripped.startswith("gh "): - continue - continues = stripped.endswith("\\") - if continues: - stripped = stripped[:-1].strip() - buffer = f"{buffer} {stripped}".strip() - if not continues: - joined.append(buffer) - buffer = "" - if buffer: - joined.append(buffer) - return joined - - -def link_targets(text: str) -> list[str]: - return [target.split("#", 1)[0].strip() for target in LINK_RE.findall(text)] diff --git a/plugins/ndf/skills/retrospective/tests/test_context_window_section.py b/plugins/ndf/skills/retrospective/tests/test_context_window_section.py deleted file mode 100644 index fa44c985f..000000000 --- a/plugins/ndf/skills/retrospective/tests/test_context_window_section.py +++ /dev/null @@ -1,69 +0,0 @@ -"""振り返りに context window の集計が載ること(#550 の AC33・AC34)。 - -固定するのは、観点の表に `context window` の行があることと、記録の雛形が確定仕様 -(`docs/specifications/ndf-context-window-metrics.md`「振り返りの記録へ貼る表」)の 3 つの表を -持つことである。**雛形へ載せてよい値は AC30 が許す列だけで、パス・本文・`agent_id` は -載せない**(投稿されるため)。 -""" -from __future__ import annotations - -import re - -from retrospective_helpers import SKILL, read - -SECTION_HEADING = "## context window の大きさ" - -SUMMARY_HEADER = ( - "| 層 | 持ち場 | モデル | 件数 | 固定費の中央値 | 実作業の中央値 " - "| 実作業 < 固定費 | 最大充填の最大 | 印 |" -) -LAYER_TOTALS_HEADER = "| 層 | 件数 | 固定費の合計 | 実作業の合計 | 総消費 |" -ROLE_USAGE_HEADER = ( - "| 持ち場 | supervisor | supervisor の実作業 | worker の件数 " - "| supervisor と worker の固定費の合計 | 印 |" -) - - -def template() -> str: - """記録の雛形(`## 振り返り` で始まる Markdown のコードブロック)を返す。""" - text = read(SKILL) - blocks = re.findall(r"```markdown\n(.*?)```", text, re.DOTALL) - found = [b for b in blocks if SECTION_HEADING in b] - assert found, "記録の雛形に context window の節が無い" - return found[0] - - -# ---------- AC33: 観点と雛形 ---------- - -def test_the_second_step_lists_the_context_window_as_a_viewpoint() -> None: - text = read(SKILL) - start = text.index("### 2. 観点ごとに事実を集める") - end = text.index("### 3. 次に変えることを決める") - rows = [ln for ln in text[start:end].splitlines() if ln.startswith("| ")] - assert any("context window" in ln for ln in rows), rows - - -def test_the_template_holds_the_three_tables() -> None: - body = template() - for header in (SUMMARY_HEADER, LAYER_TOTALS_HEADER, ROLE_USAGE_HEADER): - assert header in body, header - - -def test_the_template_asks_for_the_three_marks() -> None: - body = template() - for mark in ("束ねる候補", "割る候補", "worker を使いすぎ"): - assert mark in body, mark - - -def test_the_values_come_from_the_measuring_command() -> None: - text = read(SKILL) - assert "--agents" in text and "--session" in text - assert "/ndf:skill-stats" in text or "skill-stats" in text - - -# ---------- AC34: 投稿してよい値だけを持つ ---------- - -def test_the_template_carries_no_identifier_no_path_and_no_body() -> None: - body = template() - for forbidden in ("agent_id", "/tmp/", "~/.claude", "プロンプト"): - assert forbidden not in body, forbidden diff --git a/plugins/ndf/skills/retrospective/tests/test_record_target.py b/plugins/ndf/skills/retrospective/tests/test_record_target.py deleted file mode 100644 index 49b17d35a..000000000 --- a/plugins/ndf/skills/retrospective/tests/test_record_target.py +++ /dev/null @@ -1,148 +0,0 @@ -"""振り返りの記録先がコメント 1 件であることを固定する(#242 / #229)。 - -記録のためにブランチと Pull Request を作る費用が、記録の価値を上回っていた。実測では -7 本の Pull Request がこの用途だけで作られている。記録の本体はコメント 1 件へ移し、 -辿る経路は対象の issue の本文末尾の 1 行で作る。 - -起票先の判断表はこの Skill には無い。持つのは `out-of-scope` だけで、ここは参照だけを持つ。 -""" -from __future__ import annotations - -import pytest - -from retrospective_helpers import ( - CHANGE_TABLE_HEADING, - DECISION_TABLE_HEADING, - ISSUE_TARGET, - POST_TARGET_HEADING, - RECORD_REPO, - SKILL, - command_lines, - fenced_blocks, - gh_commands, - line_after_table, - link_targets, - read, - table, -) - -# 記録に残す 3 つの節。雛形から消えると、何を書くかが手順から読めなくなる。 -TEMPLATE_HEADINGS = ("## 何が起きたか", "## 次に変えること", "## 途中で起票した課題") - -# 投稿先を決める 3 つの状況。**起点の issue を持たない変更も網羅する。** -# 置き場所が無いことを理由に工程を飛ばす経路を残さない。 -POST_TARGET_CASES = ("1 件の issue", "複数の issue", "起点の issue を持たない") - -# 記録に触る `gh` の下位コマンド。この工程は `merged` の後に来るため、作業ツリーが消えて -# いる。`--repo` を省くと、いま作業しているディレクトリのリポジトリへ向かう。 -RECORD_COMMANDS = ( - "gh issue list", - "gh issue comment", - "gh pr comment", - "gh issue view", - "gh issue edit", -) - - -def test_the_record_is_not_a_file_under_development_history() -> None: - """記録先としてのディレクトリを求めない。""" - assert "docs/development-history/" not in read(SKILL) - - -def test_the_post_target_table_covers_three_cases() -> None: - """投稿先の表が 3 つの状況を網羅する。""" - _, rows = table(read(SKILL), POST_TARGET_HEADING) - assert len(rows) == 3, f"投稿先の行数が 3 ではない: {rows}" - starts = [row[0] for row in rows] - for case in POST_TARGET_CASES: - assert any(case in start for start in starts), f"網羅されていない状況: {case} / {starts}" - - -def test_the_posting_commands_are_written_out() -> None: - """投稿の手順が、実行できるコマンドとして載っている。""" - body = read(SKILL) - for command in ("gh issue comment", "gh pr comment"): - assert command in body, f"投稿の呼び出しが無い: {command}" - - -def test_the_record_repository_is_resolved_once() -> None: - """記録の投稿先のリポジトリを、1 か所で決める。 - - 決める場所が 2 つあると、片方だけを直したときに投稿と追記が別のリポジトリへ向かう。 - """ - definitions = [ - line.strip() - for line in command_lines(read(SKILL)) - if line.strip().startswith(f"{RECORD_REPO}=") - ] - assert len(definitions) == 1, f"投稿先の変数の定義が 1 か所ではない: {definitions}" - assert "gh repo view" in definitions[0], f"変更を行ったリポジトリから引いていない: {definitions[0]}" - - -def test_every_record_call_passes_the_record_repository() -> None: - """記録に触る `gh` の呼び出しは、すべて投稿先のリポジトリを受け取る。 - - 起票先(`out-of-scope`)とは別のものである。起票先は課題の性質で配布元になることが - あるが、記録は起点の issue と配布した Pull Request がある場所に残る。 - """ - commands = [c for c in gh_commands(read(SKILL)) if c.startswith(RECORD_COMMANDS)] - assert commands, "記録に触る `gh` の呼び出しを読み取れない" - for command in RECORD_COMMANDS: - assert any(c.startswith(command) for c in commands), f"呼び出しが無い: {command}" - missing = [c for c in commands if f'--repo "${RECORD_REPO}"' not in c] - assert not missing, f"投稿先が渡されていない: {missing}" - - -def test_the_pull_request_lookup_targets_the_record_repository() -> None: - """番号を引く `gh api` も、作業ディレクトリではなく投稿先のリポジトリを見る。""" - lines = [line for line in command_lines(read(SKILL)) if "commits/" in line and "/pulls" in line] - assert lines, "コミットから Pull Request を引く呼び出しが無い" - missing = [line for line in lines if f"${RECORD_REPO}" not in line] - assert not missing, f"投稿先のリポジトリを見ていない: {missing}" - - -def test_the_back_reference_line_has_a_fixed_form() -> None: - """本文末尾へ足す 1 行の書式が載っている。""" - lines = [line.strip() for line in read(SKILL).splitlines()] - assert any(line.startswith("振り返り: ") for line in lines), "辿る経路の 1 行の書式が無い" - - -def test_the_template_keeps_its_three_sections() -> None: - """雛形に 3 つの節が残る。""" - blocks = fenced_blocks(read(SKILL)) - assert any( - all(heading in block for heading in TEMPLATE_HEADINGS) for block in blocks - ), "雛形のコードブロックが見つからない" - - -def test_the_pull_request_number_comes_from_the_merge_commit() -> None: - """Pull Request の番号を、消えたブランチではなくコミットから引く。""" - lines = read(SKILL).splitlines() - found = [i for i, line in enumerate(lines) if "commits/" in line and "/pulls" in line] - assert found, "コミットから Pull Request を引く呼び出しが無い" - near = "\n".join(lines[found[0] : found[0] + 6]) - assert "merged_at" in near, f"マージ済みへ絞っていない: {near}" - - -def test_the_decision_table_lives_only_in_out_of_scope() -> None: - """起票先の分類は、この Skill には書き写されていない。""" - _, rows = table(read(ISSUE_TARGET), DECISION_TABLE_HEADING) - body = read(SKILL) - for row in rows: - assert row[0] not in body, f"判断表の分類が書き写されている: {row[0]}" - - -def test_the_change_table_is_followed_by_the_reference() -> None: - """落とし先の表の直後に、判断表への参照が 1 行ある。""" - line = line_after_table(read(SKILL), CHANGE_TABLE_HEADING) - targets = link_targets(line) - assert targets, f"参照のリンクが無い: {line}" - resolved = [(SKILL.parent / target).resolve() for target in targets] - assert ISSUE_TARGET.resolve() in resolved, f"判断表を指していない: {targets}" - - -@pytest.mark.parametrize("heading", [POST_TARGET_HEADING, CHANGE_TABLE_HEADING]) -def test_an_unreadable_table_fails(heading: str) -> None: - """表を読み取れないことは、素通りではなく失敗になる。""" - with pytest.raises(AssertionError): - table("見出しの無い本文", heading) diff --git a/plugins/ndf/skills/tdd-cycle/references/test-quality.md b/plugins/ndf/skills/tdd-cycle/references/test-quality.md index e643a8666..26c58345b 100644 --- a/plugins/ndf/skills/tdd-cycle/references/test-quality.md +++ b/plugins/ndf/skills/tdd-cycle/references/test-quality.md @@ -93,6 +93,21 @@ if os.getenv("TESTING"): テスト時だけ通る経路は、本番で検証されない経路を作る。依存を注入可能にするか、設定として 明示的に扱う。 +### 8. `.md` の文言を照合する + +`SKILL.md`・`references/`・`prompts/`・`docs/` などの文書を読み、言い回し・見出し・表の並び・ +語の有無を `assert "..." in text` で照合するテストを書かない。**文書の振る舞いは文言そのもの +であり、同じ文面をプロンプトに渡しても同じ結果が返るわけではない。** 固定しても振る舞いは +保証されず、文書を整えるたびに期待値を書き換える手間と、構造改善が文書の整理を「振る舞いの +変更」として取り消す副作用だけが残る(#885)。 + +| 見たいもの | 代わりに使うもの | +| --- | --- | +| 文書が指すファイル・スクリプト・Skill の実在(参照切れ) | リンクと参照を検査するスクリプト(対象リポジトリに無ければ、置くか、レビューで見る) | +| 文書の正しさ・言い回し | レビュー | +| 文書に埋め込んだ bash の振る舞い | そのブロックを取り出して実行するテスト | +| スクリプトが実行時に読む `.md`(プロンプト・表)の形式 | そのスクリプトを通すテスト(機械が読む契約) | + ## カバレッジの扱い カバレッジは「テストが実行した行の割合」であって、正しさの指標ではない。数値を上げる @@ -107,5 +122,6 @@ if os.getenv("TESTING"): - 仕様が変わり、検証対象の振る舞いがなくなった(削除して、削除理由を差分に残す) - 同じ入力・同じ検証の重複 - 実装詳細に結合しており、振る舞いを検証していない(上記の例に該当するもの) +- `.md` の文言を照合している(上記の例 8。参照切れを見ていたものは、検査スクリプトが同じ参照で失敗を返すことを確かめてから削る) **落ちるから削除する**のは禁止。落ちた原因を先に説明する。 diff --git a/plugins/ndf/skills/worktree/tests/test_scripts_reference.py b/plugins/ndf/skills/worktree/tests/test_scripts_reference.py index 72b5177ac..e17f802ec 100644 --- a/plugins/ndf/skills/worktree/tests/test_scripts_reference.py +++ b/plugins/ndf/skills/worktree/tests/test_scripts_reference.py @@ -46,14 +46,6 @@ # 環境が与える変数。決定 7 のとおり、`plugins/ndf/skills/` の実測で出た 4 つに限る。 ENVIRONMENT_VARIABLES = frozenset({"HOME", "PWD", "USER", "EDITOR"}) -# 手順書が文書の外から受け取る変数。表の宣言と突き合わせる。 -DECLARED = { - "SKILL.md": {"SCRIPTS"}, - "declaration.md": {"SCRIPTS", "main_dir"}, - "local-environment.md": {"SCRIPTS", "APP_SERVICE", "SRC_TARGET"}, - "test-execution.md": {"SCRIPTS", "WT"}, -} - # --- 解決手順を読み出して実行する ------------------------------------------------- @@ -146,8 +138,8 @@ def assigned_variables(text: str) -> set[str]: return assigned -def received_table(path: Path) -> tuple[set[str], int]: - """「この文書が受け取る値」の表の変数名と、表の終わった行の番号を返す。""" +def received_table(path: Path) -> set[str]: + """「この文書が受け取る値」の表の変数名を返す。""" lines = path.read_text(encoding="utf-8").splitlines() try: head = lines.index(RECEIVED_HEADING) @@ -168,29 +160,7 @@ def received_table(path: Path) -> tuple[set[str], int]: break index += 1 assert started, f"{path} の「{RECEIVED_HEADING}」に表が無い" - return names, index - - -def next_content_line(path: Path, index: int) -> str: - """指定の行から後ろで、最初の空でない行を返す。""" - lines = path.read_text(encoding="utf-8").splitlines() - for line in lines[index:]: - if line.strip(): - return line - pytest.fail(f"{path} の表の後ろに本文が無い") - - -# --- 条件 1: 代入の無い変数名が残っていない --------------------------------------- - - -def test_ndf_scripts_is_gone() -> None: - """`NDF_SCRIPTS` はどこにも代入されない。手順書から消えていることを確かめる。""" - remains = { - path.relative_to(SKILL_DIR).as_posix() - for path in SKILL_DIR.rglob("*.md") - if "NDF_SCRIPTS" in path.read_text(encoding="utf-8") - } - assert remains == set(), f"NDF_SCRIPTS が残っている: {sorted(remains)}" + return names # --- 条件 2: 未定義の変数を含むコマンドが無い ------------------------------------- @@ -200,19 +170,11 @@ def test_ndf_scripts_is_gone() -> None: def test_no_undefined_variable(path: Path) -> None: """使う変数は、同じ文書で代入されるか、受け取る値の表に載っている。""" text = path.read_text(encoding="utf-8") - declared, _ = received_table(path) + declared = received_table(path) undefined = used_variables(text) - assigned_variables(text) - declared assert undefined == set(), f"{path.name}: 出所の無い変数 {sorted(undefined)}" -@pytest.mark.parametrize("path", DOCUMENTS, ids=lambda p: p.name) -def test_received_table_declares_scripts(path: Path) -> None: - """4 文書すべてが、受け取る値として `$SCRIPTS` を宣言している。""" - declared, _ = received_table(path) - assert declared == DECLARED[path.name] - assert "SCRIPTS" in declared - - def test_environment_variables_are_excluded_by_name() -> None: """環境が与える変数の一覧は、決定 7 の実測で出た 4 つに限る。""" assert set(ENVIRONMENT_VARIABLES) == {"HOME", "PWD", "USER", "EDITOR"} @@ -229,21 +191,6 @@ def test_environment_variable_is_not_treated_as_undefined() -> None: assert used_variables('```bash\nls "$HOME"\n```\n') == set() -# --- 条件 3: 解決手順は 1 本しかない ---------------------------------------------- - - -def test_lookup_procedure_is_the_only_one() -> None: - """「`$SCRIPTS` を決める」の見出しを持つ手順書は 1 つだけである。""" - owners = [ - path.relative_to(SKILLS_ROOT).as_posix() - for path in sorted(SKILLS_ROOT.rglob("*.md")) - if LOOKUP_HEADING in path.read_text(encoding="utf-8") - ] - assert owners == [ - "development-workflow/references/scripts-lookup.md" - ], f"手順が 1 本ではない: {owners}" - - # --- 条件 4: 4 ランタイムの配置で解決できる --------------------------------------- @@ -307,14 +254,3 @@ def test_skill_stops_when_scripts_is_unresolved() -> None: def test_lookup_leaves_empty_value_when_nothing_is_found(elsewhere, home) -> None: """盤面への記録は従来どおり飛ばす。解決手順は空の値を残し、止めない。""" assert resolve(elsewhere, home) == "" - - -# --- 条件 6: まとめる推奨が表の直後にある ----------------------------------------- - - -@pytest.mark.parametrize("path", DOCUMENTS, ids=lambda p: p.name) -def test_bundling_is_recommended_after_the_table(path: Path) -> None: - """受け取る値の表の直後に、1 つの bash ブロックへまとめる推奨がある。""" - _, end = received_table(path) - line = next_content_line(path, end) - assert "1 つの bash ブロック" in line and "まとめる" in line, line diff --git a/plugins/ndf/skills/worktree/tests/test_setup.py b/plugins/ndf/skills/worktree/tests/test_setup.py index a8c1dc5ca..a483aaf70 100644 --- a/plugins/ndf/skills/worktree/tests/test_setup.py +++ b/plugins/ndf/skills/worktree/tests/test_setup.py @@ -586,7 +586,6 @@ def test_slot_touch_updates_last_used_at_and_keeps_released_at_null(main_repo: P # --- init: 読めない宣言を失敗として報告する(#573) --------------------------- -WORKTREE_SKILL = SCRIPTS_DIR.parent / "skills" / "worktree" / "SKILL.md" UNREADABLE_FORMS = ["broken_json", "unsupported_version", "empty_file", "directory", "unreadable_permission"] @@ -724,26 +723,6 @@ def test_status_and_check_are_unchanged_for_unreadable_forms(main_repo: Path, fo assert run(["status"], cwd=main_repo)["rc"] == 0 -def step0_section() -> str: - body = WORKTREE_SKILL.read_text(encoding="utf-8") - return body[body.index("## 0. 宣言ファイルを用意する"): body.index("## 1. 現在地を確かめる")] - - -def test_step0_stops_when_init_fails() -> None: - """受け入れ条件 10: 手順 0 は init の終了コードを見て止まり、利用者が決める。""" - section = step0_section() - assert 'exit=$?' in section - assert "先へ進まない" in section - assert "利用者" in section - - -def test_step0_limits_no_overwrite_to_readable_declarations() -> None: - """受け入れ条件 11: 上書きしないのは読める宣言に限り、読めない宣言では 1 で終わる。""" - section = step0_section() - assert "読める宣言" in section - assert "1 で終わる" in section - - # --- 個人の宣言の報告と追跡からの除外(#495 の AC19、AC21〜AC26) ------------- LOCAL_PRESENT_LINE = "個人の宣言: あり(.ndf/worktree.local.json)" diff --git a/scripts/tests/test_base_branch_consistency.py b/scripts/tests/test_base_branch_consistency.py index d27e0bf56..8200e439d 100644 --- a/scripts/tests/test_base_branch_consistency.py +++ b/scripts/tests/test_base_branch_consistency.py @@ -3,8 +3,6 @@ 起点を扱う Skill は作業ツリーの仕組みを前提にしないため、手順には共通ライブラリを 読み込まずに動く数行を書いている。写しである以上、両者が食い違う経路が残る。同じ入力に 対して同じ名前を返すことを、ここで突き合わせる(issue #202)。 - -あわせて、開発の起点を既定ブランチの字面で書いていないことを見る。 """ from __future__ import annotations @@ -31,24 +29,6 @@ INLINE_SKILLS = ("cherry-pick-pr", "deploy", "merged", "pr-review", "retrospective") MARKER = "dev_base=$(jq" -# 開発の起点を扱う Skill。起点は既定ブランチとは限らないため、字面で書かない。 -LITERAL_SKILLS = ( - "ndf-policies", - "cherry-pick-pr", - "deploy", - "merged", - "pr", - "pr-review", - "problem-solving", - "retrospective", - "worktree", -) - -# コマンドの引数に現れる既定ブランチの字面。`dev_base=${dev_base:-main}` のような -# 退避先は対象にしない(origin の HEAD すら取れないときの最後の手段である)。 -COMMAND_LITERAL = re.compile(r"^\s*git\s+\S+[^\n]*\bmain\b") - - @pytest.fixture() def repo(tmp_path: Path) -> Path: @@ -230,22 +210,6 @@ def test_inline_matches_library_when_only_a_lookalike_branch_exists( assert "develop" in library.stderr -@pytest.mark.parametrize("name", LITERAL_SKILLS) -def test_remote_default_branch_is_not_hardcoded(name: str) -> None: - """取り込む先を `origin/main` の字面で書かない。""" - text = (SKILLS / name / "SKILL.md").read_text(encoding="utf-8") - hits = [line for line in text.splitlines() if "origin/main" in line] - assert hits == [], f"{name}: {hits}" - - -@pytest.mark.parametrize("name", LITERAL_SKILLS) -def test_commands_do_not_hardcode_default_branch(name: str) -> None: - """git のコマンドの引数に `main` を書かない。""" - text = (SKILLS / name / "SKILL.md").read_text(encoding="utf-8") - hits = [line for line in text.splitlines() if COMMAND_LITERAL.match(line)] - assert hits == [], f"{name}: {hits}" - - # --- 個人の宣言では起点が変わらない(#495 の AC17) -------------------------- diff --git a/scripts/tests/test_push_fallback_docs.py b/scripts/tests/test_push_fallback_docs.py index b5dc10532..0f072b280 100644 --- a/scripts/tests/test_push_fallback_docs.py +++ b/scripts/tests/test_push_fallback_docs.py @@ -1,34 +1,13 @@ -"""push の退避の手が手順書に書かれていること(#524)。 - -**同じ退避のコマンドが 2 か所に書かれると、片方だけが更新される。** 値を持つのは共通層 -(`plugins/ndf/scripts/lib/git-credential.sh`)1 か所で、手順書はその値と一致する -コマンドを案内する。ここではその一致を固定する。 - -**`pr` と `fix` は任意のリポジトリで動く。** 共通層を読み込ませず、`git` のコマンドを -そのまま案内する(読み込みの経路を増やすと、NDF を導入していない環境で手順が成立しない)。 -""" +"""push の退避の値を持つのは共通層(`plugins/ndf/scripts/lib/git-credential.sh`)1 か所であること(#524)。""" from __future__ import annotations import re import subprocess -import sys from pathlib import Path -import pytest - REPO_ROOT = Path(__file__).resolve().parents[2] LIB = REPO_ROOT / "plugins/ndf/scripts/lib/git-credential.sh" -# 退避を案内する手順書。`cross-refactoring` と `fix` は実装が退避するため、案内の形が -# 違う(`fix` の送信は共通層の `result_posts.py` が行う、#730)。 -DOCS_WITH_COMMAND = ( - "plugins/ndf/skills/pr/SKILL.md", -) -DOCS_WITH_REFERENCE = ( - "plugins/ndf/skills/cross-refactoring/SKILL.md", - "plugins/ndf/skills/fix/SKILL.md", -) - def fallback_args() -> list[str]: out = subprocess.run( @@ -58,37 +37,3 @@ def test_the_shared_library_holds_the_value_and_nothing_else(): ) assert names == ["ndf_git_credential_fallback_args"], ( f"共通層が値の他に振る舞いを持っている: {names}") - - -@pytest.mark.parametrize("doc", DOCS_WITH_COMMAND) -def test_the_document_shows_the_same_command(doc): - """案内するコマンドが共通層の値と一致すること。""" - text = (REPO_ROOT / doc).read_text(encoding="utf-8") - # 引用の付け方は文書ごとに違うため、値そのものが順に現れることで見る - first = text.find("credential.helper=") - assert first != -1, f"{doc} に退避の案内がない" - after = text[first:] - assert "!gh auth git-credential" in after, f"{doc} に gh の helper がない" - assert after.index("credential.helper=") < after.index("!gh auth git-credential"), ( - f"{doc} は空の値を先に置いていない") - - -@pytest.mark.parametrize("doc", DOCS_WITH_COMMAND) -def test_the_document_explains_why_the_reset_comes_first(doc): - text = (REPO_ROOT / doc).read_text(encoding="utf-8") - assert "空の値を先に置く" in text, f"{doc} に順序の理由がない" - - -@pytest.mark.parametrize("doc", DOCS_WITH_REFERENCE) -def test_the_implementation_side_points_at_the_shared_library(doc): - """実装が退避する側は、値を写さず共通層を指す。""" - text = (REPO_ROOT / doc).read_text(encoding="utf-8") - assert "scripts/lib/git-credential.sh" in text, f"{doc} が共通層を指していない" - assert "credential.helper=!gh" not in text, f"{doc} が値を写している" - - -def test_the_documents_that_carry_the_command_do_not_load_the_library(): - """任意のリポジトリで動く手順書は、共通層の読み込みを求めない。""" - for doc in DOCS_WITH_COMMAND: - text = (REPO_ROOT / doc).read_text(encoding="utf-8") - assert "git-credential.sh" not in text, f"{doc} が共通層の読み込みを求めている" diff --git a/scripts/tests/test_root_conftest.py b/scripts/tests/test_root_conftest.py index 7ef4029b5..8e2d131b1 100644 --- a/scripts/tests/test_root_conftest.py +++ b/scripts/tests/test_root_conftest.py @@ -29,7 +29,7 @@ # 一覧に無い束。**シェルスクリプトを起動しない束であること**が条件である。 # `_path_without` が組み立てる `PATH` には `dirname` すら無いため、スクリプトを # 起動する束をここへ置くと、前提の検査ではなくスクリプトの側で落ちる。 -OUTSIDE_BUNDLE = "plugins/ndf/skills/retrospective/tests" +OUTSIDE_BUNDLE = "plugins/playwright-kit/skills/playwright-kit-ops/tests" def _read_root_conftest() -> str: diff --git a/scripts/tests/test_vocabulary_single_source.py b/scripts/tests/test_vocabulary_single_source.py index 484669618..84f78cd8e 100644 --- a/scripts/tests/test_vocabulary_single_source.py +++ b/scripts/tests/test_vocabulary_single_source.py @@ -21,8 +21,6 @@ SKILLS = ROOT / "plugins/ndf/skills" VOCAB_MD = SKILLS / "refactoring/references/vocabulary.md" VOCAB_PY = SKILLS / "cross-refactoring/scripts/refactor_lib/vocabulary.py" -CODE_SMELLS = SKILLS / "refactoring/references/code-smells.md" -CATALOG = SKILLS / "refactoring/references/refactoring-catalog.md" def _table(heading: str, value_column: str = "日本語の名前") -> dict[str, str]: @@ -76,21 +74,3 @@ def test_the_reader_does_not_hold_the_names() -> None: source = VOCAB_PY.read_text(encoding="utf-8") assert "長すぎるメソッド" not in source assert "メソッドの抽出" not in source - - -def test_every_smell_is_explained() -> None: - """兆候の識別子が、説明の表にも現れること。""" - text = CODE_SMELLS.read_text(encoding="utf-8") - missing = [name for name in _table("兆候") if f"`{name}`" not in text] - assert missing == [] - - -def test_every_technique_is_explained() -> None: - """手法の識別子が、カタログか説明の表に現れること。 - - **カタログに項目を置かない手法がある**(`code-smells.md` の ★ の 5 件)。 - そちらは説明の表が識別子を持つ。 - """ - text = CATALOG.read_text(encoding="utf-8") + CODE_SMELLS.read_text(encoding="utf-8") - missing = [name for name in _table("手法") if f"`{name}`" not in text] - assert missing == []