@fragments-sdk/cli 0.9.1 → 0.10.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 (106) hide show
  1. package/dist/bin.d.ts +1 -0
  2. package/dist/bin.js +435 -67
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-BW3ZATBW.js → chunk-566BNPQZ.js} +3 -5
  5. package/dist/chunk-566BNPQZ.js.map +1 -0
  6. package/dist/{chunk-5GT62FCB.js → chunk-CAMXG5HJ.js} +5 -5
  7. package/dist/chunk-D2CDBRNU.js +2 -0
  8. package/dist/{chunk-YMPGYEWK.js → chunk-D5PYOXEI.js} +2 -2
  9. package/dist/{chunk-GF6OVPIN.js → chunk-OQO55NKV.js} +405 -34
  10. package/dist/chunk-OQO55NKV.js.map +1 -0
  11. package/dist/{chunk-TOIE7VXF.js → chunk-PW7QTQA6.js} +2 -2
  12. package/dist/{chunk-AWYCDRPG.js → chunk-WXSR2II7.js} +2 -2
  13. package/dist/chunk-WXSR2II7.js.map +1 -0
  14. package/dist/{chunk-D7372LQX.js → chunk-ZDA3PLQ6.js} +8 -12
  15. package/dist/chunk-ZDA3PLQ6.js.map +1 -0
  16. package/dist/core/index.d.ts +1 -2194
  17. package/dist/core/index.js +22 -27
  18. package/dist/{discovery-Z4RDDFVR.js → discovery-NEOY4MPN.js} +3 -3
  19. package/dist/{generate-LQA2R7FN.js → generate-BGKTKO6E.js} +5 -7
  20. package/dist/{generate-LQA2R7FN.js.map → generate-BGKTKO6E.js.map} +1 -1
  21. package/dist/index.d.ts +3 -5
  22. package/dist/index.js +7 -9
  23. package/dist/index.js.map +1 -1
  24. package/dist/{init-2GEGVIUQ.js → init-Q53R5Q2T.js} +58 -6
  25. package/dist/init-Q53R5Q2T.js.map +1 -0
  26. package/dist/mcp-bin.js +5 -8
  27. package/dist/mcp-bin.js.map +1 -1
  28. package/dist/scan-OQU7M4GH.js +14 -0
  29. package/dist/scan-generate-T5QNUG7N.js +691 -0
  30. package/dist/scan-generate-T5QNUG7N.js.map +1 -0
  31. package/dist/{service-XP2EAJXD.js → service-TQYWY65E.js} +4 -6
  32. package/dist/{static-viewer-XCS7UJTO.js → static-viewer-NUBFPKWH.js} +4 -6
  33. package/dist/{test-TD6TJNVY.js → test-2CSOSS3B.js} +4 -5
  34. package/dist/{test-TD6TJNVY.js.map → test-2CSOSS3B.js.map} +1 -1
  35. package/dist/{tokens-2EXPCVP3.js → tokens-DXEGYTOJ.js} +6 -8
  36. package/dist/{tokens-2EXPCVP3.js.map → tokens-DXEGYTOJ.js.map} +1 -1
  37. package/dist/{viewer-RFA2KVBG.js → viewer-DBEPYM3G.js} +16 -19
  38. package/dist/viewer-DBEPYM3G.js.map +1 -0
  39. package/package.json +2 -1
  40. package/src/bin.ts +33 -1
  41. package/src/build.ts +1 -1
  42. package/src/commands/__tests__/scan-generate.test.ts +308 -0
  43. package/src/commands/init.ts +72 -5
  44. package/src/commands/perf.ts +1 -1
  45. package/src/commands/scan-generate.ts +1013 -0
  46. package/src/commands/setup.ts +499 -0
  47. package/src/core/auto-props.ts +1 -1
  48. package/src/core/bundle-measurer.ts +2 -2
  49. package/src/core/config.ts +2 -3
  50. package/src/core/discovery.ts +2 -2
  51. package/src/core/generators/context.ts +1 -1
  52. package/src/core/generators/registry.ts +3 -3
  53. package/src/core/generators/typescript-extractor.ts +1 -1
  54. package/src/core/graph-extractor.ts +1 -1
  55. package/src/core/index.ts +3 -205
  56. package/src/core/loader.ts +2 -2
  57. package/src/core/parser.ts +1 -1
  58. package/src/core/previewLoader.ts +1 -1
  59. package/src/index.ts +2 -2
  60. package/src/service/snippet-validation.test.ts +1 -1
  61. package/src/service/snippet-validation.ts +2 -2
  62. package/src/viewer/__tests__/viewer-integration.test.ts +3 -9
  63. package/src/viewer/vendor/shared/src/VariantPreviewCard.module.scss +2 -10
  64. package/src/viewer/vite-plugin.ts +1 -1
  65. package/dist/chunk-AWYCDRPG.js.map +0 -1
  66. package/dist/chunk-BW3ZATBW.js.map +0 -1
  67. package/dist/chunk-D7372LQX.js.map +0 -1
  68. package/dist/chunk-EKLMXTWU.js +0 -80
  69. package/dist/chunk-EKLMXTWU.js.map +0 -1
  70. package/dist/chunk-EZYXYWNF.js +0 -131
  71. package/dist/chunk-EZYXYWNF.js.map +0 -1
  72. package/dist/chunk-GF6OVPIN.js.map +0 -1
  73. package/dist/chunk-NVSPGSKB.js +0 -203
  74. package/dist/chunk-NVSPGSKB.js.map +0 -1
  75. package/dist/defineFragment-CBMS7Bab.d.ts +0 -685
  76. package/dist/init-2GEGVIUQ.js.map +0 -1
  77. package/dist/scan-JGS65S7P.js +0 -16
  78. package/dist/storyFilters-3LUYAFZF.js +0 -15
  79. package/dist/viewer-RFA2KVBG.js.map +0 -1
  80. package/src/core/__tests__/preview-runtime.test.tsx +0 -111
  81. package/src/core/composition.test.ts +0 -262
  82. package/src/core/composition.ts +0 -318
  83. package/src/core/constants.ts +0 -114
  84. package/src/core/context.ts +0 -2
  85. package/src/core/defineFragment.ts +0 -141
  86. package/src/core/figma.ts +0 -263
  87. package/src/core/fragment-types.ts +0 -214
  88. package/src/core/performance-presets.ts +0 -142
  89. package/src/core/preview-runtime.tsx +0 -144
  90. package/src/core/schema.ts +0 -229
  91. package/src/core/storyAdapter.test.ts +0 -571
  92. package/src/core/storyAdapter.ts +0 -761
  93. package/src/core/storyFilters.test.ts +0 -350
  94. package/src/core/storyFilters.ts +0 -253
  95. package/src/core/storybook-csf.ts +0 -11
  96. package/src/core/token-parser.ts +0 -321
  97. package/src/core/token-types.ts +0 -287
  98. package/src/core/types.ts +0 -784
  99. /package/dist/{chunk-5GT62FCB.js.map → chunk-CAMXG5HJ.js.map} +0 -0
  100. /package/dist/{discovery-Z4RDDFVR.js.map → chunk-D2CDBRNU.js.map} +0 -0
  101. /package/dist/{chunk-YMPGYEWK.js.map → chunk-D5PYOXEI.js.map} +0 -0
  102. /package/dist/{chunk-TOIE7VXF.js.map → chunk-PW7QTQA6.js.map} +0 -0
  103. /package/dist/{scan-JGS65S7P.js.map → discovery-NEOY4MPN.js.map} +0 -0
  104. /package/dist/{service-XP2EAJXD.js.map → scan-OQU7M4GH.js.map} +0 -0
  105. /package/dist/{static-viewer-XCS7UJTO.js.map → service-TQYWY65E.js.map} +0 -0
  106. /package/dist/{storyFilters-3LUYAFZF.js.map → static-viewer-NUBFPKWH.js.map} +0 -0
