diff --git a/docs/claude-code-web-tasks.md b/docs/claude-code-web-tasks.md index 1cf08b8..315c4b6 100644 --- a/docs/claude-code-web-tasks.md +++ b/docs/claude-code-web-tasks.md @@ -90,6 +90,25 @@ - **空でよい**。未記入なら従来の `feat: 順位 NNN の無人実装 (nightly-todo)` にフォールバックする。`無人可` が `—` の行は選ばれないので空のままでよい - `内容` 列とは**用途が違う**。あちらは agent への依頼文で長くてよい。こちらはタイトル +### 「対象ファイル」列の書き方(2026-08-15 制定、機械強制あり) + +この列は**後始末の完了判定に使う機械可読フィールド**である。「宣言された成果物がすべて変更されたか」で完了を判定するため、**書き漏らした成果物はそのまま判定の穴になる**。実際に夜間 PR [#394](https://github.com/aloekun/claude-code-hook-test/pull/394) は lint rule の fixture だけを追加してマージされ、rule 本体が無いまま完了扱いになりかけた。 + +書式(`lib-ledger` の `parse_target_files` が解釈し、`cargo test` が実台帳の全行を毎回検証する): + +- **成果物はすべてバッククォートで囲む。** 囲みの無い散文(`+ fixtures` 等)は成果物として抽出されず、**欠けていても検証を通過してしまう**ため error にする +- **リポジトリ相対パスで書く。** `main.rs` のような裸のファイル名はどの crate か決まらないため error。`src/cli-docs-lint/src/main.rs` と書く +- **複数の成果物は `+` で並べる** +- **丸括弧(全角・半角とも)の中は注釈**として無視される。行番号・「新規」・関数名などを自由に書いてよい(例: 「(`mod tests`、既存 `xxx` 拡張)」) +- **`{a,b}` は展開され、展開結果の全てが要求対象になる**(例: `tests/fixtures/incidents/{bad,good}/` は bad/good 両方の変更を要求する) +- パス区切りは `/`。絶対パス・`..` は不可 + +例: + +```text +`src/cli-docs-lint/src/adr_consistency.rs`(新規)+ `src/cli-docs-lint/src/main.rs`(CheckMode dispatch 拡張) +``` + ### Batch 1: 純テスト・軽微実装(即着手推奨、◎) `cargo test` で完結し外部依存・設計判断が最小のもの。工数昇順。 @@ -110,9 +129,9 @@ cargo test で検証完結するが、新規 module / lint rule / 軽微リフ | 順位 | Tier | 無人可 | 内容 | 対象ファイル | 工数 | 注意 | PRタイトル | |---|---|---|---|---|---|---|---| | 340 | T2 | — | `decide.rs` の rate_limit × positive-evidence 複合境界テスト + `main.rs` の rate_limit threading テスト | `src/check-ci-coderabbit/src/{decide,main}.rs` | S | (a) は純関数で容易。(b) は `main.rs` の呼び出し側を I/O 無しでテスト可能にする小さな合成関数抽出リファクタが要る | | -| 272 | T1 | — | cli-docs-lint に ADR 重複採番検出 + CLAUDE.md 索引整合チェック(新規 validator module) | `src/cli-docs-lint/src/adr_consistency.rs`(新規)+ `main.rs`(CheckMode dispatch 拡張) | S-M | 中核(validator + fixture test)は cargo test で完結。「pnpm lint:docs 経由の発火確認」は Web 外だが成功条件ではない。CLAUDE.md は docs_dir の親なので TempDir で fake 構造を組む | | -| 334 | T1 | — | docs/todo\*.md 本文の順位番号表記を検出する custom lint rule(ADR-033 仕組み化、`paths=["docs/todo*.md"]` scope、table 行除外) | `.claude/custom-lint-rules.toml` + fixtures(216 と同基盤) | M | 検証経路は 216 と同じ cargo test。**regex FP 精緻化**(preamble の「順位 220 以降」等)+ **本文 dogfood cleanup の規模**を着手前に grep 見積り(todo 記載 S だが M 見込み) | | -| 179 | T2 | — | rate-limit retry 境界(max_retries=0/1/3)で retry 継続 vs `action_required` 遷移の off-by-one を pin する parameterized テスト | `src/cli-pr-monitor/src/stages/poll/rate_limit.rs`(判定 L52)+ `config.rs`(L143-155) | S-M | **todo の「rstest 使用済」は誤り**(Cargo.lock に不在)。新 dev-dep 追加 or plain 複数 `#[test]` で代替を着手時判断。gh subprocess を踏まない早期 return 経路で構成する | | +| 272 | T1 | — | cli-docs-lint に ADR 重複採番検出 + CLAUDE.md 索引整合チェック(新規 validator module) | `src/cli-docs-lint/src/adr_consistency.rs`(新規)+ `src/cli-docs-lint/src/main.rs`(CheckMode dispatch 拡張) | S-M | 中核(validator + fixture test)は cargo test で完結。「pnpm lint:docs 経由の発火確認」は Web 外だが成功条件ではない。CLAUDE.md は docs_dir の親なので TempDir で fake 構造を組む | | +| 334 | T1 | — | docs/todo\*.md 本文の順位番号表記を検出する custom lint rule(ADR-033 仕組み化、`paths=["docs/todo*.md"]` scope、table 行除外) | `.claude/custom-lint-rules.toml` + `tests/fixtures/incidents/{bad,good}/`(216 と同基盤) | M | 検証経路は 216 と同じ cargo test。**regex FP 精緻化**(preamble の「順位 220 以降」等)+ **本文 dogfood cleanup の規模**を着手前に grep 見積り(todo 記載 S だが M 見込み) | | +| 179 | T2 | — | rate-limit retry 境界(max_retries=0/1/3)で retry 継続 vs `action_required` 遷移の off-by-one を pin する parameterized テスト | `src/cli-pr-monitor/src/stages/poll/rate_limit.rs`(判定 L52)+ `src/cli-pr-monitor/src/config.rs`(L143-155) | S-M | **todo の「rstest 使用済」は誤り**(Cargo.lock に不在)。新 dev-dep 追加 or plain 複数 `#[test]` で代替を着手時判断。gh subprocess を踏まない早期 return 経路で構成する | | ### 無人可としなかった 7 件の理由 diff --git a/src/lib-ledger/src/deployed_ledger.rs b/src/lib-ledger/src/deployed_ledger.rs new file mode 100644 index 0000000..735ddc0 --- /dev/null +++ b/src/lib-ledger/src/deployed_ledger.rs @@ -0,0 +1,147 @@ +//! 実台帳 (`docs/claude-code-web-tasks.md`) が機械可読の契約を満たすことを固定する検査。 +//! +//! # なぜ実ファイルを読むのか +//! +//! [`super::parse_target_files`] の unit test は合成入力で書式を固定するが、**実際の台帳が +//! 契約を満たしているか**は別問題である。人間が新しい行を追記したとき、曖昧な書き方 +//! (裸のファイル名 / 引用符の無い成果物) が混ざれば、後続の後始末機構はその順位を +//! 「検証不能」として扱い続ける。無言で自動化の対象外が増えると「自動化したのに半分手作業」へ寄る。 +//! +//! そこで **push 時と CI で毎回、実台帳の全行を parse し直す**。書式を外した行を足した時点で +//! 赤くなるので、書いた人へ即座に返る (`.claude/custom-lint-rules.toml` を実読する +//! `rule_test_coverage_check` / `orphan_fixture_check` と同じ形)。 +//! +//! # なぜ統合テストではなくクレート内 `#[cfg(test)]` なのか +//! +//! 表の解釈は [`super::split_cells`] と [`super::resolve_target_files_column`] が持つが、 +//! どちらも private である。統合テスト (`tests/`) から使うには公開するしかなく、初版は +//! 手で再実装して**本体から静かに乖離した** — 末尾エスケープの取りこぼしと、あいまい列を +//! 黙って先頭採用する差 (SIM-NEW-lib-ledger-deployed_ledger-L53)。#394 型の見逃しを捕まえる +//! ための検査が、自分の側で見逃す形になっていた。 +//! +//! かといってテスト都合で `pub` を足すと、本 crate の公開面が恒久的に広がる。ここは夜間ループの +//! 「何を実装してよいか」を決める入口であり、`Cargo.toml` が依存を足さない理由と同じ論理で +//! **表面も絞る**。クレート内 `#[cfg(test)]` なら private のまま同じ関数を共有でき、公開面は +//! 増えない。 + +use std::collections::BTreeSet; +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") +} + +fn ledger_path() -> PathBuf { + repo_root().join("docs").join("claude-code-web-tasks.md") +} + +fn read_ledger() -> String { + let path = ledger_path(); + std::fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "台帳を読めません ({}): {e} (false-green guard: 読めないまま緑にしない)", + path.display() + ) + }) +} + +/// 無人可 列を持つ表 (= 選択対象のタスク表) のデータ行から「対象ファイル」セルを取り出す。 +/// +/// セル分解と列解決は本体の関数をそのまま使う。あいまい列は本体と同じくエラーへ倒す — +/// ここで先頭を黙って採用すると、実際の [`super::select`] がエラーになる台帳を検査だけが +/// 通してしまう。 +fn target_file_cells(markdown: &str) -> Vec<(u32, String)> { + let mut cells = Vec::new(); + let mut columns: Option<(usize, usize)> = None; + for line in markdown.lines() { + if !line.trim_start().starts_with('|') { + columns = None; + continue; + } + let split = super::split_cells(line); + if split.iter().any(|c| c == "無人可") { + columns = Some(header_columns_for_check(&split)); + continue; + } + let Some((rank_idx, target_idx)) = columns else { + continue; + }; + let Some(rank) = parse_rank(&split, rank_idx) else { + continue; + }; + assert!( + split.len() > target_idx, + "順位 {rank} の行に 対象ファイル 列がありません (必要 {} 列、実際 {} 列)。\ + 実台帳の select() も同じ入力で「列数が足りません」で失敗する — \ + ここで読み飛ばすと、その行だけが書式検査からも順位重複検査からも外れる", + target_idx + 1, + split.len() + ); + cells.push((rank, split[target_idx].clone())); + } + cells +} + +/// データ行の順位を読む。`None` は「データ行ではない」(区切り行 `|---|` など) の意味で、 +/// 列不足とは区別する — 前者は読み飛ばしてよく、後者は検査の穴になる。 +fn parse_rank(split: &[String], rank_idx: usize) -> Option { + split.get(rank_idx)?.parse::().ok() +} + +fn header_columns_for_check(split: &[String]) -> (usize, usize) { + let Some(rank) = split.iter().position(|c| c == "順位") else { + panic!("無人可 列を持つ表に 順位 列がありません (実台帳の select() も同じ入力で失敗する)") + }; + let target = super::resolve_target_files_column(split).unwrap_or_else(|message| { + panic!("対象ファイル 列を解決できません (実台帳の select() も同じ入力で失敗する): {message}") + }); + (rank, target) +} + +#[test] +fn every_target_files_cell_in_the_deployed_ledger_is_machine_readable() { + let markdown = read_ledger(); + let cells = target_file_cells(&markdown); + assert!( + !cells.is_empty(), + "無人可 列を持つ表からデータ行が 1 件も取れませんでした — false-green guard \ + (台帳の表構成が変わった可能性)" + ); + + let mut failures: Vec = Vec::new(); + for (rank, cell) in &cells { + if let Err(message) = super::parse_target_files(cell) { + failures.push(format!("順位 {rank}: {message}")); + } + } + assert!( + failures.is_empty(), + "台帳の「対象ファイル」セルが機械可読の契約を満たしていません ({} 件):\n - {}\n\n\ + 書式: 注釈 (丸括弧) を除いた本体は、リポジトリ相対パスのバッククォート引用と `+` のみ。\n\ + 成果物はすべてバッククォートで囲み、`main.rs` のような裸のファイル名ではなく\n\ + `src//src/main.rs` と書くこと。", + failures.len(), + failures.join("\n - ") + ); +} + +/// 順位の重複が無いことも同時に固定する。重複したまま後始末が走ると、どちらの行を +/// 消すべきか決まらない。 +#[test] +fn deployed_ledger_task_ranks_are_unique() { + let markdown = read_ledger(); + let cells = target_file_cells(&markdown); + let mut seen = BTreeSet::new(); + let mut duplicates = Vec::new(); + for (rank, _) in &cells { + if !seen.insert(*rank) { + duplicates.push(*rank); + } + } + assert!( + duplicates.is_empty(), + "タスク表に重複した順位があります: {duplicates:?}" + ); +} diff --git a/src/lib-ledger/src/lib.rs b/src/lib-ledger/src/lib.rs index 87c8236..aec6594 100644 --- a/src/lib-ledger/src/lib.rs +++ b/src/lib-ledger/src/lib.rs @@ -19,10 +19,14 @@ use std::collections::{BTreeMap, BTreeSet}; +#[cfg(test)] +mod deployed_ledger; mod screening; +mod target_files; 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; /// 無人可を表すマーク。台帳の表記と一致させる。 const MARK_AUTONOMOUS: &str = "✅"; diff --git a/src/lib-ledger/src/target_files.rs b/src/lib-ledger/src/target_files.rs new file mode 100644 index 0000000..c75b9d5 --- /dev/null +++ b/src/lib-ledger/src/target_files.rs @@ -0,0 +1,339 @@ +//! 台帳の「対象ファイル」セルを機械可読なパス集合へ解釈する層。 +//! +//! # なぜ厳格な書式を要求するのか +//! +//! 後続の後始末機構は「宣言された成果物がすべて変更されたか」で完了を判定する。判定材料が +//! 曖昧なままだと、**一部の成果物だけで完了と誤判定する**。実際に夜間 PR +//! [#394](https://github.com/aloekun/claude-code-hook-test/pull/394) は lint rule の fixture +//! だけを追加して CI green でマージされ、rule 本体が無いまま完了扱いになりかけた。 +//! +//! 2026-08-14 に現行 10 行を実査したところ、次の 2 型が機械照合に耐えなかった: +//! +//! - **裸のファイル名**: `` `main.rs` `` だけが書かれ、どの crate の main.rs か台帳から決まらない +//! - **引用符の無い成果物**: 「+ fixtures」のような散文。抽出できないため、**その成果物が +//! 欠けていても検証を通過する** (= #394 と同じ失敗モード) +//! +//! そこで「注釈を除いた本体は、リポジトリ相対パスのバッククォート引用と `+` だけ」を +//! 契約とし、外れたセルは解釈せず [`Err`] にする。曖昧さを許して読み飛ばすと、 +//! 通過してはいけない実装が通過する側へ倒れる ([ADR-043](../../../docs/adr/adr-043-security-gates-fail-closed.md))。 +//! +//! # 書式 +//! +//! ```text +//! `src/a/b.rs` + `docs/c.md`(注釈は自由記述)+ `tests/fixtures/{bad,good}/` +//! ``` +//! +//! - 丸括弧 (全角『()』/ 半角) の中は**注釈**として全体を無視する。バッククォートを +//! 含んでもよい (例: 順位 284 の 「(`mod tests`、既存 `xxx` 拡張)」) +//! - 注釈を除いた本体に現れてよいのは、バッククォート引用・`+`・空白のみ +//! - 引用の中身はリポジトリ相対パス。先頭セグメントは [`ALLOWED_ROOTS`] のいずれか +//! - `{a,b}` は展開する。展開結果すべてが要求対象になる + +use std::collections::BTreeSet; + +/// リポジトリ相対パスとして認める先頭セグメント。 +/// +/// 裸のファイル名 (`main.rs`) を弾くための allowlist。ここに無いパスは「どこの何か +/// 決まらない」とみなしてエラーにする。リポジトリに新しい top-level ディレクトリを +/// 足したらここへ追加する。 +const ALLOWED_ROOTS: &[&str] = &[ + ".claude", + ".github", + ".takt", + "docs", + "scripts", + "src", + "templates", + "tests", +]; + +/// 「対象ファイル」セルからリポジトリ相対パスを取り出す。 +/// +/// 戻り値は重複を畳んだ昇順。`Err` は「このセルは機械照合に使えない」の意味で、 +/// 呼び手は該当順位の自動削除を見送って人間へ回すこと。 +pub fn parse_target_files(cell: &str) -> Result, String> { + let body = strip_annotations(cell)?; + let quoted = extract_quoted_spans(&body)?; + if quoted.is_empty() { + return Err("パスが 1 件も宣言されていません".to_string()); + } + let mut paths = BTreeSet::new(); + for span in quoted { + for path in expand_braces(&span)? { + validate_path(&path)?; + paths.insert(path); + } + } + Ok(paths.into_iter().collect()) +} + +/// 丸括弧の注釈を落とす。全角『()』と半角 `()` の両方を扱う。 +/// +/// 対応が取れない括弧はエラーにする。閉じ忘れを黙って許すと、以降のセル全体が注釈として +/// 消え「パス 0 件」になり、**宣言が空 = 何も要求しない**という最も緩い判定へ倒れる。 +fn strip_annotations(cell: &str) -> Result { + let mut out = String::new(); + let mut depth = 0usize; + for ch in cell.chars() { + match ch { + '(' | '(' => depth += 1, + ')' | ')' => { + depth = depth + .checked_sub(1) + .ok_or_else(|| format!("閉じ括弧が余分です: {cell:?}"))?; + } + _ if depth == 0 => out.push(ch), + _ => {} + } + } + if depth != 0 { + return Err(format!("括弧が閉じていません: {cell:?}")); + } + Ok(out) +} + +/// 本体からバッククォート引用を取り出す。 +/// +/// 引用の外に空白と `+` 以外が現れたらエラー。これが「引用符の無い成果物」(順位 334 の +/// 「+ fixtures」) を検出する唯一の砦で、ここを緩めると宣言漏れが素通りする。 +fn extract_quoted_spans(body: &str) -> Result, String> { + let mut spans = Vec::new(); + let mut current = String::new(); + let mut in_quote = false; + let mut separated = true; + for ch in body.chars() { + if ch == '`' { + if in_quote { + spans.push(std::mem::take(&mut current)); + separated = false; + } else if !separated { + return Err(format!( + "成果物どうしは `+` で区切ってください (空白や連結だけでは不可): {body:?}" + )); + } + in_quote = !in_quote; + continue; + } + if in_quote { + current.push(ch); + } else if ch == '+' { + separated = true; + } else if !ch.is_whitespace() { + return Err(format!( + "引用の外に文字 {ch:?} があります (成果物はすべてバッククォートで囲むこと): {body:?}" + )); + } + } + if in_quote { + return Err(format!("バッククォートが閉じていません: {body:?}")); + } + Ok(spans) +} + +/// `{a,b}` を展開する。入れ子は扱わない (台帳に現れず、許すと組合せ爆発の判断が要る)。 +fn expand_braces(span: &str) -> Result, String> { + let Some(open) = span.find('{') else { + if span.contains('}') { + return Err(format!("`}}` に対応する `{{` がありません: {span:?}")); + } + return Ok(vec![span.to_string()]); + }; + let close = span + .find('}') + .ok_or_else(|| format!("`{{` が閉じていません: {span:?}"))?; + if close < open { + return Err(format!("`{{` と `}}` の順序が逆です: {span:?}")); + } + let head = &span[..open]; + let tail = &span[close + 1..]; + if tail.contains('{') || tail.contains('}') { + return Err(format!("展開記法が 2 組以上あります: {span:?}")); + } + let inner = &span[open + 1..close]; + if inner.contains('{') { + return Err(format!("展開記法が入れ子になっています: {span:?}")); + } + let alternatives: Vec<&str> = inner.split(',').map(str::trim).collect(); + if alternatives.iter().any(|a| a.is_empty()) { + return Err(format!("展開記法に空の要素があります: {span:?}")); + } + Ok(alternatives + .into_iter() + .map(|a| format!("{head}{a}{tail}")) + .collect()) +} + +/// リポジトリ相対パスとして妥当か検査する。 +fn validate_path(path: &str) -> Result<(), String> { + if path.is_empty() { + return Err("空のパスがあります".to_string()); + } + if path.contains('\\') { + return Err(format!( + "パス区切りは `/` に統一してください (Windows 形式は不可): {path:?}" + )); + } + if path.starts_with('/') { + return Err(format!("絶対パスは使えません: {path:?}")); + } + if path.split('/').any(|seg| seg == "..") { + return Err(format!("`..` を含むパスは使えません: {path:?}")); + } + let root = path.split('/').next().unwrap_or_default(); + if !ALLOWED_ROOTS.contains(&root) { + return Err(format!( + "リポジトリ相対パスではありません (先頭が {ALLOWED_ROOTS:?} のいずれでもない): {path:?}" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ok(cell: &str) -> Vec { + parse_target_files(cell).unwrap_or_else(|e| panic!("{cell:?} が解釈できない: {e}")) + } + + #[test] + fn single_path() { + assert_eq!(ok("`src/a/b.rs`"), vec!["src/a/b.rs"]); + } + + #[test] + fn multiple_paths_joined_by_plus() { + assert_eq!( + ok("`src/a.rs` + `docs/b.md`"), + vec!["docs/b.md", "src/a.rs"], + "戻り値は昇順" + ); + } + + /// 注釈は括弧ごと落ちる。中にバッククォートがあっても宣言として数えない + /// (順位 284 の「(`mod tests`、既存 `xxx` 拡張)」が実例)。 + #[test] + fn annotations_are_ignored_even_when_they_contain_backticks() { + assert_eq!( + ok("`src/a.rs`(`mod tests`、既存 `some_test_fn` 拡張)"), + vec!["src/a.rs"] + ); + } + + #[test] + fn half_width_parentheses_are_also_annotations() { + assert_eq!(ok("`src/a.rs`(60・68 行)"), vec!["src/a.rs"]); + } + + /// 実データ: 順位 340 のファイル名途中での展開。 + #[test] + fn brace_expansion_inside_a_file_name() { + assert_eq!( + ok("`src/check-ci-coderabbit/src/{decide,main}.rs`"), + vec![ + "src/check-ci-coderabbit/src/decide.rs", + "src/check-ci-coderabbit/src/main.rs" + ] + ); + } + + /// 実データ: 順位 334 の正規化後の形 (ディレクトリ末尾 + 展開)。 + #[test] + fn brace_expansion_on_directories() { + assert_eq!( + ok("`tests/fixtures/incidents/{bad,good}/`"), + vec![ + "tests/fixtures/incidents/bad/", + "tests/fixtures/incidents/good/" + ] + ); + } + + /// **本 module の存在理由**: 引用符の無い成果物を宣言漏れとして検出する。 + /// これを許すと「+ fixtures」が消え、fixture 無しの実装が完了判定を通る (#394 型)。 + #[test] + fn unquoted_artifact_is_an_error() { + let error = parse_target_files("`.claude/custom-lint-rules.toml` + fixtures") + .expect_err("引用符の無い成果物は弾く"); + assert!(error.contains("引用の外"), "{error}"); + } + + /// 裸のファイル名はどのディレクトリか決まらない (順位 272 / 179 の正規化前の形)。 + #[test] + fn bare_file_name_is_an_error() { + let error = parse_target_files("`main.rs`").expect_err("裸のファイル名は弾く"); + assert!(error.contains("リポジトリ相対パス"), "{error}"); + } + + #[test] + fn windows_separator_is_an_error() { + assert!(parse_target_files(r"`src\a\b.rs`").is_err()); + } + + #[test] + fn absolute_path_and_parent_traversal_are_errors() { + assert!(parse_target_files("`/etc/passwd`").is_err()); + assert!(parse_target_files("`src/../../etc/passwd`").is_err()); + } + + /// 宣言 0 件は「何も要求しない」= 最も緩い判定になるため、空セルはエラー。 + #[test] + fn empty_declaration_is_an_error() { + assert!(parse_target_files("").is_err()); + assert!(parse_target_files("(注釈だけ)").is_err()); + } + + #[test] + fn unbalanced_delimiters_are_errors() { + assert!(strip_annotations("`a`(閉じない").is_err()); + assert!(strip_annotations("`a`)余分").is_err()); + assert!(parse_target_files("`src/a.rs").is_err(), "引用が閉じない"); + assert!(parse_target_files("`src/{a.rs`").is_err(), "展開が閉じない"); + assert!(parse_target_files("`src/a},b.rs`").is_err(), "対応しない閉じ括弧"); + } + + #[test] + fn nested_or_multiple_brace_groups_are_errors() { + assert!(parse_target_files("`src/{a,b}/{c,d}.rs`").is_err()); + } + + /// 入れ子の `{` は展開部の中に残り、`src/{b.rs` という壊れたパスを生む。 + /// `validate_path` は `{` を見ないため、そのまま「変更されていない成果物」として + /// 要求され続け、原因が読み取れない失敗になる。展開の時点で止める。 + #[test] + fn brace_group_containing_another_open_brace_is_an_error() { + let error = parse_target_files("`src/{a,{b}.rs`").expect_err("入れ子は弾く"); + assert!(error.contains("入れ子"), "{error}"); + } + + /// 契約は「複数の成果物は `+` で並べる」。空白区切り・連結を受理していると、 + /// 実装が文書より緩い状態になり、書式の説明が事実と食い違う。 + #[test] + fn adjacent_quotes_without_a_plus_separator_are_errors() { + for body in ["`src/a.rs` `docs/b.md`", "`src/a.rs``docs/b.md`"] { + let error = parse_target_files(body).expect_err("{body:?} は `+` 区切りが要る"); + assert!(error.contains("`+` で区切って"), "{body:?}: {error}"); + } + } + + /// `+` の前後の空白は自由。区切りが在ることだけを要求する。 + #[test] + fn plus_separator_tolerates_surrounding_whitespace() { + assert_eq!( + ok("`src/a.rs`+`docs/b.md`"), + vec!["docs/b.md", "src/a.rs"], + "空白なしの `+` も受理する" + ); + } + + #[test] + fn empty_brace_alternative_is_an_error() { + assert!(parse_target_files("`src/{a,}.rs`").is_err()); + } + + /// 同じパスを 2 回宣言しても要求は 1 件。 + #[test] + fn duplicate_paths_are_folded() { + assert_eq!(ok("`src/a.rs` + `src/a.rs`"), vec!["src/a.rs"]); + } +}