diff --git a/.claude/hooks-config.toml b/.claude/hooks-config.toml index e9be558..24c08cb 100644 --- a/.claude/hooks-config.toml +++ b/.claude/hooks-config.toml @@ -89,6 +89,24 @@ grep_recent_limit = 20 # jj log で参照する直近 commit 数 # 正規表現ベースのリテラルマッチのみ。AST解析が必要なルールは # ast-grep を下記パイプラインのステップとして追加する (ADR-007)。 +# ─── PostToolUse: ファイルサイズチェック ─── +# 順位 177 (PR #197 で Tier 1 (優先実装) 格上げ) で実装。 +# PostToolUse Edit/Write 直後にファイルサイズを確認し、threshold (50KB) 超過時に +# additionalContext でファイル分割を促す。Claude Code 読み取り安定性閾値 (50KB) を +# 機械強制する Layer 0.5。 +# +# ADR-039 § 3 opt-in pattern: default OFF (enabled = false)、明示有効化で発火。 +# touch-trigger ratchet (default true): 触られたファイルのみチェック = 既存超過 +# ファイルは未編集なら grandfather。strict mode (touch_trigger=false で全 enabled +# paths を scan) は MVP では受理のみ、3-5 PR の dogfood 後に判定 (bounded lifetime)。 +# +# Kill-switch: enabled = false で完全停止。 +[post_tool_use.file_size_check] +enabled = false # opt-in (明示有効化で発火、ADR-039 § 3 bounded lifetime: 3-5 PR dogfood 後に default-ON 昇格判定) +threshold_bytes = 51200 # 50KB (= 50 * 1024) +paths = ["docs/**/*.md", "src/**/*.rs"] # default 対象 glob +touch_trigger = true # 触られたファイルのみ check (ratchet) + # ─── PostToolUse: リンター ─── [post_tool_linter] diff --git a/docs/adr/adr-007-custom-linter-layer-boundary.md b/docs/adr/adr-007-custom-linter-layer-boundary.md index a840f00..0bc283f 100644 --- a/docs/adr/adr-007-custom-linter-layer-boundary.md +++ b/docs/adr/adr-007-custom-linter-layer-boundary.md @@ -153,3 +153,7 @@ PR #98 (Bundle Y2) post-merge-feedback で `post-pr-review.yaml` supervise step - TOML rule コメントに field 拡張手順を 4 ステップで記述(grep → alternation 追加 → test helper 追加 → fixture test + TOML test 宣言追加) - 各 field について `_detects__violation` 命名規約で個別 fixture test を確保(一括 test では削除回帰が検知不可能) - `[rules.test_coverage.main_ext_tests]` 宣言で test 名を機械強制レイヤに接続する + +## Layer 0.5 追記: file_size_check (2026-06-07、順位 177 由来) + +`[post_tool_use.file_size_check]` (PR #197 land、`hooks-post-tool-linter` 統合) は本 ADR の Q1/Q2/Q3 判断フロー対象外。ファイル content を読まず metadata (`std::fs::metadata.len()`) のみで判定する **正規表現層未満の Layer 0.5** に位置し、Layer 0 (UTF-8 整合性) と Layer 1 (正規表現 custom-rules) の間で発火する。`paths` glob filter (順位 102 / Phase D D-3 と同 helper 共有) で対象を絞り、ADR-039 opt-in pattern (default OFF + bounded lifetime dogfood) で導入リスクを抑制する。同型 (metadata-only、content 非依存) の future check は同 Layer 0.5 に追加することで regex/AST 層との責務分離が維持される。 diff --git a/docs/todo-summary.md b/docs/todo-summary.md index 7add959..4aa9e05 100644 --- a/docs/todo-summary.md +++ b/docs/todo-summary.md @@ -73,7 +73,6 @@ | 172 | 💎 Tier 3 | **CR ephemeral artifact Nitpick の統一 skip 基準を memory に codify (PR #183 T3-#3 採用) ★ Bundle DG-RULES** | todo11.md | XS | 順位 171 と同 PR 推奨、CR が `docs/todo*.md` 系 ephemeral artifact 内の行番号参照を Nitpick 指摘した場合は skip 推奨という判断基準を新 memory `feedback_coderabbit_ephemeral_nitpick.md` に codify、既存 memory `feedback_coderabbit_no_actionable_merge_signal` の補完、本リポジトリ専用 (派生プロジェクトには波及しない)、`feedback_global_config_backup` 適用推奨 | | 173 | 🔧 Tier 2 | **`combine_output` 5 crate 重複を `lib-runner-utils` (or 既存 lib-*) に extract (PR #182 dry-run S01 採用)** | todo11.md | S-M | なし (`src/cli-pr-monitor/src/runner.rs:80-89` の `combine_output` 8 行関数が `#[allow(dead_code)]` 付与で生産未使用、同関数が 4 他 crate (cli-push-runner, cli-push-pipeline, cli-merge-pipeline, hooks-post-tool-linter) にも複製 = 5 crate 横断 systemic duplication、ADR-026 Cargo workspace + ADR-012 lib-* naming で解決、Phase B dogfood の最初の実体ベース finding (A01 と並ぶ)、A01 は PR #183 で fix 済) | | 176 | 🔧 Tier 2 | **check-ci-coderabbit format extraction 関数への variant fixture 追加 (PR #185 T2-#4 採用)** | todo10.md | M | なし (順位 167-169 Bundle CR-RL の follow-up、bold-wrapper variant (`**More reviews will be available**`) / 短形態 (secs のみ) / 複数 separator / wait time なし graceful failure の 4 fixture 追加、PR #182 + #185 の 2 PR 連続観測で CR format 多様性 systemic、`extract_old_format_wait_time` / `extract_new_format_wait_time` の coverage gap 補填、regex 拡張 vs fixture 先行の 2 アプローチを着手時判断、analyzer rationale の「Edit 集中 = test gap signal」は incidental で採用根拠から除外、true 採用根拠は format 多様性 + 防御的 variant) | -| 177 | 🚀 **Tier 1 (優先実装)** | **PostToolUse hook — Edit / Write したファイルのサイズ閾値超過を検出してファイル分割を促す (2026-05-29 ユーザー追加要望、2026-06-06 優先度引上げ)** | todo10.md | S-M | なし (**Status update 2026-06-06**: 本セッションの todo9.md → todo11.md 手動分割で **4 回目の同型観測** = Very High frequency 達成、PR #133 / #172 / #186 + 本セッション、ユーザー指示で Tier 1 内優先実装に格上げ、Bundle 195-FB-Followup の次の最優先候補、直近 PR で消化推奨、PostToolUse Edit/Write 直後にサイズチェックを mechanical 強制、`.claude/hooks-config.toml` の `[post_tool_use.file_size_check]` で `enabled = false` default OFF (ADR-039 opt-in) + `threshold_bytes` (default 51200 = 50KB) + `paths` glob + `touch_trigger` ratchet を設定可能、配置先は option A = 新 binary `hooks-post-tool-file-size-check` / option B = 既存 `hooks-post-tool-linter` 統合の 2 案を着手時判断、ADR-007 custom-linter layer boundary に位置付け追記) | | 178 | 🔧 Tier 2 | **`state.rs` の behavioral invariant test を ADR-041 pattern で追加 (週次レビュー 2026-05-30 S02 採用)** | todo10.md | S | なし (Phase D dogfood で発見、`src/cli-pr-monitor/src/state.rs:226-510` の test が JSON round-trip のみ、`rate_limit=Some` 時 CI 更新 skip 等の behavioral invariant 未検証、ADR-041 sentinel 事前投入 + mutation 不在 assert pattern で 3-5 test 追加、memory `feedback_test_dry_antipattern` 適用、Effort S で high value catches state regression) | | 179 | 🔧 Tier 2 | **rate-limit retry decision boundary test を rstest parameterized で追加 (週次レビュー 2026-05-30 S03 採用)** | todo10.md | S | なし (Phase D dogfood で発見、`src/cli-pr-monitor/src/config.rs:94-122` + `stages/poll.rs` の `max_retries=3` 固定 test のみで boundary (0/1/3/off-by-one) 未検証、rstest parameterized で 3-4 case 追加 ~15 行、rstest 既存使用 + Bundle CR-RL = 順位 167-169 隣接領域 follow-up、off-by-one regression が test で検出可能化) | | 180 | 🔧 Tier 2 | **`lib-report-formatter` に markdown pipe / newline escape を追加 (週次レビュー 2026-05-30 C01 採用)** | todo10.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 化したため継続価値高) | @@ -85,6 +84,4 @@ **戦略**: Tier 1 を 2〜3 セッションで片付け → Tier 2 で ADR-032 の前提 + rate-limit + convergence cost 削減を進める → Tier 3 で ADR-032 を land + ドキュメント整備。Tier 4-5 は cleanup / 外部展開で daily efficiency への直接効果は小さい。 -**直近優先 (2026-06-06 ユーザー指示)**: **順位 177** (PostToolUse hook ファイルサイズ検出) は **4 回目の同型観測 (Very High frequency)** に到達したため Tier 1 内でも最優先。Bundle 195-FB-Followup (順位 193 + 194) の次の PR で消化推奨。todo.md / docs ファイル分割を user 判断ベースから mechanical layer に移管することで、認知負荷削減 + 早期検出が実現する。 - **Bundle 履歴**: 完了済 Bundle / post-merge-feedback 反映の経緯詳細は [docs/bundle-history.md](bundle-history.md) を参照 (2026-05-25 分離、本ファイルの index 責務集中のため)。 diff --git a/docs/todo10.md b/docs/todo10.md index 130aa40..c544aaa 100644 --- a/docs/todo10.md +++ b/docs/todo10.md @@ -64,88 +64,6 @@ regex 拡張アプローチ (#1) vs fixture のみ追加 (#2) の選択。本タ --- -### PostToolUse hook — Edit / Write したファイルのサイズ閾値超過を検出してファイル分割を促す (2026-05-29 ユーザー追加要望) - -> **動機**: 本セッション (PR #181 → #182 → #183 → #184 → #185 chain) で **docs/todo9.md が 50KB 超 + 1168 行に到達し読み取り安定性に支障**、user 判断で docs/todo10.md に split した実体観測がある (本ファイル自身がその split 結果)。同型の問題はこれまでも todo.md → todo2.md (PR #133) / todo8.md → todo9.md (PR #172) で繰り返し発生しており、現状は user 判断ベースでファイル分割している。**PostToolUse hook で Edit / Write 直後にサイズチェックを自動化** し、閾値超過時にファイル分割を促す error feedback を出すことで、user が認知負荷で気づく前に mechanical layer で promote できる構造的改善。 -> -> **本タスクの位置づけ**: PR #185 land 後の本セッション内 user 追加要望 (2026-05-29、post-merge-feedback 経由ではない直接タスク化)。memory `feedback_pipeline_over_rules` の体系適用 — user が「ファイル大きくなりすぎたら split する」を rule で覚えるのではなく、hook で機械強制する。touch-trigger ratchet pattern (= 既存超過ファイルは触られるまで grandfather) で backward compat 確保。 -> -> **Status update (2026-06-06、優先度引上げ)**: 本セッション (stale-cleanup + todo9.md → todo11.md 手動分割) で **4 回目の同型観測** が発生 (PR #133 / #172 / #186 + 本セッション)。Frequency が Medium → **Very High** に格上げ、CLAUDE.md `~/.claude/rules/common/code-review.md § 同型 finding の閾値判定` で「2 件以上同 PR 内で見つかった時点で `medium+` 扱いに昇格」「3 観測 = Tier 1 昇格」を超え、systemic risk の閾値に達している。**Bundle 195-FB-Followup (順位 193 + 194) の次の最優先候補**、ユーザー指示 (2026-06-06) で Tier 1 内でも優先実装対象に格上げ。直近 PR で消化推奨。 -> -> **参照**: `src/hooks-post-tool-comment-lint-rust/` (PostToolUse hook 既存実装、関数長 50 行制限の touch-trigger ratchet 参考)、`src/hooks-post-tool-linter/` (汎用 linter hook 既存)、`.claude/hooks-config.toml` の `[post_tool_use]` config 構造、PR #133 (todo.md → todo2.md split) / PR #172 (todo8.md → todo9.md split) / PR #186 (todo9.md → todo10.md split) / 本セッション 2026-06-06 (todo9.md → todo11.md split) の **4 PR 観測 (Very High frequency)** -> -> **実行優先度**: 🚀 **Tier 1 (優先実装)** — Effort S-M。`hooks-config.toml` への新 sub-feature 追加 + hook binary の Edit/Write 拡張で完結、touch-trigger ratchet で既存超過 grandfather。**2026-06-06 ユーザー指示で Tier 1 内でも優先実装に格上げ** (4 観測 = Very High frequency 達成)。 - -#### 設計決定 (案) - -##### 1. 配置先 (2 案、着手時判断) - -- **option A**: 新 hook binary `hooks-post-tool-file-size-check` を新設。専用性高く責務分離明確、ADR-026 Cargo workspace の lib-* / hooks-* pattern に整合 -- **option B**: 既存 `hooks-post-tool-linter` (generic linter) に新 check として統合。新 binary 追加せず Edit/Write 1 hook で済む、deploy 簡素 - -option B が Effort S 寄り、option A が将来拡張 (例: バイナリサイズ / generated ファイルサイズ等の別 check と分離) しやすい。 - -##### 2. config schema - -`.claude/hooks-config.toml` に新 section: - -```toml -# [post_tool_use.file_size_check] -# Edit / Write 直後にファイルサイズを確認し、threshold 超過なら error で -# split を促す。touch-trigger ratchet で既存超過ファイルは grandfather。 -[post_tool_use.file_size_check] -enabled = false # ADR-039 opt-in (default OFF、repo config で明示 enable) -threshold_bytes = 51200 # default 50KB (= 50 * 1024 bytes) -# 対象ファイル glob。default は markdown + Rust source。 -paths = ["docs/**/*.md", "src/**/*.rs"] -# touch-trigger ratchet: true = 既存超過ファイルは触られるまで grandfather (= 触られたら即チェック) -# false = strict mode (= 触ったかどうかに関わらず全 enabled paths を毎回チェック) -touch_trigger = true -``` - -##### 3. 動作仕様 - -- PostToolUse Edit / Write 直後に発火 -- 編集された file path が `paths` glob に match するか確認 (no match → skip) -- file size が `threshold_bytes` 超過か確認 (no 超過 → skip) -- 超過時の error 出力 (stderr JSON で hook protocol に整合): - - error message: `": ファイルサイズ bytes が threshold bytes を超過しています。ファイル分割を推奨します。"` - - recovery hint: `"docs/todo*.md の場合は新 todo.md を新設、Rust source の場合は module 分割を検討。"` - - kill-switch: `enabled = false` で完全停止 (ADR-039 § Kill-switch 整合、診断メッセージは実装の受理値を網羅する原則も適用) - -##### 4. touch-trigger ratchet の意義 - -- 既存 `docs/todo.md` (~30KB) や `docs/todo8.md` (~50KB 弱) など、本 hook 導入時に閾値近辺のファイルが存在する -- `touch_trigger = true` (default) なら未編集ファイルは grandfather、編集した瞬間にチェックが発火 = 「触ったら直す」原則 -- `touch_trigger = false` (strict) は全 enabled paths を毎回 fail する可能性 = 導入直後に大量 error を生む、適用は dogfood 後に判断 - -#### 作業計画 - -- [ ] 配置先選定 (option A = 新 binary vs option B = 既存 linter 統合) を `src/hooks-post-tool-linter/` の structure を Read で確認して決定 -- [ ] `.claude/hooks-config.toml` に `[post_tool_use.file_size_check]` section を追加 (default OFF、上記 config schema) -- [ ] hook binary 実装: Edit / Write path 取得 → glob match → size 確認 → error/PASS -- [ ] memory `feedback_test_dry_antipattern` 適用の test 追加: enabled=false / paths 不一致 / size 未超過 / size 超過 / touch_trigger=false の 5+ variant 独立 setup -- [ ] cargo clippy + cargo test pass 確認 -- [ ] dogfood: 本タスク実装後に `docs/todo10.md` を意図的に閾値超過させて hook が error を返すことを実観測 -- [ ] `pnpm build:all` + `pnpm deploy:hooks` で派生プロジェクト 2 件 (techbook-ledger / auto-review-fix-vc) へ配布判断 (各派生プロジェクトの `hooks-config.toml` で個別 enable / disable 制御可能) -- [ ] ADR-007 (custom-linter layer boundary) に本 hook の位置付けを 2-3 行追記 (= ファイルサイズは AST 解析不要の正規表現未満の単純 check 層に位置) -- [ ] 本エントリ削除 + todo-summary.md 行削除 - -#### 完了基準 - -- PostToolUse Edit / Write で対象 path 編集 → サイズ閾値超過時に error 通知が出る -- `enabled = false` で完全停止可能 (kill-switch、ADR-039 整合) -- threshold_bytes / paths / touch_trigger が config から設定可能 -- touch-trigger ratchet で既存超過ファイルは未編集なら grandfather -- 5+ variant test で各分岐独立検証 -- `cargo clippy --workspace -- -D warnings` clean (順位 175 land 後は stop_quality でも mechanical 強制) - -#### 詰まっている箇所 - -なし。Effort S-M で structural improvement、本セッション体験の直接対策。配置先 option A vs B のみが着手時の判断点。 - ---- - ### `state.rs` の behavioral invariant test を ADR-041 pattern で追加 (週次レビュー 2026-05-30 S02 採用) > **動機**: 週次レビュー WR-2026-05-30-S02 で検出。`src/cli-pr-monitor/src/state.rs:226-510` の test は JSON round-trip (serde 直列化 / 逆直列化) のみを検証し、**behavioral invariant** (例: `rate_limit` が `Some` の場合に `update_state_from_check_result()` が `ci` field を populate しない) を test していない。状態遷移 regression が test suite を通り抜ける構造的リスク。ADR-041 (Test Isolation Patterns for Multi-Condition Guards) で確立された「sentinel 事前投入 + mutation 不在を assert」pattern が本リポジトリの canonical 対策。 diff --git a/src/hooks-post-tool-linter/src/main.rs b/src/hooks-post-tool-linter/src/main.rs index 5a98558..53bbd5a 100644 --- a/src/hooks-post-tool-linter/src/main.rs +++ b/src/hooks-post-tool-linter/src/main.rs @@ -47,6 +47,7 @@ struct HookSpecificOutput { #[derive(Deserialize, Default)] struct Config { post_tool_linter: Option, + post_tool_use: Option, } #[derive(Deserialize, Default)] @@ -54,6 +55,56 @@ struct PostToolLinterConfig { pipelines: Option>, } +/// `[post_tool_use]` section: PostToolUse hook の non-linter sub-features. +/// +/// 順位 177 (PR #197 で Tier 1 (優先実装) に格上げ済) で「ファイルサイズ閾値検出」を追加。 +/// 既存 `[post_tool_linter]` (Layer 1 = custom-rules / Layer 2 = pipeline) とは独立した +/// Layer 0.5 として動作する。ADR-039 opt-in pattern 準拠で default OFF。 +#[derive(Deserialize, Default)] +struct PostToolUseConfig { + file_size_check: Option, +} + +/// `[post_tool_use.file_size_check]` section. +/// +/// PostToolUse Edit / Write 直後にファイルサイズを確認し、threshold 超過時に +/// additionalContext で分割を促す。touch-trigger ratchet (default true) で +/// 既存超過ファイルは触られるまで grandfather される。 +/// +/// 由来: 4 PR 観測 (#133 / #172 / #186 / #197) で systemic risk = Very High frequency。 +/// ADR-039 § 3 Bounded lifetime: 3-5 PR の dogfood 後に default-ON 昇格 or 却下を判定。 +#[derive(Deserialize, Clone)] +struct FileSizeCheckConfig { + /// ADR-039 § kill-switch: `false` で完全停止 (default false = opt-in)。 + #[serde(default)] + enabled: bool, + /// Threshold (bytes). Default 51200 = 50KB (Claude Code 読み取り安定性閾値)。 + #[serde(default = "default_file_size_threshold_bytes")] + threshold_bytes: u64, + /// 対象ファイルの glob list。default は markdown + Rust source。 + /// glob syntax は `compile_paths_glob()` ドキュメント参照。 + #[serde(default = "default_file_size_paths")] + paths: Vec, + /// touch-trigger ratchet: `true` (default) なら触られたファイルのみチェック = + /// 既存超過ファイルは未編集なら grandfather。`false` (strict) は将来の拡張で + /// 「全 enabled paths を毎回スキャン」を予定 (MVP では受理のみ、挙動は true と同じ)。 + #[serde(default = "default_file_size_touch_trigger")] + #[allow(dead_code)] + touch_trigger: bool, +} + +fn default_file_size_threshold_bytes() -> u64 { + 51200 +} + +fn default_file_size_paths() -> Vec { + vec!["docs/**/*.md".to_string(), "src/**/*.rs".to_string()] +} + +fn default_file_size_touch_trigger() -> bool { + true +} + #[derive(Deserialize, Clone)] struct PipelineConfig { extensions: Vec, @@ -324,6 +375,63 @@ fn combine_output(stdout: &str, stderr: &str) -> String { } } +/// 順位 177 (PR #197 で Tier 1 (優先実装) 格上げ済): +/// PostToolUse Edit / Write 直後にファイルサイズ閾値超過を検出して分割を促す。 +/// +/// 戻り値: +/// - `Some(message)`: feedback として emit する内容 (size 超過時) +/// - `None`: 無効化 / glob 不一致 / size 閾値内 / ファイル読込失敗のいずれか (no-op) +/// +/// touch-trigger ratchet: MVP では `touch_trigger` フィールドは受理のみ、true/false いずれも +/// 「触られたファイルのみチェック」(= true の挙動) に統一。strict mode (= 全 enabled paths を +/// 毎回スキャン) は ADR-039 bounded lifetime dogfood 後に拡張予定。 +fn check_file_size_threshold( + file: &str, + size_bytes: u64, + config: &FileSizeCheckConfig, +) -> Option { + if !config.enabled { + return None; + } + + let glob_set = match compile_paths_glob(&Some(config.paths.clone())) { + Ok(Some(g)) => g, + Ok(None) => return None, + Err(msg) => { + eprintln!( + "[post-tool-linter] Warning: file_size_check paths glob compile failed: {}", + msg + ); + return None; + } + }; + let normalized = file.replace('\\', "/"); + if !glob_set.is_match(&normalized) { + return None; + } + + if size_bytes <= config.threshold_bytes { + return None; + } + + let recovery_hint = if normalized.contains("docs/todo") && normalized.ends_with(".md") { + " (docs/todo*.md の場合は新 todo.md を新設して entry を移管)" + } else if normalized.ends_with(".rs") { + " (Rust source の場合は module 分割を検討)" + } else { + "" + }; + + Some(format!( + "[file-size-check] {}: ファイルサイズ {} bytes が threshold {} bytes (= {:.1} KB) を超過しています。ファイル分割を推奨します{}.", + file, + size_bytes, + config.threshold_bytes, + config.threshold_bytes as f64 / 1024.0, + recovery_hint + )) +} + /// フィードバック JSON を stdout に出力 fn emit_feedback(message: &str) { let output = HookOutput { @@ -648,63 +756,89 @@ fn check_utf8_integrity(file: &str) -> Vec { violations } -fn main() { - let config = load_config(); - - // stdin を消費(フックの仕様上必須) +fn read_hook_input_file() -> Option { let mut input = String::new(); if let Err(e) = io::stdin().read_to_string(&mut input) { eprintln!("[post-tool-linter] Warning: Failed to read stdin: {}", e); - return; + return None; } - - let hook_input: HookInput = match serde_json::from_str(&input) { - Ok(v) => v, - Err(_) => return, - }; - + let hook_input: HookInput = serde_json::from_str(&input).ok()?; let file = hook_input .tool_input .and_then(|t| t.file_path.filter(|s| !s.is_empty()).or(t.path)) .unwrap_or_default(); - if file.is_empty() { - return; + None + } else { + Some(file) } +} - // 第0層: UTF-8 整合性チェック (全ファイル対象, ~1ms) - let utf8_violations = check_utf8_integrity(&file); - if !utf8_violations.is_empty() { - let feedback = format!( - "[utf8-integrity] {} violation(s) found:\n{}", - utf8_violations.len(), - utf8_violations.join("\n") - ); - emit_feedback(&feedback); +fn run_utf8_layer(file: &str) -> bool { + let utf8_violations = check_utf8_integrity(file); + if utf8_violations.is_empty() { + return false; + } + let feedback = format!( + "[utf8-integrity] {} violation(s) found:\n{}", + utf8_violations.len(), + utf8_violations.join("\n") + ); + emit_feedback(&feedback); + true +} + +fn run_file_size_layer(file: &str, config: &Config) { + let Some(size_config) = config + .post_tool_use + .as_ref() + .and_then(|c| c.file_size_check.as_ref()) + else { + return; + }; + let Ok(metadata) = std::fs::metadata(file) else { return; + }; + if let Some(message) = check_file_size_threshold(file, metadata.len(), size_config) { + emit_feedback(&message); } +} - // 第1層: カスタムルール (正規表現ベース, ~1ms) +fn run_custom_rules_layer(file: &str) { let compiled_rules = load_custom_rules(); - let violations = run_custom_rules(&file, &compiled_rules); - if !violations.is_empty() { - let feedback = format!( - "[custom-lint] {} violation(s) found:\n{}", - violations.len(), - violations.join("\n") - ); - emit_feedback(&feedback); + let violations = run_custom_rules(file, &compiled_rules); + if violations.is_empty() { + return; } + let feedback = format!( + "[custom-lint] {} violation(s) found:\n{}", + violations.len(), + violations.join("\n") + ); + emit_feedback(&feedback); +} - // 第2層: 外部ツールパイプライン (biome, oxlint, ruff 等) +fn run_pipeline_layer(file: &str, config: Config) { let pipelines = config .post_tool_linter .and_then(|c| c.pipelines) .unwrap_or_else(default_pipelines); + if let Some(pipeline) = find_pipeline(file, &pipelines) { + run_pipeline(file, pipeline); + } +} - if let Some(pipeline) = find_pipeline(&file, &pipelines) { - run_pipeline(&file, pipeline); +fn main() { + let config = load_config(); + let Some(file) = read_hook_input_file() else { + return; + }; + if run_utf8_layer(&file) { + return; } + run_file_size_layer(&file, &config); + run_custom_rules_layer(&file); + run_pipeline_layer(&file, config); } #[cfg(test)] @@ -3081,4 +3215,137 @@ extensions = ["ts", "js"] gaps.join("\n - ") ); } + + #[test] + fn file_size_check_skips_when_disabled() { + let config = FileSizeCheckConfig { + enabled: false, + threshold_bytes: 1_000, + paths: vec!["docs/**/*.md".to_string()], + touch_trigger: true, + }; + let result = check_file_size_threshold("docs/sample.md", 100_000, &config); + assert!( + result.is_none(), + "enabled=false must short-circuit even when size exceeds threshold" + ); + } + + #[test] + fn file_size_check_skips_when_path_does_not_match_glob() { + let config = FileSizeCheckConfig { + enabled: true, + threshold_bytes: 1_000, + paths: vec!["docs/**/*.md".to_string(), "src/**/*.rs".to_string()], + touch_trigger: true, + }; + let result = check_file_size_threshold("scripts/build.sh", 100_000, &config); + assert!( + result.is_none(), + "path not matching glob must skip even when size exceeds threshold" + ); + } + + #[test] + fn file_size_check_skips_when_size_within_threshold() { + let config = FileSizeCheckConfig { + enabled: true, + threshold_bytes: 1_000, + paths: vec!["docs/**/*.md".to_string()], + touch_trigger: true, + }; + let result = check_file_size_threshold("docs/small.md", 500, &config); + assert!( + result.is_none(), + "size within threshold (500 <= 1000) must skip" + ); + } + + #[test] + fn file_size_check_emits_message_when_size_exceeds_threshold() { + let config = FileSizeCheckConfig { + enabled: true, + threshold_bytes: 1_000, + paths: vec!["src/**/*.rs".to_string()], + touch_trigger: true, + }; + let result = check_file_size_threshold("src/big.rs", 5_000, &config); + let message = result.expect("size 5000 > threshold 1000 must emit feedback message"); + assert!(message.contains("file-size-check")); + assert!(message.contains("5000")); + assert!(message.contains("1000")); + assert!(message.contains("module 分割")); + } + + #[test] + fn file_size_check_emits_todo_recovery_hint_for_docs_todo_files() { + let config = FileSizeCheckConfig { + enabled: true, + threshold_bytes: 51_200, + paths: vec!["docs/**/*.md".to_string()], + touch_trigger: true, + }; + let result = check_file_size_threshold("docs/todoXYZ.md", 60_000, &config); + let message = result.expect("60KB > 50KB threshold must emit"); + assert!( + message.contains("todo.md"), + "docs/todo* prefix path should get the todo split hint, got: {}", + message + ); + } + + #[test] + fn file_size_check_returns_none_when_paths_glob_is_empty() { + let config = FileSizeCheckConfig { + enabled: true, + threshold_bytes: 1_000, + paths: vec![], + touch_trigger: true, + }; + let result = check_file_size_threshold("docs/anything.md", 100_000, &config); + assert!( + result.is_none(), + "empty paths glob must skip (no targets configured)" + ); + } + + #[test] + fn file_size_check_treats_touch_trigger_false_same_as_true_in_mvp() { + let mut cfg_strict = FileSizeCheckConfig { + enabled: true, + threshold_bytes: 51_200, + paths: vec!["docs/**/*.md".to_string()], + touch_trigger: false, + }; + let result_strict = check_file_size_threshold("docs/oversized.md", 60_000, &cfg_strict); + cfg_strict.touch_trigger = true; + let result_ratchet = check_file_size_threshold("docs/oversized.md", 60_000, &cfg_strict); + assert!( + result_strict.is_some(), + "touch_trigger=false (MVP) must still emit for touched file" + ); + assert!( + result_ratchet.is_some(), + "touch_trigger=true must emit for touched file" + ); + assert_eq!( + result_strict, result_ratchet, + "MVP: touch_trigger=false behaves identically to true (strict mode = future work)" + ); + } + + #[test] + fn file_size_check_normalizes_windows_backslash_path() { + let config = FileSizeCheckConfig { + enabled: true, + threshold_bytes: 1_000, + paths: vec!["docs/**/*.md".to_string()], + touch_trigger: true, + }; + let result = check_file_size_threshold(r"docs\win.md", 60_000, &config); + assert!( + result.is_some(), + "Windows backslash path must be normalized to forward slash for glob match" + ); + } }