phasegate 0.138.1 → 0.140.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 (34) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +2 -2
  4. package/docs/guide/configuration.md +21 -0
  5. package/docs/guide/layer-model.md +2 -2
  6. package/package.json +1 -1
  7. package/scripts/harness/agent-integration/application/dto/handle-pre-tool-use-dto.ts +5 -0
  8. package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +4 -1
  9. package/scripts/harness/agent-integration/domain/ports/full-mode-requirement-query-port.ts +10 -1
  10. package/scripts/harness/agent-integration/infrastructure/adapters/quick-mode-full-mode-requirement-adapter.ts +9 -2
  11. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +56 -1
  12. package/scripts/harness/biome-ast-engine/application/usecases/resolve-enabled-rules-usecase.ts +1 -0
  13. package/scripts/harness/biome-ast-engine/domain/ports/rule-config-provider-port.ts +4 -0
  14. package/scripts/harness/biome-ast-engine/domain/services/lint-runner.ts +2 -2
  15. package/scripts/harness/biome-ast-engine/domain/value-objects/architecture-spec.ts +22 -1
  16. package/scripts/harness/biome-ast-engine/infrastructure/adapters/harness-config-provider-adapter.ts +4 -0
  17. package/scripts/harness/biome-ast-engine/infrastructure/adapters/typescript-source-module-analyzer-adapter.ts +3 -3
  18. package/scripts/harness/biome-ast-engine/infrastructure/parsers/layer-comment-parser.ts +11 -3
  19. package/scripts/harness/biome-ast-engine/infrastructure/parsers/unit-comment-parser.ts +12 -4
  20. package/scripts/harness/config-foundation/domain/harness-config.ts +4 -0
  21. package/scripts/harness/config-foundation/domain/services/preset-resolution-service.ts +6 -0
  22. package/scripts/harness/config-foundation/infrastructure/presets/minimal.json +3 -0
  23. package/scripts/harness/config-foundation/infrastructure/presets/standard.json +3 -0
  24. package/scripts/harness/config-foundation/infrastructure/presets/strict.json +3 -0
  25. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +15 -0
  26. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +15 -0
  27. package/scripts/harness/integrations/pre-commit.ts +38 -9
  28. package/scripts/harness/main.ts +1 -0
  29. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +15 -3
  30. package/scripts/harness/quick-mode/domain/services/comment-only-diff-detector.ts +94 -0
  31. package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +6 -1
  32. package/scripts/harness/quick-mode/domain/value-objects/changed-file.ts +27 -5
  33. package/templates/.claude/scripts/analyze-errors-hook.sh +1 -1
  34. package/templates/.claude/scripts/format-typescript-hook.sh +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.140.0] - 2026-05-09
11
+
12
+ ### Added
13
+
14
+ - **WI-024 — configurable L1 metadata tag names** — `architecture.metadataTags.unit` / `architecture.metadataTags.layer` を biome-ast-engine の L1 parser / analyzer / runner に反映し、`@module` / `@tier` などのプロジェクト語彙を単一の有効タグ名として使えるようにした。
15
+ - 既定値は従来通り `@unit` / `@layer`。
16
+ - カスタムタグ設定時は旧タグを alias として扱わず、欠落メッセージも設定タグ名を表示する。
17
+
18
+ ## [0.139.0] - 2026-05-09
19
+
20
+ ### Added
21
+
22
+ - **WI-012 — pre-commit implementation extension 設定** — `preCommit.implementationExtensions` を config schema / preset / pre-commit adapter に追加し、`.ts` 以外の実装ファイル拡張子を検査対象にできるようにした。
23
+
24
+ ### Fixed
25
+
26
+ - **WI-015 — quick mode comment-only API diff** — API 面の TypeScript ファイルでコメントのみが変わった場合、quick mode が implementation 証拠なしで `pending` に戻さないようにした。
27
+ - **WI-030 — README / layer-model drift correction** — 公開ドキュメントの validator 数と L0 記述を現行仕様に合わせて更新した。
28
+ - **WI-061 / WI-062 / WI-063 / WI-064 / WI-071 — reflected regression / skill WI closure** — 既存実装と回帰テストの証跡に基づき、対応済み WI を `tested` に整理した。
29
+
30
+ ## [0.138.1] - 2026-05-09
31
+
10
32
  ### Fixed
11
33
 
