@principal-ai/subsystems-react 0.23.0 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreWalkthroughCodeView.d.ts +2 -1
  6. package/dist/pierre/PierreWalkthroughCodeView.d.ts.map +1 -1
  7. package/dist/pierre/PierreWalkthroughCodeView.js +4 -4
  8. package/dist/pierre/PierreWalkthroughCodeView.js.map +1 -1
  9. package/dist/pierre/constructColors.d.ts +2 -3
  10. package/dist/pierre/constructColors.d.ts.map +1 -1
  11. package/dist/pierre/constructColors.js +31 -21
  12. package/dist/pierre/constructColors.js.map +1 -1
  13. package/dist/subsystem/SubsystemComponentGraph.d.ts +1 -1
  14. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  15. package/dist/subsystem/SubsystemComponentGraph.js +8 -6
  16. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  17. package/dist/subsystem/model.d.ts +69 -23
  18. package/dist/subsystem/model.d.ts.map +1 -1
  19. package/dist/subsystem/model.js +184 -40
  20. package/dist/subsystem/model.js.map +1 -1
  21. package/dist/subsystem/nodes.d.ts.map +1 -1
  22. package/dist/subsystem/nodes.js +53 -25
  23. package/dist/subsystem/nodes.js.map +1 -1
  24. package/package.json +3 -3
  25. package/src/index.ts +2 -0
  26. package/src/pierre/PierreWalkthroughCodeView.tsx +12 -5
  27. package/src/pierre/constructColors.test.ts +2 -2
  28. package/src/pierre/constructColors.ts +41 -23
  29. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +2 -1
  30. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +1 -0
  31. package/src/stories/Subsystem/ComponentGraph/Packages.stories.tsx +242 -0
  32. package/src/subsystem/SubsystemComponentGraph.tsx +23 -7
  33. package/src/subsystem/model.test.ts +167 -0
  34. package/src/subsystem/model.ts +236 -46
  35. package/src/subsystem/nodes.tsx +55 -26
