boundry 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,76 @@ All notable changes to Boundry are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
5
5
  versioning follows [semver](https://semver.org/).
6
6
 
7
+ ## [0.6.0] — 2026-07-22
8
+
9
+ ### Changed
10
+
11
+ - **`boundry diff` now emits a single "proposed changes" view by default** ([#8]),
12
+ instead of one view per changed layer. The per-layer shape (added in 0.4.0,
13
+ extended per-altitude in 0.5.0) had two failure modes on real changes: it
14
+ multiplied views (one multi-slice change produced ~21), and its per-layer
15
+ `include *` collapsed deeply-nested proposals into their grey ancestor box, so a
16
+ nested proposed leaf silently lost its amber. The new `boundry_diff` view uses
17
+ `include * -> * where tag is #proposed` with **no** bare `include *`, so it pulls
18
+ in exactly the proposed edges and the leaf modules they touch — every change is
19
+ drawn at leaf level, uncollapsed and uniformly coloured, on one landing. A
20
+ `#proposed` box with no edge yet is included by fqn so a proposed-but-unwired
21
+ module still shows, and every endpoint's ancestor chain is included so each
22
+ change nests under its layer/system for context. `approve` still deletes the
23
+ derived file (0.5.0 behaviour).
24
+ - The per-altitude per-layer views from 0.5.0 remain available behind
25
+ **`boundry diff --per-layer`** — useful for a small change, opt-in.
26
+
27
+ [#8]: https://github.com/makspiechota/boundry/issues/8
28
+
29
+ ## [0.5.0] — 2026-07-21
30
+
31
+ ### Fixed
32
+
33
+ - **`approve` no longer leaves a stale, corrupted `boundry.diff.likec4`** ([#6]).
34
+ `approve` used to walk the derived diff file along with the model and splice the
35
+ marker tokens out of its *view rules* (`style element.tag = #proposed …`,
36
+ `include … where tag is #proposed`), mangling them into invalid LikeC4 — so
37
+ `likec4 validate` (and any `arch:verify` gate) went red immediately after a
38
+ successful approve, recoverable only by a separate `diff` run. `approve` now
39
+ skips the derived file and deletes it as part of enacting: the moment the last
40
+ proposal is approved those views are stale — they frame changes that are now
41
+ approved — so the post-approve workspace is clean and validates.
42
+
43
+ ### Added
44
+
45
+ - **Per-altitude diff views** ([#7]). `boundry diff` now emits a focused view for
46
+ *every* layer that draws a changed edge, not only the common-ancestor layer. A
47
+ cross-system dependency between two deeply-nested leaves is drawable at each
48
+ endpoint's system and container as well as at the shared root; `diff` emits one
49
+ `view … of <scope>` for each, so a reviewer can open the change at whatever
50
+ altitude matters — the whole system, a specific container — instead of being
51
+ dropped at the noisy root view. The scopes are derived from how LikeC4's
52
+ `include *` actually renders: the common ancestor plus each endpoint's ancestor
53
+ chain down to its parent, never *above* the common ancestor (where both
54
+ endpoints collapse into one child and the edge becomes an undrawn self-loop) and
55
+ never the leaf endpoints themselves. Boxes are unchanged — one view at the box's
56
+ parent layer.
57
+
58
+ ### Changed
59
+
60
+ - **`verify` now compares against the accepted `boundry.lock`, not a git ref.**
61
+ The lock was introduced in 0.4.0 to decouple *approved* from *committed*, but
62
+ `verify --base <ref>` kept re-deriving its baseline from a diagram at a git ref
63
+ — quietly re-coupling "accepted" to "committed", the very thing the lock exists
64
+ to break. `verify` now reads the lock, exactly as `annotate` does, so the two
65
+ share one baseline and can never disagree about what "accepted" means. It also
66
+ catches a committed-but-*unapproved* bare edge, which the git-ref baseline
67
+ silently absorbed. The `--base` flag is gone from `verify` and `approve`;
68
+ `approve`'s anti-laundering pre-check now runs the same lock-based gate, and the
69
+ first `approve` (no lock yet) bootstraps the initial accepted state. **Tradeoff,
70
+ by design:** the baseline is the working-tree lock, so the gate leans on
71
+ `approve` being a human act — the lock moves only when someone approves, which
72
+ is why the skill forbids agents from running it.
73
+
74
+ [#6]: https://github.com/makspiechota/boundry/issues/6
75
+ [#7]: https://github.com/makspiechota/boundry/issues/7
76
+
7
77
  ## [0.4.0] — 2026-07-17
8
78
 
9
79
  Makes the "one file can be both the communication diagram and the enforcement
package/README.md CHANGED
@@ -227,15 +227,21 @@ deterministically, by splicing the LikeC4 CST. No model call, no reformatting:
227
227
  source-preserving, idempotent, byte-exact.
228
228
 
229
229
  ```bash
230
- boundry verify --arch arch --base origin/main # any edge granted without a marker?
231
- boundry approve --arch arch --base origin/main # HUMAN ONLY: strip markers = approve
230
+ boundry verify --arch arch # any edge granted without a marker, vs the lock?
231
+ boundry approve --arch arch # HUMAN ONLY: strip markers = approve, update the lock
232
232
  ```
233
233
 
234
- `verify` compares against the approved diagram at a git ref and rejects edges that
235
- appeared *without* going through a proposal. Because proposals are excluded from
236
- the allow-list, the newly-allowed set is exactly the set of self-approvals — no
237
- diff engine required. `approve --base` runs the same gate first, so it won't
238
- launder a self-granted edge into an approved one.
234
+ `verify` compares the working diagram against the accepted **`boundry.lock`** and
235
+ rejects edges that appeared *without* going through a proposal. Because proposals
236
+ are excluded from the allow-list, the newly-allowed set is exactly the set of
237
+ self-approvals — no diff engine required. `approve` runs the same gate first, so
238
+ it won't launder a self-granted edge into an approved one; then it enacts the
239
+ proposals and records the new accepted state to the lock.
240
+
241
+ The baseline is the lock, not a git ref: it's the state Boundry *owns*, so
242
+ "accepted" never collapses into merely "committed". (One consequence, by design:
243
+ the gate leans on `approve` being a human act — the lock moves only when someone
244
+ approves. That's why the skill forbids agents from running it.)
239
245
 
240
246
  **To retire an edge or a box, propose its removal** with `#proposal-delete`
241
247
  instead of deleting it. The marker colours it red; a pending deletion changes
@@ -258,12 +264,12 @@ Point your agents at
258
264
  [`.claude/skills/define-architecture-boundaries`](.claude/skills/define-architecture-boundaries/SKILL.md)
259
265
  and they'll follow this protocol.
260
266
 
261
- ### Catching drift — the lock and `annotate` (prototype)
267
+ ### Catching drift — `annotate` (prototype)
262
268
 
263
- `verify --base` trusts git for the baseline, which conflates *approved* with
264
- *committed*. If you'd rather Boundry own that baseline: `approve` records the
265
- accepted model to **`boundry.lock`** beside the diagram, and `annotate` compares
266
- against it no git ref required.
269
+ `verify` catches a self-grant and fails; `annotate` is the other half it
270
+ *rewrites* the same drift into a reviewable proposal. Both read the same baseline,
271
+ the accepted **`boundry.lock`** that `approve` records beside the diagram, so they
272
+ can never disagree about what "accepted" means.
267
273
 
268
274
  ```bash
269
275
  boundry approve --arch arch # enact proposals AND write boundry.lock
@@ -287,31 +293,43 @@ this styling back out with the marker: a `#proposed` edge returns to bare, a
287
293
  `#proposed` box stays but loses its colour, a `#proposal-delete` is removed
288
294
  outright. Requires **LikeC4 ≥ 1.58** to render.
289
295
 
290
- ### Reviewing a proposal — per-layer diff views (prototype)
296
+ ### Reviewing a proposal — the diff view (prototype)
291
297
 
292
- A proposal nested inside a box is invisible at a wider zoom: at the top level the
293
- box collapses and its inner `#proposed` edge disappears. So `diff` generates a
294
- **focused view for every layer that holds a pending change** — the tightest scope
295
- that actually draws it — into a derived `boundry.diff.likec4`:
298
+ `diff` generates a single **proposed-changes** view every pending change on one
299
+ landing into a derived `boundry.diff.likec4`:
296
300
 
297
301
  ```bash
298
302
  boundry diff --arch arch # (re)write boundry.diff.likec4
299
- likec4 serve arch # review each layer, changes coloured
303
+ likec4 serve arch # open 'Boundry diff — proposed changes'
300
304
  ```
301
305
 
302
306
  The highlighting is **generated, not hand-styled** — `diff` emits the LikeC4
303
- style rules into the derived file, so every `#proposed` box fills amber and edge
304
- goes amber + solid, every `#proposal-delete` red, deterministically. That closes
305
- the last manual seam: `annotate` marks, `diff` colours, no agent-dependent
306
- styling step. Boxes are styled *in place* (never force-included), so a nested
307
- proposal stays in its own layer; unchanged elements keep their defaults; and the
308
- rules live only in the generated views, so your own views are untouched.
307
+ rules into the derived file, so every `#proposed` box fills amber and edge goes
308
+ amber + solid, every `#proposal-delete` red, deterministically. That closes the
309
+ last manual seam: `annotate` marks, `diff` colours, no agent-dependent styling
310
+ step. Unchanged elements keep their defaults, and the rules live only in the
311
+ generated view, so your own views are untouched.
312
+
313
+ The view uses `include * -> * where tag is #proposed` (no bare `include *`), so it
314
+ pulls in exactly the proposed edges and the leaf modules they touch — a
315
+ deeply-nested proposal renders as its **own coloured node** instead of collapsing
316
+ into a grey ancestor. A proposed *module* with no edge yet is included too, so a
317
+ proposed-but-unwired box still shows. Each change nests under its layer/system for
318
+ context.
319
+
320
+ For a small change you can ask for the old per-layer shape instead — one focused
321
+ `view … of <scope>` for every layer that draws a change:
322
+
323
+ ```bash
324
+ boundry diff --arch arch --per-layer
325
+ ```
309
326
 
310
327
  The file is a **derived artifact**: overwritten every run, removed when nothing is
311
- proposed, so it always matches the current diagram regenerate it after
312
- `approve`. It reads the diagram's own markers (not the lock), so it frames
313
- whatever `annotate` or a human has marked. Being derived, it's a `.gitignore`
314
- candidate (`boundry.diff.likec4`).
328
+ proposed, so it always matches the current diagram. `approve` deletes it too, as
329
+ part of enacting the moment the last proposal is approved the view is stale, so
330
+ the post-approve workspace validates clean. It reads the diagram's own markers
331
+ (not the lock), so it frames whatever `annotate` or a human has marked. Being
332
+ derived, it's a `.gitignore` candidate (`boundry.diff.likec4`).
315
333
 
316
334
  > Rendering the coloured diff views needs **LikeC4 ≥ 1.58** (the style-rule syntax
317
335
  > Boundry emits). Boundry itself depends on that floor; the tool you review with
@@ -322,10 +340,10 @@ candidate (`boundry.diff.likec4`).
322
340
  ```
323
341
  boundry check [--arch <dir>] [--cwd <dir>] [sources...]
324
342
  boundry generate [--arch <dir>] [--cwd <dir>] [--out <file>]
325
- boundry verify [--arch <dir>] [--cwd <dir>] --base <git-ref>
326
- boundry approve [--arch <dir>] [--cwd <dir>] [--base <git-ref>]
343
+ boundry verify [--arch <dir>] [--cwd <dir>]
344
+ boundry approve [--arch <dir>] [--cwd <dir>]
327
345
  boundry annotate [--arch <dir>]
328
- boundry diff [--arch <dir>]
346
+ boundry diff [--arch <dir>] [--per-layer]
329
347
  ```
330
348
 
331
349
  | Flag | Meaning |
@@ -333,19 +351,22 @@ boundry diff [--arch <dir>]
333
351
  | `--arch <dir>` | LikeC4 workspace directory (all `.likec4` files in it are merged). Default `.`. |
334
352
  | `--cwd <dir>` | Repo root to check. `folder` paths are relative to it. Lets you run from anywhere. |
335
353
  | `--out <file>` | `generate` only: where to write the dependency-cruiser config. Default `.dependency-cruiser.cjs`. |
336
- | `--base <ref>` | `verify`/`approve`: the git ref holding the approved diagram to compare against. |
354
+ | `--per-layer` | `diff` only: emit one focused view per layer instead of the single proposed-changes view. |
337
355
  | `sources...` | `check` only: paths to lint. Default `src`. |
338
356
 
339
357
  - **`check`** compiles the rules and runs the linter. Exits non-zero on any violation.
340
358
  - **`generate`** just emits the dependency-cruiser config so you can commit it or
341
359
  run `depcruise` yourself.
342
- - **`verify`** rejects dependencies granted without a proposal.
343
- - **`approve`** enacts markers (strips `#proposed`, removes `#proposal-delete`) and
344
- writes `boundry.lock`. For humans, not agents.
360
+ - **`verify`** rejects dependencies granted without a proposal, comparing the
361
+ diagram against the accepted `boundry.lock`. Needs a lock — run `approve` once
362
+ to record one.
363
+ - **`approve`** runs the same gate, then enacts markers (strips `#proposed`,
364
+ removes `#proposal-delete`) and writes `boundry.lock`. For humans, not agents.
345
365
  - **`annotate`** rewrites undeclared additions as `#proposed`, diffing against
346
366
  `boundry.lock`.
347
- - **`diff`** generates a focused, colour-coded review view per layer that holds a
348
- pending change, into a derived `boundry.diff.likec4`.
367
+ - **`diff`** generates a single colour-coded "proposed changes" review view into a
368
+ derived `boundry.diff.likec4` (or, with `--per-layer`, one focused view per layer
369
+ that draws a change).
349
370
 
350
371
  Boundry warns (but does not fail) when a mapped folder matches **zero** files, and
351
372
  **fails outright** when a check analysed no files at all — so a passing check can
@@ -366,9 +387,8 @@ jobs:
366
387
  with: { node-version: 20 }
367
388
  - run: npm ci
368
389
  - run: npx boundry check --arch arch src
369
- # On PRs, also reject dependencies granted without a proposal.
370
- - run: npx boundry verify --arch arch --base origin/${{ github.base_ref }}
371
- if: github.event_name == 'pull_request'
390
+ # Reject any dependency granted without a proposal, vs the committed lock.
391
+ - run: npx boundry verify --arch arch
372
392
  ```
373
393
 
374
394
  ## Programmatic use (SDK)
@@ -18,6 +18,13 @@ export declare class LikeC4Visualizer implements VisualizerPort {
18
18
  * - `#proposal-delete` markers take their whole edge or box with them, so
19
19
  * approving a proposed removal actually removes it.
20
20
  * All other formatting is left intact.
21
+ *
22
+ * The derived `boundry.diff.likec4` is skipped, not approved: it holds no real
23
+ * markers, only view rules that *reference* the marker tags (`style element.tag
24
+ * = #proposed`, `include … where tag is #proposed`). Splicing those `#proposed`
25
+ * tokens out would corrupt the rules into invalid LikeC4. And it is stale the
26
+ * moment the last proposal is enacted — it describes changes that are now
27
+ * approved — so approve deletes it, leaving a workspace `likec4 validate` accepts.
21
28
  */
22
29
  approve(): Promise<void>;
23
30
  /**
@@ -39,12 +46,26 @@ export declare class LikeC4Visualizer implements VisualizerPort {
39
46
  */
40
47
  styleMarkers(): Promise<void>;
41
48
  /**
42
- * Generate a focused diff view for every layer that holds a pending change.
43
- * Walks the diagram's own `#proposed` / `#proposal-delete` markers (never the
44
- * lock — a `#proposal-delete` has no lock delta), buckets each change into the
45
- * tightest scope that draws it, and writes one `view … of <scope>` per bucket
46
- * to a derived `boundry.diff.likec4`. The file is overwritten each run and
47
- * removed when nothing is proposed, so it always reflects the current diagram.
49
+ * Generate the review views for the diagram's pending changes, into a derived
50
+ * `boundry.diff.likec4`. Walks the diagram's own `#proposed` / `#proposal-delete`
51
+ * markers (never the lock — a `#proposal-delete` has no lock delta).
52
+ *
53
+ * By default it emits a **single `boundry_diff` view** that draws every change at
54
+ * once, uncollapsed and uniformly highlighted (see `renderSingleView`) the diff
55
+ * landing. With `perLayer`, it instead emits one focused `view … of <scope>` for
56
+ * every layer that draws a change (a box in its parent layer; an edge in every
57
+ * layer that draws it, per `edgeScopes`) — useful for a small change, but it
58
+ * multiplies views and collapses deeply-nested proposals into grey ancestors on
59
+ * a broad `include *`, which is why the single view is the default (issue #8).
60
+ *
61
+ * The file is overwritten each run and removed when nothing is proposed, so it
62
+ * always reflects the current diagram.
48
63
  */
49
- emitDiffViews(): Promise<DiffView[]>;
64
+ emitDiffViews(perLayer?: boolean): Promise<DiffView[]>;
65
+ /**
66
+ * The `--per-layer` shape: buckets each change into every layer that draws it and
67
+ * renders one scoped `view … of <scope>` per layer. Kept as an opt-in to the
68
+ * single-view default.
69
+ */
70
+ private perLayerViews;
50
71
  }
@@ -28,6 +28,46 @@ function parentScope(fqn) {
28
28
  parts.pop();
29
29
  return parts.length ? parts.join('.') : undefined;
30
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
+ }
31
71
  /**
32
72
  * Deterministic highlight rules for the marker tags actually in use. Referencing
33
73
  * an undeclared tag is a hard LikeC4 error, and a tag can only be *used* if it is
@@ -64,6 +104,61 @@ function renderDiffView(scope, highlight) {
64
104
  const lines = [` title 'Boundry diff · ${scope ?? 'root'}'`, ' include *', ...highlight];
65
105
  return `${opener}\n${lines.join('\n')}\n }`;
66
106
  }
107
+ /** Every proper ancestor of an element, shallowest first — its parent chain up to (not including) the root. */
108
+ function ancestorFqns(fqn) {
109
+ const parts = fqn.split('.');
110
+ const out = [];
111
+ for (let len = 1; len <= parts.length - 1; len++)
112
+ out.push(parts.slice(0, len).join('.'));
113
+ return out;
114
+ }
115
+ /**
116
+ * The single `boundry_diff` view: one landing that draws *every* pending change at
117
+ * once, with uniform highlighting and no empty views. It sidesteps the per-layer
118
+ * shape's two failure modes (issue #8):
119
+ *
120
+ * - **No collapse.** `include * -> * where tag is #proposed` (with NO bare
121
+ * `include *`) pulls in exactly the proposed edges and the leaf modules they
122
+ * touch — so a deeply-nested proposed leaf renders as its own amber node
123
+ * instead of collapsing into a grey ancestor, as it does under `include *`.
124
+ * - **Standalone boxes.** A `#proposed` box with no edge is included by fqn, so
125
+ * a proposed-but-unwired module still shows (and is coloured by the style rule).
126
+ *
127
+ * Every endpoint's and box's full ancestor chain is included too, so each change
128
+ * nests under its layer/system for context (verified against LikeC4 1.58). Rules
129
+ * are emitted only for marker tags actually in use — referencing an undeclared tag
130
+ * is a hard LikeC4 error.
131
+ */
132
+ function renderSingleView(edges, boxes, usedTags) {
133
+ const lines = [` title 'Boundry diff — proposed changes'`];
134
+ // Edge includes: one per marker that appears on an edge. These pull the proposed
135
+ // edges and their endpoint leaves in at leaf level, uncollapsed.
136
+ if (edges.some((e) => e.marker === '#proposed')) {
137
+ lines.push(' include * -> * where tag is #proposed with { color amber; line solid }');
138
+ }
139
+ if (edges.some((e) => e.marker === '#proposal-delete')) {
140
+ lines.push(' include * -> * where tag is #proposal-delete with { color red; line solid }');
141
+ }
142
+ // Element includes: the proposed/proposal-delete boxes themselves (so a standalone
143
+ // one shows), plus the full ancestor chain of every endpoint and box (so each
144
+ // change nests under its containers). Deterministic: sorted, de-duplicated.
145
+ const includeFqns = new Set();
146
+ for (const box of boxes)
147
+ includeFqns.add(box.fqn);
148
+ const anchors = [...edges.flatMap((e) => [e.from, e.to]), ...boxes.map((b) => b.fqn)];
149
+ for (const fqn of anchors)
150
+ for (const ancestor of ancestorFqns(fqn))
151
+ includeFqns.add(ancestor);
152
+ if (includeFqns.size) {
153
+ lines.push(` include ${[...includeFqns].sort().join(', ')}`);
154
+ }
155
+ // Style rules colour the tagged boxes (edges are coloured by their include above).
156
+ if (usedTags.has('proposed'))
157
+ lines.push(' style element.tag = #proposed { color amber }');
158
+ if (usedTags.has('proposal-delete'))
159
+ lines.push(' style element.tag = #proposal-delete { color red }');
160
+ return ` view boundry_diff {\n${lines.join('\n')}\n }`;
161
+ }
67
162
  /**
68
163
  * An exemption pattern has to be a usable regex before it reaches the linter. A
69
164
  * broken or empty one would otherwise be a silent hole: it exempts files from
@@ -393,11 +488,20 @@ export class LikeC4Visualizer {
393
488
  * - `#proposal-delete` markers take their whole edge or box with them, so
394
489
  * approving a proposed removal actually removes it.
395
490
  * All other formatting is left intact.
491
+ *
492
+ * The derived `boundry.diff.likec4` is skipped, not approved: it holds no real
493
+ * markers, only view rules that *reference* the marker tags (`style element.tag
494
+ * = #proposed`, `include … where tag is #proposed`). Splicing those `#proposed`
495
+ * tokens out would corrupt the rules into invalid LikeC4. And it is stale the
496
+ * moment the last proposal is enacted — it describes changes that are now
497
+ * approved — so approve deletes it, leaving a workspace `likec4 validate` accepts.
396
498
  */
397
499
  async approve() {
398
500
  const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
399
501
  for (const doc of likec4.LangiumDocuments.all) {
400
502
  const filePath = doc.uri.fsPath;
503
+ if (filePath.endsWith(DIFF_FILE))
504
+ continue;
401
505
  const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
402
506
  const ranges = collectApprovalRanges(doc.parseResult?.value, text);
403
507
  if (ranges.length === 0)
@@ -408,6 +512,12 @@ export class LikeC4Visualizer {
408
512
  }
409
513
  writeFileSync(filePath, next);
410
514
  }
515
+ // Enacting the last proposal makes any diff views stale — they frame changes
516
+ // that are now approved. Remove the derived artifact so the post-approve
517
+ // workspace validates and never renders an approved-away change.
518
+ const diffPath = join(this.workspaceDir, DIFF_FILE);
519
+ if (existsSync(diffPath))
520
+ rmSync(diffPath);
411
521
  }
412
522
  /**
413
523
  * Deterministically mark the given edges and elements `#proposed`. The inverse
@@ -527,26 +637,28 @@ export class LikeC4Visualizer {
527
637
  }
528
638
  }
529
639
  /**
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.
640
+ * Generate the review views for the diagram's pending changes, into a derived
641
+ * `boundry.diff.likec4`. Walks the diagram's own `#proposed` / `#proposal-delete`
642
+ * markers (never the lock — a `#proposal-delete` has no lock delta).
643
+ *
644
+ * By default it emits a **single `boundry_diff` view** that draws every change at
645
+ * once, uncollapsed and uniformly highlighted (see `renderSingleView`) the diff
646
+ * landing. With `perLayer`, it instead emits one focused `view … of <scope>` for
647
+ * every layer that draws a change (a box in its parent layer; an edge in every
648
+ * layer that draws it, per `edgeScopes`) — useful for a small change, but it
649
+ * multiplies views and collapses deeply-nested proposals into grey ancestors on
650
+ * a broad `include *`, which is why the single view is the default (issue #8).
651
+ *
652
+ * The file is overwritten each run and removed when nothing is proposed, so it
653
+ * always reflects the current diagram.
536
654
  */
537
- async emitDiffViews() {
655
+ async emitDiffViews(perLayer = false) {
538
656
  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).
657
+ // Lift every marked edge and box once; both view shapes are built from this.
658
+ const edges = [];
659
+ const boxes = [];
660
+ // Which marker tags actually appear — so the emitted rules only ever reference
661
+ // declared tags (an undeclared tag reference is a hard LikeC4 error).
550
662
  const usedTags = new Set();
551
663
  for (const doc of likec4.LangiumDocuments.all) {
552
664
  const filePath = doc.uri.fsPath;
@@ -563,23 +675,53 @@ export class LikeC4Visualizer {
563
675
  const from = fqnOf(endpointElement(statement.source));
564
676
  const to = fqnOf(endpointElement(statement.target));
565
677
  if (from && to)
566
- record(commonScope(from, to));
678
+ edges.push({ from, to, marker });
567
679
  }
568
680
  else if (statement?.$type === 'Element') {
569
681
  const fqn = fqnOf(statement);
570
682
  if (fqn)
571
- record(parentScope(fqn));
683
+ boxes.push({ fqn, marker });
572
684
  }
573
685
  });
574
686
  }
575
687
  const diffPath = join(this.workspaceDir, DIFF_FILE);
576
- if (layers.size === 0) {
688
+ if (edges.length === 0 && boxes.length === 0) {
577
689
  // Nothing proposed — clear any stale views so `serve` never renders a diff
578
690
  // that no longer exists (and never dangles a reference to an approved-away box).
579
691
  if (existsSync(diffPath))
580
692
  rmSync(diffPath);
581
693
  return [];
582
694
  }
695
+ const { header, body, views } = perLayer
696
+ ? this.perLayerViews(edges, boxes, usedTags)
697
+ : {
698
+ header: 'a single landing view of every pending change',
699
+ body: renderSingleView(edges, boxes, usedTags),
700
+ views: [{ id: 'boundry_diff', changes: edges.length + boxes.length }],
701
+ };
702
+ writeFileSync(diffPath, `// Generated by \`boundry diff\` — ${header}.\n` +
703
+ `// Derived artifact: regenerate after approve; do not hand-edit.\n` +
704
+ `views {\n${body}\n}\n`);
705
+ return views;
706
+ }
707
+ /**
708
+ * The `--per-layer` shape: buckets each change into every layer that draws it and
709
+ * renders one scoped `view … of <scope>` per layer. Kept as an opt-in to the
710
+ * single-view default.
711
+ */
712
+ perLayerViews(edges, boxes, usedTags) {
713
+ const layers = new Map();
714
+ const record = (scope) => {
715
+ const key = scopeViewId(scope);
716
+ const layer = layers.get(key) ?? { scope, changes: 0 };
717
+ layer.changes += 1;
718
+ layers.set(key, layer);
719
+ };
720
+ for (const edge of edges)
721
+ for (const scope of edgeScopes(edge.from, edge.to))
722
+ record(scope);
723
+ for (const box of boxes)
724
+ record(parentScope(box.fqn));
583
725
  // Root first, then by fqn, so the output is stable across runs.
584
726
  const ordered = [...layers.values()].sort((a, b) => {
585
727
  if (!a.scope)
@@ -589,14 +731,14 @@ export class LikeC4Visualizer {
589
731
  return a.scope.localeCompare(b.scope);
590
732
  });
591
733
  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
- }));
734
+ return {
735
+ header: 'one focused view per layer with a pending change',
736
+ body: ordered.map((layer) => renderDiffView(layer.scope, highlight)).join('\n'),
737
+ views: ordered.map((layer) => ({
738
+ id: scopeViewId(layer.scope),
739
+ scope: layer.scope,
740
+ changes: layer.changes,
741
+ })),
742
+ };
601
743
  }
602
744
  }
package/dist/cli/index.js CHANGED
@@ -1,12 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { execFileSync } from 'node:child_process';
3
- import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
3
  import { join, relative, resolve } from 'node:path';
6
4
  import { Pipeline } from '../core/pipeline/pipeline.js';
7
5
  import { LikeC4Visualizer } from '../adapters/visualizer/likec4.js';
8
6
  import { DepCruiserEnforcer } from '../adapters/enforcer/depcruiser.js';
9
- const USAGE = 'usage: boundry <generate|check|approve|verify|annotate|diff> [--arch <dir>] [--base <git-ref>] [--cwd <dir>] [--out <file>] [sources...]';
7
+ const USAGE = 'usage: boundry <generate|check|approve|verify|annotate|diff> [--arch <dir>] [--cwd <dir>] [--out <file>] [--per-layer] [sources...]';
10
8
  function optValue(args, flag) {
11
9
  const i = args.indexOf(flag);
12
10
  return i >= 0 ? args[i + 1] : undefined;
@@ -22,30 +20,6 @@ function positionals(args) {
22
20
  }
23
21
  return out;
24
22
  }
25
- /**
26
- * Materialize the architecture as of a git ref into a temp workspace, so the
27
- * previously-approved boundary model can be lifted and compared against HEAD.
28
- */
29
- function materializeArchAt(archDir, ref) {
30
- const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
31
- cwd: archDir,
32
- encoding: 'utf8',
33
- }).trim();
34
- const workDir = mkdtempSync(join(tmpdir(), 'boundry-base-'));
35
- for (const file of readdirSync(archDir).filter((f) => f.endsWith('.likec4'))) {
36
- try {
37
- const content = execFileSync('git', ['show', `${ref}:${relative(gitRoot, join(archDir, file))}`], {
38
- cwd: gitRoot,
39
- encoding: 'utf8',
40
- });
41
- writeFileSync(join(workDir, file), content);
42
- }
43
- catch {
44
- // The file did not exist at `ref` — everything it declares is new.
45
- }
46
- }
47
- return workDir;
48
- }
49
23
  function countGrants(granted) {
50
24
  return granted.edges.length + granted.exemptions.length;
51
25
  }
@@ -69,17 +43,17 @@ async function main() {
69
43
  const archDir = resolve(optValue(rest, '--arch') ?? '.');
70
44
  const outArg = optValue(rest, '--out');
71
45
  const outFile = outArg ? resolve(outArg) : undefined;
72
- const baseRef = optValue(rest, '--base');
73
46
  // `--cwd` lets you check a repo without cd-ing into it. `folder` metadata is
74
47
  // relative to the target repo root, so the enforcer runs from there.
75
48
  const cwd = optValue(rest, '--cwd');
76
49
  if (cwd)
77
50
  process.chdir(resolve(cwd));
78
51
  // 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.
52
+ // it; `verify` and `annotate` read it — the baseline Boundry owns, decoupled
53
+ // from git, so "accepted" never means merely "committed".
80
54
  const lockFile = join(archDir, 'boundry.lock');
55
+ const readLock = () => existsSync(lockFile) ? readFileSync(lockFile, 'utf8') : undefined;
81
56
  const pipeline = new Pipeline(new LikeC4Visualizer(archDir), new DepCruiserEnforcer());
82
- const grantedSince = (ref) => pipeline.verify(new LikeC4Visualizer(materializeArchAt(archDir, ref)));
83
57
  if (command === 'generate') {
84
58
  const config = await pipeline.generate();
85
59
  const out = outFile ?? resolve(config.filename);
@@ -105,37 +79,43 @@ async function main() {
105
79
  return;
106
80
  }
107
81
  if (command === 'verify') {
108
- if (!baseRef) {
109
- console.error('Boundry: verify requires --base <git-ref>');
82
+ const lock = readLock();
83
+ if (!lock) {
84
+ console.error(`Boundry: no ${relative(process.cwd(), lockFile)} — run 'boundry approve' first to record the accepted state`);
110
85
  process.exitCode = 2;
111
86
  return;
112
87
  }
113
- const granted = await grantedSince(baseRef);
88
+ const granted = await pipeline.verify(lock);
114
89
  if (countGrants(granted) === 0) {
115
- console.log(`Boundry: ✓ nothing granted without a #proposed marker (vs ${baseRef})`);
90
+ console.log('Boundry: ✓ nothing granted without a #proposed marker (vs the accepted lock)');
116
91
  return;
117
92
  }
118
- console.error(`Boundry: ✗ ${countGrants(granted)} grant(s) made without a #proposed marker (vs ${baseRef})`);
93
+ console.error(`Boundry: ✗ ${countGrants(granted)} grant(s) made without a #proposed marker (vs the accepted lock)`);
119
94
  listGrants(granted);
120
95
  process.exitCode = 1;
121
96
  return;
122
97
  }
123
98
  if (command === 'approve') {
124
99
  // Approving must never launder an edge that skipped the proposal protocol.
125
- if (baseRef) {
126
- const granted = await grantedSince(baseRef);
100
+ // The accepted lock is the baseline: a bare edge added since the last approve
101
+ // is in the allow-list but not the lock, so it surfaces here; a #proposed one
102
+ // is excluded from the allow-list, so it passes and gets enacted below. The
103
+ // first approve has no lock yet — it establishes the initial accepted state.
104
+ const lock = readLock();
105
+ if (lock) {
106
+ const granted = await pipeline.verify(lock);
127
107
  if (countGrants(granted) > 0) {
128
- console.error(`Boundry: ✗ refusing to approve — ${countGrants(granted)} grant(s) were made without a #proposed marker (vs ${baseRef})`);
108
+ console.error(`Boundry: ✗ refusing to approve — ${countGrants(granted)} grant(s) were made without a #proposed marker (vs the accepted lock)`);
129
109
  listGrants(granted);
130
110
  process.exitCode = 1;
131
111
  return;
132
112
  }
133
113
  }
134
114
  else {
135
- console.error('Boundry: ⚠ no --base given — approving without verifying that every new edge was proposed');
115
+ console.error('Boundry: ⚠ no boundry.lock yet — approving to record the initial accepted state');
136
116
  }
137
- const lock = await pipeline.approve();
138
- writeFileSync(lockFile, lock);
117
+ const nextLock = await pipeline.approve();
118
+ writeFileSync(lockFile, nextLock);
139
119
  console.log('Boundry: ✓ approved — enacted the diagram and wrote', relative(process.cwd(), lockFile));
140
120
  return;
141
121
  }
@@ -159,7 +139,8 @@ async function main() {
159
139
  return;
160
140
  }
161
141
  if (command === 'diff') {
162
- const views = await pipeline.diffViews();
142
+ const perLayer = rest.includes('--per-layer');
143
+ const views = await pipeline.diffViews(perLayer);
163
144
  const diffFile = join(archDir, 'boundry.diff.likec4');
164
145
  if (views.length === 0) {
165
146
  console.log('Boundry: ✓ nothing proposed — no diff views to draw');
@@ -167,10 +148,10 @@ async function main() {
167
148
  }
168
149
  console.log(`Boundry: ✎ wrote ${views.length} diff view(s) to ${relative(process.cwd(), diffFile)}:`);
169
150
  for (const view of views) {
170
- const layer = view.scope ?? 'root';
171
- console.log(` ${view.id} (layer ${layer}, ${view.changes} change(s))`);
151
+ const where = perLayer ? `layer ${view.scope ?? 'root'}, ` : '';
152
+ console.log(` ${view.id} (${where}${view.changes} change(s))`);
172
153
  }
173
- console.log(' Open the diagram in `likec4 serve` to review each layer.');
154
+ console.log(' Open the diagram in `likec4 serve` to review.');
174
155
  return;
175
156
  }
176
157
  console.error(USAGE);
@@ -31,12 +31,18 @@ export declare class Pipeline {
31
31
  /** Diagram -> boundary model -> run the linter over `sources`. */
32
32
  check(sources: string[]): Promise<CheckResult>;
33
33
  /**
34
- * What this diagram grants that `base` did not — dependencies added without a
35
- * `#proposed` marker, and importer exemptions added. Proposals are excluded
36
- * from the allow-list, so anything reported here bypassed the approval
37
- * protocol.
34
+ * What this diagram grants that the accepted `lock` did not — dependencies
35
+ * added without a `#proposed` marker, and importer exemptions added. Proposals
36
+ * are excluded from the allow-list, so anything reported here bypassed the
37
+ * approval protocol.
38
+ *
39
+ * The baseline is the lock — the accepted state `approve` records — not a git
40
+ * ref. The lock exists precisely to decouple "accepted" from "committed", so
41
+ * verify reads from it, exactly as `annotate` does; the two can never disagree
42
+ * about what "accepted" means. It also catches a committed-but-unapproved bare
43
+ * edge, which re-deriving a baseline from a git ref would silently absorb.
38
44
  */
39
- verify(base: VisualizerPort): Promise<VerifyResult>;
45
+ verify(lock: string): Promise<VerifyResult>;
40
46
  /**
41
47
  * Enact the diagram's pending proposals (strip `#proposed`, remove
42
48
  * `#proposal-delete`), then return the resulting accepted model as a lock
@@ -54,11 +60,11 @@ export declare class Pipeline {
54
60
  */
55
61
  annotate(lock: string): Promise<AnnotateResult>;
56
62
  /**
57
- * (Re)generate a focused diff view per layer that holds a pending change, so a
58
- * reviewer sees each proposal in the scope where it is actually drawn — a
59
- * proposal nested in a box is invisible once that box collapses at a wider
60
- * view. Reads the diagram's own `#proposed` / `#proposal-delete` markers, so it
61
- * composes with `annotate` (annotate marks drift, this frames it).
63
+ * (Re)generate the review views for the diagram's pending changes. By default a
64
+ * single `boundry_diff` landing view draws every `#proposed` / `#proposal-delete`
65
+ * change at once; with `perLayer`, one focused view per layer that draws a change.
66
+ * Reads the diagram's own markers, so it composes with `annotate` (annotate marks
67
+ * drift, this frames it).
62
68
  */
63
- diffViews(): Promise<DiffView[]>;
69
+ diffViews(perLayer?: boolean): Promise<DiffView[]>;
64
70
  }
@@ -21,16 +21,23 @@ export class Pipeline {
21
21
  return this.enforcer.check(model, sources);
22
22
  }
23
23
  /**
24
- * What this diagram grants that `base` did not — dependencies added without a
25
- * `#proposed` marker, and importer exemptions added. Proposals are excluded
26
- * from the allow-list, so anything reported here bypassed the approval
27
- * protocol.
24
+ * What this diagram grants that the accepted `lock` did not — dependencies
25
+ * added without a `#proposed` marker, and importer exemptions added. Proposals
26
+ * are excluded from the allow-list, so anything reported here bypassed the
27
+ * approval protocol.
28
+ *
29
+ * The baseline is the lock — the accepted state `approve` records — not a git
30
+ * ref. The lock exists precisely to decouple "accepted" from "committed", so
31
+ * verify reads from it, exactly as `annotate` does; the two can never disagree
32
+ * about what "accepted" means. It also catches a committed-but-unapproved bare
33
+ * edge, which re-deriving a baseline from a git ref would silently absorb.
28
34
  */
29
- async verify(base) {
30
- const [baseModel, headModel] = await Promise.all([base.read(), this.visualizer.read()]);
35
+ async verify(lock) {
36
+ const base = parseModel(lock);
37
+ const head = await this.visualizer.read();
31
38
  return {
32
- edges: newlyAllowedEdges(baseModel, headModel),
33
- exemptions: newlyExemptedImporters(baseModel, headModel),
39
+ edges: newlyAllowedEdges(base, head),
40
+ exemptions: newlyExemptedImporters(base, head),
34
41
  };
35
42
  }
36
43
  /**
@@ -62,13 +69,13 @@ export class Pipeline {
62
69
  return { edges, modules };
63
70
  }
64
71
  /**
65
- * (Re)generate a focused diff view per layer that holds a pending change, so a
66
- * reviewer sees each proposal in the scope where it is actually drawn — a
67
- * proposal nested in a box is invisible once that box collapses at a wider
68
- * view. Reads the diagram's own `#proposed` / `#proposal-delete` markers, so it
69
- * composes with `annotate` (annotate marks drift, this frames it).
72
+ * (Re)generate the review views for the diagram's pending changes. By default a
73
+ * single `boundry_diff` landing view draws every `#proposed` / `#proposal-delete`
74
+ * change at once; with `perLayer`, one focused view per layer that draws a change.
75
+ * Reads the diagram's own markers, so it composes with `annotate` (annotate marks
76
+ * drift, this frames it).
70
77
  */
71
- async diffViews() {
72
- return this.visualizer.emitDiffViews();
78
+ async diffViews(perLayer = false) {
79
+ return this.visualizer.emitDiffViews(perLayer);
73
80
  }
74
81
  }
@@ -23,25 +23,26 @@ export interface VisualizerPort {
23
23
  */
24
24
  styleMarkers(): Promise<void>;
25
25
  /**
26
- * (Re)generate a focused diff view for every layer that holds a pending
27
- * change — an edge or box tagged `#proposed` or `#proposal-delete`. Each view
28
- * is scoped to the tightest element that contains the change (the model root
29
- * for a top-level one), because a proposal nested inside a box is invisible
30
- * once that box collapses at a wider scope. A derived artifact: overwritten
26
+ * (Re)generate the review views for the diagram's pending changes edges and
27
+ * boxes tagged `#proposed` or `#proposal-delete`. By default a single
28
+ * `boundry_diff` landing view draws every change at once, uncollapsed and
29
+ * uniformly highlighted. With `perLayer`, one focused `view of <scope>` is
30
+ * emitted per layer that draws a change instead. A derived artifact: overwritten
31
31
  * each run, and removed when nothing is proposed. Returns one entry per view.
32
32
  */
33
- emitDiffViews(): Promise<DiffView[]>;
33
+ emitDiffViews(perLayer?: boolean): Promise<DiffView[]>;
34
34
  }
35
35
  /**
36
- * One emitted diff view: a single layer (element scope) that holds at least one
37
- * pending change, and how many changes fall in it.
36
+ * One emitted diff view. For the default single view there is one entry
37
+ * (`boundry_diff`) counting every pending change; in `perLayer` mode there is one
38
+ * per layer, each scoped to an element and counting the changes that fall in it.
38
39
  */
39
40
  export interface DiffView {
40
- /** The generated view id, e.g. `boundry_diff_root` or `boundry_diff_billing`. */
41
+ /** The generated view id `boundry_diff`, or `boundry_diff_<scope>` per layer. */
41
42
  id: string;
42
- /** The scope element's fqn, or undefined for the model root. */
43
+ /** perLayer only: the scope element's fqn, or undefined for the model root. */
43
44
  scope?: string;
44
- /** How many pending `#proposed` / `#proposal-delete` changes this layer holds. */
45
+ /** How many pending `#proposed` / `#proposal-delete` changes this view holds. */
45
46
  changes: number;
46
47
  }
47
48
  /** A generated linter config artifact. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boundry",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Compile a C4 architecture diagram into a deterministic dependency linter. Deterministic architectural guardrails for AI agents and humans.",
5
5
  "type": "module",
6
6
  "license": "MIT",