12
34
  - **WI-106 — inception WI ID 重複防止** — `docs/inception/**/WI-XXX/description.md` の frontmatter `id` を global scan し、`_cross` と Unit 配下をまたぐ重複、および parent directory 名と `id` の不一致を `validate-metadata` 経路で検出するようにした。
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
 
@@ -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.1",
3
+ "version": "0.140.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);
@@ -60,6 +60,7 @@ export class ResolveEnabledRulesUseCase {
60
60
  const architectureSpec = freezeArchitectureSpec({
61
61
  layers: architecture.layers,
62
62
  allowedDependencies: architecture.allowedDependencies,
63
+ metadataTags: architecture.metadataTags,
63
64
  });
64
65
 
65
66
  return toResolveEnabledRulesOutput(
@@ -16,6 +16,10 @@ export interface ArchitectureProviderInfo {
16
16
  readonly preset: ArchitecturePresetIdValue;
17
17
  readonly layers: readonly string[];
18
18
  readonly allowedDependencies: Readonly<Record<string, readonly string[]>>;
19
+ readonly metadataTags?: {
20
+ readonly unit?: string;
21
+ readonly layer?: string;
22
+ };
19
23
  }
20
24
 
21
25
  export interface RuleConfigProviderPort {
@@ -75,7 +75,7 @@ export class LintRunner {
75
75
  line: 1,
76
76
  column: 1,
77
77
  ruleName: rule.name,
78
- message: '@unitコメントが必要です',
78
+ message: `${architecture.metadataTags.unit}コメントが必要です`,
79
79
  severity: rule.severity,
80
80
  })
81
81
  );
@@ -92,7 +92,7 @@ export class LintRunner {
92
92
  line: 1,
93
93
  column: 1,
94
94
  ruleName: rule.name,
95
- message: '@layerコメントが必要です',
95
+ message: `${architecture.metadataTags.layer}コメントが必要です`,
96
96
  severity: rule.severity,
97
97
  })
98
98
  );
@@ -6,8 +6,25 @@
6
6
  export type ArchitectureSpec = {
7
7
  readonly layers: readonly string[];
8
8
  readonly allowedDependencies: Readonly<Record<string, readonly string[]>>;
9
+ readonly metadataTags: ArchitectureMetadataTags;
9
10
  };
10
11
 
12
+ export type ArchitectureMetadataTags = {
13
+ readonly unit: string;
14
+ readonly layer: string;
15
+ };
16
+
17
+ export type ArchitectureSpecInput = {
18
+ readonly layers: readonly string[];
19
+ readonly allowedDependencies: Readonly<Record<string, readonly string[]>>;
20
+ readonly metadataTags?: Partial<ArchitectureMetadataTags>;
21
+ };
22
+
23
+ export const DEFAULT_METADATA_TAGS: ArchitectureMetadataTags = Object.freeze({
24
+ unit: '@unit',
25
+ layer: '@layer',
26
+ });
27
+
11
28
  const freezeDependencyMap = (
12
29
  map: Record<string, readonly string[]>
13
30
  ): Readonly<Record<string, readonly string[]>> => {
@@ -20,10 +37,14 @@ const freezeDependencyMap = (
20
37
  return Object.freeze(frozen);
21
38
  };
22
39
 
23
- export const freezeArchitectureSpec = (spec: ArchitectureSpec): ArchitectureSpec => {
40
+ export const freezeArchitectureSpec = (spec: ArchitectureSpecInput): ArchitectureSpec => {
24
41
  return Object.freeze({
25
42
  layers: Object.freeze([...spec.layers]),
26
43
  allowedDependencies: freezeDependencyMap({ ...spec.allowedDependencies }),
44
+ metadataTags: Object.freeze({
45
+ ...DEFAULT_METADATA_TAGS,
46
+ ...spec.metadataTags,
47
+ }),
27
48
  });
28
49
  };
29
50
 
@@ -29,6 +29,10 @@ const DEFAULT_ARCHITECTURE: ArchitectureConfigInput = Object.freeze({
29
29
  infrastructure: Object.freeze(['infrastructure', 'application', 'domain']),
30
30
  presentation: Object.freeze(['presentation', 'application', 'domain']),
31
31
  }),
32
+ metadataTags: Object.freeze({
33
+ unit: '@unit',
34
+ layer: '@layer',
35
+ }),
32
36
  });
33
37
 
