@principal-ai/subsystems-react 0.42.0 → 0.43.1

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 (81) hide show
  1. package/dist/index.d.ts +2 -2
  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/stories/Subsystem/C4Graph/c4Fixture.js +1 -1
  6. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +1 -1
  7. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts +6 -9
  8. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
  9. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +15 -50
  10. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
  11. package/dist/subsystem/C4Graph.d.ts +2 -3
  12. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  13. package/dist/subsystem/C4Graph.js +2 -3
  14. package/dist/subsystem/C4Graph.js.map +1 -1
  15. package/dist/subsystem/IssueList.d.ts +5 -5
  16. package/dist/subsystem/IssueList.d.ts.map +1 -1
  17. package/dist/subsystem/IssueList.js +9 -18
  18. package/dist/subsystem/IssueList.js.map +1 -1
  19. package/dist/subsystem/SubsystemComponentGraph.d.ts +16 -12
  20. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  21. package/dist/subsystem/SubsystemComponentGraph.js +99 -113
  22. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  23. package/dist/subsystem/SubsystemFileTree.d.ts +5 -5
  24. package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
  25. package/dist/subsystem/SubsystemFileTree.js +13 -49
  26. package/dist/subsystem/SubsystemFileTree.js.map +1 -1
  27. package/dist/subsystem/model.d.ts +67 -53
  28. package/dist/subsystem/model.d.ts.map +1 -1
  29. package/dist/subsystem/model.js +305 -55
  30. package/dist/subsystem/model.js.map +1 -1
  31. package/dist/subsystem/nodes.d.ts.map +1 -1
  32. package/dist/subsystem/nodes.js +11 -4
  33. package/dist/subsystem/nodes.js.map +1 -1
  34. package/dist/subsystem/toC4.d.ts +4 -4
  35. package/dist/subsystem/toC4.d.ts.map +1 -1
  36. package/dist/subsystem/toC4.js +3 -7
  37. package/dist/subsystem/toC4.js.map +1 -1
  38. package/dist/utils/elkLayout.d.ts.map +1 -1
  39. package/dist/utils/elkLayout.js +7 -0
  40. package/dist/utils/elkLayout.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/index.ts +0 -4
  43. package/src/stories/Subsystem/AgentsPanel.stories.tsx +3 -5
  44. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +1 -4
  45. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +1 -1
  46. package/src/stories/Subsystem/ComponentGraph/Appearance.stories.tsx +2 -4
  47. package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +8 -8
  48. package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +19 -19
  49. package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +0 -1
  50. package/src/stories/Subsystem/ComponentGraph/CustomEntities.stories.tsx +3 -3
  51. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +0 -2
  52. package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +2 -6
  53. package/src/stories/Subsystem/ComponentGraph/EdgeViews.stories.tsx +21 -21
  54. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +0 -3
  55. package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +30 -32
  56. package/src/stories/Subsystem/ComponentGraph/GraphTitle.stories.tsx +4 -4
  57. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -26
  58. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +3 -25
  59. package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +7 -8
  60. package/src/stories/Subsystem/ComponentGraph/ModulePathNesting.stories.tsx +116 -0
  61. package/src/stories/Subsystem/ComponentGraph/Modules.stories.tsx +9 -11
  62. package/src/stories/Subsystem/ComponentGraph/Packages.stories.tsx +3 -6
  63. package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +3 -3
  64. package/src/stories/Subsystem/ComponentGraph/Proposed.stories.tsx +8 -6
  65. package/src/stories/Subsystem/ComponentGraph/ProposedWalkthroughs.stories.tsx +0 -1
  66. package/src/stories/Subsystem/ComponentGraph/Reorder.stories.tsx +0 -1
  67. package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +26 -28
  68. package/src/stories/Subsystem/ComponentGraph/WalkthroughAutoplay.stories.tsx +0 -1
  69. package/src/stories/Subsystem/ComponentGraph/WorkspacePackageFrames.stories.tsx +2 -3
  70. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +18 -58
  71. package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +0 -2
  72. package/src/subsystem/C4Graph.tsx +2 -3
  73. package/src/subsystem/IssueList.tsx +12 -22
  74. package/src/subsystem/SubsystemComponentGraph.tsx +117 -135
  75. package/src/subsystem/SubsystemFileTree.tsx +17 -50
  76. package/src/subsystem/model.test.ts +155 -24
  77. package/src/subsystem/model.ts +352 -108
  78. package/src/subsystem/nodes.tsx +13 -3
  79. package/src/subsystem/toC4.test.ts +5 -12
  80. package/src/subsystem/toC4.ts +5 -9
  81. package/src/utils/elkLayout.ts +10 -0
@@ -108,17 +108,6 @@ export interface SubsystemDeclToken {
108
108
  color?: string;
109
109
  }
110
110
 
