boundry 0.3.0 → 0.5.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.
@@ -1,5 +1,109 @@
1
1
  import { LikeC4 } from 'likec4';
2
- import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ /** The generated, derived file holding Boundry's per-layer diff views. */
5
+ const DIFF_FILE = 'boundry.diff.likec4';
6
+ /** The generated view id for a scope — `boundry_diff_root` or `boundry_diff_<fqn>`. */
7
+ function scopeViewId(scope) {
8
+ return scope ? `boundry_diff_${scope.replace(/\./g, '_')}` : 'boundry_diff_root';
9
+ }
10
+ /**
11
+ * The tightest layer that draws an edge between two elements: the deepest
12
+ * element that contains both, i.e. the common prefix of their fqns (undefined =
13
+ * the model root). An edge nested inside a box shows there, not at a wider scope
14
+ * where the box has collapsed — which is exactly why each layer needs its own view.
15
+ */
16
+ function commonScope(from, to) {
17
+ const a = from.split('.');
18
+ const b = to.split('.');
19
+ const shared = [];
20
+ for (let i = 0; i < Math.min(a.length, b.length) && a[i] === b[i]; i++) {
21
+ shared.push(a[i]);
22
+ }
23
+ return shared.length ? shared.join('.') : undefined;
24
+ }
25
+ /** The layer that draws a box: its parent element (undefined = the model root). */
26
+ function parentScope(fqn) {
27
+ const parts = fqn.split('.');
28
+ parts.pop();
29
+ return parts.length ? parts.join('.') : undefined;
30
+ }
31
+ /**
32
+ * Every layer that actually *draws* the edge `from -> to`, so a reviewer can open
33
+ * the change at whatever altitude matters — the whole system, a specific
34
+ * container, or the endpoint's immediate parent — not only the auto-picked common
35
+ * ancestor (issue #7). The set is derived from how LikeC4's `include *` renders a
36
+ * scoped view, verified against 1.58:
37
+ *
38
+ * - The tightest common ancestor draws it nested (both endpoints inside).
39
+ * - Every proper ancestor of an endpoint *below* that common ancestor draws it
40
+ * too: that side's subtree is expanded and the far endpoint is pulled in as a
41
+ * boundary node (e.g. `of s1` → `s1.c1 -> s2`).
42
+ * - Ancestors *above* the common ancestor are deliberately excluded: both
43
+ * endpoints collapse into the same child there, so the edge becomes a
44
+ * self-loop and LikeC4 draws nothing. The common ancestor is the widest layer
45
+ * that still draws it.
46
+ *
47
+ * So the scopes are the common ancestor plus each endpoint's ancestor chain from
48
+ * just below it down to the endpoint's parent — never the endpoints themselves
49
+ * (a view scoped *to* a leaf endpoint has an empty subtree).
50
+ */
51
+ function edgeScopes(from, to) {
52
+ const common = commonScope(from, to);
53
+ const depthOf = (scope) => (scope ? scope.split('.').length : 0);
54
+ const commonDepth = depthOf(common);
55
+ const byId = new Map();
56
+ const add = (scope) => {
57
+ byId.set(scopeViewId(scope), scope);
58
+ };
59
+ add(common);
60
+ for (const endpoint of [from, to]) {
61
+ const parts = endpoint.split('.');
62
+ // Proper ancestors of the endpoint strictly below the common ancestor: prefix
63
+ // lengths (commonDepth + 1) .. (parts.length - 1). The last is the endpoint's
64
+ // parent; the endpoint itself (full length) is never a scope.
65
+ for (let len = commonDepth + 1; len <= parts.length - 1; len++) {
66
+ add(parts.slice(0, len).join('.'));
67
+ }
68
+ }
69
+ return [...byId.values()];
70
+ }
71
+ /**
72
+ * Deterministic highlight rules for the marker tags actually in use. Referencing
73
+ * an undeclared tag is a hard LikeC4 error, and a tag can only be *used* if it is
74
+ * declared — so emitting rules only for seen tags is always safe.
75
+ *
76
+ * Boxes use a style-only `style element.tag` rule: a tagged box is coloured where
77
+ * it already sits and never force-included into a wider view (which would drag a
78
+ * nested proposal out of its collapsed parent, defeating the per-layer framing).
79
+ * Edges use `include … where … with`, coloured and made *solid* so they stand out
80
+ * against LikeC4's dashed default. Amber = addition, red = removal. Verified
81
+ * against LikeC4 1.58.
82
+ */
83
+ function highlightLines(usedTags) {
84
+ const lines = [];
85
+ if (usedTags.has('proposed')) {
86
+ lines.push(' style element.tag = #proposed { color amber }');
87
+ }
88
+ if (usedTags.has('proposal-delete')) {
89
+ lines.push(' style element.tag = #proposal-delete { color red }');
90
+ }
91
+ if (usedTags.has('proposed')) {
92
+ lines.push(' include * -> * where tag is #proposed with { color amber; line solid }');
93
+ }
94
+ if (usedTags.has('proposal-delete')) {
95
+ lines.push(' include * -> * where tag is #proposal-delete with { color red; line solid }');
96
+ }
97
+ return lines;
98
+ }
99
+ /** One `view … { include * }` block, scoped with `of <element>` unless it is root. */
100
+ function renderDiffView(scope, highlight) {
101
+ const opener = scope
102
+ ? ` view ${scopeViewId(scope)} of ${scope} {`
103
+ : ` view ${scopeViewId(scope)} {`;
104
+ const lines = [` title 'Boundry diff · ${scope ?? 'root'}'`, ' include *', ...highlight];
105
+ return `${opener}\n${lines.join('\n')}\n }`;
106
+ }
3
107
  /**
4
108
  * An exemption pattern has to be a usable regex before it reaches the linter. A
5
109
  * broken or empty one would otherwise be a silent hole: it exempts files from
@@ -24,15 +128,57 @@ function assertUsableRegex(pattern, elementId) {
24
128
  }
25
129
  return pattern;
26
130
  }
27
- /** A `#proposed` marker in the source — on an element or a relationship alike. */
28
- function isProposedTag(node, text) {
29
- return (node.$type === 'TagRef' &&
30
- node.$cstNode &&
31
- text.slice(node.$cstNode.offset, node.$cstNode.end) === '#proposed');
131
+ /**
132
+ * Walk the AST through CONTAINMENT only — descending into a property solely when
133
+ * the child names this node as its `$container`. That skips cross-references (a
134
+ * relation's endpoint links to the element it names), so a node is never reached
135
+ * through another's reference to it, and traversal never leaves this document.
136
+ */
137
+ function walkContained(root, visit) {
138
+ const go = (node) => {
139
+ if (!node || typeof node !== 'object')
140
+ return;
141
+ visit(node);
142
+ for (const key of Object.keys(node)) {
143
+ if (key.startsWith('$'))
144
+ continue;
145
+ const child = node[key];
146
+ for (const kid of Array.isArray(child) ? child : [child]) {
147
+ if (kid && typeof kid === 'object' && kid.$container === node)
148
+ go(kid);
149
+ }
150
+ }
151
+ };
152
+ go(root);
153
+ }
154
+ /**
155
+ * The fully-qualified id of an element AST node — its own name and every
156
+ * enclosing element's, joined by '.'. Matches the id the computed model assigns,
157
+ * for nested elements as well as flat ones.
158
+ */
159
+ function fqnOf(astElement) {
160
+ if (!astElement)
161
+ return undefined;
162
+ const parts = [];
163
+ for (let n = astElement; n; n = n.$container) {
164
+ if (n.$type === 'Element' && n.name)
165
+ parts.unshift(n.name);
166
+ }
167
+ return parts.length ? parts.join('.') : undefined;
168
+ }
169
+ /** The element an `a -> b` endpoint (FqnRef) resolves to, or undefined. */
170
+ function endpointElement(fqnRef) {
171
+ return fqnRef?.value?.ref;
172
+ }
173
+ /** The literal source text of a TagRef node, or undefined if it is not one. */
174
+ function tagRefText(node, text) {
175
+ if (node.$type !== 'TagRef' || !node.$cstNode)
176
+ return undefined;
177
+ return text.slice(node.$cstNode.offset, node.$cstNode.end);
32
178
  }
33
179
  /**
34
- * The source to remove for one marker. A marker alone on its line takes the
35
- * whole line (so approving leaves no blank gap); an inline one takes just the
180
+ * The source to remove for a `#proposed` marker. A marker alone on its line takes
181
+ * the whole line (so approving leaves no blank gap); an inline one takes just the
36
182
  * token and the space before it. The `tag proposed` declaration in the
37
183
  * specification is untouched — the vocabulary stays for the next proposal.
38
184
  */
@@ -50,28 +196,150 @@ function proposedRemovalRange(tag, text) {
50
196
  start--;
51
197
  return { start, end };
52
198
  }
53
- function collectProposedRanges(root, text) {
54
- const ranges = [];
55
- const seen = new Set();
56
- const walk = (node) => {
57
- if (!node || typeof node !== 'object' || seen.has(node))
58
- return;
59
- seen.add(node);
60
- if (Array.isArray(node)) {
61
- for (const item of node)
62
- walk(item);
63
- return;
199
+ /**
200
+ * The nearest relationship or element the marker sits in, found via the AST
201
+ * `$container` chain — the true syntactic parent. A plain object-graph walk would
202
+ * follow the cross-reference from a relation's endpoint into the element it names
203
+ * and mis-attribute an element's marker to a relation; `$container` never does.
204
+ */
205
+ function enclosingStatement(tagRef) {
206
+ for (let n = tagRef.$container; n; n = n.$container) {
207
+ if (n.$type === 'Relation' || n.$type === 'Element')
208
+ return n;
209
+ }
210
+ return undefined;
211
+ }
212
+ /**
213
+ * The whole-statement range for a `#proposal-delete` marker: the entire relation
214
+ * or element block, from the start of its first line through its trailing
215
+ * newline, so approving the deletion removes the edge/box and leaves no gap.
216
+ */
217
+ function statementRemovalRange(statement, text) {
218
+ const { offset, end } = statement.$cstNode;
219
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
220
+ const nl = text.indexOf('\n', end);
221
+ return { start: lineStart, end: nl === -1 ? text.length : nl + 1 };
222
+ }
223
+ /** amber for `#proposed`, red for `#proposal-delete` — the intrinsic colour annotate paints. */
224
+ const MARKER_COLOR = {
225
+ '#proposed': 'amber',
226
+ '#proposal-delete': 'red',
227
+ };
228
+ /** The canonical intrinsic style annotate injects for a colour. */
229
+ function canonicalStyle(color) {
230
+ return `style { color ${color} }`;
231
+ }
232
+ /** The direct `*Body` child (ElementBody / RelationBody) of a statement, if any. */
233
+ function statementBody(stmt) {
234
+ for (const key of Object.keys(stmt)) {
235
+ if (key.startsWith('$'))
236
+ continue;
237
+ const child = stmt[key];
238
+ for (const kid of Array.isArray(child) ? child : [child]) {
239
+ if (kid && typeof kid === 'object' && kid.$container === stmt &&
240
+ typeof kid.$type === 'string' && kid.$type.endsWith('Body')) {
241
+ return kid;
242
+ }
64
243
  }
65
- if (isProposedTag(node, text)) {
244
+ }
245
+ return undefined;
246
+ }
247
+ /** The direct `*StyleProperty` children of a body. */
248
+ function styleProperties(body) {
249
+ const out = [];
250
+ for (const key of Object.keys(body)) {
251
+ if (key.startsWith('$'))
252
+ continue;
253
+ const child = body[key];
254
+ for (const kid of Array.isArray(child) ? child : [child]) {
255
+ if (kid && typeof kid === 'object' && kid.$container === body &&
256
+ typeof kid.$type === 'string' && kid.$type.endsWith('StyleProperty')) {
257
+ out.push(kid);
258
+ }
259
+ }
260
+ }
261
+ return out;
262
+ }
263
+ /**
264
+ * The style node in `body` whose source is EXACTLY the canonical `style { color
265
+ * <c> }` annotate writes — matched by whitespace-normalised text, so a richer
266
+ * user-authored style (`style { color amber; icon none }`) is never mistaken for
267
+ * ours and never stripped.
268
+ */
269
+ function canonicalStyleNode(body, color, text) {
270
+ const want = canonicalStyle(color);
271
+ return styleProperties(body).find((s) => s.$cstNode &&
272
+ text.slice(s.$cstNode.offset, s.$cstNode.end).replace(/\s+/g, ' ').trim() === want);
273
+ }
274
+ /** True when `node` is the body's only property — nothing else would survive removing it. */
275
+ function bodyHasOnly(body, node) {
276
+ for (const key of Object.keys(body)) {
277
+ if (key.startsWith('$'))
278
+ continue;
279
+ const child = body[key];
280
+ for (const kid of Array.isArray(child) ? child : [child]) {
281
+ if (kid && typeof kid === 'object' && kid.$container === body && kid !== node)
282
+ return false;
283
+ }
284
+ }
285
+ return true;
286
+ }
287
+ /** A node's whole line, trailing newline included — for stripping a box's style line. */
288
+ function lineRemovalRange(node, text) {
289
+ const { offset, end } = node.$cstNode;
290
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
291
+ const nl = text.indexOf('\n', end);
292
+ return { start: lineStart, end: nl === -1 ? text.length : nl + 1 };
293
+ }
294
+ /** A relation body in full, plus the whitespace before its `{` — turns `a -> b { … }` back into `a -> b`. */
295
+ function bodyRemovalRange(body, text) {
296
+ let start = body.$cstNode.offset;
297
+ while (start > 0 && (text[start - 1] === ' ' || text[start - 1] === '\t'))
298
+ start--;
299
+ return { start, end: body.$cstNode.end };
300
+ }
301
+ /**
302
+ * Every source range to splice out to approve a diagram. A `#proposed` marker
303
+ * strips to its token (the edge/box stays and becomes permanent) AND takes with
304
+ * it any intrinsic `style { color amber }` annotate painted on — for a box, that
305
+ * style's line; for an edge, its whole (now-empty) body, so the edge goes back to
306
+ * bare. A `#proposal-delete` marker takes its whole enclosing statement (the
307
+ * edge/box, and any style on it, are removed outright). Ranges are collected via
308
+ * a containment-only walk — descending solely into true children
309
+ * (`child.$container === node`), never cross-references — so one marker is never
310
+ * counted through another element's reference to it, and no range ever escapes
311
+ * into a different document's text.
312
+ */
313
+ function collectApprovalRanges(root, text) {
314
+ const ranges = [];
315
+ walkContained(root, (node) => {
316
+ const marker = tagRefText(node, text);
317
+ if (marker === '#proposed') {
66
318
  ranges.push(proposedRemovalRange(node, text));
319
+ // Strip the intrinsic amber style annotate paints, so an approved box does
320
+ // not stay amber forever and an approved edge goes back to bare.
321
+ const statement = enclosingStatement(node);
322
+ const body = statement && statementBody(statement);
323
+ const style = body && canonicalStyleNode(body, MARKER_COLOR['#proposed'], text);
324
+ if (style) {
325
+ if (statement.$type === 'Relation' && bodyHasOnly(body, style)) {
326
+ ranges.push(bodyRemovalRange(body, text));
327
+ }
328
+ else {
329
+ ranges.push(lineRemovalRange(style, text));
330
+ }
331
+ }
67
332
  }
68
- for (const key of Object.keys(node)) {
69
- if (!key.startsWith('$'))
70
- walk(node[key]);
333
+ else if (marker === '#proposal-delete') {
334
+ const statement = enclosingStatement(node);
335
+ if (statement?.$cstNode)
336
+ ranges.push(statementRemovalRange(statement, text));
71
337
  }
72
- };
73
- walk(root);
74
- return ranges;
338
+ });
339
+ // A whole-statement removal can contain a `#proposed` token range inside it
340
+ // (a box being deleted that also carried a proposed edge). Drop any range that
341
+ // another wholly contains, so back-to-front splicing never double-cuts.
342
+ return ranges.filter((r) => !ranges.some((o) => o !== r && o.start <= r.start && o.end >= r.end && o.end - o.start > r.end - r.start));
75
343
  }
76
344
  /**
77
345
  * First visualizer adapter. Reads a LikeC4 workspace and lifts it into the
@@ -158,17 +426,29 @@ export class LikeC4Visualizer {
158
426
  };
159
427
  }
160
428
  /**
161
- * Deterministically strip `#proposed` markers from the diagram source,
162
- * promoting intent edges to approved. Source-preserving; never an LLM edit
163
- * it locates each marked relationship in the LikeC4 CST and splices out its
164
- * proposal decoration, leaving all other formatting intact.
429
+ * Deterministically enact a diagram's pending proposals. Source-preserving,
430
+ * never an LLM edit it walks the LikeC4 CST and splices ranges directly:
431
+ * - `#proposed` markers are stripped, promoting their intent edges to
432
+ * approved (the edge/box stays, permanently allowed);
433
+ * - `#proposal-delete` markers take their whole edge or box with them, so
434
+ * approving a proposed removal actually removes it.
435
+ * All other formatting is left intact.
436
+ *
437
+ * The derived `boundry.diff.likec4` is skipped, not approved: it holds no real
438
+ * markers, only view rules that *reference* the marker tags (`style element.tag
439
+ * = #proposed`, `include … where tag is #proposed`). Splicing those `#proposed`
440
+ * tokens out would corrupt the rules into invalid LikeC4. And it is stale the
441
+ * moment the last proposal is enacted — it describes changes that are now
442
+ * approved — so approve deletes it, leaving a workspace `likec4 validate` accepts.
165
443
  */
166
444
  async approve() {
167
445
  const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
168
446
  for (const doc of likec4.LangiumDocuments.all) {
169
447
  const filePath = doc.uri.fsPath;
448
+ if (filePath.endsWith(DIFF_FILE))
449
+ continue;
170
450
  const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
171
- const ranges = collectProposedRanges(doc.parseResult?.value, text);
451
+ const ranges = collectApprovalRanges(doc.parseResult?.value, text);
172
452
  if (ranges.length === 0)
173
453
  continue;
174
454
  let next = text;
@@ -177,5 +457,206 @@ export class LikeC4Visualizer {
177
457
  }
178
458
  writeFileSync(filePath, next);
179
459
  }
460
+ // Enacting the last proposal makes any diff views stale — they frame changes
461
+ // that are now approved. Remove the derived artifact so the post-approve
462
+ // workspace validates and never renders an approved-away change.
463
+ const diffPath = join(this.workspaceDir, DIFF_FILE);
464
+ if (existsSync(diffPath))
465
+ rmSync(diffPath);
466
+ }
467
+ /**
468
+ * Deterministically mark the given edges and elements `#proposed`. The inverse
469
+ * of `approve` for additions: an undeclared new edge (a self-grant) or box is
470
+ * rewritten into an explicit, colourable proposal. It locates each target's
471
+ * relation/element in the CST by its resolved ids and injects the marker,
472
+ * skipping anything already marked, so it is idempotent.
473
+ */
474
+ async propose(edges, moduleIds) {
475
+ if (edges.length === 0 && moduleIds.length === 0)
476
+ return;
477
+ const wantEdge = new Set(edges.map((e) => `${e.from} -> ${e.to}`));
478
+ const wantModule = new Set(moduleIds);
479
+ const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
480
+ for (const doc of likec4.LangiumDocuments.all) {
481
+ const filePath = doc.uri.fsPath;
482
+ if (!filePath.endsWith('.likec4'))
483
+ continue;
484
+ const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
485
+ const inserts = [];
486
+ walkContained(doc.parseResult?.value, (node) => {
487
+ if (node.$type === 'Relation' && node.$cstNode) {
488
+ const from = fqnOf(endpointElement(node.source));
489
+ const to = fqnOf(endpointElement(node.target));
490
+ if (from && to && wantEdge.has(`${from} -> ${to}`)) {
491
+ const { offset, end } = node.$cstNode;
492
+ if (!text.slice(offset, end).includes('#proposed')) {
493
+ inserts.push({ at: end, text: ' #proposed' });
494
+ }
495
+ }
496
+ }
497
+ else if (node.$type === 'Element' && node.$cstNode) {
498
+ const id = fqnOf(node);
499
+ const body = node.body;
500
+ if (id && wantModule.has(id) && body?.$cstNode) {
501
+ const braceStart = body.$cstNode.offset;
502
+ const bodyText = text.slice(braceStart, body.$cstNode.end);
503
+ if (!bodyText.includes('#proposed')) {
504
+ // Insert as the body's first line, matching the indentation of the
505
+ // line that already follows the opening brace.
506
+ const afterBrace = text.indexOf('{', braceStart) + 1;
507
+ const nextLine = text.indexOf('\n', afterBrace) + 1;
508
+ const indent = (text.slice(nextLine).match(/^[ \t]*/) ?? [''])[0];
509
+ inserts.push({ at: afterBrace, text: `\n${indent}#proposed` });
510
+ }
511
+ }
512
+ }
513
+ });
514
+ if (inserts.length === 0)
515
+ continue;
516
+ let next = text;
517
+ for (const { at, text: fragment } of inserts.sort((a, b) => b.at - a.at)) {
518
+ next = next.slice(0, at) + fragment + next.slice(at);
519
+ }
520
+ writeFileSync(filePath, next);
521
+ }
522
+ }
523
+ /**
524
+ * Paint intrinsic style on every marked edge and box, so a proposal is
525
+ * highlighted on *every* LikeC4 surface — base views and the "relationships of
526
+ * X" panel, not just the generated diff views (which only carry view-scoped
527
+ * rules). A `#proposed` edge/box gets `style { color amber }`, a
528
+ * `#proposal-delete` `style { color red }`. Source-preserving and idempotent —
529
+ * anything already carrying the canonical style is left alone. `approve` strips
530
+ * this styling back out when it enacts the marker.
531
+ */
532
+ async styleMarkers() {
533
+ const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
534
+ for (const doc of likec4.LangiumDocuments.all) {
535
+ const filePath = doc.uri.fsPath;
536
+ if (!filePath.endsWith('.likec4') || filePath.endsWith(DIFF_FILE))
537
+ continue;
538
+ const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
539
+ const inserts = [];
540
+ walkContained(doc.parseResult?.value, (node) => {
541
+ const marker = tagRefText(node, text);
542
+ if (marker !== '#proposed' && marker !== '#proposal-delete')
543
+ return;
544
+ const color = MARKER_COLOR[marker];
545
+ const statement = enclosingStatement(node);
546
+ if (!statement)
547
+ return;
548
+ const body = statementBody(statement);
549
+ if (body && canonicalStyleNode(body, color, text))
550
+ return; // already styled
551
+ if (body?.$cstNode) {
552
+ // Style on the line right after the marker, at the marker's indent. It
553
+ // must go AFTER the marker: a body lists its tags before its style
554
+ // properties, so a style ahead of the `#proposed` tag is a parse error.
555
+ const markerEnd = node.$cstNode.end;
556
+ const lineStart = text.lastIndexOf('\n', markerEnd - 1) + 1;
557
+ const indent = (text.slice(lineStart).match(/^[ \t]*/) ?? [''])[0];
558
+ const nl = text.indexOf('\n', markerEnd);
559
+ inserts.push({
560
+ at: nl === -1 ? markerEnd : nl,
561
+ text: `\n${indent}${canonicalStyle(color)}`,
562
+ });
563
+ }
564
+ else {
565
+ // An inline-marked edge (`a -> b #proposed`) has no body — give it one,
566
+ // indented one level past the relation's own line.
567
+ const lineStart = text.lastIndexOf('\n', statement.$cstNode.offset - 1) + 1;
568
+ const indent = (text.slice(lineStart).match(/^[ \t]*/) ?? [''])[0];
569
+ inserts.push({
570
+ at: statement.$cstNode.end,
571
+ text: ` {\n${indent} ${canonicalStyle(color)}\n${indent}}`,
572
+ });
573
+ }
574
+ });
575
+ if (inserts.length === 0)
576
+ continue;
577
+ let next = text;
578
+ for (const { at, text: fragment } of inserts.sort((a, b) => b.at - a.at)) {
579
+ next = next.slice(0, at) + fragment + next.slice(at);
580
+ }
581
+ writeFileSync(filePath, next);
582
+ }
583
+ }
584
+ /**
585
+ * Generate a focused diff view for every layer that holds a pending change.
586
+ * Walks the diagram's own `#proposed` / `#proposal-delete` markers (never the
587
+ * lock — a `#proposal-delete` has no lock delta) and writes one `view … of
588
+ * <scope>` per layer to a derived `boundry.diff.likec4`. A box is bucketed into
589
+ * its parent layer; an edge into *every* layer that draws it (see `edgeScopes`),
590
+ * so a cross-system dependency can be reviewed at each altitude, not only the
591
+ * common-ancestor layer. The file is overwritten each run and removed when
592
+ * nothing is proposed, so it always reflects the current diagram.
593
+ */
594
+ async emitDiffViews() {
595
+ const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
596
+ // scopeViewId -> { scope, changes } — deterministic key, so the same diagram
597
+ // always yields the same set of views.
598
+ const layers = new Map();
599
+ const record = (scope) => {
600
+ const key = scopeViewId(scope);
601
+ const layer = layers.get(key) ?? { scope, changes: 0 };
602
+ layer.changes += 1;
603
+ layers.set(key, layer);
604
+ };
605
+ // Which marker tags actually appear — so the emitted highlight rules only ever
606
+ // reference declared tags (an undeclared tag reference is a hard LikeC4 error).
607
+ const usedTags = new Set();
608
+ for (const doc of likec4.LangiumDocuments.all) {
609
+ const filePath = doc.uri.fsPath;
610
+ if (!filePath.endsWith('.likec4') || filePath.endsWith(DIFF_FILE))
611
+ continue;
612
+ const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
613
+ walkContained(doc.parseResult?.value, (node) => {
614
+ const marker = tagRefText(node, text);
615
+ if (marker !== '#proposed' && marker !== '#proposal-delete')
616
+ return;
617
+ usedTags.add(marker.slice(1)); // drop the leading '#'
618
+ const statement = enclosingStatement(node);
619
+ if (statement?.$type === 'Relation') {
620
+ const from = fqnOf(endpointElement(statement.source));
621
+ const to = fqnOf(endpointElement(statement.target));
622
+ // One view per layer that actually draws this edge, so the reviewer can
623
+ // judge it at every altitude — not only the common-ancestor layer (#7).
624
+ if (from && to)
625
+ for (const scope of edgeScopes(from, to))
626
+ record(scope);
627
+ }
628
+ else if (statement?.$type === 'Element') {
629
+ const fqn = fqnOf(statement);
630
+ if (fqn)
631
+ record(parentScope(fqn));
632
+ }
633
+ });
634
+ }
635
+ const diffPath = join(this.workspaceDir, DIFF_FILE);
636
+ if (layers.size === 0) {
637
+ // Nothing proposed — clear any stale views so `serve` never renders a diff
638
+ // that no longer exists (and never dangles a reference to an approved-away box).
639
+ if (existsSync(diffPath))
640
+ rmSync(diffPath);
641
+ return [];
642
+ }
643
+ // Root first, then by fqn, so the output is stable across runs.
644
+ const ordered = [...layers.values()].sort((a, b) => {
645
+ if (!a.scope)
646
+ return -1;
647
+ if (!b.scope)
648
+ return 1;
649
+ return a.scope.localeCompare(b.scope);
650
+ });
651
+ const highlight = highlightLines(usedTags);
652
+ const body = ordered.map((layer) => renderDiffView(layer.scope, highlight)).join('\n');
653
+ writeFileSync(diffPath, `// Generated by \`boundry diff\` — one focused view per layer with a pending\n` +
654
+ `// change. Derived artifact: regenerate after approve; do not hand-edit.\n` +
655
+ `views {\n${body}\n}\n`);
656
+ return ordered.map((layer) => ({
657
+ id: scopeViewId(layer.scope),
658
+ scope: layer.scope,
659
+ changes: layer.changes,
660
+ }));
180
661
  }
181
662
  }