phasegate 0.150.1 → 0.150.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/docs/guide/layer-model.md +4 -0
  3. package/package.json +1 -1
  4. package/scripts/harness/phase2-extensions/application/dto/validate-doc-pointers-output.ts +4 -0
  5. package/scripts/harness/phase2-extensions/application/usecases/validate-doc-pointers-usecase.ts +23 -4
  6. package/scripts/harness/phase2-extensions/domain/aggregates/pointer-rule.ts +13 -0
  7. package/scripts/harness/phase2-extensions/domain/services/freshness-check-service.ts +10 -0
  8. package/scripts/harness/phase2-extensions/domain/value-objects/document-age.ts +2 -2
  9. package/scripts/harness/phase2-extensions/infrastructure/adapters/harness-config-freshness-adapter.ts +12 -0
  10. package/scripts/harness/phase2-extensions/presentation/formatters/pointer-result-formatter.ts +3 -1
  11. package/scripts/harness/validator-system/domain/services/l4/consistency-check-service.ts +15 -17
  12. package/scripts/harness/validator-system/domain/services/l4/drift-detection-service.ts +95 -51
  13. package/scripts/harness/validator-system/domain/services/l4/semantic-drift-service.ts +112 -0
  14. package/scripts/harness/validator-system/domain/value-objects/consistency-report.ts +2 -1
  15. package/scripts/harness/validator-system/domain/value-objects/semantic-drift-report.ts +50 -0
  16. package/scripts/harness/validator-system/index.ts +2 -0
  17. package/scripts/harness/validator-system/infrastructure/adapters/biome-ast-source-code-analyzer-adapter.ts +30 -2
  18. package/scripts/harness/validator-system/infrastructure/adapters/markdown-design-document-adapter.ts +110 -20
package/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.150.2] - 2026-05-12
11
+
12
+ ### Added
13
+
14
+ - **G3 / WI-117 / WI-118 / WI-122 / WI-139 — L4 drift, consistency, docs, and semantic drift semantics** — L4 drift detection now compares unit-scoped records, prefers `@unit` metadata, handles product construction docs and re-export/default export surfaces more precisely, reports real product-doc consistency targets, adds operational pointer/freshness semantics, and introduces semantic drift reports across design intent, implementation behavior, and test observations.
15
+
10
16
  ## [0.150.1] - 2026-05-12
11
17
 
12
18
  ### Fixed
@@ -253,3 +253,7 @@ Presets control which layers are active. Choose based on project maturity and te
253
253
  - **strict** — Full defense. Enables scheduled drift detection (L4). Runtime L0 hooks are configured through the agent and Husky hook files, not through validator presets.
254
254
 
255
255
  Presets are configured in `phasegate.config.json`, the single source of truth for all quality settings.
256
+ <!-- @work-item-id WI-117, WI-118, WI-122, WI-139 -->
257
+ ## G3 L4 Advisory Preconditions
258
+
259
+ L4 drift / consistency / docs semantic findings are advisory by default. Use fail-on-warning only when reports include Unit-scoped drift keys, real product-doc consistency targets, pointer/freshness owner and policy metadata, and semantic drift behavior coverage. This avoids treating parser limitations as blocking quality gates.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.150.1",
3
+ "version": "0.150.2",
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",
@@ -8,8 +8,12 @@ export interface PointerValidationResultDto {
8
8
  documentPath: string;
9
9
  pointerTarget: string;
10
10
  pointerType: 'file-path' | 'url';
11
+ semanticPointerType: 'reference' | 'implementation' | 'adr' | 'product-doc' | 'external-url';
12
+ owner: string;
13
+ severity: 'fail' | 'warn' | 'skip';
11
14
  isResolvable: boolean;
12
15
  errorMessage: string | null;
16
+ nextAction: string;
13
17
  }
14
18
 
