phasegate 0.132.0 → 0.133.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md 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.133.0] - 2026-05-08
11
+
12
+ ### Added
13
+
14
+ - **WI-095 — L4-001 drift-detect の design pointers 対応** — 設計見出し直下の `<!-- pointers: ... -->` / `<pointers>` block で実装ファイルpathを明示できるようにし、設計名とcode export名が異なる移行中ケースのfalse positiveを抑制。ADR-018でsyntaxとORセマンティクスを定義。
15
+
16
+ ### Fixed
17
+
18
+ - **WI-096 — `phasegate:status` が `layers.L?.enabled` user override を表示に反映しない問題を修正** — status表示用のenabledLayersをpreset値と明示overrideから合成し、`strict + L4.enabled:false` / `minimal + L4.enabled:true` の双方に対応。
19
+
10
20
  ## [0.132.0] - 2026-05-08
11
21
 
12
22
  ### Fixed
@@ -0,0 +1,47 @@
1
+ # ADR-018: drift-detect の design pointers 仕様
2
+
3
+ ## Status
4
+
5
+ Accepted — 2026-05-08
6
+
7
+ ## Context
8
+
9
+ L4-001 `drift-detect` は設計文書の `##` / `###` 見出しから抽出した要素名と、TypeScript source の export 名を双方向に比較する。WI-091 で括弧 qualifier の normalize は導入済みだが、次のようなケースでは依然として false positive が起きる。
10
+
11
+ - 設計名と実装 export 名が移行期間中だけ異なる
12
+ - 設計文書では業務概念名、コードではより具体的な型名を使う
13
+ - 同名・類似名の要素が複数 unit に存在し、ファイル path で対応を示したい
14
+ - 実装ファイル名は安定しているが export 名を変更している
15
+
16
+ GitHub Issue #4 の follow-up として WI-095 を起票した。
17
+
18
+ ## Decision
19
+
20
+ 設計見出し単位で、対応する実装ファイル path を明示できる `pointers` を導入する。初期実装では以下の2形式を正式に受け付ける。
21
+
22
+ ```markdown
23
+ ## UserProfile
24
+ <!-- pointers: scripts/harness/user/domain/user-profile.ts -->
25
+ ```
26
+
27
+ ```markdown
28
+ ## UserProfile
29
+ <pointers>
30
+ - scripts/harness/user/domain/user-profile.ts
31
+ - scripts/harness/user/domain/user-profile-types.ts
32
+ </pointers>
33
+ ```
34
+
35
+ セマンティクスは以下とする。
36
+
37
+ - pointers がない要素は従来通り、設計要素名と code export 名の完全一致で判定する
38
+ - pointers がある設計要素は、いずれか1つの pointer が code export の定義ファイル path と一致すれば、設計→コード drift とみなさない
39
+ - pointer で対応付けられた code export は、名前が設計要素名と異なっていても code→design drift とみなさない
40
+ - 複数 pointer は OR として扱う
41
+ - path 比較は POSIX separator に正規化し、絶対 path の末尾が pointer と一致する場合も一致とする
42
+
43
+ ## Consequences
44
+
45
+ `DesignDocumentPort` は後方互換のため既存 `getElements()` を維持し、optional な `getElementPointers()` を追加する。`SourceCodeAnalyzerPort` も optional な `getElementFilePathMap()` を追加する。両 optional port が未実装の場合、`DriftDetectionService` は従来挙動のまま動作する。
46
+
47
+ YAML fenced metadata は今回採用しない。Markdown本文の構造と混ざりやすく、既存のコードブロック抽出や設計例との衝突リスクが高いため、必要になった時点で別ADRで拡張する。
@@ -150,6 +150,27 @@ L4 validators are designed to run on a weekly schedule and detect slow-moving dr
150
150
 
151
151
  > **Note (2026-04-25)**: `doc-freshness` and `pointer-validation` capabilities exist as standalone CLI commands `p2:check-freshness` and `p2:validate-pointers` (implemented in the `phase2-extensions` unit), **not as L4 validators yet**. Promoting them to L4-004 / L4-005 is tracked as **WI-033**. Until that lands, run them explicitly via `npx phasegate p2:check-freshness` / `p2:validate-pointers`.
152
152
 
