boundry 0.3.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.
package/CHANGELOG.md CHANGED
@@ -4,13 +4,58 @@ 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.3.0] — unreleased
7
+ ## [0.4.0] — 2026-07-17
8
8
 
9
9
  Makes the "one file can be both the communication diagram and the enforcement
10
- model" promise hold for deep, nested C4 trees.
10
+ model" promise hold for deep, nested C4 trees, and makes a proposed change
11
+ highlight itself — deterministically, on every LikeC4 surface.
11
12
 
12
13
  ### Added
13
14
 
15
+ - **Accepted-state lock + `annotate` (prototype).** `approve` now records the
16
+ accepted model to `boundry.lock` beside the diagram — a baseline Boundry
17
+ **owns**, rather than inferring it from whatever git happens to hold, so
18
+ "approved" is decoupled from "committed". `boundry annotate` diffs the diagram
19
+ against that lock and rewrites every undeclared addition — a bare new edge (a
20
+ self-grant) or box — into an explicit `#proposed` proposal in the source,
21
+ deterministically and idempotently. Turns silent drift into a reviewable,
22
+ colourable proposal. (Additive only; deletions are reported, not round-tripped
23
+ back into the DSL — re-materialising a removed box would resurrect it as an
24
+ enforced module.)
25
+ - **Cross-surface highlighting** ([#5]). `annotate` also paints each marker with
26
+ an intrinsic `style { color amber }` (`#proposed`) / `style { color red }`
27
+ (`#proposal-delete`) on the element itself. Intrinsic style is the only styling
28
+ LikeC4 renders on **every** surface — base views *and* the "relationships of X"
29
+ panel — so a proposal is highlighted wherever a reviewer looks, not just inside
30
+ the diff views (whose view-scoped rules stop at the view boundary). Idempotent,
31
+ and `approve` strips the styling back out with the marker: a `#proposed` edge
32
+ returns to bare, a `#proposed` box loses its colour, a `#proposal-delete` is
33
+ removed outright. Needs LikeC4 ≥ 1.58 to render.
34
+ - **Per-layer diff views — `boundry diff` (prototype).** Generates a focused
35
+ LikeC4 view for every layer that holds a pending change — an edge or box tagged
36
+ `#proposed` or `#proposal-delete` — scoped to the tightest element that draws
37
+ it, into a derived `boundry.diff.likec4`. This matters because a proposal nested
38
+ inside a box is invisible once that box collapses at a wider zoom, so a single
39
+ all-up view hides it; one view per layer surfaces every change in the scope
40
+ where it renders. It reads the diagram's own markers, not the lock, so it frames
41
+ whatever `annotate` or a human marked. The file is derived — overwritten each
42
+ run, removed when nothing is proposed — so it always reflects the current
43
+ diagram; regenerate after `approve`.
44
+ - **Deterministic highlighting** ([#4]). `diff` emits the LikeC4 style rules
45
+ into the derived file, so a `#proposed` box fills amber and its edge goes
46
+ amber + solid, a `#proposal-delete` red — with no hand-styling. Boxes are
47
+ styled in place (`style element.tag`, never force-included), so a nested
48
+ proposal stays in its layer; the rules live only in the generated views, so
49
+ user-authored views are untouched. Rules are emitted only for marker tags
50
+ actually in use (referencing an undeclared tag is a hard LikeC4 error).
51
+ - **`#proposal-delete` — the deletion half of the approval lifecycle.** Tag an
52
+ edge or a box `#proposal-delete` to propose retiring it. The marker colours it
53
+ red; a pending deletion changes nothing (the edge stays allowed, the box stays
54
+ enforced), so proposing a removal never breaks the build. On `approve`, the
55
+ marked edge or box is removed from the diagram outright — deterministically, by
56
+ splicing the LikeC4 CST — where `#proposed` only strips its own marker and
57
+ leaves the edge behind. `#proposed` (amber, additive) and `#proposal-delete`
58
+ (red, subtractive) are now the two halves of every change.
14
59
  - **File-level mapping — `metadata { file 'src/x.ts' }`** ([#3]). An element maps
15
60
  to a single file instead of a folder. A file module owns exactly its file; a
16
61
  folder module owns its subtree minus any mapped descendants — nested folders
@@ -26,8 +71,19 @@ model" promise hold for deep, nested C4 trees.
26
71
 
27
72
  - **`Module.folder: string` → `Module.path: string` + `Module.kind: 'folder' | 'file'`.**
28
73
  SDK-breaking; the diagram surface and CLI are unaffected.
74
+ - **`likec4` floor raised to `^1.58.0`.** The diff-view style-rule syntax Boundry
75
+ emits is verified against LikeC4 1.58; rendering the coloured views needs a
76
+ renderer at least that new.
29
77
 
30
78
  [#3]: https://github.com/makspiechota/boundry/issues/3
79
+ [#4]: https://github.com/makspiechota/boundry/issues/4
80
+ [#5]: https://github.com/makspiechota/boundry/issues/5
81
+
82
+ ## [0.3.0] — 2026-07-16 — superseded by 0.4.0
83
+
84
+ Published early from a pre-feature snapshot (≈ 0.2.0 plus the `#anything`
85
+ wildcard) and never carried the work now listed under 0.4.0. npm versions are
86
+ immutable, so it stays on the registry; use **0.4.0**.
31
87
 
32
88
  ## [0.2.0] — 2026-07-16
33
89
 
package/README.md CHANGED
@@ -237,10 +237,86 @@ the allow-list, the newly-allowed set is exactly the set of self-approvals — n
237
237
  diff engine required. `approve --base` runs the same gate first, so it won't
238
238
  launder a self-granted edge into an approved one.
239
239
 
240
+ **To retire an edge or a box, propose its removal** with `#proposal-delete`
241
+ instead of deleting it. The marker colours it red; a pending deletion changes
242
+ nothing (the edge stays allowed, the box stays enforced) so it never breaks the
243
+ build. Then `approve` removes the marked edge or box outright:
244
+
245
+ ```likec4
246
+ module legacy 'Legacy' {
247
+ #proposal-delete
248
+ metadata { folder 'src/legacy' }
249
+ }
250
+ api -> legacy #proposal-delete
251
+ ```
252
+
253
+ So `#proposed` and `#proposal-delete` are the two halves of a change — an amber
254
+ addition that `approve` makes permanent, and a red removal that `approve` takes
255
+ away — each a visible mark on the diagram until a human acts.
256
+
240
257
  Point your agents at
241
258
  [`.claude/skills/define-architecture-boundaries`](.claude/skills/define-architecture-boundaries/SKILL.md)
242
259
  and they'll follow this protocol.
243
260
 
261
+ ### Catching drift — the lock and `annotate` (prototype)
262
+
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.
267
+
268
+ ```bash
269
+ boundry approve --arch arch # enact proposals AND write boundry.lock
270
+ boundry annotate --arch arch # rewrite undeclared additions as #proposed
271
+ ```
272
+
273
+ `annotate` finds every edge or box that drifted past the lock without a marker —
274
+ a self-grant — and rewrites it in place as a `#proposed` proposal. That's not
275
+ cosmetic: a `#proposed` edge leaves the allow-list, so the silent grant becomes a
276
+ red-again check and a highlighted box on the diagram, awaiting a real approval. It
277
+ handles additions only; a removal is reported, not re-drawn (re-adding a deleted
278
+ box would resurrect it as an enforced module).
279
+
280
+ It also **paints the markers** — every `#proposed` edge/box gets an intrinsic
281
+ `style { color amber }`, every `#proposal-delete` a `style { color red }`, written
282
+ onto the element itself. Intrinsic style is the one styling LikeC4 renders on
283
+ *every* surface — base views **and the "relationships of X" panel** — so a
284
+ proposal shows up highlighted wherever a reviewer looks, not only in the generated
285
+ diff views (whose view-scoped rules stop at the view boundary). `approve` strips
286
+ this styling back out with the marker: a `#proposed` edge returns to bare, a
287
+ `#proposed` box stays but loses its colour, a `#proposal-delete` is removed
288
+ outright. Requires **LikeC4 ≥ 1.58** to render.
289
+
290
+ ### Reviewing a proposal — per-layer diff views (prototype)
291
+
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`:
296
+
297
+ ```bash
298
+ boundry diff --arch arch # (re)write boundry.diff.likec4
299
+ likec4 serve arch # review each layer, changes coloured
300
+ ```
301
+
302
+ 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.
309
+
310
+ 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`).
315
+
316
+ > Rendering the coloured diff views needs **LikeC4 ≥ 1.58** (the style-rule syntax
317
+ > Boundry emits). Boundry itself depends on that floor; the tool you review with
318
+ > (`likec4 serve`, the CLI, or the IDE extension) has to meet it too.
319
+
244
320
  ## CLI
245
321
 
246
322
  ```
@@ -248,6 +324,8 @@ boundry check [--arch <dir>] [--cwd <dir>] [sources...]
248
324
  boundry generate [--arch <dir>] [--cwd <dir>] [--out <file>]
249
325
  boundry verify [--arch <dir>] [--cwd <dir>] --base <git-ref>
250
326
  boundry approve [--arch <dir>] [--cwd <dir>] [--base <git-ref>]
327
+ boundry annotate [--arch <dir>]
328
+ boundry diff [--arch <dir>]
251
329
  ```
252
330
 
253
331
  | Flag | Meaning |
@@ -262,7 +340,12 @@ boundry approve [--arch <dir>] [--cwd <dir>] [--base <git-ref>]
262
340
  - **`generate`** just emits the dependency-cruiser config so you can commit it or
263
341
  run `depcruise` yourself.
264
342
  - **`verify`** rejects dependencies granted without a proposal.
265
- - **`approve`** strips `#proposed` markers. For humans, not agents.
343
+ - **`approve`** enacts markers (strips `#proposed`, removes `#proposal-delete`) and
344
+ writes `boundry.lock`. For humans, not agents.
345
+ - **`annotate`** rewrites undeclared additions as `#proposed`, diffing against
346
+ `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`.
266
349
 
267
350
  Boundry warns (but does not fail) when a mapped folder matches **zero** files, and
268
351
  **fails outright** when a check analysed no files at all — so a passing check can
@@ -1,5 +1,5 @@
1
- import type { VisualizerPort } from '../../core/ports/ports.js';
2
- import type { BoundaryModel } from '../../core/model/boundary-model.js';
1
+ import type { VisualizerPort, DiffView } from '../../core/ports/ports.js';
2
+ import type { BoundaryModel, AllowedEdge } from '../../core/model/boundary-model.js';
3
3
  /**
4
4
  * First visualizer adapter. Reads a LikeC4 workspace and lifts it into the
5
5
  * boundary model: any element carrying a `folder` metadata key becomes a
@@ -11,10 +11,40 @@ export declare class LikeC4Visualizer implements VisualizerPort {
11
11
  constructor(workspaceDir: string);
12
12
  read(): Promise<BoundaryModel>;
13
13
  /**
14
- * Deterministically strip `#proposed` markers from the diagram source,
15
- * promoting intent edges to approved. Source-preserving; never an LLM edit
16
- * it locates each marked relationship in the LikeC4 CST and splices out its
17
- * proposal decoration, leaving all other formatting intact.
14
+ * Deterministically enact a diagram's pending proposals. Source-preserving,
15
+ * never an LLM edit it walks the LikeC4 CST and splices ranges directly:
16
+ * - `#proposed` markers are stripped, promoting their intent edges to
17
+ * approved (the edge/box stays, permanently allowed);
18
+ * - `#proposal-delete` markers take their whole edge or box with them, so
19
+ * approving a proposed removal actually removes it.
20
+ * All other formatting is left intact.
18
21
  */
19
22
  approve(): Promise<void>;
23
+ /**
24
+ * Deterministically mark the given edges and elements `#proposed`. The inverse
25
+ * of `approve` for additions: an undeclared new edge (a self-grant) or box is
26
+ * rewritten into an explicit, colourable proposal. It locates each target's
27
+ * relation/element in the CST by its resolved ids and injects the marker,
28
+ * skipping anything already marked, so it is idempotent.
29
+ */
30
+ propose(edges: AllowedEdge[], moduleIds: string[]): Promise<void>;
31
+ /**
32
+ * Paint intrinsic style on every marked edge and box, so a proposal is
33
+ * highlighted on *every* LikeC4 surface — base views and the "relationships of
34
+ * X" panel, not just the generated diff views (which only carry view-scoped
35
+ * rules). A `#proposed` edge/box gets `style { color amber }`, a
36
+ * `#proposal-delete` `style { color red }`. Source-preserving and idempotent —
37
+ * anything already carrying the canonical style is left alone. `approve` strips
38
+ * this styling back out when it enacts the marker.
39
+ */
40
+ styleMarkers(): Promise<void>;
41
+ /**
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.
48
+ */
49
+ emitDiffViews(): Promise<DiffView[]>;
20
50
  }
@@ -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);
32
113
  }
33
114
  /**
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
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);
138
+ }
139
+ /**
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
@@ -158,17 +386,20 @@ export class LikeC4Visualizer {
158
386
  };
159
387
  }
160
388
  /**
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.
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.
165
396
  */
166
397
  async approve() {
167
398
  const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
168
399
  for (const doc of likec4.LangiumDocuments.all) {
169
400
  const filePath = doc.uri.fsPath;
170
401
  const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
171
- const ranges = collectProposedRanges(doc.parseResult?.value, text);
402
+ const ranges = collectApprovalRanges(doc.parseResult?.value, text);
172
403
  if (ranges.length === 0)
173
404
  continue;
174
405
  let next = text;
@@ -178,4 +409,194 @@ export class LikeC4Visualizer {
178
409
  writeFileSync(filePath, next);
179
410
  }
180
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
+ }
181
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);
@@ -82,3 +82,18 @@ export declare function newlyExemptedImporters(base: BoundaryModel, head: Bounda
82
82
  * dependencies granted WITHOUT going through a proposal — i.e. self-approvals.
83
83
  */
84
84
  export declare function newlyAllowedEdges(base: BoundaryModel, head: BoundaryModel): AllowedEdge[];
85
+ /**
86
+ * Modules present at `head` but not at `base`, keyed by id. A `#proposed` element
87
+ * stays a module (the marker is visual-only), so this reports both bare-added and
88
+ * already-proposed new boxes — the annotator makes marking idempotent.
89
+ */
90
+ export declare function newlyAddedModules(base: BoundaryModel, head: BoundaryModel): Module[];
91
+ /**
92
+ * The accepted state, written as a canonical string — the lock. `approve` records
93
+ * it so change detection has a baseline it OWNS, rather than trusting whatever git
94
+ * happens to hold. Deterministic: fields and lists are sorted, so the same model
95
+ * always serializes byte-for-byte the same.
96
+ */
97
+ export declare function serializeModel(model: BoundaryModel): string;
98
+ /** Read a lock back into a model. Inverse of {@link serializeModel}. */
99
+ export declare function parseModel(text: string): BoundaryModel;
@@ -34,3 +34,43 @@ export function newlyAllowedEdges(base, head) {
34
34
  const allowedAtBase = new Set(base.allowed.map(edgeKey));
35
35
  return head.allowed.filter((edge) => !allowedAtBase.has(edgeKey(edge)));
36
36
  }
37
+ /**
38
+ * Modules present at `head` but not at `base`, keyed by id. A `#proposed` element
39
+ * stays a module (the marker is visual-only), so this reports both bare-added and
40
+ * already-proposed new boxes — the annotator makes marking idempotent.
41
+ */
42
+ export function newlyAddedModules(base, head) {
43
+ const atBase = new Set(base.modules.map((m) => m.id));
44
+ return head.modules.filter((m) => !atBase.has(m.id));
45
+ }
46
+ /**
47
+ * The accepted state, written as a canonical string — the lock. `approve` records
48
+ * it so change detection has a baseline it OWNS, rather than trusting whatever git
49
+ * happens to hold. Deterministic: fields and lists are sorted, so the same model
50
+ * always serializes byte-for-byte the same.
51
+ */
52
+ export function serializeModel(model) {
53
+ const canonical = {
54
+ modules: [...model.modules]
55
+ .sort((a, b) => a.id.localeCompare(b.id))
56
+ .map((m) => ({ id: m.id, title: m.title, path: m.path, kind: m.kind })),
57
+ allowed: [...model.allowed]
58
+ .sort((a, b) => edgeKey(a).localeCompare(edgeKey(b)))
59
+ .map((e) => ({ from: e.from, to: e.to })),
60
+ wildcards: [...model.wildcards].sort(),
61
+ exemptImporters: [...model.exemptImporters].sort(),
62
+ ...(model.governRoot ? { governRoot: model.governRoot } : {}),
63
+ };
64
+ return `${JSON.stringify(canonical, null, 2)}\n`;
65
+ }
66
+ /** Read a lock back into a model. Inverse of {@link serializeModel}. */
67
+ export function parseModel(text) {
68
+ const raw = JSON.parse(text);
69
+ return {
70
+ modules: raw.modules ?? [],
71
+ allowed: raw.allowed ?? [],
72
+ wildcards: raw.wildcards ?? [],
73
+ exemptImporters: raw.exemptImporters ?? [],
74
+ governRoot: raw.governRoot,
75
+ };
76
+ }
@@ -1,5 +1,10 @@
1
- import type { VisualizerPort, EnforcerPort, EnforcerConfig, CheckResult } from "../ports/ports.js";
2
- import type { AllowedEdge } from "../model/boundary-model.js";
1
+ import type { VisualizerPort, EnforcerPort, EnforcerConfig, CheckResult, DiffView } from "../ports/ports.js";
2
+ import type { AllowedEdge, Module } from "../model/boundary-model.js";
3
+ /** What the annotator turned into explicit `#proposed` proposals. */
4
+ export interface AnnotateResult {
5
+ edges: AllowedEdge[];
6
+ modules: Module[];
7
+ }
3
8
  /**
4
9
  * What a diagram grants that the approved base did not. Both kinds of grant are
5
10
  * reported, because both let code cross a boundary that was previously closed.
@@ -32,6 +37,28 @@ export declare class Pipeline {
32
37
  * protocol.
33
38
  */
34
39
  verify(base: VisualizerPort): Promise<VerifyResult>;
35
- /** Approve proposed edges: strip their `#proposed` markers from the diagram. */
36
- approve(): Promise<void>;
40
+ /**
41
+ * Enact the diagram's pending proposals (strip `#proposed`, remove
42
+ * `#proposal-delete`), then return the resulting accepted model as a lock
43
+ * string. The caller persists it: that lock — written by approve, not inferred
44
+ * from git — is the baseline change detection compares against.
45
+ */
46
+ approve(): Promise<string>;
47
+ /**
48
+ * Compare the diagram against a previously accepted lock and rewrite every
49
+ * undeclared addition — a bare new edge (a self-grant) or box — into an
50
+ * explicit `#proposed` proposal in the source, then paints intrinsic styling on
51
+ * every marker so the proposal is highlighted on every LikeC4 surface. Turns
52
+ * silent drift into a reviewable, colourable proposal without trusting git for
53
+ * the baseline.
54
+ */
55
+ annotate(lock: string): Promise<AnnotateResult>;
56
+ /**
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).
62
+ */
63
+ diffViews(): Promise<DiffView[]>;
37
64
  }
@@ -1,4 +1,4 @@
1
- import { newlyAllowedEdges, newlyExemptedImporters, } from "../model/boundary-model.js";
1
+ import { newlyAddedModules, newlyAllowedEdges, newlyExemptedImporters, parseModel, serializeModel, } from "../model/boundary-model.js";
2
2
  /**
3
3
  * The SDK's public surface. Orchestrates the two driven ports and stays blind
4
4
  * to any concrete diagram source or linter — swap adapters, this is untouched.
@@ -33,8 +33,42 @@ export class Pipeline {
33
33
  exemptions: newlyExemptedImporters(baseModel, headModel),
34
34
  };
35
35
  }
36
- /** Approve proposed edges: strip their `#proposed` markers from the diagram. */
36
+ /**
37
+ * Enact the diagram's pending proposals (strip `#proposed`, remove
38
+ * `#proposal-delete`), then return the resulting accepted model as a lock
39
+ * string. The caller persists it: that lock — written by approve, not inferred
40
+ * from git — is the baseline change detection compares against.
41
+ */
37
42
  async approve() {
38
- return this.visualizer.approve();
43
+ await this.visualizer.approve();
44
+ const accepted = await this.visualizer.read();
45
+ return serializeModel(accepted);
46
+ }
47
+ /**
48
+ * Compare the diagram against a previously accepted lock and rewrite every
49
+ * undeclared addition — a bare new edge (a self-grant) or box — into an
50
+ * explicit `#proposed` proposal in the source, then paints intrinsic styling on
51
+ * every marker so the proposal is highlighted on every LikeC4 surface. Turns
52
+ * silent drift into a reviewable, colourable proposal without trusting git for
53
+ * the baseline.
54
+ */
55
+ async annotate(lock) {
56
+ const accepted = parseModel(lock);
57
+ const head = await this.visualizer.read();
58
+ const edges = newlyAllowedEdges(accepted, head);
59
+ const modules = newlyAddedModules(accepted, head);
60
+ await this.visualizer.propose(edges, modules.map((m) => m.id));
61
+ await this.visualizer.styleMarkers();
62
+ return { edges, modules };
63
+ }
64
+ /**
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).
70
+ */
71
+ async diffViews() {
72
+ return this.visualizer.emitDiffViews();
39
73
  }
40
74
  }
@@ -1,4 +1,4 @@
1
- import type { BoundaryModel } from '../model/boundary-model.js';
1
+ import type { AllowedEdge, BoundaryModel } from '../model/boundary-model.js';
2
2
  /** A driven port: turns some diagram source into the boundary model. */
3
3
  export interface VisualizerPort {
4
4
  read(): Promise<BoundaryModel>;
@@ -7,6 +7,42 @@ export interface VisualizerPort {
7
7
  * promoting intent edges to approved. Source-preserving; never an LLM edit.
8
8
  */
9
9
  approve(): Promise<void>;
10
+ /**
11
+ * Deterministically mark the given edges and elements `#proposed` in the
12
+ * source. The inverse of `approve` for additions: it rewrites an undeclared
13
+ * change into an explicit, colourable proposal. Idempotent — anything already
14
+ * marked is left alone. Source-preserving; never an LLM edit.
15
+ */
16
+ propose(edges: AllowedEdge[], moduleIds: string[]): Promise<void>;
17
+ /**
18
+ * Paint intrinsic `style { color … }` on every edge/box carrying a marker, so a
19
+ * proposal is highlighted on every LikeC4 surface — base views and the
20
+ * relationships panel, not only the generated diff views. Amber for `#proposed`,
21
+ * red for `#proposal-delete`. Idempotent; `approve` strips it back out.
22
+ * Source-preserving; never an LLM edit.
23
+ */
24
+ styleMarkers(): Promise<void>;
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
31
+ * each run, and removed when nothing is proposed. Returns one entry per view.
32
+ */
33
+ emitDiffViews(): Promise<DiffView[]>;
34
+ }
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.
38
+ */
39
+ export interface DiffView {
40
+ /** The generated view id, e.g. `boundry_diff_root` or `boundry_diff_billing`. */
41
+ id: string;
42
+ /** The scope element's fqn, or undefined for the model root. */
43
+ scope?: string;
44
+ /** How many pending `#proposed` / `#proposal-delete` changes this layer holds. */
45
+ changes: number;
10
46
  }
11
47
  /** A generated linter config artifact. */
12
48
  export interface EnforcerConfig {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boundry",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "dependency-cruiser": "^16.4.0",
59
- "likec4": "^1.46.0",
59
+ "likec4": "^1.58.0",
60
60
  "typescript": "^5.6.0"
61
61
  },
62
62
  "devDependencies": {