diff --git a/.claude/hooks-config.toml b/.claude/hooks-config.toml index 1c71ccb7..44702cb9 100644 --- a/.claude/hooks-config.toml +++ b/.claude/hooks-config.toml @@ -393,6 +393,32 @@ zero_streak_months = 2 # 連続 2 か月発火 0 で非アクティブ化 trend_months = 6 # レポートが遡る月数 (月別表 / 発火 0 リストの窓) # extra_roots = ["C:\\path\\to\\other-workspace"] # 環境ごとに必要なら有効化 +# 機構レジストリ (設計決定 1、Phase A)。発火 0 リストの母集合を「窓内 rollup に現れた id」から +# 「全機構」に拡張する。rule (custom-lint-rules.toml の全 rule id) と preset ([pre_tool_validate] +# blocked_patterns) は exe が自動列挙するため、自動列挙元が無い hook / nudge 発火 id のみここに静的 +# 列挙する。id は hook 名と一致しない例がある (各 hook の lib_telemetry::record 呼び出しで実確認): +# file-length = hooks-post-tool-comment-lint-rust (file-length gate) +# hooks-stop-tool-call-leak = leak 検知 Stop 経路 (block) +# hooks-stop-tool-call-leak/prompt-recovery = leak 回収 UserPromptSubmit 経路 (warn、ADR-061) +# jj-op-verify = hooks-post-tool-jj-op-verify (operation 未記録 warn) +# hooks-stop-quality = Stop 品質ゲート (block) +# pr_monitor_catchup / reaper / staleness / workspace_stale / weekly_review_reminder / +# monthly_review_reminder = hooks-session-start の nudge 群 (warn) +[telemetry_report.registry] +hook_ids = [ + "file-length", + "hooks-stop-tool-call-leak", + "hooks-stop-tool-call-leak/prompt-recovery", + "jj-op-verify", + "hooks-stop-quality", + "pr_monitor_catchup", + "reaper", + "staleness", + "workspace_stale", + "weekly_review_reminder", + "monthly_review_reminder", +] + # 初期マッピング 1 件: ADR-053/061 の tool call leak 検知。監視 2 id が連続発火 0 かつ各月 # enabled+配備済みなら非アクティブ化候補として提示する (crate 撤去 revert PR は ADR-053/061 # bounded lifetime 手順に従う)。全試験運用 ADR の網羅登録は将来拡張。 diff --git a/docs/adr/adr-062-monthly-harness-roi-review.md b/docs/adr/adr-062-monthly-harness-roi-review.md index ff6c1a52..02dd99c2 100644 --- a/docs/adr/adr-062-monthly-harness-roi-review.md +++ b/docs/adr/adr-062-monthly-harness-roi-review.md @@ -102,6 +102,37 @@ weekly-review にあった `.failed` marker / resume 機構は**不採用**と 単体では月内の有効性を証明しないため、**月次 rollup 確定時に当月の snapshot を rollup JSON にも 保存**し、判定はこの月別記録を参照する。 +### 2 の amendment (2026-07-31, Phase A): 機構レジストリ + 発火 0 リスト再定義 + +初回 dogfood (2026-07-30) で、レポート (b) の発火 0 リストが「発火 0 = 削除候補」の中核シグナルとして +機能していないことが判明した。rollup の id entry は発火レコードからしか作られない (`aggregate.rs` +`count_firings` は block/warn のみ加算) ため、(1) 発火が止まって窓外に落ちた id と (2) 一度も発火して +いない機構が**どちらも不可視**だった (§ 決定 4 の「MVP は 1 件 + **発火 0 リスト全般**で足りる」の +後半が実装で満たされていなかった)。機構レジストリで母集合を静的に列挙してこの盲点を塞ぐ: + +- **機構レジストリ (3 供給源、すべて exe 隣接 `config_base` 基準)**: + - **rule**: `.claude/custom-lint-rules.toml` の全 rule id。incident 判定 (`incident.rs`) と同じ読み口 + (`RulesFile`) を共有する。telemetry の rule firing id は `hooks-post-tool-linter` の + `lib_telemetry::record` が `id: &rule.id` で記録する (= rule の `id` フィールドそのもの)。 + - **preset**: `hooks-config.toml` の `[pre_tool_validate] blocked_patterns` 宣言。preset firing は + `hooks-pre-tool-validate` の `record_preset_block` が `hit.source` (= blocked_patterns の宣言文字列) + を id に記録するため、宣言をそのまま列挙すれば発火 id と突き合う。 + - **hook / nudge**: 自動列挙元が無いため config `[telemetry_report.registry] hook_ids` を新設 + (ADR-039 additive。section 不在でも rule/preset は自動列挙される)。id は hook 名と一致しない例が + ある (`jj-op-verify` / `pr_monitor_catchup` / `hooks-stop-tool-call-leak/prompt-recovery` / + `file-length` / nudge 群等) ため、各 hook の `record` 呼び出しから実確認して列挙する。 +- **発火 0 集合 = (レジストリ ∪ 全 rollup 履歴に現れた id) − (窓内に発火した id)**。2 区分で提示: + - **never-fired**: レジストリにあり全履歴で発火 0。 + - **went-quiet**: 履歴に発火があるが窓内 0 (**最終発火月を併記**。全 rollup 走査で導出)。 + + incident (`[rules.incident]`) 維持推奨マークと mechanisms 監視対象マークは新リストにも適用する。 +- **degraded 実行時は (b) 全体に「参考値 (root 発見不完全)」注記**を付す (発火が発見漏れ root に + 偏在し得るため。verdict の promote 抑止と整合)。 +- 供給源単位の読取失敗は fail-open で skip しつつ**レポートに欠落を明示**する (silent fallback 排除、 + 「読めなかった」と「id が 0 件」を区別)。JSON の zero_firing entry に `provenance` + (`never_fired` / `went_quiet`) と `last_fired_month` を追加、`registry.source_failures` も出力する。 + (b) は参考情報でありユーザーゲート (自動削除しない) は不変。 + ### 3. L1: SessionStart reminder `hooks-session-start` の `[session_start.monthly_review_reminder]` (`enabled` / `threshold_days` diff --git a/src/cli-telemetry-report/src/config.rs b/src/cli-telemetry-report/src/config.rs index 32b3bf77..c5d9997c 100644 --- a/src/cli-telemetry-report/src/config.rs +++ b/src/cli-telemetry-report/src/config.rs @@ -30,11 +30,26 @@ pub struct TelemetryReportConfig { /// `jj workspace list` で発見できない root を補う追加 root (絶対パス)。 #[serde(default)] pub extra_roots: Vec, + /// 発火 0 リストの母集合を与える機構レジストリ (設計決定 1、Phase A)。section 不在でも + /// rule / preset は自動列挙されるため、hook / nudge の静的 id リストのみを持つ (ADR-039 additive)。 + #[serde(default)] + pub registry: RegistryConfig, /// 判定候補マッピング (機構ごと)。 #[serde(default)] pub mechanisms: Vec, } +/// `[telemetry_report.registry]` の設定値。自動列挙元が無い hook / nudge 発火 id を静的に列挙する +/// (設計決定 1 § hook / nudge)。id は hook 名と一致しない例がある (`jj-op-verify` / +/// `pr_monitor_catchup` / `hooks-stop-tool-call-leak/prompt-recovery` 等、各 hook の +/// `lib_telemetry::record` 実装で確認)。 +#[derive(Debug, Default, Clone, Deserialize)] +pub struct RegistryConfig { + /// hook / nudge 発火 id の静的リスト。 + #[serde(default)] + pub hook_ids: Vec, +} + impl TelemetryReportConfig { /// 有効閾値 (未設定は既定 2)。 pub fn zero_streak_months(&self) -> u64 { @@ -132,4 +147,29 @@ proposal = "enabled = false" let cfg = parse_config("[telemetry_report]\ntrend_months = 0\n"); assert_eq!(cfg.trend_months(), 1); } + + #[test] + fn registry_hook_ids_default_empty_when_absent() { + let cfg = parse_config("[telemetry_report]\n"); + assert!(cfg.registry.hook_ids.is_empty()); + } + + #[test] + fn parses_registry_hook_ids() { + let toml = r#" +[telemetry_report] + +[telemetry_report.registry] +hook_ids = ["file-length", "reaper", "hooks-stop-tool-call-leak/prompt-recovery"] +"#; + let cfg = parse_config(toml); + assert_eq!( + cfg.registry.hook_ids, + vec![ + "file-length".to_string(), + "reaper".to_string(), + "hooks-stop-tool-call-leak/prompt-recovery".to_string(), + ] + ); + } } diff --git a/src/cli-telemetry-report/src/incident.rs b/src/cli-telemetry-report/src/incident.rs index 5b0e29d6..90fef9c3 100644 --- a/src/cli-telemetry-report/src/incident.rs +++ b/src/cli-telemetry-report/src/incident.rs @@ -43,6 +43,23 @@ pub fn incident_ids_from_str(content: &str) -> BTreeSet { .collect() } +/// `config_base/custom-lint-rules.toml` から**全** rule id を読む (レジストリの rule 供給源、 +/// 設計決定 1)。incident 判定と同じ読み口 ([`RulesFile`]) を共有する。 +/// +/// ファイル不在 / parse 失敗は `None` を返す。[`incident_ids_from_str`] の fail-open (空集合) とは +/// 異なり、レジストリは供給源欠落を「never-fired 判定不能」としてレポートに明示するため、 +/// 「読めなかった」と「rule が 0 本」を区別する必要がある。 +pub fn read_all_rule_ids(config_base: &Path) -> Option> { + let content = std::fs::read_to_string(config_base.join("custom-lint-rules.toml")).ok()?; + all_rule_ids_from_str(&content) +} + +/// TOML 文字列から全 rule id を抽出する (pure)。parse 失敗は `None`。 +pub fn all_rule_ids_from_str(content: &str) -> Option> { + let parsed = toml::from_str::(content).ok()?; + Some(parsed.rules.into_iter().map(|r| r.id).collect()) +} + #[cfg(test)] mod tests { use super::*; @@ -74,4 +91,24 @@ pr = "PR #x" fn malformed_toml_yields_empty_set() { assert!(incident_ids_from_str("not = [ toml").is_empty()); } + + #[test] + fn all_rule_ids_lists_every_rule_regardless_of_incident() { + let toml = r#" +[[rules]] +id = "no-console-log" + +[[rules]] +id = "no-personal-paths" +[rules.incident] +pr = 75 +"#; + let ids = all_rule_ids_from_str(toml).unwrap(); + assert_eq!(ids, vec!["no-console-log".to_string(), "no-personal-paths".to_string()]); + } + + #[test] + fn all_rule_ids_returns_none_on_parse_failure() { + assert!(all_rule_ids_from_str("not = [ toml").is_none()); + } } diff --git a/src/cli-telemetry-report/src/main.rs b/src/cli-telemetry-report/src/main.rs index a5363b1e..3671d875 100644 --- a/src/cli-telemetry-report/src/main.rs +++ b/src/cli-telemetry-report/src/main.rs @@ -17,6 +17,7 @@ mod config; mod discover; mod incident; mod model; +mod registry; mod report; mod snapshot; mod timekit; @@ -125,6 +126,7 @@ fn finish_report( now: u64, ) -> io::Result { let incident_ids = incident::incident_rule_ids(config_base); + let registry = registry::build_registry(config_base, &config.registry.hook_ids); let verdicts = verdict::compute_verdicts( rollups, &config.mechanisms, @@ -147,6 +149,7 @@ fn finish_report( current_snapshot: snapshot, mechanisms: &config.mechanisms, incident_ids: &incident_ids, + registry: ®istry, verdicts: &verdicts, trend_months: config.trend_months(), retention_deleted, @@ -272,6 +275,7 @@ mod tests { zero_streak_months: Some(2), trend_months: Some(6), extra_roots: Vec::new(), + registry: Default::default(), mechanisms: vec![config::MechanismConfig { name: "stop_tool_call_leak".to_string(), adr: "ADR-053/061".to_string(), diff --git a/src/cli-telemetry-report/src/registry.rs b/src/cli-telemetry-report/src/registry.rs new file mode 100644 index 00000000..18824bb2 --- /dev/null +++ b/src/cli-telemetry-report/src/registry.rs @@ -0,0 +1,202 @@ +//! 機構レジストリ (設計決定 1、Phase A)。 +//! +//! 発火 0 リストの母集合を「窓内 rollup に現れた id」から「全機構」に拡張するための供給源。 +//! 発火レコードからしか rollup の id entry は作られないため、rollup だけを見ると +//! (1) 発火が止まって窓外に落ちた id (went-quiet) と (2) 一度も発火していない機構 (never-fired) が +//! どちらも不可視になる。レジストリは 3 供給源から「あるべき id」を静的に列挙してこの盲点を塞ぐ: +//! +//! - **rule**: `.claude/custom-lint-rules.toml` の全 rule id ([`crate::incident::read_all_rule_ids`])。 +//! - **preset**: `hooks-config.toml` の `[pre_tool_validate] blocked_patterns` 宣言。telemetry の +//! preset firing は `hit.source` (= blocked_patterns の宣言文字列) を id に記録するため、宣言を +//! そのまま列挙すれば発火 id と突き合う (`hooks-pre-tool-validate` の `record_preset_block` 実装で確認)。 +//! - **hook / nudge**: 自動列挙元が無いため config 静的リスト `[telemetry_report.registry] hook_ids`。 +//! +//! 各供給源の読取失敗は fail-open で skip しつつ、レポートに欠落を明示する (never-fired 判定不能の +//! 注記)。silent fallback を排除し「読めなかった」と「id が 0 件」を区別する (設計決定 1)。 + +use std::path::Path; + +use serde::Deserialize; + +/// レジストリ 1 エントリ。`kind` は telemetry の `FiringKind` と同語彙 (`rule` / `preset` / `hook`)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistryEntry { + pub id: String, + pub kind: String, +} + +/// 全機構レジストリ + 供給源欠落メモ。 +#[derive(Debug, Clone, Default)] +pub struct Registry { + /// id 昇順・重複排除済みのエントリ群。 + pub entries: Vec, + /// 読めなかった供給源の欠落説明 (レポートに明示し never-fired 判定不能を可視化する)。 + pub source_failures: Vec, +} + +/// hooks-config.toml のトップレベル (preset 列挙に必要な `[pre_tool_validate]` のみ関心)。 +#[derive(Deserialize)] +struct PresetRoot { + pre_tool_validate: Option, +} + +#[derive(Deserialize)] +struct PreToolValidateSection { + blocked_patterns: Option>, +} + +/// hooks-config.toml から preset 宣言 (`blocked_patterns`) を列挙する (pure)。 +/// +/// parse 失敗は `None` (供給源欠落として明示する)。section / field 不在は `Some(空)` = preset 宣言が +/// 無いだけで供給源自体は読めている、と区別する。 +pub fn preset_ids_from_str(content: &str) -> Option> { + let root = toml::from_str::(content).ok()?; + Some( + root.pre_tool_validate + .and_then(|p| p.blocked_patterns) + .unwrap_or_default(), + ) +} + +/// `config_base/hooks-config.toml` から preset id を読む (I/O)。読取 / parse 失敗は `None`。 +fn read_preset_ids(config_base: &Path) -> Option> { + let content = std::fs::read_to_string(config_base.join("hooks-config.toml")).ok()?; + preset_ids_from_str(&content) +} + +/// 3 供給源からレジストリを構築する (I/O)。`hook_ids` は config で解決済みの静的リスト。 +/// +/// rule / preset の供給源が読めない場合は該当 kind を skip し `source_failures` に欠落を積む。 +/// hook は自動列挙元が無く config `hook_ids` が唯一の供給源のため、空リストは実質的に供給源欠落と +/// みなし同様に `source_failures` へ積む (設計決定 1 の「読めなかった」と「id が 0 件」の区別を +/// hook 供給源にも適用する)。 +pub fn build_registry(config_base: &Path, hook_ids: &[String]) -> Registry { + let mut entries = Vec::new(); + let mut source_failures = Vec::new(); + + match crate::incident::read_all_rule_ids(config_base) { + Some(ids) => push_entries(&mut entries, ids, "rule"), + None => source_failures.push( + "rule 供給源 (custom-lint-rules.toml) が読めないため rule の never-fired 判定は不能" + .to_string(), + ), + } + + match read_preset_ids(config_base) { + Some(ids) => push_entries(&mut entries, ids, "preset"), + None => source_failures.push( + "preset 供給源 (hooks-config.toml [pre_tool_validate]) が読めないため preset の never-fired 判定は不能" + .to_string(), + ), + } + + if hook_ids.is_empty() { + source_failures.push( + "hook 供給源 ([telemetry_report.registry] hook_ids) が未設定のため hook / nudge の never-fired 判定は不能" + .to_string(), + ); + } else { + push_entries(&mut entries, hook_ids.to_vec(), "hook"); + } + + dedup_by_id(&mut entries); + Registry { + entries, + source_failures, + } +} + +/// `ids` を `kind` 付きで `entries` に追加する。 +fn push_entries(entries: &mut Vec, ids: Vec, kind: &str) { + entries.extend(ids.into_iter().map(|id| RegistryEntry { + id, + kind: kind.to_string(), + })); +} + +/// id 昇順に整列し id 重複を除去する (先勝ち。kind を跨いだ id 衝突は現実的に無いが防御的に排除)。 +fn dedup_by_id(entries: &mut Vec) { + entries.sort_by(|a, b| a.id.cmp(&b.id)); + entries.dedup_by(|a, b| a.id == b.id); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preset_ids_lists_blocked_patterns() { + let toml = r#" +[pre_tool_validate] +blocked_patterns = ["git", "default", "jj-push-guard"] +"#; + let ids = preset_ids_from_str(toml).unwrap(); + assert_eq!(ids, vec!["git".to_string(), "default".to_string(), "jj-push-guard".to_string()]); + } + + #[test] + fn preset_ids_absent_section_is_empty_not_failure() { + assert_eq!(preset_ids_from_str("[other]\nx = 1\n"), Some(Vec::new())); + } + + #[test] + fn preset_ids_parse_failure_is_none() { + assert!(preset_ids_from_str("not = [ toml").is_none()); + } + + #[test] + fn build_registry_tags_kinds_and_notes_missing_sources() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("custom-lint-rules.toml"), + "[[rules]]\nid = \"no-console-log\"\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("hooks-config.toml"), + "[pre_tool_validate]\nblocked_patterns = [\"git\"]\n", + ) + .unwrap(); + let reg = build_registry(dir.path(), &["reaper".to_string()]); + assert!(reg.source_failures.is_empty(), "両供給源が読めれば欠落なし"); + let find = |id: &str| reg.entries.iter().find(|e| e.id == id).map(|e| e.kind.as_str()); + assert_eq!(find("no-console-log"), Some("rule")); + assert_eq!(find("git"), Some("preset")); + assert_eq!(find("reaper"), Some("hook")); + } + + #[test] + fn build_registry_notes_unreadable_rule_source() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("hooks-config.toml"), "[pre_tool_validate]\n").unwrap(); + let reg = build_registry(dir.path(), &["reaper".to_string()]); + assert_eq!(reg.source_failures.len(), 1); + assert!(reg.source_failures[0].contains("rule 供給源")); + assert!(!reg.entries.iter().any(|e| e.kind == "rule")); + } + + #[test] + fn build_registry_notes_empty_hook_ids_as_missing_source() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("custom-lint-rules.toml"), + "[[rules]]\nid = \"no-console-log\"\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("hooks-config.toml"), + "[pre_tool_validate]\nblocked_patterns = [\"git\"]\n", + ) + .unwrap(); + let reg = build_registry(dir.path(), &[]); + assert!( + reg.source_failures.iter().any(|f| f.contains("hook 供給源")), + "空 hook_ids は供給源欠落として明示する" + ); + assert!(!reg.entries.iter().any(|e| e.kind == "hook"), "hook entry は積まれない"); + assert!( + !reg.source_failures.iter().any(|f| f.contains("rule 供給源") || f.contains("preset 供給源")), + "rule / preset は読めているので欠落注記は出ない" + ); + } +} diff --git a/src/cli-telemetry-report/src/report.rs b/src/cli-telemetry-report/src/report.rs index 0f36e797..ca415d56 100644 --- a/src/cli-telemetry-report/src/report.rs +++ b/src/cli-telemetry-report/src/report.rs @@ -4,13 +4,14 @@ //! 前月比、(b) 直近 N か月の発火 0 リスト、(c) 設定・配備 snapshot、(d) incident 由来の維持推奨 //! マーク、(e) 判定候補 を組み立てる。純粋関数で、ファイル書き込みは main が担う。 -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use serde_json::json; use crate::config::MechanismConfig; use crate::model::{IdStat, MonthRollup, Snapshot}; +use crate::registry::{Registry, RegistryEntry}; use crate::verdict::{Verdict, VerdictStatus}; /// レポート生成の入力一式。 @@ -23,11 +24,32 @@ pub struct ReportInput<'a> { pub current_snapshot: &'a Snapshot, pub mechanisms: &'a [MechanismConfig], pub incident_ids: &'a BTreeSet, + /// 機構レジストリ (発火 0 リストの母集合 + 供給源欠落メモ、設計決定 1)。 + pub registry: &'a Registry, pub verdicts: &'a [Verdict], pub trend_months: u64, pub retention_deleted: usize, } +/// 発火 0 の由来 (設計決定 1 § 発火 0 の 2 区分)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Provenance { + /// レジストリにあり全 rollup 履歴で発火 0 (一度も発火していない)。 + NeverFired, + /// 履歴に発火があるが窓内 0 (発火が止まった)。`last_fired_month` を併記する。 + WentQuiet, +} + +impl Provenance { + /// 機械可読キー。 + fn key(self) -> &'static str { + match self { + Provenance::NeverFired => "never_fired", + Provenance::WentQuiet => "went_quiet", + } + } +} + /// 発火 0 リストの 1 項目。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ZeroFiring { @@ -37,6 +59,10 @@ pub struct ZeroFiring { pub incident: bool, /// 判定候補の監視対象 id か。 pub monitored: bool, + /// never-fired / went-quiet の区分 (設計決定 1)。 + pub provenance: Provenance, + /// went-quiet の最終発火月 (`YYYY-MM`)。never-fired は `None`。 + pub last_fired_month: Option, } /// markdown レポートと機械可読 JSON を組み立てて返す。 @@ -85,25 +111,91 @@ fn fmt_cell(stat: Option<&IdStat>) -> String { } } -/// 窓内の id が全月で total 0 なら発火 0。incident / monitored のマークを添えて返す。 +/// id ごとの履歴情報 (全 rollup 走査で導出)。 +struct HistoryInfo { + kind: String, + /// total > 0 が観測された最大の月 (`YYYY-MM`)。一度も発火していなければ `None`。 + last_fired_month: Option, +} + +/// 全 rollup を走査し id ごとの kind と最終発火月を集める (窓に依らない全履歴)。 +fn history_index(rollups: &[MonthRollup]) -> BTreeMap { + let mut index: BTreeMap = BTreeMap::new(); + for rollup in rollups { + for (id, stat) in &rollup.ids { + let info = index.entry(id.clone()).or_insert_with(|| HistoryInfo { + kind: stat.kind.clone(), + last_fired_month: None, + }); + if !stat.kind.is_empty() { + info.kind = stat.kind.clone(); + } + if stat.counts.total() > 0 + && info.last_fired_month.as_deref().is_none_or(|m| rollup.month.as_str() > m) + { + info.last_fired_month = Some(rollup.month.clone()); + } + } + } + index +} + +/// 窓内で total > 0 の id 集合 (能動的に発火しているため発火 0 リストから除外する)。 +fn window_fired_ids(rollups: &[MonthRollup], window: &[String]) -> BTreeSet { + let mut fired = BTreeSet::new(); + for rollup in rollups.iter().filter(|r| window.contains(&r.month)) { + for (id, stat) in &rollup.ids { + if stat.counts.total() > 0 { + fired.insert(id.clone()); + } + } + } + fired +} + +/// 発火 0 集合 = (レジストリ ∪ 全 rollup 履歴に現れた id) − (窓内に発火した id) (設計決定 1)。 +/// +/// never-fired (全履歴で発火 0) と went-quiet (履歴に発火があるが窓内 0、最終発火月を併記) の +/// 2 区分に分類し、incident / monitored マークを添えて id 昇順で返す。kind はレジストリ由来を優先し、 +/// レジストリに無い履歴 id は rollup 記録の kind を採る。 pub fn zero_firing_list( rollups: &[MonthRollup], window: &[String], incident_ids: &BTreeSet, monitored_ids: &BTreeSet, + registry: &[RegistryEntry], ) -> Vec { - ids_in_window(rollups, window) + let history = history_index(rollups); + let fired = window_fired_ids(rollups, window); + let registry_kind: BTreeMap<&str, &str> = + registry.iter().map(|e| (e.id.as_str(), e.kind.as_str())).collect(); + + let mut candidates: BTreeSet = registry.iter().map(|e| e.id.clone()).collect(); + candidates.extend(history.keys().cloned()); + + candidates .into_iter() - .filter(|(id, _)| { - window - .iter() - .all(|m| stat_of(rollups, m, id).map(|s| s.counts.total()).unwrap_or(0) == 0) - }) - .map(|(id, kind)| ZeroFiring { - incident: incident_ids.contains(&id), - monitored: monitored_ids.contains(&id), - id, - kind, + .filter(|id| !fired.contains(id)) + .map(|id| { + let last_fired_month = history.get(&id).and_then(|h| h.last_fired_month.clone()); + let provenance = if last_fired_month.is_some() { + Provenance::WentQuiet + } else { + Provenance::NeverFired + }; + let kind = registry_kind + .get(id.as_str()) + .map(|k| (*k).to_string()) + .or_else(|| history.get(&id).map(|h| h.kind.clone())) + .unwrap_or_default(); + ZeroFiring { + incident: incident_ids.contains(&id), + monitored: monitored_ids.contains(&id), + provenance, + last_fired_month, + id, + kind, + } }) .collect() } @@ -191,33 +283,76 @@ fn month_over_month(rollups: &[MonthRollup], window: &[String], id: &str) -> Str } } -/// (b)+(d) 直近 N か月 発火 0 リスト (incident 維持推奨マーク併記)。 +/// (b)+(d) 直近窓で発火 0 の id (never-fired / went-quiet の 2 区分、incident 維持推奨マーク併記)。 fn format_zero_firing(input: &ReportInput, window: &[String]) -> String { let monitored = monitored_ids(input.mechanisms); - let zero = zero_firing_list(input.rollups, window, input.incident_ids, &monitored); + let zero = zero_firing_list( + input.rollups, + window, + input.incident_ids, + &monitored, + &input.registry.entries, + ); let mut out = String::from("\n## (b) 直近窓で発火 0 の id((d) incident 由来は維持推奨)\n\n"); + if !input.degraded.is_empty() { + out.push_str( + "> **参考値 (root 発見不完全)**: degraded 実行のため発火が発見漏れ root に偏在し得る。\ + never-fired 判定は完全な root 集合を前提とするため promote 抑止と整合する。\n\n", + ); + } + for note in &input.registry.source_failures { + out.push_str(&format!("> **供給源欠落**: {note}\n\n")); + } if zero.is_empty() { out.push_str("発火 0 の id はありません。\n"); return out; } - for z in zero { - let mut marks = Vec::new(); - if z.incident { - marks.push("incident 由来 — 発火 0 でも維持推奨"); - } - if z.monitored { - marks.push("判定候補の監視対象"); - } - let suffix = if marks.is_empty() { - String::new() - } else { - format!(" — {}", marks.join(" / ")) - }; - out.push_str(&format!("- `{}` ({}){}\n", z.id, z.kind, suffix)); + let (never, quiet): (Vec<_>, Vec<_>) = zero + .into_iter() + .partition(|z| z.provenance == Provenance::NeverFired); + let groups: Vec = [ + format_zero_group("never-fired(全履歴で発火 0)", &never), + format_zero_group("went-quiet(過去に発火・窓内 0)", &quiet), + ] + .into_iter() + .filter(|g| !g.is_empty()) + .collect(); + out.push_str(&groups.join("\n")); + out +} + +/// 発火 0 の 1 区分を小見出し + 箇条書きにする。空区分は空文字を返す (見出しごと省略、 +/// 区分間の空行は呼び出し側が join で挟む)。 +fn format_zero_group(title: &str, items: &[ZeroFiring]) -> String { + if items.is_empty() { + return String::new(); + } + let mut out = format!("### {title}\n\n"); + for z in items { + out.push_str(&format!("- {}\n", format_zero_line(z))); } out } +/// 発火 0 の 1 行 (`id (kind)` + went-quiet の最終発火月 + incident / monitored マーク)。 +fn format_zero_line(z: &ZeroFiring) -> String { + let mut line = format!("`{}` ({})", z.id, z.kind); + if let Some(month) = &z.last_fired_month { + line.push_str(&format!(" — 最終発火: {month}")); + } + let mut marks = Vec::new(); + if z.incident { + marks.push("incident 由来 — 発火 0 でも維持推奨"); + } + if z.monitored { + marks.push("判定候補の監視対象"); + } + if !marks.is_empty() { + line.push_str(&format!(" — {}", marks.join(" / "))); + } + line +} + /// (c) 設定・配備 snapshot (現時点)。 fn format_snapshot(input: &ReportInput) -> String { let mut out = String::from("\n## (c) 設定・配備 snapshot(現時点)\n\n"); @@ -293,13 +428,49 @@ fn format_markdown(input: &ReportInput) -> String { /// 機械可読 JSON を組み立てる。 fn build_json(input: &ReportInput) -> serde_json::Value { let window = window_months(input.rollups, input.trend_months); + json!({ + "generated_at": input.generated_at, + "report_date": input.report_date, + "roots": input.roots.iter().map(|r| r.display().to_string()).collect::>(), + "degraded": { "is_degraded": !input.degraded.is_empty(), "reasons": input.degraded }, + "window_months": window, + "monthly": monthly_json(input.rollups, &window), + "zero_firing": zero_firing_json(input, &window), + "registry": { "source_failures": input.registry.source_failures }, + "snapshot": input.current_snapshot, + "verdicts": verdicts_json(input.verdicts), + "retention_deleted": input.retention_deleted, + }) +} + +/// 発火 0 リストを JSON 配列にする (provenance / last_fired_month 付き、設計決定 1)。 +fn zero_firing_json(input: &ReportInput, window: &[String]) -> serde_json::Value { let monitored = monitored_ids(input.mechanisms); - let zero: Vec<_> = zero_firing_list(input.rollups, &window, input.incident_ids, &monitored) - .into_iter() - .map(|z| json!({ "id": z.id, "kind": z.kind, "incident": z.incident, "monitored": z.monitored })) - .collect(); - let verdicts: Vec<_> = input - .verdicts + let entries: Vec<_> = zero_firing_list( + input.rollups, + window, + input.incident_ids, + &monitored, + &input.registry.entries, + ) + .into_iter() + .map(|z| { + json!({ + "id": z.id, + "kind": z.kind, + "incident": z.incident, + "monitored": z.monitored, + "provenance": z.provenance.key(), + "last_fired_month": z.last_fired_month, + }) + }) + .collect(); + serde_json::Value::Array(entries) +} + +/// 判定候補を JSON 配列にする。 +fn verdicts_json(verdicts: &[Verdict]) -> serde_json::Value { + let entries: Vec<_> = verdicts .iter() .map(|v| { json!({ @@ -313,18 +484,7 @@ fn build_json(input: &ReportInput) -> serde_json::Value { }) }) .collect(); - json!({ - "generated_at": input.generated_at, - "report_date": input.report_date, - "roots": input.roots.iter().map(|r| r.display().to_string()).collect::>(), - "degraded": { "is_degraded": !input.degraded.is_empty(), "reasons": input.degraded }, - "window_months": window, - "monthly": monthly_json(input.rollups, &window), - "zero_firing": zero, - "snapshot": input.current_snapshot, - "verdicts": verdicts, - "retention_deleted": input.retention_deleted, - }) + serde_json::Value::Array(entries) } /// 月別集計を JSON 配列にする。 @@ -393,6 +553,20 @@ mod tests { } } + fn reg(entries: &[(&str, &str)]) -> Registry { + Registry { + entries: entries + .iter() + .map(|(id, kind)| RegistryEntry { + id: (*id).to_string(), + kind: (*kind).to_string(), + }) + .collect(), + source_failures: Vec::new(), + } + } + + #[allow(clippy::too_many_arguments)] fn base_input<'a>( rollups: &'a [MonthRollup], degraded: &'a [String], @@ -401,6 +575,7 @@ mod tests { verdicts: &'a [Verdict], snapshot: &'a Snapshot, roots: &'a [PathBuf], + registry: &'a Registry, ) -> ReportInput<'a> { ReportInput { report_date: "2026-07-29", @@ -411,6 +586,7 @@ mod tests { current_snapshot: snapshot, mechanisms, incident_ids: incident, + registry, verdicts, trend_months: 6, retention_deleted: 0, @@ -438,11 +614,56 @@ mod tests { let mut incident = BTreeSet::new(); incident.insert("quiet".to_string()); let monitored = BTreeSet::new(); - let zero = zero_firing_list(&rollups, &window, &incident, &monitored); + let zero = zero_firing_list(&rollups, &window, &incident, &monitored, &[]); assert_eq!(zero.len(), 1); assert_eq!(zero[0].id, "quiet"); assert!(zero[0].incident); assert!(!zero[0].monitored); + assert_eq!(zero[0].provenance, Provenance::NeverFired, "全月 total 0 は never-fired"); + } + + #[test] + fn never_fired_registry_id_appears_even_without_rollup() { + let rollups = vec![rollup("2026-07", true, &[("noisy", stat("hook", 2, 0))])]; + let window = window_months(&rollups, 6); + let registry = reg(&[("no-console-log", "rule"), ("reaper", "hook")]); + let zero = zero_firing_list(&rollups, &window, &BTreeSet::new(), &BTreeSet::new(), ®istry.entries); + let ids: Vec<_> = zero.iter().map(|z| z.id.as_str()).collect(); + assert!(ids.contains(&"no-console-log"), "rollup に無いレジストリ id も never-fired で出る"); + assert!(ids.contains(&"reaper")); + assert!(!ids.contains(&"noisy"), "窓内発火 id は出ない"); + let rule = zero.iter().find(|z| z.id == "no-console-log").unwrap(); + assert_eq!(rule.provenance, Provenance::NeverFired); + assert_eq!(rule.kind, "rule", "kind はレジストリ由来"); + assert!(rule.last_fired_month.is_none()); + } + + #[test] + fn went_quiet_id_shows_last_fired_month() { + let rollups = vec![ + rollup("2026-04", true, &[("wentquiet", stat("hook", 2, 0))]), + rollup("2026-05", true, &[]), + rollup("2026-06", true, &[]), + rollup("2026-07", true, &[]), + ]; + let window = window_months(&rollups, 3); + assert_eq!(window, vec!["2026-05", "2026-06", "2026-07"]); + let zero = zero_firing_list(&rollups, &window, &BTreeSet::new(), &BTreeSet::new(), &[]); + let wq = zero.iter().find(|z| z.id == "wentquiet").unwrap(); + assert_eq!(wq.provenance, Provenance::WentQuiet, "窓外で発火し窓内 0 は went-quiet"); + assert_eq!(wq.last_fired_month.as_deref(), Some("2026-04")); + } + + #[test] + fn window_fired_id_excluded() { + let rollups = vec![ + rollup("2026-06", true, &[("active", stat("hook", 1, 0))]), + rollup("2026-07", true, &[("active", stat("hook", 3, 0))]), + ]; + let window = window_months(&rollups, 6); + let registry = reg(&[("active", "hook")]); + let zero = zero_firing_list(&rollups, &window, &BTreeSet::new(), &BTreeSet::new(), ®istry.entries); + assert!(zero.is_empty(), "レジストリにあっても窓内発火なら 0 リストに出ない"); } #[test] @@ -462,17 +683,53 @@ mod tests { }]; let snapshot = Snapshot::default(); let roots = vec![PathBuf::from("/main")]; - let input = base_input(&rollups, °raded, &mechanisms, &incident, &verdicts, &snapshot, &roots); + let registry = Registry::default(); + let input = base_input(&rollups, °raded, &mechanisms, &incident, &verdicts, &snapshot, &roots, ®istry); let (md, _json) = render(&input); assert!(md.contains("# 月次ハーネス ROI レビュー (2026-07-29)")); assert!(md.contains("degraded — 本実行では判定候補の promote を抑止")); assert!(md.contains("## (a) 月別 × id 別発火数")); assert!(md.contains("## (b) 直近窓で発火 0")); + assert!(md.contains("参考値 (root 発見不完全)"), "degraded 実行は (b) に参考値注記"); assert!(md.contains("## (c) 設定・配備 snapshot")); assert!(md.contains("## (e) 判定候補")); assert!(md.contains("promote 抑止 (degraded)")); } + #[test] + fn source_failure_note_and_provenance_in_report() { + let rollups = vec![ + rollup("2026-04", true, &[("wq", stat("hook", 2, 0))]), + rollup("2026-05", true, &[]), + rollup("2026-06", true, &[]), + ]; + let degraded: Vec = Vec::new(); + let mechanisms: Vec = Vec::new(); + let incident = BTreeSet::new(); + let verdicts: Vec = Vec::new(); + let snapshot = Snapshot::default(); + let roots = vec![PathBuf::from("/main")]; + let registry = Registry { + entries: vec![RegistryEntry { id: "no-console-log".to_string(), kind: "rule".to_string() }], + source_failures: vec!["preset 供給源が読めない".to_string()], + }; + let mut input = base_input(&rollups, °raded, &mechanisms, &incident, &verdicts, &snapshot, &roots, ®istry); + input.trend_months = 2; + let (md, json) = render(&input); + assert!(md.contains("**供給源欠落**: preset 供給源が読めない")); + assert!(md.contains("never-fired(全履歴で発火 0)")); + assert!(md.contains("went-quiet(過去に発火・窓内 0)")); + assert!(md.contains("`wq` (hook) — 最終発火: 2026-04")); + assert_eq!(json["registry"]["source_failures"][0], "preset 供給源が読めない"); + let entries = json["zero_firing"].as_array().unwrap(); + let never = entries.iter().find(|e| e["id"] == "no-console-log").unwrap(); + assert_eq!(never["provenance"], "never_fired"); + assert_eq!(never["last_fired_month"], serde_json::Value::Null); + let quiet = entries.iter().find(|e| e["id"] == "wq").unwrap(); + assert_eq!(quiet["provenance"], "went_quiet"); + assert_eq!(quiet["last_fired_month"], "2026-04"); + } + #[test] fn json_reports_degraded_and_verdicts() { let rollups = vec![ @@ -493,7 +750,8 @@ mod tests { }]; let snapshot = Snapshot::default(); let roots = vec![PathBuf::from("/main"), PathBuf::from("/improve")]; - let input = base_input(&rollups, °raded, &mechanisms, &incident, &verdicts, &snapshot, &roots); + let registry = Registry::default(); + let input = base_input(&rollups, °raded, &mechanisms, &incident, &verdicts, &snapshot, &roots, ®istry); let (_md, json) = render(&input); assert_eq!(json["degraded"]["is_degraded"], false); assert_eq!(json["roots"].as_array().unwrap().len(), 2);