From 6d8a2a0773761d9150d712b54104edc6b478ca8c Mon Sep 17 00:00:00 2001 From: aloekun Date: Mon, 17 Aug 2026 01:20:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(nightly-todo):=20=E9=A0=86=E4=BD=8D=20tabl?= =?UTF-8?q?e=20=E5=AD=98=E5=9C=A8=E7=85=A7=E5=90=88=E3=82=B2=E3=83=BC?= =?UTF-8?q?=E3=83=88=E3=81=A7=E5=BE=8C=E5=A7=8B=E6=9C=AB=E6=BC=8F=E3=82=8C?= =?UTF-8?q?=E3=81=AE=E5=86=8D=E5=AE=9F=E8=A3=85=E3=82=92=E9=98=B2=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 人間が台帳タスクを手で実装してマージした場合、台帳の後始末は人手のため 漏れる (実績 4 件中 2 件)。残った行を夜間ループが再実装しうる。着手フローは 完了時に順位 table の行を消すので、順位 table からの消失を「完了済み」の 機械的シグナルとして使う。 - lib-ledger に summary_gate module を新設 - parse_summary_ranks: 順位 table (順位 + タスク 列を持つ表) から順位集合を抽出 - select_listed_in_summary: 未掲載の候補を飛ばして次の候補を選び、飛ばした 順位を呼び手へ返す (run 全体は止めない) - cli-nightly-task-select に --summary-file を追加 (複数指定可・省略不可)。 未掲載順位は stderr へ [NIGHTLY_WARN] を 1 件ずつ出す。stdout は workflow が 許可リストで絞るため、そちらだと exit 3 の run で警告が見えない - nightly-todo.yml の Select step へ todo-summary.md / todo-summary2.md を配線 - 実 exe を実ファイルに当てた検証でパーサのバグを発見: todo-summary.md の 順位 table には途中に空行があり、初版はそこで表が終わったと判定して順位 193 以降を全部取りこぼしていた。空行では終端しない実装へ修正し、実データ由来の 回帰テストを追加。データ側の空行も除去した --- .github/workflows/nightly-todo.yml | 10 + docs/todo-summary.md | 1 - docs/work-plan-nightly-lane-model.md | 4 +- src/cli-nightly-task-select/src/main.rs | 179 ++++++++++-- src/lib-ledger/src/lib.rs | 2 + src/lib-ledger/src/summary_gate.rs | 366 ++++++++++++++++++++++++ 6 files changed, 540 insertions(+), 22 deletions(-) create mode 100644 src/lib-ledger/src/summary_gate.rs diff --git a/.github/workflows/nightly-todo.yml b/.github/workflows/nightly-todo.yml index d2f77ad..c6f52a0 100644 --- a/.github/workflows/nightly-todo.yml +++ b/.github/workflows/nightly-todo.yml @@ -167,9 +167,17 @@ jobs: # (summary / target_files / caution) が含まれる。**step ログも public repo では # 第三者に可視**なので、tee は決定 14 の screening を迂回する公開面になっていた。 # 生の出力はファイルに留め ($GITHUB_OUTPUT 経由でのみ使う)、ログへは安全な行だけを出す。 + # --summary-file は順位 table の照合先。台帳に残っているのに順位 table から + # 消えた順位は「完了/取り下げ済みなのに後始末が漏れた行」なので選ばせない + # (人間が手で実装してマージした場合、台帳の後始末は人手のため漏れる)。 + # **順位 220 以降は todo-summary2.md にあるので両方渡す** — 片方だけだと、 + # もう一方に載っている順位が「消えた」と判定されて候補から落ちる。 + # exe 側は 1 つでも読めなければ exit 2 (fail-closed)。 master-ref/target/release/cli-nightly-task-select \ --ledger master-ref/docs/claude-code-web-tasks.md \ --exclude-ranks "${{ steps.inflight.outputs.exclude_ranks }}" \ + --summary-file master-ref/docs/todo-summary.md \ + --summary-file master-ref/docs/todo-summary2.md \ > "$RUNNER_TEMP/selected.txt" # 許可リスト方式 — exe が出す key のうち、ここに列挙したものだけを転送する。 # 想定外の key 注入を防ぐ fail-closed だが、**exe 側に出力を足したらここも足す** @@ -219,6 +227,8 @@ jobs: else echo "理由: タスクを選べませんでした。" echo " 直前の [NIGHTLY_SKIP] 行を参照してください (exit 3 = 該当タスク無し / exit 2 = 台帳の解釈失敗)。" + echo " [NIGHTLY_WARN] 行があれば、台帳に残っているが順位 table から消えた順位です" + echo " (完了/取り下げ済みの行が台帳に残っている = 後始末漏れ)。台帳の該当行を確認してください。" fi - name: Checkout master (agent workspace) diff --git a/docs/todo-summary.md b/docs/todo-summary.md index df7a33a..5348bbf 100644 --- a/docs/todo-summary.md +++ b/docs/todo-summary.md @@ -66,7 +66,6 @@ | 180 | 🔧 Tier 2 | **`lib-report-formatter` に markdown pipe / newline escape を追加 (週次レビュー 2026-05-30 C01 採用)** | todo12.md | S | なし (Phase D dogfood で発見、`src/lib-report-formatter/src/lib.rs:51-79` の `format_table()` が PR title / commit message の `|` / `\n`を escape せず markdown table 構造を破壊 → downstream AI facet で prompt injection リスク、`escape_markdown_pipe()` 5 行 utility + call site escape + 5 variant test で defense-in-depth 確立、本セッション 5 PR chain で AI facet 連鎖が systemic 化したため継続価値高) | | 181 | 🔧 Tier 2 | **`aggregate-weekly` facet の `findings.json` 出力を raw JSON にする (Phase D dogfood D-A 採用)** | todo12.md | XS-S | なし (本セッション 2026-05-30 Phase D dogfood で実観測した facet 出力 bug、`aggregate-weekly.md` instruction が「raw JSON 出力必須、markdown code fence で囲まない」を明示せず facet LLM が `\`\`\`json...\`\`\` ` で wrap してしまう、skill 側の手動 fence strip workaround を不要化、修正後の次 `/weekly-review` で raw JSON 出力を dogfood 観測、Phase E 試験運用前の整備) | | 182 | 🔧 Tier 2 | **`/weekly-review` skill に重複検出 (簡易 grep) を Phase 4 で追加 (Phase D dogfood D-B 採用)** | todo12.md | XS-S | なし (本セッション 2026-05-30 Phase D dogfood で WR-2026-05-30-S05 と既存 順位 173 が完全重複していた実観測、ADR-031 § Phase 4 「重複検出は MVP では実装しない」を「MVP+1 (簡易 grep + 3 択 AskUserQuestion: augment/新規/skip)」相当に格上げ、自動 merge なし原則は維持、description 先頭 40 chars の grep ヒット警告 → user 判断、`feedback_global_config_backup` 適用必須 (~/.claude/skills/ 編集前 snapshot)) | - | 193 | 🔧 Tier 2 | **Companion helper group 署名整合 compile-time validation test (PR #196 T2-1 採用) ★ Bundle 195-FB follow-up** | todo12.md | S | なし (Bundle 195-FB で 3 関数目の signature drift が CR Major + pre-push F-1 で systemic 観測、rule⑫ は literal hardcode 層、本タスクは API signature 整合性層、関数ポインタ cast による compile-time witness で signature drift を test 不通過に。`code-review.md` § Review Checklist に reviewer 注意 1 項目追加で 3 層防御 = rule⑫ + compile-time test + reviewer 注意、`feedback_global_config_backup` 適用必須) | | 194 | 💎 Tier 3 | **`development-workflow.md` 「1. Plan First」に「task 着手前に grep で既存 section 確認」step 追記 (PR #196 T3-5 採用)** | todo12.md | XS | なし (PR #123 + #196 で「既実装 section の重複計画」事象を Frequency Medium で観測、`~/.claude/rules/common/development-workflow.md` "1. Plan First" に Codification 重複確認 step を 1-2 行追記、`grep -rn` 手順 + 由来 cite (PR #123, #196)、派生プロジェクト (techbook-ledger / auto-review-fix-vc) に global rule として自動波及、`feedback_global_config_backup` 適用必須) | | 198 | 💎 Tier 3 | **ADR-NNN (採番未確定、land 時に確定): Timestamp invariant safety — 時刻計算 silent failure class の codify (PR #199 post-merge-feedback T3-2 採用、PR #203 T3-1 で 3 観測目に昇格)** | todo10.md | M | なし (PR #96 Finding D + PR #199 Bundle W + PR #203 hooks-session-start port で同型 bug class **3 件観測 = Frequency High**、PastTime newtype + proptest が実証した型層防御原則を ADR で永続化、派生プロジェクト (techbook-ledger / auto-review-fix-vc) への transferability 確保、順位 135 placeholder policy 適用、CLAUDE.md ADR list 追記) | diff --git a/docs/work-plan-nightly-lane-model.md b/docs/work-plan-nightly-lane-model.md index 2c764fc..0e56226 100644 --- a/docs/work-plan-nightly-lane-model.md +++ b/docs/work-plan-nightly-lane-model.md @@ -160,7 +160,7 @@ ### 留意点 -- 順位の**再採番**でも同じ警告が出る (台帳が旧番号を指したまま)。誤検知ではなく台帳の staleness なので、警告文言はその可能性に触れること +- ~~順位の**再採番**でも同じ警告が出る (台帳が旧番号を指したまま)。誤検知ではなく台帳の staleness なので、警告文言はその可能性に触れること~~ → **不要になった (2026-08-16)**。[ADR-033](adr/adr-033-todo-numbering-simplification.md) § 改訂 で再採番を廃止したため、順位 table に無い = 完了か取り下げの 2 択に絞れる。警告文言もその 2 択で書く ### PR-3 完了基準 @@ -248,7 +248,7 @@ skill リポ作業は PR-1 マージ後いつでも (PR-5 と独立) - [x] PR-1: docs (ADR-072/052 改訂、台帳条件 3 廃止、facet Criterion 3-2/3-3 縮小、T01/T02 撤回、todo rescope、本計画書の同梱) — 2026-08-16 実施。計画外の追加 (ユーザー承認済み): (a) 順位 449「昇格不適格判定の『両経路記載』を決定論化」を削除 (検査対象の § 昇格検査履歴 廃止で前提消滅)、(b) 台帳 § 無人可としなかった理由 の順位 284 行を lane 表記へ更新 + 見出しから件数を除去、(c) 未採番だった詳細エントリ 3 件に順位 462-464 を採番、(d) **ADR-033 改訂** — 「順位 = 追記型 ID・優先度は Tier 列・再採番はしない・細粒度の順序は行の並びで表す」を明文化し、本文の順位参照禁止を緩和。帰結として順位 334 (本文順位番号 lint) を retire - [x] PR-2: facet 出力言語の直書き + dev-conventions 記録 + todo23 エントリ削除 — 2026-08-16 実施。計画からの逸脱 1 件: **固定トークンの免除リストを言語指定と対で書いた** (workflow の `rules.condition` が `analysis complete` / `convergence_verdict: <値>` / `APPROVE` / `REJECT` を英語リテラルで照合しているため、言語指定だけを書くと gate が通らなくなる)。あわせて順位 462 の table 行削除と CLAUDE.md 索引更新 -- [ ] PR-3: 順位 table 存在照合ゲート (lib-ledger + selector + workflow 配線) +- [x] PR-3: 順位 table 存在照合ゲート (lib-ledger + selector + workflow 配線) — 2026-08-16 実施。`lib_ledger::{parse_summary_ranks, select_listed_in_summary}` を新設、`--summary-file` (複数指定可・省略不可) を selector へ追加、workflow の Select step へ 2 ファイルを配線。**実 exe を実ファイルに当てた検証でパーサのバグを発見**: `docs/todo-summary.md` の順位 table には途中に空行があり、初版は表が終わったと判定して順位 193 以降を全部取りこぼしていた (空行では終端しない実装へ修正 + 回帰テスト + データ側の空行も除去)。`workflow_dispatch` での実走観測は未実施 (下記 実走確認 2 に集約) - [ ] PR-4: 失敗マーカー + ブランチ自動掃除 + token 2 段化 + close 時案内 - [ ] PR-5: 台帳未掲載順位一覧の決定論出力 + facet 参照置き換え + todo23/24 エントリ整理 - [ ] skill リポ: Phase 4 展開先変更 + 昇格フロー縮小 + skill-sync-check diff --git a/src/cli-nightly-task-select/src/main.rs b/src/cli-nightly-task-select/src/main.rs index 37a32fe..e5f62b0 100644 --- a/src/cli-nightly-task-select/src/main.rs +++ b/src/cli-nightly-task-select/src/main.rs @@ -8,7 +8,7 @@ //! # 使い方 //! //! ```text -//! cli-nightly-task-select --ledger --exclude-ranks +//! cli-nightly-task-select --ledger --exclude-ranks --summary-file [--summary-file ...] //! ``` //! //! `--exclude-ranks` は「既に draft PR が開いている順位」のカンマ区切り。呼び手 (workflow の @@ -17,6 +17,13 @@ //! 意味が違い、後者は引数不正として止める。ここを省略可能にすると、`gh api` が失敗した run が //! 「開いている draft は無い」と解釈して同じタスクを毎晩実装し直す。 //! +//! `--summary-file` は順位 table (`docs/todo-summary*.md`) のパス。**1 つ以上必須**で、順位 220 +//! 以降は 2 つ目のファイルにあるため呼び手は両方を渡す。台帳に残っているが順位 table から +//! 消えた順位は「完了 (または取り下げ) 済みなのに後始末が漏れた行」なので選ばない +//! ([ADR-072](../../../docs/adr/adr-072-nightly-todo-loop.md)、`lib_ledger::select_listed_in_summary`)。 +//! **省略可能にしない**のは `--exclude-ranks` と同じ理由 — 渡し忘れた run が「照合できなかった」 +//! ではなく「全順位が載っている」と解釈して、完了済みタスクを再実装する。 +//! //! # exit コード //! //! - `0` = タスクを選んだ (stdout に選択結果) @@ -39,13 +46,14 @@ use lib_ledger::{screen_for_public_output, screen_for_title, Task}; const MARKER_SELECTED: &str = "[NIGHTLY_TASK]"; const MARKER_SKIP: &str = "[NIGHTLY_SKIP]"; +const MARKER_WARN: &str = "[NIGHTLY_WARN]"; const EXIT_SELECTED: i32 = 0; const EXIT_USAGE: i32 = 2; const EXIT_NO_TASK: i32 = 3; -const USAGE: &str = - "usage: cli-nightly-task-select --ledger --exclude-ranks "; +const USAGE: &str = "usage: cli-nightly-task-select --ledger --exclude-ranks \ + --summary-file [--summary-file ...]"; fn main() { std::process::exit(run(std::env::args().skip(1).collect())); @@ -60,11 +68,13 @@ fn main() { struct Cli { ledger_path: PathBuf, excluded_ranks: BTreeSet, + summary_paths: Vec, } fn parse_args(args: &[String]) -> Result { let mut ledger_path = None; let mut excluded_ranks = None; + let mut summary_paths = Vec::new(); let mut index = 0; while index < args.len() { let flag = args[index].as_str(); @@ -73,13 +83,18 @@ fn parse_args(args: &[String]) -> Result { match flag { "--ledger" => ledger_path = Some(PathBuf::from(take()?)), "--exclude-ranks" => excluded_ranks = Some(parse_ranks(take()?)?), + "--summary-file" => summary_paths.push(PathBuf::from(take()?)), other => return Err(format!("未知の引数です: {other:?}")), } index += 2; } + if summary_paths.is_empty() { + return Err("--summary-file が必要です (順位 table のパス、複数指定可)".to_string()); + } Ok(Cli { ledger_path: ledger_path.ok_or_else(|| "--ledger が必要です".to_string())?, excluded_ranks: excluded_ranks.ok_or_else(|| "--exclude-ranks が必要です".to_string())?, + summary_paths, }) } @@ -114,27 +129,71 @@ fn run(args: Vec) -> i32 { ) } }; - match lib_ledger::select(&markdown, &cli.excluded_ranks) { + let summary_ranks = match collect_summary_ranks(&cli.summary_paths) { + Ok(ranks) => ranks, + Err(message) => return skip(EXIT_USAGE, &message, false), + }; + match lib_ledger::select_listed_in_summary(&markdown, &cli.excluded_ranks, &summary_ranks) { Err(message) => skip( EXIT_USAGE, &format!("台帳を解釈できません ({display}): {message}"), false, ), - Ok(None) => skip( - EXIT_NO_TASK, - &format!( - "実装可能な無人可タスクがありません (台帳: {display}、除外済み: {} 件)", - cli.excluded_ranks.len() - ), - false, - ), - Ok(Some(task)) => { - report_selected(&task, &display); - EXIT_SELECTED + Ok(selection) => { + warn_about_unlisted_ranks(&selection.skipped_ranks); + match selection.task { + None => skip( + EXIT_NO_TASK, + &format!( + "実装可能な無人可タスクがありません (台帳: {display}、除外済み: {} 件、順位 table 未掲載で飛ばした: {} 件)", + cli.excluded_ranks.len(), + selection.skipped_ranks.len() + ), + false, + ), + Some(task) => { + report_selected(&task, &display); + EXIT_SELECTED + } + } } } } +/// 全 summary ファイルの順位を読み、和集合を返す。 +/// +/// **1 つでも読めない / 解釈できないなら全体を失敗にする** (呼び手は exit 2)。片方だけ読めた +/// 状態で続けると、そのファイルに載っている順位まで「消えた」と判定して候補を取りこぼす +/// (ADR-072 決定 2「曖昧さはすべて停止側へ」)。 +fn collect_summary_ranks(paths: &[PathBuf]) -> Result, String> { + let mut ranks = BTreeSet::new(); + for path in paths { + let display = path.display(); + let markdown = std::fs::read_to_string(path) + .map_err(|e| format!("順位 table を読めません ({display}): {e}"))?; + let parsed = lib_ledger::parse_summary_ranks(&markdown) + .map_err(|e| format!("順位 table を解釈できません ({display}): {e}"))?; + ranks.extend(parsed); + } + Ok(ranks) +} + +/// 台帳に残っているが順位 table から消えた順位を 1 件ずつ警告する。 +/// +/// **stderr へ出す。** stdout は workflow が `> selected.txt` へ捨てて許可リストの +/// `key=value` 行だけを転送するため、そちらに出すと全候補が飛ばされた run (exit 3) で +/// 警告が誰にも見えない。順位は `u32` にパース済みで構造的に安全なので、public repo の +/// step ログへ出しても決定 14 の screening 対象にならない。 +fn warn_about_unlisted_ranks(skipped_ranks: &[u32]) { + for rank in skipped_ranks { + eprintln!( + "{MARKER_WARN} 順位 {rank} は台帳に残っていますが順位 table にありません。\ + 完了または取り下げ済みの行が台帳に残っている (後始末漏れ) 可能性があります。\ + 選択せず次の候補へ進みました — 台帳の該当行を人間が確認してください。" + ); + } +} + /// 後続を動かさないすべての経路。理由を stderr へ 1 行で出す。 fn skip(code: i32, message: &str, with_usage: bool) -> i32 { eprintln!("{MARKER_SKIP} {message}"); @@ -182,18 +241,56 @@ mod tests { values.iter().map(|s| (*s).to_string()).collect() } + fn required(extra: &[&str]) -> Vec { + let mut values = vec!["--ledger", "a.md", "--exclude-ranks", ""]; + values.extend_from_slice(extra); + values.extend_from_slice(&["--summary-file", "s.md"]); + args(&values) + } + #[test] fn parses_both_required_flags() { - let cli = - parse_args(&args(&["--ledger", "a.md", "--exclude-ranks", "203,240"])).expect("parse"); + let cli = parse_args(&args(&[ + "--ledger", + "a.md", + "--exclude-ranks", + "203,240", + "--summary-file", + "s.md", + ])) + .expect("parse"); assert_eq!(cli.ledger_path, PathBuf::from("a.md")); assert_eq!(cli.excluded_ranks, [203, 240].into_iter().collect()); + assert_eq!(cli.summary_paths, vec![PathBuf::from("s.md")]); + } + + /// 順位 220 以降は 2 つ目のファイルにあるため、呼び手は両方を渡す。 + #[test] + fn the_summary_flag_can_be_repeated() { + let cli = parse_args(&args(&[ + "--ledger", + "a.md", + "--exclude-ranks", + "", + "--summary-file", + "todo-summary.md", + "--summary-file", + "todo-summary2.md", + ])) + .expect("parse"); + assert_eq!( + cli.summary_paths, + vec![ + PathBuf::from("todo-summary.md"), + PathBuf::from("todo-summary2.md") + ] + ); } /// 空文字は「数えた結果 0 件」。フラグ欠落 (= 数えられなかった) と区別する。 #[test] fn empty_exclude_list_is_an_empty_set_not_an_error() { - let cli = parse_args(&args(&["--ledger", "a.md", "--exclude-ranks", ""])).expect("parse"); + let cli = parse_args(&required(&[])).expect("parse"); assert!(cli.excluded_ranks.is_empty()); } @@ -204,11 +301,26 @@ mod tests { assert!(parse_args(&args(&[])).is_err()); } + /// `--summary-file` 欠落は「照合できなかった」であって「全順位が載っている」ではない。 + /// 省略を許すと、渡し忘れた run が完了済みタスクを再実装する。 + #[test] + fn omitting_the_summary_flag_is_a_usage_error() { + assert!(parse_args(&args(&["--ledger", "a.md", "--exclude-ranks", ""])).is_err()); + } + #[test] fn non_numeric_exclude_entries_are_usage_errors() { for raw in ["203,abc", "claude/nightly-203", "203;240", "-1"] { assert!( - parse_args(&args(&["--ledger", "a.md", "--exclude-ranks", raw])).is_err(), + parse_args(&args(&[ + "--ledger", + "a.md", + "--exclude-ranks", + raw, + "--summary-file", + "s.md" + ])) + .is_err(), "{raw:?} が引数不正として弾かれない" ); } @@ -221,6 +333,8 @@ mod tests { "a.md", "--exclude-ranks", " 203 , 240 ", + "--summary-file", + "s.md", ])) .expect("parse"); assert_eq!(cli.excluded_ranks, [203, 240].into_iter().collect()); @@ -243,12 +357,39 @@ mod tests { &path.to_string_lossy(), "--exclude-ranks", "", + "--summary-file", + &path.to_string_lossy(), ])); assert_eq!(code, EXIT_USAGE); assert_ne!(EXIT_USAGE, EXIT_SELECTED); assert_ne!(EXIT_NO_TASK, EXIT_SELECTED); } + /// 順位 table が読めないのは「全順位が消えた」ではなく入力不正。 + /// 読み飛ばすと、その run は候補を全部飛ばして毎晩 no-op になる。 + #[test] + fn an_unreadable_summary_file_is_a_usage_error() { + let dir = std::env::temp_dir().join("cli-nightly-task-select-summary"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let ledger = dir.join("ledger.md"); + std::fs::write( + &ledger, + "| 順位 | Tier | 無人可 | 内容 | 対象ファイル | 工数 | 注意 |\n\ + |---|---|---|---|---|---|---|\n\ + | 203 | T2 | ✅ | テスト追加 | `src/a.rs` | XS | なし |\n", + ) + .expect("write ledger"); + let code = run(args(&[ + "--ledger", + &ledger.to_string_lossy(), + "--exclude-ranks", + "", + "--summary-file", + &dir.join("absent-summary.md").to_string_lossy(), + ])); + assert_eq!(code, EXIT_USAGE); + } + #[test] fn newlines_in_cells_cannot_inject_extra_output_keys() { assert_eq!(one_line("a\nrank=999"), "a rank=999"); diff --git a/src/lib-ledger/src/lib.rs b/src/lib-ledger/src/lib.rs index 07e1c03..ed37128 100644 --- a/src/lib-ledger/src/lib.rs +++ b/src/lib-ledger/src/lib.rs @@ -25,6 +25,7 @@ mod deployed_ledger; mod rank_lookup; mod removal; mod screening; +mod summary_gate; mod target_files; pub use completion::{evaluate, Completion}; @@ -32,6 +33,7 @@ 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 summary_gate::{parse_summary_ranks, select_listed_in_summary, Selection}; pub use target_files::parse_target_files; diff --git a/src/lib-ledger/src/summary_gate.rs b/src/lib-ledger/src/summary_gate.rs new file mode 100644 index 0000000..4c8788a --- /dev/null +++ b/src/lib-ledger/src/summary_gate.rs @@ -0,0 +1,366 @@ +//! 順位 table (`docs/todo-summary*.md`) に載っていない順位を選ばせないゲート。 +//! +//! # なぜ要るのか +//! +//! 台帳の行は「タスクがマージされるまで残る」が、**人間が台帳タスクを手で実装してマージした +//! 場合の後始末は人手**である ([ADR-072](../../../docs/adr/adr-072-nightly-todo-loop.md) 決定 6 が +//! agent に台帳を書き換えさせないため)。実績では 4 件中 2 件で後始末が漏れており、残った行を +//! 夜間ループが再実装しうる。 +//! +//! 一方、着手フローは完了時に `docs/todo-summary*.md` の順位行を削除する。**順位 table からの +//! 消失は「完了 (または取り下げ) 済み」の機械的シグナル**であり、台帳の行が残っていても +//! そちらを見れば気づける。本 module はその照合を担う。 +//! +//! # 順位は再利用されない +//! +//! [ADR-033](../../../docs/adr/adr-033-todo-numbering-simplification.md) § 改訂 (2026-08-16) が +//! 「順位は追記型 ID で再採番しない」と定めたため、**順位 table に無い順位は「別のタスクに +//! 付け替わった」のではなく「消えた」**と読める。この一意性が本ゲートの前提である。 +//! +//! # 失敗の倒し方 +//! +//! - 順位 table を **1 つも見つけられない** → エラー (呼び手が exit 2)。summary の構成が +//! 変わったのに黙って「全順位が消えている」と解釈すると、全候補を skip して毎晩 no-op になる +//! - 行の順位セルが数値でない → エラー。読み飛ばすと、その行の順位が「消えた」扱いになる +//! - 候補が table に無い → **その順位だけ skip して次の候補へ進む** (run 全体は止めない) + +use std::collections::BTreeSet; + +use crate::Task; + +/// 順位 table を識別するために必須の列見出し。 +/// +/// 台帳側の表 (`順位` + `無人可`) や棚卸し履歴 (`順位` + `節`) と取り違えないよう、 +/// **`順位` だけでなく `タスク` も要求する**。`順位` 単独で判定すると、順位列を持つ別の表の +/// 数字を「順位 table に載っている」と誤読し、ゲートが素通りする。 +const REQUIRED_HEADERS: (&str, &str) = ("順位", "タスク"); + +/// ヘッダ行 + 区切り行。データ行はこの 2 行の後から始まる。 +const HEADER_AND_SEPARATOR_ROWS: usize = 2; + +/// 順位 table に載っている順位の集合を返す。 +/// +/// 呼び手は `docs/todo-summary.md` / `docs/todo-summary2.md` の両方に当て、**和集合**を取る +/// (順位 220 以降は 2 つ目のファイルにあり、片方だけでは全順位を見たことにならない)。 +pub fn parse_summary_ranks(markdown: &str) -> Result, String> { + let lines: Vec<&str> = markdown.lines().collect(); + let mut ranks = BTreeSet::new(); + let mut tables_scanned = 0usize; + let mut index = 0usize; + while index < lines.len() { + let Some(rank_column) = summary_table_header(&lines, index)? else { + index += 1; + continue; + }; + tables_scanned += 1; + index += HEADER_AND_SEPARATOR_ROWS; + index = consume_rows(&lines, index, rank_column, &mut ranks)?; + } + if tables_scanned == 0 { + return Err( + "順位 table (順位 + タスク 列を持つ表) が 1 つも見つかりません (summary の構成が変わった可能性があります)" + .to_string(), + ); + } + Ok(ranks) +} + +/// `index` 行が順位 table のヘッダ行なら順位列の位置を返す。 +/// +/// 区切り行の欠落だけは `Err` にする — ヘッダに見える行の直後がデータ行だと、区切り行の +/// つもりで 1 行読み飛ばして先頭の順位を取りこぼす (台帳パーサと同じ扱い)。 +fn summary_table_header(lines: &[&str], index: usize) -> Result, String> { + if !crate::is_table_row(lines[index]) { + return Ok(None); + } + let cells = crate::split_cells(lines[index]); + let position = |name: &str| cells.iter().position(|c| c.trim() == name); + let (Some(rank_column), Some(_)) = (position(REQUIRED_HEADERS.0), position(REQUIRED_HEADERS.1)) + else { + return Ok(None); + }; + if index + 1 >= lines.len() || !crate::is_separator_row(lines[index + 1]) { + return Err(format!( + "{} 行目: 順位 table のヘッダ行の直後に区切り行がありません", + index + 1 + )); + } + Ok(Some(rank_column)) +} + +/// 表のデータ行を読み切り、終端の行 index を返す。 +/// +/// **空行では終端しない。** `docs/todo-summary.md` の順位 table には実際に途中の空行があり +/// (2026-08-17 時点で 1 箇所)、そこで打ち切ると以降の順位を丸ごと「消えた」と誤判定して、 +/// 生きているタスクを毎晩飛ばす。終端は「空行以外の非表行」(散文・次の見出し) とする。 +/// +/// 空行の先が別の表のヘッダ行だった場合は、その先頭セルが数値でないため +/// [`take_rank`] がエラーを返す — 黙って別の表の数字を取り込むより、loud に止める。 +fn consume_rows( + lines: &[&str], + mut index: usize, + rank_column: usize, + ranks: &mut BTreeSet, +) -> Result { + while index < lines.len() { + let line = lines[index]; + if line.trim().is_empty() { + index += 1; + continue; + } + if !crate::is_table_row(line) { + break; + } + take_rank(&crate::split_cells(line), rank_column, index + 1, ranks)?; + index += 1; + } + Ok(index) +} + +fn take_rank( + cells: &[String], + rank_column: usize, + line_number: usize, + ranks: &mut BTreeSet, +) -> Result<(), String> { + let raw = cells + .get(rank_column) + .ok_or_else(|| format!("{line_number} 行目: 順位 table の行に順位列がありません"))?; + let rank = raw + .trim() + .parse::() + .map_err(|_| format!("{line_number} 行目: 順位を整数として読めません: {raw:?}"))?; + ranks.insert(rank); + Ok(()) +} + +/// 選択結果と、順位 table に無いために飛ばした順位。 +pub struct Selection { + pub task: Option, + /// 順位 table に載っていないため選ばなかった順位 (文書順)。 + /// + /// **空でないことは異常のシグナル**だが run は止めない。呼び手は 1 件ずつ警告を出し、 + /// 人間が台帳の行を確認できるようにする。 + pub skipped_ranks: Vec, +} + +/// 順位 table に載っている候補だけを選ぶ。 +/// +/// 載っていない候補は除外集合へ加えて次の候補を見る。**その順位だけを飛ばし、run 全体は +/// 止めない** — 台帳に 1 行 stale なものがあるだけで夜間ループが毎晩何もしなくなるのは、 +/// 後始末漏れの実害に比べて過剰である。 +/// +/// 実装は [`crate::select`] を候補ごとに呼び直す。skip は通常 0 件で台帳も 10 行規模のため、 +/// 再パースのコストより「選択ロジックを 1 箇所に保つ」ことを優先している。 +pub fn select_listed_in_summary( + markdown: &str, + excluded_ranks: &BTreeSet, + summary_ranks: &BTreeSet, +) -> Result { + let mut excluded = excluded_ranks.clone(); + let mut skipped_ranks = Vec::new(); + loop { + match crate::select(markdown, &excluded)? { + None => { + return Ok(Selection { + task: None, + skipped_ranks, + }) + } + Some(task) if summary_ranks.contains(&task.rank) => { + return Ok(Selection { + task: Some(task), + skipped_ranks, + }) + } + Some(task) => { + skipped_ranks.push(task.rank); + excluded.insert(task.rank); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn summary(rows: &str) -> String { + format!( + "# TODO 推奨実行順序サマリー\n\n\ + | 順位 | Tier | タスク | ファイル | 工数 | 依存 |\n\ + |---|---|---|---|---|---|\n{rows}\n\n**戦略**: ...\n" + ) + } + + fn ledger(rows: &str) -> String { + format!( + "## 採用タスク\n\n\ + | 順位 | Tier | 無人可 | 内容 | 対象ファイル | 工数 | 注意 |\n\ + |---|---|---|---|---|---|---|\n{rows}\n" + ) + } + + #[test] + fn collects_every_rank_in_the_summary_table() { + let ranks = parse_summary_ranks(&summary( + "| 203 | T2 | テスト追加 | todo3.md | XS | なし |\n\ + | 240 | T2 | エラー握り潰し解消 | todo9.md | XS | なし |", + )) + .expect("parse"); + assert_eq!(ranks, [203, 240].into_iter().collect()); + } + + /// 順位列を持つが `タスク` 列を持たない表 (棚卸し履歴・台帳のタスク表) は対象外。 + /// ここを取り違えると、消えた順位が「載っている」ことになりゲートが素通りする。 + #[test] + fn tables_without_the_task_column_are_ignored() { + let markdown = format!( + "{}\n## 棚卸し履歴\n\n\ + | 順位 | 節 | 判定 |\n\ + |---|---|---|\n\ + | 999 | 採用タスク | land 済みのため削除 |\n", + summary("| 203 | T2 | テスト追加 | todo3.md | XS | なし |") + ); + let ranks = parse_summary_ranks(&markdown).expect("parse"); + assert_eq!(ranks, [203].into_iter().collect()); + assert!(!ranks.contains(&999), "棚卸し履歴の順位を拾っている"); + } + + /// **実データ由来の回帰テスト。** `docs/todo-summary.md` の順位 table には途中に空行が + /// あり、初版はそこで表が終わったと判定して以降の順位 (193 以降) を全部取りこぼした。 + /// 実 exe を実ファイルに当てて初めて露見した — fixture だけでは踏めない穴だった。 + #[test] + fn a_blank_line_inside_the_table_does_not_end_it() { + let ranks = parse_summary_ranks(&summary( + "| 203 | T2 | テスト追加 | todo3.md | XS | なし |\n\ + \n\ + | 240 | T2 | 握り潰し解消 | todo9.md | XS | なし |", + )) + .expect("parse"); + assert_eq!(ranks, [203, 240].into_iter().collect()); + } + + /// 表の後の散文で終端する (空行を許すからといって、以降を無制限に読まない)。 + #[test] + fn prose_after_the_table_ends_it() { + let markdown = format!( + "{}\n| 999 | 別の表 |\n", + summary("| 203 | T2 | テスト追加 | todo3.md | XS | なし |") + ); + let ranks = parse_summary_ranks(&markdown).expect("parse"); + assert_eq!(ranks, [203].into_iter().collect()); + } + + /// 順位 table が 1 つも無いのは「全順位が消えた」ではなく構成変更。 + /// 黙って空集合を返すと全候補が skip され、毎晩 no-op になる。 + #[test] + fn a_summary_without_any_rank_table_is_an_error() { + assert!(parse_summary_ranks("# 見出しだけ\n\n本文\n").is_err()); + } + + #[test] + fn a_non_numeric_rank_cell_is_an_error() { + assert!(parse_summary_ranks(&summary( + "| 二百三 | T2 | テスト追加 | todo3.md | XS | なし |" + )) + .is_err()); + } + + #[test] + fn a_header_without_a_separator_row_is_an_error() { + let markdown = "| 順位 | Tier | タスク | ファイル |\n| 203 | T2 | x | todo3.md |\n"; + assert!(parse_summary_ranks(markdown).is_err()); + } + + /// 順位 220 以降は 2 つ目のファイルにある。呼び手が和集合を取ることを前提に、 + /// 単体では「自分のファイルに載っている分だけ」を返す。 + #[test] + fn each_file_reports_only_its_own_ranks() { + let part1 = parse_summary_ranks(&summary( + "| 203 | T2 | テスト追加 | todo3.md | XS | なし |", + )) + .expect("parse part1"); + let part2 = parse_summary_ranks(&summary( + "| 340 | T2 | 境界テスト | todo14.md | S | なし |", + )) + .expect("parse part2"); + assert!(!part1.contains(&340)); + let union: BTreeSet = part1.union(&part2).copied().collect(); + assert_eq!(union, [203, 340].into_iter().collect()); + } + + #[test] + fn selects_the_first_candidate_that_is_listed() { + let ledger = ledger( + "| 203 | T2 | ✅ | テスト追加 | `src/a.rs` | XS | なし |\n\ + | 240 | T2 | ✅ | 握り潰し解消 | `src/b.rs` | XS | なし |", + ); + let selection = select_listed_in_summary( + &ledger, + &BTreeSet::new(), + &[203, 240].into_iter().collect(), + ) + .expect("select"); + assert_eq!(selection.task.expect("task").rank, 203); + assert!(selection.skipped_ranks.is_empty()); + } + + /// 先頭候補が順位 table から消えていたら、その順位を飛ばして次の候補を選ぶ。 + /// 止めないのは、stale な 1 行で夜間ループ全体が毎晩止まるのを避けるため。 + #[test] + fn an_unlisted_candidate_is_skipped_and_the_next_one_is_selected() { + let ledger = ledger( + "| 203 | T2 | ✅ | テスト追加 | `src/a.rs` | XS | なし |\n\ + | 240 | T2 | ✅ | 握り潰し解消 | `src/b.rs` | XS | なし |", + ); + let selection = + select_listed_in_summary(&ledger, &BTreeSet::new(), &[240].into_iter().collect()) + .expect("select"); + assert_eq!(selection.task.expect("task").rank, 240); + assert_eq!(selection.skipped_ranks, vec![203]); + } + + /// 全候補が消えていたら「該当タスク無し」。飛ばした順位は呼び手が警告に使う。 + #[test] + fn all_candidates_unlisted_yields_no_task_and_reports_every_skip() { + let ledger = ledger( + "| 203 | T2 | ✅ | テスト追加 | `src/a.rs` | XS | なし |\n\ + | 240 | T2 | ✅ | 握り潰し解消 | `src/b.rs` | XS | なし |", + ); + let selection = + select_listed_in_summary(&ledger, &BTreeSet::new(), &BTreeSet::new()).expect("select"); + assert!(selection.task.is_none()); + assert_eq!(selection.skipped_ranks, vec![203, 240]); + } + + /// 既存の除外 (着手済みブランチ) と本ゲートは独立に効く。 + #[test] + fn excluded_ranks_are_not_reported_as_summary_skips() { + let ledger = ledger( + "| 203 | T2 | ✅ | テスト追加 | `src/a.rs` | XS | なし |\n\ + | 240 | T2 | ✅ | 握り潰し解消 | `src/b.rs` | XS | なし |", + ); + let selection = select_listed_in_summary( + &ledger, + &[203].into_iter().collect(), + &[203, 240].into_iter().collect(), + ) + .expect("select"); + assert_eq!(selection.task.expect("task").rank, 240); + assert!( + selection.skipped_ranks.is_empty(), + "着手済み除外を summary 由来の skip として報告している" + ); + } + + /// 台帳の解釈エラーはゲートを通しても失われない (fail-closed の維持)。 + #[test] + fn a_broken_ledger_is_still_an_error() { + let broken = ledger("| 203 | T2 | ✅ (条件付き) | テスト追加 | `src/a.rs` | XS | なし |"); + assert!( + select_listed_in_summary(&broken, &BTreeSet::new(), &[203].into_iter().collect()) + .is_err() + ); + } +}