phasegate 0.91.0 → 0.108.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 (69) hide show
  1. package/CHANGELOG.md +255 -0
  2. package/README.ja.md +17 -7
  3. package/README.md +26 -4
  4. package/docs/ADR/ADR-015-architecture-preset.md +183 -0
  5. package/docs/guide/cli-reference.md +108 -0
  6. package/docs/guide/codex-integration.md +7 -2
  7. package/docs/guide/installation.md +10 -2
  8. package/docs/guide/preset-selection.md +170 -0
  9. package/docs/guide/quick-vs-full-mode.md +3 -3
  10. package/docs/guide/retrofit-adoption.md +19 -2
  11. package/docs/guide/skills-overview.md +1 -1
  12. package/package.json +7 -1
  13. package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +108 -100
  14. package/scripts/harness/agent-integration/domain/ports/phase-gate-query-port.ts +3 -3
  15. package/scripts/harness/agent-integration/domain/value-objects/write-target-scope.ts +26 -30
  16. package/scripts/harness/agent-integration/infrastructure/adapters/file-system-story-reflection-query-adapter.ts +6 -2
  17. package/scripts/harness/biome-ast-engine/application/dto/analyze-import-graph-input.ts +3 -0
  18. package/scripts/harness/biome-ast-engine/application/dto/resolve-enabled-rules-output.ts +2 -0
  19. package/scripts/harness/biome-ast-engine/application/mappers/resolve-enabled-rules-output-mapper.ts +4 -1
  20. package/scripts/harness/biome-ast-engine/application/usecases/analyze-import-graph-usecase.ts +4 -1
  21. package/scripts/harness/biome-ast-engine/application/usecases/execute-lint-usecase.ts +2 -0
  22. package/scripts/harness/biome-ast-engine/application/usecases/resolve-enabled-rules-usecase.ts +31 -3
  23. package/scripts/harness/biome-ast-engine/composition-root.ts +10 -2
  24. package/scripts/harness/biome-ast-engine/domain/ports/rule-config-provider-port.ts +16 -0
  25. package/scripts/harness/biome-ast-engine/domain/ports/source-module-analyzer-port.ts +5 -1
  26. package/scripts/harness/biome-ast-engine/domain/services/lint-runner.ts +5 -1
  27. package/scripts/harness/biome-ast-engine/domain/value-objects/architecture-spec.ts +38 -0
  28. package/scripts/harness/biome-ast-engine/domain/value-objects/layer-boundary.ts +7 -8
  29. package/scripts/harness/biome-ast-engine/domain/value-objects/layer-name.ts +15 -23
  30. package/scripts/harness/biome-ast-engine/domain/value-objects/source-module-snapshot.ts +11 -4
  31. package/scripts/harness/biome-ast-engine/infrastructure/adapters/harness-config-provider-adapter.ts +29 -3
  32. package/scripts/harness/biome-ast-engine/infrastructure/adapters/typescript-source-module-analyzer-adapter.ts +22 -15
  33. package/scripts/harness/biome-ast-engine/infrastructure/mappers/source-module-snapshot-mapper.ts +26 -17
  34. package/scripts/harness/config-foundation/application/dto/resolved-config-output.ts +1 -0
  35. package/scripts/harness/config-foundation/application/usecases/load-resolved-config-use-case.ts +34 -2
  36. package/scripts/harness/config-foundation/application/usecases/migrate-schema-use-case.ts +89 -0
  37. package/scripts/harness/config-foundation/composition-root.ts +8 -0
  38. package/scripts/harness/config-foundation/domain/harness-config.ts +6 -0
  39. package/scripts/harness/config-foundation/domain/services/architecture-resolution-service.ts +257 -0
  40. package/scripts/harness/config-foundation/domain/value-objects/architecture-config.ts +66 -0
  41. package/scripts/harness/config-foundation/domain/value-objects/architecture-preset-catalog.ts +75 -0
  42. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +546 -0
  43. package/scripts/harness/config-foundation/infrastructure/validators/ajv-config-schema-validator.ts +18 -6
  44. package/scripts/harness/config-foundation/presentation/cli/migrate-schema-command-handler.ts +83 -0
  45. package/scripts/harness/integrations/pre-commit.ts +211 -52
  46. package/scripts/harness/main.ts +460 -340
  47. package/scripts/harness/phase-dependency-model/domain/ports/story-reflection-file-system-port.ts +2 -4
  48. package/scripts/harness/phase-dependency-model/domain/services/story-reflection-checker.ts +54 -17
  49. package/scripts/harness/phase-dependency-model/infrastructure/filesystem/file-system-story-reflection-adapter.ts +154 -36
  50. package/scripts/harness/setup/skill-deployer.ts +140 -102
  51. package/scripts/harness/skill-quality/domain/errors/skill-quality-error.ts +24 -23
  52. package/scripts/harness/skill-quality/domain/value-objects/commit-message.ts +23 -7
  53. package/scripts/harness/traceability-model/application/usecases/apply-work-item-migration-usecase.ts +52 -0
  54. package/scripts/harness/traceability-model/application/usecases/plan-work-item-migration-usecase.ts +29 -0
  55. package/scripts/harness/traceability-model/application/usecases/validate-design-story-annotations-usecase.ts +83 -18
  56. package/scripts/harness/traceability-model/composition-root.ts +48 -30
  57. package/scripts/harness/traceability-model/domain/ports/design-document-port.ts +9 -15
  58. package/scripts/harness/traceability-model/domain/ports/work-item-migration-apply-port.ts +11 -0
  59. package/scripts/harness/traceability-model/domain/ports/work-item-migration-source-port.ts +9 -0
  60. package/scripts/harness/traceability-model/domain/services/work-item-migration-planner.ts +162 -0
  61. package/scripts/harness/traceability-model/domain/value-objects/work-item-frontmatter.ts +57 -0
  62. package/scripts/harness/traceability-model/domain/value-objects/work-item-migration-candidate.ts +47 -0
  63. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-migration-apply-gateway.ts +110 -0
  64. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-migration-source-gateway.ts +182 -0
  65. package/scripts/harness/traceability-model/infrastructure/gateways/markdown-design-document-gateway.ts +29 -43
  66. package/scripts/harness/traceability-model/infrastructure/parsers/work-item-frontmatter-parser.ts +136 -0
  67. package/scripts/harness/traceability-model/presentation/cli/migrate-work-items-command-handler.ts +186 -0
  68. package/skills/quick-implementor/SKILL.md +17 -1
  69. package/templates/.husky/commit-msg +1 -0
