phasegate 0.138.0 → 0.139.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 (31) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +2 -2
  4. package/docs/folder_management_rules.md +4 -1
  5. package/docs/guide/configuration.md +21 -0
  6. package/docs/guide/layer-model.md +2 -2
  7. package/package.json +1 -1
  8. package/scripts/harness/agent-integration/application/dto/handle-pre-tool-use-dto.ts +5 -0
  9. package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +4 -1
  10. package/scripts/harness/agent-integration/domain/ports/full-mode-requirement-query-port.ts +10 -1
  11. package/scripts/harness/agent-integration/infrastructure/adapters/quick-mode-full-mode-requirement-adapter.ts +9 -2
  12. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +56 -1
  13. package/scripts/harness/config-foundation/domain/harness-config.ts +4 -0
  14. package/scripts/harness/config-foundation/domain/services/preset-resolution-service.ts +6 -0
  15. package/scripts/harness/config-foundation/infrastructure/presets/minimal.json +3 -0
  16. package/scripts/harness/config-foundation/infrastructure/presets/standard.json +3 -0
  17. package/scripts/harness/config-foundation/infrastructure/presets/strict.json +3 -0
  18. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +15 -0
  19. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +15 -0
  20. package/scripts/harness/integrations/pre-commit.ts +38 -9
  21. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +15 -3
  22. package/scripts/harness/quick-mode/domain/services/comment-only-diff-detector.ts +94 -0
  23. package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +6 -1
  24. package/scripts/harness/quick-mode/domain/value-objects/changed-file.ts +27 -5
  25. package/scripts/harness/traceability-model/application/usecases/validate-design-story-annotations-usecase.ts +35 -0
  26. package/scripts/harness/traceability-model/composition-root.ts +3 -0
  27. package/scripts/harness/traceability-model/domain/ports/work-item-identity-port.ts +13 -0
  28. package/scripts/harness/traceability-model/domain/services/work-item-identity-validation-service.ts +46 -0
  29. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-identity-gateway.ts +74 -0
  30. package/templates/.claude/scripts/analyze-errors-hook.sh +1 -1
  31. package/templates/.claude/scripts/format-typescript-hook.sh +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.139.0] - 2026-05-09
11
+
12
+ ### Added
13
+
14
+ - **WI-012 — pre-commit implementation extension 設定** — `preCommit.implementationExtensions` を config schema / preset / pre-commit adapter に追加し、`.ts` 以外の実装ファイル拡張子を検査対象にできるようにした。
15
+
16
+ ### Fixed
17
+
18
+ - **WI-015 — quick mode comment-only API diff** — API 面の TypeScript ファイルでコメントのみが変わった場合、quick mode が implementation 証拠なしで `pending` に戻さないようにした。
19
+ - **WI-030 — README / layer-model drift correction** — 公開ドキュメントの validator 数と L0 記述を現行仕様に合わせて更新した。
20
+ - **WI-061 / WI-062 / WI-063 / WI-064 / WI-071 — reflected regression / skill WI closure** — 既存実装と回帰テストの証跡に基づき、対応済み WI を `tested` に整理した。
21
+
22
+ ## [0.138.1] - 2026-05-09
23
+
24
+ ### Fixed
25
+
26
+ - **WI-106 — inception WI ID 重複防止** — `docs/inception/**/WI-XXX/description.md` の frontmatter `id` を global scan し、`_cross` と Unit 配下をまたぐ重複、および parent directory 名と `id` の不一致を `validate-metadata` 経路で検出するようにした。
27
+ - `docs/folder_management_rules.md` / `AGENTS.md` に、新規 WI 作成時の global unique ルールを明記。
28
+ - `migrate work-items` の採番が `_cross` と Unit 配下の既存 WI 番号を避けることを回帰テストで固定。
29
+
10
30
  ## [0.138.0] - 2026-05-09
11
31
 
12
32
  ### Fixed
package/README.ja.md CHANGED
@@ -148,7 +148,7 @@ npx phasegate update-skills # スキルを最新版に再デプロイ
148
148
  | security, performance, coverage 90%/95%, 要件カバレッジ |
149
149
  +------------------------------------------------------------------+
150
150
  | L4 週次 (default off) |
151
- | 設計-コード乖離, 文書整合性, デッドコード, 文書鮮度 |
151
+ | 設計-コード乖離, 文書整合性, デッドコード |
152
152
  +------------------------------------------------------------------+
