@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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import { init } from "../init.js";
|
|
7
|
+
|
|
8
|
+
describe("init", () => {
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
tmpDir = await mkdtemp(join(tmpdir(), "fragments-init-test-"));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("skips Fragments UI runtime injection for shadcn-style projects", async () => {
|
|
20
|
+
const projectDir = join(tmpDir, "shadcn-app");
|
|
21
|
+
await mkdir(join(projectDir, "src", "components", "ui"), { recursive: true });
|
|
22
|
+
|
|
23
|
+
await writeFile(
|
|
24
|
+
join(projectDir, "package.json"),
|
|
25
|
+
JSON.stringify(
|
|
26
|
+
{
|
|
27
|
+
name: "shadcn-app",
|
|
28
|
+
private: true,
|
|
29
|
+
devDependencies: {
|
|
30
|
+
tailwindcss: "^4.0.0",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
null,
|
|
34
|
+
2
|
|
35
|
+
)
|
|
36
|
+
);
|
|
37
|
+
await writeFile(join(projectDir, "vite.config.ts"), "export default {};\n");
|
|
38
|
+
await writeFile(
|
|
39
|
+
join(projectDir, "src", "main.tsx"),
|
|
40
|
+
[
|
|
41
|
+
"import { StrictMode } from 'react';",
|
|
42
|
+
"import { createRoot } from 'react-dom/client';",
|
|
43
|
+
"import App from './App';",
|
|
44
|
+
"",
|
|
45
|
+
"createRoot(document.getElementById('root')!).render(",
|
|
46
|
+
" <StrictMode><App /></StrictMode>",
|
|
47
|
+
");",
|
|
48
|
+
"",
|
|
49
|
+
].join("\n")
|
|
50
|
+
);
|
|
51
|
+
await writeFile(join(projectDir, "src", "App.tsx"), "export default function App() { return <div />; }\n");
|
|
52
|
+
await writeFile(
|
|
53
|
+
join(projectDir, "src", "components", "ui", "button.tsx"),
|
|
54
|
+
[
|
|
55
|
+
"export interface ButtonProps {",
|
|
56
|
+
" variant?: 'default' | 'secondary';",
|
|
57
|
+
"}",
|
|
58
|
+
"export function Button({ variant = 'default' }: ButtonProps) {",
|
|
59
|
+
" return <button data-variant={variant} />;",
|
|
60
|
+
"}",
|
|
61
|
+
"",
|
|
62
|
+
].join("\n")
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const result = await init({ projectRoot: projectDir });
|
|
66
|
+
|
|
67
|
+
expect(result.success).toBe(true);
|
|
68
|
+
|
|
69
|
+
const main = await readFile(join(projectDir, "src", "main.tsx"), "utf-8");
|
|
70
|
+
expect(main).not.toContain("@fragments-sdk/ui");
|
|
71
|
+
expect(main).not.toContain("ThemeProvider");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("respects explicit metadata-only mode even without an existing UI library", async () => {
|
|
75
|
+
const projectDir = join(tmpDir, "metadata-only-app");
|
|
76
|
+
await mkdir(join(projectDir, "src"), { recursive: true });
|
|
77
|
+
|
|
78
|
+
await writeFile(
|
|
79
|
+
join(projectDir, "package.json"),
|
|
80
|
+
JSON.stringify(
|
|
81
|
+
{
|
|
82
|
+
name: "metadata-only-app",
|
|
83
|
+
private: true,
|
|
84
|
+
},
|
|
85
|
+
null,
|
|
86
|
+
2
|
|
87
|
+
)
|
|
88
|
+
);
|
|
89
|
+
await writeFile(join(projectDir, "vite.config.ts"), "export default {};\n");
|
|
90
|
+
await writeFile(
|
|
91
|
+
join(projectDir, "src", "main.tsx"),
|
|
92
|
+
[
|
|
93
|
+
"import { StrictMode } from 'react';",
|
|
94
|
+
"import { createRoot } from 'react-dom/client';",
|
|
95
|
+
"import App from './App';",
|
|
96
|
+
"",
|
|
97
|
+
"createRoot(document.getElementById('root')!).render(",
|
|
98
|
+
" <StrictMode><App /></StrictMode>",
|
|
99
|
+
");",
|
|
100
|
+
"",
|
|
101
|
+
].join("\n")
|
|
102
|
+
);
|
|
103
|
+
await writeFile(join(projectDir, "src", "App.tsx"), "export default function App() { return <div />; }\n");
|
|
104
|
+
|
|
105
|
+
const result = await init({ projectRoot: projectDir, metadataOnly: true });
|
|
106
|
+
|
|
107
|
+
expect(result.success).toBe(true);
|
|
108
|
+
|
|
109
|
+
const main = await readFile(join(projectDir, "src", "main.tsx"), "utf-8");
|
|
110
|
+
expect(main).not.toContain("@fragments-sdk/ui");
|
|
111
|
+
expect(main).not.toContain("ThemeProvider");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
2
2
|
import { mkdtemp, writeFile, mkdir, rm, readFile as rf } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import {
|
|
6
6
|
extractComponentJSDocFromSource,
|
|
7
7
|
detectCompoundComponentsFromSource,
|
|
8
8
|
calculateFieldConfidence,
|
|
9
9
|
scanGenerate,
|
|
10
|
+
detectPackageManagerForProject,
|
|
11
|
+
excludeFragmentsFromTsconfig,
|
|
10
12
|
parseEnrichmentResponse,
|
|
11
13
|
buildEnrichmentSystemPrompt,
|
|
12
14
|
buildEnrichmentUserPrompt,
|
|
@@ -268,39 +270,28 @@ export function Button({ children, variant = 'primary', disabled, onClick }: But
|
|
|
268
270
|
// Read the generated file and check structure
|
|
269
271
|
const { readFile: rf } = await import("node:fs/promises");
|
|
270
272
|
const content = await rf(
|
|
271
|
-
join(tmpDir, "components", "Button", "Button.
|
|
273
|
+
join(tmpDir, "components", "Button", "Button.contract.json"),
|
|
272
274
|
"utf-8"
|
|
273
275
|
);
|
|
274
276
|
|
|
275
|
-
|
|
276
|
-
expect(content).toContain(
|
|
277
|
-
"import { defineFragment } from '@fragments-sdk/core'"
|
|
278
|
-
);
|
|
279
|
-
|
|
280
|
-
// Check confidence header
|
|
281
|
-
expect(content).toMatch(
|
|
282
|
-
/\/\/ Auto-generated by fragments init --scan \| Confidence: \d+\/100/
|
|
283
|
-
);
|
|
277
|
+
const contract = JSON.parse(content);
|
|
284
278
|
|
|
285
|
-
// Check
|
|
286
|
-
expect(
|
|
287
|
-
|
|
288
|
-
// Check component name in meta
|
|
289
|
-
expect(content).toContain("name: 'Button'");
|
|
279
|
+
// Check required contract fields
|
|
280
|
+
expect(contract.$schema).toBe("https://usefragments.com/schemas/contract.v1.json");
|
|
281
|
+
expect(contract.name).toBe("Button");
|
|
290
282
|
|
|
291
283
|
// Check JSDoc was picked up in description
|
|
292
|
-
expect(
|
|
284
|
+
expect(contract.description).toContain("A primary button for user interactions.");
|
|
293
285
|
|
|
294
286
|
// Check category was inferred (Button → Actions)
|
|
295
|
-
expect(
|
|
287
|
+
expect(contract.category).toBe("Actions");
|
|
296
288
|
|
|
297
|
-
// Check props
|
|
298
|
-
expect(
|
|
299
|
-
expect(content).toContain("variant:");
|
|
289
|
+
// Check props have content
|
|
290
|
+
expect(contract.props).toHaveProperty("variant");
|
|
300
291
|
|
|
301
|
-
// Check
|
|
302
|
-
expect(
|
|
303
|
-
expect(
|
|
292
|
+
// Check provenance block
|
|
293
|
+
expect(contract.provenance).toBeDefined();
|
|
294
|
+
expect(contract.provenance.source).toBe("extracted");
|
|
304
295
|
});
|
|
305
296
|
|
|
306
297
|
it("skips existing fragments without --force", async () => {
|
|
@@ -326,17 +317,163 @@ export function Button({ children, variant = 'primary', disabled, onClick }: But
|
|
|
326
317
|
|
|
327
318
|
const { readFile: rf } = await import("node:fs/promises");
|
|
328
319
|
const content = await rf(
|
|
329
|
-
join(tmpDir, "components", "Button", "Button.
|
|
320
|
+
join(tmpDir, "components", "Button", "Button.contract.json"),
|
|
330
321
|
"utf-8"
|
|
331
322
|
);
|
|
332
323
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
324
|
+
const contract = JSON.parse(content);
|
|
325
|
+
|
|
326
|
+
// Check propsSummary exists and has entries for local props
|
|
327
|
+
expect(contract.propsSummary).toBeDefined();
|
|
328
|
+
expect(contract.propsSummary.length).toBeGreaterThan(0);
|
|
329
|
+
expect(contract.propsSummary.some((s: string) => s.includes("variant"))).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("scanGenerate helpers", () => {
|
|
334
|
+
let tmpDir: string;
|
|
335
|
+
|
|
336
|
+
beforeAll(async () => {
|
|
337
|
+
tmpDir = await mkdtemp(join(tmpdir(), "scan-gen-helpers-"));
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
afterAll(async () => {
|
|
341
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("detects pnpm from an ancestor workspace root", async () => {
|
|
345
|
+
const workspaceRoot = join(tmpDir, "workspace-root");
|
|
346
|
+
const appDir = join(workspaceRoot, "apps", "demo");
|
|
347
|
+
await mkdir(appDir, { recursive: true });
|
|
348
|
+
await writeFile(join(workspaceRoot, "pnpm-workspace.yaml"), "packages:\n - 'apps/*'\n");
|
|
349
|
+
await writeFile(join(workspaceRoot, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
|
|
350
|
+
|
|
351
|
+
const result = await detectPackageManagerForProject(appDir);
|
|
352
|
+
|
|
353
|
+
expect(result.manager).toBe("pnpm");
|
|
354
|
+
expect(result.lockfileDir).toBe(workspaceRoot);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("adds fragment exclusions to JSONC tsconfig files with aliases", async () => {
|
|
358
|
+
const projectDir = join(tmpDir, "jsonc-app");
|
|
359
|
+
const scanDir = join(projectDir, "src", "components");
|
|
360
|
+
await mkdir(scanDir, { recursive: true });
|
|
361
|
+
await writeFile(
|
|
362
|
+
join(projectDir, "package.json"),
|
|
363
|
+
JSON.stringify({ name: "jsonc-app", private: true }, null, 2)
|
|
364
|
+
);
|
|
365
|
+
await writeFile(
|
|
366
|
+
join(projectDir, "tsconfig.app.json"),
|
|
367
|
+
`{
|
|
368
|
+
"compilerOptions": {
|
|
369
|
+
"baseUrl": ".",
|
|
370
|
+
"paths": {
|
|
371
|
+
"@/*": ["./src/*"]
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
"include": ["src"]
|
|
375
|
+
}
|
|
376
|
+
`
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
await excludeFragmentsFromTsconfig(scanDir);
|
|
380
|
+
|
|
381
|
+
const updated = JSON.parse(await rf(join(projectDir, "tsconfig.app.json"), "utf-8"));
|
|
382
|
+
expect(updated.exclude).toContain("**/*.contract.json");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("generates shadcn-style compound fragments without React imports or member syntax", async () => {
|
|
386
|
+
const projectDir = join(tmpDir, "shadcn-compound");
|
|
387
|
+
const componentDir = join(projectDir, "components");
|
|
388
|
+
const cardDir = join(componentDir, "Card");
|
|
389
|
+
await mkdir(cardDir, { recursive: true });
|
|
390
|
+
|
|
391
|
+
await writeFile(
|
|
392
|
+
join(projectDir, "package.json"),
|
|
393
|
+
JSON.stringify(
|
|
394
|
+
{
|
|
395
|
+
name: "shadcn-compound",
|
|
396
|
+
private: true,
|
|
397
|
+
devDependencies: {
|
|
398
|
+
"@fragments-sdk/core": "workspace:^",
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
null,
|
|
402
|
+
2
|
|
403
|
+
)
|
|
404
|
+
);
|
|
405
|
+
await writeFile(
|
|
406
|
+
join(projectDir, "tsconfig.app.json"),
|
|
407
|
+
JSON.stringify(
|
|
408
|
+
{
|
|
409
|
+
compilerOptions: {
|
|
410
|
+
jsx: "react-jsx",
|
|
411
|
+
},
|
|
412
|
+
include: ["components"],
|
|
413
|
+
},
|
|
414
|
+
null,
|
|
415
|
+
2
|
|
416
|
+
)
|
|
417
|
+
);
|
|
418
|
+
await writeFile(
|
|
419
|
+
join(cardDir, "Card.tsx"),
|
|
420
|
+
`export function Card(props: { children?: React.ReactNode; size?: 'default' | 'sm' }) {
|
|
421
|
+
return <div>{props.children}</div>;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function CardHeader(props: { children?: React.ReactNode }) {
|
|
425
|
+
return <div>{props.children}</div>;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function CardContent(props: { children?: React.ReactNode }) {
|
|
429
|
+
return <div>{props.children}</div>;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function CardFooter(props: { children?: React.ReactNode }) {
|
|
433
|
+
return <div>{props.children}</div>;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export { Card as default };
|
|
437
|
+
`
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
const result = await scanGenerate({
|
|
441
|
+
scanPath: componentDir,
|
|
442
|
+
force: true,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
expect(result.success).toBe(true);
|
|
446
|
+
|
|
447
|
+
const content = await rf(join(cardDir, "Card.contract.json"), "utf-8");
|
|
448
|
+
const contract = JSON.parse(content);
|
|
449
|
+
// Contract.json should have the component name and props
|
|
450
|
+
expect(contract.name).toBe("Card");
|
|
451
|
+
expect(contract.$schema).toBe("https://usefragments.com/schemas/contract.v1.json");
|
|
452
|
+
expect(contract.provenance).toBeDefined();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("falls back to inherited wrapper props for shadcn-style Label primitives", async () => {
|
|
456
|
+
const projectDir = resolve(__dirname, "../__fixtures__/shadcn-label-wrapper");
|
|
457
|
+
const scanDir = join(projectDir, "src", "components", "ui");
|
|
458
|
+
|
|
459
|
+
const result = await scanGenerate({
|
|
460
|
+
scanPath: scanDir,
|
|
461
|
+
force: true,
|
|
462
|
+
tsconfig: join(projectDir, "tsconfig.app.json"),
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
expect(result.success).toBe(true);
|
|
336
466
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
467
|
+
const fragment = await rf(join(scanDir, "label.contract.json"), "utf-8");
|
|
468
|
+
const contract = JSON.parse(fragment);
|
|
469
|
+
// Should have extracted inherited props like htmlFor and form
|
|
470
|
+
expect(contract.props).toBeDefined();
|
|
471
|
+
// Props may be in propsSummary or props object
|
|
472
|
+
const allKeys = [
|
|
473
|
+
...Object.keys(contract.props ?? {}),
|
|
474
|
+
...(contract.propsSummary ?? []).map((s: string) => s.split(':')[0]),
|
|
475
|
+
];
|
|
476
|
+
expect(allKeys.some((k: string) => k.includes('htmlFor') || k.includes('form'))).toBe(true);
|
|
340
477
|
});
|
|
341
478
|
});
|
|
342
479
|
|
|
@@ -393,22 +530,16 @@ export function CardFooter({ children }: { children: React.ReactNode }) {
|
|
|
393
530
|
expect(result.generated[0].name).toBe("Card");
|
|
394
531
|
|
|
395
532
|
const content = await rf(
|
|
396
|
-
join(tmpDir, "components", "Card", "Card.
|
|
533
|
+
join(tmpDir, "components", "Card", "Card.contract.json"),
|
|
397
534
|
"utf-8"
|
|
398
535
|
);
|
|
399
536
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
expect(
|
|
403
|
-
expect(
|
|
404
|
-
expect(
|
|
405
|
-
expect(
|
|
406
|
-
|
|
407
|
-
// Should have canonicalUsage
|
|
408
|
-
expect(content).toContain("canonicalUsage:");
|
|
409
|
-
expect(content).toContain("Card.Header");
|
|
410
|
-
expect(content).toContain("Card.Body");
|
|
411
|
-
expect(content).toContain("Card.Footer");
|
|
537
|
+
const contract = JSON.parse(content);
|
|
538
|
+
// Should have AI metadata with compound composition
|
|
539
|
+
expect(contract.ai).toBeDefined();
|
|
540
|
+
expect(contract.ai.compositionPattern).toBe("compound");
|
|
541
|
+
expect(contract.ai.subComponents).toBeDefined();
|
|
542
|
+
expect(contract.ai.subComponents.length).toBeGreaterThan(0);
|
|
412
543
|
});
|
|
413
544
|
});
|
|
414
545
|
|
|
@@ -697,30 +828,17 @@ export function Badge({ children, variant = 'default' }: BadgeProps) {
|
|
|
697
828
|
expect(result.generated[0].enriched).toBe(true);
|
|
698
829
|
|
|
699
830
|
const content = await rf(
|
|
700
|
-
join(tmpDir, "components", "Badge", "Badge.
|
|
831
|
+
join(tmpDir, "components", "Badge", "Badge.contract.json"),
|
|
701
832
|
"utf-8"
|
|
702
833
|
);
|
|
703
834
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
expect(
|
|
707
|
-
expect(
|
|
708
|
-
expect(
|
|
709
|
-
expect(
|
|
710
|
-
|
|
711
|
-
// Meta tags should be present
|
|
712
|
-
expect(content).toContain("tags:");
|
|
713
|
-
expect(content).toContain("'badge'");
|
|
714
|
-
|
|
715
|
-
// Contract should have enriched a11yRules and scenarioTags
|
|
716
|
-
expect(content).toContain("a11yRules:");
|
|
717
|
-
expect(content).toContain("'Use aria-label for icon-only badges'");
|
|
718
|
-
expect(content).toContain("scenarioTags:");
|
|
719
|
-
expect(content).toContain("'feedback.indicator.badge'");
|
|
720
|
-
|
|
721
|
-
// Should NOT have TODO markers for usage.when or usage.whenNot
|
|
722
|
-
expect(content).not.toContain("// TODO: Describe when to use Badge");
|
|
723
|
-
expect(content).not.toContain("// TODO: Describe when NOT to use Badge");
|
|
835
|
+
const contract = JSON.parse(content);
|
|
836
|
+
// Enriched usage fields should be present
|
|
837
|
+
expect(contract.usage.when.length).toBeGreaterThan(0);
|
|
838
|
+
expect(contract.usage.when).toContain("Showing status counts");
|
|
839
|
+
expect(contract.usage.whenNot).toContain("Long text content — use a Tag instead");
|
|
840
|
+
expect(contract.usage.guidelines).toBeDefined();
|
|
841
|
+
expect(contract.usage.guidelines).toContain("Keep text short (1-3 words)");
|
|
724
842
|
|
|
725
843
|
vi.doUnmock("../../ai-client.js");
|
|
726
844
|
});
|
|
@@ -747,13 +865,14 @@ export function Badge({ children, variant = 'default' }: BadgeProps) {
|
|
|
747
865
|
expect(result.success).toBe(true);
|
|
748
866
|
|
|
749
867
|
const content = await rf(
|
|
750
|
-
join(tmpDir, "components", "Badge", "Badge.
|
|
868
|
+
join(tmpDir, "components", "Badge", "Badge.contract.json"),
|
|
751
869
|
"utf-8"
|
|
752
870
|
);
|
|
753
871
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
expect(
|
|
872
|
+
const contract = JSON.parse(content);
|
|
873
|
+
// Without enrichment, usage.when/whenNot should be empty arrays
|
|
874
|
+
expect(contract.usage.when).toEqual([]);
|
|
875
|
+
expect(contract.usage.whenNot).toEqual([]);
|
|
757
876
|
expect(result.generated[0].enriched).toBeFalsy();
|
|
758
877
|
expect(result.enrichmentCost).toBeUndefined();
|
|
759
878
|
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import { verify } from "../verify.js";
|
|
7
|
+
|
|
8
|
+
describe("verify --ci", () => {
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
tmpDir = await mkdtemp(join(tmpdir(), "verify-ci-test-"));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("prefers local fragments.json and ignores installed package fragments", async () => {
|
|
20
|
+
const projectDir = join(tmpDir, "project");
|
|
21
|
+
await mkdir(projectDir, { recursive: true });
|
|
22
|
+
|
|
23
|
+
await writeFile(
|
|
24
|
+
join(projectDir, "fragments.config.ts"),
|
|
25
|
+
`export default {
|
|
26
|
+
include: ['src/**/*.fragment.tsx'],
|
|
27
|
+
exclude: [],
|
|
28
|
+
components: ['src/**/*.tsx'],
|
|
29
|
+
framework: 'react',
|
|
30
|
+
outFile: 'fragments.json',
|
|
31
|
+
};
|
|
32
|
+
`
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
await writeFile(
|
|
36
|
+
join(projectDir, "fragments.json"),
|
|
37
|
+
JSON.stringify(
|
|
38
|
+
{
|
|
39
|
+
fragments: {
|
|
40
|
+
LocalButton: {
|
|
41
|
+
meta: {
|
|
42
|
+
name: "LocalButton",
|
|
43
|
+
description: "Local button",
|
|
44
|
+
category: "Actions",
|
|
45
|
+
},
|
|
46
|
+
usage: {
|
|
47
|
+
when: ["Actions"],
|
|
48
|
+
whenNot: [],
|
|
49
|
+
},
|
|
50
|
+
props: {
|
|
51
|
+
variant: { type: "enum", required: false, values: ["primary"] },
|
|
52
|
+
},
|
|
53
|
+
variants: [{ name: "Default" }],
|
|
54
|
+
relations: [],
|
|
55
|
+
filePath: "src/components/LocalButton.fragment.tsx",
|
|
56
|
+
},
|
|
57
|
+
InstalledButton: {
|
|
58
|
+
meta: {
|
|
59
|
+
name: "InstalledButton",
|
|
60
|
+
description: "Installed button",
|
|
61
|
+
category: "Actions",
|
|
62
|
+
},
|
|
63
|
+
usage: {
|
|
64
|
+
when: ["Actions"],
|
|
65
|
+
whenNot: [],
|
|
66
|
+
},
|
|
67
|
+
props: {
|
|
68
|
+
variant: { type: "enum", required: false, values: ["primary"] },
|
|
69
|
+
},
|
|
70
|
+
variants: [{ name: "Default" }],
|
|
71
|
+
relations: [],
|
|
72
|
+
filePath: "node_modules/@fragments-sdk/ui/Button.fragment.tsx",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
null,
|
|
77
|
+
2
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const summary = await verify(undefined, {
|
|
82
|
+
ci: true,
|
|
83
|
+
config: join(projectDir, "fragments.config.ts"),
|
|
84
|
+
port: 6553,
|
|
85
|
+
minCompliance: 60,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(summary.totalComponents).toBe(1);
|
|
89
|
+
expect(summary.results.map((result) => result.component)).toEqual(["LocalButton"]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fragments discover` — list and filter components from fragments.json.
|
|
3
|
+
*
|
|
4
|
+
* Loads the compiled output and provides listing, search, category
|
|
5
|
+
* filtering, and status filtering. Outputs a formatted table, compact
|
|
6
|
+
* names list, or JSON depending on flags.
|
|
7
|
+
*
|
|
8
|
+
* This is the LIST mode only — semantic/useCase search requires the
|
|
9
|
+
* Orama sidecar available via the MCP server.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import pc from 'picocolors';
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
import { resolve } from 'node:path';
|
|
15
|
+
import type { CompiledFragmentsFile } from '../core/index.js';
|
|
16
|
+
import { BRAND } from '../core/index.js';
|
|
17
|
+
import { loadConfig } from '../core/node.js';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Types
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export interface DiscoverCommandOptions {
|
|
24
|
+
config?: string;
|
|
25
|
+
search?: string;
|
|
26
|
+
category?: string;
|
|
27
|
+
status?: string;
|
|
28
|
+
compact?: boolean;
|
|
29
|
+
json?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Command implementation
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export async function discover(options: DiscoverCommandOptions): Promise<void> {
|
|
37
|
+
const { config, configDir } = await loadConfig(options.config);
|
|
38
|
+
const outputPath = resolve(configDir, config.outFile ?? BRAND.outFile);
|
|
39
|
+
|
|
40
|
+
let data: CompiledFragmentsFile;
|
|
41
|
+
try {
|
|
42
|
+
const content = await readFile(outputPath, 'utf-8');
|
|
43
|
+
data = JSON.parse(content) as CompiledFragmentsFile;
|
|
44
|
+
} catch {
|
|
45
|
+
console.error(
|
|
46
|
+
pc.red(`Error: Could not load ${BRAND.outFile}. Run \`${BRAND.cliCommand} build\` first.`),
|
|
47
|
+
);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let fragments = Object.values(data.fragments);
|
|
52
|
+
|
|
53
|
+
// --- Search filter (name, description, tags) ---
|
|
54
|
+
if (options.search) {
|
|
55
|
+
const term = options.search.toLowerCase();
|
|
56
|
+
fragments = fragments.filter(
|
|
57
|
+
(f) =>
|
|
58
|
+
f.meta.name.toLowerCase().includes(term) ||
|
|
59
|
+
f.meta.description?.toLowerCase().includes(term) ||
|
|
60
|
+
f.meta.tags?.some((t) => t.toLowerCase().includes(term)),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Category filter ---
|
|
65
|
+
if (options.category) {
|
|
66
|
+
const cat = options.category.trim().toLowerCase();
|
|
67
|
+
fragments = fragments.filter(
|
|
68
|
+
(f) => f.meta.category?.toLowerCase() === cat,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Status filter ---
|
|
73
|
+
if (options.status) {
|
|
74
|
+
const status = options.status.trim().toLowerCase();
|
|
75
|
+
fragments = fragments.filter((f) => (f.meta.status ?? 'stable') === status);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Sort alphabetically
|
|
79
|
+
fragments.sort((a, b) => a.meta.name.localeCompare(b.meta.name));
|
|
80
|
+
|
|
81
|
+
const categories = [...new Set(fragments.map((f) => f.meta.category).filter(Boolean))].sort();
|
|
82
|
+
|
|
83
|
+
// --- JSON output ---
|
|
84
|
+
if (options.json) {
|
|
85
|
+
const output = {
|
|
86
|
+
total: fragments.length,
|
|
87
|
+
fragments: fragments.map((f) => ({
|
|
88
|
+
name: f.meta.name,
|
|
89
|
+
category: f.meta.category,
|
|
90
|
+
status: f.meta.status ?? 'stable',
|
|
91
|
+
description: f.meta.description,
|
|
92
|
+
tags: f.meta.tags,
|
|
93
|
+
variantCount: f.variants.length,
|
|
94
|
+
propCount: Object.keys(f.props ?? {}).length,
|
|
95
|
+
})),
|
|
96
|
+
categories,
|
|
97
|
+
};
|
|
98
|
+
console.log(JSON.stringify(output, null, 2));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Compact output (names only) ---
|
|
103
|
+
if (options.compact) {
|
|
104
|
+
for (const f of fragments) {
|
|
105
|
+
console.log(f.meta.name);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- Table output ---
|
|
111
|
+
if (fragments.length === 0) {
|
|
112
|
+
console.log(pc.yellow('\nNo components found matching the given filters.\n'));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log(pc.bold(`\n${BRAND.name} Components (${fragments.length})\n`));
|
|
117
|
+
|
|
118
|
+
console.log(
|
|
119
|
+
pc.dim(
|
|
120
|
+
` ${'Name'.padEnd(28)} ${'Category'.padEnd(16)} ${'Status'.padEnd(14)} ${'Variants'.padEnd(10)} ${'Props'}`,
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
console.log(pc.dim(` ${'─'.repeat(80)}`));
|
|
124
|
+
|
|
125
|
+
for (const f of fragments) {
|
|
126
|
+
const name = f.meta.name.length > 26 ? f.meta.name.slice(0, 23) + '...' : f.meta.name;
|
|
127
|
+
const category = (f.meta.category ?? '').padEnd(16);
|
|
128
|
+
const status = f.meta.status ?? 'stable';
|
|
129
|
+
const statusColored =
|
|
130
|
+
status === 'stable' ? pc.green(status.padEnd(14)) :
|
|
131
|
+
status === 'deprecated' ? pc.red(status.padEnd(14)) :
|
|
132
|
+
status === 'beta' ? pc.yellow(status.padEnd(14)) :
|
|
133
|
+
pc.cyan(status.padEnd(14));
|
|
134
|
+
const variantCount = String(f.variants.length).padEnd(10);
|
|
135
|
+
const propCount = String(Object.keys(f.props ?? {}).length);
|
|
136
|
+
|
|
137
|
+
console.log(
|
|
138
|
+
` ${pc.cyan(name.padEnd(28))} ${pc.dim(category)} ${statusColored} ${variantCount} ${propCount}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Summary
|
|
143
|
+
console.log(pc.dim(`\n ${'─'.repeat(80)}`));
|
|
144
|
+
console.log(
|
|
145
|
+
pc.dim(` ${fragments.length} component(s) across ${categories.length} categories`),
|
|
146
|
+
);
|
|
147
|
+
if (categories.length > 0) {
|
|
148
|
+
console.log(pc.dim(` Categories: ${categories.join(', ')}`));
|
|
149
|
+
}
|
|
150
|
+
console.log();
|
|
151
|
+
}
|