@squinch/core 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 (64) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +41 -0
  3. package/dist/api.d.ts +91 -0
  4. package/dist/api.js +232 -0
  5. package/dist/browser.d.ts +3 -0
  6. package/dist/browser.js +9 -0
  7. package/dist/diff/diff.d.ts +30 -0
  8. package/dist/diff/diff.js +365 -0
  9. package/dist/fonts.generated.d.ts +1 -0
  10. package/dist/fonts.generated.js +6 -0
  11. package/dist/grammar/parser.js +22 -0
  12. package/dist/grammar/parser.terms.js +115 -0
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +4 -0
  15. package/dist/layout/layout.d.ts +197 -0
  16. package/dist/layout/layout.js +1721 -0
  17. package/dist/metrics.d.ts +20 -0
  18. package/dist/metrics.generated.d.ts +4 -0
  19. package/dist/metrics.generated.js +4 -0
  20. package/dist/metrics.js +57 -0
  21. package/dist/model/build.d.ts +8 -0
  22. package/dist/model/build.js +1343 -0
  23. package/dist/model/packs.d.ts +13 -0
  24. package/dist/model/packs.js +29 -0
  25. package/dist/model/source.d.ts +10 -0
  26. package/dist/model/source.js +28 -0
  27. package/dist/model/suggest.d.ts +2 -0
  28. package/dist/model/suggest.js +24 -0
  29. package/dist/model/types.d.ts +226 -0
  30. package/dist/model/types.js +24 -0
  31. package/dist/packs/node-fs.d.ts +1 -0
  32. package/dist/packs/node-fs.js +37 -0
  33. package/dist/packs/registry.d.ts +61 -0
  34. package/dist/packs/registry.js +122 -0
  35. package/dist/packs/sanitize.d.ts +12 -0
  36. package/dist/packs/sanitize.js +127 -0
  37. package/dist/packs/sysGlyphs.d.ts +2 -0
  38. package/dist/packs/sysGlyphs.js +21 -0
  39. package/dist/render/adaptive.d.ts +13 -0
  40. package/dist/render/adaptive.js +112 -0
  41. package/dist/render/html/runtime.d.ts +1 -0
  42. package/dist/render/html/runtime.generated.d.ts +1 -0
  43. package/dist/render/html/runtime.generated.js +6 -0
  44. package/dist/render/html/runtime.js +362 -0
  45. package/dist/render/html.d.ts +39 -0
  46. package/dist/render/html.js +235 -0
  47. package/dist/render/svg.d.ts +75 -0
  48. package/dist/render/svg.js +1403 -0
  49. package/dist/render/validate.d.ts +4 -0
  50. package/dist/render/validate.js +9 -0
  51. package/dist/themes/index.d.ts +84 -0
  52. package/dist/themes/index.js +90 -0
  53. package/dist/view/dive.d.ts +55 -0
  54. package/dist/view/dive.js +57 -0
  55. package/dist/view/navigate.d.ts +38 -0
  56. package/dist/view/navigate.js +81 -0
  57. package/dist/view/resolve.d.ts +92 -0
  58. package/dist/view/resolve.js +591 -0
  59. package/fonts/inter-400.ttf +0 -0
  60. package/fonts/inter-500.ttf +0 -0
  61. package/fonts/inter-600.ttf +0 -0
  62. package/fonts/mono-400.ttf +0 -0
  63. package/metrics.json +510 -0
  64. package/package.json +89 -0
