@fragments-sdk/cli 0.6.0 → 0.7.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 (49) hide show
  1. package/dist/bin.js +294 -50
  2. package/dist/bin.js.map +1 -1
  3. package/dist/{chunk-D35RGPAG.js → chunk-7OPWMLOE.js} +435 -19
  4. package/dist/chunk-7OPWMLOE.js.map +1 -0
  5. package/dist/{chunk-SSLQXHNX.js → chunk-CVXKXVOY.js} +1 -1
  6. package/dist/{chunk-SSLQXHNX.js.map → chunk-CVXKXVOY.js.map} +1 -1
  7. package/dist/{chunk-Q7GOHVOK.js → chunk-TJ34N7C7.js} +39 -2
  8. package/dist/{chunk-Q7GOHVOK.js.map → chunk-TJ34N7C7.js.map} +1 -1
  9. package/dist/{chunk-F7ITZPDJ.js → chunk-XHUDJNN3.js} +2 -2
  10. package/dist/{core-SKRPJQZG.js → core-W2HYIQW6.js} +2 -2
  11. package/dist/{generate-7AF7WRVK.js → generate-LMTISDIJ.js} +3 -3
  12. package/dist/index.js +3 -3
  13. package/dist/{init-WKGDPYI4.js → init-7CHRKQ7P.js} +3 -3
  14. package/dist/mcp-bin.js +2 -2
  15. package/dist/{scan-K6JNMCGM.js → scan-WY23TJCP.js} +4 -4
  16. package/dist/{service-F3E4JJM7.js → service-T2L7VLTE.js} +2 -2
  17. package/dist/{static-viewer-4LQZ5AGA.js → static-viewer-GBR7YNF3.js} +2 -2
  18. package/dist/{test-CJDNJTPZ.js → test-OJRXNDO2.js} +2 -2
  19. package/dist/{tokens-JAJABYXP.js → tokens-3BWDESVM.js} +3 -3
  20. package/dist/{viewer-R3Q6WAMJ.js → viewer-SUFOISZM.js} +12 -12
  21. package/package.json +2 -2
  22. package/src/bin.ts +23 -0
  23. package/src/build.ts +43 -0
  24. package/src/commands/graph.ts +274 -0
  25. package/src/core/composition.ts +64 -1
  26. package/src/core/graph-extractor.test.ts +542 -0
  27. package/src/core/graph-extractor.ts +601 -0
  28. package/src/core/importAnalyzer.ts +5 -0
  29. package/src/viewer/components/App.tsx +128 -30
  30. package/src/viewer/components/Icons.tsx +53 -1
  31. package/src/viewer/components/Layout.tsx +7 -3
  32. package/src/viewer/components/LeftSidebar.tsx +65 -87
  33. package/src/viewer/components/PreviewFrameHost.tsx +30 -1
  34. package/src/viewer/components/PreviewToolbar.tsx +57 -10
  35. package/src/viewer/components/ViewportSelector.tsx +56 -45
  36. package/src/viewer/constants/ui.ts +4 -4
  37. package/src/viewer/preview-frame.html +22 -13
  38. package/src/viewer/styles/globals.css +42 -81
  39. package/dist/chunk-D35RGPAG.js.map +0 -1
  40. /package/dist/{chunk-F7ITZPDJ.js.map → chunk-XHUDJNN3.js.map} +0 -0
  41. /package/dist/{core-SKRPJQZG.js.map → core-W2HYIQW6.js.map} +0 -0
  42. /package/dist/{generate-7AF7WRVK.js.map → generate-LMTISDIJ.js.map} +0 -0
  43. /package/dist/{init-WKGDPYI4.js.map → init-7CHRKQ7P.js.map} +0 -0
  44. /package/dist/{scan-K6JNMCGM.js.map → scan-WY23TJCP.js.map} +0 -0
  45. /package/dist/{service-F3E4JJM7.js.map → service-T2L7VLTE.js.map} +0 -0
  46. /package/dist/{static-viewer-4LQZ5AGA.js.map → static-viewer-GBR7YNF3.js.map} +0 -0
  47. /package/dist/{test-CJDNJTPZ.js.map → test-OJRXNDO2.js.map} +0 -0
  48. /package/dist/{tokens-JAJABYXP.js.map → tokens-3BWDESVM.js.map} +0 -0
  49. /package/dist/{viewer-R3Q6WAMJ.js.map → viewer-SUFOISZM.js.map} +0 -0
