@principal-ai/subsystems-react 0.37.5 → 0.37.7
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 +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +3 -0
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +1 -0
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js +2 -0
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +1 -0
- package/dist/subsystem/C4Graph.d.ts +22 -0
- package/dist/subsystem/C4Graph.d.ts.map +1 -0
- package/dist/subsystem/C4Graph.js +305 -0
- package/dist/subsystem/C4Graph.js.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +7 -3
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/WalkthroughsPanel.d.ts +2 -0
- package/dist/subsystem/WalkthroughsPanel.d.ts.map +1 -1
- package/dist/subsystem/WalkthroughsPanel.js +114 -48
- package/dist/subsystem/WalkthroughsPanel.js.map +1 -1
- package/dist/subsystem/model.d.ts +2 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +6 -6
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +2 -1
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/toC4.d.ts +95 -0
- package/dist/subsystem/toC4.d.ts.map +1 -0
- package/dist/subsystem/toC4.js +196 -0
- package/dist/subsystem/toC4.js.map +1 -0
- package/dist/subsystem/walkthroughBrief.d.ts +18 -0
- package/dist/subsystem/walkthroughBrief.d.ts.map +1 -0
- package/dist/subsystem/walkthroughBrief.js +34 -0
- package/dist/subsystem/walkthroughBrief.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +6 -1
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +104 -0
- package/src/stories/Subsystem/C4Graph/c4Fixture.ts +6 -0
- package/src/subsystem/C4Graph.tsx +465 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +15 -3
- package/src/subsystem/WalkthroughsPanel.test.tsx +146 -0
- package/src/subsystem/WalkthroughsPanel.tsx +163 -60
- package/src/subsystem/model.ts +8 -6
- package/src/subsystem/nodes.tsx +2 -1
- package/src/subsystem/toC4.test.ts +121 -0
- package/src/subsystem/toC4.ts +277 -0
- package/src/subsystem/walkthroughBrief.test.ts +57 -0
- package/src/subsystem/walkthroughBrief.ts +40 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* toC4 — project a subsystem-model (or a composition of them) onto C4 levels.
|
|
3
|
+
*
|
|
4
|
+
* The stored document is flat: `components[]` carry `process` (runtime unit),
|
|
5
|
+
* `purl` (repo/package), `construct`, `role`, `module`, plus `relations[]`
|
|
6
|
+
* (static) and `walkthroughs[]` (dynamic). C4 wants a hierarchy above the
|
|
7
|
+
* component — system → container → component — with externals and actors
|
|
8
|
+
* outside it. This module derives that hierarchy without inventing data:
|
|
9
|
+
*
|
|
10
|
+
* system ← the repo key (purlRepoKey of the first grounded component)
|
|
11
|
+
* container ← component.process (runtime/deployment boundary)
|
|
12
|
+
* component ← a grounded component
|
|
13
|
+
* external ← construct: 'external'
|
|
14
|
+
* actor ← construct: 'custom_entity' (their authored actor/entity)
|
|
15
|
+
*
|
|
16
|
+
* Edges are rolled up to the owner of each endpoint at the chosen view, so the
|
|
17
|
+
* container view shows container↔container collaborations and the component
|
|
18
|
+
* view shows component↔component ones. Intra-owner edges (from === to) drop.
|
|
19
|
+
*
|
|
20
|
+
* Pure over the document so it is unit-testable and reusable by any renderer.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { SubsystemComponent, SubsystemModelDocument } from './model';
|
|
24
|
+
|
|
25
|
+
/** C4 levels plus the two "outside the system" concepts. */
|
|
26
|
+
export type C4Kind = 'system' | 'container' | 'component' | 'external' | 'actor';
|
|
27
|
+
|
|
28
|
+
/** Which projection to build: containers (level 2) or components (level 3). */
|
|
29
|
+
export type C4View = 'container' | 'component';
|
|
30
|
+
|
|
31
|
+
/** A C4 element the renderer can draw as a box. */
|
|
32
|
+
export interface C4Node {
|
|
33
|
+
id: string;
|
|
34
|
+
kind: C4Kind;
|
|
35
|
+
label: string;
|
|
36
|
+
/** Compound parent (container → system, component → container). */
|
|
37
|
+
parentId?: string;
|
|
38
|
+
/** Grouping key the node was derived from (process key, purl, alias). */
|
|
39
|
+
key?: string;
|
|
40
|
+
/** Source component aliases rolled into this node. */
|
|
41
|
+
members: string[];
|
|
42
|
+
/** Distinct constructs among the members (for a quick breakdown). */
|
|
43
|
+
constructs: string[];
|
|
44
|
+
/** True when any member is a `store` construct (a data store in C4). */
|
|
45
|
+
isStore: boolean;
|
|
46
|
+
/** Present only in the component view — the backing component. */
|
|
47
|
+
component?: SubsystemComponent;
|
|
48
|
+
/** Source model ids that contributed members, when attribution is supplied. */
|
|
49
|
+
models?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A compound frame (the system, or a container holding components). */
|
|
53
|
+
export interface C4Group {
|
|
54
|
+
id: string;
|
|
55
|
+
kind: 'system' | 'container';
|
|
56
|
+
label: string;
|
|
57
|
+
parentId?: string;
|
|
58
|
+
/** Member C4 node ids (or nested group ids for the system). */
|
|
59
|
+
memberIds: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A rolled-up collaboration. `relationship` = static, `flow` = walkthrough. */
|
|
63
|
+
export interface C4Edge {
|
|
64
|
+
id: string;
|
|
65
|
+
source: string;
|
|
66
|
+
target: string;
|
|
67
|
+
kind: 'relationship' | 'flow';
|
|
68
|
+
label: string;
|
|
69
|
+
mechanisms: string[];
|
|
70
|
+
count: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface C4Model {
|
|
74
|
+
view: C4View;
|
|
75
|
+
system: C4Node;
|
|
76
|
+
nodes: C4Node[];
|
|
77
|
+
groups: C4Group[];
|
|
78
|
+
edges: C4Edge[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ToC4Options {
|
|
82
|
+
view?: C4View;
|
|
83
|
+
/** Override the system title; defaults to the repo key's owner/name. */
|
|
84
|
+
systemLabel?: string;
|
|
85
|
+
/** Override the repo key used for the system id. */
|
|
86
|
+
repoKey?: string;
|
|
87
|
+
/** Model-id attribution per component alias (e.g. from a merge sidecar). */
|
|
88
|
+
modelsByAlias?: Record<string, string[]>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const EXTERNAL_PURL = 'external';
|
|
92
|
+
const UNASSIGNED = '(unassigned)';
|
|
93
|
+
|
|
94
|
+
/** A code component (not an external, not an actor/entity). */
|
|
95
|
+
export function isGroundedComponent(c: SubsystemComponent): boolean {
|
|
96
|
+
return c.construct !== 'external' && c.construct !== 'custom_entity';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `owner/name` from a purl, trimmed of scheme and fragment. */
|
|
100
|
+
export function labelFromPurl(purl: string): string {
|
|
101
|
+
const base = purl.replace(/^external:/, '').split('#')[0]?.trim() ?? '';
|
|
102
|
+
const parts = base.split('/').filter(Boolean);
|
|
103
|
+
return parts.slice(-2).join('/') || base || purl;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The repo key the system is derived from: first grounded component's purl. */
|
|
107
|
+
export function deriveRepoKey(doc: SubsystemModelDocument): string | undefined {
|
|
108
|
+
for (const c of doc.components) {
|
|
109
|
+
const p = (c.purl ?? '').split('#')[0]?.trim();
|
|
110
|
+
if (p && p !== EXTERNAL_PURL && !p.startsWith('external:')) return p;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Stable identity for an external: its real purl, else the model-local alias. */
|
|
116
|
+
function externalKey(c: SubsystemComponent): string {
|
|
117
|
+
const purl = (c.purl ?? '').trim();
|
|
118
|
+
if (purl && purl !== EXTERNAL_PURL && purl !== 'external:proposed') return purl;
|
|
119
|
+
return `alias:${c.alias}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function containerKey(c: SubsystemComponent): string {
|
|
123
|
+
return c.process?.trim() || UNASSIGNED;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Derive the C4 projection of a (possibly composed) subsystem document.
|
|
128
|
+
*
|
|
129
|
+
* @param doc - a portable document, or a composed one with canonical aliases.
|
|
130
|
+
* @param options - view + optional system naming + attribution.
|
|
131
|
+
*/
|
|
132
|
+
export function toC4(doc: SubsystemModelDocument, options: ToC4Options = {}): C4Model {
|
|
133
|
+
const view: C4View = options.view ?? 'container';
|
|
134
|
+
const repoKey = options.repoKey ?? deriveRepoKey(doc) ?? 'system';
|
|
135
|
+
const systemId = `system:${repoKey}`;
|
|
136
|
+
|
|
137
|
+
const system: C4Node = {
|
|
138
|
+
id: systemId,
|
|
139
|
+
kind: 'system',
|
|
140
|
+
label: options.systemLabel ?? labelFromPurl(repoKey),
|
|
141
|
+
key: repoKey,
|
|
142
|
+
members: [],
|
|
143
|
+
constructs: [],
|
|
144
|
+
isStore: false,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const nodes = new Map<string, C4Node>();
|
|
148
|
+
const ownerOf = new Map<string, string>();
|
|
149
|
+
|
|
150
|
+
const containerNodeId = (key: string) => `container:${key}`;
|
|
151
|
+
const componentNodeId = (alias: string) => `component:${alias}`;
|
|
152
|
+
|
|
153
|
+
for (const c of doc.components) {
|
|
154
|
+
let id: string;
|
|
155
|
+
let kind: C4Kind;
|
|
156
|
+
let label: string;
|
|
157
|
+
let parentId: string | undefined;
|
|
158
|
+
let key: string | undefined;
|
|
159
|
+
|
|
160
|
+
if (c.construct === 'external') {
|
|
161
|
+
key = externalKey(c);
|
|
162
|
+
id = `external:${key}`;
|
|
163
|
+
kind = 'external';
|
|
164
|
+
label = key.startsWith('alias:') ? c.name : labelFromPurl(key);
|
|
165
|
+
} else if (c.construct === 'custom_entity') {
|
|
166
|
+
key = c.alias;
|
|
167
|
+
id = `actor:${c.alias}`;
|
|
168
|
+
kind = 'actor';
|
|
169
|
+
label = c.name;
|
|
170
|
+
} else if (view === 'component') {
|
|
171
|
+
key = containerKey(c);
|
|
172
|
+
id = componentNodeId(c.alias);
|
|
173
|
+
kind = 'component';
|
|
174
|
+
label = c.name;
|
|
175
|
+
parentId = containerNodeId(key);
|
|
176
|
+
} else {
|
|
177
|
+
key = containerKey(c);
|
|
178
|
+
id = containerNodeId(key);
|
|
179
|
+
kind = 'container';
|
|
180
|
+
label = key === UNASSIGNED ? 'Unassigned' : key;
|
|
181
|
+
parentId = systemId;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
ownerOf.set(c.alias, id);
|
|
185
|
+
|
|
186
|
+
let node = nodes.get(id);
|
|
187
|
+
if (!node) {
|
|
188
|
+
node = {
|
|
189
|
+
id,
|
|
190
|
+
kind,
|
|
191
|
+
label,
|
|
192
|
+
parentId,
|
|
193
|
+
key,
|
|
194
|
+
members: [],
|
|
195
|
+
constructs: [],
|
|
196
|
+
isStore: false,
|
|
197
|
+
...(kind === 'component' ? { component: c } : {}),
|
|
198
|
+
};
|
|
199
|
+
nodes.set(id, node);
|
|
200
|
+
}
|
|
201
|
+
node.members.push(c.alias);
|
|
202
|
+
if (!node.constructs.includes(c.construct)) node.constructs.push(c.construct);
|
|
203
|
+
if (c.construct === 'store') node.isStore = true;
|
|
204
|
+
const models = options.modelsByAlias?.[c.alias];
|
|
205
|
+
if (models && models.length > 0) {
|
|
206
|
+
node.models = [...new Set([...(node.models ?? []), ...models])];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- Groups (compound frames) ------------------------------------------
|
|
211
|
+
const groups: C4Group[] = [];
|
|
212
|
+
if (view === 'container') {
|
|
213
|
+
const containerIds = [...nodes.values()].filter((n) => n.kind === 'container').map((n) => n.id);
|
|
214
|
+
if (containerIds.length > 0) {
|
|
215
|
+
groups.push({ id: systemId, kind: 'system', label: system.label, memberIds: containerIds });
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
const byContainer = new Map<string, string[]>();
|
|
219
|
+
for (const n of nodes.values()) {
|
|
220
|
+
if (n.kind !== 'component' || !n.parentId) continue;
|
|
221
|
+
const list = byContainer.get(n.parentId) ?? [];
|
|
222
|
+
list.push(n.id);
|
|
223
|
+
byContainer.set(n.parentId, list);
|
|
224
|
+
}
|
|
225
|
+
const containerIds: string[] = [];
|
|
226
|
+
for (const [id, memberIds] of byContainer) {
|
|
227
|
+
const label = id.slice('container:'.length);
|
|
228
|
+
groups.push({ id, kind: 'container', label: label === UNASSIGNED ? 'Unassigned' : label, parentId: systemId, memberIds });
|
|
229
|
+
containerIds.push(id);
|
|
230
|
+
}
|
|
231
|
+
if (containerIds.length > 0) {
|
|
232
|
+
groups.push({ id: systemId, kind: 'system', label: system.label, memberIds: containerIds });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// --- Edges: roll each endpoint up to its owner at this view -------------
|
|
237
|
+
const relEdges = new Map<string, C4Edge>();
|
|
238
|
+
const flowEdges = new Map<string, C4Edge>();
|
|
239
|
+
|
|
240
|
+
const addEdge = (
|
|
241
|
+
bucket: Map<string, C4Edge>,
|
|
242
|
+
source: string | undefined,
|
|
243
|
+
target: string | undefined,
|
|
244
|
+
kind: C4Edge['kind'],
|
|
245
|
+
mechanism: string,
|
|
246
|
+
) => {
|
|
247
|
+
if (!source || !target || source === target) return;
|
|
248
|
+
const key = `${source}\0${target}`;
|
|
249
|
+
let edge = bucket.get(key);
|
|
250
|
+
if (!edge) {
|
|
251
|
+
edge = { id: `${kind}:${key}`, source, target, kind, label: '', mechanisms: [], count: 0 };
|
|
252
|
+
bucket.set(key, edge);
|
|
253
|
+
}
|
|
254
|
+
edge.count += 1;
|
|
255
|
+
if (!edge.mechanisms.includes(mechanism)) edge.mechanisms.push(mechanism);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
for (const r of doc.relations ?? []) {
|
|
259
|
+
addEdge(relEdges, ownerOf.get(r.from), ownerOf.get(r.to), 'relationship', r.relationType);
|
|
260
|
+
}
|
|
261
|
+
for (const w of doc.walkthroughs ?? []) {
|
|
262
|
+
for (const s of w.steps ?? []) {
|
|
263
|
+
addEdge(flowEdges, ownerOf.get(s.from), ownerOf.get(s.to), 'flow', s.mechanism);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const edges = [...relEdges.values(), ...flowEdges.values()];
|
|
268
|
+
for (const e of edges) e.label = [...e.mechanisms].sort().join(' + ');
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
view,
|
|
272
|
+
system,
|
|
273
|
+
nodes: [...nodes.values()].filter((n) => n.kind !== 'system'),
|
|
274
|
+
groups,
|
|
275
|
+
edges,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { buildStepBrief } from './walkthroughBrief';
|
|
3
|
+
import type { SubsystemWalkthrough } from './model';
|
|
4
|
+
|
|
5
|
+
const walkthrough: SubsystemWalkthrough = {
|
|
6
|
+
id: 'auth-flow',
|
|
7
|
+
title: 'Auth flow',
|
|
8
|
+
steps: [
|
|
9
|
+
{
|
|
10
|
+
from: 'ui',
|
|
11
|
+
to: 'api',
|
|
12
|
+
mechanism: 'calls',
|
|
13
|
+
file: 'src/ui/login.tsx',
|
|
14
|
+
line: 42,
|
|
15
|
+
purl: 'pkg:github/acme/app',
|
|
16
|
+
symbol: 'Login.submit',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
from: 'api',
|
|
20
|
+
to: 'store',
|
|
21
|
+
mechanism: 'writes',
|
|
22
|
+
file: 'src/api/session.ts',
|
|
23
|
+
line: 7,
|
|
24
|
+
purl: 'pkg:github/acme/app',
|
|
25
|
+
symbol: 'Session.create',
|
|
26
|
+
annotation: 'session row is written here',
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('buildStepBrief', () => {
|
|
32
|
+
test('includes flow identity, position, and seam anchors', () => {
|
|
33
|
+
const brief = buildStepBrief(walkthrough, 0);
|
|
34
|
+
expect(brief).toContain('Walkthrough: Auth flow (auth-flow) — step 1/2');
|
|
35
|
+
expect(brief).toContain('- from: `ui`');
|
|
36
|
+
expect(brief).toContain('- to: `api`');
|
|
37
|
+
expect(brief).toContain('- mechanism: `calls`');
|
|
38
|
+
expect(brief).toContain('- symbol: `Login.submit`');
|
|
39
|
+
expect(brief).toContain('- site: `src/ui/login.tsx:42`');
|
|
40
|
+
expect(brief).toContain('- purl: `pkg:github/acme/app`');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('omits the note when the step has no annotation', () => {
|
|
44
|
+
expect(buildStepBrief(walkthrough, 0)).not.toContain('- note:');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('includes the note when the step has an annotation', () => {
|
|
48
|
+
const brief = buildStepBrief(walkthrough, 1);
|
|
49
|
+
expect(brief).toContain('Walkthrough: Auth flow (auth-flow) — step 2/2');
|
|
50
|
+
expect(brief).toContain('- note: session row is written here');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('returns an empty string for an out-of-range index', () => {
|
|
54
|
+
expect(buildStepBrief(walkthrough, 9)).toBe('');
|
|
55
|
+
expect(buildStepBrief(walkthrough, -1)).toBe('');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* walkthroughBrief — build a paste-ready markdown brief for a single
|
|
3
|
+
* walkthrough step so a reviewer can hand one hop to an agent without
|
|
4
|
+
* transcribing it by hand.
|
|
5
|
+
*
|
|
6
|
+
* The unit of handoff is one hop, not the whole flow. A bare hop is ambiguous
|
|
7
|
+
* on its own — the same `from`/`to`/`mechanism` can repeat across flows — so the
|
|
8
|
+
* brief also carries the flow it belongs to and its position within it, plus
|
|
9
|
+
* the exact seam site (`symbol`, `file:line`, `purl`) that resolves the
|
|
10
|
+
* checkout.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SubsystemWalkthrough } from './model';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Markdown brief for `walkthrough.steps[stepIndex]`. Returns `''` when the
|
|
17
|
+
* index is out of range so a caller can treat it as "nothing to copy".
|
|
18
|
+
*/
|
|
19
|
+
export function buildStepBrief(
|
|
20
|
+
walkthrough: SubsystemWalkthrough,
|
|
21
|
+
stepIndex: number,
|
|
22
|
+
): string {
|
|
23
|
+
const step = walkthrough.steps[stepIndex];
|
|
24
|
+
if (!step) return '';
|
|
25
|
+
const lines: string[] = [];
|
|
26
|
+
lines.push(
|
|
27
|
+
`Walkthrough: ${walkthrough.title} (${walkthrough.id}) — step ${stepIndex + 1}/${walkthrough.steps.length}`,
|
|
28
|
+
);
|
|
29
|
+
lines.push('');
|
|
30
|
+
lines.push(`- from: \`${step.from}\``);
|
|
31
|
+
lines.push(`- to: \`${step.to}\``);
|
|
32
|
+
lines.push(`- mechanism: \`${step.mechanism}\``);
|
|
33
|
+
lines.push(`- symbol: \`${step.symbol}\``);
|
|
34
|
+
lines.push(`- site: \`${step.file}:${step.line}\``);
|
|
35
|
+
lines.push(`- purl: \`${step.purl}\``);
|
|
36
|
+
if (step.annotation != null && step.annotation.length > 0) {
|
|
37
|
+
lines.push(`- note: ${step.annotation}`);
|
|
38
|
+
}
|
|
39
|
+
return lines.join('\n');
|
|
40
|
+
}
|