@principal-ai/subsystems-react 0.23.4 → 0.24.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 (41) hide show
  1. package/dist/index.d.ts +8 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/ComponentDeclaration.d.ts +3 -1
  6. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  7. package/dist/subsystem/ComponentDeclaration.js +2 -2
  8. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  9. package/dist/subsystem/ConstructsCatalog.d.ts +30 -0
  10. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -0
  11. package/dist/subsystem/ConstructsCatalog.js +349 -0
  12. package/dist/subsystem/ConstructsCatalog.js.map +1 -0
  13. package/dist/subsystem/DiagnosticToggle.d.ts +46 -0
  14. package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -0
  15. package/dist/subsystem/DiagnosticToggle.js +95 -0
  16. package/dist/subsystem/DiagnosticToggle.js.map +1 -0
  17. package/dist/subsystem/IssueList.d.ts +102 -0
  18. package/dist/subsystem/IssueList.d.ts.map +1 -0
  19. package/dist/subsystem/IssueList.js +282 -0
  20. package/dist/subsystem/IssueList.js.map +1 -0
  21. package/dist/subsystem/SubsystemComponentGraph.d.ts +40 -1
  22. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  23. package/dist/subsystem/SubsystemComponentGraph.js +103 -65
  24. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  25. package/dist/subsystem/model.d.ts +32 -2
  26. package/dist/subsystem/model.d.ts.map +1 -1
  27. package/dist/subsystem/model.js +47 -0
  28. package/dist/subsystem/model.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/index.ts +42 -0
  31. package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +287 -0
  32. package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +138 -0
  33. package/src/stories/Subsystem/ComponentGraph/EdgeViews.stories.tsx +159 -0
  34. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +130 -0
  35. package/src/subsystem/ComponentDeclaration.tsx +4 -1
  36. package/src/subsystem/ConstructsCatalog.tsx +644 -0
  37. package/src/subsystem/DiagnosticToggle.tsx +160 -0
  38. package/src/subsystem/IssueList.tsx +488 -0
  39. package/src/subsystem/SubsystemComponentGraph.tsx +156 -42
  40. package/src/subsystem/model.test.ts +34 -0
  41. package/src/subsystem/model.ts +72 -2
@@ -7,6 +7,9 @@
7
7
  * inside one labeled boundary frame; nodes without one sit outside every
8
8
  * boundary. Clicking a component invokes `onSelect`.
9
9
  *
10
+ * When the model has components but no topology or walkthrough edges, the
11
+ * canvas is a constructs catalog (list + signature) instead of a graph.
12
+ *
10
13
  * This is a focused fork of the package's `GraphRenderer` pipeline (same ELK
11
14
  * edge routing, delayed fitView, Background/Controls/MiniMap, node/edge type
12
15
  * injection, onNodeClick) adapted to the subsystem model — read-only.
@@ -37,18 +40,25 @@ import { IndustryMarkdownSlide } from 'themed-markdown';
37
40
  import {
38
41
  buildSubsystemGraph,
39
42
  deriveGraphEdges,
43
+ isConstructsOnlyModel,
44
+ isRelationMechanism,
45
+ isWalkthroughMechanism,
40
46
  MECHANISM_COLOR,
41
47
  MECHANISM_DESCRIPTIONS,
42
48
  subsystemGraphLayoutKey,
43
49
  walkthroughStepGraphEdgeId,
44
50
  type SubsystemComponentEdge,
45
51
  type SubsystemComponent,
52
+ type SubsystemEdgeView,
46
53
  type SubsystemEdgeMechanism,
47
54
  type SubsystemRelation,
48
55
  type SubsystemWalkthrough,
49
56
  } from './model';
57
+ import { ConstructsCatalog } from './ConstructsCatalog';
50
58
  import type { SubsystemOpenFileOptions } from './declarationRef';
51
59
  import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
60
+ import { SubsystemDiagnosticToggle, type SubsystemDiagnostic } from './DiagnosticToggle';
61
+ import { SubsystemIssueList, type SubsystemIssue } from './IssueList';
52
62
  import { SubsystemFileTree } from './SubsystemFileTree';