153
+ ### Drift-detect design pointers
154
+
155
+ L4-001 normally matches design headings to code exports by name. When a heading intentionally maps to a differently named implementation file, add a pointer directly under the heading:
156
+
157
+ ```markdown
158
+ ## UserProfile
159
+ <!-- pointers: scripts/harness/user/domain/user-profile.ts -->
160
+ ```
161
+
162
+ For multiple files:
163
+
164
+ ```markdown
165
+ ## UserProfile
166
+ <pointers>
167
+ - scripts/harness/user/domain/user-profile.ts
168
+ - scripts/harness/user/domain/user-profile-types.ts
169
+ </pointers>
170
+ ```
171
+
172
+ Pointers are optional and backward compatible. If any listed path matches the file defining a code export, drift-detect treats that design heading and export as corresponding even when their names differ.
173
+
153
174
  **Command:**
154
175
 
155
176
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.132.0",
3
+ "version": "0.133.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,4 +1,5 @@
1
1
  // @layer infrastructure
2
+ // @unit harness-api
2
3
  // harness-config-query-adapter.ts — HarnessConfigQueryAdapter
3
4
 
4
5
  import * as fs from 'node:fs/promises';
@@ -12,6 +13,7 @@ interface HarnessConfigJson {
12
13
  name: string;
13
14
  preset: 'minimal' | 'standard' | 'strict';
14
15
  };