15
19
  export interface ValidateDocPointersOutput {
@@ -35,6 +35,7 @@ export class ValidateDocPointersUseCase {
35
35
 
36
36
  const results = [];
37
37
  let totalDocuments = 0;
38
+ let skippedUrlPointers = 0;
38
39
 
39
40
  for (const rule of filteredRules) {
40
41
  const documentPaths = await this.documentScannerPort.scan(rule.documentPattern);
@@ -45,7 +46,13 @@ export class ValidateDocPointersUseCase {
45
46
  const validationResults = await this.pointerResolutionService.resolve(pointers);
46
47
 
47
48
  for (const validationResult of validationResults) {
48
- if (validationResult.pointer.isUrl() && !input.includeUrlPointers) {
49
+ const semanticPointerType = classifyPointerTarget(validationResult.pointer.target, validationResult.pointer.type);
50
+ const severity = validationResult.pointer.isUrl() && !input.includeUrlPointers
51
+ ? 'skip'
52
+ : rule.policyFor(semanticPointerType);
53
+
54
+ if (severity === 'skip') {
55
+ if (validationResult.pointer.isUrl()) skippedUrlPointers += 1;
49
56
  continue;
50
57
  }
51
58
 
@@ -53,8 +60,14 @@ export class ValidateDocPointersUseCase {
53
60
  documentPath,
54
61
  pointerTarget: validationResult.pointer.target,
55
62
  pointerType: validationResult.pointer.type,
63
+ semanticPointerType,
64
+ owner: rule.owner,
65
+ severity,
56
66
  isResolvable: validationResult.isResolvable,
57
67
  errorMessage: validationResult.errorMessage,
68
+ nextAction: validationResult.isResolvable
69
+ ? 'no action required'
70
+ : `Fix ${semanticPointerType} pointer or change policy for owner ${rule.owner}`,
58
71
  });
59
72
  }
60
73
 
@@ -66,9 +79,7 @@ export class ValidateDocPointersUseCase {
66
79
  }
67
80
 
68
81
  const brokenPointers = results.filter((result) => !result.isResolvable).length;
69
- const skippedUrlPointers = results.filter((result) => result.pointerType === 'url').length;
70
- const failingRules = filteredRules.filter((rule) => rule.shouldFailOnBroken());
71
- const passed = failingRules.length === 0 || brokenPointers === 0;
82
+ const passed = !results.some((result) => !result.isResolvable && result.severity === 'fail');
72
83
 
73
84
  return {
74
85
  results,
@@ -96,3 +107,11 @@ export class ValidateDocPointersUseCase {
96
107
  }
97
108
  }
98
109
  }
110
+
111
+ function classifyPointerTarget(target: string, rawType: 'file-path' | 'url'): 'reference' | 'implementation' | 'adr' | 'product-doc' | 'external-url' {
112
+ if (rawType === 'url') return 'external-url';
113
+ if (/docs\/ADR\/|ADR-\d{3}/i.test(target)) return 'adr';
114
+ if (/docs\/product\//.test(target)) return 'product-doc';
115
+ if (/scripts\/harness\/|\.ts$/.test(target)) return 'implementation';
116
+ return 'reference';
117
+ }
@@ -8,17 +8,23 @@ export interface PointerRuleProps {
8
8
  ruleId: string;
9
9
  documentPattern: string;
10
10
  failOnBroken: boolean;
11
+ owner?: string;
12
+ pointerPolicies?: Record<string, 'fail' | 'warn' | 'skip'>;
11
13
  }
12
14
 
13
15
  export class PointerRule {
14
16
  readonly ruleId: string;
15
17
  readonly documentPattern: string;
16
18
  readonly failOnBroken: boolean;
19
+ readonly owner: string;
20
+ readonly pointerPolicies: Readonly<Record<string, 'fail' | 'warn' | 'skip'>>;
17
21
 
18
22
  private constructor(props: PointerRuleProps) {
19
23
  this.ruleId = props.ruleId;
20
24
  this.documentPattern = props.documentPattern;
21
25
  this.failOnBroken = props.failOnBroken;
26
+ this.owner = props.owner ?? 'unowned';
27
+ this.pointerPolicies = Object.freeze({ ...(props.pointerPolicies ?? {}) });
22
28
  Object.freeze(this);
23
29
  }
24
30
 
@@ -35,4 +41,11 @@ export class PointerRule {
35
41
  shouldFailOnBroken(): boolean {
36
42
  return this.failOnBroken;
37
43
  }
44
+
45
+ policyFor(pointerType: string): 'fail' | 'warn' | 'skip' {
46
+ if (pointerType === 'external-url' && this.pointerPolicies[pointerType] === undefined) {
47
+ return 'skip';
48
+ }
49
+ return this.pointerPolicies[pointerType] ?? (this.failOnBroken ? 'fail' : 'warn');
50
+ }
38
51
  }
@@ -10,8 +10,10 @@ export interface FreshnessCheckResult {
10
10
  documentPath: string;
11
11
  ageInDays: number;
12
12
  ageSource: DocumentAgeSource;
13
+ category: 'stable' | 'stale-after-source-change';
13
14
  level: 'ok' | 'warn' | 'error';
14
15
  message: string;
16
+ nextAction: string;
15
17
  }
16
18
 
17
19
  export class FreshnessCheckService {
@@ -22,8 +24,10 @@ export class FreshnessCheckService {
22
24
  documentPath,
23
25
  ageInDays: documentAge.ageInDays,
24
26
  ageSource: documentAge.source,
27
+ category: 'stable',
25
28
  level: 'ok',
26
29
  message: 'disabled rule skipped',
30
+ nextAction: 'no action required',
27
31
  };
28
32
  }
29
33
 
@@ -39,8 +43,14 @@ export class FreshnessCheckService {
39
43
  documentPath,
40
44
  ageInDays: documentAge.ageInDays,
41
45
  ageSource: documentAge.source,
46
+ category: documentAge.source === 'related-source-change' ? 'stale-after-source-change' : 'stable',
42
47
  level,
43
48
  message: `${documentPath} is ${documentAge.ageInDays} days old`,
49
+ nextAction: level === 'ok'
50
+ ? 'no action required'
51
+ : documentAge.source === 'related-source-change'
52
+ ? 'Refresh the document against the related WI/product/source change'
53
+ : 'Review whether this stable document should have a wider freshness threshold',
44
54
  };
45
55
  }
46
56
  }
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { Phase2ExtensionsDomainError } from '../errors/phase2-extensions-domain-error.js';
6
6
 
7
- export type DocumentAgeSource = 'git-log' | 'file-mtime';
7
+ export type DocumentAgeSource = 'git-log' | 'file-mtime' | 'related-source-change';
8
8
 
9
9
  export interface DocumentAgeProps {
10
10
  ageInDays: number;
@@ -25,7 +25,7 @@ export class DocumentAge {
25
25
  if (!Number.isFinite(props.ageInDays) || props.ageInDays < 0) {
26
26
  throw new Phase2ExtensionsDomainError('L4-204', 'ageInDays は 0 以上である必要があります');
27
27
  }
28
- if (props.source !== 'git-log' && props.source !== 'file-mtime') {
28
+ if (props.source !== 'git-log' && props.source !== 'file-mtime' && props.source !== 'related-source-change') {
29
29
  throw new Phase2ExtensionsDomainError('L4-205', 'source が不正です');
30
30
  }
31
31
  return new DocumentAge({
@@ -25,6 +25,8 @@ type Phase2RuleConfig = {
25
25
  ruleId: string;
26
26
  documentPattern: string;
27
27
  failOnBroken?: boolean;
28
+ owner?: string;
29
+ pointerPolicies?: Record<string, 'fail' | 'warn' | 'skip'>;
28
30
  }>;
29
31
  };
30
32
  };
@@ -73,6 +75,14 @@ export class HarnessConfigFreshnessAdapter implements FreshnessConfigPort {
73
75
  ruleId: 'default-pointer-rule',
74
76
  documentPattern: `${designDocsRoot}/**/*.md`,
75
77
  failOnBroken: true,
78
+ owner: 'documentation',
79
+ pointerPolicies: {
80
+ 'product-doc': 'fail',
81
+ adr: 'fail',
82
+ implementation: 'warn',
83
+ reference: 'warn',
84
+ 'external-url': 'skip',
85
+ },
76
86
  }),
77
87
  ];
78
88
  }
@@ -82,6 +92,8 @@ export class HarnessConfigFreshnessAdapter implements FreshnessConfigPort {
82
92
  ruleId: rule.ruleId,
83
93
  documentPattern: rule.documentPattern,
84
94
  failOnBroken: rule.failOnBroken ?? true,
95
+ owner: rule.owner,
96
+ pointerPolicies: rule.pointerPolicies,
85
97
  }),
86
98
  );
87
99
  }
