diff --git a/.github/workflows/nightly-todo.yml b/.github/workflows/nightly-todo.yml index fc1163c..d2f77ad 100644 --- a/.github/workflows/nightly-todo.yml +++ b/.github/workflows/nightly-todo.yml @@ -464,6 +464,49 @@ jobs: --ranks "${{ steps.select.outputs.rank }}" \ --changed-files "$RUNNER_TEMP/changed.txt" + # 完了と判定できた順位を台帳・順位 table・詳細エントリから取り除き、PR に同梱する。 + # + # 由来: 後始末は「マージ時に人間が 4 手順を実行する」ルールでしか存在せず、実績は + # 4 件中 2 件で失敗していた (成否がローカル手順を踏んだか GitHub UI マージかに依存)。 + # ブランチ削除で着手済みマーカーが消える一方で台帳の行は残るため、完了済みタスクが + # 再選択される。PR に後始末を同梱すれば、マージと後始末が原子的になる。 + # + # **書き込み先は publish/ (PR ブランチの作業ツリー)**。master-ref/ は判定の入力であり、 + # ここを書き換えると次段の Gate が改ざん後の状態を読む。Guard step は agent の diff を + # 見るものでこの step より前に済んでおり、決定論 exe による台帳変更は禁止リストの + # 対象外 (ADR-072 決定 6 が禁じているのは agent の書き換え)。 + # + # exe は Implement 前ビルド + integrity 照合済みのものを使う (再ビルドしない)。 + - name: Remove the completed task from the ledger + id: ledger-removal + if: steps.integrity.outcome == 'success' && steps.ledger-completion.outcome == 'success' + run: | + set -euo pipefail + master-ref/target/release/cli-ledger-cleanup \ + --ledger publish/docs/claude-code-web-tasks.md \ + --ranks "${{ steps.select.outputs.rank }}" \ + --changed-files "$RUNNER_TEMP/changed.txt" \ + --apply + git -C publish add -A docs + # **pathspec (`-- docs`) は必須。** Guard step が `git add -A` でツリー全体を + # stage 済みなので、pathspec なしで commit すると agent の実装まで巻き込む。 + # そうなると後段の Push step の `git commit` がコミット対象を失って非ゼロで落ち、 + # `set -euo pipefail` により PR が作られなくなる (CodeRabbit #406 Critical)。 + # pathspec 付きなら docs だけがコミットされ、残りは index に留まる。 + # + # **commit はステージ有無を確認してから行う。** `apply_removals` は重複実行 + # (`AbsentFromLedger`) を正常系として扱い 0 ファイル変更で `EXIT_COMPLETE` を返す + # 設計であり、加えて agent が docs/ を一切触らない実装もありうる。どちらの場合も + # ここでのステージは空になる。無条件 commit だと "nothing to commit" で非ゼロ + # 終了し、`set -euo pipefail` で検証済みの実装ごと job が落ちる + # (pre-push simplicity review SIM-NEW-nightly-todo-yml-L497)。 + if ! git -C publish diff --cached --quiet -- docs; then + git -C publish -c user.name='nightly-todo' -c user.email='nightly-todo@users.noreply.github.com' \ + commit -m "chore(ledger): 順位 ${{ steps.select.outputs.rank }} を完了に伴い台帳から削除する" \ + -m "実装完了は cli-ledger-cleanup が台帳の宣言と PR の変更を突き合わせて判定済み。マージと後始末を原子的にするため PR へ同梱する。" \ + -- docs + fi + - name: Stop when the implementation is incomplete if: steps.integrity.outcome == 'success' && steps.ledger-completion.outcome != 'success' run: | @@ -561,13 +604,26 @@ jobs: PR_TITLE="feat: 順位 ${RANK} の無人実装 (nightly-todo)" fi echo "[NIGHTLY] PR title: ${PR_TITLE}" - # commit 直前に add をやり直さない。stage したのは Guard step で、間に挟まる Gate は - # master-ref/ の exe を実行するだけで publish/ に触れないため index は維持される。 + # commit 直前に add をやり直さない。stage したのは Guard step であり、 # 再 add しないことが「ゲートが検査した内容そのものを commit する」保証になる。 # + # 間に挟まる step のうち **台帳後始末だけは publish/ を書き換える**が、あちらは + # `-- docs` の pathspec 付きで commit するため docs 以外の stage は index に残る。 + # ここで commit されるのは agent の実装そのもので、後始末は 1 つ前のコミットに入る。 + # (pathspec を落とすと agent の実装まで前のコミットへ吸われ、ここが空になって落ちる。) + # # commit message も PR タイトルに揃える。squash マージでは PR タイトルが採用されるため # 履歴上は後者が正だが、ブランチを直接見たときに食い違わない方がよい。 - git -C publish commit -m "$PR_TITLE" + # + # **commit はステージ有無を確認してから行う。** agent の実装が docs/ のみだった + # 場合、直前の ledger-removal step が `-- docs` pathspec で全 stage を吸い切って + # おり、ここでのステージが空になりうる。無条件 commit は "nothing to commit" で + # 非ゼロ終了し `set -euo pipefail` で job を落とす + # (pre-push simplicity review SIM-NEW-nightly-todo-yml-L497/L608)。 + # 実装コミットが無くても ledger-removal のコミットが HEAD に残っているため push は成立する。 + if ! git -C publish diff --cached --quiet; then + git -C publish commit -m "$PR_TITLE" + fi git -C publish push \ "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" \ "HEAD:refs/heads/${BRANCH}" diff --git a/docs/todo14.md b/docs/todo14.md index 144b8d7..fe1394b 100644 --- a/docs/todo14.md +++ b/docs/todo14.md @@ -99,7 +99,7 @@ --- -### docs/todo*.md 本文の順位番号表記を検出する custom lint rule (ADR-033 使用禁止の仕組み化) +### docs/todo*.md 本文の順位番号表記を検出する custom lint rule (ADR-033 使用禁止の仕組み化、#303 post-merge feedback 採用) > **動機**: [ADR-033](adr/adr-033-todo-numbering-simplification.md) (2026-04-29 試験運用) が「絶対番号は table のみに保持し、本文中の順位番号表記は使用禁止」と規定し、「将来の展望」節で pre-push hook の custom_lint_rule 追加を検討済みと明記したが、未実装のまま約 3 ヶ月経過。#303 の CodeRabbit 対応でも本文参照の drift が問題化した文脈。#303 post-merge feedback で採用。 > @@ -211,7 +211,7 @@ --- -### decide.rs/main.rs の境界値・parameter threading テスト拡充 +### decide.rs/main.rs の境界値・parameter threading テスト拡充 (#311 post-merge feedback 採用) > **動機**: 前回 incident の根本原因は parameter threading の欠落 (`parse_rate_limit()` はするが `decide()` に渡さない) だった。同クラスのリグレッションを防ぐテストが、インシデント発生ドメイン (rate-limit 判定) 直下で不足している。positive evidence の複合シナリオ、呼び出し側 (`main.rs`) が `decide()` に `rate_limit` を正しく構成することの検証が未固定。 > diff --git a/src/cli-ledger-cleanup/src/apply.rs b/src/cli-ledger-cleanup/src/apply.rs new file mode 100644 index 0000000..7604852 --- /dev/null +++ b/src/cli-ledger-cleanup/src/apply.rs @@ -0,0 +1,244 @@ +//! `--apply` の I/O 層。検証を通った順位を 3 箇所から取り除いてファイルへ書き戻す。 +//! +//! # 全部揃ってから書く +//! +//! 3 ファイル (台帳 / 順位 table / 詳細エントリ) の削除結果を**すべてメモリ上で作ってから** +//! 書き出す。1 ファイルずつ書きながら進めると、途中で特定に失敗したときに「台帳からは +//! 消えたが詳細エントリは残る」という孤児が生まれる。孤児は検出機構が無い限り誰にも +//! 気づかれない。 +//! +//! 書き込み自体が途中で失敗する可能性は残るが、そこは既に「実装は完了している」と +//! 判定済みの局面であり、失敗すれば非ゼロで終わって人間が気づく。特定の失敗 (どの行を +//! 消すか決まらない) とは扱いを分ける。 + +use std::path::{Path, PathBuf}; + +/// 削除対象 1 順位ぶんの、書き戻し前の状態。 +#[derive(Debug)] +pub(crate) struct PlannedRemoval { + files: Vec<(PathBuf, String)>, +} + +impl PlannedRemoval { + /// 計画した内容をすべて書き出す。 + pub(crate) fn write_all(&self) -> Result, String> { + let mut written = Vec::new(); + for (path, body) in &self.files { + std::fs::write(path, body) + .map_err(|e| format!("{} を書けません: {e}", path.display()))?; + written.push(path.display().to_string()); + } + Ok(written) + } +} + +/// 3 箇所の削除を計画する。1 箇所でも特定できなければ `Err` で、何も書かない。 +/// +/// `docs_dir` は `docs/` の実パス。順位 table (`todo-summary.md` / `todo-summary2.md`) と +/// 詳細エントリ (`todoN.md`) はここから解決する。 +/// +/// **1 回の呼び出しにつき 1 順位。** 呼び手 (`main.rs` の `apply_removals`) が複数順位を +/// 順に計画すると、2 件目以降の順位 table / 詳細エントリの読み取りは常にディスクから行われ、 +/// 1 件目がまだ書き出していない削除を無視する。両者が同じファイルを触っていた場合、 +/// 後で書き出す側が前の削除を黙って巻き戻す (pre-push simplicity review +/// SIM-NEW-cli-ledger-cleanup-apply-rs-L51)。台帳だけはメモリ上で連鎖できるが、 +/// 順位 table / 詳細エントリはできないため、連鎖自体を提供しない。 +pub(crate) fn plan_removal( + ledger_path: &Path, + ledger_markdown: &str, + docs_dir: &Path, + rank: u32, +) -> Result { + let ledger_after = lib_ledger::remove_ledger_row(ledger_markdown, rank)?; + let (summary_path, summary_after, row) = plan_summary_removal(docs_dir, rank)?; + // NOTE: detail_file はパス区切りや `..` を含まないことを lib_ledger 側で検証済み。 + let detail_path = docs_dir.join(&row.detail_file); + let detail_before = std::fs::read_to_string(&detail_path) + .map_err(|e| format!("詳細エントリのファイルを読めません ({}): {e}", detail_path.display()))?; + let detail_after = lib_ledger::remove_detail_entry(&detail_before, &row.title) + .map_err(|e| format!("{} : {e}", detail_path.display()))?; + Ok(PlannedRemoval { + files: vec![ + (ledger_path.to_path_buf(), ledger_after), + (summary_path, summary_after), + (detail_path, detail_after), + ], + }) +} + +/// 順位 table 2 ファイルのどちらに行があるかを解決する。 +/// +/// 両方にあるのは採番が壊れている状態なので `Err`。どちらにも無いのも `Err` — +/// 台帳には載っているのに順位 table から消えている順位は、後始末の前に人間が見るべき。 +fn plan_summary_removal( + docs_dir: &Path, + rank: u32, +) -> Result<(PathBuf, String, lib_ledger::SummaryRow), String> { + let mut hits = Vec::new(); + for name in ["todo-summary.md", "todo-summary2.md"] { + let path = docs_dir.join(name); + let before = std::fs::read_to_string(&path) + .map_err(|e| format!("順位 table を読めません ({}): {e}", path.display()))?; + if let Some((after, row)) = lib_ledger::remove_summary_row(&before, rank) + .map_err(|e| format!("{} : {e}", path.display()))? + { + hits.push((path, after, row)); + } + } + match hits.len() { + 1 => Ok(hits.remove(0)), + 0 => Err(format!( + "順位 {rank} の行が順位 table (todo-summary.md / todo-summary2.md) にありません" + )), + n => Err(format!( + "順位 {rank} の行が順位 table の {n} ファイルに重複しています" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_dir(case: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "cli-ledger-cleanup-apply-{}-{case}", + std::process::id() + )); + let docs = dir.join("docs"); + std::fs::create_dir_all(&docs).expect("mkdir"); + std::fs::write( + docs.join("claude-code-web-tasks.md"), + "# 台帳\n\n\ + | 順位 | Tier | 無人可 | 内容 | 対象ファイル | 工数 | 注意 |\n\ + |---|---|---|---|---|---|---|\n\ + | 203 | T2 | ✅ | x | `src/a.rs` | XS | - |\n\ + | 240 | T2 | — | y | `src/b.rs` | XS | - |\n", + ) + .expect("write ledger"); + std::fs::write( + docs.join("todo-summary.md"), + "# サマリー\n\n\ + | 順位 | Tier | タスク | ファイル | 工数 | 依存 |\n\ + |---|---|---|---|---|---|\n\ + | 203 | 🔧 Tier 2 | **タイトル A** | todo10.md | XS | なし |\n", + ) + .expect("write summary"); + std::fs::write( + docs.join("todo-summary2.md"), + "# サマリー 2\n\n\ + | 順位 | Tier | タスク | ファイル | 工数 | 依存 |\n\ + |---|---|---|---|---|---|\n\ + | 240 | 🔧 Tier 2 | **タイトル B** | todo13.md | M | なし |\n", + ) + .expect("write summary2"); + std::fs::write( + docs.join("todo10.md"), + "# TODO\n\n---\n\n### タイトル A\n\n> 動機\n\n---\n\n### 別タスク\n\n> 動機\n", + ) + .expect("write detail"); + docs + } + + fn ledger_path(docs: &Path) -> PathBuf { + docs.join("claude-code-web-tasks.md") + } + + #[test] + fn plans_and_writes_all_three_locations() { + let docs = fixture_dir("happy"); + let path = ledger_path(&docs); + let markdown = std::fs::read_to_string(&path).expect("read"); + let plan = plan_removal(&path, &markdown, &docs, 203).expect("plan"); + let written = plan.write_all().expect("write"); + assert_eq!(written.len(), 3); + + let ledger = std::fs::read_to_string(&path).expect("read"); + assert!(!ledger.contains("| 203 |"), "台帳に残っている"); + assert!(ledger.contains("| 240 |"), "他の順位まで消えている"); + + let summary = std::fs::read_to_string(docs.join("todo-summary.md")).expect("read"); + assert!(!summary.contains("タイトル A")); + + let detail = std::fs::read_to_string(docs.join("todo10.md")).expect("read"); + assert!(!detail.contains("タイトル A")); + assert!(detail.contains("### 別タスク"), "隣のエントリまで消えている"); + } + + /// 詳細エントリの見出しが見つからない場合、**台帳も順位 table も書き換わらない**。 + /// 片方だけ消えると孤児が残る。 + #[test] + fn a_missing_detail_heading_leaves_every_file_untouched() { + let docs = fixture_dir("missing-detail"); + std::fs::write( + docs.join("todo10.md"), + "# TODO\n\n---\n\n### 別の見出し\n\n> 動機\n", + ) + .expect("rewrite detail"); + let path = ledger_path(&docs); + let markdown = std::fs::read_to_string(&path).expect("read"); + let before_ledger = markdown.clone(); + let before_summary = std::fs::read_to_string(docs.join("todo-summary.md")).expect("read"); + + assert!(plan_removal(&path, &markdown, &docs, 203).is_err()); + + assert_eq!( + std::fs::read_to_string(&path).expect("read"), + before_ledger, + "計画に失敗したのに台帳が書き換わっている" + ); + assert_eq!( + std::fs::read_to_string(docs.join("todo-summary.md")).expect("read"), + before_summary, + "計画に失敗したのに順位 table が書き換わっている" + ); + } + + /// 順位 table のどちらにも無い順位は、台帳にあっても後始末しない。 + /// + /// **fixture から 240 の行を消してから測る。** 消さないと `todo13.md` が存在しない + /// ことが失敗要因になり、0 件経路を通らないまま「エラーになった」で通ってしまう + /// (初版がその形だった)。 + #[test] + fn a_rank_absent_from_both_summary_files_is_an_error() { + let docs = fixture_dir("absent-summary"); + std::fs::write( + docs.join("todo-summary2.md"), + "# サマリー 2\n\n\ + | 順位 | Tier | タスク | ファイル | 工数 | 依存 |\n\ + |---|---|---|---|---|---|\n", + ) + .expect("rewrite summary2"); + let path = ledger_path(&docs); + let markdown = std::fs::read_to_string(&path).expect("read"); + let error = plan_removal(&path, &markdown, &docs, 240) + .expect_err("順位 table のどちらにも無いので失敗する"); + assert!( + error.contains("にありません"), + "0 件経路のメッセージになっていない: {error}" + ); + } + + /// 順位 table には行があるが、指し先の詳細ファイルが実在しない場合。 + /// 上の 0 件経路とは別の失敗要因なので、テストも分ける。 + #[test] + fn a_missing_detail_file_is_a_separate_error() { + let docs = fixture_dir("missing-detail-file"); + let path = ledger_path(&docs); + let markdown = std::fs::read_to_string(&path).expect("read"); + let error = plan_removal(&path, &markdown, &docs, 240) + .expect_err("todo13.md が無いので失敗する"); + assert!( + error.contains("todo13.md"), + "詳細ファイル欠落のメッセージになっていない: {error}" + ); + } + + #[test] + fn a_rank_absent_from_the_ledger_is_an_error() { + let docs = fixture_dir("absent-ledger"); + let path = ledger_path(&docs); + let markdown = std::fs::read_to_string(&path).expect("read"); + assert!(plan_removal(&path, &markdown, &docs, 999).is_err()); + } +} diff --git a/src/cli-ledger-cleanup/src/main.rs b/src/cli-ledger-cleanup/src/main.rs index bd6df07..3ce7fde 100644 --- a/src/cli-ledger-cleanup/src/main.rs +++ b/src/cli-ledger-cleanup/src/main.rs @@ -33,6 +33,8 @@ //! run log で「実装が足りない」と「台帳の書式が不正」を区別するためで、どちらも後始末を //! 進めない点は同じ。 +mod apply; + use std::collections::BTreeSet; use std::path::PathBuf; @@ -46,6 +48,7 @@ const EXIT_COMPLETE: i32 = 0; const EXIT_USAGE: i32 = 2; const EXIT_INCOMPLETE: i32 = 3; const EXIT_UNVERIFIABLE: i32 = 4; +const EXIT_REMOVAL_FAILED: i32 = 5; const USAGE: &str = "usage: cli-ledger-cleanup --ledger --ranks --changed-files "; @@ -57,15 +60,23 @@ struct Cli { ledger_path: PathBuf, ranks: Vec, changed_files_path: PathBuf, + /// `--apply` 指定時のみ削除する。既定は検証だけ (呼び手が誤って消さないよう opt-in)。 + apply: bool, } fn parse_args(args: &[String]) -> Result { let mut ledger_path = None; let mut ranks = None; let mut changed_files_path = None; + let mut apply = false; let mut index = 0; while index < args.len() { let flag = args[index].as_str(); + if flag == "--apply" { + apply = true; + index += 1; + continue; + } let value = args.get(index + 1); let take = || value.ok_or_else(|| format!("{flag} の値がありません")); match flag { @@ -81,6 +92,7 @@ fn parse_args(args: &[String]) -> Result { ranks: ranks.ok_or_else(|| "--ranks が必要です".to_string())?, changed_files_path: changed_files_path .ok_or_else(|| "--changed-files が必要です".to_string())?, + apply, }) } @@ -139,9 +151,75 @@ fn run(args: Vec) -> i32 { Ok(files) => files, Err(message) => return block(EXIT_USAGE, &message, false), }; - match verdict_for_all(&markdown, &cli.ranks, &changed_files) { - Err(message) => block(EXIT_USAGE, &message, false), - Ok(verdicts) => report(&verdicts, &changed_files), + let verdicts = match verdict_for_all(&markdown, &cli.ranks, &changed_files) { + Ok(verdicts) => verdicts, + Err(message) => return block(EXIT_USAGE, &message, false), + }; + let code = report(&verdicts, &changed_files); + if code != EXIT_COMPLETE || !cli.apply { + return code; + } + apply_removals(&cli, &markdown, &verdicts) +} + +/// 検証を通った順位を 3 箇所から取り除く。 +/// +/// **検証が通った場合しか呼ばれない。** 未完了 / 検証不能で削除すると、実装されていない +/// タスクが台帳から消えて誰にも追えなくなる (#394 で実際に起きかけた形)。 +/// +/// **1 回の実行で後始末できるのは 1 順位まで。** 順位 table / 詳細エントリの読み取りは +/// 常にディスクから行い、同一実行内の他順位の削除を考慮しない。2 順位以上が同じ +/// `todo-summary*.md` / `docs/todoN.md` を触っていた場合、後で書き出す側が前の削除を +/// 黙って巻き戻す (pre-push simplicity review SIM-NEW-cli-ledger-cleanup-apply-rs-L51)。 +/// `--ranks` に複数渡すこと自体は検証専用の呼び出しでは引き続きでき、`--apply` と +/// 組み合わせたときだけこの上限にかかる。 +fn apply_removals(cli: &Cli, markdown: &str, verdicts: &[(u32, RankOutcome)]) -> i32 { + let Some(docs_dir) = cli.ledger_path.parent().map(std::path::Path::to_path_buf) else { + return block(EXIT_USAGE, "台帳のパスから docs ディレクトリを解決できません", false); + }; + let targets: Vec = verdicts + .iter() + .filter(|(_, v)| !matches!(v, RankOutcome::AbsentFromLedger)) + .map(|(rank, _)| *rank) + .collect(); + let rank = match targets.as_slice() { + [] => { + println!("{MARKER_OK} 台帳に残っている順位はありません (後始末済み)"); + return EXIT_COMPLETE; + } + [rank] => *rank, + many => { + let listed = many.iter().map(u32::to_string).collect::>().join(", "); + return block( + EXIT_REMOVAL_FAILED, + &format!( + "--apply は 1 回の実行で 1 順位までです (対象: {listed})。\ + 順位ごとに --ranks を分けて実行し直してください。" + ), + false, + ); + } + }; + let plan = match apply::plan_removal(&cli.ledger_path, markdown, &docs_dir, rank) { + Ok(plan) => plan, + Err(message) => { + return block( + EXIT_REMOVAL_FAILED, + &format!("順位 {rank} の後始末を計画できません: {message}"), + false, + ) + } + }; + match plan.write_all() { + Ok(files) => { + println!("{MARKER_OK} 順位 {rank} を後始末しました: {}", files.join(", ")); + EXIT_COMPLETE + } + Err(message) => block( + EXIT_REMOVAL_FAILED, + &format!("順位 {rank} の書き出しに失敗しました: {message}"), + false, + ), } } @@ -332,6 +410,114 @@ mod tests { assert_eq!(run_with_case("absent-rank", "src/a.rs\n", "999"), EXIT_COMPLETE); } + /// `--apply` 用に、台帳 / 順位 table / 詳細エントリの 3 点が揃った docs ディレクトリを作る。 + fn apply_fixture_dir(case: &str) -> PathBuf { + let dir = std::env::temp_dir() + .join(format!("cli-ledger-cleanup-apply-{}-{case}", std::process::id())); + let docs = dir.join("docs"); + std::fs::create_dir_all(&docs).expect("mkdir"); + write( + &docs, + "claude-code-web-tasks.md", + "# 台帳\n\n\ + | 順位 | Tier | 無人可 | 内容 | 対象ファイル (実パス) | 工数 | 注意 |\n\ + |---|---|---|---|---|---|---|\n\ + | 203 | T2 | ✅ | x | `src/a.rs` | XS | - |\n\ + | 240 | T2 | ✅ | y | `src/b.rs` | XS | - |\n", + ); + write( + &docs, + "todo-summary.md", + "# サマリー\n\n\ + | 順位 | Tier | タスク | ファイル | 工数 | 依存 |\n\ + |---|---|---|---|---|---|\n\ + | 203 | 🔧 Tier 2 | **タイトル A** | todo10.md | XS | なし |\n\ + | 240 | 🔧 Tier 2 | **タイトル B** | todo10.md | XS | なし |\n", + ); + write( + &docs, + "todo-summary2.md", + "# サマリー 2\n\n\ + | 順位 | Tier | タスク | ファイル | 工数 | 依存 |\n\ + |---|---|---|---|---|---|\n", + ); + write( + &docs, + "todo10.md", + "# TODO\n\n---\n\n### タイトル A\n\n> 動機\n\n---\n\n### タイトル B\n\n> 動機\n", + ); + docs + } + + /// `--apply` かつ単一順位なら、台帳 / 順位 table / 詳細エントリの 3 点が実際に消える。 + #[test] + fn apply_with_a_single_target_removes_all_three_locations() { + let docs = apply_fixture_dir("single"); + let ledger_path = docs.join("claude-code-web-tasks.md"); + let changed_path = write(&docs, "changed.txt", "src/a.rs\n"); + + let code = run(args(&[ + "--ledger", + &ledger_path.to_string_lossy(), + "--ranks", + "203", + "--changed-files", + &changed_path.to_string_lossy(), + "--apply", + ])); + + assert_eq!(code, EXIT_COMPLETE); + let ledger = std::fs::read_to_string(&ledger_path).expect("read"); + assert!(!ledger.contains("| 203 |"), "台帳に残っている"); + assert!(ledger.contains("| 240 |"), "他の順位まで消えている"); + let summary = std::fs::read_to_string(docs.join("todo-summary.md")).expect("read"); + assert!(!summary.contains("タイトル A")); + assert!(summary.contains("タイトル B"), "他の順位まで消えている"); + } + + /// `--apply` は 1 回の実行で 1 順位まで。2 順位以上が同じ順位 table / 詳細エントリを + /// 共有していると、後で書き出す側が前の削除を黙って巻き戻す + /// (pre-push simplicity review SIM-NEW-cli-ledger-cleanup-apply-rs-L51)。 + /// この巻き戻りを未然に防ぐため、複数対象は fail-closed で拒否し、全ファイルを無傷で残す。 + #[test] + fn apply_with_multiple_targets_in_one_run_is_rejected_and_leaves_files_untouched() { + let docs = apply_fixture_dir("multi"); + let ledger_path = docs.join("claude-code-web-tasks.md"); + let summary_path = docs.join("todo-summary.md"); + let detail_path = docs.join("todo10.md"); + let ledger_before = std::fs::read_to_string(&ledger_path).expect("read"); + let summary_before = std::fs::read_to_string(&summary_path).expect("read"); + let detail_before = std::fs::read_to_string(&detail_path).expect("read"); + let changed_path = write(&docs, "changed.txt", "src/a.rs\nsrc/b.rs\n"); + + let code = run(args(&[ + "--ledger", + &ledger_path.to_string_lossy(), + "--ranks", + "203,240", + "--changed-files", + &changed_path.to_string_lossy(), + "--apply", + ])); + + assert_eq!(code, EXIT_REMOVAL_FAILED); + assert_eq!( + std::fs::read_to_string(&ledger_path).expect("read"), + ledger_before, + "台帳が書き換わっている" + ); + assert_eq!( + std::fs::read_to_string(&summary_path).expect("read"), + summary_before, + "順位 table が書き換わっている" + ); + assert_eq!( + std::fs::read_to_string(&detail_path).expect("read"), + detail_before, + "詳細エントリが書き換わっている" + ); + } + /// Windows 区切りで渡されても台帳の `/` 宣言と突き合う。 /// ここを揃えないと Windows からの実行だけが常に未完了になる。 #[test] diff --git a/src/lib-ledger/src/lib.rs b/src/lib-ledger/src/lib.rs index 5e09da5..07e1c03 100644 --- a/src/lib-ledger/src/lib.rs +++ b/src/lib-ledger/src/lib.rs @@ -23,11 +23,13 @@ mod completion; #[cfg(test)] mod deployed_ledger; mod rank_lookup; +mod removal; mod screening; mod target_files; pub use completion::{evaluate, Completion}; pub use rank_lookup::target_files_for_rank; +pub use removal::{remove_detail_entry, remove_ledger_row, remove_summary_row, SummaryRow}; use screening::is_bidi_or_invisible_format_char; pub use screening::{screen_for_public_output, screen_for_title}; pub use target_files::parse_target_files; diff --git a/src/lib-ledger/src/removal.rs b/src/lib-ledger/src/removal.rs new file mode 100644 index 0000000..4e98907 --- /dev/null +++ b/src/lib-ledger/src/removal.rs @@ -0,0 +1,396 @@ +//! 完了タスクを台帳 / 順位 table / 詳細エントリから取り除く純粋層。 +//! +//! I/O を持たない。読み取り済みの markdown 文字列を受け取り、削除後の文字列を返す。 +//! +//! # 全部消すか、何も消さないか +//! +//! 後始末は 3 箇所 (台帳の行 / `docs/todo-summary*.md` の順位行 / `docs/todoN.md` の詳細 +//! エントリ) に跨る。**1 箇所でも特定できなければ何も消さない**。片方だけ消すと、 +//! 台帳から消えた順位の詳細エントリだけが残る「孤児」ができる。孤児は検出機構が無い限り +//! 誰にも気づかれず、実際に詳細エントリ 4 件が約 3 週間放置された前例がある +//! (2026-08-12 の docs 棚卸しで回復。1:1 対応の検査自体が未実装であることも同時に判明した)。 +//! +//! # 詳細エントリの特定 +//! +//! 順位 table の行が持つ**タイトル**と**ファイル名**を鍵にする。タイトルは詳細エントリの +//! `### ` 見出しと完全一致することを要求し、一致が 0 件でも 2 件以上でも `Err` にする。 +//! 前方一致や部分一致に緩めると、似た名前の別タスクを消しうる — 消す操作は取り返しが +//! つかないので、曖昧さは停止側へ倒す ([ADR-043](../../../docs/adr/adr-043-security-gates-fail-closed.md))。 + +/// 順位 table の 1 行から取り出した、詳細エントリを引くための鍵。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SummaryRow { + /// 詳細エントリを収める `docs/todoN.md` のファイル名 (パスではなく名前)。 + pub detail_file: String, + /// 詳細エントリの `### ` 見出しと完全一致するタイトル。 + pub title: String, +} + +/// 台帳・順位 table から指定順位の行を取り除く。 +/// +/// **タスク表の行だけを消す。** 棚卸し履歴や「無人可としなかった理由」の表も順位列を持つが、 +/// あれらは削除済み順位を意図的に残す記録であり、消すと削除の根拠が追えなくなる。 +/// 見分けは「無人可 列を持つ表か」で行う (台帳のタスク表だけが持つ)。 +pub fn remove_ledger_row(markdown: &str, rank: u32) -> Result { + let mut out = Vec::new(); + let mut removed = 0usize; + let mut in_task_table = false; + for line in markdown.lines() { + if !is_table_row(line) { + in_task_table = false; + out.push(line); + continue; + } + let cells = super::split_cells(line); + if cells.iter().any(|c| c == "無人可") { + in_task_table = true; + out.push(line); + continue; + } + if in_task_table && first_cell_is_rank(&cells, rank) { + removed += 1; + continue; + } + out.push(line); + } + match removed { + 1 => Ok(join_preserving_trailing_newline(&out, markdown)), + 0 => Err(format!("台帳のタスク表に順位 {rank} の行がありません")), + n => Err(format!("台帳のタスク表に順位 {rank} の行が {n} 件あります")), + } +} + +/// 順位 table (`docs/todo-summary*.md`) から指定順位の行を取り除き、詳細エントリの鍵を返す。 +/// +/// 行が無い場合は `Ok(None)` — 順位 table は 2 ファイルに分かれており、呼び手は両方に +/// 当てて「どちらか一方に 1 件」を期待する。 +pub fn remove_summary_row( + markdown: &str, + rank: u32, +) -> Result, String> { + let mut out = Vec::new(); + let mut found: Option = None; + for line in markdown.lines() { + if !is_table_row(line) { + out.push(line); + continue; + } + let cells = super::split_cells(line); + if !first_cell_is_rank(&cells, rank) { + out.push(line); + continue; + } + if found.is_some() { + return Err(format!("順位 table に順位 {rank} の行が複数あります")); + } + found = Some(summary_row_from_cells(&cells, rank)?); + } + Ok(found.map(|row| (join_preserving_trailing_newline(&out, markdown), row))) +} + +fn summary_row_from_cells(cells: &[String], rank: u32) -> Result { + let title = cells + .get(2) + .ok_or_else(|| format!("順位 {rank} の行にタイトル列がありません"))?; + let detail_file = cells + .get(3) + .ok_or_else(|| format!("順位 {rank} の行にファイル列がありません"))?; + let title = title.trim().trim_matches('*').trim().to_string(); + if title.is_empty() { + return Err(format!("順位 {rank} の行のタイトルが空です")); + } + let detail_file = detail_file.trim().to_string(); + validate_detail_file_name(&detail_file, rank)?; + Ok(SummaryRow { + detail_file, + title, + }) +} + +/// ファイル列を `docs_dir` 直下のファイル名として安全に使えるか検証する。 +/// +/// 呼び手 (`cli-ledger-cleanup/src/apply.rs`) はこの値を検証なしで +/// `docs_dir.join(&row.detail_file)` に渡す。この列の出どころは `docs/todo-summary*.md` — +/// エージェントが自由に編集できる領域であり、無人経路のプロンプトインジェクション面でもある。 +/// パス区切りや `..` を含む値をそのまま結合すると `docs_dir` の外のファイルを書き換えられて +/// しまうため、区切りを一切含まない単純な `*.md` ファイル名だけを許可する +/// (pre-push security review SEC-NEW-apply-rs-L59)。 +/// +/// `:` も弾く。`C:evil.md` は区切りを含まないが、Windows の [`std::path::Path::join`] は +/// **ドライブ接頭辞付きの相対パスで基底パスを置き換える**ため、`docs_dir.join("C:evil.md")` +/// が `docs_dir` の外を指す。区切り文字だけを見ていると Windows でだけ穴が残る +/// (CodeRabbit #406)。 +fn validate_detail_file_name(detail_file: &str, rank: u32) -> Result<(), String> { + let is_plain_filename = !detail_file.is_empty() + && !detail_file.contains('/') + && !detail_file.contains('\\') + && !detail_file.contains(':') + && detail_file != "." + && detail_file != ".."; + if !is_plain_filename || !detail_file.ends_with(".md") { + return Err(format!( + "順位 {rank} の行のファイル列がファイル名として不正です \ + (パス区切りや `..` を含まない `*.md` のみ許可): {detail_file:?}" + )); + } + Ok(()) +} + +/// 詳細エントリ (`### ` から次の同レベル見出しの手前まで) を取り除く。 +/// +/// エントリ間は「本文 → `---` → 空行 → 次の見出し」で区切られる。見出しから次の見出しの +/// 手前までを消すと**自分の後ろの区切りが一緒に落ちる**ので、前の区切りはそのまま残す +/// (残った区切りが次のエントリの前置きになる)。前後どちらも消すと区切りが 1 本足りなくなる。 +/// +/// 例外は**最後のエントリ**で、後ろに区切りが無いため前の区切りを落とす。残すと文書末尾に +/// 宙ぶらりんの `---` が残る。 +pub fn remove_detail_entry(markdown: &str, title: &str) -> Result<String, String> { + let lines: Vec<&str> = markdown.lines().collect(); + let heading = format!("### {title}"); + let matches: Vec<usize> = lines + .iter() + .enumerate() + .filter(|(_, l)| l.trim_end() == heading) + .map(|(i, _)| i) + .collect(); + let start = match matches.as_slice() { + [only] => *only, + [] => return Err(format!("詳細エントリの見出しが見つかりません: {heading:?}")), + many => { + return Err(format!( + "詳細エントリの見出しが {} 件あります (どれを消すか決まりません): {heading:?}", + many.len() + )) + } + }; + let end = next_section_start(&lines, start); + let start = if end == lines.len() { + include_preceding_separator(&lines, start) + } else { + start + }; + let mut out: Vec<&str> = Vec::new(); + out.extend_from_slice(&lines[..start]); + out.extend_from_slice(&lines[end..]); + Ok(join_preserving_trailing_newline(&out, markdown)) +} + +/// 次の `### ` 見出し (= 同レベルの次エントリ) か `## ` 見出しの位置を返す。 +fn next_section_start(lines: &[&str], start: usize) -> usize { + lines + .iter() + .enumerate() + .skip(start + 1) + .find(|(_, l)| l.starts_with("### ") || l.starts_with("## ")) + .map(|(i, _)| i) + .unwrap_or(lines.len()) +} + +/// 見出しの直前にある区切り (`---` と空行) まで削除開始位置を引き上げる。 +fn include_preceding_separator(lines: &[&str], heading: usize) -> usize { + let mut start = heading; + while start > 0 && lines[start - 1].trim().is_empty() { + start -= 1; + } + if start > 0 && lines[start - 1].trim() == "---" { + start -= 1; + while start > 0 && lines[start - 1].trim().is_empty() { + start -= 1; + } + } + start +} + +fn is_table_row(line: &str) -> bool { + line.trim_start().starts_with('|') +} + +fn first_cell_is_rank(cells: &[String], rank: u32) -> bool { + cells + .first() + .and_then(|c| c.parse::<u32>().ok()) + .is_some_and(|parsed| parsed == rank) +} + +/// 行を連結する。元が改行で終わっていたら終端の改行を保つ。 +/// +/// 保たないと、削除のたびにファイル末尾の改行が落ちて無関係な差分が出る。 +fn join_preserving_trailing_newline(lines: &[&str], original: &str) -> String { + let mut joined = lines.join("\n"); + if original.ends_with('\n') { + joined.push('\n'); + } + joined +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ledger() -> String { + "# 台帳\n\n\ + | 順位 | Tier | 無人可 | 内容 | 対象ファイル | 工数 | 注意 |\n\ + |---|---|---|---|---|---|---|\n\ + | 203 | T2 | ✅ | x | `src/a.rs` | XS | - |\n\ + | 240 | T2 | — | y | `src/b.rs` | XS | - |\n\ + \n## 棚卸し履歴\n\n\ + | 順位 | 節 | 判定 |\n\ + |---|---|---|\n\ + | 203 | 採用タスク | 削除済み |\n" + .to_string() + } + + #[test] + fn removes_only_the_requested_task_row() { + let out = remove_ledger_row(&ledger(), 203).expect("remove"); + assert!(!out.contains("| 203 | T2 |"), "タスク行が残っている"); + assert!(out.contains("| 240 | T2 |"), "他の行まで消えている"); + } + + /// **棚卸し履歴は消さない。** 削除済み順位を意図的に残す記録であり、 + /// 消すと「なぜ消えたか」が追えなくなる。 + #[test] + fn the_audit_history_table_is_left_untouched() { + let out = remove_ledger_row(&ledger(), 203).expect("remove"); + assert!( + out.contains("| 203 | 採用タスク | 削除済み |"), + "棚卸し履歴の行まで消えている" + ); + } + + #[test] + fn an_absent_rank_is_an_error() { + assert!(remove_ledger_row(&ledger(), 999).is_err()); + } + + #[test] + fn trailing_newline_is_preserved() { + let out = remove_ledger_row(&ledger(), 203).expect("remove"); + assert!(out.ends_with('\n')); + } + + fn summary() -> String { + "# サマリー\n\n\ + | 順位 | Tier | タスク | ファイル | 工数 | 依存 |\n\ + |---|---|---|---|---|---|\n\ + | 203 | 🔧 Tier 2 | **タイトル A** | todo10.md | XS | なし |\n\ + | 240 | 🔧 Tier 2 | **タイトル B** | todo13.md | M | なし |\n" + .to_string() + } + + #[test] + fn removes_the_summary_row_and_returns_its_key() { + let (out, row) = remove_summary_row(&summary(), 203) + .expect("parse") + .expect("row exists"); + assert!(!out.contains("タイトル A")); + assert!(out.contains("タイトル B")); + assert_eq!(row.detail_file, "todo10.md"); + assert_eq!(row.title, "タイトル A", "太字マーカーは落とす"); + } + + /// 順位 table は 2 ファイルに分かれる。片方に無いのは正常。 + #[test] + fn an_absent_rank_yields_none_not_an_error() { + assert_eq!(remove_summary_row(&summary(), 999).expect("parse"), None); + } + + #[test] + fn a_duplicated_rank_in_the_summary_is_an_error() { + let duplicated = format!("{}| 203 | 🔧 Tier 2 | **重複** | todo9.md | XS | なし |\n", summary()); + assert!(remove_summary_row(&duplicated, 203).is_err()); + } + + /// ファイル列が `..` やパス区切りを含むなど、ディレクトリを飛び出す形は拒否する。 + /// `docs_dir.join(detail_file)` (`cli-ledger-cleanup/src/apply.rs`) にそのまま渡ると + /// 任意ファイル書き込みになりうる (pre-push security review SEC-NEW-apply-rs-L59)。 + #[test] + fn a_path_traversal_attempt_in_the_file_column_is_rejected() { + for detail_file in [ + "../../../docs/adr/adr-001-hooks-implementation-language.md", + "..\\..\\secrets.md", + "/etc/passwd.md", + "sub/dir.md", + "..", + "C:evil.md", + "c:evil.md", + ] { + let poisoned = format!( + "# サマリー\n\n\ + | 順位 | Tier | タスク | ファイル | 工数 | 依存 |\n\ + |---|---|---|---|---|---|\n\ + | 203 | 🔧 Tier 2 | **タイトル A** | {detail_file} | XS | なし |\n" + ); + assert!( + remove_summary_row(&poisoned, 203).is_err(), + "拒否されるべき: {detail_file:?}" + ); + } + } + + fn detail() -> String { + "# TODO\n\n---\n\n\ + ### タイトル A\n\n\ + > 動機: ...\n\n\ + #### 作業計画\n\n- [ ] やる\n\n---\n\n\ + ### タイトル B\n\n\ + > 動機: ...\n" + .to_string() + } + + /// 中間のエントリを消すと、自分の後ろの区切りが一緒に落ちる。前の区切りは残り、 + /// それが次のエントリの前置きになる — 区切りの本数はエントリ数と釣り合ったままになる。 + #[test] + fn removing_a_middle_entry_leaves_exactly_one_separator() { + let out = remove_detail_entry(&detail(), "タイトル A").expect("remove"); + assert!(!out.contains("タイトル A")); + assert!(out.contains("### タイトル B"), "次のエントリまで消えている"); + assert!(!out.contains("#### 作業計画"), "本文が残っている"); + assert_eq!( + out.matches("---").count(), + 1, + "区切りの本数が合わない: {out:?}" + ); + assert!( + out.contains("---\n\n### タイトル B"), + "残った区切りが次エントリの前置きになっていない: {out:?}" + ); + } + + /// 最後のエントリは後ろに区切りが無いので、前の区切りごと落とす。 + /// 残すと文書末尾に宙ぶらりんの `---` が残る。 + #[test] + fn removing_the_last_entry_drops_its_preceding_separator() { + let out = remove_detail_entry(&detail(), "タイトル B").expect("remove"); + assert!(out.contains("### タイトル A")); + assert!(!out.contains("タイトル B")); + assert!( + !out.trim_end().ends_with("---"), + "末尾に宙ぶらりんの区切りが残っている: {out:?}" + ); + assert_eq!(out.matches("---").count(), 1, "{out:?}"); + } + + /// 見出しが無ければ何も消さない。前方一致に緩めると似た名前の別タスクを消しうる。 + #[test] + fn a_missing_heading_is_an_error() { + assert!(remove_detail_entry(&detail(), "タイトル").is_err()); + assert!(remove_detail_entry(&detail(), "タイトル A の続き").is_err()); + } + + #[test] + fn a_duplicated_heading_is_an_error() { + let duplicated = format!("{}\n---\n\n### タイトル A\n\n> 別物\n", detail()); + assert!(remove_detail_entry(&duplicated, "タイトル A").is_err()); + } + + /// `## ` セクションの手前で止まる (次のエントリが無い場合に後続セクションを巻き込まない)。 + #[test] + fn removal_stops_at_the_next_level_two_heading() { + let markdown = "# TODO\n\n---\n\n### タイトル A\n\n> 動機\n\n## 別セクション\n\n本文\n"; + let out = remove_detail_entry(markdown, "タイトル A").expect("remove"); + assert!(out.contains("## 別セクション")); + assert!(out.contains("本文")); + assert!(!out.contains("動機")); + } +}