@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
@@ -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
+ }