From c5b5ad7fff8928aafc52f5d233b252177dbcfaa5 Mon Sep 17 00:00:00 2001 From: aloekun Date: Tue, 19 May 2026 14:34:07 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(lint):=20rule-test=20coverage=20check?= =?UTF-8?q?=20cargo=20test=20+=20global=20testing.md=20codify=20(Bundle=20?= =?UTF-8?q?137+138,=20TOML=20meta=20field=20=E6=96=B9=E5=BC=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/hooks-post-tool-linter/src/main.rs: - CustomRule に test_coverage: Option field 追加 - rule_test_coverage_check cargo test 新規 (helper 4 関数に分割) - load_deployed_custom_rules / extract_existing_test_fn_names - classify_rule_extensions / check_main_ext_coverage / check_other_ext_coverage / check_main_ext_keys_sanity - collect_rule_coverage_gaps - test gap 補填 (合計 +7 tests, 121 → 128): - rule② (no-personal-paths): positive×2 + negative×1 (元 0 tests, 配布後初の検証) - rule⑥ (no-ephemeral-todo-reference) yaml: positive×1 + negative×1 - rule⑥ (no-ephemeral-todo-reference) yml: positive×1 - .claude/custom-lint-rules.toml: - 全 10 rule に [rules.test_coverage] meta field を宣言 - 主要拡張子 (rs/toml/yaml/yml) を targets する rule は main_ext_tests. で各 ext に 1+ test を明示 - 非主要専用 rule は other_ext_tests でカバレッジ宣言 - ~/.claude/rules/common/testing.md (グローバル): - 新 section 'Custom Lint Rule Test Coverage' を追加 - 命名規約 vs TOML meta field の trade-off と採用根拠 (案 b) を codify - 派生プロジェクト (techbook-ledger / auto-review-fix-vc) へ自動波及 順位 137 + 138 (PR #163 post-merge-feedback T1-#1 + T3-#1 採用)。命名規約に依存しない明示的 mapping を採用したのは AI 生成の揺らぎを構造的に抑止する意図 (ユーザー指示)。 --- .claude/custom-lint-rules.toml | 124 +++++++++ src/hooks-post-tool-linter/src/main.rs | 331 +++++++++++++++++++++++++ 2 files changed, 455 insertions(+) diff --git a/.claude/custom-lint-rules.toml b/.claude/custom-lint-rules.toml index 242e6e0..2f6f785 100644 --- a/.claude/custom-lint-rules.toml +++ b/.claude/custom-lint-rules.toml @@ -46,6 +46,11 @@ steps = [ bad = "console.log('user data:', userData);" good = "logger.debug('user data:', userData);" +[rules.test_coverage] +# rule① は ts/tsx/js/jsx (= 全て非主要拡張子) のため other_ext_tests のみ。 +# 順位 137 の必須カバレッジは「rule あたり 1+ positive test」(主要拡張子 = rs/toml/yaml/yml)。 +other_ext_tests = ["run_custom_rules_detects_console_log"] + # ─── ルール②: 個人を特定する絶対パス禁止 (PII) ─── # # 由来: PR #75 (post-merge-feedback) で 3 箇所同時発生 (ADR-030 line 143、todo.md line 35/48)、 @@ -77,6 +82,14 @@ steps = [ bad = '具体的なファイル所在: `C:\Users\alice\.claude\projects\\` 配下' good = '具体的なファイル所在: `%USERPROFILE%\.claude\projects\\` 配下' +[rules.test_coverage] +# rule② は md/txt (= 全て非主要拡張子)。順位 137 PR で test gap 補填 (3 tests 新規追加)。 +other_ext_tests = [ + "no_personal_paths_detects_windows_user_path_in_md", + "no_personal_paths_detects_unix_home_path_in_txt", + "no_personal_paths_skips_placeholder_paths", +] + # ─── ルール③: PowerShell 空 catch ブロック禁止 (swallowed error) ─── # # 由来: PR #85 で `__parse_transcripts.ps1:8` の空 `catch {}` が CodeRabbit Major 指摘。 @@ -108,6 +121,18 @@ steps = [ bad = 'try { Get-Item $path } catch {}' good = 'try { Get-Item $path } catch { Write-Verbose "expected miss: $_"; $null }' +[rules.test_coverage] +# rule③ は ps1 のみ (非主要拡張子)。複数 case-variant の安定性 seal。 +other_ext_tests = [ + "ps_empty_catch_detects_violation", + "ps_empty_catch_detects_with_internal_whitespace", + "ps_empty_catch_skips_non_empty_block", + "ps_empty_catch_only_targets_ps1", + "ps_empty_catch_detects_capitalized_keyword", + "ps_empty_catch_detects_uppercase_keyword", + "ps_empty_catch_detects_multiline_block", +] + # ─── ルール④: PowerShell -ErrorAction SilentlyContinue 警告 ─── # # 由来: 同 PR #85。空 catch との組合せで二重に swallowed error を生む。 @@ -136,6 +161,16 @@ steps = [ bad = '$data = ConvertFrom-Json $raw -ErrorAction SilentlyContinue' good = 'try { $data = ConvertFrom-Json $raw -ErrorAction Stop } catch { Write-Error "Invalid JSON: $_"; throw }' +[rules.test_coverage] +# rule④ は ps1 のみ (非主要拡張子)。PowerShell case-insensitive variant の安定性 seal。 +other_ext_tests = [ + "ps_silent_error_detects_basic_form", + "ps_silent_error_skips_stop_action", + "ps_silent_error_skips_ignore_action", + "ps_silent_error_detects_lowercase_param", + "ps_silent_error_detects_mixed_case", +] + # ─── ルール⑤: Markdown 非 ASCII GFM アンカー検出 (mutable anchor) ─── # # 由来: PR #89 で CodeRabbit が日本語 heading への GFM 自動 anchor link を Major 指摘。 @@ -172,6 +207,17 @@ steps = [ bad = 'See [推奨実行順序](todo-summary.md#推奨実行順序サマリー)' good = 'See [推奨実行順序](todo-summary.md#recommended-order-summary) ' +[rules.test_coverage] +# rule⑤ は md のみ (非主要拡張子)。GFM anchor の positive / negative variant を網羅。 +other_ext_tests = [ + "md_mutable_anchor_detects_inline_fragment", + "md_mutable_anchor_detects_path_with_fragment", + "md_mutable_anchor_skips_ascii_fragment", + "md_mutable_anchor_skips_link_without_fragment", + "md_mutable_anchor_skips_path_only_link", + "md_mutable_anchor_skips_external_url_with_fragment", +] + # ─── ルール⑥: 非 docs ファイルからの ephemeral todo 参照禁止 ─── # # 由来: PR #94 で 3 種類のファイル (Rust raw string literal / TOML config comment / @@ -219,6 +265,29 @@ steps = [ bad = 'BLOCK_MESSAGE: &str = "詳細: docs/todoN.md (N = 数字) の \"
\" 参照";' good = 'BLOCK_MESSAGE: &str = "詳細: docs/adr/adr-NNN-feature.md 参照";' +[rules.test_coverage] +# rule⑥ は主要拡張子 4 つ (rs/toml/yaml/yml) + 非主要 8 つを extensions に含む。 +# 順位 137 PR で yaml/yml の test gap を補填 (positive 2 + negative 1 新規追加)。 +other_ext_tests = ["no_ephemeral_todo_only_targets_listed_extensions_md_skipped"] + +[rules.test_coverage.main_ext_tests] +rs = [ + "no_ephemeral_todo_detects_concrete_digit_reference", + "no_ephemeral_todo_detects_zero_digit_form", + "no_ephemeral_todo_skips_letter_placeholder", + "no_ephemeral_todo_skips_asterisk_literal", +] +toml = [ + "no_ephemeral_todo_detects_toml_ephemeral_reference", + "no_ephemeral_todo_toml_skips_permanent_adr_reference", + "no_ephemeral_todo_self_exclusion_invariant_holds_on_deployed_toml", +] +yaml = [ + "no_ephemeral_todo_detects_yaml_ephemeral_reference", + "no_ephemeral_todo_yaml_skips_permanent_adr_reference", +] +yml = ["no_ephemeral_todo_detects_yml_ephemeral_reference"] + # ─── ルール⑦: Rust 時刻フィールドの strict `>` 比較禁止 (boundary inconsistency 防止) ─── # # 由来: PR #101 で `parse_listed_findings` の `c.created_at > push_time` が CodeRabbit @@ -256,6 +325,23 @@ steps = [ bad = "comments.iter().filter(|c| c.created_at > push_time)" good = "comments.iter().filter(|c| c.created_at >= push_time)" +[rules.test_coverage] +# rule⑦ は rs のみ (主要拡張子)。時刻フィールド variant + 境界 case の網羅的 seal。 + +[rules.test_coverage.main_ext_tests] +rs = [ + "rs_time_field_strict_greater_detects_created_at_gt_push_time", + "rs_time_field_strict_greater_detects_submitted_at_gt_since", + "rs_time_field_strict_greater_detects_updated_at_gt_threshold", + "rs_time_field_strict_greater_detects_comment_event_time", + "rs_time_field_strict_greater_skips_inclusive_comparison", + "rs_time_field_strict_greater_skips_strict_less_than", + "rs_time_field_strict_greater_skips_le_inclusive", + "rs_time_field_strict_greater_skips_numeric_rhs", + "rs_time_field_strict_greater_skips_doc_comment_with_inclusive", + "rs_time_field_strict_greater_skips_unrelated_field", +] + # ─── ルール⑧: docs/ 内 Markdown の `../docs/` 相対パストラップ検出 ─── # # 由来: PR #133 (50KB 分割系列、Bundle j-1 = 順位 94 採用) で @@ -306,6 +392,17 @@ steps = [ bad = "[ADR-036](DOTDOT/docs/adr/adr-036-...) " good = "[ADR-036](adr/adr-036-...)" +[rules.test_coverage] +# rule⑧ は md のみ (非主要拡張子)。docs 配下 + root-level 両方の back-reference を網羅。 +other_ext_tests = [ + "md_no_docs_relative_detects_pr133_pattern", + "md_no_docs_relative_detects_uppercase_path", + "md_no_docs_relative_skips_same_directory_link", + "md_no_docs_relative_skips_parent_to_other_dir", + "md_no_docs_relative_detects_root_level_back_reference", + "md_no_docs_relative_detects_root_readme_back_reference", +] + # ─── ルール⑨: takt workflow yaml で persona: を持つ step に model: 必須 ─── # # 由来: Bundle Y2 (PR #98) で post-pr-review.yaml supervise step に model: が @@ -352,6 +449,19 @@ good = ''' persona: supervisor model: sonnet instruction: loop-monitor-reviewers-fix''' +[rules.test_coverage] +# rule⑨ は yaml のみ (主要拡張子)。multi-line regex variant + paths filter は別 test で網羅。 + +[rules.test_coverage.main_ext_tests] +yaml = [ + "takt_workflow_persona_detects_judge_block_violation", + "takt_workflow_persona_detects_supervise_step_violation", + "takt_workflow_persona_skips_when_model_directly_follows", + "takt_workflow_persona_detects_multiple_violations_in_same_file", + "takt_workflow_persona_detects_required_permission_mode_violation", + "takt_workflow_persona_skips_non_yaml_extension", +] + # ─── ルール⑩: `let _ = write_*` swallowed error 検出 (silent failure 防止) ─── # # 由来: PR #155 simplicity-review が BLOCKING 指摘した `write_skip_report` の Result を @@ -389,3 +499,17 @@ steps = [ [rules.example] bad = "let _ = write_state(&state);" good = "if let Err(e) = write_state(&state) { log_warn(&format!(\"state write failed: {}\", e)); }" + +[rules.test_coverage] +# rule⑩ は rs のみ (主要拡張子)。Drop / if-let-Err / named-binding 等の variant を網羅。 + +[rules.test_coverage.main_ext_tests] +rs = [ + "no_write_result_discard_detects_simple_let_underscore", + "no_write_result_discard_detects_write_skip_report_pattern", + "no_write_result_discard_detects_write_failed_marker_in_drop", + "no_write_result_discard_skips_proper_if_let_err_pattern", + "no_write_result_discard_skips_non_write_prefix_calls", + "no_write_result_discard_skips_named_binding_starting_with_underscore", + "no_write_result_discard_only_targets_rust_extension", +] diff --git a/src/hooks-post-tool-linter/src/main.rs b/src/hooks-post-tool-linter/src/main.rs index 9bd11f3..6fb0903 100644 --- a/src/hooks-post-tool-linter/src/main.rs +++ b/src/hooks-post-tool-linter/src/main.rs @@ -89,6 +89,7 @@ struct CustomRulesConfig { /// | `paths` | optional | glob pattern による file path filter (順位 102 land 済)。指定時は `extensions` との **AND** 結合で評価。例: `paths = ["docs/**/*.md"]` で docs/ 配下のみ対象。未指定 (None) または空配列は「path filter なし」(= `extensions` のみで判定) | /// | `fix` | optional | `CustomRuleFix` (strategy + steps) | /// | `example` | optional | `CustomRuleExample` (bad + good) | +/// | `test_coverage` | optional | `CustomRuleTestCoverage`。rule が targets する main ext (`rs` / `toml` / `yaml` / `yml`) ごとに対応 test 関数名を明示宣言する meta field (順位 137 land 済)。`rule_test_coverage_check` cargo test が deploy 済 TOML を読み、宣言された test 関数の存在 + 必須カバレッジ (main ext ごとに 1+ test、非 main 専用 rule には other_ext_tests 1+) を機械検証する | /// /// **glob syntax** (`globset` crate 準拠): /// @@ -113,6 +114,9 @@ struct CustomRule { paths: Option>, fix: Option, example: Option, + #[serde(default)] + #[allow(dead_code)] + test_coverage: Option, } #[derive(Deserialize, Clone)] @@ -127,6 +131,30 @@ struct CustomRuleExample { good: String, } +/// `[rules.test_coverage]` meta field。順位 137 (PR #163 T1-#1 採用) で導入。 +/// +/// 各 rule が「主要拡張子 (`rs` / `toml` / `yaml` / `yml`) のうち targets するもの」に対して +/// **少なくとも 1 個の対応 test 関数** を明示宣言する。`rule_test_coverage_check` cargo test が +/// deploy 済 `.claude/custom-lint-rules.toml` を読んで、宣言された test 関数が `main.rs` に +/// 存在することと、必須カバレッジ (main ext ごとに 1+ test、非 main 専用 rule には +/// `other_ext_tests` 1+) を機械検証する。 +/// +/// 命名規約に依存しない明示的 mapping を採用 (= 案 b、TOML meta field 方式) することで、 +/// `ps_empty_catch_*` / `md_mutable_anchor_*` / `no_ephemeral_todo_*` 等の **異なる命名 +/// 規約が混在する既存テスト** を rule_id とは独立に対応付けできる。 +#[derive(Deserialize, Clone, Default, Debug)] +#[allow(dead_code)] +struct CustomRuleTestCoverage { + /// 主要拡張子 (`rs` / `toml` / `yaml` / `yml`) → 対応 test 関数名の list。 + /// rule の `extensions` に含まれる主要拡張子について、各 ext に 1 件以上の test を必須化。 + #[serde(default)] + main_ext_tests: std::collections::BTreeMap>, + /// 主要拡張子以外 (`md` / `txt` / `ts` / `js` / `py` / `ps1` 等) の対応 test 関数名 list。 + /// rule が主要拡張子を targets しない場合に限り、1 件以上の positive test を必須化。 + #[serde(default)] + other_ext_tests: Vec, +} + // --- カスタムルール構造化出力 (additionalContext 用) --- #[derive(Serialize)] @@ -867,6 +895,7 @@ mod tests { bad: "bad code".into(), good: "good code".into(), }), + test_coverage: None, } } @@ -1377,6 +1406,63 @@ extensions = ["ts", "js"] assert_eq!(rules[0].why, ""); } + fn no_personal_paths_rule() -> CustomRule { + make_test_rule( + "no-personal-paths", + r"C:\\Users\\[A-Za-z][A-Za-z0-9_-]+\\|/home/[a-z][a-z0-9_-]+/", + &["md", "txt"], + ) + } + + /// 順位 137 (PR #163 T1-#1 採用、test gap 補填): rule② に対する positive test が + /// 不在だった (= 配布後 1 度も検証されていない rule)。Windows path で fire することを seal。 + #[test] + fn no_personal_paths_detects_windows_user_path_in_md() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "guide.md", + "Path: `C:\\Users\\alice\\.claude\\projects\\foo` is the location\n", + ); + let rules = compile_test_rules(vec![no_personal_paths_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!(violations.len(), 1); + } + + /// 順位 137 (PR #163 T1-#1 採用、test gap 補填): rule② が Unix 側 (/home//) でも fire し、 + /// .txt ファイルでも機能することを seal。 + #[test] + fn no_personal_paths_detects_unix_home_path_in_txt() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "notes.txt", + "Run from /home/bob/projects/foo to start\n", + ); + let rules = compile_test_rules(vec![no_personal_paths_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!(violations.len(), 1); + } + + /// 順位 137 補完: placeholder 表記 (`%USERPROFILE%` / `` / `~`) は fire しない + /// negative test。placeholder 検出回避戦略 (TOML rule② コメント参照: 開始文字 class で除外) を seal。 + #[test] + fn no_personal_paths_skips_placeholder_paths() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "doc.md", + "Use `%USERPROFILE%\\.claude\\` or `/.claude/` or `~/.claude/` paths\n", + ); + let rules = compile_test_rules(vec![no_personal_paths_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert!( + violations.is_empty(), + "rule② should NOT fire on placeholder paths (got {} violations)", + violations.len() + ); + } + // --- 新規ルール: PowerShell 空 catch ブロック (no-empty-powershell-catch) --- fn ps_empty_catch_rule() -> CustomRule { @@ -2033,6 +2119,65 @@ extensions = ["ts", "js"] ); } + /// 順位 137 (PR #163 T1-#1 採用、test gap 補填): YAML 拡張子で rule⑥ が機能することを seal。 + /// extensions = [..., "yaml", ...] は PR #110 で追加されたが対応する positive test は + /// 不在だった (= 主要拡張子に対する test gap)。本 test で将来 extensions から "yaml" を + /// 誤削除した場合に test fail で検出する safety net を確保。 + #[test] + fn no_ephemeral_todo_detects_yaml_ephemeral_reference() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "workflow.yaml", + &build_concrete_digit_fixture(3), + ); + let rules = compile_test_rules(vec![no_ephemeral_todo_reference_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!( + violations.len(), + 1, + "rule⑥ should fire on YAML file with ephemeral todo reference" + ); + } + + /// 順位 137 補完: YAML 拡張子でも permanent ADR 参照は fire しない negative test。 + #[test] + fn no_ephemeral_todo_yaml_skips_permanent_adr_reference() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "workflow.yaml", + "description: see docs/adr/adr-007-foo.md for context\n", + ); + let rules = compile_test_rules(vec![no_ephemeral_todo_reference_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert!( + violations.is_empty(), + "rule⑥ should NOT fire on YAML file with permanent ADR reference (got {} violations)", + violations.len() + ); + } + + /// 順位 137 (PR #163 T1-#1 採用、test gap 補填): YML 拡張子で rule⑥ が機能することを seal。 + /// extensions に "yml" を含む rule が "yaml" と独立に test されていなかったため、 + /// 主要拡張子のカバレッジ網羅としての positive test を確保。 + #[test] + fn no_ephemeral_todo_detects_yml_ephemeral_reference() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "config.yml", + &build_concrete_digit_fixture(7), + ); + let rules = compile_test_rules(vec![no_ephemeral_todo_reference_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!( + violations.len(), + 1, + "rule⑥ should fire on YML file with ephemeral todo reference" + ); + } + #[test] fn no_ephemeral_todo_self_exclusion_invariant_holds_on_deployed_toml() { let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -2462,4 +2607,190 @@ extensions = ["ts", "js"] total_violations ); } + + const MAIN_EXTENSIONS: &[&str] = &["rs", "toml", "yaml", "yml"]; + + fn load_deployed_custom_rules() -> Vec { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let toml_path = manifest_dir + .join("..") + .join("..") + .join(".claude") + .join("custom-lint-rules.toml"); + let toml_content = std::fs::read_to_string(&toml_path).unwrap_or_else(|e| { + panic!( + "failed to read deployed custom-lint-rules.toml at {}: {e} \ + (false-green guard: this test would silent-pass on missing file)", + toml_path.display() + ) + }); + let config: CustomRulesConfig = toml::from_str(&toml_content) + .expect("custom-lint-rules.toml must parse"); + let rules = config.rules.unwrap_or_default(); + assert!( + !rules.is_empty(), + "no rules found in deployed custom-lint-rules.toml — false-green guard" + ); + rules + } + + fn extract_existing_test_fn_names() -> std::collections::HashSet { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let main_rs_path = manifest_dir.join("src").join("main.rs"); + let main_rs_content = std::fs::read_to_string(&main_rs_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", main_rs_path.display())); + let fn_regex = regex::Regex::new(r"(?m)\bfn\s+([a-zA-Z_][a-zA-Z_0-9]*)\s*\(").unwrap(); + let existing_fns: std::collections::HashSet = fn_regex + .captures_iter(&main_rs_content) + .map(|cap| cap[1].to_string()) + .collect(); + assert!( + existing_fns.contains("rule_test_coverage_check"), + "false-green guard: fn_regex must find this test itself in main.rs source. \ + existing_fns count = {}", + existing_fns.len() + ); + existing_fns + } + + fn classify_rule_extensions(rule: &CustomRule) -> (Vec<&'static str>, bool) { + let targets_main: Vec<&'static str> = MAIN_EXTENSIONS + .iter() + .filter(|m| rule.extensions.iter().any(|e| e.eq_ignore_ascii_case(m))) + .copied() + .collect(); + let has_non_main_ext = rule + .extensions + .iter() + .any(|e| !MAIN_EXTENSIONS.iter().any(|m| e.eq_ignore_ascii_case(m))); + (targets_main, has_non_main_ext) + } + + fn check_main_ext_coverage( + rule: &CustomRule, + coverage: &CustomRuleTestCoverage, + targets_main: &[&str], + existing_fns: &std::collections::HashSet, + ) -> Vec { + let mut gaps: Vec = Vec::new(); + for main_ext in targets_main { + let tests = coverage.main_ext_tests.get(*main_ext); + let is_empty = tests.map(|v| v.is_empty()).unwrap_or(true); + if is_empty { + gaps.push(format!( + "rule `{}` targets main ext `{}` but `[rules.test_coverage.main_ext_tests].{}` is missing or empty (at least 1 positive test required)", + rule.id, main_ext, main_ext + )); + continue; + } + for test_name in tests.unwrap() { + if !existing_fns.contains(test_name) { + gaps.push(format!( + "rule `{}` declares test `{}` for ext `{}` but no such function exists in main.rs", + rule.id, test_name, main_ext + )); + } + } + } + gaps + } + + fn check_other_ext_coverage( + rule: &CustomRule, + coverage: &CustomRuleTestCoverage, + targets_main_empty: bool, + has_non_main_ext: bool, + existing_fns: &std::collections::HashSet, + ) -> Vec { + let mut gaps: Vec = Vec::new(); + if targets_main_empty && has_non_main_ext && coverage.other_ext_tests.is_empty() { + gaps.push(format!( + "rule `{}` targets only non-main extensions {:?} but `test_coverage.other_ext_tests` is empty (at least 1 positive test required)", + rule.id, rule.extensions + )); + } + for test_name in &coverage.other_ext_tests { + if !existing_fns.contains(test_name) { + gaps.push(format!( + "rule `{}` declares other-ext test `{}` but no such function exists in main.rs", + rule.id, test_name + )); + } + } + gaps + } + + fn check_main_ext_keys_sanity( + rule: &CustomRule, + coverage: &CustomRuleTestCoverage, + ) -> Vec { + let mut gaps: Vec = Vec::new(); + for declared_ext in coverage.main_ext_tests.keys() { + if !MAIN_EXTENSIONS.contains(&declared_ext.as_str()) { + gaps.push(format!( + "rule `{}` declares `main_ext_tests.{}` but `{}` is not in MAIN_EXTENSIONS ({:?}) — use `other_ext_tests` for non-main extensions", + rule.id, declared_ext, declared_ext, MAIN_EXTENSIONS + )); + } + if !rule.extensions.iter().any(|e| e.eq_ignore_ascii_case(declared_ext)) { + gaps.push(format!( + "rule `{}` declares `main_ext_tests.{}` but `{}` is not in rule.extensions {:?}", + rule.id, declared_ext, declared_ext, rule.extensions + )); + } + } + gaps + } + + fn collect_rule_coverage_gaps( + rule: &CustomRule, + existing_fns: &std::collections::HashSet, + ) -> Vec { + let coverage = rule.test_coverage.clone().unwrap_or_default(); + let (targets_main, has_non_main_ext) = classify_rule_extensions(rule); + let mut gaps = check_main_ext_coverage(rule, &coverage, &targets_main, existing_fns); + gaps.extend(check_other_ext_coverage( + rule, + &coverage, + targets_main.is_empty(), + has_non_main_ext, + existing_fns, + )); + gaps.extend(check_main_ext_keys_sanity(rule, &coverage)); + gaps + } + + /// 順位 137 (PR #163 T1-#1 採用): `.claude/custom-lint-rules.toml` の各 rule に対して、 + /// `[rules.test_coverage]` meta field で宣言された対応 test 関数が `main.rs` に存在し、 + /// かつ必須カバレッジ (主要拡張子 ごとに 1+ test、非主要専用 rule には `other_ext_tests` + /// 1+) が満たされていることを機械検証する。 + /// + /// 命名規約に依存しない明示的 mapping (案 b) を採用したため、rule_id と test 関数名の + /// 規約一致は要求しない。代わりに「TOML で宣言された名前が main.rs に実在するか」のみ + /// 検証する (= TOML 内の test 名 typo / test 削除時の orphan mapping も検出される)。 + #[test] + fn rule_test_coverage_check() { + let rules = load_deployed_custom_rules(); + let existing_fns = extract_existing_test_fn_names(); + let rules_with_declared_coverage = + rules.iter().filter(|r| r.test_coverage.is_some()).count(); + let mut gaps: Vec = Vec::new(); + for rule in &rules { + gaps.extend(collect_rule_coverage_gaps(rule, &existing_fns)); + } + assert_eq!( + rules_with_declared_coverage, + rules.len(), + "rules without `[rules.test_coverage]` meta field: {} of {} rules missing — \ + add the meta field to every rule to seal test coverage contract (順位 137)", + rules.len() - rules_with_declared_coverage, + rules.len() + ); + assert!( + gaps.is_empty(), + "rule test coverage gaps detected ({} issue(s)):\n - {}", + gaps.len(), + gaps.join("\n - ") + ); + } } From 4f7e021e6e71ebf2481dab82cf0943a69da443c9 Mon Sep 17 00:00:00 2001 From: aloekun Date: Tue, 19 May 2026 14:34:07 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(todo):=20=E9=A0=86=E4=BD=8D=20137=20+?= =?UTF-8?q?=20138=20=E5=AE=8C=E4=BA=86=E3=81=AB=E4=BC=B4=E3=81=84=E5=89=8A?= =?UTF-8?q?=E9=99=A4=20(Bundle=20137+138=20land)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/todo-summary.md: 行 137 + 138 を削除 (table) - docs/todo8.md: 順位 137/138 entry block を削除 (合計 90 行 -) PR 本作業 (@-: feat(lint) commit) で実装 + ドキュメント codify が land したため、todo entries を片付け。 --- docs/todo-summary.md | 2 - docs/todo8.md | 90 -------------------------------------------- 2 files changed, 92 deletions(-) diff --git a/docs/todo-summary.md b/docs/todo-summary.md index 0de6da9..2d0d73a 100644 --- a/docs/todo-summary.md +++ b/docs/todo-summary.md @@ -76,8 +76,6 @@ | 134 | 💎 Tier 3 | **ADR-035 に docs-only PR 評価の適用外基準リスト追加 (mutation / error handling / DRY / YAGNI / function length / test coverage / magic-number 等) (PR #156 T3 #2 採用)** | todo8.md | S | なし (Severity Medium = reviewer の criteria 誤適用による unnecessary review overhead / 開発体験劣化、ADR-035 は分類基準のみ定義済で適用外基準が未明示、`feedback_no_unenforced_rules.md` 例外 = ADR への追加で機械強制ではなく reviewer / Claude の judgment 補助) | | 135 | 💎 Tier 3 | **todo entry の ADR 番号 hardcode 撤廃 — 「ADR-NNN (採番未確定、land 時に確定)」placeholder 採用 (順位 78 番号 conflict 2026-05-16 観測由来)** | todo8.md | XS | なし (順位 78 (旧 ADR-038 → ADR-041) で番号 conflict が顕在化、queue 滞留 entry の hardcode が後発 PR の採番と衝突する構造リスクを convention で予防、`~/.claude/rules/common/docs-governance.md` に 2-3 行追記。採番予約簿は管理コスト過剰のため見送り、land 時 PR で空き番号確定の軽量運用に統一) | | 136 | 🚀 Tier 1 | **working copy staleness 検出 hook 2 段構え: SessionStart (jj git fetch + lineage 報告) + PreToolUse (stale 時 docs/todo*.md edit block) — 本セッション cleanup-stale-rank-39 由来** | todo8.md | M | なし (本セッションで実証された「stale parent で docs/todo*.md 読込 → 既削除 entry を再度削除提案」failure mode の structural enforcement。Claude Code Web 並列セッション運用前提下で再発確実。`feedback_no_unenforced_rules.md` 例外 = 2 つの hook で機械強制可能、案 A 予防層 + 案 B 最終 backstop の二段構え、ADR-039 experimental pattern 適用) | -| 137 | 🚀 Tier 1 | **Rule-Test Coverage Check Cargo test — `.claude/custom-lint-rules.toml` の extensions ⇔ `src/hooks-post-tool-linter/src/main.rs` test 関数名 mechanical 検証 (PR #163 T1-#1 採用)** | todo8.md | M | なし (PR #110/#151/#152/#155 4 PR 観測 = Frequency High、PR #163 順位 127 で passive reminder comment を追加したが analyzer が「PR #152 同根再発を防止できなかった実証ベース → mechanical enforcement が必要」と判定、`feedback_no_unenforced_rules.md` 原則 = 機械検知なら active enforcement layer のみが防止層として有効) | -| 138 | 💎 Tier 3 | **Rule Extension Test Pattern を `~/.claude/rules/common/testing.md` に明文化 (PR #163 T3-#1 採用)** | todo8.md | XS | なし (PR #151/#152/#163 で確立した pattern の canonical 文書化、`~/.claude/` global 配下のため派生プロジェクト (techbook-ledger / auto-review-fix-vc) へ自動波及、順位 137 = mechanical layer の 2 層構成として運用、Frequency High = 4 PR 観測で ROI 確認済) | **戦略**: Tier 1 を 2〜3 セッションで片付け → Tier 2 で ADR-032 の前提 + rate-limit + convergence cost 削減を進める → Tier 3 で ADR-032 を land + ドキュメント整備。Tier 4-5 は cleanup / 外部展開で daily efficiency への直接効果は小さい。 diff --git a/docs/todo8.md b/docs/todo8.md index a58d11b..3399ea1 100644 --- a/docs/todo8.md +++ b/docs/todo8.md @@ -386,96 +386,6 @@ - `jj git fetch` の timeout が低速 network で頻発した場合の UX → 案 A は fail-open で warning なし pass-through、案 B は fail-closed (lineage 不能 = stale 扱い) で安全側に倒す trade-off - master 判定ロジック: 現状 trunk-based 前提で master を正と扱う。feature branch 運用が始まると assumption が破綻するが、本リポジトリは当面 trunk-based のため問題なし。trunk 名 (master / main) は config 可能にしておく ---- - -### Rule-Test Coverage Check Cargo test — extensions ⇔ test 関数名 mechanical 検証 (PR #163 T1-#1 採用) - -> **動機**: PR #110 (yaml/yml 追加 → test gap) / PR #151 (toml 検出力 test gap) / PR #152 (.md FP 再現) / PR #155 (Bundle k-1 で structural fix) の 4 PR で「extensions 変更時に対応 test が追加されない」pattern が累積観測 (Frequency High = 3 PR 連続観測閾値超過)。 -> -> PR #163 で順位 127 として passive reminder comment (`.claude/custom-lint-rules.toml` rule⑥ 上に「extensions 変更時 test 追加義務」記載) を追加したが、本 PR の post-merge-feedback analyzer は「passive reminder では PR #152 同根再発を防止できなかった実証ベース → mechanical enforcement が必要」と判定。`feedback_no_unenforced_rules.md` 原則 (機械検知できなければ何もしない方がマシ) を analyzer 自身が私の comment 追加に対して適用した形。 -> -> **本タスクの位置づけ**: PR #163 post-merge-feedback Tier 1 #1 採用 (Severity Medium / Frequency High / Effort M / Adoption Risk None)。順位 127 (PR #163 で land 済の passive reminder) との相補関係: comment は point-of-edit reminder、本 cargo test は CI 層での mechanical check。順位 138 (testing.md 明文化) と同 PR land 推奨 (命名規約決定が atomic に整う)。 -> -> **参照**: `.claude/feedback-reports/163.md` Tier 1 #1、`src/hooks-post-tool-linter/src/main.rs` test module、`.claude/custom-lint-rules.toml` rule⑥ コメント - -#### 設計決定 (案) - -- **配置**: `src/hooks-post-tool-linter/src/main.rs` test module (analyzer report は `src/cli-custom-linter/src/main.rs` と記載しているが、実際の crate 名は `hooks-post-tool-linter`) -- **検証ロジック**: - 1. `.claude/custom-lint-rules.toml` を `toml::from_str` で parse - 2. 各 rule の `id` + `extensions` 配列を列挙 - 3. main.rs の test 関数名 (`fn no__detects__*` または `fn no___*` 等の規約に沿う) を静的解析または build script で抽出 - 4. 対応 test 不在の (rule, ext) ペアを列挙、空でなければ `panic!` で fail -- **検出 pattern 選択肢**: - - **案 a (命名規約方式)**: rule_id を kebab-case → snake_case 変換 (`no-ephemeral-todo-reference` → `no_ephemeral_todo_reference`)、ext を test 関数名内の suffix or prefix で確認。規約遵守に依存するが TOML schema 拡張不要 - - **案 b (TOML meta field 方式)**: rule に `[rules.test_names]` メタ field を追加して explicit 対応関係を記述。明示的だが schema 拡張あり -- **段階的導入**: 初期は warning として既存 rule 全てに対する test gap を報告のみ (ratchet 方式)、ベースライン到達後に hard fail に切り替え -- **必須カバレッジ scope**: 主要 ext = **`rs` / `toml` / `yaml`** (および `yml`) の 3+1 ext × 全 rule に対応 test 関数が存在することを必須化。その他 ext (`jsonc` / `json` / `ts` / `tsx` / `js` / `jsx` / `py` / `ps1`) は rule あたり positive single test 1 件以上で代替 (= test 関数爆発を抑制)。**この scope 決定は順位 138 (testing.md) と同一 commit で codify** することで cross-reference の atomicity を確保 - -#### 作業計画 - -- [ ] 既存 rule × 主要 ext (rs/toml/yaml) の test カバレッジを実測 (現状の test gap 把握、ベースライン確定) -- [ ] 命名規約 vs TOML meta field の trade-off 決定 (順位 138 と同 PR で確定推奨) -- [ ] Cargo test として `rule_test_coverage_check` 等の名前で実装 -- [ ] 既存 rule の test gap を test fail で発見した場合は本 PR で同時補填 (もしくは別 PR で test 追加) -- [ ] 順位 138 (`testing.md` への明文化) と命名規約 + 必須 ext scope を整合 -- [ ] 派生プロジェクト deploy 検討 (test 実装は global pattern として共有可能) -- [ ] 本エントリ削除 + todo-summary.md 行削除 - -#### 完了基準 - -- `cargo test -p hooks-post-tool-linter rule_test_coverage_check` が green = **主要 ext (`rs` / `toml` / `yaml` / `yml`) × 全 rule に対応 test 関数が存在**、その他 ext は rule あたり positive single test 1 件以上 -- 将来 extensions に新拡張子を追加した PR で、その拡張子が主要 ext かつ対応 test 不在なら pre-push の cargo test step で fail -- 順位 138 (testing.md) との cross-reference が機能 (本実装 → testing.md の規約参照、testing.md → 本実装の機械強制参照、必須 ext scope は同一 commit で codify) -- 派生プロジェクトでの動作確認 (deploy 後) - -#### 詰まっている箇所 - -- TOML meta field 方式 vs 命名規約方式 の trade-off (前者は明示的だが TOML schema 拡張、後者は規約遵守に依存) — 順位 138 と同 PR 実装時に確定推奨 - ---- - -### Rule Extension Test Pattern を `~/.claude/rules/common/testing.md` に明文化 (PR #163 T3-#1 採用) - -> **動機**: PR #151 / #152 / #163 で確立された「custom lint rule に extensions を追加 / 削除する際は同 PR で対応する positive test + negative test を追加する」pattern を、project 個別の reminder comment (`.claude/custom-lint-rules.toml` rule⑥ コメント、PR #163 順位 127 で追加済) から **global rules への canonical 文書化** に昇格する。 -> -> **本タスクの位置づけ**: PR #163 post-merge-feedback Tier 3 #1 採用 (Severity Low / Frequency High = 4 PR 観測 / Effort XS / Adoption Risk None)。順位 137 (mechanical Cargo test) と 2 層構成: 本 entry は canonical 文書化 (= 規約と思想)、137 は CI 層の機械強制 (= 規約違反を fail で検出)。 -> -> 派生プロジェクト (techbook-ledger / auto-review-fix-vc) へは `~/.claude/` global 配下のため自動波及。Frequency High でドキュメント化 ROI 確認済。 -> -> **参照**: `.claude/feedback-reports/163.md` Tier 3 #1、`~/.claude/rules/common/testing.md`、`.claude/custom-lint-rules.toml` (rule⑥ コメント、PR #163 で追加済) - -#### 設計決定 (案) - -- **配置先**: `~/.claude/rules/common/testing.md` に新 section "Custom Lint Rule Test Coverage" 等を追加 -- **記述内容案** (3-5 段落): - 1. 原則: rule の extensions を変更する場合、同 PR で positive test (新拡張子で fire) と negative test (新拡張子で non-violation は fire しない) を追加 - 2. 命名規約: `no__detects__*` (positive) / `no__skips_*` (negative) — 順位 137 の mechanical check と整合 - 3. self-exclusion パターンへの注意: test fixture が rule の対象になる meta-case を回避するため `build_*_fixture` helper を介して format! interpolation で fixture を構築 (PR #163 で実証された anti-pattern 経験ベース) - 4. mechanical enforcement の参照: 順位 137 (cargo test step) と 2 層構成であること明記 -- **派生プロジェクト deploy**: グローバル `~/.claude/rules/` 配下のため自動波及。techbook-ledger / auto-review-fix-vc 等で custom lint rule を porting する際にも同 pattern が伝播 - -#### 作業計画 - -- [ ] グローバル設定変更前に `~/.claude/` snapshot 取得 (memory rule `feedback_global_config_backup.md` 適用) -- [ ] 既存 `~/.claude/rules/common/testing.md` 構造を確認 (新 section 挿入位置) -- [ ] 新 section "Custom Lint Rule Test Coverage" を追加 -- [ ] 順位 137 (mechanical check) との cross-reference を相互記載 (本 section から 137 へ、137 実装時に testing.md への逆参照を追加) -- [ ] 派生プロジェクト動作確認 (`~/.claude/` 自動波及で deploy 不要) -- [ ] 本エントリ削除 + todo-summary.md 行削除 - -#### 完了基準 - -- `~/.claude/rules/common/testing.md` に新 section が canonical pattern として記述される -- 派生プロジェクトで custom lint rule を追加する際に同 pattern が prior として参照可能 -- 順位 137 実装時に testing.md への cross-reference が機能 - -#### 詰まっている箇所 - -- 順位 137 の命名規約決定 (`no__detects__*` vs TOML meta field) と整合させる必要があり、137 着手時に同 PR で書くと cross-reference が atomic に整う (= 本 entry は単独 land せず 137 と bundled が efficient) - ---- - ## 既知課題 (記録のみ、本セッションで未対応) ### post-merge-feedback workflow が長時間 stale marker を残す問題 (PR #119 marker observed 2026-05-15)