@@ -0,0 +1,242 @@
1
+ import '@xyflow/react/dist/style.css';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
4
+ import { SubsystemComponentGraph } from '../../../subsystem/SubsystemComponentGraph';
5
+ import type { SubsystemComponent } from '../../../subsystem/model';
6
+ import { graphSpecFromEdges } from './fixtures';
7
+
8
+ const meta = {
9
+ title: 'Subsystem/ComponentGraph/Packages',
10
+ component: SubsystemComponentGraph,
11
+ parameters: { layout: 'fullscreen' },
12
+ tags: ['autodocs'],
13
+ decorators: [
14
+ (Story) => (
15
+ <ThemeProvider theme={defaultEditorTheme}>
16
+ <Story />
17
+ </ThemeProvider>
18
+ ),
19
+ ],
20
+ } satisfies Meta<typeof SubsystemComponentGraph>;
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ const APP = 'pkg:github/acme/app';
26
+ const LIB = 'pkg:github/acme/lib';
27
+
28
+ /**
29
+ * Package frames are derived from `purl` when a graph spans multiple repos.
30
+ * No separate `package` field — single-repo graphs stay unframed at this level.
31
+ */
32
+ const multiRepoComponents: SubsystemComponent[] = [
33
+ {
34
+ id: 'dispatch',
35
+ name: 'dispatch',
36
+ construct: 'function',
37
+ symbol: 'dispatch',
38
+ file: 'src/host/dispatch.ts',
39
+ module: 'src/host/dispatch.ts',
40
+ process: 'app/host',
41
+ purl: APP,
42
+ purpose: 'host entry',
43
+ layer: 1,
44
+ },
45
+ {
46
+ id: 'session',
47
+ name: 'SessionService',
48
+ construct: 'class',
49
+ symbol: 'SessionService',
50
+ file: 'src/host/session.ts',
51
+ module: 'src/host/session.ts',
52
+ process: 'app/host',
53
+ purl: APP,
54
+ purpose: 'session state',
55
+ layer: 2,
56
+ },
57
+ {
58
+ id: 'parse',
59
+ name: 'parseEvent',
60
+ construct: 'function',
61
+ symbol: 'parseEvent',
62
+ file: 'src/parse.ts',
63
+ module: 'src/parse.ts',
64
+ process: 'lib/worker',
65
+ purl: LIB,
66
+ purpose: 'shared parser',
67
+ layer: 1,
68
+ },
69
+ {
70
+ id: 'normalize',
71
+ name: 'normalize',
72
+ construct: 'function',
73
+ symbol: 'normalize',
74
+ file: 'src/normalize.ts',
75
+ module: 'src/normalize.ts',
76
+ process: 'lib/worker',
77
+ purl: LIB,
78
+ purpose: 'normalize payloads',
79
+ layer: 2,
80
+ },
81
+ ];
82
+
83
+ const multiRepoEdges = graphSpecFromEdges([
84
+ ['dispatch', 'session', 'calls'],
85
+ ['dispatch', 'parse', 'calls'],
86
+ ['parse', 'normalize', 'calls'],
87
+ ]);
88
+
89
+ export const MultiRepoPackageFrames: Story = {
90
+ name: 'Multi-repo package frames',
91
+ args: {
92
+ components: multiRepoComponents,
93
+ relations: multiRepoEdges.relations,
94
+ walkthroughs: multiRepoEdges.walkthroughs,
95
+ },
96
+ render: (args) => (
97
+ <div style={{ width: '100vw', height: '100vh' }}>
98
+ <SubsystemComponentGraph {...args} />
99
+ </div>
100
+ ),
101
+ };
102
+
103
+ /** Same components, one purl — no package frames (process/module only). */
104
+ const singleRepo = multiRepoComponents.map((c) => ({ ...c, purl: APP }));
105
+
106
+ export const SingleRepoNoPackageFrames: Story = {
107
+ name: 'Single-repo (no package frames)',
108
+ args: {
109
+ components: singleRepo,
110
+ relations: multiRepoEdges.relations,
111
+ walkthroughs: multiRepoEdges.walkthroughs,
112
+ },
113
+ render: (args) => (
114
+ <div style={{ width: '100vw', height: '100vh' }}>
115
+ <SubsystemComponentGraph {...args} />
116
+ </div>
117
+ ),
118
+ };
119
+
120
+ /**
121
+ * Full nest: package → process → module → leaves.
122
+ * Each module path has 2+ exports so module frames survive the singleton rule.
123
+ */
124
+ const nestedComponents: SubsystemComponent[] = [
125
+ {
126
+ id: 'dispatch',
127
+ name: 'dispatch',
128
+ construct: 'function',
129
+ symbol: 'dispatch',
130
+ file: 'src/host/main.ts',
131
+ module: 'src/host/main.ts',
132
+ process: 'app/host',
133
+ purl: APP,
134
+ purpose: 'host entry',
135
+ layer: 1,
136
+ },
137
+ {
138
+ id: 'boot',
139
+ name: 'boot',
140
+ construct: 'function',
141
+ symbol: 'boot',
142
+ file: 'src/host/main.ts',
143
+ module: 'src/host/main.ts',
144
+ process: 'app/host',
145
+ purl: APP,
146
+ purpose: 'host bootstrap',
147
+ layer: 1,
148
+ },
149
+ {
150
+ id: 'session',
151
+ name: 'SessionService',
152
+ construct: 'class',
153
+ symbol: 'SessionService',
154
+ file: 'src/host/session.ts',
155
+ module: 'src/host/session.ts',
156
+ process: 'app/host',
157
+ purl: APP,
158
+ purpose: 'session state',
159
+ layer: 2,
160
+ },
161
+ {
162
+ id: 'session-types',
163
+ name: 'SessionRecord',
164
+ construct: 'type_alias',
165
+ symbol: 'SessionRecord',
166
+ file: 'src/host/session.ts',
167
+ module: 'src/host/session.ts',
168
+ process: 'app/host',
169
+ purl: APP,
170
+ purpose: 'session record shape',
171
+ layer: 2,
172
+ },
173
+ {
174
+ id: 'parse',
175
+ name: 'parseEvent',
176
+ construct: 'function',
177
+ symbol: 'parseEvent',
178
+ file: 'src/parse.ts',
179
+ module: 'src/parse.ts',
180
+ process: 'lib/worker',
181
+ purl: LIB,
182
+ purpose: 'shared parser',
183
+ layer: 1,
184
+ },
185
+ {
186
+ id: 'parse-types',
187
+ name: 'RawEvent',
188
+ construct: 'type_alias',
189
+ symbol: 'RawEvent',
190
+ file: 'src/parse.ts',
191
+ module: 'src/parse.ts',
192
+ process: 'lib/worker',
193
+ purl: LIB,
194
+ purpose: 'raw event shape',
195
+ layer: 1,
196
+ },
197
+ {
198
+ id: 'normalize',
199
+ name: 'normalize',
200
+ construct: 'function',
201
+ symbol: 'normalize',
202
+ file: 'src/normalize.ts',
203
+ module: 'src/normalize.ts',
204
+ process: 'lib/worker',
205
+ purl: LIB,
206
+ purpose: 'normalize payloads',
207
+ layer: 2,
208
+ },
209
+ {
210
+ id: 'normalize-types',
211
+ name: 'NormalizedEvent',
212
+ construct: 'type_alias',
213
+ symbol: 'NormalizedEvent',
214
+ file: 'src/normalize.ts',
215
+ module: 'src/normalize.ts',
216
+ process: 'lib/worker',
217
+ purl: LIB,
218
+ purpose: 'normalized event shape',
219
+ layer: 2,
220
+ },
221
+ ];
222
+
223
+ const nestedEdges = graphSpecFromEdges([
224
+ ['boot', 'dispatch', 'calls'],
225
+ ['dispatch', 'session', 'calls'],
226
+ ['dispatch', 'parse', 'calls'],
227
+ ['parse', 'normalize', 'calls'],
228
+ ]);
229
+
230
+ export const PackageProcessModuleNesting: Story = {
231
+ name: 'Package → process → module nesting',
232
+ args: {
233
+ components: nestedComponents,
234
+ relations: nestedEdges.relations,
235
+ walkthroughs: nestedEdges.walkthroughs,
236
+ },
237
+ render: (args) => (
238
+ <div style={{ width: '100vw', height: '100vh' }}>
239
+ <SubsystemComponentGraph {...args} />
240
+ </div>
241
+ ),
242
+ };
@@ -70,7 +70,7 @@ export interface WalkthroughViewerContext {
70
70
  /** Focused step index; `null` means the whole flow (no specific step). */
71
71
  stepIndex: number | null;
72
72
  /** Open a step's full source file over the walkthrough drawer (keeps snippets mounted). */
73
- onOpenFile: (path: string) => void;
73
+ onOpenFile: (path: string, opts?: SubsystemOpenFileOptions) => void;
74
74
  }
