phasegate 0.150.0 → 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 (31) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/docs/guide/cli-reference.md +13 -0
  5. package/docs/guide/layer-model.md +4 -0
  6. package/package.json +1 -1
  7. package/scripts/harness/main.ts +16 -0
  8. package/scripts/harness/nyquist-validation/application/dto/generate-matrix-output.ts +71 -0
  9. package/scripts/harness/nyquist-validation/application/usecases/generate-requirement-test-matrix-usecase.ts +161 -0
  10. package/scripts/harness/nyquist-validation/composition-root.ts +24 -1
  11. package/scripts/harness/nyquist-validation/domain/services/requirement-intent-coverage-service.ts +42 -0
  12. package/scripts/harness/nyquist-validation/index.ts +9 -1
  13. package/scripts/harness/nyquist-validation/infrastructure/adapters/file-system-generated-matrix-adapter.ts +24 -0
  14. package/scripts/harness/nyquist-validation/infrastructure/adapters/markdown-requirement-source-adapter.ts +37 -0
  15. package/scripts/harness/nyquist-validation/infrastructure/adapters/type-script-test-reference-source-adapter.ts +54 -0
  16. package/scripts/harness/nyquist-validation/presentation/handlers/generate-matrix-handler.ts +34 -0
  17. package/scripts/harness/phase2-extensions/application/dto/validate-doc-pointers-output.ts +4 -0
  18. package/scripts/harness/phase2-extensions/application/usecases/validate-doc-pointers-usecase.ts +23 -4
  19. package/scripts/harness/phase2-extensions/domain/aggregates/pointer-rule.ts +13 -0
  20. package/scripts/harness/phase2-extensions/domain/services/freshness-check-service.ts +10 -0
  21. package/scripts/harness/phase2-extensions/domain/value-objects/document-age.ts +2 -2
  22. package/scripts/harness/phase2-extensions/infrastructure/adapters/harness-config-freshness-adapter.ts +12 -0
  23. package/scripts/harness/phase2-extensions/presentation/formatters/pointer-result-formatter.ts +3 -1
  24. package/scripts/harness/validator-system/domain/services/l4/consistency-check-service.ts +15 -17
  25. package/scripts/harness/validator-system/domain/services/l4/drift-detection-service.ts +95 -51
  26. package/scripts/harness/validator-system/domain/services/l4/semantic-drift-service.ts +112 -0
  27. package/scripts/harness/validator-system/domain/value-objects/consistency-report.ts +2 -1
  28. package/scripts/harness/validator-system/domain/value-objects/semantic-drift-report.ts +50 -0
  29. package/scripts/harness/validator-system/index.ts +2 -0
  30. package/scripts/harness/validator-system/infrastructure/adapters/biome-ast-source-code-analyzer-adapter.ts +30 -2
  31. package/scripts/harness/validator-system/infrastructure/adapters/markdown-design-document-adapter.ts +110 -20
@@ -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';