@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.
- package/dist/subsystem/SubsystemComponentGraph.d.ts +7 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +72 -6
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/SubsystemFileTree.d.ts +5 -5
- package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
- package/dist/subsystem/SubsystemFileTree.js +13 -49
- package/dist/subsystem/SubsystemFileTree.js.map +1 -1
- package/dist/subsystem/model.d.ts +50 -3
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +306 -7
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +11 -4
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +7 -0
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/stories/Subsystem/ComponentGraph/ModulePathNesting.stories.tsx +116 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +81 -5
- package/src/subsystem/SubsystemFileTree.tsx +17 -50
- package/src/subsystem/model.test.ts +118 -0
- package/src/subsystem/model.ts +335 -8
- package/src/subsystem/nodes.tsx +13 -3
- package/src/utils/elkLayout.ts +10 -0
|
@@ -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', () => {
|
package/src/subsystem/model.ts
CHANGED
|
@@ -477,15 +477,23 @@ export function deriveGraphEdges(doc: {
|
|
|
477
477
|
}
|
|
478
478
|
|
|
479
479
|
/**
|
|
480
|
-
* True when the model has components but no
|
|
481
|
-
*
|
|
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 = {
|
|
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
|
-
|
|
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
|
),
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -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
|
|
449
|
-
|
|
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
|
-
|
|
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);
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -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);
|