@@ -12,7 +12,9 @@ export class PointerResultFormatter {
12
12
  formatText(result: ValidateDocPointersOutput): string {
13
13
  return [
14
14
  `documents=${result.summary.totalDocuments} pointers=${result.summary.totalPointers} broken=${result.summary.brokenPointers}`,
15
- ...result.results.map((entry) => `${entry.isResolvable ? 'ok' : 'broken'}: ${entry.pointerTarget}`),
15
+ ...result.results.map((entry) =>
16
+ `${entry.isResolvable ? 'ok' : entry.severity}: ${entry.semanticPointerType} ${entry.pointerTarget} owner=${entry.owner} next=${entry.nextAction}`
17
+ ),
16
18
  ].join('\n');
17
19
  }
18
20
  }
@@ -35,26 +35,24 @@ export class ConsistencyCheckService {
35
35
  const mismatchPairs: { expected: string; actual: string; location: string }[] = [];
36
36
  const checkTargets = Object.keys(layerAnnotations);
37
37
 
38
- // レイヤー記述の整合性チェック(全ドキュメントが同じレイヤーを参照していることを確認)
39
- const layerValues = Object.values(layerAnnotations);
40
- if (layerValues.length > 1) {
41
- const referenceLayer = layerValues[0];
42
- for (const [docPath, layer] of Object.entries(layerAnnotations)) {
43
- if (layer !== referenceLayer) {
44
- mismatchPairs.push({
45
- expected: referenceLayer,
46
- actual: layer,
47
- location: docPath,
48
- });
49
- }
38
+ for (const [location, annotation] of Object.entries(layerAnnotations)) {
39
+ if (annotation === 'layer:unknown') {
40
+ mismatchPairs.push({
41
+ expected: 'known layer vocabulary',
42
+ actual: 'unknown layer vocabulary',
43
+ location,
44
+ });
45
+ }
46
+
47
+ if (annotation.startsWith('unit:mismatch:')) {
48
+ mismatchPairs.push({
49
+ expected: annotation.slice('unit:mismatch:'.length),
50
+ actual: location.includes('#unit:') ? location.slice(location.indexOf('#unit:') + '#unit:'.length) : 'unknown',
51
+ location,
52
+ });
50
53
  }
51
54
  }
52
55
 
53
- // ADR実在性確認(ポートが存在する場合)
54
- // adrReferencePort.exists を使って参照 ADR の実在を確認する
55
- // DesignDocumentPort の layerAnnotations には ADR 参照が含まれないため、
56
- // ADR 参照は別途取得する(ここではシンプル実装)
57
- // ADR not found => mismatch として扱う
58
56
  const knownAdrRefs = checkTargets
59
57
  .filter((t) => t.startsWith('ADR-'))
60
58
  .map((t) => t);
@@ -9,6 +9,7 @@ import { DriftReport } from '../../value-objects/drift-report.js';
9
9
 
10
10
  export interface DriftDetectionDesignDocumentPort {
11
11
  getElements(targetUnits?: readonly string[]): Promise<string[]>;
12
+ getElementRecords?(targetUnits?: readonly string[]): Promise<readonly DriftElementRecord[]>;
12
13
  /**
13
14
  * WI-095: 設計要素 → 実装ファイル path の明示対応。
14
15
  * 実装されていれば element 名完全一致に加えて drift 判定に使う。
@@ -23,6 +24,7 @@ export interface DriftDetectionDesignDocumentPort {
23
24
 
24
25
  export interface DriftDetectionSourceCodeAnalyzerPort {
25
26
  getElements(targetUnits?: readonly string[]): Promise<string[]>;
27
+ getElementRecords?(targetUnits?: readonly string[]): Promise<readonly DriftElementRecord[]>;
26
28
  /**
27
29
  * WI-095: export element → 定義ファイル path のマップ。
28
30
  * 実装されていれば design pointer と照合する。
@@ -40,6 +42,13 @@ export interface DriftDetectionServiceDeps {
40
42
  sourceCodeAnalyzerPort: DriftDetectionSourceCodeAnalyzerPort;
41
43
  }
42
44
 
45
+ export interface DriftElementRecord {
46
+ readonly element: string;
47
+ readonly unitName: string;
48
+ readonly filePaths?: readonly string[];
49
+ readonly pointers?: readonly string[];
50
+ }
51
+
43
52
  export class DriftDetectionService {
44
53
  private readonly designDocumentPort: DriftDetectionDesignDocumentPort;
45
54
  private readonly sourceCodeAnalyzerPort: DriftDetectionSourceCodeAnalyzerPort;
@@ -50,87 +59,122 @@ export class DriftDetectionService {
50
59
  }
51
60
 
52
61
  async detect(targetUnits?: readonly string[]): Promise<readonly DriftReport[]> {
53
- const designElements = await this.designDocumentPort.getElements(targetUnits);
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
- : {};
62
+ const designRecords = await this.loadDesignRecords(targetUnits);
63
+ const codeRecords = await this.loadCodeRecords(targetUnits);
61
64
 
62
- // ISSUE-005 P3-9: element → unit のマップを取得し、DriftReport.unitName の解決に使う
63
- const designUnitMap = this.designDocumentPort.getElementUnitMap
64
- ? await this.designDocumentPort.getElementUnitMap(targetUnits)
65
- : {};
66
- const codeUnitMap = this.sourceCodeAnalyzerPort.getElementUnitMap
67
- ? await this.sourceCodeAnalyzerPort.getElementUnitMap(targetUnits)
68
- : {};
65
+ const designKeys = new Set(designRecords.map(toDriftKey));
66
+ const codeKeys = new Set(codeRecords.map(toDriftKey));
67
+ const pointerMatchedDesignKeys = new Set<string>();
68
+ const pointerMatchedCodeKeys = new Set<string>();
69
+
70
+ for (const designRecord of designRecords) {
71
+ const pointers = designRecord.pointers ?? [];
72
+ if (pointers.length === 0) continue;
69
73
 
70
- const resolveUnit = (element: string): string => {
71
- return (
72
- designUnitMap[element] ??
73
- codeUnitMap[element] ??
74
- targetUnits?.[0] ??
75
- 'unknown'
74
+ const matchingCodeRecords = codeRecords.filter((codeRecord) =>
75
+ codeRecord.unitName === designRecord.unitName &&
76
+ (codeRecord.filePaths ?? []).some((filePath) => pointers.some((pointer) => isSameOrNestedPath(filePath, pointer)))
76
77
  );
77
- };
78
78
 
79
- const designSet = new Set(designElements);
80
- const codeSet = new Set(codeElements);
81
- const pointerMatchedDesignElements = new Set<string>();
82
- const pointerMatchedCodeElements = new Set<string>();
79
+ if (matchingCodeRecords.length === 0) continue;
83
80
 
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);
81
+ pointerMatchedDesignKeys.add(toDriftKey(designRecord));
82
+
83
+ // WI-117: pointer は同一ファイル内の全 export を blanket match しない。
84
+ // 明示 pointer が名前変更の橋渡しとして使えるのは、当該ファイルの public export が 1 つだけの場合に限定する。
85
+ if (matchingCodeRecords.length === 1) {
86
+ pointerMatchedCodeKeys.add(toDriftKey(matchingCodeRecords[0]));
87
+ } else {
88
+ for (const codeRecord of matchingCodeRecords) {
89
+ if (codeRecord.element === designRecord.element) {
90
+ pointerMatchedCodeKeys.add(toDriftKey(codeRecord));
91
+ }
94
92
  }
95
93
  }
96
94
  }
97
95
 
98
96
  const reports: DriftReport[] = [];
99
97
 
100
- // 設計に存在するがコードに存在しない
101
- for (const element of designElements) {
102
- if (!codeSet.has(element) && !pointerMatchedDesignElements.has(element)) {
98
+ for (const designRecord of designRecords) {
99
+ const key = toDriftKey(designRecord);
100
+ if (!codeKeys.has(key) && !pointerMatchedDesignKeys.has(key)) {
103
101
  reports.push(
104
102
  DriftReport.create({
105
103
  direction: 'design→code',
106
- unitName: resolveUnit(element),
107
- element,
108
- description: `設計に存在するがコードに存在しない: ${element}`,
109
- recommendation: `${element} をコードに実装してください`,
104
+ unitName: designRecord.unitName,
105
+ element: designRecord.element,
106
+ description: `設計に存在するがコードに存在しない: ${designRecord.element}`,
107
+ recommendation: `${designRecord.element} をコードに実装してください`,
110
108
  })
111
109
  );
112
110
  }
113
111
  }
114
112
 
115
- // コードに存在するが設計に存在しない
116
- for (const element of codeElements) {
117
- if (!designSet.has(element) && !pointerMatchedCodeElements.has(element)) {
113
+ for (const codeRecord of codeRecords) {
114
+ const key = toDriftKey(codeRecord);
115
+ if (!designKeys.has(key) && !pointerMatchedCodeKeys.has(key)) {
118
116
  reports.push(
119
117
  DriftReport.create({
120
118
  direction: 'code→design',
121
- unitName: resolveUnit(element),
122
- element,
123
- description: `コードに存在するが設計に存在しない: ${element}`,
124
- recommendation: `${element} を設計文書に追記するか、コードから削除してください`,
119
+ unitName: codeRecord.unitName,
120
+ element: codeRecord.element,
121
+ description: `コードに存在するが設計に存在しない: ${codeRecord.element}`,
122
+ recommendation: `${codeRecord.element} を設計文書に追記するか、コードから削除してください`,
125
123
  })
126
124
  );
127
125
  }
128
126
  }
129
127
 
130
128
  return reports.sort((a, b) =>
131
- a.direction.localeCompare(b.direction) || a.unitName.localeCompare(b.unitName)
129
+ a.direction.localeCompare(b.direction) ||
130
+ a.unitName.localeCompare(b.unitName) ||
131
+ a.element.localeCompare(b.element)
132
132
  );
133
133
  }
134
+
135
+ private async loadDesignRecords(targetUnits?: readonly string[]): Promise<readonly DriftElementRecord[]> {
136
+ if (this.designDocumentPort.getElementRecords) {
137
+ return this.designDocumentPort.getElementRecords(targetUnits);
138
+ }
139
+
140
+ const designElements = await this.designDocumentPort.getElements(targetUnits);
141
+ const designPointers = this.designDocumentPort.getElementPointers
142
+ ? await this.designDocumentPort.getElementPointers(targetUnits)
143
+ : {};
144
+ const designUnitMap = this.designDocumentPort.getElementUnitMap
145
+ ? await this.designDocumentPort.getElementUnitMap(targetUnits)
146
+ : {};
147
+
148
+ return designElements.map((element) => ({
149
+ element,
150
+ unitName: designUnitMap[element] ?? targetUnits?.[0] ?? 'unknown',
151
+ pointers: designPointers[element] ?? [],
152
+ }));
153
+ }
154
+
155
+ private async loadCodeRecords(targetUnits?: readonly string[]): Promise<readonly DriftElementRecord[]> {
156
+ if (this.sourceCodeAnalyzerPort.getElementRecords) {
157
+ return this.sourceCodeAnalyzerPort.getElementRecords(targetUnits);
158
+ }
159
+
160
+ const codeElements = await this.sourceCodeAnalyzerPort.getElements(targetUnits);
161
+ const codeFilePathMap = this.sourceCodeAnalyzerPort.getElementFilePathMap
162
+ ? await this.sourceCodeAnalyzerPort.getElementFilePathMap(targetUnits)
163
+ : {};
164
+ const codeUnitMap = this.sourceCodeAnalyzerPort.getElementUnitMap
165
+ ? await this.sourceCodeAnalyzerPort.getElementUnitMap(targetUnits)
166
+ : {};
167
+
168
+ return codeElements.map((element) => ({
169
+ element,
170
+ unitName: codeUnitMap[element] ?? targetUnits?.[0] ?? 'unknown',
171
+ filePaths: codeFilePathMap[element] ?? [],
172
+ }));
173
+ }
174
+ }
175
+
176
+ function toDriftKey(record: DriftElementRecord): string {
177
+ return `${record.unitName}\0${record.element}`;
134
178
  }
135
179
 
136
180
  function normalizePath(path: string): string {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @layer domain
3
+ * @unit validator-system
4
+ * @work-item-id WI-139
5
+ */
6
+ import { SemanticDriftReport } from '../../value-objects/semantic-drift-report.js';
7
+
8
+ export interface DesignIntent {
9
+ readonly behaviorId: string;
10
+ readonly unitName: string;
11
+ readonly source: string;
12
+ }
13
+
14
+ export interface ImplementationBehavior {
15
+ readonly behaviorId: string;
16
+ readonly unitName: string;
17
+ readonly source: string;
18
+ readonly isPublic: boolean;
19
+ }
20
+
21
+ export interface TestObservation {
22
+ readonly behaviorId: string;
23
+ readonly unitName: string;
24
+ readonly source: string;
25
+ }
26
+
27
+ export interface SemanticDriftInput {
28
+ readonly designIntents: readonly DesignIntent[];
29
+ readonly implementationBehaviors: readonly ImplementationBehavior[];
30
+ readonly testObservations: readonly TestObservation[];
31
+ }
32
+
33
+ export class SemanticDriftService {
34
+ detect(input: SemanticDriftInput): readonly SemanticDriftReport[] {
35
+ const designKeys = new Set(input.designIntents.map(toBehaviorKey));
36
+ const codeKeys = new Set(input.implementationBehaviors.filter((entry) => entry.isPublic).map(toBehaviorKey));
37
+ const testKeys = new Set(input.testObservations.map(toBehaviorKey));
38
+ const reports: SemanticDriftReport[] = [];
39
+
40
+ for (const intent of input.designIntents) {
41
+ const key = toBehaviorKey(intent);
42
+ if (!codeKeys.has(key)) {
43
+ reports.push(SemanticDriftReport.create({
44
+ kind: 'design-behavior-missing-code',
45
+ behaviorId: intent.behaviorId,
46
+ unitName: intent.unitName,
47
+ severity: 'error',
48
+ location: intent.source,
49
+ nextAction: 'Implement the designed behavior or remove the design intent',
50
+ }));
51
+ }
52
+ if (!testKeys.has(key)) {
53
+ reports.push(SemanticDriftReport.create({
54
+ kind: 'design-behavior-missing-test',
55
+ behaviorId: intent.behaviorId,
56
+ unitName: intent.unitName,
57
+ severity: 'warning',
58
+ location: intent.source,
59
+ nextAction: 'Add a test observation for the design intent',
60
+ }));
61
+ }
62
+ }
63
+
64
+ for (const behavior of input.implementationBehaviors.filter((entry) => entry.isPublic)) {
65
+ const key = toBehaviorKey(behavior);
66
+ if (!designKeys.has(key)) {
67
+ reports.push(SemanticDriftReport.create({
68
+ kind: 'code-behavior-missing-design',
69
+ behaviorId: behavior.behaviorId,
70
+ unitName: behavior.unitName,
71
+ severity: 'warning',
72
+ location: behavior.source,
73
+ nextAction: 'Document the public behavior or make it private/internal',
74
+ }));
75
+ }
76
+ if (!testKeys.has(key)) {
77
+ reports.push(SemanticDriftReport.create({
78
+ kind: 'code-behavior-missing-test',
79
+ behaviorId: behavior.behaviorId,
80
+ unitName: behavior.unitName,
81
+ severity: 'warning',
82
+ location: behavior.source,
83
+ nextAction: 'Add a test observation for the public behavior',
84
+ }));
85
+ }
86
+ }
87
+
88
+ for (const observation of input.testObservations) {
89
+ const key = toBehaviorKey(observation);
90
+ if (!designKeys.has(key)) {
91
+ reports.push(SemanticDriftReport.create({
92
+ kind: 'test-observation-missing-design',
93
+ behaviorId: observation.behaviorId,
94
+ unitName: observation.unitName,
95
+ severity: 'warning',
96
+ location: observation.source,
97
+ nextAction: 'Link the test observation to design intent or loosen the test',
98
+ }));
99
+ }
100
+ }
101
+
102
+ return reports.sort((a, b) =>
103
+ a.unitName.localeCompare(b.unitName) ||
104
+ a.behaviorId.localeCompare(b.behaviorId) ||
105
+ a.kind.localeCompare(b.kind)
106
+ );
107
+ }
108
+ }
109
+
110
+ function toBehaviorKey(entry: { readonly behaviorId: string; readonly unitName: string }): string {
111
+ return `${entry.unitName}\0${entry.behaviorId}`;
112
+ }
@@ -11,6 +11,7 @@ export interface MismatchPair {
11
11
  readonly expected: string;
12
12
  readonly actual: string;
13
13
  readonly location: string;
14
+ readonly nextAction?: string;
14
15
  }
15
16
 
16
17
  export interface ConsistencyReportProps {
@@ -49,7 +50,7 @@ export class ConsistencyReport {
49
50
  code: { value: 'L4-002', toString: () => 'L4-002' },
50
51
  severity: { value: 'warning', toString: () => 'warning' },
51
52
  message: `レイヤー整合性違反: expected "${pair.expected}" but got "${pair.actual}" at ${pair.location}`,
52
- suggestion: '設計文書間のレイヤー依存方向を統一してください',
53
+ suggestion: pair.nextAction ?? '設計文書間のレイヤー依存方向を統一してください',
53
54
  }));
54
55
  }
55
56
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @layer domain
3
+ * @unit validator-system
4
+ * @work-item-id WI-139
5
+ */
6
+
7
+ export type SemanticDriftKind =
8
+ | 'design-behavior-missing-code'
9
+ | 'design-behavior-missing-test'
10
+ | 'code-behavior-missing-design'
11
+ | 'code-behavior-missing-test'
12
+ | 'test-observation-missing-design';
13
+
14
+ export interface SemanticDriftReportProps {
15
+ readonly kind: SemanticDriftKind;
16
+ readonly behaviorId: string;
17
+ readonly unitName: string;
18
+ readonly severity: 'warning' | 'error';
19
+ readonly location?: string;
20
+ readonly nextAction: string;
21
+ }
22
+
23
+ export class SemanticDriftReport {
24
+ readonly kind: SemanticDriftKind;
25
+ readonly behaviorId: string;
26
+ readonly unitName: string;
27
+ readonly severity: 'warning' | 'error';
28
+ readonly location: string | null;
29
+ readonly nextAction: string;
30
+
31
+ private constructor(props: SemanticDriftReportProps) {
32
+ this.kind = props.kind;
33
+ this.behaviorId = props.behaviorId;
34
+ this.unitName = props.unitName;
35
+ this.severity = props.severity;
36
+ this.location = props.location ?? null;
37
+ this.nextAction = props.nextAction;
38
+ Object.freeze(this);
39
+ }
40
+
41
+ static create(props: SemanticDriftReportProps): SemanticDriftReport {
42
+ if (props.behaviorId.trim().length === 0) {
43
+ throw new Error('behaviorId is required for semantic drift reports');
44
+ }
45
+ if (props.unitName.trim().length === 0) {
46
+ throw new Error('unitName is required for semantic drift reports');
47
+ }
48
+ return new SemanticDriftReport(props);
49
+ }
50
+ }
@@ -18,6 +18,7 @@ export { LayerConfig } from './domain/value-objects/layer-config.js';
18
18
  export { DriftReport } from './domain/value-objects/drift-report.js';
19
19
  export { ConsistencyReport } from './domain/value-objects/consistency-report.js';
20
20
  export { DeadCodeReport } from './domain/value-objects/dead-code-report.js';
21
+ export { SemanticDriftReport } from './domain/value-objects/semantic-drift-report.js';
21
22
 
22
23
  // --- Domain Services ---
23
24
  export { ValidatorRegistry, UnknownValidatorError } from './domain/services/validator-registry.js';
@@ -25,6 +26,7 @@ export { ValidatorExecutionService, ValidatorExecutionError } from './domain/ser
25
26
  export { DriftDetectionService } from './domain/services/l4/drift-detection-service.js';
26
27
  export { ConsistencyCheckService } from './domain/services/l4/consistency-check-service.js';
27
28
  export { DeadCodeDetectionService } from './domain/services/l4/dead-code-detection-service.js';
29
+ export { SemanticDriftService } from './domain/services/l4/semantic-drift-service.js';
28
30
 
29
31
  // --- Application DTOs ---
30
32
  export type { ValidationResultContract } from './application/dto/validation-result-contract.js';
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import * as ts from 'typescript';
9
9
  import type { SourceCodeAnalyzerPort, SourceAnalysisResult } from '../../domain/ports/source-code-analyzer-port.js';
10
+ import type { DriftElementRecord } from '../../domain/services/l4/drift-detection-service.js';
10
11
  import { readdir } from 'node:fs/promises';
11
12
  import { basename, join, relative, sep } from 'node:path';
12
13
 
@@ -44,7 +45,7 @@ export class BiomeAstSourceCodeAnalyzerAdapter implements SourceCodeAnalyzerPort
44
45
  const sourceFile = program.getSourceFile(filePath);
45
46
  if (!sourceFile) continue;
46
47
  results.push({
47
- unitName: resolveUnitName(this.sourceRoot, filePath),
48
+ unitName: resolveUnitName(this.sourceRoot, filePath, sourceFile.text),
48
49
  filePath,
49
50
  exports: extractExports(sourceFile),
50
51
  imports: extractImports(sourceFile),
@@ -86,6 +87,17 @@ export class BiomeAstSourceCodeAnalyzerAdapter implements SourceCodeAnalyzerPort
86
87
  }
87
88
  return map;
88
89
  }
90
+
91
+ async getElementRecords(targetUnits?: readonly string[]): Promise<readonly DriftElementRecord[]> {
92
+ const results = await this.analyzeExports(targetUnits);
93
+ return results.flatMap((result) =>
94
+ result.exports.map((entry) => ({
95
+ element: entry.name,
96
+ unitName: result.unitName,
97
+ filePaths: [result.filePath],
98
+ }))
99
+ );
100
+ }
89
101
  }
90
102
 
91
103
  type ExportType = SourceAnalysisResult['exports'][number]['type'];
@@ -112,6 +124,19 @@ function extractExports(sourceFile: ts.SourceFile): SourceAnalysisResult['export
112
124
  exports.push({ name: decl.name.text, type: 'const' });
113
125
  }
114
126
  }
