@fragments-sdk/cli 0.10.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 (223) hide show
  1. package/dist/ai-client-I6MDWNYA.js +21 -0
  2. package/dist/bin.js +292 -367
  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-XOISO2IS.js +139 -0
  35. package/dist/snapshot-XOISO2IS.js.map +1 -0
  36. package/dist/{static-viewer-NUBFPKWH.js → static-viewer-5GXH2MGE.js} +3 -3
  37. package/dist/static-viewer-5GXH2MGE.js.map +1 -0
  38. package/dist/{test-Z5LVO724.js → test-SI4NSHQX.js} +4 -4
  39. package/dist/{tokens-CE46OTMD.js → tokens-T6SIVUT5.js} +5 -5
  40. package/dist/{viewer-DNMNC5VS.js → viewer-7ZEAFBVN.js} +80 -58
  41. package/dist/viewer-7ZEAFBVN.js.map +1 -0
  42. package/package.json +6 -14
  43. package/src/ai-client.ts +156 -0
  44. package/src/bin.ts +74 -2
  45. package/src/build.ts +95 -33
  46. package/src/commands/__tests__/drift-sync.test.ts +252 -0
  47. package/src/commands/__tests__/scan-generate.test.ts +497 -45
  48. package/src/commands/enhance.ts +11 -35
  49. package/src/commands/init.ts +296 -193
  50. package/src/commands/scan-generate.ts +740 -139
  51. package/src/commands/scan.ts +37 -32
  52. package/src/commands/setup.ts +143 -52
  53. package/src/commands/snapshot.ts +197 -0
  54. package/src/commands/sync.ts +357 -0
  55. package/src/commands/validate.ts +43 -1
  56. package/src/core/component-extractor.test.ts +282 -0
  57. package/src/core/component-extractor.ts +1030 -0
  58. package/src/core/discovery.ts +93 -7
  59. package/src/service/enhance/props-extractor.ts +235 -13
  60. package/src/validators.ts +236 -0
  61. package/src/viewer/__tests__/viewer-integration.test.ts +85 -74
  62. package/src/viewer/server.ts +37 -22
  63. package/src/viewer/vite-plugin.ts +25 -9
  64. package/dist/chunk-5G3VZH43.js.map +0 -1
  65. package/dist/chunk-OQO55NKV.js.map +0 -1
  66. package/dist/chunk-WXSR2II7.js.map +0 -1
  67. package/dist/chunk-ZM4ZQZWZ.js.map +0 -1
  68. package/dist/init-NDQXUWDU.js +0 -796
  69. package/dist/init-NDQXUWDU.js.map +0 -1
  70. package/dist/scan-generate-SJAN5MVI.js +0 -691
  71. package/dist/scan-generate-SJAN5MVI.js.map +0 -1
  72. package/dist/viewer-DNMNC5VS.js.map +0 -1
  73. package/src/ai.ts +0 -266
  74. package/src/commands/init-framework.ts +0 -414
  75. package/src/mcp/bin.ts +0 -36
  76. package/src/migrate/bin.ts +0 -114
  77. package/src/theme/index.ts +0 -77
  78. package/src/viewer/__tests__/a11y-fixes.test.ts +0 -358
  79. package/src/viewer/__tests__/jsx-parser.test.ts +0 -502
  80. package/src/viewer/__tests__/render-utils.test.ts +0 -232
  81. package/src/viewer/__tests__/style-utils.test.ts +0 -404
  82. package/src/viewer/assets/fragments-logo.ts +0 -4
  83. package/src/viewer/assets/fragments_logo.png +0 -0
  84. package/src/viewer/bin.ts +0 -86
  85. package/src/viewer/cli/health.ts +0 -256
  86. package/src/viewer/cli/index.ts +0 -33
  87. package/src/viewer/cli/scan.ts +0 -124
  88. package/src/viewer/cli/utils.ts +0 -174
  89. package/src/viewer/components/AccessibilityPanel.tsx +0 -1457
  90. package/src/viewer/components/ActionCapture.tsx +0 -172
  91. package/src/viewer/components/ActionsPanel.tsx +0 -332
  92. package/src/viewer/components/AllVariantsPreview.tsx +0 -78
  93. package/src/viewer/components/App.tsx +0 -582
  94. package/src/viewer/components/BottomPanel.tsx +0 -288
  95. package/src/viewer/components/CodePanel.naming.test.tsx +0 -59
  96. package/src/viewer/components/CodePanel.tsx +0 -118
  97. package/src/viewer/components/CommandPalette.tsx +0 -392
  98. package/src/viewer/components/ComponentDocView.tsx +0 -164
  99. package/src/viewer/components/ComponentGraph.tsx +0 -380
  100. package/src/viewer/components/ComponentHeader.tsx +0 -88
  101. package/src/viewer/components/ContractPanel.tsx +0 -241
  102. package/src/viewer/components/EmptyVariantMessage.tsx +0 -54
  103. package/src/viewer/components/ErrorBoundary.tsx +0 -97
  104. package/src/viewer/components/FigmaEmbed.tsx +0 -238
  105. package/src/viewer/components/FragmentEditor.tsx +0 -525
  106. package/src/viewer/components/FragmentRenderer.tsx +0 -61
  107. package/src/viewer/components/HeaderSearch.tsx +0 -24
  108. package/src/viewer/components/HealthDashboard.tsx +0 -441
  109. package/src/viewer/components/HmrStatusIndicator.tsx +0 -61
  110. package/src/viewer/components/Icons.tsx +0 -479
  111. package/src/viewer/components/InteractionsPanel.tsx +0 -757
  112. package/src/viewer/components/IsolatedPreviewFrame.tsx +0 -346
  113. package/src/viewer/components/IsolatedRender.tsx +0 -113
  114. package/src/viewer/components/KeyboardShortcutsHelp.tsx +0 -53
  115. package/src/viewer/components/LandingPage.tsx +0 -421
  116. package/src/viewer/components/Layout.tsx +0 -27
  117. package/src/viewer/components/LeftSidebar.tsx +0 -472
  118. package/src/viewer/components/LoadErrorMessage.tsx +0 -102
  119. package/src/viewer/components/MultiViewportPreview.tsx +0 -522
  120. package/src/viewer/components/NoVariantsMessage.tsx +0 -59
  121. package/src/viewer/components/PanelShell.tsx +0 -161
  122. package/src/viewer/components/PerformancePanel.tsx +0 -304
  123. package/src/viewer/components/PreviewArea.tsx +0 -472
  124. package/src/viewer/components/PreviewAside.tsx +0 -168
  125. package/src/viewer/components/PreviewFrameHost.tsx +0 -303
  126. package/src/viewer/components/PreviewPane.tsx +0 -149
  127. package/src/viewer/components/PreviewToolbar.tsx +0 -80
  128. package/src/viewer/components/PropsEditor.tsx +0 -506
  129. package/src/viewer/components/PropsTable.tsx +0 -111
  130. package/src/viewer/components/RelationsSection.tsx +0 -88
  131. package/src/viewer/components/ResizablePanel.tsx +0 -271
  132. package/src/viewer/components/RightSidebar.tsx +0 -102
  133. package/src/viewer/components/RuntimeToolsRegistrar.tsx +0 -17
  134. package/src/viewer/components/ScreenshotButton.tsx +0 -90
  135. package/src/viewer/components/Sidebar.tsx +0 -169
  136. package/src/viewer/components/SkeletonLoader.tsx +0 -161
  137. package/src/viewer/components/ThemeProvider.tsx +0 -42
  138. package/src/viewer/components/Toast.tsx +0 -3
  139. package/src/viewer/components/TokenStylePanel.tsx +0 -699
  140. package/src/viewer/components/TopToolbar.tsx +0 -159
  141. package/src/viewer/components/UsageSection.tsx +0 -95
  142. package/src/viewer/components/VariantMatrix.tsx +0 -388
  143. package/src/viewer/components/VariantRenderer.tsx +0 -131
  144. package/src/viewer/components/VariantTabs.tsx +0 -40
  145. package/src/viewer/components/ViewerHeader.tsx +0 -69
  146. package/src/viewer/components/ViewerStateSync.tsx +0 -52
  147. package/src/viewer/components/ViewportSelector.tsx +0 -172
  148. package/src/viewer/components/WebMCPDevTools.tsx +0 -503
  149. package/src/viewer/components/WebMCPIntegration.tsx +0 -47
  150. package/src/viewer/components/WebMCPStatusIndicator.tsx +0 -60
  151. package/src/viewer/components/_future/CreatePage.tsx +0 -836
  152. package/src/viewer/components/viewer-utils.ts +0 -16
  153. package/src/viewer/composition-renderer.ts +0 -381
  154. package/src/viewer/constants/index.ts +0 -1
  155. package/src/viewer/constants/ui.ts +0 -166
  156. package/src/viewer/entry.tsx +0 -335
  157. package/src/viewer/hooks/index.ts +0 -2
  158. package/src/viewer/hooks/useA11yCache.ts +0 -383
  159. package/src/viewer/hooks/useA11yService.ts +0 -364
  160. package/src/viewer/hooks/useActions.ts +0 -138
  161. package/src/viewer/hooks/useAppState.ts +0 -147
  162. package/src/viewer/hooks/useCompiledFragments.ts +0 -42
  163. package/src/viewer/hooks/useFigmaIntegration.ts +0 -132
  164. package/src/viewer/hooks/useHmrStatus.ts +0 -109
  165. package/src/viewer/hooks/useKeyboardShortcuts.ts +0 -270
  166. package/src/viewer/hooks/usePreviewBridge.ts +0 -347
  167. package/src/viewer/hooks/useScrollSpy.ts +0 -78
  168. package/src/viewer/hooks/useUrlState.ts +0 -318
  169. package/src/viewer/hooks/useViewSettings.ts +0 -111
  170. package/src/viewer/index.html +0 -28
  171. package/src/viewer/intelligence/healthReport.ts +0 -505
  172. package/src/viewer/intelligence/styleDrift.ts +0 -340
  173. package/src/viewer/intelligence/usageScanner.ts +0 -309
  174. package/src/viewer/jsx-parser.ts +0 -486
  175. package/src/viewer/preview-frame-entry.tsx +0 -25
  176. package/src/viewer/preview-frame.html +0 -125
  177. package/src/viewer/public/favicon.ico +0 -0
  178. package/src/viewer/render-template.html +0 -68
  179. package/src/viewer/styles/globals.css +0 -278
  180. package/src/viewer/types/a11y.ts +0 -197
  181. package/src/viewer/utils/a11y-fixes.ts +0 -509
  182. package/src/viewer/utils/actionExport.ts +0 -372
  183. package/src/viewer/utils/colorSchemes.ts +0 -201
  184. package/src/viewer/utils/detectRelationships.ts +0 -256
  185. package/src/viewer/vendor/shared/src/ComponentDocContent.module.scss +0 -10
  186. package/src/viewer/vendor/shared/src/ComponentDocContent.module.scss.d.ts +0 -2
  187. package/src/viewer/vendor/shared/src/ComponentDocContent.tsx +0 -274
  188. package/src/viewer/vendor/shared/src/DocsHeaderBar.tsx +0 -129
  189. package/src/viewer/vendor/shared/src/DocsPageAsideHost.tsx +0 -89
  190. package/src/viewer/vendor/shared/src/DocsPageShell.tsx +0 -124
  191. package/src/viewer/vendor/shared/src/DocsSearchCommand.tsx +0 -99
  192. package/src/viewer/vendor/shared/src/DocsSidebarNav.tsx +0 -66
  193. package/src/viewer/vendor/shared/src/PropsTable.module.scss +0 -68
  194. package/src/viewer/vendor/shared/src/PropsTable.module.scss.d.ts +0 -2
  195. package/src/viewer/vendor/shared/src/PropsTable.tsx +0 -76
  196. package/src/viewer/vendor/shared/src/VariantPreviewCard.module.scss +0 -114
  197. package/src/viewer/vendor/shared/src/VariantPreviewCard.module.scss.d.ts +0 -2
  198. package/src/viewer/vendor/shared/src/VariantPreviewCard.tsx +0 -137
  199. package/src/viewer/vendor/shared/src/docs-data/index.ts +0 -32
  200. package/src/viewer/vendor/shared/src/docs-data/mcp-configs.ts +0 -72
  201. package/src/viewer/vendor/shared/src/docs-data/palettes.ts +0 -75
  202. package/src/viewer/vendor/shared/src/docs-data/setup-examples.ts +0 -55
  203. package/src/viewer/vendor/shared/src/docs-layout.scss +0 -28
  204. package/src/viewer/vendor/shared/src/docs-layout.scss.d.ts +0 -2
  205. package/src/viewer/vendor/shared/src/index.ts +0 -34
  206. package/src/viewer/vendor/shared/src/types.ts +0 -53
  207. package/src/viewer/webmcp/__tests__/analytics.test.ts +0 -108
  208. package/src/viewer/webmcp/analytics.ts +0 -165
  209. package/src/viewer/webmcp/index.ts +0 -3
  210. package/src/viewer/webmcp/posthog-bridge.ts +0 -39
  211. package/src/viewer/webmcp/runtime-tools.ts +0 -152
  212. package/src/viewer/webmcp/scan-utils.ts +0 -135
  213. package/src/viewer/webmcp/use-tool-analytics.ts +0 -69
  214. package/src/viewer/webmcp/viewer-state.ts +0 -45
  215. /package/dist/{discovery-NEOY4MPN.js.map → ai-client-I6MDWNYA.js.map} +0 -0
  216. /package/dist/{chunk-PW7QTQA6.js.map → chunk-4OC7FTJB.js.map} +0 -0
  217. /package/dist/{chunk-HRFUSSZI.js.map → chunk-AM4MRTMN.js.map} +0 -0
  218. /package/dist/{scan-CJF2DOQW.js.map → discovery-ZJQSXF56.js.map} +0 -0
  219. /package/dist/{generate-FBHSXR3D.js.map → generate-RJFS2JWA.js.map} +0 -0
  220. /package/dist/{service-TQYWY65E.js.map → scan-3PMCJ4RB.js.map} +0 -0
  221. /package/dist/{static-viewer-NUBFPKWH.js.map → service-VMGNJZ42.js.map} +0 -0
  222. /package/dist/{test-Z5LVO724.js.map → test-SI4NSHQX.js.map} +0 -0
  223. /package/dist/{tokens-CE46OTMD.js.map → tokens-T6SIVUT5.js.map} +0 -0
