boundry 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,69 @@
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
+ * Deterministic highlight rules for the marker tags actually in use. Referencing
33
+ * an undeclared tag is a hard LikeC4 error, and a tag can only be *used* if it is
34
+ * declared — so emitting rules only for seen tags is always safe.
35
+ *
36
+ * Boxes use a style-only `style element.tag` rule: a tagged box is coloured where
37
+ * it already sits and never force-included into a wider view (which would drag a
38
+ * nested proposal out of its collapsed parent, defeating the per-layer framing).
39
+ * Edges use `include … where … with`, coloured and made *solid* so they stand out
40
+ * against LikeC4's dashed default. Amber = addition, red = removal. Verified
41
+ * against LikeC4 1.58.
42
+ */
43
+ function highlightLines(usedTags) {
44
+ const lines = [];
45
+ if (usedTags.has('proposed')) {
46
+ lines.push(' style element.tag = #proposed { color amber }');
47
+ }
48
+ if (usedTags.has('proposal-delete')) {
49
+ lines.push(' style element.tag = #proposal-delete { color red }');
50
+ }
51
+ if (usedTags.has('proposed')) {
52
+ lines.push(' include * -> * where tag is #proposed with { color amber; line solid }');
53
+ }
54
+ if (usedTags.has('proposal-delete')) {
55
+ lines.push(' include * -> * where tag is #proposal-delete with { color red; line solid }');
56
+ }
57
+ return lines;
58
+ }
59
+ /** One `view … { include * }` block, scoped with `of <element>` unless it is root. */
60
+ function renderDiffView(scope, highlight) {
61
+ const opener = scope
62
+ ? ` view ${scopeViewId(scope)} of ${scope} {`
63
+ : ` view ${scopeViewId(scope)} {`;
64
+ const lines = [` title 'Boundry diff · ${scope ?? 'root'}'`, ' include *', ...highlight];
65
+ return `${opener}\n${lines.join('\n')}\n }`;
66
+ }
3
67
  /**
4
68
  * An exemption pattern has to be a usable regex before it reaches the linter. A
5
69
  * broken or empty one would otherwise be a silent hole: it exempts files from
@@ -24,15 +88,57 @@ function assertUsableRegex(pattern, elementId) {
24
88
  }
25
89
  return pattern;
26
90
  }
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');
91
+ /**
92
+ * Walk the AST through CONTAINMENT only — descending into a property solely when
93
+ * the child names this node as its `$container`. That skips cross-references (a
94
+ * relation's endpoint links to the element it names), so a node is never reached
95
+ * through another's reference to it, and traversal never leaves this document.
96
+ */
97
+ function walkContained(root, visit) {
98
+ const go = (node) => {
99
+ if (!node || typeof node !== 'object')
100
+ return;
101
+ visit(node);
102
+ for (const key of Object.keys(node)) {
103
+ if (key.startsWith('$'))
104
+ continue;
105
+ const child = node[key];
106
+ for (const kid of Array.isArray(child) ? child : [child]) {
107
+ if (kid && typeof kid === 'object' && kid.$container === node)
108
+ go(kid);
109
+ }
110
+ }
111
+ };
112
+ go(root);
113
+ }
114
+ /**
115
+ * The fully-qualified id of an element AST node — its own name and every
116
+ * enclosing element's, joined by '.'. Matches the id the computed model assigns,
117
+ * for nested elements as well as flat ones.
118
+ */
119
+ function fqnOf(astElement) {
120
+ if (!astElement)
121
+ return undefined;
122
+ const parts = [];
123
+ for (let n = astElement; n; n = n.$container) {
124
+ if (n.$type === 'Element' && n.name)
125
+ parts.unshift(n.name);
126
+ }
127
+ return parts.length ? parts.join('.') : undefined;
128
+ }
129
+ /** The element an `a -> b` endpoint (FqnRef) resolves to, or undefined. */
130
+ function endpointElement(fqnRef) {
131
+ return fqnRef?.value?.ref;
132
+ }
133
+ /** The literal source text of a TagRef node, or undefined if it is not one. */
134
+ function tagRefText(node, text) {
135
+ if (node.$type !== 'TagRef' || !node.$cstNode)
136
+ return undefined;
137
+ return text.slice(node.$cstNode.offset, node.$cstNode.end);
32
138
  }
