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,124 @@
1
+ // @unit validator-system
2
+ // @layer domain
3
+ // @work-item-id WI-132 / WI-133 / WI-136 / WI-137 / WI-138
4
+
5
+ export type PublicContractKind =
6
+ | 'cli-command'
7
+ | 'api-endpoint'
8
+ | 'port'
9
+ | 'config-option'
10
+ | 'domain-behavior'
11
+ | 'error-code';
12
+
13
+ export type BoundaryCaseKind =
14
+ | 'empty-input'
15
+ | 'missing-required'
16
+ | 'invalid-enum'
17
+ | 'duplicate-id'
18
+ | 'unknown-reference'
19
+ | 'permission-denied'
20
+ | 'config-disabled'
21
+ | 'partial-failure'
22
+ | 'idempotency'
23
+ | 'backward-compatibility';
24
+
25
+ export interface PublicContract {
26
+ readonly id: string;
27
+ readonly kind: PublicContractKind;
28
+ readonly sourcePath: string;
29
+ readonly requiredBehaviors: readonly string[];
30
+ readonly boundaryCases?: readonly BoundaryCaseKind[];
31
+ }
32
+
33
+ export interface TestObservation {
34
+ readonly id: string;
35
+ readonly kind: 'unit' | 'integration' | 'e2e' | 'adapter-contract';
36
+ readonly sourcePath: string;
37
+ readonly covers: readonly string[];
38
+ }
39
+
40
+ export interface ErrorContract {
41
+ readonly id: string;
42
+ readonly sourcePath: string;
43
+ readonly code?: string;
44
+ readonly severity?: 'error' | 'warning';
45
+ readonly message?: string;
46
+ readonly suggestion?: string;
47
+ readonly documentationRef?: string;
48
+ readonly exitCode?: number;
49
+ readonly machineFields?: readonly string[];
50
+ }
51
+
52
+ export interface StateTransition {
53
+ readonly from: string;
54
+ readonly to: string;
55
+ readonly guard?: string;
56
+ }
57
+
58
+ export interface StateMachineModel {
59
+ readonly id: string;
60
+ readonly sourcePath: string;
61
+ readonly docsStates: readonly string[];
62
+ readonly codeStates: readonly string[];
63
+ readonly transitions: readonly StateTransition[];
64
+ readonly terminalStates: readonly string[];
65
+ readonly invalidTransitions: readonly StateTransition[];
66
+ }
67
+
68
+ export interface TraceabilityGraphSlice {
69
+ readonly workItemId: string;
70
+ readonly affectedUnits: readonly string[];
71
+ readonly productUnits: readonly string[];
72
+ readonly implementationWorkItemIds: readonly string[];
73
+ readonly testWorkItemIds: readonly string[];
74
+ readonly publicDocsChanged: boolean;
75
+ readonly contractChanged: boolean;
76
+ }
77
+
78
+ export interface ContractTraceabilityInput {
79
+ readonly publicContracts: readonly PublicContract[];
80
+ readonly testObservations: readonly TestObservation[];
81
+ readonly errorContracts: readonly ErrorContract[];
82
+ readonly stateMachines: readonly StateMachineModel[];
83
+ readonly traceabilitySlices: readonly TraceabilityGraphSlice[];
84
+ }
85
+
86
+ export type ContractTraceabilityFindingKind =
87
+ | 'missing-required-behavior-test'
88
+ | 'missing-port-contract-test'
89
+ | 'missing-boundary-test'
90
+ | 'error-contract-shape'
91
+ | 'error-contract-exit-code'
92
+ | 'missing-error-path-test'
93
+ | 'state-doc-code-mismatch'
94
+ | 'state-invalid-terminal-transition'
95
+ | 'missing-transition-test'
96
+ | 'traceability-unit-mismatch'
97
+ | 'traceability-test-mismatch'
98
+ | 'public-doc-contract-sync';
99
+
100
+ export interface ContractTraceabilityFinding {
101
+ readonly kind: ContractTraceabilityFindingKind;
102
+ readonly severity: 'error' | 'warning';
103
+ readonly subject: string;
104
+ readonly sourcePath: string;
105
+ readonly message: string;
106
+ readonly suggestion: string;
107
+ }
108
+
109
+ export class ContractTraceabilityReport {
110
+ readonly findings: readonly ContractTraceabilityFinding[];
111
+
112
+ private constructor(findings: readonly ContractTraceabilityFinding[]) {
113
+ this.findings = Object.freeze([...findings]);
114
+ Object.freeze(this);
115
+ }
116
+
117
+ static create(findings: readonly ContractTraceabilityFinding[]): ContractTraceabilityReport {
118
+ return new ContractTraceabilityReport(findings);
119
+ }
120
+
121
+ hasFindings(): boolean {
122
+ return this.findings.length > 0;
123
+ }
124
+ }
@@ -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
+ }
@@ -7,6 +7,7 @@
7
7
  * L1-001〜L4-005 のバリデータを識別する不変値オブジェクト
