phasegate 0.160.20 → 0.160.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Fixed
11
+
12
+ - **WI-217 — personal inception/product consistency** — makes L4-002 validate configured personal documentation roots, scopes L4-004 freshness to `paths.designDocs`, adds personal hook L4 backstop behavior for `.phasegate-local` docs, and lets `scaffold-wi` use custom IDs and personal inception roots.
13
+
10
14
  ## [0.160.16] - 2026-05-22
11
15
 
12
16
  ### Fixed
@@ -18,4 +18,20 @@ if [ "${HARNESS_QUICK_MODE:-0}" = "1" ]; then
18
18
  else
19
19
  $PHASEGATE_CMD validate --layer L2 --format human
20
20
  fi
21
+ L2_EXIT=$?
22
+ if [ $L2_EXIT -ne 0 ]; then
23
+ echo "PhaseGate L2 validation failed."
24
+ exit 1
25
+ fi
26
+
27
+ STAGED_FILES="$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)"
28
+ if printf '%s\n' "$STAGED_FILES" | grep -E '^\.phasegate-local/(inception|product)/' >/dev/null 2>&1; then
29
+ $PHASEGATE_CMD validate --layer L4 --format human --fail-on-warning
30
+ L4_EXIT=$?
31
+ if [ $L4_EXIT -ne 0 ]; then
32
+ echo "PhaseGate local inception/product consistency failed."
33
+ exit 1
34
+ fi
35
+ fi
21
36
 
37
+ exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.160.20",
3
+ "version": "0.160.21",
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",
@@ -2,6 +2,7 @@
2
2
  * @layer application
3
3
  * @unit config-foundation
4
4
  * @work-item-id WI-133 / WI-156
5
+ * @work-item-id WI-217
5
6
  */
6
7
  import type { HarnessConfigV2 } from '../../domain/harness-config.js';
7
8
 
@@ -27,19 +28,26 @@ export function toValidatorSystemConfig(resolvedConfig: HarnessConfigV2 | undefi
27
28
  'pointer-validator': 'L4-005',
28
29
  'skill-catalog-drift': 'L4-006',
29
30
  }, /^L4-\d{3}$/);
31
+ const effectiveL4Validators = usesCustomDocumentRoots(resolvedConfig)
32
+ ? includeValidator(l4Validators, 'L4-002')
33
+ : l4Validators;
30
34
 