33
139
  /**
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
140
+ * The source to remove for a `#proposed` marker. A marker alone on its line takes
141
+ * the whole line (so approving leaves no blank gap); an inline one takes just the
36
142
  * token and the space before it. The `tag proposed` declaration in the
37
143
  * specification is untouched — the vocabulary stays for the next proposal.
38
144
  */
@@ -50,28 +156,150 @@ function proposedRemovalRange(tag, text) {
50
156
  start--;
51
157
  return { start, end };
52
158
  }
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;
159
+ /**
160
+ * The nearest relationship or element the marker sits in, found via the AST
161
+ * `$container` chain — the true syntactic parent. A plain object-graph walk would
162
+ * follow the cross-reference from a relation's endpoint into the element it names
163
+ * and mis-attribute an element's marker to a relation; `$container` never does.
164
+ */
165
+ function enclosingStatement(tagRef) {
166
+ for (let n = tagRef.$container; n; n = n.$container) {
167
+ if (n.$type === 'Relation' || n.$type === 'Element')
168
+ return n;
169
+ }
170
+ return undefined;
171
+ }
172
+ /**
173
+ * The whole-statement range for a `#proposal-delete` marker: the entire relation
174
+ * or element block, from the start of its first line through its trailing
175
+ * newline, so approving the deletion removes the edge/box and leaves no gap.
176
+ */
177
+ function statementRemovalRange(statement, text) {
178
+ const { offset, end } = statement.$cstNode;
179
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
180
+ const nl = text.indexOf('\n', end);
181
+ return { start: lineStart, end: nl === -1 ? text.length : nl + 1 };
182
+ }
183
+ /** amber for `#proposed`, red for `#proposal-delete` — the intrinsic colour annotate paints. */
184
+ const MARKER_COLOR = {
185
+ '#proposed': 'amber',
186
+ '#proposal-delete': 'red',
187
+ };
188
+ /** The canonical intrinsic style annotate injects for a colour. */
189
+ function canonicalStyle(color) {
190
+ return `style { color ${color} }`;
191
+ }
192
+ /** The direct `*Body` child (ElementBody / RelationBody) of a statement, if any. */
193
+ function statementBody(stmt) {
194
+ for (const key of Object.keys(stmt)) {
195
+ if (key.startsWith('$'))
196
+ continue;
197
+ const child = stmt[key];
198
+ for (const kid of Array.isArray(child) ? child : [child]) {
199
+ if (kid && typeof kid === 'object' && kid.$container === stmt &&
200
+ typeof kid.$type === 'string' && kid.$type.endsWith('Body')) {
201
+ return kid;
202
+ }
203
+ }
204
+ }
205
+ return undefined;
206
+ }
207
+ /** The direct `*StyleProperty` children of a body. */
208
+ function styleProperties(body) {
209
+ const out = [];
210
+ for (const key of Object.keys(body)) {
211
+ if (key.startsWith('$'))
212
+ continue;
213
+ const child = body[key];
214
+ for (const kid of Array.isArray(child) ? child : [child]) {
215
+ if (kid && typeof kid === 'object' && kid.$container === body &&
216
+ typeof kid.$type === 'string' && kid.$type.endsWith('StyleProperty')) {
217
+ out.push(kid);
218
+ }
219
+ }
220
+ }
221
+ return out;
222
+ }
223
+ /**
224
+ * The style node in `body` whose source is EXACTLY the canonical `style { color
225
+ * <c> }` annotate writes — matched by whitespace-normalised text, so a richer
226
+ * user-authored style (`style { color amber; icon none }`) is never mistaken for
227
+ * ours and never stripped.
228
+ */
229
+ function canonicalStyleNode(body, color, text) {
230
+ const want = canonicalStyle(color);
231
+ return styleProperties(body).find((s) => s.$cstNode &&
232
+ text.slice(s.$cstNode.offset, s.$cstNode.end).replace(/\s+/g, ' ').trim() === want);
233
+ }
234
+ /** True when `node` is the body's only property — nothing else would survive removing it. */
235
+ function bodyHasOnly(body, node) {
236
+ for (const key of Object.keys(body)) {
237
+ if (key.startsWith('$'))
238
+ continue;
239
+ const child = body[key];
240
+ for (const kid of Array.isArray(child) ? child : [child]) {
241
+ if (kid && typeof kid === 'object' && kid.$container === body && kid !== node)
242
+ return false;
64
243
  }
65
- if (isProposedTag(node, text)) {
244
+ }
245
+ return true;
246
+ }
247
+ /** A node's whole line, trailing newline included — for stripping a box's style line. */
248
+ function lineRemovalRange(node, text) {
249
+ const { offset, end } = node.$cstNode;
250
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
251
+ const nl = text.indexOf('\n', end);
252
+ return { start: lineStart, end: nl === -1 ? text.length : nl + 1 };
253
+ }
254
+ /** A relation body in full, plus the whitespace before its `{` — turns `a -> b { … }` back into `a -> b`. */
255
+ function bodyRemovalRange(body, text) {
256
+ let start = body.$cstNode.offset;
257
+ while (start > 0 && (text[start - 1] === ' ' || text[start - 1] === '\t'))
258
+ start--;
259
+ return { start, end: body.$cstNode.end };
260
+ }
261
+ /**
262
+ * Every source range to splice out to approve a diagram. A `#proposed` marker
263
+ * strips to its token (the edge/box stays and becomes permanent) AND takes with
264
+ * it any intrinsic `style { color amber }` annotate painted on — for a box, that
265
+ * style's line; for an edge, its whole (now-empty) body, so the edge goes back to
266
+ * bare. A `#proposal-delete` marker takes its whole enclosing statement (the
267
+ * edge/box, and any style on it, are removed outright). Ranges are collected via
268
+ * a containment-only walk — descending solely into true children
269
+ * (`child.$container === node`), never cross-references — so one marker is never
270
+ * counted through another element's reference to it, and no range ever escapes
271
+ * into a different document's text.
272
+ */
273
+ function collectApprovalRanges(root, text) {
274
+ const ranges = [];
275
+ walkContained(root, (node) => {
276
+ const marker = tagRefText(node, text);
277
+ if (marker === '#proposed') {
66
278
  ranges.push(proposedRemovalRange(node, text));
279
+ // Strip the intrinsic amber style annotate paints, so an approved box does
280
+ // not stay amber forever and an approved edge goes back to bare.
281
+ const statement = enclosingStatement(node);
282
+ const body = statement && statementBody(statement);
283
+ const style = body && canonicalStyleNode(body, MARKER_COLOR['#proposed'], text);
284
+ if (style) {
285
+ if (statement.$type === 'Relation' && bodyHasOnly(body, style)) {
286
+ ranges.push(bodyRemovalRange(body, text));
287
+ }
288
+ else {
289
+ ranges.push(lineRemovalRange(style, text));
290
+ }
291
+ }
67
292
  }
68
- for (const key of Object.keys(node)) {
69
- if (!key.startsWith('$'))
70
- walk(node[key]);
293
+ else if (marker === '#proposal-delete') {
294
+ const statement = enclosingStatement(node);
295
+ if (statement?.$cstNode)
296
+ ranges.push(statementRemovalRange(statement, text));
71
297
  }
72
- };
73
- walk(root);
74
- return ranges;
298
+ });
299
+ // A whole-statement removal can contain a `#proposed` token range inside it
300
+ // (a box being deleted that also carried a proposed edge). Drop any range that
301
+ // another wholly contains, so back-to-front splicing never double-cuts.
302
+ 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
303
  }
76
304
  /**
77
305
  * First visualizer adapter. Reads a LikeC4 workspace and lifts it into the
@@ -88,7 +316,7 @@ export class LikeC4Visualizer {
88
316
  const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
89
317
  const model = await likec4.computedModel();
90
318
  const modules = [];
91
- const folderIds = new Set();
319
+ const moduleIds = new Set();
92
320
  const wildcardIds = new Set();
93
321
  const exemptImporters = new Set();
94
322
  let governRoot;
@@ -113,12 +341,25 @@ export class LikeC4Visualizer {
113
341
  wildcardIds.add(String(el.id));
114
342
  continue;
115
343
  }
116
- const meta = el.getMetadata('folder');
117
- const folder = Array.isArray(meta) ? meta[0] : meta;
118
- if (folder) {
344
+ // An element maps to a folder (owns its subtree) or a single file (owns
345
+ // exactly itself). A file leaf keeps a guarded contract sitting beside its
346
+ // sibling sub-folders, so a deep nested diagram stays legal AND enforceable.
347
+ const folderMeta = el.getMetadata('folder');
348
+ const fileMeta = el.getMetadata('file');
349
+ const folder = Array.isArray(folderMeta) ? folderMeta[0] : folderMeta;
350
+ const file = Array.isArray(fileMeta) ? fileMeta[0] : fileMeta;
351
+ if (folder && file) {
352
+ throw new Error(`element '${String(el.id)}' declares both 'folder' and 'file' — a module maps to one path`);
353
+ }
354
+ if (folder || file) {
119
355
  const id = String(el.id);
120
- modules.push({ id, title: el.title, folder });
121
- folderIds.add(id);
356
+ modules.push({
357
+ id,
358
+ title: el.title,
359
+ path: (folder ?? file),
360
+ kind: folder ? 'folder' : 'file',
361
+ });
362
+ moduleIds.add(id);
122
363
  }
123
364
  }
124
365
  const allowed = [];
@@ -131,8 +372,8 @@ export class LikeC4Visualizer {
131
372
  const to = String(rel.target.id);
132
373
  // An edge into a wildcard is kept like any other grant, so `verify` sees a
133
374
  // self-granted `#anything` exemption as the newly-allowed edge it is.
134
- const targetGoverned = folderIds.has(to) || wildcardIds.has(to);
135
- if (from !== to && folderIds.has(from) && targetGoverned) {
375
+ const targetGoverned = moduleIds.has(to) || wildcardIds.has(to);
376
+ if (from !== to && moduleIds.has(from) && targetGoverned) {
136
377
  allowed.push({ from, to });
137
378
  }
138
379
  }
@@ -145,17 +386,20 @@ export class LikeC4Visualizer {
145
386
  };
146
387
  }
147
388
  /**
148
- * Deterministically strip `#proposed` markers from the diagram source,
149
- * promoting intent edges to approved. Source-preserving; never an LLM edit
150
- * it locates each marked relationship in the LikeC4 CST and splices out its
151
- * proposal decoration, leaving all other formatting intact.
389
+ * Deterministically enact a diagram's pending proposals. Source-preserving,
390
+ * never an LLM edit it walks the LikeC4 CST and splices ranges directly:
391
+ * - `#proposed` markers are stripped, promoting their intent edges to
392
+ * approved (the edge/box stays, permanently allowed);
393
+ * - `#proposal-delete` markers take their whole edge or box with them, so
394
+ * approving a proposed removal actually removes it.
395
+ * All other formatting is left intact.
152
396
  */
153
397
  async approve() {
154
398
  const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
155
399
  for (const doc of likec4.LangiumDocuments.all) {
156
400
  const filePath = doc.uri.fsPath;
157
401
  const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
158
- const ranges = collectProposedRanges(doc.parseResult?.value, text);
402
+ const ranges = collectApprovalRanges(doc.parseResult?.value, text);
159
403
  if (ranges.length === 0)
160
404
  continue;
161
405
  let next = text;
@@ -165,4 +409,194 @@ export class LikeC4Visualizer {
165
409
  writeFileSync(filePath, next);
166
410
  }
167
411
  }
412
+ /**
413
+ * Deterministically mark the given edges and elements `#proposed`. The inverse
414
+ * of `approve` for additions: an undeclared new edge (a self-grant) or box is
415
+ * rewritten into an explicit, colourable proposal. It locates each target's
416
+ * relation/element in the CST by its resolved ids and injects the marker,
417
+ * skipping anything already marked, so it is idempotent.
418
+ */
419
+ async propose(edges, moduleIds) {
420
+ if (edges.length === 0 && moduleIds.length === 0)
421
+ return;
422
+ const wantEdge = new Set(edges.map((e) => `${e.from} -> ${e.to}`));
423
+ const wantModule = new Set(moduleIds);
424
+ const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
425
+ for (const doc of likec4.LangiumDocuments.all) {
426
+ const filePath = doc.uri.fsPath;
427
+ if (!filePath.endsWith('.likec4'))
428
+ continue;
429
+ const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
430
+ const inserts = [];
431
+ walkContained(doc.parseResult?.value, (node) => {
432
+ if (node.$type === 'Relation' && node.$cstNode) {
433
+ const from = fqnOf(endpointElement(node.source));
434
+ const to = fqnOf(endpointElement(node.target));
435
+ if (from && to && wantEdge.has(`${from} -> ${to}`)) {
436
+ const { offset, end } = node.$cstNode;
437
+ if (!text.slice(offset, end).includes('#proposed')) {
438
+ inserts.push({ at: end, text: ' #proposed' });
439
+ }
440
+ }
441
+ }
442
+ else if (node.$type === 'Element' && node.$cstNode) {
443
+ const id = fqnOf(node);
444
+ const body = node.body;
445
+ if (id && wantModule.has(id) && body?.$cstNode) {
446
+ const braceStart = body.$cstNode.offset;
447
+ const bodyText = text.slice(braceStart, body.$cstNode.end);
448
+ if (!bodyText.includes('#proposed')) {
449
+ // Insert as the body's first line, matching the indentation of the
450
+ // line that already follows the opening brace.
451
+ const afterBrace = text.indexOf('{', braceStart) + 1;
452
+ const nextLine = text.indexOf('\n', afterBrace) + 1;
453
+ const indent = (text.slice(nextLine).match(/^[ \t]*/) ?? [''])[0];
454
+ inserts.push({ at: afterBrace, text: `\n${indent}#proposed` });
455
+ }
456
+ }
457
+ }
458
+ });
459
+ if (inserts.length === 0)
460
+ continue;
461
+ let next = text;
462
+ for (const { at, text: fragment } of inserts.sort((a, b) => b.at - a.at)) {
463
+ next = next.slice(0, at) + fragment + next.slice(at);
464
+ }
465
+ writeFileSync(filePath, next);
466
+ }
467
+ }
468
+ /**
469
+ * Paint intrinsic style on every marked edge and box, so a proposal is
470
+ * highlighted on *every* LikeC4 surface — base views and the "relationships of
471
+ * X" panel, not just the generated diff views (which only carry view-scoped
472
+ * rules). A `#proposed` edge/box gets `style { color amber }`, a
473
+ * `#proposal-delete` `style { color red }`. Source-preserving and idempotent —
474
+ * anything already carrying the canonical style is left alone. `approve` strips
475
+ * this styling back out when it enacts the marker.
476
+ */
477
+ async styleMarkers() {
478
+ const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
479
+ for (const doc of likec4.LangiumDocuments.all) {
480
+ const filePath = doc.uri.fsPath;
481
+ if (!filePath.endsWith('.likec4') || filePath.endsWith(DIFF_FILE))
482
+ continue;
483
+ const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
484
+ const inserts = [];
485
+ walkContained(doc.parseResult?.value, (node) => {
486
+ const marker = tagRefText(node, text);
487
+ if (marker !== '#proposed' && marker !== '#proposal-delete')
488
+ return;
489
+ const color = MARKER_COLOR[marker];
490
+ const statement = enclosingStatement(node);
491
+ if (!statement)
492
+ return;
493
+ const body = statementBody(statement);
494
+ if (body && canonicalStyleNode(body, color, text))
495
+ return; // already styled
496
+ if (body?.$cstNode) {
497
+ // Style on the line right after the marker, at the marker's indent. It
498
+ // must go AFTER the marker: a body lists its tags before its style
499
+ // properties, so a style ahead of the `#proposed` tag is a parse error.
500
+ const markerEnd = node.$cstNode.end;
501
+ const lineStart = text.lastIndexOf('\n', markerEnd - 1) + 1;
502
+ const indent = (text.slice(lineStart).match(/^[ \t]*/) ?? [''])[0];
503
+ const nl = text.indexOf('\n', markerEnd);
504
+ inserts.push({
505
+ at: nl === -1 ? markerEnd : nl,
506
+ text: `\n${indent}${canonicalStyle(color)}`,
507
+ });
508
+ }
509
+ else {
510
+ // An inline-marked edge (`a -> b #proposed`) has no body — give it one,
511
+ // indented one level past the relation's own line.
512
+ const lineStart = text.lastIndexOf('\n', statement.$cstNode.offset - 1) + 1;
513
+ const indent = (text.slice(lineStart).match(/^[ \t]*/) ?? [''])[0];
514
+ inserts.push({
515
+ at: statement.$cstNode.end,
516
+ text: ` {\n${indent} ${canonicalStyle(color)}\n${indent}}`,
517
+ });
518
+ }
519
+ });
520
+ if (inserts.length === 0)
521
+ continue;
522
+ let next = text;
523
+ for (const { at, text: fragment } of inserts.sort((a, b) => b.at - a.at)) {
524
+ next = next.slice(0, at) + fragment + next.slice(at);
525
+ }
526
+ writeFileSync(filePath, next);
527
+ }
528
+ }
529
+ /**
530
+ * Generate a focused diff view for every layer that holds a pending change.
531
+ * Walks the diagram's own `#proposed` / `#proposal-delete` markers (never the
532
+ * lock — a `#proposal-delete` has no lock delta), buckets each change into the
533
+ * tightest scope that draws it, and writes one `view … of <scope>` per bucket
534
+ * to a derived `boundry.diff.likec4`. The file is overwritten each run and
535
+ * removed when nothing is proposed, so it always reflects the current diagram.
536
+ */
537
+ async emitDiffViews() {
538
+ const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
539
+ // scopeViewId -> { scope, changes } — deterministic key, so the same diagram
540
+ // always yields the same set of views.
541
+ const layers = new Map();
542
+ const record = (scope) => {
543
+ const key = scopeViewId(scope);
544
+ const layer = layers.get(key) ?? { scope, changes: 0 };
545
+ layer.changes += 1;
546
+ layers.set(key, layer);
547
+ };
548
+ // Which marker tags actually appear — so the emitted highlight rules only ever
549
+ // reference declared tags (an undeclared tag reference is a hard LikeC4 error).
550
+ const usedTags = new Set();
551
+ for (const doc of likec4.LangiumDocuments.all) {
552
+ const filePath = doc.uri.fsPath;
553
+ if (!filePath.endsWith('.likec4') || filePath.endsWith(DIFF_FILE))
554
+ continue;
555
+ const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
556
+ walkContained(doc.parseResult?.value, (node) => {
557
+ const marker = tagRefText(node, text);
558
+ if (marker !== '#proposed' && marker !== '#proposal-delete')
559
+ return;
560
+ usedTags.add(marker.slice(1)); // drop the leading '#'
561
+ const statement = enclosingStatement(node);
562
+ if (statement?.$type === 'Relation') {
563
+ const from = fqnOf(endpointElement(statement.source));
564
+ const to = fqnOf(endpointElement(statement.target));
565
+ if (from && to)
566
+ record(commonScope(from, to));
567
+ }
568
+ else if (statement?.$type === 'Element') {
569
+ const fqn = fqnOf(statement);
570
+ if (fqn)
571
+ record(parentScope(fqn));
572
+ }
573
+ });
574
+ }
575
+ const diffPath = join(this.workspaceDir, DIFF_FILE);
576
+ if (layers.size === 0) {
577
+ // Nothing proposed — clear any stale views so `serve` never renders a diff
578
+ // that no longer exists (and never dangles a reference to an approved-away box).
579
+ if (existsSync(diffPath))
580
+ rmSync(diffPath);
581
+ return [];
582
+ }
583
+ // Root first, then by fqn, so the output is stable across runs.
584
+ const ordered = [...layers.values()].sort((a, b) => {
585
+ if (!a.scope)
586
+ return -1;
587
+ if (!b.scope)
588
+ return 1;
589
+ return a.scope.localeCompare(b.scope);
590
+ });
591
+ const highlight = highlightLines(usedTags);
592
+ const body = ordered.map((layer) => renderDiffView(layer.scope, highlight)).join('\n');
593
+ writeFileSync(diffPath, `// Generated by \`boundry diff\` — one focused view per layer with a pending\n` +
594
+ `// change. Derived artifact: regenerate after approve; do not hand-edit.\n` +
595
+ `views {\n${body}\n}\n`);
596
+ return ordered.map((layer) => ({
597
+ id: scopeViewId(layer.scope),
598
+ scope: layer.scope,
599
+ changes: layer.changes,
600
+ }));
601
+ }
168
602
  }
package/dist/cli/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from 'node:child_process';
3
- import { mkdtempSync, readdirSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join, relative, resolve } from 'node:path';
6
6
  import { Pipeline } from '../core/pipeline/pipeline.js';
7
7
  import { LikeC4Visualizer } from '../adapters/visualizer/likec4.js';
8
8
  import { DepCruiserEnforcer } from '../adapters/enforcer/depcruiser.js';
9
- const USAGE = 'usage: boundry <generate|check|approve|verify> [--arch <dir>] [--base <git-ref>] [--cwd <dir>] [--out <file>] [sources...]';
9
+ const USAGE = 'usage: boundry <generate|check|approve|verify|annotate|diff> [--arch <dir>] [--base <git-ref>] [--cwd <dir>] [--out <file>] [sources...]';
10
10
  function optValue(args, flag) {
11
11
  const i = args.indexOf(flag);
12
12
  return i >= 0 ? args[i + 1] : undefined;
@@ -75,6 +75,9 @@ async function main() {
75
75
  const cwd = optValue(rest, '--cwd');
76
76
  if (cwd)
77
77
  process.chdir(resolve(cwd));
78
+ // The accepted-state lock lives beside the diagram it locks. `approve` writes
79
+ // it; `annotate` reads it — the baseline Boundry owns, not one inferred from git.
80
+ const lockFile = join(archDir, 'boundry.lock');
78
81
  const pipeline = new Pipeline(new LikeC4Visualizer(archDir), new DepCruiserEnforcer());
79
82
  const grantedSince = (ref) => pipeline.verify(new LikeC4Visualizer(materializeArchAt(archDir, ref)));
80
83
  if (command === 'generate') {
@@ -131,8 +134,43 @@ async function main() {
131
134
  else {
132
135
  console.error('Boundry: ⚠ no --base given — approving without verifying that every new edge was proposed');
133
136
  }
134
- await pipeline.approve();
135
- console.log('Boundry: ✓ approved — stripped #proposed markers from the diagram');
137
+ const lock = await pipeline.approve();
138
+ writeFileSync(lockFile, lock);
139
+ console.log('Boundry: ✓ approved — enacted the diagram and wrote', relative(process.cwd(), lockFile));
140
+ return;
141
+ }
142
+ if (command === 'annotate') {
143
+ if (!existsSync(lockFile)) {
144
+ console.error(`Boundry: no ${relative(process.cwd(), lockFile)} — run 'boundry approve' first to record an accepted state`);
145
+ process.exitCode = 2;
146
+ return;
147
+ }
148
+ const { edges, modules } = await pipeline.annotate(readFileSync(lockFile, 'utf8'));
149
+ if (edges.length === 0 && modules.length === 0) {
150
+ console.log('Boundry: ✓ nothing to annotate — the diagram matches the accepted lock');
151
+ return;
152
+ }
153
+ console.log(`Boundry: ✎ marked ${edges.length} edge(s) and ${modules.length} box(es) #proposed:`);
154
+ for (const edge of edges)
155
+ console.log(` ${edge.from} → ${edge.to}`);
156
+ for (const mod of modules)
157
+ console.log(` [${mod.title}]`);
158
+ console.log(' Review the highlighted diagram, then approve or revert.');
159
+ return;
160
+ }
161
+ if (command === 'diff') {
162
+ const views = await pipeline.diffViews();
163
+ const diffFile = join(archDir, 'boundry.diff.likec4');
164
+ if (views.length === 0) {
165
+ console.log('Boundry: ✓ nothing proposed — no diff views to draw');
166
+ return;
167
+ }
168
+ console.log(`Boundry: ✎ wrote ${views.length} diff view(s) to ${relative(process.cwd(), diffFile)}:`);
169
+ for (const view of views) {
170
+ const layer = view.scope ?? 'root';
171
+ console.log(` ${view.id} (layer ${layer}, ${view.changes} change(s))`);
172
+ }
173
+ console.log(' Open the diagram in `likec4 serve` to review each layer.');
136
174
  return;
137
175
  }
138
176
  console.error(USAGE);