@principal-ai/subsystems-react 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/graphify/consolidated.d.ts +8 -0
  2. package/dist/graphify/consolidated.d.ts.map +1 -1
  3. package/dist/graphify/construct.d.ts +18 -0
  4. package/dist/graphify/construct.d.ts.map +1 -1
  5. package/dist/graphify/construct.js +23 -0
  6. package/dist/graphify/construct.js.map +1 -1
  7. package/dist/graphify/index.d.ts +2 -2
  8. package/dist/graphify/index.d.ts.map +1 -1
  9. package/dist/graphify/index.js +1 -1
  10. package/dist/graphify/index.js.map +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/subsystem/IssueList.d.ts +49 -3
  16. package/dist/subsystem/IssueList.d.ts.map +1 -1
  17. package/dist/subsystem/IssueList.js +180 -79
  18. package/dist/subsystem/IssueList.js.map +1 -1
  19. package/dist/subsystem/SubsystemComponentGraph.d.ts +9 -1
  20. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  21. package/dist/subsystem/SubsystemComponentGraph.js +461 -12
  22. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  23. package/dist/subsystem/formatDeclaration.js +19 -5
  24. package/dist/subsystem/formatDeclaration.js.map +1 -1
  25. package/dist/subsystem/model.d.ts +38 -0
  26. package/dist/subsystem/model.d.ts.map +1 -1
  27. package/dist/subsystem/model.js.map +1 -1
  28. package/dist/subsystem/nodes.d.ts.map +1 -1
  29. package/dist/subsystem/nodes.js +124 -67
  30. package/dist/subsystem/nodes.js.map +1 -1
  31. package/dist/subsystem/symbolRefs.d.ts.map +1 -1
  32. package/dist/subsystem/symbolRefs.js +2 -0
  33. package/dist/subsystem/symbolRefs.js.map +1 -1
  34. package/package.json +3 -3
  35. package/src/graphify/consolidated.ts +8 -0
  36. package/src/graphify/construct.test.ts +61 -1
  37. package/src/graphify/construct.ts +33 -0
  38. package/src/graphify/index.ts +6 -1
  39. package/src/index.ts +1 -0
  40. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +625 -0
  41. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +7 -1
  42. package/src/stories/Subsystem/ComponentGraph/NodeAnatomy.stories.tsx +40 -73
  43. package/src/stories/Subsystem/ComponentGraph/StoreValueType.stories.tsx +261 -0
  44. package/src/stories/Subsystem/StoreValueTypeDeclaration.stories.tsx +182 -0
  45. package/src/subsystem/IssueList.focus.test.tsx +113 -0
  46. package/src/subsystem/IssueList.icon.test.tsx +95 -0
  47. package/src/subsystem/IssueList.tsx +267 -111
  48. package/src/subsystem/SubsystemComponentGraph.tsx +530 -12
  49. package/src/subsystem/formatDeclaration.test.ts +64 -0
  50. package/src/subsystem/formatDeclaration.ts +20 -5
  51. package/src/subsystem/model.ts +46 -0
  52. package/src/subsystem/nodes.group.test.tsx +52 -0
  53. package/src/subsystem/nodes.tsx +102 -2
  54. package/src/subsystem/symbolRefs.ts +2 -0
@@ -2,6 +2,70 @@ import { describe, expect, test } from 'bun:test';
2
2
  import { generateDeclarationString, resolveComponentDeclaration } from './formatDeclaration';
3
3
  import type { SubsystemComponent } from './model';
4
4
 