53
63
  import { GraphLayoutCover } from './GraphLayoutCover';
54
64
  import { ComponentDeclaration } from './ComponentDeclaration';
@@ -99,6 +109,17 @@ export interface SubsystemComponentGraphProps {
99
109
  maxNodeWidth?: number;
100
110
  /** Show edge labels (mechanism names) on the graph. @default true */
101
111
  showEdgeLabels?: boolean;
112
+ /**
113
+ * Which edge vocabulary the canvas draws. The relation and walkthrough
114
+ * vocabularies are disjoint, so a graph carrying both shows one or the
115
+ * other — never both. Edges outside the view are hidden (labels go too).
116
+ * - `relations`: topology relation edges (`imports`, `extends`, …)
117
+ * - `walkthroughs`: walkthrough hop edges (`calls`, `feeds`, …), including
118
+ * step numbers when a flow is focused/hovered
119
+ * Defaults to `walkthroughs` when the model has walkthroughs but no
120
+ * relations (e.g. flow-only models), otherwise `relations`.
121
+ */
122
+ edgeView?: SubsystemEdgeView;
102
123
  /** Subsystem title displayed in the sidebar. */
103
124
  title?: string;
104
125
  /**
@@ -146,6 +167,29 @@ export interface SubsystemComponentGraphProps {
146
167
  sidebarExtra?: ReactNode;
147
168
  /** Rendered in the sidebar under the description (e.g. selection inspector). */
148
169
  sidebarAfterDescription?: ReactNode;
170
+ /**
171
+ * Diagnostics status chip in the sidebar title row (beside the description
172
+ * toggle). Shows the last verification pass's state as an icon color + count;
173
+ * `onOpen` opens the full report. Omit to hide the chip.
174
+ */
175
+ diagnostic?: SubsystemDiagnostic;
176
+ /**
177
+ * Verification issues for this graph (audit findings). When diagnostics are
178
+ * active (see `showIssues`) the sidebar's bottom panel becomes the issue
179
+ * list instead of the file tree / walkthroughs.
180
+ */
181
+ issues?: SubsystemIssue[];
182
+ /**
183
+ * Force the sidebar into diagnostics mode. Defaults to `true` whenever
184
+ * `issues` is non-empty (and nothing else has been forced).
185
+ */
186
+ showIssues?: boolean;
187
+ /** Click an issue — focus its target on the graph / open detail. */
188
+ onSelectIssue?: (issue: SubsystemIssue) => void;
189
+ /** Apply an issue's deterministic fix. */
190
+ onApplyIssueFix?: (issue: SubsystemIssue) => void;
191
+ /** Hover an issue — transiently highlight its target (null on leave). */
192
+ onHoverIssue?: (issue: SubsystemIssue | null) => void;
149
193
  /**
150
194
  * Host-injected reader/renderer for the bottom file drawer, keyed by
151
195
  * repo-root-relative path. Opening happens on declaration/file-tree clicks.
@@ -260,7 +304,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
260
304
  measured: { w: number; h: number } | null;
261
305
  }
262
306
 
263
- function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
307
+ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
264
308
  const { theme } = useTheme();
265
309
  const { fitView } = useReactFlow();
266
310
  const viewport = useViewport();
@@ -268,6 +312,13 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
268
312
  () => deriveGraphEdges({ relations, walkthroughs }),
269
313
  [relations, walkthroughs],
270
314
  );
315
+ // One edge vocabulary at a time. When the caller doesn't pick, follow the
316
+ // model: a flow-only graph defaults to walkthrough edges rather than empty.
317
+ const resolvedEdgeView: SubsystemEdgeView =
318
+ edgeView ??
319
+ (relations.length === 0 && (walkthroughs?.length ?? 0) > 0
320
+ ? 'walkthroughs'
321
+ : 'relations');
271
322
  const [built, setBuilt] = useState<{ nodes: Node[]; edges: Edge[] }>({
272
323
  nodes: [],
273
324
  edges: [],
@@ -808,6 +859,13 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
808
859
  markerEnd: nextMarker,
809
860
  };
810
861
  };
862
+ // Edges outside the selected view are hidden entirely (labels included).
863
+ const edgeInView = (e: Edge): boolean => {
864
+ const mechanism = (e.data as { mechanism?: string } | undefined)?.mechanism ?? 'imports';
865
+ return resolvedEdgeView === 'relations'
866
+ ? isRelationMechanism(mechanism)
867
+ : isWalkthroughMechanism(mechanism);
868
+ };
811
869
  if (openedEdgeIds || focusEdgeIds || previewEdgeIds) {
812
870
  return baseEdges.map((e) => {
813
871
  const vis = flowElementVisibility({
@@ -817,13 +875,18 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
817
875
  anySelected: focusEdgeIds != null,
818
876
  });
819
877
  const dimmed = previewEdgeIds ? vis.hidden || !previewEdgeIds.has(e.id) : vis.dimmed;
820
- const hidden = vis.hidden;
878
+ const hidden = vis.hidden || !edgeInView(e);
821
879
  return { ...paint(e, dimmed), hidden };
822
880
  });
823
881
  }
824
- if (!selectedEdgeId) return baseEdges;
825
- return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
826
- }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds]);
882
+ if (selectedEdgeId) {
883
+ return baseEdges.map((e) => ({
884
+ ...paint(e, e.id !== selectedEdgeId),
885
+ hidden: !edgeInView(e),
886
+ }));
887
+ }
888
+ return baseEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
889
+ }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, resolvedEdgeView]);
827
890
 
828
891
  const onNodesChange = useCallback(
829
892
  (changes: NodeChange[]) => {
@@ -902,6 +965,10 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
902
965
  // own owner-avatar header. Clicking a header collapses that repo's tree.
903
966
  const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
904
967
  const hasWalkthroughs = useMemo(() => (walkthroughs?.length ?? 0) > 0, [walkthroughs]);
968
+ // Diagnostics mode: the bottom panel shows the issue list instead of the
969
+ // file tree / walkthroughs. Explicit `showIssues` wins; otherwise any issues
970
+ // present turn it on.
971
+ const issuesActive = showIssues ?? (issues?.length ?? 0) > 0;
905
972
  const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set());
906
973
  const toggleRepoCollapsed = useCallback((key: string) => {
907
974
  setCollapsedRepos((prev) => {
@@ -1325,8 +1392,7 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1325
1392
  .filter((e) => {
1326
1393
  if (e.hidden) return false;
1327
1394
  const d = e?.data as { dimmed?: boolean } | undefined;
1328
- if (d?.dimmed) return false;
1329
- return true;
1395
+ return !d?.dimmed;
1330
1396
  })
1331
1397
  .map((e) => {
1332
1398
  const d = e.data as {
@@ -1394,7 +1460,7 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1394
1460
  {/* Sidebar: scrollable title/description on top, files or flows pinned below.
1395
1461
  The description hides by default so files/flows get the room; the
1396
1462
  title-row toggle reveals it, and the lower panel yields back to 50%. */}
