@principal-ai/subsystems-react 0.23.1 → 0.23.3

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.
@@ -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
+ };
@@ -1322,7 +1322,12 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1322
1322
  // the actual edge path (not node-center approximations).
1323
1323
  const edgeLabels = useMemo(() => {
1324
1324
  return dispEdges
1325
- .filter((e) => !e.hidden)
1325
+ .filter((e) => {
1326
+ if (e.hidden) return false;
1327
+ const d = e?.data as { dimmed?: boolean } | undefined;
1328
+ if (d?.dimmed) return false;
1329
+ return true;
1330
+ })
1326
1331
  .map((e) => {
1327
1332
  const d = e.data as {
1328
1333
  mechanism?: string;
@@ -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 = {