@@ -0,0 +1,1343 @@
1
+ import { drawable } from "../metrics.js";
2
+ // @ts-ignore generated
3
+ import { parser } from "../grammar/parser.js";
4
+ import { iconExists, packExists, iconIds, allPackNames } from "./packs.js";
5
+ import { suggest } from "./suggest.js";
6
+ import { normalizeFiles } from "./source.js";
7
+ import { themes } from "../themes/index.js";
8
+ import { ZONE_KINDS, HUES, EDGE_STYLES, EDGE_ANIMATE } from "./types.js";
9
+ export function buildModel(src) {
10
+ return buildProject([{ name: "input", src }]);
11
+ }
12
+ /** Every author-written string that reaches the SVG as text, with where to
13
+ * point when something is wrong with it. Labels, descriptions, taglines,
14
+ * titles and titleblock values all render verbatim; ids never do. */
15
+ function textsInModel(model) {
16
+ const out = [];
17
+ for (const n of model.nodes.values()) {
18
+ out.push([`label \`${n.name}\``, n.label, n.loc, n.file]);
19
+ if (n.description)
20
+ out.push([`description of \`${n.name}\``, n.description, n.loc, n.file]);
21
+ }
22
+ for (const c of model.containers.values()) {
23
+ if (c.label)
24
+ out.push([`label \`${c.name}\``, c.label, c.loc, c.file]);
25
+ const d = c.attrs["description"];
26
+ if (d)
27
+ out.push([`description of \`${c.name}\``, d, c.loc, c.file]);
28
+ }
29
+ for (const e of model.edges)
30
+ if (e.label)
31
+ out.push([`edge label`, e.label, e.loc, e.file]);
32
+ for (const z of model.zones) {
33
+ if (z.label)
34
+ out.push([`zone \`${z.id}\``, z.label, z.loc, z.file]);
35
+ if (z.detail)
36
+ out.push([`detail of zone \`${z.id}\``, z.detail, z.loc, z.file]);
37
+ }
38
+ for (const v of model.views) {
39
+ if (v.title)
40
+ out.push([`title of view \`${v.name}\``, v.title, v.loc, v.file]);
41
+ for (const [k, val] of Object.entries(v.titleblock ?? {}))
42
+ out.push([`titleblock \`${k}\``, val, v.loc, v.file]);
43
+ for (const note of v.notes)
44
+ out.push([`note`, note.text, v.loc, v.file]);
45
+ }
46
+ return out;
47
+ }
48
+ export function buildProject(input) {
49
+ // LF space from here down: every Loc, label and description a caller gets
50
+ // back is measured against normalized source (see model/source.ts).
51
+ const files = normalizeFiles(input);
52
+ const diagnostics = [];
53
+ const model = {
54
+ packs: [],
55
+ nodes: new Map(),
56
+ containers: new Map(),
57
+ edges: [],
58
+ zones: [],
59
+ flows: [],
60
+ views: [],
61
+ };
62
+ const makeCtx = (name, src) => ({
63
+ name,
64
+ src,
65
+ text: (n) => src.slice(n.from, n.to),
66
+ str: (n) => src.slice(n.from + 1, n.to - 1),
67
+ loc: (n) => {
68
+ const before = src.slice(0, n.from);
69
+ return {
70
+ from: n.from,
71
+ to: n.to,
72
+ line: before.split("\n").length,
73
+ col: n.from - before.lastIndexOf("\n"),
74
+ };
75
+ },
76
+ });
77
+ const error = (ctx, at, message, fix) => diagnostics.push({
78
+ severity: "error", message, fix, file: ctx.name,
79
+ loc: "from" in at && "line" in at ? at : ctx.loc(at),
80
+ });
81
+ const warn = (ctx, at, message, fix) => diagnostics.push({
82
+ severity: "warning", message, fix, file: ctx.name,
83
+ loc: "from" in at && "line" in at ? at : ctx.loc(at),
84
+ });
85
+ /**
86
+ * A declared theme name is static — it can be checked the moment the model is
87
+ * built. It used to be validated only inside `render`, and `check` renders
88
+ * every view with an explicit `light`, so a typo'd `theme` passed `check`
89
+ * with "1 file(s) OK" and then failed the very next `render`. For a tool
90
+ * whose loop is check-then-render, check has to be the authority.
91
+ */
92
+ const checkTheme = (ctx, at, name) => {
93
+ if (name in themes)
94
+ return;
95
+ const s = suggest(name, Object.keys(themes));
96
+ error(ctx, at, `unknown theme \`${name}\``, s ? `did you mean \`${s}\`?` : `themes: ${Object.keys(themes).join(" | ")}`);
97
+ };
98
+ /**
99
+ * `color:` takes one of the nine hue words and nothing else. Hex is refused
100
+ * with its own message rather than a did-you-mean: it is not a typo, it is
101
+ * the one thing the vocabulary exists to keep out (a literal cannot be right
102
+ * on both canvases, and the adaptive merge swaps tokens, not values).
103
+ */
104
+ const checkHue = (ctx, at, value) => {
105
+ if (HUES.includes(value))
106
+ return value;
107
+ if (/^(#|rgb|hsl)/i.test(value))
108
+ error(ctx, at, `\`color\` takes a hue, never hex`, `one of: ${HUES.join(" | ")}`);
109
+ else {
110
+ const s = suggest(value, [...HUES]);
111
+ error(ctx, at, `unknown color \`${value}\``, s ? `did you mean \`${s}\`?` : `one of: ${HUES.join(" | ")}`);
112
+ }
113
+ return undefined;
114
+ };
115
+ const attrsOf = (ctx, block) => {
116
+ const attrs = {};
117
+ const tags = [];
118
+ let description;
119
+ if (block)
120
+ for (const a of block.getChildren("Attr")) {
121
+ // Both halves are optional in a *partial* parse, which is the state the
122
+ // editor is in for most of the time anyone is typing: `tags:` with the
123
+ // value not written yet leaves an Attr with no Value, and `tags` with
124
+ // no colon leaves one with no Ident. Asserting either non-null threw
125
+ // `Cannot read properties of null`, killing the build — and with it the
126
+ // language server, on a keystroke. Same class as the round-4 crash on
127
+ // `scope`/`title`/`theme`; this pair was missed because nothing had
128
+ // ever fed the builder a half-written attribute.
129
+ const identNode = a.getChild("Ident");
130
+ const v = a.getChild("Value");
131
+ if (!identNode || !v)
132
+ continue;
133
+ const key = ctx.text(identNode);
134
+ const tagNodes = v.getChildren("Tag");
135
+ if (tagNodes.length) {
136
+ // Only `tags:` collects. This used to key off the *value* having Tag
137
+ // children, so `owner: #team` silently tagged the node — forgiving of
138
+ // a typo'd key, but an accidental tag is a lie in every tag lens.
139
+ if (key === "tags") {
140
+ tags.push(...tagNodes.map((t) => ctx.text(t).slice(1)));
141
+ }
142
+ else {
143
+ const list = tagNodes.map((t) => ctx.text(t)).join(" ");
144
+ error(ctx, a, `\`${key}\` has a tag value — tags live in \`tags:\``, `write \`tags: ${list}\`, or quote it if \`${key}\` really means the text \`"${list}"\``);
145
+ }
146
+ }
147
+ else if (key === "description") {
148
+ if (description !== undefined)
149
+ warn(ctx, a, `\`description\` appears twice — the second wins`, `keep one`);
150
+ description = v.getChild("String") ? ctx.str(v.getChild("String")) : ctx.text(v);
151
+ }
152
+ else {
153
+ if (key in attrs)
154
+ warn(ctx, a, `\`${key}\` appears twice — the second wins`, `keep one`);
155
+ attrs[key] = v.getChild("String") ? ctx.str(v.getChild("String")) : ctx.text(v);
156
+ }
157
+ }
158
+ return { attrs, tags, description };
159
+ };
160
+ // ── phase A: declarations, per file ───────────────────────────────────────
161
+ const rawEdges = [];
162
+ const rawViews = [];
163
+ const rawZones = [];
164
+ const rawFlows = [];
165
+ for (const f of files) {
166
+ const ctx = makeCtx(f.name, f.src);
167
+ const tree = parser.parse(f.src);
168
+ let sawSyntaxError = false;
169
+ const errorLines = new Set(); // one syntax error per line, not a cascade
170
+ // String spans, so text hints below never fire on label text — a label may
171
+ // legitimately read "Orders [US, EU]", and suppressing a real syntax error
172
+ // because of one would be strictly worse than staying quiet.
173
+ const strings = [];
174
+ tree.iterate({
175
+ enter(n) {
176
+ if (n.name === "String")
177
+ strings.push([n.from, n.to]);
178
+ if (n.type.isError) {
179
+ sawSyntaxError = true;
180
+ const line = f.src.slice(0, n.from).split("\n").length;
181
+ if (errorLines.has(line))
182
+ return;
183
+ errorLines.add(line);
184
+ const at = f.src.slice(Math.max(0, n.from - 12), n.from + 12).replace(/\n/g, "⏎");
185
+ error(ctx, n.node, `syntax error near \`${at}\``);
186
+ }
187
+ },
188
+ });
189
+ // The most common authoring mistake: a `layout { }` block inside a
190
+ // system/container. It only manifests as cascading syntax errors, so name
191
+ // it explicitly with the fix.
192
+ if (sawSyntaxError) {
193
+ for (const c of tree.topNode.getChildren("Container")) {
194
+ const bodyText = f.src.slice(c.from, c.to);
195
+ const m = /^[ \t]*layout[ \t]*\{/m.exec(bodyText);
196
+ if (!m)
197
+ continue;
198
+ const identNode = c.getChild("Ident");
199
+ const sys = identNode ? ctx.text(identNode) : "NAME";
200
+ const from = c.from + m.index + (m[0].length - m[0].trimStart().length);
201
+ error(ctx, ctx.loc({ from, to: from + "layout".length }), `\`layout\` block inside \`${sys}\` — layout hints live in views, not systems`, `move it below the system: view ${sys} { layout { … } }`);
202
+ }
203
+ // An opening brace on its own line. The C#/Java habit; statements end
204
+ // at newline, so `system s "S"` terminates and the lone `{` is noise.
205
+ for (const m of f.src.matchAll(/^([ \t]*(?:system|container|zone|view|flow)\b[^{\n]*?)[ \t]*\n[ \t]*\{/gm)) {
206
+ const at = m.index + m[0].lastIndexOf("{");
207
+ if (strings.some(([a2, b2]) => at >= a2 && at < b2))
208
+ continue;
209
+ error(ctx, ctx.loc({ from: at, to: at + 1 }), "the `{` must sit on the declaration's own line — statements end at newline", `write \`${m[1].trim()} {\``);
210
+ for (let i = diagnostics.length - 1; i >= 0; i--) {
211
+ const d = diagnostics[i];
212
+ if (d.file === ctx.name && d.message.startsWith("syntax error near")
213
+ && d.loc.from >= m.index && d.loc.from <= at + 1)
214
+ diagnostics.splice(i, 1);
215
+ }
216
+ }
217
+ // Fan-in. `a -> b, c` fans out, so `x, y -> z` is the guess the mirror
218
+ // image invites — but an edge has one source, and the comma list on the
219
+ // left parses as garbage.
220
+ for (const m of f.src.matchAll(/^[ \t]*([\w.]+(?:[ \t]*,[ \t]*[\w.]+)+)[ \t]*(->|~>|<->|--)[ \t]*([\w.]+)/gm)) {
221
+ if (strings.some(([a2, b2]) => m.index >= a2 && m.index < b2))
222
+ continue;
223
+ const sources = m[1].split(",").map((x) => x.trim());
224
+ error(ctx, ctx.loc({ from: m.index, to: m.index + m[0].length }), `an edge has one source — \`${m[1]}\` cannot fan in`, `write ${sources.map((x) => `\`${x} ${m[2]} ${m[3]}\``).join(", ")}; to draw them as one trunk, add \`channel ${m[1]} ${m[2]} ${m[3]}\` to the view's layout`);
225
+ for (let i = diagnostics.length - 1; i >= 0; i--) {
226
+ const d = diagnostics[i];
227
+ if (d.file === ctx.name
228
+ && (d.message.startsWith("syntax error near") || d.message.startsWith("unknown id"))
229
+ && d.loc.from >= m.index && d.loc.from <= m.index + m[0].length + 20)
230
+ diagnostics.splice(i, 1);
231
+ }
232
+ }
233
+ // The two person forms, crossed. `person analyst "Analyst"` declares one
234
+ // at the top level; `analyst = person "Analyst"` declares one inline.
235
+ // Round 16: an agent wrote `analyst = person analyst "Analyst"` — both
236
+ // at once — and got a bare syntax error. Either form alone is right.
237
+ for (const m of f.src.matchAll(/^[ \t]*([\w-]+)[ \t]*=[ \t]*person[ \t]+([\w-]+)[ \t]+(?=")/gm)) {
238
+ const at = m.index + m[0].length - m[2].length - 1;
239
+ if (strings.some(([a, b]) => at >= a && at < b))
240
+ continue;
241
+ error(ctx, ctx.loc({ from: at, to: at + m[2].length }), `\`${m[1]} = person ${m[2]}\` names the person twice`, `write \`${m[1]} = person\` and keep the label, or \`person ${m[2]}\` on its own`);
242
+ for (let i = diagnostics.length - 1; i >= 0; i--) {
243
+ const d = diagnostics[i];
244
+ if (d.file === ctx.name && d.message.startsWith("syntax error near")
245
+ && d.loc.from >= m.index && d.loc.from <= m.index + m[0].length + 40)
246
+ diagnostics.splice(i, 1);
247
+ }
248
+ }
249
+ // An unquoted attribute value with a space in it. `owner: team-orders`
250
+ // parses, so `owner: payments team` is the natural next guess — and it
251
+ // produced a bare syntax error pointing at the brace. Round 15 lost an
252
+ // iteration to exactly this. Tags are the only legitimately multi-token
253
+ // value and they start with `#`, so requiring a letter first keeps
254
+ // `tags: #a #b` out of the match.
255
+ for (const m of f.src.matchAll(/([\w-]+)[ \t]*:[ \t]*([A-Za-z][\w.-]*(?:[ \t]+[A-Za-z][\w.-]*)+)[ \t]*(?=[\n;,}])/g)) {
256
+ if (strings.some(([a, b]) => m.index >= a && m.index < b))
257
+ continue;
258
+ error(ctx, ctx.loc({ from: m.index, to: m.index + m[0].length }), `\`${m[1]}\` has a space in its value, so it needs quotes`, `write \`${m[1]}: "${m[2]}"\``);
259
+ const end = f.src.indexOf("\n}", m.index);
260
+ const stop = end === -1 ? f.src.length : end + 2;
261
+ for (let i = diagnostics.length - 1; i >= 0; i--) {
262
+ const d = diagnostics[i];
263
+ if (d.file === ctx.name && d.message.startsWith("syntax error near")
264
+ && d.loc.from >= m.index && d.loc.from <= stop)
265
+ diagnostics.splice(i, 1);
266
+ }
267
+ }
268
+ // Commas splitting a tag *value* — `tags: #pci, #core`. Everywhere else
269
+ // a comma is now an optional separator, but not here: after the comma an
270
+ // LR(1) parser cannot tell another tag from the next attribute key, so
271
+ // this one stays illegal and says so instead. (The rank-group version of
272
+ // this diagnostic is gone — `rows [a, b]` parses now.)
273
+ for (const m of f.src.matchAll(/[\w-]+[ \t]*:[ \t]*#[\w-]+(?:[ \t]*,[ \t]*#[\w-]+)+/g)) {
274
+ if (strings.some(([a, b]) => m.index >= a && m.index < b))
275
+ continue;
276
+ const comma = m.index + m[0].indexOf(",");
277
+ error(ctx, ctx.loc({ from: comma, to: comma + 1 }), "comma splits the tag list — tags separate with spaces", `write \`${m[0].replace(/[ \t]*,[ \t]*/g, " ")}\``);
278
+ // One mistake, one diagnostic. A malformed attr block derails the parse
279
+ // for the rest of its enclosing block, not just the matched text, so
280
+ // the window runs to the block's close — same widening the stray-layout
281
+ // case below needed, for the same reason.
282
+ const end = f.src.indexOf("\n}", m.index);
283
+ const stop = end === -1 ? f.src.length : end + 2;
284
+ for (let i = diagnostics.length - 1; i >= 0; i--) {
285
+ const d = diagnostics[i];
286
+ if (d.file === ctx.name && d.message.startsWith("syntax error near")
287
+ && d.loc.from >= m.index && d.loc.from <= stop)
288
+ diagnostics.splice(i, 1);
289
+ }
290
+ }
291
+ // The same mistake one level out: a `layout` block at the top of the
292
+ // file, belonging to no view. It fell through to a bare syntax error
293
+ // pointing at whatever preceded it, which names neither the problem nor
294
+ // the fix. `layout` is only ever legal inside a `view`.
295
+ const containers = tree.topNode.getChildren("Container");
296
+ const views = tree.topNode.getChildren("View");
297
+ for (const m of f.src.matchAll(/^[ \t]*layout[ \t]*\{/gm)) {
298
+ const at = m.index + (m[0].length - m[0].trimStart().length);
299
+ if ([...containers, ...views].some((n) => at >= n.from && at < n.to))
300
+ continue;
301
+ // if the file already declares a view, put it there; otherwise name one
302
+ const first = views[0]?.getChild("Path");
303
+ const into = first ? ctx.text(first) : "main";
304
+ error(ctx, ctx.loc({ from: at, to: at + "layout".length }), "`layout` block at the top level — layout hints live inside a view", first
305
+ ? `move it inside \`view ${into}\``
306
+ : `wrap it: view ${into} { include * layout { … } }`);
307
+ // One mistake, one diagnostic. The stray block derails the parser for
308
+ // the rest of the file, so it also produced a bare `syntax error near`
309
+ // for every line of itself — noise in front of the message that
310
+ // actually names the cause.
311
+ const end = f.src.indexOf("\n}", at);
312
+ const stop = end === -1 ? f.src.length : end + 2;
313
+ for (let i = diagnostics.length - 1; i >= 0; i--) {
314
+ const d = diagnostics[i];
315
+ if (d.file === ctx.name && d.message.startsWith("syntax error near")
316
+ && d.loc.from >= at && d.loc.from <= stop)
317
+ diagnostics.splice(i, 1);
318
+ }
319
+ }
320
+ }
321
+ /** Declare one node; works at any depth, including the file top level. */
322
+ function declareNode(decl, parentPath) {
323
+ const identNode = decl.getChild("Ident");
324
+ if (!identNode)
325
+ return; // partial node from error recovery
326
+ const name = ctx.text(identNode);
327
+ const path = parentPath ? `${parentPath}.${name}` : name;
328
+ const clash = model.nodes.get(path) ?? model.containers.get(path);
329
+ if (clash) {
330
+ error(ctx, decl, `duplicate id \`${name}\` in ${parentPath || "file"}`, clash.file && clash.file !== ctx.name
331
+ ? `already declared in ${clash.file}`
332
+ : `ids must be unique within their container`);
333
+ return;
334
+ }
335
+ const iconRef = decl.getChild("IconRef");
336
+ let icon;
337
+ if (!iconRef && decl.getChild("box"))
338
+ icon = { pack: "builtin", id: "box" };
339
+ if (iconRef && iconRef.getChildren("Ident").length === 2) {
340
+ const [p, i] = iconRef.getChildren("Ident").map(ctx.text);
341
+ if (!packExists(p)) {
342
+ const s = suggest(p, allPackNames());
343
+ error(ctx, iconRef, `unknown pack \`${p}\``, s ? `did you mean \`${s}\`?` : undefined);
344
+ }
345
+ else if (!iconExists(p, i)) {
346
+ const s = suggest(i, iconIds(p));
347
+ error(ctx, iconRef, `unknown icon \`${p}/${i}\``, s ? `did you mean \`${p}/${s}\`?` : `run \`squinch icons search ${i}\``);
348
+ }
349
+ else
350
+ icon = { pack: p, id: i };
351
+ }
352
+ const meta = attrsOf(ctx, decl.getChild("AttrBlock"));
353
+ // `badge:` is an icon reference like `glyph:` and gets the same two
354
+ // errors — an unchecked ref would draw a blank plate, exit 0, and leave
355
+ // the typo to be noticed by eye. The value is in practice a `logos/*`
356
+ // brand mark composited onto the icon plate (SPEC §nodes): the sanctioned
357
+ // way to say "this thing is Databricks'" for vendors that publish no
358
+ // icon grant.
359
+ if (meta.attrs["badge"]) {
360
+ const [p, i] = meta.attrs["badge"].split("/");
361
+ if (!p || !i || !packExists(p)) {
362
+ const s = p && suggest(p, allPackNames());
363
+ error(ctx, decl, `unknown pack \`${p ?? meta.attrs["badge"]}\` in badge`, s ? `did you mean \`${s}/${i ?? ""}\`?` : `use \`badge: <pack>/<id>\``);
364
+ }
365
+ else if (!iconExists(p, i)) {
366
+ const s = suggest(i, iconIds(p));
367
+ error(ctx, decl, `unknown icon \`${p}/${i}\` in badge`, s ? `did you mean \`${p}/${s}\`?` : `run \`squinch icons search ${i}\``);
368
+ }
369
+ }
370
+ const kinds = decl.getChildren("NodeKind").map((k) => ctx.text(k));
371
+ // `= person "Name"` is the same node the top-level `person id "Label"`
372
+ // form builds. Only a direct child matches here — a `person` used as a
373
+ // kind sits inside a `NodeKind`, so the two never collide.
374
+ if (!iconRef && decl.getChild("person")) {
375
+ icon = { pack: "builtin", id: "person" };
376
+ if (!kinds.includes("person"))
377
+ kinds.push("person");
378
+ }
379
+ const labelNode = decl.getChild("String");
380
+ // `#tag` written in kind position (grammar: NodeDecl). Merged with any
381
+ // block `tags:` and de-duped, so both spellings compose on one node.
382
+ const headTags = decl.getChildren("Tag").map((t) => ctx.text(t).slice(1));
383
+ model.nodes.set(path, {
384
+ path, name,
385
+ label: labelNode ? ctx.str(labelNode) : name,
386
+ icon, kinds,
387
+ description: meta.description,
388
+ tags: [...new Set([...headTags, ...meta.tags])],
389
+ attrs: meta.attrs,
390
+ color: meta.attrs["color"] ? checkHue(ctx, decl, meta.attrs["color"]) : undefined,
391
+ loc: ctx.loc(decl),
392
+ file: ctx.name,
393
+ });
394
+ model.containers.get(parentPath)?.children.push(path);
395
+ }
396
+ function walkContainer(body, parentPath) {
397
+ for (const decl of body.getChildren("NodeDecl"))
398
+ declareNode(decl, parentPath);
399
+ for (const sub of body.getChildren("Container"))
400
+ walkContainerDecl(sub, parentPath);
401
+ for (const e of body.getChildren("EdgeStmt"))
402
+ rawEdges.push({ node: e, scope: parentPath, ctx });
403
+ const meta = attrsOf(ctx, body);
404
+ const c = model.containers.get(parentPath);
405
+ if (c) {
406
+ Object.assign(c.attrs, meta.attrs);
407
+ if (meta.description)
408
+ c.attrs["description"] = meta.description;
409
+ c.tags.push(...meta.tags);
410
+ if (meta.attrs["color"])
411
+ c.color = checkHue(ctx, body, meta.attrs["color"]);
412
+ // `glyph:` used to be the one icon reference nobody checked: the view
413
+ // layer splits it on `/` and shrugs, so a typo drew a `?` plate, exited
414
+ // 0, and left you to notice by eye. Same two errors as a zone `icon:`.
415
+ if (meta.attrs["glyph"]) {
416
+ const [p, i] = meta.attrs["glyph"].split("/");
417
+ if (!p || !i || !packExists(p)) {
418
+ const s = p && suggest(p, allPackNames());
419
+ error(ctx, body, `unknown pack \`${p ?? meta.attrs["glyph"]}\` in glyph`, s ? `did you mean \`${s}/${i ?? ""}\`?` : `use \`glyph: <pack>/<id>\``);
420
+ }
421
+ else if (!iconExists(p, i)) {
422
+ const s = suggest(i, iconIds(p));
423
+ error(ctx, body, `unknown icon \`${p}/${i}\` in glyph`, s ? `did you mean \`${p}/${s}\`?` : `run \`squinch icons search ${i}\``);
424
+ }
425
+ }
426
+ }
427
+ }
428
+ function walkContainerDecl(decl, parentPath) {
429
+ const identNode = decl.getChild("Ident");
430
+ if (!identNode)
431
+ return; // partial node from error recovery; syntax error already reported
432
+ const name = ctx.text(identNode);
433
+ const path = parentPath ? `${parentPath}.${name}` : name;
434
+ const clash = model.nodes.get(path) ?? model.containers.get(path);
435
+ if (clash) {
436
+ error(ctx, decl, `duplicate id \`${name}\``, clash.file && clash.file !== ctx.name ? `already declared in ${clash.file}` : undefined);
437
+ return;
438
+ }
439
+ const labelNode = decl.getChild("String");
440
+ const kind = decl.getChild("system") ? "system" : "container";
441
+ // Only `external` means anything on a container: DESIGN §3 gives it the
442
+ // hatched card surface, and "someone else's system" is a fact about the
443
+ // whole system. `datastore` and `person` describe a single node and have
444
+ // no card treatment, so they are refused rather than quietly kept.
445
+ const kinds = [];
446
+ for (const k of decl.getChildren("NodeKind")) {
447
+ const word = ctx.text(k);
448
+ if (word === "external") {
449
+ if (!kinds.length)
450
+ kinds.push("external");
451
+ }
452
+ else
453
+ error(ctx, k, `\`${word}\` on \`${kind} ${name}\` — only \`external\` applies to a ${kind}`, `\`${word}\` describes one node: put it on a node inside \`${name}\`, or drop it`);
454
+ }
455
+ model.containers.set(path, {
456
+ path, name, kind: kind, kinds,
457
+ label: labelNode ? ctx.str(labelNode) : undefined,
458
+ // Head tags (`system s "S" #core {`); a body `tags:` pushes on top of
459
+ // these in walkContainer, and effectiveTags de-dupes downstream.
460
+ children: [], attrs: {},
461
+ tags: decl.getChildren("Tag").map((t) => ctx.text(t).slice(1)),
462
+ loc: ctx.loc(decl), file: ctx.name,
463
+ });
464
+ model.containers.get(parentPath)?.children.push(path);
465
+ const body = decl.getChild("ContainerBody");
466
+ if (body)
467
+ walkContainer(body, path);
468
+ }
469
+ const top = tree.topNode;
470
+ for (const p of top.getChildren("PackStmt")) {
471
+ const identNode = p.getChild("Ident"); // `pack` alone, mid-typing
472
+ if (!identNode)
473
+ continue;
474
+ const name = ctx.text(identNode);
475
+ if (!packExists(name)) {
476
+ const s = suggest(name, allPackNames());
477
+ error(ctx, p, `unknown pack \`${name}\``, s ? `did you mean \`${s}\`?` : undefined);
478
+ }
479
+ else if (!model.packs.includes(name))
480
+ model.packs.push(name);
481
+ }
482
+ const ft = top.getChildren("FileTheme")[0];
483
+ const ftIdent = ft?.getChild("Ident");
484
+ if (ft && !ftIdent)
485
+ error(ctx, ft, "`theme` needs a theme name", "theme dark");
486
+ else if (ftIdent) {
487
+ model.fileTheme = ctx.text(ftIdent);
488
+ checkTheme(ctx, ftIdent, model.fileTheme);
489
+ }
490
+ for (const p of top.getChildren("PersonDecl")) {
491
+ const identNode = p.getChild("Ident");
492
+ if (!identNode)
493
+ continue; // partial node from error recovery
494
+ const name = ctx.text(identNode);
495
+ const labelNode = p.getChild("String");
496
+ model.nodes.set(name, {
497
+ path: name, name,
498
+ label: labelNode ? ctx.str(labelNode) : name,
499
+ icon: { pack: "builtin", id: "person" },
500
+ kinds: ["person"],
501
+ tags: p.getChildren("Tag").map((t) => ctx.text(t).slice(1)),
502
+ attrs: {}, loc: ctx.loc(p), file: ctx.name,
503
+ });
504
+ }
505
+ for (const decl of top.getChildren("NodeDecl"))
506
+ declareNode(decl, "");
507
+ for (const decl of top.getChildren("Container"))
508
+ walkContainerDecl(decl, "");
509
+ for (const e of top.getChildren("EdgeStmt"))
510
+ rawEdges.push({ node: e, scope: "", ctx });
511
+ for (const z of top.getChildren("ZoneDecl"))
512
+ rawZones.push({ node: z, ctx });
513
+ for (const fl of top.getChildren("FlowDecl"))
514
+ rawFlows.push({ node: fl, ctx });
515
+ for (const v of top.getChildren("View"))
516
+ rawViews.push({ node: v, ctx });
517
+ }
518
+ // ── phase B: resolution across all files ─────────────────────────────────
519
+ const allPaths = () => [...model.nodes.keys(), ...model.containers.keys()];
520
+ // id → the names it lists, read straight off the parse tree rather than from
521
+ // `model.zones`, which is not built until phase C: the outer boundary is
522
+ // normally written first, so by the time its `contains` is resolved the inner
523
+ // zone it names has not been seen yet, and a fix that cannot list the members
524
+ // to copy is barely a fix.
525
+ const zoneMemberNames = new Map();
526
+ for (const { node, ctx } of rawZones) {
527
+ const i = node.getChild("Ident");
528
+ const body = node.getChild("ZoneBody");
529
+ if (!i)
530
+ continue;
531
+ // A zone sharing a name with a node or container is legal to the parser
532
+ // and poison to everything downstream: both are addressable in `rows`, so
533
+ // `rows [vpc]` would rank one of them without saying which, and "a zone id
534
+ // is an error where a node is meant" presumes the names never collide.
535
+ const zid = ctx.text(i);
536
+ if (model.nodes.has(zid) || model.containers.has(zid))
537
+ error(ctx, i, `zone \`${zid}\` has the same id as a ${model.nodes.has(zid) ? "node" : "system"}`, `rename one — \`rows\`, \`place\` and edges could name either, and the pick would be silent`);
538
+ zoneMemberNames.set(ctx.text(i), (body?.getChildren("ContainsStmt") ?? []).flatMap((c) => c.getChild("PathList")?.getChildren("Path").map((p) => ctx.text(p)) ?? []));
539
+ }
540
+ /** `zones: true` for the three ranking hints. A zone is one unit for layout —
541
+ * SPEC §5: "ranks apply to the zone as a whole … `rows` can pin a zone by its
542
+ * id" — and `unitOf` has always mapped a zone id to itself, so the engine
543
+ * could rank one all along. Only this function said otherwise, which made a
544
+ * documented capability a hard error and cost agents a `check` in three
545
+ * separate rounds. Everywhere else a zone id is still wrong: you cannot draw
546
+ * an edge to a boundary, and `contains` takes nodes. */
547
+ function resolve(ref, scope, at, ctx, opts = {}) {
548
+ if (opts.zones && zoneMemberNames.has(ref))
549
+ return ref;
550
+ const scopes = [];
551
+ let s = scope;
552
+ while (s) {
553
+ scopes.push(s);
554
+ s = s.includes(".") ? s.slice(0, s.lastIndexOf(".")) : "";
555
+ }
556
+ scopes.push("");
557
+ for (const sc of scopes) {
558
+ const candidate = sc ? `${sc}.${ref}` : ref;
559
+ if (model.nodes.has(candidate) || model.containers.has(candidate))
560
+ return candidate;
561
+ }
562
+ // Naming a zone where a node belongs is the one wrong guess that reads as
563
+ // right: zones nest by *sharing members*, so the outer one repeats the
564
+ // inner one's leaves rather than naming it. Two of twenty round-5 agents
565
+ // wrote `zone account { contains gw, vnet }` with `vnet` a zone of its own,
566
+ // and "unknown id `vnet`" — with no suggestion, since no node is remotely
567
+ // like it — told them nothing about which of the two ideas was wrong.
568
+ // Every zone id, not just the ones declared above this point: zone order in
569
+ // the file is free, and the outer boundary is usually written first.
570
+ // An "id" containing a comma is never a real reference — idents cannot
571
+ // hold one — it is error-recovery debris from a malformed statement (a
572
+ // fan-in like `x, y -> z`), and that line already carries a targeted
573
+ // syntax diagnostic. Reporting `unknown id \`x, y\`` on top adds a second
574
+ // error that reads as a second mistake.
575
+ if (ref.includes(","))
576
+ return undefined;
577
+ if (zoneMemberNames.has(ref)) {
578
+ const inner = zoneMemberNames.get(ref);
579
+ error(ctx, at, `\`${ref}\` is a zone, not a node`, `zones nest by containing the same members — list ${inner.length ? `\`${inner.join("`, `")}\`` : `\`${ref}\`'s own members`} here too, rather than naming \`${ref}\``);
580
+ return undefined;
581
+ }
582
+ // suggest full paths: a bare `create` that only exists as `a.b.create`
583
+ // must be shown with its path, or the fix reads as a no-op.
584
+ const leaf = ref.split(".").pop();
585
+ const candidates = allPaths();
586
+ const sameLeaf = candidates.filter((p) => p.split(".").pop() === leaf && p !== ref);
587
+ const sug = sameLeaf[0] ?? suggest(ref, candidates) ?? suggest(leaf, candidates.map((p) => p.split(".").pop()));
588
+ const shown = sug && !candidates.includes(sug)
589
+ ? candidates.find((p) => p.split(".").pop() === sug) ?? sug
590
+ : sug;
591
+ error(ctx, at, `unknown id \`${ref}\``, shown ? `did you mean \`${shown}\`?` : undefined);
592
+ return undefined;
593
+ }
594
+ let edgeN = 0;
595
+ for (const { node, scope, ctx } of rawEdges) {
596
+ const pathNode = node.getChild("Path");
597
+ const arrowNode = node.getChild("Arrow");
598
+ if (!pathNode || !arrowNode)
599
+ continue; // partial node from error recovery
600
+ const fromPath = resolve(ctx.text(pathNode), scope, node, ctx);
601
+ const arrow = ctx.text(arrowNode);
602
+ const labelNode = node.getChild("String");
603
+ const meta = attrsOf(ctx, node.getChild("AttrBlock"));
604
+ // an edge mid-typing (`a -> `) parses without a PathList — the editor
605
+ // asks us to build on every keystroke, so partial trees must not throw
606
+ const targets = node.getChild("PathList")?.getChildren("Path") ?? [];
607
+ // Attr validation, once per statement. Attrs used to be stored verbatim
608
+ // with no checking, so `animate: banana` rendered as ordinary flow and
609
+ // `animte: false` was a silent no-op — exactly the dropped-hint class the
610
+ // check contract forbids. Values follow the zone-colour pattern; keys get
611
+ // a warning because SPEC names an attr (`description`) that parses ahead
612
+ // of being wired.
613
+ const EDGE_ATTR_KEYS = ["description", "animate", "style", "color"];
614
+ const color = meta.attrs.color ? checkHue(ctx, node, meta.attrs.color) : undefined;
615
+ for (const key of Object.keys(meta.attrs)) {
616
+ if (EDGE_ATTR_KEYS.includes(key))
617
+ continue;
618
+ const sug = suggest(key, EDGE_ATTR_KEYS);
619
+ warn(ctx, node, `unknown edge attribute \`${key}\``, sug ? `did you mean \`${sug}\`?` : `one of: ${EDGE_ATTR_KEYS.join(", ")}`);
620
+ }
621
+ const style = meta.attrs.style;
622
+ if (style !== undefined && !EDGE_STYLES.includes(style)) {
623
+ const sug = suggest(style, [...EDGE_STYLES]);
624
+ error(ctx, node, `unknown edge style \`${style}\``, sug ? `did you mean \`${sug}\`?` : `one of: ${EDGE_STYLES.join(", ")}`);
625
+ }
626
+ const animate = meta.attrs.animate;
627
+ if (animate !== undefined && !EDGE_ANIMATE.includes(animate)) {
628
+ const sug = suggest(animate, [...EDGE_ANIMATE]);
629
+ error(ctx, node, `unknown animate value \`${animate}\``, sug ? `did you mean \`${sug}\`?` : `one of: ${EDGE_ANIMATE.join(", ")}`);
630
+ }
631
+ if (arrow === "~>" && style === "solid")
632
+ error(ctx, node, "async edges are dashed by design — `style: solid` would erase the convention", "use `style: dotted` for a different pattern, or a sync arrow `->` if the call is synchronous");
633
+ // `comet` is deliberately absent: it rides a separate element rather than
634
+ // the stroke, so it needs no dash pattern to be visible. That makes it the
635
+ // only way to animate a synchronous edge without inventing a dash the
636
+ // author never asked for — a feature, not an oversight in this list.
637
+ const travels = animate === "flow" || animate === "reverse" || animate === "slow" || animate === "fast";
638
+ if (arrow !== "~>" && travels && style !== "dashed" && style !== "dotted")
639
+ error(ctx, node, `\`animate: ${animate}\` needs a visible pattern to travel, and this edge is solid`, "add `style: dashed` (or `dotted`), or use `animate: pulse`, which works on solid lines");
640
+ if (animate === "packets" && style !== undefined)
641
+ error(ctx, node, "`animate: packets` draws its own sparse pattern — `style:` has nothing to add", "drop the `style:` attribute");
642
+ for (const target of targets) {
643
+ const toPath = resolve(ctx.text(target), scope, target, ctx);
644
+ edgeN++;
645
+ if (!fromPath || !toPath)
646
+ continue;
647
+ model.edges.push({
648
+ id: `e${edgeN}`,
649
+ from: fromPath, to: toPath, arrow,
650
+ label: labelNode ? ctx.str(labelNode) : undefined,
651
+ attrs: meta.attrs,
652
+ // trailing `#tag`s merge with any in the attr block, same as a node's
653
+ tags: [...new Set([...node.getChildren("Tag").map((t) => ctx.text(t).slice(1)), ...meta.tags])],
654
+ color,
655
+ loc: ctx.loc(node), file: ctx.name,
656
+ });
657
+ }
658
+ }
659
+ // A dashed sync edge in a model with (default-dashed) async edges wears the
660
+ // async convention: same pattern, and the same stroke once `color:` is on
661
+ // both. `dotted` is the sanctioned alternative, so the fix is one word.
662
+ if (model.edges.some((e) => e.arrow === "~>" && e.attrs.style !== "dotted"))
663
+ for (const e of model.edges)
664
+ if (e.arrow !== "~>" && e.attrs.style === "dashed")
665
+ diagnostics.push({
666
+ severity: "warning",
667
+ message: "a dashed sync edge is indistinguishable from an async one — dashes are the async convention",
668
+ fix: "use `style: dotted`, or `~>` if the call really is asynchronous",
669
+ loc: e.loc, file: e.file,
670
+ });
671
+ // duplicate edges (same endpoints + label) merge with a warning
672
+ const seen = new Map();
673
+ model.edges = model.edges.filter((e) => {
674
+ const key = `${e.from}|${e.arrow}|${e.to}|${e.label ?? ""}`;
675
+ if (seen.has(key)) {
676
+ diagnostics.push({
677
+ severity: "warning",
678
+ message: `duplicate edge ${e.from} ${e.arrow} ${e.to} merged`,
679
+ loc: e.loc, file: e.file,
680
+ });
681
+ return false;
682
+ }
683
+ seen.set(key, e);
684
+ return true;
685
+ });
686
+ // DESIGN §3: "Lint nudges labels > ~40 chars." A label wraps to at most two
687
+ // lines and is then ellipsized, so past this it is not a styling preference —
688
+ // the reader loses text that is still in the source, and only a `<title>` or
689
+ // a hover recovers it. 40 is DESIGN's number and it is well chosen: across
690
+ // the 456 labels this repo owns, exactly 4 exceed it, all of them in the
691
+ // lookbook case that exists to stress long labels.
692
+ const LABEL_MAX = 40;
693
+ for (const n of [...model.nodes.values(), ...model.containers.values()]) {
694
+ const label = ("label" in n && n.label) || n.name;
695
+ if (label.length <= LABEL_MAX)
696
+ continue;
697
+ warn({ name: n.file ?? "input" }, n.loc, `label is ${label.length} characters — it will be cut off`, "labels wrap to two lines then ellipsize: shorten it and put the detail in `description:`");
698
+ }
699
+ // Text the bundled faces cannot draw. `measure` falls back to an average
700
+ // advance for an unknown character, so layout stays sane and nothing errors
701
+ // — but the glyph is not in the subset, so it renders as *nothing*. A
702
+ // committed lookbook title read "Payments — money path" and drew "Payments
703
+ // money path" for as long as the em dash sat outside the repertoire, and no
704
+ // gate noticed, because the SVG was valid and deterministic either way.
705
+ //
706
+ // The repertoire covers ASCII plus the punctuation people type; anything
707
+ // past it (an accented name, a CJK label) is a real limitation of a
708
+ // subsetted bundle and the author deserves to hear it here rather than find
709
+ // a hole in the render.
710
+ const undrawable = (text) => [...new Set([...text])].filter((ch) => ch !== "\n" && !drawable(ch));
711
+ for (const [where, text, loc, file] of textsInModel(model)) {
712
+ const missing = undrawable(text);
713
+ if (!missing.length)
714
+ continue;
715
+ warn({ name: file ?? "input" }, loc, `${where} uses ${missing.map((c) => `\`${c}\``).join(", ")}, which the bundled font cannot draw`, `it renders as a gap, not a glyph — use an ASCII equivalent, or open an issue to add ` +
716
+ `${missing.length === 1 ? "it" : "them"} to the subset`);
717
+ }
718
+ // An empty container is legal but almost never meant. It still gets an auto
719
+ // view (SPEC §5), and that view has nothing in it — which used to render a
720
+ // 0×0 SVG that `render` called ok and resvg then refused. A cold agent wrote
721
+ // `system partner "Partner System" external { }` as a stand-in for someone
722
+ // else's estate, which is a node, not a system: you are not modelling its
723
+ // insides, so there is no altitude to descend to.
724
+ for (const c of model.containers.values()) {
725
+ if (c.children.length)
726
+ continue;
727
+ const label = c.label ? `"${c.label}"` : `"${c.name}"`;
728
+ warn({ name: c.file ?? "input" }, c.loc, `\`${c.kind} ${c.name}\` is empty`, `a ${c.kind} you are not breaking down is a node: `
729
+ + `\`${c.name} = box ${label}${c.kinds.includes("external") ? " external" : ""}\``);
730
+ }
731
+ // ── phase C: views ────────────────────────────────────────────────────────
732
+ // flows: chains resolve against the finished namespace AND must walk
733
+ // existing edges — a flow annotates structure, it never creates it
734
+ for (const { node: fl, ctx } of rawFlows) {
735
+ const identNode = fl.getChild("Ident");
736
+ const bodyNode = fl.getChild("FlowBody");
737
+ if (!identNode || !bodyNode)
738
+ continue; // partial node from error recovery
739
+ const id = ctx.text(identNode);
740
+ if (model.flows.some((f) => f.id === id)) {
741
+ error(ctx, fl, `duplicate flow \`${id}\``);
742
+ continue;
743
+ }
744
+ const labelNode = fl.getChild("String");
745
+ // flows read best with bare ids (SPEC example: api -> create -> db) — a
746
+ // bare ref binds when exactly one node path ends with it
747
+ const resolveFlowPath = (ref, at) => {
748
+ if (model.nodes.has(ref) || model.containers.has(ref))
749
+ return ref;
750
+ if (!ref.includes(".")) {
751
+ const matches = [...model.nodes.keys()].filter((k) => k.endsWith(`.${ref}`));
752
+ if (matches.length === 1)
753
+ return matches[0];
754
+ if (matches.length > 1) {
755
+ error(ctx, at, `ambiguous flow step \`${ref}\``, `use a full path: ${matches.join(" | ")}`);
756
+ return undefined;
757
+ }
758
+ }
759
+ return resolve(ref, "", at, ctx);
760
+ };
761
+ const steps = [];
762
+ for (const chain of bodyNode.getChildren("FlowChain")) {
763
+ const paths = chain.getChildren("Path").map((p) => resolveFlowPath(ctx.text(p), p));
764
+ for (let i = 0; i < paths.length - 1; i++) {
765
+ const from = paths[i], to = paths[i + 1];
766
+ if (!from || !to)
767
+ continue;
768
+ const exists = model.edges.some((e) => (e.from === from && e.to === to) ||
769
+ (e.arrow === "<->" && e.from === to && e.to === from));
770
+ if (!exists) {
771
+ error(ctx, chain, `flow \`${id}\` step ${steps.length + 1}: no edge \`${from}\` → \`${to}\``, `flows number existing edges — declare \`${from} -> ${to}\` first`);
772
+ continue;
773
+ }
774
+ steps.push({ from, to });
775
+ }
776
+ }
777
+ if (steps.length === 0)
778
+ warn(ctx, fl, `flow \`${id}\` has no steps`);
779
+ model.flows.push({
780
+ id, steps,
781
+ label: labelNode ? ctx.str(labelNode) : undefined,
782
+ loc: ctx.loc(fl), file: ctx.name,
783
+ });
784
+ }
785
+ // zones: members resolve against the finished namespace (SPEC §Zones)
786
+ for (const { node: z, ctx } of rawZones) {
787
+ const identNode = z.getChild("Ident");
788
+ const bodyNode = z.getChild("ZoneBody");
789
+ if (!identNode || !bodyNode)
790
+ continue; // partial node from error recovery
791
+ const id = ctx.text(identNode);
792
+ if (model.zones.some((existing) => existing.id === id)) {
793
+ error(ctx, z, `duplicate zone \`${id}\``, `zone ids are global — rename one of the declarations`);
794
+ continue;
795
+ }
796
+ const labelNode = z.getChild("String");
797
+ const kindNode = z.getChild("ZoneKind");
798
+ let kind = "custom";
799
+ if (kindNode) {
800
+ const raw = ctx.text(kindNode);
801
+ if (ZONE_KINDS.includes(raw))
802
+ kind = raw;
803
+ else {
804
+ const sug = suggest(raw, [...ZONE_KINDS]);
805
+ error(ctx, kindNode, `unknown zone kind \`${raw}\``, sug ? `did you mean \`${sug}\`?` : `one of: ${ZONE_KINDS.join(", ")}`);
806
+ continue;
807
+ }
808
+ }
809
+ const members = [];
810
+ // `contains <zone-id>` is sugar for the inner zone's own members. Zones
811
+ // nest by *sharing* leaves, and agents reach for naming the inner zone
812
+ // instead — the diagnostic below `resolve` teaches the expansion, and this
813
+ // just performs it, which is what that fix text already tells them to type.
814
+ // Recursive, because the inner zone may itself be written that way; `seen`
815
+ // makes a cycle (or a zone naming itself) terminate as an empty expansion,
816
+ // caught downstream by the has-no-members warning.
817
+ const expandZone = (ref, seen) => {
818
+ if (seen.has(ref))
819
+ return [];
820
+ seen.add(ref);
821
+ return (zoneMemberNames.get(ref) ?? []).flatMap((m) => zoneMemberNames.has(m) ? expandZone(m, seen) : [m]);
822
+ };
823
+ const viaSugar = new Set();
824
+ for (const c of bodyNode.getChildren("ContainsStmt")) {
825
+ const list = c.getChild("PathList");
826
+ if (!list)
827
+ continue;
828
+ for (const p of list.getChildren("Path")) {
829
+ const raw = ctx.text(p);
830
+ const sugar = zoneMemberNames.has(raw);
831
+ for (const ref of sugar ? expandZone(raw, new Set([id])) : [raw]) {
832
+ const resolved = resolve(ref, "", p, ctx);
833
+ if (!resolved)
834
+ continue;
835
+ if (members.includes(resolved)) {
836
+ // an expansion overlapping something listed by hand is nesting
837
+ // working as designed, not a mistake — only warn when the author
838
+ // wrote the same leaf twice themselves
839
+ if (!sugar && !viaSugar.has(resolved))
840
+ warn(ctx, p, `\`${resolved}\` listed twice in zone \`${id}\``);
841
+ }
842
+ else {
843
+ members.push(resolved);
844
+ if (sugar)
845
+ viaSugar.add(resolved);
846
+ }
847
+ }
848
+ }
849
+ }
850
+ if (members.length === 0)
851
+ warn(ctx, z, `zone \`${id}\` has no members`, `add \`contains <path>, …\``);
852
+ // optional chip attrs: icon (pack/id, validated like node icons) and
853
+ // label (which border corner the chip straddles)
854
+ const zAttrs = attrsOf(ctx, bodyNode);
855
+ let icon;
856
+ if (zAttrs.attrs.icon) {
857
+ const [p, i] = zAttrs.attrs.icon.split("/");
858
+ if (!p || !i || !packExists(p)) {
859
+ const s = p && suggest(p, allPackNames());
860
+ error(ctx, z, `unknown pack \`${p ?? zAttrs.attrs.icon}\` in zone icon`, s ? `did you mean \`${s}/${i ?? ""}\`?` : `use \`icon: <pack>/<id>\``);
861
+ }
862
+ else if (!iconExists(p, i)) {
863
+ const s = suggest(i, iconIds(p));
864
+ error(ctx, z, `unknown icon \`${p}/${i}\``, s ? `did you mean \`${p}/${s}\`?` : `run \`squinch icons search ${i}\``);
865
+ }
866
+ else
867
+ icon = { pack: p, id: i };
868
+ }
869
+ // Zones took theme *roles* before the hue vocabulary existed (`account`,
870
+ // `network`, …). Those words now describe what a zone IS, and the kind
871
+ // already tints it; saying so beats a Levenshtein guess that would map
872
+ // `account` to nothing useful.
873
+ const LEGACY_ROLE = {
874
+ account: "red", network: "blue", cloud: "violet", neutral: "gray", ink: "gray", muted: "gray",
875
+ };
876
+ let color;
877
+ if (zAttrs.attrs.color) {
878
+ const v = zAttrs.attrs.color;
879
+ if (v in LEGACY_ROLE)
880
+ error(ctx, z, `\`${v}\` is not a color — zones are tinted by kind`, `to override the tint, use a hue: \`color: ${LEGACY_ROLE[v]}\` (one of: ${HUES.join(" | ")})`);
881
+ else
882
+ color = checkHue(ctx, z, v);
883
+ }
884
+ const LABEL_POS = ["top-left", "top-right", "bottom-left", "bottom-right"];
885
+ let labelPos = "top-left";
886
+ if (zAttrs.attrs.label) {
887
+ if (LABEL_POS.includes(zAttrs.attrs.label))
888
+ labelPos = zAttrs.attrs.label;
889
+ else {
890
+ const s = suggest(zAttrs.attrs.label, LABEL_POS);
891
+ error(ctx, z, `unknown zone label position \`${zAttrs.attrs.label}\``, s ? `did you mean \`${s}\`?` : `one of: ${LABEL_POS.join(", ")}`);
892
+ }
893
+ }
894
+ model.zones.push({
895
+ id, kind, members, icon, labelPos, color,
896
+ detail: zAttrs.attrs.detail,
897
+ label: labelNode ? ctx.str(labelNode) : undefined,
898
+ loc: ctx.loc(z), file: ctx.name,
899
+ });
900
+ }
901
+ for (const { node: v, ctx } of rawViews) {
902
+ const nameNode = v.getChild("Path");
903
+ const bodyNode = v.getChild("ViewBody");
904
+ if (!nameNode || !bodyNode)
905
+ continue; // partial node from error recovery
906
+ const name = ctx.text(nameNode);
907
+ const body = bodyNode;
908
+ const view = {
909
+ name,
910
+ only: [], include: [], includeStar: false, exclude: [], expand: [], expandStar: false, detail: [],
911
+ context: "auto", highlight: [], colors: [], showDescriptions: false, legend: false, notes: [],
912
+ layout: { place: [], routes: [], align: [], channels: [] },
913
+ loc: ctx.loc(v), file: ctx.name,
914
+ };
915
+ // These three took their operand with a `!`, and every one of them threw a
916
+ // raw `Cannot read properties of null` — exit 2, no location, no fix — the
917
+ // moment the operand was missing or the wrong shape. That is any keystroke
918
+ // between typing `title` and typing its string, so it crashed the language
919
+ // server too, and it is what a cold agent hit by writing `scope *` (which
920
+ // parses as a ScopeStmt with no Path). Same class as the `layout`-inside-a-
921
+ // system crash from round 2: a `!` on a node that invalid input makes null.
922
+ const scopeStmt = body.getChildren("ScopeStmt")[0];
923
+ const scopePath = scopeStmt?.getChild("Path");
924
+ if (scopeStmt && !scopePath)
925
+ error(ctx, scopeStmt, "`scope` needs a single container path", "scope narrows a view to one container; to widen a view to everything use `include *`");
926
+ else if (scopePath)
927
+ view.scope = resolve(ctx.text(scopePath), "", scopeStmt, ctx);
928
+ else if (model.containers.has(name))
929
+ view.scope = name; // auto: view <container>
930
+ const title = body.getChildren("TitleStmt")[0];
931
+ const titleStr = title?.getChild("String");
932
+ if (title && !titleStr)
933
+ error(ctx, title, "`title` needs a quoted string", 'title "My Diagram"');
934
+ else if (titleStr)
935
+ view.title = ctx.str(titleStr);
936
+ const theme = body.getChildren("ThemeStmt")[0];
937
+ const themeIdent = theme?.getChild("Ident");
938
+ if (theme && !themeIdent)
939
+ error(ctx, theme, "`theme` needs a theme name", "theme dark");
940
+ else if (themeIdent) {
941
+ view.theme = ctx.text(themeIdent);
942
+ checkTheme(ctx, themeIdent, view.theme);
943
+ }
944
+ const inScope = view.scope ?? "";
945
+ // `only` is the view's filter — the *which* axis, where `scope` is *where*.
946
+ for (const on of body.getChildren("OnlyStmt")) {
947
+ for (const t of on.getChild("TargetList")?.getChildren("Target") ?? []) {
948
+ const tag = t.getChild("Tag");
949
+ if (tag)
950
+ view.only.push({ tag: ctx.text(tag).slice(1) });
951
+ else {
952
+ const path = t.getChild("Path");
953
+ const r = path && resolve(ctx.text(path), inScope, t, ctx);
954
+ if (r)
955
+ view.only.push(r);
956
+ }
957
+ }
958
+ }
959
+ for (const inc of body.getChildren("IncludeStmt")) {
960
+ if (inc.getChild("Star"))
961
+ view.includeStar = true;
962
+ for (const t of inc.getChild("TargetList")?.getChildren("Target") ?? []) {
963
+ const tag = t.getChild("Tag");
964
+ if (tag)
965
+ view.include.push({ tag: ctx.text(tag).slice(1) });
966
+ else {
967
+ const path = t.getChild("Path");
968
+ const r = path && resolve(ctx.text(path), inScope, t, ctx);
969
+ if (r)
970
+ view.include.push(r);
971
+ }
972
+ }
973
+ }
974
+ for (const exc of body.getChildren("ExcludeStmt")) {
975
+ for (const t of exc.getChild("TargetList")?.getChildren("Target") ?? []) {
976
+ const tag = t.getChild("Tag");
977
+ if (tag)
978
+ view.exclude.push({ tag: ctx.text(tag).slice(1) });
979
+ else {
980
+ const path = t.getChild("Path");
981
+ const r = path && resolve(ctx.text(path), inScope, t, ctx);
982
+ if (r)
983
+ view.exclude.push(r);
984
+ }
985
+ }
986
+ }
987
+ // `detail` carries what `include` used to smuggle: draw an outside element
988
+ // at its own depth rather than as its top-level context card.
989
+ for (const d of body.getChildren("DetailStmt")) {
990
+ const path = d.getChild("Path");
991
+ if (!path) {
992
+ error(ctx, d, "`detail` needs a path", "detail web.app");
993
+ continue;
994
+ }
995
+ const r = resolve(ctx.text(path), inScope, d, ctx);
996
+ if (r)
997
+ view.detail.push(r);
998
+ }
999
+ for (const ex of body.getChildren("ExpandStmt")) {
1000
+ if (ex.getChild("Star")) {
1001
+ view.expandStar = true;
1002
+ continue;
1003
+ }
1004
+ const path = ex.getChild("Path");
1005
+ const r = path && resolve(ctx.text(path), inScope, ex, ctx);
1006
+ if (r)
1007
+ view.expand.push(r);
1008
+ }
1009
+ const ctxStmt = body.getChildren("ContextStmt")[0];
1010
+ if (ctxStmt)
1011
+ view.context = ctxStmt.getChild("off") ? "off" : "auto";
1012
+ for (const h of body.getChildren("HighlightStmt"))
1013
+ view.highlight.push(...h.getChildren("Tag").map((t) => ctx.text(t).slice(1)));
1014
+ for (const c of body.getChildren("ColorStmt")) {
1015
+ // `color #x` with the hue not yet typed is the editor's state on most
1016
+ // keystrokes — skip, never throw
1017
+ const tagNode = c.getChild("Tag");
1018
+ const hueNode = c.getChildren("Ident").pop();
1019
+ if (!tagNode || !hueNode)
1020
+ continue;
1021
+ const hue = checkHue(ctx, c, ctx.text(hueNode));
1022
+ if (!hue)
1023
+ continue;
1024
+ const tag = ctx.text(tagNode).slice(1);
1025
+ const prior = view.colors.find((x) => x.tag === tag);
1026
+ if (prior && prior.hue !== hue)
1027
+ warn(ctx, c, `color #${tag} is stated twice with different hues — the last wins`, `keep one \`color #${tag}\` line`);
1028
+ view.colors.push({ tag, hue, loc: ctx.loc(c) });
1029
+ }
1030
+ for (const show of body.getChildren("ShowStmt")) {
1031
+ const flowKw = show.getChild("flow");
1032
+ if (!flowKw) {
1033
+ view.showDescriptions = true;
1034
+ continue;
1035
+ }
1036
+ const idNode = show.getChildren("Ident").pop();
1037
+ if (!idNode)
1038
+ continue;
1039
+ const flowId = ctx.text(idNode);
1040
+ if (!model.flows.some((f) => f.id === flowId)) {
1041
+ const s = suggest(flowId, model.flows.map((f) => f.id));
1042
+ error(ctx, show, `unknown flow \`${flowId}\``, s ? `did you mean \`${s}\`?` : `declare it: flow ${flowId} { a -> b -> c }`);
1043
+ continue;
1044
+ }
1045
+ view.showFlow = flowId;
1046
+ }
1047
+ const legendStmt = body.getChildren("LegendStmt")[0];
1048
+ if (legendStmt)
1049
+ view.legend = !legendStmt.getChild("off");
1050
+ const tb = body.getChildren("TitleBlockStmt")[0];
1051
+ if (tb)
1052
+ view.titleblock = attrsOf(ctx, tb.getChild("AttrBlock")).attrs;
1053
+ for (const n of body.getChildren("NoteStmt")) {
1054
+ // half-typed `note` — anchor and text both arrive later
1055
+ const anchorNode = n.getChild("NoteAnchor");
1056
+ const textNode = n.getChild("String");
1057
+ if (!anchorNode || !textNode)
1058
+ continue;
1059
+ const noteText = ctx.str(textNode);
1060
+ const meta = attrsOf(ctx, n.getChild("AttrBlock"));
1061
+ let anchor;
1062
+ const relpos = anchorNode.getChild("RelPos");
1063
+ const corner = anchorNode.getChild("Corner");
1064
+ if (relpos) {
1065
+ const pathNode = anchorNode.getChild("Path");
1066
+ if (!pathNode)
1067
+ continue; // `note right-of` with the target unwritten
1068
+ const r = resolve(ctx.text(pathNode), inScope, anchorNode, ctx);
1069
+ if (r)
1070
+ anchor = { kind: "relpos", relpos: ctx.text(relpos), target: r };
1071
+ }
1072
+ else if (corner) {
1073
+ anchor = { kind: "corner", corner: ctx.text(corner) };
1074
+ }
1075
+ else {
1076
+ const [a, b] = anchorNode.getChildren("Path");
1077
+ const from = resolve(ctx.text(a), inScope, a, ctx);
1078
+ const to = resolve(ctx.text(b), inScope, b, ctx);
1079
+ if (from && to)
1080
+ anchor = { kind: "edge", from, to };
1081
+ }
1082
+ if (anchor)
1083
+ view.notes.push({ anchor, text: noteText, style: meta.attrs["style"], loc: ctx.loc(n) });
1084
+ }
1085
+ for (const lb of body.getChildren("LayoutBlock")) {
1086
+ const dir = lb.getChildren("DirectionStmt")[0];
1087
+ if (dir)
1088
+ view.layout.direction = ctx.text(dir.lastChild);
1089
+ const den = lb.getChildren("DensityStmt")[0];
1090
+ const denIdent = den?.getChild("Ident");
1091
+ if (denIdent) {
1092
+ const val = ctx.text(denIdent);
1093
+ if (val === "compact" || val === "comfortable" || val === "spacious")
1094
+ view.layout.density = val;
1095
+ else
1096
+ error(ctx, den, `unknown density \`${val}\``, "use compact | comfortable | spacious");
1097
+ }
1098
+ const lin = lb.getChildren("LinesStmt")[0];
1099
+ const linIdent = lin?.getChild("Ident");
1100
+ if (linIdent) {
1101
+ const val = ctx.text(linIdent);
1102
+ if (val === "orthogonal" || val === "curved" || val === "straight")
1103
+ view.layout.lines = val;
1104
+ else
1105
+ error(ctx, lin, `unknown lines style \`${val}\``, "use orthogonal | curved | straight");
1106
+ }
1107
+ for (const al of lb.getChildren("AlignStmt")) {
1108
+ const nodes = al.getChildren("Path")
1109
+ .map((pathNode) => resolve(ctx.text(pathNode), inScope, pathNode, ctx))
1110
+ .filter((r) => !!r);
1111
+ if (nodes.length >= 2)
1112
+ view.layout.align.push({ nodes, loc: ctx.loc(al) });
1113
+ else if (nodes.length === 1)
1114
+ warn(ctx, al, "`align` needs at least two elements", "align a b — b takes a's axis");
1115
+ }
1116
+ const rowsStmt = lb.getChildren("RowsStmt")[0];
1117
+ if (rowsStmt) {
1118
+ const rows = [];
1119
+ const placed = new Set();
1120
+ for (const rank of rowsStmt.getChildren("Rank")) {
1121
+ const row = [];
1122
+ for (const pathNode of rank.getChildren("Path")) {
1123
+ const r = resolve(ctx.text(pathNode), inScope, pathNode, ctx, { zones: true });
1124
+ if (!r)
1125
+ continue;
1126
+ if (placed.has(r)) {
1127
+ error(ctx, pathNode, `\`${ctx.text(pathNode)}\` appears in \`rows\` twice`, "a node can hold only one rank position; remove one occurrence");
1128
+ continue;
1129
+ }
1130
+ placed.add(r);
1131
+ row.push(r);
1132
+ }
1133
+ rows.push(row);
1134
+ }
1135
+ view.layout.rows = rows;
1136
+ }
1137
+ const colsStmt = lb.getChildren("ColsStmt")[0];
1138
+ if (colsStmt) {
1139
+ const cols = [];
1140
+ const placed = new Set();
1141
+ for (const rank of colsStmt.getChildren("Rank")) {
1142
+ const col = [];
1143
+ for (const pathNode of rank.getChildren("Path")) {
1144
+ const r = resolve(ctx.text(pathNode), inScope, pathNode, ctx, { zones: true });
1145
+ if (!r)
1146
+ continue;
1147
+ if (placed.has(r)) {
1148
+ error(ctx, pathNode, `\`${ctx.text(pathNode)}\` appears in \`cols\` twice`, "a node can hold only one column position; remove one occurrence");
1149
+ continue;
1150
+ }
1151
+ placed.add(r);
1152
+ col.push(r);
1153
+ }
1154
+ cols.push(col);
1155
+ }
1156
+ view.layout.cols = cols;
1157
+ }
1158
+ for (const ch of lb.getChildren("ChannelStmt")) {
1159
+ const list = ch.getChild("PathList");
1160
+ const targetNode = ch.getChildren("Path").at(-1);
1161
+ if (!list || !targetNode)
1162
+ continue; // partial node from error recovery
1163
+ const target = resolve(ctx.text(targetNode), inScope, targetNode, ctx);
1164
+ const sources = list.getChildren("Path")
1165
+ .filter((p) => p !== targetNode)
1166
+ .map((p) => resolve(ctx.text(p), inScope, p, ctx))
1167
+ .filter((r) => !!r);
1168
+ if (!target)
1169
+ continue;
1170
+ if (sources.length < 2) {
1171
+ warn(ctx, ch, "`channel` needs at least two sources", "one source is just an edge — use `route` to steer it");
1172
+ continue;
1173
+ }
1174
+ view.layout.channels.push({ sources, target, loc: ctx.loc(ch) });
1175
+ }
1176
+ for (const pl of lb.getChildren("PlaceStmt")) {
1177
+ const [a, b] = pl.getChildren("Path");
1178
+ const node = resolve(ctx.text(a), inScope, a, ctx, { zones: true });
1179
+ const relposNode = pl.getChild("RelPos");
1180
+ const target = b ? resolve(ctx.text(b), inScope, b, ctx, { zones: true }) : undefined;
1181
+ if (node && target && relposNode)
1182
+ view.layout.place.push({
1183
+ node, target,
1184
+ relpos: ctx.text(relposNode),
1185
+ loc: ctx.loc(pl),
1186
+ });
1187
+ }
1188
+ for (const rt of lb.getChildren("RouteStmt")) {
1189
+ const [a, b] = rt.getChildren("Path");
1190
+ const from = resolve(ctx.text(a), inScope, a, ctx);
1191
+ const to = resolve(ctx.text(b), inScope, b, ctx);
1192
+ const sidesNodes = rt.getChildren("Side");
1193
+ const labelNode = rt.getChild("String");
1194
+ if (from && to)
1195
+ view.layout.routes.push({
1196
+ from, to,
1197
+ label: labelNode ? ctx.str(labelNode) : undefined,
1198
+ fromSide: sidesNodes[0] ? ctx.text(sidesNodes[0]) : undefined,
1199
+ toSide: sidesNodes[1] ? ctx.text(sidesNodes[1]) : undefined,
1200
+ loc: ctx.loc(rt),
1201
+ });
1202
+ }
1203
+ }
1204
+ // hint-conflict checks (SPEC §6: contradictions are errors, never silent)
1205
+ for (const pl of view.layout.place) {
1206
+ const opposite = view.layout.place.find((o) => o !== pl && o.node === pl.target && o.target === pl.node);
1207
+ if (opposite)
1208
+ diagnostics.push({
1209
+ severity: "error",
1210
+ message: `contradictory place hints: \`${pl.node}\` vs \`${pl.target}\` reference each other`,
1211
+ fix: "remove one of the two place statements",
1212
+ loc: pl.loc, file: ctx.name,
1213
+ });
1214
+ // A node in a band already has a position, so a `place` on it is a second
1215
+ // opinion — but a second opinion is only a *conflict* when it disagrees.
1216
+ //
1217
+ // This check has been wrong twice, in opposite directions. It first read
1218
+ // `(rows && relpos === "right-of") || relpos === "left-of"`, which bound
1219
+ // the wrong way and listed only the horizontal directions, so `place x
1220
+ // above y` on a banded node sailed through with one hint silently
1221
+ // ignored. Widening it to "in a band at all" then went too far the other
1222
+ // way: four of twenty cold agents wrote `rows [db bus]` alongside `place
1223
+ // bus right-of db` and were refused, though the two say the same thing.
1224
+ // Restating a band's own order is how people reinforce intent, not how
1225
+ // they contradict it, and no amount of documentation stopped them — the
1226
+ // rate held across two rounds of skill fixes, because they were right.
1227
+ const bands = view.layout.rows
1228
+ ? { kind: "rows", at: view.layout.rows }
1229
+ : view.layout.cols
1230
+ ? { kind: "cols", at: view.layout.cols }
1231
+ : undefined;
1232
+ const where = (n) => {
1233
+ const b = bands?.at.findIndex((band) => band.includes(n)) ?? -1;
1234
+ return b < 0 ? undefined : { band: b, pos: bands.at[b].indexOf(n) };
1235
+ };
1236
+ const node = where(pl.node);
1237
+ if (bands && node) {
1238
+ // In `rows`, bands run top to bottom and members left to right; in
1239
+ // `cols` it is the transpose. So one axis is the band index and the
1240
+ // other the position within it, and which is which flips with `kind`.
1241
+ const target = where(pl.target);
1242
+ const along = bands.kind === "rows" ? ["right-of", "left-of"] : ["below", "above"];
1243
+ const agrees = target &&
1244
+ (along.includes(pl.relpos)
1245
+ ? // same band, and immediately beside — `place` means adjacent
1246
+ node.band === target.band &&
1247
+ node.pos === target.pos + (pl.relpos === "right-of" || pl.relpos === "below" ? 1 : -1)
1248
+ : // the perpendicular axis: the neighbouring band, on the right side
1249
+ node.pos === target.pos &&
1250
+ node.band ===
1251
+ target.band + (pl.relpos === "below" || pl.relpos === "right-of" ? 1 : -1));
1252
+ if (!agrees)
1253
+ diagnostics.push({
1254
+ severity: "error",
1255
+ message: !target
1256
+ ? `\`${pl.node}\` is listed in \`${bands.kind}\` but is placed relative to \`${pl.target}\`, which is not`
1257
+ : `\`${pl.node}\` is placed \`${pl.relpos} ${pl.target}\`, but \`${bands.kind}\` puts it somewhere else`,
1258
+ fix: !target
1259
+ ? `add \`${pl.target}\` to ${bands.kind} too, or drop \`${pl.node}\` from ${bands.kind}`
1260
+ : `${bands.kind} already positions both; make them agree, or drop \`${pl.node}\` from ${bands.kind}`,
1261
+ loc: pl.loc, file: ctx.name,
1262
+ });
1263
+ }
1264
+ }
1265
+ if (model.views.some((other) => other.name === view.name))
1266
+ error(ctx, view.loc, `duplicate view \`${view.name}\``, `\`render --view ${view.name}\` would pick one of them silently — rename or merge`);
1267
+ model.views.push(view);
1268
+ }
1269
+ // SPEC §5: every container gets a default view, so zoom navigation always has
1270
+ // somewhere to land. Declaring `view <path>` explicitly customizes that view.
1271
+ const scoped = new Set(model.views.map((v) => v.scope).filter(Boolean));
1272
+ for (const path of model.containers.keys()) {
1273
+ if (scoped.has(path))
1274
+ continue;
1275
+ model.views.push({
1276
+ name: path,
1277
+ scope: path,
1278
+ auto: true,
1279
+ only: [], include: [], includeStar: false, exclude: [], expand: [], expandStar: false, detail: [],
1280
+ context: "auto", highlight: [], colors: [], showDescriptions: false, legend: false, notes: [],
1281
+ layout: { place: [], routes: [], align: [], channels: [] },
1282
+ loc: model.containers.get(path).loc,
1283
+ file: model.containers.get(path).file,
1284
+ });
1285
+ }
1286
+ // One mistake, one diagnostic — the rule the syntax pass and the align/cols
1287
+ // collapse already follow, applied to the loudest class there is. Round 16:
1288
+ // an agent declared nine nodes inside `system warehouse` and then referenced
1289
+ // them unqualified from a zone and a layout, and `check` answered with
1290
+ // twenty-eight `unknown id`s. Each one named its own fix, so the agent did
1291
+ // recover — but a wall of twenty-eight errors hides that it is a single
1292
+ // misunderstanding about scope, which is the thing worth telling them.
1293
+ collapseUnknownIds(diagnostics);
1294
+ return {
1295
+ model,
1296
+ diagnostics,
1297
+ ok: !diagnostics.some((d) => d.severity === "error"),
1298
+ };
1299
+ }
1300
+ /** Fold N `unknown id` errors that all want the same container prefix into one.
1301
+ * Conservative on purpose: only when every one of them agrees on the prefix,
1302
+ * and only from three up — two is not yet a pattern, and naming the ids
1303
+ * individually is more useful at that size. */
1304
+ function collapseUnknownIds(diagnostics) {
1305
+ const byPrefix = new Map();
1306
+ diagnostics.forEach((d, idx) => {
1307
+ if (d.severity !== "error")
1308
+ return;
1309
+ const ref = /^unknown id `([^`]+)`$/.exec(d.message)?.[1];
1310
+ const sug = d.fix && /^did you mean `([^`]+)`\?$/.exec(d.fix)?.[1];
1311
+ if (!ref || !sug || !sug.endsWith(`.${ref}`))
1312
+ return;
1313
+ const prefix = sug.slice(0, -(ref.length + 1));
1314
+ if (!byPrefix.has(prefix))
1315
+ byPrefix.set(prefix, []);
1316
+ byPrefix.get(prefix).push({ idx, ref });
1317
+ });
1318
+ for (const [prefix, hits] of byPrefix) {
1319
+ if (hits.length < 3)
1320
+ continue;
1321
+ const ids = [...new Set(hits.map((h) => h.ref))];
1322
+ const shown = ids.slice(0, 3).map((r) => `\`${r}\``).join(", ");
1323
+ const more = ids.length > 3 ? `, and ${ids.length - 3} more` : "";
1324
+ diagnostics[hits[0].idx] = {
1325
+ ...diagnostics[hits[0].idx],
1326
+ message: `${ids.length} ids are missing their \`${prefix}\` prefix — ${shown}${more}`,
1327
+ fix: `ids declared inside \`${prefix}\` are written \`${prefix}.<id>\` from outside it`,
1328
+ };
1329
+ for (const h of hits.slice(1))
1330
+ diagnostics[h.idx] = null;
1331
+ }
1332
+ for (let i = diagnostics.length - 1; i >= 0; i--)
1333
+ if (!diagnostics[i])
1334
+ diagnostics.splice(i, 1);
1335
+ }
1336
+ export function formatDiagnostics(diags, file = "input") {
1337
+ return diags
1338
+ .map((d) => {
1339
+ const fix = d.fix ? `\n ${d.fix}` : "";
1340
+ return `${d.file ?? file}:${d.loc.line}:${d.loc.col} ${d.severity}: ${d.message}${fix}`;
1341
+ })
1342
+ .join("\n");
1343
+ }