@githolon/dsl 0.1.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 (53) hide show
  1. package/LICENSE.md +36 -0
  2. package/compile_package.mjs +50 -0
  3. package/package.json +59 -0
  4. package/src/aggregate.ts +167 -0
  5. package/src/authoring.ts +119 -0
  6. package/src/build_package.ts +636 -0
  7. package/src/certified_read.ts +313 -0
  8. package/src/codegen_dart.ts +2732 -0
  9. package/src/codegen_dot.ts +466 -0
  10. package/src/codegen_provider_dart.ts +358 -0
  11. package/src/codegen_ts.ts +365 -0
  12. package/src/codegen_usda.ts +388 -0
  13. package/src/combined.ts +195 -0
  14. package/src/compile_engine.ts +567 -0
  15. package/src/compile_package_main.ts +496 -0
  16. package/src/compose.ts +317 -0
  17. package/src/count.ts +218 -0
  18. package/src/ctx.ts +57 -0
  19. package/src/derived.ts +138 -0
  20. package/src/directive.ts +306 -0
  21. package/src/drivers.ts +95 -0
  22. package/src/emits_guard.ts +123 -0
  23. package/src/engine_entry.ts +449 -0
  24. package/src/exists.ts +170 -0
  25. package/src/extremum.ts +227 -0
  26. package/src/fields.ts +291 -0
  27. package/src/framework/bootstrap.ts +22 -0
  28. package/src/framework/disclosure.ts +108 -0
  29. package/src/framework/domain_lifecycle.ts +108 -0
  30. package/src/framework/identity.ts +537 -0
  31. package/src/framework/impure_capability.ts +643 -0
  32. package/src/framework/rbac.ts +418 -0
  33. package/src/framework/repair.ts +150 -0
  34. package/src/framework/sync_lifecycle.ts +125 -0
  35. package/src/framework/workspace_invariant.ts +128 -0
  36. package/src/framework/workspaces.ts +817 -0
  37. package/src/index.ts +317 -0
  38. package/src/manifest.ts +947 -0
  39. package/src/ops.ts +145 -0
  40. package/src/ordered_read.ts +228 -0
  41. package/src/predicate.ts +203 -0
  42. package/src/query/compile.ts +0 -0
  43. package/src/query/relations.ts +144 -0
  44. package/src/query.ts +151 -0
  45. package/src/read.ts +54 -0
  46. package/src/relation.ts +189 -0
  47. package/src/report/csv.ts +54 -0
  48. package/src/report.ts +401 -0
  49. package/src/spatial.ts +115 -0
  50. package/src/sum.ts +194 -0
  51. package/src/usd.ts +563 -0
  52. package/src/wire.ts +149 -0
  53. package/src/wire_encode.ts +250 -0
