phasegate 0.140.0 → 0.141.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 (26) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.ja.md +3 -2
  3. package/README.md +4 -4
  4. package/docs/guide/cli-reference.md +1 -1
  5. package/docs/guide/layer-model.md +6 -1
  6. package/package.json +1 -1
  7. package/scripts/harness/biome-ast-engine/infrastructure/adapters/harness-error-formatter-adapter.ts +15 -1
  8. package/scripts/harness/biome-ast-engine/infrastructure/adapters/typescript-source-module-analyzer-adapter.ts +31 -1
  9. package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +1 -1
  10. package/scripts/harness/harness-api/domain/services/command-dispatch-service.ts +39 -4
  11. package/scripts/harness/harness-api/domain/services/status-derivation-service.ts +28 -11
  12. package/scripts/harness/harness-api/domain/value-objects/ci-check-result.ts +5 -4
  13. package/scripts/harness/harness-api/domain/value-objects/drift-report-summary.ts +107 -8
  14. package/scripts/harness/harness-api/domain/value-objects/layer-health.ts +24 -2
  15. package/scripts/harness/harness-api/infrastructure/adapters/validator-system-execution-adapter.ts +2 -0
  16. package/scripts/harness/integrations/pre-commit.ts +7 -4
  17. package/scripts/harness/main.ts +8 -1
  18. package/scripts/harness/phase-dependency-model/infrastructure/filesystem/file-system-story-reflection-adapter.ts +47 -1
  19. package/scripts/harness/validator-system/application/use-cases/run-l1-validators-usecase.ts +1 -44
  20. package/scripts/harness/validator-system/application/use-cases/run-l2-validators-usecase.ts +30 -0
  21. package/scripts/harness/validator-system/application/use-cases/run-l4-validators-usecase.ts +1 -1
  22. package/scripts/harness/validator-system/composition-root.ts +16 -13
  23. package/scripts/harness/validator-system/domain/services/cli-e2e-test-existence-service.ts +36 -5
  24. package/scripts/harness/validator-system/domain/value-objects/cli-e2e-test-coverage-report.ts +8 -2
  25. package/scripts/harness/validator-system/infrastructure/adapters/e2e-test-file-registry-adapter.ts +12 -3
  26. package/scripts/harness/validator-system/infrastructure/adapters/harness-config-validator-config-adapter.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.141.0] - 2026-05-09
11
+
12
+ ### Fixed
13
+
14
+ - **WI-024 follow-up — metadataTags suggestion text** — `architecture.metadataTags.unit` / `architecture.metadataTags.layer` を使った L1-001 / L1-002 の `suggestion` も、固定の `@unit` / `@layer` ではなく設定タグ名を表示するようにした。
15
+
10
16
  ## [0.140.0] - 2026-05-09
11
17
 
12
18
  ### Added
package/README.ja.md CHANGED
@@ -148,7 +148,8 @@ npx phasegate update-skills # スキルを最新版に再デプロイ
148
148
  | security, performance, coverage 90%/95%, 要件カバレッジ |
149
149
  +------------------------------------------------------------------+
150
150
  | L4 週次 (default off) |
151
- | 設計-コード乖離, 文書整合性, デッドコード |
151
+ | 設計-コード乖離, 文書整合性, デッドコード, |
152
+ | doc-freshness, pointer-validation |
152
153
  +------------------------------------------------------------------+
