boundry 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +42 -27
- package/dist/adapters/visualizer/likec4.d.ts +13 -4
- package/dist/adapters/visualizer/likec4.js +65 -5
- package/dist/cli/index.js +22 -42
- package/dist/core/pipeline/pipeline.d.ts +11 -5
- package/dist/core/pipeline/pipeline.js +15 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@ 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.5.0] — 2026-07-21
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`approve` no longer leaves a stale, corrupted `boundry.diff.likec4`** ([#6]).
|
|
12
|
+
`approve` used to walk the derived diff file along with the model and splice the
|
|
13
|
+
marker tokens out of its *view rules* (`style element.tag = #proposed …`,
|
|
14
|
+
`include … where tag is #proposed`), mangling them into invalid LikeC4 — so
|
|
15
|
+
`likec4 validate` (and any `arch:verify` gate) went red immediately after a
|
|
16
|
+
successful approve, recoverable only by a separate `diff` run. `approve` now
|
|
17
|
+
skips the derived file and deletes it as part of enacting: the moment the last
|
|
18
|
+
proposal is approved those views are stale — they frame changes that are now
|
|
19
|
+
approved — so the post-approve workspace is clean and validates.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Per-altitude diff views** ([#7]). `boundry diff` now emits a focused view for
|
|
24
|
+
*every* layer that draws a changed edge, not only the common-ancestor layer. A
|
|
25
|
+
cross-system dependency between two deeply-nested leaves is drawable at each
|
|
26
|
+
endpoint's system and container as well as at the shared root; `diff` emits one
|
|
27
|
+
`view … of <scope>` for each, so a reviewer can open the change at whatever
|
|
28
|
+
altitude matters — the whole system, a specific container — instead of being
|
|
29
|
+
dropped at the noisy root view. The scopes are derived from how LikeC4's
|
|
30
|
+
`include *` actually renders: the common ancestor plus each endpoint's ancestor
|
|
31
|
+
chain down to its parent, never *above* the common ancestor (where both
|
|
32
|
+
endpoints collapse into one child and the edge becomes an undrawn self-loop) and
|
|
33
|
+
never the leaf endpoints themselves. Boxes are unchanged — one view at the box's
|
|
34
|
+
parent layer.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **`verify` now compares against the accepted `boundry.lock`, not a git ref.**
|
|
39
|
+
The lock was introduced in 0.4.0 to decouple *approved* from *committed*, but
|
|
40
|
+
`verify --base <ref>` kept re-deriving its baseline from a diagram at a git ref
|
|
41
|
+
— quietly re-coupling "accepted" to "committed", the very thing the lock exists
|
|
42
|
+
to break. `verify` now reads the lock, exactly as `annotate` does, so the two
|
|
43
|
+
share one baseline and can never disagree about what "accepted" means. It also
|
|
44
|
+
catches a committed-but-*unapproved* bare edge, which the git-ref baseline
|
|
45
|
+
silently absorbed. The `--base` flag is gone from `verify` and `approve`;
|
|
46
|
+
`approve`'s anti-laundering pre-check now runs the same lock-based gate, and the
|
|
47
|
+
first `approve` (no lock yet) bootstraps the initial accepted state. **Tradeoff,
|
|
48
|
+
by design:** the baseline is the working-tree lock, so the gate leans on
|
|
49
|
+
`approve` being a human act — the lock moves only when someone approves, which
|
|
50
|
+
is why the skill forbids agents from running it.
|
|
51
|
+
|
|
52
|
+
[#6]: https://github.com/makspiechota/boundry/issues/6
|
|
53
|
+
[#7]: https://github.com/makspiechota/boundry/issues/7
|
|
54
|
+
|
|
7
55
|
## [0.4.0] — 2026-07-17
|
|
8
56
|
|
|
9
57
|
Makes the "one file can be both the communication diagram and the enforcement
|
package/README.md
CHANGED
|
@@ -227,15 +227,21 @@ deterministically, by splicing the LikeC4 CST. No model call, no reformatting:
|
|
|
227
227
|
source-preserving, idempotent, byte-exact.
|
|
228
228
|
|
|
229
229
|
```bash
|
|
230
|
-
boundry verify
|
|
231
|
-
boundry approve --arch arch
|
|
230
|
+
boundry verify --arch arch # any edge granted without a marker, vs the lock?
|
|
231
|
+
boundry approve --arch arch # HUMAN ONLY: strip markers = approve, update the lock
|
|
232
232
|
```
|
|
233
233
|
|
|
234
|
-
`verify` compares
|
|
235
|
-
appeared *without* going through a proposal. Because proposals
|
|
236
|
-
the allow-list, the newly-allowed set is exactly the set of
|
|
237
|
-
diff engine required. `approve
|
|
238
|
-
launder a self-granted edge into an approved one
|
|
234
|
+
`verify` compares the working diagram against the accepted **`boundry.lock`** and
|
|
235
|
+
rejects edges that appeared *without* going through a proposal. Because proposals
|
|
236
|
+
are excluded from the allow-list, the newly-allowed set is exactly the set of
|
|
237
|
+
self-approvals — no diff engine required. `approve` runs the same gate first, so
|
|
238
|
+
it won't launder a self-granted edge into an approved one; then it enacts the
|
|
239
|
+
proposals and records the new accepted state to the lock.
|
|
240
|
+
|
|
241
|
+
The baseline is the lock, not a git ref: it's the state Boundry *owns*, so
|
|
242
|
+
"accepted" never collapses into merely "committed". (One consequence, by design:
|
|
243
|
+
the gate leans on `approve` being a human act — the lock moves only when someone
|
|
244
|
+
approves. That's why the skill forbids agents from running it.)
|
|
239
245
|
|
|
240
246
|
**To retire an edge or a box, propose its removal** with `#proposal-delete`
|
|
241
247
|
instead of deleting it. The marker colours it red; a pending deletion changes
|
|
@@ -258,12 +264,12 @@ Point your agents at
|
|
|
258
264
|
[`.claude/skills/define-architecture-boundaries`](.claude/skills/define-architecture-boundaries/SKILL.md)
|
|
259
265
|
and they'll follow this protocol.
|
|
260
266
|
|
|
261
|
-
### Catching drift —
|
|
267
|
+
### Catching drift — `annotate` (prototype)
|
|
262
268
|
|
|
263
|
-
`verify
|
|
264
|
-
*
|
|
265
|
-
accepted
|
|
266
|
-
|
|
269
|
+
`verify` catches a self-grant and fails; `annotate` is the other half — it
|
|
270
|
+
*rewrites* the same drift into a reviewable proposal. Both read the same baseline,
|
|
271
|
+
the accepted **`boundry.lock`** that `approve` records beside the diagram, so they
|
|
272
|
+
can never disagree about what "accepted" means.
|
|
267
273
|
|
|
268
274
|
```bash
|
|
269
275
|
boundry approve --arch arch # enact proposals AND write boundry.lock
|
|
@@ -291,8 +297,8 @@ outright. Requires **LikeC4 ≥ 1.58** to render.
|
|
|
291
297
|
|
|
292
298
|
A proposal nested inside a box is invisible at a wider zoom: at the top level the
|
|
293
299
|
box collapses and its inner `#proposed` edge disappears. So `diff` generates a
|
|
294
|
-
**focused view for every layer that
|
|
295
|
-
|
|
300
|
+
**focused view for every layer that draws a pending change** — into a derived
|
|
301
|
+
`boundry.diff.likec4`:
|
|
296
302
|
|
|
297
303
|
```bash
|
|
298
304
|
boundry diff --arch arch # (re)write boundry.diff.likec4
|
|
@@ -307,11 +313,20 @@ styling step. Boxes are styled *in place* (never force-included), so a nested
|
|
|
307
313
|
proposal stays in its own layer; unchanged elements keep their defaults; and the
|
|
308
314
|
rules live only in the generated views, so your own views are untouched.
|
|
309
315
|
|
|
316
|
+
A changed edge is drawn at **every altitude it appears**, not only the
|
|
317
|
+
common-ancestor layer — so a cross-system dependency between two deeply-nested
|
|
318
|
+
leaves gets a view at each endpoint's system and container as well as at the
|
|
319
|
+
shared root. Open the change at whatever altitude matters (the whole system, a
|
|
320
|
+
specific container) instead of being dropped at the noisy root view. `diff` emits
|
|
321
|
+
only the layers that genuinely draw the edge: never above the common ancestor
|
|
322
|
+
(where it collapses to an undrawn self-loop), never the leaf endpoints themselves.
|
|
323
|
+
|
|
310
324
|
The file is a **derived artifact**: overwritten every run, removed when nothing is
|
|
311
|
-
proposed, so it always matches the current diagram
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
325
|
+
proposed, so it always matches the current diagram. `approve` deletes it too, as
|
|
326
|
+
part of enacting — the moment the last proposal is approved the views are stale,
|
|
327
|
+
so the post-approve workspace validates clean. It reads the diagram's own markers
|
|
328
|
+
(not the lock), so it frames whatever `annotate` or a human has marked. Being
|
|
329
|
+
derived, it's a `.gitignore` candidate (`boundry.diff.likec4`).
|
|
315
330
|
|
|
316
331
|
> Rendering the coloured diff views needs **LikeC4 ≥ 1.58** (the style-rule syntax
|
|
317
332
|
> Boundry emits). Boundry itself depends on that floor; the tool you review with
|
|
@@ -322,8 +337,8 @@ candidate (`boundry.diff.likec4`).
|
|
|
322
337
|
```
|
|
323
338
|
boundry check [--arch <dir>] [--cwd <dir>] [sources...]
|
|
324
339
|
boundry generate [--arch <dir>] [--cwd <dir>] [--out <file>]
|
|
325
|
-
boundry verify [--arch <dir>] [--cwd <dir>]
|
|
326
|
-
boundry approve [--arch <dir>] [--cwd <dir>]
|
|
340
|
+
boundry verify [--arch <dir>] [--cwd <dir>]
|
|
341
|
+
boundry approve [--arch <dir>] [--cwd <dir>]
|
|
327
342
|
boundry annotate [--arch <dir>]
|
|
328
343
|
boundry diff [--arch <dir>]
|
|
329
344
|
```
|
|
@@ -333,15 +348,16 @@ boundry diff [--arch <dir>]
|
|
|
333
348
|
| `--arch <dir>` | LikeC4 workspace directory (all `.likec4` files in it are merged). Default `.`. |
|
|
334
349
|
| `--cwd <dir>` | Repo root to check. `folder` paths are relative to it. Lets you run from anywhere. |
|
|
335
350
|
| `--out <file>` | `generate` only: where to write the dependency-cruiser config. Default `.dependency-cruiser.cjs`. |
|
|
336
|
-
| `--base <ref>` | `verify`/`approve`: the git ref holding the approved diagram to compare against. |
|
|
337
351
|
| `sources...` | `check` only: paths to lint. Default `src`. |
|
|
338
352
|
|
|
339
353
|
- **`check`** compiles the rules and runs the linter. Exits non-zero on any violation.
|
|
340
354
|
- **`generate`** just emits the dependency-cruiser config so you can commit it or
|
|
341
355
|
run `depcruise` yourself.
|
|
342
|
-
- **`verify`** rejects dependencies granted without a proposal
|
|
343
|
-
|
|
344
|
-
|
|
356
|
+
- **`verify`** rejects dependencies granted without a proposal, comparing the
|
|
357
|
+
diagram against the accepted `boundry.lock`. Needs a lock — run `approve` once
|
|
358
|
+
to record one.
|
|
359
|
+
- **`approve`** runs the same gate, then enacts markers (strips `#proposed`,
|
|
360
|
+
removes `#proposal-delete`) and writes `boundry.lock`. For humans, not agents.
|
|
345
361
|
- **`annotate`** rewrites undeclared additions as `#proposed`, diffing against
|
|
346
362
|
`boundry.lock`.
|
|
347
363
|
- **`diff`** generates a focused, colour-coded review view per layer that holds a
|
|
@@ -366,9 +382,8 @@ jobs:
|
|
|
366
382
|
with: { node-version: 20 }
|
|
367
383
|
- run: npm ci
|
|
368
384
|
- run: npx boundry check --arch arch src
|
|
369
|
-
#
|
|
370
|
-
- run: npx boundry verify --arch arch
|
|
371
|
-
if: github.event_name == 'pull_request'
|
|
385
|
+
# Reject any dependency granted without a proposal, vs the committed lock.
|
|
386
|
+
- run: npx boundry verify --arch arch
|
|
372
387
|
```
|
|
373
388
|
|
|
374
389
|
## Programmatic use (SDK)
|
|
@@ -18,6 +18,13 @@ export declare class LikeC4Visualizer implements VisualizerPort {
|
|
|
18
18
|
* - `#proposal-delete` markers take their whole edge or box with them, so
|
|
19
19
|
* approving a proposed removal actually removes it.
|
|
20
20
|
* All other formatting is left intact.
|
|
21
|
+
*
|
|
22
|
+
* The derived `boundry.diff.likec4` is skipped, not approved: it holds no real
|
|
23
|
+
* markers, only view rules that *reference* the marker tags (`style element.tag
|
|
24
|
+
* = #proposed`, `include … where tag is #proposed`). Splicing those `#proposed`
|
|
25
|
+
* tokens out would corrupt the rules into invalid LikeC4. And it is stale the
|
|
26
|
+
* moment the last proposal is enacted — it describes changes that are now
|
|
27
|
+
* approved — so approve deletes it, leaving a workspace `likec4 validate` accepts.
|
|
21
28
|
*/
|
|
22
29
|
approve(): Promise<void>;
|
|
23
30
|
/**
|
|
@@ -41,10 +48,12 @@ export declare class LikeC4Visualizer implements VisualizerPort {
|
|
|
41
48
|
/**
|
|
42
49
|
* Generate a focused diff view for every layer that holds a pending change.
|
|
43
50
|
* Walks the diagram's own `#proposed` / `#proposal-delete` markers (never the
|
|
44
|
-
* lock — a `#proposal-delete` has no lock delta)
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
51
|
+
* lock — a `#proposal-delete` has no lock delta) and writes one `view … of
|
|
52
|
+
* <scope>` per layer to a derived `boundry.diff.likec4`. A box is bucketed into
|
|
53
|
+
* its parent layer; an edge into *every* layer that draws it (see `edgeScopes`),
|
|
54
|
+
* so a cross-system dependency can be reviewed at each altitude, not only the
|
|
55
|
+
* common-ancestor layer. The file is overwritten each run and removed when
|
|
56
|
+
* nothing is proposed, so it always reflects the current diagram.
|
|
48
57
|
*/
|
|
49
58
|
emitDiffViews(): Promise<DiffView[]>;
|
|
50
59
|
}
|
|
@@ -28,6 +28,46 @@ function parentScope(fqn) {
|
|
|
28
28
|
parts.pop();
|
|
29
29
|
return parts.length ? parts.join('.') : undefined;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Every layer that actually *draws* the edge `from -> to`, so a reviewer can open
|
|
33
|
+
* the change at whatever altitude matters — the whole system, a specific
|
|
34
|
+
* container, or the endpoint's immediate parent — not only the auto-picked common
|
|
35
|
+
* ancestor (issue #7). The set is derived from how LikeC4's `include *` renders a
|
|
36
|
+
* scoped view, verified against 1.58:
|
|
37
|
+
*
|
|
38
|
+
* - The tightest common ancestor draws it nested (both endpoints inside).
|
|
39
|
+
* - Every proper ancestor of an endpoint *below* that common ancestor draws it
|
|
40
|
+
* too: that side's subtree is expanded and the far endpoint is pulled in as a
|
|
41
|
+
* boundary node (e.g. `of s1` → `s1.c1 -> s2`).
|
|
42
|
+
* - Ancestors *above* the common ancestor are deliberately excluded: both
|
|
43
|
+
* endpoints collapse into the same child there, so the edge becomes a
|
|
44
|
+
* self-loop and LikeC4 draws nothing. The common ancestor is the widest layer
|
|
45
|
+
* that still draws it.
|
|
46
|
+
*
|
|
47
|
+
* So the scopes are the common ancestor plus each endpoint's ancestor chain from
|
|
48
|
+
* just below it down to the endpoint's parent — never the endpoints themselves
|
|
49
|
+
* (a view scoped *to* a leaf endpoint has an empty subtree).
|
|
50
|
+
*/
|
|
51
|
+
function edgeScopes(from, to) {
|
|
52
|
+
const common = commonScope(from, to);
|
|
53
|
+
const depthOf = (scope) => (scope ? scope.split('.').length : 0);
|
|
54
|
+
const commonDepth = depthOf(common);
|
|
55
|
+
const byId = new Map();
|
|
56
|
+
const add = (scope) => {
|
|
57
|
+
byId.set(scopeViewId(scope), scope);
|
|
58
|
+
};
|
|
59
|
+
add(common);
|
|
60
|
+
for (const endpoint of [from, to]) {
|
|
61
|
+
const parts = endpoint.split('.');
|
|
62
|
+
// Proper ancestors of the endpoint strictly below the common ancestor: prefix
|
|
63
|
+
// lengths (commonDepth + 1) .. (parts.length - 1). The last is the endpoint's
|
|
64
|
+
// parent; the endpoint itself (full length) is never a scope.
|
|
65
|
+
for (let len = commonDepth + 1; len <= parts.length - 1; len++) {
|
|
66
|
+
add(parts.slice(0, len).join('.'));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...byId.values()];
|
|
70
|
+
}
|
|
31
71
|
/**
|
|
32
72
|
* Deterministic highlight rules for the marker tags actually in use. Referencing
|
|
33
73
|
* an undeclared tag is a hard LikeC4 error, and a tag can only be *used* if it is
|
|
@@ -393,11 +433,20 @@ export class LikeC4Visualizer {
|
|
|
393
433
|
* - `#proposal-delete` markers take their whole edge or box with them, so
|
|
394
434
|
* approving a proposed removal actually removes it.
|
|
395
435
|
* All other formatting is left intact.
|
|
436
|
+
*
|
|
437
|
+
* The derived `boundry.diff.likec4` is skipped, not approved: it holds no real
|
|
438
|
+
* markers, only view rules that *reference* the marker tags (`style element.tag
|
|
439
|
+
* = #proposed`, `include … where tag is #proposed`). Splicing those `#proposed`
|
|
440
|
+
* tokens out would corrupt the rules into invalid LikeC4. And it is stale the
|
|
441
|
+
* moment the last proposal is enacted — it describes changes that are now
|
|
442
|
+
* approved — so approve deletes it, leaving a workspace `likec4 validate` accepts.
|
|
396
443
|
*/
|
|
397
444
|
async approve() {
|
|
398
445
|
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
399
446
|
for (const doc of likec4.LangiumDocuments.all) {
|
|
400
447
|
const filePath = doc.uri.fsPath;
|
|
448
|
+
if (filePath.endsWith(DIFF_FILE))
|
|
449
|
+
continue;
|
|
401
450
|
const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
|
|
402
451
|
const ranges = collectApprovalRanges(doc.parseResult?.value, text);
|
|
403
452
|
if (ranges.length === 0)
|
|
@@ -408,6 +457,12 @@ export class LikeC4Visualizer {
|
|
|
408
457
|
}
|
|
409
458
|
writeFileSync(filePath, next);
|
|
410
459
|
}
|
|
460
|
+
// Enacting the last proposal makes any diff views stale — they frame changes
|
|
461
|
+
// that are now approved. Remove the derived artifact so the post-approve
|
|
462
|
+
// workspace validates and never renders an approved-away change.
|
|
463
|
+
const diffPath = join(this.workspaceDir, DIFF_FILE);
|
|
464
|
+
if (existsSync(diffPath))
|
|
465
|
+
rmSync(diffPath);
|
|
411
466
|
}
|
|
412
467
|
/**
|
|
413
468
|
* Deterministically mark the given edges and elements `#proposed`. The inverse
|
|
@@ -529,10 +584,12 @@ export class LikeC4Visualizer {
|
|
|
529
584
|
/**
|
|
530
585
|
* Generate a focused diff view for every layer that holds a pending change.
|
|
531
586
|
* Walks the diagram's own `#proposed` / `#proposal-delete` markers (never the
|
|
532
|
-
* lock — a `#proposal-delete` has no lock delta)
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
587
|
+
* lock — a `#proposal-delete` has no lock delta) and writes one `view … of
|
|
588
|
+
* <scope>` per layer to a derived `boundry.diff.likec4`. A box is bucketed into
|
|
589
|
+
* its parent layer; an edge into *every* layer that draws it (see `edgeScopes`),
|
|
590
|
+
* so a cross-system dependency can be reviewed at each altitude, not only the
|
|
591
|
+
* common-ancestor layer. The file is overwritten each run and removed when
|
|
592
|
+
* nothing is proposed, so it always reflects the current diagram.
|
|
536
593
|
*/
|
|
537
594
|
async emitDiffViews() {
|
|
538
595
|
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
@@ -562,8 +619,11 @@ export class LikeC4Visualizer {
|
|
|
562
619
|
if (statement?.$type === 'Relation') {
|
|
563
620
|
const from = fqnOf(endpointElement(statement.source));
|
|
564
621
|
const to = fqnOf(endpointElement(statement.target));
|
|
622
|
+
// One view per layer that actually draws this edge, so the reviewer can
|
|
623
|
+
// judge it at every altitude — not only the common-ancestor layer (#7).
|
|
565
624
|
if (from && to)
|
|
566
|
-
|
|
625
|
+
for (const scope of edgeScopes(from, to))
|
|
626
|
+
record(scope);
|
|
567
627
|
}
|
|
568
628
|
else if (statement?.$type === 'Element') {
|
|
569
629
|
const fqn = fqnOf(statement);
|
package/dist/cli/index.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
3
|
import { join, relative, resolve } from 'node:path';
|
|
6
4
|
import { Pipeline } from '../core/pipeline/pipeline.js';
|
|
7
5
|
import { LikeC4Visualizer } from '../adapters/visualizer/likec4.js';
|
|
8
6
|
import { DepCruiserEnforcer } from '../adapters/enforcer/depcruiser.js';
|
|
9
|
-
const USAGE = 'usage: boundry <generate|check|approve|verify|annotate|diff> [--arch <dir>] [--
|
|
7
|
+
const USAGE = 'usage: boundry <generate|check|approve|verify|annotate|diff> [--arch <dir>] [--cwd <dir>] [--out <file>] [sources...]';
|
|
10
8
|
function optValue(args, flag) {
|
|
11
9
|
const i = args.indexOf(flag);
|
|
12
10
|
return i >= 0 ? args[i + 1] : undefined;
|
|
@@ -22,30 +20,6 @@ function positionals(args) {
|
|
|
22
20
|
}
|
|
23
21
|
return out;
|
|
24
22
|
}
|
|
25
|
-
/**
|
|
26
|
-
* Materialize the architecture as of a git ref into a temp workspace, so the
|
|
27
|
-
* previously-approved boundary model can be lifted and compared against HEAD.
|
|
28
|
-
*/
|
|
29
|
-
function materializeArchAt(archDir, ref) {
|
|
30
|
-
const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
31
|
-
cwd: archDir,
|
|
32
|
-
encoding: 'utf8',
|
|
33
|
-
}).trim();
|
|
34
|
-
const workDir = mkdtempSync(join(tmpdir(), 'boundry-base-'));
|
|
35
|
-
for (const file of readdirSync(archDir).filter((f) => f.endsWith('.likec4'))) {
|
|
36
|
-
try {
|
|
37
|
-
const content = execFileSync('git', ['show', `${ref}:${relative(gitRoot, join(archDir, file))}`], {
|
|
38
|
-
cwd: gitRoot,
|
|
39
|
-
encoding: 'utf8',
|
|
40
|
-
});
|
|
41
|
-
writeFileSync(join(workDir, file), content);
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
// The file did not exist at `ref` — everything it declares is new.
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return workDir;
|
|
48
|
-
}
|
|
49
23
|
function countGrants(granted) {
|
|
50
24
|
return granted.edges.length + granted.exemptions.length;
|
|
51
25
|
}
|
|
@@ -69,17 +43,17 @@ async function main() {
|
|
|
69
43
|
const archDir = resolve(optValue(rest, '--arch') ?? '.');
|
|
70
44
|
const outArg = optValue(rest, '--out');
|
|
71
45
|
const outFile = outArg ? resolve(outArg) : undefined;
|
|
72
|
-
const baseRef = optValue(rest, '--base');
|
|
73
46
|
// `--cwd` lets you check a repo without cd-ing into it. `folder` metadata is
|
|
74
47
|
// relative to the target repo root, so the enforcer runs from there.
|
|
75
48
|
const cwd = optValue(rest, '--cwd');
|
|
76
49
|
if (cwd)
|
|
77
50
|
process.chdir(resolve(cwd));
|
|
78
51
|
// The accepted-state lock lives beside the diagram it locks. `approve` writes
|
|
79
|
-
// it; `annotate`
|
|
52
|
+
// it; `verify` and `annotate` read it — the baseline Boundry owns, decoupled
|
|
53
|
+
// from git, so "accepted" never means merely "committed".
|
|
80
54
|
const lockFile = join(archDir, 'boundry.lock');
|
|
55
|
+
const readLock = () => existsSync(lockFile) ? readFileSync(lockFile, 'utf8') : undefined;
|
|
81
56
|
const pipeline = new Pipeline(new LikeC4Visualizer(archDir), new DepCruiserEnforcer());
|
|
82
|
-
const grantedSince = (ref) => pipeline.verify(new LikeC4Visualizer(materializeArchAt(archDir, ref)));
|
|
83
57
|
if (command === 'generate') {
|
|
84
58
|
const config = await pipeline.generate();
|
|
85
59
|
const out = outFile ?? resolve(config.filename);
|
|
@@ -105,37 +79,43 @@ async function main() {
|
|
|
105
79
|
return;
|
|
106
80
|
}
|
|
107
81
|
if (command === 'verify') {
|
|
108
|
-
|
|
109
|
-
|
|
82
|
+
const lock = readLock();
|
|
83
|
+
if (!lock) {
|
|
84
|
+
console.error(`Boundry: no ${relative(process.cwd(), lockFile)} — run 'boundry approve' first to record the accepted state`);
|
|
110
85
|
process.exitCode = 2;
|
|
111
86
|
return;
|
|
112
87
|
}
|
|
113
|
-
const granted = await
|
|
88
|
+
const granted = await pipeline.verify(lock);
|
|
114
89
|
if (countGrants(granted) === 0) {
|
|
115
|
-
console.log(
|
|
90
|
+
console.log('Boundry: ✓ nothing granted without a #proposed marker (vs the accepted lock)');
|
|
116
91
|
return;
|
|
117
92
|
}
|
|
118
|
-
console.error(`Boundry: ✗ ${countGrants(granted)} grant(s) made without a #proposed marker (vs
|
|
93
|
+
console.error(`Boundry: ✗ ${countGrants(granted)} grant(s) made without a #proposed marker (vs the accepted lock)`);
|
|
119
94
|
listGrants(granted);
|
|
120
95
|
process.exitCode = 1;
|
|
121
96
|
return;
|
|
122
97
|
}
|
|
123
98
|
if (command === 'approve') {
|
|
124
99
|
// Approving must never launder an edge that skipped the proposal protocol.
|
|
125
|
-
|
|
126
|
-
|
|
100
|
+
// The accepted lock is the baseline: a bare edge added since the last approve
|
|
101
|
+
// is in the allow-list but not the lock, so it surfaces here; a #proposed one
|
|
102
|
+
// is excluded from the allow-list, so it passes and gets enacted below. The
|
|
103
|
+
// first approve has no lock yet — it establishes the initial accepted state.
|
|
104
|
+
const lock = readLock();
|
|
105
|
+
if (lock) {
|
|
106
|
+
const granted = await pipeline.verify(lock);
|
|
127
107
|
if (countGrants(granted) > 0) {
|
|
128
|
-
console.error(`Boundry: ✗ refusing to approve — ${countGrants(granted)} grant(s) were made without a #proposed marker (vs
|
|
108
|
+
console.error(`Boundry: ✗ refusing to approve — ${countGrants(granted)} grant(s) were made without a #proposed marker (vs the accepted lock)`);
|
|
129
109
|
listGrants(granted);
|
|
130
110
|
process.exitCode = 1;
|
|
131
111
|
return;
|
|
132
112
|
}
|
|
133
113
|
}
|
|
134
114
|
else {
|
|
135
|
-
console.error('Boundry: ⚠ no
|
|
115
|
+
console.error('Boundry: ⚠ no boundry.lock yet — approving to record the initial accepted state');
|
|
136
116
|
}
|
|
137
|
-
const
|
|
138
|
-
writeFileSync(lockFile,
|
|
117
|
+
const nextLock = await pipeline.approve();
|
|
118
|
+
writeFileSync(lockFile, nextLock);
|
|
139
119
|
console.log('Boundry: ✓ approved — enacted the diagram and wrote', relative(process.cwd(), lockFile));
|
|
140
120
|
return;
|
|
141
121
|
}
|
|
@@ -31,12 +31,18 @@ export declare class Pipeline {
|
|
|
31
31
|
/** Diagram -> boundary model -> run the linter over `sources`. */
|
|
32
32
|
check(sources: string[]): Promise<CheckResult>;
|
|
33
33
|
/**
|
|
34
|
-
* What this diagram grants that `
|
|
35
|
-
* `#proposed` marker, and importer exemptions added. Proposals
|
|
36
|
-
* from the allow-list, so anything reported here bypassed the
|
|
37
|
-
* protocol.
|
|
34
|
+
* What this diagram grants that the accepted `lock` did not — dependencies
|
|
35
|
+
* added without a `#proposed` marker, and importer exemptions added. Proposals
|
|
36
|
+
* are excluded from the allow-list, so anything reported here bypassed the
|
|
37
|
+
* approval protocol.
|
|
38
|
+
*
|
|
39
|
+
* The baseline is the lock — the accepted state `approve` records — not a git
|
|
40
|
+
* ref. The lock exists precisely to decouple "accepted" from "committed", so
|
|
41
|
+
* verify reads from it, exactly as `annotate` does; the two can never disagree
|
|
42
|
+
* about what "accepted" means. It also catches a committed-but-unapproved bare
|
|
43
|
+
* edge, which re-deriving a baseline from a git ref would silently absorb.
|
|
38
44
|
*/
|
|
39
|
-
verify(
|
|
45
|
+
verify(lock: string): Promise<VerifyResult>;
|
|
40
46
|
/**
|
|
41
47
|
* Enact the diagram's pending proposals (strip `#proposed`, remove
|
|
42
48
|
* `#proposal-delete`), then return the resulting accepted model as a lock
|
|
@@ -21,16 +21,23 @@ export class Pipeline {
|
|
|
21
21
|
return this.enforcer.check(model, sources);
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* What this diagram grants that `
|
|
25
|
-
* `#proposed` marker, and importer exemptions added. Proposals
|
|
26
|
-
* from the allow-list, so anything reported here bypassed the
|
|
27
|
-
* protocol.
|
|
24
|
+
* What this diagram grants that the accepted `lock` did not — dependencies
|
|
25
|
+
* added without a `#proposed` marker, and importer exemptions added. Proposals
|
|
26
|
+
* are excluded from the allow-list, so anything reported here bypassed the
|
|
27
|
+
* approval protocol.
|
|
28
|
+
*
|
|
29
|
+
* The baseline is the lock — the accepted state `approve` records — not a git
|
|
30
|
+
* ref. The lock exists precisely to decouple "accepted" from "committed", so
|
|
31
|
+
* verify reads from it, exactly as `annotate` does; the two can never disagree
|
|
32
|
+
* about what "accepted" means. It also catches a committed-but-unapproved bare
|
|
33
|
+
* edge, which re-deriving a baseline from a git ref would silently absorb.
|
|
28
34
|
*/
|
|
29
|
-
async verify(
|
|
30
|
-
const
|
|
35
|
+
async verify(lock) {
|
|
36
|
+
const base = parseModel(lock);
|
|
37
|
+
const head = await this.visualizer.read();
|
|
31
38
|
return {
|
|
32
|
-
edges: newlyAllowedEdges(
|
|
33
|
-
exemptions: newlyExemptedImporters(
|
|
39
|
+
edges: newlyAllowedEdges(base, head),
|
|
40
|
+
exemptions: newlyExemptedImporters(base, head),
|
|
34
41
|
};
|
|
35
42
|
}
|
|
36
43
|
/**
|
package/package.json
CHANGED