153
153
  ```
154
154
 
package/README.md CHANGED
@@ -146,7 +146,7 @@ npx phasegate update-skills
146
146
  +------------------------------------------------------------------+
147
147
  | L4 SCHEDULED Validators (default off) |
148
148
  | drift-detection, consistency-check, dead-code analysis, |
149
- | doc-freshness, pointer-validation |
149
+ | phase2 freshness/pointer checks via standalone p2:* commands |
150
150
  +------------------------------------------------------------------+
151
151
  ```
152
152
 
@@ -156,7 +156,7 @@ npx phasegate update-skills
156
156
  | L1 | Editor save / `phasegate lint` | `@unit` / `@layer` metadata, layer violations, AI anti-patterns, dead code |
157
157
  | L2 | Pre-commit (also evaluated inside PreToolUse at L0) | Phase gate, metadata completeness, `@work-item-id` reflection (`L2-STORY-REFLECTION`), test quality |
158
158
  | L3 | CI/CD pipeline | Security, performance, coverage (90%/95%), requirements traceability |
159
- | L4 | Scheduled (weekly). Currently `layers.L4.enabled: false` by default — opt-in per project | Design-code drift, cross-document consistency, dead code, doc freshness, pointer validation |
159
+ | L4 | Scheduled (weekly). Currently `layers.L4.enabled: false` by default — opt-in per project | Design-code drift, cross-document consistency, dead code. Doc freshness and pointer checks are standalone `p2:*` commands until WI-033. |
160
160
 
161
161
  ---
162
162
 
@@ -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
 
@@ -58,6 +58,9 @@ This file is the **Single Source of Truth** for all quality configuration in a P
58
58
  "format": "json",
59
59
  "outputDir": "reports"
60
60
  },