153
154
  ```
154
155
 
@@ -455,7 +456,7 @@ reports/
455
456
 
456
457
  | Work Item | 内容 |
457
458
  |---|---|
458
- | **[WI-033](docs/inception/_cross/WI-033/description.md)** | `doc-freshness` / `pointer-validation` L4 validator に昇格 |
459
+ | **[WI-033](docs/inception/_cross/WI-033/description.md)** | L4 運用ロールアウトの仕上げ。`doc-freshness` / `pointer-validation` L4-004 / L4-005 として登録済みで、`p2:*` 互換コマンドも維持。残りは scheduling / default / 運用 docs の整理。@work-item-id WI-116 |
459
460
 
460
461
  L3 Nyquist Validation の `requirement-test-matrix.json` 自動生成はまだ未自動化です。現時点では手動セットアップで利用できます。
461
462
 
package/README.md CHANGED
@@ -156,7 +156,7 @@ npx phasegate update-skills
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 |
159
- | L4 | Scheduled (weekly). Currently `layers.L4.enabled: false` by default — opt-in per project | Design-code drift, cross-document consistency, dead code. Doc freshness and pointer checks are standalone `p2:*` commands until WI-033. |
159
+ | L4 | Scheduled (weekly). Currently `layers.L4.enabled: false` by default — opt-in per project | Design-code drift, cross-document consistency, dead code, doc freshness, and pointer validation. `p2:*` standalone commands remain available as compatibility entry points. |
160
160
 
161
161
  ---
162
162
 
@@ -466,8 +466,8 @@ npx phasegate <command> [options]
466
466
  |---|---|
467
467
  | `init --name <name>` | Initialize project, deploy skills, generate config. Use `--with-ci` to deploy GitHub Actions workflows. |
468
468
  | `lint` | Run L1 Biome AST checks |
469
- | `validate --layer <L2-L4\|all>` | Run validators for specified layer (`--layer L0` prints runtime hook guidance) |
470
- | `ci-check` | Full CI check (L2-L4) |
469
+ | `validate --layer <L1-L4\|all>` | Run validators for specified layer (`--layer L0` prints runtime hook guidance; explicit L4 runs even when scheduled L4 is disabled) |
470
+ | `ci-check` | Full CI check (L2-L4; disabled L4 is reported as skipped) |
471
471
  | `ci:generate-template --type <aidlc-gate\|consistency-check\|pre-commit\|agent-context-refresh> --render` | Render the bundled CI/hook template to stdout |
472
472
  | `ci:auto-refresh-agent-context --dry-run` / `--apply` | Refresh AGENTS.md pointers and CLAUDE.md standard sections |
473
473
  | `refresh-claude-md --dry-run` / `--apply` | Refresh only CLAUDE.md while preserving the user-owned section |
@@ -517,7 +517,7 @@ The main path is ready for project use, but a few documented behaviors still req
517
517
 
518
518
  | Work Item | Title | Why it matters |
519
519
  |---|---|---|
520
- | **[WI-033](docs/inception/_cross/WI-033/description.md)** | Promote `doc-freshness` / `pointer-validation` to L4 validators | Both capabilities exist as `p2:check-freshness` / `p2:validate-pointers` CLI commands but are not registered as L4 validators, so `validate --layer L4` skips them. WI-033 plumbs them through `validator-system` so they run via the standard L4 path and presets. |
520
+ | **[WI-033](docs/inception/_cross/WI-033/description.md)** | Finish L4 operational rollout | `doc-freshness` and `pointer-validation` are registered as L4-004/L4-005 and also remain available through `p2:*` compatibility commands. Remaining WI-033 work is rollout polish around scheduling, defaults, and operational docs. @work-item-id WI-116 |
521
521
 
522
522
  `requirement-test-matrix.json` auto-generation for L3 Nyquist Validation is not automated yet; see the L3 guide for the current manual setup.
523
523
 
@@ -261,7 +261,7 @@ Commands exposed as npm scripts (`npm run <command>`).
261
261
  | `phasegate:status` | `--json` | Health summary |
262
262
  | `phasegate:check-ready` | `--json` | Phase Gate pass status for all stories |
263
263
  | `phasegate:check-phase` | `--unit <unitId>` `--json` | Current phase for a unit |
264
- | `phasegate:ci-check` | `--json` | All L3 validators |
264
+ | `phasegate:ci-check` | `--json` | Full CI check (L2-L4; disabled L4 is reported as skipped) |
265
265
  | `phasegate:detect-drift` | `--json` | Design-code drift report |
266
266
  | `phasegate:lint` | `--target <path>` `--json` | Lint via harness-api |
267
267
  | `phasegate:complete-check` | `--json` | L2-L4 full check |
@@ -147,8 +147,11 @@ L4 validators are designed to run on a weekly schedule and detect slow-moving dr
147
147
  | **drift-detect** | L4-001 | Bidirectional design-code drift detection. Compares design documents against the actual codebase to find divergence in either direction. |
148
148
  | **consistency-check** | L4-002 | Cross-document layer consistency. Ensures that references between design documents, ADRs, and code remain coherent. |
149
149
  | **dead-code** | L4-003 | Detects unused exports and unreachable code that should be removed. |
150
+ | **doc-freshness** | L4-004 | Checks design document freshness against the configured threshold. Also available through the `p2:check-freshness` compatibility command. |
151
+ | **pointer-validation** | L4-005 | Resolves and validates design document pointers. Also available through the `p2:validate-pointers` compatibility command. |
150
152
 
151
- `doc-freshness` and `pointer-validation` are implemented as `phase2-extensions` CLI commands (`p2:check-freshness` and `p2:validate-pointers`). They are not registered as L4 validators yet, so `validate --layer L4` does not run them until WI-033 promotes them into `validator-system`.
153
+ <!-- @work-item-id WI-116 -->
154
+ `doc-freshness` and `pointer-validation` are registered as L4-004/L4-005. The standalone `p2:check-freshness` and `p2:validate-pointers` commands remain as compatibility entry points; WI-033's remaining scope is operational rollout, not validator registration.
152
155
 
153
156
  ### Drift-detect design pointers
154
157
 
@@ -219,6 +222,8 @@ Each error catalog entry declares a `defaultSeverity` of `error` or `warning`. A
219
222
 
220
223
  **Opt-in to strict mode** via `phasegate.config.json` `validate.failOnWarning: true` or CLI `--fail-on-warning` (CLI > config). The `strict` preset defaults to `failOnWarning: true` to match the precedent set by the `ci-governance` preset adapter.
221
224
 
225
+ For L4 specifically, `validate --layer L4` is an explicit operator request and runs L4 validators even when `layers.L4.enabled` is false. `validate --layer all` and `phasegate:ci-check` honor disabled L4 as skipped results, so skipped L4 validators are visible in the report but do not become failures under `--fail-on-warning`. @work-item-id WI-107
226
+
222
227
  History: prior to v0.131.0, the aggregator's `failOnWarning` flag was effectively dead code (`hasFail = !result.passed || ...` masked it), so every warning-only fail produced exit 1 regardless of severity declaration. See ADR-017 for the rationale.
223
228
 
224
229
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.140.0",
3
+ "version": "0.141.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",
@@ -18,6 +18,20 @@ const RULE_SUGGESTIONS: Readonly<Record<string, string>> = {
18
18
  'no-comment-flood': '不要なコメントを整理し自己説明的なコードへ改善する',
19
19
  };
20
20
 
21
+ const METADATA_TAG_MESSAGE_PATTERN = /(@[a-z][a-zA-Z0-9]*)コメントが必要です/;
22
+
23
+ const resolveSuggestion = (ruleName: string, message: string): string => {
24
+ if (ruleName === 'require-unit-comment' || ruleName === 'require-layer-comment') {
25
+ const tagName = message.match(METADATA_TAG_MESSAGE_PATTERN)?.[1];
26
+
27
+ if (tagName) {
28
+ return `ファイル先頭に ${tagName} コメントを追加する`;
29
+ }
30
+ }
31
+
32
+ return RULE_SUGGESTIONS[ruleName] ?? '';
33
+ };
34
+
21
35
  /**
22
36
  * ViolationFormatterPort の実装。
23
37
  * RuleViolation をルール名から L1-001〜L1-008 コードへマッピングし、
@@ -40,7 +54,7 @@ export class HarnessErrorFormatterAdapter implements ViolationFormatterPort {
40
54
  violations.map((v) => {
41
55
  const ruleName = v.ruleName.toString();
42
56
  const code = mapRuleNameToCode(ruleName);
43
- const suggestion = RULE_SUGGESTIONS[ruleName] ?? '';
57
+ const suggestion = resolveSuggestion(ruleName, v.message);
44
58
 
45
59
  const entry: {
46
60
  code: string;
@@ -19,6 +19,36 @@ export interface TypeScriptSourceModuleAnalyzerAdapterDeps {
19
19
  readonly rootDir: string;
20
20
  }
21
21
 
22
+ const HARNESS_ROOT_PREFIX = 'scripts/harness/';
23
+ const TESTS_SEGMENT = '__tests__';
24
+ const TEST_SCOPE_SEGMENTS = new Set(['unit', 'integration', 'e2e']);
25
+
26
+ function deriveUnitNameFromPath(filePath: FilePath): string | null {
27
+ const normalizedPath = filePath.toString();
28
+ if (!normalizedPath.startsWith(HARNESS_ROOT_PREFIX)) {
29
+ return null;
30
+ }
31
+
32
+ const parts = normalizedPath.slice(HARNESS_ROOT_PREFIX.length).split('/');
33
+ if (parts.length < 2) {
34
+ return null;
35
+ }
36
+
37
+ if (parts[0] !== TESTS_SEGMENT) {
38
+ return parts[0] || null;
39
+ }
40
+
41
+ if (parts.length < 3) {
42
+ return parts[1] || null;
43
+ }
44
+
45
+ if (TEST_SCOPE_SEGMENTS.has(parts[1])) {
46
+ return parts[2] || null;
47
+ }
48
+
49
+ return parts[1] || null;
50
+ }
51
+
22
52
  /**
23
53
  * SourceModuleAnalyzerPort の実装。
24
54
  * TypeScript Compiler API を使用して各ファイルから
@@ -69,7 +99,7 @@ export class TypeScriptSourceModuleAnalyzerAdapter implements SourceModuleAnalyz
69
99
  SourceModuleSnapshot.create(
70
100
  {
71
101
  filePath,
72
- declaredUnit: unitResult.unitNames[0] ?? null,
102
+ declaredUnit: unitResult.unitNames[0] ?? deriveUnitNameFromPath(filePath),
73
103
  declaredLayer: layerResult.layerName,
74
104
  imports,
75
105
  anyTypeCount: anyCount,
@@ -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 },
14
+ L2: { enabled: resolvedConfig.layers.L2.enabled, validators: ['L2-001', 'L2-002', 'L2-003', 'L2-013'] },
15
15
  L3: { enabled: resolvedConfig.layers.L3.enabled },
16
16
  L4: { enabled: resolvedConfig.layers.L4.enabled, validators: resolvedConfig.layers.L4.validators },
17
17
  },
@@ -13,6 +13,7 @@ import type { BiomeLintPort } from '../ports/biome-lint-port.js';
13
13
  import type { ImpactAnalysisPort } from '../ports/impact-analysis-port.js';
14
14
  import type { ArtifactScannerPort } from '../ports/artifact-scanner-port.js';
15
15
  import type { ConfigQueryPort } from '../ports/config-query-port.js';
16
+ import type { LayerId } from '../value-objects/layer-health.js';
16
17
 
17
18
  export interface CommandDispatchPorts {
18
19
  validatorExecutionPort: ValidatorExecutionPort;
@@ -35,6 +36,29 @@ function makeError(message: string): HarnessError {
35
36
  return { code: 'HARNESS_ERROR', severity: 'error', message };
36
37
  }
37
38
 
39
+ type LiveValidationState = 'pass' | 'fail' | 'skipped' | 'not-run' | 'error';
40
+
41
+ function layerIdFromValidatorId(validatorId: string): LayerId | null {
42
+ const prefix = validatorId.slice(0, 2);
43
+ return prefix === 'L1' || prefix === 'L2' || prefix === 'L3' || prefix === 'L4' ? prefix : null;
44
+ }
45
+
46
+ function summarizeLayerResults(items: readonly { validatorId: string; passed: boolean; skipped?: boolean }[]): Partial<Record<LayerId, LiveValidationState>> {
47
+ const result: Partial<Record<LayerId, LiveValidationState>> = {};
48
+ for (const layerId of ['L2', 'L3', 'L4'] as const) {
49
+ const layerItems = items.filter((item) => layerIdFromValidatorId(item.validatorId) === layerId);
50
+ if (layerItems.length === 0) continue;
51
+ if (layerItems.every((item) => item.skipped === true)) {
52
+ result[layerId] = 'skipped';
53
+ } else if (layerItems.some((item) => !item.passed && item.skipped !== true)) {
54
+ result[layerId] = 'fail';
55
+ } else {
56
+ result[layerId] = 'pass';
57
+ }
58
+ }
59
+ return result;
60
+ }
61
+
38
62
  const KNOWN_COMMANDS = new Set([
39
63
  'phasegate:check-ready',
40
64
  'phasegate:check-phase',
@@ -131,7 +155,7 @@ export class CommandDispatchService {
131
155
  }
132
156
 
133
157
  case 'phasegate:ci-check': {
134
- const validatorResults = await this.ports.validatorExecutionPort.runL3Validators();
158
+ const validatorResults = await this.ports.validatorExecutionPort.runAllValidators();
135
159
  const result = CiCheckResult.fromResults(validatorResults);
136
160
  if (result.allPassed) {
137
161
  const r = HarnessApiResponse.pass({ ...summary, passed: 1 }, result);
@@ -152,13 +176,23 @@ export class CommandDispatchService {
152
176
  const r = HarnessApiResponse.pass({ ...summary, passed: 1 }, result);
153
177
  return { status: 'pass', errors: [], summary: r.summary, data: result as unknown as T, exitCode: 0 };
154
178
  }
155
- const errors = drifts.map((d) => makeError(`Drift detected in ${d.unit}: ${d.element}`));
156
- const r = HarnessApiResponse.fail(errors, { ...summary, failed: 1 }, result);
157
- return { status: 'fail', errors: r.errors, summary: r.summary, data: result as unknown as T, exitCode: 1 };
179
+ const r = HarnessApiResponse.pass({ ...summary, passed: 1, warnings: drifts.length }, result);
180
+ return { status: 'pass', errors: [], summary: r.summary, data: result as unknown as T, exitCode: 0 };
158
181
  }
159
182
 
160
183
  case 'phasegate:status': {
161
184
  const scanResult = await this.ports.artifactScannerPort.scan();
185
+ const liveValidationByLayer: Partial<Record<LayerId, LiveValidationState>> = {};
186
+ try {
187
+ const [lintResult, validatorResults] = await Promise.all([
188
+ this.ports.biomeLintPort.runLint(),
189
+ this.ports.validatorExecutionPort.runAllValidators(),
190
+ ]);
191
+ liveValidationByLayer.L1 = lintResult.passed ? 'pass' : 'fail';
192
+ Object.assign(liveValidationByLayer, summarizeLayerResults(validatorResults));
193
+ } catch {
194
+ liveValidationByLayer.L1 = liveValidationByLayer.L1 ?? 'error';
195
+ }
162
196
  let presetInfo = { name: 'standard' as const, enabledLayers: ['L1', 'L2', 'L3'] as ('L1' | 'L2' | 'L3' | 'L4')[] };
163
197
  const configPort = this.ports.configQueryPort;
164
198
  if (configPort.getPresetInfo) {
@@ -172,6 +206,7 @@ export class CommandDispatchService {
172
206
  presetInfo,
173
207
  configSummary: { configPath: 'phasegate.config.json', lastModified: '', version: '2' },
174
208
  phaseGateSummary: { totalStories: 0, passedStories: 0, pendingStories: 0 },
209
+ liveValidationByLayer,
175
210
  });
176
211
  const r = HarnessApiResponse.pass({ ...summary, passed: 1 }, statusSummary);
177
212
  return { status: 'pass', errors: [], summary: r.summary, data: statusSummary as unknown as T, exitCode: 0 };
@@ -88,27 +88,44 @@ export class StatusDerivationService {
88
88
  presetInfo: PresetInfo;
89
89
  configSummary: ConfigSummary;
90
90
  phaseGateSummary: PhaseGateSummary;
91
+ liveValidationByLayer?: Partial<Record<LayerId, 'pass' | 'fail' | 'skipped' | 'not-run' | 'error'>>;
91
92
  }): HarnessStatusSummary {
92
- const { scanResult, presetInfo, configSummary, phaseGateSummary } = input;
93
+ const { scanResult, presetInfo, configSummary, phaseGateSummary, liveValidationByLayer } = input;
93
94
 
94
95
  const layers: LayerHealth[] = ALL_LAYER_IDS.map((layerId) => {
95
96
  const enabled = presetInfo.enabledLayers.includes(layerId);
97
+ const configurationState = enabled ? 'enabled' : 'disabled';
96
98
 
97
99
  // Find from derivedLayerHealth
98
100
  const existing = scanResult.derivedLayerHealth.find((l) => l.layerId === layerId);
101
+ const layerArtifacts = scanResult.foundArtifacts.filter((a) => getLayerId(a) === layerId);
102
+ const hasPresent = layerArtifacts.some((a) => a.present === true);
103
+ const cachedArtifactState = hasPresent ? 'present' : 'missing';
104
+ const liveValidationState = liveValidationByLayer?.[layerId] ?? 'not-run';
105
+ const liveLastResult =
106
+ liveValidationState === 'pass' || liveValidationState === 'fail'
107
+ ? liveValidationState
108
+ : undefined;
99
109
 
100
110
  if (enabled) {
101
- // Check artifacts
102
- const layerArtifacts = scanResult.foundArtifacts.filter((a) => getLayerId(a) === layerId);
103
- const hasPresent = layerArtifacts.some((a) => a.present === true);
104
-
105
- if (existing && existing.lastResult) {
106
- return LayerHealth.create({ layerId, enabled, lastResult: existing.lastResult });
107
- }
108
- const lastResult = hasPresent ? 'pass' : 'unknown';
109
- return LayerHealth.create({ layerId, enabled, lastResult });
111
+ const lastResult = liveLastResult ?? existing?.lastResult ?? (hasPresent ? 'pass' : 'unknown');
112
+ return LayerHealth.create({
113
+ layerId,
114
+ enabled,
115
+ lastResult,
116
+ configurationState,
117
+ cachedArtifactState,
118
+ liveValidationState,
119
+ });
110
120
  } else {
111
- return LayerHealth.create({ layerId, enabled: false, lastResult: undefined });
121
+ return LayerHealth.create({
122
+ layerId,
123
+ enabled: false,
124
+ lastResult: undefined,
125
+ configurationState,
126
+ cachedArtifactState,
127
+ liveValidationState,
128
+ });
112
129
  }
113
130
  });
114
131
 
@@ -6,6 +6,7 @@ import type { HarnessError } from './harness-api-response.js';
6
6
  export interface ValidatorCheckItem {
7
7
  validatorId: string;
8
8
  passed: boolean;
9
+ skipped?: boolean;
9
10
  errors?: readonly HarnessError[];
10
11
  }
11
12
 
@@ -29,8 +30,8 @@ export class CiCheckResult {
29
30
  if (!props.validatorResults || props.validatorResults.length === 0) {
30
31
  throw new Error('EmptyValidatorResultsError: validatorResults must have at least one item (INV-5)');
31
32
  }
32
- // INV-6: allPassed === validatorResults.every(r => r.passed)
33
- const computedAllPassed = props.validatorResults.every((r) => r.passed);
33
+ // INV-6: allPassed === validatorResults.every(r => r.passed || r.skipped)
34
+ const computedAllPassed = props.validatorResults.every((r) => r.passed || r.skipped);
34
35
  if (props.allPassed !== computedAllPassed) {
35
36
  throw new Error(
36
37
  `HarnessApiDomainError: allPassed=${props.allPassed} does not match validatorResults state (computed: ${computedAllPassed})`
@@ -43,12 +44,12 @@ export class CiCheckResult {
43
44
  if (!validatorResults || validatorResults.length === 0) {
44
45
  throw new Error('EmptyValidatorResultsError: validatorResults must have at least one item (INV-5)');
45
46
  }
46
- const allPassed = validatorResults.every((r) => r.passed);
47
+ const allPassed = validatorResults.every((r) => r.passed || r.skipped);
47
48
  return new CiCheckResult(validatorResults, allPassed);
48
49
  }
49
50
 
50
51
  getFailedValidators(): readonly ValidatorCheckItem[] {
51
- return this.validatorResults.filter((r) => !r.passed);
52
+ return this.validatorResults.filter((r) => !r.passed && !r.skipped);
52
53
  }
53
54
 
54
55
  collectAllErrors(): readonly HarnessError[] {
@@ -8,18 +8,117 @@ export interface DriftItem {
8
8
  recommendation: string;
9
9
  }
10
10
 
11
+ type DriftCategory =
12
+ | 'code-missing-design'
13
+ | 'design-missing-code'
14
+ | 'missing-pointer'
15
+ | 'design-granularity-mismatch'
16
+ | 'uncategorized';
17
+ type DriftSeverity = 'info' | 'warning' | 'error';
18
+
19
+ export interface ActionableDriftItem extends DriftItem {
20
+ category: DriftCategory;
21
+ severity: DriftSeverity;
22
+ nextAction: string;
23
+ }
24
+
25
+ export interface DriftCategorySummary {
26
+ category: DriftCategory;
27
+ severity: DriftSeverity;
28
+ count: number;
29
+ nextAction: string;
30
+ }
31
+
11
32
  export interface DriftReportSummaryProps {
12
33
  drifts: readonly DriftItem[];
13
34
  totalCount: number;
35
+ sampleLimit?: number;
36
+ }
37
+
38
+ const DEFAULT_SAMPLE_LIMIT = 20;
39
+
40
+ function classifyDrift(drift: DriftItem): { category: DriftCategory; severity: DriftSeverity; nextAction: string } {
41
+ const direction = drift.direction.toLowerCase();
42
+ const recommendation = drift.recommendation.toLowerCase();
43
+ const element = drift.element.toLowerCase();
44
+
45
+ if (recommendation.includes('pointer') || recommendation.includes('@work-item-id')) {
46
+ return {
47
+ category: 'missing-pointer',
48
+ severity: 'warning',
49
+ nextAction: 'Add or repair the traceability pointer before changing gating policy.',
50
+ };
51
+ }
52
+ if (element.includes('heading') || recommendation.includes('granularity')) {
53
+ return {
54
+ category: 'design-granularity-mismatch',
55
+ severity: 'info',
56
+ nextAction: 'Review the design granularity or baseline this class of advisory drift.',
57
+ };
58
+ }
59
+ if (direction.includes('code') && direction.includes('design')) {
60
+ return {
61
+ category: direction.indexOf('code') < direction.indexOf('design') ? 'code-missing-design' : 'design-missing-code',
62
+ severity: 'warning',
63
+ nextAction:
64
+ direction.indexOf('code') < direction.indexOf('design')
65
+ ? 'Update the matching product/construction docs with the implementation contract.'
66
+ : 'Implement the described design element or remove stale design text.',
67
+ };
68
+ }
69
+ return {
70
+ category: 'uncategorized',
71
+ severity: 'warning',
72
+ nextAction: 'Inspect the sampled drift and assign a narrower category or baseline rule.',
73
+ };
74
+ }
75
+
76
+ function enrichDrift(drift: DriftItem): ActionableDriftItem {
77
+ return { ...drift, ...classifyDrift(drift) };
78
+ }
79
+
80
+ function summarizeByCategory(drifts: readonly ActionableDriftItem[]): readonly DriftCategorySummary[] {
81
+ const byCategory = new Map<DriftCategory, DriftCategorySummary>();
82
+ for (const drift of drifts) {
83
+ const existing = byCategory.get(drift.category);
84
+ if (existing) {
85
+ byCategory.set(drift.category, { ...existing, count: existing.count + 1 });
86
+ } else {
87
+ byCategory.set(drift.category, {
88
+ category: drift.category,
89
+ severity: drift.severity,
90
+ count: 1,
91
+ nextAction: drift.nextAction,
92
+ });
93
+ }
94
+ }
95
+ return [...byCategory.values()].sort((a, b) => b.count - a.count || a.category.localeCompare(b.category));
14
96
  }
15
97
 
16
98
  export class DriftReportSummary {
17
- readonly drifts: readonly DriftItem[];
99
+ readonly drifts: readonly ActionableDriftItem[];
18
100
  readonly totalCount: number;
101
+ readonly rawDriftCount: number;
102
+ readonly sampleLimit: number;
103
+ readonly truncated: boolean;
104
+ readonly categorySummaries: readonly DriftCategorySummary[];
105
+ readonly actionPlan: readonly DriftCategorySummary[];
19
106
 
20
- private constructor(drifts: readonly DriftItem[], totalCount: number) {
21
- this.drifts = Object.freeze([...drifts]);
107
+ private constructor(
108
+ drifts: readonly DriftItem[],
109
+ totalCount: number,
110
+ sampleLimit: number = DEFAULT_SAMPLE_LIMIT,
111
+ summarySource: readonly DriftItem[] = drifts,
112
+ ) {
113
+ const enriched = drifts.map(enrichDrift);
114
+ const enrichedSummarySource = summarySource.map(enrichDrift);
115
+ this.drifts = Object.freeze(enriched);
22
116
  this.totalCount = totalCount;
117
+ this.rawDriftCount = totalCount;
118
+ this.sampleLimit = sampleLimit;
119
+ this.truncated = totalCount > enriched.length;
120
+ this.categorySummaries = Object.freeze(summarizeByCategory(enrichedSummarySource));
121
+ this.actionPlan = Object.freeze(this.categorySummaries.slice(0, 5));
23
122
  Object.freeze(this);
24
123
  }
25
124
 
@@ -30,18 +129,18 @@ export class DriftReportSummary {
30
129
  `HarnessApiDomainError: totalCount=${props.totalCount} does not match drifts.length=${props.drifts.length} (INV-7)`
31
130
  );
32
131
  }
33
- return new DriftReportSummary(props.drifts, props.totalCount);
132
+ return new DriftReportSummary(props.drifts, props.totalCount, props.sampleLimit);
34
133
  }
35
134
 
36
- static fromDrifts(drifts: readonly DriftItem[]): DriftReportSummary {
37
- return new DriftReportSummary(drifts, drifts.length);
135
+ static fromDrifts(drifts: readonly DriftItem[], sampleLimit: number = DEFAULT_SAMPLE_LIMIT): DriftReportSummary {
136
+ return new DriftReportSummary(drifts.slice(0, sampleLimit), drifts.length, sampleLimit, drifts);
38
137
  }
39
138
 
40
139
  hasDrift(): boolean {
41
- return this.drifts.length > 0;
140
+ return this.totalCount > 0;
42
141
  }
43
142
 
44
- filterByUnit(unitId: string): readonly DriftItem[] {
143
+ filterByUnit(unitId: string): readonly ActionableDriftItem[] {
45
144
  return this.drifts.filter((d) => d.unit === unitId);
46
145
  }
47
146
  }
@@ -3,6 +3,9 @@
3
3
 
4
4
  export type LayerId = 'L1' | 'L2' | 'L3' | 'L4';
5
5
  type LastResult = 'pass' | 'fail' | 'unknown' | undefined;
6
+ type ConfigurationState = 'enabled' | 'disabled';
7
+ type CachedArtifactState = 'present' | 'missing' | 'unknown';
8
+ type LiveValidationState = 'pass' | 'fail' | 'skipped' | 'not-run' | 'error';
6
9
 
7
10
  const VALID_LAYER_IDS: readonly LayerId[] = ['L1', 'L2', 'L3', 'L4'];
8
11
  const VALID_LAST_RESULTS: readonly string[] = ['pass', 'fail', 'unknown'];
@@ -11,17 +14,33 @@ export interface LayerHealthProps {
11
14
  layerId: string;
12
15
  enabled: boolean;
13
16
  lastResult?: string;
17
+ configurationState?: ConfigurationState;
18
+ cachedArtifactState?: CachedArtifactState;
19
+ liveValidationState?: LiveValidationState;
14
20
  }
15
21
 
16
22
  export class LayerHealth {
17
23
  readonly layerId: LayerId;
18
24
  readonly enabled: boolean;
19
25
  readonly lastResult: LastResult;
26
+ readonly configurationState: ConfigurationState;
27
+ readonly cachedArtifactState: CachedArtifactState;
28
+ readonly liveValidationState: LiveValidationState;
20
29
 
21
- private constructor(layerId: LayerId, enabled: boolean, lastResult: LastResult) {
30
+ private constructor(
31
+ layerId: LayerId,
32
+ enabled: boolean,
33
+ lastResult: LastResult,
34
+ configurationState: ConfigurationState,
35
+ cachedArtifactState: CachedArtifactState,
36
+ liveValidationState: LiveValidationState,
37
+ ) {
22
38
  this.layerId = layerId;
23
39
  this.enabled = enabled;
24
40
  this.lastResult = lastResult;
41
+ this.configurationState = configurationState;
42
+ this.cachedArtifactState = cachedArtifactState;
43
+ this.liveValidationState = liveValidationState;
25
44
  Object.freeze(this);
26
45
  }
27
46
 
@@ -35,7 +54,10 @@ export class LayerHealth {
35
54
  return new LayerHealth(
36
55
  props.layerId as LayerId,
37
56
  props.enabled,
38
- props.lastResult as LastResult
57
+ props.lastResult as LastResult,
58
+ props.configurationState ?? (props.enabled ? 'enabled' : 'disabled'),
59
+ props.cachedArtifactState ?? 'unknown',
60
+ props.liveValidationState ?? 'not-run',
39
61
  );
40
62
  }
41
63
 
@@ -45,6 +45,7 @@ export class ValidatorSystemExecutionAdapter implements ValidatorExecutionPort {
45
45
  return results.map((r) => ({
46
46
  validatorId: r.validatorId,
47
47
  passed: r.passed,
48
+ skipped: r.skipped,
48
49
  errors: r.errors.map((e) => ({ code: e.code, severity: e.severity, message: e.message })),
49
50
  }));
50
51
  },
@@ -56,6 +57,7 @@ export class ValidatorSystemExecutionAdapter implements ValidatorExecutionPort {
56
57
  return report.results.map((r) => ({
57
58
  validatorId: r.validatorId,
58
59
  passed: r.passed,
60
+ skipped: r.skipped,
59
61
  errors: r.errors.map((e) => ({ code: e.code, severity: e.severity, message: e.message })),
60
62
  }));
61
63
  },
@@ -18,7 +18,6 @@ import { execSync } from "node:child_process";
18
18
  import { 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
- import { ConfigNotFoundError } from "../config-foundation/infrastructure/repositories/file-system-config-repository.js";
22
21
  import { createTraceabilityModelModule } from "../traceability-model/composition-root.js";
23
22
  import type { ValidateMetadataCommandOutput } from "../traceability-model/presentation/cli/validate-metadata-command-handler.js";
24
23
  import type { AggregatedValidationReport } from "../validator-system/application/dto/aggregated-validation-report.js";
@@ -38,13 +37,17 @@ const WORK_ITEM_PATH_PATTERN = /(?:^|\/)WI-\d+(?:\/|$)/;
38
37
  const WORK_ITEM_TRAILER_PATTERN = /^Work-Item:\s*WI-\d+\s*$/m;
39
38
  const TEST_FILE_SUFFIXES = Object.freeze([".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx"]);
40
39
 
40
+ function isConfigNotFoundError(err: unknown): boolean {
41
+ return err instanceof Error && err.name === "ConfigNotFoundError";
42
+ }
43
+
41
44
  async function loadValidatorSystemConfig(): Promise<object | undefined> {
42
45
  const configMod = createConfigFoundationModule();
43
46
  try {
44
47
  const resolvedConfig = await configMod.usecases.loadResolvedConfigUseCase.execute();
45
48
  return toValidatorSystemConfig(resolvedConfig.config);
46
49
  } catch (err) {
47
- if (err instanceof ConfigNotFoundError) return undefined;
50
+ if (isConfigNotFoundError(err)) return undefined;
48
51
  throw err;
49
52
  }
50
53
  }
@@ -57,7 +60,7 @@ async function loadTraceabilityModelOptions(): Promise<
57
60
  const resolvedConfig = await configMod.usecases.loadResolvedConfigUseCase.execute();
58
61
  return { pathRoots: { designDocsRoot: resolvedConfig.config.paths.designDocs } };
59
62
  } catch (err) {
60
- if (err instanceof ConfigNotFoundError) return undefined;
63
+ if (isConfigNotFoundError(err)) return undefined;
61
64
  throw err;
62
65
  }
63
66
  }
@@ -68,7 +71,7 @@ async function loadPreCommitImplementationExtensions(): Promise<readonly string[
68
71
  const resolvedConfig = await configMod.usecases.loadResolvedConfigUseCase.execute();
69
72
  return resolvedConfig.config.preCommit?.implementationExtensions;
70
73
  } catch (err) {
71
- if (err instanceof ConfigNotFoundError) return undefined;
74
+ if (isConfigNotFoundError(err)) return undefined;
72
75
  throw err;
73
76
  }
74
77
  }
@@ -192,6 +192,13 @@ function parseTriStateFlag(args: readonly string[], positiveFlag: string, negati
192
192
  return positiveIdx > negativeIdx;
193
193
  }
194
194
 
195
+ function parseValidateFormat(args: readonly string[]): "human" | "agent" | "ci" | undefined {
196
+ const raw = parseFlag(args, "--format");
197
+ if (raw === undefined) return undefined;
198
+ if (raw === "human" || raw === "agent" || raw === "ci") return raw;
199
+ throw new Error(`Invalid --format value for validate: '${raw}'. Supported values: human, agent, ci.`);
200
+ }
201
+
195
202
  function levenshtein(a: string, b: string): number {
196
203
  const m = a.length;
197
204
  const n = b.length;
@@ -1024,7 +1031,7 @@ async function main(): Promise<void> {
1024
1031
  const layer = parseFlag(args, "--layer") as "L0" | "L2" | "L3" | "L4" | "all" | undefined;
1025
1032
  const unit = parseFlag(args, "--unit");
1026
1033
  const phase = parseFlag(args, "--phase");
1027
- const format = parseFlag(args, "--format") as "human" | "agent" | "ci" | undefined;
1034
+ const format = parseValidateFormat(args);
1028
1035
  // WI-094 / ADR-017: --fail-on-warning / --no-fail-on-warning / 未指定→config値
1029
1036
  const failOnWarning = parseTriStateFlag(args, "--fail-on-warning", "--no-fail-on-warning");
1030
1037
  const noL4 = hasFlag(args, "--no-l4");
@@ -146,7 +146,12 @@ export class FileSystemStoryReflectionAdapter implements StoryReflectionFileSyst
146
146
  }
147
147
 
148
148
  const legacyId = await this.readLegacyId(storyId);
149
- return legacyId !== null && annotationIds.includes(legacyId);
149
+ if (legacyId === null || !annotationIds.includes(legacyId)) {
150
+ return false;
151
+ }
152
+
153
+ const unitId = this.extractProductUnitId(productPath);
154
+ return !(await this.isAmbiguousLegacyId(legacyId, storyId, unitId));
150
155
  }
151
156
 
152
157
  private extractAnnotationIds(content: string): readonly string[] {
@@ -182,6 +187,47 @@ export class FileSystemStoryReflectionAdapter implements StoryReflectionFileSyst
182
187
  return null;
183
188
  }
184
189
 
190
+ private extractProductUnitId(productPath: string): string | null {
191
+ const normalized = productPath.split(path.sep).join("/");
192
+ const match = /^docs\/product\/construction\/([^/]+)\//.exec(normalized);
193
+ return match?.[1] ?? null;
194
+ }
195
+
196
+ private async isAmbiguousLegacyId(legacyId: string, storyId: string, unitId: string | null): Promise<boolean> {
197
+ const scopedStoryIds = unitId === null
198
+ ? await this.listAllWorkItemDirectories()
199
+ : await this.listStoryDirectories(unitId);
200
+ const matchingStoryIds: string[] = [];
201
+
202
+ for (const candidateStoryId of scopedStoryIds) {
203
+ const candidateLegacyId = await this.readLegacyId(candidateStoryId);
204
+ if (candidateLegacyId === legacyId) {
205
+ matchingStoryIds.push(candidateStoryId);
206
+ }
207
+ }
208
+
209
+ return matchingStoryIds.length > 1 || (matchingStoryIds.length === 1 && matchingStoryIds[0] !== storyId);
210
+ }
211
+
212
+ private async listAllWorkItemDirectories(): Promise<readonly string[]> {
213
+ const ids = new Set<string>();
214
+ for (const id of await this.listCrossWorkItemDirectories()) {
215
+ ids.add(id);
216
+ }
217
+
218
+ const inceptionDir = path.join(this.rootDir, this.inceptionRoot);
219
+ for (const entry of await this.readDirectories(inceptionDir)) {
220
+ if (!entry.isDirectory()) continue;
221
+ const name = entry.name;
222
+ if (name.startsWith("_") || name.startsWith(".") || name === "issues") continue;
223
+ for (const id of await this.listUnitWorkItemDirectories(name)) {
224
+ ids.add(id);
225
+ }
226
+ }
227
+
228
+ return [...ids].sort();
229
+ }
230
+
185
231
  private async listDescriptionCandidates(storyId: string): Promise<readonly string[]> {
186
232
  const inceptionDir = path.join(this.rootDir, this.inceptionRoot);
187
233
  const candidates: string[] = [path.join(inceptionDir, "_cross", storyId, "description.md")];
@@ -9,38 +9,28 @@ import { ValidatorId } from '../../domain/value-objects/validator-id.js';
9
9
  import { ValidationResult } from '../../domain/value-objects/validation-result.js';
10
10
  import { ItTestMockDetectionService } from '../../domain/services/it-test-mock-detection-service.js';
11
11
  import { StubCommentDetectionService } from '../../domain/services/stub-comment-detection-service.js';
12
- import { CliE2eTestExistenceService } from '../../domain/services/cli-e2e-test-existence-service.js';
13
12
  import { ValidationResultContractMapper } from '../mappers/validation-result-contract-mapper.js';
14
13
  import type { ValidationResultContract } from '../dto/validation-result-contract.js';
15
14
  import type { RunL1ValidatorsInput } from '../dto/run-l1-validators-input.js';
16
15
  import type { ItTestFileAnalyzerPort } from '../../domain/ports/it-test-file-analyzer-port.js';
17
16
  import type { SourceFileTextScannerPort } from '../../domain/ports/source-file-text-scanner-port.js';
18
- import type { CliCommandRegistryPort } from '../../domain/ports/cli-command-registry-port.js';
19
- import type { E2eTestFileRegistryPort } from '../../domain/ports/e2e-test-file-registry-port.js';
20
17
 
21
18
  export interface RunL1ValidatorsUseCaseDeps {
22
19
  itTestFileAnalyzerPort: ItTestFileAnalyzerPort;
23
20
  sourceFileTextScannerPort: SourceFileTextScannerPort;
24
- cliCommandRegistryPort?: CliCommandRegistryPort;
25
- e2eTestFileRegistryPort?: E2eTestFileRegistryPort;
26
21
  contractMapper: ValidationResultContractMapper;
27
22
  }
28
23
 
29
24
  export class RunL1ValidatorsUseCase {
30
25
  private readonly itTestFileAnalyzerPort: ItTestFileAnalyzerPort;
31
26
  private readonly sourceFileTextScannerPort: SourceFileTextScannerPort;
32
- private readonly cliCommandRegistryPort: CliCommandRegistryPort | undefined;
33
- private readonly e2eTestFileRegistryPort: E2eTestFileRegistryPort | undefined;
34
27
  private readonly mapper: ValidationResultContractMapper;
35
28
  private readonly itTestMockDetectionService = new ItTestMockDetectionService();
36
29
  private readonly stubCommentDetectionService = new StubCommentDetectionService();
37
- private readonly cliE2eTestExistenceService = new CliE2eTestExistenceService();
38
30
 
39
31
  constructor(deps: RunL1ValidatorsUseCaseDeps) {
40
32
  this.itTestFileAnalyzerPort = deps.itTestFileAnalyzerPort;
41
33
  this.sourceFileTextScannerPort = deps.sourceFileTextScannerPort;
42
- this.cliCommandRegistryPort = deps.cliCommandRegistryPort;
43
- this.e2eTestFileRegistryPort = deps.e2eTestFileRegistryPort;
44
34
  this.mapper = deps.contractMapper;
45
35
  }
46
36
 
@@ -49,7 +39,6 @@ export class RunL1ValidatorsUseCase {
49
39
 
50
40
  results.push(await this.runL1017(input));
51
41
  results.push(await this.runL1018(input));
52
- results.push(await this.runL2013());
53
42
 
54
43
  return this.mapper.toContracts(results);
55
44
  }
@@ -115,38 +104,6 @@ export class RunL1ValidatorsUseCase {
115
104
  }
116
105
  }
117
106
 
118
- /** H08-09: L2-013 CLIコマンドE2Eテスト存在チェック (Should) */
119
- private async runL2013(): Promise<ValidationResult> {
120
- const validatorId = ValidatorId.create('L2-013');
121
- if (!this.cliCommandRegistryPort || !this.e2eTestFileRegistryPort) {
122
- return ValidationResult.skip(validatorId);
123
- }
124
- const start = Date.now();
125
- try {
126
- const commands = await this.cliCommandRegistryPort.getRegisteredCommands();
127
- const e2eFiles = await this.e2eTestFileRegistryPort.getE2eTestFiles();
128
- const report = this.cliE2eTestExistenceService.check(commands, e2eFiles);
129
- const durationMs = Date.now() - start;
130
- if (report.hasViolations()) {
131
- const errors = report.toMessages().map((msg) => ({
132
- code: { value: 'L2-013', toString: () => 'L2-013' },
133
- severity: { value: 'error', toString: () => 'error' },
134
- message: msg,
135
- suggestion: 'Add an E2E test for each registered CLI command in cli-harness.test.ts.',
136
- }));
137
- return ValidationResult.fail(validatorId, errors, durationMs);
138
- }
139
- return ValidationResult.pass(validatorId, durationMs);
140
- } catch (err) {
141
- const durationMs = Date.now() - start;
142
- return ValidationResult.fail(validatorId, [{
143
- code: { value: 'L2-013', toString: () => 'L2-013' },
144
- severity: { value: 'error', toString: () => 'error' },
145
- message: `L2-013 execution failed: ${err instanceof Error ? err.message : String(err)}`,
146
- suggestion: '',
147
- }], durationMs);
148
- }
149
- }
150
107
  }
151
108
 
152
- // @story-id H08-07
109
+ // @story-id H08-07
@@ -16,6 +16,9 @@ import type { ValidatorConfigPort } from '../../domain/ports/validator-config-po
16
16
  import type { PhaseGatePolicyPort } from '../../domain/ports/phase-gate-policy-port.js';
17
17
  import type { MetadataPolicyPort } from '../../domain/ports/metadata-policy-port.js';
18
18
  import type { TestQualityAnalyzerPort } from '../../domain/ports/test-quality-analyzer-port.js';
19
+ import type { CliCommandRegistryPort } from '../../domain/ports/cli-command-registry-port.js';
20
+ import type { E2eTestFileRegistryPort } from '../../domain/ports/e2e-test-file-registry-port.js';
21
+ import { CliE2eTestExistenceService } from '../../domain/services/cli-e2e-test-existence-service.js';
19
22
 
20
23
  export interface RunL2ValidatorsUseCaseDeps {
21
24
  validatorRegistry: ValidatorRegistry;
@@ -25,6 +28,8 @@ export interface RunL2ValidatorsUseCaseDeps {
25
28
  phaseGatePolicyPort?: PhaseGatePolicyPort;
26
29
  metadataPolicyPort?: MetadataPolicyPort;
27
30
  testQualityAnalyzerPort?: TestQualityAnalyzerPort;
31
+ cliCommandRegistryPort?: CliCommandRegistryPort;
32
+ e2eTestFileRegistryPort?: E2eTestFileRegistryPort;
28
33
  }
29
34
 
30
35
  export class RunL2ValidatorsUseCase {
@@ -35,6 +40,9 @@ export class RunL2ValidatorsUseCase {
35
40
  private readonly phaseGatePolicyPort?: PhaseGatePolicyPort;
36
41
  private readonly metadataPolicyPort?: MetadataPolicyPort;
37
42
  private readonly testQualityAnalyzerPort?: TestQualityAnalyzerPort;
43
+ private readonly cliCommandRegistryPort?: CliCommandRegistryPort;
44
+ private readonly e2eTestFileRegistryPort?: E2eTestFileRegistryPort;
45
+ private readonly cliE2eTestExistenceService = new CliE2eTestExistenceService();
38
46
 
39
47
  constructor(deps: RunL2ValidatorsUseCaseDeps) {
40
48
  this.registry = deps.validatorRegistry;
@@ -44,6 +52,8 @@ export class RunL2ValidatorsUseCase {
44
52
  this.phaseGatePolicyPort = deps.phaseGatePolicyPort;
45
53
  this.metadataPolicyPort = deps.metadataPolicyPort;
46
54
  this.testQualityAnalyzerPort = deps.testQualityAnalyzerPort;
55
+ this.cliCommandRegistryPort = deps.cliCommandRegistryPort;
56
+ this.e2eTestFileRegistryPort = deps.e2eTestFileRegistryPort;
47
57
  }
48
58
 
49
59
  async execute(input: RunL2ValidatorsInput): Promise<readonly ValidationResultContract[]> {
@@ -122,6 +132,26 @@ export class RunL2ValidatorsUseCase {
122
132
  }
123
133
  }
124
134
 
135
+ if (this.cliCommandRegistryPort && this.e2eTestFileRegistryPort) {
136
+ const l2013Result = overrideMap.get('L2-013');
137
+ if (l2013Result && !l2013Result.skipped) {
138
+ const commands = await this.cliCommandRegistryPort.getRegisteredCommands();
139
+ const e2eFiles = await this.e2eTestFileRegistryPort.getE2eTestFiles();
140
+ const report = this.cliE2eTestExistenceService.check(commands, e2eFiles);
141
+ if (report.hasViolations()) {
142
+ const errors = report.toMessages().map((msg) => ({
143
+ code: { value: 'L2-013', toString: () => 'L2-013' },
144
+ severity: { value: 'error', toString: () => 'error' },
145
+ message: msg,
146
+ suggestion: 'Add an E2E test for each registered CLI command in cli-harness.test.ts.',
147
+ }));
148
+ overrideMap.set('L2-013', ValidationResult.fail(ValidatorId.create('L2-013'), errors, 0));
149
+ } else {
150
+ overrideMap.set('L2-013', ValidationResult.pass(ValidatorId.create('L2-013'), 0));
151
+ }
152
+ }
153
+ }
154
+
125
155
  const finalResults = definitions.map(
126
156
  (definition) => overrideMap.get(definition.validatorId.value) ?? ValidationResult.skip(definition.validatorId),
127
157
  );
@@ -109,7 +109,7 @@ export class RunL4ValidatorsUseCase {
109
109
  }
110
110
 
111
111
  if (!layerConfig.enabled && !input.forceLayerEnabled) {
112
- return [];
112
+ return this.mapper.toContracts(definitions.map((definition) => ValidationResult.skip(definition.validatorId)));
113
113
  }
114
114
 
115
115
  const effectiveLayerConfig = input.forceLayerEnabled && !layerConfig.enabled
@@ -45,7 +45,7 @@ import { join } from 'node:path';
45
45
  const DEFAULT_CONFIG = {
46
46
  preset: 'standard' as const,
47
47
  layers: {
48
- L2: { enabled: true, validators: ['L2-001', 'L2-002', 'L2-003'] },
48
+ L2: { enabled: true, validators: ['L2-001', 'L2-002', 'L2-003', 'L2-013'] },
49
49
  L3: { enabled: true, validators: ['L3-001', 'L3-002', 'L3-003', 'L3-004'], coverageThreshold: 90, bundleSizeLimit: 512000 },
50
50
  L4: { enabled: true, validators: ['L4-001', 'L4-002', 'L4-003', 'L4-004', 'L4-005'] },
51
51
  },
@@ -77,6 +77,7 @@ function buildDefaultRegistry(): ValidatorRegistry {
77
77
  createDef('L2-001', 'L2', 'always', 'PhaseGatePolicyPort'),
78
78
  createDef('L2-002', 'L2', 'always', 'MetadataPolicyPort'),
79
79
  createDef('L2-003', 'L2', 'always'),
80
+ createDef('L2-013', 'L2', 'always', 'CliE2eTestExistenceService'),
80
81
  createDef('L3-001', 'L3', 'always'),
81
82
  createDef('L3-002', 'L3', 'strictOnly'),
82
83
  createDef('L3-003', 'L3', 'always'),
@@ -123,6 +124,18 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
123
124
  const securityScannerPort = new FileSystemSecurityPatternScannerAdapter();
124
125
  const performanceScannerPort = new AstPerformanceScannerAdapter();
125
126
 
127
+ const docsRoot = join(process.cwd(), 'docs/product/construction');
128
+ const cwd = process.cwd();
129
+ const e2eTestFileRegistryPort = new E2eTestFileRegistryAdapter({ e2eTestRoot: join(cwd, 'scripts/harness/__tests__/e2e') });
130
+ const cliCommandRegistryPort = new CliCommandRegistryAdapter({
131
+ commands: [
132
+ 'validate', 'lint', 'ci-check',
133
+ 'phasegate:check-ready', 'phasegate:check-phase', 'phasegate:ci-check',
134
+ 'phasegate:detect-drift', 'phasegate:lint', 'phasegate:complete-check',
135
+ 'phasegate:impact-analysis', 'phasegate:status',
136
+ ],
137
+ });
138
+
126
139
  const runL2ValidatorsUseCase = new RunL2ValidatorsUseCase({
127
140
  validatorRegistry: registry,
128
141
  validatorExecutionService: executionService,
@@ -131,6 +144,8 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
131
144
  phaseGatePolicyPort,
132
145
  metadataPolicyPort,
133
146
  testQualityAnalyzerPort,
147
+ e2eTestFileRegistryPort,
148
+ cliCommandRegistryPort,
134
149
  });
135
150
 
136
151
  const runL3ValidatorsUseCase = new RunL3ValidatorsUseCase({
@@ -143,7 +158,6 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
143
158
  performanceScannerPort,
144
159
  });
145
160
 
146
- const docsRoot = join(process.cwd(), 'docs/product/construction');
147
161
  const markdownDesignDocumentPort = new MarkdownDesignDocumentAdapter(docsRoot);
148
162
  const sourceCodeAnalyzerAdapter = new BiomeAstSourceCodeAnalyzerAdapter();
149
163
  const adrReferencePort = new AdrFoundationReferenceAdapter();
@@ -182,26 +196,15 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
182
196
  });
183
197
 
184
198
  const aggregateValidationResultsUseCase = new AggregateValidationResultsUseCase();
185
- const KNOWN_CLI_COMMANDS = [
186
- 'validate', 'lint', 'ci-check', 'detect-drift',
187
- 'phasegate:check-ready', 'phasegate:check-phase', 'phasegate:ci-check',
188
- 'phasegate:detect-drift', 'phasegate:lint', 'phasegate:complete-check',
189
- 'phasegate:impact-analysis', 'phasegate:status',
190
- ];
191
- const cwd = process.cwd();
192
199
  const itTestFileAnalyzerPort = new ItTestFileAnalyzerAdapter({
193
200
  itTestRoot: join(cwd, 'scripts/harness/__tests__/integration'),
194
201
  // アダプター自体のテストファイルは vi.mock() を使用しているため誤検知防止で除外
195
202
  excludePattern: /it-test-file-analyzer-adapter\.test\.ts$/,
196
203
  });
197
204
  const sourceFileTextScannerPort = new SourceFileTextScannerAdapter({ sourceRoot: join(cwd, 'scripts/harness') });
198
- const e2eTestFileRegistryPort = new E2eTestFileRegistryAdapter({ e2eTestRoot: join(cwd, 'scripts/harness/__tests__/e2e') });
199
- const cliCommandRegistryPort = new CliCommandRegistryAdapter({ commands: KNOWN_CLI_COMMANDS });
200
205
  const runL1ValidatorsUseCase = new RunL1ValidatorsUseCase({
201
206
  itTestFileAnalyzerPort,
202
207
  sourceFileTextScannerPort,
203
- e2eTestFileRegistryPort,
204
- cliCommandRegistryPort,
205
208
  contractMapper,
206
209
  });
207
210
 
@@ -5,15 +5,46 @@ import { CliE2eTestCoverageReport, type CliCommandCoverageEntry } from '../value
5
5
 
6
6
  export class CliE2eTestExistenceService {
7
7
  check(commands: readonly string[], e2eTestFiles: readonly string[]): CliE2eTestCoverageReport {
8
+ if (e2eTestFiles.length === 0) {
9
+ return CliE2eTestCoverageReport.create(
10
+ commands.map((commandName) => ({
11
+ commandName,
12
+ hasE2eTest: false,
13
+ status: 'limitation',
14
+ evidence: 'No CLI E2E test suite found in this project.',
15
+ })),
16
+ );
17
+ }
18
+
8
19
  const e2eContent = e2eTestFiles.join('\n').toLowerCase();
9
20
 
10
- const entries: CliCommandCoverageEntry[] = commands.map((commandName) => ({
11
- commandName,
12
- hasE2eTest: e2eContent.includes(commandName.toLowerCase()),
13
- }));
21
+ const entries: CliCommandCoverageEntry[] = commands.map((commandName) => {
22
+ const evidence = this.findCoverageEvidence(commandName, e2eContent);
23
+ return {
24
+ commandName,
25
+ hasE2eTest: evidence !== null,
26
+ status: evidence === null ? 'missing' : 'covered',
27
+ evidence: evidence ?? undefined,
28
+ };
29
+ });
14
30
 
15
31
  return CliE2eTestCoverageReport.create(entries);
16
32
  }
33
+
34
+ private findCoverageEvidence(commandName: string, e2eContent: string): string | null {
35
+ const escaped = commandName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+ const invocationPatterns = [
37
+ new RegExp(`\\brun\\(\\s*['"\`]${escaped}['"\`]`),
38
+ new RegExp(`\\brunin(?:cwd)?\\([^\\n]*['"\`]${escaped}['"\`]`),
39
+ new RegExp(`unknown command:\\s*${escaped}`),
40
+ new RegExp(`usage:\\s*phasegate\\s+${escaped}`),
41
+ ];
42
+ if (invocationPatterns.some((pattern) => pattern.test(e2eContent))) {
43
+ return commandName;
44
+ }
45
+
46
+ return e2eContent.includes(commandName.toLowerCase()) ? commandName : null;
47
+ }
17
48
  }
