@fragments-sdk/cli 0.6.0 → 0.7.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 (178) hide show
  1. package/dist/bin.js +529 -285
  2. package/dist/bin.js.map +1 -1
  3. package/dist/{chunk-F7ITZPDJ.js → chunk-32VIEOQY.js} +18 -18
  4. package/dist/chunk-32VIEOQY.js.map +1 -0
  5. package/dist/{chunk-SSLQXHNX.js → chunk-5ITIP3ES.js} +27 -27
  6. package/dist/chunk-5ITIP3ES.js.map +1 -0
  7. package/dist/{chunk-RVRTRESS.js → chunk-DQHWLAUV.js} +29 -29
  8. package/dist/chunk-DQHWLAUV.js.map +1 -0
  9. package/dist/{chunk-Q7GOHVOK.js → chunk-GCZMFLDI.js} +67 -32
  10. package/dist/chunk-GCZMFLDI.js.map +1 -0
  11. package/dist/{chunk-6JBGU74P.js → chunk-GHYYFAQN.js} +23 -23
  12. package/dist/chunk-GHYYFAQN.js.map +1 -0
  13. package/dist/{chunk-NWQ4CJOQ.js → chunk-GKX2HPZ6.js} +40 -40
  14. package/dist/chunk-GKX2HPZ6.js.map +1 -0
  15. package/dist/{chunk-D35RGPAG.js → chunk-U6VTHBNI.js} +499 -83
  16. package/dist/chunk-U6VTHBNI.js.map +1 -0
  17. package/dist/{core-SKRPJQZG.js → core-SFHPYR5H.js} +24 -26
  18. package/dist/{generate-7AF7WRVK.js → generate-54GJAWUY.js} +5 -5
  19. package/dist/generate-54GJAWUY.js.map +1 -0
  20. package/dist/index.d.ts +23 -27
  21. package/dist/index.js +10 -10
  22. package/dist/{init-WKGDPYI4.js → init-EIM5WNMP.js} +5 -5
  23. package/dist/{init-WKGDPYI4.js.map → init-EIM5WNMP.js.map} +1 -1
  24. package/dist/mcp-bin.js +73 -73
  25. package/dist/mcp-bin.js.map +1 -1
  26. package/dist/scan-KQBKUS64.js +12 -0
  27. package/dist/{service-F3E4JJM7.js → service-ED2LNCTU.js} +6 -6
  28. package/dist/{static-viewer-4LQZ5AGA.js → static-viewer-Q4F4QP5M.js} +4 -4
  29. package/dist/{test-CJDNJTPZ.js → test-6VN2DA3S.js} +19 -19
  30. package/dist/test-6VN2DA3S.js.map +1 -0
  31. package/dist/{tokens-JAJABYXP.js → tokens-P2B7ZAM3.js} +5 -5
  32. package/dist/{viewer-R3Q6WAMJ.js → viewer-GM7IQPPB.js} +199 -199
  33. package/dist/viewer-GM7IQPPB.js.map +1 -0
  34. package/package.json +2 -2
  35. package/src/ai.ts +5 -5
  36. package/src/analyze.ts +11 -11
  37. package/src/bin.ts +24 -1
  38. package/src/build.ts +64 -21
  39. package/src/commands/a11y.ts +6 -6
  40. package/src/commands/add.ts +11 -11
  41. package/src/commands/audit.ts +4 -4
  42. package/src/commands/baseline.ts +3 -3
  43. package/src/commands/build.ts +8 -8
  44. package/src/commands/compare.ts +20 -20
  45. package/src/commands/context.ts +16 -16
  46. package/src/commands/enhance.ts +36 -36
  47. package/src/commands/generate.ts +1 -1
  48. package/src/commands/graph.ts +274 -0
  49. package/src/commands/init.ts +1 -1
  50. package/src/commands/link/figma.ts +82 -82
  51. package/src/commands/link/index.ts +3 -3
  52. package/src/commands/link/storybook.ts +9 -9
  53. package/src/commands/list.ts +2 -2
  54. package/src/commands/reset.ts +15 -15
  55. package/src/commands/scan.ts +27 -27
  56. package/src/commands/storygen.ts +24 -24
  57. package/src/commands/validate.ts +2 -2
  58. package/src/commands/verify.ts +8 -8
  59. package/src/core/auto-props.ts +4 -4
  60. package/src/core/composition.test.ts +36 -36
  61. package/src/core/composition.ts +83 -20
  62. package/src/core/config.ts +6 -6
  63. package/src/core/{defineSegment.ts → defineFragment.ts} +16 -22
  64. package/src/core/discovery.ts +6 -6
  65. package/src/core/figma.ts +2 -2
  66. package/src/core/graph-extractor.test.ts +542 -0
  67. package/src/core/graph-extractor.ts +601 -0
  68. package/src/core/importAnalyzer.ts +6 -1
  69. package/src/core/index.ts +22 -23
  70. package/src/core/loader.ts +22 -22
  71. package/src/core/node.ts +5 -5
  72. package/src/core/parser.ts +31 -31
  73. package/src/core/previewLoader.ts +1 -1
  74. package/src/core/schema.ts +16 -16
  75. package/src/core/storyAdapter.test.ts +87 -87
  76. package/src/core/storyAdapter.ts +16 -16
  77. package/src/core/types.ts +21 -26
  78. package/src/diff.ts +22 -22
  79. package/src/index.ts +2 -2
  80. package/src/mcp/server.ts +80 -80
  81. package/src/migrate/__tests__/utils/utils.test.ts +3 -3
  82. package/src/migrate/bin.ts +4 -4
  83. package/src/migrate/converter.ts +16 -16
  84. package/src/migrate/index.ts +3 -3
  85. package/src/migrate/migrate.ts +3 -3
  86. package/src/migrate/parser.ts +8 -8
  87. package/src/migrate/report.ts +2 -2
  88. package/src/migrate/types.ts +4 -4
  89. package/src/screenshot.ts +22 -22
  90. package/src/service/__tests__/props-extractor.test.ts +15 -15
  91. package/src/service/analytics.ts +39 -39
  92. package/src/service/enhance/codebase-scanner.ts +1 -1
  93. package/src/service/enhance/index.ts +1 -1
  94. package/src/service/enhance/props-extractor.ts +2 -2
  95. package/src/service/enhance/types.ts +2 -2
  96. package/src/service/index.ts +2 -2
  97. package/src/service/metrics-store.ts +1 -1
  98. package/src/service/patch-generator.ts +1 -1
  99. package/src/setup.ts +52 -52
  100. package/src/shared/dev-server-client.ts +7 -7
  101. package/src/shared/fragment-loader.ts +59 -0
  102. package/src/shared/index.ts +1 -1
  103. package/src/shared/types.ts +4 -4
  104. package/src/static-viewer.ts +35 -35
  105. package/src/test/discovery.ts +6 -6
  106. package/src/test/index.ts +5 -5
  107. package/src/test/reporters/console.ts +1 -1
  108. package/src/test/reporters/junit.ts +1 -1
  109. package/src/test/runner.ts +7 -7
  110. package/src/test/types.ts +3 -3
  111. package/src/test/watch.ts +9 -9
  112. package/src/validators.ts +26 -26
  113. package/src/viewer/__tests__/render-utils.test.ts +28 -28
  114. package/src/viewer/__tests__/viewer-integration.test.ts +4 -4
  115. package/src/viewer/cli/health.ts +26 -26
  116. package/src/viewer/components/App.tsx +201 -103
  117. package/src/viewer/components/BottomPanel.tsx +17 -17
  118. package/src/viewer/components/CodePanel.tsx +3 -3
  119. package/src/viewer/components/CommandPalette.tsx +11 -11
  120. package/src/viewer/components/ComponentGraph.tsx +28 -28
  121. package/src/viewer/components/ComponentHeader.tsx +2 -2
  122. package/src/viewer/components/ContractPanel.tsx +6 -6
  123. package/src/viewer/components/FigmaEmbed.tsx +9 -9
  124. package/src/viewer/components/HealthDashboard.tsx +17 -17
  125. package/src/viewer/components/Icons.tsx +53 -1
  126. package/src/viewer/components/InteractionsPanel.tsx +2 -2
  127. package/src/viewer/components/IsolatedPreviewFrame.tsx +6 -6
  128. package/src/viewer/components/IsolatedRender.tsx +10 -10
  129. package/src/viewer/components/Layout.tsx +7 -3
  130. package/src/viewer/components/LeftSidebar.tsx +92 -114
  131. package/src/viewer/components/MultiViewportPreview.tsx +14 -14
  132. package/src/viewer/components/PreviewArea.tsx +11 -11
  133. package/src/viewer/components/PreviewFrameHost.tsx +77 -48
  134. package/src/viewer/components/PreviewToolbar.tsx +57 -10
  135. package/src/viewer/components/RightSidebar.tsx +9 -9
  136. package/src/viewer/components/Sidebar.tsx +17 -17
  137. package/src/viewer/components/StoryRenderer.tsx +2 -2
  138. package/src/viewer/components/TokenStylePanel.tsx +1 -1
  139. package/src/viewer/components/UsageSection.tsx +2 -2
  140. package/src/viewer/components/VariantMatrix.tsx +11 -11
  141. package/src/viewer/components/VariantRenderer.tsx +3 -3
  142. package/src/viewer/components/VariantTabs.tsx +2 -2
  143. package/src/viewer/components/ViewportSelector.tsx +56 -45
  144. package/src/viewer/components/_future/CreatePage.tsx +6 -6
  145. package/src/viewer/composition-renderer.ts +11 -11
  146. package/src/viewer/constants/ui.ts +4 -4
  147. package/src/viewer/entry.tsx +40 -40
  148. package/src/viewer/hooks/useFigmaIntegration.ts +1 -1
  149. package/src/viewer/hooks/usePreviewBridge.ts +5 -5
  150. package/src/viewer/hooks/useUrlState.ts +6 -6
  151. package/src/viewer/index.ts +2 -2
  152. package/src/viewer/intelligence/healthReport.ts +17 -17
  153. package/src/viewer/intelligence/styleDrift.ts +1 -1
  154. package/src/viewer/intelligence/usageScanner.ts +1 -1
  155. package/src/viewer/preview-frame.html +22 -13
  156. package/src/viewer/render-template.html +1 -1
  157. package/src/viewer/render-utils.ts +21 -21
  158. package/src/viewer/server.ts +18 -18
  159. package/src/viewer/styles/globals.css +42 -81
  160. package/src/viewer/utils/detectRelationships.ts +22 -22
  161. package/src/viewer/vite-plugin.ts +213 -213
  162. package/dist/chunk-6JBGU74P.js.map +0 -1
  163. package/dist/chunk-D35RGPAG.js.map +0 -1
  164. package/dist/chunk-F7ITZPDJ.js.map +0 -1
  165. package/dist/chunk-NWQ4CJOQ.js.map +0 -1
  166. package/dist/chunk-Q7GOHVOK.js.map +0 -1
  167. package/dist/chunk-RVRTRESS.js.map +0 -1
  168. package/dist/chunk-SSLQXHNX.js.map +0 -1
  169. package/dist/generate-7AF7WRVK.js.map +0 -1
  170. package/dist/scan-K6JNMCGM.js +0 -12
  171. package/dist/test-CJDNJTPZ.js.map +0 -1
  172. package/dist/viewer-R3Q6WAMJ.js.map +0 -1
  173. package/src/shared/segment-loader.ts +0 -59
  174. /package/dist/{core-SKRPJQZG.js.map → core-SFHPYR5H.js.map} +0 -0
  175. /package/dist/{scan-K6JNMCGM.js.map → scan-KQBKUS64.js.map} +0 -0
  176. /package/dist/{service-F3E4JJM7.js.map → service-ED2LNCTU.js.map} +0 -0
  177. /package/dist/{static-viewer-4LQZ5AGA.js.map → static-viewer-Q4F4QP5M.js.map} +0 -0
  178. /package/dist/{tokens-JAJABYXP.js.map → tokens-P2B7ZAM3.js.map} +0 -0
