@fragments-sdk/cli 0.14.3 → 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.
Files changed (135) hide show
  1. package/README.md +0 -3
  2. package/dist/bin.js +4290 -3754
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-TXFCEDOC.js → chunk-2WXKALIG.js} +2 -2
  5. package/dist/{chunk-I34BC3CU.js → chunk-32LIWN2P.js} +1006 -3
  6. package/dist/chunk-32LIWN2P.js.map +1 -0
  7. package/dist/{chunk-55KERLWL.js → chunk-65WSVDV5.js} +314 -89
  8. package/dist/chunk-65WSVDV5.js.map +1 -0
  9. package/dist/chunk-7DZC4YEV.js +294 -0
  10. package/dist/chunk-7DZC4YEV.js.map +1 -0
  11. package/dist/{chunk-LOYS64QS.js → chunk-7WHVW72L.js} +230 -19
  12. package/dist/chunk-7WHVW72L.js.map +1 -0
  13. package/dist/{chunk-PJT5IZ37.js → chunk-BJE3425I.js} +19 -52
  14. package/dist/{chunk-PJT5IZ37.js.map → chunk-BJE3425I.js.map} +1 -1
  15. package/dist/{chunk-5A6X2Y73.js → chunk-CZD3AD4Q.js} +12 -11
  16. package/dist/chunk-CZD3AD4Q.js.map +1 -0
  17. package/dist/{chunk-EYXVAMEX.js → chunk-MN3TJ3D5.js} +72 -3
  18. package/dist/chunk-MN3TJ3D5.js.map +1 -0
  19. package/dist/chunk-QCN35LJU.js +630 -0
  20. package/dist/chunk-QCN35LJU.js.map +1 -0
  21. package/dist/chunk-T47OLCSF.js +36 -0
  22. package/dist/chunk-T47OLCSF.js.map +1 -0
  23. package/dist/{chunk-APTQIBS5.js → chunk-XJQ5BIWI.js} +144 -1049
  24. package/dist/chunk-XJQ5BIWI.js.map +1 -0
  25. package/dist/codebase-scanner-VOTPXRYW.js +22 -0
  26. package/dist/converter-JLINP7CJ.js +34 -0
  27. package/dist/converter-JLINP7CJ.js.map +1 -0
  28. package/dist/core/index.js +43 -1
  29. package/dist/{generate-RYWIPDN2.js → generate-A4FP5426.js} +3 -4
  30. package/dist/{generate-RYWIPDN2.js.map → generate-A4FP5426.js.map} +1 -1
  31. package/dist/govern-scan-UCBZR6D6.js +280 -0
  32. package/dist/govern-scan-UCBZR6D6.js.map +1 -0
  33. package/dist/index.d.ts +2 -1
  34. package/dist/index.js +11 -11
  35. package/dist/{init-WRUSW7R5.js → init-HGSM35XA.js} +131 -128
  36. package/dist/init-HGSM35XA.js.map +1 -0
  37. package/dist/{init-cloud-REQ3XLHO.js → init-cloud-MQ6GRJAZ.js} +2 -2
  38. package/dist/mcp-bin.js +5 -36
  39. package/dist/mcp-bin.js.map +1 -1
  40. package/dist/scan-VNNKACG2.js +15 -0
  41. package/dist/{scan-generate-TFZVL3BT.js → scan-generate-TWRHNU5M.js} +335 -46
  42. package/dist/scan-generate-TWRHNU5M.js.map +1 -0
  43. package/dist/scanner-7LAZYPWZ.js +13 -0
  44. package/dist/{service-HKJ6B7P7.js → service-FHQU7YS7.js} +27 -23
  45. package/dist/{snapshot-C5DYIGIV.js → snapshot-KQEQ6XHL.js} +2 -2
  46. package/dist/{static-viewer-DUVC4UIM.js → static-viewer-63PG6FWY.js} +3 -3
  47. package/dist/static-viewer-63PG6FWY.js.map +1 -0
  48. package/dist/{test-JW7JIDFG.js → test-UQYUCZIS.js} +4 -6
  49. package/dist/{test-JW7JIDFG.js.map → test-UQYUCZIS.js.map} +1 -1
  50. package/dist/{tokens-KE73G5JC.js → tokens-6GYKDV6U.js} +6 -5
  51. package/dist/{tokens-KE73G5JC.js.map → tokens-6GYKDV6U.js.map} +1 -1
  52. package/dist/tokens-generate-VTZV5EEW.js +86 -0
  53. package/dist/tokens-generate-VTZV5EEW.js.map +1 -0
  54. package/package.json +6 -6
  55. package/src/bin.ts +210 -48
  56. package/src/build.ts +130 -6
  57. package/src/commands/__fixtures__/shadcn-label-wrapper/package.json +7 -0
  58. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.contract.json +42 -0
  59. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.tsx +11 -0
  60. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.contract.json +20 -0
  61. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.tsx +14 -0
  62. package/src/commands/__fixtures__/shadcn-label-wrapper/tsconfig.app.json +23 -0
  63. package/src/commands/__tests__/init.test.ts +113 -0
  64. package/src/commands/__tests__/scan-generate.test.ts +188 -69
  65. package/src/commands/__tests__/verify.test.ts +91 -0
  66. package/src/commands/discover.ts +151 -0
  67. package/src/commands/enhance.ts +3 -1
  68. package/src/commands/govern-scan.ts +386 -0
  69. package/src/commands/govern.ts +2 -2
  70. package/src/commands/init.ts +152 -28
  71. package/src/commands/inspect.ts +290 -0
  72. package/src/commands/migrate-contract.ts +85 -0
  73. package/src/commands/scan-generate.ts +438 -50
  74. package/src/commands/scan.ts +1 -0
  75. package/src/commands/setup.ts +27 -50
  76. package/src/commands/tokens-generate.ts +113 -0
  77. package/src/commands/verify.ts +195 -1
  78. package/src/core/__fixtures__/shadcn-input/input.tsx +7 -0
  79. package/src/core/__fixtures__/shadcn-input/tsconfig.json +14 -0
  80. package/src/core/__fixtures__/shadcn-label/label.tsx +11 -0
  81. package/src/core/__fixtures__/shadcn-label/primitive.tsx +14 -0
  82. package/src/core/__fixtures__/shadcn-label/tsconfig.json +14 -0
  83. package/src/core/__fixtures__/shadcn-radix-label/label.tsx +11 -0
  84. package/src/core/__fixtures__/shadcn-radix-label/node_modules/radix-ui/index.d.ts +12 -0
  85. package/src/core/__fixtures__/shadcn-radix-label/tsconfig.json +14 -0
  86. package/src/core/__tests__/contract-parity.test.ts +316 -0
  87. package/src/core/component-extractor.test.ts +39 -0
  88. package/src/core/component-extractor.ts +92 -1
  89. package/src/core/config.ts +2 -1
  90. package/src/core/discovery.ts +13 -2
  91. package/src/core/drift-verifier.ts +123 -0
  92. package/src/core/extractor-adapter.ts +80 -0
  93. package/src/mcp/__tests__/projectFields.test.ts +1 -1
  94. package/src/mcp/utils.ts +1 -50
  95. package/src/migrate/converter.ts +3 -3
  96. package/src/migrate/fragment-to-contract.ts +253 -0
  97. package/src/migrate/report.ts +1 -1
  98. package/src/scripts/token-benchmark.ts +121 -0
  99. package/src/service/__tests__/props-extractor.test.ts +94 -0
  100. package/src/service/__tests__/token-normalizer.test.ts +690 -0
  101. package/src/service/ast-utils.ts +4 -23
  102. package/src/service/babel-config.ts +23 -0
  103. package/src/service/enhance/converter.ts +61 -0
  104. package/src/service/enhance/props-extractor.ts +25 -8
  105. package/src/service/enhance/scanner.ts +5 -24
  106. package/src/service/snippet-validation.ts +9 -3
  107. package/src/service/token-normalizer.ts +510 -0
  108. package/src/shared/index.ts +1 -0
  109. package/src/shared/project-fields.ts +46 -0
  110. package/src/viewer/__tests__/viewer-integration.test.ts +8 -8
  111. package/src/viewer/preview-adapter.ts +116 -0
  112. package/src/viewer/style-utils.ts +27 -412
  113. package/src/viewer/vite-plugin.ts +2 -2
  114. package/dist/chunk-55KERLWL.js.map +0 -1
  115. package/dist/chunk-5A6X2Y73.js.map +0 -1
  116. package/dist/chunk-APTQIBS5.js.map +0 -1
  117. package/dist/chunk-EYXVAMEX.js.map +0 -1
  118. package/dist/chunk-I34BC3CU.js.map +0 -1
  119. package/dist/chunk-LOYS64QS.js.map +0 -1
  120. package/dist/chunk-ZKTFKHWN.js +0 -324
  121. package/dist/chunk-ZKTFKHWN.js.map +0 -1
  122. package/dist/discovery-VDANZAJ2.js +0 -28
  123. package/dist/init-WRUSW7R5.js.map +0 -1
  124. package/dist/scan-YJHQIRKG.js +0 -14
  125. package/dist/scan-generate-TFZVL3BT.js.map +0 -1
  126. package/dist/viewer-2TZS3NDL.js +0 -2730
  127. package/dist/viewer-2TZS3NDL.js.map +0 -1
  128. package/src/commands/dev.ts +0 -107
  129. /package/dist/{chunk-TXFCEDOC.js.map → chunk-2WXKALIG.js.map} +0 -0
  130. /package/dist/{discovery-VDANZAJ2.js.map → codebase-scanner-VOTPXRYW.js.map} +0 -0
  131. /package/dist/{init-cloud-REQ3XLHO.js.map → init-cloud-MQ6GRJAZ.js.map} +0 -0
  132. /package/dist/{scan-YJHQIRKG.js.map → scan-VNNKACG2.js.map} +0 -0
  133. /package/dist/{service-HKJ6B7P7.js.map → scanner-7LAZYPWZ.js.map} +0 -0
  134. /package/dist/{static-viewer-DUVC4UIM.js.map → service-FHQU7YS7.js.map} +0 -0
  135. /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
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { projectFields } from "../utils.js";
2
+ import { projectFields } from "../../shared/project-fields.js";
3
3
 
4
4
  describe("projectFields", () => {
5
5
  describe("basic field extraction", () => {
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';
@@ -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)$/, ".fragment.tsx");
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)$/, ".fragment.tsx");
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
+ }
@@ -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 dev</code> to view your new design system documentation</p>
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", () => {