@fragments-sdk/cli 0.14.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -3
- package/dist/bin.js +4290 -3754
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-TXFCEDOC.js → chunk-2WXKALIG.js} +2 -2
- package/dist/{chunk-I34BC3CU.js → chunk-32LIWN2P.js} +1006 -3
- package/dist/chunk-32LIWN2P.js.map +1 -0
- package/dist/{chunk-55KERLWL.js → chunk-65WSVDV5.js} +314 -89
- package/dist/chunk-65WSVDV5.js.map +1 -0
- package/dist/chunk-7DZC4YEV.js +294 -0
- package/dist/chunk-7DZC4YEV.js.map +1 -0
- package/dist/{chunk-LOYS64QS.js → chunk-7WHVW72L.js} +230 -19
- package/dist/chunk-7WHVW72L.js.map +1 -0
- package/dist/{chunk-PJT5IZ37.js → chunk-BJE3425I.js} +19 -52
- package/dist/{chunk-PJT5IZ37.js.map → chunk-BJE3425I.js.map} +1 -1
- package/dist/{chunk-5A6X2Y73.js → chunk-CZD3AD4Q.js} +12 -11
- package/dist/chunk-CZD3AD4Q.js.map +1 -0
- package/dist/{chunk-EYXVAMEX.js → chunk-MN3TJ3D5.js} +72 -3
- package/dist/chunk-MN3TJ3D5.js.map +1 -0
- package/dist/chunk-QCN35LJU.js +630 -0
- package/dist/chunk-QCN35LJU.js.map +1 -0
- package/dist/chunk-T47OLCSF.js +36 -0
- package/dist/chunk-T47OLCSF.js.map +1 -0
- package/dist/{chunk-APTQIBS5.js → chunk-XJQ5BIWI.js} +144 -1049
- package/dist/chunk-XJQ5BIWI.js.map +1 -0
- package/dist/codebase-scanner-VOTPXRYW.js +22 -0
- package/dist/converter-JLINP7CJ.js +34 -0
- package/dist/converter-JLINP7CJ.js.map +1 -0
- package/dist/core/index.js +43 -1
- package/dist/{generate-RYWIPDN2.js → generate-A4FP5426.js} +3 -4
- package/dist/{generate-RYWIPDN2.js.map → generate-A4FP5426.js.map} +1 -1
- package/dist/govern-scan-UCBZR6D6.js +280 -0
- package/dist/govern-scan-UCBZR6D6.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +11 -11
- package/dist/{init-WRUSW7R5.js → init-HGSM35XA.js} +131 -128
- package/dist/init-HGSM35XA.js.map +1 -0
- package/dist/{init-cloud-REQ3XLHO.js → init-cloud-MQ6GRJAZ.js} +2 -2
- package/dist/mcp-bin.js +5 -36
- package/dist/mcp-bin.js.map +1 -1
- package/dist/scan-VNNKACG2.js +15 -0
- package/dist/{scan-generate-TFZVL3BT.js → scan-generate-TWRHNU5M.js} +335 -46
- package/dist/scan-generate-TWRHNU5M.js.map +1 -0
- package/dist/scanner-7LAZYPWZ.js +13 -0
- package/dist/{service-HKJ6B7P7.js → service-FHQU7YS7.js} +27 -23
- package/dist/{snapshot-C5DYIGIV.js → snapshot-KQEQ6XHL.js} +2 -2
- package/dist/{static-viewer-DUVC4UIM.js → static-viewer-63PG6FWY.js} +3 -3
- package/dist/static-viewer-63PG6FWY.js.map +1 -0
- package/dist/{test-JW7JIDFG.js → test-UQYUCZIS.js} +4 -6
- package/dist/{test-JW7JIDFG.js.map → test-UQYUCZIS.js.map} +1 -1
- package/dist/{tokens-KE73G5JC.js → tokens-6GYKDV6U.js} +6 -5
- package/dist/{tokens-KE73G5JC.js.map → tokens-6GYKDV6U.js.map} +1 -1
- package/dist/tokens-generate-VTZV5EEW.js +86 -0
- package/dist/tokens-generate-VTZV5EEW.js.map +1 -0
- package/package.json +6 -6
- package/src/bin.ts +210 -48
- package/src/build.ts +130 -6
- package/src/commands/__fixtures__/shadcn-label-wrapper/package.json +7 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.contract.json +42 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.tsx +11 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.contract.json +20 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.tsx +14 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/tsconfig.app.json +23 -0
- package/src/commands/__tests__/init.test.ts +113 -0
- package/src/commands/__tests__/scan-generate.test.ts +188 -69
- package/src/commands/__tests__/verify.test.ts +91 -0
- package/src/commands/discover.ts +151 -0
- package/src/commands/enhance.ts +3 -1
- package/src/commands/govern-scan.ts +386 -0
- package/src/commands/govern.ts +2 -2
- package/src/commands/init.ts +152 -28
- package/src/commands/inspect.ts +290 -0
- package/src/commands/migrate-contract.ts +85 -0
- package/src/commands/scan-generate.ts +438 -50
- package/src/commands/scan.ts +1 -0
- package/src/commands/setup.ts +27 -50
- package/src/commands/tokens-generate.ts +113 -0
- package/src/commands/verify.ts +195 -1
- package/src/core/__fixtures__/shadcn-input/input.tsx +7 -0
- package/src/core/__fixtures__/shadcn-input/tsconfig.json +14 -0
- package/src/core/__fixtures__/shadcn-label/label.tsx +11 -0
- package/src/core/__fixtures__/shadcn-label/primitive.tsx +14 -0
- package/src/core/__fixtures__/shadcn-label/tsconfig.json +14 -0
- package/src/core/__fixtures__/shadcn-radix-label/label.tsx +11 -0
- package/src/core/__fixtures__/shadcn-radix-label/node_modules/radix-ui/index.d.ts +12 -0
- package/src/core/__fixtures__/shadcn-radix-label/tsconfig.json +14 -0
- package/src/core/__tests__/contract-parity.test.ts +316 -0
- package/src/core/component-extractor.test.ts +39 -0
- package/src/core/component-extractor.ts +92 -1
- package/src/core/config.ts +2 -1
- package/src/core/discovery.ts +13 -2
- package/src/core/drift-verifier.ts +123 -0
- package/src/core/extractor-adapter.ts +80 -0
- package/src/mcp/__tests__/projectFields.test.ts +1 -1
- package/src/mcp/utils.ts +1 -50
- package/src/migrate/converter.ts +3 -3
- package/src/migrate/fragment-to-contract.ts +253 -0
- package/src/migrate/report.ts +1 -1
- package/src/scripts/token-benchmark.ts +121 -0
- package/src/service/__tests__/props-extractor.test.ts +94 -0
- package/src/service/__tests__/token-normalizer.test.ts +690 -0
- package/src/service/ast-utils.ts +4 -23
- package/src/service/babel-config.ts +23 -0
- package/src/service/enhance/converter.ts +61 -0
- package/src/service/enhance/props-extractor.ts +25 -8
- package/src/service/enhance/scanner.ts +5 -24
- package/src/service/snippet-validation.ts +9 -3
- package/src/service/token-normalizer.ts +510 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/project-fields.ts +46 -0
- package/src/viewer/__tests__/viewer-integration.test.ts +8 -8
- package/src/viewer/preview-adapter.ts +116 -0
- package/src/viewer/style-utils.ts +27 -412
- package/src/viewer/vite-plugin.ts +2 -2
- package/dist/chunk-55KERLWL.js.map +0 -1
- package/dist/chunk-5A6X2Y73.js.map +0 -1
- package/dist/chunk-APTQIBS5.js.map +0 -1
- package/dist/chunk-EYXVAMEX.js.map +0 -1
- package/dist/chunk-I34BC3CU.js.map +0 -1
- package/dist/chunk-LOYS64QS.js.map +0 -1
- package/dist/chunk-ZKTFKHWN.js +0 -324
- package/dist/chunk-ZKTFKHWN.js.map +0 -1
- package/dist/discovery-VDANZAJ2.js +0 -28
- package/dist/init-WRUSW7R5.js.map +0 -1
- package/dist/scan-YJHQIRKG.js +0 -14
- package/dist/scan-generate-TFZVL3BT.js.map +0 -1
- package/dist/viewer-2TZS3NDL.js +0 -2730
- package/dist/viewer-2TZS3NDL.js.map +0 -1
- package/src/commands/dev.ts +0 -107
- /package/dist/{chunk-TXFCEDOC.js.map → chunk-2WXKALIG.js.map} +0 -0
- /package/dist/{discovery-VDANZAJ2.js.map → codebase-scanner-VOTPXRYW.js.map} +0 -0
- /package/dist/{init-cloud-REQ3XLHO.js.map → init-cloud-MQ6GRJAZ.js.map} +0 -0
- /package/dist/{scan-YJHQIRKG.js.map → scan-VNNKACG2.js.map} +0 -0
- /package/dist/{service-HKJ6B7P7.js.map → scanner-7LAZYPWZ.js.map} +0 -0
- /package/dist/{static-viewer-DUVC4UIM.js.map → service-FHQU7YS7.js.map} +0 -0
- /package/dist/{snapshot-C5DYIGIV.js.map → snapshot-KQEQ6XHL.js.map} +0 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extractor Adapter — framework-aware prop extraction interface.
|
|
3
|
+
*
|
|
4
|
+
* V1 ships a React/TS adapter wrapping the existing createComponentExtractor().
|
|
5
|
+
* Non-React frameworks return a no-op adapter with verificationLevel: 'none'.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createComponentExtractor, type ComponentExtractor, type ComponentMeta } from './component-extractor.js';
|
|
9
|
+
|
|
10
|
+
export interface ExtractorAdapter {
|
|
11
|
+
/** Whether this adapter can handle the given framework */
|
|
12
|
+
canHandle(framework: string): boolean;
|
|
13
|
+
|
|
14
|
+
/** Extract component metadata from source */
|
|
15
|
+
extract(sourcePath: string, exportName: string): ComponentMeta | null;
|
|
16
|
+
|
|
17
|
+
/** Clean up resources */
|
|
18
|
+
dispose(): void;
|
|
19
|
+
|
|
20
|
+
/** How thoroughly this adapter can verify contracts */
|
|
21
|
+
readonly verificationLevel: 'full' | 'partial' | 'none';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* React/TypeScript extractor adapter.
|
|
26
|
+
* Wraps the existing LanguageService-based component extractor.
|
|
27
|
+
*/
|
|
28
|
+
class ReactExtractorAdapter implements ExtractorAdapter {
|
|
29
|
+
private extractor: ComponentExtractor;
|
|
30
|
+
readonly verificationLevel = 'full' as const;
|
|
31
|
+
|
|
32
|
+
constructor(tsconfigPath?: string) {
|
|
33
|
+
this.extractor = createComponentExtractor(tsconfigPath);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
canHandle(framework: string): boolean {
|
|
37
|
+
return framework === 'react';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
extract(sourcePath: string, exportName: string): ComponentMeta | null {
|
|
41
|
+
return this.extractor.extract(sourcePath, exportName);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
dispose(): void {
|
|
45
|
+
this.extractor.dispose();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* No-op adapter for unsupported frameworks.
|
|
51
|
+
* Returns null for all extractions, allowing build to proceed with manual props.
|
|
52
|
+
*/
|
|
53
|
+
class NoopExtractorAdapter implements ExtractorAdapter {
|
|
54
|
+
readonly verificationLevel = 'none' as const;
|
|
55
|
+
|
|
56
|
+
canHandle(): boolean {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
extract(): ComponentMeta | null {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
dispose(): void {
|
|
65
|
+
// Nothing to clean up
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create an extractor adapter for the given framework.
|
|
71
|
+
*/
|
|
72
|
+
export function createExtractorAdapter(
|
|
73
|
+
framework: string,
|
|
74
|
+
tsconfigPath?: string,
|
|
75
|
+
): ExtractorAdapter {
|
|
76
|
+
if (framework === 'react') {
|
|
77
|
+
return new ReactExtractorAdapter(tsconfigPath);
|
|
78
|
+
}
|
|
79
|
+
return new NoopExtractorAdapter();
|
|
80
|
+
}
|
package/src/mcp/utils.ts
CHANGED
|
@@ -2,53 +2,4 @@
|
|
|
2
2
|
* Utility functions for the MCP server
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
* Extract specific fields from an object using dot notation paths.
|
|
7
|
-
* E.g., projectFields(obj, ['meta.name', 'usage.when']) returns { meta: { name: ... }, usage: { when: ... } }
|
|
8
|
-
*
|
|
9
|
-
* @param obj - The source object to extract fields from
|
|
10
|
-
* @param fields - Array of field paths (supports dot notation for nested fields)
|
|
11
|
-
* @returns A new object containing only the requested fields
|
|
12
|
-
*/
|
|
13
|
-
export function projectFields<T extends Record<string, unknown>>(
|
|
14
|
-
obj: T,
|
|
15
|
-
fields: string[]
|
|
16
|
-
): Partial<T> {
|
|
17
|
-
if (!fields || fields.length === 0) {
|
|
18
|
-
return obj;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const result: Record<string, unknown> = {};
|
|
22
|
-
|
|
23
|
-
for (const field of fields) {
|
|
24
|
-
const parts = field.split('.');
|
|
25
|
-
let source: unknown = obj;
|
|
26
|
-
let target = result;
|
|
27
|
-
|
|
28
|
-
for (let i = 0; i < parts.length; i++) {
|
|
29
|
-
const part = parts[i];
|
|
30
|
-
const isLast = i === parts.length - 1;
|
|
31
|
-
|
|
32
|
-
if (source === null || source === undefined || typeof source !== 'object') {
|
|
33
|
-
break;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const sourceObj = source as Record<string, unknown>;
|
|
37
|
-
const value = sourceObj[part];
|
|
38
|
-
|
|
39
|
-
if (isLast) {
|
|
40
|
-
// Set the final value
|
|
41
|
-
target[part] = value;
|
|
42
|
-
} else {
|
|
43
|
-
// Create nested object if needed
|
|
44
|
-
if (!(part in target)) {
|
|
45
|
-
target[part] = {};
|
|
46
|
-
}
|
|
47
|
-
target = target[part] as Record<string, unknown>;
|
|
48
|
-
source = value;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return result as Partial<T>;
|
|
54
|
-
}
|
|
5
|
+
export { projectFields } from '../shared/project-fields.js';
|
package/src/migrate/converter.ts
CHANGED
|
@@ -39,7 +39,7 @@ export function convertToFragment(parsed: ParsedStoryFile): ConversionResult {
|
|
|
39
39
|
|
|
40
40
|
// Determine output file path for the error result
|
|
41
41
|
const outputFile = parsed.filePath
|
|
42
|
-
.replace(/\.stories\.(tsx?|jsx?|mdx)$/, ".
|
|
42
|
+
.replace(/\.stories\.(tsx?|jsx?|mdx)$/, ".contract.json");
|
|
43
43
|
|
|
44
44
|
return {
|
|
45
45
|
sourceFile: parsed.filePath,
|
|
@@ -103,9 +103,9 @@ export function convertToFragment(parsed: ParsedStoryFile): ConversionResult {
|
|
|
103
103
|
},
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
// Determine output file path
|
|
106
|
+
// Determine output file path — prefer .contract.json over legacy .fragment.tsx
|
|
107
107
|
const outputFile = parsed.filePath
|
|
108
|
-
.replace(/\.stories\.(tsx?|jsx?|mdx)$/, ".
|
|
108
|
+
.replace(/\.stories\.(tsx?|jsx?|mdx)$/, ".contract.json");
|
|
109
109
|
|
|
110
110
|
return {
|
|
111
111
|
sourceFile: parsed.filePath,
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fragment-to-Contract Migration
|
|
3
|
+
*
|
|
4
|
+
* Converts .fragment.tsx files to .contract.json files.
|
|
5
|
+
* The contract.json format is framework-agnostic, token-efficient,
|
|
6
|
+
* and agent-first — the canonical V2 authoring format.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { resolve, dirname, relative, basename } from 'node:path';
|
|
11
|
+
import { parseFragmentFile } from '../core/parser.js';
|
|
12
|
+
import { createComponentExtractor, type ComponentMeta } from '../core/component-extractor.js';
|
|
13
|
+
import { resolveComponentSourcePath } from '../core/auto-props.js';
|
|
14
|
+
import type { ComponentContract } from '@fragments-sdk/core';
|
|
15
|
+
|
|
16
|
+
export interface MigrateOptions {
|
|
17
|
+
dryRun?: boolean;
|
|
18
|
+
tsconfigPath?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MigrateResult {
|
|
22
|
+
contractPath: string;
|
|
23
|
+
contract: ComponentContract;
|
|
24
|
+
warnings: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Compile a propsSummary array from extracted props.
|
|
29
|
+
* Format: "variant: primary|secondary (required)"
|
|
30
|
+
*/
|
|
31
|
+
function compilePropsSummaryFromExtracted(
|
|
32
|
+
props: Record<string, { typeKind: string; values?: string[]; default?: string; required: boolean; source: string }>
|
|
33
|
+
): string[] {
|
|
34
|
+
return Object.entries(props)
|
|
35
|
+
.filter(([, p]) => p.source === 'local')
|
|
36
|
+
.map(([name, prop]) => {
|
|
37
|
+
let summary = name + ': ';
|
|
38
|
+
if (prop.values && prop.values.length > 0) {
|
|
39
|
+
summary += prop.values.join('|');
|
|
40
|
+
} else {
|
|
41
|
+
summary += prop.typeKind;
|
|
42
|
+
}
|
|
43
|
+
if (prop.default !== undefined) {
|
|
44
|
+
summary += ` (default: ${prop.default})`;
|
|
45
|
+
}
|
|
46
|
+
if (prop.required) {
|
|
47
|
+
summary += ' (required)';
|
|
48
|
+
}
|
|
49
|
+
return summary;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compile a propsSummary from documented props (fallback when extraction fails).
|
|
55
|
+
*/
|
|
56
|
+
function compilePropsSummaryFromDocs(
|
|
57
|
+
props: Record<string, { type?: string; values?: readonly string[]; default?: unknown; required?: boolean }>
|
|
58
|
+
): string[] {
|
|
59
|
+
return Object.entries(props).map(([name, prop]) => {
|
|
60
|
+
let summary = name + ': ';
|
|
61
|
+
if (prop.values && prop.values.length > 0) {
|
|
62
|
+
summary += [...prop.values].join('|');
|
|
63
|
+
} else {
|
|
64
|
+
summary += prop.type ?? 'custom';
|
|
65
|
+
}
|
|
66
|
+
if (prop.default !== undefined) {
|
|
67
|
+
summary += ` (default: ${String(prop.default)})`;
|
|
68
|
+
}
|
|
69
|
+
if (prop.required) {
|
|
70
|
+
summary += ' (required)';
|
|
71
|
+
}
|
|
72
|
+
return summary;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Migrate a .fragment.tsx file to .contract.json.
|
|
78
|
+
*/
|
|
79
|
+
export async function migrateFragmentToContract(
|
|
80
|
+
fragmentPath: string,
|
|
81
|
+
configDir: string,
|
|
82
|
+
options?: MigrateOptions,
|
|
83
|
+
): Promise<MigrateResult> {
|
|
84
|
+
const warnings: string[] = [];
|
|
85
|
+
const absFragmentPath = resolve(fragmentPath);
|
|
86
|
+
|
|
87
|
+
// 1. Read and parse the .fragment.tsx file
|
|
88
|
+
const content = await readFile(absFragmentPath, 'utf-8');
|
|
89
|
+
const parsed = parseFragmentFile(content, fragmentPath);
|
|
90
|
+
warnings.push(...parsed.warnings);
|
|
91
|
+
|
|
92
|
+
if (!parsed.meta.name) {
|
|
93
|
+
throw new Error(`Fragment file ${fragmentPath} has no meta.name`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Try to extract live props from component source
|
|
97
|
+
let extractedMeta: ComponentMeta | null = null;
|
|
98
|
+
const extractor = createComponentExtractor(options?.tsconfigPath);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const componentExportName = parsed.componentName ?? parsed.meta.name;
|
|
102
|
+
const componentSourcePath = resolveComponentSourcePath(
|
|
103
|
+
absFragmentPath,
|
|
104
|
+
parsed.componentImport,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (componentExportName && componentSourcePath) {
|
|
108
|
+
try {
|
|
109
|
+
extractedMeta = extractor.extract(componentSourcePath, componentExportName);
|
|
110
|
+
} catch {
|
|
111
|
+
warnings.push('Auto-props extraction failed; falling back to documented props');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
extractor.dispose();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 3. Resolve sourcePath relative to config root
|
|
119
|
+
let sourcePath: string;
|
|
120
|
+
if (parsed.componentImport) {
|
|
121
|
+
const absSource = resolveComponentSourcePath(absFragmentPath, parsed.componentImport);
|
|
122
|
+
if (absSource) {
|
|
123
|
+
sourcePath = relative(configDir, absSource);
|
|
124
|
+
} else {
|
|
125
|
+
sourcePath = parsed.componentImport;
|
|
126
|
+
warnings.push(`Could not resolve component source path: ${parsed.componentImport}`);
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
// Fallback: assume component is adjacent
|
|
130
|
+
const fragDir = dirname(absFragmentPath);
|
|
131
|
+
sourcePath = relative(configDir, resolve(fragDir, 'index.tsx'));
|
|
132
|
+
warnings.push('No component import found; assuming ./index.tsx');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const exportName = parsed.componentName ?? parsed.meta.name;
|
|
136
|
+
|
|
137
|
+
// 4. Compile propsSummary
|
|
138
|
+
let propsSummary: string[];
|
|
139
|
+
if (extractedMeta) {
|
|
140
|
+
propsSummary = compilePropsSummaryFromExtracted(extractedMeta.props);
|
|
141
|
+
} else {
|
|
142
|
+
propsSummary = compilePropsSummaryFromDocs(parsed.props);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 5. Merge props
|
|
146
|
+
let mergedProps: Record<string, {
|
|
147
|
+
type: string;
|
|
148
|
+
description: string;
|
|
149
|
+
default?: unknown;
|
|
150
|
+
required?: boolean;
|
|
151
|
+
values?: string[];
|
|
152
|
+
constraints?: string[];
|
|
153
|
+
}>;
|
|
154
|
+
|
|
155
|
+
if (extractedMeta && Object.keys(extractedMeta.props).length > 0) {
|
|
156
|
+
mergedProps = {};
|
|
157
|
+
for (const [name, auto] of Object.entries(extractedMeta.props)) {
|
|
158
|
+
if (auto.source !== 'local' && !(name in parsed.props)) continue;
|
|
159
|
+
const documented = parsed.props[name];
|
|
160
|
+
mergedProps[name] = {
|
|
161
|
+
type: auto.typeKind,
|
|
162
|
+
description: documented?.description ?? auto.description ?? '',
|
|
163
|
+
default: auto.default !== undefined ? auto.default : documented?.default,
|
|
164
|
+
required: auto.required,
|
|
165
|
+
values: auto.values ?? (documented?.values ? [...documented.values] : undefined),
|
|
166
|
+
constraints: documented?.constraints ? [...documented.constraints] : undefined,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
mergedProps = {};
|
|
171
|
+
for (const [name, prop] of Object.entries(parsed.props)) {
|
|
172
|
+
mergedProps[name] = {
|
|
173
|
+
type: prop.type ?? 'custom',
|
|
174
|
+
description: prop.description ?? '',
|
|
175
|
+
default: prop.default,
|
|
176
|
+
required: prop.required,
|
|
177
|
+
values: prop.values ? [...prop.values] : undefined,
|
|
178
|
+
constraints: prop.constraints ? [...prop.constraints] : undefined,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 6. Determine verification status
|
|
184
|
+
const verified = false; // Default to false; only true after full extraction + clean drift pass
|
|
185
|
+
|
|
186
|
+
// 7. Build AI metadata
|
|
187
|
+
let ai: ComponentContract['ai'] | undefined;
|
|
188
|
+
if (parsed.ai) {
|
|
189
|
+
ai = { ...parsed.ai };
|
|
190
|
+
}
|
|
191
|
+
if (extractedMeta?.composition) {
|
|
192
|
+
const comp = extractedMeta.composition;
|
|
193
|
+
ai = {
|
|
194
|
+
compositionPattern: comp.pattern,
|
|
195
|
+
subComponents: comp.parts.map((p) => p.name),
|
|
196
|
+
...ai,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 8. Assemble the contract
|
|
201
|
+
const contract: ComponentContract = {
|
|
202
|
+
$schema: 'https://usefragments.com/schemas/contract.v1.json',
|
|
203
|
+
name: parsed.meta.name,
|
|
204
|
+
description: parsed.meta.description ?? `${parsed.meta.name} component`,
|
|
205
|
+
category: parsed.meta.category ?? 'Uncategorized',
|
|
206
|
+
...(parsed.meta.tags && { tags: parsed.meta.tags }),
|
|
207
|
+
...(parsed.meta.status && { status: parsed.meta.status }),
|
|
208
|
+
sourcePath,
|
|
209
|
+
exportName,
|
|
210
|
+
propsSummary,
|
|
211
|
+
props: mergedProps,
|
|
212
|
+
usage: {
|
|
213
|
+
when: parsed.usage.when ?? [],
|
|
214
|
+
whenNot: parsed.usage.whenNot ?? [],
|
|
215
|
+
...(parsed.usage.guidelines && { guidelines: parsed.usage.guidelines }),
|
|
216
|
+
...(parsed.usage.accessibility && { accessibility: parsed.usage.accessibility }),
|
|
217
|
+
},
|
|
218
|
+
...(parsed.variants.length > 0 && {
|
|
219
|
+
examples: parsed.variants.map((v) => ({
|
|
220
|
+
name: v.name,
|
|
221
|
+
description: v.description,
|
|
222
|
+
code: v.code ?? '',
|
|
223
|
+
...(v.args && { args: v.args }),
|
|
224
|
+
})),
|
|
225
|
+
}),
|
|
226
|
+
...(parsed.relations.length > 0 && {
|
|
227
|
+
relations: parsed.relations.map((r) => ({
|
|
228
|
+
component: r.component,
|
|
229
|
+
relationship: r.relationship as 'alternative' | 'parent' | 'child' | 'sibling',
|
|
230
|
+
note: r.note,
|
|
231
|
+
})),
|
|
232
|
+
}),
|
|
233
|
+
...(parsed.contract && { contract: parsed.contract }),
|
|
234
|
+
...(ai && { ai }),
|
|
235
|
+
provenance: {
|
|
236
|
+
source: 'migrated',
|
|
237
|
+
verified,
|
|
238
|
+
frameworkSupport: 'native',
|
|
239
|
+
extractedAt: new Date().toISOString(),
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// 9. Determine output path
|
|
244
|
+
const contractPath = absFragmentPath
|
|
245
|
+
.replace(/\.fragment\.tsx?$/, '.contract.json');
|
|
246
|
+
|
|
247
|
+
// 10. Write output (unless dry run)
|
|
248
|
+
if (!options?.dryRun) {
|
|
249
|
+
await writeFile(contractPath, JSON.stringify(contract, null, 2) + '\n');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { contractPath, contract, warnings };
|
|
253
|
+
}
|
package/src/migrate/report.ts
CHANGED
|
@@ -618,7 +618,7 @@ function renderFooter(): string {
|
|
|
618
618
|
return `
|
|
619
619
|
<footer class="footer">
|
|
620
620
|
<p>Generated by <a href="#">${BRAND.name}</a> Migration Tool</p>
|
|
621
|
-
<p style="margin-top: 8px">Run <code>fragments
|
|
621
|
+
<p style="margin-top: 8px">Run <code>fragments build</code> to compile your design system documentation</p>
|
|
622
622
|
</footer>
|
|
623
623
|
`;
|
|
624
624
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Token Budget Benchmark
|
|
4
|
+
*
|
|
5
|
+
* Compares token counts between .fragment.tsx and .contract.json corpora.
|
|
6
|
+
* Run: pnpm --filter @fragments-sdk/cli tsx src/scripts/token-benchmark.ts
|
|
7
|
+
*
|
|
8
|
+
* Uses character-based estimation (4 chars ≈ 1 token) as a stable proxy.
|
|
9
|
+
* The compact payload size gets a threshold assertion for regression guarding.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
13
|
+
import { glob } from 'glob';
|
|
14
|
+
import { resolve } from 'node:path';
|
|
15
|
+
|
|
16
|
+
interface CorpusStats {
|
|
17
|
+
fileCount: number;
|
|
18
|
+
totalChars: number;
|
|
19
|
+
estimatedTokens: number;
|
|
20
|
+
avgTokensPerFile: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function estimateTokens(text: string): number {
|
|
24
|
+
// GPT/Claude tokenizer approximation: ~4 chars per token for code/JSON
|
|
25
|
+
return Math.ceil(text.length / 4);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function measureCorpus(pattern: string, cwd: string): Promise<CorpusStats> {
|
|
29
|
+
const files = await glob(pattern, { cwd, absolute: true });
|
|
30
|
+
let totalChars = 0;
|
|
31
|
+
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
const content = await readFile(file, 'utf-8');
|
|
34
|
+
totalChars += content.length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const estimatedTokens = estimateTokens(' '.repeat(totalChars));
|
|
38
|
+
return {
|
|
39
|
+
fileCount: files.length,
|
|
40
|
+
totalChars,
|
|
41
|
+
estimatedTokens,
|
|
42
|
+
avgTokensPerFile: files.length > 0 ? Math.round(estimatedTokens / files.length) : 0,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function measureCompactPayload(pattern: string, cwd: string): Promise<CorpusStats> {
|
|
47
|
+
const files = await glob(pattern, { cwd, absolute: true });
|
|
48
|
+
let totalChars = 0;
|
|
49
|
+
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
try {
|
|
52
|
+
const content = await readFile(file, 'utf-8');
|
|
53
|
+
const contract = JSON.parse(content);
|
|
54
|
+
// Compact payload = name + category + propsSummary only
|
|
55
|
+
const compact = JSON.stringify({
|
|
56
|
+
name: contract.name,
|
|
57
|
+
category: contract.category,
|
|
58
|
+
propsSummary: contract.propsSummary ?? [],
|
|
59
|
+
});
|
|
60
|
+
totalChars += compact.length;
|
|
61
|
+
} catch {
|
|
62
|
+
// Skip non-JSON or malformed files
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const estimatedTokens = estimateTokens(' '.repeat(totalChars));
|
|
67
|
+
return {
|
|
68
|
+
fileCount: files.length,
|
|
69
|
+
totalChars,
|
|
70
|
+
estimatedTokens,
|
|
71
|
+
avgTokensPerFile: files.length > 0 ? Math.round(estimatedTokens / files.length) : 0,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatRow(label: string, stats: CorpusStats): string {
|
|
76
|
+
return `| ${label.padEnd(25)} | ${String(stats.fileCount).padStart(5)} | ${String(stats.totalChars).padStart(10)} | ${String(stats.estimatedTokens).padStart(10)} | ${String(stats.avgTokensPerFile).padStart(10)} |`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function main() {
|
|
80
|
+
// Default to the UI lib where the components live
|
|
81
|
+
const projectRoot = resolve(process.cwd(), '..', '..');
|
|
82
|
+
const uiDir = resolve(projectRoot, 'libs', 'ui');
|
|
83
|
+
|
|
84
|
+
console.log('# Token Budget Benchmark Report\n');
|
|
85
|
+
console.log(`Date: ${new Date().toISOString()}`);
|
|
86
|
+
console.log(`Project root: ${projectRoot}\n`);
|
|
87
|
+
|
|
88
|
+
const tsxCorpus = await measureCorpus('src/**/*.fragment.tsx', uiDir);
|
|
89
|
+
const jsonCorpus = await measureCorpus('src/**/*.contract.json', uiDir);
|
|
90
|
+
const compactPayload = await measureCompactPayload('src/**/*.contract.json', uiDir);
|
|
91
|
+
|
|
92
|
+
console.log('| Corpus | Files | Chars | Tokens | Avg/File |');
|
|
93
|
+
console.log('|---------------------------|------:|-----------:|-----------:|-----------:|');
|
|
94
|
+
console.log(formatRow('.fragment.tsx', tsxCorpus));
|
|
95
|
+
console.log(formatRow('.contract.json', jsonCorpus));
|
|
96
|
+
console.log(formatRow('Compact (propsSummary)', compactPayload));
|
|
97
|
+
|
|
98
|
+
// Calculate savings
|
|
99
|
+
if (tsxCorpus.estimatedTokens > 0 && jsonCorpus.estimatedTokens > 0) {
|
|
100
|
+
const savings = ((1 - jsonCorpus.estimatedTokens / tsxCorpus.estimatedTokens) * 100).toFixed(1);
|
|
101
|
+
console.log(`\n## Token Savings: ${savings}% (JSON vs TSX)\n`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (jsonCorpus.estimatedTokens > 0 && compactPayload.estimatedTokens > 0) {
|
|
105
|
+
const compactRatio = ((compactPayload.estimatedTokens / jsonCorpus.estimatedTokens) * 100).toFixed(1);
|
|
106
|
+
console.log(`## Compact Payload: ${compactRatio}% of full JSON\n`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Regression guard: compact payload should average < 50 tokens per component
|
|
110
|
+
if (compactPayload.fileCount > 0 && compactPayload.avgTokensPerFile > 50) {
|
|
111
|
+
console.error('WARN: Compact payload exceeds 50 tokens/component average');
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.log('Benchmark complete.');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main().catch((err) => {
|
|
119
|
+
console.error('Benchmark failed:', err);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
});
|
|
@@ -274,6 +274,79 @@ export function Component(props: MyCustomProps) {
|
|
|
274
274
|
expect(result.props).toHaveLength(1);
|
|
275
275
|
expect(result.propsTypeName).toBe("MyCustomProps");
|
|
276
276
|
});
|
|
277
|
+
|
|
278
|
+
it("should extract shadcn-style inline props and cva variants", () => {
|
|
279
|
+
const source = `
|
|
280
|
+
import * as React from "react";
|
|
281
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
282
|
+
import { Slot } from "radix-ui";
|
|
283
|
+
|
|
284
|
+
const buttonVariants = cva("", {
|
|
285
|
+
variants: {
|
|
286
|
+
variant: {
|
|
287
|
+
default: "",
|
|
288
|
+
outline: "",
|
|
289
|
+
secondary: "",
|
|
290
|
+
ghost: "",
|
|
291
|
+
},
|
|
292
|
+
size: {
|
|
293
|
+
default: "",
|
|
294
|
+
sm: "",
|
|
295
|
+
lg: "",
|
|
296
|
+
icon: "",
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
export function Button({
|
|
302
|
+
className,
|
|
303
|
+
variant = "default",
|
|
304
|
+
size = "default",
|
|
305
|
+
asChild = false,
|
|
306
|
+
...props
|
|
307
|
+
}: React.ComponentProps<"button"> &
|
|
308
|
+
VariantProps<typeof buttonVariants> & {
|
|
309
|
+
asChild?: boolean
|
|
310
|
+
}) {
|
|
311
|
+
const Comp = asChild ? Slot.Root : "button";
|
|
312
|
+
return <Comp data-variant={variant} data-size={size} className={className} {...props} />;
|
|
313
|
+
}
|
|
314
|
+
`;
|
|
315
|
+
|
|
316
|
+
const result = extractPropsFromSource(source, "Button.tsx");
|
|
317
|
+
|
|
318
|
+
expect(result.success).toBe(true);
|
|
319
|
+
|
|
320
|
+
const variantProp = result.props.find((p) => p.name === "variant");
|
|
321
|
+
const sizeProp = result.props.find((p) => p.name === "size");
|
|
322
|
+
const asChildProp = result.props.find((p) => p.name === "asChild");
|
|
323
|
+
|
|
324
|
+
expect(variantProp?.enumValues).toEqual(["default", "outline", "secondary", "ghost"]);
|
|
325
|
+
expect(sizeProp?.enumValues).toEqual(["default", "sm", "lg", "icon"]);
|
|
326
|
+
expect(asChildProp?.propType.type).toBe("boolean");
|
|
327
|
+
expect(asChildProp?.defaultValue).toBe(false);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("should infer PascalCase component names from lowercase shadcn file names", () => {
|
|
331
|
+
const source = `
|
|
332
|
+
import * as React from "react";
|
|
333
|
+
|
|
334
|
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
335
|
+
return <input className={className} type={type} {...props} />;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export { Input };
|
|
339
|
+
`;
|
|
340
|
+
|
|
341
|
+
const result = extractPropsFromSource(source, "input.tsx");
|
|
342
|
+
|
|
343
|
+
expect(result.success).toBe(true);
|
|
344
|
+
expect(result.componentName).toBe("Input");
|
|
345
|
+
|
|
346
|
+
const typeProp = result.props.find((p) => p.name === "type");
|
|
347
|
+
expect(typeProp?.propType.type).toBe("string");
|
|
348
|
+
expect(typeProp?.required).toBe(true);
|
|
349
|
+
});
|
|
277
350
|
});
|
|
278
351
|
|
|
279
352
|
describe("extractPropsFromFile", () => {
|
|
@@ -329,6 +402,27 @@ export function MyComponent(props: MyComponentProps) {
|
|
|
329
402
|
|
|
330
403
|
expect(result.componentName).toBe("MyComponent");
|
|
331
404
|
});
|
|
405
|
+
|
|
406
|
+
it("should extract inline props from lowercase shadcn-style file names", async () => {
|
|
407
|
+
const filePath = await writeTestFile(
|
|
408
|
+
"input.tsx",
|
|
409
|
+
`
|
|
410
|
+
import * as React from "react";
|
|
411
|
+
|
|
412
|
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
413
|
+
return <input className={className} type={type} {...props} />;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export { Input };
|
|
417
|
+
`
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const result = await extractPropsFromFile(filePath);
|
|
421
|
+
|
|
422
|
+
expect(result.success).toBe(true);
|
|
423
|
+
expect(result.componentName).toBe("Input");
|
|
424
|
+
expect(result.props.some((prop) => prop.name === "type")).toBe(true);
|
|
425
|
+
});
|
|
332
426
|
});
|
|
333
427
|
|
|
334
428
|
describe("convertToFragmentProps", () => {
|