@principal-ai/subsystems-react 0.50.0 → 0.51.1
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 +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/pierre/PierreTrailCodeView.d.ts +1 -1
- package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
- package/dist/pierre/PierreTrailCodeView.js +44 -21
- package/dist/pierre/PierreTrailCodeView.js.map +1 -1
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +493 -0
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +1 -0
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +329 -0
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +1 -0
- package/dist/subsystem/C4Graph.d.ts +2 -2
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +38 -73
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +119 -0
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -0
- package/dist/subsystem/C4NodeCard.js +223 -0
- package/dist/subsystem/C4NodeCard.js.map +1 -0
- package/dist/subsystem/DiagnosticToggle.d.ts +8 -1
- package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
- package/dist/subsystem/DiagnosticToggle.js +40 -6
- package/dist/subsystem/DiagnosticToggle.js.map +1 -1
- package/dist/subsystem/FileDrawer.d.ts.map +1 -1
- package/dist/subsystem/FileDrawer.js +20 -21
- package/dist/subsystem/FileDrawer.js.map +1 -1
- package/dist/subsystem/IssueList.js +1 -1
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +13 -4
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/c4.d.ts +272 -0
- package/dist/subsystem/c4.d.ts.map +1 -0
- package/dist/subsystem/c4.js +322 -0
- package/dist/subsystem/c4.js.map +1 -0
- package/dist/subsystem/c4Evidence.d.ts +143 -0
- package/dist/subsystem/c4Evidence.d.ts.map +1 -0
- package/dist/subsystem/c4Evidence.js +240 -0
- package/dist/subsystem/c4Evidence.js.map +1 -0
- package/dist/subsystem/model.d.ts +27 -7
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +15 -0
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +78 -7
- package/dist/subsystem/nodes.js.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +37 -0
- package/src/pierre/PierreTrailCodeView.tsx +49 -21
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +413 -0
- package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +293 -0
- package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +366 -0
- package/src/stories/Subsystem/ComponentGraph/Deprecated.stories.tsx +358 -0
- package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +27 -2
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -1
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +1 -1
- package/src/stories/Subsystem/ComponentGraph/{Flows.stories.tsx → Trails.stories.tsx} +4 -4
- package/src/subsystem/C4Graph.tsx +81 -107
- package/src/subsystem/C4NodeCard.test.ts +301 -0
- package/src/subsystem/C4NodeCard.tsx +301 -0
- package/src/subsystem/DiagnosticToggle.tsx +58 -4
- package/src/subsystem/FileDrawer.tsx +32 -34
- package/src/subsystem/IssueList.tsx +1 -1
- package/src/subsystem/SubsystemComponentGraph.tsx +13 -5
- package/src/subsystem/c4.test.ts +573 -0
- package/src/subsystem/c4.ts +558 -0
- package/src/subsystem/c4Evidence.test.ts +386 -0
- package/src/subsystem/c4Evidence.ts +329 -0
- package/src/subsystem/model.ts +36 -7
- package/src/subsystem/nodes.tsx +124 -5
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +0 -3
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +0 -1
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js +0 -2
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +0 -1
- package/dist/subsystem/toC4.d.ts +0 -95
- package/dist/subsystem/toC4.d.ts.map +0 -1
- package/dist/subsystem/toC4.js +0 -192
- package/dist/subsystem/toC4.js.map +0 -1
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +0 -101
- package/src/stories/Subsystem/C4Graph/c4Fixture.ts +0 -6
- package/src/subsystem/toC4.test.ts +0 -114
- package/src/subsystem/toC4.ts +0 -273
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C4 evidence — what an agent needs in order to propose elements.
|
|
3
|
+
*
|
|
4
|
+
* This module does not decide anything. It answers "what is worth asking
|
|
5
|
+
* about?" so an agent can go and read the source, then propose a
|
|
6
|
+
* `C4Element` with a rationale. The questions come from the C4 notation
|
|
7
|
+
* rules the document cannot satisfy on its own:
|
|
8
|
+
*
|
|
9
|
+
* - every container needs a technology
|
|
10
|
+
* - every element needs a description
|
|
11
|
+
* - a container is a runtime boundary, so a library is not one
|
|
12
|
+
* - a container cannot straddle two software systems
|
|
13
|
+
*
|
|
14
|
+
* The output is a set of concerns. Resolving one is the agent's job, and the
|
|
15
|
+
* answer lands in an element's `rationale`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { C4Container, C4ContainerKind, C4Element, C4ElementSet } from './c4';
|
|
19
|
+
|
|
20
|
+
/** What a concern is about — a derived key, or an existing element id. */
|
|
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. */
|
|
55
|
+
export interface C4MemberView {
|
|
56
|
+
alias: string;
|
|
57
|
+
construct: string;
|
|
58
|
+
purl: string;
|
|
59
|
+
process?: string;
|
|
60
|
+
framework?: string;
|
|
61
|
+
module?: string;
|
|
62
|
+
role?: string;
|
|
63
|
+
declaration?: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Constructs that execute at runtime — the only things a container can be. */
|
|
67
|
+
const RUNTIME_CONSTRUCTS = new Set(['function', 'class', 'custom_entity']);
|
|
68
|
+
|
|
69
|
+
function storageOf(m: C4MemberView): string | undefined {
|
|
70
|
+
const d = m.declaration as { kind?: string; storage?: string } | undefined;
|
|
71
|
+
return d?.kind === 'store' ? d.storage : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True when a component runs, and so can be part of a deployable unit. */
|
|
75
|
+
export function isRuntimeConstruct(construct: string): boolean {
|
|
76
|
+
return RUNTIME_CONSTRUCTS.has(construct);
|
|
77
|
+
}
|
|
78
|
+
|
|
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
|
+
/**
|
|
196
|
+
* Runtime components that state no `process` at all.
|
|
197
|
+
*
|
|
198
|
+
* These are the components an agent cannot place by inference, because there
|
|
199
|
+
* is nothing to infer from. They are a standing question, not a per-element
|
|
200
|
+
* concern.
|
|
201
|
+
*/
|
|
202
|
+
export function findUnassigned(members: readonly C4MemberView[]): C4MemberView[] {
|
|
203
|
+
return members.filter((m) => !m.process && isRuntimeConstruct(m.construct));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Fold a decided merge into an element set.
|
|
208
|
+
*
|
|
209
|
+
* Two process keys that are really one container become ONE element holding
|
|
210
|
+
* both sets of members. The survivor keeps the id, label, and confirmed
|
|
211
|
+
* attributes; the other disappears and its members move across.
|
|
212
|
+
*
|
|
213
|
+
* Which side survives is the **evidence-heavy** one — the element holding more
|
|
214
|
+
* members — because folding the 113-member host into the 1-member one would
|
|
215
|
+
* leave the box labelled after an almost-empty process and lose its confirmed
|
|
216
|
+
* technology. Pass `preferId` to override.
|
|
217
|
+
*
|
|
218
|
+
* Returns a new set; the input is untouched. Idempotent.
|
|
219
|
+
*/
|
|
220
|
+
export function mergeElements(
|
|
221
|
+
elements: readonly C4Element[],
|
|
222
|
+
derivedFromKeys: readonly string[],
|
|
223
|
+
preferId?: string,
|
|
224
|
+
memberCountByKey?: ReadonlyMap<string, number>,
|
|
225
|
+
): C4Element[] {
|
|
226
|
+
const wanted = new Set(derivedFromKeys.map((k) => k.trim()).filter(Boolean));
|
|
227
|
+
if (wanted.size < 2) return [...elements];
|
|
228
|
+
|
|
229
|
+
const involved = elements.filter((e) => e.derivedFrom?.some((k) => wanted.has(k)));
|
|
230
|
+
if (involved.length < 2) return [...elements];
|
|
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];
|
|
234
|
+
|
|
235
|
+
const weight = (e: C4Element): number => {
|
|
236
|
+
if (memberCountByKey) {
|
|
237
|
+
let n = 0;
|
|
238
|
+
for (const k of e.derivedFrom ?? []) n += memberCountByKey.get(k) ?? 0;
|
|
239
|
+
return n;
|
|
240
|
+
}
|
|
241
|
+
return e.members.length;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const survivor =
|
|
245
|
+
(preferId ? involved.find((e) => e.id === preferId) : undefined) ??
|
|
246
|
+
[...involved].sort((a, b) => weight(b) - weight(a) || a.id.localeCompare(b.id))[0]!;
|
|
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
|
+
};
|
|
261
|
+
|
|
262
|
+
return [...elements.filter((e) => e.id !== survivor.id && !absorbedIds.has(e.id)), merged].sort(
|
|
263
|
+
(a, b) => a.id.localeCompare(b.id),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A starting point for an agent: one proposed element per derived key, shaped
|
|
269
|
+
* by the mechanical evidence, with the open questions in the rationale.
|
|
270
|
+
*
|
|
271
|
+
* Every element comes back `proposed`. This is a scaffold to edit and argue
|
|
272
|
+
* with — not an answer, and never `accepted`.
|
|
273
|
+
*/
|
|
274
|
+
export function proposeElements(
|
|
275
|
+
options: {
|
|
276
|
+
repoKey: string;
|
|
277
|
+
rollup: ReadonlyArray<{ key: string; members: readonly C4MemberView[]; frameworks: readonly string[] }>;
|
|
278
|
+
author?: string;
|
|
279
|
+
},
|
|
280
|
+
): C4ElementSet {
|
|
281
|
+
const elements = options.rollup.map(({ key, members, frameworks }) => {
|
|
282
|
+
const concerns = deriveConcerns(key, members, { frameworks });
|
|
283
|
+
return {
|
|
284
|
+
id: `container:${key}`,
|
|
285
|
+
kind: 'container' as const,
|
|
286
|
+
members: members.map((m) => m.alias),
|
|
287
|
+
derivedFrom: [key],
|
|
288
|
+
label: key,
|
|
289
|
+
containerKind: suggestContainerKind(members),
|
|
290
|
+
// C4 requires a technology on every container. With no framework signal
|
|
291
|
+
// the honest value is empty and `summarizeElements` reports the gap —
|
|
292
|
+
// inventing "TypeScript" here would be exactly the derivation this
|
|
293
|
+
// module stopped doing.
|
|
294
|
+
technology: frameworks.length > 0 ? frameworks.join(' + ') : '',
|
|
295
|
+
state: 'proposed' as const,
|
|
296
|
+
rationale: concerns.map((c) => c.message).join(' ') || undefined,
|
|
297
|
+
author: options.author,
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
repoKey: options.repoKey,
|
|
303
|
+
elements: elements.sort((a, b) => a.id.localeCompare(b.id)),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* A first guess at what sort of container this is, offered as a *default* for
|
|
309
|
+
* the agent to override.
|
|
310
|
+
*
|
|
311
|
+
* Note there is no `library` answer, even for a library-shaped group. C4 says
|
|
312
|
+
* a module "typically" is not an element at all, so the honest response to a
|
|
313
|
+
* library is a review comment: the `library_shaped` concern carries the
|
|
314
|
+
* question, and the element is proposed as `application` for a person to
|
|
315
|
+
* reject or demote. There is deliberately no `concerns` parameter here for the
|
|
316
|
+
* caller to consult — passing one would only invite a fifth kind back into the
|
|
317
|
+
* vocabulary.
|
|
318
|
+
*
|
|
319
|
+
* The store split is deliberately conservative: only durable storage becomes
|
|
320
|
+
* `data-store`, because over-claiming that is the more damaging error.
|
|
321
|
+
*/
|
|
322
|
+
export function suggestContainerKind(members: readonly C4MemberView[]): C4ContainerKind {
|
|
323
|
+
const stores = members.filter((m) => m.construct === 'store');
|
|
324
|
+
if (stores.length > 0) {
|
|
325
|
+
const persists = stores.some((m) => storageOf(m) === 'external' || storageOf(m) === 'disk');
|
|
326
|
+
return persists ? 'data-store' : 'application';
|
|
327
|
+
}
|
|
328
|
+
return 'application';
|
|
329
|
+
}
|
package/src/subsystem/model.ts
CHANGED
|
@@ -20,7 +20,10 @@ import {
|
|
|
20
20
|
import { computeElkLayout, calculatePathLength } from '../utils/elkLayout';
|
|
21
21
|
import { EDGE_LABEL_SIDE_PADDING, EDGE_ARROW_INSET } from '../utils/edgeLabel';
|
|
22
22
|
import type { GraphifyComponentDetail } from '../graphify';
|
|
23
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
SubsystemConstructDeclaration,
|
|
25
|
+
SubsystemComponentRemoval,
|
|
26
|
+
} from '@principal-ai/subsystems-core';
|
|
24
27
|
import type { SubsystemDeclarationRef } from './declarationRef';
|
|
25
28
|
import { purlOwnerName, purlRepoKey } from './paths';
|
|
26
29
|
|
|
@@ -242,6 +245,15 @@ export interface SubsystemComponent {
|
|
|
242
245
|
* `construct` (intended shape) and `role` (topology).
|
|
243
246
|
*/
|
|
244
247
|
proposed?: boolean;
|
|
248
|
+
/**
|
|
249
|
+
* The claim was real but its source is gone upstream — deleted or renamed.
|
|
250
|
+
* The inverse of `proposed`; verification skips source checks the same way.
|
|
251
|
+
* Renders with a slate border and a `deprecated` badge that opens the removal
|
|
252
|
+
* provenance. Transitional — a cleanup pass removes the node.
|
|
253
|
+
*/
|
|
254
|
+
deprecated?: boolean;
|
|
255
|
+
/** Which commit removed this claim's source, and why. Present when deprecated. */
|
|
256
|
+
removedIn?: SubsystemComponentRemoval;
|
|
245
257
|
/**
|
|
246
258
|
* Semantic role — where the node sits in the topology (boundary element,
|
|
247
259
|
* external system), orthogonal to `construct` (what it is). Drives the
|
|
@@ -312,12 +324,8 @@ export interface SubsystemComponent {
|
|
|
312
324
|
*/
|
|
313
325
|
declaration?: SubsystemConstructDeclaration;
|
|
314
326
|
/**
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* as matching the code. `authored` = written by the authoring agent/human
|
|
318
|
-
* to highlight specific inputs/outputs — informative, not checked against
|
|
319
|
-
* source. Declaration without provenance is treated as `authored`; only
|
|
320
|
-
* tooling may claim `verified`.
|
|
327
|
+
* @deprecated No longer used — verification now tracks at the model level
|
|
328
|
+
* via `verifiedAtCommits`. Keeping the field temporarily for migration.
|
|
321
329
|
*/
|
|
322
330
|
declarationProvenance?: SubsystemDeclarationProvenance;
|
|
323
331
|
/**
|
|
@@ -1706,6 +1714,25 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
|
|
|
1706
1714
|
/** Right-badge accent for `proposed: true` (no role, or combined with role). */
|
|
1707
1715
|
export const PROPOSED_COLOR = '#b8860b'; // darkgoldenrod — not-yet-in-source
|
|
1708
1716
|
|
|
1717
|
+
/**
|
|
1718
|
+
* Border accent for `deprecated: true` — the claim was real, its source is gone.
|
|
1719
|
+
* A desaturated slate rather than true silver: role badges already own silver
|
|
1720
|
+
* (`ROLE_COLOR.entry`), and a retired claim should not read as a boundary
|
|
1721
|
+
* element. Transitional state — a cleanup pass removes the node.
|
|
1722
|
+
*/
|
|
1723
|
+
export const DEPRECATED_COLOR = '#6b7280'; // slate — retired, awaiting removal
|
|
1724
|
+
|
|
1725
|
+
/** Right-badge label + accent for `deprecated: true`; null when not deprecated. */
|
|
1726
|
+
export function deprecatedBadgeLabel(component: { deprecated?: boolean }): string | null {
|
|
1727
|
+
return component.deprecated === true ? 'deprecated' : null;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
export function deprecatedBadgeColor(component: {
|
|
1731
|
+
deprecated?: boolean;
|
|
1732
|
+
}): string | null {
|
|
1733
|
+
return component.deprecated === true ? DEPRECATED_COLOR : null;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1709
1736
|
/**
|
|
1710
1737
|
* Top-right badge label: proposed wins over role when both are set
|
|
1711
1738
|
* (proposed is the temporary exception to scan for; role stays on the model).
|
|
@@ -1851,6 +1878,7 @@ export function nodeMinWidthForBadges(component: {
|
|
|
1851
1878
|
stereotype?: string;
|
|
1852
1879
|
role?: SubsystemComponentRole;
|
|
1853
1880
|
proposed?: boolean;
|
|
1881
|
+
deprecated?: boolean;
|
|
1854
1882
|
entityKind?: string;
|
|
1855
1883
|
declaration?: AnyComponentDetail;
|
|
1856
1884
|
}): number {
|
|
@@ -1858,6 +1886,7 @@ export function nodeMinWidthForBadges(component: {
|
|
|
1858
1886
|
const rightBadges = [
|
|
1859
1887
|
storageBadgeLabel(component),
|
|
1860
1888
|
rightBadgeLabel(component),
|
|
1889
|
+
deprecatedBadgeLabel(component),
|
|
1861
1890
|
].filter((l): l is string => l != null);
|
|
1862
1891
|
if (rightBadges.length === 0) {
|
|
1863
1892
|
return Math.max(
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -19,7 +19,10 @@ import { useTheme } from '@principal-ade/industry-theme';
|
|
|
19
19
|
import {
|
|
20
20
|
edgeColor,
|
|
21
21
|
edgeStrokeStyle,
|
|
22
|
+
DEPRECATED_COLOR,
|
|
22
23
|
PROPOSED_COLOR,
|
|
24
|
+
deprecatedBadgeColor,
|
|
25
|
+
deprecatedBadgeLabel,
|
|
23
26
|
constructBadgeColor,
|
|
24
27
|
constructBadgeLabel,
|
|
25
28
|
rightBadgeLabel,
|
|
@@ -217,6 +220,13 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
217
220
|
// Store retention backing badge — memory/disk/db, beside role (orthogonal).
|
|
218
221
|
const storageLabel = storageBadgeLabel(c);
|
|
219
222
|
const storageColor = storageBadgeColor(c);
|
|
223
|
+
// Deprecated badge — clickable, unlike every other badge here (they opt out
|
|
224
|
+
// of pointer events so clicks fall through to the node). Clicking opens the
|
|
225
|
+
// removal provenance, which is the whole reason the badge exists.
|
|
226
|
+
const deprecatedLabel = deprecatedBadgeLabel(c);
|
|
227
|
+
const deprecatedColor = deprecatedBadgeColor(c);
|
|
228
|
+
const removal = c.removedIn;
|
|
229
|
+
const [showRemoval, setShowRemoval] = useState(false);
|
|
220
230
|
// Set while a file is open in the drawer: true → spotlight, false → dim,
|
|
221
231
|
// absent (no file open) → neutral.
|
|
222
232
|
const fileMatch = data.fileMatch as boolean | undefined;
|
|
@@ -225,13 +235,17 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
225
235
|
const nodeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
|
|
226
236
|
const hoverBg =
|
|
227
237
|
theme.colors.backgroundHover ?? theme.colors.backgroundTertiary ?? nodeBg;
|
|
228
|
-
// Border: proposed uses the goldenrod accent (dashed)
|
|
229
|
-
//
|
|
238
|
+
// Border: proposed uses the goldenrod accent (dashed), deprecated the slate
|
|
239
|
+
// accent (dotted, same family as issue) since both mean "not a live claim";
|
|
240
|
+
// construct color stays on the left badge. File-open spotlight still wins
|
|
241
|
+
// with primary.
|
|
230
242
|
const borderColor = fileMatch
|
|
231
243
|
? theme.colors.primary
|
|
232
244
|
: c.proposed
|
|
233
245
|
? PROPOSED_COLOR
|
|
234
|
-
:
|
|
246
|
+
: c.deprecated
|
|
247
|
+
? DEPRECATED_COLOR
|
|
248
|
+
: color;
|
|
235
249
|
// Selection is stamped into data by the graph component — React Flow's own
|
|
236
250
|
// `selected` never updates because the node stops click propagation.
|
|
237
251
|
const isSelected = selected || (data.isSelected as boolean | undefined) === true;
|
|
@@ -252,7 +266,16 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
252
266
|
// The border keeps its construct color; the severity rides on the chip.
|
|
253
267
|
// Verification skips `proposed` nodes, so the two rarely stack — issue wins.
|
|
254
268
|
const hasIssue = data.issue != null;
|
|
255
|
-
|
|
269
|
+
// Deprecated is dotted like issue but slate rather than construct-colored:
|
|
270
|
+
// "this claim was retired" is closer to a diagnostic than to a placeholder,
|
|
271
|
+
// and verification skips deprecated nodes so the two never stack in practice.
|
|
272
|
+
const borderStyle = hasIssue
|
|
273
|
+
? 'dotted'
|
|
274
|
+
: c.proposed
|
|
275
|
+
? 'dashed'
|
|
276
|
+
: c.deprecated
|
|
277
|
+
? 'dotted'
|
|
278
|
+
: 'solid';
|
|
256
279
|
|
|
257
280
|
return (
|
|
258
281
|
<div
|
|
@@ -326,7 +349,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
326
349
|
orthogonal to topology. Persistent; pointer-events pass through.
|
|
327
350
|
Sits astride the bottom edge, centered (mirroring the top construct
|
|
328
351
|
badge's straddle), so the top edge stays free for the construct tag. */}
|
|
329
|
-
{(topRightLabel != null || storageLabel != null) && (
|
|
352
|
+
{(topRightLabel != null || storageLabel != null || deprecatedLabel != null) && (
|
|
330
353
|
<div
|
|
331
354
|
style={{
|
|
332
355
|
position: 'absolute',
|
|
@@ -375,6 +398,102 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
375
398
|
{topRightLabel}
|
|
376
399
|
</span>
|
|
377
400
|
)}
|
|
401
|
+
{deprecatedLabel != null && deprecatedColor != null && (
|
|
402
|
+
// Its own click target: stops propagation so opening the provenance
|
|
403
|
+
// doesn't also select the node, and toggles a popover rather than
|
|
404
|
+
// leaning on a title tooltip (which truncates the commit subject).
|
|
405
|
+
<span
|
|
406
|
+
role="button"
|
|
407
|
+
tabIndex={0}
|
|
408
|
+
aria-expanded={showRemoval}
|
|
409
|
+
aria-label={
|
|
410
|
+
removal
|
|
411
|
+
? `Deprecated — removed in ${removal.commit}. Show removal details.`
|
|
412
|
+
: 'Deprecated — show removal details.'
|
|
413
|
+
}
|
|
414
|
+
onClick={(e) => {
|
|
415
|
+
e.stopPropagation();
|
|
416
|
+
setShowRemoval((v) => !v);
|
|
417
|
+
}}
|
|
418
|
+
onKeyDown={(e) => {
|
|
419
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
420
|
+
e.stopPropagation();
|
|
421
|
+
e.preventDefault();
|
|
422
|
+
setShowRemoval((v) => !v);
|
|
423
|
+
}
|
|
424
|
+
}}
|
|
425
|
+
style={{
|
|
426
|
+
position: 'relative',
|
|
427
|
+
fontFamily: theme.fonts.monospace,
|
|
428
|
+
fontSize: theme.fontSizes[1],
|
|
429
|
+
letterSpacing: 0.5,
|
|
430
|
+
textTransform: 'uppercase',
|
|
431
|
+
lineHeight: '17px',
|
|
432
|
+
color: deprecatedColor,
|
|
433
|
+
background: nodeBg,
|
|
434
|
+
border: `2px solid ${deprecatedColor}`,
|
|
435
|
+
borderRadius: badgeRadius,
|
|
436
|
+
padding: '2px 8px',
|
|
437
|
+
cursor: 'pointer',
|
|
438
|
+
}}
|
|
439
|
+
>
|
|
440
|
+
{deprecatedLabel}
|
|
441
|
+
</span>
|
|
442
|
+
)}
|
|
443
|
+
</div>
|
|
444
|
+
)}
|
|
445
|
+
|
|
446
|
+
{/* Removal provenance popover — anchored above the deprecated badge, so it
|
|
447
|
+
opens away from the node body and stays inside the canvas. */}
|
|
448
|
+
{showRemoval && deprecatedLabel != null && (
|
|
449
|
+
<div
|
|
450
|
+
role="dialog"
|
|
451
|
+
aria-label="Removal provenance"
|
|
452
|
+
onClick={(e) => e.stopPropagation()}
|
|
453
|
+
style={{
|
|
454
|
+
position: 'absolute',
|
|
455
|
+
bottom: '100%',
|
|
456
|
+
left: '50%',
|
|
457
|
+
transform: 'translateX(-50%)',
|
|
458
|
+
marginBottom: 6,
|
|
459
|
+
zIndex: 20,
|
|
460
|
+
width: 260,
|
|
461
|
+
padding: '8px 10px',
|
|
462
|
+
borderRadius: 6,
|
|
463
|
+
background: nodeBg,
|
|
464
|
+
border: `1px solid ${DEPRECATED_COLOR}`,
|
|
465
|
+
boxShadow: '0 6px 20px rgba(0,0,0,0.35)',
|
|
466
|
+
fontFamily: theme.fonts.body,
|
|
467
|
+
fontSize: theme.fontSizes[1],
|
|
468
|
+
color: theme.colors.text,
|
|
469
|
+
textAlign: 'left',
|
|
470
|
+
whiteSpace: 'normal',
|
|
471
|
+
cursor: 'default',
|
|
472
|
+
}}
|
|
473
|
+
>
|
|
474
|
+
<div
|
|
475
|
+
style={{
|
|
476
|
+
fontWeight: 600,
|
|
477
|
+
marginBottom: 4,
|
|
478
|
+
color: DEPRECATED_COLOR,
|
|
479
|
+
}}
|
|
480
|
+
>
|
|
481
|
+
Deprecated
|
|
482
|
+
</div>
|
|
483
|
+
{removal ? (
|
|
484
|
+
<>
|
|
485
|
+
<div style={{ fontFamily: theme.fonts.monospace, marginBottom: 4 }}>
|
|
486
|
+
removed in {removal.commit}
|
|
487
|
+
</div>
|
|
488
|
+
{removal.reason && (
|
|
489
|
+
<div style={{ color: theme.colors.textSecondary }}>{removal.reason}</div>
|
|
490
|
+
)}
|
|
491
|
+
</>
|
|
492
|
+
) : (
|
|
493
|
+
<div style={{ color: theme.colors.textSecondary }}>
|
|
494
|
+
This claim's source was removed upstream.
|
|
495
|
+
</div>
|
|
496
|
+
)}
|
|
378
497
|
</div>
|
|
379
498
|
)}
|
|
380
499
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"c4Fixture.d.ts","sourceRoot":"","sources":["../../../../src/stories/Subsystem/C4Graph/c4Fixture.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAEvE,eAAO,MAAM,cAAc,EAAE,sBAE5B,CAAC"}
|