34
38
  /**
@@ -56,8 +56,8 @@ export class TypeScriptSourceModuleAnalyzerAdapter implements SourceModuleAnalyz
56
56
  }
57
57
 
58
58
  const sourceText = sourceFile.getFullText();
59
- const unitResult = parseUnitComment(sourceText);
60
- const layerResult = parseLayerComment(sourceText);
59
+ const unitResult = parseUnitComment(sourceText, architecture?.metadataTags.unit);
60
+ const layerResult = parseLayerComment(sourceText, architecture?.metadataTags.layer);
61
61
  const densityResult = parseCommentDensity(sourceText);
62
62
  const imports = this.extractImports(sourceFile, filePath);
63
63
  const anyCount = this.countAnyTypes(sourceFile);
@@ -69,7 +69,7 @@ export class TypeScriptSourceModuleAnalyzerAdapter implements SourceModuleAnalyz
69
69
  SourceModuleSnapshot.create(
70
70
  {
71
71
  filePath,
72
- declaredUnit: unitResult.unitNames[0],
72
+ declaredUnit: unitResult.unitNames[0] ?? null,
73
73
  declaredLayer: layerResult.layerName,
74
74
  imports,
75
75
  anyTypeCount: anyCount,
@@ -1,14 +1,22 @@
1
1
  // @unit biome-ast-engine
2
2
  // @layer infrastructure
3
3
 
4
- const LAYER_COMMENT_PATTERN = /^\s*(?:\/\/|\/\*\*?\s*|\*)\s*@layer\s+(\S+)/m;
4
+ const DEFAULT_LAYER_TAG = '@layer';
5
+
6
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+
8
+ const createLayerCommentPattern = (tagName: string): RegExp =>
9
+ new RegExp(`^\\s*(?:\\/\\/|\\/\\*\\*?\\s*|\\*)\\s*${escapeRegExp(tagName)}\\s+(\\S+)`, 'm');
5
10
 
6
11
  export type LayerCommentResult = {
7
12
  readonly layerName: string | null;
8
13
  };
9
14
 
10
- export const parseLayerComment = (sourceCode: string): LayerCommentResult => {
11
- const match = sourceCode.match(LAYER_COMMENT_PATTERN);
15
+ export const parseLayerComment = (
16
+ sourceCode: string,
17
+ tagName: string = DEFAULT_LAYER_TAG
18
+ ): LayerCommentResult => {
19
+ const match = sourceCode.match(createLayerCommentPattern(tagName));
12
20
 
13
21
  if (!match) {
14
22
  return { layerName: null };
@@ -1,15 +1,23 @@
1
1
  // @unit biome-ast-engine
2
2
  // @layer infrastructure
3
3
 
4
- // カンマ区切り複数ユニット対応 + 複数行の @unit を収集、重複除去。
5
- const UNIT_COMMENT_PATTERN = /^\s*(?:\/\/|\/\*\*?\s*|\*)\s*@unit\s+(.+)/gm;
4
+ // カンマ区切り複数ユニット対応 + 複数行の metadata unit tag を収集、重複除去。
5
+ const DEFAULT_UNIT_TAG = '@unit';
6
+
7
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+
9
+ const createUnitCommentPattern = (tagName: string): RegExp =>
10
+ new RegExp(`^\\s*(?:\\/\\/|\\/\\*\\*?\\s*|\\*)\\s*${escapeRegExp(tagName)}\\s+(.+)`, 'gm');
6
11
 
7
12
  export type UnitCommentResult = {
8
13
  readonly unitNames: readonly string[];
9
14
  };
10
15
 
11
- export const parseUnitComment = (sourceCode: string): UnitCommentResult => {
12
- const matches = sourceCode.matchAll(UNIT_COMMENT_PATTERN);
16
+ export const parseUnitComment = (
17
+ sourceCode: string,
18
+ tagName: string = DEFAULT_UNIT_TAG
19
+ ): UnitCommentResult => {
20
+ const matches = sourceCode.matchAll(createUnitCommentPattern(tagName));
13
21
  const names: string[] = [];
14
22
 
15
23
  for (const match of matches) {
@@ -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`);
@@ -490,6 +490,7 @@ function toArchitectureInput(resolvedConfig: HarnessConfigV2) {
490
490
  preset: resolvedConfig.architecture.preset,
491
491
  layers: resolvedConfig.architecture.layers,
492
492
  allowedDependencies: resolvedConfig.architecture.allowedDependencies,
493
+ metadataTags: resolvedConfig.architecture.metadataTags,
493
494
  };
494
495
  }
495
496
 
@@ -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
  }
@@ -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)