Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .claude/custom-lint-rules.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ steps = [
bad = '具体的なファイル所在: `C:\Users\alice\.claude\projects\<project-id>\` 配下'
good = '具体的なファイル所在: `%USERPROFILE%\.claude\projects\<project-id>\` 配下'

[rules.incident]
pr = 75
bad_fixture = "no-personal-paths.md"
good_fixture = "no-personal-paths.md"
adr = "adr-007"

[rules.test_coverage]
# rule② は md/txt (= 全て非主要拡張子)。順位 137 PR で test gap 補填 (3 tests 新規追加)。
other_ext_tests = [
Expand Down Expand Up @@ -121,6 +127,12 @@ steps = [
bad = 'try { Get-Item $path } catch {}'
good = 'try { Get-Item $path } catch { Write-Verbose "expected miss: $_"; $null }'

[rules.incident]
pr = 85
bad_fixture = "no-empty-powershell-catch.ps1"
good_fixture = "no-empty-powershell-catch.ps1"
adr = "adr-007"

[rules.test_coverage]
# rule③ は ps1 のみ (非主要拡張子)。複数 case-variant の安定性 seal。
other_ext_tests = [
Expand Down Expand Up @@ -161,6 +173,12 @@ steps = [
bad = '$data = ConvertFrom-Json $raw -ErrorAction SilentlyContinue'
good = 'try { $data = ConvertFrom-Json $raw -ErrorAction Stop } catch { Write-Error "Invalid JSON: $_"; throw }'

[rules.incident]
pr = 85
bad_fixture = "no-silent-error-action.ps1"
good_fixture = "no-silent-error-action.ps1"
adr = "adr-007"

[rules.test_coverage]
# rule④ は ps1 のみ (非主要拡張子)。PowerShell case-insensitive variant の安定性 seal。
other_ext_tests = [
Expand Down Expand Up @@ -207,6 +225,12 @@ steps = [
bad = 'See [推奨実行順序](todo-summary.md#推奨実行順序サマリー)'
good = 'See [推奨実行順序](todo-summary.md#recommended-order-summary) <!-- with <a id="recommended-order-summary"></a> in target file -->'

[rules.incident]
pr = 89
bad_fixture = "no-mutable-anchor.md"
good_fixture = "no-mutable-anchor.md"
adr = "adr-007"

[rules.test_coverage]
# rule⑤ は md のみ (非主要拡張子)。GFM anchor の positive / negative variant を網羅。
other_ext_tests = [
Expand Down Expand Up @@ -265,6 +289,12 @@ steps = [
bad = 'BLOCK_MESSAGE: &str = "詳細: docs/todoN.md (N = 数字) の \"<section>\" 参照";'
good = 'BLOCK_MESSAGE: &str = "詳細: docs/adr/adr-NNN-feature.md 参照";'

[rules.incident]
pr = 94
bad_fixture = "no-ephemeral-todo-reference.rs"
good_fixture = "no-ephemeral-todo-reference.rs"
adr = "adr-007"

[rules.test_coverage]
# rule⑥ は主要拡張子 4 つ (rs/toml/yaml/yml) + 非主要 8 つを extensions に含む。
# 順位 137 PR で yaml/yml の test gap を補填 (positive 2 + negative 1 新規追加)。
Expand Down Expand Up @@ -325,6 +355,12 @@ steps = [
bad = "comments.iter().filter(|c| c.created_at > push_time)"
good = "comments.iter().filter(|c| c.created_at >= push_time)"

[rules.incident]
pr = 101
bad_fixture = "no-time-field-strict-greater.rs"
good_fixture = "no-time-field-strict-greater.rs"
adr = "adr-007"

[rules.test_coverage]
# rule⑦ は rs のみ (主要拡張子)。時刻フィールド variant + 境界 case の網羅的 seal。

Expand Down Expand Up @@ -392,6 +428,12 @@ steps = [
bad = "[ADR-036](DOTDOT/docs/adr/adr-036-...) <!-- DOTDOT は ../ の意。実際は `..` 2 文字 -->"
good = "[ADR-036](adr/adr-036-...)"

[rules.incident]
pr = 133
bad_fixture = "no-docs-relative-back-to-docs.md"
good_fixture = "no-docs-relative-back-to-docs.md"
adr = "adr-007"

[rules.test_coverage]
# rule⑧ は md のみ (非主要拡張子)。docs 配下 + root-level 両方の back-reference を網羅。
other_ext_tests = [
Expand Down Expand Up @@ -463,6 +505,12 @@ good = ''' persona: supervisor
model: sonnet
instruction: loop-monitor-reviewers-fix'''

[rules.incident]
pr = 98
bad_fixture = "takt-workflow-persona-without-model.yaml"
good_fixture = "takt-workflow-persona-without-model.yaml"
adr = "adr-007"

[rules.test_coverage]
# rule⑨ は yaml のみ (主要拡張子)。multi-line regex variant + paths filter は別 test で網羅。

Expand Down Expand Up @@ -517,6 +565,12 @@ steps = [
bad = "let _ = write_state(&state);"
good = "if let Err(e) = write_state(&state) { log_warn(&format!(\"state write failed: {}\", e)); }"

[rules.incident]
pr = 155
bad_fixture = "no-write-result-discard.rs"
good_fixture = "no-write-result-discard.rs"
adr = "adr-007"

[rules.test_coverage]
# rule⑩ は rs のみ (主要拡張子)。Drop / if-let-Err / named-binding 等の variant を網羅。

Expand Down Expand Up @@ -573,6 +627,12 @@ steps = [
bad = "jj log -T 'change_id ++ DESC_DOT_FIRSTLINE' # placeholder: 実コードでは description . first_line() = 曖昧性"
good = "jj log -T 'change_id ++ if(empty, \"EMPTY\", \"CONTENT\")' # commit 自体が file change を含むかで判定"

[rules.incident]
pr = 175
bad_fixture = "no-jj-template-first-line.toml"
good_fixture = "no-jj-template-first-line.toml"
adr = "adr-007"

[rules.test_coverage]
# rule⑪ は jj template が書かれうる主要 file 形式 (toml/yaml/md) を対象。
# toml/yaml は主要拡張子のため main_ext_tests、md は非主要のため other_ext_tests。
Expand Down Expand Up @@ -628,6 +688,12 @@ steps = [
bad = 'let revset = "master..@";'
good = 'let revset = format!("{}..@", default_branch);'

[rules.incident]
pr = 195
bad_fixture = "no-hardcoded-jj-revset-range.rs"
good_fixture = "no-hardcoded-jj-revset-range.rs"
adr = "adr-021"

[rules.test_coverage]
# rule⑫ は rs のみ (主要拡張子)。positive (hardcode 検出) + negative (parameterized 形式 skip) の variant を網羅。

Expand Down
5 changes: 5 additions & 0 deletions .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
".claude/skills",
".takt/runs",
".pytest_cache",
// WP-08: incident-eval fixtures are synthetic test data (intentionally reproduce
// past incidents) — quarantined from markdownlint so their deliberate "bad" content
// does not trip style gates. See docs/adr/adr-049-incident-eval-regression-suite.md.
"tests/fixtures/incidents",
"tests/fixtures/incidents/**",
"__*",
"__*/**"
]
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
- [ADR-046: ローカル LLM pre-push レビュアー — 選定スパイクと不採用判断](docs/adr/adr-046-local-llm-review-spike.md) *(却下)*
- [ADR-047: pre-push review の反証(refute)facet](docs/adr/adr-047-prepush-refute-facet.md) *(試験運用)*
- [ADR-048: reviewers→fix findings handoff の output-contract 標準化(markdown 統一・JSON 却下)](docs/adr/adr-048-facet-findings-handoff-markdown-contract.md) *(試験運用)*
- [ADR-049: incident→eval 回帰スイート(カスタムルールの由来 incident 再現テスト)](docs/adr/adr-049-incident-eval-regression-suite.md) *(試験運用)*

## Build

Expand Down
107 changes: 107 additions & 0 deletions docs/adr/adr-049-incident-eval-regression-suite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# ADR-049: incident→eval 回帰スイート (カスタムルールの由来 incident 再現テスト)

## ステータス

試験運用 (2026-07-06)

## コンテキスト

カスタムリントルール ([.claude/custom-lint-rules.toml](../../.claude/custom-lint-rules.toml)、`hooks-post-tool-linter` が適用) は
12 本あり、うち **11 本は実 incident (過去 PR で発生した具体的な事故) を由来**とする ([ADR-007](adr-007-custom-linter-layer-boundary.md) の正規表現層)。
既存の `rule_test_coverage_check` は「各ルールが対応 test 関数を宣言していること」をゲート化するが、
**ルールが由来 incident を今も検出できるか (= ハーネス自身の退行)** を機械検出する仕組みは無かった。

WP-08 (`docs/harness-improvement-plan.md`) は、各ルールを生んだ実 incident を再現する fixture を整備し、
それを hook に食わせて block/warn を assert する回帰スイートで「ハーネス自体の退行」を機械検出する。

## 決定

**incident→eval 回帰スイート**を導入する。標準的な 2 つの手法 — 回帰テスト (バグ修正には再現テストを付ける) と
linter の fixture corpus (ESLint / Clippy 等の「引っかかる例 / clean な例」) — の応用で、これに
**provenance ポリシー** (各ルールは由来 incident とその再現 fixture を機械可読に持つ) を重ねる。

### 1. provenance の構造化 ([rules.incident])

`[.claude/custom-lint-rules.toml](../../.claude/custom-lint-rules.toml)` の incident 由来 11 ルールに
`[rules.incident]` meta field を追加し、`CustomRule.incident: Option<CustomRuleIncident>` として parse する:

```toml
[rules.incident]
pr = 75 # 由来 incident の PR 番号
bad_fixture = "no-personal-paths.md" # tests/fixtures/incidents/bad/ 配下 (fire すべき入力)
good_fixture = "no-personal-paths.md" # tests/fixtures/incidents/good/ 配下 (fire しない clean 入力)
adr = "adr-007" # 設計根拠 ADR (任意)
```

追跡鎖: **incident (PR) → rule (id) → fixture (bad/good) → regression test → ADR**。数年後に
「このルールは消してよいか」を問う開発者が全て辿れる。section を持たないルール (rule① `no-console-log`
= 汎用サンプル、incident 由来でない) は fixture 要求から免除する (`NON_INCIDENT_RULES` allowlist)。
これにより「12 ルール中 11 が incident 由来」という齟齬も明示的に扱える。

### 2. fixture 設計 (1 fixture = 1 failure mode + good/bad)

`tests/fixtures/incidents/{bad,good}/` に配置。設計原則:

- **1 fixture = 1 failure mode**: 各 bad fixture は該当ルールの incident パターン**のみ**含む
(LLVM / rustc の UI test 流儀)。将来「何が検出できなくなったか」を一点に絞れる。
- **good (negative) fixture 必須**: bad は fire する、good (clean な対応) は fire しないことを両方保証し、
検出退行だけでなく **false positive 退行**も防ぐ (linter では同等に重要)。
- **テストデータ明示**: 各 fixture 冒頭コメントで synthetic test data であることと由来 PR を明示。

### 3. Hook E2E test (実 exe spawn)

[src/hooks-post-tool-linter/tests/incident_eval.rs](../../src/hooks-post-tool-linter/tests/incident_eval.rs) は
内部関数呼び出しではなく **実 exe を `CARGO_BIN_EXE_*` で spawn** し、`PostToolUse` JSON を stdin に流して
stdout を parse する。これで **arg/stdin パース → config → rule → feedback JSON → stdout** の全経路を通す
真の E2E になる (内部 API だけ呼ぶと exe の shell を通らない)。assert は **`type` / `severity` / `line` のみ**に
限定し (feedback 全文は固定しない)、文言修正で test が壊れない。

- パス filter付きルール (rule⑨ `takt-workflow-persona-without-model`、`paths = [".takt/workflows/*.yaml"]`)
は temp CWD 配下の `.takt/workflows/` に fixture を stage し相対パスで invoke して path filter も検証する。
- exe は `custom-lint-rules.toml` を自身の隣から解決するため、test は deploy 済 toml を build 先へ copy する。

### 4. coverage gate (fail-closed)

既存 `rule_test_coverage_check` と同じ crate 内 cargo test として `incident_fixture_coverage_check` を追加。
各 incident 由来ルールが `[rules.incident]` を持ち bad/good fixture が**実在**することを **fail-closed** で強制する
(hard assert、[ADR-043](adr-043-security-gates-fail-closed.md))。fixture 欠落 / ルールが incident を検出しなくなった
場合は **cargo test が失敗**する — これは開発者向けの test 失敗であって、本番の Edit/Write を止めるものではない。

### 5. fixture の隔離 (ハーネス運用を壊さない)

fixture は意図的に「悪い」内容を含むため、ハーネス自身のゲートから隔離する:

- `src/**` の外 (repo-root `tests/fixtures/`) に置き、`deployed_tests.rs` の clean-baseline 走査 (`src/**/*.rs` /
`.takt/workflows/*.yaml`) に触れない。
- `[.markdownlint-cli2.jsonc](../../.markdownlint-cli2.jsonc)` の ignores に追加。
- `.rs` fixture のヘッダは comment-lint ([ADR-036](adr-036-bundle-z-three-layer-review.md) #B-α) に触れないよう
doc コメント (`//!`) を使う。
- カスタムリンター自体は非致命 (exit 0、feedback のみ) のため、fixture を編集しても運用は止まらない。

## ADR-039 との関係

prompt/test 資産の追加であり、[ADR-039](adr-039-experimental-feature-standard-pattern.md) の config opt-in は
そのままは適用しない。bounded lifetime として、dogfood 期間で「fixture 追加忘れ / ルール検出退行を実際に
機械検出できた」ことを確認したら `試験運用` を解除する。可逆性は fixture / gate の revert で担保。

## 帰結

### 利点

- ルールの検出力退行と false positive 退行を cargo test で機械検出 (ハーネス自身の回帰スイート)。
- 各ルールが由来 incident と再現 fixture を機械可読に持ち、削除可否判断が追跡可能。
- 実 exe E2E で hook の全経路 (stdin/config/feedback/exit) を保証。
- 本 repo 初の exe-spawn integration test パターンを確立 (WP-16 CI smoke test で流用可能)。

### 欠点 / 留意点

- fail-closed のため、incident 由来 11 ルール分の fixture を揃えて一括で land する必要がある (部分導入は gate 赤)。
- exe-spawn は内部関数テストより遅い (22 spawn)。回帰網羅性とのトレードオフとして許容。
- ルールの extensions / pattern を変更する際は対応 fixture も更新が必要 (既存 `rule_test_coverage_check` と同じ保守義務)。

### 関連 ADR

- [ADR-007](adr-007-custom-linter-layer-boundary.md) — custom-linter 正規表現層 (対象 11 ルールの居所 + per-rule test checklist)
- [ADR-036](adr-036-bundle-z-three-layer-review.md) — Bundle Z 3 層 review (comment-lint #B-α、fixture ヘッダの制約源)
- [ADR-042](adr-042-rule-vs-mechanism-boundary.md) — rule vs 仕組み化 (「11 custom lint rule」の由来カウント)
- [ADR-043](adr-043-security-gates-fail-closed.md) — ゲートの fail-closed 原則
4 changes: 3 additions & 1 deletion docs/harness-improvement-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Anthropic 公式のハーネスエンジニアリング指針(決定論的基
| WP-05 | 1-A | Stop hook 高速化(nextest + 変更 crate 限定) | M | なし | 実装済(ADR-004 amendment: 逐次→並列で ~8s→~2s 約75%削減。nextest/変更crate は実測無効 → push pipeline 向け nextest を順位257へ) |
| WP-06 | 1-B | 反証(refute)facet 追加 | S-M | なし | 実装済(ADR-047、導入は default OFF、dogfood 有効化はマージ後の別 PR) |
| WP-07 | 1-B | facet 間受け渡しの JSON 化 | M | なし | 実装済(ADR-048: 公式調査で JSON 却下→markdown 契約標準化。simplicity-review 契約新設・reviewer 間で列統一) |
| WP-08 | 1-B | incident→eval 回帰スイート | S | なし | 未着手 |
| WP-08 | 1-B | incident→eval 回帰スイート | S | なし | 実装済(ADR-049: 11 incident 由来ルールに bad/good fixture + 実 exe E2E + fixture 必須 fail-closed gate) |
| WP-09 | 1-C | PR 監視の GitHub Actions 化 Phase A(読み取り専用) | M | なし | 未着手 |
| WP-10 | 1-C | 自律境界ポリシー ADR(ADR-028 の 2 段化) | S | なし | 未着手 |
| WP-11 | 2 | prompt injection 信頼境界の 3 層防御 | M-L | WP-08 | 未着手 |
Expand Down Expand Up @@ -170,6 +170,8 @@ Anthropic 公式のハーネスエンジニアリング指針(決定論的基

### WP-08: incident→eval 回帰スイート

> **実装済 (2026-07-06、[ADR-049](adr/adr-049-incident-eval-regression-suite.md))**: 12 ルール中 **11 が実 incident 由来**(rule① no-console-log は汎用サンプルで免除)と判明。`[rules.incident]` (pr/bad_fixture/good_fixture/adr) を toml に構造化し、`tests/fixtures/incidents/{bad,good}/` に **1 fixture = 1 failure mode** の bad/good ペア(good = false positive 退行ガード)を整備。**実 exe を spawn する E2E** (`tests/incident_eval.rs`、rule⑨ は temp-CWD で paths filter も検証、assert は type/severity/line のみ) と、`incident_fixture_coverage_check` (incident 由来ルールに fixture 必須を **fail-closed** で強制) を追加。fixture は src/ 外・markdownlint 除外・`//!` doc ヘッダで隔離しハーネス運用を壊さない。追跡鎖 incident→rule→fixture→test→ADR を閉じた。以下は当初ステップ (記録用)。

- **目的**: 「ハーネス自体の退行」を機械検出する。カスタムルール 12 本は全て実 incident(PR 番号)由来なので、逆方向の検証を仕組み化する。
- **ステップ**:
1. `tests/fixtures/incidents/` に由来 incident を再現する fixture を整備(例: rule② の由来である PR #75 の PII パス混入)。
Expand Down
4 changes: 4 additions & 0 deletions src/hooks-post-tool-linter/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,9 @@ lib-subprocess = { path = "../lib-subprocess" }

[dev-dependencies]
tempfile = "3"
# serde_json: WP-08 incident-eval E2E test (tests/incident_eval.rs) parses the hook's
# stdout HookOutput/LintViolation JSON. serde_json is also a [dependencies] entry but
# integration tests only see [dev-dependencies], so it is re-declared here.
serde_json = "1.0"

# [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026)
71 changes: 71 additions & 0 deletions src/hooks-post-tool-linter/src/custom_rules/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,74 @@ fn rule_test_coverage_check() {
gaps.join("\n - ")
);
}

/// WP-08 (ADR-049): incident 由来でない汎用ルール (現状 no-console-log = サンプル) を
/// fixture 要求から免除する allowlist。新規ルールは [rules.incident] + fixture を用意するか
/// ここに追加するかの二択を強制する (= 学習機会化)。
#[cfg(test)]
const NON_INCIDENT_RULES: &[&str] = &["no-console-log"];

#[cfg(test)]
fn incident_fixtures_dir(kind: &str) -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("tests")
.join("fixtures")
.join("incidents")
.join(kind)
}

#[cfg(test)]
fn collect_incident_gaps(rule: &CustomRule) -> Vec<String> {
let is_exempt = NON_INCIDENT_RULES.contains(&rule.id.as_str());
let mut gaps: Vec<String> = Vec::new();
match (&rule.incident, is_exempt) {
(None, true) => {}
(None, false) => gaps.push(format!(
"rule `{}` has no `[rules.incident]` — every incident-derived rule must record its \
originating PR + bad/good fixtures (or be added to NON_INCIDENT_RULES with justification)",
rule.id
)),
(Some(_), true) => gaps.push(format!(
"rule `{}` is listed in NON_INCIDENT_RULES yet declares `[rules.incident]` — remove one",
rule.id
)),
(Some(incident), false) => {
for (kind, name) in [("bad", &incident.bad_fixture), ("good", &incident.good_fixture)] {
let path = incident_fixtures_dir(kind).join(name);
if !path.exists() {
gaps.push(format!(
"rule `{}` (PR #{}) declares {}_fixture `{}` but no file exists at {}",
rule.id,
incident.pr,
kind,
name,
path.display()
));
}
}
}
}
gaps
}

/// WP-08 (ADR-049) incident→fixture ゲート: 各 incident 由来ルールが [rules.incident] で
/// 実 incident (PR) と bad/good fixture を宣言し、その fixture ファイルが実在することを
/// fail-closed で強制する。incident_eval.rs E2E test はその fixture を実 exe に食わせて
/// 検出/誤検知ゼロを検証し、両者で「ルール ⇔ incident ⇔ fixture ⇔ 回帰 test」の鎖を閉じる。
#[cfg(test)]
#[test]
fn incident_fixture_coverage_check() {
let rules = load_deployed_custom_rules();
let mut gaps: Vec<String> = Vec::new();
for rule in &rules {
gaps.extend(collect_incident_gaps(rule));
}
assert!(
gaps.is_empty(),
"incident-eval fixture coverage gaps detected ({} issue(s)):\n - {}",
gaps.len(),
gaps.join("\n - ")
);
}
Loading