@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.
- package/LICENSE.md +36 -0
- package/compile_package.mjs +50 -0
- package/package.json +59 -0
- package/src/aggregate.ts +167 -0
- package/src/authoring.ts +119 -0
- package/src/build_package.ts +636 -0
- package/src/certified_read.ts +313 -0
- package/src/codegen_dart.ts +2732 -0
- package/src/codegen_dot.ts +466 -0
- package/src/codegen_provider_dart.ts +358 -0
- package/src/codegen_ts.ts +365 -0
- package/src/codegen_usda.ts +388 -0
- package/src/combined.ts +195 -0
- package/src/compile_engine.ts +567 -0
- package/src/compile_package_main.ts +496 -0
- package/src/compose.ts +317 -0
- package/src/count.ts +218 -0
- package/src/ctx.ts +57 -0
- package/src/derived.ts +138 -0
- package/src/directive.ts +306 -0
- package/src/drivers.ts +95 -0
- package/src/emits_guard.ts +123 -0
- package/src/engine_entry.ts +449 -0
- package/src/exists.ts +170 -0
- package/src/extremum.ts +227 -0
- package/src/fields.ts +291 -0
- package/src/framework/bootstrap.ts +22 -0
- package/src/framework/disclosure.ts +108 -0
- package/src/framework/domain_lifecycle.ts +108 -0
- package/src/framework/identity.ts +537 -0
- package/src/framework/impure_capability.ts +643 -0
- package/src/framework/rbac.ts +418 -0
- package/src/framework/repair.ts +150 -0
- package/src/framework/sync_lifecycle.ts +125 -0
- package/src/framework/workspace_invariant.ts +128 -0
- package/src/framework/workspaces.ts +817 -0
- package/src/index.ts +317 -0
- package/src/manifest.ts +947 -0
- package/src/ops.ts +145 -0
- package/src/ordered_read.ts +228 -0
- package/src/predicate.ts +203 -0
- package/src/query/compile.ts +0 -0
- package/src/query/relations.ts +144 -0
- package/src/query.ts +151 -0
- package/src/read.ts +54 -0
- package/src/relation.ts +189 -0
- package/src/report/csv.ts +54 -0
- package/src/report.ts +401 -0
- package/src/spatial.ts +115 -0
- package/src/sum.ts +194 -0
- package/src/usd.ts +563 -0
- package/src/wire.ts +149 -0
- 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, "&")
|
|
87
|
+
.replace(/</g, "<")
|
|
88
|
+
.replace(/>/g, ">")
|
|
89
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|