5
+ describe('generateDeclarationString — store', () => {
6
+ const base: Omit<SubsystemComponent, 'declaration'> = {
7
+ id: 'st',
8
+ name: 'feeds',
9
+ construct: 'store',
10
+ symbol: 'feeds',
11
+ file: 'src/live.ts',
12
+ purl: 'pkg:github/acme/app',
13
+ };
14
+
15
+ test('renders a declared valueType as the store signature', () => {
16
+ const component: SubsystemComponent = {
17
+ ...base,
18
+ declaration: {
19
+ kind: 'store',
20
+ storage: 'memory',
21
+ properties: [],
22
+ valueType: 'Map<string, OpencodeLiveFeedState>',
23
+ },
24
+ };
25
+ expect(generateDeclarationString(component)).toBe(
26
+ '// store: feeds — Map<string, OpencodeLiveFeedState>\n// backing: memory',
27
+ );
28
+ });
29
+
30
+ test('named members render as a body under the valueType', () => {
31
+ const component: SubsystemComponent = {
32
+ ...base,
33
+ declaration: {
34
+ kind: 'store',
35
+ storage: 'memory',
36
+ valueType: 'Map<string, { width: number }>',
37
+ properties: [{ name: 'current', type: 'Map<string, { width: number }>' }],
38
+ },
39
+ };
40
+ expect(generateDeclarationString(component)).toBe(
41
+ '// store: feeds — Map<string, { width: number }>\n// backing: memory\n current: Map<string, { width: number }>;',
42
+ );
43
+ });
44
+
45
+ test('members without a valueType still render as declare const lines', () => {
46
+ const component: SubsystemComponent = {
47
+ ...base,
48
+ declaration: {
49
+ kind: 'store',
50
+ properties: [{ name: 'ROOT', type: 'string' }],
51
+ },
52
+ };
53
+ expect(generateDeclarationString(component)).toBe(
54
+ '// store: feeds\ndeclare const ROOT: string;',
55
+ );
56
+ });
57
+
58
+ test('a store with neither valueType nor members says so explicitly', () => {
59
+ const component: SubsystemComponent = {
60
+ ...base,
61
+ declaration: { kind: 'store', storage: 'memory', properties: [] },
62
+ };
63
+ expect(generateDeclarationString(component)).toBe(
64
+ '// store: feeds — no declared type (no valueType or state members)\n// backing: memory',
65
+ );
66
+ });
67
+ });
68
+
5
69
  describe('generateDeclarationString — custom_entity', () => {
6
70
  test('renders entity identity + entityKind, no attributes when none authored', () => {
7
71
  const component: SubsystemComponent = {
@@ -223,19 +223,34 @@ function generateType(
223
223
  }
224
224
 
225
225
  /**
226
- * A store renders as its retained state — ambient `declare const` lines for
227
- * the state members, never a class/method stub. The node's name labels the
228
- * block; the access mechanism lives in separate accessor nodes.
226
+ * A store renders as its retained state — the declaration of what it holds,
227
+ * the way every other construct renders its signature. The node's name labels
228
+ * the block; the access mechanism lives in separate accessor nodes.
229
+ *
230
+ * Order: the backing comment, then the store's own typed declaration
231
+ * (`valueType` when declared, otherwise the named members as a body), then any
232
+ * remaining members. A store that declares neither says so explicitly rather
233
+ * than rendering as an empty block.
229
234
  */
230
235
  function generateStore(name: string, declaration?: GraphifyComponentDetail): string {
231
236
  const store = declaration?.kind === 'store' ? declaration : undefined;
232
237
  const backing = store?.storage ? `\n// backing: ${store.storage}` : '';
233
- const props = (store?.properties ?? [])
238
+ const members = store?.properties ?? [];
239
+ const props = members
234
240
  .map((p) => `declare const ${p.name}${p.type ? `: ${p.type}` : ''};`)
235
241
  .join('\n');
236
242
 
243
+ if (store?.valueType) {
244
+ const named = members.length
245
+ ? `\n${members
246
+ .map((p) => ` ${p.name}${p.type ? `: ${p.type}` : ''};`)
247
+ .join('\n')}`
248
+ : '';
249
+ return `// store: ${name} — ${store.valueType}${backing}${named}`;
250
+ }
251
+
237
252
  if (!props) {
238
- return `// store: ${name} — no captured state members${backing}`;
253
+ return `// store: ${name} — no declared type (no valueType or state members)${backing}`;
239
254
  }
240
255
  return `// store: ${name}${backing}\n${props}`;
241
256
  }
@@ -1038,6 +1038,50 @@ export interface SubsystemGraphNodeData extends Record<string, unknown> {
1038
1038
  fileMatch?: boolean;
1039
1039
  /** True while this node is on an opened-but-unselected flow. */
1040
1040
  dimmed?: boolean;
1041
+ /** Diagnostics badge — see `SubsystemNodeIssue`. Absent when no findings. */
1042
+ issue?: SubsystemNodeIssue;
1043
+ }
1044
+
1045
+ /**
1046
+ * The per-construct verification ladder, in order. A finding's rung is the
1047
+ * earliest step it fails: file → symbol → declaration → type → signature.
1048
+ */
1049
+ export type SubsystemIssueRung =
1050
+ | 'file'
1051
+ | 'symbol'
1052
+ | 'declaration'
1053
+ | 'type'
1054
+ | 'signature';
1055
+
1056
+ /**
1057
+ * Per-node diagnostics badge — the proposed issue overlay (severity ring +
1058
+ * earliest-rung corner chip). The graph derives one per component from the
1059
+ * audit issues whose component target resolves to that node. Absent when the
1060
+ * node has no rung-mapped findings.
1061
+ */
1062
+ export interface SubsystemNodeIssue {
1063
+ /** Worst severity on the node — `error` (contradicts source) beats `info`. */
1064
+ severity: 'error' | 'info';
1065
+ /** Earliest failing rung across the node's findings. */
1066
+ rung: SubsystemIssueRung;
1067
+ /** Total findings on this node. */
1068
+ count: number;
1069
+ }
1070
+
1071
+ /**
1072
+ * Per-frame diagnostics badge — a finding about the BOUNDARY itself (its
1073
+ * containment / nesting), not about any member construct. Separate from
1074
+ * `SubsystemNodeIssue` because these have no construct rung to report: the
1075
+ * fault is the region's shape, which is also why it badges the frame rather
1076
+ * than a leaf. `kind` selects the icon (see `ISSUE_KIND_ICON`).
1077
+ */
1078
+ export interface SubsystemRegionIssue {
1079
+ /** Worst severity across the region's findings — `error` beats `info`. */
1080
+ severity: 'error' | 'info';
1081
+ /** The kind that supplies the badge icon. */
1082
+ kind: string;
1083
+ /** Total findings on this region. */
1084
+ count: number;
1041
1085
  }
1042
1086
 
1043
1087
  export interface SubsystemGroupNodeData extends Record<string, unknown> {
@@ -1046,6 +1090,8 @@ export interface SubsystemGroupNodeData extends Record<string, unknown> {
1046
1090
  dimmed?: boolean;
1047
1091
  /** Host-supplied frame color override; falls back to the derived color. */
1048
1092
  color?: string;
1093
+ /** Boundary diagnostics badge — see `SubsystemRegionIssue`. */
1094
+ issue?: SubsystemRegionIssue;
1049
1095
  }
1050
1096
 
1051
1097
  export type SubsystemGraphNode =
@@ -62,3 +62,55 @@ describe('SubsystemGroupNode frame color', () => {
62
62
  expect(frame.style.border).toContain('#ff6b35');
63
63
  });
64
64
  });
65
+
66
+ describe('SubsystemGroupNode boundary badge', () => {
67
+ // A boundary finding is a property of the region's shape, so it badges the
68
+ // frame with its own icon rather than borrowing the verification-rung chips
69
+ // that leaf constructs wear.
70
+ const moduleRegion = {
71
+ kind: 'module' as const,
72
+ key: 'src/bun/index.ts',
73
+ label: 'src/bun/index.ts',
74
+ memberAliases: ['a', 'b', 'c'],
75
+ };
76
+
77
+ test('a boundary finding badges the frame with its own icon', () => {
78
+ const { container } = renderGroup({
79
+ region: moduleRegion,
80
+ issue: {
81
+ severity: 'info',
82
+ kind: 'boundary_process_nest_disagree',
83
+ count: 1,
84
+ },
85
+ });
86
+ // The chip is aria-hidden and inert; its lucide icon carries the class.
87
+ expect(container.querySelector('.lucide-split')).not.toBeNull();
88
+ });
89
+
90
+ test('a finding with no dedicated frame icon is not badged', () => {
91
+ // `construct_unconfirmed` belongs on a leaf node, not the frame — badging
92
+ // the region with it would imply the region is at fault.
93
+ const { container } = renderGroup({
94
+ region: moduleRegion,
95
+ issue: { severity: 'error', kind: 'construct_unconfirmed', count: 1 },
96
+ });
97
+ expect(container.querySelector('svg')).toBeNull();
98
+ });
99
+
100
+ test('a region with no findings renders no chip', () => {
101
+ const { container } = renderGroup({ region: moduleRegion });
102
+ expect(container.querySelector('svg')).toBeNull();
103
+ });
104
+
105
+ test('the chip shows a count when the region has more than one finding', () => {
106
+ const { container } = renderGroup({
107
+ region: moduleRegion,
108
+ issue: {
109
+ severity: 'error',
110
+ kind: 'boundary_process_nest_disagree',
111
+ count: 3,
112
+ },
113
+ });
114
+ expect(container.textContent).toContain('3');
115
+ });
116
+ });
@@ -36,9 +36,12 @@ import {
36
36
  type SubsystemGraphNodeData,
37
37
  type SubsystemGroupNodeData,
38
38
  type SubsystemGraphEdge,
39
+ type SubsystemNodeIssue,
39
40
  } from './model';
40
41
  import { componentColor } from '../pierre/constructColors';
41
42
  import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
43
+ import { ISSUE_KIND_ICON, ISSUE_RUNG_ICON } from './IssueList';
44
+ import type { LucideIcon } from 'lucide-react';
42
45
 
43
46
  export const CONSTRUCT_LABEL: Record<string, string> = {
44
47
  class: 'class',
@@ -112,6 +115,80 @@ function useSubsystemCallbacks(): SubsystemGraphCallbacks {
112
115
  return useContext(SubsystemCallbacksContext) ?? SUBSYSTEM_CALLBACKS;
113
116
  }
114
117
 
118
+ /**
119
+ * The diagnostics overlay for a component node: the node's own border turns
120
+ * dotted (see the node's `borderStyle`) and an earliest-failing-rung corner
121
+ * chip (file → symbol → declaration → type → signature) carries the severity
122
+ * color, with a count when the node has more than one finding. The chip is
123
+ * purely additive and non-interactive; the border stays construct-colored, so
124
+ * severity never has to compete with the construct palette.
125
+ */
126
+ function NodeIssueOverlay({ issue }: { issue: SubsystemNodeIssue }) {
127
+ const { theme } = useTheme();
128
+ const isError = issue.severity === 'error';
129
+ const color = isError
130
+ ? (theme.colors.error ?? '#e5534b')
131
+ : (theme.colors.warning ?? '#d4a017');
132
+ return (
133
+ <IssueChip
134
+ color={color}
135
+ Icon={ISSUE_RUNG_ICON[issue.rung]}
136
+ count={issue.count}
137
+ anchor={{ right: -9, bottom: -8 }}
138
+ />
139
+ );
140
+ }
141
+
142
+ /**
143
+ * The shared severity chip: a severity-colored ring around the finding's icon,
144
+ * with a count when there is more than one. Component nodes anchor it to the
145
+ * bottom-right corner; a region frame anchors it to the top-right so it never
146
+ * collides with the frame's own name badge. `aria-hidden` and inert — the
147
+ * canvas badge never competes with the sidebar card for the interaction.
148
+ */
149
+ function IssueChip({
150
+ color,
151
+ Icon,
152
+ count,
153
+ anchor,
154
+ }: {
155
+ color: string;
156
+ Icon: LucideIcon;
157
+ count: number;
158
+ anchor: React.CSSProperties;
159
+ }) {
160
+ const { theme } = useTheme();
161
+ const badgeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
162
+ return (
163
+ <div
164
+ aria-hidden
165
+ style={{
166
+ position: 'absolute',
167
+ zIndex: 2,
168
+ display: 'inline-flex',
169
+ alignItems: 'center',
170
+ justifyContent: 'center',
171
+ gap: 4,
172
+ minWidth: 22,
173
+ padding: '3px 5px',
174
+ borderRadius: 5,
175
+ border: `2px solid ${color}`,
176
+ background: badgeBg,
177
+ color,
178
+ fontFamily: theme.fonts.monospace,
179
+ fontSize: theme.fontSizes[1],
180
+ fontWeight: 700,
181
+ lineHeight: 1.1,
182
+ pointerEvents: 'none',
183
+ ...anchor,
184
+ }}
185
+ >
186
+ <Icon size={14} color={color} />
187
+ {count > 1 ? <span>{count}</span> : null}
188
+ </div>
189
+ );
190
+ }
191
+
115
192
  export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeData, 'subsystem-component'>>) {
116
193
  const { theme, mode } = useTheme();
117
194
  const callbacks = useSubsystemCallbacks();
@@ -168,6 +245,12 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
168
245
  const borderW = isSelected || fileMatch ? 4 : 2;
169
246
  const badgeTop = -9 - borderW;
170
247
  const badgeEdge = -borderW;
248
+ // A node with diagnostics turns its border dotted — a "something's off"
249
+ // sibling of `proposed`'s dashed, but distinct so the two don't read alike.
250
+ // The border keeps its construct color; the severity rides on the chip.
251
+ // Verification skips `proposed` nodes, so the two rarely stack — issue wins.
252
+ const hasIssue = data.issue != null;
253
+ const borderStyle = hasIssue ? 'dotted' : c.proposed ? 'dashed' : 'solid';
171
254
 
172
255
  return (
173
256
  <div
@@ -198,8 +281,9 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
198
281
  borderRadius: nodeRadius,
199
282
  background: hover ? hoverBg : nodeBg,
200
283
  // Selected / file-matched nodes get a thicker border. Proposed nodes
201
- // use a dashed goldenrod border; left construct badge keeps construct color.
202
- border: `${borderW}px ${c.proposed ? 'dashed' : 'solid'} ${borderColor}`,
284
+ // use a dashed goldenrod border, issue nodes a dotted construct-colored
285
+ // one; the left construct badge keeps construct color either way.
286
+ border: `${borderW}px ${borderStyle} ${borderColor}`,
203
287
  boxShadow: fileMatch
204
288
  ? `0 1px 4px rgba(0,0,0,0.25), 0 0 12px ${theme.colors.primary}55`
205
289
  : '0 1px 4px rgba(0,0,0,0.25)',
@@ -339,6 +423,8 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
339
423
  </div>
340
424
  )}
341
425
 
426
+ {data.issue && <NodeIssueOverlay issue={data.issue} />}
427
+
342
428
  <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
343
429
  <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
344
430
  </div>
@@ -494,6 +580,20 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
494
580
  label
495
581
  )}
496
582
  </div>
583
+ {/* Boundary diagnostics badge — top-right, opposite the name badge, so the
584
+ two never collide however long the module path grows. */}
585
+ {data.issue && ISSUE_KIND_ICON[data.issue.kind] && (
586
+ <IssueChip
587
+ color={
588
+ data.issue.severity === 'error'
589
+ ? (theme.colors.error ?? '#e5534b')
590
+ : (theme.colors.warning ?? '#d4a017')
591
+ }
592
+ Icon={ISSUE_KIND_ICON[data.issue.kind]!}
593
+ count={data.issue.count}
594
+ anchor={{ right: 12, top: 0, transform: 'translateY(-50%)' }}
595
+ />
596
+ )}
497
597
  </div>
498
598
  );
499
599
  }
@@ -335,6 +335,8 @@ export function extractDeclarationSymbolRefs(
335
335
  for (const alt of declaration.unionOf ?? []) addType(alt, undefined, 'union');
336
336
  break;
337
337
  case 'store':
338
+ // The store's own value type (`valueTypeRef`), then its members.
339
+ addType(declaration.valueType, declaration.valueTypeRef, 'field');
338
340
  for (const p of declaration.properties ?? []) {
339
341
  addType(p.type, p.typeRef, 'field');
340
342
  }