@@ -5,6 +5,60 @@ import fg from 'fast-glob';
5
5
  import { BRAND } from '@fragments-sdk/core';
6
6
  import type { FragmentsConfig } from '@fragments-sdk/core';
7
7
 
8
+ /**
9
+ * Convert a lowercase file name to PascalCase component name.
10
+ * e.g., "button" → "Button", "date-picker" → "DatePicker"
11
+ */
12
+ function toPascalCase(name: string): string {
13
+ return name
14
+ .split(/[-_]/)
15
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
16
+ .join('');
17
+ }
18
+
19
+ /**
20
+ * Extract PascalCase named exports from a source file using regex.
21
+ * Finds patterns like:
22
+ * - `export function Button`
23
+ * - `export const Button`
24
+ * - `export { Button, CardHeader }` (from export blocks)
25
+ * - `function Button` + later `export { Button }` (shadcn pattern)
26
+ */
27
+ async function extractPascalCaseExports(filePath: string): Promise<string[]> {
28
+ try {
29
+ const content = await readFile(filePath, 'utf-8');
30
+ const exports = new Set<string>();
31
+
32
+ // Pattern 1: export function ComponentName
33
+ const exportFuncRegex = /export\s+function\s+([A-Z][a-zA-Z0-9]*)/g;
34
+ let match;
35
+ while ((match = exportFuncRegex.exec(content)) !== null) {
36
+ exports.add(match[1]);
37
+ }
38
+
39
+ // Pattern 2: export const ComponentName
40
+ const exportConstRegex = /export\s+const\s+([A-Z][a-zA-Z0-9]*)/g;
41
+ while ((match = exportConstRegex.exec(content)) !== null) {
42
+ exports.add(match[1]);
43
+ }
44
+
45
+ // Pattern 3: export { Name1, Name2, ... }
46
+ const exportBlockRegex = /export\s*\{([^}]+)\}/g;
47
+ while ((match = exportBlockRegex.exec(content)) !== null) {
48
+ const names = match[1].split(',').map((n) => n.trim().split(/\s+as\s+/)[0].trim());
49
+ for (const name of names) {
50
+ if (/^[A-Z]/.test(name)) {
51
+ exports.add(name);
52
+ }
53
+ }
54
+ }
55
+
56
+ return Array.from(exports);
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
8
62
  export interface DiscoveredFile {
9
63
  /** Absolute path to the file */
10
64
  absolutePath: string;
@@ -180,11 +234,18 @@ export async function discoverComponentsFromSource(
180
234
  absolute: false,
181
235
  });
182
236
 
183
- // Filter to only component-like files (start with uppercase)
184
- const componentFiles = files.filter((file) => {
237
+ // Separate files into PascalCase (existing behavior) and lowercase (new: parse exports)
238
+ const pascalCaseFiles: string[] = [];
239
+ const lowercaseFiles: string[] = [];
240
+
241
+ for (const file of files) {
185
242
  const name = extractComponentName(file);
186
- return /^[A-Z]/.test(name);
187
- });
243
+ if (/^[A-Z]/.test(name)) {
244
+ pascalCaseFiles.push(file);
245
+ } else if (/^[a-z]/.test(name)) {
246
+ lowercaseFiles.push(file);
247
+ }
248
+ }
188
249
 
189
250
  // Find associated story files
190
251
  const storyPatterns = [
@@ -209,11 +270,10 @@ export async function discoverComponentsFromSource(
209
270
  // Build discovered components
210
271
  const components: DiscoveredComponent[] = [];
211
272
 
212
- for (const file of componentFiles) {
273
+ // Add PascalCase-named files directly (existing behavior)
274
+ for (const file of pascalCaseFiles) {
213
275
  const name = extractComponentName(file);
214
276
  const absolutePath = resolve(configDir, file);
215
-
216
- // Look for story file
217
277
  const storyFile = storyMap.get(name);
218
278
 
219
279
  components.push({
@@ -224,6 +284,32 @@ export async function discoverComponentsFromSource(
224
284
  });
225
285
  }
226
286
 
287
+ // For lowercase files (e.g., shadcn's button.tsx, card.tsx), extract the
288
+ // primary PascalCase export as the component name. This discovers components
289
+ // from libraries that use lowercase file names.
290
+ for (const file of lowercaseFiles) {
291
+ const absolutePath = resolve(configDir, file);
292
+ const fileName = extractComponentName(file);
293
+ const pascalName = toPascalCase(fileName);
294
+
295
+ // Parse exports from the file to find PascalCase component names
296
+ const exports = await extractPascalCaseExports(absolutePath);
297
+
298
+ // Use the primary component: prefer the PascalCase version of the file name,
299
+ // otherwise take the first PascalCase export
300
+ const primaryExport = exports.find((e) => e === pascalName) || exports[0];
301
+ if (primaryExport) {
302
+ const storyFile = storyMap.get(primaryExport) || storyMap.get(fileName);
303
+
304
+ components.push({
305
+ name: primaryExport,
306
+ sourcePath: absolutePath,
307
+ relativePath: file,
308
+ storyPath: storyFile ? resolve(configDir, storyFile) : undefined,
309
+ });
310
+ }
311
+ }
312
+
227
313
  // Sort by name
228
314
  components.sort((a, b) => a.name.localeCompare(b.name));
229
315
 
@@ -68,6 +68,8 @@ export interface PropsExtractionOptions {
68
68
  propsTypeName?: string;
69
69
  /** Include inherited props from extended interfaces */
70
70
  includeInherited?: boolean;
71
+ /** Explicit component name (for lowercase file names like shadcn's button.tsx → "Button") */
72
+ componentName?: string;
71
73
  }
72
74
 
73
75
  /**
@@ -103,7 +105,7 @@ export function extractPropsFromSource(
103
105
  ): PropsExtractionResult {
104
106
  const { propsTypeName } = options;
105
107
 
106
- const componentName = inferComponentName(filePath);
108
+ const componentName = options.componentName || inferComponentName(filePath);
107
109
  const result: PropsExtractionResult = {
108
110
  filePath,
109
111
  componentName,
@@ -156,23 +158,34 @@ export function extractPropsFromSource(
156
158
  );
157
159
  }
158
160
 
159
- if (!propsDecl) {
160
- result.warnings.push(
161
- `No props type found for ${componentName}. Looked for: ${targetName}`
162
- );
161
+ if (propsDecl) {
162
+ result.propsTypeName = propsDecl.name;
163
+
164
+ // Extract props from the declaration
165
+ if (ts.isInterfaceDeclaration(propsDecl.node)) {
166
+ extractPropsFromInterface(propsDecl.node, sourceFile, result);
167
+ } else if (ts.isTypeAliasDeclaration(propsDecl.node)) {
168
+ extractPropsFromTypeAlias(propsDecl.node, sourceFile, result);
169
+ }
170
+
171
+ result.success = result.props.length > 0;
163
172
  return result;
164
173
  }
165
174
 
166
- result.propsTypeName = propsDecl.name;
167
-
168
- // Extract props from the declaration
169
- if (ts.isInterfaceDeclaration(propsDecl.node)) {
170
- extractPropsFromInterface(propsDecl.node, sourceFile, result);
171
- } else if (ts.isTypeAliasDeclaration(propsDecl.node)) {
172
- extractPropsFromTypeAlias(propsDecl.node, sourceFile, result);
175
+ // Fallback: extract props from inline function parameter types.
176
+ // This handles libraries like shadcn/ui that use inline types:
177
+ // function Button({ variant = "default", ...props }: React.ComponentProps<"button"> & { asChild?: boolean })
178
+ const inlineProps = extractPropsFromInlineParams(componentName, sourceFile);
179
+ if (inlineProps.length > 0) {
180
+ result.props = inlineProps;
181
+ result.propsTypeName = `${componentName}(inline)`;
182
+ result.success = true;
183
+ return result;
173
184
  }
174
185
 
175
- result.success = result.props.length > 0;
186
+ result.warnings.push(
187
+ `No props type found for ${componentName}. Looked for: ${targetName}`
188
+ );
176
189
  return result;
177
190
  }
178
191
 
@@ -544,6 +557,215 @@ function parseTypeNode(
544
557
  };
545
558
  }
546
559
 
560
+ /**
561
+ * Extract props from inline function parameter types.
562
+ *
563
+ * Handles the pattern common in shadcn/ui and similar libraries:
564
+ * function Button({ variant = "default", size, ...props }: SomeType & { asChild?: boolean })
565
+ *
566
+ * Extracts:
567
+ * 1. Destructured parameter names with default values
568
+ * 2. Properties from inline type literal members in intersection types
569
+ * 3. Variant enum values from cva() definitions in the same file
570
+ */
571
+ function extractPropsFromInlineParams(
572
+ componentName: string,
573
+ sourceFile: ts.SourceFile
574
+ ): ExtractedProp[] {
575
+ const props: ExtractedProp[] = [];
576
+ const seen = new Set<string>();
577
+
578
+ // Find the main component function declaration
579
+ let targetFunc: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression | undefined;
580
+
581
+ ts.forEachChild(sourceFile, (node) => {
582
+ // function ComponentName(...)
583
+ if (ts.isFunctionDeclaration(node) && node.name?.text === componentName) {
584
+ targetFunc = node;
585
+ }
586
+ // const ComponentName = (...) => ...
587
+ if (ts.isVariableStatement(node)) {
588
+ for (const decl of node.declarationList.declarations) {
589
+ if (
590
+ ts.isIdentifier(decl.name) &&
591
+ decl.name.text === componentName &&
592
+ decl.initializer &&
593
+ (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))
594
+ ) {
595
+ targetFunc = decl.initializer;
596
+ }
597
+ }
598
+ }
599
+ });
600
+
601
+ if (!targetFunc || targetFunc.parameters.length === 0) return props;
602
+
603
+ const firstParam = targetFunc.parameters[0];
604
+
605
+ // Extract destructured parameter names and defaults
606
+ if (ts.isObjectBindingPattern(firstParam.name)) {
607
+ for (const element of firstParam.name.elements) {
608
+ if (ts.isBindingElement(element) && ts.isIdentifier(element.name)) {
609
+ const name = element.name.text;
610
+ if (name === 'props' || name === 'rest' || name.startsWith('_')) continue;
611
+
612
+ // Skip common passthrough props
613
+ if (name === 'className' || name === 'children' || name === 'ref') continue;
614
+
615
+ if (seen.has(name)) continue;
616
+ seen.add(name);
617
+
618
+ const hasDefault = element.initializer !== undefined;
619
+ let defaultValue: unknown = undefined;
620
+ let enumValues: string[] | undefined;
621
+ let propType: PropType = { type: 'string' };
622
+
623
+ if (element.initializer) {
624
+ if (ts.isStringLiteral(element.initializer)) {
625
+ defaultValue = element.initializer.text;
626
+ } else if (element.initializer.kind === ts.SyntaxKind.TrueKeyword) {
627
+ defaultValue = true;
628
+ propType = { type: 'boolean' };
629
+ } else if (element.initializer.kind === ts.SyntaxKind.FalseKeyword) {
630
+ defaultValue = false;
631
+ propType = { type: 'boolean' };
632
+ } else if (ts.isNumericLiteral(element.initializer)) {
633
+ defaultValue = Number(element.initializer.text);
634
+ propType = { type: 'number' };
635
+ }
636
+ }
637
+
638
+ const prop: ExtractedProp = {
639
+ name,
640
+ type: propType.type,
641
+ propType,
642
+ description: '',
643
+ required: !hasDefault && !element.dotDotDotToken,
644
+ };
645
+
646
+ if (defaultValue !== undefined) prop.defaultValue = defaultValue;
647
+ if (enumValues) prop.enumValues = enumValues;
648
+
649
+ props.push(prop);
650
+ }
651
+ }
652
+ }
653
+
654
+ // Extract from inline type literal in intersection types
655
+ // e.g., React.ComponentProps<"button"> & { asChild?: boolean }
656
+ if (firstParam.type) {
657
+ extractFromInlineType(firstParam.type, sourceFile, props, seen);
658
+ }
659
+
660
+ // Look for cva() definitions to extract variant enum values
661
+ extractCvaVariants(sourceFile, props);
662
+
663
+ return props;
664
+ }
665
+
666
+ /**
667
+ * Extract properties from inline type annotations (intersection types, type literals)
668
+ */
669
+ function extractFromInlineType(
670
+ typeNode: ts.TypeNode,
671
+ sourceFile: ts.SourceFile,
672
+ props: ExtractedProp[],
673
+ seen: Set<string>
674
+ ): void {
675
+ if (ts.isIntersectionTypeNode(typeNode)) {
676
+ for (const type of typeNode.types) {
677
+ extractFromInlineType(type, sourceFile, props, seen);
678
+ }
679
+ } else if (ts.isTypeLiteralNode(typeNode)) {
680
+ for (const member of typeNode.members) {
681
+ if (ts.isPropertySignature(member) && ts.isIdentifier(member.name)) {
682
+ const name = member.name.text;
683
+ if (seen.has(name) || name === 'className' || name === 'children') continue;
684
+ seen.add(name);
685
+
686
+ const prop = extractPropFromSignature(member, sourceFile);
687
+ if (prop) props.push(prop);
688
+ }
689
+ }
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Extract variant enum values from cva() definitions in the file.
695
+ * shadcn uses class-variance-authority (cva) to define variants.
696
+ * This enriches discovered props with the actual enum values.
697
+ */
698
+ function extractCvaVariants(
699
+ sourceFile: ts.SourceFile,
700
+ props: ExtractedProp[]
701
+ ): void {
702
+ const cvaVariants = new Map<string, string[]>();
703
+
704
+ function visitCva(node: ts.Node): void {
705
+ // Look for cva(..., { variants: { variant: { ... }, size: { ... } } })
706
+ if (
707
+ ts.isCallExpression(node) &&
708
+ ts.isIdentifier(node.expression) &&
709
+ node.expression.text === 'cva' &&
710
+ node.arguments.length >= 2
711
+ ) {
712
+ const configArg = node.arguments[1];
713
+ if (ts.isObjectLiteralExpression(configArg)) {
714
+ for (const prop of configArg.properties) {
715
+ if (
716
+ ts.isPropertyAssignment(prop) &&
717
+ ts.isIdentifier(prop.name) &&
718
+ prop.name.text === 'variants' &&
719
+ ts.isObjectLiteralExpression(prop.initializer)
720
+ ) {
721
+ for (const variantProp of prop.initializer.properties) {
722
+ if (
723
+ ts.isPropertyAssignment(variantProp) &&
724
+ ts.isIdentifier(variantProp.name) &&
725
+ ts.isObjectLiteralExpression(variantProp.initializer)
726
+ ) {
727
+ const variantName = variantProp.name.text;
728
+ const values: string[] = [];
729
+ for (const valueProp of variantProp.initializer.properties) {
730
+ if (ts.isPropertyAssignment(valueProp)) {
731
+ const key = valueProp.name;
732
+ if (ts.isIdentifier(key)) {
733
+ values.push(key.text);
734
+ } else if (ts.isStringLiteral(key)) {
735
+ values.push(key.text);
736
+ } else if (ts.isComputedPropertyName(key)) {
737
+ const expr = key.expression;
738
+ if (ts.isStringLiteral(expr)) {
739
+ values.push(expr.text);
740
+ }
741
+ }
742
+ }
743
+ }
744
+ if (values.length > 0) {
745
+ cvaVariants.set(variantName, values);
746
+ }
747
+ }
748
+ }
749
+ }
750
+ }
751
+ }
752
+ }
753
+ ts.forEachChild(node, visitCva);
754
+ }
755
+
756
+ ts.forEachChild(sourceFile, visitCva);
757
+
758
+ // Enrich existing props with cva variant values
759
+ for (const prop of props) {
760
+ const values = cvaVariants.get(prop.name);
761
+ if (values && values.length > 0) {
762
+ prop.enumValues = values;
763
+ prop.propType = { type: 'enum', values };
764
+ prop.type = values.map((v) => `"${v}"`).join(' | ');
765
+ }
766
+ }
767
+ }
768
+
547
769
  /**
548
770
  * Infer component name from file path
549
771
  */
package/src/validators.ts CHANGED
@@ -6,6 +6,10 @@ import {
6
6
  loadFragmentFile,
7
7
  } from './core/node.js';
8
8
  import { validateSnippetPolicy, type SnippetValidationOptions } from './service/snippet-validation.js';
9
+ import { createComponentExtractor, type ComponentMeta, type PropMeta } from './core/component-extractor.js';
10
+ import { resolveComponentSourcePath } from './core/auto-props.js';
11
+ import { parseFragmentFile } from './core/parser.js';
12
+ import { readFile } from 'node:fs/promises';
9
13
 
10
14
  export interface ValidationResult {
11
15
  valid: boolean;
@@ -198,3 +202,235 @@ export async function validateSnippets(
198
202
  warnings: snippetResult.warnings,
199
203
  };
200
204
  }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Drift Detection
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /** A single prop drift finding */
211
+ export interface DriftItem {
212
+ prop: string;
213
+ kind: 'added' | 'removed' | 'type_changed' | 'required_changed' | 'values_changed' | 'default_changed';
214
+ source: string;
215
+ fragment: string;
216
+ }
217
+
218
+ /** Drift result for a single component */
219
+ export interface DriftReport {
220
+ component: string;
221
+ file: string;
222
+ drifts: DriftItem[];
223
+ compositionDrift: string | null;
224
+ }
225
+
226
+ /** Full drift validation result */
227
+ export interface DriftValidationResult extends ValidationResult {
228
+ reports: DriftReport[];
229
+ }
230
+
231
+ /**
232
+ * Validate drift — detect metadata discrepancies between component source and fragment files.
233
+ *
234
+ * Compares auto-extracted props/composition from the component source against what's
235
+ * documented in the fragment file. Reports:
236
+ * - Props in source but missing from fragment (added)
237
+ * - Props in fragment but removed from source (removed)
238
+ * - Type/required/values/default changes
239
+ * - Composition pattern changes
240
+ */
241
+ export async function validateDrift(
242
+ config: FragmentsConfig,
243
+ configDir: string,
244
+ options: { tsconfig?: string } = {}
245
+ ): Promise<DriftValidationResult> {
246
+ const fragmentFiles = await discoverFragmentFiles(config, configDir);
247
+ const errors: ValidationError[] = [];
248
+ const warnings: ValidationWarning[] = [];
249
+ const reports: DriftReport[] = [];
250
+
251
+ if (fragmentFiles.length === 0) {
252
+ return { valid: true, errors, warnings, reports };
253
+ }
254
+
255
+ const extractor = createComponentExtractor(options.tsconfig);
256
+
257
+ try {
258
+ for (const file of fragmentFiles) {
259
+ try {
260
+ // Load the fragment to get documented props
261
+ const fragment = await loadFragmentFile(file.absolutePath);
262
+ if (!fragment?.meta?.name) continue;
263
+
264
+ // Parse the fragment file to find the component import path
265
+ const fileContent = await readFile(file.absolutePath, 'utf-8');
266
+ const parsed = parseFragmentFile(fileContent, file.absolutePath);
267
+ if (!parsed.componentImport) continue;
268
+
269
+ // Resolve the component source path
270
+ const sourcePath = resolveComponentSourcePath(file.absolutePath, parsed.componentImport);
271
+ if (!sourcePath) continue;
272
+
273
+ // Extract current state from source
274
+ const meta = extractor.extract(sourcePath, fragment.meta.name);
275
+ if (!meta) continue;
276
+
277
+ // Compare props
278
+ const drifts = diffProps(fragment.props, meta.props);
279
+
280
+ // Compare composition
281
+ let compositionDrift: string | null = null;
282
+ const fragmentAi = fragment.ai;
283
+ if (meta.composition && !fragmentAi?.compositionPattern) {
284
+ compositionDrift = `Source has "${meta.composition.pattern}" composition but fragment has no ai.compositionPattern`;
285
+ } else if (!meta.composition && fragmentAi?.compositionPattern) {
286
+ compositionDrift = `Fragment declares "${fragmentAi.compositionPattern}" but source has no compound pattern`;
287
+ } else if (meta.composition && fragmentAi?.compositionPattern &&
288
+ meta.composition.pattern !== fragmentAi.compositionPattern) {
289
+ compositionDrift = `Composition pattern changed: fragment="${fragmentAi.compositionPattern}" source="${meta.composition.pattern}"`;
290
+ }
291
+
292
+ if (drifts.length > 0 || compositionDrift) {
293
+ const report: DriftReport = {
294
+ component: fragment.meta.name,
295
+ file: file.relativePath,
296
+ drifts,
297
+ compositionDrift,
298
+ };
299
+ reports.push(report);
300
+
301
+ // Classify drift items as errors (removed props) or warnings (added/changed props)
302
+ for (const drift of drifts) {
303
+ if (drift.kind === 'removed') {
304
+ errors.push({
305
+ file: file.relativePath,
306
+ message: `Prop "${drift.prop}" documented in fragment but removed from source`,
307
+ details: `Fragment: ${drift.fragment} | Source: (not found)`,
308
+ });
309
+ } else if (drift.kind === 'added') {
310
+ warnings.push({
311
+ file: file.relativePath,
312
+ message: `Prop "${drift.prop}" exists in source but not documented in fragment`,
313
+ });
314
+ } else {
315
+ warnings.push({
316
+ file: file.relativePath,
317
+ message: `Prop "${drift.prop}" ${drift.kind.replace('_', ' ')}: fragment=${drift.fragment} source=${drift.source}`,
318
+ });
319
+ }
320
+ }
321
+
322
+ if (compositionDrift) {
323
+ warnings.push({
324
+ file: file.relativePath,
325
+ message: compositionDrift,
326
+ });
327
+ }
328
+ }
329
+ } catch {
330
+ // Skip fragments that can't be analyzed — schema/coverage validators handle those
331
+ }
332
+ }
333
+ } finally {
334
+ extractor.dispose();
335
+ }
336
+
337
+ return {
338
+ valid: errors.length === 0,
339
+ errors,
340
+ warnings,
341
+ reports,
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Compare documented props (from fragment) against extracted props (from source).
347
+ * Only compares local (non-inherited) props from the source.
348
+ */
349
+ export function diffProps(
350
+ fragmentProps: Record<string, { type?: string; required?: boolean; values?: readonly string[]; default?: unknown }>,
351
+ sourceProps: Record<string, PropMeta>
352
+ ): DriftItem[] {
353
+ const drifts: DriftItem[] = [];
354
+
355
+ // Filter source to local props only
356
+ const localSourceProps = Object.fromEntries(
357
+ Object.entries(sourceProps).filter(([_, p]) => p.source === 'local')
358
+ );
359
+
360
+ // Check for props in source but not in fragment
361
+ for (const [name, sourceProp] of Object.entries(localSourceProps)) {
362
+ if (!(name in fragmentProps)) {
363
+ drifts.push({
364
+ prop: name,
365
+ kind: 'added',
366
+ source: sourceProp.type,
367
+ fragment: '(not documented)',
368
+ });
369
+ }
370
+ }
371
+
372
+ // Check for props in fragment but not in source
373
+ for (const [name, fragProp] of Object.entries(fragmentProps)) {
374
+ if (!(name in localSourceProps)) {
375
+ drifts.push({
376
+ prop: name,
377
+ kind: 'removed',
378
+ source: '(not found)',
379
+ fragment: String(fragProp.type ?? 'unknown'),
380
+ });
381
+ continue;
382
+ }
383
+
384
+ const sourceProp = localSourceProps[name];
385
+
386
+ // Type changed
387
+ if (fragProp.type && fragProp.type !== sourceProp.typeKind) {
388
+ drifts.push({
389
+ prop: name,
390
+ kind: 'type_changed',
391
+ source: sourceProp.typeKind,
392
+ fragment: String(fragProp.type),
393
+ });
394
+ }
395
+
396
+ // Required changed
397
+ if (fragProp.required !== undefined && fragProp.required !== sourceProp.required) {
398
+ drifts.push({
399
+ prop: name,
400
+ kind: 'required_changed',
401
+ source: String(sourceProp.required),
402
+ fragment: String(fragProp.required),
403
+ });
404
+ }
405
+
406
+ // Enum values changed
407
+ if (fragProp.values && sourceProp.values) {
408
+ const fragSet = new Set(fragProp.values);
409
+ const srcSet = new Set(sourceProp.values);
410
+ const added = sourceProp.values.filter(v => !fragSet.has(v));
411
+ const removed = Array.from(fragProp.values).filter(v => !srcSet.has(v));
412
+ if (added.length > 0 || removed.length > 0) {
413
+ drifts.push({
414
+ prop: name,
415
+ kind: 'values_changed',
416
+ source: sourceProp.values.join(', '),
417
+ fragment: Array.from(fragProp.values).join(', '),
418
+ });
419
+ }
420
+ }
421
+
422
+ // Default changed
423
+ if (fragProp.default !== undefined && sourceProp.default !== undefined) {
424
+ if (String(fragProp.default) !== sourceProp.default) {
425
+ drifts.push({
426
+ prop: name,
427
+ kind: 'default_changed',
428
+ source: sourceProp.default,
429
+ fragment: String(fragProp.default),
430
+ });
431
+ }
432
+ }
433
+ }
434
+
435
+ return drifts;
436
+ }