phasegate 0.142.0 → 0.144.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.ja.md +5 -4
  3. package/README.md +6 -5
  4. package/docs/guide/layer-model.md +3 -2
  5. package/docs/guide/quick-vs-full-mode.md +1 -1
  6. package/docs/principles/testing-rules.md +18 -0
  7. package/docs/templates/ci/aidlc-gate.yml +26 -3
  8. package/docs/templates/hooks/pre-push +6 -0
  9. package/package.json +1 -1
  10. package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +2 -2
  11. package/scripts/harness/integrations/pre-commit.ts +251 -3
  12. package/scripts/harness/main.ts +23 -0
  13. package/scripts/harness/quick-mode/domain/value-objects/validator-relaxation-profile.ts +4 -3
  14. package/scripts/harness/quick-mode/infrastructure/adapters/harness-config-quick-mode-config-adapter.ts +2 -1
  15. package/scripts/harness/quick-mode/infrastructure/adapters/validator-system-validator-id-registry-adapter.ts +2 -1
  16. package/scripts/harness/setup/skill-deployer.ts +19 -0
  17. package/scripts/harness/traceability-model/application/usecases/apply-work-item-status-usecase.ts +29 -4
  18. package/scripts/harness/traceability-model/domain/services/work-item-status-derivation-service.ts +20 -4
  19. package/scripts/harness/traceability-model/domain/value-objects/work-item-status-report.ts +10 -1
  20. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-status-gateway.ts +2 -1
  21. package/scripts/harness/traceability-model/presentation/cli/work-item-status-command-handler.ts +13 -4
  22. package/scripts/harness/validator-system/application/mappers/validation-result-contract-mapper.ts +8 -4
  23. package/scripts/harness/validator-system/application/use-cases/run-l2-validators-usecase.ts +28 -0
  24. package/scripts/harness/validator-system/composition-root.ts +5 -1
  25. package/scripts/harness/validator-system/domain/ports/work-item-status-policy-port.ts +11 -0
  26. package/scripts/harness/validator-system/domain/value-objects/test-quality-semantics.ts +58 -0
  27. package/scripts/harness/validator-system/domain/value-objects/validator-id.ts +2 -0
  28. package/scripts/harness/validator-system/infrastructure/adapters/biome-ast-test-quality-analyzer-adapter.ts +439 -21
  29. package/scripts/harness/validator-system/infrastructure/adapters/harness-config-validator-config-adapter.ts +1 -1
  30. package/scripts/harness/validator-system/infrastructure/adapters/traceability-work-item-status-policy-adapter.ts +52 -0
  31. package/templates/.husky/pre-push +1 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.144.0] - 2026-05-10
11
+
12
+ ### Added
13
+
14
+ - **WI-129 / WI-130 — semantic L2 test-quality validation** — `L2-003 test-quality` を test case 単位の semantic AAA / assertion strength モデルへ更新した。
15
+ - TypeScript adapter は AST から `TestCaseStructure` / `SemanticAssertion` を構築し、複数 Act、Act 観測不足、Act 以外への Assert、domain/internal mock、weak truthiness / snapshot only / length only / interaction only、error contract 不足を warning として検出する。
16
+ - lifecycle/E2E と parameterized test の例外を追加し、weak assertion policy は constructor option で差し替え可能にした。
17
+
10
18
  ## [0.142.0] - 2026-05-10
11
19
 
12
20
  ### Added
package/README.ja.md CHANGED
@@ -86,7 +86,7 @@ claude
86
86
  - `.codex/hooks.json` — Codex CLI hooks 設定(`--agent codex|both` 時)
87
87
  - `docs/principles/*.md` — アーキテクチャ哲学・テスト規約(immutable)
88
88
  - `docs/folder_management_rules.md` — ドキュメント配置ルール(**正本**)
89
- - `--with-husky` を付けると `.husky/pre-commit` ・ `.husky/commit-msg` も配置
89
+ - `--with-husky` を付けると `.husky/pre-commit` ・ `.husky/commit-msg` ・ `.husky/pre-push` も配置
90
90
  - `--with-ci` を付けると `.github/workflows/aidlc-gate.yml` ・ `.github/workflows/consistency-check.yml` ・ `.github/workflows/agent-context-refresh.yml` も配置
91
91
 
92
92
  **`init` が生成しないもの**(後で skill が作る):
@@ -142,7 +142,7 @@ npx phasegate update-skills # スキルを最新版に再デプロイ
142
142
  | @unit / @layer メタデータ, レイヤー違反, AI アンチパターン |
143
143
  +------------------------------------------------------------------+
144
144
  | L2 pre-commit |
145
- | phase-gate, story-reflection, テスト品質 (AAA/日本語名) |
145
+ | phase-gate, story-reflection, テスト品質 (semantic AAA) |
146
146
  +------------------------------------------------------------------+
147
147
  | L3 CI/CD |
148
148
  | security, performance, coverage 90%/95%, 要件カバレッジ |
@@ -293,7 +293,7 @@ npx phasegate <command> [options]
293
293
  | `baseline` | retrofit grandfather snapshot 生成(`--dry-run`, `--force`, `--paths <glob>`, `--json`) |
