@fragments-sdk/cli 0.11.1 → 0.12.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 (86) hide show
  1. package/dist/ai-client-I6MDWNYA.js +21 -0
  2. package/dist/bin.js +275 -368
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-PW7QTQA6.js → chunk-4OC7FTJB.js} +2 -2
  5. package/dist/{chunk-HRFUSSZI.js → chunk-AM4MRTMN.js} +2 -2
  6. package/dist/{chunk-5G3VZH43.js → chunk-GVDSFQ4E.js} +281 -351
  7. package/dist/chunk-GVDSFQ4E.js.map +1 -0
  8. package/dist/chunk-JJ2VRTBU.js +626 -0
  9. package/dist/chunk-JJ2VRTBU.js.map +1 -0
  10. package/dist/{chunk-D5PYOXEI.js → chunk-LVWFOLUZ.js} +148 -13
  11. package/dist/{chunk-D5PYOXEI.js.map → chunk-LVWFOLUZ.js.map} +1 -1
  12. package/dist/{chunk-WXSR2II7.js → chunk-OQKMEFOS.js} +58 -6
  13. package/dist/chunk-OQKMEFOS.js.map +1 -0
  14. package/dist/chunk-SXTKFDCR.js +104 -0
  15. package/dist/chunk-SXTKFDCR.js.map +1 -0
  16. package/dist/chunk-T5OMVL7E.js +443 -0
  17. package/dist/chunk-T5OMVL7E.js.map +1 -0
  18. package/dist/{chunk-ZM4ZQZWZ.js → chunk-TPWGL2XS.js} +39 -37
  19. package/dist/chunk-TPWGL2XS.js.map +1 -0
  20. package/dist/{chunk-OQO55NKV.js → chunk-WFS63PCW.js} +85 -11
  21. package/dist/chunk-WFS63PCW.js.map +1 -0
  22. package/dist/core/index.js +9 -1
  23. package/dist/{discovery-NEOY4MPN.js → discovery-ZJQSXF56.js} +3 -3
  24. package/dist/{generate-FBHSXR3D.js → generate-RJFS2JWA.js} +4 -4
  25. package/dist/index.js +7 -6
  26. package/dist/index.js.map +1 -1
  27. package/dist/init-ZSX3NRCZ.js +636 -0
  28. package/dist/init-ZSX3NRCZ.js.map +1 -0
  29. package/dist/mcp-bin.js +2 -2
  30. package/dist/{scan-CJF2DOQW.js → scan-3PMCJ4RB.js} +6 -6
  31. package/dist/scan-generate-SYU4PYZD.js +1115 -0
  32. package/dist/scan-generate-SYU4PYZD.js.map +1 -0
  33. package/dist/{service-TQYWY65E.js → service-VMGNJZ42.js} +3 -3
  34. package/dist/{snapshot-SV2JOFZH.js → snapshot-XOISO2IS.js} +2 -2
  35. package/dist/{static-viewer-NUBFPKWH.js → static-viewer-5GXH2MGE.js} +3 -3
  36. package/dist/static-viewer-5GXH2MGE.js.map +1 -0
  37. package/dist/{test-Z5LVO724.js → test-SI4NSHQX.js} +4 -4
  38. package/dist/{tokens-CE46OTMD.js → tokens-T6SIVUT5.js} +5 -5
  39. package/dist/{viewer-DLLJIMCK.js → viewer-7ZEAFBVN.js} +13 -13
  40. package/package.json +4 -4
  41. package/src/ai-client.ts +156 -0
  42. package/src/bin.ts +44 -2
  43. package/src/build.ts +95 -33
  44. package/src/commands/__tests__/drift-sync.test.ts +252 -0
  45. package/src/commands/__tests__/scan-generate.test.ts +497 -45
  46. package/src/commands/enhance.ts +11 -35
  47. package/src/commands/init.ts +288 -260
  48. package/src/commands/scan-generate.ts +740 -139
  49. package/src/commands/scan.ts +37 -32
  50. package/src/commands/setup.ts +143 -52
  51. package/src/commands/sync.ts +357 -0
  52. package/src/commands/validate.ts +43 -1
  53. package/src/core/component-extractor.test.ts +282 -0
  54. package/src/core/component-extractor.ts +1030 -0
  55. package/src/core/discovery.ts +93 -7
  56. package/src/service/enhance/props-extractor.ts +235 -13
  57. package/src/validators.ts +236 -0
  58. package/dist/chunk-5G3VZH43.js.map +0 -1
  59. package/dist/chunk-OQO55NKV.js.map +0 -1
  60. package/dist/chunk-WXSR2II7.js.map +0 -1
  61. package/dist/chunk-ZM4ZQZWZ.js.map +0 -1
  62. package/dist/init-UFGK5TCN.js +0 -867
  63. package/dist/init-UFGK5TCN.js.map +0 -1
  64. package/dist/scan-generate-SJAN5MVI.js +0 -691
  65. package/dist/scan-generate-SJAN5MVI.js.map +0 -1
  66. package/src/ai.ts +0 -266
  67. package/src/commands/init-framework.ts +0 -414
  68. package/src/mcp/bin.ts +0 -36
  69. package/src/migrate/bin.ts +0 -114
  70. package/src/theme/index.ts +0 -77
  71. package/src/viewer/bin.ts +0 -86
  72. package/src/viewer/cli/health.ts +0 -256
  73. package/src/viewer/cli/index.ts +0 -33
  74. package/src/viewer/cli/scan.ts +0 -124
  75. package/src/viewer/cli/utils.ts +0 -174
  76. /package/dist/{discovery-NEOY4MPN.js.map → ai-client-I6MDWNYA.js.map} +0 -0
  77. /package/dist/{chunk-PW7QTQA6.js.map → chunk-4OC7FTJB.js.map} +0 -0
  78. /package/dist/{chunk-HRFUSSZI.js.map → chunk-AM4MRTMN.js.map} +0 -0
  79. /package/dist/{scan-CJF2DOQW.js.map → discovery-ZJQSXF56.js.map} +0 -0
  80. /package/dist/{generate-FBHSXR3D.js.map → generate-RJFS2JWA.js.map} +0 -0
  81. /package/dist/{service-TQYWY65E.js.map → scan-3PMCJ4RB.js.map} +0 -0
  82. /package/dist/{static-viewer-NUBFPKWH.js.map → service-VMGNJZ42.js.map} +0 -0
  83. /package/dist/{snapshot-SV2JOFZH.js.map → snapshot-XOISO2IS.js.map} +0 -0
  84. /package/dist/{test-Z5LVO724.js.map → test-SI4NSHQX.js.map} +0 -0
  85. /package/dist/{tokens-CE46OTMD.js.map → tokens-T6SIVUT5.js.map} +0 -0
  86. /package/dist/{viewer-DLLJIMCK.js.map → viewer-7ZEAFBVN.js.map} +0 -0
