@principal-ai/subsystems-react 0.21.1 → 0.23.0

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 (92) hide show
  1. package/dist/graphify/consolidated.d.ts +5 -24
  2. package/dist/graphify/consolidated.d.ts.map +1 -1
  3. package/dist/graphify/index.d.ts +1 -1
  4. package/dist/graphify/index.d.ts.map +1 -1
  5. package/dist/graphify/index.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/pierre/PierreFileView.d.ts +3 -1
  10. package/dist/pierre/PierreFileView.d.ts.map +1 -1
  11. package/dist/pierre/PierreFileView.js +13 -3
  12. package/dist/pierre/PierreFileView.js.map +1 -1
  13. package/dist/pierre/PierreSnippetView.d.ts.map +1 -1
  14. package/dist/pierre/PierreSnippetView.js +5 -26
  15. package/dist/pierre/PierreSnippetView.js.map +1 -1
  16. package/dist/pierre/PierreWalkthroughCodeView.d.ts +3 -1
  17. package/dist/pierre/PierreWalkthroughCodeView.d.ts.map +1 -1
  18. package/dist/pierre/PierreWalkthroughCodeView.js +65 -8
  19. package/dist/pierre/PierreWalkthroughCodeView.js.map +1 -1
  20. package/dist/pierre/constructColors.d.ts.map +1 -1
  21. package/dist/pierre/constructColors.js +0 -5
  22. package/dist/pierre/constructColors.js.map +1 -1
  23. package/dist/pierre/index.d.ts +1 -1
  24. package/dist/pierre/index.d.ts.map +1 -1
  25. package/dist/pierre/index.js +1 -1
  26. package/dist/pierre/index.js.map +1 -1
  27. package/dist/pierre/scrollAnchor.d.ts +2 -0
  28. package/dist/pierre/scrollAnchor.d.ts.map +1 -1
  29. package/dist/pierre/scrollAnchor.js +7 -0
  30. package/dist/pierre/scrollAnchor.js.map +1 -1
  31. package/dist/pierre/sliceSnippet.d.ts +6 -0
  32. package/dist/pierre/sliceSnippet.d.ts.map +1 -1
  33. package/dist/pierre/sliceSnippet.js +25 -0
  34. package/dist/pierre/sliceSnippet.js.map +1 -1
  35. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
  36. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +0 -4
  37. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
  38. package/dist/subsystem/FileDrawer.d.ts +7 -2
  39. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  40. package/dist/subsystem/FileDrawer.js +13 -9
  41. package/dist/subsystem/FileDrawer.js.map +1 -1
  42. package/dist/subsystem/SubsystemComponentGraph.d.ts +2 -0
  43. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  44. package/dist/subsystem/SubsystemComponentGraph.js +97 -8
  45. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  46. package/dist/subsystem/declarationRef.d.ts +6 -1
  47. package/dist/subsystem/declarationRef.d.ts.map +1 -1
  48. package/dist/subsystem/formatDeclaration.d.ts.map +1 -1
  49. package/dist/subsystem/formatDeclaration.js +0 -15
  50. package/dist/subsystem/formatDeclaration.js.map +1 -1
  51. package/dist/subsystem/model.d.ts +61 -22
  52. package/dist/subsystem/model.d.ts.map +1 -1
  53. package/dist/subsystem/model.js +185 -65
  54. package/dist/subsystem/model.js.map +1 -1
  55. package/dist/subsystem/nodes.d.ts +3 -4
  56. package/dist/subsystem/nodes.d.ts.map +1 -1
  57. package/dist/subsystem/nodes.js +5 -6
  58. package/dist/subsystem/nodes.js.map +1 -1
  59. package/dist/utils/elkLayout.d.ts +6 -4
  60. package/dist/utils/elkLayout.d.ts.map +1 -1
  61. package/dist/utils/elkLayout.js +144 -54
  62. package/dist/utils/elkLayout.js.map +1 -1
  63. package/package.json +3 -3
  64. package/src/graphify/consolidated.ts +4 -26
  65. package/src/graphify/index.ts +0 -2
  66. package/src/index.ts +0 -2
  67. package/src/pierre/PierreFileView.tsx +25 -2
  68. package/src/pierre/PierreSnippetView.tsx +5 -31
  69. package/src/pierre/PierreWalkthroughCodeView.tsx +110 -5
  70. package/src/pierre/constructColors.test.ts +1 -2
  71. package/src/pierre/constructColors.ts +0 -5
  72. package/src/pierre/index.ts +1 -1
  73. package/src/pierre/scrollAnchor.ts +13 -0
  74. package/src/pierre/sliceSnippet.test.ts +44 -1
  75. package/src/pierre/sliceSnippet.ts +28 -0
  76. package/src/stories/Subsystem/ComponentDeclarationAudit.stories.tsx +0 -31
  77. package/src/stories/Subsystem/ComponentGraph/Appearance.stories.tsx +2 -46
  78. package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +9 -24
  79. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +2 -2
  80. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +2 -1
  81. package/src/stories/Subsystem/ComponentGraph/Modules.stories.tsx +287 -0
  82. package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +1 -1
  83. package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +6 -6
  84. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +0 -4
  85. package/src/subsystem/FileDrawer.tsx +19 -7
  86. package/src/subsystem/SubsystemComponentGraph.tsx +153 -7
  87. package/src/subsystem/declarationRef.ts +6 -1
  88. package/src/subsystem/formatDeclaration.ts +0 -19
  89. package/src/subsystem/model.test.ts +190 -9
  90. package/src/subsystem/model.ts +219 -77
  91. package/src/subsystem/nodes.tsx +6 -6
  92. package/src/utils/elkLayout.ts +148 -58
