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