111
- /**
112
- * Topology relation type — structural / module / type claims.
113
- * Belongs on `relations[]`, not on walkthrough hops.
114
- */
115
- export type SubsystemRelationType =
116
- | 'extends'
117
- | 'inherits'
118
- | 'implements'
119
- | 'mixes_in'
120
- | 'method';
121
-
122
111
  /**
123
112
  * Walkthrough hop mechanism — runtime seams with a `file:line` site.
124
113
  */
@@ -132,24 +121,21 @@ export type SubsystemWalkthroughMechanism =
132
121
  | 'watches'
133
122
  | 'registers-into';
134
123
 
135
- /** Union for derived graph-edge styling (relationType or hop mechanism). */
136
- export type SubsystemEdgeMechanism =
137
- | SubsystemRelationType
138
- | SubsystemWalkthroughMechanism;
124
+ /** Union for derived graph-edge styling — the walkthrough hop mechanism. */
125
+ export type SubsystemEdgeMechanism = SubsystemWalkthroughMechanism;
139
126
 
140
127
  /**
141
- * Which edge vocabulary the canvas draws. The two vocabularies are disjoint;
142
- * a view shows only edges (and their labels) from the selected source.
143
- * - `relations`: only topology relation edges (`extends`, `implements`, …)
128
+ * Which edge source the canvas draws. The two sources are disjoint; a view
129
+ * shows only edges (and their labels) from the selected source.
130
+ * - `graphify`: only graphify-native static edges (`imports`, `contains`, …)
144
131
  * - `walkthroughs`: only walkthrough hop edges (`calls`, `feeds`, …)
145
132
  */
146
- export type SubsystemEdgeView = 'relations' | 'walkthroughs';
133
+ export type SubsystemEdgeView = 'graphify' | 'walkthroughs';
147
134
 
148
135
  /**
149
136
  * Where a display edge came from.
150
137
  *
151
- * - `subsystem` (default): a verb from the authored vocabularies
152
- * (`SubsystemRelationType` / `SubsystemWalkthroughMechanism`), colored from
138
+ * - `subsystem` (default): a walkthrough hop mechanism verb, colored from
153
139
  * `MECHANISM_COLOR`.
154
140
  * - `graphify`: a raw relation read off graphify's static symbol graph
155
141
  * (`imports`, `contains`, `re_exports`, …). These are DERIVED, never authored,
@@ -162,10 +148,8 @@ export type SubsystemEdgeProvenance = 'subsystem' | 'graphify';
162
148
  * A graphify-native topology edge — a raw graphify relation that has no
163
149
  * subsystem mechanism equivalent.
164
150
  *
165
- * Kept structurally separate from `SubsystemRelation`: those are authored into
166
- * a portable model and validated against a closed vocabulary, whereas these are
167
- * derived from a graphify run and carry graphify's own (open) verb set. They are
168
- * a display input only — never written back into a `SubsystemModelDocument`.
151
+ * Derived from a graphify run and carry graphify's own (open) verb set. They
152
+ * are a display input only — never written back into a `SubsystemModelDocument`.
169
153
  */
