diff --git a/.claude/settings.local.json.template b/.claude/settings.local.json.template index 3307d0c..7d1c6ee 100644 --- a/.claude/settings.local.json.template +++ b/.claude/settings.local.json.template @@ -50,6 +50,11 @@ "type": "command", "command": "\"{{PROJECT_DIR}}\\.claude\\hooks-stop-quality.exe\"", "timeout": 300 + }, + { + "type": "command", + "command": "\"{{PROJECT_DIR}}\\.claude\\hooks-stop-feedback-dispatch.exe\"", + "timeout": 10 } ] } diff --git a/Cargo.lock b/Cargo.lock index 7efe21b..af44e37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,7 @@ name = "cli-merge-pipeline" version = "0.1.0" dependencies = [ "lib-jj-helpers", + "lib-pending-file", "serde", "serde_json", "toml", @@ -54,6 +55,7 @@ name = "cli-pr-monitor" version = "0.1.0" dependencies = [ "lib-jj-helpers", + "lib-pending-file", "lib-report-formatter", "serde", "serde_json", @@ -170,6 +172,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "hooks-stop-feedback-dispatch" +version = "0.1.0" +dependencies = [ + "lib-pending-file", + "serde", + "serde_json", +] + [[package]] name = "hooks-stop-quality" version = "0.1.0" @@ -213,6 +224,14 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" name = "lib-jj-helpers" version = "0.1.0" +[[package]] +name = "lib-pending-file" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "lib-report-formatter" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2353874..dc7ad03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,10 @@ members = [ "src/hooks-post-tool-linter", "src/hooks-pre-tool-validate", "src/hooks-session-start", + "src/hooks-stop-feedback-dispatch", "src/hooks-stop-quality", "src/lib-jj-helpers", + "src/lib-pending-file", "src/lib-report-formatter", ] diff --git a/docs/todo.md b/docs/todo.md index 01b2c11..c577a02 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -12,44 +12,9 @@ > > **設計の核 (state file + 現セッション起動)**: cli-merge-pipeline が `.claude/post-merge-feedback-pending.json` を書き込み、新規 Stop hook が検出 → `additionalContext` で Claude に skill 起動を指示。新セッションを spawn しないので ADR-014 選択肢 3「skill はメイン会話内で実行」の原則を維持し、セッション知見の引き継ぎ問題を構造的に回避する。 > -> **依存関係・順序**: ADR-029 (PR #69) マージ後に `1-B (CLI)` と `1-C (hook)` を並行可。最後に `1-D (有効化 + 試験運用開始)`。`1-E (skill 更新)` は独立タスクとして切り出し済み (依存: ADR-029 マージ)。 +> **依存関係・順序**: `1-C (hook)` を先に進める。最後に `1-D (有効化 + 試験運用開始)`。`1-E (skill 更新)` は独立タスクとして切り出し済み。 > -> **全タスク共通の参照先**: 設計の詳細は `docs/adr/adr-029-post-merge-feedback-auto-trigger.md` (PR #69 で新規作成)。以降のタスクはこの ADR の仕様に従う。 -> -> **採否済みのフィードバック論点** (ADR-029 に反映済み): -> 1. 多重実行耐性 → pending file に `status: "pending" | "dispatched" | "consumed"` を持たせる -> 2. atomic write / 破損耐性 → atomic rename、読み取り時は size 0 / parse 失敗で削除、ロック不要 -> 3. 既存 pending との競合 → 既存 `status != "consumed"` なら新規書き込み skip + WARN (将来キュー化への拡張余地を Note に明記) -> 4. `additionalContext` は構造化タグ形式 (`[POST_MERGE_FEEDBACK_TRIGGER]` 等) にする -> 5. `run_steps` は `Option<&PipelineContext>` で後方互換を保つ - -#### 1-B. cli-merge-pipeline の `ai` 分岐実装 (コード + テスト、1 PR) - -- **やろうとしたこと**: 現状 SKIP 実装の `run_steps` の `"ai"` 分岐 ([src/cli-merge-pipeline/src/main.rs:313-322](../src/cli-merge-pipeline/src/main.rs#L313-L322)) を、ADR-029 仕様に沿った pending file 書き込みに置き換える -- **現在地**: 未着手 - - [ ] `PipelineContext` struct を新設 (`pr_number: u64`, `owner_repo: Option`) - - [ ] `run_steps` シグネチャを `Option<&PipelineContext>` で拡張 (後方互換、pre_steps は `None` を渡す) - - [ ] `"ai"` 分岐の実装: - - ctx が `None` → SKIP + log - - 既存 pending 読み取り: `status != "consumed"` なら WARN + skip (ステップ自体は PASS 扱い)、破損ファイル (size 0 / parse 失敗 / schema_version 不一致) は削除して続行 - - 新規 pending 書き込み (`status = "pending"`): tmp file → `fs::rename` で atomic - - pending file パス: `config_path().parent() / "post-merge-feedback-pending.json"` - - [ ] unit test 追加: - - 正常書き込み (ctx ありで新規作成) - - ctx なしで SKIP - - 既存 consumed 上書き成功 - - 既存 pending/dispatched で skip + WARN - - 破損 pending (parse 失敗) で削除後書き込み - - tmp → rename の atomicity (partial file が残らない) - - [ ] **atomic rename の環境確認と fallback 戦略** (CodeRabbit PR #69 指摘): - - 実装時に `std::fs::rename` のターゲット環境挙動を確認 (本プロジェクトは Windows 11 + NTFS 想定で atomic 経路に入るが、派生プロジェクトでは要再確認) - - `fs::rename` の Err を戻り値として伝播させ、呼び出し側でログ出力する (silent fail させない) - - 旧 Windows / 非対応 FS で non-atomic fallback が走ったケースの対処方針を docstring に明記: (a) 許容する (POST_MERGE_FEEDBACK_TRIGGER が 1 回発火失敗しても次マージで復帰可能)、(b) 必要なら `ReplaceFile` / `FileRenameInfoEx` の直接呼び出しを検討 - - `owner_repo` の入力検証 (newline injection 防御、正規表現 `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` 程度) を pending file 書き込み前に実施 (push 時の security-review で指摘済) - - [ ] `"ai"` 分岐のエラーハンドリング方針明文化: pending 書き込み失敗時もステップを FAIL にせず WARN + PASS とする (merge 自体は完了しているので pipeline を止めない) -- **完了基準**: `cargo test` 通過 + ローカルで `pnpm merge-pr` 手動実行 → 正しい pending file が生成される + atomic rename の挙動確認メモが実装コードの doc コメントに残っていること -- **詰まっている箇所**: なし -- **依存関係**: ADR-029 (PR #69) マージ後に着手 +> **全タスク共通の参照先**: 設計の詳細は `docs/adr/adr-029-post-merge-feedback-auto-trigger.md` (PR #69 で新規作成、PR #70 で create_new 採用 / producer フィールド追加を反映)。以降のタスクはこの ADR の仕様に従う。 #### 1-C. hooks-stop-feedback-dispatch 新規 exe (コード + 配布統合、1 PR) @@ -81,7 +46,6 @@ - additionalContext 文字列フォーマット検証 (構造化タグの key 順序等) - **完了基準**: `cargo test` 通過 + `pnpm build:hooks-stop-feedback-dispatch` / `pnpm deploy:hooks` 成功 + hooks-stop-quality と並行動作確認 - **詰まっている箇所**: なし -- **依存関係**: ADR-029 (PR #69) マージ後に着手 #### 1-D. post_steps 有効化 + 試験運用開始 (設定 + todo 更新、1 PR) @@ -95,15 +59,15 @@ prompt = "post-merge-feedback" ``` - [ ] `templates/hooks-config.toml` にも反映 (派生プロジェクト用、デフォルト opt-in/opt-out 方針は PR 内で判断) - - [ ] `docs/todo.md` から本タスク群 (1-B〜1-D、および section ヘッダーと前文) を削除 (運用ルール: 完了タスクは ADR/仕組みに反映後に削除。1-A は PR #69 時点で削除済) + - [ ] `docs/todo.md` から本タスク群 (1-C〜1-D、および section ヘッダーと前文) を削除 (運用ルール: 完了タスクは ADR/仕組みに反映後に削除。1-A は PR #69、1-B は PR #70 で削除済) - **完了基準**: 実マージ (別 PR) の `pnpm merge-pr` で pending file が生成され、Stop 時に Claude が構造化 `additionalContext` を受け取って skill 起動を試みるフローが走ること (skill 未対応なら手動起動で検証) - **詰まっている箇所**: なし -- **依存関係**: 1-B (CLI) + 1-C (hook) 両方の完了 +- **依存関係**: 1-C (hook) の完了 #### 1-E. post-merge-feedback skill の pending file 対応 (別タスク、skill リポジトリ側で実施) - **やろうとしたこと**: skill Phase 1 の前段に「pending file 先読み (Phase 0)」を追加し、status が `"dispatched"` の場合は引数指定と同等の最優先度で採用。skill 完了時に `status = "consumed"` に更新してからファイル削除 -- **現在地**: 未着手。ADR-029 (PR #69) マージ後に仕様参照可能 +- **現在地**: 未着手 - [ ] skill リポジトリの管理場所を特定 (`$CLAUDE_SKILLS_REPO` 経由 or `~/.claude/skills/` 直接) → `/skill-sync-check` で確認 - [ ] `SKILL.md` に Phase 0 「pending file 先読み」を追加: - pending file を読み取り、`status == "dispatched"` ならその `pr_number` / `owner_repo` を採用 (引数・セッションコンテキスト・fallback より優先) @@ -113,7 +77,7 @@ - [ ] (任意) skill eval の追加: pending file ありのケース / 破損ケース / status 別の挙動 - **完了基準**: skill が pending file を正しく consume し、本プロジェクトの dogfood で Claude が自動起動した skill から Feedback Report が出力される - **詰まっている箇所**: skill の管理場所 (本プロジェクト外) の扱いは `/skill-sync-check` の結果次第 -- **依存関係**: ADR-029 (PR #69) マージ。1-B/1-C/1-D とは並行可能だが、dogfood の完結には 1-E も必要 +- **依存関係**: 1-C/1-D とは並行可能だが、dogfood の完結には 1-E も必要 #### 1-F. (追って) ADR-014 試験運用フラグ解除 + takt-test-vc 反映 diff --git a/package.json b/package.json index c92c28f..adf73f9 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,14 @@ "build:hooks-pre-tool-validate": "cargo build --release -p hooks-pre-tool-validate && cp target/release/hooks-pre-tool-validate.exe .claude/hooks-pre-tool-validate.exe", "build:hooks-post-tool-linter": "cargo build --release -p hooks-post-tool-linter && cp target/release/hooks-post-tool-linter.exe .claude/hooks-post-tool-linter.exe", "build:hooks-stop-quality": "cargo build --release -p hooks-stop-quality && cp target/release/hooks-stop-quality.exe .claude/hooks-stop-quality.exe", + "build:hooks-stop-feedback-dispatch": "cargo build --release -p hooks-stop-feedback-dispatch && cp target/release/hooks-stop-feedback-dispatch.exe .claude/hooks-stop-feedback-dispatch.exe", "build:cli-push-runner": "cargo build --release -p cli-push-runner && cp target/release/cli-push-runner.exe .claude/cli-push-runner.exe", "build:cli-pr-monitor": "cargo build --release -p cli-pr-monitor && cp target/release/cli-pr-monitor.exe .claude/cli-pr-monitor.exe", "build:check-ci-coderabbit": "cargo build --release -p check-ci-coderabbit && cp target/release/check-ci-coderabbit.exe .claude/check-ci-coderabbit.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: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-stop-quality && pnpm build:cli-push-runner && pnpm build:cli-pr-monitor && pnpm build:cli-merge-pipeline && pnpm build:check-ci-coderabbit && 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-stop-quality && pnpm build:hooks-stop-feedback-dispatch && pnpm build:cli-push-runner && pnpm build:cli-pr-monitor && pnpm build:cli-merge-pipeline && pnpm build:check-ci-coderabbit && pnpm build:hooks-settings", "push": ".\\.claude\\cli-push-runner.exe && .\\.claude\\cli-pr-monitor.exe --monitor-only", "create-pr": ".\\.claude\\cli-pr-monitor.exe", "observe-pr": ".\\.claude\\cli-pr-monitor.exe --observe", diff --git a/scripts/deploy-hooks.ts b/scripts/deploy-hooks.ts index 48d2eed..8dd9037 100644 --- a/scripts/deploy-hooks.ts +++ b/scripts/deploy-hooks.ts @@ -23,6 +23,7 @@ const EXE_FILES = [ "hooks-pre-tool-validate.exe", "hooks-post-tool-linter.exe", "hooks-stop-quality.exe", + "hooks-stop-feedback-dispatch.exe", "cli-push-runner.exe", "cli-pr-monitor.exe", "cli-merge-pipeline.exe", diff --git a/src/check-ci-coderabbit/src/main.rs b/src/check-ci-coderabbit/src/main.rs index c6a6328..78e90a8 100644 --- a/src/check-ci-coderabbit/src/main.rs +++ b/src/check-ci-coderabbit/src/main.rs @@ -51,7 +51,11 @@ fn parse_args() -> Result { } let push_time = push_time.ok_or("--push-time は必須です")?; - Ok(CliArgs { push_time, repo, pr }) + Ok(CliArgs { + push_time, + repo, + pr, + }) } // ─── gh CLI 実行 ─── @@ -212,21 +216,32 @@ fn parse_ci_runs(json: &str) -> CiStatus { .iter() .map(|item| CiRunSummary { name: item.name.clone(), - conclusion: item.conclusion.clone().unwrap_or_else(|| "pending".to_string()), + conclusion: item + .conclusion + .clone() + .unwrap_or_else(|| "pending".to_string()), }) .collect(); let has_pending = items.iter().any(|i| { matches!( i.conclusion.as_deref(), - None | Some("") | Some("pending") | Some("queued") | Some("in_progress") | Some("waiting") + None | Some("") + | Some("pending") + | Some("queued") + | Some("in_progress") + | Some("waiting") ) }); let has_failure = items.iter().any(|i| { matches!( i.conclusion.as_deref(), - Some("failure") | Some("cancelled") | Some("timed_out") | Some("action_required") | Some("stale") + Some("failure") + | Some("cancelled") + | Some("timed_out") + | Some("action_required") + | Some("stale") ) }); @@ -314,7 +329,10 @@ fn parse_new_comments(json: &str, push_time: &str) -> usize { /// suggestion は `
💡 修正イメージ` ブロックから抽出。 fn parse_findings(json: &str, push_time: &str) -> Vec { let comments: Vec = serde_json::from_str(json).unwrap_or_else(|e| { - eprintln!("[check-ci-coderabbit] pull comments JSON パースエラー: {}", e); + eprintln!( + "[check-ci-coderabbit] pull comments JSON パースエラー: {}", + e + ); vec![] }); @@ -447,25 +465,22 @@ fn parse_actionable_comments(json: &str, push_time: &str) -> Option { vec![] }); - let latest = reviews - .iter() - .filter(|r| { - let is_coderabbit = r - .user - .as_ref() - .and_then(|u| u.login.as_deref()) - .map(|l| l == "coderabbitai[bot]") - .unwrap_or(false); + let latest = reviews.iter().rfind(|r| { + let is_coderabbit = r + .user + .as_ref() + .and_then(|u| u.login.as_deref()) + .map(|l| l == "coderabbitai[bot]") + .unwrap_or(false); - let after_push_time = r - .submitted_at - .as_deref() - .map(|t| t > push_time) - .unwrap_or(false); + let after_push_time = r + .submitted_at + .as_deref() + .map(|t| t > push_time) + .unwrap_or(false); - is_coderabbit && after_push_time - }) - .last()?; + is_coderabbit && after_push_time + })?; let body = latest.body.as_deref()?; @@ -518,10 +533,7 @@ fn decide(ci: &CiStatus, cr: &CodeRabbitStatus) -> (String, String) { // CodeRabbit の review_state が not_found でもコメント/スレッドがあれば対応が必要 // (commit status は未投稿でも inline comments は先に投稿されるケースがある) if cr.review_state == "not_found" && has_actionable { - return ( - "action_required".to_string(), - "action_required".to_string(), - ); + return ("action_required".to_string(), "action_required".to_string()); } // CI が pending (runs 空 = no_ci は "pending" ではなく CI チェックをスキップ) @@ -540,14 +552,14 @@ fn decide(ci: &CiStatus, cr: &CodeRabbitStatus) -> (String, String) { // コメント/スレッドがある → 対応が必要 if has_actionable { - return ( - "action_required".to_string(), - "action_required".to_string(), - ); + return ("action_required".to_string(), "action_required".to_string()); } // すべて OK - ("complete".to_string(), "stop_monitoring_success".to_string()) + ( + "complete".to_string(), + "stop_monitoring_success".to_string(), + ) } /// 人間向けサマリーを生成 @@ -615,7 +627,14 @@ fn build_summary(ci: &CiStatus, cr: &CodeRabbitStatus) -> String { // ─── 自動取得ヘルパー ─── fn auto_detect_repo() -> Result { - run_gh(&["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) + run_gh(&[ + "repo", + "view", + "--json", + "nameWithOwner", + "-q", + ".nameWithOwner", + ]) } fn auto_detect_pr() -> Result { @@ -665,19 +684,13 @@ fn is_valid_sha(sha: &str) -> bool { // ─── メインロジック ─── fn run_check(args: CliArgs) -> CheckResult { - let repo_result = args - .repo - .map(Ok) - .unwrap_or_else(|| auto_detect_repo()); - let pr_result = args - .pr - .map(Ok) - .unwrap_or_else(|| auto_detect_pr()); + let repo_result = args.repo.map(Ok).unwrap_or_else(auto_detect_repo); + let pr_result = args.pr.map(Ok).unwrap_or_else(auto_detect_pr); // エラーメッセージを事前に抽出 (unwrap_or で move される前に) let repo_err = repo_result.as_ref().err().cloned(); let pr_err = pr_result.as_ref().err().cloned(); - let repo = repo_result.map(|r| r).unwrap_or_default(); + let repo = repo_result.unwrap_or_default(); let pr = pr_result.unwrap_or(0); if repo.is_empty() || pr == 0 || !is_valid_repo(&repo) { @@ -714,7 +727,14 @@ fn run_check(args: CliArgs) -> CheckResult { let branch = get_current_branch().unwrap_or_default(); let ci = if !branch.is_empty() { match run_gh(&[ - "run", "list", "--branch", &branch, "--limit", "5", "--json", "name,conclusion", + "run", + "list", + "--branch", + &branch, + "--limit", + "5", + "--json", + "name,conclusion", ]) { Ok(ci_json) => parse_ci_runs(&ci_json), Err(e) => { @@ -751,19 +771,13 @@ fn run_check(args: CliArgs) -> CheckResult { // 3. 新規コメント let pr_str = pr.to_string(); - let comments_json = run_gh(&[ - "api", - &format!("repos/{}/issues/{}/comments", repo, pr_str), - ]) - .unwrap_or_else(|_| "[]".to_string()); + let comments_json = run_gh(&["api", &format!("repos/{}/issues/{}/comments", repo, pr_str)]) + .unwrap_or_else(|_| "[]".to_string()); let new_comments = parse_new_comments(&comments_json, &args.push_time); // 4. Actionable comments クロスチェック - let reviews_json = run_gh(&[ - "api", - &format!("repos/{}/pulls/{}/reviews", repo, pr_str), - ]) - .unwrap_or_else(|_| "[]".to_string()); + let reviews_json = run_gh(&["api", &format!("repos/{}/pulls/{}/reviews", repo, pr_str)]) + .unwrap_or_else(|_| "[]".to_string()); let actionable = parse_actionable_comments(&reviews_json, &args.push_time); // 5. 未解決スレッド (GraphQL) — 値直接埋め込み (入力は is_valid_repo で検証済み) @@ -773,14 +787,11 @@ fn run_check(args: CliArgs) -> CheckResult { r#"{{ repository(owner: "{}", name: "{}") {{ pullRequest(number: {}) {{ reviewThreads(first: 100) {{ nodes {{ isResolved }} }} }} }} }}"#, owner, name, pr ); - let graphql_json = run_gh(&[ - "api", "graphql", - "-f", &format!("query={}", query), - ]) - .unwrap_or_else(|e| { - eprintln!("[check-ci-coderabbit] GraphQL クエリ失敗: {}", e); - "{}".to_string() - }); + let graphql_json = run_gh(&["api", "graphql", "-f", &format!("query={}", query)]) + .unwrap_or_else(|e| { + eprintln!("[check-ci-coderabbit] GraphQL クエリ失敗: {}", e); + "{}".to_string() + }); parse_unresolved_threads(&graphql_json) } else { None diff --git a/src/cli-merge-pipeline/Cargo.toml b/src/cli-merge-pipeline/Cargo.toml index 804777d..311994e 100644 --- a/src/cli-merge-pipeline/Cargo.toml +++ b/src/cli-merge-pipeline/Cargo.toml @@ -8,5 +8,6 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" lib-jj-helpers = { path = "../lib-jj-helpers" } +lib-pending-file = { path = "../lib-pending-file" } # [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026) diff --git a/src/cli-merge-pipeline/src/pending_file.rs b/src/cli-merge-pipeline/src/pending_file.rs index bccf586..013964d 100644 --- a/src/cli-merge-pipeline/src/pending_file.rs +++ b/src/cli-merge-pipeline/src/pending_file.rs @@ -4,6 +4,9 @@ //! cli-merge-pipeline が post-merge ステップ (`type = "ai"`) で書き込み、 //! hooks-stop-feedback-dispatch が Stop 時に検出して Claude に skill 起動を指示する。 //! +//! 共有スキーマ・定数・UTC ヘルパーは `lib-pending-file` に集約。 +//! 本モジュールは cli-merge-pipeline 固有の書き込みロジックと I/O を担う。 +//! //! 書き込み経路は 2 種類 (ADR-029 §破損耐性): //! - 新規作成: `OpenOptions::new().write(true).create_new(true).open(path)` で //! 最終ファイルを直接 atomic 排他作成 (O_EXCL 相当)。rename は使わない。 @@ -14,68 +17,27 @@ //! (ADR-029 §競合ポリシーの「skip + WARN で取りこぼしを観測可能」を実装レベルで保証) //! - 上書き経路: read→write 間の race は許容 (Consumed/Corrupt は稀経路、 //! 破損ポリシーで自己回復) -//! -//! 中間状態の可観測性: -//! - 新規作成経路は `create_new` で直接書くため、write 完了前の reader は -//! size=0 or 途中の JSON を観測し得る。hooks-stop-feedback-dispatch の -//! 破損ポリシー (size=0 / parse 失敗 → 削除 → silent exit) が吸収する。 -//! **完全性より排他性を優先する設計判断** (ADR-029 §破損耐性)。 -//! -//! atomic 保証の前提 (ADR-029): -//! - POSIX: `open(O_EXCL)` / `rename(2)` により atomic (同一ファイルシステム内) -//! - Windows 10 1607+ / NTFS or ReFS: `CREATE_NEW` / `FileRenameInfoEx` 経路で atomic -//! (本プロジェクトのターゲット環境) -//! - 旧 Windows / 非対応 FS: atomic 保証なし (他プロセスが中間状態を観測可能) -//! -//! 非 atomic 環境では POST_MERGE_FEEDBACK_TRIGGER が 1 回発火失敗する可能性があるが、 -//! 次のマージで復帰可能なので本 module は fallback を許容する。失敗時の Err は -//! 戻り値として呼び出し側へ伝播させ、呼び出し側が log を出す (silent fail させない)。 -use serde::{Deserialize, Serialize}; use std::fs::OpenOptions; use std::io::{ErrorKind, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +// ─── Re-exports from lib-pending-file ─── + +pub(crate) use lib_pending_file::PendingFile; +pub(crate) use lib_pending_file::{ + is_valid_owner_repo, utc_now_iso8601, FILE_NAME, SCHEMA_VERSION, STATUS_CONSUMED, + STATUS_DISPATCHED, STATUS_PENDING, +}; + +// ─── cli-merge-pipeline-local items ─── + /// プロセス内で一意な tmp ファイル名を生成するためのカウンタ。 /// 複数の writer が同時に `write_overwrite` を呼んでも tmp パスが衝突しない。 /// `write_new_exclusive` は tmp path を使わないため本カウンタを参照しない。 static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); -/// pending file のスキーマバージョン。 -/// -/// 非互換変更時に bump する。hooks-stop-feedback-dispatch (task 1-C) は -/// これと一致しない pending を「破損」として削除する。 -pub(crate) const SCHEMA_VERSION: u32 = 1; - -/// ファイル名 (`.claude/` 配下に配置) -pub(crate) const FILE_NAME: &str = "post-merge-feedback-pending.json"; - -/// ADR-029 で定義された status 値 -pub(crate) const STATUS_PENDING: &str = "pending"; -pub(crate) const STATUS_DISPATCHED: &str = "dispatched"; -pub(crate) const STATUS_CONSUMED: &str = "consumed"; - -/// pending file の JSON スキーマ (ADR-029 §Pending file JSON スキーマ v1) -/// -/// `producer` は schema v1 互換の **optional** フィールド。取りこぼし発生時に -/// 「誰が書いた pending が消えたか」を破損残骸からも追跡可能にするための観測性補助。 -/// 既存 reader (hooks-stop-feedback-dispatch / skill Phase 0) は未知 / 欠損フィールドを -/// 無視するため schema_version bump は不要。 -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct PendingFile { - pub(crate) schema_version: u32, - pub(crate) pr_number: u64, - pub(crate) owner_repo: String, - pub(crate) prompt: String, - pub(crate) status: String, - pub(crate) created_at: String, - pub(crate) dispatched_at: Option, - pub(crate) consumed_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) producer: Option, -} - /// producer 文字列を生成する (`cli-merge-pipeline@pid-{pid}@{iso8601}`)。 /// /// PID は再利用されるため timestamp を併記して時系列追跡可能にする。hostname は YAGNI で省略。 @@ -166,10 +128,6 @@ impl std::fmt::Display for WriteError { /// /// **rename は使わない** — `rename` は既存を無条件上書きするため、placeholder 方式だと /// 排他予約が自壊する (CodeRabbit PR #70 Major 指摘の本質)。 -/// -/// `write_all` の途中でエラー終了すると size=0 or 部分書き込みのファイルが残るが、 -/// ADR-029 §破損耐性 の Corrupt ポリシー (size=0 / parse 失敗 → 削除 → silent exit) が -/// 吸収する。完全性より排他性を優先する設計判断。 pub(crate) fn write_new_exclusive(path: &Path, pending: &PendingFile) -> Result<(), WriteError> { let json = serde_json::to_string_pretty(pending).map_err(|e| WriteError::Serialize(e.to_string()))?; @@ -198,9 +156,6 @@ pub(crate) fn write_new_exclusive(path: &Path, pending: &PendingFile) -> Result< /// 呼び出し元は事前に `read_existing` で `Consumed` / `Corrupt` を判定し、 /// ファイルを削除してから本関数を呼ぶことを想定。稀経路なので read→write 間の /// race は許容 (ADR-029 §競合ポリシー)。 -/// -/// tmp 名は `{file_name}.tmp.{pid}.{counter}` 形式で一意化、並行 writer の -/// staging file 共有を防ぐ。失敗時は tmp 残骸を best-effort で削除。 pub(crate) fn write_overwrite(path: &Path, pending: &PendingFile) -> Result<(), WriteError> { let json = serde_json::to_string_pretty(pending).map_err(|e| WriteError::Serialize(e.to_string()))?; @@ -231,25 +186,6 @@ pub(crate) fn write_overwrite(path: &Path, pending: &PendingFile) -> Result<(), Ok(()) } -/// `{owner}/{repo}` 形式の文字列を検証する (ADR-029 todo 1-B の security-review 反映)。 -/// -/// 許容文字: ASCII 英数字 + `_` `.` `-`。スラッシュはちょうど 1 つ、owner/repo とも非空。 -/// newline / 制御文字は弾く (pending file / additionalContext への注入防御)。 -pub(crate) fn is_valid_owner_repo(s: &str) -> bool { - let Some((owner, repo)) = s.split_once('/') else { - return false; - }; - !owner.is_empty() - && !repo.is_empty() - && !repo.contains('/') - && owner.chars().all(is_repo_ident_char) - && repo.chars().all(is_repo_ident_char) -} - -fn is_repo_ident_char(c: char) -> bool { - c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' -} - /// pending file のデフォルト配置先 (exe と同じディレクトリ = `.claude/`)。 /// /// 本プロジェクトは `pnpm deploy:hooks` で exe を `.claude/` に配置するため、 @@ -258,74 +194,6 @@ pub(crate) fn default_path(config_dir: &Path) -> PathBuf { config_dir.join(FILE_NAME) } -// ─── UTC ISO 8601 helper ─── -// -// cli-pr-monitor/src/util.rs にも同等の pub(crate) 関数が存在する。 -// 1-C (hooks-stop-feedback-dispatch) も同じ helper を必要とするため、 -// 3 callers になった時点で lib へ切り出すか判断する (現段階では duplicate でよい)。 - -/// 現在時刻を ISO 8601 UTC 文字列に変換する (std のみ, chrono 不要)。 -pub(crate) fn utc_now_iso8601() -> String { - use std::time::SystemTime; - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default(); - epoch_secs_to_iso8601(now.as_secs()) -} - -// Constants for Hatcher's proleptic Gregorian civil-date algorithm. -// Reference: https://howardhinnant.github.io/date_algorithms.html -/// Days from the proleptic Gregorian epoch (0000-03-01) to the Unix epoch (1970-01-01). -const CIVIL_EPOCH_OFFSET: i64 = 719_468; -/// Days in a 400-year Gregorian era. -const DAYS_PER_ERA: i64 = 146_097; -/// DAYS_PER_ERA - 1; used for the era-floor sign correction. -const DAYS_PER_ERA_M1: i64 = 146_096; -/// Days in a 4-year cycle (excluding century boundaries). -const DAYS_PER_4Y: u64 = 1_460; -/// Days in a 100-year cycle. -const DAYS_PER_100Y: u64 = 36_524; -/// Days in an ordinary year. -const DAYS_PER_YEAR: u64 = 365; -/// Years per 400-year Gregorian era. -const YEARS_PER_ERA: i64 = 400; -/// Multiplier for the month-to-day-of-year encoding: (5*mp + 2) / 153. -const MONTH_ENCODE_MUL: u64 = 5; -/// Divisor for the month-to-day-of-year encoding. -const MONTH_ENCODE_DIV: u64 = 153; -/// Seconds per hour. -const SECS_PER_HOUR: u64 = 3_600; -/// Seconds per minute. -const SECS_PER_MIN: u64 = 60; -/// Seconds per day. -const SECS_PER_DAY: u64 = 86_400; - -fn epoch_secs_to_iso8601(epoch: u64) -> String { - let day_count = (epoch / SECS_PER_DAY) as i64; - let time_of_day = epoch % SECS_PER_DAY; - - let z = day_count + CIVIL_EPOCH_OFFSET; - let era = (if z >= 0 { z } else { z - DAYS_PER_ERA_M1 }) / DAYS_PER_ERA; - let doe = (z - era * DAYS_PER_ERA) as u64; - let yoe = (doe - doe / DAYS_PER_4Y + doe / DAYS_PER_100Y - doe / (DAYS_PER_ERA_M1 as u64)) - / DAYS_PER_YEAR; - let y = yoe as i64 + era * YEARS_PER_ERA; - let doy = doe - (DAYS_PER_YEAR * yoe + yoe / 4 - yoe / 100); - let mp = (MONTH_ENCODE_MUL * doy + 2) / MONTH_ENCODE_DIV; - let d = doy - (MONTH_ENCODE_DIV * mp + 2) / MONTH_ENCODE_MUL + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - - let hour = time_of_day / SECS_PER_HOUR; - let min = (time_of_day % SECS_PER_HOUR) / SECS_PER_MIN; - let sec = time_of_day % SECS_PER_MIN; - - format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", - y, m, d, hour, min, sec - ) -} - #[cfg(test)] mod tests { use super::*; @@ -386,7 +254,6 @@ mod tests { write_new_exclusive(&path, &pending).unwrap(); - // 正しく書き込まれている let loaded: PendingFile = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!(loaded, pending); @@ -628,7 +495,12 @@ mod tests { fn utc_now_iso8601_matches_expected_format() { let s = utc_now_iso8601(); // YYYY-MM-DDTHH:MM:SSZ = 20 chars - assert_eq!(s.len(), 20, "unexpected length: {}", s); + assert_eq!( + s.len(), + "1970-01-01T00:00:00Z".len(), + "unexpected length: {}", + s + ); assert!(s.ends_with('Z')); assert_eq!(s.chars().nth(4), Some('-')); assert_eq!(s.chars().nth(7), Some('-')); @@ -636,9 +508,4 @@ mod tests { assert_eq!(s.chars().nth(13), Some(':')); assert_eq!(s.chars().nth(16), Some(':')); } - - #[test] - fn epoch_secs_to_iso8601_epoch_zero_is_unix_epoch() { - assert_eq!(epoch_secs_to_iso8601(0), "1970-01-01T00:00:00Z"); - } } diff --git a/src/cli-pr-monitor/Cargo.toml b/src/cli-pr-monitor/Cargo.toml index 15844d9..84d62c4 100644 --- a/src/cli-pr-monitor/Cargo.toml +++ b/src/cli-pr-monitor/Cargo.toml @@ -8,6 +8,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" lib-jj-helpers = { path = "../lib-jj-helpers" } +lib-pending-file = { path = "../lib-pending-file" } lib-report-formatter = { path = "../lib-report-formatter" } [dev-dependencies] diff --git a/src/cli-pr-monitor/src/config.rs b/src/cli-pr-monitor/src/config.rs index e157ec8..76d735e 100644 --- a/src/cli-pr-monitor/src/config.rs +++ b/src/cli-pr-monitor/src/config.rs @@ -166,11 +166,11 @@ task = "analyze PR review comments" extra_args = ["--pipeline", "--skip-git"] "#; let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.monitor.enabled, true); + assert!(config.monitor.enabled); assert_eq!(config.monitor.poll_interval_secs, 45); assert_eq!(config.monitor.max_duration_secs, 900); - assert_eq!(config.monitor.check_ci, true); - assert_eq!(config.monitor.check_coderabbit, false); + assert!(config.monitor.check_ci); + assert!(!config.monitor.check_coderabbit); let takt = config.takt.unwrap(); assert_eq!(takt.workflow, "post-pr-review"); @@ -185,7 +185,7 @@ extra_args = ["--pipeline", "--skip-git"] enabled = true "#; let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.monitor.enabled, true); + assert!(config.monitor.enabled); assert!(config.takt.is_none()); } @@ -194,7 +194,7 @@ enabled = true let toml_str = "[monitor]\n"; let config: Config = toml::from_str(toml_str).unwrap(); // serde(default) により空の [monitor] でも MonitorConfig::default() と同じ値 - assert_eq!(config.monitor.enabled, true); + assert!(config.monitor.enabled); assert_eq!(config.monitor.poll_interval_secs, DEFAULT_POLL_INTERVAL); } @@ -205,7 +205,7 @@ enabled = true enabled = false "#; let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.monitor.enabled, false); + assert!(!config.monitor.enabled); } #[test] diff --git a/src/cli-pr-monitor/src/util.rs b/src/cli-pr-monitor/src/util.rs index fce9800..abbee9e 100644 --- a/src/cli-pr-monitor/src/util.rs +++ b/src/cli-pr-monitor/src/util.rs @@ -1,4 +1,5 @@ use lib_jj_helpers::{get_jj_bookmarks as lib_get_jj_bookmarks, StderrMode}; +pub(crate) use lib_pending_file::utc_now_iso8601; use crate::log::log_info; use crate::runner::run_gh_quiet; @@ -93,44 +94,10 @@ pub(crate) fn get_jj_bookmarks() -> Vec { lib_get_jj_bookmarks(StderrMode::Silent, Some(log_info)) } -/// epoch seconds を ISO 8601 UTC 文字列に変換する (std のみ, chrono 不要) -pub(crate) fn epoch_secs_to_iso8601(epoch: u64) -> String { - let secs_per_day: u64 = 86400; - let day_count = (epoch / secs_per_day) as i64; - let time_of_day = epoch % secs_per_day; - - let z = day_count + 719468; - let era = (if z >= 0 { z } else { z - 146096 }) / 146097; - let doe = (z - era * 146097) as u64; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - - let hour = time_of_day / 3600; - let min = (time_of_day % 3600) / 60; - let sec = time_of_day % 60; - - format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", - y, m, d, hour, min, sec - ) -} - -pub(crate) fn utc_now_iso8601() -> String { - use std::time::SystemTime; - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default(); - epoch_secs_to_iso8601(now.as_secs()) -} - #[cfg(test)] mod tests { use super::*; + use lib_pending_file::epoch_secs_to_iso8601; #[test] fn epoch_zero() { diff --git a/src/cli-push-pipeline/src/main.rs b/src/cli-push-pipeline/src/main.rs index 46696a3..3115712 100644 --- a/src/cli-push-pipeline/src/main.rs +++ b/src/cli-push-pipeline/src/main.rs @@ -177,10 +177,14 @@ fn config_path() -> PathBuf { /// hooks-config.toml を読み込みパースする fn load_config() -> Result { let path = config_path(); - let content = std::fs::read_to_string(&path) - .map_err(|e| format!("hooks-config.toml の読み込みに失敗: {} ({})", path.display(), e))?; - toml::from_str(&content) - .map_err(|e| format!("hooks-config.toml のパースに失敗: {}", e)) + let content = std::fs::read_to_string(&path).map_err(|e| { + format!( + "hooks-config.toml の読み込みに失敗: {} ({})", + path.display(), + e + ) + })?; + toml::from_str(&content).map_err(|e| format!("hooks-config.toml のパースに失敗: {}", e)) } // ─── パイプライン実行 ─── @@ -217,10 +221,7 @@ fn run_pipeline() -> i32 { log_info("警告: パイプラインステップが定義されていません。push のみ実行します。"); } - log_info(&format!( - "パイプライン開始 ({} ステップ)", - steps.len() - )); + log_info(&format!("パイプライン開始 ({} ステップ)", steps.len())); // ステップを順次実行 for (i, step) in steps.iter().enumerate() { @@ -349,7 +350,9 @@ prompt = "review_changes" DEFAULT_STEP_TIMEOUT_SECS ); assert_eq!( - pipeline.push_cmd.unwrap_or_else(|| DEFAULT_PUSH_CMD.to_string()), + pipeline + .push_cmd + .unwrap_or_else(|| DEFAULT_PUSH_CMD.to_string()), DEFAULT_PUSH_CMD ); assert!(pipeline.steps.unwrap_or_default().is_empty()); diff --git a/src/cli-push-runner/src/config.rs b/src/cli-push-runner/src/config.rs index 37d9f34..f97be94 100644 --- a/src/cli-push-runner/src/config.rs +++ b/src/cli-push-runner/src/config.rs @@ -220,7 +220,7 @@ task = "t" command = "echo push" "#; let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.quality_gate.parallel.unwrap_or(true), true); + assert!(config.quality_gate.parallel.unwrap_or(true)); assert_eq!( config .quality_gate diff --git a/src/cli-push-runner/src/stages/push_jj_bookmark.rs b/src/cli-push-runner/src/stages/push_jj_bookmark.rs index 5c93846..0d10d24 100644 --- a/src/cli-push-runner/src/stages/push_jj_bookmark.rs +++ b/src/cli-push-runner/src/stages/push_jj_bookmark.rs @@ -139,8 +139,10 @@ fn run_jj(args: &[&str], error_prefix: &str) -> Result { .spawn() .map_err(|e| format!("{}: {}", error_prefix, e))?; - let stdout_handle = crate::runner::drain_pipe(child.stdout.take().expect("stdout must be piped")); - let stderr_handle = crate::runner::drain_pipe(child.stderr.take().expect("stderr must be piped")); + let stdout_handle = + crate::runner::drain_pipe(child.stdout.take().expect("stdout must be piped")); + let stderr_handle = + crate::runner::drain_pipe(child.stderr.take().expect("stderr must be piped")); let status = crate::runner::wait_with_timeout(error_prefix, &mut child, JJ_TIMEOUT_SECS) .map_err(|e| format!("{}: {}", error_prefix, e))?; @@ -149,7 +151,10 @@ fn run_jj(args: &[&str], error_prefix: &str) -> Result { let stderr_text = stderr_handle.join().unwrap_or_default(); match status { - None => Err(format!("{}: タイムアウト ({}s)", error_prefix, JJ_TIMEOUT_SECS)), + None => Err(format!( + "{}: タイムアウト ({}s)", + error_prefix, JJ_TIMEOUT_SECS + )), Some(s) if s.success() => Ok(stdout_text), Some(_) => Err(stderr_text.trim().to_string()), } diff --git a/src/hooks-pre-tool-validate/src/main.rs b/src/hooks-pre-tool-validate/src/main.rs index 9234d34..1b219a6 100644 --- a/src/hooks-pre-tool-validate/src/main.rs +++ b/src/hooks-pre-tool-validate/src/main.rs @@ -145,23 +145,22 @@ git コマンドを直接使用すると、バージョン履歴に不整合が /// プリセット: jj-immutable fn preset_jj_immutable() -> Vec { - vec![ - BlockedPattern { - pattern: Regex::new(r"(?is)\bjj\b.*--ignore-immutable").unwrap(), - message: r#"**jj --ignore-immutable がブロックされました** + vec![BlockedPattern { + pattern: Regex::new(r"(?is)\bjj\b.*--ignore-immutable").unwrap(), + message: r#"**jj --ignore-immutable がブロックされました** immutable commits(main 等)の書き換え保護を無効化するオプションのため、使用が禁止されています。 immutable commits を変更する必要がある場合は、ユーザーに確認を取ってください。"#, - }, - ] + }] } /// プリセット: jj-main-guard (jj new main / jj edit main) fn preset_jj_main_guard() -> Vec { vec![ BlockedPattern { - pattern: Regex::new(r#"(?i)(jj\s+new|pnpm\s+jj-new)\s+(?:"main"|'main'|main)(?:\s|$)"#).unwrap(), + pattern: Regex::new(r#"(?i)(jj\s+new|pnpm\s+jj-new)\s+(?:"main"|'main'|main)(?:\s|$)"#) + .unwrap(), message: r#"**jj new main がブロックされました** ローカルの main ブックマークをベースに change を作成することは禁止されています。 @@ -175,7 +174,10 @@ pnpm jj-start-change これにより origin/main を fetch してから新しい change を作成します。"#, }, BlockedPattern { - pattern: Regex::new(r#"(?i)(jj\s+edit|pnpm\s+jj-edit)\s+(?:"main"|'main'|main)(?:\s|$)"#).unwrap(), + pattern: Regex::new( + r#"(?i)(jj\s+edit|pnpm\s+jj-edit)\s+(?:"main"|'main'|main)(?:\s|$)"#, + ) + .unwrap(), message: r#"**jj edit main がブロックされました** main ブックマークが指す commit を直接編集することは禁止されています。 @@ -349,7 +351,10 @@ fn build_blocked_patterns(config: &Config) -> Vec { message: "**カスタムパターンによりブロックされました**\n\nこのコマンドは hooks-config.toml のカスタムルールによりブロックされています。", }); } else { - eprintln!("[validate-command] Warning: Invalid regex in blocked_patterns: {}", custom); + eprintln!( + "[validate-command] Warning: Invalid regex in blocked_patterns: {}", + custom + ); } } } @@ -468,7 +473,11 @@ fn load_config() -> Config { let path = config_path(); match std::fs::read_to_string(&path) { Ok(content) => toml::from_str(&content).unwrap_or_else(|e| { - eprintln!("[validate-command] Warning: Failed to parse {}: {}", path.display(), e); + eprintln!( + "[validate-command] Warning: Failed to parse {}: {}", + path.display(), + e + ); Config::default() }), Err(_) => Config::default(), // ファイル無し → デフォルト @@ -534,7 +543,7 @@ fn main() -> ExitCode { 機密ファイルの場合: 秘密情報の漏洩を防ぐため、編集できません。\n\n\ 変更が本当に必要な場合は、ユーザーに確認を取ってください。", file_path - .rsplit(|c| c == '/' || c == '\\') + .rsplit(['/', '\\']) .next() .unwrap_or(&file_path) ); @@ -606,12 +615,18 @@ mod tests { #[test] fn jj_presets_independent() { // jj-immutable のみ有効 - assert!(is_blocked_with("jj --ignore-immutable rebase", &["jj-immutable"])); + assert!(is_blocked_with( + "jj --ignore-immutable rebase", + &["jj-immutable"] + )); assert!(!is_blocked_with("jj new main", &["jj-immutable"])); // jj-main-guard のみ有効 assert!(is_blocked_with("jj new main", &["jj-main-guard"])); - assert!(!is_blocked_with("jj --ignore-immutable rebase", &["jj-main-guard"])); + assert!(!is_blocked_with( + "jj --ignore-immutable rebase", + &["jj-main-guard"] + )); } #[test] @@ -734,17 +749,26 @@ mod tests { #[test] fn gh_pr_create_guard_blocks_gh_pr_create() { - assert!(is_blocked_with("gh pr create --title 'test'", &["gh-pr-create-guard"])); + assert!(is_blocked_with( + "gh pr create --title 'test'", + &["gh-pr-create-guard"] + )); } #[test] fn gh_pr_create_guard_blocks_gh_pr_create_in_chain() { - assert!(is_blocked_with("cd /tmp && gh pr create --title 'test'", &["gh-pr-create-guard"])); + assert!(is_blocked_with( + "cd /tmp && gh pr create --title 'test'", + &["gh-pr-create-guard"] + )); } #[test] fn gh_pr_create_guard_blocks_gh_with_repo_pr_create() { - assert!(is_blocked_with("gh -R owner/repo pr create", &["gh-pr-create-guard"])); + assert!(is_blocked_with( + "gh -R owner/repo pr create", + &["gh-pr-create-guard"] + )); } #[test] @@ -771,17 +795,26 @@ mod tests { #[test] fn gh_pr_merge_guard_blocks_gh_pr_merge_squash() { - assert!(is_blocked_with("gh pr merge 42 --squash", &["gh-pr-merge-guard"])); + assert!(is_blocked_with( + "gh pr merge 42 --squash", + &["gh-pr-merge-guard"] + )); } #[test] fn gh_pr_merge_guard_blocks_gh_pr_merge_in_chain() { - assert!(is_blocked_with("cd /tmp && gh pr merge 42", &["gh-pr-merge-guard"])); + assert!(is_blocked_with( + "cd /tmp && gh pr merge 42", + &["gh-pr-merge-guard"] + )); } #[test] fn gh_pr_merge_guard_blocks_gh_with_repo_pr_merge() { - assert!(is_blocked_with("gh -R owner/repo pr merge 42", &["gh-pr-merge-guard"])); + assert!(is_blocked_with( + "gh -R owner/repo pr merge 42", + &["gh-pr-merge-guard"] + )); } #[test] @@ -796,17 +829,26 @@ mod tests { #[test] fn gh_pr_merge_guard_allows_gh_pr_create() { - assert!(!is_blocked_with("gh pr create --title 'test'", &["gh-pr-merge-guard"])); + assert!(!is_blocked_with( + "gh pr create --title 'test'", + &["gh-pr-merge-guard"] + )); } #[test] fn gh_pr_merge_guard_blocks_bash_c_gh_pr_merge() { - assert!(is_blocked_with("bash -c 'gh pr merge 42'", &["gh-pr-merge-guard"])); + assert!(is_blocked_with( + "bash -c 'gh pr merge 42'", + &["gh-pr-merge-guard"] + )); } #[test] fn gh_pr_merge_guard_blocks_sh_lc_gh_pr_merge() { - assert!(is_blocked_with("sh -lc 'gh pr merge 42 --squash'", &["gh-pr-merge-guard"])); + assert!(is_blocked_with( + "sh -lc 'gh pr merge 42 --squash'", + &["gh-pr-merge-guard"] + )); } #[test] @@ -879,7 +921,9 @@ mod tests { #[test] fn blocks_npx_playwright_electron() { - assert!(is_blocked("npx playwright test --config=playwright-electron.config.ts")); + assert!(is_blocked( + "npx playwright test --config=playwright-electron.config.ts" + )); } #[test] @@ -909,7 +953,9 @@ mod tests { #[test] fn blocks_pnpm_exec_playwright_electron() { - assert!(is_blocked("pnpm exec playwright test --config=playwright-electron.config.ts")); + assert!(is_blocked( + "pnpm exec playwright test --config=playwright-electron.config.ts" + )); } // --- jj new main: 第2層 (should block) --- @@ -1069,7 +1115,10 @@ mod tests { #[test] fn protects_with_unix_path() { - assert!(is_protected_config("/home/user/project/.eslintrc.json", &[])); + assert!(is_protected_config( + "/home/user/project/.eslintrc.json", + &[] + )); } #[test] @@ -1116,12 +1165,18 @@ mod tests { #[test] fn protects_husky_with_absolute_path() { - assert!(is_protected_config("/home/user/project/.husky/pre-commit", &[])); + assert!(is_protected_config( + "/home/user/project/.husky/pre-commit", + &[] + )); } #[test] fn protects_husky_with_windows_path() { - assert!(is_protected_config(r"e:\work\project\.husky\pre-commit", &[])); + assert!(is_protected_config( + r"e:\work\project\.husky\pre-commit", + &[] + )); } // --- case-insensitive 保護 --- @@ -1154,7 +1209,10 @@ mod tests { fn extra_protected_files_blocks() { let extra = vec!["settings.local.json".to_string()]; assert!(is_protected_config("settings.local.json", &extra)); - assert!(is_protected_config(r"e:\work\.claude\settings.local.json", &extra)); + assert!(is_protected_config( + r"e:\work\.claude\settings.local.json", + &extra + )); } #[test] @@ -1171,8 +1229,14 @@ mod tests { #[test] fn extra_protected_path_matches_full_path() { let extra = vec![".claude/hooks-config.toml".to_string()]; - assert!(is_protected_config(r"e:\work\project\.claude\hooks-config.toml", &extra)); - assert!(is_protected_config("/home/user/project/.claude/hooks-config.toml", &extra)); + assert!(is_protected_config( + r"e:\work\project\.claude\hooks-config.toml", + &extra + )); + assert!(is_protected_config( + "/home/user/project/.claude/hooks-config.toml", + &extra + )); } #[test] @@ -1194,7 +1258,10 @@ mod tests { // ベースネーム指定は従来通りどこでもマッチ let extra = vec!["hooks-config.toml".to_string()]; assert!(is_protected_config("hooks-config.toml", &extra)); - assert!(is_protected_config(r"e:\work\.claude\hooks-config.toml", &extra)); + assert!(is_protected_config( + r"e:\work\.claude\hooks-config.toml", + &extra + )); assert!(is_protected_config("other/hooks-config.toml", &extra)); } } diff --git a/src/hooks-session-start/src/main.rs b/src/hooks-session-start/src/main.rs index 0141eb7..77bcad2 100644 --- a/src/hooks-session-start/src/main.rs +++ b/src/hooks-session-start/src/main.rs @@ -186,10 +186,7 @@ mod tests { #[test] fn session_id_file_new_file_is_written() { - let tmp = std::env::temp_dir().join(format!( - "test-sid-new-{}", - std::process::id() - )); + let tmp = std::env::temp_dir().join(format!("test-sid-new-{}", std::process::id())); let _ = std::fs::remove_file(&tmp); // ファイルが存在しない → 書き込むべき @@ -208,10 +205,7 @@ mod tests { #[test] fn session_id_file_same_id_is_skipped() { - let tmp = std::env::temp_dir().join(format!( - "test-sid-same-{}", - std::process::id() - )); + let tmp = std::env::temp_dir().join(format!("test-sid-same-{}", std::process::id())); let _ = std::fs::write(&tmp, "session-A"); // 同じ ID → スキップ @@ -225,10 +219,7 @@ mod tests { #[test] fn session_id_file_different_id_is_overwritten() { - let tmp = std::env::temp_dir().join(format!( - "test-sid-diff-{}", - std::process::id() - )); + let tmp = std::env::temp_dir().join(format!("test-sid-diff-{}", std::process::id())); let _ = std::fs::write(&tmp, "session-A"); // 異なる ID → 上書き @@ -245,10 +236,7 @@ mod tests { #[test] fn session_id_file_empty_is_written() { - let tmp = std::env::temp_dir().join(format!( - "test-sid-empty-{}", - std::process::id() - )); + let tmp = std::env::temp_dir().join(format!("test-sid-empty-{}", std::process::id())); let _ = std::fs::write(&tmp, ""); // 空ファイル → 書き込むべき ("" != "session-A") diff --git a/src/hooks-stop-feedback-dispatch/Cargo.toml b/src/hooks-stop-feedback-dispatch/Cargo.toml new file mode 100644 index 0000000..f693655 --- /dev/null +++ b/src/hooks-stop-feedback-dispatch/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "hooks-stop-feedback-dispatch" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +lib-pending-file = { path = "../lib-pending-file" } + +# [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026) diff --git a/src/hooks-stop-feedback-dispatch/src/main.rs b/src/hooks-stop-feedback-dispatch/src/main.rs new file mode 100644 index 0000000..e07986c --- /dev/null +++ b/src/hooks-stop-feedback-dispatch/src/main.rs @@ -0,0 +1,376 @@ +//! Stop フィードバックディスパッチフック (ADR-029) +//! +//! `.claude/post-merge-feedback-pending.json` を検出し、status="pending" なら +//! additionalContext で Claude に `/post-merge-feedback` skill 起動を指示して +//! status="dispatched" に atomic 更新する。 +//! +//! 責務分離 (ADR-022 原則 1): +//! - hooks-stop-quality (既存) は lint / test / build を担う +//! - hooks-stop-feedback-dispatch (本 exe) は pending file の発火判定 + 更新のみ +//! - 実行順序 1 → 2 は settings.local.json.template の array order で保証 +//! +//! 無限ループ防止: +//! stop_hook_active == true なら pending を読まず silent exit (ADR-004 の先行パターン) + +use serde::{Deserialize, Serialize}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +mod pending_file; +use pending_file::{ + epoch_secs_to_iso8601, read_existing, utc_now_epoch_secs, utc_now_iso8601, write_overwrite, + ExistingPending, PendingFile, PendingLock, FILE_NAME, STATUS_DISPATCHED, +}; + +/// stale TTL (24 時間、ADR-029 §破損耐性) +const STALE_TTL_SECS: u64 = 24 * 60 * 60; + +/// Claude が実行すべき slash command (ADR-029 §additionalContext 構造化フォーマット) +const ACTION: &str = "invoke_skill"; +const REASON: &str = "cli-merge-pipeline wrote pending artifact"; + +/// Stop hook 入力 (必要なフィールドのみ) +#[derive(Deserialize)] +struct HookInput { + stop_hook_active: Option, +} + +/// hookSpecificOutput JSON 出力 (additionalContext 発火用) +#[derive(Serialize)] +struct HookSpecificOutput { + #[serde(rename = "hookEventName")] + hook_event_name: &'static str, + #[serde(rename = "additionalContext")] + additional_context: String, +} + +#[derive(Serialize)] +struct Output { + #[serde(rename = "hookSpecificOutput")] + hook_specific_output: HookSpecificOutput, +} + +/// pending file の配置パス (exe と同じディレクトリ = `.claude/`) +fn pending_path() -> PathBuf { + std::env::current_exe() + .unwrap_or_default() + .parent() + .unwrap_or(Path::new(".")) + .join(FILE_NAME) +} + +/// additionalContext 文字列を組み立てる (ADR-029 の固定キー順序) +fn build_additional_context(pending: &PendingFile) -> String { + let command = format!("/post-merge-feedback {}", pending.pr_number); + format!( + "[POST_MERGE_FEEDBACK_TRIGGER]\n\ + schema_version: {}\n\ + pr_number: {}\n\ + owner_repo: {}\n\ + action: {}\n\ + command: {}\n\ + reason: {}", + pending.schema_version, pending.pr_number, pending.owner_repo, ACTION, command, REASON, + ) +} + +/// created_at + STALE_TTL_SECS < now なら true (stale)。 +/// +/// ISO 8601 UTC `YYYY-MM-DDTHH:MM:SSZ` は固定桁 + UTC のため文字列の辞書順が +/// 時刻順と等価になる性質を利用して、epoch 計算を 1 回 (threshold 生成) に抑える。 +fn is_stale(created_at: &str, now_secs: u64) -> bool { + let threshold = epoch_secs_to_iso8601(now_secs.saturating_sub(STALE_TTL_SECS)); + created_at < threshold.as_str() +} + +/// additionalContext を stdout へ JSON として emit する +fn emit_trigger(pending: &PendingFile) { + let output = Output { + hook_specific_output: HookSpecificOutput { + hook_event_name: "Stop", + additional_context: build_additional_context(pending), + }, + }; + match serde_json::to_string(&output) { + Ok(json) => println!("{}", json), + Err(e) => eprintln!( + "[stop-feedback-dispatch] Warning: failed to serialize output: {}", + e + ), + } +} + +/// pending ファイルを status="dispatched" に更新する。失敗時は stderr に WARN を出すが +/// exit code は 0 のまま (hook は fail-open)。 +fn mark_dispatched(path: &Path, mut pending: PendingFile) { + pending.status = STATUS_DISPATCHED.to_string(); + pending.dispatched_at = Some(utc_now_iso8601()); + if let Err(e) = write_overwrite(path, &pending) { + eprintln!( + "[stop-feedback-dispatch] Warning: failed to update status to dispatched: {}", + e + ); + } +} + +/// stale pending を削除し、有効な pending から additionalContext を発火して dispatched に遷移する。 +/// +/// **排他制御** (CodeRabbit PR #71 Major fix): read→emit→write を process-level で排他化するため +/// `PendingLock` を取得する。lock 取得後に再読込して status=pending を再検証することで、初回 +/// read と lock acquire の間に別プロセスが dispatched/consumed に書き換えた場合の二重発火を防ぐ。 +/// +/// **lock leak の backstop** (CodeRabbit PR #71 2 回目 Major fix 簡潔版対応): +/// `PendingLock` は stale recovery を持たないため `kill -9` 等で leak し得るが、 +/// pending 自体の 24h TTL がここで backstop として働く。stale pending を GC する際に +/// `{pending}.lock` も一緒に削除することで、leak した lock も最大 24h で回収される。 +fn handle_pending(path: &Path, pending: PendingFile) { + if is_stale(&pending.created_at, utc_now_epoch_secs()) { + eprintln!( + "[stop-feedback-dispatch] Warning: stale pending file removed (created_at={})", + pending.created_at + ); + let _ = std::fs::remove_file(path); + // leak した可能性がある lock file も backstop で削除 + let _ = std::fs::remove_file(path.with_extension("lock")); + return; + } + + let _lock = match PendingLock::try_acquire(path) { + Ok(Some(lock)) => lock, + Ok(None) => { + // 他プロセスが dispatch 中 → 二重発火防止で何もしない + return; + } + Err(e) => { + eprintln!( + "[stop-feedback-dispatch] Warning: lock acquisition failed ({}); skip dispatch", + e + ); + return; + } + }; + + // lock 保有中に再読込: read→lock の race で別プロセスが dispatched/consumed に + // 書き換えた可能性を排除する + match read_existing(path) { + ExistingPending::Pending(reread) => { + emit_trigger(&reread); + mark_dispatched(path, reread); + } + _ => { + // 別プロセスが先に処理完了 / 破損 → 何もしない (RAII で lock 解放) + } + } +} + +fn main() { + // stdin を読む (エラー時も fail-open: silent exit) + let mut input = String::new(); + if io::stdin().read_to_string(&mut input).is_err() { + return; + } + + let hook_input: HookInput = match serde_json::from_str(&input) { + Ok(v) => v, + Err(_) => return, + }; + + // 無限ループ防止: stop_hook_active なら pending を読まない + if hook_input.stop_hook_active.unwrap_or(false) { + return; + } + + let path = pending_path(); + + match read_existing(&path) { + ExistingPending::None => {} + ExistingPending::Corrupt(reason) => { + eprintln!( + "[stop-feedback-dispatch] Warning: corrupt pending file removed ({})", + reason + ); + let _ = std::fs::remove_file(&path); + } + ExistingPending::Pending(pending) => handle_pending(&path, pending), + ExistingPending::Dispatched => { + // 二重通知防止で silent exit + } + ExistingPending::Consumed => { + let _ = std::fs::remove_file(&path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pending_file::{SCHEMA_VERSION, STATUS_PENDING}; + + fn sample_pending() -> PendingFile { + PendingFile { + schema_version: SCHEMA_VERSION, + pr_number: 123, + owner_repo: "aloekun/claude-code-hook-test".to_string(), + prompt: "post-merge-feedback".to_string(), + status: STATUS_PENDING.to_string(), + created_at: "2026-04-23T10:00:00Z".to_string(), + dispatched_at: None, + consumed_at: None, + producer: None, + } + } + + #[test] + fn stop_hook_active_true_field_parsed() { + let json = r#"{"stop_hook_active": true}"#; + let hook: HookInput = serde_json::from_str(json).unwrap(); + assert_eq!(hook.stop_hook_active, Some(true)); + } + + #[test] + fn stop_hook_active_missing_defaults_false() { + let json = r#"{}"#; + let hook: HookInput = serde_json::from_str(json).unwrap(); + assert!(!hook.stop_hook_active.unwrap_or(false)); + } + + #[test] + fn additional_context_key_order_is_fixed() { + let pending = sample_pending(); + let ctx = build_additional_context(&pending); + + // キー順序: schema_version → pr_number → owner_repo → action → command → reason + let lines: Vec<&str> = ctx.split('\n').collect(); + assert_eq!(lines[0], "[POST_MERGE_FEEDBACK_TRIGGER]"); + assert!(lines[1].starts_with("schema_version: ")); + assert!(lines[2].starts_with("pr_number: ")); + assert!(lines[3].starts_with("owner_repo: ")); + assert!(lines[4].starts_with("action: ")); + assert!(lines[5].starts_with("command: ")); + assert!(lines[6].starts_with("reason: ")); + + // 値の埋め込みも検証 + assert!(ctx.contains("schema_version: 1")); + assert!(ctx.contains("pr_number: 123")); + assert!(ctx.contains("owner_repo: aloekun/claude-code-hook-test")); + assert!(ctx.contains("action: invoke_skill")); + assert!(ctx.contains("command: /post-merge-feedback 123")); + assert!(ctx.contains("reason: cli-merge-pipeline wrote pending artifact")); + } + + #[test] + fn additional_context_tag_is_first_line() { + let pending = sample_pending(); + let ctx = build_additional_context(&pending); + assert!( + ctx.starts_with("[POST_MERGE_FEEDBACK_TRIGGER]\n"), + "tag must be on first line for reliable detection: {}", + ctx + ); + } + + #[test] + fn is_stale_returns_true_when_older_than_24h() { + // created_at = 1970-01-01T00:00:00Z, now = 2日後 (48h) → stale + let now_secs = 48 * 3600; + assert!(is_stale("1970-01-01T00:00:00Z", now_secs)); + } + + #[test] + fn is_stale_returns_false_when_within_24h() { + // created_at = 1970-01-02T00:00:00Z (86400s), now = 1日後 + 1h (90000s) → 差分 3600s < 24h + let now_secs = 86400 + 3600; + assert!(!is_stale("1970-01-02T00:00:00Z", now_secs)); + } + + #[test] + fn is_stale_boundary_exactly_24h() { + // 差分ちょうど 24h のとき、threshold == created_at → `<` なので stale=false + let now_secs = 86400 + 24 * 3600; + assert!(!is_stale("1970-01-02T00:00:00Z", now_secs)); + } + + #[test] + fn is_stale_returns_true_just_past_24h() { + // 差分 24h + 1s → threshold > created_at → stale=true + let now_secs = 86400 + 24 * 3600 + 1; + assert!(is_stale("1970-01-02T00:00:00Z", now_secs)); + } + + #[test] + fn hook_specific_output_serializes_with_correct_keys() { + let output = Output { + hook_specific_output: HookSpecificOutput { + hook_event_name: "Stop", + additional_context: "dummy".to_string(), + }, + }; + let json = serde_json::to_string(&output).unwrap(); + assert!(json.contains(r#""hookSpecificOutput""#)); + assert!(json.contains(r#""hookEventName":"Stop""#)); + assert!(json.contains(r#""additionalContext":"dummy""#)); + } + + #[test] + fn handle_pending_stale_branch_cleans_up_leaked_lock() { + // leak した lock を backstop で回収するシナリオ: + // 24h 超の stale pending を GC する際に {pending}.lock も削除される + let base_dir = std::env::temp_dir(); + let unique = format!( + "test-stale-lock-cleanup-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0), + ); + let pending_path = base_dir.join(format!("{}.json", unique)); + let lock_path = pending_path.with_extension("lock"); + + // stale pending (created_at = 1970) + leak lock を配置 + let mut pending = sample_pending(); + pending.created_at = "1970-01-01T00:00:00Z".to_string(); + std::fs::write( + &pending_path, + serde_json::to_string_pretty(&pending).unwrap(), + ) + .unwrap(); + std::fs::write(&lock_path, "pid=0 at=crashed").unwrap(); + + handle_pending(&pending_path, pending); + + assert!(!pending_path.exists(), "stale pending should be removed"); + assert!( + !lock_path.exists(), + "leaked lock should be removed as backstop" + ); + } + + #[test] + fn mark_dispatched_sets_status_and_timestamp() { + let path = std::env::temp_dir().join(format!( + "test-mark-dispatched-{}-{}.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0), + )); + // 既存の pending ファイルを作成 + let json = serde_json::to_string_pretty(&sample_pending()).unwrap(); + std::fs::write(&path, json).unwrap(); + + mark_dispatched(&path, sample_pending()); + + let loaded: PendingFile = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(loaded.status, STATUS_DISPATCHED); + assert!(loaded.dispatched_at.is_some()); + // dispatched_at は ISO 8601 フォーマット + let ts = loaded.dispatched_at.unwrap(); + assert_eq!(ts.len(), "1970-01-01T00:00:00Z".len()); + assert!(ts.ends_with('Z')); + + let _ = std::fs::remove_file(&path); + } +} diff --git a/src/hooks-stop-feedback-dispatch/src/pending_file.rs b/src/hooks-stop-feedback-dispatch/src/pending_file.rs new file mode 100644 index 0000000..459d891 --- /dev/null +++ b/src/hooks-stop-feedback-dispatch/src/pending_file.rs @@ -0,0 +1,499 @@ +//! post-merge-feedback pending file の読み取りと status 更新 (ADR-029) +//! +//! 共有スキーマ・定数・UTC ヘルパーは `lib-pending-file` に集約。 +//! 本モジュールは dispatcher 固有の読み取りロジックと status 遷移を担う。 +//! +//! dispatcher 固有の差分: +//! - `ExistingPending` を `Pending` / `Dispatched` に分けて扱う +//! (cli-merge-pipeline 側は両方 `Active(String)` にまとめているが、hook 側は分岐が必要) +//! - status 遷移 (pending → dispatched) は `write_overwrite` (tmp → rename) を使う +//! - stale TTL (24h) 判定のため `epoch_secs_to_iso8601` を内部公開 +//! - pending→dispatched の process-level 排他は `PendingLock` (RAII) で担保 (CodeRabbit PR #71) + +use std::fs::OpenOptions; +use std::io::{ErrorKind, Write}; +use std::path::{Path, PathBuf}; + +// ─── Re-exports from lib-pending-file ─── + +pub(crate) use lib_pending_file::PendingFile; +pub(crate) use lib_pending_file::{ + epoch_secs_to_iso8601, is_valid_owner_repo, utc_now_epoch_secs, utc_now_iso8601, FILE_NAME, + SCHEMA_VERSION, STATUS_CONSUMED, STATUS_DISPATCHED, STATUS_PENDING, +}; + +// ─── Dispatcher-local types ─── + +/// 既存 pending file の読み取り結果 (dispatcher 版)。 +/// +/// cli-merge-pipeline 側は `pending` / `dispatched` を `Active(String)` にまとめているが、 +/// dispatcher は両者で挙動が異なる (pending → 発火、dispatched → 二重通知しない) ので +/// 別 variant にする。 +#[derive(Debug)] +pub(crate) enum ExistingPending { + /// ファイル不在。通常経路で silent exit。 + None, + /// size 0 / parse 失敗 / schema_version 不一致 / 未知 status。削除 + silent exit。 + Corrupt(String), + /// status = "pending"。additionalContext 発火 + dispatched へ遷移。 + Pending(PendingFile), + /// status = "dispatched"。二重通知防止で silent exit。 + Dispatched, + /// status = "consumed"。skill 完了後の残骸。削除 + silent exit。 + Consumed, +} + +/// 既存 pending file の状態を判定する。 +/// +/// SEC-001: STATUS_PENDING 時に `is_valid_owner_repo` で `owner_repo` を検証する。 +/// 不正値 (newline 等) は `Corrupt` として廃棄し `additionalContext` への注入を防ぐ。 +pub(crate) fn read_existing(path: &Path) -> ExistingPending { + let meta = match std::fs::metadata(path) { + Ok(m) => m, + Err(_) => return ExistingPending::None, + }; + if meta.len() == 0 { + return ExistingPending::Corrupt("size=0".to_string()); + } + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) => return ExistingPending::Corrupt(format!("read error: {}", e)), + }; + let pending: PendingFile = match serde_json::from_str(&content) { + Ok(p) => p, + Err(e) => return ExistingPending::Corrupt(format!("parse error: {}", e)), + }; + if pending.schema_version != SCHEMA_VERSION { + return ExistingPending::Corrupt(format!( + "schema_version mismatch (got {}, want {})", + pending.schema_version, SCHEMA_VERSION + )); + } + match pending.status.as_str() { + STATUS_PENDING => { + if !is_valid_owner_repo(&pending.owner_repo) { + return ExistingPending::Corrupt(format!( + "invalid owner_repo '{}'", + pending.owner_repo + )); + } + ExistingPending::Pending(pending) + } + STATUS_DISPATCHED => ExistingPending::Dispatched, + STATUS_CONSUMED => ExistingPending::Consumed, + other => ExistingPending::Corrupt(format!("unknown status '{}'", other)), + } +} + +/// pending file を上書き書き込み (tmp → rename の 2 段階)。 +/// +/// status 更新 (pending → dispatched) で使用。呼び出し元は事前に read_existing で +/// ファイル存在を確認済みの前提。tmp 名は `{file_name}.tmp.{pid}` の一意形式。 +pub(crate) fn write_overwrite(path: &Path, pending: &PendingFile) -> Result<(), String> { + let json = + serde_json::to_string_pretty(pending).map_err(|e| format!("serialize error: {}", e))?; + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "pending".to_string()); + let tmp_name = format!("{}.tmp.{}", file_name, std::process::id()); + let tmp_path = path.with_file_name(tmp_name); + if let Err(e) = std::fs::write(&tmp_path, &json) { + let _ = std::fs::remove_file(&tmp_path); + return Err(format!("tmp write failed ({}): {}", tmp_path.display(), e)); + } + if let Err(e) = std::fs::rename(&tmp_path, path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(format!( + "rename failed ({} → {}): {}", + tmp_path.display(), + path.display(), + e + )); + } + Ok(()) +} + +// ─── Process-level dispatch lock (CodeRabbit PR #71 Major fix) ─── + +/// pending→dispatched 遷移を process-level で排他化する RAII lock。 +/// +/// ADR-029 の「ロックファイル不要」は `create_new` による pending file 自体の **新規作成 +/// atomicity** についての記述であり、pending を **read して dispatched へ write** する +/// 遷移の同期とは別軸の問題。複数の Stop hook が同一の pending を並行処理できる以上、 +/// read→emit→write の区間を process-level で排他化しないと additionalContext が重複発火する。 +/// +/// **排他方式**: `{pending}.lock` を `OpenOptions::create_new(true)` (O_EXCL 相当) で +/// 排他作成する。`write_new_exclusive` と同じ pattern。 +/// +/// **解放**: Drop で `remove_file`。panic / 早期 return でも RAII で安全に片付く。 +/// +/// **leak 対策 (CodeRabbit PR #71 の 2 回目 Major 指摘 + ユーザー合意による簡潔版対応)**: +/// lock file が `kill -9` 等で残存しても本モジュールでは **stale 回復を行わない**。 +/// 代わりに pending file 自体の 24h TTL を backstop として利用し、`main.rs` の +/// `handle_pending` が stale な pending を GC するタイミングで `{pending}.lock` も +/// 一緒に削除する設計。stale-recovery の TOCTOU race (複数プロセスが同時に「古い +/// lock を削除→新規作成」する際に他プロセスの新しい lock を誤削除するレース) を +/// 根本から避けるためのトレードオフ。leak 中の最大遅延は 24h で、ユーザーは +/// `/post-merge-feedback` を手動起動することでいつでも救済できる。 +pub(crate) struct PendingLock { + lock_path: PathBuf, +} + +impl PendingLock { + /// ロック取得を試みる。 + /// + /// 戻り値: + /// - `Ok(Some(lock))`: 排他取得成功。`lock` を drop するまで他プロセスは取得不能 + /// - `Ok(None)`: 他プロセスが保持中 (active) → dispatch しない + /// - `Err(...)`: I/O エラー (呼び出し側は stderr WARN + fail-open で継続) + pub(crate) fn try_acquire(pending_path: &Path) -> Result, String> { + let lock_path = pending_path.with_extension("lock"); + match Self::try_create(&lock_path)? { + Some(()) => Ok(Some(Self { lock_path })), + None => Ok(None), + } + } + + /// 低レベル create_new。`Ok(Some(()))` = 取得、`Ok(None)` = AlreadyExists、`Err` = I/O エラー。 + fn try_create(lock_path: &Path) -> Result, String> { + match OpenOptions::new() + .write(true) + .create_new(true) + .open(lock_path) + { + Ok(mut f) => { + // observation 目的で PID と timestamp を記録 (best effort、失敗しても非致命) + let _ = writeln!(f, "pid={} at={}", std::process::id(), utc_now_iso8601()); + Ok(Some(())) + } + Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(None), + Err(e) => Err(format!( + "lock create_new 失敗 ({}): {}", + lock_path.display(), + e + )), + } + } +} + +impl Drop for PendingLock { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.lock_path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn sample_pending(status: &str) -> PendingFile { + PendingFile { + schema_version: SCHEMA_VERSION, + pr_number: 123, + owner_repo: "aloekun/claude-code-hook-test".to_string(), + prompt: "post-merge-feedback".to_string(), + status: status.to_string(), + created_at: "2026-04-23T10:00:00Z".to_string(), + dispatched_at: None, + consumed_at: None, + producer: None, + } + } + + fn unique_tmp(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "pending-dispatch-{}-{}-{}.json", + label, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0), + )) + } + + fn write_raw(path: &std::path::Path, pending: &PendingFile) { + let json = serde_json::to_string_pretty(pending).unwrap(); + std::fs::write(path, json).unwrap(); + } + + #[test] + fn read_existing_returns_none_when_absent() { + let path = unique_tmp("absent"); + assert!(matches!(read_existing(&path), ExistingPending::None)); + } + + #[test] + fn read_existing_returns_corrupt_for_empty_file() { + let path = unique_tmp("empty"); + std::fs::write(&path, "").unwrap(); + match read_existing(&path) { + ExistingPending::Corrupt(reason) => assert!(reason.contains("size=0")), + other => panic!("expected Corrupt, got {:?}", other), + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn read_existing_returns_corrupt_for_invalid_json() { + let path = unique_tmp("bad-json"); + std::fs::write(&path, "garbage").unwrap(); + match read_existing(&path) { + ExistingPending::Corrupt(reason) => assert!(reason.contains("parse")), + other => panic!("expected Corrupt, got {:?}", other), + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn read_existing_returns_corrupt_for_schema_mismatch() { + let path = unique_tmp("bad-schema"); + let mut pending = sample_pending(STATUS_PENDING); + pending.schema_version = 99; + write_raw(&path, &pending); + match read_existing(&path) { + ExistingPending::Corrupt(reason) => assert!(reason.contains("schema_version")), + other => panic!("expected Corrupt, got {:?}", other), + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn read_existing_returns_corrupt_for_unknown_status() { + let path = unique_tmp("bad-status"); + write_raw(&path, &sample_pending("garbage")); + match read_existing(&path) { + ExistingPending::Corrupt(reason) => assert!(reason.contains("unknown status")), + other => panic!("expected Corrupt, got {:?}", other), + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn read_existing_returns_pending_with_payload() { + let path = unique_tmp("pending-payload"); + write_raw(&path, &sample_pending(STATUS_PENDING)); + match read_existing(&path) { + ExistingPending::Pending(p) => { + assert_eq!(p.status, STATUS_PENDING); + assert_eq!(p.pr_number, 123); + assert_eq!(p.owner_repo, "aloekun/claude-code-hook-test"); + } + other => panic!("expected Pending, got {:?}", other), + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn read_existing_returns_corrupt_for_invalid_owner_repo() { + let path = unique_tmp("owner-repo-injection"); + let mut pending = sample_pending(STATUS_PENDING); + pending.owner_repo = "owner/repo\nmalicious".to_string(); + write_raw(&path, &pending); + match read_existing(&path) { + ExistingPending::Corrupt(reason) => { + assert!(reason.contains("invalid owner_repo"), "reason: {}", reason); + } + other => panic!("expected Corrupt for injected owner_repo, got {:?}", other), + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn read_existing_returns_dispatched() { + let path = unique_tmp("dispatched"); + write_raw(&path, &sample_pending(STATUS_DISPATCHED)); + assert!(matches!(read_existing(&path), ExistingPending::Dispatched)); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn read_existing_returns_consumed() { + let path = unique_tmp("consumed"); + write_raw(&path, &sample_pending(STATUS_CONSUMED)); + assert!(matches!(read_existing(&path), ExistingPending::Consumed)); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn write_overwrite_updates_status() { + let path = unique_tmp("overwrite"); + write_raw(&path, &sample_pending(STATUS_PENDING)); + + let mut next = sample_pending(STATUS_DISPATCHED); + next.dispatched_at = Some("2026-04-23T10:01:00Z".to_string()); + write_overwrite(&path, &next).unwrap(); + + let loaded: PendingFile = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(loaded.status, STATUS_DISPATCHED); + assert_eq!( + loaded.dispatched_at.as_deref(), + Some("2026-04-23T10:01:00Z") + ); + + // tmp 残骸がないこと + let dir = path.parent().unwrap(); + let basename = path.file_name().unwrap().to_string_lossy().into_owned(); + let tmp_prefix = format!("{}.tmp.", basename); + let residues: Vec<_> = std::fs::read_dir(dir) + .unwrap() + .flatten() + .filter(|e| e.file_name().to_string_lossy().starts_with(&tmp_prefix)) + .collect(); + assert!(residues.is_empty(), "tmp residue left: {}", residues.len()); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn utc_now_iso8601_format() { + let s = utc_now_iso8601(); + assert_eq!(s.len(), "1970-01-01T00:00:00Z".len()); + assert!(s.ends_with('Z')); + } + + #[test] + fn epoch_secs_zero_is_unix_epoch() { + assert_eq!(epoch_secs_to_iso8601(0), "1970-01-01T00:00:00Z"); + } + + #[test] + fn epoch_secs_day_boundary() { + assert_eq!(epoch_secs_to_iso8601(86400), "1970-01-02T00:00:00Z"); + } + + #[test] + fn pending_without_producer_deserializes() { + let json = r#"{ + "schema_version": 1, + "pr_number": 42, + "owner_repo": "o/r", + "prompt": "post-merge-feedback", + "status": "pending", + "created_at": "2026-04-23T10:00:00Z", + "dispatched_at": null, + "consumed_at": null + }"#; + let p: PendingFile = serde_json::from_str(json).unwrap(); + assert_eq!(p.producer, None); + assert_eq!(p.pr_number, 42); + } + + // ─── PendingLock tests (CodeRabbit PR #71 Major fix) ─── + + #[test] + fn pending_lock_acquires_and_releases_on_drop() { + let pending_path = unique_tmp("lock-acquire"); + let lock_path = pending_path.with_extension("lock"); + + let lock = PendingLock::try_acquire(&pending_path).unwrap(); + assert!(lock.is_some(), "first try_acquire should succeed"); + assert!(lock_path.exists(), "lock file should exist while lock held"); + + drop(lock); + assert!(!lock_path.exists(), "lock file should be removed on drop"); + } + + #[test] + fn pending_lock_returns_none_when_already_held() { + let pending_path = unique_tmp("lock-double"); + let lock1 = PendingLock::try_acquire(&pending_path).unwrap(); + assert!(lock1.is_some()); + + let lock2 = PendingLock::try_acquire(&pending_path).unwrap(); + assert!(lock2.is_none(), "second try_acquire should return None"); + + drop(lock1); + let lock3 = PendingLock::try_acquire(&pending_path).unwrap(); + assert!( + lock3.is_some(), + "after first drop, re-acquire should succeed" + ); + drop(lock3); + } + + #[test] + fn pending_lock_returns_none_when_leaked() { + // leak シミュレーション: 手動で lock file を置くと try_acquire は None を返す + // (stale recovery を撤去したので、leak した lock は pending file の 24h GC で回収される) + let pending_path = unique_tmp("lock-leaked"); + let lock_path = pending_path.with_extension("lock"); + std::fs::write(&lock_path, "pid=0 at=crashed").unwrap(); + + let result = PendingLock::try_acquire(&pending_path).unwrap(); + assert!(result.is_none(), "leaked lock should block acquisition"); + + // lock file は残存する (手動 cleanup 対象 / pending GC の backstop で回収される) + assert!(lock_path.exists()); + let _ = std::fs::remove_file(&lock_path); + } + + #[test] + fn pending_lock_atomic_under_concurrent_acquirers() { + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + use std::sync::{Arc, Barrier}; + + const THREAD_COUNT: usize = 8; + + let pending_path = Arc::new(unique_tmp("lock-concurrent")); + let success_count = Arc::new(AtomicUsize::new(0)); + let none_count = Arc::new(AtomicUsize::new(0)); + + // Barrier で全スレッドの try_acquire を同期 (CodeRabbit PR #71 Minor fix)。 + // 以前は「winner が 50ms 保持」だけに頼っていたため、slow CI / Windows Defender 常駐 + // スキャン下などスレッド起動遅延が 50ms を超える環境では、winner drop 後に後発スレッドが + // 再取得して success_count=2 になる flaky 経路があった。Barrier で全スレッドの + // attempt 時刻を揃えることで、race を決定論化する。 + let barrier = Arc::new(Barrier::new(THREAD_COUNT)); + + let handles: Vec<_> = (0..THREAD_COUNT) + .map(|_| { + let p = Arc::clone(&pending_path); + let ok = Arc::clone(&success_count); + let ne = Arc::clone(&none_count); + let b = Arc::clone(&barrier); + std::thread::spawn(move || { + b.wait(); + match PendingLock::try_acquire(&p) { + Ok(Some(lock)) => { + ok.fetch_add(1, AtomicOrdering::Relaxed); + // 後発スレッドも確実にこのタイミングで attempt 済になっているが、 + // 保持時間を 50ms 設けて、万一スケジューリングが大きくずれても + // 後発が「lock 存在」を観測できる余裕を残す。 + std::thread::sleep(std::time::Duration::from_millis(50)); + drop(lock); + } + Ok(None) => { + ne.fetch_add(1, AtomicOrdering::Relaxed); + } + Err(e) => panic!("unexpected error: {}", e), + } + }) + }) + .collect(); + + for h in handles { + h.join().unwrap(); + } + + // 排他予約が効いているなら、成功は 1 スレッドのみ・残り THREAD_COUNT-1 は None + assert_eq!( + success_count.load(AtomicOrdering::Relaxed), + 1, + "exactly one thread should win the lock" + ); + assert_eq!( + none_count.load(AtomicOrdering::Relaxed), + THREAD_COUNT - 1, + "remaining {} threads should get None", + THREAD_COUNT - 1 + ); + + // lock path は drop 済みで存在しないはず + let lock_path = pending_path.with_extension("lock"); + assert!(!lock_path.exists()); + } +} diff --git a/src/hooks-stop-quality/src/main.rs b/src/hooks-stop-quality/src/main.rs index 21e2b78..825a53c 100644 --- a/src/hooks-stop-quality/src/main.rs +++ b/src/hooks-stop-quality/src/main.rs @@ -79,7 +79,11 @@ fn load_config() -> (Config, bool) { match std::fs::read_to_string(&path) { Ok(content) => { let config = toml::from_str(&content).unwrap_or_else(|e| { - eprintln!("[stop-quality] Warning: Failed to parse {}: {}", path.display(), e); + eprintln!( + "[stop-quality] Warning: Failed to parse {}: {}", + path.display(), + e + ); Config::default() }); (config, true) @@ -196,14 +200,20 @@ fn main() { // stdin を消費(fail-closed: エラー時は block) let mut input = String::new(); if let Err(e) = io::stdin().read_to_string(&mut input) { - emit_block(&format!("品質ゲートエラー: stdin読み込みに失敗しました: {}", e)); + emit_block(&format!( + "品質ゲートエラー: stdin読み込みに失敗しました: {}", + e + )); return; } let hook_input: HookInput = match serde_json::from_str(&input) { Ok(v) => v, Err(e) => { - emit_block(&format!("品質ゲートエラー: 入力JSONのパースに失敗しました: {}", e)); + emit_block(&format!( + "品質ゲートエラー: 入力JSONのパースに失敗しました: {}", + e + )); return; } }; @@ -216,15 +226,21 @@ fn main() { // 設定からステップとタイムアウトを取得 let stop_config = config.stop_quality.unwrap_or_default(); let steps = stop_config.steps.unwrap_or_default(); - let timeout = stop_config.step_timeout.unwrap_or(DEFAULT_STEP_TIMEOUT_SECS); + let timeout = stop_config + .step_timeout + .unwrap_or(DEFAULT_STEP_TIMEOUT_SECS); // ステップが無い場合は警告を出して停止許可 if steps.is_empty() { if !config_found { - eprintln!("[stop-quality] Warning: hooks-config.toml not found. Quality gate is disabled."); + eprintln!( + "[stop-quality] Warning: hooks-config.toml not found. Quality gate is disabled." + ); eprintln!("[stop-quality] Place hooks-config.toml in the same directory as this exe."); } else { - eprintln!("[stop-quality] Warning: No quality steps configured. Quality gate is disabled."); + eprintln!( + "[stop-quality] Warning: No quality steps configured. Quality gate is disabled." + ); } return; } @@ -328,8 +344,8 @@ cmd = "pnpm test" #[test] fn step_timeout_default_is_reasonable() { - assert!(DEFAULT_STEP_TIMEOUT_SECS >= 30); - assert!(DEFAULT_STEP_TIMEOUT_SECS <= 300); + const { assert!(DEFAULT_STEP_TIMEOUT_SECS >= 30) }; + const { assert!(DEFAULT_STEP_TIMEOUT_SECS <= 300) }; } #[test] diff --git a/src/lib-jj-helpers/src/lib.rs b/src/lib-jj-helpers/src/lib.rs index 7c77893..621dd3c 100644 --- a/src/lib-jj-helpers/src/lib.rs +++ b/src/lib-jj-helpers/src/lib.rs @@ -156,10 +156,7 @@ where /// /// - `stderr_mode`: `jj log` の stderr ハンドリング方針 /// - `fallback_log`: `@` 以外の revset で hit した場合の通知 (`None` なら無通知) -pub fn get_jj_bookmarks( - stderr_mode: StderrMode, - fallback_log: Option, -) -> Vec { +pub fn get_jj_bookmarks(stderr_mode: StderrMode, fallback_log: Option) -> Vec { select_from_revsets( BOOKMARK_SEARCH_REVSETS, |r| query_bookmarks_at(r, &stderr_mode), diff --git a/src/lib-pending-file/Cargo.toml b/src/lib-pending-file/Cargo.toml new file mode 100644 index 0000000..11a684e --- /dev/null +++ b/src/lib-pending-file/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "lib-pending-file" +version = "0.1.0" +edition = "2021" + +[lib] +name = "lib_pending_file" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0" + +# [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026) diff --git a/src/lib-pending-file/src/lib.rs b/src/lib-pending-file/src/lib.rs new file mode 100644 index 0000000..2d2d41d --- /dev/null +++ b/src/lib-pending-file/src/lib.rs @@ -0,0 +1,227 @@ +//! post-merge-feedback pending file の共有スキーマと UTC ヘルパー (ADR-029) +//! +//! 3 crate 間で重複していた `PendingFile` 構造体・status 定数・ISO 8601 ヘルパーを +//! 一か所に集約したライブラリクレート。 +//! +//! 消費側: +//! - `cli-merge-pipeline` — pending file の新規排他作成・上書き +//! - `hooks-stop-feedback-dispatch` — pending file の読み取りと dispatched 遷移 +//! - `cli-pr-monitor` — ISO 8601 UTC ヘルパーのみ利用 + +use serde::{Deserialize, Serialize}; + +// ─── Schema constants ─── + +/// pending file のスキーマバージョン。非互換変更時に bump する。 +pub const SCHEMA_VERSION: u32 = 1; + +/// ファイル名 (`.claude/` 配下に配置) +pub const FILE_NAME: &str = "post-merge-feedback-pending.json"; + +/// ADR-029 で定義された status 値 +pub const STATUS_PENDING: &str = "pending"; +pub const STATUS_DISPATCHED: &str = "dispatched"; +pub const STATUS_CONSUMED: &str = "consumed"; + +// ─── Shared struct ─── + +/// pending file の JSON スキーマ (ADR-029 §Pending file JSON スキーマ v1) +/// +/// `producer` は schema v1 互換の optional フィールド。取りこぼし発生時に +/// 「誰が書いた pending が消えたか」を破損残骸からも追跡可能にするための観測性補助。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PendingFile { + pub schema_version: u32, + pub pr_number: u64, + pub owner_repo: String, + pub prompt: String, + pub status: String, + pub created_at: String, + pub dispatched_at: Option, + pub consumed_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub producer: Option, +} + +// ─── Input validation ─── + +/// `{owner}/{repo}` 形式の文字列を検証する (ADR-029 §競合ポリシー の security-review 反映)。 +/// +/// 許容文字: ASCII 英数字 + `_` `.` `-`。スラッシュはちょうど 1 つ、owner/repo とも非空。 +/// newline / 制御文字は弾く (pending file / additionalContext への注入防御)。 +pub fn is_valid_owner_repo(s: &str) -> bool { + let Some((owner, repo)) = s.split_once('/') else { + return false; + }; + !owner.is_empty() + && !repo.is_empty() + && !repo.contains('/') + && owner.chars().all(is_repo_ident_char) + && repo.chars().all(is_repo_ident_char) +} + +fn is_repo_ident_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' +} + +// ─── UTC ISO 8601 helpers ─── +// +// Hatcher's proleptic Gregorian civil-date algorithm (pure std, no chrono). +// Reference: https://howardhinnant.github.io/date_algorithms.html + +/// Days from the proleptic Gregorian epoch (0000-03-01) to the Unix epoch (1970-01-01). +const CIVIL_EPOCH_OFFSET: i64 = 719_468; +/// Days in a 400-year Gregorian era. +const DAYS_PER_ERA: i64 = 146_097; +/// DAYS_PER_ERA - 1; used for the era-floor sign correction. +const DAYS_PER_ERA_M1: i64 = 146_096; +/// Days in a 4-year cycle (excluding century boundaries). +const DAYS_PER_4Y: u64 = 1_460; +/// Days in a 100-year cycle. +const DAYS_PER_100Y: u64 = 36_524; +/// Days in an ordinary year. +const DAYS_PER_YEAR: u64 = 365; +/// Years per 400-year Gregorian era. +const YEARS_PER_ERA: i64 = 400; +/// Multiplier for the month-to-day-of-year encoding: (5*mp + 2) / 153. +const MONTH_ENCODE_MUL: u64 = 5; +/// Divisor for the month-to-day-of-year encoding. +const MONTH_ENCODE_DIV: u64 = 153; +/// Seconds per hour. +const SECS_PER_HOUR: u64 = 3_600; +/// Seconds per minute. +const SECS_PER_MIN: u64 = 60; +/// Seconds per day. +const SECS_PER_DAY: u64 = 86_400; + +/// epoch 秒 → ISO 8601 UTC 文字列 (`YYYY-MM-DDTHH:MM:SSZ`)。 +pub fn epoch_secs_to_iso8601(epoch: u64) -> String { + let day_count = (epoch / SECS_PER_DAY) as i64; + let time_of_day = epoch % SECS_PER_DAY; + + let z = day_count + CIVIL_EPOCH_OFFSET; + let era = (if z >= 0 { z } else { z - DAYS_PER_ERA_M1 }) / DAYS_PER_ERA; + let doe = (z - era * DAYS_PER_ERA) as u64; + let yoe = (doe - doe / DAYS_PER_4Y + doe / DAYS_PER_100Y - doe / (DAYS_PER_ERA_M1 as u64)) + / DAYS_PER_YEAR; + let y = yoe as i64 + era * YEARS_PER_ERA; + let doy = doe - (DAYS_PER_YEAR * yoe + yoe / 4 - yoe / 100); + let mp = (MONTH_ENCODE_MUL * doy + 2) / MONTH_ENCODE_DIV; + let d = doy - (MONTH_ENCODE_DIV * mp + 2) / MONTH_ENCODE_MUL + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + let hour = time_of_day / SECS_PER_HOUR; + let min = (time_of_day % SECS_PER_HOUR) / SECS_PER_MIN; + let sec = time_of_day % SECS_PER_MIN; + + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + y, m, d, hour, min, sec + ) +} + +/// 現在時刻を ISO 8601 UTC (`YYYY-MM-DDTHH:MM:SSZ`) で返す。 +pub fn utc_now_iso8601() -> String { + use std::time::SystemTime; + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + epoch_secs_to_iso8601(now.as_secs()) +} + +/// 現在の epoch 秒を返す (stale 判定用)。 +pub fn utc_now_epoch_secs() -> u64 { + use std::time::SystemTime; + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ─── epoch_secs_to_iso8601 ─── + + #[test] + fn epoch_zero_is_unix_epoch() { + assert_eq!(epoch_secs_to_iso8601(0), "1970-01-01T00:00:00Z"); + } + + #[test] + fn epoch_day_boundary() { + assert_eq!(epoch_secs_to_iso8601(86400), "1970-01-02T00:00:00Z"); + } + + #[test] + fn epoch_known_date() { + assert_eq!(epoch_secs_to_iso8601(1_775_044_800), "2026-04-01T12:00:00Z"); + } + + #[test] + fn epoch_leap_year() { + assert_eq!(epoch_secs_to_iso8601(1_709_164_800), "2024-02-29T00:00:00Z"); + } + + #[test] + fn epoch_end_of_day() { + assert_eq!(epoch_secs_to_iso8601(1_775_087_999), "2026-04-01T23:59:59Z"); + } + + #[test] + fn utc_now_iso8601_format() { + let s = utc_now_iso8601(); + assert_eq!(s.len(), "1970-01-01T00:00:00Z".len()); + assert!(s.ends_with('Z')); + assert_eq!(s.chars().nth(4), Some('-')); + assert_eq!(s.chars().nth(7), Some('-')); + assert_eq!(s.chars().nth(10), Some('T')); + assert_eq!(s.chars().nth(13), Some(':')); + assert_eq!(s.chars().nth(16), Some(':')); + } + + // ─── is_valid_owner_repo ─── + + #[test] + fn valid_owner_repo_accepts_typical_slugs() { + assert!(is_valid_owner_repo("aloekun/claude-code-hook-test")); + assert!(is_valid_owner_repo("octo-org/my.repo")); + assert!(is_valid_owner_repo("a/b")); + assert!(is_valid_owner_repo("Ab_12/X.y-z")); + } + + #[test] + fn valid_owner_repo_rejects_malformed() { + assert!(!is_valid_owner_repo("")); + assert!(!is_valid_owner_repo("noslash")); + assert!(!is_valid_owner_repo("/missing-owner")); + assert!(!is_valid_owner_repo("missing-repo/")); + assert!(!is_valid_owner_repo("a/b/c")); + assert!(!is_valid_owner_repo("has space/repo")); + assert!(!is_valid_owner_repo("owner/repo\nfoo")); // newline injection + assert!(!is_valid_owner_repo("owner/repo\r")); + assert!(!is_valid_owner_repo("owner/repo\t")); + assert!(!is_valid_owner_repo("owner!/repo")); + } + + // ─── PendingFile serde ─── + + #[test] + fn pending_file_without_producer_deserializes() { + let json = r#"{ + "schema_version": 1, + "pr_number": 42, + "owner_repo": "o/r", + "prompt": "post-merge-feedback", + "status": "pending", + "created_at": "2026-04-23T10:00:00Z", + "dispatched_at": null, + "consumed_at": null + }"#; + let p: PendingFile = serde_json::from_str(json).unwrap(); + assert_eq!(p.producer, None); + assert_eq!(p.pr_number, 42); + } +} diff --git a/src/lib-report-formatter/src/lib.rs b/src/lib-report-formatter/src/lib.rs index f3b4668..100bfd8 100644 --- a/src/lib-report-formatter/src/lib.rs +++ b/src/lib-report-formatter/src/lib.rs @@ -106,9 +106,15 @@ pub fn format_verdict(findings: &[Finding]) -> String { if has_serious { let mut parts = Vec::new(); - if counts[0] > 0 { parts.push(format!("Critical: {}", counts[0])); } - if counts[1] > 0 { parts.push(format!("High: {}", counts[1])); } - if counts[2] > 0 { parts.push(format!("Major: {}", counts[2])); } + if counts[0] > 0 { + parts.push(format!("Critical: {}", counts[0])); + } + if counts[1] > 0 { + parts.push(format!("High: {}", counts[1])); + } + if counts[2] > 0 { + parts.push(format!("Major: {}", counts[2])); + } format!("修正が必要な指摘があります ({})", parts.join(", ")) } else { "重大な問題は見つかりませんでした。軽微な改善提案があります".to_string()