@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.
- package/dist/ai-client-I6MDWNYA.js +21 -0
- package/dist/bin.js +275 -368
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-PW7QTQA6.js → chunk-4OC7FTJB.js} +2 -2
- package/dist/{chunk-HRFUSSZI.js → chunk-AM4MRTMN.js} +2 -2
- package/dist/{chunk-5G3VZH43.js → chunk-GVDSFQ4E.js} +281 -351
- package/dist/chunk-GVDSFQ4E.js.map +1 -0
- package/dist/chunk-JJ2VRTBU.js +626 -0
- package/dist/chunk-JJ2VRTBU.js.map +1 -0
- package/dist/{chunk-D5PYOXEI.js → chunk-LVWFOLUZ.js} +148 -13
- package/dist/{chunk-D5PYOXEI.js.map → chunk-LVWFOLUZ.js.map} +1 -1
- package/dist/{chunk-WXSR2II7.js → chunk-OQKMEFOS.js} +58 -6
- package/dist/chunk-OQKMEFOS.js.map +1 -0
- package/dist/chunk-SXTKFDCR.js +104 -0
- package/dist/chunk-SXTKFDCR.js.map +1 -0
- package/dist/chunk-T5OMVL7E.js +443 -0
- package/dist/chunk-T5OMVL7E.js.map +1 -0
- package/dist/{chunk-ZM4ZQZWZ.js → chunk-TPWGL2XS.js} +39 -37
- package/dist/chunk-TPWGL2XS.js.map +1 -0
- package/dist/{chunk-OQO55NKV.js → chunk-WFS63PCW.js} +85 -11
- package/dist/chunk-WFS63PCW.js.map +1 -0
- package/dist/core/index.js +9 -1
- package/dist/{discovery-NEOY4MPN.js → discovery-ZJQSXF56.js} +3 -3
- package/dist/{generate-FBHSXR3D.js → generate-RJFS2JWA.js} +4 -4
- package/dist/index.js +7 -6
- package/dist/index.js.map +1 -1
- package/dist/init-ZSX3NRCZ.js +636 -0
- package/dist/init-ZSX3NRCZ.js.map +1 -0
- package/dist/mcp-bin.js +2 -2
- package/dist/{scan-CJF2DOQW.js → scan-3PMCJ4RB.js} +6 -6
- package/dist/scan-generate-SYU4PYZD.js +1115 -0
- package/dist/scan-generate-SYU4PYZD.js.map +1 -0
- package/dist/{service-TQYWY65E.js → service-VMGNJZ42.js} +3 -3
- package/dist/{snapshot-SV2JOFZH.js → snapshot-XOISO2IS.js} +2 -2
- package/dist/{static-viewer-NUBFPKWH.js → static-viewer-5GXH2MGE.js} +3 -3
- package/dist/static-viewer-5GXH2MGE.js.map +1 -0
- package/dist/{test-Z5LVO724.js → test-SI4NSHQX.js} +4 -4
- package/dist/{tokens-CE46OTMD.js → tokens-T6SIVUT5.js} +5 -5
- package/dist/{viewer-DLLJIMCK.js → viewer-7ZEAFBVN.js} +13 -13
- package/package.json +4 -4
- package/src/ai-client.ts +156 -0
- package/src/bin.ts +44 -2
- package/src/build.ts +95 -33
- package/src/commands/__tests__/drift-sync.test.ts +252 -0
- package/src/commands/__tests__/scan-generate.test.ts +497 -45
- package/src/commands/enhance.ts +11 -35
- package/src/commands/init.ts +288 -260
- package/src/commands/scan-generate.ts +740 -139
- package/src/commands/scan.ts +37 -32
- package/src/commands/setup.ts +143 -52
- package/src/commands/sync.ts +357 -0
- package/src/commands/validate.ts +43 -1
- package/src/core/component-extractor.test.ts +282 -0
- package/src/core/component-extractor.ts +1030 -0
- package/src/core/discovery.ts +93 -7
- package/src/service/enhance/props-extractor.ts +235 -13
- package/src/validators.ts +236 -0
- package/dist/chunk-5G3VZH43.js.map +0 -1
- package/dist/chunk-OQO55NKV.js.map +0 -1
- package/dist/chunk-WXSR2II7.js.map +0 -1
- package/dist/chunk-ZM4ZQZWZ.js.map +0 -1
- package/dist/init-UFGK5TCN.js +0 -867
- package/dist/init-UFGK5TCN.js.map +0 -1
- package/dist/scan-generate-SJAN5MVI.js +0 -691
- package/dist/scan-generate-SJAN5MVI.js.map +0 -1
- package/src/ai.ts +0 -266
- package/src/commands/init-framework.ts +0 -414
- package/src/mcp/bin.ts +0 -36
- package/src/migrate/bin.ts +0 -114
- package/src/theme/index.ts +0 -77
- package/src/viewer/bin.ts +0 -86
- package/src/viewer/cli/health.ts +0 -256
- package/src/viewer/cli/index.ts +0 -33
- package/src/viewer/cli/scan.ts +0 -124
- package/src/viewer/cli/utils.ts +0 -174
- /package/dist/{discovery-NEOY4MPN.js.map → ai-client-I6MDWNYA.js.map} +0 -0
- /package/dist/{chunk-PW7QTQA6.js.map → chunk-4OC7FTJB.js.map} +0 -0
- /package/dist/{chunk-HRFUSSZI.js.map → chunk-AM4MRTMN.js.map} +0 -0
- /package/dist/{scan-CJF2DOQW.js.map → discovery-ZJQSXF56.js.map} +0 -0
- /package/dist/{generate-FBHSXR3D.js.map → generate-RJFS2JWA.js.map} +0 -0
- /package/dist/{service-TQYWY65E.js.map → scan-3PMCJ4RB.js.map} +0 -0
- /package/dist/{static-viewer-NUBFPKWH.js.map → service-VMGNJZ42.js.map} +0 -0
- /package/dist/{snapshot-SV2JOFZH.js.map → snapshot-XOISO2IS.js.map} +0 -0
- /package/dist/{test-Z5LVO724.js.map → test-SI4NSHQX.js.map} +0 -0
- /package/dist/{tokens-CE46OTMD.js.map → tokens-T6SIVUT5.js.map} +0 -0
- /package/dist/{viewer-DLLJIMCK.js.map → viewer-7ZEAFBVN.js.map} +0 -0
package/src/core/discovery.ts
CHANGED
|
@@ -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
|
-
//
|
|
184
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
160
|
-
result.
|
|
161
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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.
|
|
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
|
+
}
|