1397
- {!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasWalkthroughs) && (
1463
+ {!hideSidebar && (title || description || diagnostic || issuesActive || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasWalkthroughs) && (
1398
1464
  <div
1399
1465
  style={{
1400
1466
  width: sidebarWidth,
@@ -1418,7 +1484,7 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1418
1484
  }}
1419
1485
  >
1420
1486
  {sidebarExtra}
1421
- {(title || description) && (
1487
+ {(title || description || diagnostic) && (
1422
1488
  <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
1423
1489
  {title && (
1424
1490
  <h2
@@ -1435,34 +1501,47 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1435
1501
  {title}
1436
1502
  </h2>
1437
1503
  )}
1438
- {description && (
1439
- <button
1440
- type="button"
1441
- aria-expanded={descriptionVisible}
1442
- aria-label={descriptionVisible ? 'Hide description' : 'Show description'}
1443
- title={descriptionVisible ? 'Hide description' : 'Show description'}
1444
- onMouseEnter={() => setDescToggleHover(true)}
1445
- onMouseLeave={() => setDescToggleHover(false)}
1446
- onClick={() => setDescriptionVisible((v) => !v)}
1447
- style={{
1448
- display: 'inline-flex',
1449
- alignItems: 'center',
1450
- justifyContent: 'center',
1451
- flexShrink: 0,
1452
- width: 22,
1453
- height: 22,
1454
- padding: 0,
1455
- border: 'none',
1456
- borderRadius: 4,
1457
- background: descToggleHover ? theme.colors.border : 'transparent',
1458
- color: descToggleHover ? theme.colors.text : (theme.colors.textMuted ?? theme.colors.textSecondary),
1459
- cursor: 'pointer',
1460
- transition: 'background 120ms ease, color 120ms ease',
1461
- }}
1462
- >
1463
- {descriptionVisible ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
1464
- </button>
1465
- )}
1504
+ <div
1505
+ style={{
1506
+ display: 'flex',
1507
+ alignItems: 'center',
1508
+ gap: 4,
1509
+ flexShrink: 0,
1510
+ // Title's flex:1 already pushes this cluster right; without a
1511
+ // title, keep the actions flush right instead of flush-left.
1512
+ marginLeft: title ? undefined : 'auto',
1513
+ }}
1514
+ >
1515
+ {diagnostic && <SubsystemDiagnosticToggle {...diagnostic} />}
1516
+ {description && (
1517
+ <button
1518
+ type="button"
1519
+ aria-expanded={descriptionVisible}
1520
+ aria-label={descriptionVisible ? 'Hide description' : 'Show description'}
1521
+ title={descriptionVisible ? 'Hide description' : 'Show description'}
1522
+ onMouseEnter={() => setDescToggleHover(true)}
1523
+ onMouseLeave={() => setDescToggleHover(false)}
1524
+ onClick={() => setDescriptionVisible((v) => !v)}
1525
+ style={{
1526
+ display: 'inline-flex',
1527
+ alignItems: 'center',
1528
+ justifyContent: 'center',
1529
+ flexShrink: 0,
1530
+ width: 22,
1531
+ height: 22,
1532
+ padding: 0,
1533
+ border: 'none',
1534
+ borderRadius: 4,
1535
+ background: descToggleHover ? theme.colors.border : 'transparent',
1536
+ color: descToggleHover ? theme.colors.text : (theme.colors.textMuted ?? theme.colors.textSecondary),
1537
+ cursor: 'pointer',
1538
+ transition: 'background 120ms ease, color 120ms ease',
1539
+ }}
1540
+ >
1541
+ {descriptionVisible ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
1542
+ </button>
1543
+ )}
1544
+ </div>
1466
1545
  </div>
1467
1546
  )}
1468
1547
  {description && descriptionVisible && (
@@ -1483,7 +1562,7 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1483
1562
  )}
1484
1563
  {sidebarAfterDescription}
1485
1564
  </div>
1486
- {(treeFilePaths.length > 0 || hasWalkthroughs) && (
1565
+ {(treeFilePaths.length > 0 || hasWalkthroughs || issuesActive) && (
1487
1566
  <div
1488
1567
  style={{
1489
1568
  ...(showDesc ? { height: '50%' as const } : { flex: 1, minHeight: 0 }),
@@ -1495,6 +1574,15 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1495
1574
  overflow: 'hidden',
1496
1575
  }}
1497
1576
  >
1577
+ {issuesActive ? (
1578
+ <SubsystemIssueList
1579
+ issues={issues ?? []}
1580
+ onSelectIssue={onSelectIssue}
1581
+ onApplyFix={onApplyIssueFix}
1582
+ onHoverIssue={onHoverIssue}
1583
+ />
1584
+ ) : (
1585
+ <>
1498
1586
  {hasWalkthroughs && (
1499
1587
  <div
1500
1588
  role="tablist"
@@ -1608,13 +1696,15 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1608
1696
  })}
1609
1697
  </>
1610
1698
  ) : null}
1699
+ </>
1700
+ )}
1611
1701
  </div>
1612
1702
  )}
1613
1703
  </div>
1614
1704
  )}
1615
1705
 
1616
1706
  {/* Drag handle between sidebar and canvas — resize the left panel. */}
1617
- {!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasWalkthroughs) && (
1707
+ {!hideSidebar && (title || description || diagnostic || issuesActive || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasWalkthroughs) && (
1618
1708
  <div
1619
1709
  onMouseDown={onSidebarResizeStart}
1620
1710
  aria-label="Resize sidebar"
@@ -2403,6 +2493,12 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
2403
2493
  return () => ro.disconnect();
2404
2494
  }, []);
2405
2495
 
2496
+ const constructsOnly = isConstructsOnlyModel({
2497
+ components: props.components,
2498
+ relations: props.relations,
2499
+ walkthroughs: props.walkthroughs,
2500
+ });
2501
+
2406
2502
  return (
2407
2503
  <div
2408
2504
  ref={wrapRef}
@@ -2416,9 +2512,27 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
2416
2512
  }}
2417
2513
  >
2418
2514
  <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
2419
- <ReactFlowProvider>
2420
- <Inner {...props} measured={size} />
2421
- </ReactFlowProvider>
2515
+ {constructsOnly ? (
2516
+ <ConstructsCatalog
2517
+ components={props.components}
2518
+ onSelect={props.onSelect}
2519
+ title={props.title}
2520
+ hideSidebar={props.hideSidebar}
2521
+ description={props.description}
2522
+ diagnostic={props.diagnostic}
2523
+ sidebarExtra={props.sidebarExtra}
2524
+ sidebarAfterDescription={props.sidebarAfterDescription}
2525
+ renderFileViewer={props.renderFileViewer}
2526
+ renderFileView={props.renderFileView}
2527
+ onFileSelect={props.onFileSelect}
2528
+ onVerifyComponent={props.onVerifyComponent}
2529
+ componentVerification={props.componentVerification}
2530
+ />
2531
+ ) : (
2532
+ <ReactFlowProvider>
2533
+ <Inner {...props} measured={size} />
2534
+ </ReactFlowProvider>
2535
+ )}
2422
2536
  </div>
2423
2537
  </div>
2424
2538
  );
@@ -13,6 +13,7 @@ import {
13
13
  buildSubsystemGraph,
14
14
  componentPackageKey,
15
15
  deriveNameFromSymbol,
16
+ isConstructsOnlyModel,
16
17
  constructBadgeLabel,
17
18
  constructBadgeColor,
18
19
  FRAMEWORK_BADGE_COLOR,
@@ -642,3 +643,36 @@ describe('subsystem graph model', () => {
642
643
  expect(subsystemGraphLayoutKey(base)).toBe(subsystemGraphLayoutKey(withRef));
643
644
  });
644
645
  });
646
+
647
+ describe('isConstructsOnlyModel', () => {
648
+ test('true when components exist and there are no edges', () => {
649
+ expect(isConstructsOnlyModel({ components: comps, relations: [], walkthroughs: [] })).toBe(true);
650
+ expect(isConstructsOnlyModel({ components: comps })).toBe(true);
651
+ });
652
+
653
+ test('false when empty, or when relations or walkthrough hops exist', () => {
654
+ expect(isConstructsOnlyModel({ components: [], relations: [] })).toBe(false);
655
+ expect(isConstructsOnlyModel({ components: comps, relations })).toBe(false);
656
+ expect(
657
+ isConstructsOnlyModel({
658
+ components: comps,
659
+ relations: [],
660
+ walkthroughs: [
661
+ {
662
+ id: 'wt',
663
+ title: 'flow',
664
+ steps: [
665
+ {
666
+ from: 'transcript',
667
+ to: 'reader',
668
+ mechanism: 'calls',
669
+ file: 'transcript.ts',
670
+ line: 1,
671
+ },
672
+ ],
673
+ },
674
+ ],
675
+ }),
676
+ ).toBe(false);
677
+ });
678
+ });
@@ -117,6 +117,14 @@ export type SubsystemEdgeMechanism =
117
117
  | SubsystemRelationType
118
118
  | SubsystemWalkthroughMechanism;
119
119
 
120
+ /**
121
+ * Which edge vocabulary the canvas draws. The two vocabularies are disjoint;
122
+ * a view shows only edges (and their labels) from the selected source.
123
+ * - `relations`: only topology relation edges (`imports`, `extends`, …)
124
+ * - `walkthroughs`: only walkthrough hop edges (`calls`, `feeds`, …)
125
+ */
126
+ export type SubsystemEdgeView = 'relations' | 'walkthroughs';
127
+
120
128
  /** A component node — the named unit, construct-tagged; `file` is its location. */
121
129
  export interface SubsystemComponent {
122
130
  id: string;
@@ -323,8 +331,8 @@ export function walkthroughStepGraphEdgeId(
323
331
  }
324
332
 
325
333
  export function deriveGraphEdges(doc: {
326
- relations?: SubsystemRelation[];
327
- walkthroughs?: SubsystemWalkthrough[];
334
+ relations?: readonly SubsystemRelation[];
335
+ walkthroughs?: readonly SubsystemWalkthrough[];
328
336
  }): SubsystemComponentEdge[] {
329
337
  const byId = new Map<string, SubsystemComponentEdge>();
330
338
  for (const r of doc.relations ?? []) {
@@ -355,6 +363,22 @@ export function deriveGraphEdges(doc: {
355
363
  return [...byId.values()];
356
364
  }
357
365
 
366
+ /**
367
+ * True when the model has components but no topology or walkthrough edges.
368
+ * Those snapshots are a catalog of declarations, not a graph.
369
+ */
370
+ export function isConstructsOnlyModel(doc: {
371
+ components: readonly { id: string }[];
372
+ relations?: readonly SubsystemRelation[];
373
+ walkthroughs?: readonly SubsystemWalkthrough[];
374
+ }): boolean {
375
+ if (doc.components.length === 0) return false;
376
+ return deriveGraphEdges({
377
+ relations: doc.relations,
378
+ walkthroughs: doc.walkthroughs,
379
+ }).length === 0;
380
+ }
381
+
358
382
  /**
359
383
  * Derive a consistent display `name` from a code `symbol` + construct.
360
384
  *
@@ -747,6 +771,52 @@ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
747
771
 
748
772
  export type SubsystemGraphEdge = Edge<SubsystemGraphEdgeData>;
749
773
 
774
+ /**
775
+ * Runtime vocabulary of relation types — mirrors `SubsystemRelationType`.
776
+ * Used to split derived display edges into their relation vs walkthrough
777
+ * source (the two unions are disjoint).
778
+ */
779
+ export const SUBSYSTEM_RELATION_TYPES = [
780
+ 'imports',
781
+ 'extends',
782
+ 'inherits',
783
+ 'implements',
784
+ 'mixes_in',
785
+ 'method',
786
+ 'references',
787
+ ] as const satisfies readonly SubsystemRelationType[];
788
+
789
+ /** Runtime vocabulary of walkthrough hop mechanisms — mirrors `SubsystemWalkthroughMechanism`. */
790
+ export const SUBSYSTEM_WALKTHROUGH_MECHANISMS = [
791
+ 'calls',
792
+ 'uses',
793
+ 'feeds',
794
+ 'produces',
795
+ 'writes',
796
+ 'reads',
797
+ 'watches',
798
+ 'registers-into',
799
+ ] as const satisfies readonly SubsystemWalkthroughMechanism[];
800
+
801
+ const RELATION_TYPE_SET: ReadonlySet<string> = new Set(SUBSYSTEM_RELATION_TYPES);
802
+ const WALKTHROUGH_MECHANISM_SET: ReadonlySet<string> = new Set(
803
+ SUBSYSTEM_WALKTHROUGH_MECHANISMS,
804
+ );
805
+
806
+ /** True when a mechanism belongs to the topology relation vocabulary. */
807
+ export function isRelationMechanism(
808
+ mechanism: string,
809
+ ): mechanism is SubsystemRelationType {
810
+ return RELATION_TYPE_SET.has(mechanism);
811
+ }
812
+
813
+ /** True when a mechanism belongs to the walkthrough hop vocabulary. */
814
+ export function isWalkthroughMechanism(
815
+ mechanism: string,
816
+ ): mechanism is SubsystemWalkthroughMechanism {
817
+ return WALKTHROUGH_MECHANISM_SET.has(mechanism);
818
+ }
819
+
750
820
  export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
751
821
  imports: '#0893d2', // blue
752
822
  calls: '#4ec9b0', // teal