phasegate 0.140.0 → 0.142.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 (34) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.ja.md +7 -3
  3. package/README.md +8 -5
  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 +40 -4
  11. package/scripts/harness/harness-api/domain/services/status-derivation-service.ts +29 -11
  12. package/scripts/harness/harness-api/domain/value-objects/ci-check-result.ts +6 -4
  13. package/scripts/harness/harness-api/domain/value-objects/drift-report-summary.ts +108 -8
  14. package/scripts/harness/harness-api/domain/value-objects/layer-health.ts +25 -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 +37 -1
  18. package/scripts/harness/phase-dependency-model/infrastructure/filesystem/file-system-story-reflection-adapter.ts +47 -1
  19. package/scripts/harness/traceability-model/application/usecases/apply-work-item-status-usecase.ts +30 -0
  20. package/scripts/harness/traceability-model/application/usecases/derive-work-item-status-usecase.ts +27 -0
  21. package/scripts/harness/traceability-model/composition-root.ts +20 -0
  22. package/scripts/harness/traceability-model/domain/ports/work-item-status-port.ts +16 -0
  23. package/scripts/harness/traceability-model/domain/services/work-item-status-derivation-service.ts +121 -0
  24. package/scripts/harness/traceability-model/domain/value-objects/work-item-status-report.ts +47 -0
  25. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-status-gateway.ts +240 -0
  26. package/scripts/harness/traceability-model/presentation/cli/work-item-status-command-handler.ts +103 -0
  27. package/scripts/harness/validator-system/application/use-cases/run-l1-validators-usecase.ts +1 -44
  28. package/scripts/harness/validator-system/application/use-cases/run-l2-validators-usecase.ts +30 -0
  29. package/scripts/harness/validator-system/application/use-cases/run-l4-validators-usecase.ts +1 -1
  30. package/scripts/harness/validator-system/composition-root.ts +16 -13
  31. package/scripts/harness/validator-system/domain/services/cli-e2e-test-existence-service.ts +36 -5
  32. package/scripts/harness/validator-system/domain/value-objects/cli-e2e-test-coverage-report.ts +8 -2
  33. package/scripts/harness/validator-system/infrastructure/adapters/e2e-test-file-registry-adapter.ts +12 -3
  34. package/scripts/harness/validator-system/infrastructure/adapters/harness-config-validator-config-adapter.ts +1 -1
