phasegate 0.129.0 → 0.130.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 (18) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/package.json +1 -1
  3. package/scripts/harness/config-foundation/index.ts +1 -1
  4. package/scripts/harness/harness-api/infrastructure/adapters/nyquist-validation-impact-analysis-adapter.ts +7 -1
  5. package/scripts/harness/harness-api/infrastructure/adapters/phase-dependency-model-query-adapter.ts +6 -4
  6. package/scripts/harness/integrations/pre-commit.ts +21 -2
  7. package/scripts/harness/main.ts +14 -2
  8. package/scripts/harness/phase-dependency-model/domain/definitions/full-phase-nodes.ts +5 -5
  9. package/scripts/harness/phase-dependency-model/domain/definitions/minimal-phase-nodes.ts +1 -1
  10. package/scripts/harness/phase-dependency-model/domain/definitions/standard-phase-nodes.ts +2 -2
  11. package/scripts/harness/phase-dependency-model/domain/values/artifact.ts +9 -4
  12. package/scripts/harness/traceability-model/composition-root.ts +33 -5
  13. package/scripts/harness/traceability-model/domain/services/traceability-chain-builder.ts +9 -4
  14. package/scripts/harness/traceability-model/domain/value-objects/project-relative-path.ts +2 -4
  15. package/scripts/harness/traceability-model/infrastructure/gateways/markdown-design-document-gateway.ts +6 -2
  16. package/scripts/harness/traceability-model/infrastructure/gateways/markdown-story-catalog-gateway.ts +8 -6
  17. package/scripts/harness/traceability-model/infrastructure/gateways/markdown-unit-definition-gateway.ts +8 -2
  18. package/scripts/harness/traceability-model/presentation/cli/validate-metadata-command-handler.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.130.0] - 2026-05-08
