phasegate 0.150.1 → 0.151.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 (28) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/docs/guide/layer-model.md +4 -0
  3. package/package.json +1 -1
  4. package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +1 -1
  5. package/scripts/harness/harness-api/domain/value-objects/harness-status-summary.ts +1 -0
  6. package/scripts/harness/phase2-extensions/application/dto/validate-doc-pointers-output.ts +5 -0
  7. package/scripts/harness/phase2-extensions/application/usecases/validate-doc-pointers-usecase.ts +24 -4
  8. package/scripts/harness/phase2-extensions/domain/aggregates/pointer-rule.ts +14 -0
  9. package/scripts/harness/phase2-extensions/domain/services/freshness-check-service.ts +11 -0
  10. package/scripts/harness/phase2-extensions/domain/value-objects/document-age.ts +3 -2
  11. package/scripts/harness/phase2-extensions/infrastructure/adapters/harness-config-freshness-adapter.ts +13 -0
  12. package/scripts/harness/phase2-extensions/presentation/formatters/pointer-result-formatter.ts +4 -1
  13. package/scripts/harness/validator-system/application/use-cases/run-l2-validators-usecase.ts +29 -1
  14. package/scripts/harness/validator-system/composition-root.ts +6 -2
  15. package/scripts/harness/validator-system/domain/ports/contract-traceability-policy-port.ts +9 -0
  16. package/scripts/harness/validator-system/domain/services/contract-traceability-coverage-service.ts +259 -0
  17. package/scripts/harness/validator-system/domain/services/l4/consistency-check-service.ts +16 -17
  18. package/scripts/harness/validator-system/domain/services/l4/drift-detection-service.ts +96 -51
  19. package/scripts/harness/validator-system/domain/services/l4/semantic-drift-service.ts +112 -0
  20. package/scripts/harness/validator-system/domain/value-objects/consistency-report.ts +3 -1
  21. package/scripts/harness/validator-system/domain/value-objects/contract-traceability-model.ts +124 -0
  22. package/scripts/harness/validator-system/domain/value-objects/semantic-drift-report.ts +50 -0
  23. package/scripts/harness/validator-system/domain/value-objects/validator-id.ts +2 -0
  24. package/scripts/harness/validator-system/index.ts +2 -0
  25. package/scripts/harness/validator-system/infrastructure/adapters/biome-ast-source-code-analyzer-adapter.ts +31 -2
  26. package/scripts/harness/validator-system/infrastructure/adapters/file-system-contract-traceability-policy-adapter.ts +115 -0
  27. package/scripts/harness/validator-system/infrastructure/adapters/harness-config-validator-config-adapter.ts +1 -1
  28. package/scripts/harness/validator-system/infrastructure/adapters/markdown-design-document-adapter.ts +111 -20