75
75
 
76
76
  type DrawerTarget =
@@ -192,12 +192,23 @@ const FileDrawerContent = memo(function FileDrawerContent({
192
192
  render,
193
193
  file,
194
194
  startLine,
195
+ fullFile,
195
196
  }: {
196
197
  render: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
197
198
  file: string;
198
199
  startLine?: number;
200
+ fullFile?: boolean;
199
201
  }) {
200
- return <>{render(file, startLine != null ? { startLine } : undefined)}</>;
202
+ return (
203
+ <>
204
+ {render(
205
+ file,
206
+ startLine != null
207
+ ? { startLine, ...(fullFile ? { fullFile: true } : {}) }
208
+ : undefined,
209
+ )}
210
+ </>
211
+ );
201
212
  });
202
213
 
203
214
  function FileOverlayCloseButton({ onClose }: { onClose: () => void }) {
@@ -240,7 +251,7 @@ const WalkthroughDrawerContent = memo(function WalkthroughDrawerContent({
240
251
  render: (ctx: WalkthroughViewerContext) => ReactNode;
241
252
  walkthrough: SubsystemWalkthrough;
242
253
  stepIndex: number | null;
243
- onOpenFile: (path: string) => void;
254
+ onOpenFile: (path: string, opts?: SubsystemOpenFileOptions) => void;
244
255
  }) {
245
256
  return <>{render({ walkthrough, stepIndex, onOpenFile })}</>;
246
257
  });
@@ -266,7 +277,10 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
266
277
  /** Bottom drawer: single file or walkthrough multi-snippet mode. */
267
278
  const [drawerTarget, setDrawerTarget] = useState<DrawerTarget | null>(null);
268
279
  /** Full-file layer over an open walkthrough drawer — walkthrough stays mounted. */
269
- const [fileOverlay, setFileOverlay] = useState<{ file: string } | null>(null);
280
+ const [fileOverlay, setFileOverlay] = useState<{
281
+ file: string;
282
+ startLine?: number;
283
+ } | null>(null);
270
284
  // Mirror so step-focus can decide whether to wait for the drawer open
271
285
  // transition before fitView (avoids framing against full-height canvas).
272
286
  const drawerOpenRef = useRef(false);
@@ -356,7 +370,7 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
356
370
  fileOverlay?.file ??
357
371
  (drawerTarget?.kind === 'file' ? drawerTarget.file : null);
358
372
  openFileRef.current = fileOverlay
359
- ? { file: fileOverlay.file }
373
+ ? { file: fileOverlay.file, startLine: fileOverlay.startLine }
360
374
  : drawerTarget?.kind === 'file'
361
375
  ? { file: drawerTarget.file, startLine: drawerTarget.startLine }
362
376
  : null;
@@ -932,8 +946,8 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
932
946
  );
