phasegate 0.138.0 → 0.138.1

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,12 @@ 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-106 — inception WI ID 重複防止** — `docs/inception/**/WI-XXX/description.md` の frontmatter `id` を global scan し、`_cross` と Unit 配下をまたぐ重複、および parent directory 名と `id` の不一致を `validate-metadata` 経路で検出するようにした。
13
+ - `docs/folder_management_rules.md` / `AGENTS.md` に、新規 WI 作成時の global unique ルールを明記。
14
+ - `migrate work-items` の採番が `_cross` と Unit 配下の既存 WI 番号を避けることを回帰テストで固定。
15
+
10
16
  ## [0.138.0] - 2026-05-09
11
17
 
12
18
  ### Fixed
@@ -77,6 +77,8 @@ scripts/harness/{unit}/(domain|application|infrastructure|presentation)/*.ts
77
77
  | `inception/_cross/{WI-XXX}/` | 複数 Unit に影響する cross-cutting WI | `_cross/WI-026/`, `_cross/WI-031/` |
78
78
  | `inception/{unit}/{WI-XXX}/` | 単一 Unit が所有する WI | `validator-system/WI-074/` |
79
79
 
80
+ `WI-XXX` は `docs/inception/**` 全体で一意でなければなりません。`_cross/WI-031` と `{unit}/WI-031` のように配置先が違っても同じ ID は使えません。`description.md` の frontmatter `id` は parent directory 名と一致させます。
81
+
80
82
  > **廃止された配置**(v0.104.0 で物理削除):
81
83
  > - `docs/inception/issues/{ISSUE-XXX}/`
82
84
  > - `docs/inception/{unit}/issues/{ISSUE-XXX}/`
@@ -266,7 +268,8 @@ TESTED @work-item-id 付きテストが存在し green
266
268
 
267
269
  `migrate work-items --apply` および新規 WI 起票時の採番:
268
270
 
269
- - 既存 WI 番号は予約(重複しない)
271
+ - 既存 WI 番号は `docs/inception/**/WI-XXX` 全体で予約(`_cross` と Unit 配下をまたいで重複しない)
272
+ - `description.md` の frontmatter `id` は parent directory 名 `WI-XXX` と一致させる
270
273
  - 空き番号の **若い順**で sequential allocation
271
274
  - legacy ID(`ISSUE-XXX` / `US-XXX` / `H{NN}-{NN}`)が存在する場合、frontmatter の `legacy_id` に保持
272
275
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.138.0",
3
+ "version": "0.138.1",
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",
@@ -12,6 +12,11 @@ import {
12
12
  import type { ProjectRelativePath } from "../../domain/value-objects/project-relative-path.js";
13
13
  import type { WorkItemFrontmatter } from "../../domain/value-objects/work-item-frontmatter.js";
14
14
  import { WorkItemFrontmatterValidationError } from "../../domain/value-objects/work-item-frontmatter.js";
15
+ import type { WorkItemIdentityPort } from "../../domain/ports/work-item-identity-port.js";
16
+ import {
17
+ WorkItemIdentityValidationService,
18
+ type WorkItemIdentityViolation,
19
+ } from "../../domain/services/work-item-identity-validation-service.js";
15
20
  import type { MetadataValidationOutput } from "../dto/metadata-validation-output.js";
16
21
 
17
22
  type DesignStoryAnnotationsValidator = Pick<MetadataValidator, "validateDesignDocument">;
@@ -41,6 +46,20 @@ status: drafted
41
46
  ---`,
42
47
  });
43
48
 
