From a71c05431952808cd86b6b2b789f703b2e631d52 Mon Sep 17 00:00:00 2001 From: aloekun Date: Wed, 29 Apr 2026 14:51:10 +0900 Subject: [PATCH 1/4] feat(lint): add PowerShell + Markdown anchor rules to ADR-007 layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle 1 (post-merge-feedback の旧順位 3 + 7 を 1 PR に統合): - no-empty-powershell-catch (error): 空 `catch {}` ブロックでの swallowed error 検出 (PR #85 T1-2 finding) - no-silent-error-action (warning): `-ErrorAction SilentlyContinue` の検出 (PR #85 T1-2 finding、片方単独 warning) - no-mutable-anchor (warning): Markdown link の non-ASCII GFM fragment 検出 (PR #89 T1-1 finding) 実装: - .claude/custom-lint-rules.toml に 3 rule 追加 - src/hooks-post-tool-linter/src/main.rs に 13 unit test 追加 (#7 の 4 edge case + ps1 / extension filter 全網羅) - cargo test: 58 passed - dogfood で 3 rule すべて発火確認 設計判断: - ADR-007 既存 pattern (regex 層 / file 単位) に適合、ADR 更新不要 - #3 の "片方単独 warning / 組合せ error" spec は engine の per-line 設計で 実現できないため、severity を rule 別に分離 (empty catch=error / SilentlyContinue=warning) で精神を保つ - #7 は ADR-007 Q2 (string literal 誤検出) が borderline だが、MVP として regex 層採用。lookbehind 非対応のため backtick 内例は誤検出するが、 task entry 削除で clean baseline 達成 Bundle 戦略 (post-merge-feedback ループ収束のため): - 個別 PR なら 2 件 → 1 PR に統合 (50% 削減) - summary table を 27 → 25 行に renumber、Tier breakdown 全更新 Closes feedback: PR #85 T1-2, PR #89 T1-1 --- .claude/custom-lint-rules.toml | 89 ++++++++++++ docs/todo.md | 69 ++++----- docs/todo2.md | 35 ----- docs/todo3.md | 122 ++++++++++------ src/hooks-post-tool-linter/src/main.rs | 188 +++++++++++++++++++++++++ 5 files changed, 390 insertions(+), 113 deletions(-) diff --git a/.claude/custom-lint-rules.toml b/.claude/custom-lint-rules.toml index 4967191..747498c 100644 --- a/.claude/custom-lint-rules.toml +++ b/.claude/custom-lint-rules.toml @@ -72,3 +72,92 @@ steps = [ [rules.example] bad = '具体的なファイル所在: `C:\Users\alice\.claude\projects\\` 配下' good = '具体的なファイル所在: `%USERPROFILE%\.claude\projects\\` 配下' + +# ─── ルール③: PowerShell 空 catch ブロック禁止 (swallowed error) ─── +# +# 由来: PR #85 で `__parse_transcripts.ps1:8` の空 `catch {}` が CodeRabbit Major 指摘。 +# Review Policy 明示的 REJECT 項目「Swallowed errors」。エラー握り潰しは debug 困難化の +# 代表的アンチパターン。 +# +# 対応する swallowed error 検出は Bash / TypeScript には既存 lint があるが、 +# PowerShell は ADR-007 既存ルールの空白だった。 + +[[rules]] +id = "no-empty-powershell-catch" +pattern = 'catch\s*\{\s*\}' +severity = "error" +message = "空の catch {} ブロックでエラーを握り潰しています" +why = "swallowed error は debug 困難化を招く Review Policy 明示的 REJECT 項目。少なくともログ出力 / 再 throw / 上位への propagate のいずれかが必要" +extensions = ["ps1"] + +[rules.fix] +strategy = "ログ出力 or 再 throw or 上位 propagate のいずれかを追加" +steps = [ + "意図的に無視する場合: catch ブロックに理由をコメントで明記し Write-Verbose / Write-Debug を追加", + "原因を log する場合: catch { Write-Error $_ } にする", + "上位に伝播する場合: catch { throw } にする", + "そもそも try/catch 不要なら削除する", +] + +[rules.example] +bad = 'try { Get-Item $path } catch {}' +good = 'try { Get-Item $path } catch { Write-Verbose "expected miss: $_"; $null }' + +# ─── ルール④: PowerShell -ErrorAction SilentlyContinue 警告 ─── +# +# 由来: 同 PR #85。空 catch との組合せで二重に swallowed error を生む。 +# 単独使用は一部の存在チェック (例: Get-Item -ErrorAction SilentlyContinue) で +# 正当な場合があるため severity = "warning" に留める。 +# 空 catch (上の error) と同時に検出されたら、両方の violation が出力される。 + +[[rules]] +id = "no-silent-error-action" +pattern = '-ErrorAction\s+SilentlyContinue' +severity = "warning" +message = "-ErrorAction SilentlyContinue はエラー握り潰しのリスクがあります" +why = "存在チェックでは正当だが、結果を確認せずに使うと swallowed error。空 catch と組み合わさると二重に危険" +extensions = ["ps1"] + +[rules.fix] +strategy = "戻り値を確認するか、明示的なエラーハンドリングに切替" +steps = [ + "存在チェックの場合: 戻り値が $null かをチェックする", + "より明示的にしたい場合: -ErrorAction Stop + try/catch を使う", + "本当にログ抑止だけ必要な場合: -ErrorAction Ignore + コメントで理由を書く", +] + +[rules.example] +bad = '$data = ConvertFrom-Json $raw -ErrorAction SilentlyContinue' +good = 'try { $data = ConvertFrom-Json $raw -ErrorAction Stop } catch { Write-Error "Invalid JSON: $_"; throw }' + +# ─── ルール⑤: Markdown 非 ASCII GFM アンカー検出 (mutable anchor) ─── +# +# 由来: PR #89 で CodeRabbit が `[docs/todo.md](todo.md#推奨実行順序サマリー)` を Major 指摘。 +# 全リポジトリ grep で 3 ファイルにわたる同一パターンを発見。GFM の自動 anchor 生成は +# heading text のスラッグ化で日本語含む heading は脆弱な ID を生成し、heading text 変更で +# silent break する。 +# +# Rust regex 制約: lookbehind 非対応のため `.md` 内で backtick (inline code) に +# 囲まれた link 例は誤検出する。本ルールを説明する文書で例を書く場合は、 +# heading 自体を ASCII にするか、コード ブロックではなく説明文として書く。 +# (本 TOML 自体は extensions = ["md"] にマッチしないため lint 対象外) + +[[rules]] +id = "no-mutable-anchor" +pattern = '\]\([^)#]*#[^\x00-\x7F)]+' +severity = "warning" +message = "非 ASCII GFM アンカー (mutable anchor) を検出しました" +why = "GFM 自動 anchor は heading text からスラッグ化されるため、日本語見出しを参照すると heading 編集で silent break する。安定 ASCII ID を持つ 明示アンカーを使ってください" +extensions = ["md"] + +[rules.fix] +strategy = "明示的な ASCII ID アンカーへの置換" +steps = [ + "参照先の heading の前に を追加", + "リンク側を [text](file.md#stable-ascii-id) に変更", + "或いは見出し自体を ASCII にできるならそうする", +] + +[rules.example] +bad = 'See [推奨実行順序](todo.md#推奨実行順序サマリー)' +good = 'See [推奨実行順序](todo.md#recommended-order-summary) ' diff --git a/docs/todo.md b/docs/todo.md index 7840015..d069072 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -12,7 +12,7 @@ --- -## 推奨実行順序サマリー (2026-04-29 更新、PR #89 post-merge-feedback 反映後) +## 推奨実行順序サマリー (2026-04-29 更新、Bundle 1 [PR #85 T1-2 + PR #89 T1-1] 完了後) 開発環境の作業効率への貢献度を基準にした推奨実行順序。詳細は各タスク冒頭の **「実行優先度」** 行を参照。 @@ -20,39 +20,40 @@ |---|---|---|---|---|---| | 1 | 🚀 Tier 1 | push 前 untracked `__*` ファイル警告 hook (PR #85 T1-4) | todo2.md | Small | なし (PR #85 直接対策) | | 2 | 🚀 Tier 1 | `cli-push-runner` jj bookmark 未設定 early-exit (PR #85 T1-3) | todo2.md | S | なし | -| 3 | 🚀 Tier 1 | PowerShell swallowed error custom_lint_rule (PR #85 T1-2) | todo2.md | XS | なし (ADR-007 拡張) | -| 4 | 🚀 Tier 1 | **Polling anti-pattern 検出ルール (PR #86 T1-1)** | todo2.md | XS | なし (PR #86 直接対策) | -| 5 | 🚀 Tier 1 | **Stop hook の `pnpm lint:md` 統合 (PR #88 T1-1)** | todo3.md | XS | なし (PR #88 直接対策、旧順位 1 完了済の gap closure) | -| 6 | 🚀 Tier 1 | **AI 生成一時スクリプト pattern の pre-push 検出 (PR #88 T1-2)** | todo3.md | Small | 順位 1 と関連 (要擦り合わせ) | -| 7 | 🚀 Tier 1 | **Markdown 非 ASCII GFM アンカー検出 lint rule (PR #89 T1-1)** | todo3.md | S | なし (PR #89 直接対策、順位 20 と二重防衛) | -| 8 | 🚀 Tier 1 | ADR-032 PR-pre: GitHub Branch Protection 整備 | todo2.md | 設定のみ | なし (依存タスクは完了済) | -| 9 | 🔧 Tier 2 | 週次レビュー (ADR-031) Phase B 実装 | todo.md | 中-高 | なし (順位 17 の compensating check 前提) | -| 10 | 🔧 Tier 2 | reviewer facet 改善 (review-simplicity / review-security の DRY/YAGNI/security 軸明文化) | todo2.md | S | なし | -| 11 | 🔧 Tier 2 | ADR-032 PR-broken-link: broken-link-check + 内部アンカー検査 統合 | todo2.md | Small-中 | なし (clean baseline 確立済) | -| 12 | 🔧 Tier 2 | `cli-pr-monitor` プロセス正常終了の integration test (PR #85 T2-2) | todo2.md | S | なし | -| 13 | 🔧 Tier 2 | **`cli-pr-monitor` ポーリング延長 + 重複起動ロック (PR #88 T2-4)** ★ rate-limit critical | todo3.md | Medium | なし (順位 4 と補完) | -| 14 | 🔧 Tier 2 | **post-pr-review に rate-limit 自動検出 + 再トリガー (PR #89 T2-1)** ★ rate-limit critical | todo3.md | Medium | なし (順位 13 と補完) | -| 15 | 🔧 Tier 2 | **`vitest` を devDependencies に固定 (PR #88 T2-3)** | todo3.md | Small | なし | -| 16 | 🔧 Tier 2 | **`pnpm create-pr` 必須引数ヘルプ改善 (PR #88 T2-5)** | todo3.md | Small | なし | -| 17 | 💎 Tier 3 | ADR-032 PR-β: 実装 (enabled=false default) | todo2.md | 中-高 | 8, 9, 11 | -| 18 | 💎 Tier 3 | ADR-032 PR-γ: enablement (1 行 flip) | todo2.md | XS | 順位 9 dogfood + 順位 17 | -| 19 | 💎 Tier 3 | ADR-032 PR-δ: dogfood + メトリクス検証 | todo2.md | (運用) | 順位 18 | -| 20 | 💎 Tier 3 | 日付ベース見出しアンカー更新ルールのグローバル明文化 (PR #85 T3-1) | todo2.md | XS | なし | -| 21 | 💎 Tier 3 | jj conflict リカバリ手順のグローバル明文化 (PR #85 T3-2) | todo2.md | XS | なし | -| 22 | 💎 Tier 3 | `__` prefix scratch file 規約のグローバル明文化 (PR #85 T3-3) | todo2.md | XS | なし | -| 23 | 💎 Tier 3 | **post-pr-monitor polling 禁止のグローバル明文化 (PR #86 T3-2)** | todo2.md | XS | なし | -| 24 | 💎 Tier 3 | **todo.md 採番管理の簡素化 ADR 起案 (PR #86 T3-3)** | todo2.md | S | なし | -| 25 | 🧹 Tier 4 | ADR-030 Phase E/F: 旧機構廃止 + dogfood | todo.md | 中 | なし (cleanup) | -| 26 | ⏳ Tier 5 | (追って) ADR-030 の takt-test-vc 反映 | todo.md | 中 | 順位 25 Phase F | - -**戦略**: Tier 1 (1〜8) を 2〜3 セッションで片付け → Tier 2 (9〜16) で ADR-032 の前提を埋めつつ rate-limit 改善 (順位 13/14) → Tier 3 (17〜24) で ADR-032 を land + ドキュメント整備。Tier 4-5 (25〜26) は cleanup / 外部展開で daily efficiency への直接効果は小さい。 - -**順位 10 (reviewer facet 改善) は全 PR の review 精度を即時向上させ、Tier 2 内で順位 9/11/12 と並列実施可能**。 -**順位 13/14 (rate-limit 系の 2 タスク) は rate-limit 直撃のため Tier 2 内で最優先候補**。順位 13 = ポーリング頻度全体の削減、順位 14 = review 単位での自動再トリガー、順位 4 (Polling anti-pattern 検出) を含む 3 層で rate-limit を抑制する設計。 -**順位 5 (Stop hook の lint:md 統合) は旧順位 1 (Markdown linter hook 統合、PR #88 で merged) の gap closure**。**順位 6 (AI 生成一時スクリプト pattern 検出) は現順位 1 (push 前 untracked `__*` hook、PR #85 T1-4) と関連** (実装前に擦り合わせ要)。 -**順位 7 (Markdown 非 ASCII anchor 検出 lint rule) と順位 20 (日付ベース見出しアンカー更新ルール) は二重防衛** (順位 7 = 決定論的検出、順位 20 = 編集時のガイドライン)。 -**順位 20-23 (T3 グローバルルール 4 件) は `~/.claude/` 配下への XS 追記なので並列実施推奨**。 -**順位 24 (採番管理簡素化 ADR) は本 table の cross-reference 維持コストを構造的に解消するメタタスク** (PR #88/#89 で連続して 30+ 箇所の renumber が発生し負債が顕在化)。 +| 3 | 🚀 Tier 1 | **Polling anti-pattern 検出ルール (PR #86 T1-1)** | todo2.md | XS | なし (PR #86 直接対策) | +| 4 | 🚀 Tier 1 | **Stop hook の `pnpm lint:md` 統合 (PR #88 T1-1)** | todo3.md | XS | なし (PR #88 直接対策、旧順位 1 完了済の gap closure) | +| 5 | 🚀 Tier 1 | **AI 生成一時スクリプト pattern の pre-push 検出 (PR #88 T1-2)** | todo3.md | Small | 順位 1 と関連 (要擦り合わせ) | +| 6 | 🚀 Tier 1 | ADR-032 PR-pre: GitHub Branch Protection 整備 | todo2.md | 設定のみ | なし (依存タスクは完了済) | +| 7 | 🔧 Tier 2 | 週次レビュー (ADR-031) Phase B 実装 | todo.md | 中-高 | なし (順位 16 の compensating check 前提) | +| 8 | 🔧 Tier 2 | reviewer facet 改善 (review-simplicity / review-security の DRY/YAGNI/security 軸明文化) | todo2.md | S | なし | +| 9 | 🔧 Tier 2 | ADR-032 PR-broken-link: broken-link-check + 内部アンカー検査 統合 | todo2.md | Small-中 | なし (clean baseline 確立済) | +| 10 | 🔧 Tier 2 | `cli-pr-monitor` プロセス正常終了の integration test (PR #85 T2-2) | todo2.md | S | なし | +| 11 | 🔧 Tier 2 | **`cli-pr-monitor` ポーリング延長 + 重複起動ロック (PR #88 T2-4)** ★ rate-limit critical | todo3.md | Medium | なし (順位 3 と補完) | +| 12 | 🔧 Tier 2 | **post-pr-review に rate-limit 自動検出 + 再トリガー (PR #89 T2-1)** ★ rate-limit critical | todo3.md | Medium | なし (順位 11 と補完) | +| 13 | 🔧 Tier 2 | **`vitest` を devDependencies に固定 (PR #88 T2-3)** | todo3.md | Small | なし | +| 14 | 🔧 Tier 2 | **`pnpm create-pr` 必須引数ヘルプ改善 (PR #88 T2-5)** | todo3.md | Small | なし | +| 15 | 🔧 Tier 2 | **`.failed` marker への recovery 手順自己文書化 (PR #90 T2-2)** | todo3.md | S | なし | +| 16 | 💎 Tier 3 | ADR-032 PR-β: 実装 (enabled=false default) | todo2.md | 中-高 | 6, 7, 9 | +| 17 | 💎 Tier 3 | ADR-032 PR-γ: enablement (1 行 flip) | todo2.md | XS | 順位 7 dogfood + 順位 16 | +| 18 | 💎 Tier 3 | ADR-032 PR-δ: dogfood + メトリクス検証 | todo2.md | (運用) | 順位 17 | +| 19 | 💎 Tier 3 | 日付ベース見出しアンカー更新ルールのグローバル明文化 (PR #85 T3-1) | todo2.md | XS | なし | +| 20 | 💎 Tier 3 | jj conflict リカバリ手順のグローバル明文化 (PR #85 T3-2) | todo2.md | XS | なし | +| 21 | 💎 Tier 3 | `__` prefix scratch file 規約のグローバル明文化 (PR #85 T3-3) | todo2.md | XS | なし | +| 22 | 💎 Tier 3 | **post-pr-monitor polling 禁止のグローバル明文化 (PR #86 T3-2)** | todo2.md | XS | なし | +| 23 | 💎 Tier 3 | **todo.md 採番管理の簡素化 ADR 起案 (PR #86 T3-3)** | todo2.md | S | なし | +| 24 | 🧹 Tier 4 | ADR-030 Phase E/F: 旧機構廃止 + dogfood | todo.md | 中 | なし (cleanup) | +| 25 | ⏳ Tier 5 | (追って) ADR-030 の takt-test-vc 反映 | todo.md | 中 | 順位 24 Phase F | + +**戦略**: Tier 1 (1〜6) を 2〜3 セッションで片付け → Tier 2 (7〜15) で ADR-032 の前提を埋めつつ rate-limit 改善 (順位 11/12) → Tier 3 (16〜23) で ADR-032 を land + ドキュメント整備。Tier 4-5 (24〜25) は cleanup / 外部展開で daily efficiency への直接効果は小さい。 + +**Bundle 1 完了 (2026-04-29)**: 旧順位 3 (PowerShell swallowed error) + 旧順位 7 (Markdown 非 ASCII anchor) を `custom-lint-rules.toml` に追加して 1 PR で統合実装。詳細は git log 参照。**順位 19 (日付ベース見出しアンカー更新ルール) は決定論的防止 (旧順位 7 = `no-mutable-anchor` rule) との二重防衛として継続有効**。 + +**順位 8 (reviewer facet 改善) は全 PR の review 精度を即時向上させ、Tier 2 内で順位 7/9/10 と並列実施可能**。 +**順位 11/12 (rate-limit 系の 2 タスク) は rate-limit 直撃のため Tier 2 内で最優先候補**。順位 11 = ポーリング頻度全体の削減、順位 12 = review 単位での自動再トリガー、順位 3 (Polling anti-pattern 検出) を含む 3 層で rate-limit を抑制する設計。 +**順位 4 (Stop hook の lint:md 統合) は旧順位 1 (Markdown linter hook 統合、PR #88 で merged) の gap closure**。**順位 5 (AI 生成一時スクリプト pattern 検出) は現順位 1 (push 前 untracked `__*` hook、PR #85 T1-4) と関連** (実装前に擦り合わせ要)。 +**順位 15 (`.failed` marker 自己文書化) は ADR-030 soft-fail 機構の運用負荷削減** (PR #89 セッションで recovery が機能した実証から派生、Effort S)。 +**順位 19-22 (T3 グローバルルール 4 件) は `~/.claude/` 配下への XS 追記なので並列実施推奨**。 +**順位 23 (採番管理簡素化 ADR) は本 table の cross-reference 維持コストを構造的に解消するメタタスク** (PR #88/#89/#90/Bundle 1 で連続して 30+ 箇所の renumber が発生し負債が顕在化)。 --- diff --git a/docs/todo2.md b/docs/todo2.md index 877d55f..3fabe54 100644 --- a/docs/todo2.md +++ b/docs/todo2.md @@ -479,41 +479,6 @@ Phase 2 (任意、段階的緩和) なし -### PowerShell `catch {}` swallowed error 検出 custom_lint_rule (PR #85 T1-2) - -> **動機**: PR #85 のレビューで `__parse_transcripts.ps1:8` の `ConvertFrom-Json -ErrorAction SilentlyContinue` + 空 `catch {}` が CodeRabbit Major 指摘 (Review Policy 明示的 REJECT 項目「Swallowed errors」)。同種の swallowed error は Bash / TypeScript では既存の custom_lint_rule で検出可能だが、PowerShell は対象外だった。 -> -> **本タスクの位置づけ**: ADR-007 (custom_lint_rule の正規表現/AST 層線引き) に従い、PowerShell スクリプトの空 `catch` ブロックと `-ErrorAction SilentlyContinue` を正規表現層で検出する。 -> -> **参照**: `.claude/feedback-reports/85.md` Tier 1 #2 -> -> **実行優先度**: 🚀 **Tier 1 (順位 3/26)** — XS 工数、ADR-007 既存基盤の拡張のみ。発生頻度は低いが該当時の影響大 (debug 困難化)。 - -#### 設計決定 (案) - -- 配置先: `.claude/custom-lint-rules.toml` の `[powershell]` セクション (extensions: `["ps1"]`) -- 検出ルール: - - 空 `catch {}` ブロック (regex: `catch\s*\{\s*\}`) - - `-ErrorAction SilentlyContinue` (regex: `-ErrorAction\s+SilentlyContinue`) -- 同時検出を REJECT 扱い (片方単独は warning、組合せで error) - -#### 作業計画 - -- [ ] `.claude/custom-lint-rules.toml` に PowerShell セクション追加 -- [ ] PostToolUse hook の linter pipeline で `ps1` を統合 -- [ ] dogfood: バックアップした `__parse_transcripts.ps1` で動作確認 -- [ ] 派生プロジェクトへ deploy -- [ ] 本 todo2.md エントリを削除 - -#### 完了基準 - -- `.ps1` ファイル編集時に空 `catch {}` + SilentlyContinue が detect される -- 既存 swallowed error 0 件 (clean baseline) - -#### 詰まっている箇所 - -なし - ### `cli-pr-monitor` プロセス正常終了の integration test (PR #85 T2-2) > **動機**: PR #85 の `pnpm create-pr` 完了後、`cli-pr-monitor.exe` がバックグラウンドで残留し手動 `taskkill` が必要だった。termination シグナル処理またはタイムアウトの問題の可能性があり、本セッションで初めて顕在化。回帰テストで継続的に検出できるようにする。 diff --git a/docs/todo3.md b/docs/todo3.md index a60d7b6..1b5d5dd 100644 --- a/docs/todo3.md +++ b/docs/todo3.md @@ -245,50 +245,6 @@ Hint: --- -### Markdown 非 ASCII GFM アンカー検出 lint rule (PR #89 T1-1) - -> **動機**: PR #89 で CodeRabbit が docs/todo3.md:7 の `[docs/todo.md](todo.md#推奨実行順序サマリー)` の non-ASCII GFM anchor を Major と判定。同パターンは PR #88 の docs/todo2.md にも存在し、fix ステップの全リポジトリ grep で 3 ファイルにわたる同一パターンが発見された。プロジェクト全体で日本語見出しを多用するため、見出しテキスト変更による silent break のリスクが構造的にある。 -> -> **本タスクの位置づけ**: ADR-007 の custom_lint_rule (`.claude/custom-lint-rules.toml`) に新規ルール `no-mutable-anchor` を追加。Markdown のリンクで non-ASCII fragment (`#` の後ろが日本語等) を検出 → 警告し、`` 明示アンカーへの誘導を提案する。 -> -> **参照**: `.claude/feedback-reports/89.md` の Tier 1 #1 finding -> -> **実行優先度**: 🚀 **Tier 1** — 工数 S。発生頻度高 (本リポジトリで 3 ファイル以上で確認)、自動検出で確実な再発防止。順位 20 (日付ベース見出しアンカー更新ルールのグローバル明文化、PR #85 T3-1) と補完関係 (本タスクは決定論的防止、順位 20 はガイドライン)。 - -#### 背景 - -- 本セッション PR #89 で CodeRabbit が docs/todo3.md:7 の anchor 切れリスクを Major 判定 -- takt fix の family_tag sweep で全リポジトリ grep → 同パターンが docs/todo.md / docs/todo2.md / docs/todo3.md の 3 ファイルに存在することを確認 -- 根本原因: GFM の自動 anchor 生成は heading text のスラッグ化で、日本語含む heading は `#日本語テキスト` 形式の脆弱な ID を生成 -- 既存の custom_lint_rule (ADR-007) には未登録 - -#### 設計決定 (案) - -- ルール名: `no-mutable-anchor` -- 検出パターン: 正規表現 `\]\([^)#]*#[^\x00-\x7F)]+` (Markdown link の括弧内の `#` 直後に non-ASCII) -- 警告メッセージ: 「Mutable anchor detected: ``. Use `` for stable cross-reference」 -- 例外: ASCII のみで構成された anchor (`#stable-id`) は許容 -- 適用範囲: `.md` ファイル全般 - -#### 作業計画 - -- [ ] `.claude/custom-lint-rules.toml` に新規ルール追加 -- [ ] 既存の non-ASCII anchor がリポジトリ全体で残存していないか確認 (PR #89 で 3 ファイル fix 済だが残存検証) -- [ ] dogfood: 試しに `[link](#日本語)` を含む `.md` を保存 → 警告が出ることを確認 -- [ ] 本 todo3.md エントリを削除 - -#### 完了基準 - -- non-ASCII anchor の Markdown link が pre-push or PostToolUse で検出され警告される -- リポジトリの全 `.md` ファイルで non-ASCII anchor の reference が 0 件 (clean baseline) -- CodeRabbit が同種の Major finding を出さなくなる - -#### 詰まっている箇所 - -なし (Effort S、ADR-007 既存基盤の拡張) - ---- - ### post-pr-review に rate-limit 自動検出 + 再トリガーロジック (PR #89 T2-1) > **動機**: PR #89 作成直後 (13:31Z) に CodeRabbit のレートリミットが発火し、post-pr-review takt workflow が CodeRabbit review を取得できなかった。手動で「rate limit comment の `updated_at` + 残り時間 + 1 分バッファ」を計算し wait → `@coderabbitai review` 投稿で再トリガーする運用で復旧したが、毎回手動判断は冗長。 @@ -333,3 +289,81 @@ Hint: #### 詰まっている箇所 - 待機機構の選定 (takt 内蔵 vs 外部) は実装着手時に検討 + +--- + +### `.failed` marker への recovery 手順自己文書化 (PR #90 T2-2) + +> **動機**: ADR-030 で確立した soft-fail 機構 (`.md.failed` marker + L2 recovery) は PR #89 セッションで実際に発火し、UserPromptSubmit hook 経由で recovery が機能することが実証された。しかし現状の marker file は識別子のみで、recovery に必要な手順 (再実行コマンド、必要な引数、想定所要時間、よくある失敗原因) が外部 (ADR-030 / skill SKILL.md) を参照しないと分からない。marker 自体に手順を埋め込めば、将来 (ドキュメント所在を忘れた時 / ADR-030 が改訂された時 / 派生プロジェクトでの再現時) の recovery が省力化される。 +> +> **本タスクの位置づけ**: ADR-030 の運用負荷削減。soft-fail 機構そのものは正しく動作しているため、UX 改善カテゴリ。marker file の content をテンプレート化し、生成側 (cli-merge-pipeline) で recovery 手順 + コマンド例 + ADR-030 への参照を含める。 +> +> **参照**: `.claude/feedback-reports/90.md` の Tier 2 #2 finding +> +> **実行優先度**: 🔧 **Tier 2** — 工数 S。daily efficiency への影響中 (recovery 発生頻度は低いが、発生時の摩擦を低減)。順位 13/14 (rate-limit 系) ほど critical ではないが、ADR-030 の long-term 運用品質に寄与。 + +#### 背景 + +- ADR-030 の L1 (cli-merge-pipeline → takt workflow 同期実行) が失敗した場合、`.claude/feedback-reports/.md.failed` marker が残存する設計 +- L2 recovery (UserPromptSubmit hook) が次セッションで marker を検出し additionalContext で再実行を促す +- PR #89 セッションで実際に soft-fail が発火し、recovery 経路が機能した実証あり +- 課題: marker file の content が空 or 識別用の最小情報のみで、再実行手順は外部ドキュメント (ADR-030 / skill SKILL.md) を参照する必要がある +- 将来リスク: ADR-030 改訂・派生プロジェクト展開・時間経過による参照先不明化により、recovery が高摩擦化する可能性 + +#### 設計決定 (案) + +- cli-merge-pipeline (or takt workflow 失敗時の marker 書込み箇所) で marker content をテンプレート化 +- テンプレート例: + +~~~markdown +# Post-Merge Feedback Failed: PR # + +This marker indicates the post-merge feedback workflow failed for PR #. +The L2 recovery hook (UserPromptSubmit) will detect this file on the next +prompt and prompt Claude to re-run the workflow. + +## Manual Recovery (if L2 hook does not fire) + +1. Check the takt run logs at `.takt/runs//` for the failure reason. +2. Re-run the workflow: + + ```sh + takt run post-merge-feedback.yaml --input pr= + ``` + +3. On success this marker will be replaced by `.claude/feedback-reports/.md`. + +## Failure Context + +- Failed at: +- takt run id: +- Last error (truncated to 500 chars): + +## Reference + +- ADR-030: docs/adr/adr-030-deterministic-post-merge-feedback.md +~~~ + +- marker 内容は ADR 改訂耐性のため「ADR-030 への参照リンク + 当時の手順」を共存させる +- 失敗の context (timestamp / run-id / stderr tail) を含めることで、再実行前に原因切り分けがしやすくなる +- 本タスク完了後、L2 hook の additionalContext からも marker content を読ませる構成にすれば自己完結度が上がる (本タスクの拡張、必須ではない) + +#### 作業計画 + +- [ ] cli-merge-pipeline の `.failed` marker 書込みロジックを確認 (現状 content がどう生成されているか) +- [ ] テンプレート文字列を crate 内 const として定義 or 外部 template ファイル化を判定 +- [ ] timestamp / run-id / stderr tail を marker に埋め込む実装 +- [ ] L2 hook (`hooks-user-prompt-feedback-recovery` 等) の additionalContext 出力で marker content を流用するか判定 (本タスクの scope 内 or 別タスク化) +- [ ] dogfood: 意図的に takt fail を inject し、marker に手順 + context が含まれることを確認 +- [ ] ADR-030 を更新 (marker format の section を追記) +- [ ] 本 todo3.md エントリを削除 + +#### 完了基準 + +- `.failed` marker file に recovery 手順 + コマンド例 + ADR-030 参照 + failure context が含まれる +- ADR-030 の本文に marker format が明文化される +- 派生プロジェクトでも同じ template が機能する (ADR-030 が外部 reference として読める前提) + +#### 詰まっている箇所 + +なし (Effort S、cli-merge-pipeline の marker 書込み箇所のテンプレート化のみ) diff --git a/src/hooks-post-tool-linter/src/main.rs b/src/hooks-post-tool-linter/src/main.rs index 7893d69..54e2d19 100644 --- a/src/hooks-post-tool-linter/src/main.rs +++ b/src/hooks-post-tool-linter/src/main.rs @@ -1091,4 +1091,192 @@ extensions = ["ts", "js"] assert!(rules[0].example.is_none()); assert_eq!(rules[0].why, ""); } + + // --- 新規ルール: PowerShell 空 catch ブロック (no-empty-powershell-catch) --- + + fn ps_empty_catch_rule() -> CustomRule { + make_test_rule("no-empty-powershell-catch", r"catch\s*\{\s*\}", &["ps1"]) + } + + fn write_file(dir: &std::path::Path, name: &str, content: &str) -> std::path::PathBuf { + use std::io::Write; + let file = dir.join(name); + let mut f = std::fs::File::create(&file).unwrap(); + f.write_all(content.as_bytes()).unwrap(); + file + } + + #[test] + fn ps_empty_catch_detects_violation() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "swallow.ps1", + "try { Get-Item $p } catch {}\n", + ); + let rules = compile_test_rules(vec![ps_empty_catch_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!(violations.len(), 1); + } + + #[test] + fn ps_empty_catch_detects_with_internal_whitespace() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file(dir.path(), "ws.ps1", "try { ... } catch { }\n"); + let rules = compile_test_rules(vec![ps_empty_catch_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!(violations.len(), 1); + } + + #[test] + fn ps_empty_catch_skips_non_empty_block() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "ok.ps1", + "try { ... } catch { Write-Error $_ }\n", + ); + let rules = compile_test_rules(vec![ps_empty_catch_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert!(violations.is_empty()); + } + + #[test] + fn ps_empty_catch_only_targets_ps1() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file(dir.path(), "elsewhere.ts", "try { x() } catch {}\n"); + let rules = compile_test_rules(vec![ps_empty_catch_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert!(violations.is_empty()); + } + + // --- 新規ルール: -ErrorAction SilentlyContinue (no-silent-error-action) --- + + fn ps_silent_error_rule() -> CustomRule { + make_test_rule( + "no-silent-error-action", + r"-ErrorAction\s+SilentlyContinue", + &["ps1"], + ) + } + + #[test] + fn ps_silent_error_detects_basic_form() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "silent.ps1", + "$d = ConvertFrom-Json $r -ErrorAction SilentlyContinue\n", + ); + let rules = compile_test_rules(vec![ps_silent_error_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!(violations.len(), 1); + } + + #[test] + fn ps_silent_error_skips_stop_action() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "stop.ps1", + "ConvertFrom-Json $r -ErrorAction Stop\n", + ); + let rules = compile_test_rules(vec![ps_silent_error_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert!(violations.is_empty()); + } + + #[test] + fn ps_silent_error_skips_ignore_action() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "ignore.ps1", + "Get-Item $p -ErrorAction Ignore\n", + ); + let rules = compile_test_rules(vec![ps_silent_error_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert!(violations.is_empty()); + } + + // --- 新規ルール: Markdown 非 ASCII GFM アンカー (no-mutable-anchor) --- + + fn md_mutable_anchor_rule() -> CustomRule { + make_test_rule( + "no-mutable-anchor", + r"\]\([^)#]*#[^\x00-\x7F)]+", + &["md"], + ) + } + + #[test] + fn md_mutable_anchor_detects_inline_fragment() { + // `[link](#日本語)` パターン (path 部空、fragment が non-ASCII) + let dir = tempfile::tempdir().unwrap(); + let file = write_file(dir.path(), "frag.md", "See [section](#推奨実行順序)\n"); + let rules = compile_test_rules(vec![md_mutable_anchor_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!(violations.len(), 1); + } + + #[test] + fn md_mutable_anchor_detects_path_with_fragment() { + // `[link](other.md#日本語)` パターン (path 部あり、fragment が non-ASCII) + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "cross.md", + "See [other](other.md#日本語見出し)\n", + ); + let rules = compile_test_rules(vec![md_mutable_anchor_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!(violations.len(), 1); + } + + #[test] + fn md_mutable_anchor_skips_ascii_fragment() { + // `[link](#stable-id)` パターン (ASCII fragment、許容) + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "ascii.md", + "See [section](#stable-ascii-id)\n", + ); + let rules = compile_test_rules(vec![md_mutable_anchor_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert!(violations.is_empty()); + } + + #[test] + fn md_mutable_anchor_skips_link_without_fragment() { + // `[link](https://example.com)` パターン (fragment なし、許容) + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "url.md", + "Visit [example](https://example.com)\n", + ); + let rules = compile_test_rules(vec![md_mutable_anchor_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert!(violations.is_empty()); + } + + #[test] + fn md_mutable_anchor_skips_path_only_link() { + // `[link](other.md)` パターン (path だけ、許容) + let dir = tempfile::tempdir().unwrap(); + let file = write_file(dir.path(), "path.md", "See [other](other.md)\n"); + let rules = compile_test_rules(vec![md_mutable_anchor_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert!(violations.is_empty()); + } + + #[test] + fn md_mutable_anchor_only_targets_md() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file(dir.path(), "other.txt", "See [section](#日本語)\n"); + let rules = compile_test_rules(vec![md_mutable_anchor_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert!(violations.is_empty()); + } } From a15b26392e937f9045e3fc9a871e3f7a8561097f Mon Sep 17 00:00:00 2001 From: aloekun Date: Wed, 29 Apr 2026 15:36:36 +0900 Subject: [PATCH 2/4] fix(lint): apply CodeRabbit findings on PR #91 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #91 で受けた CodeRabbit findings 2 件を child commit として修正。 1. Major: PowerShell rule case-insensitivity (.claude/custom-lint-rules.toml:115-118) - PowerShell の `catch` keyword と `-ErrorAction` parameter は case-insensitive なので、`Catch {}` / `CATCH {}` / `-erroraction silentlycontinue` / `-ErrorAction SILENTLYCONTINUE` などの大文字バリアントは現 regex で見逃していた - 両 rule の pattern に Rust regex `(?i)` inline flag を追加して case-insensitive マッチに変更 - test helper (ps_empty_catch_rule / ps_silent_error_rule) も同様に更新 2. Minor: docs/todo.md stale references (lines 68 / 250 / 264) - Bundle 1 の renumber (27 \u2192 25) で本文内の cross-reference が追従漏れ - line 68: `Tier 4 (順位 25/26)` \u2192 `24/25` - line 250: `Tier 5 (順位 26/26)` \u2192 `25/25`、`順位 25` \u2192 `順位 24` - line 264: `Tier 2 (順位 9/26)` \u2192 `7/25`、`順位 17 (ADR-032 PR-β)` \u2192 `順位 16` 実装 (TDD): - 先に case-insensitive variant の 4 unit test を追加し、cargo test で FAIL を実証 (RED) - (?i) flag 追加で GREEN \u2192 62 tests pass (旧 58 + 新 4) - bad/good example も大文字混在ケースで影響なしを確認 (regex は (?i) 範囲) 順位 23 (todo.md 採番管理の簡素化 ADR 起案、PR #86 T3-3) で構造的解決予定。 本 fix は当面の対症療法として cross-ref を手作業で同期。 --- .claude/custom-lint-rules.toml | 6 ++-- docs/todo.md | 6 ++-- src/hooks-post-tool-linter/src/main.rs | 50 ++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/.claude/custom-lint-rules.toml b/.claude/custom-lint-rules.toml index 747498c..5392c21 100644 --- a/.claude/custom-lint-rules.toml +++ b/.claude/custom-lint-rules.toml @@ -84,7 +84,8 @@ good = '具体的なファイル所在: `%USERPROFILE%\.claude\projects\ > **本タスクの位置づけ**: ADR-029 を partial supersede する新 ADR-030 を起案し、takt 経由の決定論的フィードバック機構へ移行する。本タスク完了で post-merge-feedback skill / pending file / Stop hook (hooks-stop-feedback-dispatch) はすべて廃止される。 > -> **実行優先度**: 🧹 **Tier 4 (順位 25/26)** — Phase A〜D は merged 済で workflow は機能。残る Phase E (旧機構廃止) / Phase F (dogfood) は cleanup 中心で daily efficiency への直接効果は小。Tier 1〜3 完了後の片付けタイミングで実施推奨。 +> **実行優先度**: 🧹 **Tier 4 (順位 24/25)** — Phase A〜D は merged 済で workflow は機能。残る Phase E (旧機構廃止) / Phase F (dogfood) は cleanup 中心で daily efficiency への直接効果は小。Tier 1〜3 完了後の片付けタイミングで実施推奨。 #### 背景: ADR-029 の構造的欠陥 (PR #74 dogfood で実証) @@ -247,7 +247,7 @@ dogfood では PR #74 マージ後、pending file が `dispatched` で stuck し > **参照**: 上位タスク「マージ後フィードバック機構の決定論化」の Phase F 完了が前提。元の 1-F (ADR-014 本採用化 + takt-test-vc 反映) は ADR-014 が ADR-030 で Superseded されるため scope 変更。 > -> **実行優先度**: ⏳ **Tier 5 (順位 26/26)** — 派生プロジェクトへの展開で本リポジトリへの効果はゼロ。順位 25 (ADR-030 Phase F) 完了後の任意タスク。 +> **実行優先度**: ⏳ **Tier 5 (順位 25/25)** — 派生プロジェクトへの展開で本リポジトリへの効果はゼロ。順位 24 (ADR-030 Phase F) 完了後の任意タスク。 - **やろうとしたこと**: 本プロジェクトで ADR-030 機構が安定稼働 (Phase F dogfood 完了) した後、takt-test-vc へ機構ごとバックポート - **現在地**: 上位タスクの Phase F 完了待ち @@ -261,7 +261,7 @@ dogfood では PR #74 マージ後、pending file が `dispatched` で stuck し > > **計画ファイル参照**: `~/.claude/plans/1-docs-todo-md-askuserquestion-validated-orbit.md` (本タスク策定時の plan、新セッションでも同じ判断を再現可能) > -> **実行優先度**: 🔧 **Tier 2 (順位 9/26)** — ADR-032 (docs-only fast path) の compensating check 前提。順位 17 (ADR-032 PR-β) 着手前に Phase B dogfood 1 回成功が必要。architecture facet の rubric に docs 整合性観点 (ADR/symbol drift, terminology drift, docs-code 整合, docs 重複/不整合) を含めること。 +> **実行優先度**: 🔧 **Tier 2 (順位 7/25)** — ADR-032 (docs-only fast path) の compensating check 前提。順位 16 (ADR-032 PR-β) 着手前に Phase B dogfood 1 回成功が必要。architecture facet の rubric に docs 整合性観点 (ADR/symbol drift, terminology drift, docs-code 整合, docs 重複/不整合) を含めること。 #### 背景: 既存レビューの空白 diff --git a/src/hooks-post-tool-linter/src/main.rs b/src/hooks-post-tool-linter/src/main.rs index 54e2d19..8368d77 100644 --- a/src/hooks-post-tool-linter/src/main.rs +++ b/src/hooks-post-tool-linter/src/main.rs @@ -1095,7 +1095,7 @@ extensions = ["ts", "js"] // --- 新規ルール: PowerShell 空 catch ブロック (no-empty-powershell-catch) --- fn ps_empty_catch_rule() -> CustomRule { - make_test_rule("no-empty-powershell-catch", r"catch\s*\{\s*\}", &["ps1"]) + make_test_rule("no-empty-powershell-catch", r"(?i)catch\s*\{\s*\}", &["ps1"]) } fn write_file(dir: &std::path::Path, name: &str, content: &str) -> std::path::PathBuf { @@ -1150,12 +1150,31 @@ extensions = ["ts", "js"] assert!(violations.is_empty()); } + #[test] + fn ps_empty_catch_detects_capitalized_keyword() { + // PowerShell は case-insensitive なので `Catch {}` も検出すべき + let dir = tempfile::tempdir().unwrap(); + let file = write_file(dir.path(), "cap.ps1", "try { Get-Item $p } Catch {}\n"); + let rules = compile_test_rules(vec![ps_empty_catch_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!(violations.len(), 1); + } + + #[test] + fn ps_empty_catch_detects_uppercase_keyword() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file(dir.path(), "upper.ps1", "try { Get-Item $p } CATCH {}\n"); + let rules = compile_test_rules(vec![ps_empty_catch_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!(violations.len(), 1); + } + // --- 新規ルール: -ErrorAction SilentlyContinue (no-silent-error-action) --- fn ps_silent_error_rule() -> CustomRule { make_test_rule( "no-silent-error-action", - r"-ErrorAction\s+SilentlyContinue", + r"(?i)-ErrorAction\s+SilentlyContinue", &["ps1"], ) } @@ -1199,6 +1218,33 @@ extensions = ["ts", "js"] assert!(violations.is_empty()); } + #[test] + fn ps_silent_error_detects_lowercase_param() { + // PowerShell parameter 名は case-insensitive なので `-erroraction silentlycontinue` も検出すべき + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "lc.ps1", + "Get-Item $p -erroraction silentlycontinue\n", + ); + let rules = compile_test_rules(vec![ps_silent_error_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!(violations.len(), 1); + } + + #[test] + fn ps_silent_error_detects_mixed_case() { + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "mixed.ps1", + "ConvertFrom-Json $r -ErrorAction SILENTLYCONTINUE\n", + ); + let rules = compile_test_rules(vec![ps_silent_error_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!(violations.len(), 1); + } + // --- 新規ルール: Markdown 非 ASCII GFM アンカー (no-mutable-anchor) --- fn md_mutable_anchor_rule() -> CustomRule { From ff7c64a1f429e19438d5ebdc2ac602a1a6e93b76 Mon Sep 17 00:00:00 2001 From: aloekun Date: Wed, 29 Apr 2026 16:23:07 +0900 Subject: [PATCH 3/4] fix(lint): detect multi-line empty catch blocks (file-level regex) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #91 の 2nd CodeRabbit review で指摘された Major finding を修正。 問題: - run_custom_rules() が `for line in content.lines()` で行ごとに regex.find() を 呼ぶ実装だったため、PowerShell 慣用形 `} catch {\n}` の複数行空ブロックが 検出できなかった (no-empty-powershell-catch は error severity なのに false negative)。 - 既存パターン (console.log( / no-personal-paths / no-mutable-anchor 等) は すべて行内完結のため挙動変化なし。SilentlyContinue は \s+ で改行を跨ぎ得るが、 PowerShell の backtick 行継続を含む正当な使用も検出対象として妥当。 修正: - run_custom_rules() を file-level マッチに変更 (`compiled.regex.find_iter(&content)` でファイル全体を走査) - match の byte offset から改行カウントで line 番号を逆算 (`content[..m.start()].bytes().filter(|b| *b == b'\n').count() + 1`) - MAX_CUSTOM_VIOLATIONS の上限と既存テスト挙動はそのまま維持 実装 (TDD): - ps_empty_catch_detects_multiline_block test を追加し RED 確認 (既存実装で 0 件検出 → 1 件期待で FAIL) - 修正後 GREEN \u2192 63 tests pass (旧 62 + 新 1) --- src/hooks-post-tool-linter/src/main.rs | 87 +++++++++++++++----------- 1 file changed, 52 insertions(+), 35 deletions(-) diff --git a/src/hooks-post-tool-linter/src/main.rs b/src/hooks-post-tool-linter/src/main.rs index 8368d77..f5681cb 100644 --- a/src/hooks-post-tool-linter/src/main.rs +++ b/src/hooks-post-tool-linter/src/main.rs @@ -368,50 +368,50 @@ 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 (line_idx, line) in content.lines().enumerate() { + for m in compiled.regex.find_iter(&content) { 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); - } + 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); } } @@ -1169,6 +1169,23 @@ extensions = ["ts", "js"] assert_eq!(violations.len(), 1); } + #[test] + fn ps_empty_catch_detects_multiline_block() { + // PowerShell の慣用形: `} catch {\n}` の複数行空ブロックも検出すべき + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "multi.ps1", + "try {\n Get-Item $p\n} catch {\n}\n", + ); + let rules = compile_test_rules(vec![ps_empty_catch_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert_eq!(violations.len(), 1); + // catch keyword is on line 3 in the fixture + let v: serde_json::Value = serde_json::from_str(&violations[0]).unwrap(); + assert_eq!(v["location"]["line"], 3); + } + // --- 新規ルール: -ErrorAction SilentlyContinue (no-silent-error-action) --- fn ps_silent_error_rule() -> CustomRule { From e69591fb3b8b560887393c1a822b3b9c22822eeb Mon Sep 17 00:00:00 2001 From: aloekun Date: Wed, 29 Apr 2026 17:24:57 +0900 Subject: [PATCH 4/4] fix(lint): exclude external URLs from no-mutable-anchor (path `:` exclusion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #91 の 3rd CodeRabbit review で指摘された Minor finding を修正。 問題: - regex `\]\([^)#]*#[^\x00-\x7F)]+` は path 部に `:` を許容するため、 `[link](https://example.com/#日本語)` のような外部 URL の fragment を GFM anchor と誤判定 (false positive)。 - 外部 URL の fragment は GFM anchor ではないため、warning rule の alert fatigue を招く。 修正: - regex を `\]\([^)#:]*#[^\x00-\x7F)]+` に変更 (path 部から `:` を除外)。 http(s):// を含む URL は path 部マッチで止まるため対象外になる。 - protocol-relative URL (`//example.com/...`) は `:` を含まないため除外 できないが、Markdown 文書では稀なので許容。 - CodeRabbit 提案の negative lookahead は Rust regex 非対応なので、 character class 否定 1 文字追加で同等効果を実現。 実装 (TDD): - md_mutable_anchor_skips_external_url_with_fragment test を追加 → RED (`[spec](https://example.com/#日本語)` で 1 件検出 → 0 件期待で FAIL) - pattern 修正後 GREEN \u2192 64 tests pass (旧 63 + 新 1) --- .claude/custom-lint-rules.toml | 6 +++++- src/hooks-post-tool-linter/src/main.rs | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.claude/custom-lint-rules.toml b/.claude/custom-lint-rules.toml index 5392c21..581ce72 100644 --- a/.claude/custom-lint-rules.toml +++ b/.claude/custom-lint-rules.toml @@ -146,7 +146,11 @@ good = 'try { $data = ConvertFrom-Json $raw -ErrorAction Stop } catch { Write-Er [[rules]] id = "no-mutable-anchor" -pattern = '\]\([^)#]*#[^\x00-\x7F)]+' +# path 部から `:` を除外することで http(s):// など外部 URL の fragment を除外 +# (外部 URL は GFM anchor ではないため対象外)。 +# protocol-relative URL (`//example.com/...`) は `:` を含まないため除外できないが、 +# Markdown 文書では稀なので許容する。 +pattern = '\]\([^)#:]*#[^\x00-\x7F)]+' severity = "warning" message = "非 ASCII GFM アンカー (mutable anchor) を検出しました" why = "GFM 自動 anchor は heading text からスラッグ化されるため、日本語見出しを参照すると heading 編集で silent break する。安定 ASCII ID を持つ 明示アンカーを使ってください" diff --git a/src/hooks-post-tool-linter/src/main.rs b/src/hooks-post-tool-linter/src/main.rs index f5681cb..2a1984e 100644 --- a/src/hooks-post-tool-linter/src/main.rs +++ b/src/hooks-post-tool-linter/src/main.rs @@ -1265,9 +1265,10 @@ extensions = ["ts", "js"] // --- 新規ルール: Markdown 非 ASCII GFM アンカー (no-mutable-anchor) --- fn md_mutable_anchor_rule() -> CustomRule { + // path 部から `:` を除外することで http(s):// など外部 URL を除外 make_test_rule( "no-mutable-anchor", - r"\]\([^)#]*#[^\x00-\x7F)]+", + r"\]\([^)#:]*#[^\x00-\x7F)]+", &["md"], ) } @@ -1342,4 +1343,18 @@ extensions = ["ts", "js"] let violations = run_custom_rules(file.to_str().unwrap(), &rules); assert!(violations.is_empty()); } + + #[test] + fn md_mutable_anchor_skips_external_url_with_fragment() { + // 外部 URL の fragment は GFM anchor ではないため誤検知すべきでない + let dir = tempfile::tempdir().unwrap(); + let file = write_file( + dir.path(), + "external.md", + "See [spec](https://example.com/#日本語)\n", + ); + let rules = compile_test_rules(vec![md_mutable_anchor_rule()]); + let violations = run_custom_rules(file.to_str().unwrap(), &rules); + assert!(violations.is_empty()); + } }