package/src/bin.ts CHANGED
@@ -39,6 +39,7 @@ import { graph } from './commands/graph.js';
39
39
  import { perf } from './commands/perf.js';
40
40
  import { doctor } from './commands/doctor.js';
41
41
  import { setup } from './commands/setup.js';
42
+ import { sync } from './commands/sync.js';
42
43
 
43
44
  // Import existing commands that were already extracted
44
45
  import { runScreenshotCommand } from './screenshot.js';
@@ -62,6 +63,8 @@ program
62
63
  .option('--schema', 'Validate fragment schema only')
63
64
  .option('--coverage', 'Validate coverage only')
64
65
  .option('--snippets', 'Validate snippet/render policy only')
66
+ .option('--drift', 'Detect metadata drift between source and fragments')
67
+ .option('--tsconfig <path>', 'Path to tsconfig.json (for drift detection)')
65
68
  .option('--snippet-mode <mode>', 'Override snippet policy mode (warn|error)')
66
69
  .option('--component-start <name>', 'Start component name for alphabetical snippet batch validation')
67
70
  .option('--component-limit <n>', 'Component count for alphabetical snippet batch validation', (value) => Number.parseInt(value, 10))
@@ -77,6 +80,33 @@ program
77
80
  }
78
81
  });
79
82
 
