@principal-ai/principal-view-react 0.16.21 → 0.16.23

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.
Files changed (47) hide show
  1. package/dist/components/GraphRenderer.js.map +1 -1
  2. package/dist/components/session-events/SessionEventFeed.d.ts.map +1 -1
  3. package/dist/components/session-events/SessionEventFeed.js +10 -3
  4. package/dist/components/session-events/SessionEventFeed.js.map +1 -1
  5. package/dist/graphify/consolidated.d.ts +170 -0
  6. package/dist/graphify/consolidated.d.ts.map +1 -0
  7. package/dist/graphify/consolidated.js +11 -0
  8. package/dist/graphify/consolidated.js.map +1 -0
  9. package/dist/graphify/index.d.ts +9 -0
  10. package/dist/graphify/index.d.ts.map +1 -0
  11. package/dist/graphify/index.js +8 -0
  12. package/dist/graphify/index.js.map +1 -0
  13. package/dist/graphify/types.d.ts +141 -0
  14. package/dist/graphify/types.d.ts.map +1 -0
  15. package/dist/graphify/types.js +8 -0
  16. package/dist/graphify/types.js.map +1 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/subsystem/SubsystemComponentGraph.d.ts +28 -0
  20. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -0
  21. package/dist/subsystem/SubsystemComponentGraph.js +365 -0
  22. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -0
  23. package/dist/subsystem/model.d.ts +131 -0
  24. package/dist/subsystem/model.d.ts.map +1 -0
  25. package/dist/subsystem/model.js +296 -0
  26. package/dist/subsystem/model.js.map +1 -0
  27. package/dist/subsystem/nodes.d.ts +28 -0
  28. package/dist/subsystem/nodes.d.ts.map +1 -0
  29. package/dist/subsystem/nodes.js +230 -0
  30. package/dist/subsystem/nodes.js.map +1 -0
  31. package/dist/utils/elkLayout.d.ts +19 -0
  32. package/dist/utils/elkLayout.d.ts.map +1 -1
  33. package/dist/utils/elkLayout.js +72 -9
  34. package/dist/utils/elkLayout.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/components/GraphRenderer.tsx +2 -2
  37. package/src/components/session-events/SessionEventFeed.tsx +20 -3
  38. package/src/graphify/consolidated.ts +202 -0
  39. package/src/graphify/index.ts +36 -0
  40. package/src/graphify/types.ts +164 -0
  41. package/src/index.ts +31 -0
  42. package/src/stories/SubsystemComponentGraph.stories.tsx +761 -0
  43. package/src/subsystem/SubsystemComponentGraph.tsx +585 -0
  44. package/src/subsystem/model.test.ts +81 -0
  45. package/src/subsystem/model.ts +434 -0
  46. package/src/subsystem/nodes.tsx +353 -0
  47. package/src/utils/elkLayout.ts +98 -9
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Subsystem component-graph data model + converters.
3
+ *
4
+ * A subsystem snapshot (see the "Subsystem artifact: facets" topic) is captured
5
+ * as component nodes (kind-tagged source units), file refs, integration edges,
6
+ * and entry points. This module defines the minimal document shape for the
7
+ * *graph* facet and converts it to React Flow nodes/edges — packages render as
8
+ * subgraphs, only cross-package edges leave the box, and shared seams
9
+ * (registries/barrels/facades) are edge targets, never member nodes.
10
+ *
11
+ * Self-contained in this package (not yet promoted to `@principal-ai/core`), so
12
+ * we can iterate on the UI + story without publishing a dependency.
13
+ */
14
+
15
+ import {
16
+ MarkerType,
17
+ type Edge,
18
+ type Node,
19
+ } from '@xyflow/react';
20
+ import { computeElkLayout } from '../utils/elkLayout';
21
+ import type { GraphifyComponentDetail } from '../graphify';
22
+
23
+ export type SubsystemComponentKind =
24
+ | 'class'
25
+ | 'module'
26
+ | 'script'
27
+ | 'registry'
28
+ | 'service'
29
+ | 'consumer'
30
+ | 'function'
31
+ | 'method'
32
+ | 'type'
33
+ | 'package';
34
+
35
+ export type SubsystemEdgeMechanism =
36
+ | 'imports'
37
+ | 'imports_from'
38
+ | 're_exports'
39
+ | 'defines'
40
+ | 'calls'
41
+ | 'extends'
42
+ | 'inherits'
43
+ | 'implements'
44
+ | 'mixes_in'
45
+ | 'uses'
46
+ | 'method'
47
+ | 'references'
48
+ | 'contains'
49
+ | 'feeds'
50
+ | 'produces'
51
+ | 'registers-into';
52
+
53
+ /** A component node — the named unit, kind-tagged; `file` is its location. */
54
+ export interface SubsystemComponent {
55
+ id: string;
56
+ name: string;
57
+ kind: SubsystemComponentKind;
58
+ /** Source location the component lives in (repo-root-relative path). */
59
+ file: string;
60
+ /** PURL identifying the repo or package this component lives in (for subgraph grouping). */
61
+ purl: string;
62
+ /** One-line purpose shown on the node. */
63
+ purpose?: string;
64
+ /** A symbol this component exposes / is (the node's identity). */
65
+ symbol?: string;
66
+ /**
67
+ * Semantic layer/phase for the layout (e.g. `1` = input, `2` = processing,
68
+ * `3` = output). When set, ELK places the component in this layer so the
69
+ * graph reads as a left-to-right pipeline and *conveys the idea* rather than
70
+ * letting ELK guess the order.
71
+ */
72
+ layer?: number;
73
+ /**
74
+ * How this session *occupied* the component — `edited` (modified it) vs
75
+ * `analyzed` (read deeply / built an understanding) vs `referenced`
76
+ * (touched in passing). Drives clustering: edits strengthen a subsystem,
77
+ * but a read-only investigation can still form one around an analyzed seam.
78
+ */
79
+ capture?: 'edited' | 'analyzed' | 'referenced';
80
+ /**
81
+ * Graphify drill-down detail for this component, when the facet is anchored
82
+ * to a graphify node. Discriminated by kind (class/function/type/module/
83
+ * external); rendered in the detail panel on click.
84
+ */
85
+ detail?: GraphifyComponentDetail;
86
+ }
87
+
88
+ /** A cross-component edge in the subsystem graph. */
89
+ export interface SubsystemComponentEdge {
90
+ id: string;
91
+ from: string; // component id
92
+ to: string; // component id or external target label
93
+ mechanism: SubsystemEdgeMechanism;
94
+ /** Concrete file/symbol refs backing the edge (the seam). */
95
+ refs?: string[];
96
+ }
97
+
98
+ /**
99
+ * Derive a consistent display `name` from a code `symbol` + kind.
100
+ *
101
+ * `symbol` is the source of truth (fully-qualified code identity). For most
102
+ * kinds the name is the symbol itself or its last dotted segment:
103
+ * - class/type/module/function/script/... symbol → symbol (e.g. `SessionReader`)
104
+ * - method `Owner.method` → last segment (e.g. `SessionReader.normalize` → `normalize`)
105
+ * - module, no symbol → basename of `file` (e.g. `transcript.ts` → `transcript`) —
106
+ * a common-sense convention for whole-file modules, not a real TS name
107
+ * - otherwise no symbol → fall back to an existing name
108
+ */
109
+ export function deriveNameFromSymbol(
110
+ symbol: string | undefined,
111
+ kind: SubsystemComponentKind,
112
+ existingName?: string,
113
+ file?: string,
114
+ ): string {
115
+ if (symbol && symbol.trim()) {
116
+ if (kind === 'method') {
117
+ const idx = symbol.lastIndexOf('.');
118
+ return idx >= 0 ? symbol.slice(idx + 1) : symbol;
119
+ }
120
+ return symbol;
121
+ }
122
+ // Module nodes without a symbol are whole files → use the file basename.
123
+ if (kind === 'module' && file) {
124
+ const base = file.split('/').pop() ?? '';
125
+ const clean = base.replace(/\.[^.]+$/, ''); // strip extension
126
+ if (clean) return clean;
127
+ }
128
+ return existingName ?? 'untitled';
129
+ }
130
+
131
+ export interface SubsystemGraphDocument {
132
+ components: SubsystemComponent[];
133
+ edges: SubsystemComponentEdge[];
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // React Flow conversion
138
+ // ---------------------------------------------------------------------------
139
+
140
+ export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
141
+
142
+ export interface SubsystemGraphNodeData extends Record<string, unknown> {
143
+ component: SubsystemComponent;
144
+ }
145
+
146
+ export type SubsystemGraphNode = Node<SubsystemGraphNodeData, SubsystemGraphNodeType>;
147
+
148
+ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
149
+ mechanism: SubsystemEdgeMechanism;
150
+ refs?: string[];
151
+ /** True while another edge is selected — render this edge (and its label)
152
+ * dimmed to focus the selected relationship. */
153
+ dimmed?: boolean;
154
+ /** ELK-computed label midpoint (from the actual edge path, not node centers). */
155
+ labelX?: number;
156
+ labelY?: number;
157
+ /** ELK-computed SVG edge path (overrides React Flow's default path). */
158
+ elkPath?: string;
159
+ }
160
+
161
+ export type SubsystemGraphEdge = Edge<SubsystemGraphEdgeData>;
162
+
163
+ export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
164
+ imports: '#0893d2', // blue
165
+ imports_from: '#5aa9e6', // light blue
166
+ re_exports: '#3aa5c9', // cyan-blue
167
+ defines: '#2e86ab', // steel blue
168
+ calls: '#4ec9b0', // teal
169
+ extends: '#b48ead', // purple
170
+ inherits: '#9b6fd0', // purple
171
+ implements: '#c586c0', // magenta
172
+ mixes_in: '#d474a8', // pink-magenta
173
+ uses: '#e3b341', // gold
174
+ method: '#c586c0', // magenta
175
+ references: '#e07a5f', // terracotta
176
+ contains: '#6c5ce7', // indigo
177
+ feeds: '#22c55e', // green — data-flow into a processor
178
+ produces: '#e07a5f', // terracotta — emits an output type
179
+ 'registers-into': '#ff6b35', // orange
180
+ };
181
+
182
+ const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed' | 'dotted'> = {
183
+ imports: 'solid',
184
+ imports_from: 'solid',
185
+ re_exports: 'solid',
186
+ defines: 'solid',
187
+ calls: 'solid',
188
+ extends: 'dashed',
189
+ inherits: 'dashed',
190
+ implements: 'dashed',
191
+ mixes_in: 'dashed',
192
+ uses: 'solid',
193
+ method: 'solid',
194
+ references: 'dotted',
195
+ contains: 'solid',
196
+ feeds: 'solid',
197
+ produces: 'solid',
198
+ 'registers-into': 'dashed',
199
+ };
200
+
201
+ /** Package color palette (derived deterministically from the package name). */
202
+ export function packageColor(name: string): string {
203
+ const palette = [
204
+ '#0893d2',
205
+ '#4ec9b0',
206
+ '#ff6b35',
207
+ '#b48ead',
208
+ '#e3b341',
209
+ '#5aa9e6',
210
+ ];
211
+ let h = 0;
212
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
213
+ return palette[h % palette.length];
214
+ }
215
+
216
+ /** Color per component kind — the informative signal (rather than package). */
217
+ export const KIND_COLOR: Record<SubsystemComponentKind, string> = {
218
+ class: '#0893d2', // blue
219
+ module: '#4ec9b0', // teal
220
+ script: '#ff6b35', // orange
221
+ registry: '#b48ead', // purple
222
+ service: '#e3b341', // gold
223
+ consumer: '#5aa9e6', // light blue
224
+ function: '#6c5ce7', // indigo (distinct from module teal + script orange)
225
+ method: '#c586c0', // magenta
226
+ type: '#e07a5f', // terracotta
227
+ package: '#2e86ab', // steel blue (an npm package container)
228
+ };
229
+
230
+ /**
231
+ * Convert a subsystem graph document into React Flow nodes. We render **flat**
232
+ * (no React Flow parent/group nodes) for robustness: package regions are laid
233
+ * out in a grid and each component carries its package + a `pkgBounds`
234
+ * rectangle on its node data so the group wrapper (drawn by the graph
235
+ * component) can frame it. Only components' real positions matter to React
236
+ * Flow; the package boundary is a visual region, not a sub-flow node.
237
+ */
238
+ export function convertSubsystemToNodes(
239
+ doc: SubsystemGraphDocument,
240
+ opts: { maxNodeWidth?: number } = {},
241
+ ): SubsystemGraphNode[] {
242
+ const { maxNodeWidth } = opts;
243
+ // Group components by package.
244
+ const byPkg = new Map<string, SubsystemComponent[]>();
245
+ for (const c of doc.components) {
246
+ const list = byPkg.get(c.purl) ?? [];
247
+ list.push(c);
248
+ byPkg.set(c.purl, list);
249
+ }
250
+
251
+ const nodes: SubsystemGraphNode[] = [];
252
+ const COLS = 2;
253
+ const COL_W = 240;
254
+ const ROW_H = 150;
255
+ const PAD = 30;
256
+ const GROUP_GAP = 60;
257
+
258
+ let cursorY = PAD;
259
+ for (const comps of byPkg.values()) {
260
+ const rows = Math.ceil(comps.length / COLS);
261
+ const heightPx = ROW_H * rows;
262
+
263
+ comps.forEach((c, i) => {
264
+ const col = i % COLS;
265
+ const row = Math.floor(i / COLS);
266
+ const isPkg = c.kind === 'package';
267
+ // Estimate rendered node width from the longest text line so ELK reserves
268
+ // the right space (names can wrap, so we cap at the configurable max).
269
+ const text = [c.symbol, c.name, c.file.split('/').pop() ?? '']
270
+ .filter((t): t is string => !!t)
271
+ .sort((a, b) => b.length - a.length)[0];
272
+ const cap = maxNodeWidth ?? (isPkg ? 320 : 300);
273
+ const textWidth = Math.min(cap, Math.max(60, (text?.length ?? 10) * 8));
274
+ // Account for CSS minWidth and padding/border so ELK's port positions match
275
+ // the actual rendered node boundaries.
276
+ const cssMinWidth = isPkg ? 200 : 150;
277
+ const cssPadding = isPkg ? 24 : 20; // horizontal padding (left + right)
278
+ const cssBorder = 4; // 2px border each side
279
+ const rawWidth = Math.max(cssMinWidth, (isPkg ? Math.max(220, textWidth) : textWidth) + cssPadding + cssBorder);
280
+ const nodeWidth = Math.max(cssMinWidth, Math.min(cap, rawWidth));
281
+ nodes.push({
282
+ id: c.id,
283
+ type: 'subsystem-component',
284
+ position: { x: PAD + col * COL_W, y: cursorY + row * ROW_H },
285
+ width: nodeWidth,
286
+ height: isPkg ? 100 : 84,
287
+ data: { component: c },
288
+ });
289
+ });
290
+ cursorY += heightPx + PAD * 2 + GROUP_GAP;
291
+ }
292
+ return nodes;
293
+ }
294
+
295
+ /**
296
+ * Convert a subsystem graph document into React Flow edges. Edges whose target
297
+ * is an external label (not a component id) point at a synthetic stub so the
298
+ * relationship is visible without a member node.
299
+ */
300
+ export function convertSubsystemToEdges(doc: SubsystemGraphDocument): SubsystemGraphEdge[] {
301
+ const compIds = new Set(doc.components.map((c) => c.id));
302
+ const edges: SubsystemGraphEdge[] = [];
303
+
304
+ for (const e of doc.edges) {
305
+ const color = MECHANISM_COLOR[e.mechanism];
306
+ const style = MECHANISM_STYLE[e.mechanism];
307
+ // If `to` is a real component, connect directly; otherwise point at a stub node.
308
+ const isExternal = !compIds.has(e.to);
309
+ const targetId = isExternal ? `external:${e.to}` : e.to;
310
+
311
+ edges.push({
312
+ id: e.id,
313
+ source: e.from,
314
+ target: targetId,
315
+ data: { mechanism: e.mechanism, refs: e.refs },
316
+ type: 'subsystem-edge',
317
+ markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 },
318
+ style: { color, stroke: color, strokeDasharray: style === 'dashed' ? '6 4' : undefined },
319
+ // `label` feeds ELK's label-space reservation only; the visible label is
320
+ // rendered by the custom SubsystemEdge as an HTML overlay.
321
+ label: e.mechanism,
322
+ // Mechanism label rendered via the custom SubsystemEdge (an HTML overlay
323
+ // above the SVG edges, so it can't be hidden behind other edge lines).
324
+ });
325
+ }
326
+ return edges;
327
+ }
328
+
329
+ /**
330
+ * Build the full React Flow graph (nodes + edges) using **ELK auto-layout** to
331
+ * position every node (including external stub targets), so the graph is
332
+ * layered with minimized crossings.
333
+ */
334
+ export async function buildSubsystemGraph(
335
+ doc: SubsystemGraphDocument,
336
+ opts: { maxNodeWidth?: number; showEdgeLabels?: boolean; measuredWidths?: Map<string, number>; measuredHeights?: Map<string, number> } = {},
337
+ ): Promise<{
338
+ nodes: SubsystemGraphNode[];
339
+ edges: SubsystemGraphEdge[];
340
+ }> {
341
+ const { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights } = opts;
342
+ const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
343
+ const edges = convertSubsystemToEdges(doc);
344
+
345
+ // External edge targets that aren't real components → create stub nodes so
346
+ // cross-package edges have something to land on.
347
+ const realIds = new Set(doc.components.map((c) => c.id));
348
+ const externalIds: string[] = [];
349
+ for (const e of doc.edges) {
350
+ if (!realIds.has(e.to)) {
351
+ const extId = `external:${e.to}`;
352
+ if (!externalIds.includes(extId)) externalIds.push(extId);
353
+ }
354
+ }
355
+ for (const extId of externalIds) {
356
+ const label = extId.replace(/^external:/, '');
357
+ const extTextWidth = Math.min(300, Math.max(150, label.length * 8));
358
+ nodes.push({
359
+ id: extId,
360
+ type: 'subsystem-component',
361
+ position: { x: 0, y: 0 },
362
+ width: Math.max(150, extTextWidth + 24),
363
+ height: 60,
364
+ data: {
365
+ component: {
366
+ id: extId,
367
+ name: label,
368
+ kind: 'consumer',
369
+ purl: 'external',
370
+ file: '',
371
+ purpose: 'cross-package integration target (not a member node)',
372
+ },
373
+ },
374
+ draggable: false,
375
+ });
376
+ }
377
+
378
+ // Apply measured dimensions (second pass) so ELK gets the real node sizes.
379
+ if (measuredWidths && measuredWidths.size > 0) {
380
+ for (const n of nodes) {
381
+ const mw = measuredWidths.get(n.id);
382
+ if (mw) n.width = mw;
383
+ }
384
+ }
385
+ if (measuredHeights && measuredHeights.size > 0) {
386
+ for (const n of nodes) {
387
+ const mh = measuredHeights.get(n.id);
388
+ if (mh) n.height = mh;
389
+ }
390
+ }
391
+
392
+ // ELK auto-layout: position nodes (layered, minimized crossings).
393
+ let placedNodes = nodes;
394
+ let labelPositions = new Map<string, { x: number; y: number }>();
395
+ let elkPathStrings = new Map<string, string>();
396
+ if (nodes.length > 0) {
397
+ try {
398
+ const result = await computeElkLayout(nodes, edges, {
399
+ routingStyle: 'orthogonal',
400
+ direction: 'RIGHT',
401
+ nodeSpacing: 60,
402
+ edgeSpacing: 30,
403
+ edgeNodeSpacing: 60,
404
+ interLayerSpacing: 120,
405
+ preserveNodePositions: false,
406
+ edgeLabels: showEdgeLabels === false ? { enabled: false } : { enabled: true, placement: 'CENTER' },
407
+ });
408
+ placedNodes = result.nodes as SubsystemGraphNode[];
409
+ labelPositions = result.edgeLabelPositions;
410
+ elkPathStrings = result.edgePaths;
411
+ } catch (err) {
412
+ // Fall back to the (unpositioned) grid if ELK is unavailable.
413
+ console.warn('[subsystem-graph] ELK layout failed, using manual positions:', err);
414
+ }
415
+ }
416
+
417
+ // Stamp ELK-computed label midpoints onto edge data so the overlay can
418
+ // position labels at the actual path midpoint (not the node-center midpoint).
419
+ for (const e of edges) {
420
+ const pos = labelPositions.get(e.id);
421
+ if (pos) {
422
+ const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
423
+ d.labelX = pos.x;
424
+ d.labelY = pos.y;
425
+ }
426
+ const elkP = elkPathStrings.get(e.id);
427
+ if (elkP) {
428
+ const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
429
+ d.elkPath = elkP;
430
+ }
431
+ }
432
+
433
+ return { nodes: placedNodes, edges };
434
+ }