@principal-ai/subsystems-react 0.43.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.
@@ -22,7 +22,9 @@ import {
22
22
  boundaryMinWidthForBadge,
23
23
  MODULE_BADGE_INSET,
24
24
  packageGroupNodeId,
25
+ directoryGroupNodeId,
25
26
  buildBoundaryLayoutGroups,
27
+ buildBoundaryLayoutGroupsByPath,
26
28
  buildSubsystemGraph,
27
29
  componentPackageKey,
28
30
  deriveNameFromSymbol,
@@ -851,6 +853,122 @@ describe('isConstructsOnlyModel', () => {
851
853
  }),
852
854
  ).toBe(false);
853
855
  });
856
+
857
+ test('false when module/process containment frames exist, even with no edges', () => {
858
+ expect(
859
+ isConstructsOnlyModel({
860
+ components: [
861
+ { alias: 'a', module: 'src/a.ts' },
862
+ { alias: 'b', module: 'src/b.ts' },
863
+ ],
864
+ }),
865
+ ).toBe(false);
866
+ expect(
867
+ isConstructsOnlyModel({
868
+ components: [{ alias: 'a', process: 'app/server' }],
869
+ }),
870
+ ).toBe(false);
871
+ // Blank/whitespace membership is not a frame.
872
+ expect(
873
+ isConstructsOnlyModel({ components: [{ alias: 'a', module: ' ' }] }),
874
+ ).toBe(true);
875
+ });
876
+ });
877
+
878
+ describe('buildBoundaryLayoutGroupsByPath', () => {
879
+ const pathComps: SubsystemComponent[] = [
880
+ { alias: 'record', name: 'record', construct: 'type_alias', file: 'src/session/transcript.ts', module: 'src/session/transcript.ts', purl: 'pkg:github/p/app' },
881
+ { alias: 'parse', name: 'parse', construct: 'function', file: 'src/session/transcript.ts', module: 'src/session/transcript.ts', purl: 'pkg:github/p/app' },
882
+ { alias: 'tool-name', name: 'toolName', construct: 'function', file: 'src/session/paths.ts', module: 'src/session/paths.ts', purl: 'pkg:github/p/app' },
883
+ { alias: 'boot', name: 'boot', construct: 'function', file: 'src/host/main.ts', module: 'src/host/main.ts', purl: 'pkg:github/p/app' },
884
+ { alias: 'store', name: 'store', construct: 'store', file: 'src/host/store.ts', module: 'src/host/store.ts', purl: 'pkg:github/p/app' },
885
+ ];
886
+
887
+ test('derives directory frames and nests modules under them', () => {
888
+ const groups = buildBoundaryLayoutGroupsByPath(
889
+ { components: pathComps },
890
+ { showSingletonFrames: true },
891
+ );
892
+ const byId = new Map(groups.map((g) => [g.id, g]));
893
+
894
+ expect(byId.has(directoryGroupNodeId('src'))).toBe(true);
895
+ expect(byId.has(directoryGroupNodeId('src/session'))).toBe(true);
896
+ expect(byId.has(directoryGroupNodeId('src/host'))).toBe(true);
897
+
898
+ // directory → directory nesting
899
+ expect(byId.get(directoryGroupNodeId('src/session'))?.parentId).toBe(
900
+ directoryGroupNodeId('src'),
901
+ );
902
+ // module → directory nesting
903
+ expect(byId.get(moduleGroupNodeId('src/session/transcript.ts'))?.parentId).toBe(
904
+ directoryGroupNodeId('src/session'),
905
+ );
906
+ // top directory has no frame above it
907
+ expect(byId.get(directoryGroupNodeId('src'))?.parentId).toBeUndefined();
908
+
909
+ // Labels are relative to the parent frame, not the full path.
910
+ expect(byId.get(directoryGroupNodeId('src'))?.region.label).toBe('src');
911
+ expect(byId.get(directoryGroupNodeId('src/session'))?.region.label).toBe('session');
912
+ expect(byId.get(moduleGroupNodeId('src/session/transcript.ts'))?.region.label).toBe(
913
+ 'transcript.ts',
914
+ );
915
+ });
916
+
917
+ test('each leaf is claimed by exactly one group (no compound duplicates)', () => {
918
+ const groups = buildBoundaryLayoutGroupsByPath(
919
+ { components: pathComps },
920
+ { showSingletonFrames: true },
921
+ );
922
+ const groupIds = new Set(groups.map((g) => g.id));
923
+ const leafCount = new Map<string, number>();
924
+ for (const g of groups) {
925
+ for (const m of g.memberAliases) {
926
+ // group ids are allowed to repeat (as child references); leaves must not
927
+ if (groupIds.has(m)) continue;
928
+ leafCount.set(m, (leafCount.get(m) ?? 0) + 1);
929
+ }
930
+ }
931
+ for (const [alias, n] of leafCount) {
932
+ expect(n, `${alias} claimed ${n} times`).toBe(1);
933
+ }
934
+ });
935
+
936
+ test('compacts single-child directory chains (app → app/book)', () => {
937
+ const comps: SubsystemComponent[] = [
938
+ { alias: 'page', name: 'page', construct: 'function', file: 'app/book/page.tsx', module: 'app/book/page.tsx', purl: 'pkg:github/p/app' },
939
+ { alias: 'actions', name: 'actions', construct: 'function', file: 'app/book/actions.ts', module: 'app/book/actions.ts', purl: 'pkg:github/p/app' },
940
+ { alias: 'lib-a', name: 'a', construct: 'function', file: 'lib/a.ts', module: 'lib/a.ts', purl: 'pkg:github/p/app' },
941
+ { alias: 'lib-b', name: 'b', construct: 'function', file: 'lib/b.ts', module: 'lib/b.ts', purl: 'pkg:github/p/app' },
942
+ ];
943
+ const groups = buildBoundaryLayoutGroupsByPath(
944
+ { components: comps },
945
+ { showSingletonFrames: true },
946
+ );
947
+ const ids = new Set(groups.map((g) => g.id));
948
+ // `app` has a single child dir and no direct files → merged into `app/book`.
949
+ expect(ids.has(directoryGroupNodeId('app'))).toBe(false);
950
+ expect(ids.has(directoryGroupNodeId('app/book'))).toBe(true);
951
+ const appBook = groups.find((g) => g.id === directoryGroupNodeId('app/book'))!;
952
+ expect(appBook.parentId).toBeUndefined();
953
+ expect(appBook.region.label).toBe('app/book');
954
+ expect(ids.has(directoryGroupNodeId('lib'))).toBe(true);
955
+ });
956
+
957
+ test('exact mode adds no directory frames', () => {
958
+ const groups = buildBoundaryLayoutGroups(
959
+ { components: pathComps },
960
+ { showSingletonFrames: true, moduleNesting: 'exact' },
961
+ );
962
+ expect(groups.some((g) => g.region.kind === 'directory')).toBe(false);
963
+ });
964
+
965
+ test('path mode is selected by the moduleNesting option', () => {
966
+ const groups = buildBoundaryLayoutGroups(
967
+ { components: pathComps },
968
+ { showSingletonFrames: true, moduleNesting: 'path' },
969
+ );
970
+ expect(groups.some((g) => g.region.kind === 'directory')).toBe(true);
971
+ });
854
972
  });