61
+ "preCommit": {
62
+ "implementationExtensions": [".ts"]
63
+ },
61
64
  "baseline": {
62
65
  "enabled": true,
63
66
  "path": ".phasegate/baseline.json"
@@ -144,6 +147,24 @@ Set a flag to `false` only when the project intentionally accepts the risk of me
144
147
  | `gates` | `array` | `[]` | Array of custom phase gate definitions. See [gates\[\]](#gates-custom-phase-gates) below. Optional; defaults to empty. |
145
148
  | `storyReflection` | `object` | preset-based | See [storyReflection](#storyreflection-inception--product-gate) below. Omit entirely for zero-config defaults per preset. |
146
149
 
150
+ #### `preCommit`
151
+
152
+ | Sub-field | Type | Default | Description |
153
+ |-----------|------|---------|-------------|
154
+ | `implementationExtensions` | `string[]` | `[".ts"]` | File extensions treated as implementation files by `phasegate pre-commit`. Add entries such as `".py"` or `".go"` for non-TypeScript projects. |
155
+
156
+ Example for Python and TypeScript:
157
+
158
+ ```jsonc
159
+ {
160
+ "preCommit": {
161
+ "implementationExtensions": [".ts", ".py"]
162
+ }
163
+ }
164
+ ```
165
+
166
+ The metadata validators read `@unit` and `@layer` with language-agnostic regular expressions, so Python `# @unit api` and Go `// @unit api` style comments are both valid as long as the file extension is included here.
167
+
147
168
  ##### Phase Dependency Presets
148
169
 
149
170
  | Preset | Phase 3 Gates | storyReflection default | Use Case |
@@ -147,8 +147,8 @@ L4 validators are designed to run on a weekly schedule and detect slow-moving dr
147
147
  | **drift-detect** | L4-001 | Bidirectional design-code drift detection. Compares design documents against the actual codebase to find divergence in either direction. |
148
148
  | **consistency-check** | L4-002 | Cross-document layer consistency. Ensures that references between design documents, ADRs, and code remain coherent. |
149
149
  | **dead-code** | L4-003 | Detects unused exports and unreachable code that should be removed. |
150
- | **doc-freshness** | L4-004 | Checks whether design documents exceed the configured freshness threshold. The standalone `p2:check-freshness` CLI remains available. |
151
- | **pointer-validation** | L4-005 | Validates file-path pointers embedded in design documents. The standalone `p2:validate-pointers` CLI remains available. |
150
+
151
+ `doc-freshness` and `pointer-validation` are implemented as `phase2-extensions` CLI commands (`p2:check-freshness` and `p2:validate-pointers`). They are not registered as L4 validators yet, so `validate --layer L4` does not run them until WI-033 promotes them into `validator-system`.
152
152
 
153
153
  ### Drift-detect design pointers
154
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.138.0",
3
+ "version": "0.139.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",
@@ -6,6 +6,11 @@
6
6
  export interface HandlePreToolUseInput {
7
7
  toolName: string;
8
8
  targetFilePaths: string[];
9
+ targetChanges?: {
10
+ filePath: string;
11
+ beforeContent?: string | null;
12
+ afterContent?: string | null;
13
+ }[];
9
14
  }
10
15
 
11
16
  export interface HandlePreToolUseOutput {
@@ -143,7 +143,10 @@ export class HandlePreToolUseUseCase {
143
143
  if (grandfather.allGrandfathered) {
144
144
  this.grandfatherLogger("full-mode", input.targetFilePaths);
145
145
  } else {
146
- const fullModeResult = await this.fullModeRequirementQueryPort.check(input.targetFilePaths);
146
+ const fullModeResult = await this.fullModeRequirementQueryPort.check(
147
+ input.targetFilePaths,
148
+ input.targetChanges,
149
+ );
147
150
  if (fullModeResult.requiresFullMode) {
148
151
  // ISSUE-021: 当該Unitの必須設計文書が揃っている場合は full mode block を bypass
149
152
  //(hook がスキルコンテキストを参照できない構造的ギャップへの対処)
@@ -8,6 +8,15 @@ export interface FullModeRequirementQueryResult {
8
8
  readonly dominantCategory?: string;
9
9
  }
10
10
 
11
+ export interface FullModeTargetChange {
12
+ readonly filePath: string;
13
+ readonly beforeContent?: string | null;
14
+ readonly afterContent?: string | null;
15
+ }
16
+
11
17
  export interface FullModeRequirementQueryPort {
12
- check(targetFilePaths: readonly string[]): Promise<FullModeRequirementQueryResult>;
18
+ check(
19
+ targetFilePaths: readonly string[],
20
+ targetChanges?: readonly FullModeTargetChange[],
21
+ ): Promise<FullModeRequirementQueryResult>;
13
22
  }
@@ -4,6 +4,7 @@
4
4
  import type {
5
5
  FullModeRequirementQueryPort,
6
6
  FullModeRequirementQueryResult,
7
+ FullModeTargetChange,
7
8
  } from '../../domain/ports/full-mode-requirement-query-port.js';
8
9
  import type { ClassifyChangeCategoryUseCase } from '../../../quick-mode/application/usecases/classify-change-category-usecase.js';
9
10
 
@@ -18,14 +19,20 @@ export class QuickModeFullModeRequirementAdapter implements FullModeRequirementQ
18
19
  this.classifyUseCaseFactory = deps.classifyUseCaseFactory;
19
20
  }
20
21
 
21
- async check(targetFilePaths: readonly string[]): Promise<FullModeRequirementQueryResult> {
22
+ async check(
23
+ targetFilePaths: readonly string[],
24
+ targetChanges?: readonly FullModeTargetChange[],
25
+ ): Promise<FullModeRequirementQueryResult> {
22
26
  if (targetFilePaths.length === 0) {
23
27
  return { requiresFullMode: false };
24
28
  }
25
29
 
26
30
  try {
27
31
  const useCase = this.classifyUseCaseFactory();
28
- const contract = await useCase.execute({ paths: [...targetFilePaths] });
32
+ const contract = await useCase.execute({
33
+ paths: [...targetFilePaths],
34
+ targetChanges,
35
+ });
29
36
  if (!contract.fullModeRequired) {
30
37
  return {
31
38
  requiresFullMode: false,
@@ -27,10 +27,21 @@ interface PreToolUseHookInput {
27
27
  file_path?: string;
28
28
  paths?: string[];
29
29
  command?: string;
30
+ content?: string;
31
+ old_string?: string;
32
+ new_string?: string;
33
+ old_str?: string;
34
+ new_str?: string;
30
35
  [key: string]: unknown;
31
36
  };
32
37
  }
33
38
 
39
+ interface TargetChange {
40
+ filePath: string;
41
+ beforeContent?: string | null;
42
+ afterContent?: string | null;
43
+ }
44
+
34
45
  async function readStdin(): Promise<string> {
35
46
  const chunks: Buffer[] = [];
36
47
  for await (const chunk of process.stdin) {
@@ -98,6 +109,8 @@ async function main(): Promise<void> {
98
109
  targetFilePaths.push(...input.tool_input.paths.map(toRelative));
99
110
  }
100
111
 
112
+ const targetChanges = await buildTargetChanges(input, cwd, toRelative);
113
+
101
114
  // Bash 経由書き込みのフェーズゲート対応 (A-2.5)
102
115
  // Bash command 文字列からリダイレクト・tee・sed -i・cp・mv・touch 等の
103
116
  // 書き込み先ファイルパスを抽出し、フェーズゲートチェック対象に含める。
@@ -141,7 +154,7 @@ async function main(): Promise<void> {
141
154
  errorGuidanceQueryPort,
142
155
  });
143
156
 
144
- const output = await useCase.execute({ toolName: effectiveToolName, targetFilePaths });
157
+ const output = await useCase.execute({ toolName: effectiveToolName, targetFilePaths, targetChanges });
145
158
 
146
159
  if (output.shouldBlock) {
147
160
  const msg = output.error?.message
@@ -165,6 +178,48 @@ async function main(): Promise<void> {
165
178
  }
166
179
  }
167
180
 
181
+ async function buildTargetChanges(
182
+ input: PreToolUseHookInput,
183
+ cwd: string,
184
+ toRelative: (p: string) => string,
185
+ ): Promise<TargetChange[]> {
186
+ const toolInput = input.tool_input;
187
+ if (toolInput === undefined) {
188
+ return [];
189
+ }
190
+
191
+ const rawPath = toolInput.file_path ?? toolInput.path;
192
+ if (rawPath === undefined) {
193
+ return [];
194
+ }
195
+
196
+ const filePath = toRelative(rawPath);
197
+ const oldString = typeof toolInput.old_string === 'string' ? toolInput.old_string : toolInput.old_str;
198
+ const newString = typeof toolInput.new_string === 'string' ? toolInput.new_string : toolInput.new_str;
199
+ if (typeof oldString === 'string' && typeof newString === 'string') {
200
+ return [{ filePath, beforeContent: oldString, afterContent: newString }];
201
+ }
202
+
203
+ if (typeof toolInput.content === 'string') {
204
+ return [{
205
+ filePath,
206
+ beforeContent: await readExistingContent(cwd, rawPath),
207
+ afterContent: toolInput.content,
208
+ }];
209
+ }
210
+
211
+ return [];
212
+ }
213
+
214
+ async function readExistingContent(cwd: string, filePath: string): Promise<string | null> {
215
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
216
+ try {
217
+ return await fs.readFile(absolutePath, 'utf8');
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
168
223
  main().catch((error) => {
169
224
  process.stderr.write(`予期しないエラー: ${String(error)}\n`);
170
225
  process.exit(2);
@@ -52,6 +52,7 @@ export interface HarnessConfigSourceDocument {
52
52
  reporting: HarnessConfigResolvedDocument['reporting'];
53
53
  ci?: HarnessConfigResolvedDocument['ci'];
54
54
  validate?: HarnessConfigResolvedDocument['validate'];
55
+ preCommit?: Partial<HarnessConfigResolvedDocument['preCommit']>;
55
56
  architecture?: ArchitectureConfigSource;
56
57
  }
57
58
 
@@ -117,6 +118,9 @@ export interface HarnessConfigResolvedDocument {
117
118
  validate: {
118
119
  failOnWarning: boolean;
119
120
  };
121
+ preCommit?: {
122
+ implementationExtensions: string[];
123
+ };
120
124
  architecture?: ArchitectureConfigDocument;
121
125
  }
122
126
 
@@ -19,6 +19,7 @@ export interface PresetDefinition {
19
19
  paths: HarnessConfigResolvedDocument['paths'];
20
20
  reporting: HarnessConfigResolvedDocument['reporting'];
21
21
  validate: HarnessConfigResolvedDocument['validate'];
22
+ preCommit?: HarnessConfigResolvedDocument['preCommit'];
22
23
  }
23
24
 
24
25
  export class InvalidPresetDefinitionError extends ConfigFoundationDomainError {
@@ -184,6 +185,11 @@ export class PresetResolutionService {
184
185
  sourceDocument.validate,
185
186
  'validate',
186
187
  ),
188
+ preCommit: deepMerge(
189
+ presetDefinition.preCommit ?? { implementationExtensions: ['.ts'] },
190
+ sourceDocument.preCommit,
191
+ 'preCommit',
192
+ ),
187
193
  };
188
194
  }
189
195
 
@@ -49,5 +49,8 @@
49
49
  },
50
50
  "validate": {
51
51
  "failOnWarning": false
52
+ },
53
+ "preCommit": {
54
+ "implementationExtensions": [".ts"]
52
55
  }
53
56
  }
@@ -49,5 +49,8 @@
49
49
  },
50
50
  "validate": {
51
51
  "failOnWarning": false
52
+ },
53
+ "preCommit": {
54
+ "implementationExtensions": [".ts"]
52
55
  }
53
56
  }
@@ -49,5 +49,8 @@
49
49
  },
50
50
  "validate": {
51
51
  "failOnWarning": true
52
+ },
53
+ "preCommit": {
54
+ "implementationExtensions": [".ts"]
52
55
  }
53
56
  }
@@ -357,6 +357,21 @@
357
357
  }
358
358
  }
359
359
  },
360
+ "preCommit": {
361
+ "type": "object",
362
+ "additionalProperties": false,
363
+ "properties": {
364
+ "implementationExtensions": {
365
+ "type": "array",
366
+ "items": {
367
+ "type": "string",
368
+ "pattern": "^\\.[A-Za-z0-9]+$"
369
+ },
370
+ "minItems": 1,
371
+ "uniqueItems": true
372
+ }
373
+ }
374
+ },
360
375
  "planningMode": {
361
376
  "type": "object",
362
377
  "additionalProperties": false,
@@ -357,6 +357,21 @@
357
357
  }
358
358
  }
359
359
  },
360
+ "preCommit": {
361
+ "type": "object",
362
+ "additionalProperties": false,
363
+ "properties": {
364
+ "implementationExtensions": {
365
+ "type": "array",
366
+ "items": {
367
+ "type": "string",
368
+ "pattern": "^\\.[A-Za-z0-9]+$"
369
+ },
370
+ "minItems": 1,
371
+ "uniqueItems": true
372
+ }
373
+ }
374
+ },
360
375
  "planningMode": {
361
376
  "type": "object",
362
377
  "additionalProperties": false,
@@ -32,7 +32,7 @@ const BOLD = "\x1b[1m";
32
32
  const DIM = "\x1b[2m";
33
33
  const RESET = "\x1b[0m";
34
34
 
35
- const TS_EXTENSION = ".ts";
35
+ const DEFAULT_IMPLEMENTATION_EXTENSIONS = Object.freeze([".ts"]);
36
36
  const MD_EXTENSION = ".md";
37
37
  const WORK_ITEM_PATH_PATTERN = /(?:^|\/)WI-\d+(?:\/|$)/;
38
38
  const WORK_ITEM_TRAILER_PATTERN = /^Work-Item:\s*WI-\d+\s*$/m;
@@ -62,6 +62,17 @@ async function loadTraceabilityModelOptions(): Promise<
62
62
  }
63
63
  }
64
64
 
65
+ async function loadPreCommitImplementationExtensions(): Promise<readonly string[] | undefined> {
66
+ const configMod = createConfigFoundationModule();
67
+ try {
68
+ const resolvedConfig = await configMod.usecases.loadResolvedConfigUseCase.execute();
69
+ return resolvedConfig.config.preCommit?.implementationExtensions;
70
+ } catch (err) {
71
+ if (err instanceof ConfigNotFoundError) return undefined;
72
+ throw err;
73
+ }
74
+ }
75
+
65
76
  function isTestFile(path: string): boolean {
66
77
  return TEST_FILE_SUFFIXES.some((suffix) => path.endsWith(suffix));
67
78
  }
@@ -179,6 +190,7 @@ export interface PreCommitOptions {
179
190
  * intentionally opt-in and preserves existing local pre-commit behavior.
180
191
  */
181
192
  readonly commitMessage?: string;
193
+ readonly implementationExtensions?: readonly string[];
182
194
  }
183
195
 
184
196
  function getStagedFiles(): string[] {
@@ -233,17 +245,29 @@ function hasWorkItemTrailer(commitMessage: string): boolean {
233
245
  return WORK_ITEM_TRAILER_PATTERN.test(commitMessage);
234
246
  }
235
247
 
248
+ function normalizeImplementationExtensions(extensions: readonly string[] | undefined): readonly string[] {
249
+ const rawExtensions = extensions === undefined || extensions.length === 0
250
+ ? DEFAULT_IMPLEMENTATION_EXTENSIONS
251
+ : extensions;
252
+ return rawExtensions.map((extension) => extension.startsWith(".") ? extension : `.${extension}`);
253
+ }
254
+
255
+ function hasAnyExtension(filePath: string, extensions: readonly string[]): boolean {
256
+ return extensions.some((extension) => filePath.endsWith(extension));
257
+ }
258
+
236
259
  export async function runPreCommit(
237
260
  stagedFiles: readonly string[],
238
261
  deps: PreCommitDeps,
239
262
  options: PreCommitOptions = {},
240
263
  ): Promise<PreCommitResult> {
241
- const tsFiles = stagedFiles.filter((f) => f.endsWith(TS_EXTENSION));
264
+ const implementationExtensions = normalizeImplementationExtensions(options.implementationExtensions);
265
+ const implementationFiles = stagedFiles.filter((f) => hasAnyExtension(f, implementationExtensions));
242
266
  const mdFiles = stagedFiles.filter((f) => f.endsWith(MD_EXTENSION) && isMetadataMarkdownFile(f));
243
- const testFiles = tsFiles.filter((f) => isTestFile(f));
267
+ const testFiles = implementationFiles.filter((f) => isTestFile(f));
244
268
  const metadataFiles = [...mdFiles, ...testFiles];
245
269
 
246
- if (tsFiles.length === 0 && mdFiles.length === 0) {
270
+ if (implementationFiles.length === 0 && mdFiles.length === 0) {
247
271
  return {
248
272
  exitCode: 0,
249
273
  stdout: `${DIM}[phasegate] No staged files to check. Skipping.${RESET}`,
@@ -252,17 +276,18 @@ export async function runPreCommit(
252
276
 
253
277
  const sections: string[] = [];
254
278
  sections.push(
255
- `${BOLD}[phasegate]${RESET} Pre-commit check ` + `(${tsFiles.length} .ts file(s), ${mdFiles.length} .md file(s))`,
279
+ `${BOLD}[phasegate]${RESET} Pre-commit check ` +
280
+ `(${implementationFiles.length} implementation file(s), ${mdFiles.length} .md file(s))`,
256
281
  );
257
282
 
258
283
  let exitCode: 0 | 1 | 2 = 0;
259
284
 
260
- if (tsFiles.length > 0) {
285
+ if (implementationFiles.length > 0) {
261
286
  // staged TS file を Unit ごとにグルーピングし、Unit 単位で L2 phase gate
262
287
  // (L2-001 の `{unit}_unit.md` 等)を評価する。Unit を特定できないファイルは
263
288
  // 別グループ(unitName='')として従来挙動で評価する。
264
289
  const filesByUnit = new Map<string, string[]>();
265
- for (const f of tsFiles) {
290
+ for (const f of implementationFiles) {
266
291
  const unit = (await resolveUnitName(f)) ?? "";
267
292
  const bucket = filesByUnit.get(unit);
268
293
  if (bucket) {
@@ -285,7 +310,7 @@ export async function runPreCommit(
285
310
  const merged = mergePerUnitResults(runs);
286
311
  const report = buildReport(merged);
287
312
  sections.push("");
288
- sections.push(`${BOLD}== TypeScript 実装 (${tsFiles.length} file(s)) ==${RESET}`);
313
+ sections.push(`${BOLD}== 実装ファイル (${implementationFiles.length} file(s)) ==${RESET}`);
289
314
  sections.push(new HumanValidationResultFormatter().format(report));
290
315
  if (!report.overallPassed) {
291
316
  exitCode = maxExitCode(exitCode, 1);
@@ -345,6 +370,7 @@ export async function runPreCommitCli(): Promise<void> {
345
370
  },
346
371
  {
347
372
  commitMessage: process.env.PHASEGATE_COMMIT_MESSAGE,
373
+ implementationExtensions: await loadPreCommitImplementationExtensions(),
348
374
  },
349
375
  );
350
376
 
@@ -378,7 +404,10 @@ export async function runCommitMsgCli(commitMessagePath: string | undefined): Pr
378
404
  runL2ValidatorsUseCase: validatorMod.runL2ValidatorsUseCase,
379
405
  validateMetadataCommandHandler: traceabilityMod.validateMetadataCommandHandler,
380
406
  },
381
- { commitMessage },
407
+ {
408
+ commitMessage,
409
+ implementationExtensions: await loadPreCommitImplementationExtensions(),
410
+ },
382
411
  );
383
412
 
384
413
  process.stdout.write(`${result.stdout}\n`);
@@ -13,6 +13,11 @@ import type { ChangeCategoryClassificationContract, ChangeCategoryPerFile } from
13
13
 
14
14
  export interface ClassifyChangeCategoryUseCaseInput {
15
15
  readonly paths: readonly string[];
16
+ readonly targetChanges?: readonly {
17
+ readonly filePath: string;
18
+ readonly beforeContent?: string | null;
19
+ readonly afterContent?: string | null;
20
+ }[];
16
21
  }
17
22
 
18
23
  export interface ClassifyChangeCategoryUseCaseDeps {
@@ -42,9 +47,16 @@ export class ClassifyChangeCategoryUseCase {
42
47
  });
43
48
  }
44
49
 
45
- const changedFiles = input.paths.map((p) =>
46
- ChangedFile.create({ filePath: p, changeKind: 'MODIFY' })
47
- );
50
+ const targetChanges = new Map((input.targetChanges ?? []).map((change) => [change.filePath, change]));
51
+ const changedFiles = input.paths.map((p) => {
52
+ const targetChange = targetChanges.get(p);
53
+ return ChangedFile.create({
54
+ filePath: p,
55
+ changeKind: 'MODIFY',
56
+ beforeContent: targetChange?.beforeContent ?? null,
57
+ afterContent: targetChange?.afterContent ?? null,
58
+ });
59
+ });
48
60
 
49
61
  const classification = this.judgmentEngine.classify(changedFiles, config);
50
62
  const eligibility = this.judgmentEngine.judge(changedFiles, config);
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @layer domain
3
+ * @unit quick-mode
4
+ * @work-item-id WI-015
5
+ */
6
+
7
+ import type { ChangedFile } from '../value-objects/changed-file.js';
8
+
9
+ type ScannerState = 'normal' | 'single' | 'double' | 'template' | 'lineComment' | 'blockComment';
10
+
11
+ function stripCommentsAndWhitespace(source: string): string {
12
+ let state: ScannerState = 'normal';
13
+ let output = '';
14
+ let escaped = false;
15
+
16
+ for (let i = 0; i < source.length; i += 1) {
17
+ const char = source[i] ?? '';
18
+ const next = source[i + 1] ?? '';
19
+
20
+ if (state === 'lineComment') {
21
+ if (char === '\n' || char === '\r') {
22
+ state = 'normal';
23
+ }
24
+ continue;
25
+ }
26
+
27
+ if (state === 'blockComment') {
28
+ if (char === '*' && next === '/') {
29
+ i += 1;
30
+ state = 'normal';
31
+ }
32
+ continue;
33
+ }
34
+
35
+ if (state === 'single' || state === 'double' || state === 'template') {
36
+ output += char;
37
+ if (escaped) {
38
+ escaped = false;
39
+ continue;
40
+ }
41
+ if (char === '\\') {
42
+ escaped = true;
43
+ continue;
44
+ }
45
+ if (
46
+ (state === 'single' && char === "'") ||
47
+ (state === 'double' && char === '"') ||
48
+ (state === 'template' && char === '`')
49
+ ) {
50
+ state = 'normal';
51
+ }
52
+ continue;
53
+ }
54
+
55
+ if (char === '/' && next === '/') {
56
+ state = 'lineComment';
57
+ i += 1;
58
+ continue;
59
+ }
60
+ if (char === '/' && next === '*') {
61
+ state = 'blockComment';
62
+ i += 1;
63
+ continue;
64
+ }
65
+ if (char === "'") {
66
+ state = 'single';
67
+ output += char;
68
+ continue;
69
+ }
70
+ if (char === '"') {
71
+ state = 'double';
72
+ output += char;
73
+ continue;
74
+ }
75
+ if (char === '`') {
76
+ state = 'template';
77
+ output += char;
78
+ continue;
79
+ }
80
+ if (!/\s/.test(char)) {
81
+ output += char;
82
+ }
83
+ }
84
+
85
+ return output;
86
+ }
87
+
88
+ export function isCommentOnlyDiff(file: ChangedFile): boolean {
89
+ if (typeof file.beforeContent !== 'string' || typeof file.afterContent !== 'string') {
90
+ return false;
91
+ }
92
+
93
+ return stripCommentsAndWhitespace(file.beforeContent) === stripCommentsAndWhitespace(file.afterContent);
94
+ }
@@ -8,6 +8,7 @@
8
8
  import { ChangeCategory } from '../value-objects/change-category.js';
9
9
  import { ChangeClassification } from '../value-objects/change-classification.js';
10
10
  import { QuickModeEligibility } from '../value-objects/quick-mode-eligibility.js';
11
+ import { isCommentOnlyDiff } from './comment-only-diff-detector.js';
11
12
  import type { ChangedFile } from '../value-objects/changed-file.js';
12
13
  import type { QuickModeConfig } from '../value-objects/quick-mode-config.js';
13
14
 
@@ -25,6 +26,10 @@ const RISK_PRIORITY: Record<string, number> = {
25
26
  function categorizeFile(file: ChangedFile): ChangeCategory {
26
27
  const { filePath, changeKind } = file;
27
28
 
29
+ if (isCommentOnlyDiff(file)) {
30
+ return ChangeCategory.fromString('docs');
31
+ }
32
+
28
33
  // api: *port.ts or *adapter.ts(最高優先度)
29
34
  if (filePath.endsWith('port.ts') || filePath.endsWith('adapter.ts')) {
30
35
  return ChangeCategory.fromString('api');
@@ -137,7 +142,7 @@ export class QuickModeJudgmentEngine {
137
142
  // 3. API_CONTRACT評価: *port.ts / *adapter.ts の変更
138
143
  if (config.isFullModeRequiredFor('apiContractChange')) {
139
144
  const apiContractFiles = changedFiles.filter(
140
- (f) => f.filePath.endsWith('port.ts') || f.filePath.endsWith('adapter.ts')
145
+ (f) => (f.filePath.endsWith('port.ts') || f.filePath.endsWith('adapter.ts')) && !isCommentOnlyDiff(f)
141
146
  );
142
147
 
143
148
  if (apiContractFiles.length > 0) {
@@ -10,14 +10,31 @@ import { type ChangeKind, isChangeKind } from '../types/change-kind.js';
10
10
  export class ChangedFile {
11
11
  readonly filePath: string;
12
12
  readonly changeKind: ChangeKind;
13
+ readonly beforeContent!: string | null;
14
+ readonly afterContent!: string | null;
13
15
 
14
- private constructor(filePath: string, changeKind: ChangeKind) {
16
+ private constructor(filePath: string, changeKind: ChangeKind, beforeContent: string | null, afterContent: string | null) {
15
17
  this.filePath = filePath;
16
18
  this.changeKind = changeKind;
19
+ Object.defineProperty(this, 'beforeContent', {
20
+ value: beforeContent,
21
+ enumerable: false,
22
+ writable: false,
23
+ });
24
+ Object.defineProperty(this, 'afterContent', {
25
+ value: afterContent,
26
+ enumerable: false,
27
+ writable: false,
28
+ });
17
29
  }
18
30
 
19
- static create(params: { filePath: string; changeKind: string }): ChangedFile {
20
- const { filePath, changeKind } = params;
31
+ static create(params: {
32
+ filePath: string;
33
+ changeKind: string;
34
+ beforeContent?: string | null;
35
+ afterContent?: string | null;
36
+ }): ChangedFile {
37
+ const { filePath, changeKind, beforeContent = null, afterContent = null } = params;
21
38
 
22
39
  if (!filePath) {
23
40
  throw new Error('filePath must not be empty');
@@ -29,7 +46,7 @@ export class ChangedFile {
29
46
  throw new Error(`changeKind must be one of 'CREATE', 'MODIFY', 'DELETE'. Got: "${changeKind}"`);
30
47
  }
31
48
 
32
- return new ChangedFile(filePath, changeKind);
49
+ return new ChangedFile(filePath, changeKind, beforeContent, afterContent);
33
50
  }
34
51
 
35
52
  isUnder(directoryPrefix: string): boolean {
@@ -51,6 +68,11 @@ export class ChangedFile {
51
68
  }
52
69
 
53
70
  equals(other: ChangedFile): boolean {
54
- return this.filePath === other.filePath && this.changeKind === other.changeKind;
71
+ return (
72
+ this.filePath === other.filePath &&
73
+ this.changeKind === other.changeKind &&
74
+ this.beforeContent === other.beforeContent &&
75
+ this.afterContent === other.afterContent
76
+ );
55
77
  }
56
78
  }
@@ -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
+ }
@@ -15,7 +15,7 @@ CONFIG_FILE="$SCRIPT_DIR/hook-config.json"
15
15
  TARGET_DIRS=()
16
16
 
17
17
  if [[ -f "$CONFIG_FILE" ]] && command -v jq >/dev/null 2>&1; then
18
- # bash 3.2 (macOS default) has no mapfile; use portable while-read.
18
+ # bash 3.2 compatible: use portable while-read.
19
19
  while IFS= read -r line; do
20
20
  [[ -n "$line" ]] && TARGET_DIRS+=("$line")
21
21
  done < <(jq -r '.targetDirs[]' "$CONFIG_FILE" 2>/dev/null)
@@ -17,7 +17,7 @@ FORMATTER="biome"
17
17
  FORMATTER_ARGS=()
18
18
 
19
19
  if [[ -f "$CONFIG_FILE" ]] && command -v jq &> /dev/null; then
20
- # bash 3.2 (macOS default) has no mapfile; use portable while-read.
20
+ # bash 3.2 compatible: use portable while-read.
21
21
  while IFS= read -r line; do
22
22
  [[ -n "$line" ]] && TARGET_DIRS+=("$line")
23
23
  done < <(jq -r '.targetDirs[]' "$CONFIG_FILE" 2>/dev/null)