@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
|
@@ -1,57 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* C4 evidence —
|
|
2
|
+
* C4 evidence — the pure read-side of proposing and verifying C4 containers.
|
|
3
3
|
*
|
|
4
|
-
* This module does not decide anything.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* This module does not decide anything. The proposing agent reads the
|
|
5
|
+
* subsystem diagrams, queries the existing containers, reasons, and writes
|
|
6
|
+
* its own rationale; what lives here is only the mechanical part of that
|
|
7
|
+
* loop:
|
|
8
8
|
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
9
|
+
* - `proposeElements` — the proposal scaffold: skip keys containers
|
|
10
|
+
* already claim, skip boundaries no container can be discerned from
|
|
11
|
+
* (no runtime code behind them)
|
|
12
|
+
* - `verifyProcessBoundaries` — a boundary's verification status, read
|
|
13
|
+
* off the claiming container
|
|
14
|
+
* - `verificationIssues` — those statuses as dynamic-topology findings,
|
|
15
|
+
* so verification surfaces through the standard diagnostics pipeline
|
|
16
|
+
* - `findUnassigned` — runtime components no `process` names at all
|
|
13
17
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* Judgements — is this the same unit under a different name? is one function
|
|
19
|
+
* a deployable unit? — belong to the agent and the human, never to a
|
|
20
|
+
* matcher.
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
import type { C4Container, C4ContainerKind, C4Element, C4ElementSet } from './c4';
|
|
24
|
+
import type { SubsystemIssue } from './IssueList';
|
|
19
25
|
|
|
20
|
-
/**
|
|
21
|
-
export interface C4Concern {
|
|
22
|
-
/** The process key, module path, or element id this concerns. */
|
|
23
|
-
target: string;
|
|
24
|
-
kind: C4ConcernKind;
|
|
25
|
-
message: string;
|
|
26
|
-
/** Aliases that triggered it. */
|
|
27
|
-
members?: string[];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* The questions worth a human's attention.
|
|
32
|
-
*
|
|
33
|
-
* Each maps to something an agent cannot settle by reading a string — it has
|
|
34
|
-
* to open the source, or ask the user.
|
|
35
|
-
*/
|
|
36
|
-
export type C4ConcernKind =
|
|
37
|
-
/** two or more process keys may be the same deployable unit */
|
|
38
|
-
| 'merge_candidate'
|
|
39
|
-
/** exactly one member — a lone function is not a deployable unit */
|
|
40
|
-
| 'singleton'
|
|
41
|
-
/** no member executes at runtime — a library is not a container */
|
|
42
|
-
| 'library_shaped'
|
|
43
|
-
/** a store whose storage kind was never decided */
|
|
44
|
-
| 'store_unclassified'
|
|
45
|
-
/** members claim more than one repo — a container cannot straddle systems */
|
|
46
|
-
| 'spans_repos'
|
|
47
|
-
/** an external with no resolvable identity */
|
|
48
|
-
| 'external_unidentified'
|
|
49
|
-
/** a runtime component with no process at all */
|
|
50
|
-
| 'unassigned'
|
|
51
|
-
/** C4 requires technology on every container */
|
|
52
|
-
| 'missing_technology';
|
|
53
|
-
|
|
54
|
-
/** The fields a concern check reads off a component. */
|
|
26
|
+
/** The fields the propose/verify gates read off a component. */
|
|
55
27
|
export interface C4MemberView {
|
|
56
28
|
alias: string;
|
|
57
29
|
construct: string;
|
|
@@ -76,122 +48,6 @@ export function isRuntimeConstruct(construct: string): boolean {
|
|
|
76
48
|
return RUNTIME_CONSTRUCTS.has(construct);
|
|
77
49
|
}
|
|
78
50
|
|
|
79
|
-
/**
|
|
80
|
-
* Derive the questions worth asking about one candidate element.
|
|
81
|
-
*
|
|
82
|
-
* Pure over the rollup, so it runs identically against a single model, the 40
|
|
83
|
-
* composed models, or a fixture. Pure *questions* — it never proposes an
|
|
84
|
-
* answer, because the answers are architectural.
|
|
85
|
-
*/
|
|
86
|
-
export function deriveConcerns(
|
|
87
|
-
key: string,
|
|
88
|
-
members: readonly C4MemberView[],
|
|
89
|
-
tech: { frameworks: readonly string[] } = { frameworks: [] },
|
|
90
|
-
): C4Concern[] {
|
|
91
|
-
const out: C4Concern[] = [];
|
|
92
|
-
const aliases = members.map((m) => m.alias);
|
|
93
|
-
const runtime = members.filter((m) => isRuntimeConstruct(m.construct));
|
|
94
|
-
|
|
95
|
-
if (members.length === 1) {
|
|
96
|
-
out.push({
|
|
97
|
-
target: key,
|
|
98
|
-
kind: 'singleton',
|
|
99
|
-
message: `process "${key}" has exactly one member (${aliases[0]}) — a lone function is not a deployable unit`,
|
|
100
|
-
members: aliases,
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// No member executes at runtime ⇒ this cannot be a deployable unit,
|
|
105
|
-
// regardless of how many members it has. A lone interface and a package of
|
|
106
|
-
// interfaces fail the same way.
|
|
107
|
-
if (members.length > 0 && runtime.length === 0) {
|
|
108
|
-
out.push({
|
|
109
|
-
target: key,
|
|
110
|
-
kind: 'library_shaped',
|
|
111
|
-
message: `process "${key}" has ${members.length} member(s) but none execute at runtime (${[...new Set(members.map((m) => m.construct))].join(', ')}) — this reads as a library, not a container`,
|
|
112
|
-
members: aliases,
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (runtime.length > 0 && tech.frameworks.length === 0) {
|
|
117
|
-
out.push({
|
|
118
|
-
target: key,
|
|
119
|
-
kind: 'missing_technology',
|
|
120
|
-
message: `container "${key}" has no framework signal — C4 requires a technology on every container`,
|
|
121
|
-
members: aliases,
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const repos = new Set(
|
|
126
|
-
members
|
|
127
|
-
.map((m) => (m.purl ?? '').split('#')[0])
|
|
128
|
-
.filter((r) => r && !r.startsWith('external:')),
|
|
129
|
-
);
|
|
130
|
-
if (repos.size > 1) {
|
|
131
|
-
out.push({
|
|
132
|
-
target: key,
|
|
133
|
-
kind: 'spans_repos',
|
|
134
|
-
message: `process "${key}" spans ${repos.size} repo keys (${[...repos].join(', ')}) — a C4 container cannot straddle a system boundary`,
|
|
135
|
-
members: aliases,
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const unclassified = members.filter((m) => m.construct === 'store' && !storageOf(m));
|
|
140
|
-
if (unclassified.length > 0) {
|
|
141
|
-
out.push({
|
|
142
|
-
target: key,
|
|
143
|
-
kind: 'store_unclassified',
|
|
144
|
-
message: `${unclassified.length} store(s) in "${key}" declare no storage kind (${unclassified.map((m) => m.alias).join(', ')}) — decide whether each is a C4 data store or an internal detail`,
|
|
145
|
-
members: unclassified.map((m) => m.alias),
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const unidentified = members.filter(
|
|
150
|
-
(m) => m.construct === 'external' && (!m.purl || m.purl === 'external' || m.purl.startsWith('external:proposed')),
|
|
151
|
-
);
|
|
152
|
-
if (unidentified.length > 0) {
|
|
153
|
-
out.push({
|
|
154
|
-
target: key,
|
|
155
|
-
kind: 'external_unidentified',
|
|
156
|
-
message: `${unidentified.length} external(s) have no resolvable identity (${unidentified.map((m) => m.alias).join(', ')}) — a C4 box named "external" is not a diagram`,
|
|
157
|
-
members: unidentified.map((m) => m.alias),
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return out;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Suggest merges between keys that share a trailing role.
|
|
166
|
-
*
|
|
167
|
-
* `subsystems-studio/host` and `principal-studio/host` are the same role under
|
|
168
|
-
* two app names. This is a *question*, never a merge — only the user knows
|
|
169
|
-
* whether they are one process or two.
|
|
170
|
-
*/
|
|
171
|
-
export function suggestMerges(
|
|
172
|
-
rollup: ReadonlyArray<{ key: string; memberCount: number }>,
|
|
173
|
-
): Array<{ role: string; keys: string[]; reason: string }> {
|
|
174
|
-
const byRole = new Map<string, string[]>();
|
|
175
|
-
for (const { key } of rollup) {
|
|
176
|
-
const slash = key.lastIndexOf('/');
|
|
177
|
-
if (slash <= 0) continue; // a bare key has no app/role shape
|
|
178
|
-
const role = key.slice(slash + 1);
|
|
179
|
-
const list = byRole.get(role) ?? [];
|
|
180
|
-
list.push(key);
|
|
181
|
-
byRole.set(role, list);
|
|
182
|
-
}
|
|
183
|
-
const out: Array<{ role: string; keys: string[]; reason: string }> = [];
|
|
184
|
-
for (const [role, keys] of [...byRole].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
185
|
-
if (keys.length < 2) continue;
|
|
186
|
-
out.push({
|
|
187
|
-
role,
|
|
188
|
-
keys: [...keys].sort(),
|
|
189
|
-
reason: `${keys.length} process keys end in "/${role}" (${[...keys].sort().join(', ')}) — one deployable unit under two names, or two units?`,
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
return out;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
51
|
/**
|
|
196
52
|
* Runtime components that state no `process` at all.
|
|
197
53
|
*
|
|
@@ -204,87 +60,158 @@ export function findUnassigned(members: readonly C4MemberView[]): C4MemberView[]
|
|
|
204
60
|
}
|
|
205
61
|
|
|
206
62
|
/**
|
|
207
|
-
*
|
|
63
|
+
* How far a process boundary has travelled toward being a container.
|
|
208
64
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
65
|
+
* A boundary starts `unassigned` — no container claims it. Proposing an
|
|
66
|
+
* element for it (see `proposeElements`) assigns the boundary to a container,
|
|
67
|
+
* which reads as `proposed` until a human decides. `accepted` verifies the
|
|
68
|
+
* boundary: someone approved this run of code as one deployable unit.
|
|
69
|
+
* `rejected` means the proposal was wrong and the boundary should not be
|
|
70
|
+
* drawn as a box.
|
|
71
|
+
*/
|
|
72
|
+
export type C4ProcessVerification =
|
|
73
|
+
| 'unassigned'
|
|
74
|
+
| 'proposed'
|
|
75
|
+
| 'rejected'
|
|
76
|
+
| 'verified';
|
|
77
|
+
|
|
78
|
+
/** One process boundary's assignment and its review state. */
|
|
79
|
+
export interface C4ProcessBoundaryStatus {
|
|
80
|
+
/** The `process` key, e.g. `principal-studio/host`. */
|
|
81
|
+
key: string;
|
|
82
|
+
/** Aliases carrying this key in the model, when the caller supplied them. */
|
|
83
|
+
memberAliases: string[];
|
|
84
|
+
status: C4ProcessVerification;
|
|
85
|
+
/** The container whose state decided the status. Absent when `unassigned`. */
|
|
86
|
+
element?: C4Element;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read a process boundary's verification off its container assignment.
|
|
212
91
|
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
92
|
+
* The claimant is the container whose `process` equals the boundary's key —
|
|
93
|
+
* a plain string match, no indirection. Two keys that are one deployable unit
|
|
94
|
+
* under two names are fixed in the model document (a consolidation proposal
|
|
95
|
+
* rewrites the components' `process` fields), so the reader never sees a
|
|
96
|
+
* discrepancy to resolve. When several containers claim one key (a
|
|
97
|
+
* hand-edited set can do this), the decided one wins: accepted over rejected
|
|
98
|
+
* over proposed, so one reviewer's yes outranks another's no.
|
|
217
99
|
*
|
|
218
|
-
*
|
|
100
|
+
* Pure: the same rollup and elements always read the same. `rollup` accepts
|
|
101
|
+
* `SubsystemProcessRegion`s directly — only `key` and `memberAliases` are read.
|
|
219
102
|
*/
|
|
220
|
-
export function
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
// Only containers merge. A component has a required `container` field, and
|
|
232
|
-
// folding one into another would silently repoint it at a new parent.
|
|
233
|
-
if (involved.some((e) => e.kind !== 'container')) return [...elements];
|
|
103
|
+
export function verifyProcessBoundaries(options: {
|
|
104
|
+
rollup: ReadonlyArray<{ key: string; memberAliases?: readonly string[] }>;
|
|
105
|
+
elements: readonly C4Element[];
|
|
106
|
+
}): C4ProcessBoundaryStatus[] {
|
|
107
|
+
const byKey = new Map<string, C4Element[]>();
|
|
108
|
+
for (const e of options.elements) {
|
|
109
|
+
if (e.kind !== 'container' || !e.process) continue;
|
|
110
|
+
const list = byKey.get(e.process) ?? [];
|
|
111
|
+
list.push(e);
|
|
112
|
+
byKey.set(e.process, list);
|
|
113
|
+
}
|
|
234
114
|
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
115
|
+
const precedence = { accepted: 0, rejected: 1, proposed: 2 } as const;
|
|
116
|
+
return options.rollup.map(({ key, memberAliases }) => {
|
|
117
|
+
const aliases = [...(memberAliases ?? [])];
|
|
118
|
+
const claimants = byKey.get(key) ?? [];
|
|
119
|
+
if (claimants.length === 0) {
|
|
120
|
+
return { key, memberAliases: aliases, status: 'unassigned' as const };
|
|
240
121
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const absorbed = involved.filter((e) => e.id !== survivor.id);
|
|
249
|
-
const absorbedIds = new Set(absorbed.map((e) => e.id));
|
|
250
|
-
|
|
251
|
-
const merged: C4Container = {
|
|
252
|
-
...(survivor as C4Container),
|
|
253
|
-
members: [...new Set([...survivor.members, ...absorbed.flatMap((e) => e.members)])],
|
|
254
|
-
derivedFrom: [
|
|
255
|
-
...new Set([...(survivor.derivedFrom ?? []), ...absorbed.flatMap((e) => e.derivedFrom ?? [])]),
|
|
256
|
-
],
|
|
257
|
-
rationale:
|
|
258
|
-
[survivor.rationale, ...absorbed.map((e) => e.rationale)].filter(Boolean).join(' | ') ||
|
|
259
|
-
undefined,
|
|
260
|
-
};
|
|
122
|
+
const decided = [...claimants].sort(
|
|
123
|
+
(a, b) => precedence[a.state] - precedence[b.state] || a.id.localeCompare(b.id),
|
|
124
|
+
)[0]!;
|
|
125
|
+
const status = decided.state === 'accepted' ? ('verified' as const) : decided.state;
|
|
126
|
+
return { key, memberAliases: aliases, status, element: decided };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
261
129
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Verification statuses as diagnostics findings — the dynamic-topology layer
|
|
132
|
+
* of the issues list. One finding per boundary that is NOT verified: an
|
|
133
|
+
* unclaimed boundary is the absence a proposing agent run resolves, a
|
|
134
|
+
* `proposed` one awaits the human's decision, a `rejected` one records that
|
|
135
|
+
* the decision was no. `verified` reports nothing — the audit reports
|
|
136
|
+
* absence only, and silence is the good state.
|
|
137
|
+
*
|
|
138
|
+
* The findings ride the existing pipeline untouched: they badge the process
|
|
139
|
+
* frame (target kind `process`), list under Dynamic topology, focus their
|
|
140
|
+
* members on click, and carry the fix the decision needs. Pure over its
|
|
141
|
+
* input, like the rest of this module.
|
|
142
|
+
*/
|
|
143
|
+
export function verificationIssues(
|
|
144
|
+
statuses: readonly C4ProcessBoundaryStatus[],
|
|
145
|
+
): SubsystemIssue[] {
|
|
146
|
+
const out: SubsystemIssue[] = [];
|
|
147
|
+
for (const { key, status, element } of statuses) {
|
|
148
|
+
if (status === 'verified') continue;
|
|
149
|
+
const label = element?.label ?? key;
|
|
150
|
+
out.push({
|
|
151
|
+
id: `process-verification:${key}`,
|
|
152
|
+
severity: 'info',
|
|
153
|
+
kind: `boundary_process_${status}`,
|
|
154
|
+
message:
|
|
155
|
+
status === 'unassigned'
|
|
156
|
+
? `No C4 container claims this process boundary yet — a proposing agent run would scaffold one.`
|
|
157
|
+
: status === 'proposed'
|
|
158
|
+
? `Container "${label}" claims this boundary and awaits a decision.`
|
|
159
|
+
: `Container "${label}" was rejected for this boundary — it stays unclaimed until a container is accepted.`,
|
|
160
|
+
target: { kind: 'process', id: key, label: key },
|
|
161
|
+
fix:
|
|
162
|
+
status === 'unassigned'
|
|
163
|
+
? { label: 'Propose container' }
|
|
164
|
+
: status === 'proposed'
|
|
165
|
+
? { label: 'Accept container' }
|
|
166
|
+
: undefined,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
265
170
|
}
|
|
266
171
|
|
|
267
172
|
/**
|
|
268
|
-
* A starting point for an agent
|
|
269
|
-
*
|
|
173
|
+
* A starting point for an agent, shaped by the two questions that matter when
|
|
174
|
+
* assessing a process boundary:
|
|
270
175
|
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
176
|
+
* 1. **Does a container already exist for it?** (`elements`) — a key one
|
|
177
|
+
* already claims is never re-proposed; proposing another would be
|
|
178
|
+
* exactly the duplicate this module exists to prevent. The exact claim
|
|
179
|
+
* is a fact and skipped here; whether a *similarly named* container is
|
|
180
|
+
* the same unit is a judgement, so it belongs to the agent querying the
|
|
181
|
+
* container list — the fix there is a consolidation proposal, not a
|
|
182
|
+
* second box.
|
|
183
|
+
* 2. **Can the boundary be discerned into a container?** — code that never
|
|
184
|
+
* runs cannot be, so a library-shaped group gets no proposal; that
|
|
185
|
+
* fact is the agent's to report, not the scaffold's.
|
|
186
|
+
*
|
|
187
|
+
* For boundaries passing both, one `proposed` container per key. The
|
|
188
|
+
* rationale is the proposing agent's own reasoning, written on the proposal
|
|
189
|
+
* it POSTs — this scaffold does not fabricate one. A scaffold to edit and
|
|
190
|
+
* argue with — not an answer, and never `accepted`.
|
|
273
191
|
*/
|
|
274
|
-
export function proposeElements(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
192
|
+
export function proposeElements(options: {
|
|
193
|
+
repoKey: string;
|
|
194
|
+
rollup: ReadonlyArray<{
|
|
195
|
+
key: string;
|
|
196
|
+
members: readonly C4MemberView[];
|
|
197
|
+
frameworks: readonly string[];
|
|
198
|
+
}>;
|
|
199
|
+
/** Containers that already exist (from the element store), by `process` claim. */
|
|
200
|
+
elements?: readonly C4Element[];
|
|
201
|
+
}): C4ElementSet {
|
|
202
|
+
const existing = options.elements ?? [];
|
|
203
|
+
const claimed = new Set(existing.flatMap((e) => (e.kind === 'container' && e.process ? [e.process] : [])));
|
|
204
|
+
|
|
205
|
+
const elements: C4Container[] = [];
|
|
206
|
+
for (const { key, members, frameworks } of options.rollup) {
|
|
207
|
+
if (claimed.has(key)) continue; // already covered — exact claim, a fact
|
|
208
|
+
const runtime = members.filter((m) => isRuntimeConstruct(m.construct));
|
|
209
|
+
if (members.length > 0 && runtime.length === 0) continue; // not discernible: code that never runs
|
|
210
|
+
|
|
211
|
+
elements.push({
|
|
284
212
|
id: `container:${key}`,
|
|
285
|
-
kind: 'container'
|
|
286
|
-
|
|
287
|
-
derivedFrom: [key],
|
|
213
|
+
kind: 'container',
|
|
214
|
+
process: key,
|
|
288
215
|
label: key,
|
|
289
216
|
containerKind: suggestContainerKind(members),
|
|
290
217
|
// C4 requires a technology on every container. With no framework signal
|
|
@@ -292,11 +219,9 @@ export function proposeElements(
|
|
|
292
219
|
// inventing "TypeScript" here would be exactly the derivation this
|
|
293
220
|
// module stopped doing.
|
|
294
221
|
technology: frameworks.length > 0 ? frameworks.join(' + ') : '',
|
|
295
|
-
state: 'proposed'
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
};
|
|
299
|
-
});
|
|
222
|
+
state: 'proposed',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
300
225
|
|
|
301
226
|
return {
|
|
302
227
|
repoKey: options.repoKey,
|
package/src/subsystem/model.ts
CHANGED
|
@@ -1429,6 +1429,12 @@ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
|
|
|
1429
1429
|
/** True while another edge is selected — render this edge (and its label)
|
|
1430
1430
|
* dimmed to focus the selected relationship. */
|
|
1431
1431
|
dimmed?: boolean;
|
|
1432
|
+
/**
|
|
1433
|
+
* A backing trail step declares itself `proposed` — a planned seam between
|
|
1434
|
+
* otherwise-real components. The SVG edge and its label draw dashed;
|
|
1435
|
+
* distinct from a proposed ENDPOINT, which dashes the node instead.
|
|
1436
|
+
*/
|
|
1437
|
+
proposedStep?: boolean;
|
|
1432
1438
|
/** ELK-computed label midpoint (from the actual edge path, not node centers). */
|
|
1433
1439
|
labelX?: number;
|
|
1434
1440
|
labelY?: number;
|
|
@@ -113,4 +113,19 @@ describe('SubsystemGroupNode boundary badge', () => {
|
|
|
113
113
|
});
|
|
114
114
|
expect(container.textContent).toContain('3');
|
|
115
115
|
});
|
|
116
|
+
|
|
117
|
+
test('a process verification finding badges the process frame too', () => {
|
|
118
|
+
// Dynamic-topology findings target kind `process`; the frame badge is the
|
|
119
|
+
// same mechanism as a module finding — the boundary's state is the fault.
|
|
120
|
+
const { container } = renderGroup({
|
|
121
|
+
region,
|
|
122
|
+
issue: {
|
|
123
|
+
severity: 'info',
|
|
124
|
+
kind: 'boundary_process_unassigned',
|
|
125
|
+
count: 1,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
// The kind has a dedicated frame icon, so the lucide icon carries the class.
|
|
129
|
+
expect(container.querySelector('.lucide-circle-ellipsis')).not.toBeNull();
|
|
130
|
+
});
|
|
116
131
|
});
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -146,10 +146,10 @@ function NodeIssueOverlay({ issue }: { issue: SubsystemNodeIssue }) {
|
|
|
146
146
|
|
|
147
147
|
/**
|
|
148
148
|
* The shared severity chip: a severity-colored ring around the finding's icon,
|
|
149
|
-
* with a count when there is more than one. Component nodes
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
149
|
+
* with a count when there is more than one. Component nodes and region frames
|
|
150
|
+
* both anchor it overhanging the bottom-right corner — the issues convention.
|
|
151
|
+
* `aria-hidden` and inert — the canvas badge never competes with the sidebar
|
|
152
|
+
* card for the interaction.
|
|
153
153
|
*/
|
|
154
154
|
function IssueChip({
|
|
155
155
|
color,
|
|
@@ -709,8 +709,8 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
|
|
|
709
709
|
label
|
|
710
710
|
)}
|
|
711
711
|
</div>
|
|
712
|
-
{/* Boundary diagnostics badge —
|
|
713
|
-
|
|
712
|
+
{/* Boundary diagnostics badge — overhanging the bottom-right corner, the
|
|
713
|
+
same anchor component chips wear. */}
|
|
714
714
|
{data.issue && ISSUE_KIND_ICON[data.issue.kind] && (
|
|
715
715
|
<IssueChip
|
|
716
716
|
color={
|
|
@@ -720,7 +720,7 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
|
|
|
720
720
|
}
|
|
721
721
|
Icon={ISSUE_KIND_ICON[data.issue.kind]!}
|
|
722
722
|
count={data.issue.count}
|
|
723
|
-
anchor={{ right:
|
|
723
|
+
anchor={{ right: -9, bottom: -8 }}
|
|
724
724
|
/>
|
|
725
725
|
)}
|
|
726
726
|
</div>
|
|
@@ -833,8 +833,11 @@ export function SubsystemEdge({
|
|
|
833
833
|
const edgeRef = { mechanism, provenance: data?.provenance };
|
|
834
834
|
const color = edgeColor(edgeRef);
|
|
835
835
|
// Dash style encodes provenance (graphify) and relationship kind (dashed =
|
|
836
|
-
// inverted-control or observational: hierarchy, registration, watches).
|
|
837
|
-
|
|
836
|
+
// inverted-control or observational: hierarchy, registration, watches). A
|
|
837
|
+
// step-proposed edge also dashes — the seam itself is planned — matching
|
|
838
|
+
// the dashed edge label and a proposed node's dashed border.
|
|
839
|
+
const isDashed =
|
|
840
|
+
edgeStrokeStyle(edgeRef) === 'dashed' || data?.proposedStep === true;
|
|
838
841
|
const dimmed = data?.dimmed === true;
|
|
839
842
|
// Dim the stroke by color, never via path `opacity`. SVG markers are shared
|
|
840
843
|
// by id; opacity on the referencing path paints every arrowhead that uses
|
|
@@ -24,6 +24,11 @@ export interface TechBrand {
|
|
|
24
24
|
svg: string;
|
|
25
25
|
/** Matched against the technology string. First match in `TECH_BRANDS` wins. */
|
|
26
26
|
match: RegExp;
|
|
27
|
+
/**
|
|
28
|
+
* Representative brand hue, for the card border. The dominant colour of the
|
|
29
|
+
* mark, chosen to stay legible as a thin line.
|
|
30
|
+
*/
|
|
31
|
+
color: string;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/**
|
|
@@ -36,12 +41,14 @@ export const TECH_BRANDS: readonly TechBrand[] = [
|
|
|
36
41
|
source: 'https://react.dev',
|
|
37
42
|
svg: reactSvg,
|
|
38
43
|
match: /\breact\b/i,
|
|
44
|
+
color: '#61dafb',
|
|
39
45
|
},
|
|
40
46
|
{
|
|
41
47
|
name: 'Bun',
|
|
42
48
|
source: 'https://bun.sh/logo.svg',
|
|
43
49
|
svg: bunSvg,
|
|
44
50
|
match: /\bbun\b/i,
|
|
51
|
+
color: '#fbf0df',
|
|
45
52
|
},
|
|
46
53
|
{
|
|
47
54
|
name: 'Node.js',
|
|
@@ -50,6 +57,7 @@ export const TECH_BRANDS: readonly TechBrand[] = [
|
|
|
50
57
|
// Matches "Node" and "Node.js"; "Electrobun" has no word boundary before
|
|
51
58
|
// "bun" and never reaches here.
|
|
52
59
|
match: /\bnode(\.js)?\b/i,
|
|
60
|
+
color: '#5fa04e',
|
|
53
61
|
},
|
|
54
62
|
];
|
|
55
63
|
|
|
@@ -11,13 +11,32 @@ import {
|
|
|
11
11
|
EDGE_LABEL_WIDTH,
|
|
12
12
|
EDGE_LABEL_SIDE_PADDING,
|
|
13
13
|
EDGE_LABEL_EDGE_GAP,
|
|
14
|
+
estimateEdgeLabelWidth,
|
|
14
15
|
} from './edgeLabel';
|
|
15
16
|
|
|
17
|
+
describe('estimateEdgeLabelWidth', () => {
|
|
18
|
+
test('grows with the text', () => {
|
|
19
|
+
expect(estimateEdgeLabelWidth('file I/O')).toBeGreaterThan(estimateEdgeLabelWidth('RPC'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('clamps a very short label to a readable minimum', () => {
|
|
23
|
+
expect(estimateEdgeLabelWidth('')).toBe(48);
|
|
24
|
+
expect(estimateEdgeLabelWidth('a')).toBe(48);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('fits the longest C4 protocol we render', () => {
|
|
28
|
+
// A rough sanity ceiling: 'file I/O' plus chip padding must sit under the
|
|
29
|
+
// shared fixed width, or C4 would be wider than the box it replaced.
|
|
30
|
+
expect(estimateEdgeLabelWidth('file I/O')).toBeLessThan(EDGE_LABEL_WIDTH);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
16
34
|
describe('edge-label geometry', () => {
|
|
17
|
-
test('the
|
|
35
|
+
test('the reserved run is the chip plus clearance on both sides', () => {
|
|
36
|
+
// The reserved label box carries the chip width plus the side padding,
|
|
37
|
+
// because ELK's CENTER_LAYER adds no clearance of its own.
|
|
18
38
|
expect(EDGE_LABEL_EDGE_GAP).toBe(
|
|
19
39
|
EDGE_LABEL_WIDTH + EDGE_LABEL_SIDE_PADDING * 2,
|
|
20
40
|
);
|
|
21
|
-
expect(EDGE_LABEL_EDGE_GAP).toBeGreaterThan(EDGE_LABEL_WIDTH);
|
|
22
41
|
});
|
|
23
42
|
});
|
package/src/utils/edgeLabel.ts
CHANGED
|
@@ -19,6 +19,34 @@ export const EDGE_LABEL_HEIGHT = 40;
|
|
|
19
19
|
/** Label font size. Fixed so every label renders at the same text size. */
|
|
20
20
|
export const EDGE_LABEL_FONT_SIZE = 14;
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Approx monospace advance at `EDGE_LABEL_FONT_SIZE` (14px), incl. the tiny
|
|
24
|
+
* letter-spacing. Fira Code / SF Mono / Courier sit at ~0.6em (8.4px); rounded
|
|
25
|
+
* up so a width estimate never undershoots and clips the label.
|
|
26
|
+
*/
|
|
27
|
+
const LABEL_CHAR_WIDTH = 8.5;
|
|
28
|
+
|
|
29
|
+
/** Chip chrome: horizontal padding is 8+8 in the overlay (see C4EdgeLabels). */
|
|
30
|
+
const LABEL_CHIP_CHROME = 16;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Estimated rendered width of an edge label's chip, from its text. Both the
|
|
34
|
+
* overlay and the ELK reservation must use this same number, or the reserved
|
|
35
|
+
* run drifts from what renders. Clamped to a minimum so a one-word protocol
|
|
36
|
+
* still reads as a chip.
|
|
37
|
+
*/
|
|
38
|
+
export function estimateEdgeLabelWidth(text: string): number {
|
|
39
|
+
return Math.max(48, (text?.length ?? 0) * LABEL_CHAR_WIDTH + LABEL_CHIP_CHROME);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reserved label width for C4's own edges, used as the fallback/max when a
|
|
44
|
+
* caller doesn't size per-label. C4's lines carry short protocols (`RPC`,
|
|
45
|
+
* `HTTP`, `file I/O`), so the shared 140 (sized for the component graph's
|
|
46
|
+
* `registers-into`) is wider than needed and inflates the run between boxes.
|
|
47
|
+
*/
|
|
48
|
+
export const C4_LABEL_WIDTH = 90;
|
|
49
|
+
|
|
22
50
|
/**
|
|
23
51
|
* Clear space we want on each side of a label box.
|
|
24
52
|
*
|