From 04f5b6ad4b7d65d6c0d12036ac2cd875935781c4 Mon Sep 17 00:00:00 2001 From: aloekun Date: Tue, 12 May 2026 20:58:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(hooks-post-tool-linter):=20paths=20filter?= =?UTF-8?q?=20=E3=82=92=20CustomRule=20=E3=81=AB=E8=BF=BD=E5=8A=A0=20(?= =?UTF-8?q?=E9=A0=86=E4=BD=8D=20102=E3=80=81Phase=20D=20D-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #140 post-merge-feedback Tier 1 #2 採用。lint runner に glob ベースの paths filter を追加し、path-sensitive な lint rule を explicit filter で表現可能にする。 実装 (src/hooks-post-tool-linter/src/main.rs + Cargo.toml): - globset = 0.4 を依存追加 (BurntSushi 製、ripgrep の依存と同じ) - CustomRule::paths: Option> field 追加 (optional、既存 rule に影響なし) - CompiledRule に paths_glob: Option を cache (lint 実行時の repeated compile を回避) - compile_paths_glob() helper: None / Some(empty) → None (filter なし)、glob valid → Some(GlobSet)、invalid → Err (rule drop) - rule_matches_path() helper: paths_glob None なら全 path 受容、Some なら GlobSet match で判定 - Windows-style backslash path も に normalize して match - run_custom_rules で extensions × paths を AND 結合 (両方マッチで rule 対象) - 関数長 50 行制限維持のため build_violation_json / collect_violations_for_rule に切り出し - CustomRule doc comment の planned: paths → supported: paths に更新 unit test 7 件追加: - paths_filter_none_accepts_any_path - paths_filter_empty_vec_accepts_any_path - paths_filter_recursive_glob_matches_docs_only - paths_filter_normalizes_windows_separators - paths_filter_multiple_globs_or_semantics - paths_filter_invalid_glob_drops_rule - run_custom_rules_extensions_and_paths_are_anded (AND 結合 E2E) 意図的保留 (順位 118 として todo 化): - rule⑧ への paths = ["docs/**/*.md"] migration は当初計画していたが、D-2 (順位 101) で追加した root-level MD (CLAUDE.md / README.md) からの ../docs/ fire 挙動が scope narrow で壊れるため保留。trade-off 検討は別 PR に分離。 cargo test pass: hooks-post-tool-linter 102 tests (新規 7 + 既存 95、ZERO regression)。 Phase D D-3: 順位 115 (PR #147) land 後の **初の real lint_screen dogfood**。 LINT_SCREEN_ENABLED=true 経路で push pipeline 経由 lint_screen を実行し、metrics (screen_decision / findings / fallback_reason / Diagnostic section / latency) を観測。 --- .claude/custom-lint-rules.toml | 10 +- Cargo.lock | 24 ++ docs/local-llm-offload-analysis.md | 2 +- docs/todo-summary.md | 2 +- docs/todo6.md | 33 --- docs/todo8.md | 27 +++ src/hooks-post-tool-linter/Cargo.toml | 3 + src/hooks-post-tool-linter/src/main.rs | 310 +++++++++++++++++++------ 8 files changed, 305 insertions(+), 106 deletions(-) diff --git a/.claude/custom-lint-rules.toml b/.claude/custom-lint-rules.toml index aa56065a..ecf10868 100644 --- a/.claude/custom-lint-rules.toml +++ b/.claude/custom-lint-rules.toml @@ -252,12 +252,20 @@ good = "comments.iter().filter(|c| c.created_at >= push_time)" # # Bundle Z #B-α と同じ「決定論的防止層」哲学 (ADR-007 の正規表現層)。 # -# 自己限定設計 (extensions = ["md"] のみ、`paths` filter なし): +# 自己限定設計 (extensions = ["md"] のみ、`paths` filter 未適用): # pattern `](../docs/` は parent-directory 参照を伴う `docs/` への back-link で、 # 文書 が docs/ 配下 (または同等位置) にある場合のみ意味を持つ。root-level README.md # 等の通常 Markdown では `](docs/...)` 形式が自然で `](../docs/...)` は出現しない # ため、pattern semantics で自己限定される。 # +# Phase D D-3 (順位 102) で `paths` filter は実装済 だが、本 rule への適用は +# **意図的に保留** (2026-05-12)。理由: D-2 (順位 101) で追加した root-level MD +# (CLAUDE.md / README.md) からの `../docs/` 参照を fire = true positive で扱う +# design intent が、production レベルで `paths = ["docs/**/*.md"]` 適用により +# scope narrow されて壊れる (root-level MD の実 path は docs/ 配下ではないため +# rule 対象外になり、broken link が検出できなくなる)。順位 102 land 後の trade-off +# 検討は別 PR で行う (todo: 順位 102 follow-up「rule⑧ への paths 適用範囲検討」)。 +# # Self-exclusion: 本 TOML の message / why / example で説明文として `](../docs/` # 形式を直接記述しないこと (lint 対象は extensions = ["md"] のみで .toml は対象外 # だが、説明用に書く場合も backtick code 内の同パターンが他文書に流れる事故を予防)。 diff --git a/Cargo.lock b/Cargo.lock index eb9f3cd6..95e4d7bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,16 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bytes" version = "1.11.1" @@ -282,6 +292,19 @@ dependencies = [ "wasip3", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "h2" version = "0.4.14" @@ -337,6 +360,7 @@ dependencies = [ name = "hooks-post-tool-linter" version = "0.1.0" dependencies = [ + "globset", "regex", "serde", "serde_json", diff --git a/docs/local-llm-offload-analysis.md b/docs/local-llm-offload-analysis.md index 6ae8fb95..928a51f8 100644 --- a/docs/local-llm-offload-analysis.md +++ b/docs/local-llm-offload-analysis.md @@ -240,7 +240,7 @@ Phase C fix + Phase D 前提整備 (順位 109) 完了で **real pipeline 経由 | **D-1** ✅ | 順位 112 + 113 + 114 = ADR amendments bundle (ADR-038 eprintln scope / ADR-027 metrics override / 新規 ADR Local LLM context size) + 順位 115 backlog 化 | S+ | 298 (insert 228 / delete 70) | docs + 1 Rust comment | **PR #145 land 済 (2026-05-12)**、lint_screen dogfood は skip (workflow gap) | | **D-2** ✅ | 順位 101 + 106 + 103 = lint rule code touch (rule⑧ edge case test / self-exclusion assertion / lint runner field comment) | S+S+S | 172 (insert 84 / delete 88) | Rust test/comment mix | **PR #146 land 済 (2026-05-12)**、lint_screen dogfood は skip (順位 115 未 land 時点) | | **115** ✅ | `LINT_SCREEN_ENABLED` env var override (D-1 で発見した workflow gap 解消) | S | 想定通り (Rust impl + test 10 件) | Rust impl + Phase D guide rewrite | **PR #147 想定で land 中**、D-3 着手 unblock | -| **D-3** ⏳ | 順位 102 = `paths` filter を lint runner に実装 (impl + test + 既存 rule migration) | M | ~250-350 | Rust impl + test | **順位 115 land 後すぐ着手可、初の real dogfood + num_ctx 32768 上限テスト** | +| **D-3** ⏳ | 順位 102 = `paths` filter を lint runner に実装 (impl + test、既存 rule⑧ migration は 順位 118 で trade-off 検討に保留) | M | 実 ~270 (insert) | Rust impl + 7 unit tests + glob filter helper | **PR #148 想定で land 中、初の real lint_screen dogfood (`$env:LINT_SCREEN_ENABLED=true` 経路) + num_ctx 32768 上限テスト** | **size ramp-up 設計**: small → mid → mid-large の漸増で、small PR 単体での fallback 観測と large PR で num_ctx 限界に近づく挙動を両方カバー。**D-1 / D-2 は workflow gap により lint_screen dogfood をスキップ、実質 metrics 観測は D-3 のみ**。3 PR 観測予定だったが kill-switch 基準 (3/5 で停止) を踏まえて D-3 単独でも判定可能 (採用昇格 / 継続観測 / 却下) と位置付ける。 diff --git a/docs/todo-summary.md b/docs/todo-summary.md index e64e15bf..62c79779 100644 --- a/docs/todo-summary.md +++ b/docs/todo-summary.md @@ -67,7 +67,6 @@ | 97 | 🔧 Tier 2 | **`with_num_ctx(X)` override 値 serialization 検証テスト (PR #136 T2-#1 採用)** | todo6.md | S | なし (PR #136 で追加した builder method の wiring を mockito で seal、Phase d で num_ctx tweak する局面の silent degrade 防止、CodeRabbit が見逃した test gap を post-merge-feedback agent が独立発見) | | 99 | 💎 Tier 3 | **ADR-038 に PR #138 learning 2 件を追記 (cost-aware 実装層選択 + attention dilution pitfall) (PR #138 T3-#1+#2 採用)** | todo6.md | S | なし (lint_screen が takt facet → Rust stage に pivot した cost 根拠 + Phase b' v2 の diff header full 追加で agreement 75%→50% 33pt 低下した attention dilution 観測の 2 件を ADR に codify、次回 LLM 系 feature 開発時の prior assumption に) | | 100 | 💎 Tier 3 | **`development-workflow.md` に 「同一ファイル複数編集の 1 task 統合」 + 「partial completion + 後続 PR 追補明記」 を追補 (PR #139 T3-#1 採用)** | todo6.md | XS | なし (PR #119/#120/#121 sub-PR 分割 + PR #139 partial completion で systemic に観測された 2 暗黙知を `~/.claude/rules/common/development-workflow.md` に codify、`feedback_no_unenforced_rules.md` 例外 = 既存実践の明文化のため非機械強制でも採用相当) | -| 102 | 🚀 Tier 1 | **`paths` filter を lint runner に実装 (PR #140 T1-#2 採用)** | todo6.md | M | なし (rule⑧ で `extensions = ["md"]` のみで pattern semantics に依存して self-limit したが、path-sensitive な lint rule 追加時に同設計-実装 gap が systemic に再発、`src/hooks-post-tool-linter/src/filter.rs` 等で paths filter サポート + 単体 test 同 commit) | | 104 | 💎 Tier 3 | **ADR-007 amendment: semantic self-limitation 安全条件 + lint rule 最小テストチェックリスト (PR #140 T3-#1 採用)** | todo6.md | S | なし (rule⑧ で `paths` filter 不在を pattern semantics で代替した判断の rationale を ADR-007 に追記。「semantic self-limitation OK な条件」と「explicit `paths` filter 必須な条件」、lint rule 最小テストチェックリスト = pattern detection / case-insensitive / false positive skip の 3 項目最低化、3 ソース観測) | | 105 | 💎 Tier 3 | **グローバル CLAUDE.md に lint runner サポートフィールド一覧表 (PR #140 T3-#2 採用)** | todo6.md | XS | なし (`~/.claude/CLAUDE.md` に `pattern` / `extensions` / `severity` (planned: `paths`) の field 一覧を表形式で追加、派生プロジェクト (techbook-ledger / auto-review-fix-vc) で rule porting 時の理解統一、順位 103 の code comment と相補) | | 107 | 💎 Tier 3 | **`development-workflow.md` に PR #125→#141 anti-pattern 事例補強 (PR #141 T3-#2 採用)** | todo6.md | XS | なし (`~/.claude/rules/common/development-workflow.md` の「タスク完了削除手順」に「マージ後 N 日間 todo.md 残存 → 後続 phase で手動発見」事例を追記、memory `feedback_verify_task_not_already_done` を central rule にも反映、`feedback_todo_no_history` と合わせて「マージ → 即削除」サイクルを強調) | @@ -76,6 +75,7 @@ | 111 | 💎 Tier 3 | **`docs-governance.md` に todo5/todo6 routing rule 明文化 (PR #142 T3-#1 採用)** | todo6.md | S | なし (Phase/bundle 関連 → todo6、global rules/lint → todo5 等の routing rule を `~/.claude/rules/common/docs-governance.md` に追記、PR #142 で実証された file pointer bifurcation の構造的予防、CR Minor #2 と同根) | | 116 | 💎 Tier 3 | **ADR-040 `step_timeout` 説明に sublinear / KV cache locality clarification 追記 (PR #145 T3-#1 採用)** | todo8.md | XS | なし (L42-48 で「sublinear (3.33x)」と「per-invoke latency が概ね線形」が並存し reference table 600s と formula 720s が乖離。実測値 600s 採択 + 保守上限 720s + sublinear 性の KV cache locality 根拠を 2-3 行追記して整合化、永続 ADR の数値正確性確保) | | 117 | 💎 Tier 3 | **`coding-style.md § Cross-File Reference Lifecycle` に ephemeral → permanent 知識移管 edit order 追記 (PR #145 T3-#3 採用)** | todo8.md | S | なし (PR #145 で lib.rs L128-139 → ADR-040 移管 + Phase C/D empirical data 移管の 2 観測。既存ルール (参照方向制約) と complementary な「① permanent target 先行作成・validate → ② 参照追加 → ③ 参照元削除」3 ステップ原則を `~/.claude/rules/common/coding-style.md` に codify、次回 ephemeral 計画書 retire 時の checklist として再利用) | +| 118 | 💎 Tier 3 | **rule⑧ への paths filter 適用範囲検討 (順位 102 land 時の意図的保留、follow-up)** | todo8.md | XS | 順位 102 (PR #148 想定で land 中、Phase D D-3) で paths filter は実装済だが、rule⑧ への `paths = ["docs/**/*.md"]` migration は D-2 (PR #146、順位 101) で追加した root-level MD fire intent を壊すため保留。4 案 (保留継続 / broader glob / explicit list / rule split) の trade-off 評価を ADR-007 amendment (順位 104) と整合させて結論を出す | **戦略**: 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/todo6.md b/docs/todo6.md index 115e1cef..c765bfe7 100644 --- a/docs/todo6.md +++ b/docs/todo6.md @@ -329,39 +329,6 @@ config.rs + push-runner-config.toml + review-simplicity.md + ADR で family_tag --- -### `paths` filter を lint runner に実装 (PR #140 T1-#2 採用) - -> **動機**: rule⑧ (PR #140) の TOML コメントで「`paths` filter は lint runner 未実装、`extensions` のみ」と明記し pattern semantics で self-limit したが、これは設計-実装 gap の workaround。今後 path-sensitive な lint rule (例: `tests/` 内のみ / `src/cli-*/` のみ等) を追加するたびに同じ workaround を強いる systemic pattern が予測される。`src/hooks-post-tool-linter/src/filter.rs` (or 等価な path filter モジュール) で `paths = [...]` glob filter をサポートする。 -> -> **本タスクの位置づけ**: PR #140 post-merge-feedback Tier 1 #2 採用 (Severity Medium / Frequency Medium = systemic / Effort M / Adoption Risk None)。 -> -> **参照**: `.claude/feedback-reports/140.md` Tier 1 #2、`src/hooks-post-tool-linter/src/main.rs` の `CustomRule` struct (line ~76-87)、PR #140 rule⑧ TOML コメント、ADR-007 (custom lint rule の正規表現/AST 層線引き) - -#### 設計決定の余地 - -- **glob 構文**: `**/*.md` 形式で十分か / `regex` ベースの方が柔軟か → 既存 `extensions` が単純 string match なので glob で平仄を取る方が自然 -- **AND vs OR**: `extensions` と `paths` 両方指定時の filter 結合 (両方 match を要求 = AND が直感的) -- **test 規模**: 単体 test を同 commit に含める (PR #140 教訓: rule 追加と同 PR で test 不在を防ぐ) - -#### 作業計画 - -- [ ] 設計決定 (glob 構文 / AND-OR / test 戦略) を確定 -- [ ] `CustomRule` struct に `paths: Option>` を追加 (Optional で既存 rule に影響なし) -- [ ] glob match 実装 (例: `globset` crate or 既存 `glob` 機能で代替) -- [ ] `extensions` × `paths` の組合せ test (path match / 不一致 / 未指定で AND fallback の各ケース) -- [ ] rule⑧ を `paths = ["docs/**/*.md"]` で書き換え (semantic self-limit から explicit filter へ) -- [ ] 順位 101 の test 整理と整合性確認 (本実装後に depth-1 root MD は paths filter で skip される設計に変わる可能性) -- [ ] ADR-007 amendment (順位 104) と同 PR で land 推奨 (rationale 同期) -- [ ] 本 todo6.md エントリ削除 + todo-summary.md 行削除 - -#### 完了基準 - -- `paths` filter が動作 (test 検証) -- rule⑧ の TOML コメントから「paths filter 未実装」記述が消え、explicit filter に置き換わる -- 後続 path-sensitive rule 追加時の動線が確立 - ---- - ### ADR-007 amendment: semantic self-limitation 安全条件 + lint rule 最小テストチェックリスト (PR #140 T3-#1 採用) > **動機**: rule⑧ (PR #140) で `paths` filter 不在を pattern semantics で代替した判断は妥当だったが、**どんな条件下で semantic self-limitation が安全か** / **explicit filter が必須な条件は何か** が ADR-007 に明文化されていない。3 ソース (PR diff / prepush / session) でこの documentation 不足を独立指摘。同時に lint rule 最小テストチェックリスト (pattern detection / case-insensitive / false positive skip の 3 項目) も ADR レベルで確立すると future rule author の prior が安定化する。 diff --git a/docs/todo8.md b/docs/todo8.md index 09fc0035..8fbf8cdd 100644 --- a/docs/todo8.md +++ b/docs/todo8.md @@ -33,6 +33,33 @@ --- +### rule⑧ への paths filter 適用範囲検討 (順位 102 land 時に意図的保留、follow-up) + +> **動機**: 順位 102 (PR #148 想定で land 中、Phase D D-3) で paths filter が lint runner に実装されたが、当初計画した rule⑧ への `paths = ["docs/**/*.md"]` migration は **意図的に保留**。理由: D-2 (PR #146、順位 101) で追加した「root-level MD (CLAUDE.md / README.md) からの `../docs/` 参照を fire = true positive で扱う」design intent が、`paths = ["docs/**/*.md"]` 適用で scope narrow されて壊れる (root-level MD の実 path が docs/ 配下ではないため rule 対象外になり、broken link 検出を失う)。本タスクで以下のいずれを採用するか検討する: +> +> 1. **保留継続** (現状維持): rule⑧ は `extensions = ["md"]` のみで run、root-level fire を保護 +> 2. **broader glob**: `paths = ["**/*.md"]` で全 .md 受容 (= extensions filter と機能的同等、demonstration 用途) +> 3. **explicit list**: `paths = ["docs/**/*.md", "*.md", ".claude/**/*.md"]` で docs/ + root + .claude/ をカバー +> 4. **rule split**: rule⑧-docs (docs/ scope) + rule⑧-root (root scope) に分割 +> +> **本タスクの位置づけ**: 順位 102 follow-up (Severity Low / Frequency Low = 1 観測 / Effort XS / Adoption Risk None)。実 production lint behavior に影響しない range で trade-off 評価。 +> +> **参照**: PR #148 (順位 102 land) の TOML rule⑧ コメント、PR #146 (D-2、順位 101) の `md_no_docs_relative_detects_root_*` tests + +#### 作業計画 + +- [ ] 4 案の trade-off を ADR-007 amendment (順位 104) と整合させて評価 +- [ ] 採用案を `.claude/custom-lint-rules.toml` rule⑧ に適用 (案 1 保留継続なら no-op だが、本エントリ削除で結論明示) +- [ ] 既存 test (`md_no_docs_relative_*` group) との整合性確認 +- [ ] 本エントリ削除 + todo-summary.md 行削除 + +#### 完了基準 + +- rule⑧ scope の設計判断が ADR-007 amendment と本タスク entry で明確化される +- 同型 trade-off (filter scope narrow vs coverage 保持) を将来 rule 追加時に逆引きできる + +--- + ### `coding-style.md § Cross-File Reference Lifecycle` に「ephemeral → permanent 知識移管 edit order」追記 (PR #145 T3-#3 採用) > **動機**: PR #145 で lib.rs L128-139 dogfood evolution コメントを ADR-040 に migrate した際、edit 順序が曖昧だった (ADR-040 を先に作るべきか、lib.rs 側の参照削除を先にすべきか)。同パターンが (1) lib.rs コメント → ADR-040、(2) Phase C/D empirical data → ADR-040 で 2 回観測。既存の Cross-File Reference Lifecycle ルール は「参照方向の制約」(permanent → ephemeral 禁止) に特化しており、移管作業の edit order checklist は complementary で重複なし。次回同型の永続化作業 (ephemeral 計画書 retire 時の permanent value 移管 等) で再発防止策として codify する。 diff --git a/src/hooks-post-tool-linter/Cargo.toml b/src/hooks-post-tool-linter/Cargo.toml index 0862eb64..a8d7c931 100644 --- a/src/hooks-post-tool-linter/Cargo.toml +++ b/src/hooks-post-tool-linter/Cargo.toml @@ -8,6 +8,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" regex = "1.10" +# globset: paths filter (順位 102、PR #140 T1-#2 採用) for CustomRule +# BurntSushi-maintained, ripgrep の依存。`**/` 形式の recursive 対応が標準で揃う。 +globset = "0.4" [dev-dependencies] tempfile = "3" diff --git a/src/hooks-post-tool-linter/src/main.rs b/src/hooks-post-tool-linter/src/main.rs index 62545c3c..9a3aa991 100644 --- a/src/hooks-post-tool-linter/src/main.rs +++ b/src/hooks-post-tool-linter/src/main.rs @@ -6,6 +6,7 @@ //! .claude/hooks-config.toml の [post_tool_linter] セクションから //! 拡張子ごとのパイプラインを読み込みます。 +use globset::{Glob, GlobSet, GlobSetBuilder}; use regex::Regex; use serde::{Deserialize, Serialize}; use std::io::{self, Read}; @@ -85,14 +86,20 @@ struct CustomRulesConfig { /// | `message` | ✅ | 違反時のメッセージ | /// | `extensions` | ✅ | 対象拡張子の list (例: `["rs", "toml"]`)。空配列を使うと全 file が対象になる anti-pattern なので避ける | /// | `why` | optional | ルールの根拠 (ADR 参照 / PR 由来等)。省略可だが post-merge-feedback 由来は明記推奨 | +/// | `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) | /// -/// **planned field** (実装時に本コメントも併せて更新する): +/// **glob syntax** (`globset` crate 準拠): /// -/// - `paths` (順位 102): glob pattern による file path filter。現状は `extensions` のみで file scope を絞り、 -/// path semantics は pattern 自体で self-limit する設計 (ADR-007 amendment 参照、順位 104)。 -/// `paths` 実装時には `extensions` × `paths` の AND 結合で評価する。 +/// - `*` = 同階層の 0+ 文字 (path separator は含まない) +/// - `**` = 任意階層の recursive match (`docs/**/*.md` は `docs/a.md` / `docs/adr/b.md` 両方マッチ) +/// - `?` = 単一文字 +/// - `[abc]` = 文字 class +/// +/// **`extensions` × `paths` の AND 結合の意義**: `extensions` は file 種別 (rust / toml / md) を絞る軸、 +/// `paths` は file 位置 (docs/ 配下 / tests/ 配下) を絞る軸で直交。両方マッチで初めて rule 対象とすることで、 +/// rule scope を明示的に二次元で表現できる (ADR-007 amendment 順位 104 で codify 予定)。 #[derive(Deserialize, Clone)] struct CustomRule { id: String, @@ -102,6 +109,8 @@ struct CustomRule { #[serde(default)] why: String, extensions: Vec, + #[serde(default)] + paths: Option>, fix: Option, example: Option, } @@ -324,10 +333,69 @@ fn custom_rules_path() -> PathBuf { .join("custom-lint-rules.toml") } -/// コンパイル済み正規表現を持つルール +/// コンパイル済み正規表現と paths glob set を持つルール。 +/// +/// `paths_glob` は `rule.paths` が `Some(non-empty)` の場合のみ compiled GlobSet を保持し、 +/// `None` (path filter なし) では `None` を保持する。Empty Vec は **filter なし** として扱う +/// (= `None` と同等) ことで「[]` と `None` の semantic 差を排除し、`Option>` の意味を +/// 「未指定 or 明示空 = 全 path 受容」に統一する。 struct CompiledRule { rule: CustomRule, regex: Regex, + paths_glob: Option, +} + +/// `CustomRule::paths` を GlobSet に compile する。 +/// +/// - `None` または `Some(empty Vec)` → `Ok(None)` (filter なし) +/// - `Some(non-empty)` で全 glob valid → `Ok(Some(GlobSet))` +/// - 1 つでも glob が invalid → `Err(error message)` (rule 全体を破棄) +fn compile_paths_glob(paths: &Option>) -> Result, String> { + let Some(pattern_list) = paths else { + return Ok(None); + }; + if pattern_list.is_empty() { + return Ok(None); + } + let mut builder = GlobSetBuilder::new(); + for pattern in pattern_list { + let glob = Glob::new(pattern) + .map_err(|e| format!("invalid glob '{}': {}", pattern, e))?; + builder.add(glob); + } + builder + .build() + .map(Some) + .map_err(|e| format!("failed to build GlobSet: {}", e)) +} + +/// `CustomRule` 単体を compile し、`CompiledRule` を返す。失敗時は warn log + None。 +fn compile_rule(rule: CustomRule) -> Option { + let regex = match Regex::new(&rule.pattern) { + Ok(r) => r, + Err(e) => { + eprintln!( + "[post-tool-linter] Warning: Invalid regex in rule '{}': {}", + rule.id, e + ); + return None; + } + }; + let paths_glob = match compile_paths_glob(&rule.paths) { + Ok(g) => g, + Err(msg) => { + eprintln!( + "[post-tool-linter] Warning: rule '{}' paths filter compile failed, dropping rule: {}", + rule.id, msg + ); + return None; + } + }; + Some(CompiledRule { + rule, + regex, + paths_glob, + }) } /// カスタムルール設定を読み込み、正規表現をプリコンパイルする @@ -355,19 +423,7 @@ fn load_custom_rules() -> Vec { ); } - rules - .into_iter() - .filter_map(|rule| match Regex::new(&rule.pattern) { - Ok(regex) => Some(CompiledRule { rule, regex }), - Err(e) => { - eprintln!( - "[post-tool-linter] Warning: Invalid regex in rule '{}': {}", - rule.id, e - ); - None - } - }) - .collect() + rules.into_iter().filter_map(compile_rule).collect() } fn find_powershell_rules_missing_case_insensitive_flag(rules: &[CustomRule]) -> Vec { @@ -395,7 +451,65 @@ fn rule_matches_ext(rule: &CustomRule, file: &str) -> bool { } } -/// カスタムルールをファイルに適用し、構造化された違反 JSON を返す +/// `compiled.paths_glob` が `None` (filter なし) または `Some(GlobSet)` で file path がマッチする場合 true。 +/// +/// 順位 102 (PR #140 T1-#2 採用、Phase D D-3): `extensions` filter と AND 結合で評価する path filter。 +/// 比較対象は **path 全体** で、Unix-style separator (`/`) のみで matching する。Windows path 入力 +/// (`\` 含む) は事前に normalize しておく必要があるが、本 hook の入力 (`tool_input.file_path` / +/// `tool_input.path`) は Claude Code が POSIX-style で渡すため通常は問題なし。 +fn rule_matches_path(compiled: &CompiledRule, file: &str) -> bool { + let Some(globset) = compiled.paths_glob.as_ref() else { + return true; + }; + let normalized = file.replace('\\', "/"); + globset.is_match(&normalized) +} + +/// 1 件の regex match と rule 定義から `LintViolation` の JSON 文字列を構築する。 +/// +/// `m.start()` 以前の `\n` 数 + 1 を 1-indexed line number として算出 (`find_iter` の byte +/// offset を line 番号に変換するため line-by-line search では捕捉できない multiline pattern +/// = 例: PowerShell `} catch {\n}` にも対応)。 +fn build_violation_json(file: &str, rule: &CustomRule, m: regex::Match, content: &str) -> Option { + let line_no = content[..m.start()].bytes().filter(|b| *b == b'\n').count() + 1; + let violation = LintViolation { + r#type: rule.id.to_uppercase().replace('-', "_"), + severity: rule.severity.clone(), + location: ViolationLocation { + file: file.to_string(), + line: line_no, + symbol: m.as_str().to_string(), + }, + message: rule.message.clone(), + why: rule.why.clone(), + fix: ViolationFix { + strategy: rule.fix.as_ref().map_or_else(String::new, |f| f.strategy.clone()), + steps: rule.fix.as_ref().map_or_else(Vec::new, |f| f.steps.clone()), + }, + example: ViolationExample { + bad: rule.example.as_ref().map_or_else(String::new, |e| e.bad.clone()), + good: rule.example.as_ref().map_or_else(String::new, |e| e.good.clone()), + }, + }; + serde_json::to_string(&violation).ok() +} + +fn collect_violations_for_rule( + file: &str, + content: &str, + compiled: &CompiledRule, + violations: &mut Vec, +) { + for m in compiled.regex.find_iter(content) { + if violations.len() >= MAX_CUSTOM_VIOLATIONS { + return; + } + if let Some(json) = build_violation_json(file, &compiled.rule, m, content) { + violations.push(json); + } + } +} + fn run_custom_rules(file: &str, rules: &[CompiledRule]) -> Vec { let content = match std::fs::read_to_string(file) { Ok(c) => c, @@ -404,53 +518,14 @@ fn run_custom_rules(file: &str, rules: &[CompiledRule]) -> Vec { let mut violations = Vec::new(); - // line-by-line search cannot detect multiline patterns (e.g., PowerShell `} catch {\n}`) for compiled in rules { if !rule_matches_ext(&compiled.rule, file) { continue; } - - for m in compiled.regex.find_iter(&content) { - if violations.len() >= MAX_CUSTOM_VIOLATIONS { - break; - } - - let line_no = content[..m.start()].bytes().filter(|b| *b == b'\n').count() + 1; - let rule = &compiled.rule; - let violation = LintViolation { - r#type: rule.id.to_uppercase().replace('-', "_"), - severity: rule.severity.clone(), - location: ViolationLocation { - file: file.to_string(), - line: line_no, - symbol: m.as_str().to_string(), - }, - message: rule.message.clone(), - why: rule.why.clone(), - fix: ViolationFix { - strategy: rule - .fix - .as_ref() - .map_or_else(String::new, |f| f.strategy.clone()), - steps: rule.fix.as_ref().map_or_else(Vec::new, |f| f.steps.clone()), - }, - example: ViolationExample { - bad: rule - .example - .as_ref() - .map_or_else(String::new, |e| e.bad.clone()), - good: rule - .example - .as_ref() - .map_or_else(String::new, |e| e.good.clone()), - }, - }; - - if let Ok(json) = serde_json::to_string(&violation) { - violations.push(json); - } + if !rule_matches_path(compiled, file) { + continue; } - + collect_violations_for_rule(file, &content, compiled, &mut violations); if violations.len() >= MAX_CUSTOM_VIOLATIONS { break; } @@ -770,6 +845,7 @@ mod tests { message: "test message".into(), why: "test reason".into(), extensions: extensions.iter().map(|e| e.to_string()).collect(), + paths: None, fix: Some(CustomRuleFix { strategy: "test strategy".into(), steps: vec!["step1".into()], @@ -781,6 +857,17 @@ mod tests { } } + fn make_test_rule_with_paths( + id: &str, + pattern: &str, + extensions: &[&str], + paths: &[&str], + ) -> CustomRule { + let mut rule = make_test_rule(id, pattern, extensions); + rule.paths = Some(paths.iter().map(|p| p.to_string()).collect()); + rule + } + #[test] fn rule_matches_ts_extension() { let rule = make_test_rule("test", "pattern", &["ts", "tsx"]); @@ -815,18 +902,101 @@ mod tests { assert!(rule_matches_ext(&rule, r"e:\work\project\src\app.ts")); } - // --- カスタムルール: 違反検出 --- + /// 順位 102 (PR #140 T1-#2 採用): paths filter 未指定 (None) → 全 path 受容 (filter なし扱い) + #[test] + fn paths_filter_none_accepts_any_path() { + let rule = make_test_rule("test", "x", &["md"]); + let compiled = compile_rule(rule).expect("rule must compile"); + assert!(rule_matches_path(&compiled, "any/file.md")); + assert!(rule_matches_path(&compiled, "docs/adr/foo.md")); + assert!(rule_matches_path(&compiled, "README.md")); + } + + /// 順位 102: paths filter empty (Some(vec![])) → None と同等扱い (全 path 受容) + #[test] + fn paths_filter_empty_vec_accepts_any_path() { + let rule = make_test_rule_with_paths("test", "x", &["md"], &[]); + let compiled = compile_rule(rule).expect("rule must compile"); + assert!(rule_matches_path(&compiled, "any/file.md")); + } + + /// 順位 102: paths filter `docs/**/*.md` で docs 配下のみ match (rule⑧ の migration target) + #[test] + fn paths_filter_recursive_glob_matches_docs_only() { + let rule = make_test_rule_with_paths("test", "x", &["md"], &["docs/**/*.md"]); + let compiled = compile_rule(rule).expect("rule must compile"); + assert!(rule_matches_path(&compiled, "docs/spec.md")); + assert!(rule_matches_path(&compiled, "docs/adr/adr-001.md")); + assert!(rule_matches_path(&compiled, "docs/a/b/c/deep.md")); + assert!(!rule_matches_path(&compiled, "README.md")); + assert!(!rule_matches_path(&compiled, "CLAUDE.md")); + } + + /// 順位 102: Windows-style backslash path も normalize して match できる (Claude Code hook 実環境想定) + #[test] + fn paths_filter_normalizes_windows_separators() { + let rule = make_test_rule_with_paths("test", "x", &["md"], &["docs/**/*.md"]); + let compiled = compile_rule(rule).expect("rule must compile"); + assert!(rule_matches_path(&compiled, r"docs\adr\adr-001.md")); + } + + /// 順位 102: paths filter は複数 glob を OR で評価 (= いずれか 1 つに match で受容) + #[test] + fn paths_filter_multiple_globs_or_semantics() { + let rule = + make_test_rule_with_paths("test", "x", &["md"], &["docs/**/*.md", "tests/**/*.md"]); + let compiled = compile_rule(rule).expect("rule must compile"); + assert!(rule_matches_path(&compiled, "docs/foo.md")); + assert!(rule_matches_path(&compiled, "tests/integration.md")); + assert!(!rule_matches_path(&compiled, "src/main.md")); + } + + /// 順位 102: invalid glob は compile_rule 段階で reject されて rule 自体が drop される + #[test] + fn paths_filter_invalid_glob_drops_rule() { + let rule = make_test_rule_with_paths("test", "x", &["md"], &["docs/[unclosed"]); + assert!( + compile_rule(rule).is_none(), + "invalid glob in paths should cause compile_rule to drop the rule" + ); + } + + /// 順位 102: extensions × paths AND 結合 = 拡張子マッチ AND path マッチ 両方を要求 + #[test] + fn run_custom_rules_extensions_and_paths_are_anded() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let docs_dir = dir.path().join("docs"); + std::fs::create_dir(&docs_dir).unwrap(); + let in_docs = docs_dir.join("foo.md"); + let mut f = std::fs::File::create(&in_docs).unwrap(); + f.write_all(b"FORBIDDEN\n").unwrap(); + + let outside = dir.path().join("README.md"); + let mut f2 = std::fs::File::create(&outside).unwrap(); + f2.write_all(b"FORBIDDEN\n").unwrap(); + + let rule = make_test_rule_with_paths("test", "FORBIDDEN", &["md"], &["**/docs/**/*.md"]); + let compiled = compile_test_rules(vec![rule]); + + let in_docs_violations = run_custom_rules(in_docs.to_str().unwrap(), &compiled); + let outside_violations = run_custom_rules(outside.to_str().unwrap(), &compiled); + + assert_eq!( + in_docs_violations.len(), + 1, + "docs 配下 + .md = 両方マッチで violation 検出" + ); + assert!( + outside_violations.is_empty(), + "root-level README.md は paths filter で除外 (= AND の片方が false)" + ); + } + /// テスト用: CustomRule からコンパイル済みルールを生成するヘルパー fn compile_test_rules(rules: Vec) -> Vec { - rules - .into_iter() - .filter_map(|rule| { - Regex::new(&rule.pattern) - .ok() - .map(|regex| CompiledRule { rule, regex }) - }) - .collect() + rules.into_iter().filter_map(compile_rule).collect() } #[test]