@principal-ai/subsystems-react 0.51.1 → 0.51.2
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/subsystem/C4NodeCard.d.ts +19 -4
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +49 -14
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +11 -2
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +72 -3
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/c4.d.ts +7 -6
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/subsystem/c4Evidence.d.ts +1 -1
- package/dist/subsystem/c4Evidence.js +1 -1
- package/package.json +3 -3
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +7 -4
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +168 -2
- package/src/subsystem/C4NodeCard.test.ts +47 -10
- package/src/subsystem/C4NodeCard.tsx +66 -15
- package/src/subsystem/IssueList.tsx +12 -1
- package/src/subsystem/SubsystemComponentGraph.tsx +85 -2
- package/src/subsystem/c4.ts +7 -6
- package/src/subsystem/c4Evidence.test.ts +4 -4
- package/src/subsystem/c4Evidence.ts +1 -1
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
nodeStyle,
|
|
7
7
|
nodeSubtitle,
|
|
8
8
|
nodeTag,
|
|
9
|
+
nodeTypeLabel,
|
|
9
10
|
nodeShape,
|
|
10
11
|
nodeSize,
|
|
11
12
|
nodeMissing,
|
|
@@ -147,13 +148,13 @@ const VARIANTS: Array<{ group: string; note: string; node: C4Node }> = [
|
|
|
147
148
|
},
|
|
148
149
|
{
|
|
149
150
|
group: '',
|
|
150
|
-
note: '',
|
|
151
|
+
note: 'A queue or topic is a data store, not its own kind. C4 says an individual queue is a bucket of messages — a data store — and that the message bus itself is not a container at all. The queue-ness lives in the label and technology.',
|
|
151
152
|
node: node({
|
|
152
153
|
id: 'b2',
|
|
153
154
|
label: 'Audit correction queue',
|
|
154
155
|
members: ['s2'],
|
|
155
156
|
constructs: ['store'],
|
|
156
|
-
containerKind: '
|
|
157
|
+
containerKind: 'data-store',
|
|
157
158
|
technology: 'agent session transport',
|
|
158
159
|
description: 'Proposals awaiting a human decision.',
|
|
159
160
|
state: 'accepted',
|
|
@@ -333,7 +334,7 @@ function Gallery() {
|
|
|
333
334
|
|
|
334
335
|
<div style={{ fontFamily: 'monospace', fontSize: 11, marginBottom: 26, lineHeight: 1.9 }}>
|
|
335
336
|
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
|
|
336
|
-
<span style={{ color: '#e8e8ea' }}>KIND — shape and border weight</span>
|
|
337
|
+
<span style={{ color: '#e8e8ea' }}>KIND — shape, icon, and border weight</span>
|
|
337
338
|
</div>
|
|
338
339
|
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap', marginTop: 4 }}>
|
|
339
340
|
{KINDS.map((kind) => {
|
|
@@ -384,7 +385,9 @@ function Gallery() {
|
|
|
384
385
|
</div>
|
|
385
386
|
)}
|
|
386
387
|
<Case
|
|
387
|
-
caption={`${v.node.id} · "${nodeTag(v.node)}"
|
|
388
|
+
caption={`${v.node.id} · "${nodeTag(v.node)}"${
|
|
389
|
+
nodeTypeLabel(v.node) ? ` / ${nodeTypeLabel(v.node)}` : ''
|
|
390
|
+
} · ${v.node.state} · ${nodeShape(v.node).kind} ${
|
|
388
391
|
nodeSize(v.node).width
|
|
389
392
|
}×${nodeSize(v.node).height} · ${nodeSubtitle(v.node)}${
|
|
390
393
|
nodeMissing(v.node).length ? ` · missing ${nodeMissing(v.node).join('+')}` : ''
|
|
@@ -61,8 +61,34 @@ const ISSUES: SubsystemIssue[] = [
|
|
|
61
61
|
message: 'Step 2 references a line that moved (was :42).',
|
|
62
62
|
target: {
|
|
63
63
|
kind: 'step',
|
|
64
|
-
id: '
|
|
65
|
-
label: '
|
|
64
|
+
id: 'story-steps',
|
|
65
|
+
label: 'Story steps',
|
|
66
|
+
detail: 'step 2',
|
|
67
|
+
stepIndex: 1,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'i20',
|
|
72
|
+
severity: 'info',
|
|
73
|
+
kind: 'step_unconfirmed',
|
|
74
|
+
message: 'Trail "Story steps" step 1: call site not verified (entry → store via writes)',
|
|
75
|
+
target: {
|
|
76
|
+
kind: 'step',
|
|
77
|
+
id: 'story-steps',
|
|
78
|
+
label: 'Story steps',
|
|
79
|
+
detail: 'step 1',
|
|
80
|
+
stepIndex: 0,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'i21',
|
|
85
|
+
severity: 'info',
|
|
86
|
+
kind: 'step_stale',
|
|
87
|
+
message: 'Trail "Story steps" step 2: call site changed since verification (lines 42-44 in src/checkout/api.ts)',
|
|
88
|
+
target: {
|
|
89
|
+
kind: 'step',
|
|
90
|
+
id: 'story-steps',
|
|
91
|
+
label: 'Story steps',
|
|
66
92
|
detail: 'step 2',
|
|
67
93
|
stepIndex: 1,
|
|
68
94
|
},
|
|
@@ -181,3 +207,143 @@ export const GraphWithIssues: Story = {
|
|
|
181
207
|
</div>
|
|
182
208
|
),
|
|
183
209
|
};
|
|
210
|
+
|
|
211
|
+
/** Trail step verification findings — unconfirmed and stale call sites. */
|
|
212
|
+
const TRAIL_STEP_ISSUES: SubsystemIssue[] = [
|
|
213
|
+
{
|
|
214
|
+
id: 'ts1',
|
|
215
|
+
severity: 'info',
|
|
216
|
+
kind: 'step_unconfirmed',
|
|
217
|
+
message: 'Trail "Add to Cart" step 1: call site not verified (addToCart → cartStore via writes)',
|
|
218
|
+
target: {
|
|
219
|
+
kind: 'step',
|
|
220
|
+
id: 'add-to-cart',
|
|
221
|
+
label: 'Add to Cart',
|
|
222
|
+
detail: 'step 1',
|
|
223
|
+
stepIndex: 0,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
id: 'ts2',
|
|
228
|
+
severity: 'info',
|
|
229
|
+
kind: 'step_unconfirmed',
|
|
230
|
+
message: 'Trail "Add to Cart" step 2: call site not verified (cartStore → inventoryService via calls)',
|
|
231
|
+
target: {
|
|
232
|
+
kind: 'step',
|
|
233
|
+
id: 'add-to-cart',
|
|
234
|
+
label: 'Add to Cart',
|
|
235
|
+
detail: 'step 2',
|
|
236
|
+
stepIndex: 1,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: 'ts3',
|
|
241
|
+
severity: 'info',
|
|
242
|
+
kind: 'step_stale',
|
|
243
|
+
message: 'Trail "Checkout" step 1: call site changed since verification (lines 42-44 in src/checkout/api.ts)',
|
|
244
|
+
target: {
|
|
245
|
+
kind: 'step',
|
|
246
|
+
id: 'checkout',
|
|
247
|
+
label: 'Checkout',
|
|
248
|
+
detail: 'step 1',
|
|
249
|
+
stepIndex: 0,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: 'ts4',
|
|
254
|
+
severity: 'info',
|
|
255
|
+
kind: 'step_stale',
|
|
256
|
+
message: 'Trail "Checkout" step 3: call site changed since verification (lines 78-82 in src/checkout/api.ts)',
|
|
257
|
+
target: {
|
|
258
|
+
kind: 'step',
|
|
259
|
+
id: 'checkout',
|
|
260
|
+
label: 'Checkout',
|
|
261
|
+
detail: 'step 3',
|
|
262
|
+
stepIndex: 2,
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
/** Trail step verification: shows unconfirmed and stale call site findings. */
|
|
268
|
+
export const TrailStepVerification: Story = {
|
|
269
|
+
render: () => (
|
|
270
|
+
<div style={{ display: 'flex', flexDirection: 'column', width: 380, height: '100vh', borderRight: '1px solid #333' }}>
|
|
271
|
+
<SubsystemIssueList
|
|
272
|
+
issues={TRAIL_STEP_ISSUES}
|
|
273
|
+
focusCategory="trail"
|
|
274
|
+
onSelectIssue={() => {}}
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
),
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** Trail step verification badges on graph edges. */
|
|
281
|
+
const stepVerificationComponents = components([
|
|
282
|
+
['api', 'checkoutApi', 'function', 'src/checkout/api.ts', 'pkg:github/you/your-app', 'Handles cart requests.', 'checkoutApi'],
|
|
283
|
+
['store', 'cartStore', 'store', 'src/checkout/cartStore.ts', 'pkg:github/you/your-app', 'Retained cart state.', 'cartStore'],
|
|
284
|
+
['payment', 'PaymentService', 'class', 'src/payment/service.ts', 'pkg:github/you/your-app', 'Processes payments.', 'PaymentService'],
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
const stepVerificationTrails = [
|
|
288
|
+
{
|
|
289
|
+
id: 'checkout-flow',
|
|
290
|
+
title: 'Checkout',
|
|
291
|
+
steps: [
|
|
292
|
+
{ from: 'api', to: 'store', mechanism: 'writes' as const },
|
|
293
|
+
{ from: 'store', to: 'payment', mechanism: 'calls' as const },
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
const stepVerificationIssues: SubsystemIssue[] = [
|
|
299
|
+
{
|
|
300
|
+
id: 'sv1',
|
|
301
|
+
severity: 'info',
|
|
302
|
+
kind: 'step_unconfirmed',
|
|
303
|
+
message: 'Trail "Checkout" step 1: call site not verified (checkoutApi → cartStore via writes)',
|
|
304
|
+
target: {
|
|
305
|
+
kind: 'step',
|
|
306
|
+
id: 'checkout-flow',
|
|
307
|
+
label: 'Checkout',
|
|
308
|
+
detail: 'step 1',
|
|
309
|
+
stepIndex: 0,
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: 'sv2',
|
|
314
|
+
severity: 'info',
|
|
315
|
+
kind: 'step_stale',
|
|
316
|
+
message: 'Trail "Checkout" step 2: call site changed since verification (lines 42-44 in src/checkout/api.ts)',
|
|
317
|
+
target: {
|
|
318
|
+
kind: 'step',
|
|
319
|
+
id: 'checkout-flow',
|
|
320
|
+
label: 'Checkout',
|
|
321
|
+
detail: 'step 2',
|
|
322
|
+
stepIndex: 1,
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
/** Graph with trail step verification badges on edges — ? for unconfirmed, clock for stale. */
|
|
328
|
+
export const GraphWithStepVerificationBadges: Story = {
|
|
329
|
+
render: () => (
|
|
330
|
+
<div style={{ width: '100%', height: '100vh' }}>
|
|
331
|
+
<SubsystemComponentGraph
|
|
332
|
+
components={stepVerificationComponents}
|
|
333
|
+
trails={stepVerificationTrails}
|
|
334
|
+
initialTrailId="checkout-flow"
|
|
335
|
+
title="Checkout with Step Verification"
|
|
336
|
+
description="Shows edge badges for step verification status: question mark (?) for unconfirmed call sites, clock for stale call sites."
|
|
337
|
+
diagnostic={{
|
|
338
|
+
status: 'issues',
|
|
339
|
+
issueCount: stepVerificationIssues.length,
|
|
340
|
+
onToggle: () => {},
|
|
341
|
+
}}
|
|
342
|
+
issues={stepVerificationIssues}
|
|
343
|
+
showIssues
|
|
344
|
+
onSelectIssue={() => {}}
|
|
345
|
+
onApplyIssueFix={() => {}}
|
|
346
|
+
/>
|
|
347
|
+
</div>
|
|
348
|
+
),
|
|
349
|
+
};
|
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
nodeStyle,
|
|
4
4
|
nodeSubtitle,
|
|
5
5
|
nodeTag,
|
|
6
|
+
nodeKindIcon,
|
|
7
|
+
nodeTypeLabel,
|
|
6
8
|
nodeShape,
|
|
7
9
|
nodeMissing,
|
|
8
10
|
NODE_W,
|
|
@@ -111,6 +113,36 @@ describe('nodeTag — what the box IS', () => {
|
|
|
111
113
|
});
|
|
112
114
|
});
|
|
113
115
|
|
|
116
|
+
describe('nodeKindIcon — the kind as a glyph, not a word', () => {
|
|
117
|
+
test('every kind has its own icon', () => {
|
|
118
|
+
const icons = (['container', 'component', 'external-system', 'person'] as const).map((kind) =>
|
|
119
|
+
nodeKindIcon(node({ kind })),
|
|
120
|
+
);
|
|
121
|
+
for (const icon of icons) expect(icon).toBeDefined();
|
|
122
|
+
// Different kinds must not share a glyph, or the icon says nothing.
|
|
123
|
+
expect(new Set(icons).size).toBe(4);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('the icon does not depend on confirmation state', () => {
|
|
127
|
+
expect(nodeKindIcon(node({ state: 'proposed' }))).toBe(
|
|
128
|
+
nodeKindIcon(node({ state: 'accepted' })),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('nodeTypeLabel — the one word in the icon row', () => {
|
|
134
|
+
test('a container names its kind', () => {
|
|
135
|
+
expect(nodeTypeLabel(node({ containerKind: 'application' }))).toBe('application');
|
|
136
|
+
expect(nodeTypeLabel(node({ containerKind: 'data-store' }))).toBe('data-store');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('the other three kinds have no subtype, so no word', () => {
|
|
140
|
+
for (const kind of ['component', 'external-system', 'person'] as const) {
|
|
141
|
+
expect(nodeTypeLabel(node({ kind }))).toBeUndefined();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
114
146
|
describe('nodeShape — kind without relying on colour or dash', () => {
|
|
115
147
|
test('a container is a sharp rectangle', () => {
|
|
116
148
|
const s = nodeShape(node());
|
|
@@ -142,14 +174,19 @@ describe('nodeShape — kind without relying on colour or dash', () => {
|
|
|
142
174
|
});
|
|
143
175
|
|
|
144
176
|
describe('nodeSubtitle', () => {
|
|
145
|
-
test('shows the container kind
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
)
|
|
177
|
+
test('shows the technology, and does not repeat the container kind', () => {
|
|
178
|
+
const s = nodeSubtitle(
|
|
179
|
+
node({ containerKind: 'application', technology: 'Bun + Electrobun' }),
|
|
180
|
+
);
|
|
181
|
+
expect(s).toBe('Bun + Electrobun');
|
|
182
|
+
expect(s).not.toContain('application');
|
|
149
183
|
});
|
|
150
184
|
|
|
151
|
-
test('
|
|
152
|
-
|
|
185
|
+
test('a container with no technology falls back to its construct breakdown', () => {
|
|
186
|
+
const s = nodeSubtitle(
|
|
187
|
+
node({ containerKind: 'data-store', technology: '', constructs: ['store'], members: ['a'] }),
|
|
188
|
+
);
|
|
189
|
+
expect(s).toBe('1 store · 1 component');
|
|
153
190
|
});
|
|
154
191
|
|
|
155
192
|
test('falls back to the construct breakdown when neither is stated', () => {
|
|
@@ -286,16 +323,16 @@ describe('card metrics', () => {
|
|
|
286
323
|
});
|
|
287
324
|
|
|
288
325
|
describe('container kinds', () => {
|
|
289
|
-
test('every container kind renders as
|
|
290
|
-
const kinds = ['application', 'data-store'
|
|
326
|
+
test('every container kind renders as the icon-row label without special-casing', () => {
|
|
327
|
+
const kinds = ['application', 'data-store'] as const;
|
|
291
328
|
for (const containerKind of kinds) {
|
|
292
|
-
expect(
|
|
329
|
+
expect(nodeTypeLabel(node({ containerKind }))).toBe(containerKind);
|
|
293
330
|
}
|
|
294
331
|
});
|
|
295
332
|
|
|
296
333
|
test('a library is not one of them', () => {
|
|
297
334
|
// C4 says a module "typically" is not an element at all, so there is no
|
|
298
335
|
// kind to render. A library-shaped group is a review comment.
|
|
299
|
-
expect(['application', 'data-store'
|
|
336
|
+
expect(['application', 'data-store']).not.toContain('library');
|
|
300
337
|
});
|
|
301
338
|
});
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
16
|
+
import { Cloud, Component, Container, UserRound } from 'lucide-react';
|
|
17
|
+
import type { LucideIcon } from 'lucide-react';
|
|
16
18
|
import { describeConstructBreakdown } from './model';
|
|
17
19
|
import type { C4Node } from './c4';
|
|
18
20
|
|
|
@@ -102,14 +104,14 @@ function baseName(path: string): string {
|
|
|
102
104
|
/**
|
|
103
105
|
* The kind + technology line under the label.
|
|
104
106
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
+
* The container kind moved up to the icon row (see `nodeTypeLabel`), so it is
|
|
108
|
+
* not repeated here. Technology is the remaining confirmed attribute; without
|
|
109
|
+
* one we fall back to what the document told us.
|
|
107
110
|
*/
|
|
108
111
|
export function nodeSubtitle(node: C4Node): string {
|
|
109
|
-
|
|
110
|
-
if (parts.length > 0) return parts.join(' · ');
|
|
112
|
+
if (node.technology) return node.technology;
|
|
111
113
|
|
|
112
|
-
// No
|
|
114
|
+
// No stated technology: fall back to what the document told us.
|
|
113
115
|
if (node.kind === 'component') {
|
|
114
116
|
return `${node.component?.construct ?? 'code'}${node.component?.file ? ` · ${baseName(node.component.file)}` : ''}`;
|
|
115
117
|
}
|
|
@@ -127,6 +129,34 @@ export function nodeTag(node: C4Node): string {
|
|
|
127
129
|
return node.kind;
|
|
128
130
|
}
|
|
129
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The glyph for the C4 kind, shown in place of a word. The kind is already
|
|
134
|
+
* carried by the box shape (see `nodeShape`); the icon lets the top row spend
|
|
135
|
+
* its one piece of text on the container's kind instead of repeating
|
|
136
|
+
* "container".
|
|
137
|
+
*/
|
|
138
|
+
export function nodeKindIcon(node: C4Node): LucideIcon {
|
|
139
|
+
switch (node.kind) {
|
|
140
|
+
case 'container':
|
|
141
|
+
return Container;
|
|
142
|
+
case 'component':
|
|
143
|
+
return Component;
|
|
144
|
+
case 'external-system':
|
|
145
|
+
return Cloud;
|
|
146
|
+
case 'person':
|
|
147
|
+
return UserRound;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The word beside the kind icon: what sort of container this is — C4's two,
|
|
153
|
+
* `application` or `data-store`. The other three kinds have no such subtype,
|
|
154
|
+
* so they show nothing; the icon and shape are the whole label.
|
|
155
|
+
*/
|
|
156
|
+
export function nodeTypeLabel(node: C4Node): string | undefined {
|
|
157
|
+
return node.kind === 'container' ? node.containerKind : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
130
160
|
/** How a box is drawn. Shape carries the C4 kind, independent of colour. */
|
|
131
161
|
export type C4ShapeKind = 'rect' | 'square' | 'pill';
|
|
132
162
|
|
|
@@ -150,8 +180,8 @@ export interface NodeShape {
|
|
|
150
180
|
* An external system is separated from a container by radius alone, which is
|
|
151
181
|
* a weak cue. That is deliberate rather than an oversight: the border dash is
|
|
152
182
|
* the only channel that could say "outside our control" loudly, and it is
|
|
153
|
-
* already spent on proposed-vs-accepted. The kind
|
|
154
|
-
*
|
|
183
|
+
* already spent on proposed-vs-accepted. The kind icon says it, and the frame
|
|
184
|
+
* position says it structurally.
|
|
155
185
|
*/
|
|
156
186
|
export function nodeShape(node: C4Node): NodeShape {
|
|
157
187
|
switch (node.kind) {
|
|
@@ -197,6 +227,8 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
|
|
|
197
227
|
const style = nodeStyle(node, theme, selected);
|
|
198
228
|
const shape = nodeShape(node);
|
|
199
229
|
const missing = nodeMissing(node);
|
|
230
|
+
const Icon = nodeKindIcon(node);
|
|
231
|
+
const typeLabel = nodeTypeLabel(node);
|
|
200
232
|
|
|
201
233
|
const size = nodeSize(node);
|
|
202
234
|
|
|
@@ -223,23 +255,42 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
|
|
|
223
255
|
fontFamily: theme.fonts.body,
|
|
224
256
|
}}
|
|
225
257
|
>
|
|
226
|
-
{/* The kind is
|
|
227
|
-
|
|
228
|
-
|
|
258
|
+
{/* The kind is carried by the icon and the box shape; the one word here
|
|
259
|
+
is the container's kind. Confirmation state is carried by the border
|
|
260
|
+
(see `nodeStyle`), not by a word. */}
|
|
229
261
|
<span
|
|
230
262
|
style={{
|
|
263
|
+
display: 'flex',
|
|
264
|
+
alignItems: 'center',
|
|
265
|
+
gap: 6,
|
|
231
266
|
fontFamily: theme.fonts.monospace,
|
|
232
267
|
fontSize: theme.fontSizes[0],
|
|
233
268
|
color: style.color,
|
|
234
|
-
fontWeight: 600,
|
|
235
269
|
letterSpacing: 0.5,
|
|
236
270
|
textTransform: 'uppercase',
|
|
237
|
-
|
|
238
|
-
overflow: 'hidden',
|
|
239
|
-
textOverflow: 'ellipsis',
|
|
271
|
+
minWidth: 0,
|
|
240
272
|
}}
|
|
241
273
|
>
|
|
242
|
-
|
|
274
|
+
<span
|
|
275
|
+
title={nodeTag(node)}
|
|
276
|
+
aria-label={nodeTag(node)}
|
|
277
|
+
style={{ display: 'inline-flex', flexShrink: 0 }}
|
|
278
|
+
>
|
|
279
|
+
<Icon size={13} strokeWidth={2.25} />
|
|
280
|
+
</span>
|
|
281
|
+
{typeLabel && (
|
|
282
|
+
<span
|
|
283
|
+
style={{
|
|
284
|
+
fontWeight: 600,
|
|
285
|
+
whiteSpace: 'nowrap',
|
|
286
|
+
overflow: 'hidden',
|
|
287
|
+
textOverflow: 'ellipsis',
|
|
288
|
+
minWidth: 0,
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
{typeLabel}
|
|
292
|
+
</span>
|
|
293
|
+
)}
|
|
243
294
|
</span>
|
|
244
295
|
{/* Two lines rather than an ellipsis: a truncated label hides the very
|
|
245
296
|
thing that distinguishes two containers from each other. The fixed
|
|
@@ -17,10 +17,12 @@ import {
|
|
|
17
17
|
ChevronDown,
|
|
18
18
|
ChevronRight,
|
|
19
19
|
CircleCheck,
|
|
20
|
+
CircleHelp,
|
|
20
21
|
Component,
|
|
21
22
|
FileX,
|
|
22
23
|
FolderGit2,
|
|
23
24
|
Footprints,
|
|
25
|
+
History,
|
|
24
26
|
MapPin,
|
|
25
27
|
Network,
|
|
26
28
|
Route,
|
|
@@ -104,6 +106,8 @@ const KIND_CATEGORY: Record<string, SubsystemIssueCategory> = {
|
|
|
104
106
|
boundary_module_file_mismatch: 'static-topology',
|
|
105
107
|
boundary_process_nest_disagree: 'dynamic-topology',
|
|
106
108
|
trail: 'trail',
|
|
109
|
+
step_unconfirmed: 'trail',
|
|
110
|
+
step_stale: 'trail',
|
|
107
111
|
};
|
|
108
112
|
|
|
109
113
|
/**
|
|
@@ -164,6 +168,9 @@ export const ISSUE_KIND_ICON: Record<string, LucideIcon> = {
|
|
|
164
168
|
// One module, more than one claimed process parent — a containment that can't
|
|
165
169
|
// be drawn, which is why the region sits at the root instead of nested.
|
|
166
170
|
boundary_process_nest_disagree: Split,
|
|
171
|
+
// Trail step verification: unconfirmed = no augmentation yet, stale = code changed
|
|
172
|
+
step_unconfirmed: CircleHelp,
|
|
173
|
+
step_stale: History,
|
|
167
174
|
};
|
|
168
175
|
|
|
169
176
|
/**
|
|
@@ -244,6 +251,8 @@ const KIND_LABEL: Record<string, string> = {
|
|
|
244
251
|
boundary_module_file_mismatch: 'Declaration file outside module',
|
|
245
252
|
boundary_process_nest_disagree: 'Module spans multiple process contexts',
|
|
246
253
|
trail: 'Step issue',
|
|
254
|
+
step_unconfirmed: 'Call site not verified',
|
|
255
|
+
step_stale: 'Call site changed',
|
|
247
256
|
};
|
|
248
257
|
|
|
249
258
|
/** Label for an issue's kind. Explicit `kindLabel` wins; unknown kinds humanize. */
|
|
@@ -275,8 +284,10 @@ const KIND_ORDER: Record<string, number> = {
|
|
|
275
284
|
boundary_module_file_mismatch: 23,
|
|
276
285
|
// Runtime topology — process containment
|
|
277
286
|
boundary_process_nest_disagree: 30,
|
|
278
|
-
// Trail
|
|
287
|
+
// Trail — mechanical issues first, then step verification
|
|
279
288
|
trail: 40,
|
|
289
|
+
step_unconfirmed: 41,
|
|
290
|
+
step_stale: 42,
|
|
280
291
|
};
|
|
281
292
|
|
|
282
293
|
const KIND_ORDER_FALLBACK = 100;
|
|
@@ -1184,6 +1184,37 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1184
1184
|
return map;
|
|
1185
1185
|
}, [issues]);
|
|
1186
1186
|
|
|
1187
|
+
// Per-edge step verification badge: maps edge IDs to step verification status.
|
|
1188
|
+
// Keyed by the React Flow edge id (from → mechanism → to). An edge can have
|
|
1189
|
+
// multiple steps with issues; we show the worst status (stale > unconfirmed).
|
|
1190
|
+
const stepIssueBadgeByEdgeId = useMemo(() => {
|
|
1191
|
+
const map = new Map<string, { kind: 'step_unconfirmed' | 'step_stale'; severity: 'info' | 'error'; count: number }>();
|
|
1192
|
+
if (!issues?.length || !trails?.length) {
|
|
1193
|
+
console.log('[stepIssueBadge] early return: issues=', issues?.length, 'trails=', trails?.length);
|
|
1194
|
+
return map;
|
|
1195
|
+
}
|
|
1196
|
+
for (const issue of issues) {
|
|
1197
|
+
if (issue.kind !== 'step_unconfirmed' && issue.kind !== 'step_stale') continue;
|
|
1198
|
+
const stepInfo = issueStep(issue);
|
|
1199
|
+
console.log('[stepIssueBadge] issue', issue.id, 'kind=', issue.kind, 'stepInfo=', stepInfo);
|
|
1200
|
+
if (!stepInfo) continue;
|
|
1201
|
+
const step = stepInfo.trail.steps[stepInfo.stepIndex];
|
|
1202
|
+
if (!step) continue;
|
|
1203
|
+
const edgeId = trailStepGraphEdgeId(step);
|
|
1204
|
+
console.log('[stepIssueBadge] -> edgeId=', edgeId);
|
|
1205
|
+
const prev = map.get(edgeId);
|
|
1206
|
+
// step_stale is worse than step_unconfirmed
|
|
1207
|
+
const isWorse = issue.kind === 'step_stale' && prev?.kind !== 'step_stale';
|
|
1208
|
+
map.set(edgeId, {
|
|
1209
|
+
kind: isWorse || !prev ? issue.kind as 'step_unconfirmed' | 'step_stale' : prev.kind,
|
|
1210
|
+
severity: prev?.severity === 'error' || issue.severity === 'error' ? 'error' : 'info',
|
|
1211
|
+
count: (prev?.count ?? 0) + 1,
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
console.log('[stepIssueBadge] final map size=', map.size);
|
|
1215
|
+
return map;
|
|
1216
|
+
}, [issues, trails, issueStep]);
|
|
1217
|
+
|
|
1187
1218
|
// Expanded verification layers in the diagnostics list. When any layer with
|
|
1188
1219
|
// findings is expanded, the canvas dims everything that layer does not
|
|
1189
1220
|
// implicate (see `issueFocus`). The list publishes this through
|
|
@@ -2452,7 +2483,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2452
2483
|
// doesn't intercept pointer events). Uses ELK-computed label midpoints from
|
|
2453
2484
|
// the actual edge path (not node-center approximations).
|
|
2454
2485
|
const edgeLabels = useMemo(() => {
|
|
2455
|
-
|
|
2486
|
+
const labels = dispEdges
|
|
2456
2487
|
.filter((e) => {
|
|
2457
2488
|
if (e.hidden) return false;
|
|
2458
2489
|
const d = e?.data as { dimmed?: boolean } | undefined;
|
|
@@ -2467,6 +2498,8 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2467
2498
|
labelY?: number;
|
|
2468
2499
|
pathLength?: number;
|
|
2469
2500
|
} | undefined;
|
|
2501
|
+
const stepIssue = stepIssueBadgeByEdgeId.get(e.id);
|
|
2502
|
+
console.log('[edgeLabels] edge', e.id, 'stepIssue=', stepIssue);
|
|
2470
2503
|
return {
|
|
2471
2504
|
id: e.id,
|
|
2472
2505
|
mechanism: d?.mechanism ?? 'uses',
|
|
@@ -2476,9 +2509,12 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2476
2509
|
midY: d?.labelY ?? 0,
|
|
2477
2510
|
pathLength: d?.pathLength ?? 0,
|
|
2478
2511
|
stepNos: selectedFlowStepNos?.get(e.id),
|
|
2512
|
+
stepIssue,
|
|
2479
2513
|
};
|
|
2480
2514
|
});
|
|
2481
|
-
|
|
2515
|
+
console.log('[edgeLabels] total labels=', labels.length, 'with stepIssue=', labels.filter(l => l.stepIssue).length);
|
|
2516
|
+
return labels;
|
|
2517
|
+
}, [dispEdges, selectedFlowStepNos, stepIssueBadgeByEdgeId]);
|
|
2482
2518
|
|
|
2483
2519
|
// Unique source files across components → sidebar file trees.
|
|
2484
2520
|
const treeFilePaths = treeFiles;
|
|
@@ -2958,6 +2994,53 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2958
2994
|
>
|
|
2959
2995
|
{text}
|
|
2960
2996
|
</span>
|
|
2997
|
+
{lbl.stepIssue && (() => {
|
|
2998
|
+
const StepIssueIcon = ISSUE_KIND_ICON[lbl.stepIssue.kind];
|
|
2999
|
+
// Match IssueList: error severity = red, info severity = amber
|
|
3000
|
+
const issueColor = lbl.stepIssue.severity === 'error'
|
|
3001
|
+
? (theme.colors.error ?? '#e5534b')
|
|
3002
|
+
: (theme.colors.warning ?? '#d4a017');
|
|
3003
|
+
const badgeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
|
|
3004
|
+
return (
|
|
3005
|
+
<span
|
|
3006
|
+
aria-hidden
|
|
3007
|
+
title={lbl.stepIssue.kind === 'step_stale'
|
|
3008
|
+
? 'Call site changed since verification'
|
|
3009
|
+
: 'Call site not yet verified'}
|
|
3010
|
+
style={{
|
|
3011
|
+
position: 'absolute',
|
|
3012
|
+
right: -7,
|
|
3013
|
+
bottom: -6,
|
|
3014
|
+
zIndex: 2,
|
|
3015
|
+
display: 'inline-flex',
|
|
3016
|
+
alignItems: 'center',
|
|
3017
|
+
justifyContent: 'center',
|
|
3018
|
+
gap: 3,
|
|
3019
|
+
minWidth: 18,
|
|
3020
|
+
padding: '2px 4px',
|
|
3021
|
+
borderRadius: 4,
|
|
3022
|
+
border: `1.5px solid ${issueColor}`,
|
|
3023
|
+
background: badgeBg,
|
|
3024
|
+
pointerEvents: 'none',
|
|
3025
|
+
}}
|
|
3026
|
+
>
|
|
3027
|
+
{StepIssueIcon && (
|
|
3028
|
+
<StepIssueIcon size={12} color={issueColor} />
|
|
3029
|
+
)}
|
|
3030
|
+
{lbl.stepIssue.count > 1 && (
|
|
3031
|
+
<span style={{
|
|
3032
|
+
color: issueColor,
|
|
3033
|
+
fontFamily: theme.fonts.monospace,
|
|
3034
|
+
fontSize: 10,
|
|
3035
|
+
fontWeight: 700,
|
|
3036
|
+
lineHeight: 1,
|
|
3037
|
+
}}>
|
|
3038
|
+
{lbl.stepIssue.count}
|
|
3039
|
+
</span>
|
|
3040
|
+
)}
|
|
3041
|
+
</span>
|
|
3042
|
+
);
|
|
3043
|
+
})()}
|
|
2961
3044
|
</div>
|
|
2962
3045
|
);
|
|
2963
3046
|
})}
|
package/src/subsystem/c4.ts
CHANGED
|
@@ -83,12 +83,13 @@ interface C4ElementBase {
|
|
|
83
83
|
/**
|
|
84
84
|
* What kind of thing a container is.
|
|
85
85
|
*
|
|
86
|
-
* `application`
|
|
87
|
-
* system reads or writes.
|
|
88
|
-
*
|
|
89
|
-
*
|
|
86
|
+
* C4 recognises exactly two: an `application` (a deployable program) or a
|
|
87
|
+
* `data-store` (something the system reads or writes). There is deliberately
|
|
88
|
+
* no `queue` kind — c4model is explicit that an individual queue or topic *is*
|
|
89
|
+
* a data store, and that the message bus itself is not a container at all, so
|
|
90
|
+
* a queue is a `data-store` whose technology/label say "queue".
|
|
90
91
|
*/
|
|
91
|
-
export type C4ContainerKind = 'application' | 'data-store'
|
|
92
|
+
export type C4ContainerKind = 'application' | 'data-store';
|
|
92
93
|
|
|
93
94
|
/** A deployable unit or a store — the C4 level 2 box. */
|
|
94
95
|
export interface C4Container extends C4ElementBase {
|
|
@@ -161,7 +162,7 @@ export interface C4Node {
|
|
|
161
162
|
technology?: string;
|
|
162
163
|
description?: string;
|
|
163
164
|
state: C4ElementState;
|
|
164
|
-
/** `application` / `data-store
|
|
165
|
+
/** `application` / `data-store`. Set on containers only. */
|
|
165
166
|
containerKind?: C4ContainerKind;
|
|
166
167
|
/** Provenance, when the element consolidated several derived keys. */
|
|
167
168
|
derivedFrom?: string[];
|
|
@@ -196,10 +196,10 @@ describe('suggestContainerKind', () => {
|
|
|
196
196
|
});
|
|
197
197
|
|
|
198
198
|
test('there is no library answer, even for a library-shaped group', () => {
|
|
199
|
-
// The kinds are application | data-store
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
199
|
+
// The kinds are application | data-store. A package of interfaces still
|
|
200
|
+
// comes back `application`, with the question left to a person in the
|
|
201
|
+
// `library_shaped` concern. Returning `library` here would put a non-C4
|
|
202
|
+
// concept into the vocabulary.
|
|
203
203
|
const members = [member({ alias: 'a', construct: 'interface' })];
|
|
204
204
|
expect(deriveConcerns('x', members).some((c) => c.kind === 'library_shaped')).toBe(true);
|
|
205
205
|
expect(suggestContainerKind(members)).toBe('application');
|