16
+ layers?: Partial<Record<LayerId, { enabled?: boolean }>>;
15
17
  paths?: {
16
18
  designDocs?: string;
17
19
  integrationTests?: string;
@@ -46,7 +48,18 @@ export class HarnessConfigQueryAdapter implements ConfigQueryPort {
46
48
  async getPresetInfo(): Promise<PresetInfo> {
47
49
  const config = await this.readConfig();
48
50
  const preset = config.project.preset;
49
- const enabledLayers = PRESET_LAYERS[preset] ?? ['L1', 'L2', 'L3'];
51
+ const presetLayers = PRESET_LAYERS[preset] ?? ['L1', 'L2', 'L3'];
52
+ const enabledLayerSet = new Set<LayerId>(presetLayers);
53
+
54
+ for (const [layerId, layerConfig] of Object.entries(config.layers ?? {}) as [LayerId, { enabled?: boolean }][]) {
55
+ if (layerConfig.enabled === true) {
56
+ enabledLayerSet.add(layerId);
57
+ } else if (layerConfig.enabled === false) {
58
+ enabledLayerSet.delete(layerId);
59
+ }
60
+ }
61
+
62
+ const enabledLayers = (['L1', 'L2', 'L3', 'L4'] as const).filter((layerId) => enabledLayerSet.has(layerId));
50
63
  return { name: preset, enabledLayers };
51
64
  }
52
65
 
@@ -8,6 +8,7 @@
8
8
  export interface DesignDocumentConcept {
9
9
  readonly name: string;
10
10
  readonly type: 'class' | 'interface' | 'type' | 'value-object' | 'service';
11
+ readonly pointers?: readonly string[];
11
12
  }
12
13
 
13
14
  export interface StructuredDesignDoc {
@@ -24,4 +25,6 @@ export interface DesignDocumentPort {
24
25
  getLayerAnnotations?(targetDocs?: readonly string[]): Promise<Record<string, string>>;
25
26
  /** DriftDetectionService用: 要素名一覧取得 */
26
27
  getElements?(targetUnits?: readonly string[]): Promise<string[]>;
28
+ /** DriftDetectionService用: 設計要素から対応する実装ファイルへの明示ポインタ */
29
+ getElementPointers?(targetUnits?: readonly string[]): Promise<Record<string, readonly string[]>>;
27
30
  }
@@ -11,4 +11,5 @@ export interface SourceAnalysisResult {
11
11
  export interface SourceCodeAnalyzerPort {
12
12
  analyzeExports(targetUnits?: readonly string[]): Promise<readonly SourceAnalysisResult[]>;
13
13
  getElements?(targetUnits?: readonly string[]): Promise<string[]>;
14
+ getElementFilePathMap?(targetUnits?: readonly string[]): Promise<Record<string, readonly string[]>>;
14
15
  }
@@ -9,6 +9,11 @@ import { DriftReport } from '../../value-objects/drift-report.js';
9
9
 
10
10
  export interface DriftDetectionDesignDocumentPort {
11
11
  getElements(targetUnits?: readonly string[]): Promise<string[]>;
12
+ /**
13
+ * WI-095: 設計要素 → 実装ファイル path の明示対応。
14
+ * 実装されていれば element 名完全一致に加えて drift 判定に使う。
15
+ */
16
+ getElementPointers?(targetUnits?: readonly string[]): Promise<Record<string, readonly string[]>>;
12
17
  /**
13
18
  * ISSUE-005 P3-9: element → unit 名のマップ。
14
19
  * 実装されていれば DriftReport.unitName の解決に使われる (fallback: 'unknown')。
@@ -18,6 +23,11 @@ export interface DriftDetectionDesignDocumentPort {
18
23
 
19
24
  export interface DriftDetectionSourceCodeAnalyzerPort {
20
25
  getElements(targetUnits?: readonly string[]): Promise<string[]>;
26
+ /**
27
+ * WI-095: export element → 定義ファイル path のマップ。
28
+ * 実装されていれば design pointer と照合する。
29
+ */
30
+ getElementFilePathMap?(targetUnits?: readonly string[]): Promise<Record<string, readonly string[]>>;
21
31
  /**
22
32
  * ISSUE-005 P3-9: element → unit 名のマップ。
23
33
  * 実装されていれば DriftReport.unitName の解決に使われる (fallback: 'unknown')。
@@ -42,6 +52,12 @@ export class DriftDetectionService {
42
52
  async detect(targetUnits?: readonly string[]): Promise<readonly DriftReport[]> {
43
53
  const designElements = await this.designDocumentPort.getElements(targetUnits);
44
54
  const codeElements = await this.sourceCodeAnalyzerPort.getElements(targetUnits);
55
+ const designPointers = this.designDocumentPort.getElementPointers
56
+ ? await this.designDocumentPort.getElementPointers(targetUnits)
57
+ : {};
58
+ const codeFilePathMap = this.sourceCodeAnalyzerPort.getElementFilePathMap
59
+ ? await this.sourceCodeAnalyzerPort.getElementFilePathMap(targetUnits)
60
+ : {};
45
61
 
46
62
  // ISSUE-005 P3-9: element → unit のマップを取得し、DriftReport.unitName の解決に使う
47
63
  const designUnitMap = this.designDocumentPort.getElementUnitMap
@@ -62,12 +78,28 @@ export class DriftDetectionService {
62
78
 
63
79
  const designSet = new Set(designElements);
64
80
  const codeSet = new Set(codeElements);
81
+ const pointerMatchedDesignElements = new Set<string>();
82
+ const pointerMatchedCodeElements = new Set<string>();
83
+
84
+ for (const element of designElements) {
85
+ const pointers = designPointers[element] ?? [];
86
+ if (pointers.length === 0) continue;
87
+ const matchedCodeElements = codeElements.filter((codeElement) =>
88
+ (codeFilePathMap[codeElement] ?? []).some((filePath) => pointers.some((pointer) => isSameOrNestedPath(filePath, pointer)))
89
+ );
90
+ if (matchedCodeElements.length > 0) {
91
+ pointerMatchedDesignElements.add(element);
92
+ for (const codeElement of matchedCodeElements) {
93
+ pointerMatchedCodeElements.add(codeElement);
94
+ }
95
+ }
96
+ }
65
97
 
66
98
  const reports: DriftReport[] = [];
67
99
 
68
100
  // 設計に存在するがコードに存在しない
69
101
  for (const element of designElements) {
70
- if (!codeSet.has(element)) {
102
+ if (!codeSet.has(element) && !pointerMatchedDesignElements.has(element)) {
71
103
  reports.push(
72
104
  DriftReport.create({
73
105
  direction: 'design→code',
@@ -82,7 +114,7 @@ export class DriftDetectionService {
82
114
 
83
115
  // コードに存在するが設計に存在しない
84
116
  for (const element of codeElements) {
85
- if (!designSet.has(element)) {
117
+ if (!designSet.has(element) && !pointerMatchedCodeElements.has(element)) {
86
118
  reports.push(
87
119
  DriftReport.create({
88
120
  direction: 'code→design',
@@ -100,3 +132,13 @@ export class DriftDetectionService {
100
132
  );
101
133
  }
102
134
  }
135
+
136
+ function normalizePath(path: string): string {
137
+ return path.replace(/\\/g, '/').replace(/^\.\//, '');
138
+ }
139
+
140
+ function isSameOrNestedPath(actualPath: string, pointerPath: string): boolean {
141
+ const actual = normalizePath(actualPath);
142
+ const pointer = normalizePath(pointerPath);
143
+ return actual === pointer || actual.endsWith(`/${pointer}`);
144
+ }
@@ -61,6 +61,17 @@ export class BiomeAstSourceCodeAnalyzerAdapter implements SourceCodeAnalyzerPort
61
61
  }
62
62
  return map;
63
63
  }
64
+
65
+ async getElementFilePathMap(targetUnits?: readonly string[]): Promise<Record<string, readonly string[]>> {
66
+ const results = await this.analyzeExports(targetUnits);
67
+ const map: Record<string, string[]> = {};
68
+ for (const result of results) {
69
+ for (const entry of result.exports) {
70
+ map[entry.name] = [...(map[entry.name] ?? []), result.filePath];
71
+ }
72
+ }
73
+ return map;
74
+ }
64
75
  }
65
76
 
66
77
  type ExportType = SourceAnalysisResult['exports'][number]['type'];
@@ -30,9 +30,39 @@ function isMetaHeading(name: string): boolean {
30
30
  return DEFAULT_META_HEADING_PATTERNS.some((p) => p.test(name));
31
31
  }
32
32
 
33
- function extractConceptNames(markdown: string): string[] {
33
+ function extractPointerLines(lines: readonly string[], startIndex: number, headingRegex: RegExp): string[] {
34
+ const pointers: string[] = [];
35
+ for (let i = startIndex + 1; i < lines.length; i++) {
36
+ const line = lines[i];
37
+ if (headingRegex.test(line)) break;
38
+
39
+ const commentMatch = /<!--\s*pointers?\s*:\s*(.+?)\s*-->/i.exec(line);
40
+ if (commentMatch) {
41
+ pointers.push(...commentMatch[1].split(',').map((p) => p.trim()).filter(Boolean));
42
+ continue;
43
+ }
44
+
45
+ const blockStartMatch = /^<pointers>\s*$/i.exec(line.trim());
46
+ if (blockStartMatch) {
47
+ for (let j = i + 1; j < lines.length; j++) {
48
+ const blockLine = lines[j].trim();
49
+ if (/^<\/pointers>\s*$/i.test(blockLine)) {
50
+ i = j;
51
+ break;
52
+ }
53
+ const itemMatch = /^-\s+(.+?)\s*$/.exec(blockLine);
54
+ if (itemMatch) pointers.push(itemMatch[1].trim());
55
+ }
56
+ continue;
57
+ }
58
+ }
59
+
60
+ return Array.from(new Set(pointers));
61
+ }
62
+
63
+ function extractConcepts(markdown: string): Array<{ name: string; pointers?: readonly string[] }> {
34
64
  const lines = markdown.split(/\r?\n/);
35
- const headings: string[] = [];
65
+ const concepts: Array<{ name: string; pointers?: readonly string[] }> = [];
36
66
  const headingRegex = /^(#{2,3})\s+(.+?)\s*$/;
37
67
  for (let i = 0; i < lines.length; i++) {
38
68
  const m = headingRegex.exec(lines[i]);
@@ -59,10 +89,11 @@ function extractConceptNames(markdown: string): string[] {
59
89
  .replace(/[((][^))]*[))]/g, '')
60
90
  .trim();
61
91
  if (stripped.length > 0) {
62
- headings.push(stripped);
92
+ const pointers = extractPointerLines(lines, i, headingRegex);
93
+ concepts.push(pointers.length > 0 ? { name: stripped, pointers } : { name: stripped });
63
94
  }
64
95
  }
65
- return headings;
96
+ return concepts;
66
97
  }
67
98
 
68
99
  export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
@@ -92,7 +123,7 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
92
123
  const doc: StructuredDesignDoc = {
93
124
  unitName,
94
125
  docPath,
95
- concepts: extractConceptNames(markdown).map((name) => ({ name, type: 'class' })),
126
+ concepts: extractConcepts(markdown).map((concept) => ({ ...concept, type: 'class' })),
96
127
  layerDependencies: [],
97
128
  adrRefs: Array.from(new Set(markdown.match(ADR_PATTERN) ?? [])),
98
129
  };
@@ -115,6 +146,19 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
115
146
  return docs.flatMap((doc) => doc.concepts.map((concept) => concept.name));
116
147
  }
117
148
 
149
+ async getElementPointers(targetUnits?: readonly string[]): Promise<Record<string, readonly string[]>> {
150
+ const docs = await this.loadDesignDocuments(targetUnits);
151
+ const map: Record<string, readonly string[]> = {};
152
+ for (const doc of docs) {
153
+ for (const concept of doc.concepts) {
154
+ if (concept.pointers && concept.pointers.length > 0) {
155
+ map[concept.name] = concept.pointers;
156
+ }
157
+ }
158
+ }
159
+ return map;
160
+ }
161
+
118
162
  /**
119
163
  * ISSUE-005 P3-9: element 名から unit 名を引けるマップを返す。
120
164
  * 同名 element が複数 unit に存在する場合は最初に見つかった unit を採用する。