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.
- package/CHANGELOG.md +13 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/docs/guide/cli-reference.md +13 -0
- package/docs/guide/layer-model.md +4 -0
- package/package.json +1 -1
- package/scripts/harness/main.ts +16 -0
- package/scripts/harness/nyquist-validation/application/dto/generate-matrix-output.ts +71 -0
- package/scripts/harness/nyquist-validation/application/usecases/generate-requirement-test-matrix-usecase.ts +161 -0
- package/scripts/harness/nyquist-validation/composition-root.ts +24 -1
- package/scripts/harness/nyquist-validation/domain/services/requirement-intent-coverage-service.ts +42 -0
- package/scripts/harness/nyquist-validation/index.ts +9 -1
- package/scripts/harness/nyquist-validation/infrastructure/adapters/file-system-generated-matrix-adapter.ts +24 -0
- package/scripts/harness/nyquist-validation/infrastructure/adapters/markdown-requirement-source-adapter.ts +37 -0
- package/scripts/harness/nyquist-validation/infrastructure/adapters/type-script-test-reference-source-adapter.ts +54 -0
- package/scripts/harness/nyquist-validation/presentation/handlers/generate-matrix-handler.ts +34 -0
- package/scripts/harness/phase2-extensions/application/dto/validate-doc-pointers-output.ts +4 -0
- package/scripts/harness/phase2-extensions/application/usecases/validate-doc-pointers-usecase.ts +23 -4
- package/scripts/harness/phase2-extensions/domain/aggregates/pointer-rule.ts +13 -0
- package/scripts/harness/phase2-extensions/domain/services/freshness-check-service.ts +10 -0
- package/scripts/harness/phase2-extensions/domain/value-objects/document-age.ts +2 -2
- package/scripts/harness/phase2-extensions/infrastructure/adapters/harness-config-freshness-adapter.ts +12 -0
- package/scripts/harness/phase2-extensions/presentation/formatters/pointer-result-formatter.ts +3 -1
- package/scripts/harness/validator-system/domain/services/l4/consistency-check-service.ts +15 -17
- package/scripts/harness/validator-system/domain/services/l4/drift-detection-service.ts +95 -51
- package/scripts/harness/validator-system/domain/services/l4/semantic-drift-service.ts +112 -0
- package/scripts/harness/validator-system/domain/value-objects/consistency-report.ts +2 -1
- package/scripts/harness/validator-system/domain/value-objects/semantic-drift-report.ts +50 -0
- package/scripts/harness/validator-system/index.ts +2 -0
- package/scripts/harness/validator-system/infrastructure/adapters/biome-ast-source-code-analyzer-adapter.ts +30 -2
- package/scripts/harness/validator-system/infrastructure/adapters/markdown-design-document-adapter.ts +110 -20
package/scripts/harness/phase2-extensions/application/usecases/validate-doc-pointers-usecase.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/scripts/harness/phase2-extensions/presentation/formatters/pointer-result-formatter.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
54
|
-
const
|
|
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
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
102
|
-
if (!
|
|
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:
|
|
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
|
-
|
|
117
|
-
if (!
|
|
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:
|
|
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) ||
|
|
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';
|