@principal-ai/subsystems-react 0.51.3 → 0.52.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/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/subsystem/C4Graph.d.ts +3 -7
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +202 -63
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +26 -26
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +56 -30
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts +1 -1
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +20 -1
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +74 -26
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/c4.d.ts +48 -49
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js +50 -0
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/subsystem/c4Evidence.d.ts +88 -84
- package/dist/subsystem/c4Evidence.d.ts.map +1 -1
- package/dist/subsystem/c4Evidence.js +113 -166
- package/dist/subsystem/c4Evidence.js.map +1 -1
- package/dist/subsystem/model.d.ts +6 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +9 -7
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/techIcons/index.d.ts +5 -0
- package/dist/subsystem/techIcons/index.d.ts.map +1 -1
- package/dist/subsystem/techIcons/index.js +3 -0
- package/dist/subsystem/techIcons/index.js.map +1 -1
- package/dist/utils/edgeLabel.d.ts +14 -0
- package/dist/utils/edgeLabel.d.ts.map +1 -1
- package/dist/utils/edgeLabel.js +24 -0
- package/dist/utils/edgeLabel.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +65 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +145 -11
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +8 -6
- package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +198 -0
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +42 -6
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +9 -7
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +686 -2
- package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +21 -1
- package/src/subsystem/C4Graph.tsx +296 -99
- package/src/subsystem/C4NodeCard.test.ts +32 -22
- package/src/subsystem/C4NodeCard.tsx +67 -39
- package/src/subsystem/IssueList.tsx +23 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +79 -23
- package/src/subsystem/c4.protocolColor.test.ts +26 -0
- package/src/subsystem/c4.ts +76 -52
- package/src/subsystem/c4Evidence.test.ts +177 -237
- package/src/subsystem/c4Evidence.ts +160 -235
- package/src/subsystem/model.ts +6 -0
- package/src/subsystem/nodes.group.test.tsx +15 -0
- package/src/subsystem/nodes.tsx +12 -9
- package/src/subsystem/techIcons/index.tsx +8 -0
- package/src/utils/edgeLabel.test.ts +21 -2
- package/src/utils/edgeLabel.ts +28 -0
- package/src/utils/elkLayout.test.ts +50 -0
- package/src/utils/elkLayout.ts +191 -11
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +0 -602
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
nodeSize,
|
|
13
13
|
COMPONENT_SIZE,
|
|
14
14
|
} from './C4NodeCard';
|
|
15
|
-
import type {
|
|
15
|
+
import type { C4Element, C4Container } from './c4';
|
|
16
16
|
|
|
17
17
|
const theme = {
|
|
18
18
|
colors: {
|
|
@@ -25,12 +25,20 @@ const theme = {
|
|
|
25
25
|
} as Parameters<typeof nodeStyle>[1];
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* A
|
|
29
|
-
* element carries a decision — so the card never has to cope with a
|
|
30
|
-
* proposed, and no test pretends otherwise.
|
|
28
|
+
* A container element with overridable fields. `state` is required — every
|
|
29
|
+
* authored element carries a decision — so the card never has to cope with a
|
|
30
|
+
* box nobody proposed, and no test pretends otherwise.
|
|
31
31
|
*/
|
|
32
|
-
function node(over: Partial<
|
|
33
|
-
return {
|
|
32
|
+
function node(over: Partial<C4Container> = {}): C4Element {
|
|
33
|
+
return {
|
|
34
|
+
id: 'n',
|
|
35
|
+
kind: 'container',
|
|
36
|
+
label: 'n',
|
|
37
|
+
containerKind: 'application',
|
|
38
|
+
technology: '',
|
|
39
|
+
state: 'proposed',
|
|
40
|
+
...over,
|
|
41
|
+
};
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
describe('nodeStyle — one axis per channel', () => {
|
|
@@ -39,16 +47,12 @@ describe('nodeStyle — one axis per channel', () => {
|
|
|
39
47
|
expect(nodeStyle(node({ state: 'accepted' }), theme, true).dash).toBe('solid');
|
|
40
48
|
});
|
|
41
49
|
|
|
42
|
-
test('accepted is solid
|
|
43
|
-
|
|
44
|
-
expect(s.dash).toBe('solid');
|
|
45
|
-
expect(s.color).toBe('#4ec9b0');
|
|
50
|
+
test('accepted is solid', () => {
|
|
51
|
+
expect(nodeStyle(node({ state: 'accepted' }), theme).dash).toBe('solid');
|
|
46
52
|
});
|
|
47
53
|
|
|
48
|
-
test('proposed is dashed
|
|
49
|
-
|
|
50
|
-
expect(s.dash).toBe('dashed');
|
|
51
|
-
expect(s.color).toBe('#e8a33a');
|
|
54
|
+
test('proposed is dashed', () => {
|
|
55
|
+
expect(nodeStyle(node({ state: 'proposed' }), theme).dash).toBe('dashed');
|
|
52
56
|
});
|
|
53
57
|
|
|
54
58
|
test('rejected is dotted and muted', () => {
|
|
@@ -67,12 +71,18 @@ describe('nodeStyle — one axis per channel', () => {
|
|
|
67
71
|
}
|
|
68
72
|
});
|
|
69
73
|
|
|
70
|
-
test('
|
|
71
|
-
// Same state,
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
);
|
|
75
|
-
|
|
74
|
+
test('colour follows the technology, not the state', () => {
|
|
75
|
+
// Same kind and state, different tech: different hue.
|
|
76
|
+
const react = nodeStyle(node({ technology: 'React 19', state: 'accepted' }), theme).color;
|
|
77
|
+
const bun = nodeStyle(node({ technology: 'Bun', state: 'accepted' }), theme).color;
|
|
78
|
+
expect(react).not.toBe(bun);
|
|
79
|
+
// State does not move the colour.
|
|
80
|
+
expect(nodeStyle(node({ technology: 'React 19', state: 'proposed' }), theme).color).toBe(react);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('an unknown technology falls back to the muted border', () => {
|
|
84
|
+
expect(nodeStyle(node({ technology: '', state: 'accepted' }), theme).color).toBe('#555');
|
|
85
|
+
expect(nodeStyle(node({ technology: 'COBOL', state: 'accepted' }), theme).color).toBe('#555');
|
|
76
86
|
});
|
|
77
87
|
|
|
78
88
|
test('border weight carries the level, not the state', () => {
|
|
@@ -165,10 +175,10 @@ describe('nodeShape — kind without relying on colour or dash', () => {
|
|
|
165
175
|
expect(nodeShape(node({ kind: 'component' })).kind).toBe('square');
|
|
166
176
|
});
|
|
167
177
|
|
|
168
|
-
test('an external system
|
|
178
|
+
test('an external system has no corner radius', () => {
|
|
169
179
|
const s = nodeShape(node({ kind: 'external-system' }));
|
|
170
180
|
expect(s.kind).toBe('rect');
|
|
171
|
-
expect(s.radius).
|
|
181
|
+
expect(s.radius).toBe(0);
|
|
172
182
|
});
|
|
173
183
|
|
|
174
184
|
test('a person is a pill', () => {
|
|
@@ -16,7 +16,7 @@ import { useTheme } from '@principal-ade/industry-theme';
|
|
|
16
16
|
import { AppWindow, Database } from 'lucide-react';
|
|
17
17
|
import type { LucideIcon } from 'lucide-react';
|
|
18
18
|
import { TechMark, technologyBrand } from './techIcons';
|
|
19
|
-
import type {
|
|
19
|
+
import type { C4Element } from './c4';
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Card box for a container: a wide rectangle. A container carries four things
|
|
@@ -38,7 +38,7 @@ export const COMPONENT_SIZE = 140;
|
|
|
38
38
|
* Per-level box size. The graph lays out against these, so a square component
|
|
39
39
|
* and a rectangular container do not collide at a shared nominal width.
|
|
40
40
|
*/
|
|
41
|
-
export function nodeSize(node:
|
|
41
|
+
export function nodeSize(node: C4Element): { width: number; height: number } {
|
|
42
42
|
return node.kind === 'component'
|
|
43
43
|
? { width: COMPONENT_SIZE, height: COMPONENT_SIZE }
|
|
44
44
|
: { width: NODE_W, height: NODE_H };
|
|
@@ -59,35 +59,50 @@ export interface NodeStyle {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* Border style for one node: colour
|
|
63
|
-
* and level.
|
|
62
|
+
* Border style for one node: colour from **technology**, dash and weight from
|
|
63
|
+
* **confirmation state** and level.
|
|
64
64
|
*
|
|
65
|
-
* One axis per channel, so nothing is said twice
|
|
65
|
+
* One axis per channel, so nothing is said twice:
|
|
66
66
|
*
|
|
67
|
-
* colour
|
|
67
|
+
* colour → technology (the brand hue; see `technologyBrand`)
|
|
68
|
+
* dash → confirmation state (solid accepted, dashed proposed)
|
|
68
69
|
* weight → C4 level, as a grayscale redundancy for the square
|
|
69
70
|
* shape + size → C4 kind (`nodeShape`, `nodeSize`)
|
|
70
71
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* the border says who vouched for it.
|
|
72
|
+
* Colour moved from state to technology: on a container diagram the reader
|
|
73
|
+
* wants "is this Bun or React" at a glance, and the state already reads off the
|
|
74
|
+
* dash. A selected node overrides colour so a click target stands out.
|
|
75
75
|
*/
|
|
76
|
-
|
|
76
|
+
/** The technology string for an element, if its kind has one. */
|
|
77
|
+
export function nodeTechnology(node: C4Element): string | undefined {
|
|
78
|
+
switch (node.kind) {
|
|
79
|
+
case 'container':
|
|
80
|
+
case 'component':
|
|
81
|
+
return node.technology;
|
|
82
|
+
case 'external-system':
|
|
83
|
+
return node.technology;
|
|
84
|
+
case 'person':
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function nodeStyle(node: C4Element, theme: Theme, selected = false): NodeStyle {
|
|
77
90
|
const muted = theme.colors.border ?? '#555';
|
|
91
|
+
const techColor = technologyBrand(nodeTechnology(node))?.color;
|
|
78
92
|
if (selected) return { color: theme.colors.primary ?? '#5aa', dash: 'solid', width: 3 };
|
|
79
93
|
|
|
80
94
|
// Border weight carries the level, so a component stays a component even
|
|
81
95
|
// in grayscale or for a reader who cannot separate the hues.
|
|
82
96
|
const weight = node.kind === 'component' ? 1 : 2;
|
|
97
|
+
const color = techColor ?? muted;
|
|
83
98
|
|
|
84
99
|
switch (node.state) {
|
|
85
100
|
case 'accepted':
|
|
86
|
-
// Solid
|
|
87
|
-
return { color
|
|
101
|
+
// Solid — part of the architecture now.
|
|
102
|
+
return { color, dash: 'solid', width: weight };
|
|
88
103
|
case 'proposed':
|
|
89
104
|
// Dashed — an agent asked; a human has not answered.
|
|
90
|
-
return { color
|
|
105
|
+
return { color, dash: 'dashed', width: weight };
|
|
91
106
|
case 'rejected':
|
|
92
107
|
// Should never be drawn (a rejected element is not projected); belt-and-braces.
|
|
93
108
|
return { color: muted, dash: 'dotted', width: 1 };
|
|
@@ -99,7 +114,7 @@ export function nodeStyle(node: C4Node, theme: Theme, selected = false): NodeSty
|
|
|
99
114
|
* asks every element to carry. No fallback — when nothing is stated, the
|
|
100
115
|
* notation-gap row says so rather than the card inventing a summary.
|
|
101
116
|
*/
|
|
102
|
-
export function nodeSubtitle(node:
|
|
117
|
+
export function nodeSubtitle(node: C4Element): string {
|
|
103
118
|
return node.description ?? '';
|
|
104
119
|
}
|
|
105
120
|
|
|
@@ -109,7 +124,7 @@ export function nodeSubtitle(node: C4Node): string {
|
|
|
109
124
|
* Confirmation state is a separate axis carried by the border (see
|
|
110
125
|
* `nodeStyle`), not by a word on the card.
|
|
111
126
|
*/
|
|
112
|
-
export function nodeTag(node:
|
|
127
|
+
export function nodeTag(node: C4Element): string {
|
|
113
128
|
return node.kind;
|
|
114
129
|
}
|
|
115
130
|
|
|
@@ -119,11 +134,17 @@ export function nodeTag(node: C4Node): string {
|
|
|
119
134
|
* The C4 kind itself does not need a glyph — the box shape already carries it
|
|
120
135
|
* (square vs rectangle vs pill), and the row's one text slot is worth more
|
|
121
136
|
* spent on the technology. Only containers have a C4-recognised split, so the
|
|
122
|
-
* other
|
|
137
|
+
* other kinds return nothing.
|
|
123
138
|
*/
|
|
124
|
-
export function nodeKindIcon(node:
|
|
125
|
-
|
|
126
|
-
|
|
139
|
+
export function nodeKindIcon(node: C4Element): LucideIcon | undefined {
|
|
140
|
+
switch (node.kind) {
|
|
141
|
+
case 'container':
|
|
142
|
+
return node.containerKind === 'data-store' ? Database : AppWindow;
|
|
143
|
+
case 'component':
|
|
144
|
+
case 'external-system':
|
|
145
|
+
case 'person':
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
127
148
|
}
|
|
128
149
|
|
|
129
150
|
/**
|
|
@@ -131,9 +152,17 @@ export function nodeKindIcon(node: C4Node): LucideIcon | undefined {
|
|
|
131
152
|
* Postgres…). Falls back to the container kind / C4 kind when no technology is
|
|
132
153
|
* stated, so the row is never blank.
|
|
133
154
|
*/
|
|
134
|
-
export function nodeTopLabel(node:
|
|
135
|
-
|
|
136
|
-
|
|
155
|
+
export function nodeTopLabel(node: C4Element): string {
|
|
156
|
+
const technology = nodeTechnology(node);
|
|
157
|
+
if (technology) return technology;
|
|
158
|
+
switch (node.kind) {
|
|
159
|
+
case 'container':
|
|
160
|
+
return node.containerKind;
|
|
161
|
+
case 'component':
|
|
162
|
+
case 'external-system':
|
|
163
|
+
case 'person':
|
|
164
|
+
return node.kind;
|
|
165
|
+
}
|
|
137
166
|
}
|
|
138
167
|
|
|
139
168
|
/** How a box is drawn. Shape carries the C4 kind, independent of colour. */
|
|
@@ -151,26 +180,24 @@ export interface NodeShape {
|
|
|
151
180
|
*
|
|
152
181
|
* The convention:
|
|
153
182
|
*
|
|
154
|
-
* container wide rectangle,
|
|
183
|
+
* container wide rectangle, slight radius — a runtime boundary
|
|
155
184
|
* component square — a part inside one (see `nodeSize`)
|
|
156
|
-
* external system wide rectangle,
|
|
185
|
+
* external system wide rectangle, no radius — someone else's system
|
|
157
186
|
* person pill — not a box at all
|
|
158
187
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* already spent on proposed-vs-accepted. The kind icon says it, and the frame
|
|
163
|
-
* position says it structurally.
|
|
188
|
+
* A container keeps a slight radius; an external system is drawn with none, so
|
|
189
|
+
* the two are distinguishable by corner treatment as well as by the kind icon
|
|
190
|
+
* and frame position. The border dash stays spent on proposed-vs-accepted.
|
|
164
191
|
*/
|
|
165
|
-
export function nodeShape(node:
|
|
192
|
+
export function nodeShape(node: C4Element): NodeShape {
|
|
166
193
|
switch (node.kind) {
|
|
167
194
|
case 'person':
|
|
168
195
|
return { kind: 'pill', radius: NODE_H / 2 };
|
|
169
196
|
case 'external-system':
|
|
170
|
-
return { kind: 'rect', radius:
|
|
197
|
+
return { kind: 'rect', radius: 0 };
|
|
171
198
|
case 'component':
|
|
172
199
|
return { kind: 'square', radius: 6 };
|
|
173
|
-
|
|
200
|
+
case 'container':
|
|
174
201
|
return { kind: 'rect', radius: 4 };
|
|
175
202
|
}
|
|
176
203
|
}
|
|
@@ -184,7 +211,7 @@ export function nodeShape(node: C4Node): NodeShape {
|
|
|
184
211
|
* component drawn without a parent already reports `unknown_container`, and
|
|
185
212
|
* that is the question worth asking first.
|
|
186
213
|
*/
|
|
187
|
-
export function nodeMissing(node:
|
|
214
|
+
export function nodeMissing(node: C4Element): string[] {
|
|
188
215
|
if (node.kind !== 'container') return [];
|
|
189
216
|
const missing: string[] = [];
|
|
190
217
|
if (!node.technology) missing.push('technology');
|
|
@@ -193,7 +220,7 @@ export function nodeMissing(node: C4Node): string[] {
|
|
|
193
220
|
}
|
|
194
221
|
|
|
195
222
|
export interface C4NodeCardProps {
|
|
196
|
-
node:
|
|
223
|
+
node: C4Element;
|
|
197
224
|
selected?: boolean;
|
|
198
225
|
/** Draw React Flow's edge handles. Off when rendering a bare gallery. */
|
|
199
226
|
handles?: React.ReactNode;
|
|
@@ -206,7 +233,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
|
|
|
206
233
|
const style = nodeStyle(node, theme, selected);
|
|
207
234
|
const shape = nodeShape(node);
|
|
208
235
|
const Icon = nodeKindIcon(node);
|
|
209
|
-
const brand = technologyBrand(node
|
|
236
|
+
const brand = technologyBrand(nodeTechnology(node));
|
|
210
237
|
const topLabel = nodeTopLabel(node);
|
|
211
238
|
const subtitle = nodeSubtitle(node);
|
|
212
239
|
|
|
@@ -232,7 +259,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
|
|
|
232
259
|
padding: centered ? '10px 30px' : '10px 12px',
|
|
233
260
|
borderRadius: shape.radius,
|
|
234
261
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
235
|
-
border: `${style.width} ${style.dash} ${style.color}`,
|
|
262
|
+
border: `${style.width}px ${style.dash} ${style.color}`,
|
|
236
263
|
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
237
264
|
cursor: onClick ? 'pointer' : 'default',
|
|
238
265
|
fontFamily: theme.fonts.body,
|
|
@@ -272,8 +299,8 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
|
|
|
272
299
|
</span>
|
|
273
300
|
{Icon && (
|
|
274
301
|
<span
|
|
275
|
-
title={node.containerKind
|
|
276
|
-
aria-label={node.containerKind
|
|
302
|
+
title={node.kind === 'container' ? node.containerKind : node.kind}
|
|
303
|
+
aria-label={node.kind === 'container' ? node.containerKind : node.kind}
|
|
277
304
|
style={{ display: 'inline-flex', flexShrink: 0, color: style.color }}
|
|
278
305
|
>
|
|
279
306
|
<Icon size={13} strokeWidth={2.25} />
|
|
@@ -286,6 +313,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
|
|
|
286
313
|
below sits at the same y on every card. */}
|
|
287
314
|
<span
|
|
288
315
|
style={{
|
|
316
|
+
fontFamily: theme.fonts.body,
|
|
289
317
|
fontWeight: 600,
|
|
290
318
|
fontSize: theme.fontSizes[2],
|
|
291
319
|
color: theme.colors.text,
|
|
@@ -17,7 +17,10 @@ import {
|
|
|
17
17
|
ChevronDown,
|
|
18
18
|
ChevronRight,
|
|
19
19
|
CircleCheck,
|
|
20
|
+
CircleDashed,
|
|
21
|
+
CircleEllipsis,
|
|
20
22
|
CircleHelp,
|
|
23
|
+
CircleX,
|
|
21
24
|
Component,
|
|
22
25
|
FileX,
|
|
23
26
|
FolderGit2,
|
|
@@ -41,6 +44,7 @@ export type SubsystemIssueSeverity = 'error' | 'info';
|
|
|
41
44
|
export type SubsystemIssueTargetKind =
|
|
42
45
|
| 'component'
|
|
43
46
|
| 'module'
|
|
47
|
+
| 'process'
|
|
44
48
|
| 'trail'
|
|
45
49
|
| 'step'
|
|
46
50
|
| 'repo'
|
|
@@ -105,6 +109,12 @@ const KIND_CATEGORY: Record<string, SubsystemIssueCategory> = {
|
|
|
105
109
|
graphify_unavailable: 'repo',
|
|
106
110
|
boundary_module_file_mismatch: 'static-topology',
|
|
107
111
|
boundary_process_nest_disagree: 'dynamic-topology',
|
|
112
|
+
// C4 process verification (verifyProcessBoundaries): a boundary whose
|
|
113
|
+
// container claim is not an accepted one. `verified` reports nothing —
|
|
114
|
+
// the audit reports absence only.
|
|
115
|
+
boundary_process_unassigned: 'dynamic-topology',
|
|
116
|
+
boundary_process_proposed: 'dynamic-topology',
|
|
117
|
+
boundary_process_rejected: 'dynamic-topology',
|
|
108
118
|
trail: 'trail',
|
|
109
119
|
step_unconfirmed: 'trail',
|
|
110
120
|
step_stale: 'trail',
|
|
@@ -168,6 +178,11 @@ export const ISSUE_KIND_ICON: Record<string, LucideIcon> = {
|
|
|
168
178
|
// One module, more than one claimed process parent — a containment that can't
|
|
169
179
|
// be drawn, which is why the region sits at the root instead of nested.
|
|
170
180
|
boundary_process_nest_disagree: Split,
|
|
181
|
+
// C4 process verification: unclaimed = no container at all, proposed = an
|
|
182
|
+
// agent scaffold awaits a decision, rejected = a container was declined.
|
|
183
|
+
boundary_process_unassigned: CircleEllipsis,
|
|
184
|
+
boundary_process_proposed: CircleDashed,
|
|
185
|
+
boundary_process_rejected: CircleX,
|
|
171
186
|
// Trail step verification: unconfirmed = no augmentation yet, stale = code changed
|
|
172
187
|
step_unconfirmed: CircleHelp,
|
|
173
188
|
step_stale: History,
|
|
@@ -250,6 +265,11 @@ const KIND_LABEL: Record<string, string> = {
|
|
|
250
265
|
graphify_unavailable: 'Graphify cache unavailable',
|
|
251
266
|
boundary_module_file_mismatch: 'Declaration file outside module',
|
|
252
267
|
boundary_process_nest_disagree: 'Module spans multiple process contexts',
|
|
268
|
+
// C4 process verification (labels read as boundary states, since the layer
|
|
269
|
+
// scopes them to process frames).
|
|
270
|
+
boundary_process_unassigned: 'Process boundary unclaimed',
|
|
271
|
+
boundary_process_proposed: 'Container awaiting decision',
|
|
272
|
+
boundary_process_rejected: 'Container rejected',
|
|
253
273
|
trail: 'Step issue',
|
|
254
274
|
step_unconfirmed: 'Call site not verified',
|
|
255
275
|
step_stale: 'Call site changed',
|
|
@@ -284,6 +304,9 @@ const KIND_ORDER: Record<string, number> = {
|
|
|
284
304
|
boundary_module_file_mismatch: 23,
|
|
285
305
|
// Runtime topology — process containment
|
|
286
306
|
boundary_process_nest_disagree: 30,
|
|
307
|
+
boundary_process_unassigned: 31,
|
|
308
|
+
boundary_process_proposed: 32,
|
|
309
|
+
boundary_process_rejected: 33,
|
|
287
310
|
// Trail — mechanical issues first, then step verification
|
|
288
311
|
trail: 40,
|
|
289
312
|
step_unconfirmed: 41,
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
deriveGraphEdges,
|
|
40
40
|
isConstructsOnlyModel,
|
|
41
41
|
moduleGroupNodeId,
|
|
42
|
+
processGroupNodeId,
|
|
42
43
|
isTrailMechanism,
|
|
43
44
|
edgeColor,
|
|
44
45
|
MECHANISM_DESCRIPTIONS,
|
|
@@ -1099,16 +1100,24 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1099
1100
|
[components],
|
|
1100
1101
|
);
|
|
1101
1102
|
|
|
1102
|
-
// A `module` issue target names a boundary frame, so focusing
|
|
1103
|
-
// that region on the canvas. Null when the target names no
|
|
1104
|
-
// dropped from the layout, or a region key nothing
|
|
1105
|
-
|
|
1103
|
+
// A `module` / `process` issue target names a boundary frame, so focusing
|
|
1104
|
+
// the card frames that region on the canvas. Null when the target names no
|
|
1105
|
+
// frame (a singleton dropped from the layout, or a region key nothing
|
|
1106
|
+
// declares).
|
|
1107
|
+
const issueFrameNodeId = useCallback(
|
|
1106
1108
|
(issue: SubsystemIssue): string | null => {
|
|
1107
1109
|
const target = issue.target;
|
|
1108
|
-
if (target?.kind
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1110
|
+
if (target?.kind === 'module') {
|
|
1111
|
+
const key = target.id ?? target.label;
|
|
1112
|
+
if (!components.some((c) => c.module?.trim() === key)) return null;
|
|
1113
|
+
return moduleGroupNodeId(key);
|
|
1114
|
+
}
|
|
1115
|
+
if (target?.kind === 'process') {
|
|
1116
|
+
const key = target.id ?? target.label;
|
|
1117
|
+
if (!components.some((c) => c.process === key)) return null;
|
|
1118
|
+
return processGroupNodeId(key);
|
|
1119
|
+
}
|
|
1120
|
+
return null;
|
|
1112
1121
|
},
|
|
1113
1122
|
[components],
|
|
1114
1123
|
);
|
|
@@ -1159,7 +1168,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1159
1168
|
}, [issues, issueComponent]);
|
|
1160
1169
|
|
|
1161
1170
|
// Per-frame diagnostics badge: the same fold, but for findings about a
|
|
1162
|
-
// BOUNDARY rather than a construct. Keyed by the region's React Flow node
|
|
1171
|
+
// BOUNDARY rather than a construct. Keyed by the region's React Flow node
|
|
1172
|
+
// id — a `module` target names a module frame, a `process` target a process
|
|
1173
|
+
// frame (dynamic-topology findings, e.g. C4 process verification).
|
|
1163
1174
|
// Only kinds with a dedicated frame icon qualify (`ISSUE_KIND_ICON`) — a
|
|
1164
1175
|
// finding with no icon of its own has nothing to badge the frame with, and
|
|
1165
1176
|
// borrowing its layer's icon would imply the frame is at fault for it.
|
|
@@ -1168,8 +1179,12 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1168
1179
|
if (!issues?.length) return map;
|
|
1169
1180
|
for (const issue of issues) {
|
|
1170
1181
|
const target = issue.target;
|
|
1171
|
-
if (target
|
|
1172
|
-
|
|
1182
|
+
if (!target || !ISSUE_KIND_ICON[issue.kind]) continue;
|
|
1183
|
+
if (target.kind !== 'module' && target.kind !== 'process') continue;
|
|
1184
|
+
const id =
|
|
1185
|
+
target.kind === 'module'
|
|
1186
|
+
? moduleGroupNodeId(target.id ?? target.label)
|
|
1187
|
+
: processGroupNodeId(target.id ?? target.label);
|
|
1173
1188
|
const prev = map.get(id);
|
|
1174
1189
|
map.set(id, {
|
|
1175
1190
|
severity:
|
|
@@ -1211,6 +1226,22 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1211
1226
|
return map;
|
|
1212
1227
|
}, [issues, trails, issueStep]);
|
|
1213
1228
|
|
|
1229
|
+
// Edges backed by a step that ITSELF declares `proposed` — a planned seam
|
|
1230
|
+
// between otherwise-real components. Distinct from a proposed ENDPOINT
|
|
1231
|
+
// (the node is the placeholder there, and draws its own dashed border):
|
|
1232
|
+
// here the seam is the claim that is not real yet, so the edge label draws
|
|
1233
|
+
// dashed. Proposed steps are never verified, so this never competes with
|
|
1234
|
+
// the step-issue badge.
|
|
1235
|
+
const proposedEdgeIds = useMemo(() => {
|
|
1236
|
+
const set = new Set<string>();
|
|
1237
|
+
for (const trail of trails ?? []) {
|
|
1238
|
+
for (const step of trail.steps) {
|
|
1239
|
+
if (step.proposed) set.add(trailStepGraphEdgeId(step));
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
return set;
|
|
1243
|
+
}, [trails]);
|
|
1244
|
+
|
|
1214
1245
|
// Expanded verification layers in the diagnostics list. When any layer with
|
|
1215
1246
|
// findings is expanded, the canvas dims everything that layer does not
|
|
1216
1247
|
// implicate (see `issueFocus`). The list publishes this through
|
|
@@ -1251,6 +1282,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1251
1282
|
// target:
|
|
1252
1283
|
// component → that node
|
|
1253
1284
|
// module → every component in that module
|
|
1285
|
+
// process → every component with that `process` key
|
|
1254
1286
|
// trail → the flow's nodes + its step edges
|
|
1255
1287
|
// repo → every component of that repo
|
|
1256
1288
|
// graph → whole-graph finding; implicates nothing specific
|
|
@@ -1282,6 +1314,11 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1282
1314
|
nodeIds.add(c.alias);
|
|
1283
1315
|
}
|
|
1284
1316
|
}
|
|
1317
|
+
} else if (t.kind === 'process') {
|
|
1318
|
+
const key = t.id ?? t.label;
|
|
1319
|
+
for (const c of components) {
|
|
1320
|
+
if (c.process === key) nodeIds.add(c.alias);
|
|
1321
|
+
}
|
|
1285
1322
|
} else if (t.kind === 'trail') {
|
|
1286
1323
|
const wt = (trails ?? []).find(
|
|
1287
1324
|
(w) => w.id === t.id || w.title === t.label,
|
|
@@ -1472,7 +1509,8 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1472
1509
|
// Host override wins over the library's derived frame color.
|
|
1473
1510
|
const color = region?.key != null ? boundaryColors?.[region.key] : undefined;
|
|
1474
1511
|
// Boundary findings badge the FRAME, not a member — a region's shape is
|
|
1475
|
-
// the fault, so no leaf construct carries it.
|
|
1512
|
+
// the fault, so no leaf construct carries it. Process verification
|
|
1513
|
+
// findings ride the same path: their target kind is `process`.
|
|
1476
1514
|
const regionIssue = regionIssueByNodeId.get(n.id);
|
|
1477
1515
|
return {
|
|
1478
1516
|
...n,
|
|
@@ -1530,6 +1568,17 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1530
1568
|
() => convertedEdges.map((e) => e.id).sort().join(','),
|
|
1531
1569
|
[convertedEdges],
|
|
1532
1570
|
);
|
|
1571
|
+
// Stamp `proposedStep` onto edge data so the SVG edge itself (not just its
|
|
1572
|
+
// label) draws dashed when a backing trail step declares itself proposed.
|
|
1573
|
+
const proposedStampedEdges = useMemo(
|
|
1574
|
+
() =>
|
|
1575
|
+
baseEdges.map((e) =>
|
|
1576
|
+
proposedEdgeIds.has(e.id)
|
|
1577
|
+
? { ...e, data: { ...(e.data as object), proposedStep: true } }
|
|
1578
|
+
: e,
|
|
1579
|
+
),
|
|
1580
|
+
[baseEdges, proposedEdgeIds],
|
|
1581
|
+
);
|
|
1533
1582
|
|
|
1534
1583
|
const dispEdges = useMemo(() => {
|
|
1535
1584
|
const paint = (e: Edge, dimmed: boolean): Edge => {
|
|
@@ -1558,7 +1607,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1558
1607
|
return isTrailMechanism(mechanism);
|
|
1559
1608
|
};
|
|
1560
1609
|
if (openedEdgeIds || focusEdgeIds || previewEdgeIds || issueFocusEdgeIds) {
|
|
1561
|
-
return
|
|
1610
|
+
return proposedStampedEdges.map((e) => {
|
|
1562
1611
|
const vis = flowElementVisibility({
|
|
1563
1612
|
inOpened: openedEdgeIds?.has(e.id) === true,
|
|
1564
1613
|
inSelected: focusEdgeIds?.has(e.id) === true,
|
|
@@ -1572,13 +1621,13 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1572
1621
|
});
|
|
1573
1622
|
}
|
|
1574
1623
|
if (selectedEdgeId) {
|
|
1575
|
-
return
|
|
1624
|
+
return proposedStampedEdges.map((e) => ({
|
|
1576
1625
|
...paint(e, e.id !== selectedEdgeId),
|
|
1577
1626
|
hidden: !edgeInView(e),
|
|
1578
1627
|
}));
|
|
1579
1628
|
}
|
|
1580
|
-
return
|
|
1581
|
-
}, [
|
|
1629
|
+
return proposedStampedEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
|
|
1630
|
+
}, [proposedStampedEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
|
|
1582
1631
|
|
|
1583
1632
|
const onNodesChange = useCallback(
|
|
1584
1633
|
(changes: NodeChange[]) => {
|
|
@@ -2349,7 +2398,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2349
2398
|
} else {
|
|
2350
2399
|
// Nothing selectable — but a module target names a boundary frame, so
|
|
2351
2400
|
// frame that region.
|
|
2352
|
-
const moduleNodeId =
|
|
2401
|
+
const moduleNodeId = issueFrameNodeId(issue);
|
|
2353
2402
|
if (moduleNodeId) {
|
|
2354
2403
|
setSelected(null);
|
|
2355
2404
|
setSelectedEdgeId(null);
|
|
@@ -2367,7 +2416,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2367
2416
|
[
|
|
2368
2417
|
issueComponent,
|
|
2369
2418
|
issueStep,
|
|
2370
|
-
|
|
2419
|
+
issueFrameNodeId,
|
|
2371
2420
|
focusTrailStep,
|
|
2372
2421
|
onSelect,
|
|
2373
2422
|
onSelectIssue,
|
|
@@ -2391,7 +2440,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2391
2440
|
setSelectedEdgeId(null);
|
|
2392
2441
|
} else {
|
|
2393
2442
|
const step = issueStep(issue);
|
|
2394
|
-
const moduleNodeId = step ? null :
|
|
2443
|
+
const moduleNodeId = step ? null : issueFrameNodeId(issue);
|
|
2395
2444
|
if (step) {
|
|
2396
2445
|
// Only unwind a step focus this card still owns. A dim-mode graph
|
|
2397
2446
|
// keeps its step focus in hover state instead (owned by the pointer,
|
|
@@ -2418,7 +2467,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2418
2467
|
fitView({ padding: 0.1, duration: 300, minZoom: 0.05, maxZoom: 2 });
|
|
2419
2468
|
});
|
|
2420
2469
|
},
|
|
2421
|
-
[issueComponent, issueStep,
|
|
2470
|
+
[issueComponent, issueStep, issueFrameNodeId, fitView],
|
|
2422
2471
|
);
|
|
2423
2472
|
|
|
2424
2473
|
// Construct tokens inside a trail snippet. A step's line is an edge
|
|
@@ -2495,6 +2544,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2495
2544
|
pathLength?: number;
|
|
2496
2545
|
} | undefined;
|
|
2497
2546
|
const stepIssue = stepIssueBadgeByEdgeId.get(e.id);
|
|
2547
|
+
const proposed = proposedEdgeIds.has(e.id);
|
|
2498
2548
|
return {
|
|
2499
2549
|
id: e.id,
|
|
2500
2550
|
mechanism: d?.mechanism ?? 'uses',
|
|
@@ -2505,10 +2555,11 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2505
2555
|
pathLength: d?.pathLength ?? 0,
|
|
2506
2556
|
stepNos: selectedFlowStepNos?.get(e.id),
|
|
2507
2557
|
stepIssue,
|
|
2558
|
+
proposed,
|
|
2508
2559
|
};
|
|
2509
2560
|
});
|
|
2510
2561
|
return labels;
|
|
2511
|
-
}, [dispEdges, selectedFlowStepNos, stepIssueBadgeByEdgeId]);
|
|
2562
|
+
}, [dispEdges, selectedFlowStepNos, stepIssueBadgeByEdgeId, proposedEdgeIds]);
|
|
2512
2563
|
|
|
2513
2564
|
// Unique source files across components → sidebar file trees.
|
|
2514
2565
|
const treeFilePaths = treeFiles;
|
|
@@ -2935,7 +2986,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2935
2986
|
crisp rounded box; soft / ambiguous ones (uses, feeds,
|
|
2936
2987
|
watches …) get a real lobed cloud silhouette — ambiguous, not
|
|
2937
2988
|
a dashed proposal (or a pill, which border-radius can only
|
|
2938
|
-
ever make).
|
|
2989
|
+
ever make). A step that ITSELF declares `proposed` dashes the
|
|
2990
|
+
silhouette — the seam is planned, distinct from a proposed
|
|
2991
|
+
ENDPOINT, which dashes the node instead. */}
|
|
2939
2992
|
{verifiable ? (
|
|
2940
2993
|
<span
|
|
2941
2994
|
aria-hidden
|
|
@@ -2943,7 +2996,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2943
2996
|
position: 'absolute',
|
|
2944
2997
|
inset: 0,
|
|
2945
2998
|
borderRadius: 4,
|
|
2946
|
-
border:
|
|
2999
|
+
border: lbl.proposed
|
|
3000
|
+
? `1.5px dashed ${color}`
|
|
3001
|
+
: `0.5px solid ${color}`,
|
|
2947
3002
|
background:
|
|
2948
3003
|
theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
2949
3004
|
pointerEvents: 'none',
|
|
@@ -2968,6 +3023,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2968
3023
|
stroke={hexWithAlpha(color, 0.75)}
|
|
2969
3024
|
strokeWidth={1.2}
|
|
2970
3025
|
strokeLinejoin="round"
|
|
3026
|
+
strokeDasharray={lbl.proposed ? '4 3' : undefined}
|
|
2971
3027
|
/>
|
|
2972
3028
|
</svg>
|
|
2973
3029
|
)}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { PROTOCOL_COLOR, PROTOCOL_COLOR_FALLBACK, protocolColor } from './c4';
|
|
3
|
+
|
|
4
|
+
describe('protocolColor', () => {
|
|
5
|
+
test('maps a known protocol to its hue', () => {
|
|
6
|
+
expect(protocolColor('HTTP')).toBe(PROTOCOL_COLOR.http);
|
|
7
|
+
expect(protocolColor('RPC')).toBe(PROTOCOL_COLOR.rpc);
|
|
8
|
+
expect(protocolColor('file I/O')).toBe(PROTOCOL_COLOR['file i/o']);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('matches a token inside a longer protocol string', () => {
|
|
12
|
+
expect(protocolColor('Electrobun RPC')).toBe(PROTOCOL_COLOR.rpc);
|
|
13
|
+
expect(protocolColor('HTTPS')).toBe(PROTOCOL_COLOR.https);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('prefers the longest matching token', () => {
|
|
17
|
+
// `JSON-RPC` must find `json-rpc`, not the shorter `rpc`.
|
|
18
|
+
expect(protocolColor('JSON-RPC')).toBe(PROTOCOL_COLOR['json-rpc']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('falls back for an unknown or missing protocol', () => {
|
|
22
|
+
expect(protocolColor('carrier pigeon')).toBe(PROTOCOL_COLOR_FALLBACK);
|
|
23
|
+
expect(protocolColor(undefined)).toBe(PROTOCOL_COLOR_FALLBACK);
|
|
24
|
+
expect(protocolColor('')).toBe(PROTOCOL_COLOR_FALLBACK);
|
|
25
|
+
});
|
|
26
|
+
});
|