@behaghel/pi-domain-tree 0.4.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.
@@ -0,0 +1,1065 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ import { access, readFile } from "fs/promises";
4
+ import { dirname, extname, join, relative, resolve } from "path";
5
+ import { LineCounter, parseDocument } from "yaml";
6
+
7
+ export type DomainEntry = {
8
+ name: string;
9
+ description?: string;
10
+ type?: string;
11
+ status?: string;
12
+ code?: string[];
13
+ spec?: string;
14
+ subdomains?: Record<string, DomainEntry>;
15
+ };
16
+
17
+ export type DomainGroup = {
18
+ name: string;
19
+ kind: "group";
20
+ description?: string;
21
+ domains: Record<string, DomainTreeEntry>;
22
+ };
23
+
24
+ export type DomainTreeEntry = DomainEntry | DomainGroup;
25
+
26
+ export type ContextMapEntry = {
27
+ provider: string;
28
+ consumers: string[];
29
+ pattern: string;
30
+ contract?: string;
31
+ };
32
+
33
+ export type Domains = Record<string, DomainTreeEntry> & {
34
+ _contextMap?: string;
35
+ _contextEntries?: ContextMapEntry[];
36
+ _project?: { name?: string; description?: string };
37
+ _systemSpecs?: string[];
38
+ };
39
+
40
+ export type DomainNode = {
41
+ path: string[];
42
+ entry: DomainEntry;
43
+ type: string;
44
+ };
45
+
46
+ export type DomainResolution = {
47
+ domain: string;
48
+ subdomain: string | null;
49
+ type: string;
50
+ };
51
+
52
+ export type ManifestDiagnostic = {
53
+ path: string;
54
+ message: string;
55
+ line?: number;
56
+ column?: number;
57
+ };
58
+
59
+ export type DomainManifestResult = {
60
+ domains: Domains | null;
61
+ diagnostics: ManifestDiagnostic[];
62
+ };
63
+
64
+ export class DomainManifestError extends Error {
65
+ readonly diagnostics: ManifestDiagnostic[];
66
+
67
+ constructor(diagnostics: ManifestDiagnostic[]) {
68
+ super(diagnostics.map((diagnostic) =>
69
+ `${diagnostic.path}: ${diagnostic.message}`,
70
+ ).join("\n"));
71
+ this.name = "DomainManifestError";
72
+ this.diagnostics = diagnostics;
73
+ }
74
+ }
75
+
76
+ function normalizeDomainDir(pathValue: string): string {
77
+ return pathValue.replace(/\/+$|^\.\//g, "");
78
+ }
79
+
80
+ export function specLabelForEntry(entry: DomainEntry): string | null {
81
+ if (entry.spec) return normalizeDomainDir(entry.spec).replace(/\/+$/, "") + "/";
82
+ const codePaths = entry.code || [];
83
+ if (codePaths.length === 0) return null;
84
+ return `${normalizeDomainDir(codePaths[0])}/`;
85
+ }
86
+
87
+ export function specDirForEntry(root: string, entry: DomainEntry): string | null {
88
+ const label = specLabelForEntry(entry);
89
+ return label ? join(root, label) : null;
90
+ }
91
+
92
+ function pathMatches(relPath: string, manifestPath: string): boolean {
93
+ const norm = normalizeDomainDir(manifestPath);
94
+ if (norm.endsWith("/*")) {
95
+ const globPrefix = norm.replace(/\/\*$/, "");
96
+ return relPath === globPrefix || relPath.startsWith(globPrefix + "/");
97
+ }
98
+ return relPath === norm || relPath.startsWith(norm + "/");
99
+ }
100
+
101
+ function matchLength(manifestPath: string): number {
102
+ return normalizeDomainDir(manifestPath).replace(/\/\*$/, "").length;
103
+ }
104
+
105
+ function toResolution(node: DomainNode): DomainResolution {
106
+ return {
107
+ domain: node.path[0],
108
+ subdomain: node.path.length > 1 ? node.path.slice(1).join(" > ") : null,
109
+ type: node.type,
110
+ };
111
+ }
112
+
113
+ function isRecord(value: unknown): value is Record<string, unknown> {
114
+ return typeof value === "object" && value !== null && !Array.isArray(value);
115
+ }
116
+
117
+ function stringArray(value: unknown): string[] | undefined {
118
+ if (!Array.isArray(value)) return undefined;
119
+ return value.filter((item): item is string => typeof item === "string");
120
+ }
121
+
122
+ function parseDomainEntries(value: unknown): Record<string, DomainTreeEntry> {
123
+ if (!isRecord(value)) return {};
124
+ const entries: Record<string, DomainTreeEntry> = {};
125
+ for (const [name, rawEntry] of Object.entries(value)) {
126
+ if (!isRecord(rawEntry)) continue;
127
+ if (rawEntry.kind === "group") {
128
+ entries[name] = {
129
+ name,
130
+ kind: "group",
131
+ description: typeof rawEntry.description === "string"
132
+ ? rawEntry.description
133
+ : undefined,
134
+ domains: parseDomainEntries(rawEntry.domains),
135
+ };
136
+ continue;
137
+ }
138
+
139
+ entries[name] = {
140
+ name,
141
+ description: typeof rawEntry.description === "string"
142
+ ? rawEntry.description
143
+ : undefined,
144
+ type: typeof rawEntry.type === "string" ? rawEntry.type : undefined,
145
+ status: typeof rawEntry.status === "string" ? rawEntry.status : undefined,
146
+ code: stringArray(rawEntry.code),
147
+ spec: typeof rawEntry.spec === "string" ? rawEntry.spec : undefined,
148
+ subdomains: parseDomainEntries(rawEntry.subdomains) as Record<string, DomainEntry>,
149
+ };
150
+ }
151
+ return entries;
152
+ }
153
+
154
+ function isDomainGroup(entry: DomainTreeEntry): entry is DomainGroup {
155
+ return "kind" in entry && entry.kind === "group";
156
+ }
157
+
158
+ const TOP_LEVEL_FIELDS = new Set([
159
+ "project",
160
+ "domains",
161
+ "context-map",
162
+ "system-specs",
163
+ ]);
164
+ const PROJECT_FIELDS = new Set(["name", "description"]);
165
+ const GROUP_FIELDS = new Set(["kind", "description", "domains"]);
166
+ const DOMAIN_FIELDS = new Set([
167
+ "description",
168
+ "type",
169
+ "status",
170
+ "owners",
171
+ "language",
172
+ "code",
173
+ "spec",
174
+ "subdomains",
175
+ ]);
176
+ const CONTEXT_FIELDS = new Set([
177
+ "provider",
178
+ "consumers",
179
+ "pattern",
180
+ "contract",
181
+ ]);
182
+ const DOMAIN_TYPES = new Set([
183
+ "core",
184
+ "supporting",
185
+ "generic",
186
+ "shared-kernel",
187
+ ]);
188
+ const DOMAIN_STATUSES = new Set(["active", "deprecated", "planned"]);
189
+ const LANGUAGE_FIELDS = new Set(["term", "meaning"]);
190
+
191
+ function reportUnknownFields(
192
+ value: Record<string, unknown>,
193
+ allowed: Set<string>,
194
+ path: string,
195
+ diagnostics: ManifestDiagnostic[],
196
+ ) {
197
+ for (const key of Object.keys(value)) {
198
+ if (!allowed.has(key)) {
199
+ diagnostics.push({
200
+ path: `${path}.${key}`,
201
+ message: `Unsupported field \`${key}\`.`,
202
+ });
203
+ }
204
+ }
205
+ }
206
+
207
+ function validateString(
208
+ value: unknown,
209
+ path: string,
210
+ diagnostics: ManifestDiagnostic[],
211
+ ) {
212
+ if (value !== undefined && typeof value !== "string") {
213
+ diagnostics.push({ path, message: "Expected a string." });
214
+ }
215
+ }
216
+
217
+ function validateStringList(
218
+ value: unknown,
219
+ path: string,
220
+ diagnostics: ManifestDiagnostic[],
221
+ ) {
222
+ if (
223
+ value !== undefined &&
224
+ (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
225
+ ) {
226
+ diagnostics.push({ path, message: "Expected a list of strings." });
227
+ }
228
+ }
229
+
230
+ function validateDomainEntries(
231
+ value: unknown,
232
+ path: string,
233
+ diagnostics: ManifestDiagnostic[],
234
+ allowGroups: boolean,
235
+ ) {
236
+ if (!isRecord(value)) {
237
+ diagnostics.push({ path, message: "Expected a mapping of named domains." });
238
+ return;
239
+ }
240
+ for (const [name, rawEntry] of Object.entries(value)) {
241
+ const entryPath = `${path}.${name}`;
242
+ if (!isRecord(rawEntry)) {
243
+ diagnostics.push({ entryPath, message: "Expected a domain mapping." });
244
+ continue;
245
+ }
246
+
247
+ if (rawEntry.kind === "group") {
248
+ if (!allowGroups) {
249
+ diagnostics.push({
250
+ path: `${entryPath}.kind`,
251
+ message: "Groups belong under a group's `domains`, not a domain's `subdomains`.",
252
+ });
253
+ }
254
+ reportUnknownFields(rawEntry, GROUP_FIELDS, entryPath, diagnostics);
255
+ validateString(rawEntry.description, `${entryPath}.description`, diagnostics);
256
+ validateDomainEntries(
257
+ rawEntry.domains,
258
+ `${entryPath}.domains`,
259
+ diagnostics,
260
+ true,
261
+ );
262
+ continue;
263
+ }
264
+
265
+ reportUnknownFields(rawEntry, DOMAIN_FIELDS, entryPath, diagnostics);
266
+ validateString(rawEntry.description, `${entryPath}.description`, diagnostics);
267
+ if (rawEntry.type !== undefined && !DOMAIN_TYPES.has(String(rawEntry.type))) {
268
+ diagnostics.push({
269
+ path: `${entryPath}.type`,
270
+ message: "Expected `core`, `supporting`, `generic`, or `shared-kernel`.",
271
+ });
272
+ }
273
+ if (
274
+ rawEntry.status !== undefined &&
275
+ !DOMAIN_STATUSES.has(String(rawEntry.status))
276
+ ) {
277
+ diagnostics.push({
278
+ path: `${entryPath}.status`,
279
+ message: "Expected `active`, `deprecated`, or `planned`.",
280
+ });
281
+ }
282
+ validateStringList(rawEntry.owners, `${entryPath}.owners`, diagnostics);
283
+ if (rawEntry.language !== undefined && !Array.isArray(rawEntry.language)) {
284
+ diagnostics.push({
285
+ path: `${entryPath}.language`,
286
+ message: "Expected a list of ubiquitous-language entries.",
287
+ });
288
+ } else if (Array.isArray(rawEntry.language)) {
289
+ for (const [index, languageEntry] of rawEntry.language.entries()) {
290
+ const languagePath = `${entryPath}.language[${index}]`;
291
+ if (!isRecord(languageEntry)) {
292
+ diagnostics.push({
293
+ path: languagePath,
294
+ message: "Expected a `term` and `meaning` mapping.",
295
+ });
296
+ continue;
297
+ }
298
+ reportUnknownFields(
299
+ languageEntry,
300
+ LANGUAGE_FIELDS,
301
+ languagePath,
302
+ diagnostics,
303
+ );
304
+ if (!(typeof languageEntry.term === "string" && languageEntry.term.length > 0)) {
305
+ diagnostics.push({
306
+ path: `${languagePath}.term`,
307
+ message: "Declare a term.",
308
+ });
309
+ }
310
+ if (!(typeof languageEntry.meaning === "string" && languageEntry.meaning.length > 0)) {
311
+ diagnostics.push({
312
+ path: `${languagePath}.meaning`,
313
+ message: "Declare the term's meaning.",
314
+ });
315
+ }
316
+ }
317
+ }
318
+ validateStringList(rawEntry.code, `${entryPath}.code`, diagnostics);
319
+ validateString(rawEntry.spec, `${entryPath}.spec`, diagnostics);
320
+ const hasCode = Array.isArray(rawEntry.code) && rawEntry.code.length > 0;
321
+ const hasSpec = typeof rawEntry.spec === "string" && rawEntry.spec.length > 0;
322
+ if (!hasCode && !hasSpec) {
323
+ diagnostics.push({
324
+ path: entryPath,
325
+ message: "Declare `kind: group`, or provide a domain specification anchor with `code` or `spec`.",
326
+ });
327
+ }
328
+ if (rawEntry.subdomains !== undefined) {
329
+ validateDomainEntries(
330
+ rawEntry.subdomains,
331
+ `${entryPath}.subdomains`,
332
+ diagnostics,
333
+ false,
334
+ );
335
+ }
336
+ }
337
+ }
338
+
339
+ function diagnosticPathParts(path: string): Array<string | number> {
340
+ const normalized = path.startsWith("domains.yaml.")
341
+ ? path.slice("domains.yaml.".length)
342
+ : path;
343
+ const parts: Array<string | number> = [];
344
+ for (const match of normalized.matchAll(/([^.\[\]]+)|\[(\d+)\]/g)) {
345
+ parts.push(match[2] === undefined ? match[1] : Number(match[2]));
346
+ }
347
+ return parts;
348
+ }
349
+
350
+ function locateDiagnostics(
351
+ diagnostics: ManifestDiagnostic[],
352
+ document: ReturnType<typeof parseDocument>,
353
+ lineCounter: LineCounter,
354
+ ): ManifestDiagnostic[] {
355
+ return diagnostics.map((diagnostic) => {
356
+ if (diagnostic.line && diagnostic.column) return diagnostic;
357
+ const parts = diagnosticPathParts(diagnostic.path);
358
+ while (parts.length > 0) {
359
+ const node = document.getIn(parts, true) as { range?: [number, number, number] } | undefined;
360
+ if (node?.range) {
361
+ const position = lineCounter.linePos(node.range[0]);
362
+ return {
363
+ ...diagnostic,
364
+ line: position.line,
365
+ column: position.col,
366
+ };
367
+ }
368
+ parts.pop();
369
+ }
370
+ return diagnostic;
371
+ });
372
+ }
373
+
374
+ function validateContextMap(
375
+ value: unknown,
376
+ domainPaths: Set<string>,
377
+ diagnostics: ManifestDiagnostic[],
378
+ ) {
379
+ if (value === undefined) return;
380
+ if (!Array.isArray(value)) {
381
+ diagnostics.push({ path: "context-map", message: "Expected a list." });
382
+ return;
383
+ }
384
+ for (const [index, rawEntry] of value.entries()) {
385
+ const entryPath = `context-map[${index}]`;
386
+ if (!isRecord(rawEntry)) {
387
+ diagnostics.push({ path: entryPath, message: "Expected a relationship mapping." });
388
+ continue;
389
+ }
390
+ reportUnknownFields(rawEntry, CONTEXT_FIELDS, entryPath, diagnostics);
391
+ validateString(rawEntry.provider, `${entryPath}.provider`, diagnostics);
392
+ validateStringList(rawEntry.consumers, `${entryPath}.consumers`, diagnostics);
393
+ validateString(rawEntry.pattern, `${entryPath}.pattern`, diagnostics);
394
+ validateString(rawEntry.contract, `${entryPath}.contract`, diagnostics);
395
+
396
+ if (!(typeof rawEntry.provider === "string" && rawEntry.provider.length > 0)) {
397
+ diagnostics.push({
398
+ path: `${entryPath}.provider`,
399
+ message: "Declare a provider domain.",
400
+ });
401
+ } else if (!domainPaths.has(rawEntry.provider)) {
402
+ diagnostics.push({
403
+ path: `${entryPath}.provider`,
404
+ message: `Context provider \`${rawEntry.provider}\` is not declared as a domain.`,
405
+ });
406
+ }
407
+ if (!Array.isArray(rawEntry.consumers) || rawEntry.consumers.length === 0) {
408
+ diagnostics.push({
409
+ path: `${entryPath}.consumers`,
410
+ message: "Declare at least one consumer.",
411
+ });
412
+ }
413
+ if (Array.isArray(rawEntry.consumers)) {
414
+ for (const [consumerIndex, consumer] of rawEntry.consumers.entries()) {
415
+ if (typeof consumer === "string" && !domainPaths.has(consumer)) {
416
+ diagnostics.push({
417
+ path: `${entryPath}.consumers[${consumerIndex}]`,
418
+ message: `Context consumer \`${consumer}\` is not declared as a domain.`,
419
+ });
420
+ }
421
+ }
422
+ }
423
+ if (!(typeof rawEntry.pattern === "string" && rawEntry.pattern.length > 0)) {
424
+ diagnostics.push({
425
+ path: `${entryPath}.pattern`,
426
+ message: "Declare an integration pattern.",
427
+ });
428
+ } else if (!CONTEXT_PATTERNS.has(rawEntry.pattern)) {
429
+ diagnostics.push({
430
+ path: `${entryPath}.pattern`,
431
+ message: `Context relationship uses unsupported pattern \`${rawEntry.pattern}\`.`,
432
+ });
433
+ }
434
+ if (!(typeof rawEntry.contract === "string" && rawEntry.contract.length > 0)) {
435
+ diagnostics.push({
436
+ path: `${entryPath}.contract`,
437
+ message: "Declare a canonical contract path.",
438
+ });
439
+ }
440
+ }
441
+ }
442
+
443
+ export function parseDomainManifest(content: string): DomainManifestResult {
444
+ const lineCounter = new LineCounter();
445
+ const document = parseDocument(content, { lineCounter, prettyErrors: false });
446
+ if (document.errors.length > 0) {
447
+ return {
448
+ domains: null,
449
+ diagnostics: document.errors.map((error) => {
450
+ const position = lineCounter.linePos(error.pos[0]);
451
+ return {
452
+ path: "domains.yaml",
453
+ message: error.message,
454
+ line: position.line,
455
+ column: position.col,
456
+ };
457
+ }),
458
+ };
459
+ }
460
+
461
+ const manifest = document.toJS();
462
+ const diagnostics: ManifestDiagnostic[] = [];
463
+ if (!isRecord(manifest)) {
464
+ return {
465
+ domains: null,
466
+ diagnostics: [{ path: "domains.yaml", message: "Expected a YAML mapping." }],
467
+ };
468
+ }
469
+
470
+ reportUnknownFields(manifest, TOP_LEVEL_FIELDS, "domains.yaml", diagnostics);
471
+ if (manifest.project !== undefined) {
472
+ if (!isRecord(manifest.project)) {
473
+ diagnostics.push({ path: "project", message: "Expected a mapping." });
474
+ } else {
475
+ reportUnknownFields(manifest.project, PROJECT_FIELDS, "project", diagnostics);
476
+ validateString(manifest.project.name, "project.name", diagnostics);
477
+ validateString(
478
+ manifest.project.description,
479
+ "project.description",
480
+ diagnostics,
481
+ );
482
+ }
483
+ }
484
+ validateDomainEntries(manifest.domains, "domains", diagnostics, true);
485
+ validateStringList(manifest["system-specs"], "system-specs", diagnostics);
486
+ if (Array.isArray(manifest["system-specs"])) {
487
+ for (const [index, systemSpecPath] of manifest["system-specs"].entries()) {
488
+ if (
489
+ typeof systemSpecPath === "string" &&
490
+ (systemSpecPath.startsWith("/") ||
491
+ systemSpecPath.startsWith("\\") ||
492
+ /^[A-Za-z]:[\\/]/.test(systemSpecPath) ||
493
+ systemSpecPath.split(/[\\/]/).includes(".."))
494
+ ) {
495
+ diagnostics.push({
496
+ path: `system-specs[${index}]`,
497
+ message: "System-spec paths must stay within the project root.",
498
+ });
499
+ }
500
+ }
501
+ }
502
+ if (
503
+ Array.isArray(manifest["system-specs"]) &&
504
+ manifest["system-specs"].length > 0 &&
505
+ !(isRecord(manifest.project) && typeof manifest.project.name === "string" && manifest.project.name.length > 0)
506
+ ) {
507
+ diagnostics.push({
508
+ path: "project.name",
509
+ message: "A project name is required when `system-specs` are declared.",
510
+ });
511
+ }
512
+
513
+ const domains = parseDomainEntries(manifest.domains) as Domains;
514
+ if (isRecord(manifest.project)) {
515
+ domains._project = {
516
+ name: typeof manifest.project.name === "string" ? manifest.project.name : undefined,
517
+ description: typeof manifest.project.description === "string"
518
+ ? manifest.project.description
519
+ : undefined,
520
+ };
521
+ }
522
+ if (Array.isArray(manifest["system-specs"])) {
523
+ domains._systemSpecs = stringArray(manifest["system-specs"]) || [];
524
+ }
525
+ const domainPaths = new Set(
526
+ flattenDomains(domains).map((node) => node.path.join("/")),
527
+ );
528
+ validateContextMap(manifest["context-map"], domainPaths, diagnostics);
529
+
530
+ const rawContextMap = Array.isArray(manifest["context-map"])
531
+ ? manifest["context-map"]
532
+ : [];
533
+ const contextEntries: ContextMapEntry[] = [];
534
+ for (const rawEntry of rawContextMap) {
535
+ if (!isRecord(rawEntry)) continue;
536
+ contextEntries.push({
537
+ provider: typeof rawEntry.provider === "string" ? rawEntry.provider : "",
538
+ consumers: stringArray(rawEntry.consumers) || [],
539
+ pattern: typeof rawEntry.pattern === "string" ? rawEntry.pattern : "",
540
+ contract: typeof rawEntry.contract === "string" ? rawEntry.contract : undefined,
541
+ });
542
+ }
543
+ const contextMapMatch = content.match(/context-map:\s*\n((?:\s+.*\n?)*)/);
544
+ if (contextMapMatch) domains._contextMap = contextMapMatch[1];
545
+ if (contextEntries.length > 0) domains._contextEntries = contextEntries;
546
+
547
+ const locatedDiagnostics = locateDiagnostics(
548
+ diagnostics,
549
+ document,
550
+ lineCounter,
551
+ );
552
+ return {
553
+ domains: locatedDiagnostics.length === 0 ? domains : null,
554
+ diagnostics: locatedDiagnostics,
555
+ };
556
+ }
557
+
558
+ export function parseDomainsYaml(content: string): Domains {
559
+ const result = parseDomainManifest(content);
560
+ if (!result.domains) throw new DomainManifestError(result.diagnostics);
561
+ return result.domains;
562
+ }
563
+
564
+ export function flattenDomains(domains: Domains): DomainNode[] {
565
+ const nodes: DomainNode[] = [];
566
+ const walk = (
567
+ entries: Record<string, DomainTreeEntry>,
568
+ parentPath: string[],
569
+ inheritedType = "supporting",
570
+ ) => {
571
+ for (const [name, entry] of Object.entries(entries)) {
572
+ if (name.startsWith("_")) continue;
573
+ const path = [...parentPath, name];
574
+ if (isDomainGroup(entry)) {
575
+ walk(entry.domains, path, inheritedType);
576
+ continue;
577
+ }
578
+ const type = entry.type || inheritedType;
579
+ nodes.push({ path, entry, type });
580
+ if (entry.subdomains) walk(entry.subdomains, path, type);
581
+ }
582
+ };
583
+ walk(domains, []);
584
+ return nodes;
585
+ }
586
+
587
+ export function entryForPath(domains: Domains, path: string[]): DomainEntry | null {
588
+ let entry: DomainTreeEntry | undefined = domains[path[0]];
589
+ for (const part of path.slice(1)) {
590
+ entry = entry && isDomainGroup(entry)
591
+ ? entry.domains[part]
592
+ : entry?.subdomains?.[part];
593
+ }
594
+ return entry && !isDomainGroup(entry) ? entry : null;
595
+ }
596
+
597
+ export function entryForResolution(domains: Domains, resolved: DomainResolution): DomainEntry | null {
598
+ return entryForPath(domains, [resolved.domain, ...(resolved.subdomain ? resolved.subdomain.split(" > ") : [])]);
599
+ }
600
+
601
+ export function resolveDomainForFilePath(filePath: string, root: string, domains: Domains): DomainResolution | null {
602
+ const absPath = resolve(root, filePath);
603
+ const relPath = relative(root, absPath);
604
+ const candidates: Array<{ node: DomainNode; length: number; kind: "code" | "spec" }> = [];
605
+
606
+ for (const node of flattenDomains(domains)) {
607
+ for (const cp of node.entry.code || []) {
608
+ if (pathMatches(relPath, cp)) candidates.push({ node, length: matchLength(cp), kind: "code" });
609
+ }
610
+ if (node.entry.spec && pathMatches(relPath, node.entry.spec)) {
611
+ candidates.push({ node, length: matchLength(node.entry.spec), kind: "spec" });
612
+ }
613
+ }
614
+ if (candidates.length === 0) return null;
615
+
616
+ candidates.sort((a, b) => {
617
+ if (b.length !== a.length) return b.length - a.length;
618
+ if (a.kind !== b.kind) return a.kind === "spec" ? -1 : 1;
619
+ const aHasExplicitSpec = Boolean(a.node.entry.spec);
620
+ const bHasExplicitSpec = Boolean(b.node.entry.spec);
621
+ if (aHasExplicitSpec !== bHasExplicitSpec) return aHasExplicitSpec ? 1 : -1;
622
+ return b.node.path.length - a.node.path.length;
623
+ });
624
+
625
+ return toResolution(candidates[0].node);
626
+ }
627
+
628
+ function isAncestorPath(ancestor: string[], descendant: string[]): boolean {
629
+ return (
630
+ ancestor.length < descendant.length &&
631
+ ancestor.every((part, index) => descendant[index] === part)
632
+ );
633
+ }
634
+
635
+ export function findAmbiguousCodeMappings(domains: Domains): string[] {
636
+ const byPath = new Map<string, DomainNode[]>();
637
+ for (const node of flattenDomains(domains)) {
638
+ for (const cp of node.entry.code || []) {
639
+ const key = normalizeDomainDir(cp);
640
+ byPath.set(key, [...(byPath.get(key) || []), node]);
641
+ }
642
+ }
643
+ const issues: string[] = [];
644
+ for (const [cp, nodes] of byPath.entries()) {
645
+ const conflicting = new Set<DomainNode>();
646
+ for (let left = 0; left < nodes.length; left++) {
647
+ for (let right = left + 1; right < nodes.length; right++) {
648
+ const leftNode = nodes[left];
649
+ const rightNode = nodes[right];
650
+ if (
651
+ !isAncestorPath(leftNode.path, rightNode.path) &&
652
+ !isAncestorPath(rightNode.path, leftNode.path)
653
+ ) {
654
+ conflicting.add(leftNode);
655
+ conflicting.add(rightNode);
656
+ }
657
+ }
658
+ }
659
+ if (conflicting.size === 0) continue;
660
+ const labels = [...conflicting]
661
+ .map((node) => node.path.join(" > "))
662
+ .join(", ");
663
+ issues.push(
664
+ `Code path \`${cp}/\` is declared by unrelated domains: ${labels}. Use distinct code paths or explicit spec paths to establish one semantic owner.`,
665
+ );
666
+ }
667
+ return issues;
668
+ }
669
+
670
+ const CONTEXT_PATTERNS = new Set([
671
+ "shared-kernel",
672
+ "customer-supplier",
673
+ "conformist",
674
+ "anti-corruption-layer",
675
+ "open-host-service",
676
+ "published-language",
677
+ "partnership",
678
+ "separate-ways",
679
+ ]);
680
+
681
+ const NORMATIVE_FRONTMATTER_KEYS = new Set([
682
+ "domain",
683
+ "status",
684
+ "term",
685
+ "aliases",
686
+ ]);
687
+ const SYSTEM_FRONTMATTER_KEYS = new Set(["system", "status"]);
688
+ const FORBIDDEN_NORMATIVE_SECTIONS = new Set([
689
+ "roadmap",
690
+ "rollout",
691
+ "progress",
692
+ "migration plan",
693
+ "implementation plan",
694
+ "delivery status",
695
+ "verification plan",
696
+ "milestones",
697
+ "deadlines",
698
+ "assignees",
699
+ ]);
700
+
701
+ function parseFrontmatter(content: string): {
702
+ metadata: Map<string, unknown>;
703
+ body: string;
704
+ } | null {
705
+ const match = content.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
706
+ if (!match) return null;
707
+ const document = parseDocument(match[1]);
708
+ const value = document.errors.length === 0 ? document.toJS() : null;
709
+ const metadata = new Map<string, unknown>(
710
+ isRecord(value) ? Object.entries(value) : [],
711
+ );
712
+ return { metadata, body: content.slice(match[0].length) };
713
+ }
714
+
715
+ function validateTimelessNormativeBody(body: string): string[] {
716
+ const issues: string[] = [];
717
+ for (const line of body.split("\n")) {
718
+ const heading = line.match(/^#{1,6}\s+(.+?)\s*#*$/)?.[1]?.trim();
719
+ if (heading && FORBIDDEN_NORMATIVE_SECTIONS.has(heading.toLowerCase())) {
720
+ issues.push(
721
+ `Normative specifications must not contain a \`${heading}\` section.`,
722
+ );
723
+ }
724
+ }
725
+ return issues;
726
+ }
727
+
728
+ function validateNormativeStatus(
729
+ metadata: Map<string, unknown>,
730
+ label: string,
731
+ ): string[] {
732
+ const status = metadata.get("status");
733
+ if (!status) return [`${label} frontmatter must declare \`status\`.`];
734
+ if (
735
+ typeof status !== "string" ||
736
+ !["draft", "approved", "stale"].includes(status)
737
+ ) {
738
+ return [
739
+ `${label} frontmatter \`status\` must be \`draft\`, \`approved\`, or \`stale\`.`,
740
+ ];
741
+ }
742
+ return [];
743
+ }
744
+
745
+ export function validateNormativeSpecContent(
746
+ content: string,
747
+ expectedDomain: string,
748
+ isReadme: boolean,
749
+ ): string[] {
750
+ const parsed = parseFrontmatter(content);
751
+ if (!parsed) {
752
+ return isReadme
753
+ ? ["README.md must declare normative frontmatter with `domain` and `status`."]
754
+ : [];
755
+ }
756
+
757
+ const { metadata, body } = parsed;
758
+ const issues: string[] = [];
759
+ for (const key of metadata.keys()) {
760
+ if (!NORMATIVE_FRONTMATTER_KEYS.has(key)) {
761
+ issues.push(`Unsupported normative frontmatter key \`${key}\`.`);
762
+ }
763
+ }
764
+ if (metadata.get("domain") !== expectedDomain) {
765
+ issues.push(
766
+ `Normative frontmatter domain must be \`${expectedDomain}\`.`,
767
+ );
768
+ }
769
+ issues.push(...validateNormativeStatus(metadata, "Normative"));
770
+ const term = metadata.get("term");
771
+ const aliases = metadata.get("aliases");
772
+ if (term !== undefined && !(typeof term === "string" && term.trim().length > 0)) {
773
+ issues.push("Normative frontmatter `term` must be a non-empty string.");
774
+ }
775
+ if (aliases !== undefined && term === undefined) {
776
+ issues.push("Normative frontmatter `aliases` requires a canonical `term`.");
777
+ }
778
+ if (
779
+ aliases !== undefined &&
780
+ (!Array.isArray(aliases) || aliases.some((alias) => typeof alias !== "string"))
781
+ ) {
782
+ issues.push("Normative frontmatter `aliases` must be a list of strings.");
783
+ }
784
+ issues.push(...validateTimelessNormativeBody(body));
785
+ return issues;
786
+ }
787
+
788
+ export function validateSystemSpecContent(
789
+ content: string,
790
+ expectedSystem: string,
791
+ ): string[] {
792
+ const parsed = parseFrontmatter(content);
793
+ if (!parsed) {
794
+ return ["System specifications must declare `system` and `status` frontmatter."];
795
+ }
796
+ const { metadata, body } = parsed;
797
+ const issues: string[] = [];
798
+ for (const key of metadata.keys()) {
799
+ if (!SYSTEM_FRONTMATTER_KEYS.has(key)) {
800
+ issues.push(`Unsupported system-spec frontmatter key \`${key}\`.`);
801
+ }
802
+ }
803
+ if (metadata.get("system") !== expectedSystem) {
804
+ issues.push(`System frontmatter must identify \`${expectedSystem}\`.`);
805
+ }
806
+ issues.push(...validateNormativeStatus(metadata, "System"));
807
+ if (!/\b(?:MUST(?: NOT)?|SHOULD(?: NOT)?|MAY)\b/.test(body)) {
808
+ issues.push(
809
+ "System specifications must contain at least one RFC 2119 requirement keyword.",
810
+ );
811
+ }
812
+ issues.push(...validateTimelessNormativeBody(body));
813
+ return issues;
814
+ }
815
+
816
+ export type WikiDocument = {
817
+ path: string;
818
+ content: string;
819
+ domain?: string;
820
+ system?: string;
821
+ };
822
+
823
+ export type WikiDiagnostic = {
824
+ path: string;
825
+ message: string;
826
+ };
827
+
828
+ export type TermOwner = {
829
+ canonicalTerm: string;
830
+ aliases: string[];
831
+ domain: string;
832
+ path: string;
833
+ };
834
+
835
+ export type TermIndex = {
836
+ owners: TermOwner[];
837
+ byLabel: Map<string, { owner: TermOwner; matchedAlias: string | null }>;
838
+ diagnostics: WikiDiagnostic[];
839
+ };
840
+
841
+ function normalizeTerm(value: string): string {
842
+ return value.normalize("NFKC").trim().toLocaleLowerCase("en-US");
843
+ }
844
+
845
+ export function buildTermIndex(documents: WikiDocument[]): TermIndex {
846
+ const owners: TermOwner[] = [];
847
+ const byLabel = new Map<
848
+ string,
849
+ { owner: TermOwner; matchedAlias: string | null }
850
+ >();
851
+ const diagnostics: WikiDiagnostic[] = [];
852
+ for (const document of documents) {
853
+ if (!document.domain) continue;
854
+ const parsed = parseFrontmatter(document.content);
855
+ const term = parsed?.metadata.get("term");
856
+ if (!(typeof term === "string" && term.trim().length > 0)) continue;
857
+ const rawAliases = parsed?.metadata.get("aliases");
858
+ const aliases = Array.isArray(rawAliases)
859
+ ? rawAliases.filter((alias): alias is string => typeof alias === "string")
860
+ : [];
861
+ const owner: TermOwner = {
862
+ canonicalTerm: term,
863
+ aliases,
864
+ domain: document.domain,
865
+ path: document.path,
866
+ };
867
+ owners.push(owner);
868
+ for (const label of [term, ...aliases]) {
869
+ const normalized = normalizeTerm(label);
870
+ const existing = byLabel.get(normalized);
871
+ if (existing && existing.owner.path !== owner.path) {
872
+ diagnostics.push({
873
+ path: document.path,
874
+ message: `Ubiquitous-language label \`${label}\` is already owned by \`${existing.owner.path}\`.`,
875
+ });
876
+ continue;
877
+ }
878
+ if (!existing) {
879
+ byLabel.set(normalized, {
880
+ owner,
881
+ matchedAlias: normalizeTerm(label) === normalizeTerm(term)
882
+ ? null
883
+ : label,
884
+ });
885
+ }
886
+ }
887
+ }
888
+ return { owners, byLabel, diagnostics };
889
+ }
890
+
891
+ export function resolveTerm(
892
+ index: TermIndex,
893
+ query: string,
894
+ ): {
895
+ canonicalTerm: string;
896
+ matchedAlias: string | null;
897
+ domain: string;
898
+ path: string;
899
+ } | null {
900
+ const match = index.byLabel.get(normalizeTerm(query));
901
+ if (!match) return null;
902
+ return {
903
+ canonicalTerm: match.owner.canonicalTerm,
904
+ matchedAlias: match.matchedAlias,
905
+ domain: match.owner.domain,
906
+ path: match.owner.path,
907
+ };
908
+ }
909
+
910
+ function markdownWithoutFencedCode(content: string): string {
911
+ let inFence = false;
912
+ return content.split("\n").map((line) => {
913
+ if (/^\s*(```|~~~)/.test(line)) {
914
+ inFence = !inFence;
915
+ return "";
916
+ }
917
+ return inFence ? "" : line;
918
+ }).join("\n");
919
+ }
920
+
921
+ function markdownHeadingAnchors(content: string): Set<string> {
922
+ const anchors = new Set<string>();
923
+ const occurrences = new Map<string, number>();
924
+ for (const line of markdownWithoutFencedCode(content).split("\n")) {
925
+ const heading = line.match(/^#{1,6}\s+(.+?)\s*#*$/)?.[1]?.trim();
926
+ if (!heading) continue;
927
+ const base = heading
928
+ .toLocaleLowerCase("en-US")
929
+ .replace(/<[^>]+>/g, "")
930
+ .replace(/[^\p{L}\p{N}\s_-]/gu, "")
931
+ .replace(/\s+/g, "-");
932
+ const count = occurrences.get(base) || 0;
933
+ occurrences.set(base, count + 1);
934
+ anchors.add(count === 0 ? base : `${base}-${count}`);
935
+ }
936
+ return anchors;
937
+ }
938
+
939
+ function isExternalLink(target: string): boolean {
940
+ return target.startsWith("//") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(target);
941
+ }
942
+
943
+ export async function validateSpecificationWiki(
944
+ root: string,
945
+ documents: WikiDocument[],
946
+ ): Promise<WikiDiagnostic[]> {
947
+ const index = buildTermIndex(documents);
948
+ const diagnostics = [...index.diagnostics];
949
+ const documentByPath = new Map(
950
+ documents.map((document) => [resolve(root, document.path), document]),
951
+ );
952
+ for (const document of documents) {
953
+ const sourcePath = resolve(root, document.path);
954
+ const parsed = parseFrontmatter(document.content);
955
+ const body = markdownWithoutFencedCode(parsed?.body || document.content);
956
+ for (const match of body.matchAll(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g)) {
957
+ const linkText = match[1].trim();
958
+ let target = match[2].trim().replace(/^<|>$/g, "");
959
+ target = target.match(/^(?:<[^>]+>|\S+)/)?.[0]?.replace(/^<|>$/g, "") || target;
960
+ if (isExternalLink(target)) continue;
961
+ if (target.startsWith("/")) {
962
+ diagnostics.push({
963
+ path: document.path,
964
+ message: `Specification link \`${target}\` must be relative.`,
965
+ });
966
+ continue;
967
+ }
968
+ const hashIndex = target.indexOf("#");
969
+ const rawTargetPath = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
970
+ const rawFragment = hashIndex >= 0 ? target.slice(hashIndex + 1) : "";
971
+ let targetPath: string;
972
+ let fragment: string;
973
+ try {
974
+ targetPath = decodeURIComponent(rawTargetPath);
975
+ fragment = decodeURIComponent(rawFragment);
976
+ } catch {
977
+ diagnostics.push({
978
+ path: document.path,
979
+ message: `Specification link \`${target}\` contains invalid URL encoding.`,
980
+ });
981
+ continue;
982
+ }
983
+ const targetAbsolutePath = targetPath
984
+ ? resolve(dirname(sourcePath), targetPath)
985
+ : sourcePath;
986
+ const relativeTarget = relative(root, targetAbsolutePath);
987
+ if (relativeTarget === ".." || relativeTarget.startsWith("../") || relativeTarget.startsWith("..\\")) {
988
+ diagnostics.push({
989
+ path: document.path,
990
+ message: `Specification link \`${target}\` escapes the project root.`,
991
+ });
992
+ continue;
993
+ }
994
+ try {
995
+ await access(targetAbsolutePath);
996
+ } catch {
997
+ diagnostics.push({
998
+ path: document.path,
999
+ message: `Specification link target \`${target}\` does not exist.`,
1000
+ });
1001
+ continue;
1002
+ }
1003
+
1004
+ const termMatch = index.byLabel.get(normalizeTerm(linkText));
1005
+ if (
1006
+ termMatch &&
1007
+ resolve(root, termMatch.owner.path) !== targetAbsolutePath
1008
+ ) {
1009
+ diagnostics.push({
1010
+ path: document.path,
1011
+ message: `Term link \`${linkText}\` must target its canonical page \`${termMatch.owner.path}\`.`,
1012
+ });
1013
+ }
1014
+
1015
+ if (fragment && extname(targetAbsolutePath).toLowerCase() === ".md") {
1016
+ const targetContent = documentByPath.get(targetAbsolutePath)?.content ||
1017
+ await readFile(targetAbsolutePath, "utf-8");
1018
+ if (!markdownHeadingAnchors(targetContent).has(fragment.toLowerCase())) {
1019
+ diagnostics.push({
1020
+ path: document.path,
1021
+ message: `Markdown heading anchor \`#${fragment}\` does not exist in \`${relativeTarget}\`.`,
1022
+ });
1023
+ }
1024
+ }
1025
+ }
1026
+ }
1027
+ return diagnostics;
1028
+ }
1029
+
1030
+ export function findContextMapIssues(domains: Domains): string[] {
1031
+ const domainPaths = new Set(
1032
+ flattenDomains(domains).map((node) => node.path.join("/")),
1033
+ );
1034
+ const issues: string[] = [];
1035
+ for (const entry of domains._contextEntries || []) {
1036
+ if (!domainPaths.has(entry.provider)) {
1037
+ issues.push(
1038
+ `Context provider \`${entry.provider}\` is not declared in the domain tree.`,
1039
+ );
1040
+ }
1041
+ if (entry.consumers.length === 0) {
1042
+ issues.push(
1043
+ `Context relationship \`${entry.provider}\` has no consumers.`,
1044
+ );
1045
+ }
1046
+ for (const consumer of entry.consumers) {
1047
+ if (!domainPaths.has(consumer)) {
1048
+ issues.push(
1049
+ `Context consumer \`${consumer}\` is not declared in the domain tree.`,
1050
+ );
1051
+ }
1052
+ }
1053
+ if (!CONTEXT_PATTERNS.has(entry.pattern)) {
1054
+ issues.push(
1055
+ `Context relationship \`${entry.provider}\` uses unsupported pattern \`${entry.pattern}\`.`,
1056
+ );
1057
+ }
1058
+ if (!entry.contract) {
1059
+ issues.push(
1060
+ `Context relationship \`${entry.provider}\` has no canonical contract path.`,
1061
+ );
1062
+ }
1063
+ }
1064
+ return issues;
1065
+ }