83
+ // ============================================================================
84
+ // SYNC COMMAND
85
+ // ============================================================================
86
+ program
87
+ .command('sync')
88
+ .description('Auto-update fragment files from component source')
89
+ .option('-c, --config <path>', 'Path to config file')
90
+ .option('--tsconfig <path>', 'Path to tsconfig.json')
91
+ .option('--dry-run', 'Preview changes without writing')
92
+ .option('--component <name>', 'Sync specific component only')
93
+ .action(async (options) => {
94
+ try {
95
+ const result = await sync({
96
+ config: options.config,
97
+ tsconfig: options.tsconfig,
98
+ dryRun: options.dryRun,
99
+ component: options.component,
100
+ });
101
+ if (!result.success) {
102
+ process.exit(1);
103
+ }
104
+ } catch (error) {
105
+ console.error(pc.red('Error:'), error instanceof Error ? error.message : error);
106
+ process.exit(1);
107
+ }
108
+ });
109
+
80
110
  // ============================================================================
81
111
  // BUILD COMMAND
82
112
  // ============================================================================
@@ -794,10 +824,16 @@ program
794
824
  // ============================================================================
795
825
  program
796
826
  .command('init')
797
- .description('Initialize fragments in a project (interactive by default)')
827
+ .description('Initialize fragments in a project (zero-config by default)')
798
828
  .option('--force', 'Overwrite existing config')
799
- .option('-y, --yes', 'Non-interactive mode - auto-detect and use defaults')
829
+ .option('-y, --yes', 'Non-interactive mode (now the default)')
830
+ .option('--configure', 'Interactive mode for theme seeds, snapshots, etc.')
800
831
  .option('--scan <path>', 'Scan a TypeScript component directory and generate fragment files')