@@ -0,0 +1,542 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { CompiledFragment, CompiledBlock } from './types.js';
3
+ import {
4
+ buildComponentGraph,
5
+ extractJsxUsageEdges,
6
+ extractBlockEdges,
7
+ extractRelationEdges,
8
+ inferRequiredChildren,
9
+ generateCommonPatterns,
10
+ mergeAndDeduplicate,
11
+ } from './graph-extractor.js';
12
+ import type { GraphEdge } from '@fragments-sdk/context/graph';
13
+ import { EDGE_TYPE_WEIGHTS } from '@fragments-sdk/context/graph';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Test helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function makeFragment(name: string, overrides: Partial<CompiledFragment> = {}): CompiledFragment {
20
+ return {
21
+ filePath: `src/components/${name}/${name}.fragment.tsx`,
22
+ meta: {
23
+ name,
24
+ description: `The ${name} component`,
25
+ category: 'test',
26
+ status: 'stable',
27
+ ...overrides.meta,
28
+ },
29
+ usage: {
30
+ when: [`Use ${name} for testing`],
31
+ whenNot: [],
32
+ ...overrides.usage,
33
+ },
34
+ props: overrides.props ?? {},
35
+ variants: overrides.variants ?? [
36
+ { name: 'Default', description: `Default ${name}` },
37
+ ],
38
+ relations: overrides.relations,
39
+ ai: overrides.ai,
40
+ };
41
+ }
42
+
43
+ function makeBlock(name: string, components: string[], category = 'test'): CompiledBlock {
44
+ return {
45
+ filePath: `src/blocks/${name}.block.ts`,
46
+ name,
47
+ description: `${name} block`,
48
+ category,
49
+ components,
50
+ code: `<${components[0]}></${components[0]}>`,
51
+ tags: [],
52
+ };
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // extractJsxUsageEdges
57
+ // ---------------------------------------------------------------------------
58
+
59
+ describe('extractJsxUsageEdges()', () => {
60
+ it('detects JSX component usage in variant code', () => {
61
+ const fragments: Record<string, CompiledFragment> = {
62
+ Dialog: makeFragment('Dialog', {
63
+ variants: [
64
+ {
65
+ name: 'Default',
66
+ description: 'Default dialog',
67
+ code: '<Dialog>\n <Button>Close</Button>\n</Dialog>',
68
+ },
69
+ ],
70
+ }),
71
+ Button: makeFragment('Button'),
72
+ };
73
+
74
+ const edges = extractJsxUsageEdges(fragments, new Set(['Dialog', 'Button']));
75
+ expect(edges.length).toBe(1);
76
+ expect(edges[0].source).toBe('Dialog');
77
+ expect(edges[0].target).toBe('Button');
78
+ expect(edges[0].type).toBe('renders');
79
+ });
80
+
81
+ it('handles compound component notation (Header.Nav)', () => {
82
+ const fragments: Record<string, CompiledFragment> = {
83
+ AppShell: makeFragment('AppShell', {
84
+ variants: [
85
+ {
86
+ name: 'Default',
87
+ description: 'Default shell',
88
+ code: '<AppShell>\n <Header.Nav>...</Header.Nav>\n <Sidebar.Content>...</Sidebar.Content>\n</AppShell>',
89
+ },
90
+ ],
91
+ }),
92
+ Header: makeFragment('Header'),
93
+ Sidebar: makeFragment('Sidebar'),
94
+ };
95
+
96
+ const edges = extractJsxUsageEdges(
97
+ fragments,
98
+ new Set(['AppShell', 'Header', 'Sidebar']),
99
+ );
100
+ expect(edges.length).toBe(2);
101
+ expect(edges.some(e => e.target === 'Header')).toBe(true);
102
+ expect(edges.some(e => e.target === 'Sidebar')).toBe(true);
103
+ });
104
+
105
+ it('does not create self-referencing edges', () => {
106
+ const fragments: Record<string, CompiledFragment> = {
107
+ Button: makeFragment('Button', {
108
+ variants: [
109
+ {
110
+ name: 'Default',
111
+ description: 'Default',
112
+ code: '<Button>Click me</Button>',
113
+ },
114
+ ],
115
+ }),
116
+ };
117
+
118
+ const edges = extractJsxUsageEdges(fragments, new Set(['Button']));
119
+ expect(edges.length).toBe(0);
120
+ });
121
+
122
+ it('deduplicates across variants', () => {
123
+ const fragments: Record<string, CompiledFragment> = {
124
+ Dialog: makeFragment('Dialog', {
125
+ variants: [
126
+ { name: 'V1', description: 'V1', code: '<Dialog><Button>OK</Button></Dialog>' },
127
+ { name: 'V2', description: 'V2', code: '<Dialog><Button>Cancel</Button></Dialog>' },
128
+ ],
129
+ }),
130
+ Button: makeFragment('Button'),
131
+ };
132
+
133
+ const edges = extractJsxUsageEdges(fragments, new Set(['Dialog', 'Button']));
134
+ // Should only create one edge even though Button appears in two variants
135
+ expect(edges.length).toBe(1);
136
+ });
137
+
138
+ it('skips variants without code', () => {
139
+ const fragments: Record<string, CompiledFragment> = {
140
+ Dialog: makeFragment('Dialog', {
141
+ variants: [
142
+ { name: 'NoCode', description: 'No code variant' },
143
+ ],
144
+ }),
145
+ };
146
+
147
+ const edges = extractJsxUsageEdges(fragments, new Set(['Dialog']));
148
+ expect(edges.length).toBe(0);
149
+ });
150
+
151
+ it('ignores unknown component tags', () => {
152
+ const fragments: Record<string, CompiledFragment> = {
153
+ Dialog: makeFragment('Dialog', {
154
+ variants: [
155
+ { name: 'Default', description: 'Default', code: '<Dialog><UnknownThing /></Dialog>' },
156
+ ],
157
+ }),
158
+ };
159
+
160
+ const edges = extractJsxUsageEdges(fragments, new Set(['Dialog']));
161
+ expect(edges.length).toBe(0);
162
+ });
163
+ });
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // extractBlockEdges
167
+ // ---------------------------------------------------------------------------
168
+
169
+ describe('extractBlockEdges()', () => {
170
+ it('creates pairwise composes edges', () => {
171
+ const blocks: Record<string, CompiledBlock> = {
172
+ LoginForm: makeBlock('LoginForm', ['Dialog', 'Input', 'Button']),
173
+ };
174
+
175
+ const edges = extractBlockEdges(blocks);
176
+ // 3 components → 3 pairwise edges (Dialog-Input, Dialog-Button, Input-Button)
177
+ expect(edges.length).toBe(3);
178
+ expect(edges.every(e => e.type === 'composes')).toBe(true);
179
+ expect(edges.every(e => e.provenance === 'block:LoginForm')).toBe(true);
180
+ });
181
+
182
+ it('handles single-component blocks', () => {
183
+ const blocks: Record<string, CompiledBlock> = {
184
+ Solo: makeBlock('Solo', ['Button']),
185
+ };
186
+
187
+ const edges = extractBlockEdges(blocks);
188
+ expect(edges.length).toBe(0);
189
+ });
190
+
191
+ it('handles multiple blocks', () => {
192
+ const blocks: Record<string, CompiledBlock> = {
193
+ Login: makeBlock('Login', ['Dialog', 'Button']),
194
+ Search: makeBlock('Search', ['Input', 'Button']),
195
+ };
196
+
197
+ const edges = extractBlockEdges(blocks);
198
+ // Login: Dialog-Button = 1 edge; Search: Input-Button = 1 edge
199
+ expect(edges.length).toBe(2);
200
+ });
201
+
202
+ it('preserves block provenance', () => {
203
+ const blocks: Record<string, CompiledBlock> = {
204
+ Dashboard: makeBlock('Dashboard', ['Header', 'Sidebar']),
205
+ };
206
+
207
+ const edges = extractBlockEdges(blocks);
208
+ expect(edges[0].provenance).toBe('block:Dashboard');
209
+ });
210
+ });
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // extractRelationEdges
214
+ // ---------------------------------------------------------------------------
215
+
216
+ describe('extractRelationEdges()', () => {
217
+ it('maps parent relation correctly', () => {
218
+ const fragments: Record<string, CompiledFragment> = {
219
+ Button: makeFragment('Button', {
220
+ relations: [
221
+ { component: 'Header', relationship: 'parent', note: 'Used in Header actions' },
222
+ ],
223
+ }),
224
+ };
225
+
226
+ const edges = extractRelationEdges(fragments);
227
+ expect(edges.length).toBe(1);
228
+ // parent relation: Header is parent of Button → source=Header, target=Button
229
+ expect(edges[0].source).toBe('Header');
230
+ expect(edges[0].target).toBe('Button');
231
+ expect(edges[0].type).toBe('parent-of');
232
+ });
233
+
234
+ it('maps child relation correctly', () => {
235
+ const fragments: Record<string, CompiledFragment> = {
236
+ Header: makeFragment('Header', {
237
+ relations: [
238
+ { component: 'Button', relationship: 'child', note: 'Renders action buttons' },
239
+ ],
240
+ }),
241
+ };
242
+
243
+ const edges = extractRelationEdges(fragments);
244
+ expect(edges.length).toBe(1);
245
+ expect(edges[0].source).toBe('Header');
246
+ expect(edges[0].target).toBe('Button');
247
+ expect(edges[0].type).toBe('parent-of');
248
+ });
249
+
250
+ it('maps alternative relation', () => {
251
+ const fragments: Record<string, CompiledFragment> = {
252
+ Dialog: makeFragment('Dialog', {
253
+ relations: [
254
+ { component: 'Popover', relationship: 'alternative', note: 'For non-modal overlays' },
255
+ ],
256
+ }),
257
+ };
258
+
259
+ const edges = extractRelationEdges(fragments);
260
+ expect(edges[0].type).toBe('alternative-to');
261
+ expect(edges[0].note).toBe('For non-modal overlays');
262
+ });
263
+
264
+ it('maps sibling relation', () => {
265
+ const fragments: Record<string, CompiledFragment> = {
266
+ Header: makeFragment('Header', {
267
+ relations: [
268
+ { component: 'Sidebar', relationship: 'sibling', note: 'Both in AppShell' },
269
+ ],
270
+ }),
271
+ };
272
+
273
+ const edges = extractRelationEdges(fragments);
274
+ expect(edges[0].type).toBe('sibling-of');
275
+ });
276
+
277
+ it('maps composition relation', () => {
278
+ const fragments: Record<string, CompiledFragment> = {
279
+ Input: makeFragment('Input', {
280
+ relations: [
281
+ { component: 'Button', relationship: 'composition', note: 'Often paired in forms' },
282
+ ],
283
+ }),
284
+ };
285
+
286
+ const edges = extractRelationEdges(fragments);
287
+ expect(edges[0].type).toBe('composes');
288
+ });
289
+
290
+ it('skips fragments without relations', () => {
291
+ const fragments: Record<string, CompiledFragment> = {
292
+ Button: makeFragment('Button'),
293
+ };
294
+
295
+ const edges = extractRelationEdges(fragments);
296
+ expect(edges.length).toBe(0);
297
+ });
298
+
299
+ it('skips unknown relationship types', () => {
300
+ const fragments: Record<string, CompiledFragment> = {
301
+ Button: makeFragment('Button', {
302
+ relations: [
303
+ { component: 'Icon', relationship: 'unknown-type' as any, note: 'Custom relation' },
304
+ ],
305
+ }),
306
+ };
307
+
308
+ const edges = extractRelationEdges(fragments);
309
+ expect(edges.length).toBe(0);
310
+ });
311
+ });
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // inferRequiredChildren
315
+ // ---------------------------------------------------------------------------
316
+
317
+ describe('inferRequiredChildren()', () => {
318
+ it('identifies sub-components present in ALL variants', () => {
319
+ const fragments: Record<string, CompiledFragment> = {
320
+ Dialog: makeFragment('Dialog', {
321
+ variants: [
322
+ { name: 'V1', description: 'V1', code: '<Dialog><Dialog.Content>...</Dialog.Content><Dialog.Footer>...</Dialog.Footer></Dialog>' },
323
+ { name: 'V2', description: 'V2', code: '<Dialog><Dialog.Content>...</Dialog.Content></Dialog>' },
324
+ ],
325
+ }),
326
+ };
327
+
328
+ const autoDetected = new Map([['Dialog', { subComponents: ['Content', 'Footer'] }]]);
329
+ const result = inferRequiredChildren(fragments, autoDetected);
330
+
331
+ expect(result.get('Dialog')).toEqual(['Content']);
332
+ });
333
+
334
+ it('returns empty for components with no subs', () => {
335
+ const fragments: Record<string, CompiledFragment> = {
336
+ Button: makeFragment('Button', {
337
+ variants: [{ name: 'Default', description: 'Default', code: '<Button>Click</Button>' }],
338
+ }),
339
+ };
340
+
341
+ const autoDetected = new Map<string, any>();
342
+ const result = inferRequiredChildren(fragments, autoDetected);
343
+ expect(result.size).toBe(0);
344
+ });
345
+
346
+ it('uses ai.subComponents when autoDetected is empty', () => {
347
+ const fragments: Record<string, CompiledFragment> = {
348
+ Dialog: makeFragment('Dialog', {
349
+ ai: { subComponents: ['Content', 'Footer'] },
350
+ variants: [
351
+ { name: 'V1', description: 'V1', code: '<Dialog><Dialog.Content>...</Dialog.Content><Dialog.Footer>...</Dialog.Footer></Dialog>' },
352
+ { name: 'V2', description: 'V2', code: '<Dialog><Dialog.Content>...</Dialog.Content><Dialog.Footer>...</Dialog.Footer></Dialog>' },
353
+ ],
354
+ }),
355
+ };
356
+
357
+ const autoDetected = new Map<string, any>();
358
+ const result = inferRequiredChildren(fragments, autoDetected);
359
+ expect(result.get('Dialog')).toEqual(['Content', 'Footer']);
360
+ });
361
+ });
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // generateCommonPatterns
365
+ // ---------------------------------------------------------------------------
366
+
367
+ describe('generateCommonPatterns()', () => {
368
+ it('generates skeleton patterns from variant code', () => {
369
+ const fragments: Record<string, CompiledFragment> = {
370
+ Dialog: makeFragment('Dialog', {
371
+ variants: [
372
+ {
373
+ name: 'Default',
374
+ description: 'Default',
375
+ code: '<Dialog>\n <Dialog.Content>Hello</Dialog.Content>\n <Dialog.Footer>Close</Dialog.Footer>\n</Dialog>',
376
+ },
377
+ ],
378
+ }),
379
+ };
380
+
381
+ const autoDetected = new Map([['Dialog', { subComponents: ['Content', 'Footer'] }]]);
382
+ const result = generateCommonPatterns(fragments, autoDetected);
383
+
384
+ expect(result.has('Dialog')).toBe(true);
385
+ const patterns = result.get('Dialog')!;
386
+ expect(patterns[0]).toContain('<Dialog>');
387
+ expect(patterns[0]).toContain('Dialog.Content');
388
+ expect(patterns[0]).toContain('Dialog.Footer');
389
+ });
390
+
391
+ it('returns empty for components without sub-components', () => {
392
+ const fragments: Record<string, CompiledFragment> = {
393
+ Button: makeFragment('Button'),
394
+ };
395
+
396
+ const autoDetected = new Map<string, any>();
397
+ const result = generateCommonPatterns(fragments, autoDetected);
398
+ expect(result.size).toBe(0);
399
+ });
400
+ });
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // mergeAndDeduplicate
404
+ // ---------------------------------------------------------------------------
405
+
406
+ describe('mergeAndDeduplicate()', () => {
407
+ it('deduplicates by (source, target, type)', () => {
408
+ const edges: GraphEdge[] = [
409
+ { source: 'A', target: 'B', type: 'imports', weight: 0.5, provenance: 'test1' },
410
+ { source: 'A', target: 'B', type: 'imports', weight: 1.0, provenance: 'test2' },
411
+ ];
412
+
413
+ const result = mergeAndDeduplicate(edges);
414
+ expect(result.length).toBe(1);
415
+ expect(result[0].weight).toBe(1.0); // keeps highest weight
416
+ });
417
+
418
+ it('preserves different edge types between same components', () => {
419
+ const edges: GraphEdge[] = [
420
+ { source: 'A', target: 'B', type: 'imports', weight: 1.0, provenance: 'test' },
421
+ { source: 'A', target: 'B', type: 'renders', weight: 0.5, provenance: 'test' },
422
+ ];
423
+
424
+ const result = mergeAndDeduplicate(edges);
425
+ expect(result.length).toBe(2);
426
+ });
427
+
428
+ it('preserves edges in different directions', () => {
429
+ const edges: GraphEdge[] = [
430
+ { source: 'A', target: 'B', type: 'imports', weight: 1.0, provenance: 'test' },
431
+ { source: 'B', target: 'A', type: 'imports', weight: 1.0, provenance: 'test' },
432
+ ];
433
+
434
+ const result = mergeAndDeduplicate(edges);
435
+ expect(result.length).toBe(2);
436
+ });
437
+
438
+ it('handles empty input', () => {
439
+ expect(mergeAndDeduplicate([])).toEqual([]);
440
+ });
441
+ });
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // buildComponentGraph (integration, with skipSourceAnalysis)
445
+ // ---------------------------------------------------------------------------
446
+
447
+ describe('buildComponentGraph()', () => {
448
+ it('builds a graph from fragments and blocks', async () => {
449
+ const fragments: Record<string, CompiledFragment> = {
450
+ Button: makeFragment('Button'),
451
+ Dialog: makeFragment('Dialog', {
452
+ variants: [
453
+ { name: 'Default', description: 'Default', code: '<Dialog><Button>OK</Button></Dialog>' },
454
+ ],
455
+ relations: [
456
+ { component: 'Popover', relationship: 'alternative', note: 'Non-modal' },
457
+ ],
458
+ }),
459
+ Input: makeFragment('Input'),
460
+ Popover: makeFragment('Popover'),
461
+ };
462
+
463
+ const blocks: Record<string, CompiledBlock> = {
464
+ LoginForm: makeBlock('LoginForm', ['Dialog', 'Input', 'Button']),
465
+ };
466
+
467
+ const result = await buildComponentGraph(fragments, blocks, '/tmp/fake', {
468
+ skipSourceAnalysis: true,
469
+ });
470
+
471
+ expect(result.graph.nodes.length).toBe(4);
472
+ expect(result.graph.edges.length).toBeGreaterThan(0);
473
+ expect(result.graph.health.nodeCount).toBe(4);
474
+
475
+ // Check renders edge: Dialog → Button
476
+ expect(result.graph.edges.some(
477
+ e => e.source === 'Dialog' && e.target === 'Button' && e.type === 'renders'
478
+ )).toBe(true);
479
+
480
+ // Check composes edges from block
481
+ expect(result.graph.edges.some(e => e.type === 'composes')).toBe(true);
482
+
483
+ // Check relation edge: Dialog → Popover alternative
484
+ expect(result.graph.edges.some(
485
+ e => e.source === 'Dialog' && e.target === 'Popover' && e.type === 'alternative-to'
486
+ )).toBe(true);
487
+ });
488
+
489
+ it('auto-detects metadata and reports warnings', async () => {
490
+ const fragments: Record<string, CompiledFragment> = {
491
+ Dialog: makeFragment('Dialog', {
492
+ ai: {
493
+ compositionPattern: 'compound',
494
+ subComponents: ['Content'],
495
+ // Missing Footer which is auto-detected below
496
+ },
497
+ variants: [
498
+ { name: 'Default', description: 'Default', code: '<Dialog><Dialog.Content>...</Dialog.Content></Dialog>' },
499
+ ],
500
+ }),
501
+ };
502
+
503
+ const result = await buildComponentGraph(fragments, {}, '/tmp/fake', {
504
+ skipSourceAnalysis: true,
505
+ });
506
+
507
+ // Should produce a valid graph even with skipSourceAnalysis
508
+ expect(result.graph.nodes.length).toBe(1);
509
+ });
510
+
511
+ it('handles empty fragments', async () => {
512
+ const result = await buildComponentGraph({}, {}, '/tmp/fake', {
513
+ skipSourceAnalysis: true,
514
+ });
515
+
516
+ expect(result.graph.nodes.length).toBe(0);
517
+ expect(result.graph.edges.length).toBe(0);
518
+ expect(result.graph.health.nodeCount).toBe(0);
519
+ });
520
+
521
+ it('computes health metrics', async () => {
522
+ const fragments: Record<string, CompiledFragment> = {
523
+ Button: makeFragment('Button'),
524
+ Input: makeFragment('Input'),
525
+ Orphan: makeFragment('Orphan'),
526
+ };
527
+
528
+ const blocks: Record<string, CompiledBlock> = {
529
+ Form: makeBlock('Form', ['Button', 'Input']),
530
+ };
531
+
532
+ const result = await buildComponentGraph(fragments, blocks, '/tmp/fake', {
533
+ skipSourceAnalysis: true,
534
+ });
535
+
536
+ expect(result.graph.health.nodeCount).toBe(3);
537
+ // Button and Input have composes edge, Orphan has none
538
+ expect(result.graph.health.orphans).toContain('Orphan');
539
+ // Composition coverage: 2/3 components are in blocks
540
+ expect(result.graph.health.compositionCoverage).toBe(67);
541
+ });
542
+ });