170
154
  export interface SubsystemGraphifyRelation {
171
155
  id: string;
@@ -192,8 +176,8 @@ export interface SubsystemGraphifyRelation {
192
176
  /** A component node — the named unit, construct-tagged; `file` is its location. */
193
177
  export interface SubsystemComponent {
194
178
  /**
195
- * Model-local stable alias, unique per model. Referenced by relation /
196
- * walkthrough `from` / `to`; edges point at the alias, not the location.
179
+ * Model-local stable alias, unique per model. Referenced by walkthrough
180
+ * `from` / `to`; edges point at the alias, not the location.
197
181
  * Code identity (for composed multi-model views) lives on
198
182
  * `purl` + `file` + `symbol`, not here.
199
183
  */
@@ -330,19 +314,9 @@ export interface SubsystemComponent {
330
314
  declarationRef?: SubsystemDeclarationRef;
331
315
  }
332
316
 
333
- /** A topology relation between components (structural / module / type). */
334
- export interface SubsystemRelation {
335
- id: string;
336
- from: string;
337
- to: string;
338
- relationType: SubsystemRelationType;
339
- /** Concrete file/symbol refs backing the relation. */
340
- refs?: string[];
341
- }
342
-
343
317
  /**
344
- * Derived / display graph edge used by renderers. Built from `relations`
345
- * and/or walkthrough hops — not authored as its own document field.
318
+ * Derived / display graph edge used by renderers. Built from walkthrough hops
319
+ * (and graphify-native relations) — not authored as its own document field.
346
320
  */
347
321
  export interface SubsystemComponentEdge {
348
322
  id: string;
@@ -352,8 +326,8 @@ export interface SubsystemComponentEdge {
352
326
  * The edge verb. For `provenance: 'subsystem'` (the default) this is a
353
327
  * `SubsystemEdgeMechanism`; for `provenance: 'graphify'` it is the raw
354
328
  * graphify relation. Typed as `string` because the display edge is a derived
355
- * structure and graphify's verb set is open — the authored vocabularies
356
- * (`SubsystemRelationType` / `SubsystemWalkthroughMechanism`) stay closed.
329
+ * structure and graphify's verb set is open — the walkthrough vocabulary
330
+ * (`SubsystemWalkthroughMechanism`) stays closed.
357
331
  */
358
332
  mechanism: string;
359
333
  /** Origin of the edge; absent means `'subsystem'`. */
@@ -414,13 +388,11 @@ export interface SubsystemWalkthrough {
414
388
 
415
389
  export interface SubsystemModelDocument {
416
390
  components: SubsystemComponent[];
417
- /** Topology relations (structural / module / type). May be empty. */
418
- relations: SubsystemRelation[];
419
391
  /** Ordered runtime walkthroughs (one per named behavior). */
420
392
  walkthroughs?: SubsystemWalkthrough[];
421
393
  }
422
394
 
423
- /** Stable id for a derived graph edge from a relation or walkthrough hop. */
395
+ /** Stable id for a derived graph edge from a walkthrough hop. */
424
396
  export function derivedGraphEdgeId(
425
397
  from: string,
426
398
  to: string,
@@ -429,10 +401,6 @@ export function derivedGraphEdgeId(
429
401
  return `${from}--${mechanism}-->${to}`;
430
402
  }
431
403
 
432
- /**
433
- * Build display edges for the graph canvas from topology relations and
434
- * walkthrough hops (deduped by from/to/mechanism).
435
- */
436
404
  /** React Flow / canvas edge id for a walkthrough hop. */
437
405
  export function walkthroughStepGraphEdgeId(
438
406
  step: Pick<SubsystemWalkthroughStep, 'from' | 'to' | 'mechanism'>,
@@ -472,23 +440,10 @@ export function reorderTargetIndex(boundary: number, from: number): number {
472
440
  }
473
441
 
474
442
  export function deriveGraphEdges(doc: {
475
- relations?: readonly SubsystemRelation[];
476
443
  walkthroughs?: readonly SubsystemWalkthrough[];
477
444
  graphifyRelations?: readonly SubsystemGraphifyRelation[];
478
445
  }): SubsystemComponentEdge[] {
479
446
  const byId = new Map<string, SubsystemComponentEdge>();
480
- for (const r of doc.relations ?? []) {
481
- const id = r.id || derivedGraphEdgeId(r.from, r.to, r.relationType);
482
- if (!byId.has(id)) {
483
- byId.set(id, {
484
- id,
485
- from: r.from,
486
- to: r.to,
487
- mechanism: r.relationType,
488
- refs: r.refs,
489
- });
490
- }
491
- }
492
447
  for (const w of doc.walkthroughs ?? []) {
493
448
  for (const step of w.steps) {
494
449
  const id = derivedGraphEdgeId(step.from, step.to, step.mechanism);
@@ -522,18 +477,24 @@ export function deriveGraphEdges(doc: {
522
477
  }
523
478
 
524
479
  /**
525
- * True when the model has components but no topology or walkthrough edges.
526
- * Those snapshots are a catalog of declarations, not a graph.
480
+ * True when the model has components but no graph structure at all: no
481
+ * walkthrough/graphify edges AND no boundary containment (`module` / `process`)
482
+ * to frame. Those snapshots are a catalog of declarations, not a graph.
483
+ *
484
+ * A model that carries `module` / `process` membership still draws frames even
485
+ * with zero edges — that is static topology, so it is not constructs-only.
527
486
  */
528
487
  export function isConstructsOnlyModel(doc: {
529
- components: readonly { alias: string }[];
530
- relations?: readonly SubsystemRelation[];
488
+ components: readonly { alias: string; module?: string; process?: string }[];
531
489
  walkthroughs?: readonly SubsystemWalkthrough[];
532
490
  graphifyRelations?: readonly SubsystemGraphifyRelation[];
533
491
  }): boolean {
534
492
  if (doc.components.length === 0) return false;
493
+ const hasBoundary = doc.components.some(
494
+ (c) => (c.module?.trim() ?? '') !== '' || (c.process?.trim() ?? '') !== '',
495
+ );
496
+ if (hasBoundary) return false;
535
497
  return deriveGraphEdges({
536
- relations: doc.relations,
537
498
  walkthroughs: doc.walkthroughs,
538
499
  graphifyRelations: doc.graphifyRelations,
539
500
  }).length === 0;
@@ -655,7 +616,7 @@ export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
655
616
  */
656
617
  export interface SubsystemProcessRegion {
657
618
  /** Discriminator — which field / identity produced this region. */
658
- kind: 'process' | 'module' | 'package';
619
+ kind: 'process' | 'module' | 'directory' | 'package';
659
620
  /** The `process` / `module` value, or purl repo key for packages. */
660
621
  key: string;
661
622
  /** Display label for the boundary frame. */
@@ -678,6 +639,15 @@ export interface BoundaryFrameOptions {
678
639
  * - `never`: no package frames
679
640
  */
680
641
  packageFrames?: 'multi-repo' | 'always' | 'never';
642
+ /**
643
+ * How module frames group.
644
+ * - `exact` (default): one frame per distinct `module` string.
645
+ * - `path`: derive directory frames from the path segments of each
646
+ * component's `module`, and nest module frames under the directories that
647
+ * contain them. Directory frames are interposed between process/package
648
+ * and module frames.
649
+ */
650
+ moduleNesting?: 'exact' | 'path';
681
651
  }
682
652
 
683
653
  /** React Flow id for a process boundary group node. */
@@ -690,6 +660,23 @@ export function moduleGroupNodeId(moduleKey: string): string {
690
660
  return `module:${moduleKey}`;
691
661
  }
692
662
 
663
+ /** React Flow id for a directory boundary group node (path-derived nesting). */
664
+ export function directoryGroupNodeId(dirKey: string): string {
665
+ return `directory:${dirKey}`;
666
+ }
667
+
668
+ /** Normalize a module path to forward slashes, no trailing slash or `./`. */
669
+ function normalizeModulePath(p: string): string {
670
+ return p.trim().replace(/\\/g, '/').replace(/\/+$/, '').replace(/^\.\//, '');
671
+ }
672
+
673
+ /** Parent directory of a path-like key, or `''` when it has no slash. */
674
+ function parentDirectory(path: string): string {
675
+ const norm = normalizeModulePath(path);
676
+ const idx = norm.lastIndexOf('/');
677
+ return idx <= 0 ? '' : norm.slice(0, idx);
678
+ }
679
+
693
680
  export const MODULE_BADGE_INSET = 12;
694
681
  const MODULE_BADGE_CHAR_WIDTH = 12.5;
695
682
  const MODULE_BADGE_CHROME = 16;
@@ -765,6 +752,7 @@ export function packageGroupNodeId(packageKey: string): string {
765
752
  /** React Flow id for a boundary group of any kind. */
766
753
  export function boundaryGroupNodeId(region: Pick<SubsystemProcessRegion, 'kind' | 'key'>): string {
767
754
  if (region.kind === 'module') return moduleGroupNodeId(region.key);
755
+ if (region.kind === 'directory') return directoryGroupNodeId(region.key);
768
756
  if (region.kind === 'package') return packageGroupNodeId(region.key);
769
757
  return processGroupNodeId(region.key);
770
758
  }
@@ -887,6 +875,9 @@ export function buildBoundaryLayoutGroups(
887
875
  doc: Pick<SubsystemModelDocument, 'components'>,
888
876
  opts: BoundaryFrameOptions = {},
889
877
  ): BoundaryLayoutGroup[] {
878
+ if (opts.moduleNesting === 'path') {
879
+ return buildBoundaryLayoutGroupsByPath(doc, opts);
880
+ }
890
881
  const showSingletons = opts.showSingletonFrames === true;
891
882
  const packageMode = opts.packageFrames ?? 'multi-repo';
892
883
  const byAlias = new Map(doc.components.map((c) => [c.alias, c]));
@@ -1030,6 +1021,261 @@ export function buildBoundaryLayoutGroups(
1030
1021
  return [...moduleGroups, ...processGroups, ...packageGroups];
1031
1022
  }
1032
1023
 
1024
+ /**
1025
+ * Path-nesting variant of {@link buildBoundaryLayoutGroups}.
1026
+ *
1027
+ * Adds directory frames derived from each component's `module` path segments
1028
+ * (`src/session/transcript.ts` → `src` → `src/session` → module frame) and
1029
+ * nests module frames under the deepest directory that contains them. Directory
1030
+ * frames are kept when they hold 2+ leaves (or `showSingletonFrames`). Process
1031
+ * and package frames nest above directories exactly as before.
1032
+ *
1033
+ * Module frames are unchanged (one per exact `module` string); leaves still
1034
+ * parent to their module frame in `convertSubsystemToNodes`.
1035
+ */
1036
+ export function buildBoundaryLayoutGroupsByPath(
1037
+ doc: Pick<SubsystemModelDocument, 'components'>,
1038
+ opts: BoundaryFrameOptions = {},
1039
+ ): BoundaryLayoutGroup[] {
1040
+ const showSingletons = opts.showSingletonFrames === true;
1041
+ const packageMode = opts.packageFrames ?? 'multi-repo';
1042
+ const byAlias = new Map(doc.components.map((c) => [c.alias, c]));
1043
+
1044
+ const allPackages = getSubsystemPackageRegions(doc);
1045
+ const packageEligible =
1046
+ packageMode === 'always'
1047
+ ? true
1048
+ : packageMode === 'never'
1049
+ ? false
1050
+ : allPackages.length >= 2;
1051
+ const packages = packageEligible
1052
+ ? allPackages.filter((r) => keepRegion(r, showSingletons))
1053
+ : [];
1054
+ const keptPackageKeys = new Set(packages.map((r) => r.key));
1055
+
1056
+ const processes = getSubsystemRegions(doc).filter((r) =>
1057
+ keepRegion(r, showSingletons),
1058
+ );
1059
+ const keptProcessKeys = new Set(processes.map((r) => r.key));
1060
+
1061
+ const modules = getSubsystemModuleRegions(doc).filter((r) =>
1062
+ keepRegion(r, showSingletons),
1063
+ );
1064
+
1065
+ // Directory regions: every ancestor directory of a component's module path,
1066
+ // with the union of all aliases beneath it.
1067
+ const dirMembers = new Map<string, Set<string>>();
1068
+ for (const c of doc.components) {
1069
+ const m = c.module?.trim();
1070
+ if (!m) continue;
1071
+ let d = parentDirectory(m);
1072
+ while (d) {
1073
+ const set = dirMembers.get(d) ?? new Set<string>();
1074
+ set.add(c.alias);
1075
+ dirMembers.set(d, set);
1076
+ d = parentDirectory(d);
1077
+ }
1078
+ }
1079
+ const keptDirKeys = new Set<string>();
1080
+ for (const [d, members] of dirMembers) {
1081
+ if (members.size >= 2 || showSingletons) keptDirKeys.add(d);
1082
+ }
1083
+
1084
+ // Files sitting directly in each directory (drives chain compaction below).
1085
+ const directFileCount = new Map<string, number>();
1086
+ for (const c of doc.components) {
1087
+ const m = c.module?.trim();
1088
+ if (!m) continue;
1089
+ const d = parentDirectory(m);
1090
+ if (d) directFileCount.set(d, (directFileCount.get(d) ?? 0) + 1);
1091
+ }
1092
+ // Compact single-child directory chains, mirroring the file tree: a directory
1093
+ // whose only child is a single subdirectory (and which holds no files
1094
+ // directly) is merged into it. `app` → `app/book` renders one `app/book`
1095
+ // frame, not `app` wrapping `book`.
1096
+ let compacting = true;
1097
+ while (compacting) {
1098
+ compacting = false;
1099
+ const childDirsOf = new Map<string, string[]>();
1100
+ for (const d of keptDirKeys) {
1101
+ let a = parentDirectory(d);
1102
+ while (a && !keptDirKeys.has(a)) a = parentDirectory(a);
1103
+ if (!a) continue;
1104
+ const list = childDirsOf.get(a) ?? [];
1105
+ list.push(d);
1106
+ childDirsOf.set(a, list);
1107
+ }
1108
+ for (const d of [...keptDirKeys]) {
1109
+ const childDirs = childDirsOf.get(d) ?? [];
1110
+ if (childDirs.length === 1 && (directFileCount.get(d) ?? 0) === 0) {
1111
+ keptDirKeys.delete(d);
1112
+ compacting = true;
1113
+ }
1114
+ }
1115
+ }
1116
+
1117
+ const nearestKeptDirAncestor = (dirKey: string): string | undefined => {
1118
+ let p = parentDirectory(dirKey);
1119
+ while (p) {
1120
+ if (keptDirKeys.has(p)) return p;
1121
+ p = parentDirectory(p);
1122
+ }
1123
+ return undefined;
1124
+ };
1125
+ const dirRegions: SubsystemProcessRegion[] = [...keptDirKeys]
1126
+ .sort()
1127
+ .map((key) => {
1128
+ // Label is the path relative to the enclosing kept directory (which
1129
+ // already carries the prefix): `book` under `app`, or the full collapsed
1130
+ // path `app/book` when the chain was compacted.
1131
+ const ancestor = nearestKeptDirAncestor(key);
1132
+ return {
1133
+ kind: 'directory' as const,
1134
+ key,
1135
+ label: ancestor ? key.slice(ancestor.length + 1) : key,
1136
+ memberAliases: [...(dirMembers.get(key) ?? [])],
1137
+ };
1138
+ });
1139
+ const moduleDirParent = (moduleKey: string): string | undefined => {
1140
+ let d = parentDirectory(moduleKey);
1141
+ while (d) {
1142
+ if (keptDirKeys.has(d)) return d;
1143
+ d = parentDirectory(d);
1144
+ }
1145
+ return undefined;
1146
+ };
1147
+
1148
+ const sharedProcessParent = (
1149
+ memberAliases: readonly string[],
1150
+ ): string | undefined => {
1151
+ const seen = new Set<string>();
1152
+ for (const alias of memberAliases) {
1153
+ const p = byAlias.get(alias)?.process?.trim();
1154
+ if (p) seen.add(p);
1155
+ }
1156
+ if (seen.size !== 1) return undefined;
1157
+ const p = [...seen][0]!;
1158
+ return keptProcessKeys.has(p) ? processGroupNodeId(p) : undefined;
1159
+ };
1160
+ const sharedPackageParent = (
1161
+ memberAliases: readonly string[],
1162
+ ): string | undefined => {
1163
+ const seen = new Set<string>();
1164
+ for (const alias of memberAliases) {
1165
+ const c = byAlias.get(alias);
1166
+ const pkg = c ? componentPackageKey(c) : undefined;
1167
+ if (pkg) seen.add(pkg);
1168
+ }
1169
+ if (seen.size !== 1) return undefined;
1170
+ const pkg = [...seen][0]!;
1171
+ return keptPackageKeys.has(pkg) ? packageGroupNodeId(pkg) : undefined;
1172
+ };
1173
+ const frameParent = (memberAliases: readonly string[]): string | undefined =>
1174
+ sharedProcessParent(memberAliases) ?? sharedPackageParent(memberAliases);
1175
+
1176
+ const moduleGroups: BoundaryLayoutGroup[] = modules.map((r) => {
1177
+ const dirParent = moduleDirParent(r.key);
1178
+ const parentId = dirParent
1179
+ ? directoryGroupNodeId(dirParent)
1180
+ : frameParent(r.memberAliases);
1181
+ // Relative label when nested: `app/book/actions.ts` under `app/book`
1182
+ // reads as `actions.ts`. At the root the full module path stays.
1183
+ const label = dirParent ? r.key.slice(dirParent.length + 1) : r.label;
1184
+ return {
1185
+ id: moduleGroupNodeId(r.key),
1186
+ memberAliases: [...r.memberAliases],
1187
+ parentId,
1188
+ region: { ...r, label },
1189
+ };
1190
+ });
1191
+
1192
+ const dirGroupByKey = new Map(dirRegions.map((r) => [r.key, r]));
1193
+ const moduleRegionByKey = new Map(modules.map((r) => [r.key, r]));
1194
+ const dirGroups: BoundaryLayoutGroup[] = dirRegions.map((r) => {
1195
+ const id = directoryGroupNodeId(r.key);
1196
+ const ancestor = nearestKeptDirAncestor(r.key);
1197
+ const parentId = ancestor
1198
+ ? directoryGroupNodeId(ancestor)
1199
+ : frameParent(r.memberAliases);
1200
+ // Immediate children: subdirectories and module frames parented here.
1201
+ // Only child *group ids* go in the layout member list — a group's
1202
+ // descendant leaves belong to its child groups alone, or ELK sees the
1203
+ // same leaf in two compound parents and rejects the layout.
1204
+ const childDirKeys = dirRegions
1205
+ .filter((d) => nearestKeptDirAncestor(d.key) === r.key)
1206
+ .map((d) => d.key);
1207
+ const childModuleKeys = modules
1208
+ .filter((m) => moduleDirParent(m.key) === r.key)
1209
+ .map((m) => m.key);
1210
+ const claimed = new Set<string>();
1211
+ for (const dk of childDirKeys) {
1212
+ for (const a of dirGroupByKey.get(dk)?.memberAliases ?? []) claimed.add(a);
1213
+ }
1214
+ for (const mk of childModuleKeys) {
1215
+ for (const a of moduleRegionByKey.get(mk)?.memberAliases ?? []) claimed.add(a);
1216
+ }
1217
+ const directLeaves = r.memberAliases.filter((a) => !claimed.has(a));
1218
+ return {
1219
+ id,
1220
+ memberAliases: [
1221
+ ...childDirKeys.map(directoryGroupNodeId),
1222
+ ...childModuleKeys.map(moduleGroupNodeId),
1223
+ ...directLeaves,
1224
+ ],
1225
+ parentId,
1226
+ region: r,
1227
+ };
1228
+ });
1229
+
1230
+ const frameGroups = [...dirGroups, ...moduleGroups];
1231
+
1232
+ // Aliases owned by a module frame (singleton or not) must not become direct
1233
+ // leaves of an enclosing process/package frame.
1234
+ const moduleMemberAliases = new Set<string>();
1235
+ for (const r of modules) for (const a of r.memberAliases) moduleMemberAliases.add(a);
1236
+
1237
+ const processGroups: BoundaryLayoutGroup[] = processes.map((r) => {
1238
+ const processId = processGroupNodeId(r.key);
1239
+ const nested = frameGroups.filter((g) => g.parentId === processId);
1240
+ const claimed = new Set<string>();
1241
+ for (const g of nested) for (const a of g.region.memberAliases) claimed.add(a);
1242
+ const directLeaves = r.memberAliases.filter(
1243
+ (alias) => !claimed.has(alias) && !moduleMemberAliases.has(alias),
1244
+ );
1245
+ return {
1246
+ id: processId,
1247
+ memberAliases: [...nested.map((g) => g.id), ...directLeaves],
1248
+ parentId: sharedPackageParent(r.memberAliases),
1249
+ region: r,
1250
+ };
1251
+ });
1252
+
1253
+ const processMemberAliases = new Set<string>();
1254
+ for (const r of processes) for (const a of r.memberAliases) processMemberAliases.add(a);
1255
+
1256
+ const packageGroups: BoundaryLayoutGroup[] = packages.map((r) => {
1257
+ const packageId = packageGroupNodeId(r.key);
1258
+ const nested = [...processGroups, ...frameGroups].filter(
1259
+ (g) => g.parentId === packageId,
1260
+ );
1261
+ const claimed = new Set<string>();
1262
+ for (const g of nested) for (const a of g.region.memberAliases) claimed.add(a);
1263
+ const directLeaves = r.memberAliases.filter(
1264
+ (alias) =>
1265
+ !claimed.has(alias) &&
1266
+ !moduleMemberAliases.has(alias) &&
1267
+ !processMemberAliases.has(alias),
1268
+ );
1269
+ return {
1270
+ id: packageId,
1271
+ memberAliases: [...nested.map((g) => g.id), ...directLeaves],
1272
+ region: r,
1273
+ };
1274
+ });
1275
+
1276
+ return [...frameGroups, ...processGroups, ...packageGroups];
1277
+ }
1278
+
1033
1279
  export interface SubsystemGraphNodeData extends Record<string, unknown> {
1034
1280
  component: SubsystemComponent;
1035
1281
  /** Set while a file is open in the drawer: true if this node's component
@@ -1131,19 +1377,6 @@ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
1131
1377
 
1132
1378
  export type SubsystemGraphEdge = Edge<SubsystemGraphEdgeData>;
1133
1379
 
1134
- /**
1135
- * Runtime vocabulary of relation types — mirrors `SubsystemRelationType`.
1136
- * Used to split derived display edges into their relation vs walkthrough
1137
- * source (the two unions are disjoint).
1138
- */
1139
- export const SUBSYSTEM_RELATION_TYPES = [
1140
- 'extends',
1141
- 'inherits',
1142
- 'implements',
1143
- 'mixes_in',
1144
- 'method',
1145
- ] as const satisfies readonly SubsystemRelationType[];
1146
-
1147
1380
  /** Runtime vocabulary of walkthrough hop mechanisms — mirrors `SubsystemWalkthroughMechanism`. */
1148
1381
  export const SUBSYSTEM_WALKTHROUGH_MECHANISMS = [
1149
1382
  'calls',
@@ -1156,18 +1389,10 @@ export const SUBSYSTEM_WALKTHROUGH_MECHANISMS = [
1156
1389
  'registers-into',
1157
1390
  ] as const satisfies readonly SubsystemWalkthroughMechanism[];
1158
1391
 
1159
- const RELATION_TYPE_SET: ReadonlySet<string> = new Set(SUBSYSTEM_RELATION_TYPES);
1160
1392
  const WALKTHROUGH_MECHANISM_SET: ReadonlySet<string> = new Set(
1161
1393
  SUBSYSTEM_WALKTHROUGH_MECHANISMS,
1162
1394
  );
1163
1395
 
1164
- /** True when a mechanism belongs to the topology relation vocabulary. */
1165
- export function isRelationMechanism(
1166
- mechanism: string,
1167
- ): mechanism is SubsystemRelationType {
1168
- return RELATION_TYPE_SET.has(mechanism);
1169
- }
1170
-
1171
1396
  /** True when a mechanism belongs to the walkthrough hop vocabulary. */
1172
1397
  export function isWalkthroughMechanism(
1173
1398
  mechanism: string,
@@ -1177,12 +1402,7 @@ export function isWalkthroughMechanism(
1177
1402
 
1178
1403
  export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
1179
1404
  calls: '#22c55e', // green
1180
- extends: '#b48ead', // purple
1181
- inherits: '#9b6fd0', // purple
1182
- implements: '#c586c0', // magenta
1183
- mixes_in: '#d474a8', // pink-magenta
1184
1405
  uses: '#e3b341', // gold
1185
- method: '#c586c0', // magenta
1186
1406
  feeds: '#4ec9b0', // teal — data-flow into a processor
1187
1407
  produces: '#a78bfa', // violet — emits an output type
1188
1408
  writes: '#e8853a', // orange — mutates retained state
@@ -1193,12 +1413,7 @@ export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
1193
1413
 
1194
1414
  export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed' | 'dotted'> = {
1195
1415
  calls: 'solid',
1196
- extends: 'dashed',
1197
- inherits: 'dashed',
1198
- implements: 'dashed',
1199
- mixes_in: 'dashed',
1200
1416
  uses: 'solid',
1201
- method: 'solid',
1202
1417
  feeds: 'solid',
1203
1418
  produces: 'solid',
1204
1419
  writes: 'solid',
@@ -1363,12 +1578,7 @@ export function boundaryFill(color: string, alpha = '1f'): string {
1363
1578
  * directly verifiable" styling of edge labels. */
1364
1579
  export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][] = [
1365
1580
  ['calls', 'function/method call (call graph edge)', true],
1366
- ['extends', 'class inheritance', true],
1367
- ['inherits', 'class inheritance', true],
1368
- ['implements', 'implements interface / protocol', true],
1369
- ['mixes_in', 'applies mixin', true],
1370
1581
  ['uses', 'general dependency (import, call, or reference)', false],
1371
- ['method', 'structural: has method / member', true],
1372
1582
  ['feeds', 'data flow: output feeds into input', false],
1373
1583
  ['produces', 'data flow: produces / outputs', false],
1374
1584
  ['writes', 'state access: mutates retained state', true],
@@ -1377,6 +1587,30 @@ export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][]
1377
1587
  ['registers-into', 'registration pattern', false],
1378
1588
  ];
1379
1589
 
1590
+ /**
1591
+ * Single border/badge color for directory (folder) frames. Folders read as one
1592
+ * kind of container, so they share a neutral hue rather than hashing per-path
1593
+ * like modules / processes / packages. Host `boundaryColors` still overrides.
1594
+ */
1595
+ export const FOLDER_FRAME_COLOR = '#7aa2d4';
1596
+
1597
+ /**
1598
+ * True when a boundary frame names a folder. `directory` frames always are.
1599
+ * A `module` frame is a folder when its key looks like a directory path — the
1600
+ * last segment has no file extension (the model has no explicit file/folder
1601
+ * flag, so this is a heuristic; `Dockerfile`-style extensionless files read as
1602
+ * folders).
1603
+ */
1604
+ export function isFolderFrame(
1605
+ region: { kind: string; key: string } | undefined,
1606
+ ): boolean {
1607
+ if (!region) return false;
1608
+ if (region.kind === 'directory') return true;
1609
+ if (region.kind !== 'module') return false;
1610
+ const last = region.key.split('/').pop() ?? '';
1611
+ return last !== '' && !last.includes('.');
1612
+ }
1613
+
1380
1614
  /** Package color palette (derived deterministically from the package name). */
1381
1615
  export function packageColor(name: string): string {
1382
1616
  const palette = [
@@ -1733,7 +1967,7 @@ export function convertSubsystemToEdges(
1733
1967
 
1734
1968
  /** Stable key for layout-affecting graph fields (ignores declarationRef, etc.). */
1735
1969
  export function subsystemGraphLayoutKey(
1736
- doc: Pick<SubsystemModelDocument, 'components' | 'relations' | 'walkthroughs'> & {
1970
+ doc: Pick<SubsystemModelDocument, 'components' | 'walkthroughs'> & {
1737
1971
  graphifyRelations?: readonly SubsystemGraphifyRelation[];
1738
1972
  },
1739
1973
  ): string {
@@ -1792,10 +2026,15 @@ export async function buildSubsystemGraph(
1792
2026
  measuredHeights,
1793
2027
  showSingletonFrames,
1794
2028
  packageFrames,
2029
+ moduleNesting,
1795
2030
  graphifyRelations,
1796
2031
  orderByLine = false,
1797
2032
  } = opts;
1798
- const frameOpts: BoundaryFrameOptions = { showSingletonFrames, packageFrames };
2033
+ const frameOpts: BoundaryFrameOptions = {
2034
+ showSingletonFrames,
2035
+ packageFrames,
2036
+ moduleNesting,
2037
+ };
1799
2038
  const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
1800
2039
  const edges = convertSubsystemToEdges(doc, graphifyRelations);
1801
2040
  // Nested boundary tree: package → process → module → leaves.
@@ -1887,7 +2126,12 @@ export async function buildSubsystemGraph(
1887
2126
  try {
1888
2127
  const result = await computeElkLayout(nodes, edges, {
1889
2128
  routingStyle: 'orthogonal',
1890
- direction: 'RIGHT',
2129
+ // Edge-driven graphs flow left-to-right. With no edges (pure
2130
+ // containment, e.g. the static-topology layer) a RIGHT layout stacks
2131
+ // disconnected top-level frames in one vertical column; DOWN makes the
2132
+ // layered pass place those sibling frames along the horizontal axis
2133
+ // while each frame's members stack vertically.
2134
+ direction: edges.length > 0 ? 'RIGHT' : 'DOWN',
1891
2135
  nodeSpacing: 60,
1892
2136
  edgeSpacing: 30,
1893
2137
  edgeNodeSpacing: 60,
@@ -1908,7 +2152,7 @@ export async function buildSubsystemGraph(
1908
2152
  // room for the collapsed badge. Processes/packages never collapse:
1909
2153
  // size the frame to hold the full centered label plus padding.
1910
2154
  minWidth:
1911
- g.region.kind === 'module'
2155
+ g.region.kind === 'module' || g.region.kind === 'directory'
1912
2156
  ? moduleMinWidthForBadge(g.region.label)
1913
2157
  : boundaryMinWidthForBadge(g.region.label),
1914
2158
  })),
@@ -1973,7 +2217,7 @@ export async function buildSubsystemGraph(
1973
2217
  position: { x: 0, y: 0 },
1974
2218
  width: Math.max(
1975
2219
  400,
1976
- g.region.kind === 'module'
2220
+ g.region.kind === 'module' || g.region.kind === 'directory'
1977
2221
  ? moduleMinWidthForBadge(g.region.label)
1978
2222
  : boundaryMinWidthForBadge(g.region.label),
1979
2223
  ),