diff --git a/.claude/custom-lint-rules.toml b/.claude/custom-lint-rules.toml new file mode 100644 index 00000000..bcf62d4b --- /dev/null +++ b/.claude/custom-lint-rules.toml @@ -0,0 +1,43 @@ +# custom-lint-rules.toml — プロジェクト固有のカスタムリントルール +# +# このファイルは .claude/ 直下に配置し、hooks-post-tool-linter が読み込みます。 +# プロジェクトごとに異なるルールセットを定義できます。 +# +# ── ルール定義 ── +# +# [[rules]] +# id = "ルールID(一意)" +# pattern = "検出する正規表現パターン" +# severity = "error" | "warning" +# message = "違反時のメッセージ" +# why = "なぜこのルールが必要か(ADR参照など)" +# extensions = ["対象ファイル拡張子"] +# +# [rules.fix] +# strategy = "修正戦略の概要" +# steps = ["修正手順1", "修正手順2"] +# +# [rules.example] +# bad = "NG例コード" +# good = "OK例コード" + +# ─── ルール①: console.log 禁止 ─── + +[[rules]] +id = "no-console-log" +pattern = 'console\.\s*log\s*\(' +severity = "error" +message = "console.log は本番コードでは使用禁止です" +why = "デバッグコードの残留防止。本番環境では構造化ログ(logger)を使用してください" +extensions = ["ts", "tsx", "js", "jsx"] + +[rules.fix] +strategy = "削除 or logger置換" +steps = [ + "デバッグ目的の場合: console.log 行を削除する", + "ログ出力が必要な場合: logger.debug() / logger.info() に置き換える", +] + +[rules.example] +bad = "console.log('user data:', userData);" +good = "logger.debug('user data:', userData);" diff --git a/.claude/hooks-config.toml b/.claude/hooks-config.toml index b986b85a..3a4e83fc 100644 --- a/.claude/hooks-config.toml +++ b/.claude/hooks-config.toml @@ -24,6 +24,11 @@ blocked_patterns = [ # 追加の保護ファイル (デフォルトリストに追加) extra_protected_files = [] +# ─── PostToolUse: カスタムリンター ─── +# プロジェクト固有のカスタムルールは custom-lint-rules.toml に定義。 +# 正規表現ベースのリテラルマッチのみ。AST解析が必要なルールは +# ast-grep を下記パイプラインのステップとして追加する (ADR-007)。 + # ─── PostToolUse: リンター ─── [post_tool_linter] diff --git a/.claude/hooks-post-tool-linter/Cargo.lock b/.claude/hooks-post-tool-linter/Cargo.lock index 77e8a0c0..d3742ff1 100644 --- a/.claude/hooks-post-tool-linter/Cargo.lock +++ b/.claude/hooks-post-tool-linter/Cargo.lock @@ -2,27 +2,112 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hooks-post-tool-linter" version = "0.1.0" dependencies = [ + "regex", "serde", "serde_json", + "tempfile", "toml", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.13.0" @@ -30,7 +115,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -39,12 +126,52 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -63,6 +190,60 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -126,6 +307,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "toml" version = "0.8.23" @@ -173,6 +367,79 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "0.7.15" @@ -182,6 +449,94 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/.claude/hooks-post-tool-linter/Cargo.toml b/.claude/hooks-post-tool-linter/Cargo.toml index 27fa552f..4fcd4295 100644 --- a/.claude/hooks-post-tool-linter/Cargo.toml +++ b/.claude/hooks-post-tool-linter/Cargo.toml @@ -7,6 +7,10 @@ edition = "2021" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" +regex = "1.10" + +[dev-dependencies] +tempfile = "3" [profile.release] opt-level = 3 diff --git a/.claude/hooks-post-tool-linter/src/main.rs b/.claude/hooks-post-tool-linter/src/main.rs index 5017a417..6be91d87 100644 --- a/.claude/hooks-post-tool-linter/src/main.rs +++ b/.claude/hooks-post-tool-linter/src/main.rs @@ -6,6 +6,7 @@ //! .claude/hooks-config.toml の [post_tool_linter] セクションから //! 拡張子ごとのパイプラインを読み込みます。 +use regex::Regex; use serde::{Deserialize, Serialize}; use std::io::{self, Read}; use std::path::{Path, PathBuf}; @@ -65,6 +66,70 @@ struct StepConfig { fix: bool, } +// --- カスタムルール設定 (custom-lint-rules.toml) --- + +#[derive(Deserialize, Default)] +struct CustomRulesConfig { + rules: Option>, +} + +#[derive(Deserialize, Clone)] +struct CustomRule { + id: String, + pattern: String, + severity: String, + message: String, + #[serde(default)] + why: String, + extensions: Vec, + fix: Option, + example: Option, +} + +#[derive(Deserialize, Clone)] +struct CustomRuleFix { + strategy: String, + steps: Vec, +} + +#[derive(Deserialize, Clone)] +struct CustomRuleExample { + bad: String, + good: String, +} + +// --- カスタムルール構造化出力 (additionalContext 用) --- + +#[derive(Serialize)] +struct LintViolation { + r#type: String, + severity: String, + location: ViolationLocation, + message: String, + why: String, + fix: ViolationFix, + example: ViolationExample, +} + +#[derive(Serialize)] +struct ViolationLocation { + file: String, + line: usize, + symbol: String, +} + +#[derive(Serialize)] +struct ViolationFix { + strategy: String, + steps: Vec, +} + +#[derive(Serialize)] +struct ViolationExample { + bad: String, + good: String, +} + /// デフォルトパイプライン (設定ファイルが無い場合のフォールバック) fn default_pipelines() -> Vec { vec![ @@ -189,6 +254,118 @@ fn run_pipeline(file: &str, pipeline: &PipelineConfig) { } } +/// カスタムルール設定ファイルのパス解決 +fn custom_rules_path() -> PathBuf { + std::env::current_exe() + .unwrap_or_default() + .parent() + .unwrap_or(Path::new(".")) + .join("custom-lint-rules.toml") +} + +/// コンパイル済み正規表現を持つルール +struct CompiledRule { + rule: CustomRule, + regex: Regex, +} + +/// カスタムルール設定を読み込み、正規表現をプリコンパイルする +fn load_custom_rules() -> Vec { + let path = custom_rules_path(); + let rules = match std::fs::read_to_string(&path) { + Ok(content) => { + let config: CustomRulesConfig = toml::from_str(&content).unwrap_or_else(|e| { + eprintln!("[post-tool-linter] Warning: Failed to parse {}: {}", path.display(), e); + CustomRulesConfig::default() + }); + config.rules.unwrap_or_default() + } + Err(_) => return Vec::new(), + }; + + 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() +} + +/// カスタムルール違反の最大出力件数 (外部ツール診断の20行制限と同等) +const MAX_CUSTOM_VIOLATIONS: usize = 20; + +/// ファイル拡張子がルールの対象かチェック +fn rule_matches_ext(rule: &CustomRule, file: &str) -> bool { + let ext = Path::new(file) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()); + + match ext { + Some(ext) => rule.extensions.iter().any(|e| e.to_lowercase() == ext), + None => false, + } +} + +/// カスタムルールをファイルに適用し、構造化された違反 JSON を返す +fn run_custom_rules(file: &str, rules: &[CompiledRule]) -> Vec { + let content = match std::fs::read_to_string(file) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + let mut violations = Vec::new(); + + for compiled in rules { + if !rule_matches_ext(&compiled.rule, file) { + continue; + } + + for (line_idx, line) in content.lines().enumerate() { + if violations.len() >= MAX_CUSTOM_VIOLATIONS { + break; + } + + if let Some(m) = compiled.regex.find(line) { + 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_idx + 1, + 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 violations.len() >= MAX_CUSTOM_VIOLATIONS { + break; + } + } + + violations +} + fn main() { let config = load_config(); @@ -213,6 +390,19 @@ fn main() { return; } + // 第1層: カスタムルール (正規表現ベース, ~1ms) + 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); + } + + // 第2層: 外部ツールパイプライン (biome, oxlint, ruff 等) let pipelines = config .post_tool_linter .and_then(|c| c.pipelines) @@ -409,4 +599,269 @@ mod tests { assert!(!last.fix, "Last step should be a check (fix=false)"); } } + + // --- カスタムルール: ルール拡張子マッチ --- + + fn make_test_rule(id: &str, pattern: &str, extensions: &[&str]) -> CustomRule { + CustomRule { + id: id.into(), + pattern: pattern.into(), + severity: "error".into(), + message: "test message".into(), + why: "test reason".into(), + extensions: extensions.iter().map(|e| e.to_string()).collect(), + fix: Some(CustomRuleFix { + strategy: "test strategy".into(), + steps: vec!["step1".into()], + }), + example: Some(CustomRuleExample { + bad: "bad code".into(), + good: "good code".into(), + }), + } + } + + #[test] + fn rule_matches_ts_extension() { + let rule = make_test_rule("test", "pattern", &["ts", "tsx"]); + assert!(rule_matches_ext(&rule, "src/app.ts")); + assert!(rule_matches_ext(&rule, "src/App.tsx")); + } + + #[test] + fn rule_does_not_match_other_extension() { + let rule = make_test_rule("test", "pattern", &["ts"]); + assert!(!rule_matches_ext(&rule, "main.rs")); + assert!(!rule_matches_ext(&rule, "style.css")); + } + + #[test] + fn rule_matches_case_insensitive() { + let rule = make_test_rule("test", "pattern", &["ts"]); + assert!(rule_matches_ext(&rule, "file.TS")); + assert!(rule_matches_ext(&rule, "file.Ts")); + } + + #[test] + fn rule_no_match_for_no_extension() { + let rule = make_test_rule("test", "pattern", &["ts"]); + assert!(!rule_matches_ext(&rule, "Makefile")); + assert!(!rule_matches_ext(&rule, "")); + } + + #[test] + fn rule_matches_windows_path() { + let rule = make_test_rule("test", "pattern", &["ts"]); + assert!(rule_matches_ext(&rule, r"e:\work\project\src\app.ts")); + } + + // --- カスタムルール: 違反検出 --- + + /// テスト用: 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() + } + + #[test] + fn run_custom_rules_detects_console_log() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("test.ts"); + { + let mut f = std::fs::File::create(&file).unwrap(); + writeln!(f, "const x = 1;").unwrap(); + writeln!(f, "console.log('debug');").unwrap(); + writeln!(f, "const y = 2;").unwrap(); + } + + let rules = compile_test_rules(vec![make_test_rule("no-console-log", r"console\.log\(", &["ts"])]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + + assert_eq!(violations.len(), 1); + let v: serde_json::Value = serde_json::from_str(&violations[0]).unwrap(); + assert_eq!(v["type"], "NO_CONSOLE_LOG"); + assert_eq!(v["severity"], "error"); + assert_eq!(v["location"]["line"], 2); + assert_eq!(v["message"], "test message"); + } + + #[test] + fn run_custom_rules_no_violation_on_clean_file() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("clean.ts"); + { + let mut f = std::fs::File::create(&file).unwrap(); + writeln!(f, "const x = 1;").unwrap(); + writeln!(f, "logger.info('message');").unwrap(); + } + + let rules = compile_test_rules(vec![make_test_rule("no-console-log", r"console\.log\(", &["ts"])]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + + assert!(violations.is_empty()); + } + + #[test] + fn run_custom_rules_skips_non_matching_extension() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("test.rs"); + { + let mut f = std::fs::File::create(&file).unwrap(); + writeln!(f, "console.log('should be ignored');").unwrap(); + } + + let rules = compile_test_rules(vec![make_test_rule("no-console-log", r"console\.log\(", &["ts"])]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + + assert!(violations.is_empty()); + } + + #[test] + fn run_custom_rules_multiple_violations() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("multi.ts"); + { + let mut f = std::fs::File::create(&file).unwrap(); + writeln!(f, "console.log('first');").unwrap(); + writeln!(f, "const x = 1;").unwrap(); + writeln!(f, "console.log('second');").unwrap(); + } + + let rules = compile_test_rules(vec![make_test_rule("no-console-log", r"console\.log\(", &["ts"])]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + + assert_eq!(violations.len(), 2); + let v1: serde_json::Value = serde_json::from_str(&violations[0]).unwrap(); + let v2: serde_json::Value = serde_json::from_str(&violations[1]).unwrap(); + assert_eq!(v1["location"]["line"], 1); + assert_eq!(v2["location"]["line"], 3); + } + + #[test] + fn run_custom_rules_respects_max_violations() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("many.ts"); + { + let mut f = std::fs::File::create(&file).unwrap(); + for i in 0..30 { + writeln!(f, "console.log('line {}');", i).unwrap(); + } + } + + let rules = compile_test_rules(vec![make_test_rule("no-console-log", r"console\.log\(", &["ts"])]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + + assert_eq!(violations.len(), MAX_CUSTOM_VIOLATIONS); + } + + #[test] + fn compile_test_rules_filters_invalid_regex() { + let rules = vec![ + make_test_rule("bad-rule", r"[invalid(", &["ts"]), + make_test_rule("good-rule", r"console\.log\(", &["ts"]), + ]; + let compiled = compile_test_rules(rules); + + // 不正な正規表現のルールはフィルタされ、有効なルールのみ残る + assert_eq!(compiled.len(), 1); + assert_eq!(compiled[0].rule.id, "good-rule"); + } + + #[test] + fn run_custom_rules_nonexistent_file() { + let rules = compile_test_rules(vec![make_test_rule("test", r"pattern", &["ts"])]); + let violations = run_custom_rules("/nonexistent/file.ts", &rules); + assert!(violations.is_empty()); + } + + // --- カスタムルール: 構造化 JSON 出力 --- + + #[test] + fn violation_json_has_all_fields() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("test.ts"); + { + let mut f = std::fs::File::create(&file).unwrap(); + writeln!(f, "console.log('x');").unwrap(); + } + + let rules = compile_test_rules(vec![make_test_rule("no-console-log", r"console\.log\(", &["ts"])]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + let v: serde_json::Value = serde_json::from_str(&violations[0]).unwrap(); + + // 記事のフォーマットに準拠した全フィールドの存在を確認 + assert!(v.get("type").is_some()); + assert!(v.get("severity").is_some()); + assert!(v.get("location").is_some()); + assert!(v["location"].get("file").is_some()); + assert!(v["location"].get("line").is_some()); + assert!(v["location"].get("symbol").is_some()); + assert!(v.get("message").is_some()); + assert!(v.get("why").is_some()); + assert!(v.get("fix").is_some()); + assert!(v["fix"].get("strategy").is_some()); + assert!(v["fix"].get("steps").is_some()); + assert!(v.get("example").is_some()); + assert!(v["example"].get("bad").is_some()); + assert!(v["example"].get("good").is_some()); + } + + // --- カスタムルール: TOML パース --- + + #[test] + fn parse_custom_rules_toml() { + let toml_str = r#" +[[rules]] +id = "no-console-log" +pattern = 'console\.log\(' +severity = "error" +message = "console.log は禁止" +why = "デバッグコード残留防止" +extensions = ["ts", "tsx"] + +[rules.fix] +strategy = "削除 or logger置換" +steps = ["console.log行を削除する"] + +[rules.example] +bad = "console.log('x');" +good = "logger.debug('x');" +"#; + + let config: CustomRulesConfig = toml::from_str(toml_str).unwrap(); + let rules = config.rules.unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].id, "no-console-log"); + assert_eq!(rules[0].severity, "error"); + assert_eq!(rules[0].extensions, vec!["ts", "tsx"]); + assert!(rules[0].fix.is_some()); + assert!(rules[0].example.is_some()); + } + + #[test] + fn parse_custom_rules_toml_minimal() { + let toml_str = r#" +[[rules]] +id = "no-todo" +pattern = "TODO" +severity = "warning" +message = "TODO残留" +extensions = ["ts", "js"] +"#; + + let config: CustomRulesConfig = toml::from_str(toml_str).unwrap(); + let rules = config.rules.unwrap(); + assert_eq!(rules.len(), 1); + assert!(rules[0].fix.is_none()); + assert!(rules[0].example.is_none()); + assert_eq!(rules[0].why, ""); + } } diff --git a/CLAUDE.md b/CLAUDE.md index 0a83dcc1..db54bb2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,7 @@ - [ADR-004: Stop フックによる品質ゲート](docs/adr/adr-004-stop-hook-quality-gate.md) - [ADR-005: hooks の exe パスをテンプレートから自動生成](docs/adr/adr-005-hooks-path-resolution-with-template.md) - [ADR-006: hooks の設定駆動型アーキテクチャ](docs/adr/adr-006-config-driven-hooks.md) +- [ADR-007: カスタムリンターの正規表現層/AST層の線引き](docs/adr/adr-007-custom-linter-layer-boundary.md) ## Build diff --git a/docs/adr/adr-007-custom-linter-layer-boundary.md b/docs/adr/adr-007-custom-linter-layer-boundary.md new file mode 100644 index 00000000..681e93e8 --- /dev/null +++ b/docs/adr/adr-007-custom-linter-layer-boundary.md @@ -0,0 +1,77 @@ +# ADR-007: カスタムリンターの正規表現層/AST層の線引き + +## ステータス + +承認済み (2026-03-29) + +## コンテキスト + +Claude Code の実装中に発見した禁止事項を都度カスタムリンタールールとしてビルドアップし、PostToolUse フックでフィードバックループを構築する方針を採用した(参考: harness-engineering-best-practices-2026)。 + +カスタムルールの検出エンジンとして「正規表現(Rust exe 内蔵)」と「AST 解析(ast-grep 外部委譲)」の 2 層構成を取るが、ルール追加時にどちらの層に配置するかの判断基準が必要。 + +### 考慮事項 + +- 正規表現は高速(~1ms)だがコメント・文字列リテラル内の誤検出リスクがある +- AST 解析は正確だがプロセス起動コスト(~50-100ms)が毎回発生する +- PostToolUse hook は Write/Edit のたびに発火するため、速度が体験に直結する +- ルールは段階的に増やすため、判断基準が曖昧だと層の責務が崩れる + +## 決定 + +### 判断フロー(3 問で決定) + +```text +Q1. 違反は 1 行だけ見て判定できるか? + └─ No → AST 層 + +Q2. コメント・文字列リテラル内の誤検出が問題になるか? + └─ Yes → AST 層 + +Q3. パターンはリテラル文字列のマッチのみで表現できるか? + (後読み・先読み・複雑なキャプチャグループは「No」) + └─ No → AST 層 + └─ Yes → 正規表現層 +``` + +3 問すべてが正規表現層を指す場合のみ正規表現層に配置する。**迷ったら AST 層に寄せる**。 + +### 正規表現層(Rust exe 内・custom-lint-rules.toml) + +- **用途**: リテラル文字列のマッチのみ +- **速度**: ~1ms(プロセス起動なし) +- **設定**: `.claude/custom-lint-rules.toml` +- **適用例**: + - `console.log(` — トークンが固定、コメント内に書く動機がない + - `from '../../../` — 深い相対パスの文字列マッチ + - `from '../infra/` — 禁止 import パスの文字列マッチ + - `TODO` / `FIXME` 残留検出 + +### AST 層(ast-grep 外部委譲・YAML ルールファイル) + +- **用途**: 構文上の文脈が必要なもの + 複雑なパターン +- **速度**: ~50-100ms(プロセス起動コスト) +- **設定**: `hooks-config.toml` のパイプラインステップ + ast-grep YAML ルール +- **適用例**: + - TypeScript `any` 型使用禁止(`company`, `many` 等の誤検出回避) + - 命名規則違反(変数宣言の文脈が必要) + - 特定メソッド呼び出し禁止(スコープ判定が必要) + - コメント・文字列内の誤検出が許容できないルール全般 + +### 配置の具体例 + +| ルール | Q1 (1行) | Q2 (誤検出) | Q3 (リテラル) | 配置 | +|--------|----------|------------|-------------|------| +| console.log 禁止 | Yes | No | Yes | 正規表現層 | +| 深い相対パス制限 | Yes | No | Yes | 正規表現層 | +| 禁止 import パス | Yes | No | Yes | 正規表現層 | +| any 型使用禁止 | Yes | **Yes** | - | AST 層 | +| 命名規則違反 | **No** | - | - | AST 層 | +| 未使用 export | **No** | - | - | AST 層 | + +## 影響 + +- ルール追加時の判断が 3 問のフローチャートで機械的に決定可能 +- 正規表現層は「シンプルで速い」、AST 層は「正確だが遅い」という責務分離が維持される +- 将来 ast-grep を導入する際、既存の正規表現ルールを移行する必要がない(責務が明確に分かれているため) +- `regex` crate 追加による exe サイズ増加は約 200KB 程度(許容範囲) diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 00000000..94acb829 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,57 @@ +/** + * 最小限の Logger 実装 + * + * カスタムリンター (no-console-log) で console.log を禁止しているため、 + * 内部実装は console.info / console.warn / console.error を使用する。 + * + * フル実装(ログレベル制御・フォーマット・出力先切替)は別PRで対応予定。 + */ + +type LogLevel = "debug" | "info" | "warn" | "error"; + +const LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +class Logger { + private level: LogLevel; + + constructor(level: LogLevel = "debug") { + this.level = level; + } + + private shouldLog(target: LogLevel): boolean { + return LEVEL_PRIORITY[target] >= LEVEL_PRIORITY[this.level]; + } + + debug(...args: unknown[]): void { + if (this.shouldLog("debug")) { + // console.info を使用: console.log はカスタムリンターで禁止 (ADR-007) + console.info("[DEBUG]", ...args); + } + } + + info(...args: unknown[]): void { + if (this.shouldLog("info")) { + console.info("[INFO]", ...args); + } + } + + warn(...args: unknown[]): void { + if (this.shouldLog("warn")) { + console.warn("[WARN]", ...args); + } + } + + error(...args: unknown[]): void { + if (this.shouldLog("error")) { + console.error("[ERROR]", ...args); + } + } +} + +export const logger = new Logger(); +export { Logger, LogLevel };