933
947
 
934
948
  const onOpenFileFromWalkthrough = useCallback(
935
- (file: string) => {
936
- setFileOverlay({ file });
949
+ (file: string, opts?: SubsystemOpenFileOptions) => {
950
+ setFileOverlay({ file, startLine: opts?.startLine });
937
951
  onFileSelect?.(file);
938
952
  },
939
953
  [onFileSelect],
@@ -1955,6 +1969,8 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1955
1969
  <FileDrawerContent
1956
1970
  render={renderDrawerContent}
1957
1971
  file={fileOverlay.file}
1972
+ startLine={fileOverlay.startLine}
1973
+ fullFile
1958
1974
  />
1959
1975
  </div>
1960
1976
  </div>
@@ -5,11 +5,17 @@ import {
5
5
  convertSubsystemToGroups,
6
6
  getSubsystemRegions,
7
7
  getSubsystemModuleRegions,
8
+ getSubsystemPackageRegions,
8
9
  processGroupNodeId,
9
10
  moduleGroupNodeId,
11
+ packageGroupNodeId,
12
+ buildBoundaryLayoutGroups,
10
13
  buildSubsystemGraph,
14
+ componentPackageKey,
11
15
  deriveNameFromSymbol,
12
16
  constructBadgeLabel,
17
+ constructBadgeColor,
18
+ FRAMEWORK_BADGE_COLOR,
13
19
  rightBadgeLabel,
14
20
  nodeMinWidthForBadges,
15
21
  formatPurl,
@@ -148,6 +154,15 @@ describe('subsystem graph model', () => {
148
154
  ).toBe('custom_entity');
149
155
  });
150
156
 
157
+ test('constructBadgeColor uses React brand only for framework stereotype badges', () => {
158
+ expect(
159
+ constructBadgeColor({ framework: 'react', stereotype: 'component' }),
160
+ ).toBe(FRAMEWORK_BADGE_COLOR.react);
161
+ expect(constructBadgeColor({ framework: 'react' })).toBeNull();
162
+ expect(constructBadgeColor({ stereotype: 'hook' })).toBeNull();
163
+ expect(constructBadgeColor({ framework: 'vue', stereotype: 'component' })).toBeNull();
164
+ });
165
+
151
166
  test('nodeMinWidthForBadges widens for long construct badges and role pairs', () => {
152
167
  const plain = nodeMinWidthForBadges({ construct: 'function' });
153
168
  expect(plain).toBe(150);
@@ -445,6 +460,158 @@ describe('subsystem graph model', () => {
445
460
  expect(nodes.find((n) => n.id === moduleGroupNodeId('src/session/transcript.ts'))).toBeDefined();
446
461
  });
447
462
 
463
+ test('getSubsystemPackageRegions groups by purl repo key, skipping externals', () => {
464
+ const regions = getSubsystemPackageRegions({
465
+ components: [
466
+ { id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app#a.ts' },
467
+ { id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app' },
468
+ { id: 'c', name: 'c', construct: 'function', file: 'c.ts', purl: 'pkg:github/other/lib' },
469
+ { id: 'ext', name: 'Stripe', construct: 'external', file: '', purl: 'pkg:npm/stripe' },
470
+ ],
471
+ });
472
+ expect(regions.map((r) => r.key).sort()).toEqual([
473
+ 'pkg:github/acme/app',
474
+ 'pkg:github/other/lib',
475
+ ].sort());
476
+ expect(regions.every((r) => r.kind === 'package')).toBe(true);
477
+ expect(componentPackageKey({ construct: 'external', purl: 'pkg:npm/stripe' })).toBeUndefined();
478
+ });
479
+
480
+ test('buildBoundaryLayoutGroups skips package frames for single-repo graphs', () => {
481
+ const groups = buildBoundaryLayoutGroups({
482
+ components: [
483
+ { id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app' },
484
+ { id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app' },
485
+ ],
486
+ });
487
+ expect(groups.filter((g) => g.region.kind === 'package')).toHaveLength(0);
488
+ });
489
+
490
+ test('buildBoundaryLayoutGroups frames packages when multi-repo', () => {
491
+ const groups = buildBoundaryLayoutGroups({
492
+ components: [
493
+ { id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app' },
494
+ { id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app' },
495
+ { id: 'c', name: 'c', construct: 'function', file: 'c.ts', purl: 'pkg:github/other/lib' },
496
+ { id: 'd', name: 'd', construct: 'function', file: 'd.ts', purl: 'pkg:github/other/lib' },
497
+ ],
498
+ });
499
+ const pkgs = groups.filter((g) => g.region.kind === 'package');
500
+ expect(pkgs.map((g) => g.region.key).sort()).toEqual([
501
+ 'pkg:github/acme/app',
502
+ 'pkg:github/other/lib',
503
+ ].sort());
504
+ });
505
+
506
+ test('buildBoundaryLayoutGroups nests process under package', () => {
507
+ const groups = buildBoundaryLayoutGroups({
508
+ components: [
509
+ {
510
+ id: 'a',
511
+ name: 'a',
512
+ construct: 'function',
513
+ file: 'a.ts',
514
+ purl: 'pkg:github/acme/app',
515
+ process: 'app/host',
516
+ },
517
+ {
518
+ id: 'b',
519
+ name: 'b',
520
+ construct: 'function',
521
+ file: 'b.ts',
522
+ purl: 'pkg:github/acme/app',
523
+ process: 'app/host',
524
+ },
525
+ {
526
+ id: 'c',
527
+ name: 'c',
528
+ construct: 'function',
529
+ file: 'c.ts',
530
+ purl: 'pkg:github/other/lib',
531
+ process: 'lib/worker',
532
+ },
533
+ {
534
+ id: 'd',
535
+ name: 'd',
536
+ construct: 'function',
537
+ file: 'd.ts',
538
+ purl: 'pkg:github/other/lib',
539
+ process: 'lib/worker',
540
+ },
541
+ ],
542
+ });
543
+ const host = groups.find((g) => g.id === processGroupNodeId('app/host'));
544
+ expect(host?.parentId).toBe(packageGroupNodeId('pkg:github/acme/app'));
545
+ });
546
+
547
+ test('buildSubsystemGraph nests package → process → module', async () => {
548
+ const { nodes } = await buildSubsystemGraph({
549
+ components: [
550
+ {
551
+ id: 'a1',
552
+ name: 'a1',
553
+ construct: 'function',
554
+ file: 'a.ts',
555
+ purl: 'pkg:github/acme/app',
556
+ process: 'app/host',
557
+ module: 'src/a.ts',
558
+ },
559
+ {
560
+ id: 'a2',
561
+ name: 'a2',
562
+ construct: 'function',
563
+ file: 'a.ts',
564
+ purl: 'pkg:github/acme/app',
565
+ process: 'app/host',
566
+ module: 'src/a.ts',
567
+ },
568
+ {
569
+ id: 'b1',
570
+ name: 'b1',
571
+ construct: 'function',
572
+ file: 'b.ts',
573
+ purl: 'pkg:github/other/lib',
574
+ process: 'lib/worker',
575
+ module: 'src/b.ts',
576
+ },
577
+ {
578
+ id: 'b2',
579
+ name: 'b2',
580
+ construct: 'function',
581
+ file: 'b.ts',
582
+ purl: 'pkg:github/other/lib',
583
+ process: 'lib/worker',
584
+ module: 'src/b.ts',
585
+ },
586
+ ],
587
+ relations: [],
588
+ });
589
+ const pkgA = nodes.find((n) => n.id === packageGroupNodeId('pkg:github/acme/app'));
590
+ const proc = nodes.find((n) => n.id === processGroupNodeId('app/host'));
591
+ const mod = nodes.find((n) => n.id === moduleGroupNodeId('src/a.ts'));
592
+ expect(pkgA).toBeDefined();
593
+ expect((proc as { parentId?: string }).parentId).toBe(
594
+ packageGroupNodeId('pkg:github/acme/app'),
595
+ );
596
+ expect((mod as { parentId?: string }).parentId).toBe(processGroupNodeId('app/host'));
597
+ expect((nodes.find((n) => n.id === 'a1') as { parentId?: string }).parentId).toBe(
598
+ moduleGroupNodeId('src/a.ts'),
599
+ );
600
+ });
601
+
602
+ test('packageFrames always draws single-repo package frames when multi-member', () => {
603
+ const groups = buildBoundaryLayoutGroups(
604
+ {
605
+ components: [
606
+ { id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app' },
607
+ { id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app' },
608
+ ],
609
+ },
610
+ { packageFrames: 'always' },
611
+ );
612
+ expect(groups.filter((g) => g.region.kind === 'package')).toHaveLength(1);
613
+ });
614
+
448
615
  test('subsystemGraphLayoutKey ignores declarationRef-only changes', () => {
449
616
  const base = doc;
450
617
  const withRef = {