@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.
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
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { readFile, writeFile, access, mkdir } from "node:fs/promises";
15
15
  import { resolve, basename, dirname, relative, join } from "node:path";
16
+ import { execSync } from "node:child_process";
16
17
  import * as ts from "typescript";
17
18
  import pc from "picocolors";
18
19
  import { BRAND } from "../core/index.js";
@@ -100,6 +101,177 @@ export interface EnrichmentResult {
100
101
  tags: string[];
101
102
  }
102
103
 
104
+ // ---------------------------------------------------------------------------
105
+ // Post-generation helpers
106
+ // ---------------------------------------------------------------------------
107
+
108
+ type PackageManager = "pnpm" | "yarn" | "npm";
109
+
110
+ interface CompoundPartReference {
111
+ partName: string;
112
+ tagName: string;
113
+ importName?: string;
114
+ }
115
+
116
+ interface PackageManagerDetection {
117
+ manager: PackageManager;
118
+ lockfileDir: string | null;
119
+ }
120
+
121
+ async function findNearestAncestor(scanPath: string, targetFile: string): Promise<string | null> {
122
+ let dir = resolve(scanPath);
123
+ for (let i = 0; i < 20; i++) {
124
+ try {
125
+ await access(join(dir, targetFile));
126
+ return dir;
127
+ } catch {
128
+ const parent = dirname(dir);
129
+ if (parent === dir) break;
130
+ dir = parent;
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+
136
+ async function findNearestPackageJsonDir(scanPath: string): Promise<string | null> {
137
+ return findNearestAncestor(scanPath, "package.json");
138
+ }
139
+
140
+ export async function detectPackageManagerForProject(projectDir: string): Promise<PackageManagerDetection> {
141
+ let dir = resolve(projectDir);
142
+
143
+ for (let i = 0; i < 20; i++) {
144
+ for (const [fileName, manager] of [
145
+ ["pnpm-lock.yaml", "pnpm"],
146
+ ["pnpm-workspace.yaml", "pnpm"],
147
+ ["yarn.lock", "yarn"],
148
+ ["package-lock.json", "npm"],
149
+ ] as const) {
150
+ try {
151
+ await access(join(dir, fileName));
152
+ return { manager, lockfileDir: dir };
153
+ } catch {
154
+ // continue
155
+ }
156
+ }
157
+
158
+ const parent = dirname(dir);
159
+ if (parent === dir) break;
160
+ dir = parent;
161
+ }
162
+
163
+ return { manager: "npm", lockfileDir: null };
164
+ }
165
+
166
+ export function resolveCoreInstallCommand(manager: PackageManager): string {
167
+ switch (manager) {
168
+ case "pnpm":
169
+ return "pnpm add -D @fragments-sdk/core";
170
+ case "yarn":
171
+ return "yarn add -D @fragments-sdk/core";
172
+ default:
173
+ return "npm install -D @fragments-sdk/core";
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Ensure `@fragments-sdk/core` is in the project's devDependencies.
179
+ * Detects package manager from lockfiles and runs install if needed.
180
+ */
181
+ export async function ensureCoreDependency(scanPath: string): Promise<void> {
182
+ const projectDir = await findNearestPackageJsonDir(scanPath);
183
+ if (!projectDir) return;
184
+ const pkgJsonPath = join(projectDir, "package.json");
185
+
186
+ try {
187
+ const pkgRaw = await readFile(pkgJsonPath, "utf-8");
188
+ const pkg = JSON.parse(pkgRaw);
189
+ const deps = pkg.dependencies || {};
190
+ const devDeps = pkg.devDependencies || {};
191
+
192
+ if (deps["@fragments-sdk/core"] || devDeps["@fragments-sdk/core"]) {
193
+ console.log(pc.dim(" · @fragments-sdk/core already installed"));
194
+ return;
195
+ }
196
+
197
+ const { manager } = await detectPackageManagerForProject(projectDir);
198
+ const cmd = resolveCoreInstallCommand(manager);
199
+
200
+ execSync(cmd, { cwd: projectDir, stdio: "ignore" });
201
+ console.log(pc.green(" ✓ Installed @fragments-sdk/core"));
202
+ } catch (e) {
203
+ console.log(
204
+ pc.yellow(
205
+ ` ⚠ Could not auto-install @fragments-sdk/core: ${e instanceof Error ? e.message : String(e)}`
206
+ )
207
+ );
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Add `**\/*.fragment.tsx` to the exclude array of the project's tsconfig
213
+ * so generated fragment files don't break the host app's TypeScript build.
214
+ */
215
+ export async function excludeFragmentsFromTsconfig(scanPath: string): Promise<void> {
216
+ const projectDir = await findNearestPackageJsonDir(scanPath);
217
+ if (!projectDir) return;
218
+
219
+ // Prefer tsconfig.app.json (Vite projects), fall back to tsconfig.json
220
+ let tsconfigPath: string | null = null;
221
+ let tsconfigName = "";
222
+ for (const name of ["tsconfig.app.json", "tsconfig.json"]) {
223
+ const candidate = join(projectDir, name);
224
+ try {
225
+ await access(candidate);
226
+ tsconfigPath = candidate;
227
+ tsconfigName = name;
228
+ break;
229
+ } catch {
230
+ // try next
231
+ }
232
+ }
233
+
234
+ if (!tsconfigPath) return;
235
+
236
+ try {
237
+ const raw = await readFile(tsconfigPath, "utf-8");
238
+ const parsed = ts.parseConfigFileTextToJson(tsconfigPath, raw);
239
+ if (parsed.error || !parsed.config) {
240
+ throw new Error(parsed.error?.messageText?.toString() ?? "Unable to parse tsconfig");
241
+ }
242
+ const config = parsed.config as Record<string, unknown>;
243
+
244
+ const exclude = Array.isArray(config.exclude) ? [...(config.exclude as string[])] : [];
245
+ const ext = BRAND.fileExtension;
246
+ const alreadyExcluded = exclude.some(
247
+ (pattern: string) =>
248
+ pattern.includes(`*${ext}`) || pattern.includes("*.fragment.*") || pattern.includes("*.contract.*")
249
+ );
250
+
251
+ if (alreadyExcluded) {
252
+ console.log(
253
+ pc.dim(` · ${ext} already excluded in ${tsconfigName}`)
254
+ );
255
+ return;
256
+ }
257
+
258
+ exclude.push(`**/*${ext}`);
259
+ config.exclude = exclude;
260
+ const updated = `${JSON.stringify(config, null, 2)}\n`;
261
+
262
+ await writeFile(tsconfigPath, updated, "utf-8");
263
+ console.log(
264
+ pc.green(` ✓ Added ${ext} exclusion to ${tsconfigName}`)
265
+ );
266
+ } catch (e) {
267
+ console.log(
268
+ pc.yellow(
269
+ ` ⚠ Could not update ${tsconfigName}: ${e instanceof Error ? e.message : String(e)}`
270
+ )
271
+ );
272
+ }
273
+ }
274
+
103
275
  // ---------------------------------------------------------------------------
104
276
  // Main orchestrator
105
277
  // ---------------------------------------------------------------------------
@@ -302,14 +474,27 @@ export async function scanGenerate(
302
474
  componentBaseName
303
475
  );
304
476
 
305
- // Generate the fragment file
306
- const content = generateFragmentWithTodos(
307
- comp.name,
308
- importPath,
309
- data,
310
- confidence,
311
- enrichment
312
- );
477
+ // Generate the fragment file — contract.json or legacy TSX
478
+ let content: string;
479
+ if (fragmentPath.endsWith('.contract.json')) {
480
+ content = generateContractJsonFromScan(
481
+ comp.name,
482
+ componentBaseName,
483
+ data,
484
+ confidence,
485
+ comp.sourcePath,
486
+ scanPath,
487
+ enrichment,
488
+ );
489
+ } else {
490
+ content = generateFragmentWithTodos(
491
+ comp.name,
492
+ importPath,
493
+ data,
494
+ confidence,
495
+ enrichment
496
+ );
497
+ }
313
498
 
314
499
  await writeFile(fragmentPath, content, "utf-8");
315
500
 
@@ -343,6 +528,13 @@ export async function scanGenerate(
343
528
  }
344
529
  }
345
530
 
531
+ // Post-generation setup: ensure @fragments-sdk/core is installed and
532
+ // exclude .fragment.tsx files from the host project's tsconfig
533
+ if (generated.length > 0) {
534
+ await ensureCoreDependency(scanPath);
535
+ await excludeFragmentsFromTsconfig(scanPath);
536
+ }
537
+
346
538
  // Summary
347
539
  const avgConfidence =
348
540
  generated.length > 0
@@ -754,12 +946,12 @@ export function buildEnrichmentUserPrompt(
754
946
  data: ComponentData
755
947
  ): string {
756
948
  const props = data.meta?.props ?? {};
757
- const localProps = Object.entries(props).filter(([_, p]) => p.source === 'local');
949
+ const fragmentProps = getFragmentPropEntries(props);
758
950
  const composition = data.meta?.composition ?? null;
759
951
  const description = data.meta?.description || '';
760
952
  const category = inferCategoryFromMeta(
761
953
  name,
762
- Object.fromEntries(localProps)
954
+ Object.fromEntries(fragmentProps)
763
955
  );
764
956
 
765
957
  const lines: string[] = [
@@ -771,10 +963,10 @@ export function buildEnrichmentUserPrompt(
771
963
  lines.push(`Description: ${description}`);
772
964
  }
773
965
 
774
- if (localProps.length > 0) {
966
+ if (fragmentProps.length > 0) {
775
967
  lines.push('');
776
968
  lines.push('Props:');
777
- for (const [propName, prop] of localProps) {
969
+ for (const [propName, prop] of fragmentProps) {
778
970
  let propLine = ` - ${propName}: ${prop.typeKind}`;
779
971
  if (prop.values && prop.values.length > 0) {
780
972
  propLine += ` (${prop.values.join(' | ')})`;
@@ -962,10 +1154,8 @@ export function calculateFieldConfidence(
962
1154
  const todoFields: string[] = [];
963
1155
 
964
1156
  const props = data.meta?.props ?? {};
965
- // Use only local props — inherited HTML/React props inflate confidence
966
- // without adding value to the generated fragment output
967
- const localEntries = Object.values(props).filter(p => p.source === 'local');
968
- const hasProps = localEntries.length > 0;
1157
+ const fragmentPropEntries = getFragmentPropEntries(props);
1158
+ const hasProps = fragmentPropEntries.length > 0;
969
1159
 
970
1160
  // Props extracted: +30
971
1161
  if (hasProps) {
@@ -980,10 +1170,8 @@ export function calculateFieldConfidence(
980
1170
  }
981
1171
 
982
1172
  // Category inferred from path/name (not fallback): +10
983
- const localProps = Object.fromEntries(
984
- Object.entries(props).filter(([_, p]) => p.source === 'local')
985
- );
986
- const category = inferCategoryFromMeta(data.component.name, localProps);
1173
+ const fragmentProps = Object.fromEntries(fragmentPropEntries);
1174
+ const category = inferCategoryFromMeta(data.component.name, fragmentProps);
987
1175
  if (category !== "Components") {
988
1176
  score += 10;
989
1177
  } else {
@@ -997,7 +1185,7 @@ export function calculateFieldConfidence(
997
1185
 
998
1186
  // All prop types resolved (no "custom"): +10
999
1187
  if (hasProps) {
1000
- const allResolved = localEntries.every((p) => p.typeKind !== "custom");
1188
+ const allResolved = fragmentPropEntries.every(([_, p]) => p.typeKind !== "custom");
1001
1189
  if (allResolved) {
1002
1190
  score += 10;
1003
1191
  }
@@ -1010,7 +1198,7 @@ export function calculateFieldConfidence(
1010
1198
 
1011
1199
  // Has default values: +5
1012
1200
  if (hasProps) {
1013
- const hasDefaults = localEntries.some((p) => p.default !== undefined);
1201
+ const hasDefaults = fragmentPropEntries.some(([_, p]) => p.default !== undefined);
1014
1202
  if (hasDefaults) {
1015
1203
  score += 5;
1016
1204
  }
@@ -1179,21 +1367,70 @@ interface ContractBlock {
1179
1367
  scenarioTags?: string[];
1180
1368
  }
1181
1369
 
1370
+ const INHERITED_PROP_PRIORITY = [
1371
+ "htmlFor",
1372
+ "type",
1373
+ "value",
1374
+ "defaultValue",
1375
+ "checked",
1376
+ "defaultChecked",
1377
+ "disabled",
1378
+ "required",
1379
+ "placeholder",
1380
+ "name",
1381
+ "id",
1382
+ "form",
1383
+ "accept",
1384
+ "multiple",
1385
+ "min",
1386
+ "max",
1387
+ "step",
1388
+ "pattern",
1389
+ "role",
1390
+ "children",
1391
+ ] as const;
1392
+
1393
+ function getFragmentPropEntries(props: Record<string, PropMeta>): Array<[string, PropMeta]> {
1394
+ const entries = Object.entries(props);
1395
+ const localEntries = entries.filter(([_, prop]) => prop.source === "local");
1396
+ if (localEntries.length > 0) {
1397
+ return localEntries;
1398
+ }
1399
+
1400
+ const prioritized = new Set(INHERITED_PROP_PRIORITY);
1401
+ const inheritedEntries = entries
1402
+ .filter(([name, prop]) => {
1403
+ if (prop.source !== "inherited") return false;
1404
+ if (name === "key" || name === "ref" || name === "className" || name === "style") return false;
1405
+ if (name.startsWith("on")) return false;
1406
+ if (name.startsWith("aria-") || name.startsWith("data-")) return false;
1407
+ return prioritized.has(name as typeof INHERITED_PROP_PRIORITY[number]);
1408
+ })
1409
+ .sort(([nameA], [nameB]) => {
1410
+ const rankA = INHERITED_PROP_PRIORITY.indexOf(nameA as typeof INHERITED_PROP_PRIORITY[number]);
1411
+ const rankB = INHERITED_PROP_PRIORITY.indexOf(nameB as typeof INHERITED_PROP_PRIORITY[number]);
1412
+ return rankA - rankB;
1413
+ });
1414
+
1415
+ return inheritedEntries.slice(0, 8);
1416
+ }
1417
+
1182
1418
  function buildContractBlock(
1183
1419
  componentName: string,
1184
1420
  props: Record<string, PropMeta>,
1185
1421
  composition: CompositionMeta | null,
1186
- accessibility: { role?: string; requirements?: string[] }
1422
+ accessibility: { role?: string; requirements?: string[] },
1423
+ compoundPartRefs: CompoundPartReference[] = []
1187
1424
  ): ContractBlock | null {
1188
- const localEntries = Object.entries(props).filter(([_, p]) => p.source === 'local');
1189
- if (localEntries.length === 0 && !composition && !accessibility.requirements?.length) {
1425
+ const relevantEntries = getFragmentPropEntries(props);
1426
+ if (relevantEntries.length === 0 && !composition && !accessibility.requirements?.length) {
1190
1427
  return null;
1191
1428
  }
1192
1429
 
1193
1430
  const contract: ContractBlock = { propsSummary: [] };
1194
1431
 
1195
1432
  // propsSummary: "propName: value1 | value2 | value3" for enums, "propName: type" otherwise
1196
- for (const [name, prop] of localEntries) {
1433
+ for (const [name, prop] of relevantEntries) {
1197
1434
  let summary = name + ': ';
1198
1435
  if (prop.typeKind === 'enum' && prop.values && prop.values.length > 0) {
1199
1436
  summary += prop.values.join(' | ');
@@ -1227,8 +1464,12 @@ function buildContractBlock(
1227
1464
 
1228
1465
  // canonicalUsage: generate 1 JSX snippet using actual component + sub-component names
1229
1466
  if (composition && composition.parts.length > 0) {
1230
- const innerLines = composition.parts
1231
- .map(part => ` <${componentName}.${part.name}>...</${componentName}.${part.name}>`)
1467
+ const resolvedParts = compoundPartRefs.length > 0
1468
+ ? compoundPartRefs
1469
+ : composition.parts.map((part) => ({ partName: part.name, tagName: `${componentName}.${part.name}` }));
1470
+
1471
+ const innerLines = resolvedParts
1472
+ .map((part) => ` <${part.tagName}>...</${part.tagName}>`)
1232
1473
  .join('\n');
1233
1474
  contract.canonicalUsage = [
1234
1475
  `<${componentName}>\n${innerLines}\n</${componentName}>`,
@@ -1346,6 +1587,30 @@ function escapeQuotes(str: string): string {
1346
1587
  return str.replace(/'/g, "\\'");
1347
1588
  }
1348
1589
 
1590
+ function resolveCompoundPartReferences(
1591
+ componentName: string,
1592
+ composition: CompositionMeta | null,
1593
+ exportNames: string[]
1594
+ ): CompoundPartReference[] {
1595
+ if (!composition || composition.parts.length === 0) return [];
1596
+
1597
+ return composition.parts.map((part) => {
1598
+ const directExportName = `${componentName}${part.name}`;
1599
+ if (exportNames.includes(directExportName)) {
1600
+ return {
1601
+ partName: part.name,
1602
+ tagName: directExportName,
1603
+ importName: directExportName,
1604
+ };
1605
+ }
1606
+
1607
+ return {
1608
+ partName: part.name,
1609
+ tagName: `${componentName}.${part.name}`,
1610
+ };
1611
+ });
1612
+ }
1613
+
1349
1614
  function generateFragmentWithTodos(
1350
1615
  componentName: string,
1351
1616
  importPath: string,
@@ -1354,26 +1619,38 @@ function generateFragmentWithTodos(
1354
1619
  enrichment?: EnrichmentResult
1355
1620
  ): string {
1356
1621
  const props = data.meta?.props ?? {};
1357
- // Use only local props for inference — inherited HTML/React props cause
1358
- // wrong descriptions (e.g., Card getting "Form card for user input" from
1359
- // inherited defaultValue) and wrong roles (e.g., Card getting role: 'button'
1360
- // from inherited onClick)
1361
1622
  const localProps = Object.fromEntries(
1362
1623
  Object.entries(props).filter(([_, p]) => p.source === 'local')
1363
1624
  );
1625
+ const fragmentProps = Object.fromEntries(getFragmentPropEntries(props));
1626
+ const inferenceProps = Object.keys(localProps).length > 0 ? localProps : fragmentProps;
1364
1627
  const composition = data.meta?.composition ?? null;
1365
- const description = data.meta?.description || inferDescriptionFromMeta(componentName, localProps);
1628
+ const description = data.meta?.description || inferDescriptionFromMeta(componentName, inferenceProps);
1366
1629
  const descriptionTodo = data.meta?.description ? "" : " // TODO: Review description";
1367
- const category = inferCategoryFromMeta(componentName, localProps);
1630
+ const category = inferCategoryFromMeta(componentName, inferenceProps);
1368
1631
  const categoryTodo = category === "Components" ? " // TODO: Set correct category" : "";
1369
1632
  const status = inferStatus(data.component.sourcePath);
1370
- const accessibility = inferAccessibilityFromMeta(localProps);
1633
+ const accessibility = inferAccessibilityFromMeta(inferenceProps);
1634
+ const exportNames = data.meta?.exports ?? [];
1635
+ const compoundPartRefs = resolveCompoundPartReferences(componentName, composition, exportNames);
1636
+ const importNames = Array.from(
1637
+ new Set([
1638
+ componentName,
1639
+ ...compoundPartRefs.flatMap((part) => (part.importName ? [part.importName] : [])),
1640
+ ])
1641
+ );
1371
1642
 
1372
1643
  // Build props block from PropMeta
1373
1644
  const propsBlock = buildPropsBlockFromMeta(props);
1374
1645
 
1375
1646
  // Build contract block — enrichment can replace/augment a11yRules and add scenarioTags
1376
- const contract = buildContractBlock(componentName, props, composition, accessibility);
1647
+ const contract = buildContractBlock(
1648
+ componentName,
1649
+ props,
1650
+ composition,
1651
+ accessibility,
1652
+ compoundPartRefs
1653
+ );
1377
1654
  if (contract && enrichment) {
1378
1655
  if (enrichment.a11yRules.length > 0) {
1379
1656
  contract.a11yRules = enrichment.a11yRules;
@@ -1408,11 +1685,12 @@ function generateFragmentWithTodos(
1408
1685
  const variantsBlock = buildVariantsBlock(
1409
1686
  componentName,
1410
1687
  data.storyVariants,
1411
- composition
1688
+ composition,
1689
+ compoundPartRefs
1412
1690
  );
1413
1691
 
1414
1692
  // Build AI metadata block with composition info
1415
- const aiBlock = buildAIBlock(composition);
1693
+ const aiBlock = buildAIBlock(composition, compoundPartRefs);
1416
1694
 
1417
1695
  // Build provenance block
1418
1696
  const provenanceBlock = buildProvenanceBlock(confidence, props);
@@ -1424,9 +1702,8 @@ function generateFragmentWithTodos(
1424
1702
 
1425
1703
  return `// Auto-generated by fragments init --scan | Confidence: ${confidence.score}/100
1426
1704
  // ${confidence.todoFields.length} TODO(s) — search for "TODO:" and fill in human knowledge${compoundComment}
1427
- import React from 'react';
1428
1705
  import { defineFragment } from '@fragments-sdk/core';
1429
- import { ${componentName} } from '${importPath}';
1706
+ import { ${importNames.join(", ")} } from '${importPath}';
1430
1707
 
1431
1708
  export default defineFragment({
1432
1709
  component: ${componentName},
@@ -1450,7 +1727,7 @@ ${provenanceBlock}});
1450
1727
  }
1451
1728
 
1452
1729
  function buildPropsBlockFromMeta(props: Record<string, PropMeta>): string {
1453
- const entries = Object.entries(props).filter(([_, p]) => p.source === 'local');
1730
+ const entries = getFragmentPropEntries(props);
1454
1731
  if (entries.length === 0) return "{}";
1455
1732
 
1456
1733
  const lines = entries.map(([name, prop]) => {
@@ -1485,7 +1762,8 @@ function buildPropsBlockFromMeta(props: Record<string, PropMeta>): string {
1485
1762
  function buildVariantsBlock(
1486
1763
  componentName: string,
1487
1764
  storyVariants: StoryVariant[],
1488
- composition?: CompositionMeta | null
1765
+ composition?: CompositionMeta | null,
1766
+ compoundPartRefs: CompoundPartReference[] = []
1489
1767
  ): string {
1490
1768
  const entries: string[] = [];
1491
1769
 
@@ -1494,7 +1772,7 @@ function buildVariantsBlock(
1494
1772
  if (!hasDefault) {
1495
1773
  if (composition && composition.pattern === 'compound' && composition.parts.length > 0) {
1496
1774
  // Generate a compound-aware default variant
1497
- entries.push(formatCompoundVariantEntry(componentName, composition));
1775
+ entries.push(formatCompoundVariantEntry(componentName, composition, compoundPartRefs));
1498
1776
  } else {
1499
1777
  entries.push(formatVariantEntry(componentName, "Default", `Default ${componentName}`, {}));
1500
1778
  }
@@ -1535,11 +1813,15 @@ function formatVariantEntry(
1535
1813
  */
1536
1814
  function formatCompoundVariantEntry(
1537
1815
  componentName: string,
1538
- composition: CompositionMeta
1816
+ composition: CompositionMeta,
1817
+ compoundPartRefs: CompoundPartReference[] = []
1539
1818
  ): string {
1540
- const parts = composition.parts;
1541
- const innerJsx = parts
1542
- .map((part) => ` <${componentName}.${part.name}>...</${componentName}.${part.name}>`)
1819
+ const resolvedParts = compoundPartRefs.length > 0
1820
+ ? compoundPartRefs
1821
+ : composition.parts.map((part) => ({ partName: part.name, tagName: `${componentName}.${part.name}` }));
1822
+
1823
+ const innerJsx = resolvedParts
1824
+ .map((part) => ` <${part.tagName}>...</${part.tagName}>`)
1543
1825
  .join('\n');
1544
1826
 
1545
1827
  const jsxCode = `<${componentName}>\n${innerJsx}\n </${componentName}>`;
@@ -1555,11 +1837,24 @@ function formatCompoundVariantEntry(
1555
1837
  /**
1556
1838
  * Build the ai block with composition metadata.
1557
1839
  */
1558
- function buildAIBlock(composition: CompositionMeta | null): string {
1840
+ function buildAIBlock(
1841
+ composition: CompositionMeta | null,
1842
+ compoundPartRefs: CompoundPartReference[] = []
1843
+ ): string {
1559
1844
  if (!composition || composition.parts.length === 0) return "";
1560
1845
 
1561
- const subComponents = composition.parts.map((p) => `'${p.name}'`).join(', ');
1562
- const pattern = `\n <Component>\n${composition.parts.map((p) => ` <Component.${p.name}>...</Component.${p.name}>`).join('\n')}\n </Component>`;
1846
+ const resolvedParts = compoundPartRefs.length > 0
1847
+ ? compoundPartRefs
1848
+ : composition.parts.map<CompoundPartReference>((part) => ({
1849
+ partName: part.name,
1850
+ tagName: `Component.${part.name}`,
1851
+ }));
1852
+ const subComponents = resolvedParts
1853
+ .map((part) => `'${part.importName ?? part.partName}'`)
1854
+ .join(', ');
1855
+ const pattern = `\n <Component>\n${resolvedParts
1856
+ .map((part) => ` <${part.tagName}>...</${part.tagName}>`)
1857
+ .join('\n')}\n </Component>`;
1563
1858
 
1564
1859
  return `
1565
1860
 
@@ -1612,3 +1907,96 @@ function buildJsxString(
1612
1907
 
1613
1908
  return `<${componentName}${propsStr} />`;
1614
1909
  }
1910
+
1911
+ // ---------------------------------------------------------------------------
1912
+ // Contract JSON generator for init --scan
1913
+ // ---------------------------------------------------------------------------
1914
+
1915
+ function generateContractJsonFromScan(
1916
+ componentName: string,
1917
+ _baseName: string,
1918
+ data: ComponentData,
1919
+ confidence: FieldConfidence,
1920
+ sourcePath: string,
1921
+ scanPath: string,
1922
+ enrichment?: EnrichmentResult,
1923
+ ): string {
1924
+ const props = data.meta?.props ?? {};
1925
+ const localProps = Object.fromEntries(
1926
+ Object.entries(props).filter(([, p]) => p.source === 'local')
1927
+ );
1928
+ // Fall back to all props if no local props (wrapper components like shadcn Label)
1929
+ const effectiveProps = Object.keys(localProps).length > 0 ? localProps : props;
1930
+ const composition = data.meta?.composition ?? null;
1931
+ const description = data.meta?.description || `${componentName} component`;
1932
+ const category = inferCategoryFromMeta(componentName, effectiveProps);
1933
+
1934
+ // Build props schema
1935
+ const propsSchema: Record<string, unknown> = {};
1936
+ for (const [name, prop] of Object.entries(effectiveProps)) {
1937
+ propsSchema[name] = {
1938
+ type: prop.typeKind,
1939
+ description: prop.description ?? '',
1940
+ ...(prop.values?.length && { values: prop.values }),
1941
+ ...(prop.default !== undefined && { default: prop.default }),
1942
+ ...(prop.required && { required: true }),
1943
+ };
1944
+ }
1945
+
1946
+ // Build propsSummary
1947
+ const propsSummary = Object.entries(effectiveProps).map(([name, prop]) => {
1948
+ let summary = `${name}: `;
1949
+ if (prop.values?.length) {
1950
+ summary += prop.values.join('|');
1951
+ } else {
1952
+ summary += prop.typeKind;
1953
+ }
1954
+ if (prop.default !== undefined) summary += ` (default: ${prop.default})`;
1955
+ if (prop.required) summary += ' (required)';
1956
+ return summary;
1957
+ });
1958
+
1959
+ // Build AI metadata
1960
+ let ai: Record<string, unknown> | undefined;
1961
+ if (composition) {
1962
+ ai = {
1963
+ compositionPattern: composition.pattern,
1964
+ subComponents: composition.parts.map(p => p.name),
1965
+ ...(composition.required.length > 0 && { requiredChildren: composition.required }),
1966
+ };
1967
+ }
1968
+
1969
+ // Resolve sourcePath relative to scanPath
1970
+ const relativeSourcePath = relative(scanPath, sourcePath);
1971
+
1972
+ // Use enrichment if available
1973
+ const usage = enrichment ? {
1974
+ when: enrichment.when ?? [],
1975
+ whenNot: enrichment.whenNot ?? [],
1976
+ ...(enrichment.guidelines?.length && { guidelines: enrichment.guidelines }),
1977
+ } : {
1978
+ when: [],
1979
+ whenNot: [],
1980
+ };
1981
+
1982
+ const contract = {
1983
+ $schema: 'https://usefragments.com/schemas/contract.v1.json',
1984
+ name: componentName,
1985
+ description,
1986
+ category,
1987
+ sourcePath: relativeSourcePath,
1988
+ exportName: componentName,
1989
+ propsSummary,
1990
+ props: propsSchema,
1991
+ usage,
1992
+ ...(ai && { ai }),
1993
+ provenance: {
1994
+ source: 'extracted' as const,
1995
+ verified: confidence.score >= 70,
1996
+ frameworkSupport: 'native' as const,
1997
+ extractedAt: new Date().toISOString(),
1998
+ },
1999
+ };
2000
+
2001
+ return JSON.stringify(contract, null, 2) + '\n';
2002
+ }
@@ -146,6 +146,7 @@ export async function scan(options: ScanOptions = {}): Promise<ScanResult> {
146
146
  try {
147
147
  const extraction = await extractPropsFromFile(comp.sourcePath, {
148
148
  propsTypeName: `${comp.name}Props`,
149
+ componentName: comp.name,
149
150
  });
150
151
 
151
152
  propsResults.set(comp.name, extraction);