@@ -0,0 +1,110 @@
1
+ // @unit traceability-model
2
+ // @layer infrastructure
3
+
4
+ import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
5
+ import * as path from "node:path";
6
+ import type { WorkItemMigrationApplyPort } from "../../domain/ports/work-item-migration-apply-port.js";
7
+ import type {
8
+ WorkItemMigrationAppliedCandidate,
9
+ WorkItemMigrationCandidate,
10
+ } from "../../domain/value-objects/work-item-migration-candidate.js";
11
+
12
+ export interface FileSystemWorkItemMigrationApplyGatewayDeps {
13
+ readonly rootDir: string;
14
+ }
15
+
16
+ export class FileSystemWorkItemMigrationApplyGateway implements WorkItemMigrationApplyPort {
17
+ private readonly rootDir: string;
18
+
19
+ constructor(deps: FileSystemWorkItemMigrationApplyGatewayDeps) {
20
+ this.rootDir = deps.rootDir;
21
+ }
22
+
23
+ async apply(candidate: WorkItemMigrationCandidate): Promise<WorkItemMigrationAppliedCandidate> {
24
+ const sourceDir = path.join(this.rootDir, candidate.sourcePath);
25
+ const targetDir = path.join(this.rootDir, candidate.targetPath);
26
+
27
+ await assertPathDoesNotExist(targetDir);
28
+ await mkdir(path.dirname(targetDir), { recursive: true });
29
+ await rename(sourceDir, targetDir);
30
+
31
+ const descriptionPath = await this.normalizeDescriptionFile(targetDir, candidate);
32
+ await this.ensureFrontmatter(descriptionPath, candidate.frontmatterPreview);
33
+
34
+ return Object.freeze({
35
+ legacyId: candidate.legacyId,
36
+ nextId: candidate.nextId,
37
+ sourcePath: candidate.sourcePath,
38
+ targetPath: candidate.targetPath,
39
+ descriptionPath: path.posix.join(candidate.targetPath, "description.md"),
40
+ });
41
+ }
42
+
43
+ private async normalizeDescriptionFile(targetDir: string, candidate: WorkItemMigrationCandidate): Promise<string> {
44
+ const descriptionPath = path.join(targetDir, "description.md");
45
+ if (candidate.descriptionFileName === "description.md") {
46
+ return descriptionPath;
47
+ }
48
+
49
+ await assertPathDoesNotExist(descriptionPath);
50
+ if (candidate.descriptionFileName === null) {
51
+ await writeFile(descriptionPath, `# ${candidate.legacyId}\n`, "utf8");
52
+ return descriptionPath;
53
+ }
54
+
55
+ await rename(path.join(targetDir, candidate.descriptionFileName), descriptionPath);
56
+ return descriptionPath;
57
+ }
58
+
59
+ private async ensureFrontmatter(descriptionPath: string, frontmatterPreview: string): Promise<void> {
60
+ const content = await readFile(descriptionPath, "utf8");
61
+ const existing = parseFrontmatter(content);
62
+
63
+ if (existing === null) {
64
+ await writeFile(descriptionPath, `${frontmatterPreview}\n\n${content}`, "utf8");
65
+ return;
66
+ }
67
+
68
+ if (matchesPlannerFrontmatter(existing.frontmatter, frontmatterPreview)) {
69
+ return;
70
+ }
71
+
72
+ const body = content.slice(existing.length).replace(/^\s+/, "");
73
+ await writeFile(descriptionPath, `${frontmatterPreview}\n\n${body}`, "utf8");
74
+ }
75
+ }
76
+
77
+ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
78
+ const ID_PATTERN = /^id:\s*(\S+)\s*$/m;
79
+ const LEGACY_ID_PATTERN = /^legacy_id:\s*(\S+)\s*$/m;
80
+
81
+ function parseFrontmatter(content: string): { readonly frontmatter: string; readonly length: number } | null {
82
+ const match = FRONTMATTER_PATTERN.exec(content);
83
+ if (!match) return null;
84
+ return { frontmatter: match[1], length: match[0].length };
85
+ }
86
+
87
+ function matchesPlannerFrontmatter(existing: string, preview: string): boolean {
88
+ const previewMatch = FRONTMATTER_PATTERN.exec(`${preview}\n`);
89
+ if (!previewMatch) return false;
90
+ const previewBody = previewMatch[1];
91
+
92
+ const previewId = ID_PATTERN.exec(previewBody)?.[1];
93
+ const existingId = ID_PATTERN.exec(existing)?.[1];
94
+ if (previewId === undefined || previewId !== existingId) return false;
95
+
96
+ const previewLegacy = LEGACY_ID_PATTERN.exec(previewBody)?.[1];
97
+ if (previewLegacy === undefined) return true;
98
+
99
+ const existingLegacy = LEGACY_ID_PATTERN.exec(existing)?.[1];
100
+ return existingLegacy === previewLegacy;
101
+ }
102
+
103
+ async function assertPathDoesNotExist(targetPath: string): Promise<void> {
104
+ try {
105
+ await access(targetPath);
106
+ } catch {
107
+ return;
108
+ }
109
+ throw new Error(`Path already exists: ${targetPath}`);
110
+ }
@@ -0,0 +1,182 @@
1
+ // @unit traceability-model
2
+ // @layer infrastructure
3
+
4
+ import * as fs from "node:fs";
5
+ import { readdir, readFile } from "node:fs/promises";
6
+ import * as path from "node:path";
7
+ import type { WorkItemMigrationSourcePort } from "../../domain/ports/work-item-migration-source-port.js";
8
+ import type {
9
+ LegacyIssueDirectory,
10
+ WorkItemDescriptionFileName,
11
+ } from "../../domain/value-objects/work-item-migration-candidate.js";
12
+
13
+ const ISSUE_DIR_PATTERN = /^(?:ISSUE|WI)-\d+$/;
14
+ const H_ID_DIR_PATTERN = /^H\d{2}-\d{2}$/;
15
+ const WI_DIR_PATTERN = /^WI-\d+$/;
16
+ const SKIPPED_INCEPTION_DIRS = new Set(["_shared", "_operation", "_cross", "issues"]);
17
+
18
+ export interface FileSystemWorkItemMigrationSourceGatewayDeps {
19
+ readonly rootDir: string;
20
+ }
21
+
22
+ export class FileSystemWorkItemMigrationSourceGateway implements WorkItemMigrationSourcePort {
23
+ private readonly rootDir: string;
24
+
25
+ constructor(deps: FileSystemWorkItemMigrationSourceGatewayDeps) {
26
+ this.rootDir = deps.rootDir;
27
+ }
28
+
29
+ async listLegacyIssueDirectories(): Promise<readonly LegacyIssueDirectory[]> {
30
+ const entries: LegacyIssueDirectory[] = [];
31
+ entries.push(...(await this.listCrossIssueDirectories()));
32
+ entries.push(...(await this.listUnitLegacyDirectories()));
33
+ return Object.freeze(entries);
34
+ }
35
+
36
+ async listExistingWorkItemIds(): Promise<readonly string[]> {
37
+ const inceptionDir = path.join(this.rootDir, "docs", "inception");
38
+ if (!fs.existsSync(inceptionDir)) return Object.freeze([]);
39
+
40
+ const ids = new Set<string>();
41
+ const crossDir = path.join(inceptionDir, "_cross");
42
+ if (fs.existsSync(crossDir)) {
43
+ for (const name of await readdir(crossDir)) {
44
+ if (WI_DIR_PATTERN.test(name)) ids.add(name);
45
+ }
46
+ }
47
+
48
+ for (const unitName of await readdir(inceptionDir)) {
49
+ if (SKIPPED_INCEPTION_DIRS.has(unitName)) continue;
50
+ const unitDir = path.join(inceptionDir, unitName);
51
+ if (!fs.statSync(unitDir).isDirectory()) continue;
52
+ for (const name of await readdir(unitDir)) {
53
+ if (WI_DIR_PATTERN.test(name)) ids.add(name);
54
+ }
55
+ }
56
+
57
+ return Object.freeze([...ids].sort());
58
+ }
59
+
60
+ private async listCrossIssueDirectories(): Promise<readonly LegacyIssueDirectory[]> {
61
+ const issuesDir = path.join(this.rootDir, "docs", "inception", "issues");
62
+ if (!fs.existsSync(issuesDir)) return Object.freeze([]);
63
+
64
+ const names = (await readdir(issuesDir)).sort();
65
+ const entries: LegacyIssueDirectory[] = [];
66
+ for (const name of names) {
67
+ if (!ISSUE_DIR_PATTERN.test(name)) continue;
68
+ const sourcePath = `docs/inception/issues/${name}`;
69
+ const entry = await this.createEntry({
70
+ legacyId: name,
71
+ sourcePath,
72
+ targetPath: `docs/inception/_cross/${toTargetId(name)}`,
73
+ scope: "cross",
74
+ });
75
+ entries.push(entry);
76
+ }
77
+ return Object.freeze(entries);
78
+ }
79
+
80
+ private async listUnitLegacyDirectories(): Promise<readonly LegacyIssueDirectory[]> {
81
+ const inceptionDir = path.join(this.rootDir, "docs", "inception");
82
+ if (!fs.existsSync(inceptionDir)) return Object.freeze([]);
83
+
84
+ const unitNames = (await readdir(inceptionDir)).sort();
85
+ const entries: LegacyIssueDirectory[] = [];
86
+ for (const unitName of unitNames) {
87
+ if (SKIPPED_INCEPTION_DIRS.has(unitName)) continue;
88
+ const unitDir = path.join(inceptionDir, unitName);
89
+ if (!fs.statSync(unitDir).isDirectory()) continue;
90
+
91
+ entries.push(...(await this.collectUnitIssueEntries(unitName)));
92
+ entries.push(...(await this.collectUnitHIdEntries(unitName)));
93
+ }
94
+
95
+ return Object.freeze(entries);
96
+ }
97
+
98
+ private async collectUnitIssueEntries(unitName: string): Promise<readonly LegacyIssueDirectory[]> {
99
+ const issuesDir = path.join(this.rootDir, "docs", "inception", unitName, "issues");
100
+ if (!fs.existsSync(issuesDir)) return Object.freeze([]);
101
+
102
+ const entries: LegacyIssueDirectory[] = [];
103
+ for (const name of (await readdir(issuesDir)).sort()) {
104
+ if (!ISSUE_DIR_PATTERN.test(name)) continue;
105
+ const sourcePath = `docs/inception/${unitName}/issues/${name}`;
106
+ const entry = await this.createEntry({
107
+ legacyId: name,
108
+ sourcePath,
109
+ targetPath: `docs/inception/${unitName}/${toTargetId(name)}`,
110
+ scope: "unit",
111
+ unitName,
112
+ });
113
+ entries.push(entry);
114
+ }
115
+ return Object.freeze(entries);
116
+ }
117
+
118
+ private async collectUnitHIdEntries(unitName: string): Promise<readonly LegacyIssueDirectory[]> {
119
+ const unitDir = path.join(this.rootDir, "docs", "inception", unitName);
120
+ const entries: LegacyIssueDirectory[] = [];
121
+ for (const name of (await readdir(unitDir)).sort()) {
122
+ if (!H_ID_DIR_PATTERN.test(name)) continue;
123
+ const childDir = path.join(unitDir, name);
124
+ if (!fs.statSync(childDir).isDirectory()) continue;
125
+
126
+ const sourcePath = `docs/inception/${unitName}/${name}`;
127
+ const descriptionFileName = this.resolveDescriptionFileName(sourcePath);
128
+ const content =
129
+ descriptionFileName === null
130
+ ? ""
131
+ : await readFile(path.join(this.rootDir, sourcePath, descriptionFileName), "utf8");
132
+
133
+ entries.push({
134
+ legacyId: name,
135
+ sourcePath,
136
+ scope: "unit",
137
+ unitName,
138
+ descriptionFileName,
139
+ content,
140
+ targetExists: false,
141
+ });
142
+ }
143
+ return Object.freeze(entries);
144
+ }
145
+
146
+ private async createEntry(args: {
147
+ readonly legacyId: string;
148
+ readonly sourcePath: string;
149
+ readonly targetPath: string;
150
+ readonly scope: "cross" | "unit";
151
+ readonly unitName?: string;
152
+ }): Promise<LegacyIssueDirectory> {
153
+ const descriptionFileName = this.resolveDescriptionFileName(args.sourcePath);
154
+ const content =
155
+ descriptionFileName === null
156
+ ? ""
157
+ : await readFile(path.join(this.rootDir, args.sourcePath, descriptionFileName), "utf8");
158
+
159
+ return {
160
+ legacyId: args.legacyId,
161
+ sourcePath: args.sourcePath,
162
+ scope: args.scope,
163
+ ...(args.unitName !== undefined ? { unitName: args.unitName } : {}),
164
+ descriptionFileName,
165
+ content,
166
+ targetExists: fs.existsSync(path.join(this.rootDir, args.targetPath)),
167
+ };
168
+ }
169
+
170
+ private resolveDescriptionFileName(sourcePath: string): WorkItemDescriptionFileName | null {
171
+ const descriptionPath = path.join(this.rootDir, sourcePath, "description.md");
172
+ if (fs.existsSync(descriptionPath)) return "description.md";
173
+
174
+ const issueDescriptionPath = path.join(this.rootDir, sourcePath, "issue_description.md");
175
+ return fs.existsSync(issueDescriptionPath) ? "issue_description.md" : null;
176
+ }
177
+ }
178
+
179
+ function toTargetId(legacyId: string): string {
180
+ if (legacyId.startsWith("WI-")) return legacyId;
181
+ return `WI-${legacyId.replace(/^ISSUE-/, "").padStart(3, "0")}`;
182
+ }
@@ -4,17 +4,20 @@
4
4
  *