294
294
  | `scaffold-design --unit <id> --phase <logical\|domain\|uiux\|unit-test\|it-test>` | 最小構成の設計文書を `templates/` から生成 |
295
295
  | `phasegate:status` | 全体の健全性サマリ |
296
- | `work-items:status --dry-run` / `--apply` | 成果物から WI status を導出し、必要に応じて `description.md` frontmatter を更新 |
296
+ | `work-items:status --dry-run` / `--apply` | 成果物から WI status を導出し、必要に応じて `description.md` frontmatter を更新。`--apply` は既定で downgrade を拒否し、必要時のみ `--allow-downgrade` を指定 |
297
297
  | `phasegate:check-phase --unit <id>` | 指定 Unit の現在フェーズ |
298
298
  | `phasegate:detect-drift` | 設計-コード乖離レポート |
299
299
  | `migrate work-items --dry-run` / `--apply` | 既存リポジトリの旧 `ISSUE-XXX` / `H{NN}-{NN}` directory を WI 統一レイアウト(`_cross/{WI-XXX}/` / `{unit}/{WI-XXX}/`)へ移行。frontmatter(`type` / `severity` / `legacy_id` / `affects`)を自動注入。冪等。`--json` で CI/スクリプト連携可。詳細: [Work Item Migration](docs/guide/cli-reference.md#work-item-migration) |
@@ -305,6 +305,7 @@ npx phasegate <command> [options]
305
305
  | `list-errors --layer <L0-L4>` | エラー定義一覧 |
306
306
  | `hook <pre-tool-use\|post-tool-use\|stop>` | agent hook を起動(stdin から JSON) |
307
307
  | `pre-commit` | L2 pre-commit バリデータをステージファイルに適用 |
308
+ | `bypass:audit --base <ref> [--head <ref>]` | push/CI range に pre-commit validation を再適用し、gate failure に structured bypass evidence を要求 |
308
309
 
309
310
  完全な CLI Reference: [CLI Reference](docs/guide/cli-reference.md)
310
311
 
@@ -407,7 +408,7 @@ TESTED (@work-item-id 付きテストあり / green)
407
408
 
408
409
  `type: chore` は DRAFTED で完結。`type: fix` は DRAFTED → REFLECTED → IMPLEMENTED の簡略パス。`status` は PhaseGate が自動更新します。
409
410
 
410
- `phasegate work-items:status --dry-run` で current status / derived status / reason / next action を確認できます。単一 WI に絞る場合は `--id WI-XXX`、CI 風に stale status を検出する場合は `--fail-on-stale`、`description.md` frontmatter の `status:` 行だけを書き戻す場合は `--apply` を指定します。
411
+ `phasegate work-items:status --dry-run` で current status / derived status / reason / next action / structured missing evidence を確認できます。単一 WI に絞る場合は `--id WI-XXX`、CI 風に stale status を検出する場合は `--fail-on-stale`、`description.md` frontmatter の `status:` 行だけを書き戻す場合は `--apply` を指定します。標準 L2 validation は `L2-014 work-item-status-staleness` も実行し、stale WI status を pre-commit / CI の fail signal として扱います。
411
412
 
412
413
  詳細仕様: [`docs/folder_management_rules.md`](docs/folder_management_rules.md)
413
414
 
package/README.md CHANGED
@@ -85,7 +85,7 @@ claude
85
85
  - `.claude/skills` and/or `.codex/skills` links for agent use
86
86
  - `.claude/settings.json` and/or `.codex/hooks.json` hook configuration
87
87
  - `docs/principles/*.md` and `docs/folder_management_rules.md`
88
- - `.husky/pre-commit` and `.husky/commit-msg` when `--with-husky` is passed
88
+ - `.husky/pre-commit`, `.husky/commit-msg`, and `.husky/pre-push` when `--with-husky` is passed
89
89
  - `.github/workflows/aidlc-gate.yml`, `.github/workflows/consistency-check.yml`, and `.github/workflows/agent-context-refresh.yml` when `--with-ci` is passed
90
90
 
91
91
  `init` intentionally does **not** create `docs/inception/` work item directories or `docs/product/` design documents. Those are produced later by skills such as `/product-architect`, `/domain-designer`, and `/logical-designer`. That is the core contract: no design, no code.
@@ -139,7 +139,7 @@ npx phasegate update-skills
139
139
  +------------------------------------------------------------------+
140
140
  | L2 PRE-COMMIT Validators |
141
141
  | phase-gate, metadata completeness, story-reflection, |
142
- | test-quality (AAA pattern) |
142
+ | test-quality (semantic AAA + assertion strength) |
143
143
  +------------------------------------------------------------------+
144
144
  | L3 CI/CD Validators |
145
145
  | security, performance, coverage threshold, nyquist traceability |
@@ -152,7 +152,7 @@ npx phasegate update-skills
152
152
 
153
153
  | Layer | Trigger | Key Checks |
154
154
  |---|---|---|
155
- | L0 | AI agent runtime (`.claude/settings.json` / `.codex/hooks.json`) + Husky git hooks | PreToolUse blocks Write/Edit/Bash that violate gates; PostToolUse runs lint/format; Stop enforces ReentryGuard + `complete-check`; `.husky/pre-commit` runs `phasegate pre-commit`; `.husky/commit-msg` enforces `Work-Item: WI-XXX` trailer |
155
+ | L0 | AI agent runtime (`.claude/settings.json` / `.codex/hooks.json`) + Husky git hooks | PreToolUse blocks Write/Edit/Bash that violate gates; PostToolUse runs lint/format; Stop enforces ReentryGuard + `complete-check`; `.husky/pre-commit` runs `phasegate pre-commit`; `.husky/commit-msg` enforces `Work-Item: WI-XXX` and bypass trailers; `.husky/pre-push` runs `phasegate bypass:audit` |
156
156
  | L1 | Editor save / `phasegate lint` | `@unit` / `@layer` metadata, layer violations, AI anti-patterns, dead code |
157
157
  | L2 | Pre-commit (also evaluated inside PreToolUse at L0) | Phase gate, metadata completeness, `@work-item-id` reflection (`L2-STORY-REFLECTION`), test quality |
158
158
  | L3 | CI/CD pipeline | Security, performance, coverage (90%/95%), requirements traceability |
@@ -247,7 +247,7 @@ TESTED (test files annotated with @work-item-id, all green)
247
247
 
248
248
  `type: chore` ends at DRAFTED. `type: fix` shortcuts via DRAFTED → REFLECTED → IMPLEMENTED. PhaseGate auto-updates `status`.
249
249
 
250
- Use `phasegate work-items:status --dry-run` to inspect current status, derived status, reason, and next action. Add `--id WI-XXX` to scope the report, `--fail-on-stale` for CI-style stale status detection, or `--apply` to update only the `status:` line in each stale `description.md` frontmatter.
250
+ Use `phasegate work-items:status --dry-run` to inspect current status, derived status, reason, next action, and structured missing evidence. Add `--id WI-XXX` to scope the report, `--fail-on-stale` for CI-style stale status detection, or `--apply` to update only the `status:` line in each stale `description.md` frontmatter. Standard L2 validation also runs `L2-014 work-item-status-staleness`; stale WI status fails `validate --layer L2` for pre-commit/CI use.
251
251
 
252
252
  Full spec: [`docs/folder_management_rules.md`](docs/folder_management_rules.md)
253
253
 
@@ -476,7 +476,7 @@ npx phasegate <command> [options]
476
476
  | `p2:check-agent-context` | Check AGENTS.md / CLAUDE.md freshness |
477
477
  | `update-skills` | Update skills to latest version |
478
478
  | `phasegate:status` | Display overall harness health summary |
479
- | `work-items:status --dry-run` / `--apply` | Derive WI status from artifacts and optionally update stale `description.md` frontmatter |
479
+ | `work-items:status --dry-run` / `--apply` | Derive WI status from artifacts and optionally update stale `description.md` frontmatter. Apply refuses downgrades unless `--allow-downgrade` is supplied. |
480
480
  | `phasegate:check-phase --unit <id>` | Check current phase for a Unit |
481
481
  | `check-change-category --paths <csv>` | Classify changed files into Quick Mode categories and report whether Full Mode is required (`--format json`, `--fail-on-full-required`) |
482
482
  | `baseline` | Create `.phasegate/baseline.json` snapshot for Phase A-2 retrofit grandfather (`--dry-run`, `--force`, `--paths <glob,glob,...>`, `--json`). `baseline.enabled` defaults to `true` since v0.71.0. |
@@ -484,6 +484,7 @@ npx phasegate <command> [options]
484
484
  | `list-errors --layer <L0-L4>` | List error definitions with fix examples |
485
485
  | `hook <pre-tool-use\|post-tool-use\|stop>` | Run a Claude Code hook (reads JSON from stdin) |
486
486
  | `pre-commit` | Run L2 pre-commit validators on staged files |
487
+ | `bypass:audit --base <ref> [--head <ref>]` | Replay pre-commit validation over a push/CI range and require structured bypass evidence for gate failures |
487
488
  | `delegate-sonnet [...args]` | Delegate task to Sonnet 4.6 (transparent wrapper) |
488
489
  | `migrate work-items --dry-run` / `--apply` | Migrate legacy `ISSUE-XXX` / `H{NN}-{NN}` directories under `docs/inception/` to the unified `WI-XXX` layout (frontmatter `type` / `legacy_id` / `affects` injected). Sequential allocator skips numbers already used by existing WIs. See [CLI Reference -- Work Item Migration](docs/guide/cli-reference.md#work-item-migration). |
489
490
  | `migrate --schema v3` | Upgrade `phasegate.config.json` to v3 schema by adding the `architecture` key (idempotent). |
@@ -50,7 +50,8 @@ Deployed by `phasegate init --with-husky` into `.husky/`.
50
50
  | Hook file | Invokes | Responsibility |
51
51
  |-----------|---------|----------------|
52
52
  | **.husky/pre-commit** | `npx phasegate pre-commit` | Runs L2 validators (phase-gate / metadata / story-reflection / test-quality) on staged files. Fails the commit on violation. |
53
- | **.husky/commit-msg** | `npx phasegate commit-msg $1` | Enforces the `Work-Item: WI-XXX` trailer when WI directories or their contents are staged. Ensures every commit is traceable to a work item. |
53
+ | **.husky/commit-msg** | `npx phasegate commit-msg $1` | Enforces the `Work-Item: WI-XXX` trailer when WI directories or their contents are staged, and validates structured bypass trailers when present. |
54
+ | **.husky/pre-push** | `npx phasegate bypass:audit --base origin/main --head HEAD` | Replays pre-commit validation for the push range so commits created with `git commit --no-verify` still need complete bypass evidence before leaving the workstation. |
54
55
 
55
56
  ### About `validate --layer L0`
56
57
 
@@ -107,7 +108,7 @@ L2 validators run before every commit. They enforce process discipline and test
107
108
  |-----------|-------------|
108
109
  | **phase-gate** | Enforces design-before-implementation order. Code changes to `scripts/harness/` are blocked unless the corresponding design documents exist in `docs/product/construction/`. |
109
110
  | **metadata** | Verifies completeness of source file annotations: `@unit`, `@layer`, `@US-XXX`, and `@story` |
110
- | **test-quality** | Enforces test authoring standards: AAA pattern (Arrange/Act/Assert), `actual` variable naming, single-act-per-test, and no mocking in domain layer tests |
111
+ | **test-quality** | Enforces test authoring standards through a runner-independent semantic model: AAA pattern (Arrange/Act/Assert), named Act observation, single-act-per-test, assertion strength, lifecycle/E2E exceptions, and no domain/internal mocking in domain layer tests |
111
112
 
112
113
  **Command:**
113
114
 
@@ -46,7 +46,7 @@ flowchart TD
46
46
  | L4 Scheduled | drift + consistency + dead-code | skipped |
47
47
  | Commit prefix | conventional (`feat:`, `fix:`, ...) | `[quick] ...` |
48
48
 
49
- Both modes keep **L1 in full strength** and **L2 metadata / test-quality** — `@unit` / `@layer` comments and AAA test structure are non-negotiable regardless of flow.
49
+ Both modes keep **L1 in full strength** and **L2 metadata / test-quality** — `@unit` / `@layer` comments, semantic AAA structure, and assertion-strength checks are non-negotiable regardless of flow.
50
50
 
51
51
  ---
52
52
 
@@ -12,6 +12,24 @@
12
12
  - 実行結果は `actual` に代入する。
13
13
  - テストの関心外の共通準備はヘルパーやファクトリに寄せる。ただし、各テストの前提が読めなくなるほど隠蔽しない。
14
14
 
15
+ ## Semantic AAA と Assertion Quality
16
+
17
+ <!-- @work-item-id WI-129, WI-130 -->
18
+
19
+ AAA は特定の runner やコメント表記ではなく、テストケースが以下の意味構造を持つこととして扱う。
20
+
21
+ | 構造 | 意味 |
22
+ |------|------|
23
+ | Arrange | 前提条件、入力、fixture、制御可能な依存を組み立てる |
24
+ | Act | 観測したいふるまいを実行する。Unit / Integration では原則 1 つ |
25
+ | Assert | Act の観測結果、状態変化、発行イベント、永続化結果、error contract、interaction を検証する |
26
+
27
+ Assertion Quality は matcher 名だけで判断しない。観測対象は `observed output` / `state` / `emitted event` / `persisted effect` / `error contract` / `interaction` に分類し、観測強度は `exact value` / `shape` / `invariant` / `range` / `weak truthiness` / `snapshot only` / `interaction only` / `length only` に分類する。
28
+
29
+ - `toBeTruthy()` 相当の truthiness のみ、snapshot のみ、length のみ、mock call count のみは弱い観測として扱う。
30
+ - error case は error type / code / message / recovery hint などの contract を検証する。
31
+ - E2E の一連のライフサイクルは複数 Act を許可してよいが、各 Act の直後に観測可能な Assert を置く。
32
+
15
33
  ## テスト構造
16
34
 
17
35
  `target` / `describe` / `context` / `it` は以下の役割で使う。
@@ -5,9 +5,10 @@
5
5
  #
6
6
  # 実行タイミング: Pull Request(open / synchronize / reopen)
7
7
  # 実行内容:
8
- # 1. harness:lint L1 Biome AST ルール検証
9
- # 2. harness:ci-check L3 CI バリデータ(security / performance / coverage / nyquist)
10
- # 3. 失敗時: PR にエラーサマリーをコメント
8
+ # 1. bypass:audit bypass trailer / evidence 監査
9
+ # 2. harness:lint L1 Biome AST ルール検証
10
+ # 3. harness:ci-check L3 CI バリデータ(security / performance / coverage / nyquist)
11
+ # 4. 失敗時: PR にエラーサマリーをコメント
11
12
 
12
13
  name: AIDLC Quality Gate
13
14
 
@@ -25,6 +26,8 @@ jobs:
25
26
  steps:
26
27
  - name: Checkout
27
28
  uses: actions/checkout@v4
29
+ with:
30
+ fetch-depth: 0
28
31
 
29
32
  - name: Setup Node.js
30
33
  uses: actions/setup-node@v4
@@ -40,6 +43,21 @@ jobs:
40
43
  - name: Install dependencies
41
44
  run: pnpm install --frozen-lockfile
42
45
 
46
+ # Bypass audit: local `git commit --no-verify` can skip hooks, but CI
47
+ # replays the gate over the PR range and requires structured evidence.
48
+ - name: Bypass Audit
49
+ id: bypass_audit
50
+ run: |
51
+ set +e
52
+ git fetch origin "${{ github.base_ref }}" --depth=1
53
+ RESULT=$(npx phasegate bypass:audit --base "origin/${{ github.base_ref }}" --head HEAD 2>&1)
54
+ EXIT_CODE=$?
55
+ echo "result<<EOF" >> $GITHUB_OUTPUT
56
+ echo "$RESULT" >> $GITHUB_OUTPUT
57
+ echo "EOF" >> $GITHUB_OUTPUT
58
+ echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
59
+ exit $EXIT_CODE
60
+
43
61
  # L1: Biome AST ルール
44
62
  - name: L1 Lint (Biome AST)
45
63
  id: lint
@@ -75,11 +93,16 @@ jobs:
75
93
  script: |
76
94
  const lintResult = `${{ steps.lint.outputs.result }}`;
77
95
  const ciResult = `${{ steps.ci_check.outputs.result }}`;
96
+ const bypassResult = `${{ steps.bypass_audit.outputs.result }}`;
78
97
  const lintFailed = '${{ steps.lint.outputs.exit_code }}' !== '0';
79
98
  const ciFailed = '${{ steps.ci_check.outputs.exit_code }}' !== '0';
99
+ const bypassFailed = '${{ steps.bypass_audit.outputs.exit_code }}' !== '0';
80
100
 
81
101
  let body = '## ❌ AIDLC Quality Gate — 失敗\n\n';
82
102
 
103
+ if (bypassFailed) {
104
+ body += '### Bypass Audit エラー\n```text\n' + bypassResult + '\n```\n\n';
105
+ }
83
106
  if (lintFailed) {
84
107
  body += '### L1 Lint エラー\n```json\n' + lintResult + '\n```\n\n';
85
108
  }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env sh
2
+ # PhaseGate pre-push hook template.
3
+ #
4
+ # Audits commits that may have bypassed local hooks with `git commit --no-verify`.
5
+
6
+ npx phasegate bypass:audit --base origin/main --head HEAD
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.142.0",
3
+ "version": "0.144.0",
4
4
  "packageManager": "pnpm@10.30.1",
5
5
  "description": "Phasegate — AI-agnostic quality defense toolkit. Enforces structural integrity between design intent and code.",
6
6
  "license": "MIT",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @layer application
3
3
  * @unit config-foundation
4
- * @work-item-id WI-092 / WI-094 / WI-033
4
+ * @work-item-id WI-092 / WI-094 / WI-033 / WI-140
5
5
  */
6
6
  import type { HarnessConfigV2 } from '../../domain/harness-config.js';
7
7
 
@@ -11,7 +11,7 @@ export function toValidatorSystemConfig(resolvedConfig: HarnessConfigV2 | undefi
11
11
  return {
12
12
  project: { preset: resolvedConfig.project.preset },
13
13
  layers: {
14
- L2: { enabled: resolvedConfig.layers.L2.enabled, validators: ['L2-001', 'L2-002', 'L2-003', 'L2-013'] },
14
+ L2: { enabled: resolvedConfig.layers.L2.enabled, validators: ['L2-001', 'L2-002', 'L2-003', 'L2-013', 'L2-014'] },
15
15
  L3: { enabled: resolvedConfig.layers.L3.enabled },
16
16
  L4: { enabled: resolvedConfig.layers.L4.enabled, validators: resolvedConfig.layers.L4.validators },
17
17
  },
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @unit harness-api
3
3
  * @layer presentation
4
- * @work-item-id WI-092
4
+ * @work-item-id WI-141
5
5
  *
6
6
  * Pre-commit CLI entry.
7
7
  * Runs L2 validators against staged TypeScript files AND design-document
@@ -14,8 +14,8 @@
14
14
  * 2 = runtime error
15
15
  */
16
16
 
17
- import { execSync } from "node:child_process";
18
- import { readFile } from "node:fs/promises";
17
+ import { execFileSync, execSync } from "node:child_process";
18
+ import { access, readFile } from "node:fs/promises";
19
19
  import { createConfigFoundationModule } from "../config-foundation/composition-root.js";
20
20
  import { toValidatorSystemConfig } from "../config-foundation/application/mappers/validator-system-config-mapper.js";
21
21
  import { createTraceabilityModelModule } from "../traceability-model/composition-root.js";
@@ -36,6 +36,9 @@ const MD_EXTENSION = ".md";
36
36
  const WORK_ITEM_PATH_PATTERN = /(?:^|\/)WI-\d+(?:\/|$)/;
37
37
  const WORK_ITEM_TRAILER_PATTERN = /^Work-Item:\s*WI-\d+\s*$/m;
38
38
  const TEST_FILE_SUFFIXES = Object.freeze([".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx"]);
39
+ const BYPASS_TRAILER_NAMES = Object.freeze(["Bypass-Reason", "Bypass-Evidence", "Bypass-Owner"]);
40
+ const OPTIONAL_BYPASS_TRAILER_NAMES = Object.freeze(["Bypass-Report"]);
41
+ const NON_BYPASSABLE_VALIDATOR_IDS = Object.freeze(["L2-002", "L2-003", "L2-014"]);
39
42
 
40
43
  function isConfigNotFoundError(err: unknown): boolean {
41
44
  return err instanceof Error && err.name === "ConfigNotFoundError";
@@ -184,6 +187,7 @@ export interface PreCommitDeps {
184
187
  export interface PreCommitResult {
185
188
  readonly exitCode: 0 | 1 | 2;
186
189
  readonly stdout: string;
190
+ readonly blockerClasses: readonly BypassBlockerClass[];
187
191
  }
188
192
 
189
193
  export interface PreCommitOptions {
@@ -194,6 +198,28 @@ export interface PreCommitOptions {
194
198
  */
195
199
  readonly commitMessage?: string;
196
200
  readonly implementationExtensions?: readonly string[];
201
+ readonly allowConditionalBypass?: boolean;
202
+ readonly evidenceRoot?: string;
203
+ }
204
+
205
+ export interface BypassBlockerClass {
206
+ readonly code: string;
207
+ readonly label: string;
208
+ readonly bypassable: boolean;
209
+ }
210
+
211
+ export interface BypassTrailerValidationResult {
212
+ readonly hasAnyBypassTrailer: boolean;
213
+ readonly complete: boolean;
214
+ readonly errors: readonly string[];
215
+ }
216
+
217
+ export interface BypassAuditOptions {
218
+ readonly baseRef?: string;
219
+ readonly headRef?: string;
220
+ readonly commitMessages?: readonly string[];
221
+ readonly changedFiles?: readonly string[];
222
+ readonly evidenceRoot?: string;
197
223
  }
198
224
 
199
225
  function getStagedFiles(): string[] {
@@ -248,6 +274,95 @@ function hasWorkItemTrailer(commitMessage: string): boolean {
248
274
  return WORK_ITEM_TRAILER_PATTERN.test(commitMessage);
249
275
  }
250
276
 
277
+ function extractTrailer(commitMessage: string, trailerName: string): string | undefined {
278
+ const pattern = new RegExp(`^${trailerName}:\\s*(.+?)\\s*$`, "m");
279
+ return pattern.exec(commitMessage)?.[1]?.trim();
280
+ }
281
+
282
+ function hasTrailer(commitMessage: string, trailerName: string): boolean {
283
+ return extractTrailer(commitMessage, trailerName) !== undefined;
284
+ }
285
+
286
+ function isPathLikeEvidence(value: string): boolean {
287
+ return value.startsWith("report:");
288
+ }
289
+
290
+ function evidencePath(value: string): string {
291
+ return value.slice("report:".length).trim();
292
+ }
293
+
294
+ async function pathExists(path: string, root = process.cwd()): Promise<boolean> {
295
+ try {
296
+ const { resolve } = await import("node:path");
297
+ await access(resolve(root, path));
298
+ return true;
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ export async function validateBypassTrailers(
305
+ commitMessage: string,
306
+ evidenceRoot = process.cwd(),
307
+ ): Promise<BypassTrailerValidationResult> {
308
+ const allTrailerNames = [...BYPASS_TRAILER_NAMES, ...OPTIONAL_BYPASS_TRAILER_NAMES];
309
+ const hasAnyBypassTrailer = allTrailerNames.some((name) => hasTrailer(commitMessage, name));
310
+ if (!hasAnyBypassTrailer) {
311
+ return { hasAnyBypassTrailer: false, complete: false, errors: [] };
312
+ }
313
+
314
+ const errors: string[] = [];
315
+ for (const name of BYPASS_TRAILER_NAMES) {
316
+ if (!hasTrailer(commitMessage, name)) {
317
+ errors.push(`Missing required bypass trailer: ${name}`);
318
+ }
319
+ }
320
+
321
+ const evidence = extractTrailer(commitMessage, "Bypass-Evidence");
322
+ if (evidence !== undefined) {
323
+ if (evidence.startsWith("command:")) {
324
+ const command = evidence.slice("command:".length).trim();
325
+ if (command.length === 0) {
326
+ errors.push("Bypass-Evidence command must not be empty.");
327
+ }
328
+ } else if (isPathLikeEvidence(evidence)) {
329
+ const reportPath = evidencePath(evidence);
330
+ if (reportPath.length === 0) {
331
+ errors.push("Bypass-Evidence report path must not be empty.");
332
+ } else if (!(await pathExists(reportPath, evidenceRoot))) {
333
+ errors.push(`Bypass-Evidence report does not exist: ${reportPath}`);
334
+ }
335
+ } else {
336
+ errors.push("Bypass-Evidence must start with command: or report:.");
337
+ }
338
+ }
339
+
340
+ const report = extractTrailer(commitMessage, "Bypass-Report");
341
+ if (report !== undefined && !(await pathExists(report, evidenceRoot))) {
342
+ errors.push(`Bypass-Report does not exist: ${report}`);
343
+ }
344
+
345
+ return { hasAnyBypassTrailer: true, complete: errors.length === 0, errors };
346
+ }
347
+
348
+ function classifyValidatorFailure(result: ValidationResultContract): BypassBlockerClass | undefined {
349
+ if (result.passed || result.skipped) return undefined;
350
+ const nonBypassable = NON_BYPASSABLE_VALIDATOR_IDS.includes(result.validatorId);
351
+ return {
352
+ code: result.validatorId,
353
+ label: result.validatorId === "L2-003" ? "test-quality" : result.validatorId,
354
+ bypassable: !nonBypassable,
355
+ };
356
+ }
357
+
358
+ function metadataBlocker(): BypassBlockerClass {
359
+ return { code: "metadata", label: "metadata", bypassable: false };
360
+ }
361
+
362
+ function hasNonBypassableBlocker(blockers: readonly BypassBlockerClass[]): boolean {
363
+ return blockers.some((blocker) => !blocker.bypassable);
364
+ }
365
+
251
366
  function normalizeImplementationExtensions(extensions: readonly string[] | undefined): readonly string[] {
252
367
  const rawExtensions = extensions === undefined || extensions.length === 0
253
368
  ? DEFAULT_IMPLEMENTATION_EXTENSIONS
@@ -274,6 +389,7 @@ export async function runPreCommit(
274
389
  return {
275
390
  exitCode: 0,
276
391
  stdout: `${DIM}[phasegate] No staged files to check. Skipping.${RESET}`,
392
+ blockerClasses: [],
277
393
  };
278
394
  }
279
395
 
@@ -284,6 +400,7 @@ export async function runPreCommit(
284
400
  );
285
401
 
286
402
  let exitCode: 0 | 1 | 2 = 0;
403
+ const blockerClasses: BypassBlockerClass[] = [];
287
404
 
288
405
  if (implementationFiles.length > 0) {
289
406
  // staged TS file を Unit ごとにグルーピングし、Unit 単位で L2 phase gate
@@ -311,6 +428,10 @@ export async function runPreCommit(
311
428
  }
312
429
 
313
430
  const merged = mergePerUnitResults(runs);
431
+ blockerClasses.push(...merged.flatMap((result) => {
432
+ const blocker = classifyValidatorFailure(result);
433
+ return blocker === undefined ? [] : [blocker];
434
+ }));
314
435
  const report = buildReport(merged);
315
436
  sections.push("");
316
437
  sections.push(`${BOLD}== 実装ファイル (${implementationFiles.length} file(s)) ==${RESET}`);
@@ -328,6 +449,9 @@ export async function runPreCommit(
328
449
  sections.push(`${BOLD}== 設計 / テスト メタデータ注釈 (${metadataFiles.length} file(s)) ==${RESET}`);
329
450
  sections.push(metadataResult.text);
330
451
  exitCode = maxExitCode(exitCode, metadataResult.exitCode);
452
+ if (metadataResult.exitCode !== 0) {
453
+ blockerClasses.push(metadataBlocker());
454
+ }
331
455
  }
332
456
 
333
457
  if (options.commitMessage !== undefined && requiresWorkItemTrailer(stagedFiles)) {
@@ -343,6 +467,29 @@ export async function runPreCommit(
343
467
  }
344
468
  }
345
469
 
470
+ if (options.commitMessage !== undefined) {
471
+ const bypassValidation = await validateBypassTrailers(options.commitMessage, options.evidenceRoot);
472
+ if (bypassValidation.hasAnyBypassTrailer) {
473
+ sections.push("");
474
+ sections.push(`${BOLD}== Bypass audit ==${RESET}`);
475
+ if (!bypassValidation.complete) {
476
+ for (const error of bypassValidation.errors) {
477
+ sections.push(`${RED}FAIL${RESET} ${error}`);
478
+ }
479
+ exitCode = maxExitCode(exitCode, 1);
480
+ } else if (hasNonBypassableBlocker(blockerClasses)) {
481
+ const labels = blockerClasses.filter((blocker) => !blocker.bypassable).map((blocker) => blocker.label);
482
+ sections.push(`${RED}FAIL${RESET} Bypass rejected for non-bypassable blocker(s): ${labels.join(", ")}`);
483
+ exitCode = maxExitCode(exitCode, 1);
484
+ } else if (exitCode !== 0 && options.allowConditionalBypass === true) {
485
+ sections.push(`${GREEN}PASS${RESET} Conditional bypass evidence is complete.`);
486
+ exitCode = 0;
487
+ } else {
488
+ sections.push(`${GREEN}PASS${RESET} Bypass trailers are complete.`);
489
+ }
490
+ }
491
+ }
492
+
346
493
  sections.push("");
347
494
  if (exitCode === 0) {
348
495
  sections.push(`${GREEN}[phasegate]${RESET} All checks passed.`);
@@ -353,6 +500,72 @@ export async function runPreCommit(
353
500
  return {
354
501
  exitCode,
355
502
  stdout: sections.join("\n"),
503
+ blockerClasses,
504
+ };
505
+ }
506
+
507
+ function getChangedFilesInRange(baseRef: string, headRef: string): string[] {
508
+ try {
509
+ const output = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACM", `${baseRef}..${headRef}`], {
510
+ encoding: "utf-8",
511
+ stdio: ["ignore", "pipe", "ignore"],
512
+ });
513
+ return output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
514
+ } catch {
515
+ return [];
516
+ }
517
+ }
518
+
519
+ function getCommitMessagesInRange(baseRef: string, headRef: string): string[] {
520
+ try {
521
+ const output = execFileSync("git", ["log", "--format=%B%x1e", `${baseRef}..${headRef}`], {
522
+ encoding: "utf-8",
523
+ stdio: ["ignore", "pipe", "ignore"],
524
+ });
525
+ return output.split("\x1e").map((message) => message.trim()).filter((message) => message.length > 0);
526
+ } catch {
527
+ return [];
528
+ }
529
+ }
530
+
531
+ function hasCompleteBypassTrailerSet(results: readonly BypassTrailerValidationResult[]): boolean {
532
+ return results.some((result) => result.hasAnyBypassTrailer && result.complete);
533
+ }
534
+
535
+ export async function runBypassAudit(
536
+ deps: PreCommitDeps,
537
+ options: BypassAuditOptions = {},
538
+ ): Promise<PreCommitResult> {
539
+ const baseRef = options.baseRef ?? "origin/main";
540
+ const headRef = options.headRef ?? "HEAD";
541
+ const changedFiles = options.changedFiles ?? getChangedFilesInRange(baseRef, headRef);
542
+ const commitMessages = options.commitMessages ?? getCommitMessagesInRange(baseRef, headRef);
543
+ const syntheticCommitMessage = commitMessages.join("\n\n");
544
+
545
+ const result = await runPreCommit(changedFiles, deps, {
546
+ commitMessage: syntheticCommitMessage,
547
+ allowConditionalBypass: true,
548
+ evidenceRoot: options.evidenceRoot,
549
+ });
550
+ const bypassResults = await Promise.all(
551
+ commitMessages.map((message) => validateBypassTrailers(message, options.evidenceRoot)),
552
+ );
553
+
554
+ const sections = [
555
+ `${BOLD}[phasegate]${RESET} Bypass audit (${baseRef}..${headRef})`,
556
+ result.stdout,
557
+ ];
558
+ let exitCode = result.exitCode;
559
+ if (result.exitCode !== 0 && !hasCompleteBypassTrailerSet(bypassResults)) {
560
+ sections.push("");
561
+ sections.push(`${RED}FAIL${RESET} Gate failure requires complete bypass trailers.`);
562
+ exitCode = 1;
563
+ }
564
+
565
+ return {
566
+ exitCode,
567
+ stdout: sections.join("\n"),
568
+ blockerClasses: result.blockerClasses,
356
569
  };
357
570
  }
358
571
 
@@ -410,6 +623,41 @@ export async function runCommitMsgCli(commitMessagePath: string | undefined): Pr
410
623
  {
411
624
  commitMessage,
412
625
  implementationExtensions: await loadPreCommitImplementationExtensions(),
626
+ evidenceRoot: process.cwd(),
627
+ },
628
+ );
629
+
630
+ process.stdout.write(`${result.stdout}\n`);
631
+ process.exit(result.exitCode);
632
+ } catch (err) {
633
+ const msg = err instanceof Error ? err.message : String(err);
634
+ process.stderr.write(`${RED}[phasegate] Unexpected error:${RESET} ${msg}\n`);
635
+ process.exit(2);
636
+ }
637
+ }
638
+
639
+ function parseCliFlag(args: readonly string[], flag: string): string | undefined {
640
+ const index = args.indexOf(flag);
641
+ if (index === -1 || index + 1 >= args.length) return undefined;
642
+ return args[index + 1];
643
+ }
644
+
645
+ export async function runBypassAuditCli(args: readonly string[] = []): Promise<void> {
646
+ try {
647
+ const validatorMod = createValidatorSystemModule(await loadValidatorSystemConfig());
648
+ const traceabilityMod = createTraceabilityModelModule(
649
+ process.cwd(),
650
+ await loadTraceabilityModelOptions(),
651
+ );
652
+ const result = await runBypassAudit(
653
+ {
654
+ runL2ValidatorsUseCase: validatorMod.runL2ValidatorsUseCase,
655
+ validateMetadataCommandHandler: traceabilityMod.validateMetadataCommandHandler,
656
+ },
657
+ {
658
+ baseRef: parseCliFlag(args, "--base"),
659
+ headRef: parseCliFlag(args, "--head"),
660
+ evidenceRoot: process.cwd(),
413
661
  },
414
662
  );
415
663