@@ -0,0 +1,1013 @@
1
+ /**
2
+ * fragments init --scan - Generate fragment files from any TypeScript component library
3
+ *
4
+ * Phase 2 of the Universal GenUI strategy. Scans a component directory and
5
+ * generates .fragment.tsx files with TODO markers for uncertain fields.
6
+ *
7
+ * Combines:
8
+ * - Component discovery (core/discovery.ts)
9
+ * - Props extraction (service/enhance/props-extractor.ts)
10
+ * - JSDoc extraction (new: TypeScript AST)
11
+ * - Compound component detection (new: Object.assign pattern)
12
+ * - Confidence scoring with TODO markers
13
+ */
14
+
15
+ import { readFile, writeFile, access, mkdir } from "node:fs/promises";
16
+ import { resolve, basename, dirname, relative, join } from "node:path";
17
+ import * as ts from "typescript";
18
+ import pc from "picocolors";
19
+ import { BRAND } from "../core/index.js";
20
+ import {
21
+ discoverAllComponents,
22
+ type DiscoveredComponent,
23
+ } from "../core/node.js";
24
+ import {
25
+ extractPropsFromFile,
26
+ convertToFragmentProps,
27
+ type PropsExtractionResult,
28
+ type ExtractedProp,
29
+ } from "../service/index.js";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export interface ScanGenerateOptions {
36
+ /** Path to scan for components */
37
+ scanPath: string;
38
+ /** Output directory (default: colocated with source) */
39
+ outputDir?: string;
40
+ /** Overwrite existing fragment files */
41
+ force?: boolean;
42
+ /** Custom component file patterns */
43
+ patterns?: string[];
44
+ /** Skip Storybook story extraction */
45
+ skipStorybook?: boolean;
46
+ /** Verbose logging */
47
+ verbose?: boolean;
48
+ }
49
+
50
+ export interface ScanGenerateResult {
51
+ success: boolean;
52
+ generated: Array<{
53
+ name: string;
54
+ path: string;
55
+ confidence: number;
56
+ todoCount: number;
57
+ }>;
58
+ skipped: Array<{ name: string; reason: string }>;
59
+ errors: Array<{ name: string; error: string }>;
60
+ averageConfidence: number;
61
+ }
62
+
63
+ interface ComponentData {
64
+ component: DiscoveredComponent;
65
+ props: PropsExtractionResult | null;
66
+ jsDoc: string | null;
67
+ compoundChildren: string[];
68
+ storyVariants: StoryVariant[];
69
+ }
70
+
71
+ interface StoryVariant {
72
+ name: string;
73
+ args: Record<string, unknown>;
74
+ }
75
+
76
+ interface FieldConfidence {
77
+ score: number;
78
+ todoFields: string[];
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Main orchestrator
83
+ // ---------------------------------------------------------------------------
84
+
85
+ export async function scanGenerate(
86
+ options: ScanGenerateOptions
87
+ ): Promise<ScanGenerateResult> {
88
+ const scanPath = resolve(options.scanPath);
89
+ const generated: ScanGenerateResult["generated"] = [];
90
+ const skipped: ScanGenerateResult["skipped"] = [];
91
+ const errors: ScanGenerateResult["errors"] = [];
92
+
93
+ console.log(pc.cyan(`\n${BRAND.name} Scan → Generate\n`));
94
+ console.log(pc.dim(`Scanning: ${scanPath}\n`));
95
+
96
+ // Phase 1: Discover components
97
+ console.log(pc.dim("Phase 1: Discovering components..."));
98
+
99
+ // Use broader patterns than default — the scan path IS the component root,
100
+ // not a project root with src/components/ inside it
101
+ const defaultScanPatterns = [
102
+ "**/*.tsx",
103
+ "**/*.ts",
104
+ ];
105
+
106
+ const components = await discoverAllComponents(scanPath, {
107
+ patterns: options.patterns || defaultScanPatterns,
108
+ exclude: [
109
+ "**/*.test.*",
110
+ "**/*.spec.*",
111
+ "**/*.stories.*",
112
+ "**/*.fragment.*",
113
+ "**/*.d.ts",
114
+ "**/__tests__/**",
115
+ "**/__mocks__/**",
116
+ "**/node_modules/**",
117
+ "**/dist/**",
118
+ ],
119
+ });
120
+
121
+ if (components.length === 0) {
122
+ console.log(
123
+ pc.yellow("No components found. Check the path or file patterns.")
124
+ );
125
+ return {
126
+ success: false,
127
+ generated: [],
128
+ skipped: [],
129
+ errors: [{ name: "*", error: "No components found" }],
130
+ averageConfidence: 0,
131
+ };
132
+ }
133
+
134
+ console.log(pc.green(` Found ${components.length} components`));
135
+
136
+ // Phase 2: Extract data for each component
137
+ console.log(pc.dim("\nPhase 2: Extracting component metadata..."));
138
+
139
+ const componentDataList: ComponentData[] = [];
140
+
141
+ for (const comp of components) {
142
+ let propsResult: PropsExtractionResult | null = null;
143
+ try {
144
+ propsResult = await extractPropsFromFile(comp.sourcePath, {
145
+ propsTypeName: `${comp.name}Props`,
146
+ });
147
+ } catch {
148
+ // Props extraction can fail for complex types — continue gracefully
149
+ }
150
+
151
+ let jsDoc: string | null = null;
152
+ try {
153
+ jsDoc = await extractComponentJSDoc(comp.sourcePath, comp.name);
154
+ } catch {
155
+ // JSDoc extraction failure is non-fatal
156
+ }
157
+
158
+ let compoundChildren: string[] = [];
159
+ try {
160
+ compoundChildren = await detectCompoundComponents(comp.sourcePath);
161
+ } catch {
162
+ // Compound detection failure is non-fatal
163
+ }
164
+
165
+ let storyVariants: StoryVariant[] = [];
166
+ if (!options.skipStorybook && comp.storyPath) {
167
+ try {
168
+ storyVariants = await extractStoryVariantsFromFile(comp.storyPath);
169
+ } catch {
170
+ // Story extraction failure is non-fatal
171
+ }
172
+ }
173
+
174
+ componentDataList.push({
175
+ component: comp,
176
+ props: propsResult,
177
+ jsDoc,
178
+ compoundChildren,
179
+ storyVariants,
180
+ });
181
+ }
182
+
183
+ const propsExtracted = componentDataList.filter(
184
+ (d) => d.props?.success && d.props.props.length > 0
185
+ ).length;
186
+ console.log(pc.green(` Extracted props for ${propsExtracted} components`));
187
+
188
+ // Phase 3: Generate fragment files
189
+ console.log(pc.dim("\nPhase 3: Generating fragment files..."));
190
+
191
+ for (const data of componentDataList) {
192
+ const comp = data.component;
193
+ const componentDir = dirname(comp.sourcePath);
194
+ const componentBaseName = basename(comp.sourcePath, ".tsx");
195
+
196
+ // Determine output path
197
+ let fragmentDir: string;
198
+ if (options.outputDir) {
199
+ fragmentDir = resolve(options.outputDir, comp.name);
200
+ await mkdir(fragmentDir, { recursive: true });
201
+ } else {
202
+ fragmentDir = componentDir;
203
+ }
204
+
205
+ const fragmentPath = join(
206
+ fragmentDir,
207
+ `${componentBaseName}${BRAND.fileExtension}`
208
+ );
209
+
210
+ // Check if fragment already exists
211
+ let fragmentExists = false;
212
+ try {
213
+ await access(fragmentPath);
214
+ fragmentExists = true;
215
+ } catch {
216
+ // Doesn't exist
217
+ }
218
+
219
+ if (fragmentExists && !options.force) {
220
+ skipped.push({ name: comp.name, reason: "Fragment already exists" });
221
+ if (options.verbose) {
222
+ console.log(pc.dim(` Skipping ${comp.name} (fragment exists)`));
223
+ }
224
+ continue;
225
+ }
226
+
227
+ try {
228
+ // Calculate confidence
229
+ const confidence = calculateFieldConfidence(data);
230
+
231
+ // Compute import path
232
+ const importPath = computeImportPath(
233
+ fragmentDir,
234
+ comp.sourcePath,
235
+ componentBaseName
236
+ );
237
+
238
+ // Generate the fragment file
239
+ const content = generateFragmentWithTodos(
240
+ comp.name,
241
+ importPath,
242
+ data,
243
+ confidence
244
+ );
245
+
246
+ await writeFile(fragmentPath, content, "utf-8");
247
+
248
+ const relPath = relative(process.cwd(), fragmentPath);
249
+ generated.push({
250
+ name: comp.name,
251
+ path: relPath,
252
+ confidence: confidence.score,
253
+ todoCount: confidence.todoFields.length,
254
+ });
255
+
256
+ const confColor =
257
+ confidence.score >= 70
258
+ ? pc.green
259
+ : confidence.score >= 40
260
+ ? pc.yellow
261
+ : pc.red;
262
+ console.log(
263
+ pc.green(` ✓ ${comp.name}`) +
264
+ pc.dim(` (confidence: `) +
265
+ confColor(`${confidence.score}`) +
266
+ pc.dim(`, TODOs: ${confidence.todoFields.length})`)
267
+ );
268
+ } catch (e) {
269
+ errors.push({
270
+ name: comp.name,
271
+ error: e instanceof Error ? e.message : String(e),
272
+ });
273
+ console.log(pc.red(` ✗ ${comp.name}: ${e instanceof Error ? e.message : String(e)}`));
274
+ }
275
+ }
276
+
277
+ // Summary
278
+ const avgConfidence =
279
+ generated.length > 0
280
+ ? Math.round(
281
+ generated.reduce((sum, g) => sum + g.confidence, 0) /
282
+ generated.length
283
+ )
284
+ : 0;
285
+
286
+ console.log(pc.dim("\n────────────────────────────────────────"));
287
+ console.log(pc.green(`\n✓ Generated ${generated.length} fragment file(s)`));
288
+
289
+ if (skipped.length > 0) {
290
+ console.log(
291
+ pc.dim(` Skipped ${skipped.length} (use --force to overwrite)`)
292
+ );
293
+ }
294
+ if (errors.length > 0) {
295
+ console.log(pc.yellow(` ${errors.length} error(s)`));
296
+ }
297
+
298
+ console.log(pc.dim(` Average confidence: ${avgConfidence}/100`));
299
+
300
+ const totalTodos = generated.reduce((sum, g) => sum + g.todoCount, 0);
301
+ if (totalTodos > 0) {
302
+ console.log(
303
+ pc.dim(` Total TODOs: ${totalTodos}`) +
304
+ pc.dim(` — search for "TODO:" in generated files`)
305
+ );
306
+ }
307
+
308
+ console.log();
309
+
310
+ return {
311
+ success: errors.length === 0,
312
+ generated,
313
+ skipped,
314
+ errors,
315
+ averageConfidence: avgConfidence,
316
+ };
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // JSDoc Extraction
321
+ // ---------------------------------------------------------------------------
322
+
323
+ /**
324
+ * Extract the JSDoc description from a component's exported declaration.
325
+ * Uses TypeScript AST — no program needed, just source file parsing.
326
+ */
327
+ export async function extractComponentJSDoc(
328
+ filePath: string,
329
+ componentName?: string
330
+ ): Promise<string | null> {
331
+ const content = await readFile(filePath, "utf-8");
332
+ return extractComponentJSDocFromSource(content, filePath, componentName);
333
+ }
334
+
335
+ export function extractComponentJSDocFromSource(
336
+ source: string,
337
+ filePath: string,
338
+ componentName?: string
339
+ ): string | null {
340
+ const sourceFile = ts.createSourceFile(
341
+ filePath,
342
+ source,
343
+ ts.ScriptTarget.ESNext,
344
+ true,
345
+ filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
346
+ );
347
+
348
+ const targetName =
349
+ componentName || basename(filePath).replace(/\.(tsx?|jsx?)$/, "");
350
+
351
+ let componentDoc: string | null = null;
352
+ let propsInterfaceDoc: string | null = null;
353
+
354
+ for (const statement of sourceFile.statements) {
355
+ // export function ComponentName(...)
356
+ if (
357
+ ts.isFunctionDeclaration(statement) &&
358
+ statement.name?.text === targetName &&
359
+ hasExportModifier(statement)
360
+ ) {
361
+ const doc = getLeadingJSDoc(statement, sourceFile);
362
+ if (doc) componentDoc = doc;
363
+ }
364
+
365
+ // export const ComponentName = ...
366
+ if (
367
+ ts.isVariableStatement(statement) &&
368
+ hasExportModifier(statement)
369
+ ) {
370
+ for (const decl of statement.declarationList.declarations) {
371
+ if (ts.isIdentifier(decl.name) && decl.name.text === targetName) {
372
+ const doc = getLeadingJSDoc(statement, sourceFile);
373
+ if (doc) componentDoc = doc;
374
+ }
375
+ }
376
+ }
377
+
378
+ // export default function ...
379
+ if (
380
+ ts.isFunctionDeclaration(statement) &&
381
+ hasDefaultExportModifier(statement)
382
+ ) {
383
+ const doc = getLeadingJSDoc(statement, sourceFile);
384
+ if (doc) componentDoc = doc;
385
+ }
386
+
387
+ // Fallback: JSDoc on the {Name}Props interface
388
+ if (
389
+ ts.isInterfaceDeclaration(statement) &&
390
+ (statement.name.text === `${targetName}Props` ||
391
+ statement.name.text === `${targetName}Properties`)
392
+ ) {
393
+ const doc = getLeadingJSDoc(statement, sourceFile);
394
+ if (doc) propsInterfaceDoc = doc;
395
+ }
396
+ }
397
+
398
+ // Prefer component-level JSDoc; fall back to props interface JSDoc
399
+ return componentDoc || propsInterfaceDoc;
400
+ }
401
+
402
+ function hasExportModifier(node: ts.Node): boolean {
403
+ const modifiers = ts.canHaveModifiers(node)
404
+ ? ts.getModifiers(node)
405
+ : undefined;
406
+ return modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
407
+ }
408
+
409
+ function hasDefaultExportModifier(node: ts.Node): boolean {
410
+ const modifiers = ts.canHaveModifiers(node)
411
+ ? ts.getModifiers(node)
412
+ : undefined;
413
+ if (!modifiers) return false;
414
+ return (
415
+ modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) &&
416
+ modifiers.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)
417
+ );
418
+ }
419
+
420
+ function getLeadingJSDoc(
421
+ node: ts.Node,
422
+ sourceFile: ts.SourceFile
423
+ ): string | null {
424
+ const fullText = sourceFile.getFullText();
425
+ const nodeStart = node.getFullStart();
426
+ const nodePos = node.getStart(sourceFile);
427
+ const leadingText = fullText.slice(nodeStart, nodePos);
428
+
429
+ const jsDocMatch = leadingText.match(/\/\*\*([\s\S]*?)\*\//);
430
+ if (!jsDocMatch) return null;
431
+
432
+ // Parse JSDoc content — extract description lines, skip tags
433
+ const lines = jsDocMatch[1].split("\n");
434
+ const descriptionLines: string[] = [];
435
+
436
+ for (const line of lines) {
437
+ const trimmed = line.replace(/^\s*\*?\s?/, "").trim();
438
+ if (trimmed.startsWith("@")) break; // Stop at first tag
439
+ if (trimmed) descriptionLines.push(trimmed);
440
+ }
441
+
442
+ return descriptionLines.length > 0
443
+ ? descriptionLines.join(" ")
444
+ : null;
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Compound Component Detection
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /**
452
+ * Detect compound component patterns (Object.assign) in a source file.
453
+ * Returns the names of sub-components found.
454
+ */
455
+ export async function detectCompoundComponents(
456
+ filePath: string
457
+ ): Promise<string[]> {
458
+ const content = await readFile(filePath, "utf-8");
459
+ return detectCompoundComponentsFromSource(content, filePath);
460
+ }
461
+
462
+ export function detectCompoundComponentsFromSource(
463
+ source: string,
464
+ filePath: string
465
+ ): string[] {
466
+ const sourceFile = ts.createSourceFile(
467
+ filePath,
468
+ source,
469
+ ts.ScriptTarget.ESNext,
470
+ true,
471
+ filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
472
+ );
473
+
474
+ const subComponents: string[] = [];
475
+
476
+ function visit(node: ts.Node) {
477
+ // Look for Object.assign(Root, { Sub1, Sub2 }) or Object.assign(Root, { Sub1: SubComponent })
478
+ if (
479
+ ts.isCallExpression(node) &&
480
+ ts.isPropertyAccessExpression(node.expression) &&
481
+ ts.isIdentifier(node.expression.expression) &&
482
+ node.expression.expression.text === "Object" &&
483
+ node.expression.name.text === "assign" &&
484
+ node.arguments.length >= 2
485
+ ) {
486
+ const secondArg = node.arguments[1];
487
+ if (ts.isObjectLiteralExpression(secondArg)) {
488
+ for (const prop of secondArg.properties) {
489
+ if (ts.isShorthandPropertyAssignment(prop)) {
490
+ subComponents.push(prop.name.text);
491
+ } else if (
492
+ ts.isPropertyAssignment(prop) &&
493
+ ts.isIdentifier(prop.name)
494
+ ) {
495
+ subComponents.push(prop.name.text);
496
+ }
497
+ }
498
+ }
499
+ }
500
+
501
+ ts.forEachChild(node, visit);
502
+ }
503
+
504
+ ts.forEachChild(sourceFile, visit);
505
+ return subComponents;
506
+ }
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // Story Variant Extraction (lightweight, from generate.ts patterns)
510
+ // ---------------------------------------------------------------------------
511
+
512
+ async function extractStoryVariantsFromFile(
513
+ storyPath: string
514
+ ): Promise<StoryVariant[]> {
515
+ const content = await readFile(storyPath, "utf-8");
516
+ const variants: StoryVariant[] = [];
517
+
518
+ const exportMatches = content.matchAll(
519
+ /export\s+const\s+([A-Z][a-zA-Z0-9]*)\s*[=:]/g
520
+ );
521
+
522
+ for (const match of exportMatches) {
523
+ const name = match[1];
524
+ if (name === "default" || name.endsWith("Args") || name.endsWith("Meta")) {
525
+ continue;
526
+ }
527
+
528
+ const args = extractStoryArgs(content, name);
529
+ variants.push({ name, args });
530
+ }
531
+
532
+ return variants;
533
+ }
534
+
535
+ function extractStoryArgs(
536
+ content: string,
537
+ storyName: string
538
+ ): Record<string, unknown> {
539
+ const storyPattern = new RegExp(
540
+ `export\\s+const\\s+${storyName}[^=]*=\\s*\\{([\\s\\S]*?)\\n\\};`
541
+ );
542
+ const storyMatch = content.match(storyPattern);
543
+ if (!storyMatch) return {};
544
+
545
+ const storyBody = storyMatch[1];
546
+ const argsStart = storyBody.indexOf("args:");
547
+ if (argsStart === -1) return {};
548
+
549
+ const braceStart = storyBody.indexOf("{", argsStart);
550
+ if (braceStart === -1) return {};
551
+
552
+ let depth = 0;
553
+ let braceEnd = -1;
554
+ for (let i = braceStart; i < storyBody.length; i++) {
555
+ if (storyBody[i] === "{") depth++;
556
+ else if (storyBody[i] === "}") {
557
+ depth--;
558
+ if (depth === 0) {
559
+ braceEnd = i;
560
+ break;
561
+ }
562
+ }
563
+ }
564
+ if (braceEnd === -1) return {};
565
+
566
+ const argsBlock = storyBody.slice(braceStart + 1, braceEnd).trim();
567
+ return parseArgsBlock(argsBlock);
568
+ }
569
+
570
+ function parseArgsBlock(argsBlock: string): Record<string, unknown> {
571
+ const args: Record<string, unknown> = {};
572
+ const pairPattern =
573
+ /(\w+)\s*:\s*(?:['"]([^'"]*?)['"]|(true|false)|(\d+(?:\.\d+)?))/g;
574
+ let pairMatch: RegExpExecArray | null;
575
+
576
+ while ((pairMatch = pairPattern.exec(argsBlock)) !== null) {
577
+ const key = pairMatch[1];
578
+ if (pairMatch[2] !== undefined) {
579
+ args[key] = pairMatch[2];
580
+ } else if (pairMatch[3] !== undefined) {
581
+ args[key] = pairMatch[3] === "true";
582
+ } else if (pairMatch[4] !== undefined) {
583
+ args[key] = Number(pairMatch[4]);
584
+ }
585
+ }
586
+
587
+ return args;
588
+ }
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // Confidence Scoring
592
+ // ---------------------------------------------------------------------------
593
+
594
+ export function calculateFieldConfidence(data: ComponentData): FieldConfidence {
595
+ let score = 0;
596
+ const todoFields: string[] = [];
597
+
598
+ // Props extracted: +30
599
+ const hasProps =
600
+ data.props?.success && data.props.props.length > 0;
601
+ if (hasProps) {
602
+ score += 30;
603
+ }
604
+
605
+ // JSDoc description found: +15
606
+ if (data.jsDoc) {
607
+ score += 15;
608
+ } else {
609
+ todoFields.push("meta.description");
610
+ }
611
+
612
+ // Category inferred from path/name (not fallback): +10
613
+ const category = inferCategory(
614
+ data.component.name,
615
+ data.props?.success ? data.props.props : []
616
+ );
617
+ if (category !== "Components") {
618
+ score += 10;
619
+ } else {
620
+ todoFields.push("meta.category");
621
+ }
622
+
623
+ // Story variants found: +25
624
+ if (data.storyVariants.length > 0) {
625
+ score += 25;
626
+ }
627
+
628
+ // All prop types resolved (no "custom"): +10
629
+ if (hasProps) {
630
+ const allResolved = data.props!.props.every(
631
+ (p) => p.propType.type !== "custom"
632
+ );
633
+ if (allResolved) {
634
+ score += 10;
635
+ }
636
+ }
637
+
638
+ // Compound component detected: +5
639
+ if (data.compoundChildren.length > 0) {
640
+ score += 5;
641
+ }
642
+
643
+ // Has default values: +5
644
+ if (hasProps) {
645
+ const hasDefaults = data.props!.props.some(
646
+ (p) => p.defaultValue !== undefined
647
+ );
648
+ if (hasDefaults) {
649
+ score += 5;
650
+ }
651
+ }
652
+
653
+ // usage.when and usage.whenNot always get TODOs (human knowledge needed)
654
+ todoFields.push("usage.when");
655
+ todoFields.push("usage.whenNot");
656
+
657
+ return { score: Math.min(score, 100), todoFields };
658
+ }
659
+
660
+ // ---------------------------------------------------------------------------
661
+ // Category / Status / Description inference (adapted from generate.ts + scan.ts)
662
+ // ---------------------------------------------------------------------------
663
+
664
+ const CATEGORY_PATTERNS: Record<string, string[]> = {
665
+ Actions: ["button", "action", "cta", "fab", "floatingaction"],
666
+ Forms: [
667
+ "form", "input", "select", "checkbox", "radio", "textarea", "field",
668
+ "textfield", "datepicker", "switch", "slider", "segmented",
669
+ ],
670
+ Layout: [
671
+ "layout", "container", "grid", "flex", "stack", "box", "divider", "spacer",
672
+ "sidebar",
673
+ ],
674
+ Navigation: [
675
+ "nav", "menu", "breadcrumb", "tab", "link", "pagination", "stepper",
676
+ "topbar",
677
+ ],
678
+ Feedback: [
679
+ "alert", "toast", "notification", "message", "badge", "indicator",
680
+ "progress", "spinner", "loading", "loader", "lozenge", "chip",
681
+ ],
682
+ "Data Display": [
683
+ "table", "list", "card", "avatar", "stat", "timeline", "tree", "datalist",
684
+ "datacard",
685
+ ],
686
+ Overlays: [
687
+ "modal", "dialog", "drawer", "popover", "tooltip", "dropdown",
688
+ "slidepanel",
689
+ ],
690
+ Typography: ["text", "heading", "title", "label", "paragraph"],
691
+ Media: ["image", "video", "icon", "carousel"],
692
+ };
693
+
694
+ function inferCategory(
695
+ componentName: string,
696
+ props: ExtractedProp[]
697
+ ): string {
698
+ const lower = componentName.toLowerCase();
699
+
700
+ for (const [category, patterns] of Object.entries(CATEGORY_PATTERNS)) {
701
+ for (const pattern of patterns) {
702
+ if (lower.includes(pattern)) {
703
+ return category;
704
+ }
705
+ }
706
+ }
707
+
708
+ // Prop-based fallbacks
709
+ const propNames = new Set(props.map((p) => p.name));
710
+ if (propNames.has("onClick") || propNames.has("onPress")) return "Actions";
711
+ if (propNames.has("value") || propNames.has("defaultValue")) return "Forms";
712
+ if (propNames.has("children")) return "Layout";
713
+
714
+ return "Components";
715
+ }
716
+
717
+ function inferStatus(
718
+ filePath: string
719
+ ): "draft" | "experimental" | "beta" | "stable" | "deprecated" {
720
+ const lowerPath = filePath.toLowerCase();
721
+ if (lowerPath.includes("/experimental/") || lowerPath.includes("/labs/"))
722
+ return "experimental";
723
+ if (lowerPath.includes("/beta/")) return "beta";
724
+ if (lowerPath.includes("/deprecated/") || lowerPath.includes("/legacy/"))
725
+ return "deprecated";
726
+ if (lowerPath.includes("/draft/") || lowerPath.includes("/wip/"))
727
+ return "draft";
728
+ return "stable";
729
+ }
730
+
731
+ function inferDescription(
732
+ componentName: string,
733
+ props: ExtractedProp[]
734
+ ): string {
735
+ const words = componentName
736
+ .replace(/([A-Z])/g, " $1")
737
+ .trim()
738
+ .toLowerCase();
739
+
740
+ const propNames = new Set(props.map((p) => p.name));
741
+ const hasOnClick =
742
+ propNames.has("onClick") || propNames.has("onPress");
743
+ const hasValue =
744
+ propNames.has("value") || propNames.has("defaultValue");
745
+ const hasChildren = propNames.has("children");
746
+
747
+ if (hasOnClick && !hasValue)
748
+ return `Interactive ${words} element for triggering actions`;
749
+ if (hasValue) return `Form ${words} for user input`;
750
+ if (hasChildren) return `Container ${words} for grouping content`;
751
+ return `${words.charAt(0).toUpperCase() + words.slice(1)} component`;
752
+ }
753
+
754
+ function inferAccessibility(props: ExtractedProp[]): {
755
+ role?: string;
756
+ requirements?: string[];
757
+ } {
758
+ const propNames = new Set(props.map((p) => p.name));
759
+ const accessibility: { role?: string; requirements?: string[] } = {};
760
+
761
+ const hasOnClick =
762
+ propNames.has("onClick") || propNames.has("onPress");
763
+ const hasAriaLabel =
764
+ propNames.has("ariaLabel") || propNames.has("aria-label");
765
+ const hasDisabled = propNames.has("disabled");
766
+ const hasHref = propNames.has("href");
767
+
768
+ if (hasOnClick && !hasHref) accessibility.role = "button";
769
+ else if (hasHref) accessibility.role = "link";
770
+
771
+ const requirements: string[] = [];
772
+ if (hasOnClick && !hasAriaLabel)
773
+ requirements.push("Should have visible text or aria-label");
774
+ if (hasDisabled)
775
+ requirements.push(
776
+ "Disabled state should be conveyed to assistive technology"
777
+ );
778
+ if (requirements.length > 0) accessibility.requirements = requirements;
779
+
780
+ return accessibility;
781
+ }
782
+
783
+ // ---------------------------------------------------------------------------
784
+ // Import path computation
785
+ // ---------------------------------------------------------------------------
786
+
787
+ function computeImportPath(
788
+ fragmentDir: string,
789
+ sourcePath: string,
790
+ componentBaseName: string
791
+ ): string {
792
+ const sourceDir = dirname(sourcePath);
793
+
794
+ // Colocated: fragment sits next to the source file
795
+ if (fragmentDir === sourceDir) {
796
+ // If source is index.tsx, import from '.'
797
+ if (componentBaseName === "index") {
798
+ return ".";
799
+ }
800
+ return `./${componentBaseName}`;
801
+ }
802
+
803
+ // Different directory: compute relative path
804
+ let rel = relative(fragmentDir, sourceDir);
805
+ if (!rel.startsWith(".")) rel = `./${rel}`;
806
+ if (componentBaseName !== "index") {
807
+ rel = `${rel}/${componentBaseName}`;
808
+ }
809
+ return rel;
810
+ }
811
+
812
+ // ---------------------------------------------------------------------------
813
+ // Fragment file generation with TODO markers
814
+ // ---------------------------------------------------------------------------
815
+
816
+ function escapeQuotes(str: string): string {
817
+ return str.replace(/'/g, "\\'");
818
+ }
819
+
820
+ function generateFragmentWithTodos(
821
+ componentName: string,
822
+ importPath: string,
823
+ data: ComponentData,
824
+ confidence: FieldConfidence
825
+ ): string {
826
+ const props = data.props?.success ? data.props.props : [];
827
+ const isDefaultExport = data.props?.success
828
+ ? !data.props.propsTypeName // heuristic: if no explicit props type, might be default export
829
+ : false;
830
+ // For scan-generated files we always use named import
831
+ const description = data.jsDoc || inferDescription(componentName, props);
832
+ const descriptionTodo = data.jsDoc ? "" : " // TODO: Review description";
833
+ const category = inferCategory(componentName, props);
834
+ const categoryTodo = category === "Components" ? " // TODO: Set correct category" : "";
835
+ const status = inferStatus(data.component.sourcePath);
836
+ const accessibility = inferAccessibility(props);
837
+
838
+ // Build props block
839
+ const propsBlock = buildPropsBlock(props);
840
+
841
+ // Build accessibility block
842
+ const accessibilityBlock = buildAccessibilityBlock(accessibility);
843
+
844
+ // Build variants
845
+ const variantsBlock = buildVariantsBlock(
846
+ componentName,
847
+ data.storyVariants
848
+ );
849
+
850
+ // Build compound children comment
851
+ const compoundComment =
852
+ data.compoundChildren.length > 0
853
+ ? `\n // Compound sub-components detected: ${data.compoundChildren.join(", ")}`
854
+ : "";
855
+
856
+ return `// Auto-generated by fragments init --scan | Confidence: ${confidence.score}/100
857
+ // ${confidence.todoFields.length} TODO(s) — search for "TODO:" and fill in human knowledge
858
+ import React from 'react';
859
+ import { defineFragment } from '@fragments-sdk/core';
860
+ import { ${componentName} } from '${importPath}';
861
+
862
+ export default defineFragment({
863
+ component: ${componentName},
864
+ ${compoundComment}
865
+ meta: {
866
+ name: '${escapeQuotes(componentName)}',
867
+ description: '${escapeQuotes(description)}',${descriptionTodo}
868
+ category: '${escapeQuotes(category)}',${categoryTodo}
869
+ status: '${status}',
870
+ },
871
+
872
+ usage: {
873
+ when: [
874
+ // TODO: Describe when to use ${componentName}
875
+ ],
876
+ whenNot: [
877
+ // TODO: Describe when NOT to use ${componentName}
878
+ ],
879
+ },
880
+
881
+ props: ${propsBlock},${accessibilityBlock}
882
+
883
+ variants: [
884
+ ${variantsBlock}
885
+ ],
886
+ });
887
+ `;
888
+ }
889
+
890
+ function buildPropsBlock(props: ExtractedProp[]): string {
891
+ if (props.length === 0) return "{}";
892
+
893
+ const lines = props.map((prop) => {
894
+ const type = prop.propType.type;
895
+ const parts: string[] = [` type: '${type}'`];
896
+
897
+ if (prop.description) {
898
+ parts.push(
899
+ ` description: '${escapeQuotes(prop.description.replace(/\n/g, " "))}'`
900
+ );
901
+ }
902
+
903
+ parts.push(` required: ${prop.required}`);
904
+
905
+ if (prop.defaultValue !== undefined) {
906
+ parts.push(` default: ${JSON.stringify(prop.defaultValue)}`);
907
+ }
908
+
909
+ if (prop.enumValues && prop.enumValues.length > 0) {
910
+ parts.push(` values: ${JSON.stringify(prop.enumValues)}`);
911
+ }
912
+
913
+ const todoComment =
914
+ type === "custom" ? " // TODO: Review type" : "";
915
+
916
+ return ` ${prop.name}: {\n${parts.join(",\n")},\n },${todoComment}`;
917
+ });
918
+
919
+ return `{\n${lines.join("\n")}\n }`;
920
+ }
921
+
922
+ function buildAccessibilityBlock(accessibility: {
923
+ role?: string;
924
+ requirements?: string[];
925
+ }): string {
926
+ if (
927
+ !accessibility.role &&
928
+ (!accessibility.requirements || accessibility.requirements.length === 0)
929
+ ) {
930
+ return "";
931
+ }
932
+
933
+ const parts: string[] = [];
934
+ if (accessibility.role) {
935
+ parts.push(` role: '${accessibility.role}'`);
936
+ }
937
+ if (accessibility.requirements && accessibility.requirements.length > 0) {
938
+ const reqs = accessibility.requirements
939
+ .map((r) => `'${escapeQuotes(r)}'`)
940
+ .join(", ");
941
+ parts.push(` requirements: [${reqs}]`);
942
+ }
943
+
944
+ return `\n\n accessibility: {\n${parts.join(",\n")},\n },`;
945
+ }
946
+
947
+ function buildVariantsBlock(
948
+ componentName: string,
949
+ storyVariants: StoryVariant[]
950
+ ): string {
951
+ const entries: string[] = [];
952
+
953
+ // Always include a Default variant
954
+ const hasDefault = storyVariants.some((v) => v.name === "Default");
955
+ if (!hasDefault) {
956
+ entries.push(formatVariantEntry(componentName, "Default", `Default ${componentName}`, {}));
957
+ }
958
+
959
+ for (const variant of storyVariants) {
960
+ const description = variant.name.replace(/([A-Z])/g, " $1").trim();
961
+ entries.push(
962
+ formatVariantEntry(
963
+ componentName,
964
+ variant.name,
965
+ `${description} ${componentName}`,
966
+ variant.args
967
+ )
968
+ );
969
+ }
970
+
971
+ return entries.join("\n");
972
+ }
973
+
974
+ function formatVariantEntry(
975
+ componentName: string,
976
+ name: string,
977
+ description: string,
978
+ args: Record<string, unknown>
979
+ ): string {
980
+ const jsxCode = buildJsxString(componentName, args);
981
+ return ` {
982
+ name: '${escapeQuotes(name)}',
983
+ description: '${escapeQuotes(description)}',
984
+ code: \`${jsxCode}\`,
985
+ render: () => ${jsxCode},
986
+ },`;
987
+ }
988
+
989
+ function buildJsxString(
990
+ componentName: string,
991
+ args: Record<string, unknown>
992
+ ): string {
993
+ const { children, ...restArgs } = args;
994
+ const propParts: string[] = [];
995
+
996
+ for (const [key, value] of Object.entries(restArgs)) {
997
+ if (typeof value === "string") {
998
+ propParts.push(`${key}="${escapeQuotes(value)}"`);
999
+ } else if (typeof value === "boolean") {
1000
+ propParts.push(value ? key : `${key}={false}`);
1001
+ } else if (typeof value === "number") {
1002
+ propParts.push(`${key}={${value}}`);
1003
+ }
1004
+ }
1005
+
1006
+ const propsStr = propParts.length > 0 ? " " + propParts.join(" ") : "";
1007
+
1008
+ if (typeof children === "string") {
1009
+ return `<${componentName}${propsStr}>${children}</${componentName}>`;
1010
+ }
1011
+
1012
+ return `<${componentName}${propsStr} />`;
1013
+ }