5
5
  * docs/product/construction/{unit}/ 配下の設計文書を読み込み DesignDocumentPort を実装するゲートウェイ
6
6
  */
7
- import { readFile, readdir } from 'node:fs/promises';
8
- import * as fs from 'node:fs';
9
- import * as path from 'node:path';
10
- import type { DesignDocumentPort } from '../../domain/ports/design-document-port.js';
11
- import type { ProjectRelativePathLike } from '../../domain/value-objects/chain-link.js';
12
- import { ProjectRelativePath } from '../../domain/value-objects/project-relative-path.js';
13
- import { StoryIdAnnotation } from '../../domain/value-objects/story-id-annotation.js';
14
- import { StoryId } from '../../domain/value-objects/story-id.js';
15
- import { DesignDocumentFlags } from '../../domain/value-objects/design-document-flags.js';
16
- import { parseStoryAnnotations } from '../parsers/markdown-story-annotation-parser.js';
17
- import { parseFrontmatterFlags } from '../parsers/frontmatter-flag-parser.js';
7
+
8
+ import * as fs from "node:fs";
9
+ import { readdir, readFile } from "node:fs/promises";
10
+ import * as path from "node:path";
11
+ import type { DesignDocumentPort } from "../../domain/ports/design-document-port.js";
12
+ import type { ProjectRelativePathLike } from "../../domain/value-objects/chain-link.js";
13
+ import { DesignDocumentFlags } from "../../domain/value-objects/design-document-flags.js";
14
+ import { ProjectRelativePath } from "../../domain/value-objects/project-relative-path.js";
15
+ import { StoryId } from "../../domain/value-objects/story-id.js";
16
+ import { StoryIdAnnotation } from "../../domain/value-objects/story-id-annotation.js";
17
+ import type { WorkItemFrontmatter } from "../../domain/value-objects/work-item-frontmatter.js";
18
+ import { parseFrontmatterFlags } from "../parsers/frontmatter-flag-parser.js";
19
+ import { parseStoryAnnotations } from "../parsers/markdown-story-annotation-parser.js";
20
+ import { parseWorkItemFrontmatter } from "../parsers/work-item-frontmatter-parser.js";
18
21
 