8
8
  * Wave 2A で L1-017, L1-018, L2-013 を追加
9
9
  * WI-140 で L2-014 を追加
10
+ * WI-132/WI-133/WI-136/WI-137/WI-138 で L2-015 を追加
10
11
  */
11
12
 
12
13
  export class InvalidValidatorIdError extends Error {
@@ -29,6 +30,7 @@ const VALIDATOR_NAME_MAP: Record<string, string> = {
29
30
  'L2-003': 'test-quality',
30
31
  'L2-013': 'cli-e2e-test-existence',
31
32
  'L2-014': 'work-item-status-staleness',
33
+ 'L2-015': 'contract-traceability-coverage',
32
34
  'L3-001': 'security',
33
35
  'L3-002': 'performance',
34
36
  'L3-003': 'coverage',
@@ -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';
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * @layer infrastructure
3
3
  * @unit validator-system
4
+ * @work-item-id WI-117
4
5
  *
5
6
  * BiomeAstSourceCodeAnalyzerAdapter — SourceCodeAnalyzerPort実装
6
7
  * TypeScript Compiler API を使用してエクスポートを正確に抽出する(L4-001, L4-003)
7
8
  */
8
9
  import * as ts from 'typescript';
9
10
  import type { SourceCodeAnalyzerPort, SourceAnalysisResult } from '../../domain/ports/source-code-analyzer-port.js';
11
+ import type { DriftElementRecord } from '../../domain/services/l4/drift-detection-service.js';
10
12
  import { readdir } from 'node:fs/promises';
11
13
  import { basename, join, relative, sep } from 'node:path';
12
14
 
@@ -44,7 +46,7 @@ export class BiomeAstSourceCodeAnalyzerAdapter implements SourceCodeAnalyzerPort
44
46
  const sourceFile = program.getSourceFile(filePath);
45
47
  if (!sourceFile) continue;
46
48
  results.push({
47
- unitName: resolveUnitName(this.sourceRoot, filePath),
49
+ unitName: resolveUnitName(this.sourceRoot, filePath, sourceFile.text),
48
50
  filePath,
49
51
  exports: extractExports(sourceFile),
50
52
  imports: extractImports(sourceFile),
@@ -86,6 +88,17 @@ export class BiomeAstSourceCodeAnalyzerAdapter implements SourceCodeAnalyzerPort
86
88
  }
87
89
  return map;
88
90
  }
91
+
92
+ async getElementRecords(targetUnits?: readonly string[]): Promise<readonly DriftElementRecord[]> {
93
+ const results = await this.analyzeExports(targetUnits);
94
+ return results.flatMap((result) =>
95
+ result.exports.map((entry) => ({
96
+ element: entry.name,
97
+ unitName: result.unitName,
98
+ filePaths: [result.filePath],
99
+ }))
100
+ );
101
+ }
89
102
  }
90
103
 
91
104
  type ExportType = SourceAnalysisResult['exports'][number]['type'];
@@ -112,6 +125,19 @@ function extractExports(sourceFile: ts.SourceFile): SourceAnalysisResult['export
112
125
  exports.push({ name: decl.name.text, type: 'const' });
113
126
  }
114
127
  }
