boundry 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +106 -2
- package/README.md +113 -15
- package/dist/adapters/visualizer/likec4.d.ts +45 -6
- package/dist/adapters/visualizer/likec4.js +512 -31
- package/dist/cli/index.js +59 -41
- 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 +42 -9
- package/dist/core/pipeline/pipeline.js +52 -11
- package/dist/core/ports/ports.d.ts +37 -1
- package/package.json +2 -2
|
@@ -1,5 +1,109 @@
|
|
|
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
|
+
* Every layer that actually *draws* the edge `from -> to`, so a reviewer can open
|
|
33
|
+
* the change at whatever altitude matters — the whole system, a specific
|
|
34
|
+
* container, or the endpoint's immediate parent — not only the auto-picked common
|
|
35
|
+
* ancestor (issue #7). The set is derived from how LikeC4's `include *` renders a
|
|
36
|
+
* scoped view, verified against 1.58:
|
|
37
|
+
*
|
|
38
|
+
* - The tightest common ancestor draws it nested (both endpoints inside).
|
|
39
|
+
* - Every proper ancestor of an endpoint *below* that common ancestor draws it
|
|
40
|
+
* too: that side's subtree is expanded and the far endpoint is pulled in as a
|
|
41
|
+
* boundary node (e.g. `of s1` → `s1.c1 -> s2`).
|
|
42
|
+
* - Ancestors *above* the common ancestor are deliberately excluded: both
|
|
43
|
+
* endpoints collapse into the same child there, so the edge becomes a
|
|
44
|
+
* self-loop and LikeC4 draws nothing. The common ancestor is the widest layer
|
|
45
|
+
* that still draws it.
|
|
46
|
+
*
|
|
47
|
+
* So the scopes are the common ancestor plus each endpoint's ancestor chain from
|
|
48
|
+
* just below it down to the endpoint's parent — never the endpoints themselves
|
|
49
|
+
* (a view scoped *to* a leaf endpoint has an empty subtree).
|
|
50
|
+
*/
|
|
51
|
+
function edgeScopes(from, to) {
|
|
52
|
+
const common = commonScope(from, to);
|
|
53
|
+
const depthOf = (scope) => (scope ? scope.split('.').length : 0);
|
|
54
|
+
const commonDepth = depthOf(common);
|
|
55
|
+
const byId = new Map();
|
|
56
|
+
const add = (scope) => {
|
|
57
|
+
byId.set(scopeViewId(scope), scope);
|
|
58
|
+
};
|
|
59
|
+
add(common);
|
|
60
|
+
for (const endpoint of [from, to]) {
|
|
61
|
+
const parts = endpoint.split('.');
|
|
62
|
+
// Proper ancestors of the endpoint strictly below the common ancestor: prefix
|
|
63
|
+
// lengths (commonDepth + 1) .. (parts.length - 1). The last is the endpoint's
|
|
64
|
+
// parent; the endpoint itself (full length) is never a scope.
|
|
65
|
+
for (let len = commonDepth + 1; len <= parts.length - 1; len++) {
|
|
66
|
+
add(parts.slice(0, len).join('.'));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...byId.values()];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Deterministic highlight rules for the marker tags actually in use. Referencing
|
|
73
|
+
* an undeclared tag is a hard LikeC4 error, and a tag can only be *used* if it is
|
|
74
|
+
* declared — so emitting rules only for seen tags is always safe.
|
|
75
|
+
*
|
|
76
|
+
* Boxes use a style-only `style element.tag` rule: a tagged box is coloured where
|
|
77
|
+
* it already sits and never force-included into a wider view (which would drag a
|
|
78
|
+
* nested proposal out of its collapsed parent, defeating the per-layer framing).
|
|
79
|
+
* Edges use `include … where … with`, coloured and made *solid* so they stand out
|
|
80
|
+
* against LikeC4's dashed default. Amber = addition, red = removal. Verified
|
|
81
|
+
* against LikeC4 1.58.
|
|
82
|
+
*/
|
|
83
|
+
function highlightLines(usedTags) {
|
|
84
|
+
const lines = [];
|
|
85
|
+
if (usedTags.has('proposed')) {
|
|
86
|
+
lines.push(' style element.tag = #proposed { color amber }');
|
|
87
|
+
}
|
|
88
|
+
if (usedTags.has('proposal-delete')) {
|
|
89
|
+
lines.push(' style element.tag = #proposal-delete { color red }');
|
|
90
|
+
}
|
|
91
|
+
if (usedTags.has('proposed')) {
|
|
92
|
+
lines.push(' include * -> * where tag is #proposed with { color amber; line solid }');
|
|
93
|
+
}
|
|
94
|
+
if (usedTags.has('proposal-delete')) {
|
|
95
|
+
lines.push(' include * -> * where tag is #proposal-delete with { color red; line solid }');
|
|
96
|
+
}
|
|
97
|
+
return lines;
|
|
98
|
+
}
|
|
99
|
+
/** One `view … { include * }` block, scoped with `of <element>` unless it is root. */
|
|
100
|
+
function renderDiffView(scope, highlight) {
|
|
101
|
+
const opener = scope
|
|
102
|
+
? ` view ${scopeViewId(scope)} of ${scope} {`
|
|
103
|
+
: ` view ${scopeViewId(scope)} {`;
|
|
104
|
+
const lines = [` title 'Boundry diff · ${scope ?? 'root'}'`, ' include *', ...highlight];
|
|
105
|
+
return `${opener}\n${lines.join('\n')}\n }`;
|
|
106
|
+
}
|
|
3
107
|
/**
|
|
4
108
|
* An exemption pattern has to be a usable regex before it reaches the linter. A
|
|
5
109
|
* broken or empty one would otherwise be a silent hole: it exempts files from
|
|
@@ -24,15 +128,57 @@ function assertUsableRegex(pattern, elementId) {
|
|
|
24
128
|
}
|
|
25
129
|
return pattern;
|
|
26
130
|
}
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Walk the AST through CONTAINMENT only — descending into a property solely when
|
|
133
|
+
* the child names this node as its `$container`. That skips cross-references (a
|
|
134
|
+
* relation's endpoint links to the element it names), so a node is never reached
|
|
135
|
+
* through another's reference to it, and traversal never leaves this document.
|
|
136
|
+
*/
|
|
137
|
+
function walkContained(root, visit) {
|
|
138
|
+
const go = (node) => {
|
|
139
|
+
if (!node || typeof node !== 'object')
|
|
140
|
+
return;
|
|
141
|
+
visit(node);
|
|
142
|
+
for (const key of Object.keys(node)) {
|
|
143
|
+
if (key.startsWith('$'))
|
|
144
|
+
continue;
|
|
145
|
+
const child = node[key];
|
|
146
|
+
for (const kid of Array.isArray(child) ? child : [child]) {
|
|
147
|
+
if (kid && typeof kid === 'object' && kid.$container === node)
|
|
148
|
+
go(kid);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
go(root);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The fully-qualified id of an element AST node — its own name and every
|
|
156
|
+
* enclosing element's, joined by '.'. Matches the id the computed model assigns,
|
|
157
|
+
* for nested elements as well as flat ones.
|
|
158
|
+
*/
|
|
159
|
+
function fqnOf(astElement) {
|
|
160
|
+
if (!astElement)
|
|
161
|
+
return undefined;
|
|
162
|
+
const parts = [];
|
|
163
|
+
for (let n = astElement; n; n = n.$container) {
|
|
164
|
+
if (n.$type === 'Element' && n.name)
|
|
165
|
+
parts.unshift(n.name);
|
|
166
|
+
}
|
|
167
|
+
return parts.length ? parts.join('.') : undefined;
|
|
168
|
+
}
|
|
169
|
+
/** The element an `a -> b` endpoint (FqnRef) resolves to, or undefined. */
|
|
170
|
+
function endpointElement(fqnRef) {
|
|
171
|
+
return fqnRef?.value?.ref;
|
|
172
|
+
}
|
|
173
|
+
/** The literal source text of a TagRef node, or undefined if it is not one. */
|
|
174
|
+
function tagRefText(node, text) {
|
|
175
|
+
if (node.$type !== 'TagRef' || !node.$cstNode)
|
|
176
|
+
return undefined;
|
|
177
|
+
return text.slice(node.$cstNode.offset, node.$cstNode.end);
|
|
32
178
|
}
|
|
33
179
|
/**
|
|
34
|
-
* The source to remove for
|
|
35
|
-
* whole line (so approving leaves no blank gap); an inline one takes just the
|
|
180
|
+
* The source to remove for a `#proposed` marker. A marker alone on its line takes
|
|
181
|
+
* the whole line (so approving leaves no blank gap); an inline one takes just the
|
|
36
182
|
* token and the space before it. The `tag proposed` declaration in the
|
|
37
183
|
* specification is untouched — the vocabulary stays for the next proposal.
|
|
38
184
|
*/
|
|
@@ -50,28 +196,150 @@ function proposedRemovalRange(tag, text) {
|
|
|
50
196
|
start--;
|
|
51
197
|
return { start, end };
|
|
52
198
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
199
|
+
/**
|
|
200
|
+
* The nearest relationship or element the marker sits in, found via the AST
|
|
201
|
+
* `$container` chain — the true syntactic parent. A plain object-graph walk would
|
|
202
|
+
* follow the cross-reference from a relation's endpoint into the element it names
|
|
203
|
+
* and mis-attribute an element's marker to a relation; `$container` never does.
|
|
204
|
+
*/
|
|
205
|
+
function enclosingStatement(tagRef) {
|
|
206
|
+
for (let n = tagRef.$container; n; n = n.$container) {
|
|
207
|
+
if (n.$type === 'Relation' || n.$type === 'Element')
|
|
208
|
+
return n;
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The whole-statement range for a `#proposal-delete` marker: the entire relation
|
|
214
|
+
* or element block, from the start of its first line through its trailing
|
|
215
|
+
* newline, so approving the deletion removes the edge/box and leaves no gap.
|
|
216
|
+
*/
|
|
217
|
+
function statementRemovalRange(statement, text) {
|
|
218
|
+
const { offset, end } = statement.$cstNode;
|
|
219
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
220
|
+
const nl = text.indexOf('\n', end);
|
|
221
|
+
return { start: lineStart, end: nl === -1 ? text.length : nl + 1 };
|
|
222
|
+
}
|
|
223
|
+
/** amber for `#proposed`, red for `#proposal-delete` — the intrinsic colour annotate paints. */
|
|
224
|
+
const MARKER_COLOR = {
|
|
225
|
+
'#proposed': 'amber',
|
|
226
|
+
'#proposal-delete': 'red',
|
|
227
|
+
};
|
|
228
|
+
/** The canonical intrinsic style annotate injects for a colour. */
|
|
229
|
+
function canonicalStyle(color) {
|
|
230
|
+
return `style { color ${color} }`;
|
|
231
|
+
}
|
|
232
|
+
/** The direct `*Body` child (ElementBody / RelationBody) of a statement, if any. */
|
|
233
|
+
function statementBody(stmt) {
|
|
234
|
+
for (const key of Object.keys(stmt)) {
|
|
235
|
+
if (key.startsWith('$'))
|
|
236
|
+
continue;
|
|
237
|
+
const child = stmt[key];
|
|
238
|
+
for (const kid of Array.isArray(child) ? child : [child]) {
|
|
239
|
+
if (kid && typeof kid === 'object' && kid.$container === stmt &&
|
|
240
|
+
typeof kid.$type === 'string' && kid.$type.endsWith('Body')) {
|
|
241
|
+
return kid;
|
|
242
|
+
}
|
|
64
243
|
}
|
|
65
|
-
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
/** The direct `*StyleProperty` children of a body. */
|
|
248
|
+
function styleProperties(body) {
|
|
249
|
+
const out = [];
|
|
250
|
+
for (const key of Object.keys(body)) {
|
|
251
|
+
if (key.startsWith('$'))
|
|
252
|
+
continue;
|
|
253
|
+
const child = body[key];
|
|
254
|
+
for (const kid of Array.isArray(child) ? child : [child]) {
|
|
255
|
+
if (kid && typeof kid === 'object' && kid.$container === body &&
|
|
256
|
+
typeof kid.$type === 'string' && kid.$type.endsWith('StyleProperty')) {
|
|
257
|
+
out.push(kid);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The style node in `body` whose source is EXACTLY the canonical `style { color
|
|
265
|
+
* <c> }` annotate writes — matched by whitespace-normalised text, so a richer
|
|
266
|
+
* user-authored style (`style { color amber; icon none }`) is never mistaken for
|
|
267
|
+
* ours and never stripped.
|
|
268
|
+
*/
|
|
269
|
+
function canonicalStyleNode(body, color, text) {
|
|
270
|
+
const want = canonicalStyle(color);
|
|
271
|
+
return styleProperties(body).find((s) => s.$cstNode &&
|
|
272
|
+
text.slice(s.$cstNode.offset, s.$cstNode.end).replace(/\s+/g, ' ').trim() === want);
|
|
273
|
+
}
|
|
274
|
+
/** True when `node` is the body's only property — nothing else would survive removing it. */
|
|
275
|
+
function bodyHasOnly(body, node) {
|
|
276
|
+
for (const key of Object.keys(body)) {
|
|
277
|
+
if (key.startsWith('$'))
|
|
278
|
+
continue;
|
|
279
|
+
const child = body[key];
|
|
280
|
+
for (const kid of Array.isArray(child) ? child : [child]) {
|
|
281
|
+
if (kid && typeof kid === 'object' && kid.$container === body && kid !== node)
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
/** A node's whole line, trailing newline included — for stripping a box's style line. */
|
|
288
|
+
function lineRemovalRange(node, text) {
|
|
289
|
+
const { offset, end } = node.$cstNode;
|
|
290
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
291
|
+
const nl = text.indexOf('\n', end);
|
|
292
|
+
return { start: lineStart, end: nl === -1 ? text.length : nl + 1 };
|
|
293
|
+
}
|
|
294
|
+
/** A relation body in full, plus the whitespace before its `{` — turns `a -> b { … }` back into `a -> b`. */
|
|
295
|
+
function bodyRemovalRange(body, text) {
|
|
296
|
+
let start = body.$cstNode.offset;
|
|
297
|
+
while (start > 0 && (text[start - 1] === ' ' || text[start - 1] === '\t'))
|
|
298
|
+
start--;
|
|
299
|
+
return { start, end: body.$cstNode.end };
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Every source range to splice out to approve a diagram. A `#proposed` marker
|
|
303
|
+
* strips to its token (the edge/box stays and becomes permanent) AND takes with
|
|
304
|
+
* it any intrinsic `style { color amber }` annotate painted on — for a box, that
|
|
305
|
+
* style's line; for an edge, its whole (now-empty) body, so the edge goes back to
|
|
306
|
+
* bare. A `#proposal-delete` marker takes its whole enclosing statement (the
|
|
307
|
+
* edge/box, and any style on it, are removed outright). Ranges are collected via
|
|
308
|
+
* a containment-only walk — descending solely into true children
|
|
309
|
+
* (`child.$container === node`), never cross-references — so one marker is never
|
|
310
|
+
* counted through another element's reference to it, and no range ever escapes
|
|
311
|
+
* into a different document's text.
|
|
312
|
+
*/
|
|
313
|
+
function collectApprovalRanges(root, text) {
|
|
314
|
+
const ranges = [];
|
|
315
|
+
walkContained(root, (node) => {
|
|
316
|
+
const marker = tagRefText(node, text);
|
|
317
|
+
if (marker === '#proposed') {
|
|
66
318
|
ranges.push(proposedRemovalRange(node, text));
|
|
319
|
+
// Strip the intrinsic amber style annotate paints, so an approved box does
|
|
320
|
+
// not stay amber forever and an approved edge goes back to bare.
|
|
321
|
+
const statement = enclosingStatement(node);
|
|
322
|
+
const body = statement && statementBody(statement);
|
|
323
|
+
const style = body && canonicalStyleNode(body, MARKER_COLOR['#proposed'], text);
|
|
324
|
+
if (style) {
|
|
325
|
+
if (statement.$type === 'Relation' && bodyHasOnly(body, style)) {
|
|
326
|
+
ranges.push(bodyRemovalRange(body, text));
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
ranges.push(lineRemovalRange(style, text));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
67
332
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
333
|
+
else if (marker === '#proposal-delete') {
|
|
334
|
+
const statement = enclosingStatement(node);
|
|
335
|
+
if (statement?.$cstNode)
|
|
336
|
+
ranges.push(statementRemovalRange(statement, text));
|
|
71
337
|
}
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
|
|
338
|
+
});
|
|
339
|
+
// A whole-statement removal can contain a `#proposed` token range inside it
|
|
340
|
+
// (a box being deleted that also carried a proposed edge). Drop any range that
|
|
341
|
+
// another wholly contains, so back-to-front splicing never double-cuts.
|
|
342
|
+
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
343
|
}
|
|
76
344
|
/**
|
|
77
345
|
* First visualizer adapter. Reads a LikeC4 workspace and lifts it into the
|
|
@@ -158,17 +426,29 @@ export class LikeC4Visualizer {
|
|
|
158
426
|
};
|
|
159
427
|
}
|
|
160
428
|
/**
|
|
161
|
-
* Deterministically
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
429
|
+
* Deterministically enact a diagram's pending proposals. Source-preserving,
|
|
430
|
+
* never an LLM edit — it walks the LikeC4 CST and splices ranges directly:
|
|
431
|
+
* - `#proposed` markers are stripped, promoting their intent edges to
|
|
432
|
+
* approved (the edge/box stays, permanently allowed);
|
|
433
|
+
* - `#proposal-delete` markers take their whole edge or box with them, so
|
|
434
|
+
* approving a proposed removal actually removes it.
|
|
435
|
+
* All other formatting is left intact.
|
|
436
|
+
*
|
|
437
|
+
* The derived `boundry.diff.likec4` is skipped, not approved: it holds no real
|
|
438
|
+
* markers, only view rules that *reference* the marker tags (`style element.tag
|
|
439
|
+
* = #proposed`, `include … where tag is #proposed`). Splicing those `#proposed`
|
|
440
|
+
* tokens out would corrupt the rules into invalid LikeC4. And it is stale the
|
|
441
|
+
* moment the last proposal is enacted — it describes changes that are now
|
|
442
|
+
* approved — so approve deletes it, leaving a workspace `likec4 validate` accepts.
|
|
165
443
|
*/
|
|
166
444
|
async approve() {
|
|
167
445
|
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
168
446
|
for (const doc of likec4.LangiumDocuments.all) {
|
|
169
447
|
const filePath = doc.uri.fsPath;
|
|
448
|
+
if (filePath.endsWith(DIFF_FILE))
|
|
449
|
+
continue;
|
|
170
450
|
const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
|
|
171
|
-
const ranges =
|
|
451
|
+
const ranges = collectApprovalRanges(doc.parseResult?.value, text);
|
|
172
452
|
if (ranges.length === 0)
|
|
173
453
|
continue;
|
|
174
454
|
let next = text;
|
|
@@ -177,5 +457,206 @@ export class LikeC4Visualizer {
|
|
|
177
457
|
}
|
|
178
458
|
writeFileSync(filePath, next);
|
|
179
459
|
}
|
|
460
|
+
// Enacting the last proposal makes any diff views stale — they frame changes
|
|
461
|
+
// that are now approved. Remove the derived artifact so the post-approve
|
|
462
|
+
// workspace validates and never renders an approved-away change.
|
|
463
|
+
const diffPath = join(this.workspaceDir, DIFF_FILE);
|
|
464
|
+
if (existsSync(diffPath))
|
|
465
|
+
rmSync(diffPath);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Deterministically mark the given edges and elements `#proposed`. The inverse
|
|
469
|
+
* of `approve` for additions: an undeclared new edge (a self-grant) or box is
|
|
470
|
+
* rewritten into an explicit, colourable proposal. It locates each target's
|
|
471
|
+
* relation/element in the CST by its resolved ids and injects the marker,
|
|
472
|
+
* skipping anything already marked, so it is idempotent.
|
|
473
|
+
*/
|
|
474
|
+
async propose(edges, moduleIds) {
|
|
475
|
+
if (edges.length === 0 && moduleIds.length === 0)
|
|
476
|
+
return;
|
|
477
|
+
const wantEdge = new Set(edges.map((e) => `${e.from} -> ${e.to}`));
|
|
478
|
+
const wantModule = new Set(moduleIds);
|
|
479
|
+
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
480
|
+
for (const doc of likec4.LangiumDocuments.all) {
|
|
481
|
+
const filePath = doc.uri.fsPath;
|
|
482
|
+
if (!filePath.endsWith('.likec4'))
|
|
483
|
+
continue;
|
|
484
|
+
const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
|
|
485
|
+
const inserts = [];
|
|
486
|
+
walkContained(doc.parseResult?.value, (node) => {
|
|
487
|
+
if (node.$type === 'Relation' && node.$cstNode) {
|
|
488
|
+
const from = fqnOf(endpointElement(node.source));
|
|
489
|
+
const to = fqnOf(endpointElement(node.target));
|
|
490
|
+
if (from && to && wantEdge.has(`${from} -> ${to}`)) {
|
|
491
|
+
const { offset, end } = node.$cstNode;
|
|
492
|
+
if (!text.slice(offset, end).includes('#proposed')) {
|
|
493
|
+
inserts.push({ at: end, text: ' #proposed' });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
else if (node.$type === 'Element' && node.$cstNode) {
|
|
498
|
+
const id = fqnOf(node);
|
|
499
|
+
const body = node.body;
|
|
500
|
+
if (id && wantModule.has(id) && body?.$cstNode) {
|
|
501
|
+
const braceStart = body.$cstNode.offset;
|
|
502
|
+
const bodyText = text.slice(braceStart, body.$cstNode.end);
|
|
503
|
+
if (!bodyText.includes('#proposed')) {
|
|
504
|
+
// Insert as the body's first line, matching the indentation of the
|
|
505
|
+
// line that already follows the opening brace.
|
|
506
|
+
const afterBrace = text.indexOf('{', braceStart) + 1;
|
|
507
|
+
const nextLine = text.indexOf('\n', afterBrace) + 1;
|
|
508
|
+
const indent = (text.slice(nextLine).match(/^[ \t]*/) ?? [''])[0];
|
|
509
|
+
inserts.push({ at: afterBrace, text: `\n${indent}#proposed` });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
if (inserts.length === 0)
|
|
515
|
+
continue;
|
|
516
|
+
let next = text;
|
|
517
|
+
for (const { at, text: fragment } of inserts.sort((a, b) => b.at - a.at)) {
|
|
518
|
+
next = next.slice(0, at) + fragment + next.slice(at);
|
|
519
|
+
}
|
|
520
|
+
writeFileSync(filePath, next);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Paint intrinsic style on every marked edge and box, so a proposal is
|
|
525
|
+
* highlighted on *every* LikeC4 surface — base views and the "relationships of
|
|
526
|
+
* X" panel, not just the generated diff views (which only carry view-scoped
|
|
527
|
+
* rules). A `#proposed` edge/box gets `style { color amber }`, a
|
|
528
|
+
* `#proposal-delete` `style { color red }`. Source-preserving and idempotent —
|
|
529
|
+
* anything already carrying the canonical style is left alone. `approve` strips
|
|
530
|
+
* this styling back out when it enacts the marker.
|
|
531
|
+
*/
|
|
532
|
+
async styleMarkers() {
|
|
533
|
+
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
534
|
+
for (const doc of likec4.LangiumDocuments.all) {
|
|
535
|
+
const filePath = doc.uri.fsPath;
|
|
536
|
+
if (!filePath.endsWith('.likec4') || filePath.endsWith(DIFF_FILE))
|
|
537
|
+
continue;
|
|
538
|
+
const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
|
|
539
|
+
const inserts = [];
|
|
540
|
+
walkContained(doc.parseResult?.value, (node) => {
|
|
541
|
+
const marker = tagRefText(node, text);
|
|
542
|
+
if (marker !== '#proposed' && marker !== '#proposal-delete')
|
|
543
|
+
return;
|
|
544
|
+
const color = MARKER_COLOR[marker];
|
|
545
|
+
const statement = enclosingStatement(node);
|
|
546
|
+
if (!statement)
|
|
547
|
+
return;
|
|
548
|
+
const body = statementBody(statement);
|
|
549
|
+
if (body && canonicalStyleNode(body, color, text))
|
|
550
|
+
return; // already styled
|
|
551
|
+
if (body?.$cstNode) {
|
|
552
|
+
// Style on the line right after the marker, at the marker's indent. It
|
|
553
|
+
// must go AFTER the marker: a body lists its tags before its style
|
|
554
|
+
// properties, so a style ahead of the `#proposed` tag is a parse error.
|
|
555
|
+
const markerEnd = node.$cstNode.end;
|
|
556
|
+
const lineStart = text.lastIndexOf('\n', markerEnd - 1) + 1;
|
|
557
|
+
const indent = (text.slice(lineStart).match(/^[ \t]*/) ?? [''])[0];
|
|
558
|
+
const nl = text.indexOf('\n', markerEnd);
|
|
559
|
+
inserts.push({
|
|
560
|
+
at: nl === -1 ? markerEnd : nl,
|
|
561
|
+
text: `\n${indent}${canonicalStyle(color)}`,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
// An inline-marked edge (`a -> b #proposed`) has no body — give it one,
|
|
566
|
+
// indented one level past the relation's own line.
|
|
567
|
+
const lineStart = text.lastIndexOf('\n', statement.$cstNode.offset - 1) + 1;
|
|
568
|
+
const indent = (text.slice(lineStart).match(/^[ \t]*/) ?? [''])[0];
|
|
569
|
+
inserts.push({
|
|
570
|
+
at: statement.$cstNode.end,
|
|
571
|
+
text: ` {\n${indent} ${canonicalStyle(color)}\n${indent}}`,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
if (inserts.length === 0)
|
|
576
|
+
continue;
|
|
577
|
+
let next = text;
|
|
578
|
+
for (const { at, text: fragment } of inserts.sort((a, b) => b.at - a.at)) {
|
|
579
|
+
next = next.slice(0, at) + fragment + next.slice(at);
|
|
580
|
+
}
|
|
581
|
+
writeFileSync(filePath, next);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Generate a focused diff view for every layer that holds a pending change.
|
|
586
|
+
* Walks the diagram's own `#proposed` / `#proposal-delete` markers (never the
|
|
587
|
+
* lock — a `#proposal-delete` has no lock delta) and writes one `view … of
|
|
588
|
+
* <scope>` per layer to a derived `boundry.diff.likec4`. A box is bucketed into
|
|
589
|
+
* its parent layer; an edge into *every* layer that draws it (see `edgeScopes`),
|
|
590
|
+
* so a cross-system dependency can be reviewed at each altitude, not only the
|
|
591
|
+
* common-ancestor layer. The file is overwritten each run and removed when
|
|
592
|
+
* nothing is proposed, so it always reflects the current diagram.
|
|
593
|
+
*/
|
|
594
|
+
async emitDiffViews() {
|
|
595
|
+
const likec4 = await LikeC4.fromWorkspace(this.workspaceDir);
|
|
596
|
+
// scopeViewId -> { scope, changes } — deterministic key, so the same diagram
|
|
597
|
+
// always yields the same set of views.
|
|
598
|
+
const layers = new Map();
|
|
599
|
+
const record = (scope) => {
|
|
600
|
+
const key = scopeViewId(scope);
|
|
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).
|
|
607
|
+
const usedTags = new Set();
|
|
608
|
+
for (const doc of likec4.LangiumDocuments.all) {
|
|
609
|
+
const filePath = doc.uri.fsPath;
|
|
610
|
+
if (!filePath.endsWith('.likec4') || filePath.endsWith(DIFF_FILE))
|
|
611
|
+
continue;
|
|
612
|
+
const text = doc.textDocument?.getText?.() ?? readFileSync(filePath, 'utf8');
|
|
613
|
+
walkContained(doc.parseResult?.value, (node) => {
|
|
614
|
+
const marker = tagRefText(node, text);
|
|
615
|
+
if (marker !== '#proposed' && marker !== '#proposal-delete')
|
|
616
|
+
return;
|
|
617
|
+
usedTags.add(marker.slice(1)); // drop the leading '#'
|
|
618
|
+
const statement = enclosingStatement(node);
|
|
619
|
+
if (statement?.$type === 'Relation') {
|
|
620
|
+
const from = fqnOf(endpointElement(statement.source));
|
|
621
|
+
const to = fqnOf(endpointElement(statement.target));
|
|
622
|
+
// One view per layer that actually draws this edge, so the reviewer can
|
|
623
|
+
// judge it at every altitude — not only the common-ancestor layer (#7).
|
|
624
|
+
if (from && to)
|
|
625
|
+
for (const scope of edgeScopes(from, to))
|
|
626
|
+
record(scope);
|
|
627
|
+
}
|
|
628
|
+
else if (statement?.$type === 'Element') {
|
|
629
|
+
const fqn = fqnOf(statement);
|
|
630
|
+
if (fqn)
|
|
631
|
+
record(parentScope(fqn));
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
const diffPath = join(this.workspaceDir, DIFF_FILE);
|
|
636
|
+
if (layers.size === 0) {
|
|
637
|
+
// Nothing proposed — clear any stale views so `serve` never renders a diff
|
|
638
|
+
// that no longer exists (and never dangles a reference to an approved-away box).
|
|
639
|
+
if (existsSync(diffPath))
|
|
640
|
+
rmSync(diffPath);
|
|
641
|
+
return [];
|
|
642
|
+
}
|
|
643
|
+
// Root first, then by fqn, so the output is stable across runs.
|
|
644
|
+
const ordered = [...layers.values()].sort((a, b) => {
|
|
645
|
+
if (!a.scope)
|
|
646
|
+
return -1;
|
|
647
|
+
if (!b.scope)
|
|
648
|
+
return 1;
|
|
649
|
+
return a.scope.localeCompare(b.scope);
|
|
650
|
+
});
|
|
651
|
+
const highlight = highlightLines(usedTags);
|
|
652
|
+
const body = ordered.map((layer) => renderDiffView(layer.scope, highlight)).join('\n');
|
|
653
|
+
writeFileSync(diffPath, `// Generated by \`boundry diff\` — one focused view per layer with a pending\n` +
|
|
654
|
+
`// change. Derived artifact: regenerate after approve; do not hand-edit.\n` +
|
|
655
|
+
`views {\n${body}\n}\n`);
|
|
656
|
+
return ordered.map((layer) => ({
|
|
657
|
+
id: scopeViewId(layer.scope),
|
|
658
|
+
scope: layer.scope,
|
|
659
|
+
changes: layer.changes,
|
|
660
|
+
}));
|
|
180
661
|
}
|
|
181
662
|
}
|