@@ -0,0 +1,542 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { CompiledSegment, 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 makeSegment(name: string, overrides: Partial<CompiledSegment> = {}): CompiledSegment {
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 segments: Record<string, CompiledSegment> = {
62
+ Dialog: makeSegment('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: makeSegment('Button'),
72
+ };
73
+
74
+ const edges = extractJsxUsageEdges(segments, 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 segments: Record<string, CompiledSegment> = {
83
+ AppShell: makeSegment('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: makeSegment('Header'),
93
+ Sidebar: makeSegment('Sidebar'),
94
+ };
95
+
96
+ const edges = extractJsxUsageEdges(
97
+ segments,
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 segments: Record<string, CompiledSegment> = {
107
+ Button: makeSegment('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(segments, new Set(['Button']));
119
+ expect(edges.length).toBe(0);
120
+ });
121
+
122
+ it('deduplicates across variants', () => {
123
+ const segments: Record<string, CompiledSegment> = {
124
+ Dialog: makeSegment('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: makeSegment('Button'),
131
+ };
132
+
133
+ const edges = extractJsxUsageEdges(segments, 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 segments: Record<string, CompiledSegment> = {
140
+ Dialog: makeSegment('Dialog', {
141
+ variants: [
142
+ { name: 'NoCode', description: 'No code variant' },
143
+ ],
144
+ }),
145
+ };
146
+
147
+ const edges = extractJsxUsageEdges(segments, new Set(['Dialog']));
148
+ expect(edges.length).toBe(0);
149
+ });
150
+
151
+ it('ignores unknown component tags', () => {
152
+ const segments: Record<string, CompiledSegment> = {
153
+ Dialog: makeSegment('Dialog', {
154
+ variants: [
155
+ { name: 'Default', description: 'Default', code: '<Dialog><UnknownThing /></Dialog>' },
156
+ ],
157
+ }),
158
+ };
159
+
160
+ const edges = extractJsxUsageEdges(segments, 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 segments: Record<string, CompiledSegment> = {
219
+ Button: makeSegment('Button', {
220
+ relations: [
221
+ { component: 'Header', relationship: 'parent', note: 'Used in Header actions' },
222
+ ],
223
+ }),
224
+ };
225
+
226
+ const edges = extractRelationEdges(segments);
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 segments: Record<string, CompiledSegment> = {
236
+ Header: makeSegment('Header', {
237
+ relations: [
238
+ { component: 'Button', relationship: 'child', note: 'Renders action buttons' },
239
+ ],
240
+ }),
241
+ };
242
+
243
+ const edges = extractRelationEdges(segments);
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 segments: Record<string, CompiledSegment> = {
252
+ Dialog: makeSegment('Dialog', {
253
+ relations: [
254
+ { component: 'Popover', relationship: 'alternative', note: 'For non-modal overlays' },
255
+ ],
256
+ }),
257
+ };
258
+
259
+ const edges = extractRelationEdges(segments);
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 segments: Record<string, CompiledSegment> = {
266
+ Header: makeSegment('Header', {
267
+ relations: [
268
+ { component: 'Sidebar', relationship: 'sibling', note: 'Both in AppShell' },
269
+ ],
270
+ }),
271
+ };
272
+
273
+ const edges = extractRelationEdges(segments);
274
+ expect(edges[0].type).toBe('sibling-of');
275
+ });
276
+
277
+ it('maps composition relation', () => {
278
+ const segments: Record<string, CompiledSegment> = {
279
+ Input: makeSegment('Input', {
280
+ relations: [
281
+ { component: 'Button', relationship: 'composition', note: 'Often paired in forms' },
282
+ ],
283
+ }),
284
+ };
285
+
286
+ const edges = extractRelationEdges(segments);
287
+ expect(edges[0].type).toBe('composes');
288
+ });
289
+
290
+ it('skips segments without relations', () => {
291
+ const segments: Record<string, CompiledSegment> = {
292
+ Button: makeSegment('Button'),
293
+ };
294
+
295
+ const edges = extractRelationEdges(segments);
296
+ expect(edges.length).toBe(0);
297
+ });
298
+
299
+ it('skips unknown relationship types', () => {
300
+ const segments: Record<string, CompiledSegment> = {
301
+ Button: makeSegment('Button', {
302
+ relations: [
303
+ { component: 'Icon', relationship: 'unknown-type' as any, note: 'Custom relation' },
304
+ ],
305
+ }),
306
+ };
307
+
308
+ const edges = extractRelationEdges(segments);
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 segments: Record<string, CompiledSegment> = {
320
+ Dialog: makeSegment('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(segments, autoDetected);
330
+
331
+ expect(result.get('Dialog')).toEqual(['Content']);
332
+ });
333
+
334
+ it('returns empty for components with no subs', () => {
335
+ const segments: Record<string, CompiledSegment> = {
336
+ Button: makeSegment('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(segments, autoDetected);
343
+ expect(result.size).toBe(0);
344
+ });
345
+
346
+ it('uses ai.subComponents when autoDetected is empty', () => {
347
+ const segments: Record<string, CompiledSegment> = {
348
+ Dialog: makeSegment('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(segments, 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 segments: Record<string, CompiledSegment> = {
370
+ Dialog: makeSegment('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(segments, 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 segments: Record<string, CompiledSegment> = {
393
+ Button: makeSegment('Button'),
394
+ };
395
+
396
+ const autoDetected = new Map<string, any>();
397
+ const result = generateCommonPatterns(segments, 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 segments and blocks', async () => {
449
+ const segments: Record<string, CompiledSegment> = {
450
+ Button: makeSegment('Button'),
451
+ Dialog: makeSegment('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: makeSegment('Input'),
460
+ Popover: makeSegment('Popover'),
461
+ };
462
+
463
+ const blocks: Record<string, CompiledBlock> = {
464
+ LoginForm: makeBlock('LoginForm', ['Dialog', 'Input', 'Button']),
465
+ };
466
+
467
+ const result = await buildComponentGraph(segments, 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 segments: Record<string, CompiledSegment> = {
491
+ Dialog: makeSegment('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(segments, {}, '/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 segments', 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 segments: Record<string, CompiledSegment> = {
523
+ Button: makeSegment('Button'),
524
+ Input: makeSegment('Input'),
525
+ Orphan: makeSegment('Orphan'),
526
+ };
527
+
528
+ const blocks: Record<string, CompiledBlock> = {
529
+ Form: makeBlock('Form', ['Button', 'Input']),
530
+ };
531
+
532
+ const result = await buildComponentGraph(segments, 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
+ });