127
+ } else if (ts.isExportDeclaration(node) && node.exportClause) {
128
+ if (ts.isNamedExports(node.exportClause)) {
129
+ for (const element of node.exportClause.elements) {
130
+ exports.push({ name: element.name.text, type: 'type' });
131
+ }
132
+ }
133
+ } else if (ts.isExportDeclaration(node) && !node.exportClause && ts.isStringLiteral(node.moduleSpecifier)) {
134
+ exports.push({ name: `* from ${node.moduleSpecifier.text}`, type: 'type' });
135
+ }
136
+
137
+ const hasDefault = modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
138
+ if (hasDefault) {
139
+ exports.push({ name: 'default', type: 'type' });
115
140
  }
116
141
  });
117
142
 
@@ -182,7 +207,10 @@ async function walkTsFiles(root: string, excludePattern: RegExp): Promise<string
182
207
  }
183
208
  }
184
209
 
185
- function resolveUnitName(sourceRoot: string, filePath: string): string {
210
+ function resolveUnitName(sourceRoot: string, filePath: string, sourceText: string): string {
211
+ const unitMatch = /@unit\s+([a-z0-9-]+)/i.exec(sourceText);
212
+ if (unitMatch) return unitMatch[1];
213
+
186
214
  const relativePath = relative(sourceRoot, filePath);
187
215
  const [firstSegment] = relativePath.split(sep);
188
216
  return firstSegment || basename(filePath);
@@ -5,10 +5,20 @@
5
5
  * MarkdownDesignDocumentAdapter — DesignDocumentPort実装
6
6
  */
7
7
  import type { DesignDocumentPort, StructuredDesignDoc } from '../../domain/ports/design-document-port.js';
8
+ import type { DriftElementRecord } from '../../domain/services/l4/drift-detection-service.js';
8
9
  import { readFile, readdir } from 'node:fs/promises';
9
10
  import { join } from 'node:path';
10
11
 
11
12
  const ADR_PATTERN = /ADR-\d{3}/g;
13
+ const WORK_ITEM_PATTERN = /@work-item-id\s+(WI-\d{3})/g;
14
+ const LAYER_PATTERN = /@layer\s+([a-z0-9-]+)/gi;
15
+ const UNIT_PATTERN = /@unit\s+([a-z0-9-]+)/gi;
16
+ const CONSTRUCTION_DOC_NAMES = [
17
+ 'domain_model.md',
18
+ 'logical_design.md',
19
+ 'unit_test_design.md',
20
+ 'it_test_design.md',
21
+ ];
12
22
 
13
23
  // ISSUE-005 P3-8: メタ見出し / 議論用セクションを drift 対象から除外するマーカー。
14
24
  // 見出し行の直後 (同一行末 or 次の非空行) に置かれたコメントを拾う。
@@ -111,26 +121,28 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
111
121
  const results: StructuredDesignDoc[] = [];
112
122
 
113
123
  for (const unitName of unitNames) {
114
- const docPath = join(this.docsRoot, unitName, 'domain_model.md');
115
- const cached = this.cache.get(docPath);
116
- if (cached) {
117
- results.push(cached);
118
- continue;
119
- }
124
+ for (const docName of CONSTRUCTION_DOC_NAMES) {
125
+ const docPath = join(this.docsRoot, unitName, docName);
126
+ const cached = this.cache.get(docPath);
127
+ if (cached) {
128
+ results.push(cached);
129
+ continue;
130
+ }
120
131
 
121
- try {
122
- const markdown = await readFile(docPath, 'utf8');
123
- const doc: StructuredDesignDoc = {
124
- unitName,
125
- docPath,
126
- concepts: extractConcepts(markdown).map((concept) => ({ ...concept, type: 'class' })),
127
- layerDependencies: [],
128
- adrRefs: Array.from(new Set(markdown.match(ADR_PATTERN) ?? [])),
129
- };
130
- this.cache.set(docPath, doc);
131
- results.push(doc);
132
- } catch {
133
- continue;
132
+ try {
133
+ const markdown = await readFile(docPath, 'utf8');
134
+ const doc: StructuredDesignDoc = {
135
+ unitName,
136
+ docPath,
137
+ concepts: extractConcepts(markdown).map((concept) => ({ ...concept, type: 'class' })),
138
+ layerDependencies: extractLayerDependencies(markdown),
139
+ adrRefs: Array.from(new Set(markdown.match(ADR_PATTERN) ?? [])),
140
+ };
141
+ this.cache.set(docPath, doc);
142
+ results.push(doc);
143
+ } catch {
144
+ continue;
145
+ }
134
146
  }
135
147
  }
136
148
 
@@ -138,7 +150,32 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
138
150
  }
139
151
 
140
152
  async getLayerAnnotations(targetDocs?: readonly string[]): Promise<Record<string, string>> {
141
- return {};
153
+ const docs = targetDocs && targetDocs.length > 0
154
+ ? await this.loadExplicitDocs(targetDocs)
155
+ : await this.loadDesignDocuments();
156
+ const annotations: Record<string, string> = {};
157
+
158
+ for (const doc of docs) {
159
+ const markdown = await readFile(doc.docPath, 'utf8');
160
+ const layers = Array.from(new Set(Array.from(markdown.matchAll(LAYER_PATTERN)).map((match) => match[1])));
161
+ const units = Array.from(new Set(Array.from(markdown.matchAll(UNIT_PATTERN)).map((match) => match[1])));
162
+ const workItems = Array.from(new Set(Array.from(markdown.matchAll(WORK_ITEM_PATTERN)).map((match) => match[1])));
163
+
164
+ for (const layer of layers) {
165
+ annotations[`${doc.docPath}#layer:${layer}`] = isKnownLayer(layer) ? 'layer:known' : 'layer:unknown';
166
+ }
167
+ for (const unit of units) {
168
+ annotations[`${doc.docPath}#unit:${unit}`] = unit === doc.unitName ? 'unit:matched' : `unit:mismatch:${doc.unitName}`;
169
+ }
170
+ for (const adrRef of doc.adrRefs) {
171
+ annotations[adrRef] = 'adr:referenced';
172
+ }
173
+ for (const workItemId of workItems) {
174
+ annotations[`${doc.docPath}#work-item:${workItemId}`] = 'work-item:referenced';
175
+ }
176
+ }
177
+
178
+ return annotations;
142
179
  }
143
180
 
144
181
  async getElements(targetUnits?: readonly string[]): Promise<string[]> {
@@ -146,6 +183,17 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
146
183
  return docs.flatMap((doc) => doc.concepts.map((concept) => concept.name));
147
184
  }
148
185
 
186
+ async getElementRecords(targetUnits?: readonly string[]): Promise<readonly DriftElementRecord[]> {
187
+ const docs = await this.loadDesignDocuments(targetUnits);
188
+ return docs.flatMap((doc) =>
189
+ doc.concepts.map((concept) => ({
190
+ element: concept.name,
191
+ unitName: doc.unitName,
192
+ pointers: concept.pointers ?? [],
193
+ }))
194
+ );
195
+ }
196
+
149
197
  async getElementPointers(targetUnits?: readonly string[]): Promise<Record<string, readonly string[]>> {
150
198
  const docs = await this.loadDesignDocuments(targetUnits);
151
199
  const map: Record<string, readonly string[]> = {};
@@ -176,6 +224,26 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
176
224
  return map;
177
225
  }
178
226
 
227
+ private async loadExplicitDocs(targetDocs: readonly string[]): Promise<readonly StructuredDesignDoc[]> {
228
+ const docs: StructuredDesignDoc[] = [];
229
+ for (const docPath of targetDocs) {
230
+ try {
231
+ const markdown = await readFile(docPath, 'utf8');
232
+ const unitName = inferUnitNameFromDocPath(this.docsRoot, docPath);
233
+ docs.push({
234
+ unitName,
235
+ docPath,
236
+ concepts: extractConcepts(markdown).map((concept) => ({ ...concept, type: 'class' })),
237
+ layerDependencies: extractLayerDependencies(markdown),
238
+ adrRefs: Array.from(new Set(markdown.match(ADR_PATTERN) ?? [])),
239
+ });
240
+ } catch {
241
+ continue;
242
+ }
243
+ }
244
+ return docs;
245
+ }
246
+
179
247
  private async listUnitNames(): Promise<string[]> {
180
248
  try {
181
249
  const entries = await readdir(this.docsRoot, { withFileTypes: true });
@@ -185,3 +253,25 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
185
253
  }
186
254
  }
187
255
  }
256
+
257
+ function extractLayerDependencies(markdown: string): Array<{ from: string; to: string }> {
258
+ const dependencies: Array<{ from: string; to: string }> = [];
259
+ const dependencyPattern = /([a-z0-9-]+)\s*(?:->|→)\s*([a-z0-9-]+)/gi;
260
+ for (const match of markdown.matchAll(dependencyPattern)) {
261
+ dependencies.push({ from: match[1], to: match[2] });
262
+ }
263
+ return dependencies;
264
+ }
265
+
266
+ function isKnownLayer(layer: string): boolean {
267
+ return ['domain', 'application', 'infrastructure', 'presentation', 'test'].includes(layer);
268
+ }
269
+
270
+ function inferUnitNameFromDocPath(docsRoot: string, docPath: string): string {
271
+ const normalizedRoot = docsRoot.replace(/\\/g, '/').replace(/\/+$/, '');
272
+ const normalizedPath = docPath.replace(/\\/g, '/');
273
+ const relativePath = normalizedPath.startsWith(`${normalizedRoot}/`)
274
+ ? normalizedPath.slice(normalizedRoot.length + 1)
275
+ : normalizedPath;
276
+ return relativePath.split('/')[0] || 'unknown';
277
+ }