31
35
  return {
32
36
  project: { preset: resolvedConfig.project.preset },
37
+ paths: {
38
+ designDocs: resolvedConfig.paths.designDocs,
39
+ inceptionDocs: resolvedConfig.paths.inceptionDocs,
40
+ },
33
41
  layers: {
34
42
  L2: { enabled: resolvedConfig.layers.L2.enabled, validators: ['L2-001', 'L2-002', 'L2-003', 'L2-013', 'L2-014', 'L2-015'] },
35
43
  L3: {
36
44
  enabled: resolvedConfig.layers.L3.enabled,
37
- ...(l3Validators.length > 0 ? { validators: l3Validators } : {}),
45
+ validators: l3Validators.length > 0 ? l3Validators : ['L3-001', 'L3-002', 'L3-003', 'L3-004'],
38
46
  coverageThreshold: resolvedConfig.layers.L3.coverageThreshold,
39
47
  },
40
48
  L4: {
41
49
  enabled: resolvedConfig.layers.L4.enabled,
42
- ...(l4Validators.length > 0 ? { validators: l4Validators } : {}),
50
+ validators: effectiveL4Validators.length > 0 ? effectiveL4Validators : ['L4-001', 'L4-002', 'L4-003', 'L4-004', 'L4-005', 'L4-006'],
43
51
  },
44
52
  },
45
53
  harnesses: {
@@ -53,6 +61,15 @@ export function toValidatorSystemConfig(resolvedConfig: HarnessConfigV2 | undefi
53
61
  };
54
62
  }
55
63
 
64
+ function usesCustomDocumentRoots(resolvedConfig: HarnessConfigV2): boolean {
65
+ return resolvedConfig.paths.designDocs !== 'docs/product/construction'
66
+ || resolvedConfig.paths.inceptionDocs !== 'docs/inception';
67
+ }
68
+
69
+ function includeValidator(validators: readonly string[], validatorId: string): readonly string[] {
70
+ return validators.includes(validatorId) ? validators : [...validators, validatorId];
71
+ }
72
+
56
73
  function normalizeValidators(
57
74
  validators: readonly string[],
58
75
  aliases: Readonly<Record<string, string>>,
@@ -435,9 +435,7 @@
435
435
  "additionalProperties": false,
436
436
  "required": [
437
437
  "designDocs",
438
- "inceptionDocs",
439
- "principlesDocs",
440
- "folderRulesDoc"
438
+ "inceptionDocs"
441
439
  ],
442
440
  "properties": {
443
441
  "designDocs": {
@@ -18,6 +18,7 @@
18
18
  * @work-item-id WI-205
19
19
  * @work-item-id WI-206
20
20
  * @work-item-id WI-213
21
+ * @work-item-id WI-217
21
22
  *
22
23
  * Phasegate CLI エントリポイント。
23
24
  * 各Unitの Composition Root からハンドラーを取得し、コマンドに応じてディスパッチする。
@@ -169,7 +170,7 @@ Setup:
169
170
  --with-husky, --with-ci, --yes)
170
171
  update-skills Alias for reconcile (kept for compatibility)
171
172
  doctor Diagnose silent installation failures (--json, --strict, --personal, --agent <claude|codex|both>, --report-out <path>)
172
- scaffold-wi <unit|_cross> <story|issue|chore>
173
+ scaffold-wi <unit|_cross> <story|issue|fix|refactor|chore>
173
174
  Create docs/inception/{unit}/WI-XXX/description.md
174
175
  emit-agent-rules Print AGENTS.md / CLAUDE.md WI workflow rules block
175
176
  install Install phasegate managed files (--dry-run|--apply, --force, --personal, --agent <claude|codex|both>)
@@ -280,14 +281,14 @@ function hasFlag(args: readonly string[], flag: string): boolean {
280
281
  }
281
282
 
282
283
  type WorkflowMode = "standard" | "strict";
283
- type ScaffoldWorkItemType = "story" | "issue" | "chore";
284
+ type ScaffoldWorkItemType = "story" | "issue" | "fix" | "refactor" | "chore";
284
285
 
285
286
  function parseWorkflowMode(value: string | undefined): WorkflowMode {
286
287
  return value === "strict" ? "strict" : "standard";
287
288
  }
288
289
 
289
290
  function parseScaffoldWorkItemType(value: string | undefined): ScaffoldWorkItemType | null {
290
- if (value === "story" || value === "issue" || value === "chore") return value;
291
+ if (value === "story" || value === "issue" || value === "fix" || value === "refactor" || value === "chore") return value;
291
292
  return null;
292
293
  }
293
294
 
@@ -319,8 +320,8 @@ async function listFilesRecursive(root: string): Promise<string[]> {
319
320
  }
320
321
  }
321
322
 
322
- async function nextWorkItemId(rootDir: string): Promise<string> {
323
- const files = await listFilesRecursive(join(rootDir, "docs", "inception"));
323
+ async function nextWorkItemId(rootDir: string, inceptionRoot = "docs/inception"): Promise<string> {
324
+ const files = await listFilesRecursive(join(rootDir, inceptionRoot));
324
325
  let max = 0;
325
326
  for (const file of files) {
326
327
  const match = file.match(/\/WI-(\d{3})\/description\.md$/);
@@ -336,19 +337,25 @@ async function countLegacyPlansWithoutWorkItems(rootDir: string): Promise<number
336
337
  return files.filter((file) => file.includes("/codding_plan/") || file.endsWith("_plan.md")).length;
337
338
  }
338
339
 
339
- async function scaffoldInceptionRoots(rootDir: string, unit: string | null = null): Promise<void> {
340
- await fsMkdir(join(rootDir, "docs", "inception", "_shared"), { recursive: true });
341
- await fsMkdir(join(rootDir, "docs", "inception", "_cross"), { recursive: true });
340
+ async function scaffoldInceptionRoots(rootDir: string, unit: string | null = null, inceptionRoot = "docs/inception"): Promise<void> {
341
+ await fsMkdir(join(rootDir, inceptionRoot, "_shared"), { recursive: true });
342
+ await fsMkdir(join(rootDir, inceptionRoot, "_cross"), { recursive: true });
342
343
  if (unit && unit !== "_cross" && unit !== "_shared") {
343
- await fsMkdir(join(rootDir, "docs", "inception", unit), { recursive: true });
344
- await fsWriteFile(join(rootDir, "docs", "inception", unit, ".gitkeep"), "", "utf8").catch(() => undefined);
344
+ await fsMkdir(join(rootDir, inceptionRoot, unit), { recursive: true });
345
+ await fsWriteFile(join(rootDir, inceptionRoot, unit, ".gitkeep"), "", "utf8").catch(() => undefined);
345
346
  }
346
347
  }
347
348
 
348
- async function scaffoldWorkItem(rootDir: string, unit: string, type: ScaffoldWorkItemType): Promise<string> {
349
- const id = await nextWorkItemId(rootDir);
350
- await scaffoldInceptionRoots(rootDir, unit);
351
- const targetBase = unit === "_cross" ? join(rootDir, "docs", "inception", "_cross") : join(rootDir, "docs", "inception", unit);
349
+ async function scaffoldWorkItem(
350
+ rootDir: string,
351
+ unit: string,
352
+ type: ScaffoldWorkItemType,
353
+ options: { readonly id?: string; readonly inceptionRoot?: string } = {},
354
+ ): Promise<string> {
355
+ const inceptionRoot = options.inceptionRoot ?? "docs/inception";
356
+ const id = options.id ?? await nextWorkItemId(rootDir, inceptionRoot);
357
+ await scaffoldInceptionRoots(rootDir, unit, inceptionRoot);
358
+ const targetBase = unit === "_cross" ? join(rootDir, inceptionRoot, "_cross") : join(rootDir, inceptionRoot, unit);
352
359
  const targetDir = join(targetBase, id);
353
360
  await fsMkdir(targetDir, { recursive: true });
354
361
  const descriptionPath = join(targetDir, "description.md");
@@ -754,15 +761,18 @@ Options:
754
761
  Examples:
755
762
  phasegate check-change-category --paths src/foo.ts,src/bar.ts
756
763
  phasegate check-change-category --paths src/foo.ts --format json`,
757
- "scaffold-wi": `Usage: phasegate scaffold-wi <unit|_cross> <story|issue|chore>
764
+ "scaffold-wi": `Usage: phasegate scaffold-wi <unit|_cross> <story|issue|fix|refactor|chore> [options]
758
765
 
759
766
  Create docs/inception/{unit}/WI-XXX/description.md.
760
767
 
761
768
  Arguments:
762
769
  <unit|_cross> Unit id or _cross for cross-cutting work items.
763
- <story|issue|chore> Work item type.
770
+ <story|issue|fix|refactor|chore>
771
+ Work item type.
764
772
 
765
773
  Options:
774
+ --id <work-item-id> Use the supplied id instead of allocating WI-XXX.
775
+ --root <path> Inception root. Defaults to docs/inception, or personal paths.inceptionDocs.
766
776
  --help, -h Show this help`,
767
777
  "scaffold-design": `Usage: phasegate scaffold-design --unit <id> --phase <phase> [options]
768
778
 
@@ -2035,13 +2045,25 @@ async function main(): Promise<void> {
2035
2045
  }
2036
2046
 
2037
2047
  case "scaffold-wi": {
2048
+ const KNOWN_SCAFFOLD_WI_FLAGS = ["--id", "--root"];
2049
+ const flagError = validateKnownFlags(args, KNOWN_SCAFFOLD_WI_FLAGS);
2050
+ if (flagError) {
2051
+ console.error(flagError);
2052
+ process.exit(2);
2053
+ }
2038
2054
  const unit = args[1];
2039
2055
  const type = parseScaffoldWorkItemType(args[2]);
2040
2056
  if (!unit || !type) {
2041
- console.error("Usage: phasegate scaffold-wi <unit|_cross> <story|issue|chore>");
2057
+ console.error("Usage: phasegate scaffold-wi <unit|_cross> <story|issue|fix|refactor|chore> [--id <work-item-id>] [--root <path>]");
2042
2058
  process.exit(2);
2043
2059
  }
2044
- const descriptionPath = await scaffoldWorkItem(rootDir, unit, type);
2060
+ const configuredPersonalRoot = resolvedConfig?.paths.inceptionDocs?.startsWith(".phasegate-local/")
2061
+ ? resolvedConfig.paths.inceptionDocs
2062
+ : undefined;
2063
+ const descriptionPath = await scaffoldWorkItem(rootDir, unit, type, {
2064
+ id: parseFlag(args, "--id"),
2065
+ inceptionRoot: parseFlag(args, "--root") ?? configuredPersonalRoot,
2066
+ });
2045
2067
  console.log(`Created ${descriptionPath}`);
2046
2068
  process.exit(0);
2047
2069
  break;
@@ -18,4 +18,5 @@ export interface ValidationResultContract {
18
18
  }[];
19
19
  readonly durationMs: number;
20
20
  readonly skipped?: boolean;
21
+ readonly skipReason?: string;
21
22
  }
@@ -24,6 +24,7 @@ export class ValidationResultContractMapper {
24
24
  }),
25
25
  durationMs: result.durationMs,
26
26
  skipped: result.skipped,
27
+ ...(result.skipReason ? { skipReason: result.skipReason } : {}),
27
28
  };
28
29
  }
29
30
 
@@ -2,6 +2,7 @@
2
2
  * @layer application
3
3
  * @unit validator-system
4
4
  * @work-item-id WI-107 / WI-156
5
+ * @work-item-id WI-217
5
6
  *
6
7
  * RunL4ValidatorsUseCase — H08-03: L4バリデータ実行
7
8
  */
@@ -70,6 +71,10 @@ export interface RunL4ValidatorsUseCaseDeps {
70
71
  deadCodeDetectionService?: DeadCodeDetectionService;
71
72
  architectureSemanticAnalysisService?: ArchitectureSemanticAnalysisService;
72
73
  skillCatalogDriftPort?: SkillCatalogDriftPort;
74
+ pathRoots?: {
75
+ readonly inceptionRoot: string;
76
+ readonly designRoot: string;
77
+ };
73
78
  checkDocFreshnessUseCase?: CheckDocFreshnessUseCasePort;
74
79
  validateDocPointersUseCase?: ValidateDocPointersUseCasePort;
75
80
  }
@@ -85,6 +90,7 @@ export class RunL4ValidatorsUseCase {
85
90
  private readonly architectureSemanticAnalysisService?: ArchitectureSemanticAnalysisService;
86
91
  private readonly skillCatalogDriftPort?: SkillCatalogDriftPort;
87
92
  private readonly skillCatalogDriftService = new SkillCatalogDriftService();
93
+ private readonly pathRoots: { readonly inceptionRoot: string; readonly designRoot: string };
88
94
  private readonly checkDocFreshnessUseCase?: CheckDocFreshnessUseCasePort;
89
95
  private readonly validateDocPointersUseCase?: ValidateDocPointersUseCasePort;
90
96
 
@@ -98,6 +104,10 @@ export class RunL4ValidatorsUseCase {
98
104
  this.deadCodeDetectionService = deps.deadCodeDetectionService;
99
105
  this.architectureSemanticAnalysisService = deps.architectureSemanticAnalysisService;
100
106
  this.skillCatalogDriftPort = deps.skillCatalogDriftPort;
107
+ this.pathRoots = deps.pathRoots ?? {
108
+ inceptionRoot: 'docs/inception',
109
+ designRoot: 'docs/product/construction',
110
+ };
101
111
  this.checkDocFreshnessUseCase = deps.checkDocFreshnessUseCase;
102
112
  this.validateDocPointersUseCase = deps.validateDocPointersUseCase;
103
113
  }
@@ -158,13 +168,25 @@ export class RunL4ValidatorsUseCase {
158
168
  const l4002Result = overrideMap.get('L4-002');
159
169
  if (l4002Result && !l4002Result.skipped) {
160
170
  const report = await this.consistencyCheckService.check(input.targetUnits ? [...input.targetUnits] : undefined);
171
+ const reflectionResult = this.usesConfiguredDocumentRoots()
172
+ ? await this.consistencyCheckService.checkWorkItemReflection(this.pathRoots)
173
+ : undefined;
161
174
  const architectureSemanticErrors = this.architectureSemanticAnalysisService
162
175
  ? await this.architectureSemanticAnalysisService.analyze()
163
176
  : [];
164
- if (report.hasMismatches() || architectureSemanticErrors.length > 0) {
177
+ if (report.hasMismatches() || (reflectionResult?.report.hasMismatches() ?? false) || architectureSemanticErrors.length > 0) {
165
178
  overrideMap.set(
166
179
  'L4-002',
167
- ValidationResult.fail(ValidatorId.create('L4-002'), [...report.toHarnessErrors(), ...architectureSemanticErrors], 0),
180
+ ValidationResult.fail(
181
+ ValidatorId.create('L4-002'),
182
+ [...report.toHarnessErrors(), ...(reflectionResult?.report.toHarnessErrors() ?? []), ...architectureSemanticErrors],
183
+ 0,
184
+ ),
185
+ );
186
+ } else if (reflectionResult?.skipReason && !report.hasMismatches() && architectureSemanticErrors.length === 0) {
187
+ overrideMap.set(
188
+ 'L4-002',
189
+ ValidationResult.skipWithReason(ValidatorId.create('L4-002'), reflectionResult.skipReason),
168
190
  );
169
191
  }
170
192
  }
@@ -187,7 +209,10 @@ export class RunL4ValidatorsUseCase {
187
209
  if (this.checkDocFreshnessUseCase) {
188
210
  const l4004Result = overrideMap.get('L4-004');
189
211
  if (l4004Result && !l4004Result.skipped) {
190
- const freshnessOutput = await this.checkDocFreshnessUseCase.execute({ format: 'json' });
212
+ const freshnessOutput = await this.checkDocFreshnessUseCase.execute({
213
+ format: 'json',
214
+ targetPattern: `${this.pathRoots.designRoot.replace(/\/+$/g, '')}/**/*.md`,
215
+ });
191
216
  const errors = this.toDocFreshnessHarnessErrors(freshnessOutput);
192
217
  overrideMap.set(
193
218
  'L4-004',
@@ -251,6 +276,10 @@ export class RunL4ValidatorsUseCase {
251
276
  return [...executionErrors, ...freshnessFindings];
252
277
  }
253
278
 
279
+ private usesConfiguredDocumentRoots(): boolean {
280
+ return this.pathRoots.inceptionRoot !== 'docs/inception' || this.pathRoots.designRoot !== 'docs/product/construction';
281
+ }
282
+
254
283
  private toPointerValidationHarnessErrors(output: ValidateDocPointersOutputContract): readonly ValidationResult['errors'][number][] {
255
284
  const executionErrors = output.errors.map((error) => this.toHarnessErrorLike(
256
285
  'L4-005',
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * DI 組み立て — validator-system の全依存関係を構築する
6
6
  * @work-item-id WI-110 / WI-111 / WI-132 / WI-133 / WI-136 / WI-137 / WI-138 / WI-156
7
+ * @work-item-id WI-217
7
8
  */
8
9
  import { ValidatorId } from './domain/value-objects/validator-id.js';
9
10
  import { ValidatorDefinition } from './domain/value-objects/validator-definition.js';
@@ -37,6 +38,7 @@ import { AdrFoundationReferenceAdapter } from './infrastructure/adapters/adr-fou
37
38
  import { ImportGraphSourceAnalysisAdapter } from './infrastructure/adapters/import-graph-source-analysis-adapter.js';
38
39
  import { FileSystemArchitectureSemanticSourceAdapter } from './infrastructure/adapters/file-system-architecture-semantic-source-adapter.js';
39
40
  import { FileSystemSkillCatalogDriftAdapter } from './infrastructure/adapters/file-system-skill-catalog-drift-adapter.js';
41
+ import { FileSystemWorkItemReflectionAdapter } from './infrastructure/adapters/file-system-work-item-reflection-adapter.js';
40
42
  import { DriftDetectionService } from './domain/services/l4/drift-detection-service.js';
41
43
  import { ConsistencyCheckService } from './domain/services/l4/consistency-check-service.js';
42
44
  import { DeadCodeDetectionService } from './domain/services/l4/dead-code-detection-service.js';
@@ -55,6 +57,10 @@ const DEFAULT_CONFIG = {
55
57
  L3: { enabled: true, validators: ['L3-001', 'L3-002', 'L3-003', 'L3-004'], coverageThreshold: 90, bundleSizeLimit: 512000 },
56
58
  L4: { enabled: true, validators: ['L4-001', 'L4-002', 'L4-003', 'L4-004', 'L4-005', 'L4-006'] },
57
59
  },
60
+ paths: {
61
+ designDocs: 'docs/product/construction',
62
+ inceptionDocs: 'docs/inception',
63
+ },
58
64
  validate: { failOnWarning: false },
59
65
  architecture: {
60
66
  capabilityPolicies: {
@@ -149,8 +155,10 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
149
155
  const workItemStatusPolicyPort = new TraceabilityWorkItemStatusPolicyAdapter(process.cwd());
150
156
  const contractTraceabilityPolicyPort = new FileSystemContractTraceabilityPolicyAdapter();
151
157
 
152
- const docsRoot = join(process.cwd(), 'docs/product/construction');
153
158
  const cwd = process.cwd();
159
+ const designDocsRoot = configData.paths?.designDocs ?? 'docs/product/construction';
160
+ const inceptionDocsRoot = configData.paths?.inceptionDocs ?? 'docs/inception';
161
+ const docsRoot = join(cwd, designDocsRoot);
154
162
  const e2eTestFileRegistryPort = new E2eTestFileRegistryAdapter({ e2eTestRoot: join(cwd, 'scripts/harness/__tests__/e2e') });
155
163
  const cliCommandRegistryPort = new CliCommandRegistryAdapter({
156
164
  commands: [
@@ -191,6 +199,7 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
191
199
  const sourceAnalysisPort = new ImportGraphSourceAnalysisAdapter();
192
200
  const architectureSemanticSourcePort = new FileSystemArchitectureSemanticSourceAdapter();
193
201
  const skillCatalogDriftPort = new FileSystemSkillCatalogDriftAdapter(cwd);
202
+ const workItemReflectionPort = new FileSystemWorkItemReflectionAdapter(cwd);
194
203
 
195
204
  const driftDetectionService = new DriftDetectionService({
196
205
  designDocumentPort: markdownDesignDocumentPort,
@@ -199,6 +208,7 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
199
208
  const consistencyCheckService = new ConsistencyCheckService({
200
209
  designDocumentPort: markdownDesignDocumentPort,
201
210
  adrReferencePort,
211
+ workItemReflectionPort,
202
212
  });
203
213
  const deadCodeDetectionService = new DeadCodeDetectionService({
204
214
  sourceAnalysisPort,
@@ -219,6 +229,10 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
219
229
  deadCodeDetectionService,
220
230
  architectureSemanticAnalysisService,
221
231
  skillCatalogDriftPort,
232
+ pathRoots: {
233
+ inceptionRoot: inceptionDocsRoot,
234
+ designRoot: designDocsRoot,
235
+ },
222
236
  checkDocFreshnessUseCase: phase2Extensions.checkDocFreshnessUseCase,
223
237
  validateDocPointersUseCase: phase2Extensions.validateDocPointersUseCase,
224
238
  });
@@ -7,6 +7,7 @@
7
7
  * 設計文書間のレイヤー整合性検証(L4-002)
8
8
  */
9
9
  import { ConsistencyReport } from '../../value-objects/consistency-report.js';
10
+ import type { MismatchPair } from '../../value-objects/consistency-report.js';
10
11
 
11
12
  export interface ConsistencyDesignDocumentPort {
12
13
  getLayerAnnotations(targetDocs?: readonly string[]): Promise<Record<string, string>>;
@@ -16,24 +17,47 @@ export interface ConsistencyAdrReferencePort {
16
17
  exists(adrRef: string): Promise<boolean>;
17
18
  }
18
19
 
20
+ export interface WorkItemReflectionSnapshot {
21
+ readonly workItems: readonly {
22
+ readonly id: string;
23
+ readonly path: string;
24
+ readonly type?: string;
25
+ }[];
26
+ readonly productRefs: readonly {
27
+ readonly id: string;
28
+ readonly path: string;
29
+ }[];
30
+ readonly skipReason?: string;
31
+ }
32
+
33
+ export interface WorkItemReflectionPort {
34
+ collect(input: {
35
+ readonly inceptionRoot: string;
36
+ readonly designRoot: string;
37
+ }): Promise<WorkItemReflectionSnapshot>;
38
+ }
39
+
19
40
  export interface ConsistencyCheckServiceDeps {
20
41
  designDocumentPort: ConsistencyDesignDocumentPort;
21
42
  adrReferencePort: ConsistencyAdrReferencePort;
43
+ workItemReflectionPort?: WorkItemReflectionPort;
22
44
  }
23
45
 
24
46
  export class ConsistencyCheckService {
25
47
  private readonly designDocumentPort: ConsistencyDesignDocumentPort;
26
48
  private readonly adrReferencePort: ConsistencyAdrReferencePort;
49
+ private readonly workItemReflectionPort?: WorkItemReflectionPort;
27
50
 
28
51
  constructor(deps: ConsistencyCheckServiceDeps) {
29
52
  this.designDocumentPort = deps.designDocumentPort;
30
53
  this.adrReferencePort = deps.adrReferencePort;
54
+ this.workItemReflectionPort = deps.workItemReflectionPort;
31
55
  }
32
56
 
33
57
  async check(targetDocs?: readonly string[]): Promise<ConsistencyReport> {
34
58
  const layerAnnotations = await this.designDocumentPort.getLayerAnnotations(targetDocs);
35
59
 
36
- const mismatchPairs: { expected: string; actual: string; location: string }[] = [];
60
+ const mismatchPairs: MismatchPair[] = [];
37
61
  const checkTargets = Object.keys(layerAnnotations);
38
62
 
39
63
  for (const [location, annotation] of Object.entries(layerAnnotations)) {
@@ -75,4 +99,57 @@ export class ConsistencyCheckService {
75
99
  checkedAt: new Date().toISOString(),
76
100
  });
77
101
  }
102
+
103
+ async checkWorkItemReflection(input: {
104
+ readonly inceptionRoot: string;
105
+ readonly designRoot: string;
106
+ }): Promise<{ readonly report: ConsistencyReport; readonly skipReason?: string }> {
107
+ if (!this.workItemReflectionPort) {
108
+ return {
109
+ report: ConsistencyReport.create({ mismatchPairs: [], checkTargets: [], checkedAt: new Date().toISOString() }),
110
+ skipReason: 'work item reflection scanner is not configured',
111
+ };
112
+ }
113
+
114
+ const snapshot = await this.workItemReflectionPort.collect(input);
115
+ const checkTargets = [
116
+ ...snapshot.workItems.map((item) => `${item.path}#work-item:${item.id}`),
117
+ ...snapshot.productRefs.map((ref) => `${ref.path}#work-item:${ref.id}`),
118
+ ];
119
+ const productRefIds = new Set(snapshot.productRefs.map((ref) => ref.id));
120
+ const workItemIds = new Set(snapshot.workItems.map((item) => item.id));
121
+ const reflectionRequiredItems = snapshot.workItems.filter((item) => item.type !== 'chore');
122
+ const mismatchPairs: MismatchPair[] = [];
123
+
124
+ for (const item of reflectionRequiredItems) {
125
+ if (!productRefIds.has(item.id)) {
126
+ mismatchPairs.push({
127
+ expected: `product docs contain @work-item-id ${item.id}`,
128
+ actual: 'missing product reflection',
129
+ location: item.path,
130
+ nextAction: `Add @work-item-id ${item.id} to the matching product construction document under ${input.designRoot}.`,
131
+ });
132
+ }
133
+ }
134
+
135
+ for (const ref of snapshot.productRefs) {
136
+ if (!workItemIds.has(ref.id)) {
137
+ mismatchPairs.push({
138
+ expected: `inception description exists for ${ref.id}`,
139
+ actual: 'orphan product reflection',
140
+ location: ref.path,
141
+ nextAction: `Create or restore an inception description for ${ref.id} under ${input.inceptionRoot}, or remove the stale product annotation.`,
142
+ });
143
+ }
144
+ }
145
+
146
+ return {
147
+ report: ConsistencyReport.create({
148
+ mismatchPairs,
149
+ checkTargets,
150
+ checkedAt: new Date().toISOString(),
151
+ }),
152
+ skipReason: snapshot.skipReason,
153
+ };
154
+ }
78
155
  }
@@ -22,6 +22,7 @@ export interface ValidationResultRawProps {
22
22
  readonly errors: readonly HarnessErrorLike[];
23
23
  readonly durationMs: number;
24
24
  readonly skipped: boolean;
25
+ readonly skipReason?: string;
25
26
  }
26
27
 
27
28
  export class ValidationResult {
@@ -30,6 +31,7 @@ export class ValidationResult {
30
31
  readonly errors: readonly HarnessErrorLike[];
31
32
  readonly durationMs: number;
32
33
  readonly skipped: boolean;
34
+ readonly skipReason?: string;
33
35
 
34
36
  private constructor(props: ValidationResultRawProps) {
35
37
  this.validatorId = props.validatorId;
@@ -37,6 +39,7 @@ export class ValidationResult {
37
39
  this.errors = Object.freeze([...props.errors]);
38
40
  this.durationMs = props.durationMs;
39
41
  this.skipped = props.skipped;
42
+ this.skipReason = props.skipReason;
40
43
  Object.freeze(this);
41
44
  }
42
45
 
@@ -71,6 +74,10 @@ export class ValidationResult {
71
74
  return new ValidationResult({ validatorId, passed: true, errors: [], durationMs: 0, skipped: true });
72
75
  }
73
76
 
77
+ static skipWithReason(validatorId: ValidatorId, skipReason: string): ValidationResult {
78
+ return new ValidationResult({ validatorId, passed: true, errors: [], durationMs: 0, skipped: true, skipReason });
79
+ }
80
+
74
81
  hasErrors(): boolean {
75
82
  return this.errors.length > 0;
76
83
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @layer infrastructure
3
+ * @unit validator-system
4
+ * @work-item-id WI-217
5
+ */
6
+ import { readFile, readdir } from 'node:fs/promises';
7
+ import { join, relative } from 'node:path';
8
+ import type {
9
+ WorkItemReflectionPort,
10
+ WorkItemReflectionSnapshot,
11
+ } from '../../domain/services/l4/consistency-check-service.js';
12
+
13
+ const DESCRIPTION_FILE = 'description.md';
14
+ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---/;
15
+ const WORK_ITEM_ANNOTATION_PATTERN = /@work-item-id\s+([^<\r\n]+)/g;
16
+
17
+ function normalizePath(value: string): string {
18
+ return value.replace(/\\/g, '/');
19
+ }
20
+
21
+ async function listFiles(root: string): Promise<string[]> {
22
+ try {
23
+ const entries = await readdir(root, { withFileTypes: true });
24
+ const files: string[] = [];
25
+ for (const entry of entries) {
26
+ const path = join(root, entry.name);
27
+ if (entry.isDirectory()) {
28
+ files.push(...(await listFiles(path)));
29
+ } else if (entry.isFile()) {
30
+ files.push(path);
31
+ }
32
+ }
33
+ return files;
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ function extractFrontmatterValue(markdown: string, key: string): string | undefined {
40
+ const frontmatter = FRONTMATTER_PATTERN.exec(markdown)?.[1];
41
+ if (!frontmatter) return undefined;
42
+ const pattern = new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'm');
43
+ return pattern.exec(frontmatter)?.[1]?.replace(/^["']|["']$/g, '').trim();
44
+ }
45
+
46
+ function extractWorkItemRefs(markdown: string): string[] {
47
+ const refs = new Set<string>();
48
+ for (const match of markdown.matchAll(WORK_ITEM_ANNOTATION_PATTERN)) {
49
+ const raw = match[1].replace(/-->.*/, '');
50
+ for (const token of raw.split(/[,\s]+/).map((part) => part.trim()).filter(Boolean)) {
51
+ if (/^[A-Za-z][A-Za-z0-9_-]*-\d+(?:-\d+)*$/.test(token)) {
52
+ refs.add(token);
53
+ }
54
+ }
55
+ }
56
+ return [...refs];
57
+ }
58
+
59
+ export class FileSystemWorkItemReflectionAdapter implements WorkItemReflectionPort {
60
+ constructor(private readonly projectRoot: string) {}
61
+
62
+ async collect(input: {
63
+ readonly inceptionRoot: string;
64
+ readonly designRoot: string;
65
+ }): Promise<WorkItemReflectionSnapshot> {
66
+ const inceptionRoot = join(this.projectRoot, input.inceptionRoot);
67
+ const designRoot = join(this.projectRoot, input.designRoot);
68
+ const descriptionFiles = (await listFiles(inceptionRoot))
69
+ .filter((path) => path.endsWith(`/${DESCRIPTION_FILE}`));
70
+
71
+ if (descriptionFiles.length === 0) {
72
+ return {
73
+ workItems: [],
74
+ productRefs: [],
75
+ skipReason: `no work item descriptions found under ${input.inceptionRoot}`,
76
+ };
77
+ }
78
+
79
+ const workItems: Array<WorkItemReflectionSnapshot['workItems'][number]> = [];
80
+ for (const path of descriptionFiles) {
81
+ const markdown = await readFile(path, 'utf8');
82
+ const id = extractFrontmatterValue(markdown, 'id');
83
+ if (!id) continue;
84
+ workItems.push({
85
+ id,
86
+ path: normalizePath(relative(this.projectRoot, path)),
87
+ type: extractFrontmatterValue(markdown, 'type'),
88
+ });
89
+ }
90
+
91
+ const productRefs: Array<WorkItemReflectionSnapshot['productRefs'][number]> = [];
92
+ const productFiles = (await listFiles(designRoot)).filter((path) => path.endsWith('.md'));
93
+ for (const path of productFiles) {
94
+ const markdown = await readFile(path, 'utf8');
95
+ for (const id of extractWorkItemRefs(markdown)) {
96
+ productRefs.push({ id, path: normalizePath(relative(this.projectRoot, path)) });
97
+ }
98
+ }
99
+
100
+ return { workItems, productRefs };
101
+ }
102
+ }
@@ -18,6 +18,9 @@ export class AgentValidationResultFormatter {
18
18
  lines.push(`VALIDATOR: ${result.validatorId}`);
19
19
  lines.push(`STATUS: ${result.skipped ? 'SKIPPED' : result.passed ? 'PASSED' : 'FAILED'}`);
20
20
  lines.push(`DURATION: ${result.durationMs}ms`);
21
+ if (result.skipped && result.skipReason) {
22
+ lines.push(`SKIP_REASON: ${result.skipReason}`);
23
+ }
21
24
  if (result.errors.length > 0) {
22
25
  lines.push('ERRORS:');
23
26
  for (const error of result.errors) {
@@ -25,6 +25,9 @@ export class HumanValidationResultFormatter {
25
25
  ? 'FAIL'
26
26
  : 'WARN';
27
27
  lines.push(`[${status}] ${result.validatorId} (${result.durationMs}ms)`);
28
+ if (result.skipped && result.skipReason) {
29
+ lines.push(` → ${result.skipReason}`);
30
+ }
28
31
  for (const error of result.errors) {
29
32
  lines.push(` ⚠ ${error.message}`);
30
33
  lines.push(` → ${error.suggestion}`);