@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.
- package/README.md +0 -3
- package/dist/bin.js +4290 -3754
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-TXFCEDOC.js → chunk-2WXKALIG.js} +2 -2
- package/dist/{chunk-I34BC3CU.js → chunk-32LIWN2P.js} +1006 -3
- package/dist/chunk-32LIWN2P.js.map +1 -0
- package/dist/{chunk-55KERLWL.js → chunk-65WSVDV5.js} +314 -89
- package/dist/chunk-65WSVDV5.js.map +1 -0
- package/dist/chunk-7DZC4YEV.js +294 -0
- package/dist/chunk-7DZC4YEV.js.map +1 -0
- package/dist/{chunk-LOYS64QS.js → chunk-7WHVW72L.js} +230 -19
- package/dist/chunk-7WHVW72L.js.map +1 -0
- package/dist/{chunk-PJT5IZ37.js → chunk-BJE3425I.js} +19 -52
- package/dist/{chunk-PJT5IZ37.js.map → chunk-BJE3425I.js.map} +1 -1
- package/dist/{chunk-5A6X2Y73.js → chunk-CZD3AD4Q.js} +12 -11
- package/dist/chunk-CZD3AD4Q.js.map +1 -0
- package/dist/{chunk-EYXVAMEX.js → chunk-MN3TJ3D5.js} +72 -3
- package/dist/chunk-MN3TJ3D5.js.map +1 -0
- package/dist/chunk-QCN35LJU.js +630 -0
- package/dist/chunk-QCN35LJU.js.map +1 -0
- package/dist/chunk-T47OLCSF.js +36 -0
- package/dist/chunk-T47OLCSF.js.map +1 -0
- package/dist/{chunk-APTQIBS5.js → chunk-XJQ5BIWI.js} +144 -1049
- package/dist/chunk-XJQ5BIWI.js.map +1 -0
- package/dist/codebase-scanner-VOTPXRYW.js +22 -0
- package/dist/converter-JLINP7CJ.js +34 -0
- package/dist/converter-JLINP7CJ.js.map +1 -0
- package/dist/core/index.js +43 -1
- package/dist/{generate-RYWIPDN2.js → generate-A4FP5426.js} +3 -4
- package/dist/{generate-RYWIPDN2.js.map → generate-A4FP5426.js.map} +1 -1
- package/dist/govern-scan-UCBZR6D6.js +280 -0
- package/dist/govern-scan-UCBZR6D6.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +11 -11
- package/dist/{init-WRUSW7R5.js → init-HGSM35XA.js} +131 -128
- package/dist/init-HGSM35XA.js.map +1 -0
- package/dist/{init-cloud-REQ3XLHO.js → init-cloud-MQ6GRJAZ.js} +2 -2
- package/dist/mcp-bin.js +5 -36
- package/dist/mcp-bin.js.map +1 -1
- package/dist/scan-VNNKACG2.js +15 -0
- package/dist/{scan-generate-TFZVL3BT.js → scan-generate-TWRHNU5M.js} +335 -46
- package/dist/scan-generate-TWRHNU5M.js.map +1 -0
- package/dist/scanner-7LAZYPWZ.js +13 -0
- package/dist/{service-HKJ6B7P7.js → service-FHQU7YS7.js} +27 -23
- package/dist/{snapshot-C5DYIGIV.js → snapshot-KQEQ6XHL.js} +2 -2
- package/dist/{static-viewer-DUVC4UIM.js → static-viewer-63PG6FWY.js} +3 -3
- package/dist/static-viewer-63PG6FWY.js.map +1 -0
- package/dist/{test-JW7JIDFG.js → test-UQYUCZIS.js} +4 -6
- package/dist/{test-JW7JIDFG.js.map → test-UQYUCZIS.js.map} +1 -1
- package/dist/{tokens-KE73G5JC.js → tokens-6GYKDV6U.js} +6 -5
- package/dist/{tokens-KE73G5JC.js.map → tokens-6GYKDV6U.js.map} +1 -1
- package/dist/tokens-generate-VTZV5EEW.js +86 -0
- package/dist/tokens-generate-VTZV5EEW.js.map +1 -0
- package/package.json +6 -6
- package/src/bin.ts +210 -48
- package/src/build.ts +130 -6
- package/src/commands/__fixtures__/shadcn-label-wrapper/package.json +7 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.contract.json +42 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.tsx +11 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.contract.json +20 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.tsx +14 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/tsconfig.app.json +23 -0
- package/src/commands/__tests__/init.test.ts +113 -0
- package/src/commands/__tests__/scan-generate.test.ts +188 -69
- package/src/commands/__tests__/verify.test.ts +91 -0
- package/src/commands/discover.ts +151 -0
- package/src/commands/enhance.ts +3 -1
- package/src/commands/govern-scan.ts +386 -0
- package/src/commands/govern.ts +2 -2
- package/src/commands/init.ts +152 -28
- package/src/commands/inspect.ts +290 -0
- package/src/commands/migrate-contract.ts +85 -0
- package/src/commands/scan-generate.ts +438 -50
- package/src/commands/scan.ts +1 -0
- package/src/commands/setup.ts +27 -50
- package/src/commands/tokens-generate.ts +113 -0
- package/src/commands/verify.ts +195 -1
- package/src/core/__fixtures__/shadcn-input/input.tsx +7 -0
- package/src/core/__fixtures__/shadcn-input/tsconfig.json +14 -0
- package/src/core/__fixtures__/shadcn-label/label.tsx +11 -0
- package/src/core/__fixtures__/shadcn-label/primitive.tsx +14 -0
- package/src/core/__fixtures__/shadcn-label/tsconfig.json +14 -0
- package/src/core/__fixtures__/shadcn-radix-label/label.tsx +11 -0
- package/src/core/__fixtures__/shadcn-radix-label/node_modules/radix-ui/index.d.ts +12 -0
- package/src/core/__fixtures__/shadcn-radix-label/tsconfig.json +14 -0
- package/src/core/__tests__/contract-parity.test.ts +316 -0
- package/src/core/component-extractor.test.ts +39 -0
- package/src/core/component-extractor.ts +92 -1
- package/src/core/config.ts +2 -1
- package/src/core/discovery.ts +13 -2
- package/src/core/drift-verifier.ts +123 -0
- package/src/core/extractor-adapter.ts +80 -0
- package/src/mcp/__tests__/projectFields.test.ts +1 -1
- package/src/mcp/utils.ts +1 -50
- package/src/migrate/converter.ts +3 -3
- package/src/migrate/fragment-to-contract.ts +253 -0
- package/src/migrate/report.ts +1 -1
- package/src/scripts/token-benchmark.ts +121 -0
- package/src/service/__tests__/props-extractor.test.ts +94 -0
- package/src/service/__tests__/token-normalizer.test.ts +690 -0
- package/src/service/ast-utils.ts +4 -23
- package/src/service/babel-config.ts +23 -0
- package/src/service/enhance/converter.ts +61 -0
- package/src/service/enhance/props-extractor.ts +25 -8
- package/src/service/enhance/scanner.ts +5 -24
- package/src/service/snippet-validation.ts +9 -3
- package/src/service/token-normalizer.ts +510 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/project-fields.ts +46 -0
- package/src/viewer/__tests__/viewer-integration.test.ts +8 -8
- package/src/viewer/preview-adapter.ts +116 -0
- package/src/viewer/style-utils.ts +27 -412
- package/src/viewer/vite-plugin.ts +2 -2
- package/dist/chunk-55KERLWL.js.map +0 -1
- package/dist/chunk-5A6X2Y73.js.map +0 -1
- package/dist/chunk-APTQIBS5.js.map +0 -1
- package/dist/chunk-EYXVAMEX.js.map +0 -1
- package/dist/chunk-I34BC3CU.js.map +0 -1
- package/dist/chunk-LOYS64QS.js.map +0 -1
- package/dist/chunk-ZKTFKHWN.js +0 -324
- package/dist/chunk-ZKTFKHWN.js.map +0 -1
- package/dist/discovery-VDANZAJ2.js +0 -28
- package/dist/init-WRUSW7R5.js.map +0 -1
- package/dist/scan-YJHQIRKG.js +0 -14
- package/dist/scan-generate-TFZVL3BT.js.map +0 -1
- package/dist/viewer-2TZS3NDL.js +0 -2730
- package/dist/viewer-2TZS3NDL.js.map +0 -1
- package/src/commands/dev.ts +0 -107
- /package/dist/{chunk-TXFCEDOC.js.map → chunk-2WXKALIG.js.map} +0 -0
- /package/dist/{discovery-VDANZAJ2.js.map → codebase-scanner-VOTPXRYW.js.map} +0 -0
- /package/dist/{init-cloud-REQ3XLHO.js.map → init-cloud-MQ6GRJAZ.js.map} +0 -0
- /package/dist/{scan-YJHQIRKG.js.map → scan-VNNKACG2.js.map} +0 -0
- /package/dist/{service-HKJ6B7P7.js.map → scanner-7LAZYPWZ.js.map} +0 -0
- /package/dist/{static-viewer-DUVC4UIM.js.map → service-FHQU7YS7.js.map} +0 -0
- /package/dist/{snapshot-C5DYIGIV.js.map → snapshot-KQEQ6XHL.js.map} +0 -0
|
@@ -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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
966
|
+
if (fragmentProps.length > 0) {
|
|
775
967
|
lines.push('');
|
|
776
968
|
lines.push('Props:');
|
|
777
|
-
for (const [propName, prop] of
|
|
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
|
-
|
|
966
|
-
|
|
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
|
|
984
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
1189
|
-
if (
|
|
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
|
|
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
|
|
1231
|
-
|
|
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,
|
|
1628
|
+
const description = data.meta?.description || inferDescriptionFromMeta(componentName, inferenceProps);
|
|
1366
1629
|
const descriptionTodo = data.meta?.description ? "" : " // TODO: Review description";
|
|
1367
|
-
const category = inferCategoryFromMeta(componentName,
|
|
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(
|
|
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(
|
|
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 { ${
|
|
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 =
|
|
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
|
|
1541
|
-
|
|
1542
|
-
.map((part) =>
|
|
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(
|
|
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
|
|
1562
|
-
|
|
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
|
+
}
|
package/src/commands/scan.ts
CHANGED
|
@@ -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);
|