@@ -32,7 +32,6 @@ export type SubsystemComponentConstruct =
32
32
  | 'interface'
33
33
  | 'type_alias'
34
34
  | 'enum'
35
- | 'module'
36
35
  | 'store'
37
36
  | 'external'
38
37
  | 'custom_entity';
@@ -92,16 +91,12 @@ export interface SubsystemDeclToken {
92
91
  */
93
92
  export type SubsystemRelationType =
94
93
  | 'imports'
95
- | 'imports_from'
96
- | 're_exports'
97
- | 'defines'
98
94
  | 'extends'
99
95
  | 'inherits'
100
96
  | 'implements'
101
97
  | 'mixes_in'
102
98
  | 'method'
103
- | 'references'
104
- | 'contains';
99
+ | 'references';
105
100
 
106
101
  /**
107
102
  * Walkthrough hop mechanism — runtime seams with a `file:line` site.
@@ -190,11 +185,21 @@ export interface SubsystemComponent {
190
185
  /**
191
186
  * Runtime process membership — which deployment unit this node is a
192
187
  * member of (e.g. `principal-studio/host`, `principal-studio/renderer`). Nodes
193
- * sharing a `process` are drawn inside one boundary region (grouping is
194
- * `process ?? purl`); nodes without one sit outside every boundary
195
- * (external actors, services, libraries).
188
+ * sharing a `process` are drawn inside one boundary region; nodes without
189
+ * one sit outside every process boundary (external actors, services,
190
+ * libraries). Orthogonal to `module` (source-file frame).
196
191
  */
197
192
  process?: string;
193
+ /**
194
+ * Source-module membership — which file/module this export belongs to
195
+ * (e.g. `src/session/transcript.ts`). Nodes sharing a `module` are drawn
196
+ * inside one boundary frame. Prefer this over inventing a module construct:
197
+ * each export keeps its real construct (`function` / `class` / …) and the
198
+ * file reads as a frame. Orthogonal to `process` (runtime deployment).
199
+ * When both are set, the module frame is the component's parent (finer
200
+ * grain); process framing still groups siblings that share a process.
201
+ */
202
+ module?: string;
198
203
  /** A symbol this component exposes / is (the node's identity). */
199
204
  symbol?: string;
200
205
  /**
@@ -354,26 +359,20 @@ export function deriveGraphEdges(doc: {
354
359
  *
355
360
  * `symbol` is the source of truth (fully-qualified code identity). The name
356
361
  * is the symbol itself:
357
- * - class/type/module/function/script/... symbol → symbol (e.g. `SessionReader`)
358
- * - method `Owner.method` → last segment (e.g. `SessionReader.normalize` → `normalize`)
359
- * - module, no symbol → basename of `file` (e.g. `transcript.ts` → `transcript`) —
360
- * a common-sense convention for whole-file modules, not a real TS name
361
- * - otherwise no symbol → fall back to an existing name
362
+ * - class/type/function/... symbol → symbol (e.g. `SessionReader`)
363
+ * - method `Owner.method` → last segment (e.g. `SessionReader.normalize` → `normalize`)
364
+ * - otherwise no symbol → fall back to an existing name
362
365
  */
363
366
  export function deriveNameFromSymbol(
364
367
  symbol: string | undefined,
365
368
  construct: SubsystemComponentConstruct,
366
369
  existingName?: string,
367
- file?: string,
370
+ _file?: string,
368
371
  stereotype?: string,
369
372
  ): string {
370
373
  let name: string | undefined;
371
374
  if (symbol && symbol.trim()) {
372
375
  name = symbol;
373
- } else if (construct === 'module' && file) {
374
- const base = file.split('/').pop() ?? '';
375
- const clean = base.replace(/\.[^.]+$/, ''); // strip extension
376
- if (clean) name = clean;
377
376
  }
378
377
  if (!name) name = existingName ?? 'untitled';
379
378
 
@@ -382,7 +381,7 @@ export function deriveNameFromSymbol(
382
381
  // Executable constructs wear `()`; brace-bodied constructs (interface,
383
382
  // type_alias, enum) wear ` {}`. Classes render bare — the construct badge
384
383
  // already says "class".
385
- // Everything else (class, store, module, external) renders bare.
384
+ // Everything else (class, store, external) renders bare.
386
385
  if (stereotype === 'component' && !name.startsWith('<')) {
387
386
  return `<${name}>`;
388
387
  }
@@ -431,11 +430,13 @@ export function formatPurl(purl: string): string {
431
430
  export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
432
431
 
433
432
  /**
434
- * One process boundary region — all components sharing a `process` value.
435
- * Nodes without a `process` sit outside every boundary (no region).
433
+ * One boundary region — either a process (runtime deployment unit) or a
434
+ * module (source file). Nodes without that field sit outside those frames.
436
435
  */
437
436
  export interface SubsystemProcessRegion {
438
- /** The `process` value (e.g. `principal-studio/host`). */
437
+ /** Discriminator — which field produced this region. */
438
+ kind: 'process' | 'module';
439
+ /** The `process` or `module` value (e.g. `principal-studio/host`, `src/a.ts`). */
439
440
  key: string;
440
441
  /** Display label for the boundary frame. */
441
442
  label: string;
@@ -448,9 +449,21 @@ export function processGroupNodeId(processKey: string): string {
448
449
  return `process:${processKey}`;
449
450
  }
450
451
 
452
+ /** React Flow id for a module boundary group node. */
453
+ export function moduleGroupNodeId(moduleKey: string): string {
454
+ return `module:${moduleKey}`;
455
+ }
456
+
457
+ /** React Flow id for a boundary group of either kind. */
458
+ export function boundaryGroupNodeId(region: Pick<SubsystemProcessRegion, 'kind' | 'key'>): string {
459
+ return region.kind === 'module'
460
+ ? moduleGroupNodeId(region.key)
461
+ : processGroupNodeId(region.key);
462
+ }
463
+
451
464
  /**
452
- * Derive boundary regions from a document — one per distinct non-empty
453
- * `process` value, in first-appearance order.
465
+ * Derive process boundary regions — one per distinct non-empty `process`
466
+ * value, in first-appearance order.
454
467
  */
455
468
  export function getSubsystemRegions(
456
469
  doc: Pick<SubsystemModelDocument, 'components'>,
@@ -464,12 +477,100 @@ export function getSubsystemRegions(
464
477
  byProcess.set(p, list);
465
478
  }
466
479
  return [...byProcess.entries()].map(([key, memberIds]) => ({
480
+ kind: 'process' as const,
467
481
  key,
468
482
  label: key,
469
483
  memberIds,
470
484
  }));
471
485
  }
472
486
 
487
+ /**
488
+ * Derive module boundary regions — one per distinct non-empty `module`
489
+ * value, in first-appearance order. Label is the module path (file).
490
+ */
491
+ export function getSubsystemModuleRegions(
492
+ doc: Pick<SubsystemModelDocument, 'components'>,
493
+ ): SubsystemProcessRegion[] {
494
+ const byModule = new Map<string, string[]>();
495
+ for (const c of doc.components) {
496
+ const m = c.module?.trim();
497
+ if (!m) continue;
498
+ const list = byModule.get(m) ?? [];
499
+ list.push(c.id);
500
+ byModule.set(m, list);
501
+ }
502
+ return [...byModule.entries()].map(([key, memberIds]) => ({
503
+ kind: 'module' as const,
504
+ key,
505
+ label: key,
506
+ memberIds,
507
+ }));
508
+ }
509
+
510
+ /**
511
+ * One compound frame for ELK / React Flow — module frames may nest under a
512
+ * process frame via `parentId`.
513
+ */
514
+ export interface BoundaryLayoutGroup {
515
+ id: string;
516
+ memberIds: string[];
517
+ /** When set, this group is a child of another boundary group (process). */
518
+ parentId?: string;
519
+ region: SubsystemProcessRegion;
520
+ }
521
+
522
+ /**
523
+ * Build the process → module → leaf group tree for layout.
524
+ * Multi-member modules nest under a process when every member shares that
525
+ * process; process ELK children are nested module group ids plus any
526
+ * process members that are not inside a kept module frame.
527
+ */
528
+ export function buildBoundaryLayoutGroups(
529
+ doc: Pick<SubsystemModelDocument, 'components'>,
530
+ ): BoundaryLayoutGroup[] {
531
+ const byId = new Map(doc.components.map((c) => [c.id, c]));
532
+ const modules = getSubsystemModuleRegions(doc).filter((r) => r.memberIds.length >= 2);
533
+ const processes = getSubsystemRegions(doc).filter((r) => r.memberIds.length >= 2);
534
+ const keptProcessKeys = new Set(processes.map((r) => r.key));
535
+
536
+ const moduleGroups: BoundaryLayoutGroup[] = modules.map((r) => {
537
+ const processesOfMembers = new Set<string>();
538
+ for (const id of r.memberIds) {
539
+ const p = byId.get(id)?.process?.trim();
540
+ if (p) processesOfMembers.add(p);
541
+ }
542
+ let parentId: string | undefined;
543
+ if (processesOfMembers.size === 1) {
544
+ const p = [...processesOfMembers][0]!;
545
+ if (keptProcessKeys.has(p)) parentId = processGroupNodeId(p);
546
+ }
547
+ return {
548
+ id: moduleGroupNodeId(r.key),
549
+ memberIds: [...r.memberIds],
550
+ parentId,
551
+ region: r,
552
+ };
553
+ });
554
+
555
+ const processGroups: BoundaryLayoutGroup[] = processes.map((r) => {
556
+ const processId = processGroupNodeId(r.key);
557
+ const nestedModules = moduleGroups.filter((m) => m.parentId === processId);
558
+ const nestedModuleKeys = new Set(nestedModules.map((m) => m.region.key));
559
+ const directLeaves = r.memberIds.filter((id) => {
560
+ const mod = byId.get(id)?.module?.trim();
561
+ if (!mod) return true;
562
+ return !nestedModuleKeys.has(mod);
563
+ });
564
+ return {
565
+ id: processId,
566
+ memberIds: [...nestedModules.map((m) => m.id), ...directLeaves],
567
+ region: r,
568
+ };
569
+ });
570
+
571
+ return [...moduleGroups, ...processGroups];
572
+ }
573
+
473
574
  export interface SubsystemGraphNodeData extends Record<string, unknown> {
474
575
  component: SubsystemComponent;
475
576
  /** Set while a file is open in the drawer: true if this node's component
@@ -509,9 +610,6 @@ export type SubsystemGraphEdge = Edge<SubsystemGraphEdgeData>;
509
610
 
510
611
  export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
511
612
  imports: '#0893d2', // blue
512
- imports_from: '#5aa9e6', // light blue
513
- re_exports: '#3aa5c9', // cyan-blue
514
- defines: '#2e86ab', // steel blue
515
613
  calls: '#4ec9b0', // teal
516
614
  extends: '#b48ead', // purple
517
615
  inherits: '#9b6fd0', // purple
@@ -520,7 +618,6 @@ export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
520
618
  uses: '#e3b341', // gold
521
619
  method: '#c586c0', // magenta
522
620
  references: '#e07a5f', // terracotta
523
- contains: '#6c5ce7', // indigo
524
621
  feeds: '#22c55e', // green — data-flow into a processor
525
622
  produces: '#e07a5f', // terracotta — emits an output type
526
623
  writes: '#2f9e44', // deep green — mutates retained state
@@ -531,9 +628,6 @@ export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
531
628
 
532
629
  export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed' | 'dotted'> = {
533
630
  imports: 'solid',
534
- imports_from: 'solid',
535
- re_exports: 'solid',
536
- defines: 'solid',
537
631
  calls: 'solid',
538
632
  extends: 'dashed',
539
633
  inherits: 'dashed',
@@ -542,7 +636,6 @@ export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed'
542
636
  uses: 'solid',
543
637
  method: 'solid',
544
638
  references: 'dotted',
545
- contains: 'solid',
546
639
  feeds: 'solid',
547
640
  produces: 'solid',
548
641
  writes: 'solid',
@@ -555,9 +648,6 @@ export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed'
555
648
  * directly verifiable" styling of edge labels. */
556
649
  export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][] = [
557
650
  ['imports', 'import statement (code-level dependency)', true],
558
- ['imports_from', 'imported by (reverse dependency)', true],
559
- ['re_exports', 're-exports symbols from', true],
560
- ['defines', 'defines / declares symbol', true],
561
651
  ['calls', 'function/method call (call graph edge)', true],
562
652
  ['extends', 'class inheritance', true],
563
653
  ['inherits', 'class inheritance', true],
@@ -566,7 +656,6 @@ export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][]
566
656
  ['uses', 'general dependency (import, call, or reference)', false],
567
657
  ['method', 'structural: has method / member', true],
568
658
  ['references', 'type / symbol reference (not a call)', true],
569
- ['contains', 'structural: contains / encapsulates', true],
570
659
  ['feeds', 'data flow: output feeds into input', false],
571
660
  ['produces', 'data flow: produces / outputs', false],
572
661
  ['writes', 'state access: mutates retained state', true],
@@ -750,23 +839,23 @@ export function nodeMinWidthForBadges(component: {
750
839
 
751
840
  /**
752
841
  * Convert a subsystem graph document into React Flow nodes. Components that
753
- * carry a `process` get a `parentId` pointing at their boundary group node
754
- * (`process:<process>`); nodes without one stay top-level (outside every
755
- * boundary). The initial grid groups by `process ?? purl` so the pre-ELK
756
- * positions are already clustered; ELK then refines with compound layout.
842
+ * carry a `module` get a `parentId` pointing at their module frame
843
+ * (`module:<path>`); otherwise a `process` stamps `process:<process>`. Module
844
+ * frames may themselves nest under a process frame (stamped on the group node
845
+ * in `buildSubsystemGraph`). Nodes without either stay top-level. The initial
846
+ * grid clusters by `module ?? process ?? purl`; ELK then refines with compound
847
+ * layout.
757
848
  */
758
849
  export function convertSubsystemToNodes(
759
850
  doc: SubsystemModelDocument,
760
851
  opts: { maxNodeWidth?: number } = {},
761
852
  ): SubsystemGraphNode[] {
762
853
  const { maxNodeWidth } = opts;
763
- // Group components into boundary regions by process when authored, else by
764
- // package. Process is runtime membership (drawn as one boundary region);
765
- // purl is code identity — nodes without a process (external actors,
766
- // services, libraries) fall back to purl and sit outside every boundary.
854
+ // Cluster for the pre-ELK grid: module (source file) → process (runtime)
855
+ // → purl (package identity).
767
856
  const byPkg = new Map<string, SubsystemComponent[]>();
768
857
  for (const c of doc.components) {
769
- const regionKey = c.process ?? c.purl;
858
+ const regionKey = c.module ?? c.process ?? c.purl;
770
859
  const list = byPkg.get(regionKey) ?? [];
771
860
  list.push(c);
772
861
  byPkg.set(regionKey, list);
@@ -801,11 +890,17 @@ export function convertSubsystemToNodes(
801
890
  const cssBorder = 4; // 2px border each side
802
891
  const rawWidth = Math.max(cssMinWidth, textWidth + cssPadding + cssBorder);
803
892
  const nodeWidth = Math.max(cssMinWidth, Math.min(cap, rawWidth));
893
+ const moduleKey = c.module?.trim();
804
894
  const processKey = c.process?.trim();
895
+ const parentId = moduleKey
896
+ ? moduleGroupNodeId(moduleKey)
897
+ : processKey
898
+ ? processGroupNodeId(processKey)
899
+ : undefined;
805
900
  nodes.push({
806
901
  id: c.id,
807
902
  type: 'subsystem-component',
808
- ...(processKey ? { parentId: processGroupNodeId(processKey) } : {}),
903
+ ...(parentId ? { parentId } : {}),
809
904
  position: { x: PAD + col * COL_W, y: cursorY + row * ROW_H },
810
905
  width: nodeWidth,
811
906
  height: 84,
@@ -818,20 +913,21 @@ export function convertSubsystemToNodes(
818
913
  }
819
914
 
820
915
  /**
821
- * Convert boundary regions into React Flow parent (group) nodes. One per
822
- * distinct `process` value; member components point at these via `parentId`.
916
+ * Convert boundary regions into React Flow parent (group) nodes. Module
917
+ * frames that share a process nest under that process via `parentId`.
823
918
  * Positions/sizes are placeholders — ELK compound layout overwrites them.
824
919
  */
825
920
  export function convertSubsystemToGroups(
826
921
  doc: Pick<SubsystemModelDocument, 'components'>,
827
922
  ): SubsystemGraphNode[] {
828
- return getSubsystemRegions(doc).map((region) => ({
829
- id: processGroupNodeId(region.key),
830
- type: 'subsystem-group',
923
+ return buildBoundaryLayoutGroups(doc).map((g) => ({
924
+ id: g.id,
925
+ type: 'subsystem-group' as const,
831
926
  position: { x: 0, y: 0 },
832
927
  width: 400,
833
928
  height: 300,
834
- data: { region },
929
+ ...(g.parentId ? { parentId: g.parentId } : {}),
930
+ data: { region: g.region },
835
931
  }));
836
932
  }
837
933
 
@@ -874,8 +970,8 @@ export function subsystemGraphLayoutKey(
874
970
  doc: Pick<SubsystemModelDocument, 'components' | 'relations' | 'walkthroughs'>,
875
971
  ): string {
876
972
  const components = doc.components
877
- .map(({ id, purl, name, symbol, construct, file, purpose, process }) =>
878
- [id, purl, name, symbol ?? '', construct, file, purpose ?? '', process ?? ''].join('\0'))
973
+ .map(({ id, purl, name, symbol, construct, file, purpose, process, module }) =>
974
+ [id, purl, name, symbol ?? '', construct, file, purpose ?? '', process ?? '', module ?? ''].join('\0'))
879
975
  .sort()
880
976
  .join('\n');
881
977
  const edgeKey = deriveGraphEdges(doc)
@@ -901,15 +997,25 @@ export async function buildSubsystemGraph(
901
997
  const { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights } = opts;
902
998
  const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
903
999
  const edges = convertSubsystemToEdges(doc);
904
- // Boundary regions: one per multi-member process. Singletons get no frame —
905
- // strip the parentId convertSubsystemToNodes stamped so React Flow never
906
- // points at a non-existent parent.
907
- const regions = getSubsystemRegions(doc).filter((r) => r.memberIds.length >= 2);
908
- const regionKeys = new Set(regions.map((r) => r.key));
1000
+ // Nested boundary tree: process → module → leaves (when both fields set).
1001
+ const layoutGroups = buildBoundaryLayoutGroups(doc);
1002
+ const regions = layoutGroups.map((g) => g.region);
1003
+ const regionIds = new Set(layoutGroups.map((g) => g.id));
1004
+ const byId = new Map(doc.components.map((c) => [c.id, c]));
1005
+
1006
+ // Drop leaf parentIds that point at singleton / missing frames; fall back
1007
+ // from a dropped module frame to a kept process frame when possible.
909
1008
  for (const n of nodes) {
910
1009
  if (n.type !== 'subsystem-component') continue;
911
- const proc = (n.data as SubsystemGraphNodeData).component?.process?.trim();
912
- if (proc && !regionKeys.has(proc)) {
1010
+ const parentId = (n as { parentId?: string }).parentId;
1011
+ if (!parentId) continue;
1012
+ if (regionIds.has(parentId)) continue;
1013
+ const comp = (n.data as SubsystemGraphNodeData).component;
1014
+ const processKey = comp?.process?.trim() || byId.get(n.id)?.process?.trim();
1015
+ const processId = processKey ? processGroupNodeId(processKey) : undefined;
1016
+ if (processId && regionIds.has(processId)) {
1017
+ (n as { parentId?: string }).parentId = processId;
1018
+ } else {
913
1019
  delete (n as { parentId?: string }).parentId;
914
1020
  }
915
1021
  }
@@ -961,8 +1067,7 @@ export async function buildSubsystemGraph(
961
1067
  }
962
1068
  }
963
1069
 
964
- // ELK auto-layout: position nodes (layered, minimized crossings) with
965
- // process partitions as compound parents so boundaries shape the layout.
1070
+ // ELK auto-layout: nested compound parents (process → module → leaves).
966
1071
  let placedNodes = nodes;
967
1072
  let labelPositions = new Map<string, { x: number; y: number }>();
968
1073
  let elkPathStrings = new Map<string, string>();
@@ -978,32 +1083,69 @@ export async function buildSubsystemGraph(
978
1083
  interLayerSpacing: 120,
979
1084
  preserveNodePositions: false,
980
1085
  edgeLabels: showEdgeLabels === false ? { enabled: false } : { enabled: true, placement: 'CENTER' },
981
- groups: regions.map((r) => ({
982
- id: processGroupNodeId(r.key),
983
- memberIds: r.memberIds,
1086
+ groups: layoutGroups.map((g) => ({
1087
+ id: g.id,
1088
+ memberIds: g.memberIds,
1089
+ parentId: g.parentId,
984
1090
  })),
985
1091
  });
986
- const groupNodes: SubsystemGraphNode[] = regions.flatMap((region) => {
987
- const bounds = result.groupBounds.get(processGroupNodeId(region.key));
988
- if (!bounds) return [];
1092
+ const builtGroupIds = new Set(result.groupBounds.keys());
1093
+ // Parents before children — process frames, then nested module frames.
1094
+ const processGroupNodes: SubsystemGraphNode[] = [];
1095
+ const moduleGroupNodes: SubsystemGraphNode[] = [];
1096
+ for (const g of layoutGroups) {
1097
+ const bounds = result.groupBounds.get(g.id);
1098
+ if (!bounds) continue;
1099
+ const parentId =
1100
+ g.parentId && builtGroupIds.has(g.parentId) ? g.parentId : undefined;
989
1101
  const group: SubsystemGraphNode = {
990
- id: processGroupNodeId(region.key),
1102
+ id: g.id,
991
1103
  type: 'subsystem-group',
992
1104
  position: { x: bounds.x, y: bounds.y },
993
1105
  width: Math.max(200, bounds.width),
994
1106
  height: Math.max(160, bounds.height),
995
- data: { region },
1107
+ ...(parentId ? { parentId } : {}),
1108
+ data: { region: g.region },
996
1109
  };
997
- return [group];
998
- });
999
- // Parents first — React Flow resolves children via parentId.
1000
- placedNodes = [...groupNodes, ...(result.nodes as SubsystemGraphNode[])];
1110
+ if (g.region.kind === 'process') processGroupNodes.push(group);
1111
+ else moduleGroupNodes.push(group);
1112
+ }
1113
+ // Clear leaf parentIds that point at groups ELK dropped.
1114
+ for (const n of result.nodes as SubsystemGraphNode[]) {
1115
+ if (n.type !== 'subsystem-component') continue;
1116
+ const parentId = (n as { parentId?: string }).parentId;
1117
+ if (parentId && !builtGroupIds.has(parentId)) {
1118
+ delete (n as { parentId?: string }).parentId;
1119
+ }
1120
+ }
1121
+ placedNodes = [
1122
+ ...processGroupNodes,
1123
+ ...moduleGroupNodes,
1124
+ ...(result.nodes as SubsystemGraphNode[]),
1125
+ ];
1001
1126
  labelPositions = result.edgeLabelPositions;
1002
1127
  elkPathStrings = result.edgePaths;
1003
1128
  elkPathPoints = result.edgePathPoints;
1004
1129
  } catch (err) {
1005
- // Fall back to the (unpositioned) grid if ELK is unavailable.
1130
+ // Fall back to the (unpositioned) grid if ELK is unavailable — still
1131
+ // emit multi-member frames so parentId targets exist.
1006
1132
  console.warn('[subsystem-graph] ELK layout failed, using manual positions:', err);
1133
+ const processGroupNodes: SubsystemGraphNode[] = [];
1134
+ const moduleGroupNodes: SubsystemGraphNode[] = [];
1135
+ for (const g of layoutGroups) {
1136
+ const group: SubsystemGraphNode = {
1137
+ id: g.id,
1138
+ type: 'subsystem-group',
1139
+ position: { x: 0, y: 0 },
1140
+ width: 400,
1141
+ height: 300,
1142
+ ...(g.parentId ? { parentId: g.parentId } : {}),
1143
+ data: { region: g.region },
1144
+ };
1145
+ if (g.region.kind === 'process') processGroupNodes.push(group);
1146
+ else moduleGroupNodes.push(group);
1147
+ }
1148
+ placedNodes = [...processGroupNodes, ...moduleGroupNodes, ...nodes];
1007
1149
  }
1008
1150
  }
1009
1151
 
@@ -43,7 +43,6 @@ export const CONSTRUCT_LABEL: Record<string, string> = {
43
43
  interface: 'interface',
44
44
  type_alias: 'type alias',
45
45
  enum: 'enum',
46
- module: 'module',
47
46
  store: 'store',
48
47
  external: 'external',
49
48
  custom_entity: 'entity',
@@ -326,10 +325,9 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
326
325
  }
327
326
 
328
327
  /**
329
- * Process boundary frame — a React Flow parent node. Members render inside
330
- * via `parentId`; this draws the labeled container only (no handles, no
331
- * selection). The border color derives deterministically from the process key
332
- * so each deployment unit reads as its own region.
328
+ * Boundary frame — a React Flow parent node for a process or module region.
329
+ * Members render inside via `parentId`; this draws the labeled container only
330
+ * (no handles, no selection). Border color derives from the region key.
333
331
  */
334
332
  export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData, 'subsystem-group'>>) {
335
333
  const { theme } = useTheme();
@@ -343,6 +341,8 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
343
341
  const color = packageColor(region?.key ?? 'process');
344
342
  const dimmed = data.dimmed === true;
345
343
  const hidden = (data as { hidden?: boolean }).hidden === true;
344
+ const label =
345
+ region?.kind === 'module' ? `module · ${region.label}` : (region?.label ?? '');
346
346
 
347
347
  if (!region) return null;
348
348
 
@@ -378,7 +378,7 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
378
378
  whiteSpace: 'nowrap',
379
379
  }}
380
380
  >
381
- {region.label}
381
+ {label}
382
382
  </div>
383
383
  </div>
384
384
  );