832
+ .option('--enrich', 'Use AI to fill knowledge fields during --scan (requires API key)')
833
+ .option('--dry-run', 'Show what --enrich would generate without calling API')
834
+ .option('--provider <provider>', 'AI provider for enrichment: anthropic or openai')
835
+ .option('--api-key <key>', 'API key for AI enrichment')
836
+ .option('--model <model>', 'Override AI model for enrichment')
801
837
  .action(async (options) => {
802
838
  try {
803
839
  const { init } = await import('./commands/init.js');
@@ -806,6 +842,12 @@ program
806
842
  force: options.force,
807
843
  yes: options.scan ? true : options.yes,
808
844
  scan: options.scan,
845
+ configure: options.configure,
846
+ enrich: options.enrich,
847
+ dryRun: options.dryRun,
848
+ provider: options.provider,
849
+ apiKey: options.apiKey,
850
+ model: options.model,
809
851
  });
810
852
 
811
853
  if (!result.success) {
package/src/build.ts CHANGED
@@ -21,10 +21,13 @@ import {
21
21
  generateContextMd,
22
22
  } from "./core/node.js";
23
23
  import {
24
- extractCustomPropsFromComponentFile,
25
24
  resolveComponentSourcePath,
26
- type AutoDetectedPropDefinition,
27
25
  } from "./core/auto-props.js";
26
+ import {
27
+ createComponentExtractor,
28
+ type PropMeta,
29
+ type ComponentMeta,
30
+ } from "./core/component-extractor.js";
28
31
  import { buildComponentGraph } from "./core/graph-extractor.js";
29
32
  import { serializeGraph } from "@fragments-sdk/context/graph";
30
33
  import { resolvePerformanceConfig } from "./core/index.js";
@@ -53,17 +56,21 @@ function normalizeParsedProps(
53
56
 
54
57
  function mergeDocumentedAndAutoProps(
55
58
  documentedProps: Record<string, CompiledProp>,
56
- autoProps: Record<string, AutoDetectedPropDefinition>
59
+ autoProps: Record<string, PropMeta>
57
60
  ): Record<string, CompiledProp> {
58
61
  return Object.fromEntries(
59
- Object.keys(autoProps).map((name) => {
62
+ Object.keys(autoProps)
63
+ // Strip inherited HTML/React props — they're identical across all components
64
+ // and bloat fragments.json. MCP consumers know these exist implicitly.
65
+ .filter((name) => autoProps[name].source === 'local' || name in documentedProps)
66
+ .map((name) => {
60
67
  const documented = documentedProps[name];
61
68
  const auto = autoProps[name];
62
69
 
63
70
  return [
64
71
  name,
65
72
  {
66
- type: auto.type,
73
+ type: auto.typeKind,
67
74
  description: documented?.description ?? auto.description ?? "",
68
75
  default: auto.default !== undefined ? auto.default : documented?.default,
69
76
  required: auto.required,
@@ -75,6 +82,30 @@ function mergeDocumentedAndAutoProps(
75
82
  );
76
83
  }
77
84
 
85
+ /**
86
+ * Auto-compile a propsSummary for the contract from extracted props.
87
+ * Format: "variant: primary|secondary|ghost (required)"
88
+ */
89
+ function compilePropsSummary(props: Record<string, PropMeta>): string[] {
90
+ return Object.entries(props)
91
+ .filter(([_, p]) => p.source === 'local')
92
+ .map(([name, prop]) => {
93
+ let summary = name + ': ';
94
+ if (prop.values && prop.values.length > 0) {
95
+ summary += prop.values.join('|');
96
+ } else {
97
+ summary += prop.typeKind;
98
+ }
99
+ if (prop.default !== undefined) {
100
+ summary += ` (default: ${prop.default})`;
101
+ }
102
+ if (prop.required) {
103
+ summary += ' (required)';
104
+ }
105
+ return summary;
106
+ });
107
+ }
108
+
78
109
  export interface BuildResult {
79
110
  success: boolean;
80
111
  outputPath: string;
@@ -98,6 +129,15 @@ export async function buildFragments(
98
129
  const warnings: Array<{ file: string; warning: string }> = [];
99
130
  const fragments: CompiledFragmentsFile["fragments"] = {};
100
131
 
132
+ // Create a persistent extractor — shared LanguageService across all fragments
133
+ // Try to find a tsconfig.json in the config directory
134
+ const tsconfigCandidates = [
135
+ resolve(configDir, 'tsconfig.json'),
136
+ resolve(configDir, '..', 'tsconfig.json'),
137
+ ];
138
+ const tsconfigPath = tsconfigCandidates.find((p) => existsSync(p));
139
+ const extractor = createComponentExtractor(tsconfigPath);
140
+
101
141
  for (const file of files) {
102
142
  try {
103
143
  // Read file content as text
@@ -139,38 +179,38 @@ export async function buildFragments(
139
179
  parsed.componentImport
140
180
  );
141
181
 
182
+ // Extract full component metadata using persistent LanguageService
183
+ let extractedMeta: ComponentMeta | null = null;
142
184
  if (componentExportName && componentSourcePath) {
143
- const autoPropsResult = extractCustomPropsFromComponentFile(
144
- componentSourcePath,
145
- componentExportName
146
- );
147
-
148
- for (const warning of autoPropsResult.warnings) {
149
- warnings.push({ file: file.relativePath, warning });
185
+ try {
186
+ extractedMeta = extractor.extract(componentSourcePath, componentExportName);
187
+ } catch {
188
+ // Extraction failure is non-fatal — fall back to documented props
150
189
  }
151
190
 
152
- const hasAutoProps = Object.keys(autoPropsResult.props).length > 0;
153
- if (autoPropsResult.resolved && hasAutoProps) {
154
- const removedDocumentedProps = Object.keys(documentedProps).filter(
155
- (propName) => !(propName in autoPropsResult.props)
156
- );
191
+ if (extractedMeta) {
192
+ const autoProps = extractedMeta.props;
193
+ const hasAutoProps = Object.keys(autoProps).length > 0;
157
194
 
158
- if (removedDocumentedProps.length > 0) {
195
+ if (hasAutoProps) {
196
+ const removedDocumentedProps = Object.keys(documentedProps).filter(
197
+ (propName) => !(propName in autoProps)
198
+ );
199
+
200
+ if (removedDocumentedProps.length > 0) {
201
+ warnings.push({
202
+ file: file.relativePath,
203
+ warning: `Removed ${removedDocumentedProps.length} documented props not present in source API: ${removedDocumentedProps.join(", ")}`,
204
+ });
205
+ }
206
+
207
+ mergedProps = mergeDocumentedAndAutoProps(documentedProps, autoProps);
208
+ } else if (Object.keys(documentedProps).length > 0) {
159
209
  warnings.push({
160
210
  file: file.relativePath,
161
- warning: `Removed ${removedDocumentedProps.length} documented props not present in source API: ${removedDocumentedProps.join(", ")}`,
211
+ warning: "Auto-props extraction returned no props; falling back to documented props",
162
212
  });
163
213
  }
164
-
165
- mergedProps = mergeDocumentedAndAutoProps(
166
- documentedProps,
167
- autoPropsResult.props
168
- );
169
- } else if (autoPropsResult.resolved && !hasAutoProps && Object.keys(documentedProps).length > 0) {
170
- warnings.push({
171
- file: file.relativePath,
172
- warning: "Auto-props extraction returned no custom props; falling back to documented props",
173
- });
174
214
  }
175
215
  } else if (!componentExportName) {
176
216
  warnings.push({
@@ -184,6 +224,26 @@ export async function buildFragments(
184
224
  });
185
225
  }
186
226
 
227
+ // Auto-compile contract if not manually authored
228
+ let contract = parsed.contract;
229
+ if (!contract?.propsSummary && extractedMeta) {
230
+ const summary = compilePropsSummary(extractedMeta.props);
231
+ if (summary.length > 0) {
232
+ contract = { ...contract, propsSummary: summary };
233
+ }
234
+ }
235
+
236
+ // Auto-enrich AI metadata from extractor's composition data
237
+ let ai = parsed.ai;
238
+ if (extractedMeta?.composition) {
239
+ const comp = extractedMeta.composition;
240
+ ai = {
241
+ compositionPattern: comp.pattern,
242
+ subComponents: comp.parts.map((p) => p.name),
243
+ ...ai, // Manually authored ai fields take precedence
244
+ };
245
+ }
246
+
187
247
  // Build compiled fragment from parsed metadata
188
248
  const compiled: CompiledFragment = {
189
249
  filePath: file.relativePath,
@@ -221,10 +281,10 @@ export async function buildFragments(
221
281
  ...(v.figma && { figma: v.figma }),
222
282
  ...(v.args && { args: v.args }),
223
283
  })),
224
- // Include AI metadata if present
225
- ...(parsed.ai && { ai: parsed.ai }),
226
- // Include contract metadata if present
227
- ...(parsed.contract && { contract: parsed.contract }),
284
+ // Include AI metadata (auto-enriched or manual)
285
+ ...(ai && { ai }),
286
+ // Include contract metadata (auto-compiled or manual)
287
+ ...(contract && { contract }),
228
288
  };
229
289
 
230
290
  fragments[parsed.meta.name] = compiled;
@@ -236,6 +296,8 @@ export async function buildFragments(
236
296
  }
237
297
  }
238
298
 
299
+ extractor.dispose();
300
+
239
301
  // Discover and compile block files
240
302
  const blocks: Record<string, CompiledBlock> = {};
241
303
  try {
@@ -0,0 +1,252 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { mkdtemp, writeFile, mkdir, rm, readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { diffProps, validateDrift, type DriftItem } from '../../validators.js';
6
+ import type { PropMeta } from '../../core/component-extractor.js';
7
+ import type { FragmentsConfig } from '@fragments-sdk/core';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helper: build a PropMeta for tests
11
+ // ---------------------------------------------------------------------------
12
+
13
+ function makeProp(overrides: Partial<PropMeta> & { name: string }): PropMeta {
14
+ return {
15
+ type: overrides.type ?? 'string',
16
+ typeKind: overrides.typeKind ?? 'string',
17
+ required: overrides.required ?? false,
18
+ source: overrides.source ?? 'local',
19
+ name: overrides.name,
20
+ ...(overrides.values && { values: overrides.values }),
21
+ ...(overrides.default !== undefined && { default: overrides.default }),
22
+ ...(overrides.description && { description: overrides.description }),
23
+ };
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Unit tests: diffProps
28
+ // ---------------------------------------------------------------------------
29
+
30
+ describe('diffProps', () => {
31
+ it('detects added props (in source but not in fragment)', () => {
32
+ const fragmentProps = {
33
+ children: { type: 'node', required: true },
34
+ };
35
+ const sourceProps: Record<string, PropMeta> = {
36
+ children: makeProp({ name: 'children', typeKind: 'node', required: true }),
37
+ variant: makeProp({ name: 'variant', typeKind: 'enum', values: ['primary', 'secondary'] }),
38
+ };
39
+
40
+ const drifts = diffProps(fragmentProps, sourceProps);
41
+ const added = drifts.filter(d => d.kind === 'added');
42
+ expect(added.length).toBe(1);
43
+ expect(added[0].prop).toBe('variant');
44
+ });
45
+
46
+ it('detects removed props (in fragment but not in source)', () => {
47
+ const fragmentProps = {
48
+ children: { type: 'node', required: true },
49
+ loading: { type: 'boolean', description: 'Show loading' },
50
+ };
51
+ const sourceProps: Record<string, PropMeta> = {
52
+ children: makeProp({ name: 'children', typeKind: 'node', required: true }),
53
+ };
54
+
55
+ const drifts = diffProps(fragmentProps, sourceProps);
56
+ const removed = drifts.filter(d => d.kind === 'removed');
57
+ expect(removed.length).toBe(1);
58
+ expect(removed[0].prop).toBe('loading');
59
+ });
60
+
61
+ it('detects type changes', () => {
62
+ const fragmentProps = {
63
+ value: { type: 'string', required: false },
64
+ };
65
+ const sourceProps: Record<string, PropMeta> = {
66
+ value: makeProp({ name: 'value', typeKind: 'number' }),
67
+ };
68
+
69
+ const drifts = diffProps(fragmentProps, sourceProps);
70
+ const typeChanged = drifts.filter(d => d.kind === 'type_changed');
71
+ expect(typeChanged.length).toBe(1);
72
+ expect(typeChanged[0].source).toBe('number');
73
+ expect(typeChanged[0].fragment).toBe('string');
74
+ });
75
+
76
+ it('detects required status changes', () => {
77
+ const fragmentProps = {
78
+ name: { type: 'string', required: false },
79
+ };
80
+ const sourceProps: Record<string, PropMeta> = {
81
+ name: makeProp({ name: 'name', typeKind: 'string', required: true }),
82
+ };
83
+
84
+ const drifts = diffProps(fragmentProps, sourceProps);
85
+ const reqChanged = drifts.filter(d => d.kind === 'required_changed');
86
+ expect(reqChanged.length).toBe(1);
87
+ expect(reqChanged[0].source).toBe('true');
88
+ expect(reqChanged[0].fragment).toBe('false');
89
+ });
90
+
91
+ it('detects enum value changes', () => {
92
+ const fragmentProps = {
93
+ variant: { type: 'enum', values: ['primary', 'secondary'] as const },
94
+ };
95
+ const sourceProps: Record<string, PropMeta> = {
96
+ variant: makeProp({
97
+ name: 'variant',
98
+ typeKind: 'enum',
99
+ values: ['primary', 'secondary', 'ghost'],
100
+ }),
101
+ };
102
+
103
+ const drifts = diffProps(fragmentProps, sourceProps);
104
+ const valChanged = drifts.filter(d => d.kind === 'values_changed');
105
+ expect(valChanged.length).toBe(1);
106
+ expect(valChanged[0].source).toContain('ghost');
107
+ });
108
+
109
+ it('detects default value changes', () => {
110
+ const fragmentProps = {
111
+ size: { type: 'enum', default: 'md' },
112
+ };
113
+ const sourceProps: Record<string, PropMeta> = {
114
+ size: makeProp({ name: 'size', typeKind: 'enum', default: 'lg' }),
115
+ };
116
+
117
+ const drifts = diffProps(fragmentProps, sourceProps);
118
+ const defChanged = drifts.filter(d => d.kind === 'default_changed');
119
+ expect(defChanged.length).toBe(1);
120
+ expect(defChanged[0].source).toBe('lg');
121
+ expect(defChanged[0].fragment).toBe('md');
122
+ });
123
+
124
+ it('filters out inherited source props', () => {
125
+ const fragmentProps = {};
126
+ const sourceProps: Record<string, PropMeta> = {
127
+ className: makeProp({ name: 'className', typeKind: 'string', source: 'inherited' }),
128
+ style: makeProp({ name: 'style', typeKind: 'object', source: 'inherited' }),
129
+ };
130
+
131
+ const drifts = diffProps(fragmentProps, sourceProps);
132
+ expect(drifts.length).toBe(0);
133
+ });
134
+
135
+ it('returns empty array when in sync', () => {
136
+ const fragmentProps = {
137
+ children: { type: 'node', required: true },
138
+ variant: { type: 'enum', values: ['a', 'b'] as const },
139
+ };
140
+ const sourceProps: Record<string, PropMeta> = {
141
+ children: makeProp({ name: 'children', typeKind: 'node', required: true }),
142
+ variant: makeProp({ name: 'variant', typeKind: 'enum', values: ['a', 'b'] }),
143
+ };
144
+
145
+ const drifts = diffProps(fragmentProps, sourceProps);
146
+ expect(drifts.length).toBe(0);
147
+ });
148
+
149
+ it('detects multiple drift types simultaneously', () => {
150
+ const fragmentProps = {
151
+ children: { type: 'node', required: true },
152
+ loading: { type: 'boolean' },
153
+ variant: { type: 'enum', values: ['a'] as const },
154
+ };
155
+ const sourceProps: Record<string, PropMeta> = {
156
+ children: makeProp({ name: 'children', typeKind: 'node', required: true }),
157
+ size: makeProp({ name: 'size', typeKind: 'enum', values: ['sm', 'md'] }),
158
+ variant: makeProp({ name: 'variant', typeKind: 'enum', values: ['a', 'b'] }),
159
+ };
160
+
161
+ const drifts = diffProps(fragmentProps, sourceProps);
162
+ expect(drifts.length).toBe(3);
163
+
164
+ const kinds = drifts.map(d => d.kind);
165
+ expect(kinds).toContain('added'); // size
166
+ expect(kinds).toContain('removed'); // loading
167
+ expect(kinds).toContain('values_changed'); // variant
168
+ });
169
+ });
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Integration: validateDrift with real files
173
+ // ---------------------------------------------------------------------------
174
+
175
+ describe('validateDrift integration', () => {
176
+ let tmpDir: string;
177
+
178
+ beforeAll(async () => {
179
+ tmpDir = await mkdtemp(join(tmpdir(), 'drift-int-'));
180
+
181
+ const compDir = join(tmpDir, 'components', 'Tag');
182
+ await mkdir(compDir, { recursive: true });
183
+
184
+ // Simple component — no JSX to avoid esbuild issues
185
+ await writeFile(
186
+ join(compDir, 'index.tsx'),
187
+ `export interface TagProps {
188
+ /** Tag label text */
189
+ label: string;
190
+ /** Color variant */
191
+ color?: 'blue' | 'green' | 'red';
192
+ }
193
+
194
+ export function Tag({ label, color = 'blue' }: TagProps) {
195
+ return label;
196
+ }
197
+ `,
198
+ 'utf-8'
199
+ );
200
+
201
+ // Fragment with missing 'color' prop and stale 'icon' prop
202
+ await writeFile(
203
+ join(compDir, 'Tag.fragment.tsx'),
204
+ `import { defineFragment } from '@fragments-sdk/core';
205
+ import { Tag } from './index';
206
+
207
+ export default defineFragment({
208
+ component: Tag,
209
+ meta: { name: 'Tag', description: 'A tag component', category: 'Display', status: 'stable' },
210
+ usage: { when: ['Label items'], whenNot: ['Navigation'] },
211
+ props: {
212
+ label: { type: 'string', description: 'Tag label text', required: true },
213
+ icon: { type: 'node', description: 'Leading icon' },
214
+ },
215
+ variants: [],
216
+ });
217
+ `,
218
+ 'utf-8'
219
+ );
220
+ });
221
+
222
+ afterAll(async () => {
223
+ await rm(tmpDir, { recursive: true, force: true });
224
+ });
225
+
226
+ it('detects drift in real fragment files', async () => {
227
+ const config: FragmentsConfig = {
228
+ include: ['components/**/*.fragment.tsx'],
229
+ exclude: [],
230
+ components: ['components/**/index.tsx'],
231
+ outFile: 'fragments.json',
232
+ dataDir: '.fragments',
233
+ } as FragmentsConfig;
234
+
235
+ const result = await validateDrift(config, tmpDir);
236
+
237
+ // Should find drift: 'color' added (in source, not in fragment), 'icon' removed (in fragment, not in source)
238
+ expect(result.reports.length).toBe(1);
239
+ expect(result.reports[0].component).toBe('Tag');
240
+
241
+ const driftKinds = result.reports[0].drifts.map(d => d.kind);
242
+ expect(driftKinds).toContain('added');
243
+ expect(driftKinds).toContain('removed');
244
+
245
+ // 'icon' removal should be an error (breaks existing code)
246
+ expect(result.valid).toBe(false);
247
+ expect(result.errors.some(e => e.message.includes('icon'))).toBe(true);
248
+
249
+ // 'color' addition should be a warning (new undocumented prop)
250
+ expect(result.warnings.some(w => w.message.includes('color'))).toBe(true);
251
+ });
252
+ });