phasegate 0.83.0 → 0.107.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.
- package/CHANGELOG.md +313 -0
- package/README.ja.md +15 -7
- package/README.md +24 -4
- package/docs/ADR/ADR-014-presentation-domain-dependency.md +82 -0
- package/docs/ADR/ADR-015-architecture-preset.md +183 -0
- package/docs/guide/codex-integration.md +7 -2
- package/docs/guide/installation.md +10 -2
- package/docs/guide/preset-selection.md +170 -0
- package/docs/guide/quick-vs-full-mode.md +3 -3
- package/docs/guide/retrofit-adoption.md +19 -2
- package/docs/guide/skills-overview.md +1 -1
- package/package.json +7 -1
- package/scripts/harness/adr-foundation/application/dto/application-errors.ts +0 -13
- package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +108 -100
- package/scripts/harness/agent-integration/domain/ports/error-guidance-query-port.ts +0 -8
- package/scripts/harness/agent-integration/domain/ports/phase-gate-query-port.ts +3 -3
- package/scripts/harness/agent-integration/domain/value-objects/write-target-scope.ts +26 -30
- package/scripts/harness/agent-integration/infrastructure/adapters/file-system-story-reflection-query-adapter.ts +6 -2
- package/scripts/harness/biome-ast-engine/application/dto/analyze-import-graph-input.ts +3 -0
- package/scripts/harness/biome-ast-engine/application/dto/resolve-enabled-rules-output.ts +2 -0
- package/scripts/harness/biome-ast-engine/application/mappers/resolve-enabled-rules-output-mapper.ts +4 -1
- package/scripts/harness/biome-ast-engine/application/usecases/analyze-import-graph-usecase.ts +4 -1
- package/scripts/harness/biome-ast-engine/application/usecases/execute-lint-usecase.ts +2 -0
- package/scripts/harness/biome-ast-engine/application/usecases/resolve-enabled-rules-usecase.ts +31 -3
- package/scripts/harness/biome-ast-engine/composition-root.ts +10 -2
- package/scripts/harness/biome-ast-engine/domain/ports/rule-config-provider-port.ts +16 -0
- package/scripts/harness/biome-ast-engine/domain/ports/source-module-analyzer-port.ts +5 -1
- package/scripts/harness/biome-ast-engine/domain/services/lint-runner.ts +5 -1
- package/scripts/harness/biome-ast-engine/domain/services/rule-definition-registry.ts +1 -0
- package/scripts/harness/biome-ast-engine/domain/value-objects/architecture-spec.ts +38 -0
- package/scripts/harness/biome-ast-engine/domain/value-objects/layer-boundary.ts +7 -8
- package/scripts/harness/biome-ast-engine/domain/value-objects/layer-name.ts +15 -23
- package/scripts/harness/biome-ast-engine/domain/value-objects/source-module-snapshot.ts +11 -4
- package/scripts/harness/biome-ast-engine/infrastructure/adapters/harness-config-provider-adapter.ts +29 -3
- package/scripts/harness/biome-ast-engine/infrastructure/adapters/typescript-source-module-analyzer-adapter.ts +22 -15
- package/scripts/harness/biome-ast-engine/infrastructure/mappers/source-module-snapshot-mapper.ts +26 -17
- package/scripts/harness/config-foundation/application/dto/resolved-config-output.ts +1 -0
- package/scripts/harness/config-foundation/application/usecases/load-resolved-config-use-case.ts +34 -2
- package/scripts/harness/config-foundation/application/usecases/migrate-schema-use-case.ts +89 -0
- package/scripts/harness/config-foundation/composition-root.ts +8 -0
- package/scripts/harness/config-foundation/domain/harness-config.ts +6 -0
- package/scripts/harness/config-foundation/domain/services/architecture-resolution-service.ts +257 -0
- package/scripts/harness/config-foundation/domain/value-objects/architecture-config.ts +66 -0
- package/scripts/harness/config-foundation/domain/value-objects/architecture-preset-catalog.ts +75 -0
- package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +546 -0
- package/scripts/harness/config-foundation/infrastructure/validators/ajv-config-schema-validator.ts +18 -6
- package/scripts/harness/config-foundation/presentation/cli/migrate-schema-command-handler.ts +83 -0
- package/scripts/harness/integrations/pre-commit.ts +211 -52
- package/scripts/harness/main.ts +460 -340
- package/scripts/harness/phase-dependency-model/domain/ports/story-reflection-file-system-port.ts +2 -4
- package/scripts/harness/phase-dependency-model/domain/services/story-reflection-checker.ts +54 -17
- package/scripts/harness/phase-dependency-model/infrastructure/filesystem/file-system-story-reflection-adapter.ts +154 -36
- package/scripts/harness/setup/skill-deployer.ts +140 -102
- package/scripts/harness/skill-quality/domain/errors/skill-quality-error.ts +24 -23
- package/scripts/harness/skill-quality/domain/value-objects/commit-message.ts +23 -7
- package/scripts/harness/traceability-model/application/usecases/apply-work-item-migration-usecase.ts +52 -0
- package/scripts/harness/traceability-model/application/usecases/plan-work-item-migration-usecase.ts +29 -0
- package/scripts/harness/traceability-model/application/usecases/validate-design-story-annotations-usecase.ts +83 -18
- package/scripts/harness/traceability-model/composition-root.ts +48 -30
- package/scripts/harness/traceability-model/domain/ports/design-document-port.ts +9 -15
- package/scripts/harness/traceability-model/domain/ports/work-item-migration-apply-port.ts +11 -0
- package/scripts/harness/traceability-model/domain/ports/work-item-migration-source-port.ts +9 -0
- package/scripts/harness/traceability-model/domain/services/metadata-validator.ts +1 -1
- package/scripts/harness/traceability-model/domain/services/work-item-migration-planner.ts +162 -0
- package/scripts/harness/traceability-model/domain/value-objects/story-id.ts +3 -3
- package/scripts/harness/traceability-model/domain/value-objects/work-item-frontmatter.ts +57 -0
- package/scripts/harness/traceability-model/domain/value-objects/work-item-migration-candidate.ts +47 -0
- package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-migration-apply-gateway.ts +110 -0
- package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-migration-source-gateway.ts +182 -0
- package/scripts/harness/traceability-model/infrastructure/gateways/markdown-design-document-gateway.ts +29 -43
- package/scripts/harness/traceability-model/infrastructure/parsers/story-catalog-parser.ts +2 -2
- package/scripts/harness/traceability-model/infrastructure/parsers/work-item-frontmatter-parser.ts +136 -0
- package/scripts/harness/traceability-model/presentation/cli/migrate-work-items-command-handler.ts +186 -0
- package/skills/quick-implementor/SKILL.md +17 -1
- package/templates/.husky/commit-msg +1 -0
- package/scripts/harness/adr-foundation/application/dto/seed-adr-definition.ts +0 -21
- package/scripts/harness/adr-foundation/application/usecases/seed-initial-adrs-use-case.ts +0 -84
- package/scripts/harness/adr-foundation/infrastructure/seeds/initial-adr-definitions.ts +0 -161
|
@@ -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
|
-
|
|
8
|
-
import * as fs from
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import type {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
110
|
-
filePath
|
|
111
|
-
|
|
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,
|
|
104
|
+
const content = await readFile(absolutePath, "utf8");
|
|
119
105
|
this.contentCache.set(filePath.value, content);
|
|
120
106
|
return content;
|
|
121
107
|
}
|
|
@@ -10,7 +10,7 @@ export interface ParsedStoryCatalog {
|
|
|
10
10
|
readonly aliasMap: ReadonlyMap<string, string>;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
const STORY_ID_LINE_PATTERN = /\bH[0-9]{2}-[0-9]{2}\b/g;
|
|
13
|
+
const STORY_ID_LINE_PATTERN = /\bH(?:F\d+|[0-9]{2})-[0-9]{2}\b/g;
|
|
14
14
|
const TABLE_ALIAS_PATTERN =
|
|
15
15
|
/\|\s*(H[0-9]{2}-[0-9]{2})\s*\|.*?\|\s*(US-[0-9]{3})\s*\|/g;
|
|
16
16
|
const INLINE_ALIAS_PATTERN =
|
|
@@ -58,7 +58,7 @@ export function parseStoryCatalog(content: string): ParsedStoryCatalog {
|
|
|
58
58
|
|
|
59
59
|
// 前後行コンテキスト形式: H03-01 の直後に 旧US: US-001
|
|
60
60
|
for (let i = 0; i < lines.length; i++) {
|
|
61
|
-
const storyIdMatch = /\b(H[0-9]{2}-[0-9]{2})\b/.exec(lines[i]);
|
|
61
|
+
const storyIdMatch = /\b(H(?:F\d+|[0-9]{2})-[0-9]{2})\b/.exec(lines[i]);
|
|
62
62
|
if (storyIdMatch) {
|
|
63
63
|
const nextLine = i + 1 < lines.length ? lines[i + 1] : '';
|
|
64
64
|
const legacyMatch = /旧US\s*[::]\s*(US-[0-9]{3})/.exec(nextLine);
|
package/scripts/harness/traceability-model/infrastructure/parsers/work-item-frontmatter-parser.ts
ADDED
|
@@ -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
|
+
}
|