boundry 0.6.0 → 0.7.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,49 @@ 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.7.0] — 2026-07-24
8
+
9
+ The enforced contract moves from the diagram to the **lock**. The diagram becomes
10
+ a proposal surface; enforcement is engine-independent and changes only on
11
+ `approve`.
12
+
13
+ ### Changed
14
+
15
+ - **`check` and `generate` now enforce the accepted `boundry.lock`, not the
16
+ working diagram** ([#10]). The lock — a serialized boundary model, no LikeC4 in
17
+ it — is the source of truth; the diagram is a staging surface for proposing
18
+ changes to it. Consequences:
19
+ - **Enforcement is engine-independent.** `check`/`generate` read the lock, so
20
+ they need no diagram at all — you can swap the LikeC4 adapter, or enforce with
21
+ only a committed lock. The visualizer is a driven port again, not something
22
+ enforcement rides on.
23
+ - **Editing the diagram never moves enforcement on its own.** Drawing a proposal
24
+ or deleting a box changes nothing until `approve` folds it into the lock.
25
+ - **`check` runs a parity gate first.** When a diagram is present, `check`
26
+ rejects any *un-annotated* drift between it and the lock — a self-granted edge
27
+ (add without `#proposed`) **or** a silent removal (delete without
28
+ `#proposal-delete`) — so the diagram can never silently disagree with what the
29
+ lock enforces. This subsumes `verify` (now the parity gate on its own, extended
30
+ to removals). With no diagram, the gate is skipped and the lock alone enforces.
31
+ - **`check`/`generate` require a lock** — with none, they error "run `approve`
32
+ first" (the first `approve` bootstraps it). `approve` refuses to accept any
33
+ un-annotated drift, so every change reaches the lock through a reviewed marker.
34
+ - The `boundry.lock` **must be committed** — it is the contract CI enforces.
35
+
36
+ ### Added
37
+
38
+ - **`annotate` is symmetric — it stages removals, not just additions** ([#9]). It
39
+ already rewrote a bare new edge/box as `#proposed`; it now also detects an
40
+ element or edge that is in the lock but **deleted** from the diagram and
41
+ **re-materialises it** into the source tagged `#proposal-delete` — reconstructing
42
+ a deleted nested subtree in place, re-declaring deleted edges by fqn. So a
43
+ restructure that removes a component is reviewable the same way an addition is:
44
+ *delete → `annotate` → review (`diff` draws it red) → `approve` removes it from
45
+ the lock*. Idempotent; `styleMarkers` colours it red like any marker.
46
+
47
+ [#9]: https://github.com/makspiechota/boundry/issues/9
48
+ [#10]: https://github.com/makspiechota/boundry/issues/10
49
+
7
50
  ## [0.6.0] — 2026-07-22
8
51
 
9
52
  ### Changed
package/README.md CHANGED
@@ -227,16 +227,17 @@ 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 # any edge granted without a marker, vs the lock?
230
+ boundry verify --arch arch # any change vs the lock that skipped a marker?
231
231
  boundry approve --arch arch # HUMAN ONLY: strip markers = approve, update the lock
232
232
  ```
233
233
 
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.
234
+ `verify` is the **parity gate**: it compares the working diagram against the
235
+ accepted **`boundry.lock`** and rejects anything that changed *without* a marker
236
+ an addition without `#proposed` (a self-grant) or a removal without
237
+ `#proposal-delete` (a silent deletion). `check` runs this gate before it enforces,
238
+ so the diagram can never silently disagree with the lock. `approve` runs it too,
239
+ so it won't accept un-annotated drift; then it enacts the markers and records the
240
+ new accepted state to the lock.
240
241
 
241
242
  The baseline is the lock, not a git ref: it's the state Boundry *owns*, so
242
243
  "accepted" never collapses into merely "committed". (One consequence, by design:
@@ -266,22 +267,25 @@ and they'll follow this protocol.
266
267
 
267
268
  ### Catching drift — `annotate` (prototype)
268
269
 
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.
270
+ `verify` catches drift and fails; `annotate` is the other half — it *rewrites* the
271
+ same drift into a reviewable proposal. Both read the same baseline, the accepted
272
+ **`boundry.lock`** that `approve` records beside the diagram, so they can never
273
+ disagree about what "accepted" means.
273
274
 
274
275
  ```bash
275
276
  boundry approve --arch arch # enact proposals AND write boundry.lock
276
- boundry annotate --arch arch # rewrite undeclared additions as #proposed
277
+ boundry annotate --arch arch # stage drift as #proposed / #proposal-delete
277
278
  ```
278
279
 
279
- `annotate` finds every edge or box that drifted past the lock without a marker —
280
- a self-grantand rewrites it in place as a `#proposed` proposal. That's not
281
- cosmetic: a `#proposed` edge leaves the allow-list, so the silent grant becomes a
282
- red-again check and a highlighted box on the diagram, awaiting a real approval. It
283
- handles additions only; a removal is reported, not re-drawn (re-adding a deleted
284
- box would resurrect it as an enforced module).
280
+ `annotate` is **symmetric**. An **addition** that drifted past the lock without a
281
+ marker — a bare new edge or box is rewritten in place as `#proposed`: it leaves
282
+ the allow-list, so the silent grant becomes a red-again check awaiting approval. A
283
+ **removal** an element or edge that is in the lock but was *deleted* from the
284
+ diagram is **re-materialised** back into the source tagged `#proposal-delete`,
285
+ reconstructing a deleted nested subtree in place and re-declaring its edges. So a
286
+ restructure that removes a component is reviewable exactly like one that adds: the
287
+ deletion comes back as a red proposal you can see in `diff` and enact with
288
+ `approve`, rather than as silent drift a failing `check` only hints at.
285
289
 
286
290
  It also **paints the markers** — every `#proposed` edge/box gets an intrinsic
287
291
  `style { color amber }`, every `#proposal-delete` a `style { color red }`, written
@@ -354,20 +358,29 @@ boundry diff [--arch <dir>] [--per-layer]
354
358
  | `--per-layer` | `diff` only: emit one focused view per layer instead of the single proposed-changes view. |
355
359
  | `sources...` | `check` only: paths to lint. Default `src`. |
356
360
 
357
- - **`check`** compiles the rules and runs the linter. Exits non-zero on any violation.
358
- - **`generate`** just emits the dependency-cruiser config so you can commit it or
359
- run `depcruise` yourself.
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.
365
- - **`annotate`** rewrites undeclared additions as `#proposed`, diffing against
366
- `boundry.lock`.
361
+ - **`check`** enforces the accepted **`boundry.lock`** (not the working diagram)
362
+ against your code, and runs the linter. When a diagram is present it first runs
363
+ the **parity gate** — rejecting any un-annotated drift between the diagram and
364
+ the lock then enforces. With no diagram, the lock alone enforces
365
+ (engine-independent). Needs a lock; exits non-zero on any violation.
366
+ - **`generate`** emits the dependency-cruiser config from the lock, so it matches
367
+ exactly what `check` enforces.
368
+ - **`verify`** is the parity gate on its own: it rejects any change between the
369
+ diagram and the lock that skipped a marker — an addition without `#proposed`
370
+ **or** a removal without `#proposal-delete`.
371
+ - **`approve`** folds the diagram into the lock — the only op that changes what is
372
+ enforced. It refuses un-annotated drift first, then enacts markers (strips
373
+ `#proposed`, removes `#proposal-delete`) and writes `boundry.lock`. For humans,
374
+ not agents.
375
+ - **`annotate`** stages drift as proposals, symmetrically: an undeclared addition
376
+ becomes `#proposed`; an element/edge deleted from the diagram is re-materialised
377
+ as `#proposal-delete`. Diffs against `boundry.lock`.
367
378
  - **`diff`** generates a single colour-coded "proposed changes" review view into a
368
379
  derived `boundry.diff.likec4` (or, with `--per-layer`, one focused view per layer
369
380
  that draws a change).
370
381
 
382
+ **Commit `boundry.lock`** — it is the enforced contract, and CI checks against it.
383
+
371
384
  Boundry warns (but does not fail) when a mapped folder matches **zero** files, and
372
385
  **fails outright** when a check analysed no files at all — so a passing check can
373
386
  never silently enforce nothing. A guardrail that fails open is worse than none.
@@ -386,9 +399,9 @@ jobs:
386
399
  - uses: actions/setup-node@v4
387
400
  with: { node-version: 20 }
388
401
  - run: npm ci
402
+ # Enforces the committed boundry.lock against the code, and rejects any
403
+ # un-annotated drift between the diagram and the lock (the parity gate).
389
404
  - run: npx boundry check --arch arch src
390
- # Reject any dependency granted without a proposal, vs the committed lock.
391
- - run: npx boundry verify --arch arch
392
405
  ```
393
406
 
394
407
  ## Programmatic use (SDK)
@@ -397,6 +410,7 @@ The CLI is a thin wrapper over the SDK. Everything is pluggable — the diagram
397
410
  source and the target linter are both adapters behind ports.
398
411
 
399
412
  ```ts
413
+ import { readFileSync } from 'node:fs';
400
414
  import { Pipeline, LikeC4Visualizer, DepCruiserEnforcer } from 'boundry';
401
415
 
402
416
  const pipeline = new Pipeline(
@@ -404,7 +418,9 @@ const pipeline = new Pipeline(
404
418
  new DepCruiserEnforcer(),
405
419
  );
406
420
 
407
- const result = await pipeline.check(['src']);
421
+ // Enforce the accepted lock — the committed contract — against your code.
422
+ const lock = readFileSync('arch/boundry.lock', 'utf8');
423
+ const result = await pipeline.check(lock, ['src']);
408
424
  if (!result.ok) {
409
425
  for (const v of result.violations) console.error(`${v.from} → ${v.to}`);
410
426
  process.exit(1);
@@ -1,5 +1,5 @@
1
1
  import type { VisualizerPort, DiffView } from '../../core/ports/ports.js';
2
- import type { BoundaryModel, AllowedEdge } from '../../core/model/boundary-model.js';
2
+ import type { BoundaryModel, Module, 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
@@ -35,6 +35,17 @@ export declare class LikeC4Visualizer implements VisualizerPort {
35
35
  * skipping anything already marked, so it is idempotent.
36
36
  */
37
37
  propose(edges: AllowedEdge[], moduleIds: string[]): Promise<void>;
38
+ /**
39
+ * Re-materialise deleted elements and edges into the source, tagged
40
+ * `#proposal-delete`, so a removal is a reviewable red proposal rather than
41
+ * silent drift. Modules are rebuilt with their folder/file mapping and nesting
42
+ * (a deleted subtree is reconstructed as one nested block, inserted into the
43
+ * surviving parent's body, or the model root for a top-level one); edges are
44
+ * re-declared by fqn at the model root. Idempotent — anything already present in
45
+ * the source (by fqn / by endpoints) is skipped. Never an LLM edit: it splices
46
+ * text at CST anchors, and `styleMarkers`/`diff` then colour it red.
47
+ */
48
+ proposeDeletions(modules: Module[], edges: AllowedEdge[]): Promise<void>;
38
49
  /**
39
50
  * Paint intrinsic style on every marked edge and box, so a proposal is
40
51
  * highlighted on *every* LikeC4 surface — base views and the "relationships of
@@ -575,6 +575,114 @@ export class LikeC4Visualizer {
575
575
  writeFileSync(filePath, next);
576
576
  }
577
577
  }
578
+ /**
579
+ * Re-materialise deleted elements and edges into the source, tagged
580
+ * `#proposal-delete`, so a removal is a reviewable red proposal rather than
581
+ * silent drift. Modules are rebuilt with their folder/file mapping and nesting
582
+ * (a deleted subtree is reconstructed as one nested block, inserted into the
583
+ * surviving parent's body, or the model root for a top-level one); edges are
584
+ * re-declared by fqn at the model root. Idempotent — anything already present in
585
+ * the source (by fqn / by endpoints) is skipped. Never an LLM edit: it splices
586
+ * text at CST anchors, and `styleMarkers`/`diff` then colour it red.
587
+ */
588
+ async proposeDeletions(modules, edges) {
589
+ if (modules.length === 0 && edges.length === 0)
590
+ return;
591
+ const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
592
+ const docs = [];
593
+ const existingFqns = new Set();
594
+ const existingEdges = new Set();
595
+ for (const doc of likec4.LangiumDocuments.all) {
596
+ const filePath = doc.uri.fsPath;
597
+ if (!filePath.endsWith('.likec4') || filePath.endsWith(DIFF_FILE))
598
+ continue;
599
+ const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
600
+ const elementByFqn = new Map();
601
+ let modelBlock;
602
+ walkContained(doc.parseResult?.value, (node) => {
603
+ if (node.$type === 'Element') {
604
+ const fqn = fqnOf(node);
605
+ if (fqn) {
606
+ elementByFqn.set(fqn, node);
607
+ existingFqns.add(fqn);
608
+ }
609
+ if (node.$container?.$type === 'Model' && !modelBlock)
610
+ modelBlock = node.$container;
611
+ }
612
+ else if (node.$type === 'Relation') {
613
+ const from = fqnOf(endpointElement(node.source));
614
+ const to = fqnOf(endpointElement(node.target));
615
+ if (from && to)
616
+ existingEdges.add(`${from} -> ${to}`);
617
+ }
618
+ });
619
+ docs.push({ filePath, text, elementByFqn, modelBlock });
620
+ }
621
+ docs.sort((a, b) => a.filePath.localeCompare(b.filePath));
622
+ const primary = docs.find((d) => d.modelBlock);
623
+ // Idempotency: only re-add what is genuinely absent from the source.
624
+ const addModules = modules.filter((m) => !existingFqns.has(m.id));
625
+ const addEdges = edges.filter((e) => !existingEdges.has(`${e.from} -> ${e.to}`));
626
+ if (addModules.length === 0 && addEdges.length === 0)
627
+ return;
628
+ const childrenOf = (fqn) => addModules.filter((m) => parentScope(m.id) === fqn);
629
+ const moduleBlock = (m) => {
630
+ const indent = ' '.repeat(m.id.split('.').length);
631
+ const name = m.id.split('.').pop();
632
+ const mapping = m.kind === 'file' ? `file '${m.path}'` : `folder '${m.path}'`;
633
+ const lines = [
634
+ `${indent}module ${name} '${m.title}' {`,
635
+ `${indent} #proposal-delete`,
636
+ `${indent} metadata { ${mapping} }`,
637
+ ...childrenOf(m.id).map(moduleBlock),
638
+ `${indent}}`,
639
+ ];
640
+ return lines.join('\n');
641
+ };
642
+ // Accumulate insertions keyed by an anchor position, so several blocks that
643
+ // land in the same body (or the model root) merge into one splice.
644
+ const inserts = [];
645
+ // The start of the line the block's `{ … }` closes on, so an inserted block
646
+ // sits as whole lines and the closing brace keeps its own indentation.
647
+ const atClosingLine = (text, cst) => {
648
+ const brace = text.lastIndexOf('}', cst.end - 1);
649
+ return text.lastIndexOf('\n', brace) + 1;
650
+ };
651
+ // A module is a subtree ROOT when its parent is not itself being re-added — so
652
+ // its parent survives, or it is top-level. Reconstruct each root's whole
653
+ // subtree as one block (deepest-first recursion), then anchor it.
654
+ for (const root of addModules.filter((m) => !addModules.some((p) => p.id === parentScope(m.id)))) {
655
+ const parent = parentScope(root.id);
656
+ const block = `${moduleBlock(root)}\n`;
657
+ if (!parent) {
658
+ if (primary)
659
+ inserts.push({ filePath: primary.filePath, at: atClosingLine(primary.text, primary.modelBlock.$cstNode), text: block });
660
+ }
661
+ else {
662
+ const owner = docs.find((d) => d.elementByFqn.has(parent));
663
+ if (owner)
664
+ inserts.push({ filePath: owner.filePath, at: atClosingLine(owner.text, owner.elementByFqn.get(parent).body.$cstNode), text: block });
665
+ }
666
+ }
667
+ if (addEdges.length && primary) {
668
+ const text = addEdges.map((e) => ` ${e.from} -> ${e.to} #proposal-delete\n`).join('');
669
+ inserts.push({ filePath: primary.filePath, at: atClosingLine(primary.text, primary.modelBlock.$cstNode), text });
670
+ }
671
+ // Apply per file, back-to-front, so earlier offsets stay valid.
672
+ const byFile = new Map();
673
+ for (const ins of inserts) {
674
+ const list = byFile.get(ins.filePath) ?? [];
675
+ list.push({ at: ins.at, text: ins.text });
676
+ byFile.set(ins.filePath, list);
677
+ }
678
+ for (const [filePath, list] of byFile) {
679
+ let next = docs.find((d) => d.filePath === filePath).text;
680
+ for (const { at, text } of list.sort((a, b) => b.at - a.at)) {
681
+ next = next.slice(0, at) + text + next.slice(at);
682
+ }
683
+ writeFileSync(filePath, next);
684
+ }
685
+ }
578
686
  /**
579
687
  * Paint intrinsic style on every marked edge and box, so a proposal is
580
688
  * highlighted on *every* LikeC4 surface — base views and the "relationships of
package/dist/cli/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
3
3
  import { join, relative, resolve } from 'node:path';
4
- import { Pipeline } from '../core/pipeline/pipeline.js';
4
+ import { Pipeline, inParity } from '../core/pipeline/pipeline.js';
5
5
  import { LikeC4Visualizer } from '../adapters/visualizer/likec4.js';
6
6
  import { DepCruiserEnforcer } from '../adapters/enforcer/depcruiser.js';
7
7
  const USAGE = 'usage: boundry <generate|check|approve|verify|annotate|diff> [--arch <dir>] [--cwd <dir>] [--out <file>] [--per-layer] [sources...]';
@@ -20,22 +20,36 @@ function positionals(args) {
20
20
  }
21
21
  return out;
22
22
  }
23
- function countGrants(granted) {
24
- return granted.edges.length + granted.exemptions.length;
23
+ /** How many un-annotated differences from the lock the parity gate found. */
24
+ function countDrift(d) {
25
+ return d.edges.length + d.exemptions.length + d.removedEdges.length + d.removedModules.length;
25
26
  }
26
- function listGrants(granted) {
27
- for (const edge of granted.edges)
28
- console.error(` ${edge.from} ${edge.to}`);
29
- for (const pattern of granted.exemptions) {
30
- console.error(` exemption '${pattern}' — lifts matching files out of every rule`);
27
+ /** Report every way the diagram drifted from the lock without a marker. */
28
+ function listDrift(d) {
29
+ for (const edge of d.edges)
30
+ console.error(` + ${edge.from} ${edge.to} (added without #proposed)`);
31
+ for (const pattern of d.exemptions) {
32
+ console.error(` + exemption '${pattern}' — lifts matching files out of every rule`);
31
33
  }
32
- if (granted.edges.length > 0) {
33
- console.error(' Mark them #proposed so the grant is an explicit, reviewable act.');
34
+ for (const edge of d.removedEdges)
35
+ console.error(` ${edge.from} ${edge.to} (deleted without #proposal-delete)`);
36
+ for (const mod of d.removedModules)
37
+ console.error(` − [${mod.title}] ${mod.path} (deleted without #proposal-delete)`);
38
+ if (d.edges.length > 0) {
39
+ console.error(' Mark additions #proposed so the grant is an explicit, reviewable act.');
34
40
  }
35
- if (granted.exemptions.length > 0) {
41
+ if (d.removedEdges.length > 0 || d.removedModules.length > 0) {
42
+ console.error(" Run 'boundry annotate' to stage removals as #proposal-delete, then review.");
43
+ }
44
+ if (d.exemptions.length > 0) {
36
45
  console.error(' An exemption cannot be proposed — a human adds it to the diagram, or not at all.');
37
46
  }
38
47
  }
48
+ /** Whether the workspace has an authored diagram (any .likec4 but the derived diff). */
49
+ function hasDiagram(archDir) {
50
+ return existsSync(archDir) &&
51
+ readdirSync(archDir).some((f) => f.endsWith('.likec4') && !f.endsWith('boundry.diff.likec4'));
52
+ }
39
53
  async function main() {
40
54
  const [command, ...rest] = process.argv.slice(2);
41
55
  // Resolve paths against the *original* cwd before we optionally move into the
@@ -54,16 +68,45 @@ async function main() {
54
68
  const lockFile = join(archDir, 'boundry.lock');
55
69
  const readLock = () => existsSync(lockFile) ? readFileSync(lockFile, 'utf8') : undefined;
56
70
  const pipeline = new Pipeline(new LikeC4Visualizer(archDir), new DepCruiserEnforcer());
71
+ // Enforcement runs against the accepted lock — the engine-independent contract —
72
+ // not the working diagram, so a lock is required for `generate` and `check`.
73
+ const requireLock = () => {
74
+ const lock = readLock();
75
+ if (!lock) {
76
+ console.error(`Boundry: no ${relative(process.cwd(), lockFile)} — run 'boundry approve' first to record the accepted state`);
77
+ process.exitCode = 2;
78
+ }
79
+ return lock;
80
+ };
57
81
  if (command === 'generate') {
58
- const config = await pipeline.generate();
82
+ const lock = requireLock();
83
+ if (!lock)
84
+ return;
85
+ const config = await pipeline.generate(lock);
59
86
  const out = outFile ?? resolve(config.filename);
60
87
  writeFileSync(out, config.content);
61
88
  console.log(`Boundry: wrote ${out}`);
62
89
  return;
63
90
  }
64
91
  if (command === 'check') {
92
+ const lock = requireLock();
93
+ if (!lock)
94
+ return;
95
+ // Parity gate: reject un-annotated drift before enforcing, so the diagram can
96
+ // never silently disagree with what the lock enforces. Skipped when there is
97
+ // no diagram — the lock alone drives enforcement (engine-independent).
98
+ if (hasDiagram(archDir)) {
99
+ const drift = await pipeline.verify(lock);
100
+ if (!inParity(drift)) {
101
+ console.error(`Boundry: ✗ ${countDrift(drift)} un-annotated change(s) between the diagram and the accepted lock`);
102
+ listDrift(drift);
103
+ console.error(' Enforcement is against the lock; annotate → review → approve to accept these.');
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+ }
65
108
  const sources = positionals(rest);
66
- const result = await pipeline.check(sources.length ? sources : ['src']);
109
+ const result = await pipeline.check(lock, sources.length ? sources : ['src']);
67
110
  for (const warning of result.warnings) {
68
111
  console.error(`Boundry: ⚠ ${warning}`);
69
112
  }
@@ -79,34 +122,30 @@ async function main() {
79
122
  return;
80
123
  }
81
124
  if (command === 'verify') {
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`);
85
- process.exitCode = 2;
125
+ const lock = requireLock();
126
+ if (!lock)
86
127
  return;
87
- }
88
- const granted = await pipeline.verify(lock);
89
- if (countGrants(granted) === 0) {
90
- console.log('Boundry: ✓ nothing granted without a #proposed marker (vs the accepted lock)');
128
+ const drift = await pipeline.verify(lock);
129
+ if (inParity(drift)) {
130
+ console.log('Boundry: ✓ the diagram is in parity with the accepted lock (no un-annotated drift)');
91
131
  return;
92
132
  }
93
- console.error(`Boundry: ✗ ${countGrants(granted)} grant(s) made without a #proposed marker (vs the accepted lock)`);
94
- listGrants(granted);
133
+ console.error(`Boundry: ✗ ${countDrift(drift)} un-annotated change(s) between the diagram and the accepted lock`);
134
+ listDrift(drift);
95
135
  process.exitCode = 1;
96
136
  return;
97
137
  }
98
138
  if (command === 'approve') {
99
- // Approving must never launder an edge that skipped the proposal protocol.
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.
139
+ // Approve folds the diagram into the lock the only op that changes what is
140
+ // enforced. It refuses to accept un-annotated drift: every change must have
141
+ // gone through a marker (#proposed / #proposal-delete) so it was reviewable.
142
+ // The first approve has no lock yet — it establishes the initial accepted state.
104
143
  const lock = readLock();
105
144
  if (lock) {
106
- const granted = await pipeline.verify(lock);
107
- if (countGrants(granted) > 0) {
108
- console.error(`Boundry: ✗ refusing to approve — ${countGrants(granted)} grant(s) were made without a #proposed marker (vs the accepted lock)`);
109
- listGrants(granted);
145
+ const drift = await pipeline.verify(lock);
146
+ if (!inParity(drift)) {
147
+ console.error(`Boundry: ✗ refusing to approve — ${countDrift(drift)} un-annotated change(s) vs the accepted lock`);
148
+ listDrift(drift);
110
149
  process.exitCode = 1;
111
150
  return;
112
151
  }
@@ -125,17 +164,23 @@ async function main() {
125
164
  process.exitCode = 2;
126
165
  return;
127
166
  }
128
- const { edges, modules } = await pipeline.annotate(readFileSync(lockFile, 'utf8'));
129
- if (edges.length === 0 && modules.length === 0) {
167
+ const { edges, modules, removedEdges, removedModules } = await pipeline.annotate(readFileSync(lockFile, 'utf8'));
168
+ const total = edges.length + modules.length + removedEdges.length + removedModules.length;
169
+ if (total === 0) {
130
170
  console.log('Boundry: ✓ nothing to annotate — the diagram matches the accepted lock');
131
171
  return;
132
172
  }
133
- console.log(`Boundry: ✎ marked ${edges.length} edge(s) and ${modules.length} box(es) #proposed:`);
173
+ console.log(`Boundry: ✎ staged ${edges.length + modules.length} addition(s) as #proposed, ` +
174
+ `${removedEdges.length + removedModules.length} removal(s) as #proposal-delete:`);
134
175
  for (const edge of edges)
135
- console.log(` ${edge.from} → ${edge.to}`);
176
+ console.log(` + ${edge.from} → ${edge.to}`);
136
177
  for (const mod of modules)
137
- console.log(` [${mod.title}]`);
138
- console.log(' Review the highlighted diagram, then approve or revert.');
178
+ console.log(` + [${mod.title}]`);
179
+ for (const edge of removedEdges)
180
+ console.log(` − ${edge.from} → ${edge.to}`);
181
+ for (const mod of removedModules)
182
+ console.log(` − [${mod.title}]`);
183
+ console.log(' Review the highlighted diagram (boundry diff), then approve or revert.');
139
184
  return;
140
185
  }
141
186
  if (command === 'diff') {
@@ -88,6 +88,23 @@ export declare function newlyAllowedEdges(base: BoundaryModel, head: BoundaryMod
88
88
  * already-proposed new boxes — the annotator makes marking idempotent.
89
89
  */
90
90
  export declare function newlyAddedModules(base: BoundaryModel, head: BoundaryModel): Module[];
91
+ /**
92
+ * Edges allowed at `base` but no longer at `head` — a dependency that was
93
+ * *removed*. A `#proposal-delete` edge stays in the model's allow-list (a pending
94
+ * deletion changes nothing until approved), so it is still present at `head` and
95
+ * never appears here. This delta is therefore exactly the set of edges deleted
96
+ * *without* going through a `#proposal-delete` proposal — silent removals, the
97
+ * mirror of {@link newlyAllowedEdges}.
98
+ */
99
+ export declare function removedEdges(base: BoundaryModel, head: BoundaryModel): AllowedEdge[];
100
+ /**
101
+ * Modules present at `base` but no longer at `head` — a box that was *removed*. A
102
+ * `#proposal-delete` box stays a module until approved, so it is still present at
103
+ * `head`; only a box deleted outright appears here. The mirror of
104
+ * {@link newlyAddedModules}, and — unlike a new box, which grants nothing — a
105
+ * removed box drops enforcement of its folder, so it must go through a proposal.
106
+ */
107
+ export declare function removedModules(base: BoundaryModel, head: BoundaryModel): Module[];
91
108
  /**
92
109
  * The accepted state, written as a canonical string — the lock. `approve` records
93
110
  * it so change detection has a baseline it OWNS, rather than trusting whatever git
@@ -43,6 +43,29 @@ export function newlyAddedModules(base, head) {
43
43
  const atBase = new Set(base.modules.map((m) => m.id));
44
44
  return head.modules.filter((m) => !atBase.has(m.id));
45
45
  }
46
+ /**
47
+ * Edges allowed at `base` but no longer at `head` — a dependency that was
48
+ * *removed*. A `#proposal-delete` edge stays in the model's allow-list (a pending
49
+ * deletion changes nothing until approved), so it is still present at `head` and
50
+ * never appears here. This delta is therefore exactly the set of edges deleted
51
+ * *without* going through a `#proposal-delete` proposal — silent removals, the
52
+ * mirror of {@link newlyAllowedEdges}.
53
+ */
54
+ export function removedEdges(base, head) {
55
+ const allowedAtHead = new Set(head.allowed.map(edgeKey));
56
+ return base.allowed.filter((edge) => !allowedAtHead.has(edgeKey(edge)));
57
+ }
58
+ /**
59
+ * Modules present at `base` but no longer at `head` — a box that was *removed*. A
60
+ * `#proposal-delete` box stays a module until approved, so it is still present at
61
+ * `head`; only a box deleted outright appears here. The mirror of
62
+ * {@link newlyAddedModules}, and — unlike a new box, which grants nothing — a
63
+ * removed box drops enforcement of its folder, so it must go through a proposal.
64
+ */
65
+ export function removedModules(base, head) {
66
+ const atHead = new Set(head.modules.map((m) => m.id));
67
+ return base.modules.filter((m) => !atHead.has(m.id));
68
+ }
46
69
  /**
47
70
  * The accepted state, written as a canonical string — the lock. `approve` records
48
71
  * it so change detection has a baseline it OWNS, rather than trusting whatever git
@@ -1,23 +1,35 @@
1
1
  import type { VisualizerPort, EnforcerPort, EnforcerConfig, CheckResult, DiffView } from "../ports/ports.js";
2
2
  import type { AllowedEdge, Module } from "../model/boundary-model.js";
3
- /** What the annotator turned into explicit `#proposed` proposals. */
3
+ /** What the annotator turned into explicit proposals. */
4
4
  export interface AnnotateResult {
5
+ /** Additions marked `#proposed`. */
5
6
  edges: AllowedEdge[];
6
7
  modules: Module[];
8
+ /** Removals re-materialised as `#proposal-delete`. */
9
+ removedEdges: AllowedEdge[];
10
+ removedModules: Module[];
7
11
  }
8
12
  /**
9
- * What a diagram grants that the approved base did not. Both kinds of grant are
10
- * reported, because both let code cross a boundary that was previously closed.
13
+ * How the working diagram differs from the accepted `lock` the parity gate. The
14
+ * diagram may differ from the lock *only* through marked proposals; anything here
15
+ * is un-annotated drift that would change enforcement without going through
16
+ * review. Additions (grants) must be `#proposed`; removals must be
17
+ * `#proposal-delete`. Parity holds when all four are empty.
11
18
  *
12
- * This lives with the pipeline rather than the ports: no driven port deals in
13
- * it, it is what `verify` returns.
19
+ * This lives with the pipeline rather than the ports: no driven port deals in it.
14
20
  */
15
21
  export interface VerifyResult {
16
- /** Dependencies added without going through a `#proposed` proposal. */
22
+ /** Dependencies added without a `#proposed` marker (a self-grant). */
17
23
  edges: AllowedEdge[];
18
24
  /** Importer exemptions added, lifting files out of every rule. */
19
25
  exemptions: string[];
26
+ /** Edges deleted without a `#proposal-delete` marker (a silent removal). */
27
+ removedEdges: AllowedEdge[];
28
+ /** Modules deleted without a `#proposal-delete` marker — drops enforcement. */
29
+ removedModules: Module[];
20
30
  }
31
+ /** True when the diagram is in parity with the lock — no un-annotated drift. */
32
+ export declare function inParity(result: VerifyResult): boolean;
21
33
  /**
22
34
  * The SDK's public surface. Orchestrates the two driven ports and stays blind
23
35
  * to any concrete diagram source or linter — swap adapters, this is untouched.
@@ -26,21 +38,32 @@ export declare class Pipeline {
26
38
  private readonly visualizer;
27
39
  private readonly enforcer;
28
40
  constructor(visualizer: VisualizerPort, enforcer: EnforcerPort);
29
- /** Diagram -> boundary model -> generated linter config. */
30
- generate(): Promise<EnforcerConfig>;
31
- /** Diagram -> boundary model -> run the linter over `sources`. */
32
- check(sources: string[]): Promise<CheckResult>;
33
41
  /**
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.
42
+ * Render the enforced contract the accepted `lock` into the linter config,
43
+ * so the emitted config matches exactly what `check` enforces. The lock is a
44
+ * serialized boundary model, engine-independent: no diagram is read here.
45
+ */
46
+ generate(lock: string): Promise<EnforcerConfig>;
47
+ /**
48
+ * Run the linter over `sources` against the enforced contract — the accepted
49
+ * `lock`, not the working diagram. Enforcement is engine-independent (no diagram
50
+ * needed) and changes only when `approve` folds the diagram into the lock, so
51
+ * drawing or deleting in the diagram never moves enforcement on its own. The
52
+ * caller runs the parity gate (`verify`) first to reject un-annotated drift.
53
+ */
54
+ check(lock: string, sources: string[]): Promise<CheckResult>;
55
+ /**
56
+ * The parity gate: how the working diagram differs from the accepted `lock`.
57
+ * The diagram may differ only through marked proposals — a `#proposed` edge is
58
+ * excluded from the allow-list, a `#proposal-delete` element stays present until
59
+ * approved — so anything reported here is un-annotated drift: a self-granted
60
+ * addition (bare edge / exemption) or a silent removal (an edge or box deleted
61
+ * without `#proposal-delete`). `check` runs this before enforcing, so the
62
+ * diagram can never silently disagree with what the lock enforces.
38
63
  *
39
64
  * 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.
65
+ * ref: the lock exists precisely to decouple "accepted" from "committed", so
66
+ * verify reads from it, exactly as `annotate` does.
44
67
  */
45
68
  verify(lock: string): Promise<VerifyResult>;
46
69
  /**
@@ -52,11 +75,14 @@ export declare class Pipeline {
52
75
  approve(): Promise<string>;
53
76
  /**
54
77
  * Compare the diagram against a previously accepted lock and rewrite every
55
- * undeclared addition a bare new edge (a self-grant) or box into an
56
- * explicit `#proposed` proposal in the source, then paints intrinsic styling on
57
- * every marker so the proposal is highlighted on every LikeC4 surface. Turns
58
- * silent drift into a reviewable, colourable proposal without trusting git for
59
- * the baseline.
78
+ * undeclared *change* into an explicit proposal in the sourcesymmetrically:
79
+ * - an addition (a bare new edge / box) becomes `#proposed`;
80
+ * - a removal (an edge or box in the lock but deleted from the diagram) is
81
+ * re-materialised, tagged `#proposal-delete`, so the deletion becomes a
82
+ * reviewable red proposal rather than silent drift.
83
+ * Then it paints intrinsic styling on every marker so the proposal is
84
+ * highlighted on every LikeC4 surface. Turns drift in either direction into a
85
+ * reviewable, colourable proposal — the input to `diff` and `approve`.
60
86
  */
61
87
  annotate(lock: string): Promise<AnnotateResult>;
62
88
  /**
@@ -1,4 +1,11 @@
1
- import { newlyAddedModules, newlyAllowedEdges, newlyExemptedImporters, parseModel, serializeModel, } from "../model/boundary-model.js";
1
+ import { newlyAddedModules, newlyAllowedEdges, newlyExemptedImporters, parseModel, removedEdges, removedModules, serializeModel, } from "../model/boundary-model.js";
2
+ /** True when the diagram is in parity with the lock — no un-annotated drift. */
3
+ export function inParity(result) {
4
+ return (result.edges.length === 0 &&
5
+ result.exemptions.length === 0 &&
6
+ result.removedEdges.length === 0 &&
7
+ result.removedModules.length === 0);
8
+ }
2
9
  /**
3
10
  * The SDK's public surface. Orchestrates the two driven ports and stays blind
4
11
  * to any concrete diagram source or linter — swap adapters, this is untouched.
@@ -10,27 +17,36 @@ export class Pipeline {
10
17
  this.visualizer = visualizer;
11
18
  this.enforcer = enforcer;
12
19
  }
13
- /** Diagram -> boundary model -> generated linter config. */
14
- async generate() {
15
- const model = await this.visualizer.read();
16
- return this.enforcer.render(model);
20
+ /**
21
+ * Render the enforced contract — the accepted `lock` — into the linter config,
22
+ * so the emitted config matches exactly what `check` enforces. The lock is a
23
+ * serialized boundary model, engine-independent: no diagram is read here.
24
+ */
25
+ async generate(lock) {
26
+ return this.enforcer.render(parseModel(lock));
17
27
  }
18
- /** Diagram -> boundary model -> run the linter over `sources`. */
19
- async check(sources) {
20
- const model = await this.visualizer.read();
21
- return this.enforcer.check(model, sources);
28
+ /**
29
+ * Run the linter over `sources` against the enforced contract — the accepted
30
+ * `lock`, not the working diagram. Enforcement is engine-independent (no diagram
31
+ * needed) and changes only when `approve` folds the diagram into the lock, so
32
+ * drawing or deleting in the diagram never moves enforcement on its own. The
33
+ * caller runs the parity gate (`verify`) first to reject un-annotated drift.
34
+ */
35
+ async check(lock, sources) {
36
+ return this.enforcer.check(parseModel(lock), sources);
22
37
  }
23
38
  /**
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.
39
+ * The parity gate: how the working diagram differs from the accepted `lock`.
40
+ * The diagram may differ only through marked proposals — a `#proposed` edge is
41
+ * excluded from the allow-list, a `#proposal-delete` element stays present until
42
+ * approved — so anything reported here is un-annotated drift: a self-granted
43
+ * addition (bare edge / exemption) or a silent removal (an edge or box deleted
44
+ * without `#proposal-delete`). `check` runs this before enforcing, so the
45
+ * diagram can never silently disagree with what the lock enforces.
28
46
  *
29
47
  * 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.
48
+ * ref: the lock exists precisely to decouple "accepted" from "committed", so
49
+ * verify reads from it, exactly as `annotate` does.
34
50
  */
35
51
  async verify(lock) {
36
52
  const base = parseModel(lock);
@@ -38,6 +54,8 @@ export class Pipeline {
38
54
  return {
39
55
  edges: newlyAllowedEdges(base, head),
40
56
  exemptions: newlyExemptedImporters(base, head),
57
+ removedEdges: removedEdges(base, head),
58
+ removedModules: removedModules(base, head),
41
59
  };
42
60
  }
43
61
  /**
@@ -53,20 +71,26 @@ export class Pipeline {
53
71
  }
54
72
  /**
55
73
  * Compare the diagram against a previously accepted lock and rewrite every
56
- * undeclared addition a bare new edge (a self-grant) or box into an
57
- * explicit `#proposed` proposal in the source, then paints intrinsic styling on
58
- * every marker so the proposal is highlighted on every LikeC4 surface. Turns
59
- * silent drift into a reviewable, colourable proposal without trusting git for
60
- * the baseline.
74
+ * undeclared *change* into an explicit proposal in the sourcesymmetrically:
75
+ * - an addition (a bare new edge / box) becomes `#proposed`;
76
+ * - a removal (an edge or box in the lock but deleted from the diagram) is
77
+ * re-materialised, tagged `#proposal-delete`, so the deletion becomes a
78
+ * reviewable red proposal rather than silent drift.
79
+ * Then it paints intrinsic styling on every marker so the proposal is
80
+ * highlighted on every LikeC4 surface. Turns drift in either direction into a
81
+ * reviewable, colourable proposal — the input to `diff` and `approve`.
61
82
  */
62
83
  async annotate(lock) {
63
84
  const accepted = parseModel(lock);
64
85
  const head = await this.visualizer.read();
65
86
  const edges = newlyAllowedEdges(accepted, head);
66
87
  const modules = newlyAddedModules(accepted, head);
88
+ const removedEs = removedEdges(accepted, head);
89
+ const removedMs = removedModules(accepted, head);
67
90
  await this.visualizer.propose(edges, modules.map((m) => m.id));
91
+ await this.visualizer.proposeDeletions(removedMs, removedEs);
68
92
  await this.visualizer.styleMarkers();
69
- return { edges, modules };
93
+ return { edges, modules, removedEdges: removedEs, removedModules: removedMs };
70
94
  }
71
95
  /**
72
96
  * (Re)generate the review views for the diagram's pending changes. By default a
@@ -1,4 +1,4 @@
1
- import type { AllowedEdge, BoundaryModel } from '../model/boundary-model.js';
1
+ import type { AllowedEdge, BoundaryModel, Module } 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>;
@@ -14,6 +14,15 @@ export interface VisualizerPort {
14
14
  * marked is left alone. Source-preserving; never an LLM edit.
15
15
  */
16
16
  propose(edges: AllowedEdge[], moduleIds: string[]): Promise<void>;
17
+ /**
18
+ * Re-materialise elements and edges that were deleted from the diagram (present
19
+ * in the accepted lock, absent now) back into the source, tagged
20
+ * `#proposal-delete`, so a removal becomes a reviewable red proposal instead of
21
+ * silent drift. Modules are reconstructed with their folder/file mapping and
22
+ * nesting; edges are re-declared by fqn. The inverse of `approve` for removals.
23
+ * Idempotent — anything already present is left alone. Never an LLM edit.
24
+ */
25
+ proposeDeletions(modules: Module[], edges: AllowedEdge[]): Promise<void>;
17
26
  /**
18
27
  * Paint intrinsic `style { color … }` on every edge/box carrying a marker, so a
19
28
  * proposal is highlighted on every LikeC4 surface — base views and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boundry",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",