boundry 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -2
- package/README.md +84 -1
- package/dist/adapters/visualizer/likec4.d.ts +36 -6
- package/dist/adapters/visualizer/likec4.js +452 -31
- package/dist/cli/index.js +42 -4
- package/dist/core/model/boundary-model.d.ts +15 -0
- package/dist/core/model/boundary-model.js +40 -0
- package/dist/core/pipeline/pipeline.d.ts +31 -4
- package/dist/core/pipeline/pipeline.js +37 -3
- package/dist/core/ports/ports.d.ts +37 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,13 +4,58 @@ All notable changes to Boundry are documented here.
|
|
|
4
4
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
|
5
5
|
versioning follows [semver](https://semver.org/).
|
|
6
6
|
|
|
7
|
-
## [0.
|
|
7
|
+
## [0.4.0] — 2026-07-17
|
|
8
8
|
|
|
9
9
|
Makes the "one file can be both the communication diagram and the enforcement
|
|
10
|
-
model" promise hold for deep, nested C4 trees
|
|
10
|
+
model" promise hold for deep, nested C4 trees, and makes a proposed change
|
|
11
|
+
highlight itself — deterministically, on every LikeC4 surface.
|
|
11
12
|
|
|
12
13
|
### Added
|
|
13
14
|
|
|
15
|
+
- **Accepted-state lock + `annotate` (prototype).** `approve` now records the
|
|
16
|
+
accepted model to `boundry.lock` beside the diagram — a baseline Boundry
|
|
17
|
+
**owns**, rather than inferring it from whatever git happens to hold, so
|
|
18
|
+
"approved" is decoupled from "committed". `boundry annotate` diffs the diagram
|
|
19
|
+
against that lock and rewrites every undeclared addition — a bare new edge (a
|
|
20
|
+
self-grant) or box — into an explicit `#proposed` proposal in the source,
|
|
21
|
+
deterministically and idempotently. Turns silent drift into a reviewable,
|
|
22
|
+
colourable proposal. (Additive only; deletions are reported, not round-tripped
|
|
23
|
+
back into the DSL — re-materialising a removed box would resurrect it as an
|
|
24
|
+
enforced module.)
|
|
25
|
+
- **Cross-surface highlighting** ([#5]). `annotate` also paints each marker with
|
|
26
|
+
an intrinsic `style { color amber }` (`#proposed`) / `style { color red }`
|
|
27
|
+
(`#proposal-delete`) on the element itself. Intrinsic style is the only styling
|
|
28
|
+
LikeC4 renders on **every** surface — base views *and* the "relationships of X"
|
|
29
|
+
panel — so a proposal is highlighted wherever a reviewer looks, not just inside
|
|
30
|
+
the diff views (whose view-scoped rules stop at the view boundary). Idempotent,
|
|
31
|
+
and `approve` strips the styling back out with the marker: a `#proposed` edge
|
|
32
|
+
returns to bare, a `#proposed` box loses its colour, a `#proposal-delete` is
|
|
33
|
+
removed outright. Needs LikeC4 ≥ 1.58 to render.
|
|
34
|
+
- **Per-layer diff views — `boundry diff` (prototype).** Generates a focused
|
|
35
|
+
LikeC4 view for every layer that holds a pending change — an edge or box tagged
|
|
36
|
+
`#proposed` or `#proposal-delete` — scoped to the tightest element that draws
|
|
37
|
+
it, into a derived `boundry.diff.likec4`. This matters because a proposal nested
|
|
38
|
+
inside a box is invisible once that box collapses at a wider zoom, so a single
|
|
39
|
+
all-up view hides it; one view per layer surfaces every change in the scope
|
|
40
|
+
where it renders. It reads the diagram's own markers, not the lock, so it frames
|
|
41
|
+
whatever `annotate` or a human marked. The file is derived — overwritten each
|
|
42
|
+
run, removed when nothing is proposed — so it always reflects the current
|
|
43
|
+
diagram; regenerate after `approve`.
|
|
44
|
+
- **Deterministic highlighting** ([#4]). `diff` emits the LikeC4 style rules
|
|
45
|
+
into the derived file, so a `#proposed` box fills amber and its edge goes
|
|
46
|
+
amber + solid, a `#proposal-delete` red — with no hand-styling. Boxes are
|
|
47
|
+
styled in place (`style element.tag`, never force-included), so a nested
|
|
48
|
+
proposal stays in its layer; the rules live only in the generated views, so
|
|
49
|
+
user-authored views are untouched. Rules are emitted only for marker tags
|
|
50
|
+
actually in use (referencing an undeclared tag is a hard LikeC4 error).
|
|
51
|
+
- **`#proposal-delete` — the deletion half of the approval lifecycle.** Tag an
|
|
52
|
+
edge or a box `#proposal-delete` to propose retiring it. The marker colours it
|
|
53
|
+
red; a pending deletion changes nothing (the edge stays allowed, the box stays
|
|
54
|
+
enforced), so proposing a removal never breaks the build. On `approve`, the
|
|
55
|
+
marked edge or box is removed from the diagram outright — deterministically, by
|
|
56
|
+
splicing the LikeC4 CST — where `#proposed` only strips its own marker and
|
|
57
|
+
leaves the edge behind. `#proposed` (amber, additive) and `#proposal-delete`
|
|
58
|
+
(red, subtractive) are now the two halves of every change.
|
|
14
59
|
- **File-level mapping — `metadata { file 'src/x.ts' }`** ([#3]). An element maps
|
|
15
60
|
to a single file instead of a folder. A file module owns exactly its file; a
|
|
16
61
|
folder module owns its subtree minus any mapped descendants — nested folders
|
|
@@ -26,8 +71,19 @@ model" promise hold for deep, nested C4 trees.
|
|
|
26
71
|
|
|
27
72
|
- **`Module.folder: string` → `Module.path: string` + `Module.kind: 'folder' | 'file'`.**
|
|
28
73
|
SDK-breaking; the diagram surface and CLI are unaffected.
|
|
74
|
+
- **`likec4` floor raised to `^1.58.0`.** The diff-view style-rule syntax Boundry
|
|
75
|
+
emits is verified against LikeC4 1.58; rendering the coloured views needs a
|
|
76
|
+
renderer at least that new.
|
|
29
77
|
|
|
30
78
|
[#3]: https://github.com/makspiechota/boundry/issues/3
|
|
79
|
+
[#4]: https://github.com/makspiechota/boundry/issues/4
|
|
80
|
+
[#5]: https://github.com/makspiechota/boundry/issues/5
|
|
81
|
+
|
|
82
|
+
## [0.3.0] — 2026-07-16 — superseded by 0.4.0
|
|
83
|
+
|
|
84
|
+
Published early from a pre-feature snapshot (≈ 0.2.0 plus the `#anything`
|
|
85
|
+
wildcard) and never carried the work now listed under 0.4.0. npm versions are
|
|
86
|
+
immutable, so it stays on the registry; use **0.4.0**.
|
|
31
87
|
|
|
32
88
|
## [0.2.0] — 2026-07-16
|
|
33
89
|
|
package/README.md
CHANGED
|
@@ -237,10 +237,86 @@ the allow-list, the newly-allowed set is exactly the set of self-approvals — n
|
|
|
237
237
|
diff engine required. `approve --base` runs the same gate first, so it won't
|
|
238
238
|
launder a self-granted edge into an approved one.
|
|
239
239
|
|
|
240
|
+
**To retire an edge or a box, propose its removal** with `#proposal-delete`
|
|
241
|
+
instead of deleting it. The marker colours it red; a pending deletion changes
|
|
242
|
+
nothing (the edge stays allowed, the box stays enforced) so it never breaks the
|
|
243
|
+
build. Then `approve` removes the marked edge or box outright:
|
|
244
|
+
|
|
245
|
+
```likec4
|
|
246
|
+
module legacy 'Legacy' {
|
|
247
|
+
#proposal-delete
|
|
248
|
+
metadata { folder 'src/legacy' }
|
|
249
|
+
}
|
|
250
|
+
api -> legacy #proposal-delete
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
So `#proposed` and `#proposal-delete` are the two halves of a change — an amber
|
|
254
|
+
addition that `approve` makes permanent, and a red removal that `approve` takes
|
|
255
|
+
away — each a visible mark on the diagram until a human acts.
|
|
256
|
+
|
|
240
257
|
Point your agents at
|
|
241
258
|
[`.claude/skills/define-architecture-boundaries`](.claude/skills/define-architecture-boundaries/SKILL.md)
|
|
242
259
|
and they'll follow this protocol.
|
|
243
260
|
|
|
261
|
+
### Catching drift — the lock and `annotate` (prototype)
|
|
262
|
+
|
|
263
|
+
`verify --base` trusts git for the baseline, which conflates *approved* with
|
|
264
|
+
*committed*. If you'd rather Boundry own that baseline: `approve` records the
|
|
265
|
+
accepted model to **`boundry.lock`** beside the diagram, and `annotate` compares
|
|
266
|
+
against it — no git ref required.
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
boundry approve --arch arch # enact proposals AND write boundry.lock
|
|
270
|
+
boundry annotate --arch arch # rewrite undeclared additions as #proposed
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
`annotate` finds every edge or box that drifted past the lock without a marker —
|
|
274
|
+
a self-grant — and rewrites it in place as a `#proposed` proposal. That's not
|
|
275
|
+
cosmetic: a `#proposed` edge leaves the allow-list, so the silent grant becomes a
|
|
276
|
+
red-again check and a highlighted box on the diagram, awaiting a real approval. It
|
|
277
|
+
handles additions only; a removal is reported, not re-drawn (re-adding a deleted
|
|
278
|
+
box would resurrect it as an enforced module).
|
|
279
|
+
|
|
280
|
+
It also **paints the markers** — every `#proposed` edge/box gets an intrinsic
|
|
281
|
+
`style { color amber }`, every `#proposal-delete` a `style { color red }`, written
|
|
282
|
+
onto the element itself. Intrinsic style is the one styling LikeC4 renders on
|
|
283
|
+
*every* surface — base views **and the "relationships of X" panel** — so a
|
|
284
|
+
proposal shows up highlighted wherever a reviewer looks, not only in the generated
|
|
285
|
+
diff views (whose view-scoped rules stop at the view boundary). `approve` strips
|
|
286
|
+
this styling back out with the marker: a `#proposed` edge returns to bare, a
|
|
287
|
+
`#proposed` box stays but loses its colour, a `#proposal-delete` is removed
|
|
288
|
+
outright. Requires **LikeC4 ≥ 1.58** to render.
|
|
289
|
+
|
|
290
|
+
### Reviewing a proposal — per-layer diff views (prototype)
|
|
291
|
+
|
|
292
|
+
A proposal nested inside a box is invisible at a wider zoom: at the top level the
|
|
293
|
+
box collapses and its inner `#proposed` edge disappears. So `diff` generates a
|
|
294
|
+
**focused view for every layer that holds a pending change** — the tightest scope
|
|
295
|
+
that actually draws it — into a derived `boundry.diff.likec4`:
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
boundry diff --arch arch # (re)write boundry.diff.likec4
|
|
299
|
+
likec4 serve arch # review each layer, changes coloured
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The highlighting is **generated, not hand-styled** — `diff` emits the LikeC4
|
|
303
|
+
style rules into the derived file, so every `#proposed` box fills amber and edge
|
|
304
|
+
goes amber + solid, every `#proposal-delete` red, deterministically. That closes
|
|
305
|
+
the last manual seam: `annotate` marks, `diff` colours, no agent-dependent
|
|
306
|
+
styling step. Boxes are styled *in place* (never force-included), so a nested
|
|
307
|
+
proposal stays in its own layer; unchanged elements keep their defaults; and the
|
|
308
|
+
rules live only in the generated views, so your own views are untouched.
|
|
309
|
+
|
|
310
|
+
The file is a **derived artifact**: overwritten every run, removed when nothing is
|
|
311
|
+
proposed, so it always matches the current diagram — regenerate it after
|
|
312
|
+
`approve`. It reads the diagram's own markers (not the lock), so it frames
|
|
313
|
+
whatever `annotate` or a human has marked. Being derived, it's a `.gitignore`
|
|
314
|
+
candidate (`boundry.diff.likec4`).
|
|
315
|
+
|
|
316
|
+
> Rendering the coloured diff views needs **LikeC4 ≥ 1.58** (the style-rule syntax
|
|
317
|
+
> Boundry emits). Boundry itself depends on that floor; the tool you review with
|
|
318
|
+
> (`likec4 serve`, the CLI, or the IDE extension) has to meet it too.
|
|
319
|
+
|
|
244
320
|
## CLI
|
|
245
321
|
|
|
246
322
|
```
|
|
@@ -248,6 +324,8 @@ boundry check [--arch <dir>] [--cwd <dir>] [sources...]
|
|
|
248
324
|
boundry generate [--arch <dir>] [--cwd <dir>] [--out <file>]
|
|
249
325
|
boundry verify [--arch <dir>] [--cwd <dir>] --base <git-ref>
|
|
250
326
|
boundry approve [--arch <dir>] [--cwd <dir>] [--base <git-ref>]
|
|
327
|
+
boundry annotate [--arch <dir>]
|
|
328
|
+
boundry diff [--arch <dir>]
|
|
251
329
|
```
|
|
252
330
|
|
|
253
331
|
| Flag | Meaning |
|
|
@@ -262,7 +340,12 @@ boundry approve [--arch <dir>] [--cwd <dir>] [--base <git-ref>]
|
|
|
262
340
|
- **`generate`** just emits the dependency-cruiser config so you can commit it or
|
|
263
341
|
run `depcruise` yourself.
|
|
264
342
|
- **`verify`** rejects dependencies granted without a proposal.
|
|
265
|
-
- **`approve`** strips `#proposed
|
|
343
|
+
- **`approve`** enacts markers (strips `#proposed`, removes `#proposal-delete`) and
|
|
344
|
+
writes `boundry.lock`. For humans, not agents.
|
|
345
|
+
- **`annotate`** rewrites undeclared additions as `#proposed`, diffing against
|
|
346
|
+
`boundry.lock`.
|
|
347
|
+
- **`diff`** generates a focused, colour-coded review view per layer that holds a
|
|
348
|
+
pending change, into a derived `boundry.diff.likec4`.
|
|
266
349
|
|
|
267
350
|
Boundry warns (but does not fail) when a mapped folder matches **zero** files, and
|
|
268
351
|
**fails outright** when a check analysed no files at all — so a passing check can
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { VisualizerPort } from '../../core/ports/ports.js';
|
|
2
|
-
import type { BoundaryModel } from '../../core/model/boundary-model.js';
|
|
1
|
+
import type { VisualizerPort, DiffView } from '../../core/ports/ports.js';
|
|
2
|
+
import type { BoundaryModel, AllowedEdge } from '../../core/model/boundary-model.js';
|
|
3
3
|
/**
|
|
4
4
|
* First visualizer adapter. Reads a LikeC4 workspace and lifts it into the
|
|
5
5
|
* boundary model: any element carrying a `folder` metadata key becomes a
|
|
@@ -11,10 +11,40 @@ export declare class LikeC4Visualizer implements VisualizerPort {
|
|
|
11
11
|
constructor(workspaceDir: string);
|
|
12
12
|
read(): Promise<BoundaryModel>;
|
|
13
13
|
/**
|
|
14
|
-
* Deterministically
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* Deterministically enact a diagram's pending proposals. Source-preserving,
|
|
15
|
+
* never an LLM edit — it walks the LikeC4 CST and splices ranges directly:
|
|
16
|
+
* - `#proposed` markers are stripped, promoting their intent edges to
|
|
17
|
+
* approved (the edge/box stays, permanently allowed);
|
|
18
|
+
* - `#proposal-delete` markers take their whole edge or box with them, so
|
|
19
|
+
* approving a proposed removal actually removes it.
|
|
20
|
+
* All other formatting is left intact.
|
|
18
21
|
*/
|
|
19
22
|
approve(): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Deterministically mark the given edges and elements `#proposed`. The inverse
|
|
25
|
+
* of `approve` for additions: an undeclared new edge (a self-grant) or box is
|
|
26
|
+
* rewritten into an explicit, colourable proposal. It locates each target's
|
|
27
|
+
* relation/element in the CST by its resolved ids and injects the marker,
|
|
28
|
+
* skipping anything already marked, so it is idempotent.
|
|
29
|
+
*/
|
|
30
|
+
propose(edges: AllowedEdge[], moduleIds: string[]): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Paint intrinsic style on every marked edge and box, so a proposal is
|
|
33
|
+
* highlighted on *every* LikeC4 surface — base views and the "relationships of
|
|
34
|
+
* X" panel, not just the generated diff views (which only carry view-scoped
|
|
35
|
+
* rules). A `#proposed` edge/box gets `style { color amber }`, a
|
|
36
|
+
* `#proposal-delete` `style { color red }`. Source-preserving and idempotent —
|
|
37
|
+
* anything already carrying the canonical style is left alone. `approve` strips
|
|
38
|
+
* this styling back out when it enacts the marker.
|
|
39
|
+
*/
|
|
40
|
+
styleMarkers(): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Generate a focused diff view for every layer that holds a pending change.
|
|
43
|
+
* Walks the diagram's own `#proposed` / `#proposal-delete` markers (never the
|
|
44
|
+
* lock — a `#proposal-delete` has no lock delta), buckets each change into the
|
|
45
|
+
* tightest scope that draws it, and writes one `view … of <scope>` per bucket
|
|
46
|
+
* to a derived `boundry.diff.likec4`. The file is overwritten each run and
|
|
47
|
+
* removed when nothing is proposed, so it always reflects the current diagram.
|
|
48
|
+
*/
|
|
49
|
+
emitDiffViews(): Promise<DiffView[]>;
|
|
20
50
|
}
|
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
import { LikeC4 } from 'likec4';
|
|
2
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** The generated, derived file holding Boundry's per-layer diff views. */
|
|
5
|
+
const DIFF_FILE = 'boundry.diff.likec4';
|
|
6
|
+
/** The generated view id for a scope — `boundry_diff_root` or `boundry_diff_<fqn>`. */
|
|
7
|
+
function scopeViewId(scope) {
|
|
8
|
+
return scope ? `boundry_diff_${scope.replace(/\./g, '_')}` : 'boundry_diff_root';
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The tightest layer that draws an edge between two elements: the deepest
|
|
12
|
+
* element that contains both, i.e. the common prefix of their fqns (undefined =
|
|
13
|
+
* the model root). An edge nested inside a box shows there, not at a wider scope
|
|
14
|
+
* where the box has collapsed — which is exactly why each layer needs its own view.
|
|
15
|
+
*/
|
|
16
|
+
function commonScope(from, to) {
|
|
17
|
+
const a = from.split('.');
|
|
18
|
+
const b = to.split('.');
|
|
19
|
+
const shared = [];
|
|
20
|
+
for (let i = 0; i < Math.min(a.length, b.length) && a[i] === b[i]; i++) {
|
|
21
|
+
shared.push(a[i]);
|
|
22
|
+
}
|
|
23
|
+
return shared.length ? shared.join('.') : undefined;
|
|
24
|
+
}
|
|
25
|
+
/** The layer that draws a box: its parent element (undefined = the model root). */
|
|
26
|
+
function parentScope(fqn) {
|
|
27
|
+
const parts = fqn.split('.');
|
|
28
|
+
parts.pop();
|
|
29
|
+
return parts.length ? parts.join('.') : undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Deterministic highlight rules for the marker tags actually in use. Referencing
|
|
33
|
+
* an undeclared tag is a hard LikeC4 error, and a tag can only be *used* if it is
|
|
34
|
+
* declared — so emitting rules only for seen tags is always safe.
|
|
35
|
+
*
|
|
36
|
+
* Boxes use a style-only `style element.tag` rule: a tagged box is coloured where
|
|
37
|
+
* it already sits and never force-included into a wider view (which would drag a
|
|
38
|
+
* nested proposal out of its collapsed parent, defeating the per-layer framing).
|
|
39
|
+
* Edges use `include … where … with`, coloured and made *solid* so they stand out
|
|
40
|
+
* against LikeC4's dashed default. Amber = addition, red = removal. Verified
|
|
41
|
+
* against LikeC4 1.58.
|
|
42
|
+
*/
|
|
43
|
+
function highlightLines(usedTags) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
if (usedTags.has('proposed')) {
|
|
46
|
+
lines.push(' style element.tag = #proposed { color amber }');
|
|
47
|
+
}
|
|
48
|
+
if (usedTags.has('proposal-delete')) {
|
|
49
|
+
lines.push(' style element.tag = #proposal-delete { color red }');
|
|
50
|
+
}
|
|
51
|
+
if (usedTags.has('proposed')) {
|
|
52
|
+
lines.push(' include * -> * where tag is #proposed with { color amber; line solid }');
|
|
53
|
+
}
|
|
54
|
+
if (usedTags.has('proposal-delete')) {
|
|
55
|
+
lines.push(' include * -> * where tag is #proposal-delete with { color red; line solid }');
|
|
56
|
+
}
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
/** One `view … { include * }` block, scoped with `of <element>` unless it is root. */
|
|
60
|
+
function renderDiffView(scope, highlight) {
|
|
61
|
+
const opener = scope
|
|
62
|
+
? ` view ${scopeViewId(scope)} of ${scope} {`
|
|
63
|
+
: ` view ${scopeViewId(scope)} {`;
|
|
64
|
+
const lines = [` title 'Boundry diff · ${scope ?? 'root'}'`, ' include *', ...highlight];
|
|
65
|
+
return `${opener}\n${lines.join('\n')}\n }`;
|
|
66
|
+
}
|
|
3
67
|
/**
|
|
4
68
|
* An exemption pattern has to be a usable regex before it reaches the linter. A
|
|
5
69
|
* broken or empty one would otherwise be a silent hole: it exempts files from
|
|
@@ -24,15 +88,57 @@ function assertUsableRegex(pattern, elementId) {
|
|
|
24
88
|
}
|
|
25
89
|
return pattern;
|
|
26
90
|
}
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Walk the AST through CONTAINMENT only — descending into a property solely when
|
|
93
|
+
* the child names this node as its `$container`. That skips cross-references (a
|
|
94
|
+
* relation's endpoint links to the element it names), so a node is never reached
|
|
95
|
+
* through another's reference to it, and traversal never leaves this document.
|
|
96
|
+
*/
|
|
97
|
+
function walkContained(root, visit) {
|
|
98
|
+
const go = (node) => {
|
|
99
|
+
if (!node || typeof node !== 'object')
|
|
100
|
+
return;
|
|
101
|
+
visit(node);
|
|
102
|
+
for (const key of Object.keys(node)) {
|
|
103
|
+
if (key.startsWith('$'))
|
|
104
|
+
continue;
|
|
105
|
+
const child = node[key];
|
|
106
|
+
for (const kid of Array.isArray(child) ? child : [child]) {
|
|
107
|
+
if (kid && typeof kid === 'object' && kid.$container === node)
|
|
108
|
+
go(kid);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
go(root);
|
|
32
113
|
}
|
|
33
114
|
/**
|
|
34
|
-
* The
|
|
35
|
-
*
|
|
115
|
+
* The fully-qualified id of an element AST node — its own name and every
|
|
116
|
+
* enclosing element's, joined by '.'. Matches the id the computed model assigns,
|
|
117
|
+
* for nested elements as well as flat ones.
|
|
118
|
+
*/
|
|
119
|
+
function fqnOf(astElement) {
|
|
120
|
+
if (!astElement)
|
|
121
|
+
return undefined;
|
|
122
|
+
const parts = [];
|
|
123
|
+
for (let n = astElement; n; n = n.$container) {
|
|
124
|
+
if (n.$type === 'Element' && n.name)
|
|
125
|
+
parts.unshift(n.name);
|
|
126
|
+
}
|
|
127
|
+
return parts.length ? parts.join('.') : undefined;
|
|
128
|
+
}
|
|
129
|
+
/** The element an `a -> b` endpoint (FqnRef) resolves to, or undefined. */
|
|
130
|
+
function endpointElement(fqnRef) {
|
|
131
|
+
return fqnRef?.value?.ref;
|
|
132
|
+
}
|
|
133
|
+
/** The literal source text of a TagRef node, or undefined if it is not one. */
|
|
134
|
+
function tagRefText(node, text) {
|
|
135
|
+
if (node.$type !== 'TagRef' || !node.$cstNode)
|
|
136
|
+
return undefined;
|
|
137
|
+
return text.slice(node.$cstNode.offset, node.$cstNode.end);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The source to remove for a `#proposed` marker. A marker alone on its line takes
|
|
141
|
+
* the whole line (so approving leaves no blank gap); an inline one takes just the
|
|
36
142
|
* token and the space before it. The `tag proposed` declaration in the
|
|
37
143
|
* specification is untouched — the vocabulary stays for the next proposal.
|
|
38
144
|
*/
|
|
@@ -50,28 +156,150 @@ function proposedRemovalRange(tag, text) {
|
|
|
50
156
|
start--;
|
|
51
157
|
return { start, end };
|
|
52
158
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
159
|
+
/**
|
|
160
|
+
* The nearest relationship or element the marker sits in, found via the AST
|
|
161
|
+
* `$container` chain — the true syntactic parent. A plain object-graph walk would
|
|
162
|
+
* follow the cross-reference from a relation's endpoint into the element it names
|
|
163
|
+
* and mis-attribute an element's marker to a relation; `$container` never does.
|
|
164
|
+
*/
|
|
165
|
+
function enclosingStatement(tagRef) {
|
|
166
|
+
for (let n = tagRef.$container; n; n = n.$container) {
|
|
167
|
+
if (n.$type === 'Relation' || n.$type === 'Element')
|
|
168
|
+
return n;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The whole-statement range for a `#proposal-delete` marker: the entire relation
|
|
174
|
+
* or element block, from the start of its first line through its trailing
|
|
175
|
+
* newline, so approving the deletion removes the edge/box and leaves no gap.
|
|
176
|
+
*/
|
|
177
|
+
function statementRemovalRange(statement, text) {
|
|
178
|
+
const { offset, end } = statement.$cstNode;
|
|
179
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
180
|
+
const nl = text.indexOf('\n', end);
|
|
181
|
+
return { start: lineStart, end: nl === -1 ? text.length : nl + 1 };
|
|
182
|
+
}
|
|
183
|
+
/** amber for `#proposed`, red for `#proposal-delete` — the intrinsic colour annotate paints. */
|
|
184
|
+
const MARKER_COLOR = {
|
|
185
|
+
'#proposed': 'amber',
|
|
186
|
+
'#proposal-delete': 'red',
|
|
187
|
+
};
|
|
188
|
+
/** The canonical intrinsic style annotate injects for a colour. */
|
|
189
|
+
function canonicalStyle(color) {
|
|
190
|
+
return `style { color ${color} }`;
|
|
191
|
+
}
|
|
192
|
+
/** The direct `*Body` child (ElementBody / RelationBody) of a statement, if any. */
|
|
193
|
+
function statementBody(stmt) {
|
|
194
|
+
for (const key of Object.keys(stmt)) {
|
|
195
|
+
if (key.startsWith('$'))
|
|
196
|
+
continue;
|
|
197
|
+
const child = stmt[key];
|
|
198
|
+
for (const kid of Array.isArray(child) ? child : [child]) {
|
|
199
|
+
if (kid && typeof kid === 'object' && kid.$container === stmt &&
|
|
200
|
+
typeof kid.$type === 'string' && kid.$type.endsWith('Body')) {
|
|
201
|
+
return kid;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
/** The direct `*StyleProperty` children of a body. */
|
|
208
|
+
function styleProperties(body) {
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const key of Object.keys(body)) {
|
|
211
|
+
if (key.startsWith('$'))
|
|
212
|
+
continue;
|
|
213
|
+
const child = body[key];
|
|
214
|
+
for (const kid of Array.isArray(child) ? child : [child]) {
|
|
215
|
+
if (kid && typeof kid === 'object' && kid.$container === body &&
|
|
216
|
+
typeof kid.$type === 'string' && kid.$type.endsWith('StyleProperty')) {
|
|
217
|
+
out.push(kid);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The style node in `body` whose source is EXACTLY the canonical `style { color
|
|
225
|
+
* <c> }` annotate writes — matched by whitespace-normalised text, so a richer
|
|
226
|
+
* user-authored style (`style { color amber; icon none }`) is never mistaken for
|
|
227
|
+
* ours and never stripped.
|
|
228
|
+
*/
|
|
229
|
+
function canonicalStyleNode(body, color, text) {
|
|
230
|
+
const want = canonicalStyle(color);
|
|
231
|
+
return styleProperties(body).find((s) => s.$cstNode &&
|
|
232
|
+
text.slice(s.$cstNode.offset, s.$cstNode.end).replace(/\s+/g, ' ').trim() === want);
|
|
233
|
+
}
|
|
234
|
+
/** True when `node` is the body's only property — nothing else would survive removing it. */
|
|
235
|
+
function bodyHasOnly(body, node) {
|
|
236
|
+
for (const key of Object.keys(body)) {
|
|
237
|
+
if (key.startsWith('$'))
|
|
238
|
+
continue;
|
|
239
|
+
const child = body[key];
|
|
240
|
+
for (const kid of Array.isArray(child) ? child : [child]) {
|
|
241
|
+
if (kid && typeof kid === 'object' && kid.$container === body && kid !== node)
|
|
242
|
+
return false;
|
|
64
243
|
}
|
|
65
|
-
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
/** A node's whole line, trailing newline included — for stripping a box's style line. */
|
|
248
|
+
function lineRemovalRange(node, text) {
|
|
249
|
+
const { offset, end } = node.$cstNode;
|
|
250
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
251
|
+
const nl = text.indexOf('\n', end);
|
|
252
|
+
return { start: lineStart, end: nl === -1 ? text.length : nl + 1 };
|
|
253
|
+
}
|
|
254
|
+
/** A relation body in full, plus the whitespace before its `{` — turns `a -> b { … }` back into `a -> b`. */
|
|
255
|
+
function bodyRemovalRange(body, text) {
|
|
256
|
+
let start = body.$cstNode.offset;
|
|
257
|
+
while (start > 0 && (text[start - 1] === ' ' || text[start - 1] === '\t'))
|
|
258
|
+
start--;
|
|
259
|
+
return { start, end: body.$cstNode.end };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Every source range to splice out to approve a diagram. A `#proposed` marker
|
|
263
|
+
* strips to its token (the edge/box stays and becomes permanent) AND takes with
|
|
264
|
+
* it any intrinsic `style { color amber }` annotate painted on — for a box, that
|
|
265
|
+
* style's line; for an edge, its whole (now-empty) body, so the edge goes back to
|
|
266
|
+
* bare. A `#proposal-delete` marker takes its whole enclosing statement (the
|
|
267
|
+
* edge/box, and any style on it, are removed outright). Ranges are collected via
|
|
268
|
+
* a containment-only walk — descending solely into true children
|
|
269
|
+
* (`child.$container === node`), never cross-references — so one marker is never
|
|
270
|
+
* counted through another element's reference to it, and no range ever escapes
|
|
271
|
+
* into a different document's text.
|
|
272
|
+
*/
|
|
273
|
+
function collectApprovalRanges(root, text) {
|
|
274
|
+
const ranges = [];
|
|
275
|
+
walkContained(root, (node) => {
|
|
276
|
+
const marker = tagRefText(node, text);
|
|
277
|
+
if (marker === '#proposed') {
|
|
66
278
|
ranges.push(proposedRemovalRange(node, text));
|
|
279
|
+
// Strip the intrinsic amber style annotate paints, so an approved box does
|
|
280
|
+
// not stay amber forever and an approved edge goes back to bare.
|
|
281
|
+
const statement = enclosingStatement(node);
|
|
282
|
+
const body = statement && statementBody(statement);
|
|
283
|
+
const style = body && canonicalStyleNode(body, MARKER_COLOR['#proposed'], text);
|
|
284
|
+
if (style) {
|
|
285
|
+
if (statement.$type === 'Relation' && bodyHasOnly(body, style)) {
|
|
286
|
+
ranges.push(bodyRemovalRange(body, text));
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
ranges.push(lineRemovalRange(style, text));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
67
292
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
293
|
+
else if (marker === '#proposal-delete') {
|
|
294
|
+
const statement = enclosingStatement(node);
|
|
295
|
+
if (statement?.$cstNode)
|
|
296
|
+
ranges.push(statementRemovalRange(statement, text));
|
|
71
297
|
}
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
|
|
298
|
+
});
|
|
299
|
+
// A whole-statement removal can contain a `#proposed` token range inside it
|
|
300
|
+
// (a box being deleted that also carried a proposed edge). Drop any range that
|
|
301
|
+
// another wholly contains, so back-to-front splicing never double-cuts.
|
|
302
|
+
return ranges.filter((r) => !ranges.some((o) => o !== r && o.start <= r.start && o.end >= r.end && o.end - o.start > r.end - r.start));
|
|
75
303
|
}
|
|
76
304
|
/**
|
|
77
305
|
* First visualizer adapter. Reads a LikeC4 workspace and lifts it into the
|
|
@@ -158,17 +386,20 @@ export class LikeC4Visualizer {
|
|
|
158
386
|
};
|
|
159
387
|
}
|
|
160
388
|
/**
|
|
161
|
-
* Deterministically
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
389
|
+
* Deterministically enact a diagram's pending proposals. Source-preserving,
|
|
390
|
+
* never an LLM edit — it walks the LikeC4 CST and splices ranges directly:
|
|
391
|
+
* - `#proposed` markers are stripped, promoting their intent edges to
|
|
392
|
+
* approved (the edge/box stays, permanently allowed);
|
|
393
|
+
* - `#proposal-delete` markers take their whole edge or box with them, so
|
|
394
|
+
* approving a proposed removal actually removes it.
|
|
395
|
+
* All other formatting is left intact.
|
|
165
396
|
*/
|
|
166
397
|
async approve() {
|
|
167
398
|
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
168
399
|
for (const doc of likec4.LangiumDocuments.all) {
|
|
169
400
|
const filePath = doc.uri.fsPath;
|
|
170
401
|
const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
|
|
171
|
-
const ranges =
|
|
402
|
+
const ranges = collectApprovalRanges(doc.parseResult?.value, text);
|
|
172
403
|
if (ranges.length === 0)
|
|
173
404
|
continue;
|
|
174
405
|
let next = text;
|
|
@@ -178,4 +409,194 @@ export class LikeC4Visualizer {
|
|
|
178
409
|
writeFileSync(filePath, next);
|
|
179
410
|
}
|
|
180
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* Deterministically mark the given edges and elements `#proposed`. The inverse
|
|
414
|
+
* of `approve` for additions: an undeclared new edge (a self-grant) or box is
|
|
415
|
+
* rewritten into an explicit, colourable proposal. It locates each target's
|
|
416
|
+
* relation/element in the CST by its resolved ids and injects the marker,
|
|
417
|
+
* skipping anything already marked, so it is idempotent.
|
|
418
|
+
*/
|
|
419
|
+
async propose(edges, moduleIds) {
|
|
420
|
+
if (edges.length === 0 && moduleIds.length === 0)
|
|
421
|
+
return;
|
|
422
|
+
const wantEdge = new Set(edges.map((e) => `${e.from} -> ${e.to}`));
|
|
423
|
+
const wantModule = new Set(moduleIds);
|
|
424
|
+
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
425
|
+
for (const doc of likec4.LangiumDocuments.all) {
|
|
426
|
+
const filePath = doc.uri.fsPath;
|
|
427
|
+
if (!filePath.endsWith('.likec4'))
|
|
428
|
+
continue;
|
|
429
|
+
const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
|
|
430
|
+
const inserts = [];
|
|
431
|
+
walkContained(doc.parseResult?.value, (node) => {
|
|
432
|
+
if (node.$type === 'Relation' && node.$cstNode) {
|
|
433
|
+
const from = fqnOf(endpointElement(node.source));
|
|
434
|
+
const to = fqnOf(endpointElement(node.target));
|
|
435
|
+
if (from && to && wantEdge.has(`${from} -> ${to}`)) {
|
|
436
|
+
const { offset, end } = node.$cstNode;
|
|
437
|
+
if (!text.slice(offset, end).includes('#proposed')) {
|
|
438
|
+
inserts.push({ at: end, text: ' #proposed' });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else if (node.$type === 'Element' && node.$cstNode) {
|
|
443
|
+
const id = fqnOf(node);
|
|
444
|
+
const body = node.body;
|
|
445
|
+
if (id && wantModule.has(id) && body?.$cstNode) {
|
|
446
|
+
const braceStart = body.$cstNode.offset;
|
|
447
|
+
const bodyText = text.slice(braceStart, body.$cstNode.end);
|
|
448
|
+
if (!bodyText.includes('#proposed')) {
|
|
449
|
+
// Insert as the body's first line, matching the indentation of the
|
|
450
|
+
// line that already follows the opening brace.
|
|
451
|
+
const afterBrace = text.indexOf('{', braceStart) + 1;
|
|
452
|
+
const nextLine = text.indexOf('\n', afterBrace) + 1;
|
|
453
|
+
const indent = (text.slice(nextLine).match(/^[ \t]*/) ?? [''])[0];
|
|
454
|
+
inserts.push({ at: afterBrace, text: `\n${indent}#proposed` });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
if (inserts.length === 0)
|
|
460
|
+
continue;
|
|
461
|
+
let next = text;
|
|
462
|
+
for (const { at, text: fragment } of inserts.sort((a, b) => b.at - a.at)) {
|
|
463
|
+
next = next.slice(0, at) + fragment + next.slice(at);
|
|
464
|
+
}
|
|
465
|
+
writeFileSync(filePath, next);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Paint intrinsic style on every marked edge and box, so a proposal is
|
|
470
|
+
* highlighted on *every* LikeC4 surface — base views and the "relationships of
|
|
471
|
+
* X" panel, not just the generated diff views (which only carry view-scoped
|
|
472
|
+
* rules). A `#proposed` edge/box gets `style { color amber }`, a
|
|
473
|
+
* `#proposal-delete` `style { color red }`. Source-preserving and idempotent —
|
|
474
|
+
* anything already carrying the canonical style is left alone. `approve` strips
|
|
475
|
+
* this styling back out when it enacts the marker.
|
|
476
|
+
*/
|
|
477
|
+
async styleMarkers() {
|
|
478
|
+
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
479
|
+
for (const doc of likec4.LangiumDocuments.all) {
|
|
480
|
+
const filePath = doc.uri.fsPath;
|
|
481
|
+
if (!filePath.endsWith('.likec4') || filePath.endsWith(DIFF_FILE))
|
|
482
|
+
continue;
|
|
483
|
+
const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
|
|
484
|
+
const inserts = [];
|
|
485
|
+
walkContained(doc.parseResult?.value, (node) => {
|
|
486
|
+
const marker = tagRefText(node, text);
|
|
487
|
+
if (marker !== '#proposed' && marker !== '#proposal-delete')
|
|
488
|
+
return;
|
|
489
|
+
const color = MARKER_COLOR[marker];
|
|
490
|
+
const statement = enclosingStatement(node);
|
|
491
|
+
if (!statement)
|
|
492
|
+
return;
|
|
493
|
+
const body = statementBody(statement);
|
|
494
|
+
if (body && canonicalStyleNode(body, color, text))
|
|
495
|
+
return; // already styled
|
|
496
|
+
if (body?.$cstNode) {
|
|
497
|
+
// Style on the line right after the marker, at the marker's indent. It
|
|
498
|
+
// must go AFTER the marker: a body lists its tags before its style
|
|
499
|
+
// properties, so a style ahead of the `#proposed` tag is a parse error.
|
|
500
|
+
const markerEnd = node.$cstNode.end;
|
|
501
|
+
const lineStart = text.lastIndexOf('\n', markerEnd - 1) + 1;
|
|
502
|
+
const indent = (text.slice(lineStart).match(/^[ \t]*/) ?? [''])[0];
|
|
503
|
+
const nl = text.indexOf('\n', markerEnd);
|
|
504
|
+
inserts.push({
|
|
505
|
+
at: nl === -1 ? markerEnd : nl,
|
|
506
|
+
text: `\n${indent}${canonicalStyle(color)}`,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
// An inline-marked edge (`a -> b #proposed`) has no body — give it one,
|
|
511
|
+
// indented one level past the relation's own line.
|
|
512
|
+
const lineStart = text.lastIndexOf('\n', statement.$cstNode.offset - 1) + 1;
|
|
513
|
+
const indent = (text.slice(lineStart).match(/^[ \t]*/) ?? [''])[0];
|
|
514
|
+
inserts.push({
|
|
515
|
+
at: statement.$cstNode.end,
|
|
516
|
+
text: ` {\n${indent} ${canonicalStyle(color)}\n${indent}}`,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
if (inserts.length === 0)
|
|
521
|
+
continue;
|
|
522
|
+
let next = text;
|
|
523
|
+
for (const { at, text: fragment } of inserts.sort((a, b) => b.at - a.at)) {
|
|
524
|
+
next = next.slice(0, at) + fragment + next.slice(at);
|
|
525
|
+
}
|
|
526
|
+
writeFileSync(filePath, next);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Generate a focused diff view for every layer that holds a pending change.
|
|
531
|
+
* Walks the diagram's own `#proposed` / `#proposal-delete` markers (never the
|
|
532
|
+
* lock — a `#proposal-delete` has no lock delta), buckets each change into the
|
|
533
|
+
* tightest scope that draws it, and writes one `view … of <scope>` per bucket
|
|
534
|
+
* to a derived `boundry.diff.likec4`. The file is overwritten each run and
|
|
535
|
+
* removed when nothing is proposed, so it always reflects the current diagram.
|
|
536
|
+
*/
|
|
537
|
+
async emitDiffViews() {
|
|
538
|
+
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
539
|
+
// scopeViewId -> { scope, changes } — deterministic key, so the same diagram
|
|
540
|
+
// always yields the same set of views.
|
|
541
|
+
const layers = new Map();
|
|
542
|
+
const record = (scope) => {
|
|
543
|
+
const key = scopeViewId(scope);
|
|
544
|
+
const layer = layers.get(key) ?? { scope, changes: 0 };
|
|
545
|
+
layer.changes += 1;
|
|
546
|
+
layers.set(key, layer);
|
|
547
|
+
};
|
|
548
|
+
// Which marker tags actually appear — so the emitted highlight rules only ever
|
|
549
|
+
// reference declared tags (an undeclared tag reference is a hard LikeC4 error).
|
|
550
|
+
const usedTags = new Set();
|
|
551
|
+
for (const doc of likec4.LangiumDocuments.all) {
|
|
552
|
+
const filePath = doc.uri.fsPath;
|
|
553
|
+
if (!filePath.endsWith('.likec4') || filePath.endsWith(DIFF_FILE))
|
|
554
|
+
continue;
|
|
555
|
+
const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
|
|
556
|
+
walkContained(doc.parseResult?.value, (node) => {
|
|
557
|
+
const marker = tagRefText(node, text);
|
|
558
|
+
if (marker !== '#proposed' && marker !== '#proposal-delete')
|
|
559
|
+
return;
|
|
560
|
+
usedTags.add(marker.slice(1)); // drop the leading '#'
|
|
561
|
+
const statement = enclosingStatement(node);
|
|
562
|
+
if (statement?.$type === 'Relation') {
|
|
563
|
+
const from = fqnOf(endpointElement(statement.source));
|
|
564
|
+
const to = fqnOf(endpointElement(statement.target));
|
|
565
|
+
if (from && to)
|
|
566
|
+
record(commonScope(from, to));
|
|
567
|
+
}
|
|
568
|
+
else if (statement?.$type === 'Element') {
|
|
569
|
+
const fqn = fqnOf(statement);
|
|
570
|
+
if (fqn)
|
|
571
|
+
record(parentScope(fqn));
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
const diffPath = join(this.workspaceDir, DIFF_FILE);
|
|
576
|
+
if (layers.size === 0) {
|
|
577
|
+
// Nothing proposed — clear any stale views so `serve` never renders a diff
|
|
578
|
+
// that no longer exists (and never dangles a reference to an approved-away box).
|
|
579
|
+
if (existsSync(diffPath))
|
|
580
|
+
rmSync(diffPath);
|
|
581
|
+
return [];
|
|
582
|
+
}
|
|
583
|
+
// Root first, then by fqn, so the output is stable across runs.
|
|
584
|
+
const ordered = [...layers.values()].sort((a, b) => {
|
|
585
|
+
if (!a.scope)
|
|
586
|
+
return -1;
|
|
587
|
+
if (!b.scope)
|
|
588
|
+
return 1;
|
|
589
|
+
return a.scope.localeCompare(b.scope);
|
|
590
|
+
});
|
|
591
|
+
const highlight = highlightLines(usedTags);
|
|
592
|
+
const body = ordered.map((layer) => renderDiffView(layer.scope, highlight)).join('\n');
|
|
593
|
+
writeFileSync(diffPath, `// Generated by \`boundry diff\` — one focused view per layer with a pending\n` +
|
|
594
|
+
`// change. Derived artifact: regenerate after approve; do not hand-edit.\n` +
|
|
595
|
+
`views {\n${body}\n}\n`);
|
|
596
|
+
return ordered.map((layer) => ({
|
|
597
|
+
id: scopeViewId(layer.scope),
|
|
598
|
+
scope: layer.scope,
|
|
599
|
+
changes: layer.changes,
|
|
600
|
+
}));
|
|
601
|
+
}
|
|
181
602
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { mkdtempSync, readdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join, relative, resolve } from 'node:path';
|
|
6
6
|
import { Pipeline } from '../core/pipeline/pipeline.js';
|
|
7
7
|
import { LikeC4Visualizer } from '../adapters/visualizer/likec4.js';
|
|
8
8
|
import { DepCruiserEnforcer } from '../adapters/enforcer/depcruiser.js';
|
|
9
|
-
const USAGE = 'usage: boundry <generate|check|approve|verify> [--arch <dir>] [--base <git-ref>] [--cwd <dir>] [--out <file>] [sources...]';
|
|
9
|
+
const USAGE = 'usage: boundry <generate|check|approve|verify|annotate|diff> [--arch <dir>] [--base <git-ref>] [--cwd <dir>] [--out <file>] [sources...]';
|
|
10
10
|
function optValue(args, flag) {
|
|
11
11
|
const i = args.indexOf(flag);
|
|
12
12
|
return i >= 0 ? args[i + 1] : undefined;
|
|
@@ -75,6 +75,9 @@ async function main() {
|
|
|
75
75
|
const cwd = optValue(rest, '--cwd');
|
|
76
76
|
if (cwd)
|
|
77
77
|
process.chdir(resolve(cwd));
|
|
78
|
+
// The accepted-state lock lives beside the diagram it locks. `approve` writes
|
|
79
|
+
// it; `annotate` reads it — the baseline Boundry owns, not one inferred from git.
|
|
80
|
+
const lockFile = join(archDir, 'boundry.lock');
|
|
78
81
|
const pipeline = new Pipeline(new LikeC4Visualizer(archDir), new DepCruiserEnforcer());
|
|
79
82
|
const grantedSince = (ref) => pipeline.verify(new LikeC4Visualizer(materializeArchAt(archDir, ref)));
|
|
80
83
|
if (command === 'generate') {
|
|
@@ -131,8 +134,43 @@ async function main() {
|
|
|
131
134
|
else {
|
|
132
135
|
console.error('Boundry: ⚠ no --base given — approving without verifying that every new edge was proposed');
|
|
133
136
|
}
|
|
134
|
-
await pipeline.approve();
|
|
135
|
-
|
|
137
|
+
const lock = await pipeline.approve();
|
|
138
|
+
writeFileSync(lockFile, lock);
|
|
139
|
+
console.log('Boundry: ✓ approved — enacted the diagram and wrote', relative(process.cwd(), lockFile));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (command === 'annotate') {
|
|
143
|
+
if (!existsSync(lockFile)) {
|
|
144
|
+
console.error(`Boundry: no ${relative(process.cwd(), lockFile)} — run 'boundry approve' first to record an accepted state`);
|
|
145
|
+
process.exitCode = 2;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const { edges, modules } = await pipeline.annotate(readFileSync(lockFile, 'utf8'));
|
|
149
|
+
if (edges.length === 0 && modules.length === 0) {
|
|
150
|
+
console.log('Boundry: ✓ nothing to annotate — the diagram matches the accepted lock');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
console.log(`Boundry: ✎ marked ${edges.length} edge(s) and ${modules.length} box(es) #proposed:`);
|
|
154
|
+
for (const edge of edges)
|
|
155
|
+
console.log(` ${edge.from} → ${edge.to}`);
|
|
156
|
+
for (const mod of modules)
|
|
157
|
+
console.log(` [${mod.title}]`);
|
|
158
|
+
console.log(' Review the highlighted diagram, then approve or revert.');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (command === 'diff') {
|
|
162
|
+
const views = await pipeline.diffViews();
|
|
163
|
+
const diffFile = join(archDir, 'boundry.diff.likec4');
|
|
164
|
+
if (views.length === 0) {
|
|
165
|
+
console.log('Boundry: ✓ nothing proposed — no diff views to draw');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
console.log(`Boundry: ✎ wrote ${views.length} diff view(s) to ${relative(process.cwd(), diffFile)}:`);
|
|
169
|
+
for (const view of views) {
|
|
170
|
+
const layer = view.scope ?? 'root';
|
|
171
|
+
console.log(` ${view.id} (layer ${layer}, ${view.changes} change(s))`);
|
|
172
|
+
}
|
|
173
|
+
console.log(' Open the diagram in `likec4 serve` to review each layer.');
|
|
136
174
|
return;
|
|
137
175
|
}
|
|
138
176
|
console.error(USAGE);
|
|
@@ -82,3 +82,18 @@ export declare function newlyExemptedImporters(base: BoundaryModel, head: Bounda
|
|
|
82
82
|
* dependencies granted WITHOUT going through a proposal — i.e. self-approvals.
|
|
83
83
|
*/
|
|
84
84
|
export declare function newlyAllowedEdges(base: BoundaryModel, head: BoundaryModel): AllowedEdge[];
|
|
85
|
+
/**
|
|
86
|
+
* Modules present at `head` but not at `base`, keyed by id. A `#proposed` element
|
|
87
|
+
* stays a module (the marker is visual-only), so this reports both bare-added and
|
|
88
|
+
* already-proposed new boxes — the annotator makes marking idempotent.
|
|
89
|
+
*/
|
|
90
|
+
export declare function newlyAddedModules(base: BoundaryModel, head: BoundaryModel): Module[];
|
|
91
|
+
/**
|
|
92
|
+
* The accepted state, written as a canonical string — the lock. `approve` records
|
|
93
|
+
* it so change detection has a baseline it OWNS, rather than trusting whatever git
|
|
94
|
+
* happens to hold. Deterministic: fields and lists are sorted, so the same model
|
|
95
|
+
* always serializes byte-for-byte the same.
|
|
96
|
+
*/
|
|
97
|
+
export declare function serializeModel(model: BoundaryModel): string;
|
|
98
|
+
/** Read a lock back into a model. Inverse of {@link serializeModel}. */
|
|
99
|
+
export declare function parseModel(text: string): BoundaryModel;
|
|
@@ -34,3 +34,43 @@ export function newlyAllowedEdges(base, head) {
|
|
|
34
34
|
const allowedAtBase = new Set(base.allowed.map(edgeKey));
|
|
35
35
|
return head.allowed.filter((edge) => !allowedAtBase.has(edgeKey(edge)));
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Modules present at `head` but not at `base`, keyed by id. A `#proposed` element
|
|
39
|
+
* stays a module (the marker is visual-only), so this reports both bare-added and
|
|
40
|
+
* already-proposed new boxes — the annotator makes marking idempotent.
|
|
41
|
+
*/
|
|
42
|
+
export function newlyAddedModules(base, head) {
|
|
43
|
+
const atBase = new Set(base.modules.map((m) => m.id));
|
|
44
|
+
return head.modules.filter((m) => !atBase.has(m.id));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The accepted state, written as a canonical string — the lock. `approve` records
|
|
48
|
+
* it so change detection has a baseline it OWNS, rather than trusting whatever git
|
|
49
|
+
* happens to hold. Deterministic: fields and lists are sorted, so the same model
|
|
50
|
+
* always serializes byte-for-byte the same.
|
|
51
|
+
*/
|
|
52
|
+
export function serializeModel(model) {
|
|
53
|
+
const canonical = {
|
|
54
|
+
modules: [...model.modules]
|
|
55
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
56
|
+
.map((m) => ({ id: m.id, title: m.title, path: m.path, kind: m.kind })),
|
|
57
|
+
allowed: [...model.allowed]
|
|
58
|
+
.sort((a, b) => edgeKey(a).localeCompare(edgeKey(b)))
|
|
59
|
+
.map((e) => ({ from: e.from, to: e.to })),
|
|
60
|
+
wildcards: [...model.wildcards].sort(),
|
|
61
|
+
exemptImporters: [...model.exemptImporters].sort(),
|
|
62
|
+
...(model.governRoot ? { governRoot: model.governRoot } : {}),
|
|
63
|
+
};
|
|
64
|
+
return `${JSON.stringify(canonical, null, 2)}\n`;
|
|
65
|
+
}
|
|
66
|
+
/** Read a lock back into a model. Inverse of {@link serializeModel}. */
|
|
67
|
+
export function parseModel(text) {
|
|
68
|
+
const raw = JSON.parse(text);
|
|
69
|
+
return {
|
|
70
|
+
modules: raw.modules ?? [],
|
|
71
|
+
allowed: raw.allowed ?? [],
|
|
72
|
+
wildcards: raw.wildcards ?? [],
|
|
73
|
+
exemptImporters: raw.exemptImporters ?? [],
|
|
74
|
+
governRoot: raw.governRoot,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import type { VisualizerPort, EnforcerPort, EnforcerConfig, CheckResult } from "../ports/ports.js";
|
|
2
|
-
import type { AllowedEdge } from "../model/boundary-model.js";
|
|
1
|
+
import type { VisualizerPort, EnforcerPort, EnforcerConfig, CheckResult, DiffView } from "../ports/ports.js";
|
|
2
|
+
import type { AllowedEdge, Module } from "../model/boundary-model.js";
|
|
3
|
+
/** What the annotator turned into explicit `#proposed` proposals. */
|
|
4
|
+
export interface AnnotateResult {
|
|
5
|
+
edges: AllowedEdge[];
|
|
6
|
+
modules: Module[];
|
|
7
|
+
}
|
|
3
8
|
/**
|
|
4
9
|
* What a diagram grants that the approved base did not. Both kinds of grant are
|
|
5
10
|
* reported, because both let code cross a boundary that was previously closed.
|
|
@@ -32,6 +37,28 @@ export declare class Pipeline {
|
|
|
32
37
|
* protocol.
|
|
33
38
|
*/
|
|
34
39
|
verify(base: VisualizerPort): Promise<VerifyResult>;
|
|
35
|
-
/**
|
|
36
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Enact the diagram's pending proposals (strip `#proposed`, remove
|
|
42
|
+
* `#proposal-delete`), then return the resulting accepted model as a lock
|
|
43
|
+
* string. The caller persists it: that lock — written by approve, not inferred
|
|
44
|
+
* from git — is the baseline change detection compares against.
|
|
45
|
+
*/
|
|
46
|
+
approve(): Promise<string>;
|
|
47
|
+
/**
|
|
48
|
+
* Compare the diagram against a previously accepted lock and rewrite every
|
|
49
|
+
* undeclared addition — a bare new edge (a self-grant) or box — into an
|
|
50
|
+
* explicit `#proposed` proposal in the source, then paints intrinsic styling on
|
|
51
|
+
* every marker so the proposal is highlighted on every LikeC4 surface. Turns
|
|
52
|
+
* silent drift into a reviewable, colourable proposal without trusting git for
|
|
53
|
+
* the baseline.
|
|
54
|
+
*/
|
|
55
|
+
annotate(lock: string): Promise<AnnotateResult>;
|
|
56
|
+
/**
|
|
57
|
+
* (Re)generate a focused diff view per layer that holds a pending change, so a
|
|
58
|
+
* reviewer sees each proposal in the scope where it is actually drawn — a
|
|
59
|
+
* proposal nested in a box is invisible once that box collapses at a wider
|
|
60
|
+
* view. Reads the diagram's own `#proposed` / `#proposal-delete` markers, so it
|
|
61
|
+
* composes with `annotate` (annotate marks drift, this frames it).
|
|
62
|
+
*/
|
|
63
|
+
diffViews(): Promise<DiffView[]>;
|
|
37
64
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { newlyAllowedEdges, newlyExemptedImporters, } from "../model/boundary-model.js";
|
|
1
|
+
import { newlyAddedModules, newlyAllowedEdges, newlyExemptedImporters, parseModel, serializeModel, } from "../model/boundary-model.js";
|
|
2
2
|
/**
|
|
3
3
|
* The SDK's public surface. Orchestrates the two driven ports and stays blind
|
|
4
4
|
* to any concrete diagram source or linter — swap adapters, this is untouched.
|
|
@@ -33,8 +33,42 @@ export class Pipeline {
|
|
|
33
33
|
exemptions: newlyExemptedImporters(baseModel, headModel),
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Enact the diagram's pending proposals (strip `#proposed`, remove
|
|
38
|
+
* `#proposal-delete`), then return the resulting accepted model as a lock
|
|
39
|
+
* string. The caller persists it: that lock — written by approve, not inferred
|
|
40
|
+
* from git — is the baseline change detection compares against.
|
|
41
|
+
*/
|
|
37
42
|
async approve() {
|
|
38
|
-
|
|
43
|
+
await this.visualizer.approve();
|
|
44
|
+
const accepted = await this.visualizer.read();
|
|
45
|
+
return serializeModel(accepted);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Compare the diagram against a previously accepted lock and rewrite every
|
|
49
|
+
* undeclared addition — a bare new edge (a self-grant) or box — into an
|
|
50
|
+
* explicit `#proposed` proposal in the source, then paints intrinsic styling on
|
|
51
|
+
* every marker so the proposal is highlighted on every LikeC4 surface. Turns
|
|
52
|
+
* silent drift into a reviewable, colourable proposal without trusting git for
|
|
53
|
+
* the baseline.
|
|
54
|
+
*/
|
|
55
|
+
async annotate(lock) {
|
|
56
|
+
const accepted = parseModel(lock);
|
|
57
|
+
const head = await this.visualizer.read();
|
|
58
|
+
const edges = newlyAllowedEdges(accepted, head);
|
|
59
|
+
const modules = newlyAddedModules(accepted, head);
|
|
60
|
+
await this.visualizer.propose(edges, modules.map((m) => m.id));
|
|
61
|
+
await this.visualizer.styleMarkers();
|
|
62
|
+
return { edges, modules };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* (Re)generate a focused diff view per layer that holds a pending change, so a
|
|
66
|
+
* reviewer sees each proposal in the scope where it is actually drawn — a
|
|
67
|
+
* proposal nested in a box is invisible once that box collapses at a wider
|
|
68
|
+
* view. Reads the diagram's own `#proposed` / `#proposal-delete` markers, so it
|
|
69
|
+
* composes with `annotate` (annotate marks drift, this frames it).
|
|
70
|
+
*/
|
|
71
|
+
async diffViews() {
|
|
72
|
+
return this.visualizer.emitDiffViews();
|
|
39
73
|
}
|
|
40
74
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BoundaryModel } from '../model/boundary-model.js';
|
|
1
|
+
import type { AllowedEdge, BoundaryModel } from '../model/boundary-model.js';
|
|
2
2
|
/** A driven port: turns some diagram source into the boundary model. */
|
|
3
3
|
export interface VisualizerPort {
|
|
4
4
|
read(): Promise<BoundaryModel>;
|
|
@@ -7,6 +7,42 @@ export interface VisualizerPort {
|
|
|
7
7
|
* promoting intent edges to approved. Source-preserving; never an LLM edit.
|
|
8
8
|
*/
|
|
9
9
|
approve(): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Deterministically mark the given edges and elements `#proposed` in the
|
|
12
|
+
* source. The inverse of `approve` for additions: it rewrites an undeclared
|
|
13
|
+
* change into an explicit, colourable proposal. Idempotent — anything already
|
|
14
|
+
* marked is left alone. Source-preserving; never an LLM edit.
|
|
15
|
+
*/
|
|
16
|
+
propose(edges: AllowedEdge[], moduleIds: string[]): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Paint intrinsic `style { color … }` on every edge/box carrying a marker, so a
|
|
19
|
+
* proposal is highlighted on every LikeC4 surface — base views and the
|
|
20
|
+
* relationships panel, not only the generated diff views. Amber for `#proposed`,
|
|
21
|
+
* red for `#proposal-delete`. Idempotent; `approve` strips it back out.
|
|
22
|
+
* Source-preserving; never an LLM edit.
|
|
23
|
+
*/
|
|
24
|
+
styleMarkers(): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* (Re)generate a focused diff view for every layer that holds a pending
|
|
27
|
+
* change — an edge or box tagged `#proposed` or `#proposal-delete`. Each view
|
|
28
|
+
* is scoped to the tightest element that contains the change (the model root
|
|
29
|
+
* for a top-level one), because a proposal nested inside a box is invisible
|
|
30
|
+
* once that box collapses at a wider scope. A derived artifact: overwritten
|
|
31
|
+
* each run, and removed when nothing is proposed. Returns one entry per view.
|
|
32
|
+
*/
|
|
33
|
+
emitDiffViews(): Promise<DiffView[]>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One emitted diff view: a single layer (element scope) that holds at least one
|
|
37
|
+
* pending change, and how many changes fall in it.
|
|
38
|
+
*/
|
|
39
|
+
export interface DiffView {
|
|
40
|
+
/** The generated view id, e.g. `boundry_diff_root` or `boundry_diff_billing`. */
|
|
41
|
+
id: string;
|
|
42
|
+
/** The scope element's fqn, or undefined for the model root. */
|
|
43
|
+
scope?: string;
|
|
44
|
+
/** How many pending `#proposed` / `#proposal-delete` changes this layer holds. */
|
|
45
|
+
changes: number;
|
|
10
46
|
}
|
|
11
47
|
/** A generated linter config artifact. */
|
|
12
48
|
export interface EnforcerConfig {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "boundry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Compile a C4 architecture diagram into a deterministic dependency linter. Deterministic architectural guardrails for AI agents and humans.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"dependency-cruiser": "^16.4.0",
|
|
59
|
-
"likec4": "^1.
|
|
59
|
+
"likec4": "^1.58.0",
|
|
60
60
|
"typescript": "^5.6.0"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|