boundry 0.2.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 +82 -1
- package/README.md +123 -4
- package/dist/adapters/enforcer/depcruiser.js +42 -24
- package/dist/adapters/visualizer/likec4.d.ts +36 -6
- package/dist/adapters/visualizer/likec4.js +473 -39
- package/dist/cli/index.js +42 -4
- package/dist/core/model/boundary-model.d.ts +29 -3
- 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
|
@@ -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);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
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);
|
|
32
138
|
}
|
|
33
139
|
/**
|
|
34
|
-
* The source to remove for
|
|
35
|
-
* whole line (so approving leaves no blank gap); an inline one takes just the
|
|
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
|
|
@@ -88,7 +316,7 @@ export class LikeC4Visualizer {
|
|
|
88
316
|
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
89
317
|
const model = await likec4.computedModel();
|
|
90
318
|
const modules = [];
|
|
91
|
-
const
|
|
319
|
+
const moduleIds = new Set();
|
|
92
320
|
const wildcardIds = new Set();
|
|
93
321
|
const exemptImporters = new Set();
|
|
94
322
|
let governRoot;
|
|
@@ -113,12 +341,25 @@ export class LikeC4Visualizer {
|
|
|
113
341
|
wildcardIds.add(String(el.id));
|
|
114
342
|
continue;
|
|
115
343
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
344
|
+
// An element maps to a folder (owns its subtree) or a single file (owns
|
|
345
|
+
// exactly itself). A file leaf keeps a guarded contract sitting beside its
|
|
346
|
+
// sibling sub-folders, so a deep nested diagram stays legal AND enforceable.
|
|
347
|
+
const folderMeta = el.getMetadata('folder');
|
|
348
|
+
const fileMeta = el.getMetadata('file');
|
|
349
|
+
const folder = Array.isArray(folderMeta) ? folderMeta[0] : folderMeta;
|
|
350
|
+
const file = Array.isArray(fileMeta) ? fileMeta[0] : fileMeta;
|
|
351
|
+
if (folder && file) {
|
|
352
|
+
throw new Error(`element '${String(el.id)}' declares both 'folder' and 'file' — a module maps to one path`);
|
|
353
|
+
}
|
|
354
|
+
if (folder || file) {
|
|
119
355
|
const id = String(el.id);
|
|
120
|
-
modules.push({
|
|
121
|
-
|
|
356
|
+
modules.push({
|
|
357
|
+
id,
|
|
358
|
+
title: el.title,
|
|
359
|
+
path: (folder ?? file),
|
|
360
|
+
kind: folder ? 'folder' : 'file',
|
|
361
|
+
});
|
|
362
|
+
moduleIds.add(id);
|
|
122
363
|
}
|
|
123
364
|
}
|
|
124
365
|
const allowed = [];
|
|
@@ -131,8 +372,8 @@ export class LikeC4Visualizer {
|
|
|
131
372
|
const to = String(rel.target.id);
|
|
132
373
|
// An edge into a wildcard is kept like any other grant, so `verify` sees a
|
|
133
374
|
// self-granted `#anything` exemption as the newly-allowed edge it is.
|
|
134
|
-
const targetGoverned =
|
|
135
|
-
if (from !== to &&
|
|
375
|
+
const targetGoverned = moduleIds.has(to) || wildcardIds.has(to);
|
|
376
|
+
if (from !== to && moduleIds.has(from) && targetGoverned) {
|
|
136
377
|
allowed.push({ from, to });
|
|
137
378
|
}
|
|
138
379
|
}
|
|
@@ -145,17 +386,20 @@ export class LikeC4Visualizer {
|
|
|
145
386
|
};
|
|
146
387
|
}
|
|
147
388
|
/**
|
|
148
|
-
* Deterministically
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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.
|
|
152
396
|
*/
|
|
153
397
|
async approve() {
|
|
154
398
|
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
155
399
|
for (const doc of likec4.LangiumDocuments.all) {
|
|
156
400
|
const filePath = doc.uri.fsPath;
|
|
157
401
|
const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
|
|
158
|
-
const ranges =
|
|
402
|
+
const ranges = collectApprovalRanges(doc.parseResult?.value, text);
|
|
159
403
|
if (ranges.length === 0)
|
|
160
404
|
continue;
|
|
161
405
|
let next = text;
|
|
@@ -165,4 +409,194 @@ export class LikeC4Visualizer {
|
|
|
165
409
|
writeFileSync(filePath, next);
|
|
166
410
|
}
|
|
167
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
|
+
}
|
|
168
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);
|