@@ -0,0 +1,259 @@
1
+ // @unit validator-system
2
+ // @layer domain
3
+ // @work-item-id WI-132 / WI-133 / WI-136 / WI-137 / WI-138
4
+
5
+ import {
6
+ type BoundaryCaseKind,
7
+ type ContractTraceabilityFinding,
8
+ type ContractTraceabilityInput,
9
+ ContractTraceabilityReport,
10
+ type ErrorContract,
11
+ type PublicContract,
12
+ type StateMachineModel,
13
+ type TestObservation,
14
+ type TraceabilityGraphSlice,
15
+ } from '../value-objects/contract-traceability-model.js';
16
+
17
+ export class ContractTraceabilityCoverageService {
18
+ check(input: ContractTraceabilityInput): ContractTraceabilityReport {
19
+ return ContractTraceabilityReport.create([
20
+ ...this.checkPublicContracts(input.publicContracts, input.testObservations),
21
+ ...this.checkErrorContracts(input.errorContracts, input.testObservations),
22
+ ...this.checkStateMachines(input.stateMachines, input.testObservations),
23
+ ...this.checkTraceability(input.traceabilitySlices),
24
+ ]);
25
+ }
26
+
27
+ private checkPublicContracts(
28
+ contracts: readonly PublicContract[],
29
+ observations: readonly TestObservation[],
30
+ ): readonly ContractTraceabilityFinding[] {
31
+ const findings: ContractTraceabilityFinding[] = [];
32
+ for (const contract of contracts) {
33
+ for (const behavior of contract.requiredBehaviors) {
34
+ if (!this.isCovered(`${contract.id}:${behavior}`, observations)) {
35
+ findings.push(this.finding(
36
+ 'missing-required-behavior-test',
37
+ contract.id,
38
+ contract.sourcePath,
39
+ `Public contract ${contract.id} requires behavior "${behavior}" but no matching test observation covers it.`,
40
+ `Add a test observation covering ${contract.id}:${behavior}.`,
41
+ ));
42
+ }
43
+ }
44
+
45
+ if (contract.kind === 'port' && !observations.some((observation) => (
46
+ observation.kind === 'adapter-contract' && observation.covers.includes(contract.id)
47
+ ))) {
48
+ findings.push(this.finding(
49
+ 'missing-port-contract-test',
50
+ contract.id,
51
+ contract.sourcePath,
52
+ `Port contract ${contract.id} has no adapter contract test observation.`,
53
+ `Add an adapter contract test that covers ${contract.id}.`,
54
+ ));
55
+ }
56
+
57
+ for (const boundaryCase of contract.boundaryCases ?? []) {
58
+ if (!this.isBoundaryCovered(contract, boundaryCase, observations)) {
59
+ findings.push(this.finding(
60
+ 'missing-boundary-test',
61
+ contract.id,
62
+ contract.sourcePath,
63
+ `Contract ${contract.id} requires boundary case "${boundaryCase}" but no matching test observation covers it.`,
64
+ `Add a boundary test observation covering ${contract.id}:boundary:${boundaryCase}.`,
65
+ ));
66
+ }
67
+ }
68
+ }
69
+ return findings;
70
+ }
71
+
72
+ private checkErrorContracts(
73
+ contracts: readonly ErrorContract[],
74
+ observations: readonly TestObservation[],
75
+ ): readonly ContractTraceabilityFinding[] {
76
+ const findings: ContractTraceabilityFinding[] = [];
77
+ for (const contract of contracts) {
78
+ const missingShapeFields = [
79
+ ['code', contract.code],
80
+ ['severity', contract.severity],
81
+ ['message', contract.message],
82
+ ['suggestion', contract.suggestion],
83
+ ['documentationRef', contract.documentationRef],
84
+ ].filter(([, value]) => !this.hasUsefulText(value));
85
+
86
+ if (missingShapeFields.length > 0) {
87
+ findings.push(this.finding(
88
+ 'error-contract-shape',
89
+ contract.id,
90
+ contract.sourcePath,
91
+ `Error contract ${contract.id} is missing required fields: ${missingShapeFields.map(([field]) => field).join(', ')}.`,
92
+ 'Provide stable code, severity, message, suggestion, and documentation reference.',
93
+ ));
94
+ }
95
+
96
+ if (this.isGenericSuggestion(contract.suggestion)) {
97
+ findings.push(this.finding(
98
+ 'error-contract-shape',
99
+ contract.id,
100
+ contract.sourcePath,
101
+ `Error contract ${contract.id} has a generic or empty recovery suggestion.`,
102
+ 'Replace the suggestion with a concrete next action.',
103
+ ));
104
+ }
105
+
106
+ if (contract.exitCode !== undefined && contract.severity !== undefined) {
107
+ const expected = contract.severity === 'error' ? [1, 2] : [0];
108
+ if (!expected.includes(contract.exitCode)) {
109
+ findings.push(this.finding(
110
+ 'error-contract-exit-code',
111
+ contract.id,
112
+ contract.sourcePath,
113
+ `Error contract ${contract.id} severity=${contract.severity} is inconsistent with exitCode=${contract.exitCode}.`,
114
+ 'Align warning contracts with exit 0 and error contracts with exit 1 or 2.',
115
+ ));
116
+ }
117
+ }
118
+
119
+ if (!this.isCovered(`${contract.id}:error-path`, observations)) {
120
+ findings.push(this.finding(
121
+ 'missing-error-path-test',
122
+ contract.id,
123
+ contract.sourcePath,
124
+ `Error contract ${contract.id} has no error path test observation.`,
125
+ `Add a test observation covering ${contract.id}:error-path.`,
126
+ ));
127
+ }
128
+ }
129
+ return findings;
130
+ }
131
+
132
+ private checkStateMachines(
133
+ machines: readonly StateMachineModel[],
134
+ observations: readonly TestObservation[],
135
+ ): readonly ContractTraceabilityFinding[] {
136
+ const findings: ContractTraceabilityFinding[] = [];
137
+ for (const machine of machines) {
138
+ const codeStates = new Set(machine.codeStates);
139
+ const docsStates = new Set(machine.docsStates);
140
+ const mismatchedStates = [
141
+ ...machine.docsStates.filter((state) => !codeStates.has(state)),
142
+ ...machine.codeStates.filter((state) => !docsStates.has(state)),
143
+ ];
144
+ if (mismatchedStates.length > 0) {
145
+ findings.push(this.finding(
146
+ 'state-doc-code-mismatch',
147
+ machine.id,
148
+ machine.sourcePath,
149
+ `State machine ${machine.id} has docs/code state mismatch: ${mismatchedStates.join(', ')}.`,
150
+ 'Update docs and code state definitions so they describe the same states.',
151
+ ));
152
+ }
153
+
154
+ for (const transition of machine.invalidTransitions) {
155
+ if (machine.terminalStates.includes(transition.from)) {
156
+ findings.push(this.finding(
157
+ 'state-invalid-terminal-transition',
158
+ machine.id,
159
+ machine.sourcePath,
160
+ `State machine ${machine.id} defines invalid transition from terminal state ${transition.from} to ${transition.to}.`,
161
+ 'Remove terminal-state outgoing transitions or mark them as rejected behavior.',
162
+ ));
163
+ }
164
+ }
165
+
166
+ for (const transition of machine.transitions) {
167
+ const key = `${machine.id}:transition:${transition.from}->${transition.to}`;
168
+ if (!this.isCovered(key, observations)) {
169
+ findings.push(this.finding(
170
+ 'missing-transition-test',
171
+ machine.id,
172
+ machine.sourcePath,
173
+ `State transition ${transition.from}->${transition.to} has no success/failure test observation.`,
174
+ `Add a test observation covering ${key}.`,
175
+ ));
176
+ }
177
+ }
178
+ }
179
+ return findings;
180
+ }
181
+
182
+ private checkTraceability(slices: readonly TraceabilityGraphSlice[]): readonly ContractTraceabilityFinding[] {
183
+ const findings: ContractTraceabilityFinding[] = [];
184
+ for (const slice of slices) {
185
+ const productUnits = new Set(slice.productUnits);
186
+ const missingUnits = slice.affectedUnits.filter((unit) => !productUnits.has(unit));
187
+ if (missingUnits.length > 0) {
188
+ findings.push(this.finding(
189
+ 'traceability-unit-mismatch',
190
+ slice.workItemId,
191
+ slice.workItemId,
192
+ `${slice.workItemId} affects units without product reflection: ${missingUnits.join(', ')}.`,
193
+ `Reflect @work-item-id ${slice.workItemId} in product docs for each affected unit.`,
194
+ ));
195
+ }
196
+
197
+ if (slice.implementationWorkItemIds.includes(slice.workItemId) && !slice.testWorkItemIds.includes(slice.workItemId)) {
198
+ findings.push(this.finding(
199
+ 'traceability-test-mismatch',
200
+ slice.workItemId,
201
+ slice.workItemId,
202
+ `${slice.workItemId} has implementation evidence but no matching test observation.`,
203
+ `Add tests annotated with @work-item-id ${slice.workItemId}.`,
204
+ ));
205
+ }
206
+
207
+ if (slice.publicDocsChanged !== slice.contractChanged) {
208
+ findings.push(this.finding(
209
+ 'public-doc-contract-sync',
210
+ slice.workItemId,
211
+ slice.workItemId,
212
+ `${slice.workItemId} public docs and public contract changes are not synchronized.`,
213
+ 'Update public docs and contract declarations in the same WI, or document why only one changed.',
214
+ ));
215
+ }
216
+ }
217
+ return findings;
218
+ }
219
+
220
+ private isBoundaryCovered(
221
+ contract: PublicContract,
222
+ boundaryCase: BoundaryCaseKind,
223
+ observations: readonly TestObservation[],
224
+ ): boolean {
225
+ return this.isCovered(`${contract.id}:boundary:${boundaryCase}`, observations)
226
+ || this.isCovered(`${contract.id}:${boundaryCase}`, observations);
227
+ }
228
+
229
+ private isCovered(requiredKey: string, observations: readonly TestObservation[]): boolean {
230
+ return observations.some((observation) => observation.covers.includes(requiredKey));
231
+ }
232
+
233
+ private hasUsefulText(value: unknown): boolean {
234
+ return typeof value === 'string' && value.trim().length > 0;
235
+ }
236
+
237
+ private isGenericSuggestion(value: unknown): boolean {
238
+ if (!this.hasUsefulText(value)) return true;
239
+ const normalized = String(value).trim().toLowerCase();
240
+ return ['fix it', 'check the error', 'see logs', 'unknown'].includes(normalized);
241
+ }
242
+
243
+ private finding(
244
+ kind: ContractTraceabilityFinding['kind'],
245
+ subject: string,
246
+ sourcePath: string,
247
+ message: string,
248
+ suggestion: string,
249
+ ): ContractTraceabilityFinding {
250
+ return {
251
+ kind,
252
+ severity: 'error',
253
+ subject,
254
+ sourcePath,
255
+ message,
256
+ suggestion,
257
+ };
258
+ }
259
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @layer domain
3
3
  * @unit validator-system
4
+ * @work-item-id WI-118
4
5
  *
5
6
  * ConsistencyCheckService ドメインサービス
6
7
  * 設計文書間のレイヤー整合性検証(L4-002)
@@ -35,26 +36,24 @@ export class ConsistencyCheckService {
35
36
  const mismatchPairs: { expected: string; actual: string; location: string }[] = [];
36
37
  const checkTargets = Object.keys(layerAnnotations);
37
38
 
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
- }
39
+ for (const [location, annotation] of Object.entries(layerAnnotations)) {
40
+ if (annotation === 'layer:unknown') {
41
+ mismatchPairs.push({
42
+ expected: 'known layer vocabulary',
43
+ actual: 'unknown layer vocabulary',
44
+ location,
45
+ });
46
+ }
47
+
48
+ if (annotation.startsWith('unit:mismatch:')) {
49
+ mismatchPairs.push({
50
+ expected: annotation.slice('unit:mismatch:'.length),
51
+ actual: location.includes('#unit:') ? location.slice(location.indexOf('#unit:') + '#unit:'.length) : 'unknown',
52
+ location,
53
+ });
50
54
  }
51
55
  }
52
56
 
53
- // ADR実在性確認(ポートが存在する場合)
54
- // adrReferencePort.exists を使って参照 ADR の実在を確認する
55
- // DesignDocumentPort の layerAnnotations には ADR 参照が含まれないため、
56
- // ADR 参照は別途取得する(ここではシンプル実装)
57
- // ADR not found => mismatch として扱う
58
57
  const knownAdrRefs = checkTargets
59
58
  .filter((t) => t.startsWith('ADR-'))
60
59
  .map((t) => t);
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @layer domain
3
3
  * @unit validator-system
4
+ * @work-item-id WI-117
4
5
  *
5
6
  * DriftDetectionService ドメインサービス
6
7
  * 設計文書(domain_model.md等)とソースコード実装の双方向乖離検出(L4-001)
@@ -9,6 +10,7 @@ import { DriftReport } from '../../value-objects/drift-report.js';
9
10
 
10
11
  export interface DriftDetectionDesignDocumentPort {
11
12
  getElements(targetUnits?: readonly string[]): Promise<string[]>;
13
+ getElementRecords?(targetUnits?: readonly string[]): Promise<readonly DriftElementRecord[]>;
12
14
  /**
13
15
  * WI-095: 設計要素 → 実装ファイル path の明示対応。
14
16
  * 実装されていれば element 名完全一致に加えて drift 判定に使う。
@@ -23,6 +25,7 @@ export interface DriftDetectionDesignDocumentPort {
23
25
 
24
26
  export interface DriftDetectionSourceCodeAnalyzerPort {
25
27
  getElements(targetUnits?: readonly string[]): Promise<string[]>;
28
+ getElementRecords?(targetUnits?: readonly string[]): Promise<readonly DriftElementRecord[]>;
26
29
  /**
27
30
  * WI-095: export element → 定義ファイル path のマップ。
28
31
  * 実装されていれば design pointer と照合する。
@@ -40,6 +43,13 @@ export interface DriftDetectionServiceDeps {
40
43
  sourceCodeAnalyzerPort: DriftDetectionSourceCodeAnalyzerPort;
41
44
  }
42
45
 
46
+ export interface DriftElementRecord {
47
+ readonly element: string;
48
+ readonly unitName: string;
49
+ readonly filePaths?: readonly string[];
50
+ readonly pointers?: readonly string[];
51
+ }
52
+
43
53
  export class DriftDetectionService {
44
54
  private readonly designDocumentPort: DriftDetectionDesignDocumentPort;
45
55
  private readonly sourceCodeAnalyzerPort: DriftDetectionSourceCodeAnalyzerPort;
@@ -50,87 +60,122 @@ export class DriftDetectionService {
50
60
  }
51
61
 
52
62
  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
- : {};
63
+ const designRecords = await this.loadDesignRecords(targetUnits);
64
+ const codeRecords = await this.loadCodeRecords(targetUnits);
61
65
 
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
- : {};
66
+ const designKeys = new Set(designRecords.map(toDriftKey));
67
+ const codeKeys = new Set(codeRecords.map(toDriftKey));
68
+ const pointerMatchedDesignKeys = new Set<string>();
69
+ const pointerMatchedCodeKeys = new Set<string>();
70
+
71
+ for (const designRecord of designRecords) {
72
+ const pointers = designRecord.pointers ?? [];
73
+ if (pointers.length === 0) continue;
69
74
 
70
- const resolveUnit = (element: string): string => {
71
- return (
72
- designUnitMap[element] ??
73
- codeUnitMap[element] ??
74
- targetUnits?.[0] ??
75
- 'unknown'
75
+ const matchingCodeRecords = codeRecords.filter((codeRecord) =>
76
+ codeRecord.unitName === designRecord.unitName &&
77
+ (codeRecord.filePaths ?? []).some((filePath) => pointers.some((pointer) => isSameOrNestedPath(filePath, pointer)))
76
78
  );
77
- };
78
79
 
79
- const designSet = new Set(designElements);
80
- const codeSet = new Set(codeElements);
81
- const pointerMatchedDesignElements = new Set<string>();
82
- const pointerMatchedCodeElements = new Set<string>();
80
+ if (matchingCodeRecords.length === 0) continue;
83
81
 
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);
82
+ pointerMatchedDesignKeys.add(toDriftKey(designRecord));
83
+
84
+ // WI-117: pointer は同一ファイル内の全 export を blanket match しない。
85
+ // 明示 pointer が名前変更の橋渡しとして使えるのは、当該ファイルの public export が 1 つだけの場合に限定する。
86
+ if (matchingCodeRecords.length === 1) {
87
+ pointerMatchedCodeKeys.add(toDriftKey(matchingCodeRecords[0]));
88
+ } else {
89
+ for (const codeRecord of matchingCodeRecords) {
90
+ if (codeRecord.element === designRecord.element) {
91
+ pointerMatchedCodeKeys.add(toDriftKey(codeRecord));
92
+ }
94
93
  }
95
94
  }
96
95
  }
97
96
 
98
97
  const reports: DriftReport[] = [];
99
98
 
100
- // 設計に存在するがコードに存在しない
101
- for (const element of designElements) {
102
- if (!codeSet.has(element) && !pointerMatchedDesignElements.has(element)) {
99
+ for (const designRecord of designRecords) {
100
+ const key = toDriftKey(designRecord);
101
+ if (!codeKeys.has(key) && !pointerMatchedDesignKeys.has(key)) {
103
102
  reports.push(
104
103
  DriftReport.create({
105
104
  direction: 'design→code',
106
- unitName: resolveUnit(element),
107
- element,
108
- description: `設計に存在するがコードに存在しない: ${element}`,
109
- recommendation: `${element} をコードに実装してください`,
105
+ unitName: designRecord.unitName,
106
+ element: designRecord.element,
107
+ description: `設計に存在するがコードに存在しない: ${designRecord.element}`,
108
+ recommendation: `${designRecord.element} をコードに実装してください`,
110
109
  })
111
110
  );
112
111
  }
113
112
  }
114
113
 
115
- // コードに存在するが設計に存在しない
116
- for (const element of codeElements) {
117
- if (!designSet.has(element) && !pointerMatchedCodeElements.has(element)) {
114
+ for (const codeRecord of codeRecords) {
115
+ const key = toDriftKey(codeRecord);
116
+ if (!designKeys.has(key) && !pointerMatchedCodeKeys.has(key)) {
118
117
  reports.push(
119
118
  DriftReport.create({
120
119
  direction: 'code→design',
121
- unitName: resolveUnit(element),
122
- element,
123
- description: `コードに存在するが設計に存在しない: ${element}`,
124
- recommendation: `${element} を設計文書に追記するか、コードから削除してください`,
120
+ unitName: codeRecord.unitName,
121
+ element: codeRecord.element,
122
+ description: `コードに存在するが設計に存在しない: ${codeRecord.element}`,
123
+ recommendation: `${codeRecord.element} を設計文書に追記するか、コードから削除してください`,
125
124
  })
126
125
  );
127
126
  }
128
127
  }
129
128
 
130
129
  return reports.sort((a, b) =>
131
- a.direction.localeCompare(b.direction) || a.unitName.localeCompare(b.unitName)
130
+ a.direction.localeCompare(b.direction) ||
131
+ a.unitName.localeCompare(b.unitName) ||
132
+ a.element.localeCompare(b.element)
132
133
  );
133
134
  }
135
+
136
+ private async loadDesignRecords(targetUnits?: readonly string[]): Promise<readonly DriftElementRecord[]> {
137
+ if (this.designDocumentPort.getElementRecords) {
138
+ return this.designDocumentPort.getElementRecords(targetUnits);
139
+ }
140
+
141
+ const designElements = await this.designDocumentPort.getElements(targetUnits);
142
+ const designPointers = this.designDocumentPort.getElementPointers
143
+ ? await this.designDocumentPort.getElementPointers(targetUnits)
144
+ : {};
145
+ const designUnitMap = this.designDocumentPort.getElementUnitMap
146
+ ? await this.designDocumentPort.getElementUnitMap(targetUnits)
147
+ : {};
148
+
149
+ return designElements.map((element) => ({
150
+ element,
151
+ unitName: designUnitMap[element] ?? targetUnits?.[0] ?? 'unknown',
152
+ pointers: designPointers[element] ?? [],
153
+ }));
154
+ }
155
+
156
+ private async loadCodeRecords(targetUnits?: readonly string[]): Promise<readonly DriftElementRecord[]> {
157
+ if (this.sourceCodeAnalyzerPort.getElementRecords) {
158
+ return this.sourceCodeAnalyzerPort.getElementRecords(targetUnits);
159
+ }
160
+
161
+ const codeElements = await this.sourceCodeAnalyzerPort.getElements(targetUnits);
162
+ const codeFilePathMap = this.sourceCodeAnalyzerPort.getElementFilePathMap
163
+ ? await this.sourceCodeAnalyzerPort.getElementFilePathMap(targetUnits)
164
+ : {};
165
+ const codeUnitMap = this.sourceCodeAnalyzerPort.getElementUnitMap
166
+ ? await this.sourceCodeAnalyzerPort.getElementUnitMap(targetUnits)
167
+ : {};
168
+
169
+ return codeElements.map((element) => ({
170
+ element,
171
+ unitName: codeUnitMap[element] ?? targetUnits?.[0] ?? 'unknown',
172
+ filePaths: codeFilePathMap[element] ?? [],
173
+ }));
174
+ }
175
+ }
176
+
177
+ function toDriftKey(record: DriftElementRecord): string {
178
+ return `${record.unitName}\0${record.element}`;
134
179
  }
135
180
 
136
181
  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
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @layer domain
3
3
  * @unit validator-system
4
+ * @work-item-id WI-118
4
5
  *
5
6
  * ConsistencyReport 値オブジェクト
6
7
  * 設計文書間のレイヤー整合性検証結果VO(L4-002専用)
@@ -11,6 +12,7 @@ export interface MismatchPair {
11
12
  readonly expected: string;
12
13
  readonly actual: string;
13
14
  readonly location: string;
15
+ readonly nextAction?: string;
14
16
  }
15
17
 
16
18
  export interface ConsistencyReportProps {
@@ -49,7 +51,7 @@ export class ConsistencyReport {
49
51
  code: { value: 'L4-002', toString: () => 'L4-002' },
50
52
  severity: { value: 'warning', toString: () => 'warning' },
51
53
  message: `レイヤー整合性違反: expected "${pair.expected}" but got "${pair.actual}" at ${pair.location}`,
52
- suggestion: '設計文書間のレイヤー依存方向を統一してください',
54
+ suggestion: pair.nextAction ?? '設計文書間のレイヤー依存方向を統一してください',
53
55
  }));
54
56
  }
55
57
  }