@@ -0,0 +1,240 @@
1
+ // @unit traceability-model
2
+ // @layer infrastructure
3
+ // @work-item-id WI-126
4
+
5
+ import { readdir, readFile, writeFile } from "node:fs/promises";
6
+ import * as path from "node:path";
7
+ import type { WorkItemStatusPort } from "../../domain/ports/work-item-status-port.js";
8
+ import type { WorkItemFrontmatter } from "../../domain/value-objects/work-item-frontmatter.js";
9
+ import type {
10
+ WorkItemStatusApplyResult,
11
+ WorkItemStatusInput,
12
+ WorkItemStatusReport,
13
+ } from "../../domain/value-objects/work-item-status-report.js";
14
+ import { parseWorkItemFrontmatter } from "../parsers/work-item-frontmatter-parser.js";
15
+
16
+ const WI_DIR_PATTERN = /^WI-\d+$/;
17
+ const MARKDOWN_PATTERN = /\.mdx?$/;
18
+ const SOURCE_PATTERN = /\.(?:ts|tsx|js|jsx)$/;
19
+ const SKIPPED_DIRS = new Set(["archive", "_shared", "_operation", "share", "node_modules", ".git"]);
20
+
21
+ export interface FileSystemWorkItemStatusGatewayDeps {
22
+ readonly rootDir: string;
23
+ readonly inceptionRoot?: string;
24
+ readonly productRoot?: string;
25
+ readonly sourceRoot?: string;
26
+ readonly testRoot?: string;
27
+ }
28
+
29
+ export class FileSystemWorkItemStatusGateway implements WorkItemStatusPort {
30
+ private readonly rootDir: string;
31
+ private readonly inceptionRoot: string;
32
+ private readonly productRoot: string;
33
+ private readonly sourceRoot: string;
34
+ private readonly testRoot: string;
35
+
36
+ constructor(deps: FileSystemWorkItemStatusGatewayDeps) {
37
+ this.rootDir = deps.rootDir;
38
+ this.inceptionRoot = deps.inceptionRoot ?? "docs/inception";
39
+ this.productRoot = deps.productRoot ?? "docs/product";
40
+ this.sourceRoot = deps.sourceRoot ?? "scripts/harness";
41
+ this.testRoot = deps.testRoot ?? "scripts/harness/__tests__";
42
+ }
43
+
44
+ async listWorkItemStatusInputs(): Promise<readonly WorkItemStatusInput[]> {
45
+ const entries = await this.listDescriptions(this.inceptionRoot);
46
+ const productFiles = await this.listFiles(this.productRoot, MARKDOWN_PATTERN);
47
+ const sourceFiles = (await this.listFiles(this.sourceRoot, SOURCE_PATTERN)).filter(
48
+ (filePath) => !filePath.startsWith(this.testRoot),
49
+ );
50
+ const testFiles = await this.listFiles(this.testRoot, SOURCE_PATTERN);
51
+
52
+ const inputs: WorkItemStatusInput[] = [];
53
+ for (const entry of entries) {
54
+ const content = await readFile(path.join(this.rootDir, entry.descriptionPath), "utf8");
55
+ const frontmatter = parseWorkItemFrontmatter(content);
56
+ if (frontmatter === null) continue;
57
+
58
+ const aliases = this.aliasesFor(frontmatter);
59
+ const productReflectionPaths = await this.filesContainingAny(productFiles, aliases);
60
+ const implementationPaths = await this.filesContainingAny(sourceFiles, aliases);
61
+ const testPaths = await this.filesContainingAny(testFiles, aliases);
62
+ const wiDir = path.posix.dirname(entry.descriptionPath);
63
+ const existingInceptionArtifacts = await this.existingInceptionArtifacts(wiDir);
64
+
65
+ inputs.push(Object.freeze({
66
+ descriptionPath: entry.descriptionPath,
67
+ directoryId: entry.directoryId,
68
+ ownerUnit: entry.ownerUnit,
69
+ frontmatter,
70
+ requiredInceptionArtifacts: this.requiredInceptionArtifacts(frontmatter),
71
+ existingInceptionArtifacts,
72
+ affectedUnits: this.affectedUnits(entry.ownerUnit, frontmatter),
73
+ productReflectionPaths,
74
+ implementationPaths,
75
+ testPaths,
76
+ }));
77
+ }
78
+ return Object.freeze(inputs.sort((a, b) => a.descriptionPath.localeCompare(b.descriptionPath)));
79
+ }
80
+
81
+ async applyDerivedStatuses(
82
+ reports: readonly WorkItemStatusReport[],
83
+ ): Promise<WorkItemStatusApplyResult> {
84
+ const updated: WorkItemStatusReport[] = [];
85
+ const unchanged: WorkItemStatusReport[] = [];
86
+ for (const report of reports) {
87
+ if (!report.stale) {
88
+ unchanged.push(report);
89
+ continue;
90
+ }
91
+ const absolutePath = path.join(this.rootDir, report.descriptionPath);
92
+ const content = await readFile(absolutePath, "utf8");
93
+ const nextContent = this.replaceStatusLine(content, report.derivedStatus);
94
+ await writeFile(absolutePath, nextContent, "utf8");
95
+ updated.push(report);
96
+ }
97
+ return Object.freeze({
98
+ updated: Object.freeze(updated),
99
+ unchanged: Object.freeze(unchanged),
100
+ });
101
+ }
102
+
103
+ private async listDescriptions(relativeDir: string): Promise<readonly {
104
+ readonly descriptionPath: string;
105
+ readonly directoryId: string;
106
+ readonly ownerUnit: string | null;
107
+ }[]> {
108
+ const results: {
109
+ descriptionPath: string;
110
+ directoryId: string;
111
+ ownerUnit: string | null;
112
+ }[] = [];
113
+ await this.collectDescriptions(relativeDir, results);
114
+ return results;
115
+ }
116
+
117
+ private async collectDescriptions(
118
+ relativeDir: string,
119
+ results: { descriptionPath: string; directoryId: string; ownerUnit: string | null }[],
120
+ ): Promise<void> {
121
+ let entries;
122
+ try {
123
+ entries = await readdir(path.join(this.rootDir, relativeDir), { withFileTypes: true });
124
+ } catch {
125
+ return;
126
+ }
127
+
128
+ for (const entry of entries) {
129
+ if (!entry.isDirectory() || SKIPPED_DIRS.has(entry.name)) continue;
130
+ const childDir = path.posix.join(relativeDir, entry.name);
131
+ if (WI_DIR_PATTERN.test(entry.name)) {
132
+ results.push({
133
+ descriptionPath: path.posix.join(childDir, "description.md"),
134
+ directoryId: entry.name,
135
+ ownerUnit: this.ownerUnitFor(childDir),
136
+ });
137
+ continue;
138
+ }
139
+ await this.collectDescriptions(childDir, results);
140
+ }
141
+ }
142
+
143
+ private ownerUnitFor(wiDir: string): string | null {
144
+ const relative = wiDir.slice(this.inceptionRoot.length).replace(/^\/+/, "");
145
+ const first = relative.split("/")[0];
146
+ if (!first || first === "_cross") return null;
147
+ return first;
148
+ }
149
+
150
+ private async listFiles(relativeDir: string, pattern: RegExp): Promise<readonly string[]> {
151
+ const results: string[] = [];
152
+ await this.collectFiles(relativeDir, pattern, results);
153
+ return Object.freeze(results.sort());
154
+ }
155
+
156
+ private async collectFiles(relativeDir: string, pattern: RegExp, results: string[]): Promise<void> {
157
+ let entries;
158
+ try {
159
+ entries = await readdir(path.join(this.rootDir, relativeDir), { withFileTypes: true });
160
+ } catch {
161
+ return;
162
+ }
163
+ for (const entry of entries) {
164
+ if (SKIPPED_DIRS.has(entry.name)) continue;
165
+ const childPath = path.posix.join(relativeDir, entry.name);
166
+ if (entry.isDirectory()) {
167
+ await this.collectFiles(childPath, pattern, results);
168
+ } else if (entry.isFile() && pattern.test(entry.name)) {
169
+ results.push(childPath);
170
+ }
171
+ }
172
+ }
173
+
174
+ private async filesContainingAny(files: readonly string[], aliases: readonly string[]): Promise<readonly string[]> {
175
+ const results: string[] = [];
176
+ for (const filePath of files) {
177
+ const content = await readFile(path.join(this.rootDir, filePath), "utf8");
178
+ if (aliases.some((alias) => this.containsWorkItemAnnotation(content, alias))) {
179
+ results.push(filePath);
180
+ }
181
+ }
182
+ return Object.freeze(results);
183
+ }
184
+
185
+ private containsWorkItemAnnotation(content: string, alias: string): boolean {
186
+ const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
187
+ return new RegExp(`@(work-item-id|story-id|issue-id|story)\\s+[^\\n]*\\b${escaped}\\b`).test(content);
188
+ }
189
+
190
+ private aliasesFor(frontmatter: WorkItemFrontmatter): readonly string[] {
191
+ return Object.freeze([frontmatter.id, frontmatter.legacyId].filter((value): value is string => Boolean(value)));
192
+ }
193
+
194
+ private requiredInceptionArtifacts(frontmatter: WorkItemFrontmatter): readonly string[] {
195
+ switch (frontmatter.type) {
196
+ case "story":
197
+ return Object.freeze(["description.md", "logical_design.md", "domain_model.md", "unit_test_design.md"]);
198
+ case "issue":
199
+ return Object.freeze(["description.md", "logical_design.md", "domain_model.md"]);
200
+ case "refactor":
201
+ return Object.freeze(["description.md", "logical_design.md"]);
202
+ case "fix":
203
+ case "chore":
204
+ return Object.freeze(["description.md"]);
205
+ }
206
+ }
207
+
208
+ private async existingInceptionArtifacts(wiDir: string): Promise<readonly string[]> {
209
+ try {
210
+ const entries = await readdir(path.join(this.rootDir, wiDir), { withFileTypes: true });
211
+ return Object.freeze(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
212
+ } catch {
213
+ return Object.freeze([]);
214
+ }
215
+ }
216
+
217
+ private affectedUnits(ownerUnit: string | null, frontmatter: WorkItemFrontmatter): readonly string[] {
218
+ if (frontmatter.affects && frontmatter.affects.length > 0) {
219
+ return Object.freeze([...frontmatter.affects]);
220
+ }
221
+ if (ownerUnit) return Object.freeze([ownerUnit]);
222
+ return Object.freeze([]);
223
+ }
224
+
225
+ private replaceStatusLine(content: string, status: string): string {
226
+ if (!content.startsWith("---")) {
227
+ throw new Error("description.md frontmatter is missing");
228
+ }
229
+ const frontmatterEnd = content.indexOf("\n---", 4);
230
+ if (frontmatterEnd === -1) {
231
+ throw new Error("description.md frontmatter is not closed");
232
+ }
233
+ const frontmatter = content.slice(0, frontmatterEnd);
234
+ if (!/(\nstatus:\s*)[^\n]+/.test(frontmatter)) {
235
+ throw new Error("description.md frontmatter status is missing");
236
+ }
237
+ const nextFrontmatter = frontmatter.replace(/(\nstatus:\s*)[^\n]+/, `$1${status}`);
238
+ return nextFrontmatter + content.slice(frontmatterEnd);
239
+ }
240
+ }
@@ -0,0 +1,103 @@
1
+ // @unit traceability-model
2
+ // @layer presentation
3
+ // @work-item-id WI-126
4
+
5
+ import type { ApplyWorkItemStatusUseCase } from "../../application/usecases/apply-work-item-status-usecase.js";
6
+ import type { DeriveWorkItemStatusUseCase } from "../../application/usecases/derive-work-item-status-usecase.js";
7
+ import type {
8
+ WorkItemStatusApplyResult,
9
+ WorkItemStatusReport,
10
+ } from "../../domain/value-objects/work-item-status-report.js";
11
+
12
+ export interface WorkItemStatusCommandInput {
13
+ readonly dryRun?: boolean;
14
+ readonly apply?: boolean;
15
+ readonly json?: boolean;
16
+ readonly failOnStale?: boolean;
17
+ readonly id?: string;
18
+ }
19
+
20
+ export interface WorkItemStatusCommandOutput {
21
+ readonly exitCode: 0 | 1 | 2;
22
+ readonly text: string;
23
+ readonly reports: readonly WorkItemStatusReport[];
24
+ }
25
+
26
+ export interface WorkItemStatusCommandHandlerDeps {
27
+ readonly deriveWorkItemStatusUseCase: Pick<DeriveWorkItemStatusUseCase, "execute">;
28
+ readonly applyWorkItemStatusUseCase: Pick<ApplyWorkItemStatusUseCase, "execute">;
29
+ }
30
+
31
+ export class WorkItemStatusCommandHandler {
32
+ private readonly deriveWorkItemStatusUseCase: Pick<DeriveWorkItemStatusUseCase, "execute">;
33
+ private readonly applyWorkItemStatusUseCase: Pick<ApplyWorkItemStatusUseCase, "execute">;
34
+
35
+ constructor(deps: WorkItemStatusCommandHandlerDeps) {
36
+ this.deriveWorkItemStatusUseCase = deps.deriveWorkItemStatusUseCase;
37
+ this.applyWorkItemStatusUseCase = deps.applyWorkItemStatusUseCase;
38
+ }
39
+
40
+ async execute(input: WorkItemStatusCommandInput): Promise<Readonly<WorkItemStatusCommandOutput>> {
41
+ if (input.apply && input.dryRun) {
42
+ return Object.freeze({
43
+ exitCode: 2,
44
+ text: "Error: --dry-run and --apply cannot be used together",
45
+ reports: Object.freeze([]),
46
+ });
47
+ }
48
+ if (!input.apply && !input.dryRun) {
49
+ return Object.freeze({
50
+ exitCode: 2,
51
+ text: "Error: either --dry-run or --apply is required",
52
+ reports: Object.freeze([]),
53
+ });
54
+ }
55
+
56
+ if (input.apply) {
57
+ const result = await this.applyWorkItemStatusUseCase.execute({ id: input.id });
58
+ const reports = Object.freeze([...result.updated, ...result.unchanged]);
59
+ return Object.freeze({
60
+ exitCode: 0,
61
+ text: input.json ? JSON.stringify(result, null, 2) : this.formatApply(result),
62
+ reports,
63
+ });
64
+ }
65
+
66
+ const allReports = await this.deriveWorkItemStatusUseCase.execute();
67
+ const reports = input.id
68
+ ? allReports.filter((report) => report.id === input.id)
69
+ : allReports;
70
+ const stale = reports.some((report) => report.stale);
71
+ return Object.freeze({
72
+ exitCode: stale && input.failOnStale ? 1 : 0,
73
+ text: input.json ? JSON.stringify({ reports }, null, 2) : this.formatReports(reports),
74
+ reports,
75
+ });
76
+ }
77
+
78
+ private formatReports(reports: readonly WorkItemStatusReport[]): string {
79
+ const lines = ["Work item status report"];
80
+ for (const report of reports) {
81
+ const marker = report.stale ? "STALE" : "OK";
82
+ lines.push(
83
+ `[${marker}] ${report.id}: current=${report.currentStatus} derived=${report.derivedStatus}`,
84
+ ` path: ${report.descriptionPath}`,
85
+ ` reason: ${report.reason}`,
86
+ ` next: ${report.nextAction}`,
87
+ );
88
+ }
89
+ return lines.join("\n");
90
+ }
91
+
92
+ private formatApply(result: WorkItemStatusApplyResult): string {
93
+ const lines = ["Work item status apply"];
94
+ for (const report of result.updated) {
95
+ lines.push(`updated ${report.id}: ${report.currentStatus} -> ${report.derivedStatus}`);
96
+ }
97
+ if (result.updated.length === 0) {
98
+ lines.push("no updates required");
99
+ }
100
+ lines.push(`unchanged: ${result.unchanged.length}`);
101
+ return lines.join("\n");
102
+ }
103
+ }
@@ -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