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.
- package/CHANGELOG.md +6 -0
- package/docs/guide/layer-model.md +4 -0
- package/package.json +1 -1
- 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/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
|
@@ -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 {
|
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';
|
|
@@ -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);
|
package/scripts/harness/validator-system/infrastructure/adapters/markdown-design-document-adapter.ts
CHANGED
|
@@ -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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
+
}
|