18
49
 
19
- // @story-id H08-07
50
+ // @story-id H08-07
@@ -9,6 +9,8 @@
9
9
  export interface CliCommandCoverageEntry {
10
10
  readonly commandName: string;
11
11
  readonly hasE2eTest: boolean;
12
+ readonly status?: 'covered' | 'missing' | 'limitation';
13
+ readonly evidence?: string;
12
14
  }
13
15
 
14
16
  export class CliE2eTestCoverageReport {
@@ -28,7 +30,11 @@ export class CliE2eTestCoverageReport {
28
30
  }
29
31
 
30
32
  uncoveredCommands(): readonly CliCommandCoverageEntry[] {
31
- return this.entries.filter((e) => !e.hasE2eTest);
33
+ return this.entries.filter((e) => !e.hasE2eTest && e.status !== 'limitation');
34
+ }
35
+
36
+ limitations(): readonly CliCommandCoverageEntry[] {
37
+ return this.entries.filter((e) => e.status === 'limitation');
32
38
  }
33
39
 
34
40
  hasViolations(): boolean {
@@ -42,4 +48,4 @@ export class CliE2eTestCoverageReport {
42
48
  }
43
49
  }
44
50
 
45
- // @story-id H08-07
51
+ // @story-id H08-07
@@ -6,7 +6,7 @@
6
6
  * E2Eテストファイルのパス一覧を提供する。
7
7
  */
8
8
  import type { E2eTestFileRegistryPort } from '../../domain/ports/e2e-test-file-registry-port.js';
9
- import { readdir } from 'node:fs/promises';
9
+ import { readFile, readdir } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
 
12
12
  export interface E2eTestFileRegistryAdapterOptions {
@@ -22,7 +22,16 @@ export class E2eTestFileRegistryAdapter implements E2eTestFileRegistryPort {
22
22
 
23
23
  async getE2eTestFiles(): Promise<readonly string[]> {
24
24
  if (!this.e2eTestRoot) return [];
25
- return this.findFiles(this.e2eTestRoot, /\.test\.ts$/);
25
+ const filePaths = await this.findFiles(this.e2eTestRoot, /\.test\.ts$/);
26
+ const contents: string[] = [];
27
+ for (const filePath of filePaths) {
28
+ try {
29
+ contents.push(`${filePath}\n${await readFile(filePath, 'utf-8')}`);
30
+ } catch {
31
+ contents.push(filePath);
32
+ }
33
+ }
34
+ return contents;
26
35
  }
27
36
 
28
37
  private async findFiles(dir: string, pattern: RegExp): Promise<string[]> {
@@ -45,4 +54,4 @@ export class E2eTestFileRegistryAdapter implements E2eTestFileRegistryPort {
45
54
  }
46
55
  }
47
56
 
48
- // @story-id H08-07
57
+ // @story-id H08-07
@@ -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'],
41
+ L2: ['L2-001', 'L2-002', 'L2-003', 'L2-013'],
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
  };