@@ -0,0 +1,466 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded
4
+ // QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
5
+ // wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
6
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
+
8
+ /**
9
+ * Graphviz `.dot` ENTITY-RELATIONSHIP emitter (DSL decision-6's diagram projection:
10
+ * the SAME in-memory `DomainModule` that lowers to TS/Dart/manifest ALSO renders to a
11
+ * human-readable ER diagram). It is a PURE function `DomainModule -> string`: no file
12
+ * reads, no clock, no IO. Like every other Nomos artifact it is DETERMINISTIC — every
13
+ * collection (aggregates, fields, edges, contexts) is SORTED so the same domain yields
14
+ * BYTE-IDENTICAL `.dot` every run ([[determinism-or-death]] applies to generated
15
+ * artifacts too: a diff in the diagram must mean a diff in the domain).
16
+ *
17
+ * This is an OPTIONAL, OFF-BY-DEFAULT projection: nothing here is part of the domain
18
+ * IDENTITY (`manifest.ts`) — it is a pure presentation layer, like `codegen_dart.ts`.
19
+ * The emit step writes `<domain>.dot` ONLY when its caller passes the `--emit-dot`
20
+ * compiler flag; when the flag is absent this module is never reached and not one
21
+ * existing generated byte changes.
22
+ *
23
+ * What it draws:
24
+ * - each AGGREGATE as a Graphviz record/HTML-table node listing its fields
25
+ * (`name : kind`), marking the id field (🔑), ref fields (→Target), and OPTIONAL
26
+ * fields; engine-projected DERIVED / COMBINED read fields render in a distinct
27
+ * italic compartment (they live ONLY in the read model, never the ledger).
28
+ * - each REF/RELATION as an edge `source → target` with a CARDINALITY marker drawn
29
+ * in crow's-foot notation (an aggregate that REFERENCES another is the "many"
30
+ * end pointing at the "one" end of its target).
31
+ * - aggregates GROUPED by BOUNDED CONTEXT — the set of directives that own (write)
32
+ * an aggregate via `.creates/.mutates/.ensures/.archives`. Each context is a
33
+ * coloured `subgraph cluster_*`.
34
+ * - cross-workspace RELATION declarations (`relation(...).proposes(...)`) as dashed
35
+ * PR-tier edges annotated with the proposed directive + the disclosure read.
36
+ * - a header comment naming the domain + "generated by the Nomos DSL".
37
+ */
38
+ import type { AggregateHandle } from "./aggregate.js";
39
+ import type { Field, FieldKind } from "./fields.js";
40
+ import type { Directive } from "./directive.js";
41
+ import type { RelationDecl } from "./relation.js";
42
+ import type { DerivedDecl } from "./derived.js";
43
+ import type { CombinedDecl } from "./combined.js";
44
+ import type { DomainModule } from "./codegen_dart.js";
45
+
46
+ /** A directive of any payload type — the emitter only introspects the contract shape. */
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
+ type AnyDirective = Directive<any>;
49
+
50
+ /** Options for the ER emitter. Both default to OFF / minimal, so the smallest call
51
+ * `emitDomainDot(mod)` produces the clean core diagram. */
52
+ export interface DotOptions {
53
+ /** Also draw the cross-workspace RELATION declarations as dashed PR-tier edges. Default true. */
54
+ readonly relations?: boolean;
55
+ /** Annotate aggregate-invariant presence with a guard badge on the node. Default true. */
56
+ readonly invariants?: boolean;
57
+ }
58
+
59
+ /** A palette of distinct, light, print-friendly fills — one per bounded context.
60
+ * Indexed by the context's SORTED position so colour assignment is deterministic. */
61
+ const CONTEXT_FILLS: readonly string[] = [
62
+ "#E8F0FE", // blue
63
+ "#E6F4EA", // green
64
+ "#FEF7E0", // amber
65
+ "#FCE8E6", // red
66
+ "#F3E8FD", // purple
67
+ "#E4F7FB", // cyan
68
+ "#FFF0E6", // orange
69
+ "#EFEFEF", // grey
70
+ ];
71
+ /** The matching darker border per fill (same index). */
72
+ const CONTEXT_BORDERS: readonly string[] = [
73
+ "#1A73E8",
74
+ "#34A853",
75
+ "#F9AB00",
76
+ "#EA4335",
77
+ "#A142F4",
78
+ "#12B5CB",
79
+ "#FA7B17",
80
+ "#9AA0A6",
81
+ ];
82
+
83
+ /** Escape a string for inclusion inside a Graphviz HTML-like label (table cells). */
84
+ function htmlEscape(s: string): string {
85
+ return s
86
+ .replace(/&/g, "&amp;")
87
+ .replace(/</g, "&lt;")
88
+ .replace(/>/g, "&gt;")
89
+ .replace(/"/g, "&quot;");
90
+ }
91
+
92
+ /** Escape a string for a Graphviz quoted attribute value (edge/graph labels). */
93
+ function attrEscape(s: string): string {
94
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
95
+ }
96
+
97
+ /** A Graphviz-safe node id: only `[A-Za-z0-9_]`, so a wire id with odd chars is safe.
98
+ * Deterministic + injective for the id space the DSL allows (alnum + `_`). */
99
+ function nodeId(aggregateId: string): string {
100
+ return "agg_" + aggregateId.replace(/[^A-Za-z0-9_]/g, "_");
101
+ }
102
+
103
+ /** A short human kind label for a field — the type shown after the field name. For a
104
+ * ref it names the TARGET aggregate type (`→Target`); a map shows its value kind. */
105
+ function fieldKindLabel(field: Field): string {
106
+ const kind: FieldKind = field.kind;
107
+ if (kind === "ref") {
108
+ const target = field.refAggregateId ?? "?";
109
+ const ws = field.refWorkspace !== undefined ? `@${field.refWorkspace} ` : "";
110
+ return `ref →${ws}${target}`;
111
+ }
112
+ if (kind === "map") {
113
+ return `map<${field.mapValueKind ?? "json"}>`;
114
+ }
115
+ return kind;
116
+ }
117
+
118
+ /** Heuristic: which field is the aggregate's IDENTITY field? The DSL has no explicit
119
+ * id marker, but the universal convention (every golden + tenant domain) is a field
120
+ * named exactly `<lowerFirst(typeStem)>Id` or simply `id`. We pick, deterministically:
121
+ * 1. a field literally named `id`, else
122
+ * 2. the first (sorted) field whose name ends in `Id` AND is a non-ref scalar.
123
+ * Returns `undefined` when nothing matches (still a valid node — just no 🔑). */
124
+ function idFieldOf(agg: AggregateHandle): string | undefined {
125
+ const names = Object.keys(agg.fields).sort();
126
+ if (names.includes("id")) return "id";
127
+ for (const n of names) {
128
+ const f = (agg.fields as Record<string, Field>)[n]!;
129
+ if (n.endsWith("Id") && f.kind !== "ref") return n;
130
+ }
131
+ return undefined;
132
+ }
133
+
134
+ /** Resolve, per aggregate id, the SORTED set of directive ids that WRITE it (its
135
+ * bounded context membership). An aggregate written by directives is owned by the
136
+ * context those directives form; an aggregate NOBODY writes (a pure read target /
137
+ * cross-workspace endpoint) lands in a synthetic `(unowned)` context. */
138
+ function ownersByAggregate(directives: readonly AnyDirective[]): Map<string, string[]> {
139
+ const out = new Map<string, string[]>();
140
+ for (const d of directives) {
141
+ const list = out.get(d.aggregateId) ?? [];
142
+ list.push(d.id);
143
+ out.set(d.aggregateId, list);
144
+ }
145
+ for (const [k, v] of out) out.set(k, [...new Set(v)].sort());
146
+ return out;
147
+ }
148
+
149
+ /**
150
+ * The BOUNDED CONTEXT key for an aggregate — its ownership cluster. An aggregate is
151
+ * OWNED by the directives that DECLARE it as their referential target
152
+ * (`.creates/.mutates/.ensures/.archives`); the context is named, deterministically, by
153
+ * the lexicographically-FIRST owning directive id. An aggregate NOBODY writes (a pure
154
+ * read target / cross-workspace endpoint) lands in a synthetic `<id> (unowned)` context.
155
+ *
156
+ * Grouping uses union-find over each directive's declared target set so that, the day a
157
+ * directive can declare MULTIPLE targets, co-declared aggregates collapse into one
158
+ * context automatically. Today each directive declares a single `aggregateId` (plan-body
159
+ * fan-out to sibling aggregates is executable behaviour the emitter cannot statically
160
+ * see), so each declared aggregate forms its own context — the honest, knowable grouping.
161
+ */
162
+ function contextOf(
163
+ aggregates: readonly AggregateHandle[],
164
+ directives: readonly AnyDirective[],
165
+ ): Map<string, string> {
166
+ // union-find over aggregate ids
167
+ const parent = new Map<string, string>();
168
+ const find = (x: string): string => {
169
+ let r = x;
170
+ while (parent.get(r) !== r) r = parent.get(r)!;
171
+ // path-compress
172
+ let c = x;
173
+ while (parent.get(c) !== r) {
174
+ const n = parent.get(c)!;
175
+ parent.set(c, r);
176
+ c = n;
177
+ }
178
+ return r;
179
+ };
180
+ const union = (a: string, b: string): void => {
181
+ const ra = find(a);
182
+ const rb = find(b);
183
+ if (ra === rb) return;
184
+ // deterministic merge: the lexicographically-smaller root wins
185
+ if (ra < rb) parent.set(rb, ra);
186
+ else parent.set(ra, rb);
187
+ };
188
+ for (const a of aggregates) if (!parent.has(a.id)) parent.set(a.id, a.id);
189
+ // any aggregate only referenced by a directive must also exist as a node root
190
+ for (const d of directives) if (!parent.has(d.aggregateId)) parent.set(d.aggregateId, d.aggregateId);
191
+
192
+ // group aggregates co-written by one directive (multi-aggregate plans bind contexts)
193
+ const byDirective = new Map<string, string[]>();
194
+ for (const d of directives) {
195
+ const list = byDirective.get(d.id) ?? [];
196
+ list.push(d.aggregateId);
197
+ byDirective.set(d.id, list);
198
+ }
199
+ for (const aggs of byDirective.values()) {
200
+ for (let i = 1; i < aggs.length; i++) union(aggs[0]!, aggs[i]!);
201
+ }
202
+
203
+ // context label = the SORTED-first owning directive id of the group, else the root
204
+ // aggregate id itself (for an unowned aggregate). Build root → label deterministically.
205
+ const owners = ownersByAggregate(directives);
206
+ const rootLabel = new Map<string, string>();
207
+ const allRoots = [...new Set([...parent.keys()].map(find))].sort();
208
+ for (const root of allRoots) {
209
+ // gather every aggregate id in this root's group
210
+ const members = [...parent.keys()].filter((k) => find(k) === root).sort();
211
+ // the group's owning directives, sorted; first = label, else "(unowned)"
212
+ const dirs = members.flatMap((m) => owners.get(m) ?? []);
213
+ const uniq = [...new Set(dirs)].sort();
214
+ rootLabel.set(root, uniq.length > 0 ? uniq[0]! : `${root} (unowned)`);
215
+ }
216
+
217
+ const out = new Map<string, string>();
218
+ for (const a of aggregates) out.set(a.id, rootLabel.get(find(a.id))!);
219
+ // also place referenced-only aggregates if any (defensive — usually all are in `aggregates`)
220
+ return out;
221
+ }
222
+
223
+ /** Build the HTML-like record label for ONE aggregate node. */
224
+ function aggregateLabel(
225
+ agg: AggregateHandle,
226
+ border: string,
227
+ deriveds: readonly DerivedDecl[],
228
+ combineds: readonly CombinedDecl[],
229
+ showInvariant: boolean,
230
+ ): string {
231
+ const idField = idFieldOf(agg);
232
+ const fieldNames = Object.keys(agg.fields).sort();
233
+ const guard = showInvariant && agg.hasInvariant === true ? " 🛡" : "";
234
+
235
+ const titleRow =
236
+ `<TR><TD BGCOLOR="${border}" ALIGN="CENTER">` +
237
+ `<FONT COLOR="white"><B>${htmlEscape(agg.id)}${guard}</B></FONT></TD></TR>`;
238
+
239
+ const fieldRows = fieldNames
240
+ .map((name) => {
241
+ const f = (agg.fields as Record<string, Field>)[name]!;
242
+ const key = name === idField ? "🔑 " : "";
243
+ const opt = f.isOptional ? "?" : "";
244
+ const kindLabel = fieldKindLabel(f);
245
+ // a ref field is highlighted (it is the edge source); optional fields are dimmed.
246
+ const nameHtml = `<B>${htmlEscape(key + name)}${opt}</B>`;
247
+ const kindHtml = `<FONT COLOR="#5F6368"><I> : ${htmlEscape(kindLabel)}</I></FONT>`;
248
+ return `<TR><TD ALIGN="LEFT" PORT="${attrEscape(name)}">${nameHtml}${kindHtml}</TD></TR>`;
249
+ })
250
+ .join("");
251
+
252
+ // engine-projected READ fields (derived + combined) — a distinct compartment; these
253
+ // live ONLY in the read model, NEVER the ledger, so they are drawn italic + dimmed.
254
+ const readFields = [
255
+ ...deriveds.map((d) => ({ id: d.id, kind: "derived" })),
256
+ ...combineds.map((c) => ({ id: c.id, kind: `combined ←${c.reads}` })),
257
+ ].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
258
+ const readRows =
259
+ readFields.length === 0
260
+ ? ""
261
+ : `<TR><TD ALIGN="CENTER" BGCOLOR="#F1F3F4">` +
262
+ `<FONT COLOR="#5F6368" POINT-SIZE="9"><I>projection (derived)</I></FONT></TD></TR>` +
263
+ readFields
264
+ .map(
265
+ (r) =>
266
+ `<TR><TD ALIGN="LEFT"><FONT COLOR="#5F6368"><I>∂ ${htmlEscape(r.id)} : ${htmlEscape(
267
+ r.kind,
268
+ )}</I></FONT></TD></TR>`,
269
+ )
270
+ .join("");
271
+
272
+ return (
273
+ `<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4">` +
274
+ titleRow +
275
+ fieldRows +
276
+ readRows +
277
+ `</TABLE>>`
278
+ );
279
+ }
280
+
281
+ interface DotEdge {
282
+ /** sort key — the full edge text, for deterministic ordering */
283
+ readonly key: string;
284
+ readonly line: string;
285
+ }
286
+
287
+ /**
288
+ * Emit the Graphviz `.dot` ENTITY-RELATIONSHIP diagram for one domain module.
289
+ *
290
+ * PURE + DETERMINISTIC: every collection is sorted, so the same `DomainModule` yields
291
+ * byte-identical output. The result renders cleanly with `dot -Tpng`.
292
+ */
293
+ export function emitDomainDot(mod: DomainModule, opts: DotOptions = {}): string {
294
+ const drawRelations = opts.relations ?? true;
295
+ const drawInvariants = opts.invariants ?? true;
296
+
297
+ const aggregates = [...mod.aggregates].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
298
+ const directives = [...mod.directives].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
299
+ const knownIds = new Set(aggregates.map((a) => a.id));
300
+
301
+ // derived/combined read fields grouped by owner aggregate type.
302
+ const derivedsByType = new Map<string, DerivedDecl[]>();
303
+ for (const d of mod.deriveds ?? []) {
304
+ const list = derivedsByType.get(d.of) ?? [];
305
+ list.push(d);
306
+ derivedsByType.set(d.of, list);
307
+ }
308
+ const combinedsByType = new Map<string, CombinedDecl[]>();
309
+ for (const c of mod.combineds ?? []) {
310
+ const list = combinedsByType.get(c.of) ?? [];
311
+ list.push(c);
312
+ combinedsByType.set(c.of, list);
313
+ }
314
+
315
+ // ---- bounded contexts (deterministic colour assignment) -------------------
316
+ const ctxByAgg = contextOf(aggregates, directives);
317
+ const contextLabels = [...new Set(ctxByAgg.values())].sort();
318
+ const ctxIndex = new Map<string, number>();
319
+ contextLabels.forEach((label, i) => ctxIndex.set(label, i));
320
+ const fillFor = (label: string): string =>
321
+ CONTEXT_FILLS[ctxIndex.get(label)! % CONTEXT_FILLS.length]!;
322
+ const borderFor = (label: string): string =>
323
+ CONTEXT_BORDERS[ctxIndex.get(label)! % CONTEXT_BORDERS.length]!;
324
+
325
+ // ---- header ---------------------------------------------------------------
326
+ const lines: string[] = [];
327
+ lines.push(`// Entity-relationship diagram for domain "${mod.name}"`);
328
+ lines.push(`// generated by the Nomos DSL — codegen_dot.ts (deterministic, sorted)`);
329
+ lines.push(`// boxes = aggregates · crow's-foot edges = refs · clusters = bounded contexts`);
330
+ lines.push(`digraph ${nodeId(mod.name)}_er {`);
331
+ lines.push(` graph [rankdir=LR, splines=polyline, nodesep=0.6, ranksep=1.2, fontname="Helvetica", labelloc="t", label="${attrEscape(mod.name)} — Nomos domain ER"];`);
332
+ lines.push(` node [shape=plaintext, fontname="Helvetica"];`);
333
+ lines.push(` edge [fontname="Helvetica", fontsize=10, color="#5F6368"];`);
334
+ lines.push("");
335
+
336
+ // ---- one cluster per bounded context, nodes sorted within -----------------
337
+ contextLabels.forEach((label, i) => {
338
+ const members = aggregates.filter((a) => ctxByAgg.get(a.id) === label);
339
+ if (members.length === 0) return;
340
+ const fill = fillFor(label);
341
+ const border = borderFor(label);
342
+ lines.push(` subgraph cluster_ctx_${i} {`);
343
+ lines.push(` label="context: ${attrEscape(label)}";`);
344
+ lines.push(` style="rounded,filled"; color="${border}"; fillcolor="${fill}"; fontname="Helvetica-Bold"; fontsize=11;`);
345
+ for (const agg of members) {
346
+ const label2 = aggregateLabel(
347
+ agg,
348
+ border,
349
+ derivedsByType.get(agg.id) ?? [],
350
+ combinedsByType.get(agg.id) ?? [],
351
+ drawInvariants,
352
+ );
353
+ lines.push(` ${nodeId(agg.id)} [label=${label2}];`);
354
+ }
355
+ lines.push(` }`);
356
+ lines.push("");
357
+ });
358
+
359
+ // ---- REF edges (intra/inter-aggregate references) -------------------------
360
+ // crow's-foot: the referencing aggregate is the MANY end (crow + tee = "one or many"
361
+ // pointing at the target's ONE end). We draw arrowtail at the source (crow) and
362
+ // arrowhead at the target (none, classic ER "one"). A ref field's edge is labelled
363
+ // with the field name + cardinality `*..1`.
364
+ const refEdges: DotEdge[] = [];
365
+ for (const agg of aggregates) {
366
+ const fieldNames = Object.keys(agg.fields).sort();
367
+ for (const name of fieldNames) {
368
+ const f = (agg.fields as Record<string, Field>)[name]!;
369
+ if (f.kind !== "ref" || f.refAggregateId === undefined) continue;
370
+ const target = f.refAggregateId;
371
+ const targetNode = knownIds.has(target) ? nodeId(target) : nodeId(target);
372
+ const cross = f.refWorkspace !== undefined ? ` @${f.refWorkspace}` : "";
373
+ // many (source) -> one (target): crow's-foot at the source tail.
374
+ const line =
375
+ ` ${nodeId(agg.id)}:${attrEscape(name)} -> ${targetNode} ` +
376
+ `[arrowtail=crowtee, arrowhead=none, dir=both, ` +
377
+ `label="${attrEscape(name)}${cross}", taillabel="*", headlabel="1"];`;
378
+ refEdges.push({ key: `${agg.id}.${name}->${target}`, line });
379
+ // ensure a referenced-but-undeclared target still appears as a node.
380
+ if (!knownIds.has(target)) {
381
+ refEdges.push({
382
+ key: `~node~${target}`,
383
+ line: ` ${nodeId(target)} [label="${attrEscape(target)}", shape=box, style="dashed,filled", fillcolor="#FFFFFF", color="#9AA0A6"];`,
384
+ });
385
+ }
386
+ }
387
+ }
388
+ // dedupe synthetic-node lines + sort all edges for determinism.
389
+ const seen = new Set<string>();
390
+ const refLines = refEdges
391
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
392
+ .filter((e) => {
393
+ if (seen.has(e.key)) return false;
394
+ seen.add(e.key);
395
+ return true;
396
+ })
397
+ .map((e) => e.line);
398
+ if (refLines.length > 0) {
399
+ lines.push(` // --- references (crow's-foot: * -> 1) ---`);
400
+ lines.push(...refLines);
401
+ lines.push("");
402
+ }
403
+
404
+ // ---- cross-workspace RELATION edges (dashed PR-tier) ----------------------
405
+ if (drawRelations) {
406
+ const relEdges: DotEdge[] = [];
407
+ // relation endpoints (source/target aggregate TYPEs) that are NOT declared in THIS
408
+ // domain — they live in another workspace/domain. Draw them as dashed "external"
409
+ // boxes so the edge lands on a labelled node, not a bare default. Deduped + sorted.
410
+ const externalEndpoints = new Set<string>();
411
+ for (const d of directives) {
412
+ for (const rel of d.declaredRelations ?? []) {
413
+ relEdges.push(relationEdge(rel, knownIds));
414
+ if (!knownIds.has(rel.source)) externalEndpoints.add(rel.source);
415
+ if (!knownIds.has(rel.target)) externalEndpoints.add(rel.target);
416
+ }
417
+ }
418
+ for (const ext of [...externalEndpoints].sort()) {
419
+ relEdges.push({
420
+ key: `~ext~${ext}`,
421
+ line:
422
+ ` ${nodeId(ext)} [label="${attrEscape(ext)}\\n(external domain)", shape=box, ` +
423
+ `style="dashed,filled,rounded", fillcolor="#FFFFFF", color="#9AA0A6", fontcolor="#5F6368"];`,
424
+ });
425
+ }
426
+ const relLines = relEdges
427
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
428
+ .map((e) => e.line);
429
+ if (relLines.length > 0) {
430
+ lines.push(` // --- cross-workspace relations (dashed: PR-tier, source proposes -> target adjudicates) ---`);
431
+ lines.push(...relLines);
432
+ lines.push("");
433
+ }
434
+ }
435
+
436
+ lines.push(`}`);
437
+ // trailing newline keeps the artifact POSIX-clean + byte-stable.
438
+ return lines.join("\n") + "\n";
439
+ }
440
+
441
+ /** Render one cross-workspace relation as a dashed PR-tier edge `source → target`,
442
+ * annotated with the proposed directive + the disclosed read (the disclosure contract). */
443
+ function relationEdge(rel: RelationDecl, knownIds: Set<string>): DotEdge {
444
+ const src = nodeId(rel.source);
445
+ const tgt = nodeId(rel.target);
446
+ const guard = rel.hasInvariant ? " 🛡" : "";
447
+ const select = [...rel.evidence.select].sort().join(",");
448
+ // Each segment is attr-escaped INDIVIDUALLY, then joined with a literal Graphviz line
449
+ // break (`\n` — the two source chars backslash-n, NOT escaped, so the renderer wraps).
450
+ const label = [
451
+ `${attrEscape(rel.id)}${guard}`,
452
+ `proposes ${attrEscape(rel.proposes)}`,
453
+ `discloses {${attrEscape(select)}}`,
454
+ ].join("\\n");
455
+ // a relation proposes a contribution INTO the target → arrow points at the target;
456
+ // it is a 0..* (many sources may propose) to 1..1 (one target directive) PR edge.
457
+ const line =
458
+ ` ${src} -> ${tgt} [style=dashed, color="#A142F4", penwidth=1.5, ` +
459
+ `arrowhead=veevee, arrowtail=odot, dir=both, fontcolor="#A142F4", ` +
460
+ `label="${label}", taillabel="0..*", headlabel="1"];`;
461
+ // best-effort: if an endpoint is unknown it still resolves to a node id (Graphviz
462
+ // creates a default box). `knownIds` is accepted for parity with refEdges; relation
463
+ // endpoints are aggregate TYPE ids that may live in another domain entirely.
464
+ void knownIds;
465
+ return { key: `rel:${rel.id}:${rel.source}->${rel.target}`, line };
466
+ }