11
+
12
+ ### Fixed
13
+
14
+ - **WI-093 — `paths.designDocs` を L2-001 / traceability-model の product 直下文書まで完全 threading (GitHub Issue #4 finding #4)** — WI-085 で残っていた `docs/product/product_overview.md` / `docs/product/user_stories.md` hardcoded 経路を解消。
15
+ - **phase-dependency-model**: Level 1 product 文書を `{designDocsRoot}/../...` で定義し、`Artifact.resolve()` / `expandRoots()` で POSIX 正規化。`paths.designDocs: "mydocs/product/construction"` の場合、L2 blocker は `mydocs/product/product_overview.md` / `mydocs/product/user_stories.md` を参照する。
16
+ - **traceability-model**: `MarkdownStoryCatalogGateway` / `MarkdownDesignDocumentGateway` / `MarkdownUnitDefinitionGateway` / `TraceabilityChainBuilder` に custom design docs root を注入し、`mydocs/product/user_stories.md` と `mydocs/product/construction/{unit}` を読むよう修正。
17
+ - **後方互換**: config 未指定時は従来通り `docs/product/user_stories.md` / `docs/product/construction/{unit}` を利用。
18
+ - **検証 (publish 前 local/self-host)**: `pnpm exec tsc --noEmit` pass。対象テスト 4 files / 29 tests pass。`pnpm test` は 453 files / 3520 tests pass。`/private/tmp/phasegate-local-wi093` の symlink なし reporter fixture で `validate --layer L2 --format human` を実行し、L2-001 blocker が `mydocs/product/product_overview.md` を参照することを確認。post-publish dogfood は publish 後に実施予定。
19
+
10
20
  ## [0.129.0] - 2026-05-08
11
21
 
12
22
  ### Fixed
@@ -17,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
27
  - **fallback**: `phasegate.config.json` 不在時のみ validator-system の default config にフォールバック。不正 config は従来通り runtime error として扱う。
18
28
  - **検証 (publish 前 local/self-host)**: PhaseGate 自身の `phasegate.config.json` (`layers.L4.enabled: false`) で `validate --layer L4` は exit 0 / PASS / validator 0 件。`phasegate:detect-drift --json` は drift detection direct 経路を維持し、既存 drift 2065 件により exit 1。
19
29
  - **テスト**: `pnpm test` は 452 files / 3518 tests pass。対象テスト 3 files / 11 tests pass。変更ファイルの `validate-metadata` pass。
20
- - **post-publish dogfood**: npm publish 後に、別ディレクトリで `npx phasegate@0.129.0` を使って `phasegate:detect-drift` / `validate --layer L4` / pre-commit 経路を確認する。
30
+ - **post-publish dogfood**: `/private/tmp/phasegate-dogfood-wi092` `npx phasegate@0.129.0` を使い検証済み。`validate --layer L4 --format human` は exit 0 / PASS / validator 0 件、`phasegate:status --json` は L4 `enabled:false`、`phasegate:detect-drift --json` fixture drift 1 件を返して exit 1、`pre-commit` は staged markdown 2 件を検査して PASS。
21
31
 
22
32
  ## [0.128.0] - 2026-05-08
23
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.129.0",
3
+ "version": "0.130.0",
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",
@@ -1,5 +1,5 @@
1
1
  // @unit config-foundation
2
- // @layer public-api
2
+ // @layer application
3
3
 
4
4
  export { createConfigFoundationModule } from './composition-root.js';
5
5
 
@@ -1,3 +1,4 @@
1
+ // @unit harness-api
1
2
  // @layer infrastructure
2
3
  // nyquist-validation-impact-analysis-adapter.ts — NyquistValidationImpactAnalysisAdapter
3
4
  // Wave 2完了後にリアル実装へ差し替え(旧: @stub: wave2-pending)
@@ -24,8 +25,13 @@ export class NyquistValidationImpactAnalysisAdapter implements ImpactAnalysisPor
24
25
  return {
25
26
  async analyzeImpact(storyId: string): Promise<ImpactAnalysisResult | null> {
26
27
  try {
28
+ const { createConfigFoundationModule } = await import('../../../config-foundation/composition-root.js');
29
+ const configModule = createConfigFoundationModule();
30
+ const resolvedConfig = await configModule.usecases.loadResolvedConfigUseCase.execute();
27
31
  const { createTraceabilityModelModule } = await import('../../../traceability-model/composition-root.js');
28
- const traceModule = createTraceabilityModelModule(rootDir);
32
+ const traceModule = createTraceabilityModelModule(rootDir, {
33
+ pathRoots: { designDocsRoot: resolvedConfig.config.paths.designDocs },
34
+ });
29
35
  const storyIds = await traceModule.storyCatalog.getAllStoryIds();
30
36
 
31
37
  const { createNyquistValidationModule } = await import('../../../nyquist-validation/composition-root.js');
@@ -25,15 +25,17 @@ export class PhaseDependencyModelQueryAdapter implements PhaseGateQueryPort {
25
25
  return {
26
26
  async queryAllStories(): Promise<PhaseGateStoryResult[]> {
27
27
  try {
28
- const { createTraceabilityModelModule } = await import('../../../traceability-model/composition-root.js');
29
- const traceModule = createTraceabilityModelModule(rootDir);
30
- const storyIds = await traceModule.storyCatalog.getAllStoryIds();
31
-
32
28
  // WI-085: paths config を phase-dependency-model に流入させる
33
29
  const { createConfigFoundationModule } = await import('../../../config-foundation/composition-root.js');
34
30
  const { toPhaseConfigSection } = await import('../../../config-foundation/application/mappers/phase-config-section-mapper.js');
35
31
  const configModule = createConfigFoundationModule();
36
32
  const resolvedConfig = await configModule.usecases.loadResolvedConfigUseCase.execute();
33
+ const { createTraceabilityModelModule } = await import('../../../traceability-model/composition-root.js');
34
+ const traceModule = createTraceabilityModelModule(rootDir, {
35
+ pathRoots: { designDocsRoot: resolvedConfig.config.paths.designDocs },
36
+ });
37
+ const storyIds = await traceModule.storyCatalog.getAllStoryIds();
38
+
37
39
  const { createPhaseDependencyModelModule } = await import('../../../phase-dependency-model/composition-root.js');
38
40
  const phaseModule = createPhaseDependencyModelModule({
39
41
  rootDir,
@@ -49,6 +49,19 @@ async function loadValidatorSystemConfig(): Promise<object | undefined> {
49
49
  }
50
50
  }
51
51
 
52
+ async function loadTraceabilityModelOptions(): Promise<
53
+ { readonly pathRoots: { readonly designDocsRoot: string } } | undefined
54
+ > {
55
+ const configMod = createConfigFoundationModule();
56
+ try {
57
+ const resolvedConfig = await configMod.usecases.loadResolvedConfigUseCase.execute();
58
+ return { pathRoots: { designDocsRoot: resolvedConfig.config.paths.designDocs } };
59
+ } catch (err) {
60
+ if (err instanceof ConfigNotFoundError) return undefined;
61
+ throw err;
62
+ }
63
+ }
64
+
52
65
  function isTestFile(path: string): boolean {
53
66
  return TEST_FILE_SUFFIXES.some((suffix) => path.endsWith(suffix));
54
67
  }
@@ -319,7 +332,10 @@ export async function runPreCommitCli(): Promise<void> {
319
332
  try {
320
333
  const stagedFiles = getStagedFiles();
321
334
  const validatorMod = createValidatorSystemModule(await loadValidatorSystemConfig());
322
- const traceabilityMod = createTraceabilityModelModule(process.cwd());
335
+ const traceabilityMod = createTraceabilityModelModule(
336
+ process.cwd(),
337
+ await loadTraceabilityModelOptions(),
338
+ );
323
339
 
324
340
  const result = await runPreCommit(
325
341
  stagedFiles,
@@ -351,7 +367,10 @@ export async function runCommitMsgCli(commitMessagePath: string | undefined): Pr
351
367
  const stagedFiles = getStagedFiles();
352
368
  const commitMessage = await readFile(commitMessagePath, "utf-8");
353
369
  const validatorMod = createValidatorSystemModule(await loadValidatorSystemConfig());
354
- const traceabilityMod = createTraceabilityModelModule(process.cwd());
370
+ const traceabilityMod = createTraceabilityModelModule(
371
+ process.cwd(),
372
+ await loadTraceabilityModelOptions(),
373
+ );
355
374
 
356
375
  const result = await runPreCommit(
357
376
  stagedFiles,
@@ -67,6 +67,12 @@ function getProjectRoot(): string {
67
67
  return process.cwd();
68
68
  }
69
69
 
70
+ function toTraceabilityModelOptions(resolvedConfig: HarnessConfigV2 | undefined) {
71
+ return resolvedConfig
72
+ ? { pathRoots: { designDocsRoot: resolvedConfig.paths.designDocs } }
73
+ : undefined;
74
+ }
75
+
70
76
  async function pathExists(path: string): Promise<boolean> {
71
77
  try {
72
78
  await access(path);
@@ -834,7 +840,10 @@ async function main(): Promise<void> {
834
840
 
835
841
  case "migrate": {
836
842
  if (args[1] === "work-items") {
837
- const mod = createTraceabilityModelModule(rootDir);
843
+ const mod = createTraceabilityModelModule(
844
+ rootDir,
845
+ toTraceabilityModelOptions(resolvedConfig),
846
+ );
838
847
  const result = await mod.migrateWorkItemsCommandHandler.execute({
839
848
  dryRun: hasFlag(args, "--dry-run"),
840
849
  apply: hasFlag(args, "--apply"),
@@ -901,7 +910,10 @@ async function main(): Promise<void> {
901
910
 
902
911
  // ── traceability-model ──
903
912
  case "validate-metadata": {
904
- const mod = createTraceabilityModelModule(rootDir);
913
+ const mod = createTraceabilityModelModule(
914
+ rootDir,
915
+ toTraceabilityModelOptions(resolvedConfig),
916
+ );
905
917
  const filePaths = parsePositionalArgs(args.slice(1));
906
918
  const result = await mod.validateMetadataCommandHandler.execute({
907
919
  filePaths,
@@ -31,7 +31,7 @@ export const FULL_PHASE_NODES: readonly PhaseNode[] = Object.freeze([
31
31
  },
32
32
  {
33
33
  name: 'product-overview',
34
- path: 'docs/product/product_overview.md',
34
+ path: '{designDocsRoot}/../product_overview.md',
35
35
  required: true,
36
36
  },
37
37
  ]),
@@ -43,7 +43,7 @@ export const FULL_PHASE_NODES: readonly PhaseNode[] = Object.freeze([
43
43
  },
44
44
  {
45
45
  name: 'user-stories',
46
- path: 'docs/product/user_stories.md',
46
+ path: '{designDocsRoot}/../user_stories.md',
47
47
  required: true,
48
48
  },
49
49
  ]),
@@ -55,7 +55,7 @@ export const FULL_PHASE_NODES: readonly PhaseNode[] = Object.freeze([
55
55
  },
56
56
  {
57
57
  name: 'user-story-mapping',
58
- path: 'docs/product/user_story_mapping.md',
58
+ path: '{designDocsRoot}/../user_story_mapping.md',
59
59
  required: true,
60
60
  },
61
61
  ]),
@@ -67,12 +67,12 @@ export const FULL_PHASE_NODES: readonly PhaseNode[] = Object.freeze([
67
67
  },
68
68
  {
69
69
  name: 'unit-definition',
70
- path: 'docs/product/units/{unit}_unit.md',
70
+ path: '{designDocsRoot}/../units/{unit}_unit.md',
71
71
  required: true,
72
72
  },
73
73
  {
74
74
  name: 'integration-contract',
75
- path: 'docs/product/units/integration_contract.md',
75
+ path: '{designDocsRoot}/../units/integration_contract.md',
76
76
  required: true,
77
77
  },
78
78
  ]),
@@ -31,7 +31,7 @@ export const MINIMAL_PHASE_NODES: readonly PhaseNode[] = Object.freeze([
31
31
  },
32
32
  {
33
33
  name: 'product-overview',
34
- path: 'docs/product/product_overview.md',
34
+ path: '{designDocsRoot}/../product_overview.md',
35
35
  required: true,
36
36
  },
37
37
  ]),
@@ -31,7 +31,7 @@ export const STANDARD_PHASE_NODES: readonly PhaseNode[] = Object.freeze([
31
31
  },
32
32
  {
33
33
  name: 'product-overview',
34
- path: 'docs/product/product_overview.md',
34
+ path: '{designDocsRoot}/../product_overview.md',
35
35
  required: true,
36
36
  },
37
37
  ]),
@@ -43,7 +43,7 @@ export const STANDARD_PHASE_NODES: readonly PhaseNode[] = Object.freeze([
43
43
  },
44
44
  {
45
45
  name: 'user-stories',
46
- path: 'docs/product/user_stories.md',
46
+ path: '{designDocsRoot}/../user_stories.md',
47
47
  required: true,
48
48
  },
49
49
  ]),
@@ -2,6 +2,7 @@
2
2
  * @layer domain
3
3
  * @unit phase-dependency-model
4
4
  */
5
+ import path from 'node:path';
5
6
 
6
7
  const ALLOWED_PLACEHOLDERS = new Set([
7
8
  'unit',
@@ -22,6 +23,8 @@ export const DEFAULT_PATH_ROOTS: PathRoots = Object.freeze({
22
23
  inceptionDocsRoot: 'docs/inception',
23
24
  });
24
25
 
26
+ const normalizeProjectPath = (value: string): string => path.posix.normalize(value);
27
+
25
28
  export interface ArtifactCreateArgs {
26
29
  readonly name: string;
27
30
  readonly path: string;
@@ -105,13 +108,15 @@ export class Artifact {
105
108
  throw new InvalidArtifactPathError(this.path);
106
109
  }
107
110
 
108
- return resolvedPath;
111
+ return normalizeProjectPath(resolvedPath);
109
112
  }
110
113
 
111
114
  expandRoots(pathRoots: PathRoots = DEFAULT_PATH_ROOTS): string {
112
- return this.path
113
- .replaceAll('{designDocsRoot}', pathRoots.designDocsRoot)
114
- .replaceAll('{inceptionDocsRoot}', pathRoots.inceptionDocsRoot);
115
+ return normalizeProjectPath(
116
+ this.path
117
+ .replaceAll('{designDocsRoot}', pathRoots.designDocsRoot)
118
+ .replaceAll('{inceptionDocsRoot}', pathRoots.inceptionDocsRoot),
119
+ );
115
120
  }
116
121
 
117
122
  equals(other: Artifact): boolean {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @layer composition
2
+ * @layer application
3
3
  * @unit traceability-model
4
4
  *
5
5
  * traceability-model ユニットの Composition Root。
@@ -25,12 +25,39 @@ import { MarkdownUnitDefinitionGateway } from "./infrastructure/gateways/markdow
25
25
  import { MigrateWorkItemsCommandHandler } from "./presentation/cli/migrate-work-items-command-handler.js";
26
26
  import { ValidateMetadataCommandHandler } from "./presentation/cli/validate-metadata-command-handler.js";
27
27
 
28
- export function createTraceabilityModelModule(rootDir: string) {
28
+ export interface TraceabilityModelPathRoots {
29
+ readonly designDocsRoot: string;
30
+ }
31
+
32
+ export interface TraceabilityModelModuleOptions {
33
+ readonly pathRoots?: TraceabilityModelPathRoots;
34
+ }
35
+
36
+ const deriveProductDocsRoot = (designDocsRoot: string): string => {
37
+ const normalized = designDocsRoot.replace(/\/+$/, "");
38
+ if (normalized.endsWith("/construction")) {
39
+ return normalized.slice(0, -"/construction".length);
40
+ }
41
+ return normalized;
42
+ };
43
+
44
+ export function createTraceabilityModelModule(
45
+ rootDir: string,
46
+ options: TraceabilityModelModuleOptions = {},
47
+ ) {
48
+ const designDocsRoot = options.pathRoots?.designDocsRoot ?? "docs/product/construction";
49
+ const productDocsRoot = deriveProductDocsRoot(designDocsRoot);
50
+ const storyCatalogPath = `${productDocsRoot}/user_stories.md`;
51
+
29
52
  // Infrastructure gateways
30
- const storyCatalog = new MarkdownStoryCatalogGateway({ rootDir });
31
- const unitDefinition = new MarkdownUnitDefinitionGateway({ rootDir });
53
+ const storyCatalog = new MarkdownStoryCatalogGateway({ rootDir, storyCatalogPath });
54
+ const unitDefinition = new MarkdownUnitDefinitionGateway({
55
+ rootDir,
56
+ productDocsRoot,
57
+ designDocsRoot,
58
+ });
32
59
  const metadataReader = new FileSystemMetadataReader({ rootDir });
33
- const designDocument = new MarkdownDesignDocumentGateway({ rootDir });
60
+ const designDocument = new MarkdownDesignDocumentGateway({ rootDir, designDocsRoot });
34
61
  const inceptionPlan = new FileSystemInceptionPlanGateway({ rootDir });
35
62
  const workItemMigrationSource = new FileSystemWorkItemMigrationSourceGateway({ rootDir });
36
63
  const workItemMigrationApply = new FileSystemWorkItemMigrationApplyGateway({ rootDir });
@@ -46,6 +73,7 @@ export function createTraceabilityModelModule(rootDir: string) {
46
73
  designDocumentPort: designDocument,
47
74
  storyCatalogPort: storyCatalog,
48
75
  inceptionPlanPort: inceptionPlan,
76
+ storyCatalogPath,
49
77
  });
50
78
  const storyIdAliasResolver = new StoryIdAliasResolver(storyCatalog);
51
79
 
@@ -17,7 +17,7 @@ import type { StoryIdLike } from '../value-objects/story-reference.js';
17
17
  import { TraceabilityChain } from '../value-objects/traceability-chain.js';
18
18
 
19
19
  const PROJECT_RELATIVE_PATH_PATTERN = /^(docs|scripts|inception)\//;
20
- const STORY_CATALOG_PATH = 'docs/product/user_stories.md';
20
+ const DEFAULT_STORY_CATALOG_PATH = 'docs/product/user_stories.md';
21
21
 
22
22
  const createPath = (value: string): ProjectRelativePathLike =>
23
23
  Object.freeze({
@@ -136,6 +136,7 @@ export class TraceabilityChainBuilder {
136
136
  private readonly designDocumentPort: DesignDocumentPort;
137
137
  private readonly storyCatalogPort: StoryCatalogPort;
138
138
  private readonly inceptionPlanPort: InceptionPlanPort;
139
+ private readonly storyCatalogPath: ProjectRelativePathLike;
139
140
 
140
141
  constructor(deps: {
141
142
  readonly metadataReaderPort: MetadataReaderPort;
@@ -143,12 +144,16 @@ export class TraceabilityChainBuilder {
143
144
  readonly designDocumentPort: DesignDocumentPort;
144
145
  readonly storyCatalogPort: StoryCatalogPort;
145
146
  readonly inceptionPlanPort: InceptionPlanPort;
147
+ readonly storyCatalogPath?: string;
146
148
  }) {
147
149
  this.metadataReaderPort = deps.metadataReaderPort;
148
150
  this.unitDefinitionPort = deps.unitDefinitionPort;
149
151
  this.designDocumentPort = deps.designDocumentPort;
150
152
  this.storyCatalogPort = deps.storyCatalogPort;
151
153
  this.inceptionPlanPort = deps.inceptionPlanPort;
154
+ this.storyCatalogPath = createPath(
155
+ deps.storyCatalogPath ?? DEFAULT_STORY_CATALOG_PATH,
156
+ );
152
157
  }
153
158
 
154
159
  async build(origin: string | ProjectRelativePathLike): Promise<TraceabilityChain> {
@@ -235,7 +240,7 @@ export class TraceabilityChainBuilder {
235
240
  designToStoryLinks.push(
236
241
  createLink({
237
242
  from: designDocument,
238
- to: createPath(STORY_CATALOG_PATH),
243
+ to: this.storyCatalogPath,
239
244
  linkType: 'design-to-story',
240
245
  resolved: false,
241
246
  }),
@@ -248,7 +253,7 @@ export class TraceabilityChainBuilder {
248
253
  designToStoryLinks.push(
249
254
  createLink({
250
255
  from: designDocument,
251
- to: createPath(STORY_CATALOG_PATH),
256
+ to: this.storyCatalogPath,
252
257
  linkType: 'design-to-story',
253
258
  resolved: storyResolved,
254
259
  }),
@@ -265,7 +270,7 @@ export class TraceabilityChainBuilder {
265
270
  );
266
271
  storyToPlanLinks.push(
267
272
  createLink({
268
- from: createPath(STORY_CATALOG_PATH),
273
+ from: this.storyCatalogPath,
269
274
  to:
270
275
  planRoot ??
271
276
  createPlaceholderPath(
@@ -2,10 +2,8 @@
2
2
  * @layer domain
3
3
  * @unit traceability-model
4
4
  *
5
- * docs/ または scripts/ 配下のPOSIX相対パス
5
+ * プロジェクト相対POSIXパス
6
6
  */
7
- const ALLOWED_PROJECT_ROOTS = new Set(['docs', 'scripts']);
8
-
9
7
  export class ProjectRelativePathError extends Error {
10
8
  readonly value: string;
11
9
 
@@ -42,7 +40,7 @@ const normalizeSegments = (rawValue: string): readonly string[] => {
42
40
  return segment;
43
41
  });
44
42
 
45
- if (segments.length === 0 || !ALLOWED_PROJECT_ROOTS.has(segments[0])) {
43
+ if (segments.length === 0) {
46
44
  throw new ProjectRelativePathError(rawValue);
47
45
  }
48
46
 
@@ -21,18 +21,22 @@ import { parseWorkItemFrontmatter } from "../parsers/work-item-frontmatter-parse
21
21
 
22
22
  export interface MarkdownDesignDocumentGatewayDeps {
23
23
  readonly rootDir: string;
24
+ readonly designDocsRoot?: string;
24
25
  }
25
26
 
26
27
  export class MarkdownDesignDocumentGateway implements DesignDocumentPort {
27
28
  private readonly rootDir: string;
29
+ private readonly designDocsRoot: string;
28
30
  private readonly contentCache = new Map<string, string>();
29
31
 
30
32
  constructor(deps: MarkdownDesignDocumentGatewayDeps) {
31
33
  this.rootDir = deps.rootDir;
34
+ this.designDocsRoot = deps.designDocsRoot ?? path.join("docs", "product", "construction");
32
35
  }
33
36
 
34
37
  async listByUnit(unitName: string): Promise<readonly ProjectRelativePathLike[]> {
35
- const constructionDir = path.join(this.rootDir, "docs", "product", "construction", unitName);
38
+ const constructionRoot = path.posix.join(this.designDocsRoot, unitName);
39
+ const constructionDir = path.join(this.rootDir, constructionRoot);
36
40
 
37
41
  if (!fs.existsSync(constructionDir)) {
38
42
  return [];
@@ -43,7 +47,7 @@ export class MarkdownDesignDocumentGateway implements DesignDocumentPort {
43
47
 
44
48
  for (const entry of entries) {
45
49
  if (entry.endsWith(".md")) {
46
- results.push(ProjectRelativePath.create(`docs/product/construction/${unitName}/${entry}`));
50
+ results.push(ProjectRelativePath.create(path.posix.join(constructionRoot, entry)));
47
51
  }
48
52
  }
49
53
 
@@ -13,16 +13,23 @@ import { parseStoryCatalog } from '../parsers/story-catalog-parser.js';
13
13
 
14
14
  export interface MarkdownStoryCatalogGatewayDeps {
15
15
  readonly rootDir: string;
16
+ readonly storyCatalogPath?: string;
16
17
  }
17
18
 
18
19
  export class MarkdownStoryCatalogGateway implements StoryCatalogPort {
19
20
  private readonly rootDir: string;
21
+ private readonly storyCatalogPath: string;
20
22
  private cachedStoryIds: readonly StoryIdLike[] | null = null;
21
23
  private cachedAliasMap: ReadonlyMap<string, StoryIdLike> | null = null;
22
24
  private cachedMtime: number | null = null;
23
25
 
24
26
  constructor(deps: MarkdownStoryCatalogGatewayDeps) {
25
27
  this.rootDir = deps.rootDir;
28
+ this.storyCatalogPath = deps.storyCatalogPath ?? path.join(
29
+ 'docs',
30
+ 'product',
31
+ 'user_stories.md',
32
+ );
26
33
  }
27
34
 
28
35
  async getAllStoryIds(): Promise<readonly StoryIdLike[]> {
@@ -48,12 +55,7 @@ export class MarkdownStoryCatalogGateway implements StoryCatalogPort {
48
55
  }
49
56
 
50
57
  private async ensureLoaded(): Promise<void> {
51
- const filePath = path.join(
52
- this.rootDir,
53
- 'docs',
54
- 'product',
55
- 'user_stories.md',
56
- );
58
+ const filePath = path.join(this.rootDir, this.storyCatalogPath);
57
59
 
58
60
  let currentMtime: number;
59
61
  try {
@@ -15,14 +15,20 @@ const UNIT_ID_PATTERN = /Unit\s+ID\*{0,2}\s*[::]\s*(\S+)/;
15
15
 
16
16
  export interface MarkdownUnitDefinitionGatewayDeps {
17
17
  readonly rootDir: string;
18
+ readonly productDocsRoot?: string;
19
+ readonly designDocsRoot?: string;
18
20
  }
19
21
 
20
22
  export class MarkdownUnitDefinitionGateway implements UnitDefinitionPort {
21
23
  private readonly rootDir: string;
24
+ private readonly productDocsRoot: string;
25
+ private readonly designDocsRoot: string;
22
26
  private cachedUnitNames: readonly string[] | null = null;
23
27
 
24
28
  constructor(deps: MarkdownUnitDefinitionGatewayDeps) {
25
29
  this.rootDir = deps.rootDir;
30
+ this.productDocsRoot = deps.productDocsRoot ?? path.join('docs', 'product');
31
+ this.designDocsRoot = deps.designDocsRoot ?? path.join('docs', 'product', 'construction');
26
32
  }
27
33
 
28
34
  async getAllUnitNames(): Promise<readonly string[]> {
@@ -47,7 +53,7 @@ export class MarkdownUnitDefinitionGateway implements UnitDefinitionPort {
47
53
  return null;
48
54
  }
49
55
 
50
- const constructionPath = `docs/product/construction/${unitName}`;
56
+ const constructionPath = path.posix.join(this.designDocsRoot, unitName);
51
57
  const absolutePath = path.join(this.rootDir, constructionPath);
52
58
 
53
59
  if (!fs.existsSync(absolutePath)) {
@@ -68,7 +74,7 @@ export class MarkdownUnitDefinitionGateway implements UnitDefinitionPort {
68
74
  return;
69
75
  }
70
76
 
71
- const unitsDir = path.join(this.rootDir, 'docs', 'product', 'units');
77
+ const unitsDir = path.join(this.rootDir, this.productDocsRoot, 'units');
72
78
  if (!fs.existsSync(unitsDir)) {
73
79
  this.cachedUnitNames = Object.freeze([]);
74
80
  return;
@@ -110,7 +110,7 @@ export class ValidateMetadataCommandHandler {
110
110
  results: Object.freeze([]),
111
111
  text:
112
112
  `Error: invalid file path: "${err.value}"\n` +
113
- ` Hint: paths must be project-relative and start with 'docs/' or 'scripts/'.`,
113
+ ` Hint: paths must be project-relative POSIX paths without '..' segments.`,
114
114
  });
115
115
  }
116
116
  return Object.freeze({