boundry 0.5.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 +65 -0
- package/README.md +77 -56
- package/dist/adapters/visualizer/likec4.d.ts +33 -10
- package/dist/adapters/visualizer/likec4.js +225 -35
- package/dist/cli/index.js +90 -44
- package/dist/core/model/boundary-model.d.ts +17 -0
- package/dist/core/model/boundary-model.js +23 -0
- package/dist/core/pipeline/pipeline.d.ts +55 -29
- package/dist/core/pipeline/pipeline.js +54 -30
- package/dist/core/ports/ports.d.ts +22 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,71 @@ 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
|
+
|
|
50
|
+
## [0.6.0] — 2026-07-22
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- **`boundry diff` now emits a single "proposed changes" view by default** ([#8]),
|
|
55
|
+
instead of one view per changed layer. The per-layer shape (added in 0.4.0,
|
|
56
|
+
extended per-altitude in 0.5.0) had two failure modes on real changes: it
|
|
57
|
+
multiplied views (one multi-slice change produced ~21), and its per-layer
|
|
58
|
+
`include *` collapsed deeply-nested proposals into their grey ancestor box, so a
|
|
59
|
+
nested proposed leaf silently lost its amber. The new `boundry_diff` view uses
|
|
60
|
+
`include * -> * where tag is #proposed` with **no** bare `include *`, so it pulls
|
|
61
|
+
in exactly the proposed edges and the leaf modules they touch — every change is
|
|
62
|
+
drawn at leaf level, uncollapsed and uniformly coloured, on one landing. A
|
|
63
|
+
`#proposed` box with no edge yet is included by fqn so a proposed-but-unwired
|
|
64
|
+
module still shows, and every endpoint's ancestor chain is included so each
|
|
65
|
+
change nests under its layer/system for context. `approve` still deletes the
|
|
66
|
+
derived file (0.5.0 behaviour).
|
|
67
|
+
- The per-altitude per-layer views from 0.5.0 remain available behind
|
|
68
|
+
**`boundry diff --per-layer`** — useful for a small change, opt-in.
|
|
69
|
+
|
|
70
|
+
[#8]: https://github.com/makspiechota/boundry/issues/8
|
|
71
|
+
|
|
7
72
|
## [0.5.0] — 2026-07-21
|
|
8
73
|
|
|
9
74
|
### Fixed
|
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
|
|
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
|
|
235
|
-
rejects
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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 #
|
|
277
|
+
boundry annotate --arch arch # stage drift as #proposed / #proposal-delete
|
|
277
278
|
```
|
|
278
279
|
|
|
279
|
-
`annotate`
|
|
280
|
-
a
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
@@ -293,38 +297,41 @@ this styling back out with the marker: a `#proposed` edge returns to bare, a
|
|
|
293
297
|
`#proposed` box stays but loses its colour, a `#proposal-delete` is removed
|
|
294
298
|
outright. Requires **LikeC4 ≥ 1.58** to render.
|
|
295
299
|
|
|
296
|
-
### Reviewing a proposal —
|
|
300
|
+
### Reviewing a proposal — the diff view (prototype)
|
|
297
301
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
**focused view for every layer that draws a pending change** — into a derived
|
|
301
|
-
`boundry.diff.likec4`:
|
|
302
|
+
`diff` generates a single **proposed-changes** view — every pending change on one
|
|
303
|
+
landing — into a derived `boundry.diff.likec4`:
|
|
302
304
|
|
|
303
305
|
```bash
|
|
304
306
|
boundry diff --arch arch # (re)write boundry.diff.likec4
|
|
305
|
-
likec4 serve arch #
|
|
307
|
+
likec4 serve arch # open 'Boundry diff — proposed changes'
|
|
306
308
|
```
|
|
307
309
|
|
|
308
310
|
The highlighting is **generated, not hand-styled** — `diff` emits the LikeC4
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
311
|
+
rules into the derived file, so every `#proposed` box fills amber and edge goes
|
|
312
|
+
amber + solid, every `#proposal-delete` red, deterministically. That closes the
|
|
313
|
+
last manual seam: `annotate` marks, `diff` colours, no agent-dependent styling
|
|
314
|
+
step. Unchanged elements keep their defaults, and the rules live only in the
|
|
315
|
+
generated view, so your own views are untouched.
|
|
316
|
+
|
|
317
|
+
The view uses `include * -> * where tag is #proposed` (no bare `include *`), so it
|
|
318
|
+
pulls in exactly the proposed edges and the leaf modules they touch — a
|
|
319
|
+
deeply-nested proposal renders as its **own coloured node** instead of collapsing
|
|
320
|
+
into a grey ancestor. A proposed *module* with no edge yet is included too, so a
|
|
321
|
+
proposed-but-unwired box still shows. Each change nests under its layer/system for
|
|
322
|
+
context.
|
|
323
|
+
|
|
324
|
+
For a small change you can ask for the old per-layer shape instead — one focused
|
|
325
|
+
`view … of <scope>` for every layer that draws a change:
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
boundry diff --arch arch --per-layer
|
|
329
|
+
```
|
|
323
330
|
|
|
324
331
|
The file is a **derived artifact**: overwritten every run, removed when nothing is
|
|
325
332
|
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
|
|
327
|
-
|
|
333
|
+
part of enacting — the moment the last proposal is approved the view is stale, so
|
|
334
|
+
the post-approve workspace validates clean. It reads the diagram's own markers
|
|
328
335
|
(not the lock), so it frames whatever `annotate` or a human has marked. Being
|
|
329
336
|
derived, it's a `.gitignore` candidate (`boundry.diff.likec4`).
|
|
330
337
|
|
|
@@ -340,7 +347,7 @@ boundry generate [--arch <dir>] [--cwd <dir>] [--out <file>]
|
|
|
340
347
|
boundry verify [--arch <dir>] [--cwd <dir>]
|
|
341
348
|
boundry approve [--arch <dir>] [--cwd <dir>]
|
|
342
349
|
boundry annotate [--arch <dir>]
|
|
343
|
-
boundry diff [--arch <dir>]
|
|
350
|
+
boundry diff [--arch <dir>] [--per-layer]
|
|
344
351
|
```
|
|
345
352
|
|
|
346
353
|
| Flag | Meaning |
|
|
@@ -348,20 +355,31 @@ boundry diff [--arch <dir>]
|
|
|
348
355
|
| `--arch <dir>` | LikeC4 workspace directory (all `.likec4` files in it are merged). Default `.`. |
|
|
349
356
|
| `--cwd <dir>` | Repo root to check. `folder` paths are relative to it. Lets you run from anywhere. |
|
|
350
357
|
| `--out <file>` | `generate` only: where to write the dependency-cruiser config. Default `.dependency-cruiser.cjs`. |
|
|
358
|
+
| `--per-layer` | `diff` only: emit one focused view per layer instead of the single proposed-changes view. |
|
|
351
359
|
| `sources...` | `check` only: paths to lint. Default `src`. |
|
|
352
360
|
|
|
353
|
-
- **`check`**
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
- **`
|
|
364
|
-
|
|
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`.
|
|
378
|
+
- **`diff`** generates a single colour-coded "proposed changes" review view into a
|
|
379
|
+
derived `boundry.diff.likec4` (or, with `--per-layer`, one focused view per layer
|
|
380
|
+
that draws a change).
|
|
381
|
+
|
|
382
|
+
**Commit `boundry.lock`** — it is the enforced contract, and CI checks against it.
|
|
365
383
|
|
|
366
384
|
Boundry warns (but does not fail) when a mapped folder matches **zero** files, and
|
|
367
385
|
**fails outright** when a check analysed no files at all — so a passing check can
|
|
@@ -381,9 +399,9 @@ jobs:
|
|
|
381
399
|
- uses: actions/setup-node@v4
|
|
382
400
|
with: { node-version: 20 }
|
|
383
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).
|
|
384
404
|
- run: npx boundry check --arch arch src
|
|
385
|
-
# Reject any dependency granted without a proposal, vs the committed lock.
|
|
386
|
-
- run: npx boundry verify --arch arch
|
|
387
405
|
```
|
|
388
406
|
|
|
389
407
|
## Programmatic use (SDK)
|
|
@@ -392,6 +410,7 @@ The CLI is a thin wrapper over the SDK. Everything is pluggable — the diagram
|
|
|
392
410
|
source and the target linter are both adapters behind ports.
|
|
393
411
|
|
|
394
412
|
```ts
|
|
413
|
+
import { readFileSync } from 'node:fs';
|
|
395
414
|
import { Pipeline, LikeC4Visualizer, DepCruiserEnforcer } from 'boundry';
|
|
396
415
|
|
|
397
416
|
const pipeline = new Pipeline(
|
|
@@ -399,7 +418,9 @@ const pipeline = new Pipeline(
|
|
|
399
418
|
new DepCruiserEnforcer(),
|
|
400
419
|
);
|
|
401
420
|
|
|
402
|
-
|
|
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']);
|
|
403
424
|
if (!result.ok) {
|
|
404
425
|
for (const v of result.violations) console.error(`${v.from} → ${v.to}`);
|
|
405
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
|
|
@@ -46,14 +57,26 @@ export declare class LikeC4Visualizer implements VisualizerPort {
|
|
|
46
57
|
*/
|
|
47
58
|
styleMarkers(): Promise<void>;
|
|
48
59
|
/**
|
|
49
|
-
* Generate
|
|
50
|
-
* Walks the diagram's own `#proposed` / `#proposal-delete`
|
|
51
|
-
* lock — a `#proposal-delete` has no lock delta)
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
60
|
+
* Generate the review views for the diagram's pending changes, into a derived
|
|
61
|
+
* `boundry.diff.likec4`. Walks the diagram's own `#proposed` / `#proposal-delete`
|
|
62
|
+
* markers (never the lock — a `#proposal-delete` has no lock delta).
|
|
63
|
+
*
|
|
64
|
+
* By default it emits a **single `boundry_diff` view** that draws every change at
|
|
65
|
+
* once, uncollapsed and uniformly highlighted (see `renderSingleView`) — the diff
|
|
66
|
+
* landing. With `perLayer`, it instead emits one focused `view … of <scope>` for
|
|
67
|
+
* every layer that draws a change (a box in its parent layer; an edge in every
|
|
68
|
+
* layer that draws it, per `edgeScopes`) — useful for a small change, but it
|
|
69
|
+
* multiplies views and collapses deeply-nested proposals into grey ancestors on
|
|
70
|
+
* a broad `include *`, which is why the single view is the default (issue #8).
|
|
71
|
+
*
|
|
72
|
+
* The file is overwritten each run and removed when nothing is proposed, so it
|
|
73
|
+
* always reflects the current diagram.
|
|
74
|
+
*/
|
|
75
|
+
emitDiffViews(perLayer?: boolean): Promise<DiffView[]>;
|
|
76
|
+
/**
|
|
77
|
+
* The `--per-layer` shape: buckets each change into every layer that draws it and
|
|
78
|
+
* renders one scoped `view … of <scope>` per layer. Kept as an opt-in to the
|
|
79
|
+
* single-view default.
|
|
57
80
|
*/
|
|
58
|
-
|
|
81
|
+
private perLayerViews;
|
|
59
82
|
}
|
|
@@ -104,6 +104,61 @@ function renderDiffView(scope, highlight) {
|
|
|
104
104
|
const lines = [` title 'Boundry diff · ${scope ?? 'root'}'`, ' include *', ...highlight];
|
|
105
105
|
return `${opener}\n${lines.join('\n')}\n }`;
|
|
106
106
|
}
|
|
107
|
+
/** Every proper ancestor of an element, shallowest first — its parent chain up to (not including) the root. */
|
|
108
|
+
function ancestorFqns(fqn) {
|
|
109
|
+
const parts = fqn.split('.');
|
|
110
|
+
const out = [];
|
|
111
|
+
for (let len = 1; len <= parts.length - 1; len++)
|
|
112
|
+
out.push(parts.slice(0, len).join('.'));
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The single `boundry_diff` view: one landing that draws *every* pending change at
|
|
117
|
+
* once, with uniform highlighting and no empty views. It sidesteps the per-layer
|
|
118
|
+
* shape's two failure modes (issue #8):
|
|
119
|
+
*
|
|
120
|
+
* - **No collapse.** `include * -> * where tag is #proposed` (with NO bare
|
|
121
|
+
* `include *`) pulls in exactly the proposed edges and the leaf modules they
|
|
122
|
+
* touch — so a deeply-nested proposed leaf renders as its own amber node
|
|
123
|
+
* instead of collapsing into a grey ancestor, as it does under `include *`.
|
|
124
|
+
* - **Standalone boxes.** A `#proposed` box with no edge is included by fqn, so
|
|
125
|
+
* a proposed-but-unwired module still shows (and is coloured by the style rule).
|
|
126
|
+
*
|
|
127
|
+
* Every endpoint's and box's full ancestor chain is included too, so each change
|
|
128
|
+
* nests under its layer/system for context (verified against LikeC4 1.58). Rules
|
|
129
|
+
* are emitted only for marker tags actually in use — referencing an undeclared tag
|
|
130
|
+
* is a hard LikeC4 error.
|
|
131
|
+
*/
|
|
132
|
+
function renderSingleView(edges, boxes, usedTags) {
|
|
133
|
+
const lines = [` title 'Boundry diff — proposed changes'`];
|
|
134
|
+
// Edge includes: one per marker that appears on an edge. These pull the proposed
|
|
135
|
+
// edges and their endpoint leaves in at leaf level, uncollapsed.
|
|
136
|
+
if (edges.some((e) => e.marker === '#proposed')) {
|
|
137
|
+
lines.push(' include * -> * where tag is #proposed with { color amber; line solid }');
|
|
138
|
+
}
|
|
139
|
+
if (edges.some((e) => e.marker === '#proposal-delete')) {
|
|
140
|
+
lines.push(' include * -> * where tag is #proposal-delete with { color red; line solid }');
|
|
141
|
+
}
|
|
142
|
+
// Element includes: the proposed/proposal-delete boxes themselves (so a standalone
|
|
143
|
+
// one shows), plus the full ancestor chain of every endpoint and box (so each
|
|
144
|
+
// change nests under its containers). Deterministic: sorted, de-duplicated.
|
|
145
|
+
const includeFqns = new Set();
|
|
146
|
+
for (const box of boxes)
|
|
147
|
+
includeFqns.add(box.fqn);
|
|
148
|
+
const anchors = [...edges.flatMap((e) => [e.from, e.to]), ...boxes.map((b) => b.fqn)];
|
|
149
|
+
for (const fqn of anchors)
|
|
150
|
+
for (const ancestor of ancestorFqns(fqn))
|
|
151
|
+
includeFqns.add(ancestor);
|
|
152
|
+
if (includeFqns.size) {
|
|
153
|
+
lines.push(` include ${[...includeFqns].sort().join(', ')}`);
|
|
154
|
+
}
|
|
155
|
+
// Style rules colour the tagged boxes (edges are coloured by their include above).
|
|
156
|
+
if (usedTags.has('proposed'))
|
|
157
|
+
lines.push(' style element.tag = #proposed { color amber }');
|
|
158
|
+
if (usedTags.has('proposal-delete'))
|
|
159
|
+
lines.push(' style element.tag = #proposal-delete { color red }');
|
|
160
|
+
return ` view boundry_diff {\n${lines.join('\n')}\n }`;
|
|
161
|
+
}
|
|
107
162
|
/**
|
|
108
163
|
* An exemption pattern has to be a usable regex before it reaches the linter. A
|
|
109
164
|
* broken or empty one would otherwise be a silent hole: it exempts files from
|
|
@@ -520,6 +575,114 @@ export class LikeC4Visualizer {
|
|
|
520
575
|
writeFileSync(filePath, next);
|
|
521
576
|
}
|
|
522
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
|
+
}
|
|
523
686
|
/**
|
|
524
687
|
* Paint intrinsic style on every marked edge and box, so a proposal is
|
|
525
688
|
* highlighted on *every* LikeC4 surface — base views and the "relationships of
|
|
@@ -582,28 +745,28 @@ export class LikeC4Visualizer {
|
|
|
582
745
|
}
|
|
583
746
|
}
|
|
584
747
|
/**
|
|
585
|
-
* Generate
|
|
586
|
-
* Walks the diagram's own `#proposed` / `#proposal-delete`
|
|
587
|
-
* lock — a `#proposal-delete` has no lock delta)
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
748
|
+
* Generate the review views for the diagram's pending changes, into a derived
|
|
749
|
+
* `boundry.diff.likec4`. Walks the diagram's own `#proposed` / `#proposal-delete`
|
|
750
|
+
* markers (never the lock — a `#proposal-delete` has no lock delta).
|
|
751
|
+
*
|
|
752
|
+
* By default it emits a **single `boundry_diff` view** that draws every change at
|
|
753
|
+
* once, uncollapsed and uniformly highlighted (see `renderSingleView`) — the diff
|
|
754
|
+
* landing. With `perLayer`, it instead emits one focused `view … of <scope>` for
|
|
755
|
+
* every layer that draws a change (a box in its parent layer; an edge in every
|
|
756
|
+
* layer that draws it, per `edgeScopes`) — useful for a small change, but it
|
|
757
|
+
* multiplies views and collapses deeply-nested proposals into grey ancestors on
|
|
758
|
+
* a broad `include *`, which is why the single view is the default (issue #8).
|
|
759
|
+
*
|
|
760
|
+
* The file is overwritten each run and removed when nothing is proposed, so it
|
|
761
|
+
* always reflects the current diagram.
|
|
593
762
|
*/
|
|
594
|
-
async emitDiffViews() {
|
|
763
|
+
async emitDiffViews(perLayer = false) {
|
|
595
764
|
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
596
|
-
//
|
|
597
|
-
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const layer = layers.get(key) ?? { scope, changes: 0 };
|
|
602
|
-
layer.changes += 1;
|
|
603
|
-
layers.set(key, layer);
|
|
604
|
-
};
|
|
605
|
-
// Which marker tags actually appear — so the emitted highlight rules only ever
|
|
606
|
-
// reference declared tags (an undeclared tag reference is a hard LikeC4 error).
|
|
765
|
+
// Lift every marked edge and box once; both view shapes are built from this.
|
|
766
|
+
const edges = [];
|
|
767
|
+
const boxes = [];
|
|
768
|
+
// Which marker tags actually appear — so the emitted rules only ever reference
|
|
769
|
+
// declared tags (an undeclared tag reference is a hard LikeC4 error).
|
|
607
770
|
const usedTags = new Set();
|
|
608
771
|
for (const doc of likec4.LangiumDocuments.all) {
|
|
609
772
|
const filePath = doc.uri.fsPath;
|
|
@@ -619,27 +782,54 @@ export class LikeC4Visualizer {
|
|
|
619
782
|
if (statement?.$type === 'Relation') {
|
|
620
783
|
const from = fqnOf(endpointElement(statement.source));
|
|
621
784
|
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).
|
|
624
785
|
if (from && to)
|
|
625
|
-
|
|
626
|
-
record(scope);
|
|
786
|
+
edges.push({ from, to, marker });
|
|
627
787
|
}
|
|
628
788
|
else if (statement?.$type === 'Element') {
|
|
629
789
|
const fqn = fqnOf(statement);
|
|
630
790
|
if (fqn)
|
|
631
|
-
|
|
791
|
+
boxes.push({ fqn, marker });
|
|
632
792
|
}
|
|
633
793
|
});
|
|
634
794
|
}
|
|
635
795
|
const diffPath = join(this.workspaceDir, DIFF_FILE);
|
|
636
|
-
if (
|
|
796
|
+
if (edges.length === 0 && boxes.length === 0) {
|
|
637
797
|
// Nothing proposed — clear any stale views so `serve` never renders a diff
|
|
638
798
|
// that no longer exists (and never dangles a reference to an approved-away box).
|
|
639
799
|
if (existsSync(diffPath))
|
|
640
800
|
rmSync(diffPath);
|
|
641
801
|
return [];
|
|
642
802
|
}
|
|
803
|
+
const { header, body, views } = perLayer
|
|
804
|
+
? this.perLayerViews(edges, boxes, usedTags)
|
|
805
|
+
: {
|
|
806
|
+
header: 'a single landing view of every pending change',
|
|
807
|
+
body: renderSingleView(edges, boxes, usedTags),
|
|
808
|
+
views: [{ id: 'boundry_diff', changes: edges.length + boxes.length }],
|
|
809
|
+
};
|
|
810
|
+
writeFileSync(diffPath, `// Generated by \`boundry diff\` — ${header}.\n` +
|
|
811
|
+
`// Derived artifact: regenerate after approve; do not hand-edit.\n` +
|
|
812
|
+
`views {\n${body}\n}\n`);
|
|
813
|
+
return views;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* The `--per-layer` shape: buckets each change into every layer that draws it and
|
|
817
|
+
* renders one scoped `view … of <scope>` per layer. Kept as an opt-in to the
|
|
818
|
+
* single-view default.
|
|
819
|
+
*/
|
|
820
|
+
perLayerViews(edges, boxes, usedTags) {
|
|
821
|
+
const layers = new Map();
|
|
822
|
+
const record = (scope) => {
|
|
823
|
+
const key = scopeViewId(scope);
|
|
824
|
+
const layer = layers.get(key) ?? { scope, changes: 0 };
|
|
825
|
+
layer.changes += 1;
|
|
826
|
+
layers.set(key, layer);
|
|
827
|
+
};
|
|
828
|
+
for (const edge of edges)
|
|
829
|
+
for (const scope of edgeScopes(edge.from, edge.to))
|
|
830
|
+
record(scope);
|
|
831
|
+
for (const box of boxes)
|
|
832
|
+
record(parentScope(box.fqn));
|
|
643
833
|
// Root first, then by fqn, so the output is stable across runs.
|
|
644
834
|
const ordered = [...layers.values()].sort((a, b) => {
|
|
645
835
|
if (!a.scope)
|
|
@@ -649,14 +839,14 @@ export class LikeC4Visualizer {
|
|
|
649
839
|
return a.scope.localeCompare(b.scope);
|
|
650
840
|
});
|
|
651
841
|
const highlight = highlightLines(usedTags);
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
}
|
|
842
|
+
return {
|
|
843
|
+
header: 'one focused view per layer with a pending change',
|
|
844
|
+
body: ordered.map((layer) => renderDiffView(layer.scope, highlight)).join('\n'),
|
|
845
|
+
views: ordered.map((layer) => ({
|
|
846
|
+
id: scopeViewId(layer.scope),
|
|
847
|
+
scope: layer.scope,
|
|
848
|
+
changes: layer.changes,
|
|
849
|
+
})),
|
|
850
|
+
};
|
|
661
851
|
}
|
|
662
852
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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
|
-
const USAGE = 'usage: boundry <generate|check|approve|verify|annotate|diff> [--arch <dir>] [--cwd <dir>] [--out <file>] [sources...]';
|
|
7
|
+
const USAGE = 'usage: boundry <generate|check|approve|verify|annotate|diff> [--arch <dir>] [--cwd <dir>] [--out <file>] [--per-layer] [sources...]';
|
|
8
8
|
function optValue(args, flag) {
|
|
9
9
|
const i = args.indexOf(flag);
|
|
10
10
|
return i >= 0 ? args[i + 1] : undefined;
|
|
@@ -20,22 +20,36 @@ function positionals(args) {
|
|
|
20
20
|
}
|
|
21
21
|
return out;
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
console.error(
|
|
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 (
|
|
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
|
|
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 =
|
|
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
|
-
|
|
89
|
-
|
|
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: ✗ ${
|
|
94
|
-
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
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
|
|
107
|
-
if (
|
|
108
|
-
console.error(`Boundry: ✗ refusing to approve — ${
|
|
109
|
-
|
|
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,21 +164,28 @@ async function main() {
|
|
|
125
164
|
process.exitCode = 2;
|
|
126
165
|
return;
|
|
127
166
|
}
|
|
128
|
-
const { edges, modules } = await pipeline.annotate(readFileSync(lockFile, 'utf8'));
|
|
129
|
-
|
|
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: ✎
|
|
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
|
-
|
|
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') {
|
|
142
|
-
const
|
|
187
|
+
const perLayer = rest.includes('--per-layer');
|
|
188
|
+
const views = await pipeline.diffViews(perLayer);
|
|
143
189
|
const diffFile = join(archDir, 'boundry.diff.likec4');
|
|
144
190
|
if (views.length === 0) {
|
|
145
191
|
console.log('Boundry: ✓ nothing proposed — no diff views to draw');
|
|
@@ -147,10 +193,10 @@ async function main() {
|
|
|
147
193
|
}
|
|
148
194
|
console.log(`Boundry: ✎ wrote ${views.length} diff view(s) to ${relative(process.cwd(), diffFile)}:`);
|
|
149
195
|
for (const view of views) {
|
|
150
|
-
const
|
|
151
|
-
console.log(` ${view.id} (
|
|
196
|
+
const where = perLayer ? `layer ${view.scope ?? 'root'}, ` : '';
|
|
197
|
+
console.log(` ${view.id} (${where}${view.changes} change(s))`);
|
|
152
198
|
}
|
|
153
|
-
console.log(' Open the diagram in `likec4 serve` to review
|
|
199
|
+
console.log(' Open the diagram in `likec4 serve` to review.');
|
|
154
200
|
return;
|
|
155
201
|
}
|
|
156
202
|
console.error(USAGE);
|
|
@@ -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
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
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
|
|
41
|
-
* verify reads from it, exactly as `annotate` does
|
|
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,19 +75,22 @@ 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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
78
|
+
* undeclared *change* into an explicit proposal in the source — symmetrically:
|
|
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
|
/**
|
|
63
|
-
* (Re)generate
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
89
|
+
* (Re)generate the review views for the diagram's pending changes. By default a
|
|
90
|
+
* single `boundry_diff` landing view draws every `#proposed` / `#proposal-delete`
|
|
91
|
+
* change at once; with `perLayer`, one focused view per layer that draws a change.
|
|
92
|
+
* Reads the diagram's own markers, so it composes with `annotate` (annotate marks
|
|
93
|
+
* drift, this frames it).
|
|
68
94
|
*/
|
|
69
|
-
diffViews(): Promise<DiffView[]>;
|
|
95
|
+
diffViews(perLayer?: boolean): Promise<DiffView[]>;
|
|
70
96
|
}
|
|
@@ -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
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
31
|
-
* verify reads from it, exactly as `annotate` does
|
|
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,29 +71,35 @@ export class Pipeline {
|
|
|
53
71
|
}
|
|
54
72
|
/**
|
|
55
73
|
* Compare the diagram against a previously accepted lock and rewrite every
|
|
56
|
-
* undeclared
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
74
|
+
* undeclared *change* into an explicit proposal in the source — symmetrically:
|
|
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
|
-
* (Re)generate
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
96
|
+
* (Re)generate the review views for the diagram's pending changes. By default a
|
|
97
|
+
* single `boundry_diff` landing view draws every `#proposed` / `#proposal-delete`
|
|
98
|
+
* change at once; with `perLayer`, one focused view per layer that draws a change.
|
|
99
|
+
* Reads the diagram's own markers, so it composes with `annotate` (annotate marks
|
|
100
|
+
* drift, this frames it).
|
|
77
101
|
*/
|
|
78
|
-
async diffViews() {
|
|
79
|
-
return this.visualizer.emitDiffViews();
|
|
102
|
+
async diffViews(perLayer = false) {
|
|
103
|
+
return this.visualizer.emitDiffViews(perLayer);
|
|
80
104
|
}
|
|
81
105
|
}
|
|
@@ -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
|
|
@@ -23,25 +32,26 @@ export interface VisualizerPort {
|
|
|
23
32
|
*/
|
|
24
33
|
styleMarkers(): Promise<void>;
|
|
25
34
|
/**
|
|
26
|
-
* (Re)generate
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
35
|
+
* (Re)generate the review views for the diagram's pending changes — edges and
|
|
36
|
+
* boxes tagged `#proposed` or `#proposal-delete`. By default a single
|
|
37
|
+
* `boundry_diff` landing view draws every change at once, uncollapsed and
|
|
38
|
+
* uniformly highlighted. With `perLayer`, one focused `view … of <scope>` is
|
|
39
|
+
* emitted per layer that draws a change instead. A derived artifact: overwritten
|
|
31
40
|
* each run, and removed when nothing is proposed. Returns one entry per view.
|
|
32
41
|
*/
|
|
33
|
-
emitDiffViews(): Promise<DiffView[]>;
|
|
42
|
+
emitDiffViews(perLayer?: boolean): Promise<DiffView[]>;
|
|
34
43
|
}
|
|
35
44
|
/**
|
|
36
|
-
* One emitted diff view
|
|
37
|
-
* pending change
|
|
45
|
+
* One emitted diff view. For the default single view there is one entry
|
|
46
|
+
* (`boundry_diff`) counting every pending change; in `perLayer` mode there is one
|
|
47
|
+
* per layer, each scoped to an element and counting the changes that fall in it.
|
|
38
48
|
*/
|
|
39
49
|
export interface DiffView {
|
|
40
|
-
/** The generated view id
|
|
50
|
+
/** The generated view id — `boundry_diff`, or `boundry_diff_<scope>` per layer. */
|
|
41
51
|
id: string;
|
|
42
|
-
/**
|
|
52
|
+
/** perLayer only: the scope element's fqn, or undefined for the model root. */
|
|
43
53
|
scope?: string;
|
|
44
|
-
/** How many pending `#proposed` / `#proposal-delete` changes this
|
|
54
|
+
/** How many pending `#proposed` / `#proposal-delete` changes this view holds. */
|
|
45
55
|
changes: number;
|
|
46
56
|
}
|
|
47
57
|
/** A generated linter config artifact. */
|
package/package.json
CHANGED