diff --git a/Cargo.lock b/Cargo.lock index dec9478..4473ba3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,6 +93,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cli-docs-lint" +version = "0.1.0" +dependencies = [ + "regex", + "tempfile", +] + [[package]] name = "cli-finding-classifier" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 27ab4b6..072cb0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ resolver = "2" members = [ "src/check-ci-coderabbit", + "src/cli-docs-lint", "src/cli-finding-classifier", "src/cli-merge-pipeline", "src/cli-pr-monitor", diff --git a/docs/handoff-rank-8-weekly-review-phase-b.md b/docs/handoff-rank-8-weekly-review-phase-b.md index 67ad909..7d7c37c 100644 --- a/docs/handoff-rank-8-weekly-review-phase-b.md +++ b/docs/handoff-rank-8-weekly-review-phase-b.md @@ -26,12 +26,12 @@ facet 数は増やさず prompt 重点配分で対応。MVP 優先観点は ** | 観点 | 担当 facet | prompt 重点 | |---|---|---| | ① ハーネス遵守 (rule < pipeline < hook 重複) | architecture-whole | **MVP 最優先** — facet criteria の筆頭、rule/pipeline/hook 重複検出、順位 146-151 Bundle 既存ルール仕組み化の継続的発見源 | -| ② docs 内整合性 | architecture-whole の sub criterion | ADR 間 supersedes / cross-reference / todo routing、順位 95 / 96 と補完 | +| ② docs 内整合性 | architecture-whole の sub criterion | ADR 間 supersedes / cross-reference / todo routing、cli-docs-lint (preamble + cross-ref、push-runner lint group 統合済) と補完 | | ③ docs-source 矛盾 | architecture-whole の sub criterion | 重要 ADR 限定リスト (ADR-007 / 012 / 021 / 022 等) で context 圧迫回避 | | ④ セキュリティ | security-whole | ADR-031 設計通り、変更なし | | ⑤ Todo 妥当性 | **MVP 対象外** (順位 136 land 済 hook へ委譲) | hook = 編集時 immediate guard / 週次 = batch 棚卸し で責務分離。Phase B+1 で順位 154 facet として再評価 | | ⑥ テストロジック (振る舞い vs 実装詳細、境界) | simplicity-whole | **MVP 最優先** — facet criteria の筆頭、TDD anti-pattern + 境界欠落、順位 38 (cargo-mutants L3 weekly) と cross-validate | -| ⑦ ファイルサイズ (50KB) | aggregate 前の Rust 機械 pre-step (Phase B+1) | facet 不要、機械検査で十分。順位 154 で順位 95 / 147 と scope 整理 | +| ⑦ ファイルサイズ (50KB) | aggregate 前の Rust 機械 pre-step (Phase B+1) | facet 不要、機械検査で十分。順位 154 で cli-docs-lint (preamble check) / 順位 147 と scope 整理 | ## 3. 依存タスク現状 @@ -40,12 +40,11 @@ facet 数は増やさず prompt 重点配分で対応。MVP 優先観点は ** | **136** | working copy staleness 検出 hook 2 段構え | ✅ **land 済 (PR #177)** | 観点 ⑤ 責務分離が完成、Phase B MVP の 6 観点 scope が clean | | 20 | ADR-032 PR-β 実装 (compensating check) | ⚠️ 未着手 (ADR-032 自体が未起案) | 着手前提として entry が言及しているが hard blocker ではない。順位 8 完了後の Phase E (dogfood + 本採用判断) で順位 20 整合性を見直す程度で OK | | 38 | cargo-mutants L3 weekly | ⚠️ 未着手 | bundle 化推奨だが必須ではない。Phase B land 後、Phase B+1 (順位 153 / 154 と並列) で着手判断 | -| **95** | preamble file count 自動照合 CI | ⚠️ 未着手 | **着手前推奨** (順位 8 着手前の docs 機械整合性層、案 A プラン残り) | -| **96** | Markdown cross-reference validator CI | ⚠️ 未着手 | **着手前推奨** (順位 8 着手前の docs 機械整合性層、案 A プラン残り) | +| ~~95~~ / ~~96~~ | preamble file count 自動照合 + Markdown cross-reference validator | ✅ **land 済 (cli-docs-lint binary、PR #178 直後の PR で land)** | 観点 ② docs 内整合性 が機械層で先行確保済、Phase B architecture-whole facet の context 圧迫低減 | **推奨実装順序**: -1. 順位 95 + 96 を bundle 化して land (案 A プラン残り、観点 ② docs 内整合性 を機械層で先行確保) — **本セッションで未着手、別セッションで先行推奨** +1. ~~順位 95 + 96 を bundle 化して land~~ — **cli-docs-lint として land 済**。観点 ② docs 内整合性 が機械層で確保済 2. 順位 8 Phase B 着手 (本資料の主目的) 3. Phase B dogfood 2-3 週運用 (試験運用 flag、ADR-039 bounded lifetime) 4. Phase B+1 (順位 38 / 153 / 154 のいずれか or bundle、dogfood 結果次第) @@ -146,7 +145,7 @@ e2e 検証 + dogfood (Phase B/C merge 後)。詳細は同 entry 参照。 3. **todo.md 順位 8 entry を読む** (line 219 周辺、特に「7 観点責務 mapping」表) 4. **既存 facet 構造を確認** (`.takt/facets/instructions/` の現状確認、特に `review-simplicity.md` / `review-security.md` / `aggregate-feedback.md`) 5. **persona 配置場所を調査** (`.takt/personas/` または config、grep で探す) -6. **順位 95 + 96 が land 済かを確認** (未 land なら先に bundle land 推奨、land 済なら順位 8 直接着手) +6. ~~順位 95 + 96 land 状況確認~~ — cli-docs-lint として land 済、順位 8 直接着手で OK 7. **Phase B 実装着手** (上記 § 4 工程順、PR diff target 250-800 行を意識して fit するか確認) 8. **着手前に AskUserQuestion で MVP 範囲の最終確認** (3 facets で start vs 5 facets + pre-step、ユーザーは Bundle 1-3 land で patterns 確立済のため 3 facets 推奨想定) diff --git a/docs/todo-summary.md b/docs/todo-summary.md index 7f78d6e..d2e407d 100644 --- a/docs/todo-summary.md +++ b/docs/todo-summary.md @@ -47,8 +47,6 @@ | 81 | 🚀 Tier 1 | **cli-pr-monitor: CR 投稿エラー (`Failed to post review comments`) auto-retry 拡張 (PR #120 T1-2 採用) ★ Bundle f (defer)** | todo5.md | M | 1 観測のみで systemic 性未確認 (§A-2 P-5 PR で defer 判断、ADR-018 §追記 2026-05-08 で re-trigger 条件 = 2 件以上の同型観測を規定) | | 92 | 🔧 Tier 2 | **scale-aware eval fixtures (200+ 行) — Phase d 投入前の必須 infrastructure (PR #132 T2-#5 採用)** | todo6.md | M | なし (PR #132 smoke で観測した mistral:7b 大規模 diff JSON 不完全 (`missing field 'screen_decision'`) を fixture 化、Phase d 着手前の改善 ループ reference point 確保。順位 91 = Bundle i ペアタスクは既存 test カバー判明で 2026-05-24 削除済) | | 93 | 💎 Tier 3 | **`coding-style.md` Cross-File Reference Lifecycle に partial fix 例を追記 (PR #132 T3-#8 採用)** | todo6.md | XS | なし (PR #94 / #111 / #132 で反復した「変更差分外ファイルへの partial fix 再発」パターンを anti-pattern 例として codify、独立並列実施可) | -| 95 | 🔧 Tier 2 | **`docs/todo*.md` preamble file count 自動照合スクリプト (PR #133 T2-#4 採用) ★ Bundle j** | todo6.md | S | なし (PR #133 で todo6.md「六つ」/ todo7.md「七つ」が実 8 ファイルと乖離した実例。todo*.md 分割が今後も繰り返す pattern (todo3 → 4 → 5 → 6 → 7) のため CI 層で自動検証) | -| 96 | 🔧 Tier 2 | **Markdown cross-reference validator CI step (PR #133 T2-#3 採用) ★ Bundle j** | todo6.md | M | 順位 10 (ADR-032 PR-broken-link) と方向性が近接、fold-in 検討の余地あり。順位 94 (regex 規約) + 順位 95 (count 照合) と組み合わせて docs/ 整合性の多層検証 | | 97 | 🔧 Tier 2 | **`with_num_ctx(X)` override 値 serialization 検証テスト (PR #136 T2-#1 採用)** | todo6.md | S | なし (PR #136 で追加した builder method の wiring を mockito で seal、Phase d で num_ctx tweak する局面の silent degrade 防止、CodeRabbit が見逃した test gap を post-merge-feedback agent が独立発見) | | 100 | 💎 Tier 3 | **`development-workflow.md` に 「同一ファイル複数編集の 1 task 統合」 + 「partial completion + 後続 PR 追補明記」 を追補 (PR #139 T3-#1 採用)** | todo6.md | XS | なし (PR #119/#120/#121 sub-PR 分割 + PR #139 partial completion で systemic に観測された 2 暗黙知を `~/.claude/rules/common/development-workflow.md` に codify、`feedback_no_unenforced_rules.md` 例外 = 既存実践の明文化のため非機械強制でも採用相当) | | 105 | 💎 Tier 3 | **グローバル CLAUDE.md に lint runner サポートフィールド一覧表 (PR #140 T3-#2 採用)** | todo6.md | XS | なし (`~/.claude/CLAUDE.md` に `pattern` / `extensions` / `severity` (planned: `paths`) の field 一覧を表形式で追加、派生プロジェクト (techbook-ledger / auto-review-fix-vc) で rule porting 時の理解統一、順位 103 の code comment と相補) | @@ -72,7 +70,7 @@ | 151 | 🔧 Tier 2 | **PR diff lines check 追加 — `git-workflow.md` § Multi-PR chaining 移管 (PR #172 仕組み化方針切替由来、ユーザー判断 2026-05-25 = 条件付き block 3 段階) ★ Bundle 既存ルール仕組み化** | todo9.md | S | なし (`src/cli-push-runner/src/stages/pr_size_check.rs` 新 stage 追加、`push-runner-config.toml` `[pr_size_check]` section で threshold 設定可能化 (default: block 1500 / warning 800)、jj diff stat 計測、大型 refactoring 時の override は config 編集、git-workflow.md § Multi-PR chaining 縮小) | | 152 | 🔧 Tier 2 | **todo entry 削除時の事前 land 確認手順 — 順位 136 hook 拡張 or 独立 follow-up (PR #173 T2-1 採用、2026-05-26)** | todo9.md | XS-S | 順位 136 (working copy staleness + 既実装 grep) と同型機械強制、lifecycle 補完 = 順位 136 (add/edit 時) + 本タスク (delete 時)。PreToolUse hook で `docs/todo*.md` 削除時に対応 land commit を `jj log` で grep 検証、land 確認なら allow + 証跡出力、未確認なら warning (block しない)。順位 136 hook 統合 (~+15 行) or 独立 (~40 行) のいずれか、ADR-042 § Decision matrix 適用 (mechanizable + FP 低 + Adoption Risk None) | | 153 | 🔧 Tier 2 | **`review-harness-whole` facet 追加 — 観点 ① 独立 facet 化 (順位 8 follow-up、Phase B+1、2026-05-26 ユーザー合意) ★ 週次拡張** | todo9.md | S | 順位 8 Phase B land + 2-3 週 dogfood 後に着手判断 (extract 不要なら close)、順位 146-151 Bundle 既存ルール仕組み化の継続的発見源、architecture-whole から ① 観点を extract して context 圧迫回避 | -| 154 | 🔧 Tier 2 | **`review-todo-whole` facet + aggregate 前 file size pre-step — 観点 ⑤ ⑦ 拡張 (順位 8 follow-up、Phase B+1、2026-05-26 ユーザー合意) ★ 週次拡張** | todo9.md | M | 順位 136 land + Phase B 2-3 週 dogfood 完了後着手、順位 95 / 147 と scope 整理必要 (CI 即時 vs 週次 batch)、ADR-031 3 層分離原則で file size は LLM 不要の Rust pre-step に分離 | +| 154 | 🔧 Tier 2 | **`review-todo-whole` facet + aggregate 前 file size pre-step — 観点 ⑤ ⑦ 拡張 (順位 8 follow-up、Phase B+1、2026-05-26 ユーザー合意) ★ 週次拡張** | todo9.md | M | 順位 136 land + Phase B 2-3 週 dogfood 完了後着手、cli-docs-lint (preamble) / 順位 147 (file length) と scope 整理必要 (CI 即時 vs 週次 batch)、ADR-031 3 層分離原則で file size は LLM 不要の Rust pre-step に分離 | | 155 | 🚀 Tier 1 | **cli-pr-monitor fix chain 末尾に空 commit 検査 + `jj abandon` step 追加 (PR #174 T1-#1 採用)** | todo9.md | S | なし (PR #174 で `kqvluqyv` 空 commit が PR diff 汚染した実証ベース、`master..@` 範囲を `jj log` で sweep して機械強制、既存 `CleanupEmptyFixCommit` action の補完層) | | 157 | 🔧 Tier 2 | **Bundle 1 dogfood checklist 実行 — `__test.ps1` block + override env 確認 (PR #174 T2-#2 採用、ADR-039 bounded lifetime data point #1)** | todo9.md | XS | なし (PR #174 PR body の未消化 dogfood、Bundle 2 PR merge 前の前提条件として消化、結果は Bundle 2 PR body に記録) | | 160 | 💎 Tier 3 | **`docs-governance.md` に「ADR multi-variant pattern section 追加時の checklist」codify (PR #176 T3-#1 採用)** | todo9.md | XS | なし (PR #175 Minor + PR #176 Nitpick の 2 連続観測 = Frequency Medium で採用条件成立、ADR 拡張時の variant 網羅性 + 擬似コード vs 実コード齟齬を reviewer / Claude 視点で防止する checklist、global file `~/.claude/rules/common/docs-governance.md` 編集のため本リポジトリ外で実施、`feedback_global_config_backup` 適用) | diff --git a/docs/todo.md b/docs/todo.md index 8a20cdf..08f0b36 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -286,14 +286,14 @@ SessionStart hook (hooks-session-start.exe 拡張) | 観点 | 担当 facet | prompt 重点 | |---|---|---| | ① ハーネス遵守 (rule < pipeline < hook 重複) | architecture-whole | **MVP 最優先** — facet criteria の筆頭、rule/pipeline/hook 重複検出、順位 146-151 Bundle 既存ルール仕組み化の継続的発見源 | -| ② docs 内整合性 | architecture-whole の sub criterion | ADR 間 supersedes / cross-reference / todo routing、順位 10 / 95 / 96 と補完 | +| ② docs 内整合性 | architecture-whole の sub criterion | ADR 間 supersedes / cross-reference / todo routing、cli-docs-lint (preamble + cross-ref、push-runner lint group 統合済) / 順位 10 と補完 | | ③ docs-source 矛盾 | architecture-whole の sub criterion | 重要 ADR 限定リスト (ADR-007 / 012 / 021 / 022 等) で context 圧迫回避 | | ④ セキュリティ | security-whole | ADR-031 設計通り、変更なし | | ⑤ Todo 妥当性 | **MVP 対象外** (順位 136 hook へ委譲) | hook = 編集時 immediate guard / 週次 = batch 棚卸し で責務分離、Phase B+1 で順位 154 facet として再評価 | | ⑥ テストロジック (振る舞い vs 実装詳細、境界) | simplicity-whole | **MVP 最優先** — facet criteria の筆頭、TDD anti-pattern + 境界欠落、順位 38 (cargo-mutants L3 weekly) と cross-validate | -| ⑦ ファイルサイズ (50KB) | aggregate 前の Rust 機械 pre-step (Phase B+1) | facet 不要、機械検査で十分。順位 154 で順位 95 / 147 と scope 整理 | +| ⑦ ファイルサイズ (50KB) | aggregate 前の Rust 機械 pre-step (Phase B+1) | facet 不要、機械検査で十分。順位 154 で cli-docs-lint (preamble check) / 順位 147 と scope 整理 | -**Bundle 戦略**: **Phase B 単体で land** (順位 38 / 95 / 96 は別 PR、PR diff 250-800 行に収める方針)。Phase B+1 で観点 ① ⑤ ⑦ を独立 facet / pre-step に extract する余地を残す (順位 153 / 154 を follow-up 登録済)。 +**Bundle 戦略**: **Phase B 単体で land** (順位 38 は別 PR、cli-docs-lint = preamble + cross-ref check は別 PR で先行 land 済、PR diff 250-800 行に収める方針)。Phase B+1 で観点 ① ⑤ ⑦ を独立 facet / pre-step に extract する余地を残す (順位 153 / 154 を follow-up 登録済)。 #### 作業計画 @@ -301,23 +301,23 @@ SessionStart hook (hooks-session-start.exe 拡張) - [ ] `architecture-reviewer` persona 定義 (allowed_tools: Read/Glob/Grep のみ、knowledge: architecture) - 既存 persona 定義の場所を調査して同様に追加 (`.takt/personas/` または config 内) -- [ ] [.takt/facets/instructions/review-simplicity-whole.md](.takt/facets/instructions/review-simplicity-whole.md): 既存 `review-simplicity.md` から派生コピー、diff 局所制約を whole-tree 向けに改変 (主要 dir Glob 順読、累積複雑度視点) -- [ ] [.takt/facets/instructions/review-security-whole.md](.takt/facets/instructions/review-security-whole.md): 既存 `review-security.md` から派生、whole-tree 版 -- [ ] [.takt/facets/instructions/review-architecture-whole.md](.takt/facets/instructions/review-architecture-whole.md): 新規。観点は ADR 整合性 / モジュール境界 / ADR-012 命名規約 / 循環依存 / レイヤ侵犯 -- [ ] [.takt/facets/instructions/aggregate-weekly.md](.takt/facets/instructions/aggregate-weekly.md): 既存 `aggregate-feedback.md` を参考に、3 レポートを統合し finding JSON + markdown を出力 -- [ ] [.takt/workflows/weekly-review.yaml](.takt/workflows/weekly-review.yaml): `parallel: [simplicity-whole, security-whole, architecture-whole]` → `aggregate-weekly` の 2 step。`post-merge-feedback.yaml` の構造をテンプレート流用 +- [ ] `.takt/facets/instructions/review-simplicity-whole.md` (新規): 既存 `review-simplicity.md` から派生コピー、diff 局所制約を whole-tree 向けに改変 (主要 dir Glob 順読、累積複雑度視点) +- [ ] `.takt/facets/instructions/review-security-whole.md` (新規): 既存 `review-security.md` から派生、whole-tree 版 +- [ ] `.takt/facets/instructions/review-architecture-whole.md` (新規): 観点は ADR 整合性 / モジュール境界 / ADR-012 命名規約 / 循環依存 / レイヤ侵犯 +- [ ] `.takt/facets/instructions/aggregate-weekly.md` (新規): 既存 `aggregate-feedback.md` を参考に、3 レポートを統合し finding JSON + markdown を出力 +- [ ] `.takt/workflows/weekly-review.yaml` (新規): `parallel: [simplicity-whole, security-whole, architecture-whole]` → `aggregate-weekly` の 2 step。`post-merge-feedback.yaml` の構造をテンプレート流用 - [ ] takt 単体 dry-run 検証: `takt run weekly-review.yaml` で 4 レポートが `.takt/runs/-weekly-review/reports/` に生成されることを確認 - [ ] PR 作成・マージ ##### Phase C: skill + SessionStart hook (PR 3) -- [ ] [.claude/skills/weekly-review/SKILL.md](.claude/skills/weekly-review/SKILL.md) 定義 +- [ ] `.claude/skills/weekly-review/SKILL.md` (新規) 定義 - トリガー条件: `/weekly-review` 明示呼出のみ (一般的なレビュー依頼では発動しない) - 4 Phase: 起動条件チェック → takt 起動 → AskUserQuestion 採否対話 → todo.md 反映 - フラグ: `--dry-run` (todo.md 触らない) / `--resume` (`.failed` marker 検出時の再開) - [ ] pending JSON schema 確定: `.claude/weekly-review-pending.json` に finding 配列 + decision フィールド - [ ] todo.md 反映ロジック実装 (skill 内): 採用 finding を `## 現在進行中` の新セクション「週次レビュー採用 (YYYY-MM-DD)」にまとめて追記。各 finding を「動機 / 位置づけ / 背景 / 設計決定 / サブタスク / 完了基準」フォーマットへマッピング。重複検出は MVP 不要 (skill 側で警告のみ) -- [ ] [src/hooks-session-start/](src/hooks-session-start/) 拡張: `.claude/weekly-review-last-run.json` の mtime チェック + 7 日経過時の reminder 出力 + `*.md.failed` 検出時の recovery context 出力 (ADR-001 = Rust 一択) +- [ ] [src/hooks-session-start/](../src/hooks-session-start/) 拡張: `.claude/weekly-review-last-run.json` の mtime チェック + 7 日経過時の reminder 出力 + `*.md.failed` 検出時の recovery context 出力 (ADR-001 = Rust 一択) - [ ] `.gitignore` 更新: `.claude/weekly-reviews/`, `.claude/weekly-review-pending.json`, `.claude/weekly-review-last-run.json` を除外 - [ ] `pnpm build:all` + `pnpm deploy:hooks` で hook を派生プロジェクトに配布 - [ ] PR 作成・マージ @@ -353,13 +353,13 @@ SessionStart hook (hooks-session-start.exe 拡張) ##### 既存コンポーネントとの参照関係 - **既存 takt workflow テンプレート元**: - - `.takt/workflows/post-merge-feedback.yaml`: parallel + aggregate の 2-step 構造の流用元 ([参照](.takt/workflows/post-merge-feedback.yaml)) + - `.takt/workflows/post-merge-feedback.yaml`: parallel + aggregate の 2-step 構造の流用元 ([参照](../.takt/workflows/post-merge-feedback.yaml)) - **既存 facet 派生元**: - - `.takt/facets/instructions/review-simplicity.md`: whole-tree 版を派生 ([参照](.takt/facets/instructions/review-simplicity.md)) - - `.takt/facets/instructions/review-security.md`: 同上 ([参照](.takt/facets/instructions/review-security.md)) - - `.takt/facets/instructions/aggregate-feedback.md`: aggregate-weekly の参考 ([参照](.takt/facets/instructions/aggregate-feedback.md)) + - `.takt/facets/instructions/review-simplicity.md`: whole-tree 版を派生 ([参照](../.takt/facets/instructions/review-simplicity.md)) + - `.takt/facets/instructions/review-security.md`: 同上 ([参照](../.takt/facets/instructions/review-security.md)) + - `.takt/facets/instructions/aggregate-feedback.md`: aggregate-weekly の参考 ([参照](../.takt/facets/instructions/aggregate-feedback.md)) - **既存 hook 拡張先**: - - `src/hooks-session-start/`: SessionStart hook crate (Rust)。reminder ロジックを追加 ([参照](src/hooks-session-start/)) + - `src/hooks-session-start/`: SessionStart hook crate (Rust)。reminder ロジックを追加 ([参照](../src/hooks-session-start/)) - **既存 skill 規約**: - 他 skill (`post-merge-feedback`, `pre-push-review` 等) の SKILL.md フォーマット (frontmatter / トリガー条件 / Phase 構成 / 例外的動作) を踏襲 diff --git a/docs/todo2.md b/docs/todo2.md index 7147149..591016d 100644 --- a/docs/todo2.md +++ b/docs/todo2.md @@ -229,7 +229,7 @@ ADR-031 の `architecture-reviewer` facet (whole-tree) の rubric に以下の ##### Phase β: ADR-032 起案 + 実装 (PR-β) -- [ ] [docs/adr/adr-032-docs-only-fast-path.md](adr/adr-032-docs-only-fast-path.md) 起案 (試験運用) +- [ ] `docs/adr/adr-032-docs-only-fast-path.md` 起案 (試験運用、本 PR 着手時に新規作成) - [ ] [src/lib-jj-helpers/src/lib.rs](../src/lib-jj-helpers/src/lib.rs) に追加: - `classify_docs_only(files: &[String]) -> DocsOnlyClassification` 純関数 + unit test (boundary cases) - `should_force_full_review(files: &[String], threshold: usize) -> bool` (PR サイズ guard) diff --git a/docs/todo3.md b/docs/todo3.md index afad7ae..ce423a2 100644 --- a/docs/todo3.md +++ b/docs/todo3.md @@ -2,7 +2,7 @@ > **運用ルール** ([docs/todo.md](todo.md) と同一): 各タスクには **やろうとしたこと / 現在地 / 詰まっている箇所** を必ず書く。完了タスクは ADR か仕組みに反映後、このファイルから削除する。過去の経緯は git log で追跡可能。 > -> **本ファイルの位置付け**: docs/todo2.md がファイルサイズ約 50KB に到達したため、Claude Code の読み取り安定性 (50KB 超で不安定化) を考慮して PR #88 以降の新規エントリは本ファイルに記録した。本ファイルも PR #96 セッションで 50KB 接近のため、それ以降の新規エントリは [docs/todo4.md](todo4.md) へ。todo.md / todo2.md / todo3.md / todo4.md の既存エントリは引き続き有効、相互に独立。新セッションでは四つすべてを確認すること。 +> **本ファイルの位置付け**: docs/todo2.md がファイルサイズ約 50KB に到達したため、Claude Code の読み取り安定性 (50KB 超で不安定化) を考慮して PR #88 以降の新規エントリは本ファイルに記録した。本ファイルも PR #96 セッションで 50KB 接近のため、それ以降の新規エントリは [docs/todo4.md](todo4.md) へ。todo.md / todo2-9.md の既存エントリは引き続き有効、相互に独立。新セッションでは十つすべてを確認すること (todo.md / todo2-9.md / todo-summary.md)。 > > **推奨実行順序**: 全タスク横断のサマリーは [docs/todo-summary.md](todo-summary.md#recommended-order-summary) を参照。 diff --git a/docs/todo4.md b/docs/todo4.md index 941f253..33f37ca 100644 --- a/docs/todo4.md +++ b/docs/todo4.md @@ -2,7 +2,7 @@ > **運用ルール** ([docs/todo.md](todo.md) と同一): 各タスクには **やろうとしたこと / 現在地 / 詰まっている箇所** を必ず書く。完了タスクは ADR か仕組みに反映後、このファイルから削除する。過去の経緯は git log で追跡可能。 > -> **本ファイルの位置付け**: docs/todo3.md がファイルサイズ約 50KB に到達したため、Claude Code の読み取り安定性 (50KB 超で不安定化) を考慮して新規エントリは本ファイルに記録していた。**本ファイルも 50KB に到達したため、PR #101 セッション以降の新規エントリは [docs/todo5.md](todo5.md) へ**。本ファイルは既存タスクの編集・完了削除専用。todo.md / todo2.md / todo3.md / todo5.md の既存エントリは引き続き有効、相互に独立。新セッションでは五つすべてを確認すること。 +> **本ファイルの位置付け**: docs/todo3.md がファイルサイズ約 50KB に到達したため、Claude Code の読み取り安定性 (50KB 超で不安定化) を考慮して新規エントリは本ファイルに記録していた。**本ファイルも 50KB に到達したため、PR #101 セッション以降の新規エントリは [docs/todo5.md](todo5.md) へ**。本ファイルは既存タスクの編集・完了削除専用。todo.md / todo2-9.md の既存エントリは引き続き有効、相互に独立。新セッションでは十つすべてを確認すること (todo.md / todo2-9.md / todo-summary.md)。 > > **推奨実行順序**: 全タスク横断のサマリーは [docs/todo-summary.md](todo-summary.md#recommended-order-summary) を参照。 diff --git a/docs/todo5.md b/docs/todo5.md index e4f2f8f..55841b1 100644 --- a/docs/todo5.md +++ b/docs/todo5.md @@ -2,7 +2,7 @@ > **運用ルール** ([docs/todo.md](todo.md) と同一): 各タスクには **やろうとしたこと / 現在地 / 詰まっている箇所** を必ず書く。完了タスクは ADR か仕組みに反映後、このファイルから削除する。過去の経緯は git log で追跡可能。 > -> **本ファイルの位置付け**: docs/todo4.md がファイルサイズ約 50KB に到達したため、Claude Code の読み取り安定性 (50KB 超で不安定化) を考慮して PR #101 セッション以降の新規エントリは本ファイルに記録していた。**本ファイルも 67KB に到達したため、2026-05-09 に PR #101〜#109 由来の古い半分を [docs/todo7.md](todo7.md) へ分離した**。本ファイル残存は PR #110 以降のエントリのみ。新規エントリは [docs/todo6.md](todo6.md) へ。todo.md / todo2.md / todo3.md / todo4.md / todo6.md / todo7.md の既存エントリは引き続き有効、相互に独立。新セッションでは七つすべてを確認すること。 +> **本ファイルの位置付け**: docs/todo4.md がファイルサイズ約 50KB に到達したため、Claude Code の読み取り安定性 (50KB 超で不安定化) を考慮して PR #101 セッション以降の新規エントリは本ファイルに記録していた。**本ファイルも 67KB に到達したため、2026-05-09 に PR #101〜#109 由来の古い半分を [docs/todo7.md](todo7.md) へ分離した**。本ファイル残存は PR #110 以降のエントリのみ。新規エントリは [docs/todo6.md](todo6.md) へ。todo.md / todo2-9.md の既存エントリは引き続き有効、相互に独立。新セッションでは十つすべてを確認すること (todo.md / todo2-9.md / todo-summary.md)。 > > **推奨実行順序**: 全タスク横断のサマリーは [docs/todo-summary.md](todo-summary.md#recommended-order-summary) を参照。 diff --git a/docs/todo6.md b/docs/todo6.md index 38ee502..507cb93 100644 --- a/docs/todo6.md +++ b/docs/todo6.md @@ -2,7 +2,7 @@ > **運用ルール** ([docs/todo.md](todo.md) と同一): 各タスクには **やろうとしたこと / 現在地 / 詰まっている箇所** を必ず書く。完了タスクは ADR か仕組みに反映後、このファイルから削除する。過去の経緯は git log で追跡可能。 > -> **本ファイルの位置付け**: docs/todo5.md / 本ファイルが 50KB に到達 (PR #143 T3-#1) のため **新規エントリは [docs/todo8.md](todo8.md) へ移行**。本ファイルは既存タスクの編集・完了削除専用。todo.md / todo2-7.md / todo8.md の既存エントリは引き続き有効、相互に独立。新セッションでは九つすべてを確認 (todo.md / todo2-8.md / todo-summary.md)。 +> **本ファイルの位置付け**: docs/todo5.md / 本ファイルが 50KB に到達 (PR #143 T3-#1) のため **新規エントリは [docs/todo8.md](todo8.md) へ移行**。本ファイルは既存タスクの編集・完了削除専用。todo.md / todo2-9.md の既存エントリは引き続き有効、相互に独立。新セッションでは十つすべてを確認すること (todo.md / todo2-9.md / todo-summary.md)。 > > **推奨実行順序**: 全タスク横断のサマリーは [docs/todo-summary.md](todo-summary.md#recommended-order-summary) を参照。 @@ -94,96 +94,6 @@ config.rs + push-runner-config.toml + review-simplicity.md + ADR で family_tag --- -### `docs/todo*.md` preamble file count 自動照合スクリプト (PR #133 T2-#4 採用) ★ Bundle j - -> **動機**: PR #133 で `docs/todo6.md` L5 (「六つすべてを確認すること」) と `docs/todo7.md` L5 (「七つすべて」) が実 8 ファイル (todo.md / todo2-7.md / todo-summary.md) と乖離。CodeRabbit Minor finding として 2 件検出され、fix commit (`4889413`) で修正したが、`todo*.md` 分割が今後も繰り返される pattern (todo3 → 4 → 5 → 6 → 7) のため CI 層で自動検証する価値がある。Tier 1 #1 (custom lint) と相補で防御層を構築。 -> -> **本タスクの位置づけ**: PR #133 post-merge-feedback Tier 2 #4 採用 (Severity Low / Frequency Medium / Effort S / Adoption Risk None)。shell script のみで実装可能、機械検知が容易な低リスク CI step。 -> -> **参照**: `.claude/feedback-reports/133.md` Tier 2 #4、PR #133 fix commit `4889413`、CodeRabbit PR #133 review #1/#2 -> -> **実行優先度**: 🔧 **Tier 2** — Effort S。`.github/workflows/lint.yml` (現状未存在のため新規作成も視野) または PostToolUse hook + Stop hook での実装も検討可能。 - -#### 設計決定 (案) - -- **配置先**: `.github/workflows/lint.yml` の docs check job に追加 (本リポジトリは現状 GitHub Actions 未設定なので、最初の workflow 作成を含む)。代替案として PostToolUse / Stop hook で local 段階で検出も可 -- **検出ロジック (shell)**: - ```bash - EXPECTED=$(find docs -maxdepth 1 -name "todo*.md" | wc -l) - for f in docs/todo*.md; do - # preamble 内の "X つ" 数詞を抽出、期待値と照合 - PREAMBLE=$(sed -n '5p' "$f") - # 「八つ」(8) / 「七つ」(7) 等の漢数字 → 数値変換で照合 - ... - done - ``` -- **数詞 → 数値マッピング**: 一/二/三/四/五/六/七/八/九/十 を hash で持つ -- **対象範囲**: `docs/todo*.md` のみ (todo-summary.md の preamble 別仕様は scope 外) - -#### 作業計画 - -- [ ] 現状 `.github/workflows/` が無いことを確認 (PR #133 で確認済) し、新規 lint.yml の足場を作るか PostToolUse hook 拡張で代替するか判定 -- [ ] shell script (or Rust hook) で count 検証ロジックを実装 -- [ ] 漢数字 → 数値マッピングと preamble grep の正規表現を定義 -- [ ] PR #133 の修正前状態 (todo6.md「六つ」/ todo7.md「七つ」) を re-introduce した synthetic test で fail することを確認 -- [ ] 採用後の dogfood で false positive がないことを 2-3 PR で確認 -- [ ] 本 todo6.md エントリを削除 - -#### 完了基準 - -- preamble count と実 file 数の乖離が CI / hook で検出される -- PR #133 fix commit で修正した同型問題が機械的に再発防止される - -#### 詰まっている箇所 - -- **GitHub Actions 未設定 repo であること**: workflows 新設は本タスク scope を超える可能性。代替として PostToolUse hook (Rust) での検証が低コスト。Tier 2 #3 (Markdown cross-reference validator) と同 PR で `.github/workflows/lint.yml` 新設を検討する形がまとまりよい -- **数詞表記の揺れ**: 「八つ」「8 つ」「8つ」等の異表記許容範囲を着手時に確定する必要 - ---- - -### Markdown cross-reference validator CI step (PR #133 T2-#3 採用) ★ Bundle j - -> **動機**: PR #133 で `docs/todo7.md` L103 の壊れ ADR link (`../docs/adr/...`) が pre-push lint で早期検知できなかった (CodeRabbit Minor finding で post-PR 検出)。既存 `markdown-link-check` 系 tool は `docs/` 内 relative path を起点 file の directory レベルで正規化しないため broken link を見逃す。custom validator で directory-aware に解決する CI step が必要。Tier 1 #1 (custom lint で `../docs/` パターンを規約レベルで block) と Tier 2 #4 (preamble count 照合) と組み合わせて、docs/ 全体の構造的一貫性を多層検証する。 -> -> **本タスクの位置づけ**: PR #133 post-merge-feedback Tier 2 #3 採用 (Severity Medium / Frequency Medium / Effort M / Adoption Risk: 実装工数中)。 -> -> **参照**: `.claude/feedback-reports/133.md` Tier 2 #3、PR #133 fix commit `4889413` (todo7.md L103 修正)、関連 task: 順位 10 (ADR-032 PR-broken-link) -> -> **実行優先度**: 🔧 **Tier 2** — Effort M。validator 実装 + CI 統合。 - -#### 設計決定 (案) - -- **配置先**: `.github/workflows/lint.yml` に validator step 追加 (順位 95 と同 PR で workflows 新設するのが効率的) -- **実装方針候補**: - - **A**: 既存 `markdown-link-check` を fork or wrapper で directory-aware 化 - - **B**: custom Rust binary (cli-markdown-link-validator 等、既存 cli-* と同 workspace) で書き起こし - - **C**: 軽量 shell + ripgrep ベースの解析 (`rg '\]\([^http][^\)]*\)' docs/` → 各 link を file path 起点で resolve) -- **検証範囲**: `docs/**/*.md` 内の relative link (`./`, `../`, または anchor 付きの内部 link) すべて -- **既存タスクとの関係**: 順位 10 (ADR-032 PR-broken-link) と方向性が近接。同タスクとして fold-in 検討の余地あり - -#### 作業計画 - -- [ ] 既存 `markdown-link-check` 系 tool の機能調査 (directory-aware resolution の有無) -- [ ] 順位 10 (ADR-032 PR-broken-link) との重複排除判定: 同タスクで包含するか、独立 task として残すか -- [ ] 実装方針 A/B/C の比較評価 (Effort vs maintainability) -- [ ] PR #133 で混入した `../docs/adr/...` パターンを synthetic test で検出 -- [ ] PR #133 で正常な相対 link (例: `[docs/todo-summary.md](todo-summary.md)`) を false positive 検出しないことを確認 -- [ ] 採用後の dogfood で 3-5 PR の false positive 率測定 -- [ ] 本 todo6.md エントリを削除 - -#### 完了基準 - -- `docs/` 内 broken relative link が CI で検出される -- PR #133 と同型の `../docs/` トラップを Tier 1 #1 と Tier 2 #3 の二重防御で抑止 -- 既存正常 link で false positive 率 < 5% - -#### 詰まっている箇所 - -- **順位 10 (ADR-032 PR-broken-link) との関係整理**: 設計上 fold-in が妥当か、独立 task が妥当か着手時に判断必要 -- **GitHub Actions 未設定**: 順位 95 (Tier 2 #4) と同様、workflows 新設の判断を含む。この場合 95 + 96 + (将来の lint workflow 整備) を 1 PR の Bundle として land する案も検討余地 - ---- - ### `with_num_ctx(X)` override 値 serialization 検証テスト (PR #136 T2-#1 採用) > **動機**: PR #136 (§8.D / num_ctx 8192 land) で `OllamaClient::with_num_ctx` builder method を追加した際、test として `num_ctx_is_serialized_into_request_body` を入れたが、これは default 値 (8192) のみを mockito で assert する。`with_num_ctx(X)` を経由した override (例: 16384) が実際に request body に反映されるかは未検証で、builder chaining が壊れた場合 (例: `with_num_ctx` body の typo `self.num_ctx = num_ctx` → `self.num_ctx = self.num_ctx`) に **silent degrade** = default 値が常に送信されて override が無視される、を test で捕捉できない。 diff --git a/docs/todo7.md b/docs/todo7.md index 7eb7d58..3d87fd9 100644 --- a/docs/todo7.md +++ b/docs/todo7.md @@ -2,7 +2,7 @@ > **運用ルール** ([docs/todo.md](todo.md) と同一): 各タスクには **やろうとしたこと / 現在地 / 詰まっている箇所** を必ず書く。完了タスクは ADR か仕組みに反映後、このファイルから削除する。過去の経緯は git log で追跡可能。 > -> **本ファイルの位置付け**: docs/todo5.md がファイルサイズ 67KB に到達して Claude Code の読み取り安定性 (50KB 超で不安定化) を損なったため、2026-05-09 に **PR #101〜#109 由来の古い半分のタスクを本ファイルへ分離** した。todo5.md には PR #110 以降のタスクが残存。本ファイルは既存タスクの編集・完了削除専用、新規タスクは追加しない (新規エントリは [docs/todo6.md](todo6.md) へ)。todo.md / todo2.md / todo3.md / todo4.md / todo5.md / todo6.md の既存エントリは引き続き有効、相互に独立。新セッションでは八つすべてを確認すること (todo.md / todo2-7.md / todo-summary.md)。 +> **本ファイルの位置付け**: docs/todo5.md がファイルサイズ 67KB に到達して Claude Code の読み取り安定性 (50KB 超で不安定化) を損なったため、2026-05-09 に **PR #101〜#109 由来の古い半分のタスクを本ファイルへ分離** した。todo5.md には PR #110 以降のタスクが残存。本ファイルは既存タスクの編集・完了削除専用、新規タスクは追加しない (新規エントリは [docs/todo6.md](todo6.md) へ)。todo.md / todo2-9.md の既存エントリは引き続き有効、相互に独立。新セッションでは十つすべてを確認すること (todo.md / todo2-9.md / todo-summary.md)。 > > **推奨実行順序**: 全タスク横断のサマリーは [docs/todo-summary.md](todo-summary.md#recommended-order-summary) を参照。 diff --git a/docs/todo9.md b/docs/todo9.md index 7f4cda9..e33f1f4 100644 --- a/docs/todo9.md +++ b/docs/todo9.md @@ -125,7 +125,7 @@ - **配置方式の選択** (実装時判断): - 案 A: `push-runner-config.toml` の `[quality_gate]` に coverage step 追加 (pre-push 時に gate) - 案 B: `.github/workflows/coverage.yml` 新設 (CI 時に gate) - - 推奨: 案 A (本リポジトリは takt ベース push-runner で gate 統一済、`.github/workflows/` は未存在で順位 96 で初導入予定) + - 推奨: 案 A (本リポジトリは takt ベース push-runner で gate 統一済、`.github/workflows/` は未存在、docs 整合性も cli-docs-lint で push-runner 配下に統合済) - **ツール**: `cargo llvm-cov --fail-under-lines 80` (workspace 全体) - **段階導入**: 現状実測カバレッジが 80% 未満の crate がある場合、crate 別閾値設定 or temporary exception - **rule docs 縮小**: testing.md § 「Minimum Test Coverage: 80%」は実行時 gate 化により「ガイドライン」記述を削除可能 @@ -402,7 +402,7 @@ > > **本タスクの位置づけ**: 順位 8 の follow-up、Phase B+1。順位 136 hook land 後に着手判断 (= hook の immediate guard が機能している前提で、週次は batch 棚卸しに focus)。`feedback_pipeline_over_rules.md` 適用で、機械検査可能な観点 (file size) を LLM facet に乗せず分離する設計。 > -> **参照**: 順位 8 entry (todo.md 「7 観点責務 mapping」表)、順位 136 entry (todo8.md、todo hook 2 段構え)、順位 95 (preamble file count CI 自動照合)、順位 147 (file length lint 800 行)、ADR-031 (3 層分離 = Rust 機械 / takt AI / skill ask)、`feedback_pipeline_over_rules.md` +> **参照**: 順位 8 entry (todo.md 「7 観点責務 mapping」表)、順位 136 entry (todo8.md、todo hook 2 段構え)、cli-docs-lint (preamble file count + cross-ref、push-runner lint group 統合済)、順位 147 (file length lint 800 行)、ADR-031 (3 層分離 = Rust 機械 / takt AI / skill ask)、`feedback_pipeline_over_rules.md` > > **実行優先度**: 🔧 **Tier 2** — Effort M (facet 新規 + Rust pre-step ~80 行)。順位 136 land + Phase B 2-3 週 dogfood 完了後に着手。 @@ -426,7 +426,7 @@ #### 作業計画 - [ ] 順位 136 hook land 待ち -- [ ] Phase B 2-3 週 dogfood 完了 + 観点 ⑤ ⑦ の必要性再評価 (順位 95 / 147 land 状況も確認) +- [ ] Phase B 2-3 週 dogfood 完了 + 観点 ⑤ ⑦ の必要性再評価 (cli-docs-lint / 順位 147 land 状況も確認) - [ ] `review-todo-whole.md` instruction 設計 (順位 136 hook が拾える範囲との境界明示) - [ ] aggregate 前 Rust pre-step 実装 (新 binary `cli-weekly-review-prep` or aggregate facet 内 step) - [ ] takt workflow weekly-review.yaml に facet + pre-step 追加 @@ -443,7 +443,7 @@ - 順位 136 hook 実装次第 (hook が拾える範囲が確定後に週次の補完範囲を確定) - Phase B dogfood 結果次第 (有用な finding が出るかは運用観察) -- 順位 95 (preamble count CI 自動照合) との scope 重複整理: CI = 機械検査即時 / 週次 pre-step = aggregate 入力、両立可能だが integration 検討 +- cli-docs-lint (preamble count、push-runner lint group 統合済) との scope 重複整理: push-runner = 機械検査即時 / 週次 pre-step = aggregate 入力、両立可能だが integration 検討 --- diff --git a/package.json b/package.json index 079118c..aa1e42f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "lint": "npx oxlint src/", "lint:md": "npx --no-install markdownlint-cli2 \"**/*.md\"", + "lint:docs": ".\\.claude\\cli-docs-lint.exe", "test": "npx vitest run", "test:e2e": "npx tsx scripts/e2e.ts", "build": "npx tsc --noEmit --pretty || true", @@ -20,8 +21,9 @@ "build:cli-finding-classifier": "cargo build --release -p cli-finding-classifier && cp target/release/cli-finding-classifier.exe .claude/cli-finding-classifier.exe", "build:hooks-session-start": "cargo build --release -p hooks-session-start && cp target/release/hooks-session-start.exe .claude/hooks-session-start.exe", "build:cli-merge-pipeline": "cargo build --release -p cli-merge-pipeline && cp target/release/cli-merge-pipeline.exe .claude/cli-merge-pipeline.exe", + "build:cli-docs-lint": "cargo build --release -p cli-docs-lint && cp target/release/cli-docs-lint.exe .claude/cli-docs-lint.exe", "build:hooks-settings": "node -e \"const fs=require('fs');const t=fs.readFileSync('.claude/settings.local.json.template','utf8');const p=process.cwd().replace(/\\\\/g,'\\\\\\\\');fs.writeFileSync('.claude/settings.local.json',t.replace(/\\{\\{PROJECT_DIR\\}\\}/g,p))\" && echo settings.local.json generated", - "build:all": "pnpm build:hooks-session-start && pnpm build:hooks-pre-tool-validate && pnpm build:hooks-post-tool-linter && pnpm build:hooks-post-tool-comment-lint-rust && pnpm build:hooks-stop-quality && pnpm build:hooks-stop-feedback-dispatch && pnpm build:hooks-user-prompt-feedback-recovery && pnpm build:cli-push-runner && pnpm build:cli-pr-monitor && pnpm build:cli-merge-pipeline && pnpm build:check-ci-coderabbit && pnpm build:cli-finding-classifier && pnpm build:hooks-settings", + "build:all": "pnpm build:hooks-session-start && pnpm build:hooks-pre-tool-validate && pnpm build:hooks-post-tool-linter && pnpm build:hooks-post-tool-comment-lint-rust && pnpm build:hooks-stop-quality && pnpm build:hooks-stop-feedback-dispatch && pnpm build:hooks-user-prompt-feedback-recovery && pnpm build:cli-push-runner && pnpm build:cli-pr-monitor && pnpm build:cli-merge-pipeline && pnpm build:check-ci-coderabbit && pnpm build:cli-finding-classifier && pnpm build:cli-docs-lint && pnpm build:hooks-settings", "push": ".\\.claude\\cli-push-runner.exe && .\\.claude\\cli-pr-monitor.exe --monitor-only", "create-pr": ".\\.claude\\cli-pr-monitor.exe", "mark-notified": ".\\.claude\\cli-pr-monitor.exe --mark-notified", diff --git a/push-runner-config.toml b/push-runner-config.toml index 1a0c36a..fecc34d 100644 --- a/push-runner-config.toml +++ b/push-runner-config.toml @@ -41,9 +41,16 @@ parallel = true # 全体で local 269s 観測のため 180s 超過。32768 入れることで context overflow 解消するが per-invoke latency は cost) step_timeout = 600 +# `pnpm lint:docs` (cli-docs-lint) は ADR-039 試験運用標準パターンを適用: +# - Config opt-in: 派生 repo の templates/push-runner-config.toml では本コマンドを除外。 +# 本リポジトリで明示的に追加して dogfood を開始。 +# - Kill-switch: 環境変数 CLI_DOCS_LINT_DISABLE=1 で個別 push の意図的バイパス。 +# 永続的な無効化は本 commands array から "pnpm lint:docs" を削除する revert PR。 +# - Bounded lifetime: 3-5 PR の dogfood (false positive 観測 / 検出効果 / +# override 使用頻度) 後に templates への default-ON 昇格 or 却下を判定。 [[quality_gate.groups]] name = "lint" -commands = ["pnpm lint"] +commands = ["pnpm lint", "pnpm lint:docs"] [[quality_gate.groups]] name = "test" diff --git a/scripts/deploy-hooks.ts b/scripts/deploy-hooks.ts index 2aba07a..6232016 100644 --- a/scripts/deploy-hooks.ts +++ b/scripts/deploy-hooks.ts @@ -30,6 +30,7 @@ const EXE_FILES = [ "cli-pr-monitor.exe", "cli-merge-pipeline.exe", "check-ci-coderabbit.exe", + "cli-docs-lint.exe", ]; const SETTINGS_TEMPLATE = "settings.local.json.template"; diff --git a/src/cli-docs-lint/Cargo.toml b/src/cli-docs-lint/Cargo.toml new file mode 100644 index 0000000..ac03569 --- /dev/null +++ b/src/cli-docs-lint/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "cli-docs-lint" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "cli-docs-lint" +path = "src/main.rs" + +[lib] +name = "cli_docs_lint" +path = "src/lib.rs" + +[dependencies] +regex = "1" + +[dev-dependencies] +tempfile = "3" + +# [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026) diff --git a/src/cli-docs-lint/src/cross_ref.rs b/src/cli-docs-lint/src/cross_ref.rs new file mode 100644 index 0000000..c8a37c6 --- /dev/null +++ b/src/cli-docs-lint/src/cross_ref.rs @@ -0,0 +1,231 @@ +//! cross-ref check — `docs/**/*.md` 内の relative link が directory-aware で +//! resolve できるかを検証する。 +//! +//! 由来: PR #133 で TODO 系 markdown の壊れた ADR link (`../docs/adr/...`) が +//! pre-push lint で早期検知できなかった事例。既存 `markdown-link-check` 系 +//! tool は relative path を起点 file の directory レベルで正規化しないため +//! broken link を見逃す。本実装は file の親 directory を起点に resolve する。 + +use crate::Violation; +use regex::Regex; +use std::fs; +use std::path::{Path, PathBuf}; + +const ABSOLUTE_URL_PREFIXES: &[&str] = &[ + "http://", + "https://", + "mailto:", + "ftp://", + "file://", + "tel:", + "irc://", +]; + +/// `docs_dir` 配下のすべての `.md` ファイルを再帰走査し、broken relative link +/// を Violation として返す。 +pub fn check(docs_dir: &Path) -> Result, String> { + let md_files = collect_md_files(docs_dir)?; + let link_re = inline_link_regex(); + let mut violations = Vec::new(); + for path in &md_files { + let content = fs::read_to_string(path) + .map_err(|e| format!("読み込み失敗 {}: {}", path.display(), e))?; + violations.extend(check_file(path, &content, &link_re)); + } + Ok(violations) +} + +/// 単一ファイル中の link をすべて検査する。 +pub fn check_file(path: &Path, content: &str, link_re: &Regex) -> Vec { + let parent = match path.parent() { + Some(p) => p, + None => return Vec::new(), + }; + content + .lines() + .enumerate() + .flat_map(|(idx, line)| { + link_re + .captures_iter(line) + .filter_map(|cap| cap.get(2).map(|m| m.as_str().to_string())) + .filter_map(move |target| validate_link(path, parent, idx + 1, &target)) + }) + .collect() +} + +fn validate_link( + source: &Path, + parent: &Path, + line_no: usize, + target: &str, +) -> Option { + let (path_part, _anchor) = split_anchor(target); + if path_part.is_empty() { + return None; + } + if is_absolute_url(path_part) { + return None; + } + let resolved = parent.join(path_part); + if resolved.exists() { + return None; + } + Some(Violation { + file: source.display().to_string(), + line: line_no, + message: format!( + "broken relative link: \"{}\" は {} から見て存在しません (resolved: {})", + target, + parent.display(), + resolved.display() + ), + }) +} + +fn split_anchor(target: &str) -> (&str, Option<&str>) { + match target.find('#') { + Some(idx) => (&target[..idx], Some(&target[idx + 1..])), + None => (target, None), + } +} + +fn is_absolute_url(target: &str) -> bool { + ABSOLUTE_URL_PREFIXES + .iter() + .any(|prefix| target.starts_with(prefix)) +} + +fn inline_link_regex() -> Regex { + Regex::new(r#"(?:^|[^!])\[([^\]]*?)\]\(([^)\s]+)(?:\s+"[^"]*")?\)"#).unwrap() +} + +fn collect_md_files(root: &Path) -> Result, String> { + let mut paths = Vec::new(); + walk(root, &mut paths)?; + paths.sort(); + Ok(paths) +} + +fn walk(dir: &Path, out: &mut Vec) -> Result<(), String> { + let entries = fs::read_dir(dir) + .map_err(|e| format!("ディレクトリ読み込み失敗 {}: {}", dir.display(), e))?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, out)?; + continue; + } + if path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("md")) + .unwrap_or(false) + { + out.push(path); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn write(path: &Path, body: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, body).unwrap(); + } + + #[test] + fn resolves_sibling_relative_link() { + let tmp = TempDir::new().unwrap(); + let docs = tmp.path().join("docs"); + write(&docs.join("a.md"), "[link](b.md)"); + write(&docs.join("b.md"), "target"); + let v = check(&docs).unwrap(); + assert!(v.is_empty(), "expected no violations, got {:?}", v); + } + + #[test] + fn detects_broken_parent_relative_link() { + let tmp = TempDir::new().unwrap(); + let docs = tmp.path().join("docs"); + write(&docs.join("a.md"), "see [adr](../docs/adr/foo.md)"); + let v = check(&docs).unwrap(); + assert_eq!(v.len(), 1, "got {:?}", v); + assert!(v[0].message.contains("broken")); + assert!(v[0].message.contains("../docs/adr/foo.md")); + } + + #[test] + fn resolves_grandparent_relative_link_when_target_exists() { + let tmp = TempDir::new().unwrap(); + let docs = tmp.path().join("docs"); + write(&docs.join("adr").join("a.md"), "[back](../README.md)"); + write(&docs.join("README.md"), "ok"); + let v = check(&docs).unwrap(); + assert!(v.is_empty(), "expected no violations, got {:?}", v); + } + + #[test] + fn ignores_absolute_urls() { + let tmp = TempDir::new().unwrap(); + let docs = tmp.path().join("docs"); + write(&docs.join("a.md"), "[g](https://example.com) [m](mailto:a@b.c)"); + let v = check(&docs).unwrap(); + assert!(v.is_empty(), "expected no violations, got {:?}", v); + } + + #[test] + fn ignores_pure_anchor_links() { + let tmp = TempDir::new().unwrap(); + let docs = tmp.path().join("docs"); + write(&docs.join("a.md"), "[top](#heading)"); + let v = check(&docs).unwrap(); + assert!(v.is_empty(), "expected no violations, got {:?}", v); + } + + #[test] + fn validates_file_existence_ignoring_anchor() { + let tmp = TempDir::new().unwrap(); + let docs = tmp.path().join("docs"); + write(&docs.join("a.md"), "[b](b.md#section)"); + write(&docs.join("b.md"), "ok"); + let v = check(&docs).unwrap(); + assert!(v.is_empty(), "expected no violations, got {:?}", v); + } + + #[test] + fn detects_broken_link_with_anchor() { + let tmp = TempDir::new().unwrap(); + let docs = tmp.path().join("docs"); + write(&docs.join("a.md"), "[b](missing.md#section)"); + let v = check(&docs).unwrap(); + assert_eq!(v.len(), 1, "got {:?}", v); + assert!(v[0].message.contains("missing.md")); + } + + #[test] + fn does_not_flag_image_alt_brackets_as_link() { + let tmp = TempDir::new().unwrap(); + let docs = tmp.path().join("docs"); + write(&docs.join("a.md"), "![alt text](nope.png) and [real](b.md)"); + write(&docs.join("b.md"), "ok"); + let v = check(&docs).unwrap(); + assert!(v.is_empty(), "image link はチェック対象外、got {:?}", v); + } + + #[test] + fn walks_subdirectories() { + let tmp = TempDir::new().unwrap(); + let docs = tmp.path().join("docs"); + write(&docs.join("adr").join("a.md"), "[broken](no.md)"); + write(&docs.join("README.md"), "[broken2](nope.md)"); + let v = check(&docs).unwrap(); + assert_eq!(v.len(), 2, "got {:?}", v); + } +} diff --git a/src/cli-docs-lint/src/lib.rs b/src/cli-docs-lint/src/lib.rs new file mode 100644 index 0000000..297eac3 --- /dev/null +++ b/src/cli-docs-lint/src/lib.rs @@ -0,0 +1,32 @@ +//! cli-docs-lint — docs/ 整合性チェッカー +//! +//! 順位 95 (preamble file count 自動照合) と順位 96 (Markdown cross-reference +//! validator) を統合した CLI。push-runner-config.toml の quality_gate.lint +//! group から `pnpm lint:docs` 経由で実行される。 +//! +//! 検査内容: +//! - **preamble**: `docs/todoN.md` の preamble に書かれた Kanji 数詞 (X つ) が +//! 実 `docs/todo*.md` ファイル数と一致するか +//! - **cross-ref**: `docs/**/*.md` 内の relative link が directory-aware で +//! resolve できるか (broken link 検出) +//! +//! PR #133 で検出された 2 種類の docs 整合性問題を機械的に再発防止する。 + +pub mod cross_ref; +pub mod preamble; + +use std::fmt; + +/// 単一の違反を表す共通型。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Violation { + pub file: String, + pub line: usize, + pub message: String, +} + +impl fmt::Display for Violation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}: {}", self.file, self.line, self.message) + } +} diff --git a/src/cli-docs-lint/src/main.rs b/src/cli-docs-lint/src/main.rs new file mode 100644 index 0000000..89b3756 --- /dev/null +++ b/src/cli-docs-lint/src/main.rs @@ -0,0 +1,248 @@ +//! cli-docs-lint — docs/ 整合性チェッカー CLI +//! +//! 使い方: +//! cli-docs-lint 全 check (preamble + cross-ref) 実行 +//! cli-docs-lint --check preamble preamble 検査のみ +//! cli-docs-lint --check cross-ref cross-reference 検査のみ +//! cli-docs-lint --docs-dir 検査対象 docs/ ディレクトリ (default: ./docs) +//! +//! 終了コード: +//! 0 - 違反なし (または kill-switch 発動で skip) +//! 1 - 違反あり (stderr に詳細出力) +//! 2 - 引数エラーまたは I/O エラー +//! +//! # 試験運用ステータス (ADR-039 標準パターン適用) +//! +//! 本 binary は新規 lint として導入されたため、ADR-039 の試験運用標準パターン +//! (config opt-in + kill-switch + bounded lifetime) を適用する。 +//! +//! - **Config opt-in**: 派生 repo の `templates/push-runner-config.toml` では +//! `pnpm lint:docs` を `quality_gate.lint` commands から除外 (= default OFF)。 +//! 本リポジトリの `push-runner-config.toml` で明示的に追加して dogfood を開始。 +//! - **Kill-switch**: 環境変数 `CLI_DOCS_LINT_DISABLE=1` を設定すると検査を +//! skip して exit code 0 で終了する (= 個別 push の意図的バイパス)。永続的な +//! 無効化は `push-runner-config.toml` の `quality_gate.lint` commands から +//! `pnpm lint:docs` を削除する revert PR で行う。 +//! - **Bounded lifetime**: 本リポジトリで 3-5 PR の dogfood (false positive 観測 / +//! 検出効果 / override 使用頻度) 後に、`templates/push-runner-config.toml` への +//! default-ON 昇格 or 却下を判定する。判定結果は本 module doc と +//! `push-runner-config.toml` の `[cli_docs_lint]` section コメントに反映する。 + +use cli_docs_lint::{cross_ref, preamble, Violation}; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Debug, PartialEq, Eq)] +enum CheckMode { + All, + Preamble, + CrossRef, +} + +#[derive(Debug)] +struct CliArgs { + mode: CheckMode, + docs_dir: PathBuf, +} + +fn parse_args(args: &[String]) -> Result { + let mut mode = CheckMode::All; + let mut docs_dir = PathBuf::from("docs"); + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--check" => { + i += 1; + let raw = args.get(i).ok_or("--check には引数が必要です")?; + mode = match raw.as_str() { + "preamble" => CheckMode::Preamble, + "cross-ref" => CheckMode::CrossRef, + "all" => CheckMode::All, + other => { + return Err(format!( + "--check は preamble / cross-ref / all のいずれか (got: {})", + other + )) + } + }; + } + "--docs-dir" => { + i += 1; + let raw = args.get(i).ok_or("--docs-dir には引数が必要です")?; + docs_dir = PathBuf::from(raw); + } + "--help" | "-h" => { + return Err("HELP".to_string()); + } + other => return Err(format!("不明な引数: {}", other)), + } + i += 1; + } + Ok(CliArgs { mode, docs_dir }) +} + +fn print_help() { + eprintln!( + "cli-docs-lint — docs/ 整合性チェッカー\n\n\ + Usage:\n \ + cli-docs-lint [--check preamble|cross-ref|all] [--docs-dir ]\n\n\ + Checks:\n \ + preamble TODO 系 markdown の preamble 数詞 vs 実ファイル数\n \ + cross-ref docs/**/*.md の relative link validator (directory-aware)" + ); +} + +fn run(args: &CliArgs) -> Result, String> { + let mut violations = Vec::new(); + if matches!(args.mode, CheckMode::All | CheckMode::Preamble) { + violations.extend(preamble::check(&args.docs_dir)?); + } + if matches!(args.mode, CheckMode::All | CheckMode::CrossRef) { + violations.extend(cross_ref::check(&args.docs_dir)?); + } + Ok(violations) +} + +const KILL_SWITCH_ENV: &str = "CLI_DOCS_LINT_DISABLE"; + +fn is_kill_switch_value(raw: Option<&str>) -> bool { + match raw { + Some(v) => v == "1" || v.eq_ignore_ascii_case("true"), + None => false, + } +} + +fn is_kill_switch_enabled() -> bool { + is_kill_switch_value(std::env::var(KILL_SWITCH_ENV).ok().as_deref()) +} + +fn main() -> ExitCode { + if is_kill_switch_enabled() { + eprintln!( + "[cli-docs-lint] SKIP — kill-switch env var {}=1 detected (ADR-039 試験運用 bypass)", + KILL_SWITCH_ENV + ); + return ExitCode::from(0); + } + + let args: Vec = std::env::args().collect(); + let parsed = match parse_args(&args) { + Ok(p) => p, + Err(e) if e == "HELP" => { + print_help(); + return ExitCode::from(0); + } + Err(e) => { + eprintln!("[cli-docs-lint] 引数エラー: {}", e); + print_help(); + return ExitCode::from(2); + } + }; + + match run(&parsed) { + Ok(violations) if violations.is_empty() => { + eprintln!("[cli-docs-lint] OK ({})", describe_mode(&parsed.mode)); + ExitCode::from(0) + } + Ok(violations) => { + eprintln!( + "[cli-docs-lint] {} violation(s) found:", + violations.len() + ); + for v in &violations { + eprintln!(" {}", v); + } + ExitCode::from(1) + } + Err(e) => { + eprintln!("[cli-docs-lint] 実行エラー: {}", e); + ExitCode::from(2) + } + } +} + +fn describe_mode(mode: &CheckMode) -> &'static str { + match mode { + CheckMode::All => "preamble + cross-ref", + CheckMode::Preamble => "preamble only", + CheckMode::CrossRef => "cross-ref only", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(extra: &[&str]) -> Vec { + let mut v = vec!["cli-docs-lint".to_string()]; + v.extend(extra.iter().map(|s| s.to_string())); + v + } + + #[test] + fn default_mode_is_all() { + let parsed = parse_args(&args(&[])).unwrap(); + assert_eq!(parsed.mode, CheckMode::All); + assert_eq!(parsed.docs_dir, PathBuf::from("docs")); + } + + #[test] + fn parses_preamble_mode() { + let parsed = parse_args(&args(&["--check", "preamble"])).unwrap(); + assert_eq!(parsed.mode, CheckMode::Preamble); + } + + #[test] + fn parses_cross_ref_mode() { + let parsed = parse_args(&args(&["--check", "cross-ref"])).unwrap(); + assert_eq!(parsed.mode, CheckMode::CrossRef); + } + + #[test] + fn parses_docs_dir_override() { + let parsed = parse_args(&args(&["--docs-dir", "some/other"])).unwrap(); + assert_eq!(parsed.docs_dir, PathBuf::from("some/other")); + } + + #[test] + fn rejects_unknown_check() { + let err = parse_args(&args(&["--check", "spelling"])).unwrap_err(); + assert!(err.contains("preamble")); + } + + #[test] + fn rejects_unknown_flag() { + let err = parse_args(&args(&["--no-such"])).unwrap_err(); + assert!(err.contains("不明な引数")); + } + + #[test] + fn help_is_signaled_separately() { + let err = parse_args(&args(&["--help"])).unwrap_err(); + assert_eq!(err, "HELP"); + } + + #[test] + fn kill_switch_value_one_enables() { + assert!(is_kill_switch_value(Some("1"))); + } + + #[test] + fn kill_switch_value_true_case_insensitive() { + assert!(is_kill_switch_value(Some("true"))); + assert!(is_kill_switch_value(Some("TRUE"))); + assert!(is_kill_switch_value(Some("True"))); + } + + #[test] + fn kill_switch_value_unset_means_disabled() { + assert!(!is_kill_switch_value(None)); + } + + #[test] + fn kill_switch_value_empty_or_zero_means_disabled() { + assert!(!is_kill_switch_value(Some(""))); + assert!(!is_kill_switch_value(Some("0"))); + assert!(!is_kill_switch_value(Some("false"))); + } +} diff --git a/src/cli-docs-lint/src/preamble.rs b/src/cli-docs-lint/src/preamble.rs new file mode 100644 index 0000000..6a9273d --- /dev/null +++ b/src/cli-docs-lint/src/preamble.rs @@ -0,0 +1,301 @@ +//! preamble check — `docs/` 配下の TODO 系 markdown に書かれた Kanji 数詞 +//! (例: 十つ) が、実 `docs/todo*.md` ファイル数と一致するかを検証する。 +//! +//! 由来: PR #133 で TODO 系 markdown の preamble 数詞が実ファイル数と乖離した +//! CodeRabbit Minor finding 2 件 (fix commit `4889413`)。TODO 系 markdown 分割 +//! が今後も繰り返される pattern のため CI 層で機械的に再発防止する。 + +use crate::Violation; +use regex::Regex; +use std::fs; +use std::path::{Path, PathBuf}; + +const PREAMBLE_SCAN_LINES: usize = 12; +const TODO_SUMMARY_FILE: &str = "todo-summary.md"; + +/// `docs/` 配下の preamble 整合性を検査する。 +pub fn check(docs_dir: &Path) -> Result, String> { + let todo_files = list_todo_files(docs_dir)?; + let expected_total = todo_files.len(); + let has_summary = todo_files.iter().any(|p| is_todo_summary(p)); + let expected_without_summary = if has_summary { + expected_total.saturating_sub(1) + } else { + expected_total + }; + + let mut violations = Vec::new(); + for path in &todo_files { + if is_todo_summary(path) { + continue; + } + let content = fs::read_to_string(path) + .map_err(|e| format!("読み込み失敗 {}: {}", path.display(), e))?; + violations.extend(check_one(path, &content, expected_total, expected_without_summary)); + } + Ok(violations) +} + +/// 単一ファイルの preamble を検査する。 +/// +/// `expected_total` は TODO 系 markdown 全体の件数 (todo-summary.md 含む)、 +/// `expected_without_summary` は summary を除いた件数。 +/// preamble 内で `todo-summary.md` への言及があれば前者、無ければ後者と比較する。 +pub fn check_one( + path: &Path, + content: &str, + expected_total: usize, + expected_without_summary: usize, +) -> Vec { + let number_re = number_regex(); + content + .lines() + .take(PREAMBLE_SCAN_LINES) + .enumerate() + .filter_map(|(idx, line)| { + check_line(path, idx + 1, line, &number_re, expected_total, expected_without_summary) + }) + .collect() +} + +fn check_line( + path: &Path, + line_no: usize, + line: &str, + number_re: &Regex, + expected_total: usize, + expected_without_summary: usize, +) -> Option { + let caps = number_re.captures(line)?; + let raw = caps.get(1)?.as_str(); + let Some(parsed) = parse_number_token(raw) else { + return Some(make_violation( + path, + line_no, + format!( + "preamble の数詞「{}つ」を解釈できませんでした (対応: 一〜二十 の漢数字 or 数字 + つ)", + raw + ), + )); + }; + + let includes_summary = line.contains(TODO_SUMMARY_FILE); + let expected = if includes_summary { + expected_total + } else { + expected_without_summary + }; + if parsed == expected { + return None; + } + let summary_note = if includes_summary { + "todo-summary.md を含む" + } else { + "todo-summary.md を含まない" + }; + Some(make_violation( + path, + line_no, + format!( + "preamble の数詞「{}つ」({}) が実ファイル数 {} と一致しません。期待値: {} つ。TODO 系 markdown の分割・統合に追従して preamble を更新してください", + raw, summary_note, expected, expected + ), + )) +} + +fn make_violation(path: &Path, line: usize, message: String) -> Violation { + Violation { + file: path.display().to_string(), + line, + message, + } +} + +fn number_regex() -> Regex { + Regex::new(r"([一二三四五六七八九十百\d]+)\s*つ").unwrap() +} + +fn is_todo_summary(path: &Path) -> bool { + path.file_name() + .and_then(|s| s.to_str()) + .map(|name| name == TODO_SUMMARY_FILE) + .unwrap_or(false) +} + +/// `docs/todo*.md` を name 順に列挙する (todo-summary.md も含む)。 +pub fn list_todo_files(docs_dir: &Path) -> Result, String> { + let entries = fs::read_dir(docs_dir) + .map_err(|e| format!("docs ディレクトリ読み込み失敗 {}: {}", docs_dir.display(), e))?; + let mut paths: Vec = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + if name.starts_with("todo") && name.ends_with(".md") { + paths.push(path); + } + } + paths.sort(); + Ok(paths) +} + +/// 漢数字 / アラビア数字を数値に変換する。一〜二十 をサポート。 +fn parse_number_token(s: &str) -> Option { + if let Ok(n) = s.parse::() { + return Some(n); + } + match s { + "一" => Some(1), + "二" => Some(2), + "三" => Some(3), + "四" => Some(4), + "五" => Some(5), + "六" => Some(6), + "七" => Some(7), + "八" => Some(8), + "九" => Some(9), + "十" => Some(10), + "十一" => Some(11), + "十二" => Some(12), + "十三" => Some(13), + "十四" => Some(14), + "十五" => Some(15), + "十六" => Some(16), + "十七" => Some(17), + "十八" => Some(18), + "十九" => Some(19), + "二十" => Some(20), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn write(path: &Path, body: &str) { + fs::write(path, body).unwrap(); + } + + #[test] + fn parse_number_token_kanji_one_through_ten() { + assert_eq!(parse_number_token("一"), Some(1)); + assert_eq!(parse_number_token("五"), Some(5)); + assert_eq!(parse_number_token("十"), Some(10)); + } + + #[test] + fn parse_number_token_kanji_compound() { + assert_eq!(parse_number_token("十一"), Some(11)); + assert_eq!(parse_number_token("二十"), Some(20)); + } + + #[test] + fn parse_number_token_arabic() { + assert_eq!(parse_number_token("10"), Some(10)); + assert_eq!(parse_number_token("3"), Some(3)); + } + + #[test] + fn parse_number_token_unknown_returns_none() { + assert_eq!(parse_number_token("百"), None); + assert_eq!(parse_number_token("肆"), None); + } + + #[test] + fn check_one_matches_kanji_to_expected_with_summary() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("todo3.md"); + let body = "# TODO (Part 3)\n\n> stuff\n>\n> 新セッションでは十つすべてを確認すること (todo.md / todo2-9.md / todo-summary.md)。\n\nbody\n"; + write(&p, body); + let v = check_one(&p, body, 10, 9); + assert!(v.is_empty(), "expected no violations, got {:?}", v); + } + + #[test] + fn check_one_detects_mismatch_with_summary_reference() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("todo3.md"); + let body = "# TODO\n\n> blah\n>\n> 新セッションでは七つすべてを確認すること (todo.md / todo2-7.md / todo-summary.md)。\n"; + write(&p, body); + let v = check_one(&p, body, 8, 7); + assert_eq!(v.len(), 1); + assert!(v[0].message.contains("七つ")); + assert!(v[0].message.contains("8")); + } + + #[test] + fn check_one_detects_mismatch_without_summary_reference() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("todo3.md"); + let body = "# TODO\n\n> blah\n>\n> 各 entry を確認すること。新セッションでは四つすべてを確認すること。\n"; + write(&p, body); + let v = check_one(&p, body, 10, 9); + assert_eq!(v.len(), 1); + assert!(v[0].message.contains("四つ")); + assert!(v[0].message.contains("9")); + } + + #[test] + fn check_one_ignores_content_after_preamble_scan_window() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("todo3.md"); + let mut lines: Vec = (0..15).map(|i| format!("line{}", i)).collect(); + lines[13] = "本文中の「七つ」言及 (preamble 外なので検査しない)".to_string(); + let body = lines.join("\n"); + write(&p, &body); + let v = check_one(&p, &body, 10, 9); + assert!(v.is_empty(), "expected no violations, got {:?}", v); + } + + #[test] + fn list_todo_files_returns_only_todo_pattern() { + let tmp = TempDir::new().unwrap(); + write(&tmp.path().join("todo.md"), ""); + write(&tmp.path().join("todo3.md"), ""); + write(&tmp.path().join("todo-summary.md"), ""); + write(&tmp.path().join("README.md"), ""); + write(&tmp.path().join("adr-001.md"), ""); + let files = list_todo_files(tmp.path()).unwrap(); + let names: Vec = files + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().to_string()) + .collect(); + assert_eq!(names, vec!["todo-summary.md", "todo.md", "todo3.md"]); + } + + #[test] + fn check_skips_todo_summary_md() { + let tmp = TempDir::new().unwrap(); + write(&tmp.path().join("todo-summary.md"), "# Summary\n\n> 百つ\n"); + write(&tmp.path().join("todo.md"), "# TODO\n"); + write(&tmp.path().join("todo2.md"), "# TODO\n"); + let v = check(tmp.path()).unwrap(); + assert!(v.is_empty(), "expected no violations, got {:?}", v); + } + + #[test] + fn check_without_summary_does_not_undercount() { + let tmp = TempDir::new().unwrap(); + write( + &tmp.path().join("todo.md"), + "# TODO\n\n> stuff\n>\n> 新セッションでは二つすべてを確認すること。\n", + ); + write( + &tmp.path().join("todo2.md"), + "# TODO\n\n> stuff\n>\n> 新セッションでは二つすべてを確認すること。\n", + ); + let v = check(tmp.path()).unwrap(); + assert!( + v.is_empty(), + "expected no violations when summary absent (2 files = 二つ), got {:?}", + v + ); + } +} diff --git a/templates/push-runner-config.toml b/templates/push-runner-config.toml index b85f76f..bbf906d 100644 --- a/templates/push-runner-config.toml +++ b/templates/push-runner-config.toml @@ -10,6 +10,14 @@ parallel = true step_timeout = 120 # プロジェクトの品質チェックコマンドに置き換えてください +# +# `pnpm lint:docs` (cli-docs-lint.exe、deploy:hooks で配布) は ADR-039 試験運用 +# 標準パターン適用中のため、本テンプレートでは **default OFF** (commands array +# から除外済)。dogfood する派生 repo は明示的に commands に追加してください: +# commands = ["pnpm lint", "pnpm lint:docs"] +# Kill-switch: 環境変数 CLI_DOCS_LINT_DISABLE=1 で個別 push の意図的バイパス可能。 +# 本リポジトリ (claude-code-hook-test) で 3-5 PR dogfood 後に default-ON 昇格 or +# 却下を判定。判定結果は src/cli-docs-lint/src/main.rs の module doc に反映する。 [[quality_gate.groups]] name = "lint" commands = ["pnpm lint"]