19
22
  export interface MarkdownDesignDocumentGatewayDeps {
20
23
  readonly rootDir: string;
@@ -28,16 +31,8 @@ export class MarkdownDesignDocumentGateway implements DesignDocumentPort {
28
31
  this.rootDir = deps.rootDir;
29
32
  }
30
33
 
31
- async listByUnit(
32
- unitName: string,
33
- ): Promise<readonly ProjectRelativePathLike[]> {
34
- const constructionDir = path.join(
35
- this.rootDir,
36
- 'docs',
37
- 'product',
38
- 'construction',
39
- unitName,
40
- );
34
+ async listByUnit(unitName: string): Promise<readonly ProjectRelativePathLike[]> {
35
+ const constructionDir = path.join(this.rootDir, "docs", "product", "construction", unitName);
41
36
 
42
37
  if (!fs.existsSync(constructionDir)) {
43
38
  return [];
@@ -47,27 +42,19 @@ export class MarkdownDesignDocumentGateway implements DesignDocumentPort {
47
42
  const results: ProjectRelativePathLike[] = [];
48
43
 
49
44
  for (const entry of entries) {
50
- if (entry.endsWith('.md')) {
51
- results.push(
52
- ProjectRelativePath.create(
53
- `docs/product/construction/${unitName}/${entry}`,
54
- ),
55
- );
45
+ if (entry.endsWith(".md")) {
46
+ results.push(ProjectRelativePath.create(`docs/product/construction/${unitName}/${entry}`));
56
47
  }
57
48
  }
58
49
 
59
50
  return results;
60
51
  }
61
52
 
62
- async findConstructionDocuments(
63
- unitName: string,
64
- ): Promise<readonly ProjectRelativePathLike[]> {
53
+ async findConstructionDocuments(unitName: string): Promise<readonly ProjectRelativePathLike[]> {
65
54
  return this.listByUnit(unitName);
66
55
  }
67
56
 
68
- async readStoryAnnotations(
69
- filePath: ProjectRelativePathLike,
70
- ): Promise<readonly StoryIdAnnotation[]> {
57
+ async readStoryAnnotations(filePath: ProjectRelativePathLike): Promise<readonly StoryIdAnnotation[]> {
71
58
  const content = await this.getContent(filePath);
72
59
  const parsed = parseStoryAnnotations(content);
73
60
 
@@ -92,30 +79,29 @@ export class MarkdownDesignDocumentGateway implements DesignDocumentPort {
92
79
  return annotations;
93
80
  }
94
81
 
95
- async readStoryIdAnnotations(
96
- filePath: ProjectRelativePathLike,
97
- ): Promise<readonly StoryIdAnnotation[]> {
82
+ async readStoryIdAnnotations(filePath: ProjectRelativePathLike): Promise<readonly StoryIdAnnotation[]> {
98
83
  return this.readStoryAnnotations(filePath);
99
84
  }
100
85
 
101
- async readFrontmatterFlags(
102
- filePath: ProjectRelativePathLike,
103
- ): Promise<DesignDocumentFlags> {
86
+ async readFrontmatterFlags(filePath: ProjectRelativePathLike): Promise<DesignDocumentFlags> {
104
87
  const content = await this.getContent(filePath);
105
88
  const parsed = parseFrontmatterFlags(content);
106
89
  return DesignDocumentFlags.create(parsed.initialCreation);
107
90
  }
108
91
 
109
- private async getContent(
110
- filePath: ProjectRelativePathLike,
111
- ): Promise<string> {
92
+ async readWorkItemFrontmatter(filePath: ProjectRelativePathLike): Promise<WorkItemFrontmatter | null> {
93
+ const content = await this.getContent(filePath);
94
+ return parseWorkItemFrontmatter(content);
95
+ }
96
+
97
+ private async getContent(filePath: ProjectRelativePathLike): Promise<string> {
112
98
  const cached = this.contentCache.get(filePath.value);
113
99
  if (cached !== undefined) {
114
100
  return cached;
115
101
  }
116
102
 
117
103
  const absolutePath = path.join(this.rootDir, filePath.value);
118
- const content = await readFile(absolutePath, 'utf8');
104
+ const content = await readFile(absolutePath, "utf8");
119
105
  this.contentCache.set(filePath.value, content);
120
106
  return content;
121
107
  }
@@ -0,0 +1,136 @@
1
+ // @unit traceability-model
2
+ // @layer infrastructure
3
+ /**
4
+ * 設計文書先頭 YAML frontmatter から WorkItem メタデータを抽出するパーサー(H03-04 / ISSUE-026 Phase A-2)。
5
+ *
6
+ * - 既存 `parseFrontmatterFlags` (initial_creation) とは独立実装・後方互換
7
+ * - frontmatter 不在時は `null` を返し、必須キー不足・enum 違反時は
8
+ * `WorkItemFrontmatterValidationError` を throw する
9
+ * - YAML の一般的な edge case(anchor / 複数行 string / tagged type)は非対応。
10
+ * 本 parser は WI frontmatter の単純な key: value + flat/block list のみを扱う
11
+ */
12
+
13
+ import {
14
+ WORK_ITEM_ID_PATTERN,
15
+ WORK_ITEM_SEVERITIES,
16
+ WORK_ITEM_STATUSES,
17
+ WORK_ITEM_TYPES,
18
+ type WorkItemFrontmatter,
19
+ WorkItemFrontmatterValidationError,
20
+ type WorkItemSeverity,
21
+ type WorkItemStatus,
22
+ type WorkItemType,
23
+ } from "../../domain/value-objects/work-item-frontmatter.js";
24
+
25
+ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---/;
26
+
27
+ const scalarPattern = (key: string): RegExp => new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, "m");
28
+
29
+ const flowArrayPattern = (key: string): RegExp => new RegExp(`^\\s*${key}\\s*:\\s*\\[([^\\]]*)\\]\\s*$`, "m");
30
+
31
+ const blockArrayPattern = (key: string): RegExp =>
32
+ new RegExp(`^\\s*${key}\\s*:\\s*\\r?\\n((?:[ \\t]+-[ \\t]+.+\\r?\\n?)+)`, "m");
33
+
34
+ export function parseWorkItemFrontmatter(content: string): WorkItemFrontmatter | null {
35
+ const match = FRONTMATTER_PATTERN.exec(content);
36
+ if (!match) return null;
37
+
38
+ const body = match[1];
39
+
40
+ const id = extractScalar(body, "id");
41
+ const type = extractScalar(body, "type");
42
+
43
+ if (!id && !type) {
44
+ return null;
45
+ }
46
+
47
+ if (!id) {
48
+ throw new WorkItemFrontmatterValidationError("id が不足しています");
49
+ }
50
+ if (!type) {
51
+ throw new WorkItemFrontmatterValidationError("type が不足しています");
52
+ }
53
+ if (!WORK_ITEM_ID_PATTERN.test(id)) {
54
+ throw new WorkItemFrontmatterValidationError(`id 形式が不正です: ${id}`);
55
+ }
56
+ if (!WORK_ITEM_TYPES.has(type as WorkItemType)) {
57
+ throw new WorkItemFrontmatterValidationError(`type 値が enum 外: ${type}`);
58
+ }
59
+
60
+ const severityRaw = extractScalar(body, "severity");
61
+ if (severityRaw !== undefined && !WORK_ITEM_SEVERITIES.has(severityRaw as WorkItemSeverity)) {
62
+ throw new WorkItemFrontmatterValidationError(`severity 値が enum 外: ${severityRaw}`);
63
+ }
64
+
65
+ const statusRaw = extractScalar(body, "status");
66
+ if (statusRaw !== undefined && !WORK_ITEM_STATUSES.has(statusRaw as WorkItemStatus)) {
67
+ throw new WorkItemFrontmatterValidationError(`status 値が enum 外: ${statusRaw}`);
68
+ }
69
+
70
+ const affects = extractArray(body, "affects");
71
+ const source = extractScalar(body, "source");
72
+ const legacyId = extractScalar(body, "legacy_id");
73
+
74
+ const result: {
75
+ id: string;
76
+ type: WorkItemType;
77
+ affects?: readonly string[];
78
+ severity?: WorkItemSeverity;
79
+ status?: WorkItemStatus;
80
+ source?: string;
81
+ legacyId?: string;
82
+ } = {
83
+ id,
84
+ type: type as WorkItemType,
85
+ };
86
+ if (affects) result.affects = affects;
87
+ if (severityRaw) result.severity = severityRaw as WorkItemSeverity;
88
+ if (statusRaw) result.status = statusRaw as WorkItemStatus;
89
+ if (source) result.source = source;
90
+ if (legacyId) result.legacyId = legacyId;
91
+
92
+ return result;
93
+ }
94
+
95
+ function extractScalar(body: string, key: string): string | undefined {
96
+ const match = scalarPattern(key).exec(body);
97
+ if (!match) return undefined;
98
+ const raw = match[1].trim();
99
+ if (raw.length === 0) return undefined;
100
+ // フロー配列記法 (`[a, b]`) は scalar として扱わず、array 抽出側に委ねる
101
+ if (raw.startsWith("[")) return undefined;
102
+ return stripYamlQuotes(raw);
103
+ }
104
+
105
+ function extractArray(body: string, key: string): readonly string[] | undefined {
106
+ const flow = flowArrayPattern(key).exec(body);
107
+ if (flow) {
108
+ const items = flow[1]
109
+ .split(",")
110
+ .map((s) => stripYamlQuotes(s.trim()))
111
+ .filter((s) => s.length > 0);
112
+ return items.length > 0 ? items : undefined;
113
+ }
114
+
115
+ const block = blockArrayPattern(key).exec(body);
116
+ if (block) {
117
+ const items = block[1]
118
+ .split(/\r?\n/)
119
+ .map((line) => line.replace(/^[ \t]+-[ \t]+/, "").trim())
120
+ .map(stripYamlQuotes)
121
+ .filter((s) => s.length > 0);
122
+ return items.length > 0 ? items : undefined;
123
+ }
124
+
125
+ return undefined;
126
+ }
127
+
128
+ function stripYamlQuotes(value: string): string {
129
+ if (value.length < 2) return value;
130
+ const first = value[0];
131
+ const last = value[value.length - 1];
132
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
133
+ return value.slice(1, -1);
134
+ }
135
+ return value;
136
+ }