phasegate 0.142.0 → 0.143.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.
- package/README.ja.md +4 -3
- package/README.md +5 -4
- package/docs/guide/layer-model.md +2 -1
- package/docs/templates/ci/aidlc-gate.yml +26 -3
- package/docs/templates/hooks/pre-push +6 -0
- package/package.json +1 -1
- package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +2 -2
- package/scripts/harness/integrations/pre-commit.ts +251 -3
- package/scripts/harness/main.ts +23 -0
- package/scripts/harness/quick-mode/domain/value-objects/validator-relaxation-profile.ts +4 -3
- package/scripts/harness/quick-mode/infrastructure/adapters/harness-config-quick-mode-config-adapter.ts +2 -1
- package/scripts/harness/quick-mode/infrastructure/adapters/validator-system-validator-id-registry-adapter.ts +2 -1
- package/scripts/harness/setup/skill-deployer.ts +19 -0
- package/scripts/harness/traceability-model/application/usecases/apply-work-item-status-usecase.ts +29 -4
- package/scripts/harness/traceability-model/domain/services/work-item-status-derivation-service.ts +20 -4
- package/scripts/harness/traceability-model/domain/value-objects/work-item-status-report.ts +10 -1
- package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-status-gateway.ts +2 -1
- package/scripts/harness/traceability-model/presentation/cli/work-item-status-command-handler.ts +13 -4
- package/scripts/harness/validator-system/application/mappers/validation-result-contract-mapper.ts +8 -4
- package/scripts/harness/validator-system/application/use-cases/run-l2-validators-usecase.ts +28 -0
- package/scripts/harness/validator-system/composition-root.ts +5 -1
- package/scripts/harness/validator-system/domain/ports/work-item-status-policy-port.ts +11 -0
- package/scripts/harness/validator-system/domain/value-objects/validator-id.ts +2 -0
- package/scripts/harness/validator-system/infrastructure/adapters/harness-config-validator-config-adapter.ts +1 -1
- package/scripts/harness/validator-system/infrastructure/adapters/traceability-work-item-status-policy-adapter.ts +52 -0
- package/templates/.husky/pre-push +1 -0
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 が作る):
|
|
@@ -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
|
|
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.
|
|
@@ -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`
|
|
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
|
|
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
|
|
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
|
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
#
|
|
6
6
|
# 実行タイミング: Pull Request(open / synchronize / reopen)
|
|
7
7
|
# 実行内容:
|
|
8
|
-
# 1.
|
|
9
|
-
# 2. harness:
|
|
10
|
-
# 3.
|
|
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
|
}
|
package/package.json
CHANGED
package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts
CHANGED
|
@@ -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-
|
|
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
|
|
package/scripts/harness/main.ts
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
deployHookScripts,
|
|
47
47
|
deployHuskyCommitMsgHook,
|
|
48
48
|
deployHuskyHook,
|
|
49
|
+
deployHuskyPrePushHook,
|
|
49
50
|
deploySkills,
|
|
50
51
|
getCategoryForSkill,
|
|
51
52
|
getDeployedVersion,
|
|
@@ -154,6 +155,7 @@ Commands:
|
|
|
154
155
|
hook <pre-tool-use|post-tool-use|stop|session-start|user-prompt-submit> Run agent hook (reads JSON from stdin; writes JSON to stdout for session-start/user-prompt-submit)
|
|
155
156
|
pre-commit Run L2 pre-commit validators on staged files
|
|
156
157
|
commit-msg <message-file> Validate commit message trailers against staged files
|
|
158
|
+
bypass:audit --base <ref> [--head <ref>] Audit bypass evidence for a push/CI commit range
|
|
157
159
|
delegate-sonnet [...args] Delegate task to Sonnet 4.6 (forwards args to scripts/delegate-sonnet.sh)
|
|
158
160
|
|
|
159
161
|
Skills:
|
|
@@ -316,6 +318,8 @@ Options:
|
|
|
316
318
|
--apply Update only the status line in each stale description.md frontmatter.
|
|
317
319
|
--id <WI-XXX> Limit report/apply to one work item.
|
|
318
320
|
--fail-on-stale Return exit code 1 when dry-run finds stale status.
|
|
321
|
+
--allow-downgrade Allow apply to lower a frontmatter status.
|
|
322
|
+
--changed-only Reserve apply scope for changed WI files; currently accepted as a no-op policy flag.
|
|
319
323
|
--json Output machine-readable JSON.
|
|
320
324
|
--help, -h Show this help`,
|
|
321
325
|
"phasegate:detect-drift": `Usage: phasegate phasegate:detect-drift [options]
|
|
@@ -716,6 +720,7 @@ async function main(): Promise<void> {
|
|
|
716
720
|
const withHusky = hasFlag(args, "--with-husky");
|
|
717
721
|
const huskyResult = withHusky ? await deployHuskyHook(harnessRoot, rootDir) : null;
|
|
718
722
|
const huskyCommitMsgResult = withHusky ? await deployHuskyCommitMsgHook(harnessRoot, rootDir) : null;
|
|
723
|
+
const huskyPrePushResult = withHusky ? await deployHuskyPrePushHook(harnessRoot, rootDir) : null;
|
|
719
724
|
const ciWorkflowResult = withCi ? await deployCiWorkflows(harnessRoot, rootDir) : null;
|
|
720
725
|
console.log(
|
|
721
726
|
`✓ Skills deployed to ${result.targetDir} (${result.deployedSkills.length} skills, set: ${skillSet})`,
|
|
@@ -781,6 +786,13 @@ async function main(): Promise<void> {
|
|
|
781
786
|
console.log(` .husky/commit-msg already exists, skipped`);
|
|
782
787
|
}
|
|
783
788
|
}
|
|
789
|
+
if (huskyPrePushResult !== null) {
|
|
790
|
+
if (huskyPrePushResult.created) {
|
|
791
|
+
console.log(`✓ .husky/pre-push deployed`);
|
|
792
|
+
} else {
|
|
793
|
+
console.log(` .husky/pre-push already exists, skipped`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
784
796
|
if (ciWorkflowResult !== null) {
|
|
785
797
|
if (ciWorkflowResult.copiedFiles.length > 0) {
|
|
786
798
|
console.log(`✓ CI workflows deployed (${ciWorkflowResult.copiedFiles.length} files)`);
|
|
@@ -983,6 +995,8 @@ async function main(): Promise<void> {
|
|
|
983
995
|
dryRun: hasFlag(args, "--dry-run"),
|
|
984
996
|
apply: hasFlag(args, "--apply"),
|
|
985
997
|
failOnStale: hasFlag(args, "--fail-on-stale"),
|
|
998
|
+
allowDowngrade: hasFlag(args, "--allow-downgrade"),
|
|
999
|
+
changedOnly: hasFlag(args, "--changed-only"),
|
|
986
1000
|
id: parseFlag(args, "--id"),
|
|
987
1001
|
json,
|
|
988
1002
|
});
|
|
@@ -1583,6 +1597,15 @@ Examples:
|
|
|
1583
1597
|
break;
|
|
1584
1598
|
}
|
|
1585
1599
|
|
|
1600
|
+
case "bypass:audit": {
|
|
1601
|
+
const preCommitPath = join(harnessRoot, "scripts/harness/integrations/pre-commit.js");
|
|
1602
|
+
const preCommitMod = (await import(preCommitPath)) as {
|
|
1603
|
+
runBypassAuditCli: (args: readonly string[]) => Promise<void>;
|
|
1604
|
+
};
|
|
1605
|
+
await preCommitMod.runBypassAuditCli(args.slice(1));
|
|
1606
|
+
break;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1586
1609
|
case "delegate-sonnet": {
|
|
1587
1610
|
const { spawn } = await import("node:child_process");
|
|
1588
1611
|
const scriptPath = join(harnessRoot, "scripts/delegate-sonnet.sh");
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @layer domain
|
|
3
3
|
* @unit quick-mode
|
|
4
|
+
* @work-item-id WI-140
|
|
4
5
|
*
|
|
5
6
|
* Quick Mode時のバリデータ実行構成を表す値オブジェクト
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
const L2_IDS = ['L2-001', 'L2-002', 'L2-003'] as const;
|
|
9
|
+
const L2_IDS = ['L2-001', 'L2-002', 'L2-003', 'L2-014'] as const;
|
|
9
10
|
const L3_IDS = ['L3-001', 'L3-002', 'L3-003', 'L3-004'] as const;
|
|
10
11
|
|
|
11
12
|
type L2Id = (typeof L2_IDS)[number];
|
|
@@ -48,7 +49,7 @@ export class ValidatorRelaxationProfile {
|
|
|
48
49
|
return new ValidatorRelaxationProfile({
|
|
49
50
|
levelDependencyRelaxed: false,
|
|
50
51
|
l1: { all: true },
|
|
51
|
-
l2: { maintained: ['L2-002', 'L2-003'], skipped: ['L2-001'] },
|
|
52
|
+
l2: { maintained: ['L2-002', 'L2-003', 'L2-014'], skipped: ['L2-001'] },
|
|
52
53
|
l3: { maintained: ['L3-001'], skipped: ['L3-002', 'L3-003', 'L3-004'] },
|
|
53
54
|
l4: { all: false },
|
|
54
55
|
phaseExecution: { twoPhaseRequired: false },
|
|
@@ -65,7 +66,7 @@ export class ValidatorRelaxationProfile {
|
|
|
65
66
|
}): ValidatorRelaxationProfile {
|
|
66
67
|
const { l2, l3 } = params;
|
|
67
68
|
|
|
68
|
-
// INV-P5: l2.maintained ∪ l2.skipped = {L2-001, L2-002, L2-003}
|
|
69
|
+
// INV-P5: l2.maintained ∪ l2.skipped = {L2-001, L2-002, L2-003, L2-014}
|
|
69
70
|
const l2Union = [...l2.maintained, ...l2.skipped].sort();
|
|
70
71
|
const l2Expected = [...L2_IDS].sort();
|
|
71
72
|
if (JSON.stringify(l2Union) !== JSON.stringify(l2Expected)) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @layer infrastructure
|
|
3
3
|
* @unit quick-mode
|
|
4
|
+
* @work-item-id WI-140
|
|
4
5
|
*
|
|
5
6
|
* phasegate.config.json から QuickModeConfig を取得する Adapter
|
|
6
7
|
*/
|
|
@@ -25,7 +26,7 @@ export class HarnessConfigParseError extends Error {
|
|
|
25
26
|
|
|
26
27
|
const DEFAULT_QUICK_MODE_CONFIG = {
|
|
27
28
|
allowedCategories: ['bugfix', 'docs', 'test', 'config'],
|
|
28
|
-
maintainedLayers: ['L1', 'L2-002', 'L2-003', 'L3-001'],
|
|
29
|
+
maintainedLayers: ['L1', 'L2-002', 'L2-003', 'L2-014', 'L3-001'],
|
|
29
30
|
relaxedGates: ['L2-001', 'L3-002', 'L3-003', 'L3-004', 'L4'],
|
|
30
31
|
};
|
|
31
32
|
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @layer infrastructure
|
|
3
3
|
* @unit quick-mode
|
|
4
|
+
* @work-item-id WI-140
|
|
4
5
|
*
|
|
5
6
|
* integration_contract.md §9 の確定ID一覧を静的定義で保持する ValidatorIdRegistry Adapter
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
const STATIC_VALIDATOR_IDS: readonly string[] = Object.freeze([
|
|
9
10
|
'L1-001', 'L1-002', 'L1-003', 'L1-004', 'L1-005', 'L1-006', 'L1-007', 'L1-008',
|
|
10
|
-
'L2-001', 'L2-002', 'L2-003',
|
|
11
|
+
'L2-001', 'L2-002', 'L2-003', 'L2-014',
|
|
11
12
|
'L3-001', 'L3-002', 'L3-003', 'L3-004',
|
|
12
13
|
'L4-001', 'L4-002', 'L4-003', 'L4-004', 'L4-005',
|
|
13
14
|
]);
|
|
@@ -617,6 +617,25 @@ export async function deployHuskyCommitMsgHook(
|
|
|
617
617
|
return { created: true, path: targetPath };
|
|
618
618
|
}
|
|
619
619
|
|
|
620
|
+
export async function deployHuskyPrePushHook(
|
|
621
|
+
harnessRoot: string,
|
|
622
|
+
projectRoot: string,
|
|
623
|
+
): Promise<DeployHuskyHookResult> {
|
|
624
|
+
const targetPath = join(projectRoot, ".husky", "pre-push");
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
await fs.access(targetPath);
|
|
628
|
+
return { created: false, path: targetPath };
|
|
629
|
+
} catch {}
|
|
630
|
+
|
|
631
|
+
const sourcePath = join(harnessRoot, "docs", "templates", "hooks", "pre-push");
|
|
632
|
+
await fs.mkdir(join(projectRoot, ".husky"), { recursive: true });
|
|
633
|
+
await fs.copyFile(sourcePath, targetPath);
|
|
634
|
+
await fs.chmod(targetPath, 0o755);
|
|
635
|
+
|
|
636
|
+
return { created: true, path: targetPath };
|
|
637
|
+
}
|
|
638
|
+
|
|
620
639
|
export interface DeployCodexHooksResult {
|
|
621
640
|
created: boolean;
|
|
622
641
|
path: string;
|
package/scripts/harness/traceability-model/application/usecases/apply-work-item-status-usecase.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// @unit traceability-model
|
|
2
2
|
// @layer application
|
|
3
|
-
// @work-item-id WI-126
|
|
3
|
+
// @work-item-id WI-126 / WI-140
|
|
4
4
|
|
|
5
5
|
import type { WorkItemStatusPort } from "../../domain/ports/work-item-status-port.js";
|
|
6
|
-
import type { WorkItemStatusApplyResult } from "../../domain/value-objects/work-item-status-report.js";
|
|
6
|
+
import type { WorkItemStatusApplyResult, WorkItemStatusReport } from "../../domain/value-objects/work-item-status-report.js";
|
|
7
7
|
import type { DeriveWorkItemStatusUseCase } from "./derive-work-item-status-usecase.js";
|
|
8
|
+
import type { WorkItemStatus } from "../../domain/value-objects/work-item-frontmatter.js";
|
|
8
9
|
|
|
9
10
|
export interface ApplyWorkItemStatusUseCaseDeps {
|
|
10
11
|
readonly deriveWorkItemStatusUseCase: Pick<DeriveWorkItemStatusUseCase, "execute">;
|
|
@@ -20,11 +21,35 @@ export class ApplyWorkItemStatusUseCase {
|
|
|
20
21
|
this.workItemStatusPort = deps.workItemStatusPort;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
async execute(input: { readonly id?: string } = {}): Promise<WorkItemStatusApplyResult> {
|
|
24
|
+
async execute(input: { readonly id?: string; readonly allowDowngrade?: boolean; readonly changedOnly?: boolean } = {}): Promise<WorkItemStatusApplyResult> {
|
|
24
25
|
const reports = await this.deriveWorkItemStatusUseCase.execute();
|
|
25
26
|
const targetReports = input.id
|
|
26
27
|
? reports.filter((report) => report.id === input.id)
|
|
27
28
|
: reports;
|
|
28
|
-
|
|
29
|
+
const blocked = input.allowDowngrade ? [] : targetReports.filter((report) => this.isDowngrade(report));
|
|
30
|
+
const allowed = input.allowDowngrade ? targetReports : targetReports.filter((report) => !this.isDowngrade(report));
|
|
31
|
+
const result = await this.workItemStatusPort.applyDerivedStatuses(allowed);
|
|
32
|
+
return Object.freeze({
|
|
33
|
+
updated: result.updated,
|
|
34
|
+
unchanged: result.unchanged,
|
|
35
|
+
blocked: Object.freeze([...result.blocked, ...blocked]),
|
|
36
|
+
});
|
|
29
37
|
}
|
|
38
|
+
|
|
39
|
+
private isDowngrade(report: WorkItemStatusReport): boolean {
|
|
40
|
+
if (!report.stale) return false;
|
|
41
|
+
return statusOrder(report.derivedStatus) < statusOrder(report.currentStatus);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const STATUS_ORDER: Record<WorkItemStatus, number> = {
|
|
46
|
+
drafted: 0,
|
|
47
|
+
reflected: 1,
|
|
48
|
+
implemented: 2,
|
|
49
|
+
tested: 3,
|
|
50
|
+
completed: 3,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function statusOrder(status: WorkItemStatus): number {
|
|
54
|
+
return STATUS_ORDER[status];
|
|
30
55
|
}
|
package/scripts/harness/traceability-model/domain/services/work-item-status-derivation-service.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @unit traceability-model
|
|
2
2
|
// @layer domain
|
|
3
|
-
// @work-item-id WI-126
|
|
3
|
+
// @work-item-id WI-126 / WI-140
|
|
4
4
|
|
|
5
5
|
import type { WorkItemStatus } from "../value-objects/work-item-frontmatter.js";
|
|
6
6
|
import type {
|
|
@@ -34,10 +34,18 @@ export class WorkItemStatusDerivationService {
|
|
|
34
34
|
nextAction,
|
|
35
35
|
evidence: Object.freeze({
|
|
36
36
|
hasRequiredInceptionArtifacts: this.hasRequiredInceptionArtifacts(input),
|
|
37
|
+
missingInceptionArtifacts: Object.freeze([...this.missingInceptionArtifacts(input)]),
|
|
37
38
|
reflectedUnits: Object.freeze([...this.reflectedUnits(input)]),
|
|
38
39
|
missingReflectionUnits: Object.freeze([...this.missingReflectionUnits(input)]),
|
|
39
40
|
implementationPaths: Object.freeze([...input.implementationPaths]),
|
|
40
41
|
testPaths: Object.freeze([...input.testPaths]),
|
|
42
|
+
missingImplementation: this.missingImplementation(input),
|
|
43
|
+
missingTests: this.missingTests(input),
|
|
44
|
+
validation: Object.freeze({
|
|
45
|
+
state: "not-run" as const,
|
|
46
|
+
source: "work-items:status",
|
|
47
|
+
blockingValidation: Object.freeze([]),
|
|
48
|
+
}),
|
|
41
49
|
}),
|
|
42
50
|
});
|
|
43
51
|
}
|
|
@@ -56,10 +64,10 @@ export class WorkItemStatusDerivationService {
|
|
|
56
64
|
return "drafted";
|
|
57
65
|
}
|
|
58
66
|
|
|
67
|
+
if (!reflected) return "drafted";
|
|
68
|
+
if (!implemented) return "reflected";
|
|
59
69
|
if (tested) return "tested";
|
|
60
|
-
|
|
61
|
-
if (reflected) return "reflected";
|
|
62
|
-
return "drafted";
|
|
70
|
+
return "implemented";
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
private reasonFor(input: WorkItemStatusInput, status: WorkItemStatus): string {
|
|
@@ -114,6 +122,14 @@ export class WorkItemStatusDerivationService {
|
|
|
114
122
|
return input.affectedUnits.filter((unit) => !reflected.has(unit));
|
|
115
123
|
}
|
|
116
124
|
|
|
125
|
+
private missingImplementation(input: WorkItemStatusInput): boolean {
|
|
126
|
+
return input.frontmatter.type !== "chore" && this.missingReflectionUnits(input).length === 0 && input.implementationPaths.length === 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private missingTests(input: WorkItemStatusInput): boolean {
|
|
130
|
+
return input.frontmatter.type !== "chore" && input.frontmatter.type !== "fix" && input.implementationPaths.length > 0 && input.testPaths.length === 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
117
133
|
private extractConstructionUnit(filePath: string): string | null {
|
|
118
134
|
const match = /^docs\/product\/construction\/([^/]+)\//.exec(filePath);
|
|
119
135
|
return match?.[1] ?? null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @unit traceability-model
|
|
2
2
|
// @layer domain
|
|
3
|
-
// @work-item-id WI-126
|
|
3
|
+
// @work-item-id WI-126 / WI-140
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
6
|
WorkItemFrontmatter,
|
|
@@ -10,10 +10,18 @@ import type {
|
|
|
10
10
|
|
|
11
11
|
export interface WorkItemStatusEvidence {
|
|
12
12
|
readonly hasRequiredInceptionArtifacts: boolean;
|
|
13
|
+
readonly missingInceptionArtifacts: readonly string[];
|
|
13
14
|
readonly reflectedUnits: readonly string[];
|
|
14
15
|
readonly missingReflectionUnits: readonly string[];
|
|
15
16
|
readonly implementationPaths: readonly string[];
|
|
16
17
|
readonly testPaths: readonly string[];
|
|
18
|
+
readonly missingImplementation: boolean;
|
|
19
|
+
readonly missingTests: boolean;
|
|
20
|
+
readonly validation: {
|
|
21
|
+
readonly state: "passed" | "failed" | "not-run";
|
|
22
|
+
readonly source: string;
|
|
23
|
+
readonly blockingValidation: readonly string[];
|
|
24
|
+
};
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
export interface WorkItemStatusInput {
|
|
@@ -44,4 +52,5 @@ export interface WorkItemStatusReport {
|
|
|
44
52
|
export interface WorkItemStatusApplyResult {
|
|
45
53
|
readonly updated: readonly WorkItemStatusReport[];
|
|
46
54
|
readonly unchanged: readonly WorkItemStatusReport[];
|
|
55
|
+
readonly blocked: readonly WorkItemStatusReport[];
|
|
47
56
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @unit traceability-model
|
|
2
2
|
// @layer infrastructure
|
|
3
|
-
// @work-item-id WI-126
|
|
3
|
+
// @work-item-id WI-126 / WI-140
|
|
4
4
|
|
|
5
5
|
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
6
6
|
import * as path from "node:path";
|
|
@@ -97,6 +97,7 @@ export class FileSystemWorkItemStatusGateway implements WorkItemStatusPort {
|
|
|
97
97
|
return Object.freeze({
|
|
98
98
|
updated: Object.freeze(updated),
|
|
99
99
|
unchanged: Object.freeze(unchanged),
|
|
100
|
+
blocked: Object.freeze([]),
|
|
100
101
|
});
|
|
101
102
|
}
|
|
102
103
|
|
package/scripts/harness/traceability-model/presentation/cli/work-item-status-command-handler.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @unit traceability-model
|
|
2
2
|
// @layer presentation
|
|
3
|
-
// @work-item-id WI-126
|
|
3
|
+
// @work-item-id WI-126 / WI-140
|
|
4
4
|
|
|
5
5
|
import type { ApplyWorkItemStatusUseCase } from "../../application/usecases/apply-work-item-status-usecase.js";
|
|
6
6
|
import type { DeriveWorkItemStatusUseCase } from "../../application/usecases/derive-work-item-status-usecase.js";
|
|
@@ -14,6 +14,8 @@ export interface WorkItemStatusCommandInput {
|
|
|
14
14
|
readonly apply?: boolean;
|
|
15
15
|
readonly json?: boolean;
|
|
16
16
|
readonly failOnStale?: boolean;
|
|
17
|
+
readonly allowDowngrade?: boolean;
|
|
18
|
+
readonly changedOnly?: boolean;
|
|
17
19
|
readonly id?: string;
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -54,10 +56,14 @@ export class WorkItemStatusCommandHandler {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
if (input.apply) {
|
|
57
|
-
const result = await this.applyWorkItemStatusUseCase.execute({
|
|
58
|
-
|
|
59
|
+
const result = await this.applyWorkItemStatusUseCase.execute({
|
|
60
|
+
id: input.id,
|
|
61
|
+
allowDowngrade: input.allowDowngrade,
|
|
62
|
+
changedOnly: input.changedOnly,
|
|
63
|
+
});
|
|
64
|
+
const reports = Object.freeze([...result.updated, ...result.unchanged, ...result.blocked]);
|
|
59
65
|
return Object.freeze({
|
|
60
|
-
exitCode: 0,
|
|
66
|
+
exitCode: result.blocked.length > 0 ? 1 : 0,
|
|
61
67
|
text: input.json ? JSON.stringify(result, null, 2) : this.formatApply(result),
|
|
62
68
|
reports,
|
|
63
69
|
});
|
|
@@ -97,6 +103,9 @@ export class WorkItemStatusCommandHandler {
|
|
|
97
103
|
if (result.updated.length === 0) {
|
|
98
104
|
lines.push("no updates required");
|
|
99
105
|
}
|
|
106
|
+
for (const report of result.blocked) {
|
|
107
|
+
lines.push(`blocked ${report.id}: ${report.currentStatus} -> ${report.derivedStatus} requires --allow-downgrade`);
|
|
108
|
+
}
|
|
100
109
|
lines.push(`unchanged: ${result.unchanged.length}`);
|
|
101
110
|
return lines.join("\n");
|
|
102
111
|
}
|
package/scripts/harness/validator-system/application/mappers/validation-result-contract-mapper.ts
CHANGED
|
@@ -12,12 +12,16 @@ export class ValidationResultContractMapper {
|
|
|
12
12
|
return {
|
|
13
13
|
validatorId: result.validatorId.value,
|
|
14
14
|
passed: result.passed,
|
|
15
|
-
errors: result.errors.map((e) =>
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
errors: result.errors.map((e) => {
|
|
16
|
+
const { code, severity, message, suggestion, ...details } = e;
|
|
17
|
+
return {
|
|
18
|
+
...details,
|
|
19
|
+
code: typeof code === 'string' ? code : code.toString(),
|
|
20
|
+
severity: typeof severity === 'string' ? severity : severity.toString(),
|
|
18
21
|
message: e.message,
|
|
19
22
|
suggestion: e.suggestion,
|
|
20
|
-
|
|
23
|
+
};
|
|
24
|
+
}),
|
|
21
25
|
durationMs: result.durationMs,
|
|
22
26
|
skipped: result.skipped,
|
|
23
27
|
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* @unit validator-system
|
|
4
4
|
*
|
|
5
5
|
* RunL2ValidatorsUseCase — H08-01: L2バリデータ実行
|
|
6
|
+
* @work-item-id WI-140
|
|
6
7
|
*/
|
|
7
8
|
import { readFile } from 'node:fs/promises';
|
|
8
9
|
import { ValidatorId, InvalidValidatorIdError } from '../../domain/value-objects/validator-id.js';
|
|
@@ -19,6 +20,7 @@ import type { TestQualityAnalyzerPort } from '../../domain/ports/test-quality-an
|
|
|
19
20
|
import type { CliCommandRegistryPort } from '../../domain/ports/cli-command-registry-port.js';
|
|
20
21
|
import type { E2eTestFileRegistryPort } from '../../domain/ports/e2e-test-file-registry-port.js';
|
|
21
22
|
import { CliE2eTestExistenceService } from '../../domain/services/cli-e2e-test-existence-service.js';
|
|
23
|
+
import type { WorkItemStatusPolicyPort } from '../../domain/ports/work-item-status-policy-port.js';
|
|
22
24
|
|
|
23
25
|
export interface RunL2ValidatorsUseCaseDeps {
|
|
24
26
|
validatorRegistry: ValidatorRegistry;
|
|
@@ -30,6 +32,7 @@ export interface RunL2ValidatorsUseCaseDeps {
|
|
|
30
32
|
testQualityAnalyzerPort?: TestQualityAnalyzerPort;
|
|
31
33
|
cliCommandRegistryPort?: CliCommandRegistryPort;
|
|
32
34
|
e2eTestFileRegistryPort?: E2eTestFileRegistryPort;
|
|
35
|
+
workItemStatusPolicyPort?: WorkItemStatusPolicyPort;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export class RunL2ValidatorsUseCase {
|
|
@@ -42,6 +45,7 @@ export class RunL2ValidatorsUseCase {
|
|
|
42
45
|
private readonly testQualityAnalyzerPort?: TestQualityAnalyzerPort;
|
|
43
46
|
private readonly cliCommandRegistryPort?: CliCommandRegistryPort;
|
|
44
47
|
private readonly e2eTestFileRegistryPort?: E2eTestFileRegistryPort;
|
|
48
|
+
private readonly workItemStatusPolicyPort?: WorkItemStatusPolicyPort;
|
|
45
49
|
private readonly cliE2eTestExistenceService = new CliE2eTestExistenceService();
|
|
46
50
|
|
|
47
51
|
constructor(deps: RunL2ValidatorsUseCaseDeps) {
|
|
@@ -54,6 +58,7 @@ export class RunL2ValidatorsUseCase {
|
|
|
54
58
|
this.testQualityAnalyzerPort = deps.testQualityAnalyzerPort;
|
|
55
59
|
this.cliCommandRegistryPort = deps.cliCommandRegistryPort;
|
|
56
60
|
this.e2eTestFileRegistryPort = deps.e2eTestFileRegistryPort;
|
|
61
|
+
this.workItemStatusPolicyPort = deps.workItemStatusPolicyPort;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
async execute(input: RunL2ValidatorsInput): Promise<readonly ValidationResultContract[]> {
|
|
@@ -152,6 +157,29 @@ export class RunL2ValidatorsUseCase {
|
|
|
152
157
|
}
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
if (this.workItemStatusPolicyPort) {
|
|
161
|
+
const l2014Result = overrideMap.get('L2-014');
|
|
162
|
+
if (l2014Result && !l2014Result.skipped) {
|
|
163
|
+
const staleReports = await this.workItemStatusPolicyPort.findStaleReports(input.targetPaths);
|
|
164
|
+
if (staleReports.length > 0) {
|
|
165
|
+
const errors = staleReports.map((report) => ({
|
|
166
|
+
code: { value: 'L2-014', toString: () => 'L2-014' },
|
|
167
|
+
severity: { value: 'error', toString: () => 'error' },
|
|
168
|
+
message: `${report.id} status is stale: current=${report.currentStatus}, derived=${report.derivedStatus}`,
|
|
169
|
+
suggestion: report.nextAction,
|
|
170
|
+
workItemId: report.id,
|
|
171
|
+
descriptionPath: report.descriptionPath,
|
|
172
|
+
currentStatus: report.currentStatus,
|
|
173
|
+
derivedStatus: report.derivedStatus,
|
|
174
|
+
evidence: report.evidence,
|
|
175
|
+
}));
|
|
176
|
+
overrideMap.set('L2-014', ValidationResult.fail(ValidatorId.create('L2-014'), errors, 0));
|
|
177
|
+
} else {
|
|
178
|
+
overrideMap.set('L2-014', ValidationResult.pass(ValidatorId.create('L2-014'), 0));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
155
183
|
const finalResults = definitions.map(
|
|
156
184
|
(definition) => overrideMap.get(definition.validatorId.value) ?? ValidationResult.skip(definition.validatorId),
|
|
157
185
|
);
|
|
@@ -22,6 +22,7 @@ import { ItTestFileAnalyzerAdapter } from './infrastructure/adapters/it-test-fil
|
|
|
22
22
|
import { SourceFileTextScannerAdapter } from './infrastructure/adapters/source-file-text-scanner-adapter.js';
|
|
23
23
|
import { E2eTestFileRegistryAdapter } from './infrastructure/adapters/e2e-test-file-registry-adapter.js';
|
|
24
24
|
import { CliCommandRegistryAdapter } from './infrastructure/adapters/cli-command-registry-adapter.js';
|
|
25
|
+
import { TraceabilityWorkItemStatusPolicyAdapter } from './infrastructure/adapters/traceability-work-item-status-policy-adapter.js';
|
|
25
26
|
import { PhaseDependencyPhaseGatePolicyAdapter } from './infrastructure/adapters/phase-dependency-phase-gate-policy-adapter.js';
|
|
26
27
|
import { TraceabilityMetadataPolicyAdapter } from './infrastructure/adapters/traceability-metadata-policy-adapter.js';
|
|
27
28
|
import { NyquistAcCoveragePolicyAdapter } from './infrastructure/adapters/nyquist-ac-coverage-policy-adapter.js';
|
|
@@ -45,7 +46,7 @@ import { join } from 'node:path';
|
|
|
45
46
|
const DEFAULT_CONFIG = {
|
|
46
47
|
preset: 'standard' as const,
|
|
47
48
|
layers: {
|
|
48
|
-
L2: { enabled: true, validators: ['L2-001', 'L2-002', 'L2-003', 'L2-013'] },
|
|
49
|
+
L2: { enabled: true, validators: ['L2-001', 'L2-002', 'L2-003', 'L2-013', 'L2-014'] },
|
|
49
50
|
L3: { enabled: true, validators: ['L3-001', 'L3-002', 'L3-003', 'L3-004'], coverageThreshold: 90, bundleSizeLimit: 512000 },
|
|
50
51
|
L4: { enabled: true, validators: ['L4-001', 'L4-002', 'L4-003', 'L4-004', 'L4-005'] },
|
|
51
52
|
},
|
|
@@ -78,6 +79,7 @@ function buildDefaultRegistry(): ValidatorRegistry {
|
|
|
78
79
|
createDef('L2-002', 'L2', 'always', 'MetadataPolicyPort'),
|
|
79
80
|
createDef('L2-003', 'L2', 'always'),
|
|
80
81
|
createDef('L2-013', 'L2', 'always', 'CliE2eTestExistenceService'),
|
|
82
|
+
createDef('L2-014', 'L2', 'always', 'WorkItemStatusPolicyPort'),
|
|
81
83
|
createDef('L3-001', 'L3', 'always'),
|
|
82
84
|
createDef('L3-002', 'L3', 'strictOnly'),
|
|
83
85
|
createDef('L3-003', 'L3', 'always'),
|
|
@@ -123,6 +125,7 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
|
|
|
123
125
|
const testQualityAnalyzerPort = new BiomeAstTestQualityAnalyzerAdapter();
|
|
124
126
|
const securityScannerPort = new FileSystemSecurityPatternScannerAdapter();
|
|
125
127
|
const performanceScannerPort = new AstPerformanceScannerAdapter();
|
|
128
|
+
const workItemStatusPolicyPort = new TraceabilityWorkItemStatusPolicyAdapter(process.cwd());
|
|
126
129
|
|
|
127
130
|
const docsRoot = join(process.cwd(), 'docs/product/construction');
|
|
128
131
|
const cwd = process.cwd();
|
|
@@ -146,6 +149,7 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
|
|
|
146
149
|
testQualityAnalyzerPort,
|
|
147
150
|
e2eTestFileRegistryPort,
|
|
148
151
|
cliCommandRegistryPort,
|
|
152
|
+
workItemStatusPolicyPort,
|
|
149
153
|
});
|
|
150
154
|
|
|
151
155
|
const runL3ValidatorsUseCase = new RunL3ValidatorsUseCase({
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer domain
|
|
3
|
+
* @unit validator-system
|
|
4
|
+
* @work-item-id WI-140
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { WorkItemStatusReport } from "../../../traceability-model/domain/value-objects/work-item-status-report.js";
|
|
8
|
+
|
|
9
|
+
export interface WorkItemStatusPolicyPort {
|
|
10
|
+
findStaleReports(targetPaths?: readonly string[]): Promise<readonly WorkItemStatusReport[]>;
|
|
11
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* ValidatorId 値オブジェクト
|
|
6
6
|
* L1-001〜L4-005 のバリデータを識別する不変値オブジェクト
|
|
7
7
|
* Wave 2A で L1-017, L1-018, L2-013 を追加
|
|
8
|
+
* WI-140 で L2-014 を追加
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
export class InvalidValidatorIdError extends Error {
|
|
@@ -26,6 +27,7 @@ const VALIDATOR_NAME_MAP: Record<string, string> = {
|
|
|
26
27
|
'L2-002': 'metadata',
|
|
27
28
|
'L2-003': 'test-quality',
|
|
28
29
|
'L2-013': 'cli-e2e-test-existence',
|
|
30
|
+
'L2-014': 'work-item-status-staleness',
|
|
29
31
|
'L3-001': 'security',
|
|
30
32
|
'L3-002': 'performance',
|
|
31
33
|
'L3-003': 'coverage',
|
|
@@ -38,7 +38,7 @@ export class HarnessConfigValidatorConfigAdapter implements ValidatorConfigPort
|
|
|
38
38
|
const layerData = this.config.layers?.[layer] ?? {};
|
|
39
39
|
|
|
40
40
|
const defaultValidators: Record<string, string[]> = {
|
|
41
|
-
L2: ['L2-001', 'L2-002', 'L2-003', 'L2-013'],
|
|
41
|
+
L2: ['L2-001', 'L2-002', 'L2-003', 'L2-013', 'L2-014'],
|
|
42
42
|
L3: ['L3-001', 'L3-002', 'L3-003', 'L3-004'],
|
|
43
43
|
L4: ['L4-001', 'L4-002', 'L4-003', 'L4-004', 'L4-005'],
|
|
44
44
|
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer infrastructure
|
|
3
|
+
* @unit validator-system
|
|
4
|
+
* @work-item-id WI-140
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createTraceabilityModelModule } from "../../../traceability-model/composition-root.js";
|
|
8
|
+
import type { WorkItemStatusPolicyPort } from "../../domain/ports/work-item-status-policy-port.js";
|
|
9
|
+
import type { WorkItemStatusReport } from "../../../traceability-model/domain/value-objects/work-item-status-report.js";
|
|
10
|
+
|
|
11
|
+
export class TraceabilityWorkItemStatusPolicyAdapter implements WorkItemStatusPolicyPort {
|
|
12
|
+
private readonly rootDir: string;
|
|
13
|
+
|
|
14
|
+
constructor(rootDir: string) {
|
|
15
|
+
this.rootDir = rootDir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async findStaleReports(targetPaths: readonly string[] = []): Promise<readonly WorkItemStatusReport[]> {
|
|
19
|
+
if (targetPaths.length === 0) return Object.freeze([]);
|
|
20
|
+
|
|
21
|
+
const traceability = createTraceabilityModelModule(this.rootDir);
|
|
22
|
+
const output = await traceability.workItemStatusCommandHandler.execute({ dryRun: true });
|
|
23
|
+
const staleReports = output.reports.filter(
|
|
24
|
+
(report) => report.stale && report.evidence.hasRequiredInceptionArtifacts,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const normalizedTargets = targetPaths.map((targetPath) => targetPath.replace(/^\.\//, ""));
|
|
28
|
+
const explicitlyTargetedWorkItems = this.extractTargetedWorkItemIds(normalizedTargets);
|
|
29
|
+
if (explicitlyTargetedWorkItems.size > 0) {
|
|
30
|
+
return Object.freeze(staleReports.filter((report) => explicitlyTargetedWorkItems.has(report.id)));
|
|
31
|
+
}
|
|
32
|
+
return Object.freeze(staleReports.filter((report) => this.matchesAnyTarget(report, normalizedTargets)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private extractTargetedWorkItemIds(targetPaths: readonly string[]): ReadonlySet<string> {
|
|
36
|
+
const ids = new Set<string>();
|
|
37
|
+
for (const targetPath of targetPaths) {
|
|
38
|
+
const match = /^docs\/inception\/.+\/(WI-\d+)\/description\.md$/.exec(targetPath);
|
|
39
|
+
if (match) ids.add(match[1]);
|
|
40
|
+
}
|
|
41
|
+
return ids;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private matchesAnyTarget(report: WorkItemStatusReport, targetPaths: readonly string[]): boolean {
|
|
45
|
+
const evidencePaths = [
|
|
46
|
+
report.descriptionPath,
|
|
47
|
+
...report.evidence.implementationPaths,
|
|
48
|
+
...report.evidence.testPaths,
|
|
49
|
+
];
|
|
50
|
+
return targetPaths.some((targetPath) => evidencePaths.some((evidencePath) => evidencePath === targetPath));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
npx phasegate bypass:audit --base origin/main --head HEAD
|