855
973
 
856
974
  describe('describeConstructBreakdown', () => {
@@ -477,15 +477,23 @@ export function deriveGraphEdges(doc: {
477
477
  }
478
478
 
479
479
  /**
480
- * True when the model has components but no walkthrough or graphify edges.
481
- * 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.
482
486
  */
483
487
  export function isConstructsOnlyModel(doc: {
484
- components: readonly { alias: string }[];
488
+ components: readonly { alias: string; module?: string; process?: string }[];
485
489
  walkthroughs?: readonly SubsystemWalkthrough[];
486
490
  graphifyRelations?: readonly SubsystemGraphifyRelation[];
487
491
  }): boolean {
488
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;
489
497
  return deriveGraphEdges({
490
498
  walkthroughs: doc.walkthroughs,
491
499
  graphifyRelations: doc.graphifyRelations,
@@ -608,7 +616,7 @@ export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
608
616
  */
609
617
  export interface SubsystemProcessRegion {
610
618
  /** Discriminator — which field / identity produced this region. */
611
- kind: 'process' | 'module' | 'package';
619
+ kind: 'process' | 'module' | 'directory' | 'package';
612
620
  /** The `process` / `module` value, or purl repo key for packages. */
613
621
  key: string;
614
622
  /** Display label for the boundary frame. */
@@ -631,6 +639,15 @@ export interface BoundaryFrameOptions {
631
639
  * - `never`: no package frames
632
640
  */
633
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';
634
651
  }
635
652
 
636
653
  /** React Flow id for a process boundary group node. */
@@ -643,6 +660,23 @@ export function moduleGroupNodeId(moduleKey: string): string {
643
660
  return `module:${moduleKey}`;
644
661
  }
645
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
+
646
680
  export const MODULE_BADGE_INSET = 12;
647
681
  const MODULE_BADGE_CHAR_WIDTH = 12.5;
648
682
  const MODULE_BADGE_CHROME = 16;
@@ -718,6 +752,7 @@ export function packageGroupNodeId(packageKey: string): string {
718
752
  /** React Flow id for a boundary group of any kind. */
719
753
  export function boundaryGroupNodeId(region: Pick<SubsystemProcessRegion, 'kind' | 'key'>): string {
720
754
  if (region.kind === 'module') return moduleGroupNodeId(region.key);
755
+ if (region.kind === 'directory') return directoryGroupNodeId(region.key);
721
756
  if (region.kind === 'package') return packageGroupNodeId(region.key);
722
757
  return processGroupNodeId(region.key);
723
758
  }
@@ -840,6 +875,9 @@ export function buildBoundaryLayoutGroups(
840
875
  doc: Pick<SubsystemModelDocument, 'components'>,
841
876
  opts: BoundaryFrameOptions = {},
842
877
  ): BoundaryLayoutGroup[] {
878
+ if (opts.moduleNesting === 'path') {
879
+ return buildBoundaryLayoutGroupsByPath(doc, opts);
880
+ }
843
881
  const showSingletons = opts.showSingletonFrames === true;
844
882
  const packageMode = opts.packageFrames ?? 'multi-repo';
845
883
  const byAlias = new Map(doc.components.map((c) => [c.alias, c]));
@@ -983,6 +1021,261 @@ export function buildBoundaryLayoutGroups(
983
1021
  return [...moduleGroups, ...processGroups, ...packageGroups];
984
1022
  }
985
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
+
986
1279
  export interface SubsystemGraphNodeData extends Record<string, unknown> {
987
1280
  component: SubsystemComponent;
988
1281
  /** Set while a file is open in the drawer: true if this node's component
@@ -1294,6 +1587,30 @@ export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][]
1294
1587
  ['registers-into', 'registration pattern', false],
1295
1588
  ];
1296
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
+
1297
1614
  /** Package color palette (derived deterministically from the package name). */
1298
1615
  export function packageColor(name: string): string {
1299
1616
  const palette = [
@@ -1709,10 +2026,15 @@ export async function buildSubsystemGraph(
1709
2026
  measuredHeights,
1710
2027
  showSingletonFrames,
1711
2028
  packageFrames,
2029
+ moduleNesting,
1712
2030
  graphifyRelations,
1713
2031
  orderByLine = false,
1714
2032
  } = opts;
1715
- const frameOpts: BoundaryFrameOptions = { showSingletonFrames, packageFrames };
2033
+ const frameOpts: BoundaryFrameOptions = {
2034
+ showSingletonFrames,
2035
+ packageFrames,
2036
+ moduleNesting,
2037
+ };
1716
2038
  const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
1717
2039
  const edges = convertSubsystemToEdges(doc, graphifyRelations);
1718
2040
  // Nested boundary tree: package → process → module → leaves.
@@ -1804,7 +2126,12 @@ export async function buildSubsystemGraph(
1804
2126
  try {
1805
2127
  const result = await computeElkLayout(nodes, edges, {
1806
2128
  routingStyle: 'orthogonal',
1807
- 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',
1808
2135
  nodeSpacing: 60,
1809
2136
  edgeSpacing: 30,
1810
2137
  edgeNodeSpacing: 60,
@@ -1825,7 +2152,7 @@ export async function buildSubsystemGraph(
1825
2152
  // room for the collapsed badge. Processes/packages never collapse:
1826
2153
  // size the frame to hold the full centered label plus padding.
1827
2154
  minWidth:
1828
- g.region.kind === 'module'
2155
+ g.region.kind === 'module' || g.region.kind === 'directory'
1829
2156
  ? moduleMinWidthForBadge(g.region.label)
1830
2157
  : boundaryMinWidthForBadge(g.region.label),
1831
2158
  })),
@@ -1890,7 +2217,7 @@ export async function buildSubsystemGraph(
1890
2217
  position: { x: 0, y: 0 },
1891
2218
  width: Math.max(
1892
2219
  400,
1893
- g.region.kind === 'module'
2220
+ g.region.kind === 'module' || g.region.kind === 'directory'
1894
2221
  ? moduleMinWidthForBadge(g.region.label)
1895
2222
  : boundaryMinWidthForBadge(g.region.label),
1896
2223
  ),
@@ -33,6 +33,8 @@ import {
33
33
  moduleBadgeHoverLabel,
34
34
  moduleBadgeWidth,
35
35
  packageColor,
36
+ FOLDER_FRAME_COLOR,
37
+ isFolderFrame,
36
38
  type SubsystemGraphNodeData,
37
39
  type SubsystemGroupNodeData,
38
40
  type SubsystemGraphEdge,
@@ -445,8 +447,14 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
445
447
  selected?: boolean;
446
448
  };
447
449
  const region = data.region;
448
- // Explicit host override wins over the derived (hash-based) frame color.
449
- const color = data.color ?? packageColor(region?.key ?? 'process');
450
+ // Explicit host override wins over the derived frame color. Folder frames
451
+ // (directory, or a module whose key names a folder) share one neutral hue;
452
+ // everything else hashes per key.
453
+ const color =
454
+ data.color ??
455
+ (isFolderFrame(region ?? undefined)
456
+ ? FOLDER_FRAME_COLOR
457
+ : packageColor(region?.key ?? 'process'));
450
458
  const dimmed = data.dimmed === true;
451
459
  const hidden = (data as { hidden?: boolean }).hidden === true;
452
460
  // Label is the region identity alone (path / process key / owner/name).
@@ -454,7 +462,9 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
454
462
  // — don't prefix `module ·` / `package ·` or the path reads twice.
455
463
  const label = region?.label ?? '';
456
464
  const [expandedLabel, setExpandedLabel] = useState<string | null>(null);
457
- const isModule = region?.kind === 'module';
465
+ // Directory frames (path-derived nesting) render like modules — dashed,
466
+ // collapsible path badge.
467
+ const isModule = region?.kind === 'module' || region?.kind === 'directory';
458
468
  const isProcess = region?.kind === 'process';
459
469
  const expanded = isModule && expandedLabel === label;
460
470
  const availableBadgeWidth = Math.max(0, (width ?? 400) - MODULE_BADGE_INSET * 2 - 4);
@@ -759,6 +759,16 @@ export async function computeElkLayout(
759
759
  if (parentId && builtGroups.has(parentId)) continue; // nested inside parent
760
760
  elkParents.push(node);
761
761
  }
762
+ // Order root-level compounds by model order (the caller's `groups` array), so
763
+ // disconnected sibling frames lay out deterministically — e.g. `app/book`
764
+ // before `lib`. Without this, planCompoundGroups' bottom-up build order
765
+ // decides placement.
766
+ const groupOrder = new Map(groupDefs.map((g, i) => [g.id, i]));
767
+ elkParents.sort(
768
+ (a, b) =>
769
+ (groupOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
770
+ (groupOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER),
771
+ );
762
772
 
763
773
  // Create ELK graph
764
774
  const rootOptions = getElkOptions(options);