128
+ } else if (ts.isExportDeclaration(node) && node.exportClause) {
129
+ if (ts.isNamedExports(node.exportClause)) {
130
+ for (const element of node.exportClause.elements) {
131
+ exports.push({ name: element.name.text, type: 'type' });
132
+ }
133
+ }
134
+ } else if (ts.isExportDeclaration(node) && !node.exportClause && ts.isStringLiteral(node.moduleSpecifier)) {
135
+ exports.push({ name: `* from ${node.moduleSpecifier.text}`, type: 'type' });
136
+ }
137
+
138
+ const hasDefault = modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
139
+ if (hasDefault) {
140
+ exports.push({ name: 'default', type: 'type' });
115
141
  }
116
142
  });
117
143
 
@@ -182,7 +208,10 @@ async function walkTsFiles(root: string, excludePattern: RegExp): Promise<string
182
208
  }
183
209
  }
184
210
 
185
- function resolveUnitName(sourceRoot: string, filePath: string): string {
211
+ function resolveUnitName(sourceRoot: string, filePath: string, sourceText: string): string {
212
+ const unitMatch = /@unit\s+([a-z0-9-]+)/i.exec(sourceText);
213
+ if (unitMatch) return unitMatch[1];
214
+
186
215
  const relativePath = relative(sourceRoot, filePath);
187
216
  const [firstSegment] = relativePath.split(sep);
188
217
  return firstSegment || basename(filePath);
@@ -0,0 +1,115 @@
1
+ // @unit validator-system
2
+ // @layer infrastructure
3
+ // @work-item-id WI-132 / WI-133 / WI-136 / WI-137 / WI-138
4
+
5
+ import { readFile } from 'node:fs/promises';
6
+ import type { ContractTraceabilityPolicyPort } from '../../domain/ports/contract-traceability-policy-port.js';
7
+ import type {
8
+ BoundaryCaseKind,
9
+ ContractTraceabilityInput,
10
+ PublicContract,
11
+ PublicContractKind,
12
+ TestObservation,
13
+ } from '../../domain/value-objects/contract-traceability-model.js';
14
+
15
+ const CONTRACT_KINDS = new Set<PublicContractKind>([
16
+ 'cli-command',
17
+ 'api-endpoint',
18
+ 'port',
19
+ 'config-option',
20
+ 'domain-behavior',
21
+ 'error-code',
22
+ ]);
23
+
24
+ const BOUNDARY_KINDS = new Set<BoundaryCaseKind>([
25
+ 'empty-input',
26
+ 'missing-required',
27
+ 'invalid-enum',
28
+ 'duplicate-id',
29
+ 'unknown-reference',
30
+ 'permission-denied',
31
+ 'config-disabled',
32
+ 'partial-failure',
33
+ 'idempotency',
34
+ 'backward-compatibility',
35
+ ]);
36
+
37
+ export class FileSystemContractTraceabilityPolicyAdapter implements ContractTraceabilityPolicyPort {
38
+ async collect(targetPaths: readonly string[]): Promise<ContractTraceabilityInput> {
39
+ const publicContracts: PublicContract[] = [];
40
+ const testObservations: TestObservation[] = [];
41
+
42
+ for (const targetPath of targetPaths) {
43
+ let content: string;
44
+ try {
45
+ content = await readFile(targetPath, 'utf-8');
46
+ } catch {
47
+ continue;
48
+ }
49
+
50
+ publicContracts.push(...this.extractContracts(targetPath, content));
51
+ testObservations.push(...this.extractObservations(targetPath, content));
52
+ }
53
+
54
+ return {
55
+ publicContracts,
56
+ testObservations,
57
+ errorContracts: [],
58
+ stateMachines: [],
59
+ traceabilitySlices: [],
60
+ };
61
+ }
62
+
63
+ private extractContracts(sourcePath: string, content: string): readonly PublicContract[] {
64
+ return [...content.matchAll(/@phasegate-contract\s+([^\n\r*]+)/g)].flatMap((match) => {
65
+ const attrs = this.parseAttrs(match[1]);
66
+ const id = attrs.get('id');
67
+ const kind = attrs.get('kind');
68
+ if (!id || !this.isContractKind(kind)) return [];
69
+
70
+ return [{
71
+ id,
72
+ kind,
73
+ sourcePath,
74
+ requiredBehaviors: this.splitList(attrs.get('behaviors')),
75
+ boundaryCases: this.splitList(attrs.get('boundary')).filter(this.isBoundaryKind),
76
+ }];
77
+ });
78
+ }
79
+
80
+ private extractObservations(sourcePath: string, content: string): readonly TestObservation[] {
81
+ return [...content.matchAll(/@phasegate-observation\s+([^\n\r*]+)/g)].flatMap((match, index) => {
82
+ const attrs = this.parseAttrs(match[1]);
83
+ const covers = this.splitList(attrs.get('covers'));
84
+ if (covers.length === 0) return [];
85
+ const kind = attrs.get('kind');
86
+ return [{
87
+ id: attrs.get('id') ?? `${sourcePath}#observation-${index + 1}`,
88
+ kind: kind === 'adapter-contract' || kind === 'integration' || kind === 'e2e' ? kind : 'unit',
89
+ sourcePath,
90
+ covers,
91
+ }];
92
+ });
93
+ }
94
+
95
+ private parseAttrs(raw: string): Map<string, string> {
96
+ const attrs = new Map<string, string>();
97
+ for (const match of raw.matchAll(/([a-zA-Z][a-zA-Z0-9_-]*)=("[^"]*"|'[^']*'|[^\s]+)/g)) {
98
+ attrs.set(match[1], match[2].replace(/^['"]|['"]$/g, ''));
99
+ }
100
+ return attrs;
101
+ }
102
+
103
+ private splitList(raw: string | undefined): string[] {
104
+ if (!raw) return [];
105
+ return raw.split(',').map((value) => value.trim()).filter((value) => value.length > 0);
106
+ }
107
+
108
+ private isContractKind(value: string | undefined): value is PublicContractKind {
109
+ return value !== undefined && CONTRACT_KINDS.has(value as PublicContractKind);
110
+ }
111
+
112
+ private isBoundaryKind(value: string): value is BoundaryCaseKind {
113
+ return BOUNDARY_KINDS.has(value as BoundaryCaseKind);
114
+ }
115
+ }
@@ -38,7 +38,7 @@ export class HarnessConfigValidatorConfigAdapter implements ValidatorConfigPort
38
38
  const layerData = this.config.layers?.[layer] ?? {};
39
39
 
40
40
  const defaultValidators: Record<string, string[]> = {
41
- L2: ['L2-001', 'L2-002', 'L2-003', 'L2-013', 'L2-014'],
41
+ L2: ['L2-001', 'L2-002', 'L2-003', 'L2-013', 'L2-014', 'L2-015'],
42
42
  L3: ['L3-001', 'L3-002', 'L3-003', 'L3-004'],
43
43
  L4: ['L4-001', 'L4-002', 'L4-003', 'L4-004', 'L4-005'],
44
44
  };
@@ -1,14 +1,25 @@
1
1
  /**
2
2
  * @layer infrastructure
3
3
  * @unit validator-system
4
+ * @work-item-id WI-117, WI-118
4
5
  *
5
6
  * MarkdownDesignDocumentAdapter — DesignDocumentPort実装
6
7
  */
7
8
  import type { DesignDocumentPort, StructuredDesignDoc } from '../../domain/ports/design-document-port.js';
9
+ import type { DriftElementRecord } from '../../domain/services/l4/drift-detection-service.js';
8
10
  import { readFile, readdir } from 'node:fs/promises';
9
11
  import { join } from 'node:path';
10
12
 
11
13
  const ADR_PATTERN = /ADR-\d{3}/g;
14
+ const WORK_ITEM_PATTERN = /@work-item-id\s+(WI-\d{3})/g;
15
+ const LAYER_PATTERN = /@layer\s+([a-z0-9-]+)/gi;
16
+ const UNIT_PATTERN = /@unit\s+([a-z0-9-]+)/gi;
17
+ const CONSTRUCTION_DOC_NAMES = [
18
+ 'domain_model.md',
19
+ 'logical_design.md',
20
+ 'unit_test_design.md',
21
+ 'it_test_design.md',
22
+ ];
12
23
 
13
24
  // ISSUE-005 P3-8: メタ見出し / 議論用セクションを drift 対象から除外するマーカー。
14
25
  // 見出し行の直後 (同一行末 or 次の非空行) に置かれたコメントを拾う。
@@ -111,26 +122,28 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
111
122
  const results: StructuredDesignDoc[] = [];
112
123
 
113
124
  for (const unitName of unitNames) {
114
- const docPath = join(this.docsRoot, unitName, 'domain_model.md');
115
- const cached = this.cache.get(docPath);
116
- if (cached) {
117
- results.push(cached);
118
- continue;
119
- }
125
+ for (const docName of CONSTRUCTION_DOC_NAMES) {
126
+ const docPath = join(this.docsRoot, unitName, docName);
127
+ const cached = this.cache.get(docPath);
128
+ if (cached) {
129
+ results.push(cached);
130
+ continue;
131
+ }
120
132
 
121
- try {
122
- const markdown = await readFile(docPath, 'utf8');
123
- const doc: StructuredDesignDoc = {
124
- unitName,
125
- docPath,
126
- concepts: extractConcepts(markdown).map((concept) => ({ ...concept, type: 'class' })),
127
- layerDependencies: [],
128
- adrRefs: Array.from(new Set(markdown.match(ADR_PATTERN) ?? [])),
129
- };
130
- this.cache.set(docPath, doc);
131
- results.push(doc);
132
- } catch {
133
- continue;
133
+ try {
134
+ const markdown = await readFile(docPath, 'utf8');
135
+ const doc: StructuredDesignDoc = {
136
+ unitName,
137
+ docPath,
138
+ concepts: extractConcepts(markdown).map((concept) => ({ ...concept, type: 'class' })),
139
+ layerDependencies: extractLayerDependencies(markdown),
140
+ adrRefs: Array.from(new Set(markdown.match(ADR_PATTERN) ?? [])),
141
+ };
142
+ this.cache.set(docPath, doc);
143
+ results.push(doc);
144
+ } catch {
145
+ continue;
146
+ }
134
147
  }
135
148
  }
136
149
 
@@ -138,7 +151,32 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
138
151
  }
139
152
 
140
153
  async getLayerAnnotations(targetDocs?: readonly string[]): Promise<Record<string, string>> {
141
- return {};
154
+ const docs = targetDocs && targetDocs.length > 0
155
+ ? await this.loadExplicitDocs(targetDocs)
156
+ : await this.loadDesignDocuments();
157
+ const annotations: Record<string, string> = {};
158
+
159
+ for (const doc of docs) {
160
+ const markdown = await readFile(doc.docPath, 'utf8');
161
+ const layers = Array.from(new Set(Array.from(markdown.matchAll(LAYER_PATTERN)).map((match) => match[1])));
162
+ const units = Array.from(new Set(Array.from(markdown.matchAll(UNIT_PATTERN)).map((match) => match[1])));
163
+ const workItems = Array.from(new Set(Array.from(markdown.matchAll(WORK_ITEM_PATTERN)).map((match) => match[1])));
164
+
165
+ for (const layer of layers) {
166
+ annotations[`${doc.docPath}#layer:${layer}`] = isKnownLayer(layer) ? 'layer:known' : 'layer:unknown';
167
+ }
168
+ for (const unit of units) {
169
+ annotations[`${doc.docPath}#unit:${unit}`] = unit === doc.unitName ? 'unit:matched' : `unit:mismatch:${doc.unitName}`;
170
+ }
171
+ for (const adrRef of doc.adrRefs) {
172
+ annotations[adrRef] = 'adr:referenced';
173
+ }
174
+ for (const workItemId of workItems) {
175
+ annotations[`${doc.docPath}#work-item:${workItemId}`] = 'work-item:referenced';
176
+ }
177
+ }
178
+
179
+ return annotations;
142
180
  }
143
181
 
144
182
  async getElements(targetUnits?: readonly string[]): Promise<string[]> {
@@ -146,6 +184,17 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
146
184
  return docs.flatMap((doc) => doc.concepts.map((concept) => concept.name));
147
185
  }
148
186
 
187
+ async getElementRecords(targetUnits?: readonly string[]): Promise<readonly DriftElementRecord[]> {
188
+ const docs = await this.loadDesignDocuments(targetUnits);
189
+ return docs.flatMap((doc) =>
190
+ doc.concepts.map((concept) => ({
191
+ element: concept.name,
192
+ unitName: doc.unitName,
193
+ pointers: concept.pointers ?? [],
194
+ }))
195
+ );
196
+ }
197
+
149
198
  async getElementPointers(targetUnits?: readonly string[]): Promise<Record<string, readonly string[]>> {
150
199
  const docs = await this.loadDesignDocuments(targetUnits);
151
200
  const map: Record<string, readonly string[]> = {};
@@ -176,6 +225,26 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
176
225
  return map;
177
226
  }
178
227
 
228
+ private async loadExplicitDocs(targetDocs: readonly string[]): Promise<readonly StructuredDesignDoc[]> {
229
+ const docs: StructuredDesignDoc[] = [];
230
+ for (const docPath of targetDocs) {
231
+ try {
232
+ const markdown = await readFile(docPath, 'utf8');
233
+ const unitName = inferUnitNameFromDocPath(this.docsRoot, docPath);
234
+ docs.push({
235
+ unitName,
236
+ docPath,
237
+ concepts: extractConcepts(markdown).map((concept) => ({ ...concept, type: 'class' })),
238
+ layerDependencies: extractLayerDependencies(markdown),
239
+ adrRefs: Array.from(new Set(markdown.match(ADR_PATTERN) ?? [])),
240
+ });
241
+ } catch {
242
+ continue;
243
+ }
244
+ }
245
+ return docs;
246
+ }
247
+
179
248
  private async listUnitNames(): Promise<string[]> {
180
249
  try {
181
250
  const entries = await readdir(this.docsRoot, { withFileTypes: true });
@@ -185,3 +254,25 @@ export class MarkdownDesignDocumentAdapter implements DesignDocumentPort {
185
254
  }
186
255
  }
187
256
  }
257
+
258
+ function extractLayerDependencies(markdown: string): Array<{ from: string; to: string }> {
259
+ const dependencies: Array<{ from: string; to: string }> = [];
260
+ const dependencyPattern = /([a-z0-9-]+)\s*(?:->|→)\s*([a-z0-9-]+)/gi;
261
+ for (const match of markdown.matchAll(dependencyPattern)) {
262
+ dependencies.push({ from: match[1], to: match[2] });
263
+ }
264
+ return dependencies;
265
+ }
266
+
267
+ function isKnownLayer(layer: string): boolean {
268
+ return ['domain', 'application', 'infrastructure', 'presentation', 'test'].includes(layer);
269
+ }
270
+
271
+ function inferUnitNameFromDocPath(docsRoot: string, docPath: string): string {
272
+ const normalizedRoot = docsRoot.replace(/\\/g, '/').replace(/\/+$/, '');
273
+ const normalizedPath = docPath.replace(/\\/g, '/');
274
+ const relativePath = normalizedPath.startsWith(`${normalizedRoot}/`)
275
+ ? normalizedPath.slice(normalizedRoot.length + 1)
276
+ : normalizedPath;
277
+ return relativePath.split('/')[0] || 'unknown';
278
+ }