49
+ const toWorkItemIdentityError = (violation: WorkItemIdentityViolation): TraceabilityHarnessError =>
50
+ Object.freeze({
51
+ code: "L2-002",
52
+ severity: "error",
53
+ message: violation.message,
54
+ suggestion: "docs/inception/**/WI-XXX/description.md の parent directory 名と frontmatter id を一致させ、WI id を inception 全体で一意にしてください",
55
+ fix_example: `---
56
+ id: ${violation.workItemId}
57
+ type: issue
58
+ severity: normal
59
+ status: drafted
60
+ ---`,
61
+ });
62
+
44
63
  const mergeValidationResult = (
45
64
  additionalErrors: readonly TraceabilityHarnessError[],
46
65
  result: MetadataValidationResult,
@@ -92,15 +111,22 @@ export class DesignDocumentReadApplicationError extends Error {
92
111
  export interface ValidateDesignStoryAnnotationsUseCaseDeps {
93
112
  readonly designDocumentPort: DesignDocumentPort;
94
113
  readonly validator: DesignStoryAnnotationsValidator;
114
+ readonly workItemIdentityPort?: WorkItemIdentityPort;
115
+ readonly workItemIdentityValidationService?: WorkItemIdentityValidationService;
95
116
  }
96
117
 
97
118
  export class ValidateDesignStoryAnnotationsUseCase {
98
119
  private readonly designDocumentPort: DesignDocumentPort;
99
120
  private readonly validator: DesignStoryAnnotationsValidator;
121
+ private readonly workItemIdentityPort?: WorkItemIdentityPort;
122
+ private readonly workItemIdentityValidationService: WorkItemIdentityValidationService;
100
123
 
101
124
  constructor(deps: ValidateDesignStoryAnnotationsUseCaseDeps) {
102
125
  this.designDocumentPort = deps.designDocumentPort;
103
126
  this.validator = deps.validator;
127
+ this.workItemIdentityPort = deps.workItemIdentityPort;
128
+ this.workItemIdentityValidationService =
129
+ deps.workItemIdentityValidationService ?? new WorkItemIdentityValidationService();
104
130
  }
105
131
 
106
132
  async execute(filePaths: readonly ProjectRelativePath[]): Promise<readonly Readonly<MetadataValidationOutput>[]> {
@@ -128,6 +154,11 @@ export class ValidateDesignStoryAnnotationsUseCase {
128
154
  }
129
155
  }
130
156
  }
157
+ if (this.workItemIdentityPort && isWorkItemDescriptionPath(filePath)) {
158
+ const entries = await this.workItemIdentityPort.listWorkItemIdentities();
159
+ const violations = this.workItemIdentityValidationService.validate(entries);
160
+ workItemFrontmatterErrors.push(...violations.map(toWorkItemIdentityError));
161
+ }
131
162
 
132
163
  const flags = await this.designDocumentPort.readFrontmatterFlags(filePath);
133
164
  const annotations = await this.designDocumentPort.readStoryAnnotations(filePath);
@@ -146,3 +177,7 @@ export class ValidateDesignStoryAnnotationsUseCase {
146
177
  return Object.freeze(results);
147
178
  }
148
179
  }
180
+
181
+ function isWorkItemDescriptionPath(filePath: ProjectRelativePath): boolean {
182
+ return /(?:^|\/)WI-\d+\/description\.md$/.test(filePath.toString());
183
+ }
@@ -19,6 +19,7 @@ import { FileSystemInceptionPlanGateway } from "./infrastructure/gateways/file-s
19
19
  import { FileSystemMetadataReader } from "./infrastructure/gateways/file-system-metadata-reader.js";
20
20
  import { FileSystemWorkItemMigrationApplyGateway } from "./infrastructure/gateways/file-system-work-item-migration-apply-gateway.js";
21
21
  import { FileSystemWorkItemMigrationSourceGateway } from "./infrastructure/gateways/file-system-work-item-migration-source-gateway.js";
22
+ import { FileSystemWorkItemIdentityGateway } from "./infrastructure/gateways/file-system-work-item-identity-gateway.js";
22
23
  import { MarkdownDesignDocumentGateway } from "./infrastructure/gateways/markdown-design-document-gateway.js";
23
24
  import { MarkdownStoryCatalogGateway } from "./infrastructure/gateways/markdown-story-catalog-gateway.js";
24
25
  import { MarkdownUnitDefinitionGateway } from "./infrastructure/gateways/markdown-unit-definition-gateway.js";
@@ -61,6 +62,7 @@ export function createTraceabilityModelModule(
61
62
  const inceptionPlan = new FileSystemInceptionPlanGateway({ rootDir });
62
63
  const workItemMigrationSource = new FileSystemWorkItemMigrationSourceGateway({ rootDir });
63
64
  const workItemMigrationApply = new FileSystemWorkItemMigrationApplyGateway({ rootDir });
65
+ const workItemIdentity = new FileSystemWorkItemIdentityGateway({ rootDir });
64
66
 
65
67
  // Domain services
66
68
  const metadataValidator = new MetadataValidator({
@@ -85,6 +87,7 @@ export function createTraceabilityModelModule(
85
87
  const validateDesignStoryAnnotationsUseCase = new ValidateDesignStoryAnnotationsUseCase({
86
88
  designDocumentPort: designDocument,
87
89
  validator: metadataValidator,
90
+ workItemIdentityPort: workItemIdentity,
88
91
  });
89
92
  const validateTestStoryMetadataUseCase = new ValidateTestStoryMetadataUseCase({
90
93
  metadataReaderPort: metadataReader,
@@ -0,0 +1,13 @@
1
+ // @unit traceability-model
2
+ // @layer domain
3
+ // @work-item-id WI-106
4
+
5
+ export interface WorkItemIdentityEntry {
6
+ readonly filePath: string;
7
+ readonly directoryId: string;
8
+ readonly frontmatterId: string;
9
+ }
10
+
11
+ export interface WorkItemIdentityPort {
12
+ listWorkItemIdentities(): Promise<readonly WorkItemIdentityEntry[]>;
13
+ }
@@ -0,0 +1,46 @@
1
+ // @unit traceability-model
2
+ // @layer domain
3
+ // @work-item-id WI-106
4
+
5
+ import type { WorkItemIdentityEntry } from "../ports/work-item-identity-port.js";
6
+
7
+ export interface WorkItemIdentityViolation {
8
+ readonly code: "duplicate-id" | "directory-id-mismatch";
9
+ readonly workItemId: string;
10
+ readonly filePaths: readonly string[];
11
+ readonly message: string;
12
+ }
13
+
14
+ export class WorkItemIdentityValidationService {
15
+ validate(entries: readonly WorkItemIdentityEntry[]): readonly WorkItemIdentityViolation[] {
16
+ const violations: WorkItemIdentityViolation[] = [];
17
+ const byId = new Map<string, WorkItemIdentityEntry[]>();
18
+
19
+ for (const entry of entries) {
20
+ if (entry.directoryId !== entry.frontmatterId) {
21
+ violations.push({
22
+ code: "directory-id-mismatch",
23
+ workItemId: entry.frontmatterId,
24
+ filePaths: Object.freeze([entry.filePath]),
25
+ message: `WI directory id ${entry.directoryId} does not match frontmatter id ${entry.frontmatterId}: ${entry.filePath}`,
26
+ });
27
+ }
28
+
29
+ const existing = byId.get(entry.frontmatterId) ?? [];
30
+ existing.push(entry);
31
+ byId.set(entry.frontmatterId, existing);
32
+ }
33
+
34
+ for (const [workItemId, sameIdEntries] of byId.entries()) {
35
+ if (sameIdEntries.length <= 1) continue;
36
+ violations.push({
37
+ code: "duplicate-id",
38
+ workItemId,
39
+ filePaths: Object.freeze(sameIdEntries.map((entry) => entry.filePath).sort()),
40
+ message: `WI id ${workItemId} is duplicated: ${sameIdEntries.map((entry) => entry.filePath).sort().join(", ")}`,
41
+ });
42
+ }
43
+
44
+ return Object.freeze(violations);
45
+ }
46
+ }
@@ -0,0 +1,74 @@
1
+ // @unit traceability-model
2
+ // @layer infrastructure
3
+ // @work-item-id WI-106
4
+
5
+ import { readdir, readFile } from "node:fs/promises";
6
+ import * as path from "node:path";
7
+ import type {
8
+ WorkItemIdentityEntry,
9
+ WorkItemIdentityPort,
10
+ } from "../../domain/ports/work-item-identity-port.js";
11
+ import { parseWorkItemFrontmatter } from "../parsers/work-item-frontmatter-parser.js";
12
+
13
+ const WI_DIR_PATTERN = /^WI-\d+$/;
14
+ const SKIPPED_DIRS = new Set(["archive", "_shared", "_operation", "share"]);
15
+
16
+ export interface FileSystemWorkItemIdentityGatewayDeps {
17
+ readonly rootDir: string;
18
+ readonly inceptionRoot?: string;
19
+ }
20
+
21
+ export class FileSystemWorkItemIdentityGateway implements WorkItemIdentityPort {
22
+ private readonly rootDir: string;
23
+ private readonly inceptionRoot: string;
24
+
25
+ constructor(deps: FileSystemWorkItemIdentityGatewayDeps) {
26
+ this.rootDir = deps.rootDir;
27
+ this.inceptionRoot = deps.inceptionRoot ?? "docs/inception";
28
+ }
29
+
30
+ async listWorkItemIdentities(): Promise<readonly WorkItemIdentityEntry[]> {
31
+ const results: WorkItemIdentityEntry[] = [];
32
+ await this.collect(this.inceptionRoot, results);
33
+ return Object.freeze(results.sort((a, b) => a.filePath.localeCompare(b.filePath)));
34
+ }
35
+
36
+ private async collect(relativeDir: string, results: WorkItemIdentityEntry[]): Promise<void> {
37
+ let entries;
38
+ try {
39
+ entries = await readdir(path.join(this.rootDir, relativeDir), { withFileTypes: true });
40
+ } catch {
41
+ return;
42
+ }
43
+
44
+ for (const entry of entries) {
45
+ if (!entry.isDirectory()) continue;
46
+ if (SKIPPED_DIRS.has(entry.name)) continue;
47
+
48
+ const childDir = path.posix.join(relativeDir, entry.name);
49
+ if (WI_DIR_PATTERN.test(entry.name)) {
50
+ const descriptionPath = path.posix.join(childDir, "description.md");
51
+ const frontmatter = await this.readFrontmatterId(descriptionPath);
52
+ if (frontmatter !== null) {
53
+ results.push({
54
+ filePath: descriptionPath,
55
+ directoryId: entry.name,
56
+ frontmatterId: frontmatter,
57
+ });
58
+ }
59
+ continue;
60
+ }
61
+
62
+ await this.collect(childDir, results);
63
+ }
64
+ }
65
+
66
+ private async readFrontmatterId(descriptionPath: string): Promise<string | null> {
67
+ try {
68
+ const content = await readFile(path.join(this.rootDir, descriptionPath), "utf8");
69
+ return parseWorkItemFrontmatter(content)?.id ?? null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+ }