@fragments-sdk/cli 0.14.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +0 -3
  2. package/dist/bin.js +4290 -3754
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-TXFCEDOC.js → chunk-2WXKALIG.js} +2 -2
  5. package/dist/{chunk-I34BC3CU.js → chunk-32LIWN2P.js} +1006 -3
  6. package/dist/chunk-32LIWN2P.js.map +1 -0
  7. package/dist/{chunk-55KERLWL.js → chunk-65WSVDV5.js} +314 -89
  8. package/dist/chunk-65WSVDV5.js.map +1 -0
  9. package/dist/chunk-7DZC4YEV.js +294 -0
  10. package/dist/chunk-7DZC4YEV.js.map +1 -0
  11. package/dist/{chunk-LOYS64QS.js → chunk-7WHVW72L.js} +230 -19
  12. package/dist/chunk-7WHVW72L.js.map +1 -0
  13. package/dist/{chunk-PJT5IZ37.js → chunk-BJE3425I.js} +19 -52
  14. package/dist/{chunk-PJT5IZ37.js.map → chunk-BJE3425I.js.map} +1 -1
  15. package/dist/{chunk-5A6X2Y73.js → chunk-CZD3AD4Q.js} +12 -11
  16. package/dist/chunk-CZD3AD4Q.js.map +1 -0
  17. package/dist/{chunk-EYXVAMEX.js → chunk-MN3TJ3D5.js} +72 -3
  18. package/dist/chunk-MN3TJ3D5.js.map +1 -0
  19. package/dist/chunk-QCN35LJU.js +630 -0
  20. package/dist/chunk-QCN35LJU.js.map +1 -0
  21. package/dist/chunk-T47OLCSF.js +36 -0
  22. package/dist/chunk-T47OLCSF.js.map +1 -0
  23. package/dist/{chunk-APTQIBS5.js → chunk-XJQ5BIWI.js} +144 -1049
  24. package/dist/chunk-XJQ5BIWI.js.map +1 -0
  25. package/dist/codebase-scanner-VOTPXRYW.js +22 -0
  26. package/dist/converter-JLINP7CJ.js +34 -0
  27. package/dist/converter-JLINP7CJ.js.map +1 -0
  28. package/dist/core/index.js +43 -1
  29. package/dist/{generate-RYWIPDN2.js → generate-A4FP5426.js} +3 -4
  30. package/dist/{generate-RYWIPDN2.js.map → generate-A4FP5426.js.map} +1 -1
  31. package/dist/govern-scan-UCBZR6D6.js +280 -0
  32. package/dist/govern-scan-UCBZR6D6.js.map +1 -0
  33. package/dist/index.d.ts +2 -1
  34. package/dist/index.js +11 -11
  35. package/dist/{init-WRUSW7R5.js → init-HGSM35XA.js} +131 -128
  36. package/dist/init-HGSM35XA.js.map +1 -0
  37. package/dist/{init-cloud-REQ3XLHO.js → init-cloud-MQ6GRJAZ.js} +2 -2
  38. package/dist/mcp-bin.js +5 -36
  39. package/dist/mcp-bin.js.map +1 -1
  40. package/dist/scan-VNNKACG2.js +15 -0
  41. package/dist/{scan-generate-TFZVL3BT.js → scan-generate-TWRHNU5M.js} +335 -46
  42. package/dist/scan-generate-TWRHNU5M.js.map +1 -0
  43. package/dist/scanner-7LAZYPWZ.js +13 -0
  44. package/dist/{service-HKJ6B7P7.js → service-FHQU7YS7.js} +27 -23
  45. package/dist/{snapshot-C5DYIGIV.js → snapshot-KQEQ6XHL.js} +2 -2
  46. package/dist/{static-viewer-DUVC4UIM.js → static-viewer-63PG6FWY.js} +3 -3
  47. package/dist/static-viewer-63PG6FWY.js.map +1 -0
  48. package/dist/{test-JW7JIDFG.js → test-UQYUCZIS.js} +4 -6
  49. package/dist/{test-JW7JIDFG.js.map → test-UQYUCZIS.js.map} +1 -1
  50. package/dist/{tokens-KE73G5JC.js → tokens-6GYKDV6U.js} +6 -5
  51. package/dist/{tokens-KE73G5JC.js.map → tokens-6GYKDV6U.js.map} +1 -1
  52. package/dist/tokens-generate-VTZV5EEW.js +86 -0
  53. package/dist/tokens-generate-VTZV5EEW.js.map +1 -0
  54. package/package.json +6 -6
  55. package/src/bin.ts +210 -48
  56. package/src/build.ts +130 -6
  57. package/src/commands/__fixtures__/shadcn-label-wrapper/package.json +7 -0
  58. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.contract.json +42 -0
  59. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.tsx +11 -0
  60. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.contract.json +20 -0
  61. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.tsx +14 -0
  62. package/src/commands/__fixtures__/shadcn-label-wrapper/tsconfig.app.json +23 -0
  63. package/src/commands/__tests__/init.test.ts +113 -0
  64. package/src/commands/__tests__/scan-generate.test.ts +188 -69
  65. package/src/commands/__tests__/verify.test.ts +91 -0
  66. package/src/commands/discover.ts +151 -0
  67. package/src/commands/enhance.ts +3 -1
  68. package/src/commands/govern-scan.ts +386 -0
  69. package/src/commands/govern.ts +2 -2
  70. package/src/commands/init.ts +152 -28
  71. package/src/commands/inspect.ts +290 -0
  72. package/src/commands/migrate-contract.ts +85 -0
  73. package/src/commands/scan-generate.ts +438 -50
  74. package/src/commands/scan.ts +1 -0
  75. package/src/commands/setup.ts +27 -50
  76. package/src/commands/tokens-generate.ts +113 -0
  77. package/src/commands/verify.ts +195 -1
  78. package/src/core/__fixtures__/shadcn-input/input.tsx +7 -0
  79. package/src/core/__fixtures__/shadcn-input/tsconfig.json +14 -0
  80. package/src/core/__fixtures__/shadcn-label/label.tsx +11 -0
  81. package/src/core/__fixtures__/shadcn-label/primitive.tsx +14 -0
  82. package/src/core/__fixtures__/shadcn-label/tsconfig.json +14 -0
  83. package/src/core/__fixtures__/shadcn-radix-label/label.tsx +11 -0
  84. package/src/core/__fixtures__/shadcn-radix-label/node_modules/radix-ui/index.d.ts +12 -0
  85. package/src/core/__fixtures__/shadcn-radix-label/tsconfig.json +14 -0
  86. package/src/core/__tests__/contract-parity.test.ts +316 -0
  87. package/src/core/component-extractor.test.ts +39 -0
  88. package/src/core/component-extractor.ts +92 -1
  89. package/src/core/config.ts +2 -1
  90. package/src/core/discovery.ts +13 -2
  91. package/src/core/drift-verifier.ts +123 -0
  92. package/src/core/extractor-adapter.ts +80 -0
  93. package/src/mcp/__tests__/projectFields.test.ts +1 -1
  94. package/src/mcp/utils.ts +1 -50
  95. package/src/migrate/converter.ts +3 -3
  96. package/src/migrate/fragment-to-contract.ts +253 -0
  97. package/src/migrate/report.ts +1 -1
  98. package/src/scripts/token-benchmark.ts +121 -0
  99. package/src/service/__tests__/props-extractor.test.ts +94 -0
  100. package/src/service/__tests__/token-normalizer.test.ts +690 -0
  101. package/src/service/ast-utils.ts +4 -23
  102. package/src/service/babel-config.ts +23 -0
  103. package/src/service/enhance/converter.ts +61 -0
  104. package/src/service/enhance/props-extractor.ts +25 -8
  105. package/src/service/enhance/scanner.ts +5 -24
  106. package/src/service/snippet-validation.ts +9 -3
  107. package/src/service/token-normalizer.ts +510 -0
  108. package/src/shared/index.ts +1 -0
  109. package/src/shared/project-fields.ts +46 -0
  110. package/src/viewer/__tests__/viewer-integration.test.ts +8 -8
  111. package/src/viewer/preview-adapter.ts +116 -0
  112. package/src/viewer/style-utils.ts +27 -412
  113. package/src/viewer/vite-plugin.ts +2 -2
  114. package/dist/chunk-55KERLWL.js.map +0 -1
  115. package/dist/chunk-5A6X2Y73.js.map +0 -1
  116. package/dist/chunk-APTQIBS5.js.map +0 -1
  117. package/dist/chunk-EYXVAMEX.js.map +0 -1
  118. package/dist/chunk-I34BC3CU.js.map +0 -1
  119. package/dist/chunk-LOYS64QS.js.map +0 -1
  120. package/dist/chunk-ZKTFKHWN.js +0 -324
  121. package/dist/chunk-ZKTFKHWN.js.map +0 -1
  122. package/dist/discovery-VDANZAJ2.js +0 -28
  123. package/dist/init-WRUSW7R5.js.map +0 -1
  124. package/dist/scan-YJHQIRKG.js +0 -14
  125. package/dist/scan-generate-TFZVL3BT.js.map +0 -1
  126. package/dist/viewer-2TZS3NDL.js +0 -2730
  127. package/dist/viewer-2TZS3NDL.js.map +0 -1
  128. package/src/commands/dev.ts +0 -107
  129. /package/dist/{chunk-TXFCEDOC.js.map → chunk-2WXKALIG.js.map} +0 -0
  130. /package/dist/{discovery-VDANZAJ2.js.map → codebase-scanner-VOTPXRYW.js.map} +0 -0
  131. /package/dist/{init-cloud-REQ3XLHO.js.map → init-cloud-MQ6GRJAZ.js.map} +0 -0
  132. /package/dist/{scan-YJHQIRKG.js.map → scan-VNNKACG2.js.map} +0 -0
  133. /package/dist/{service-HKJ6B7P7.js.map → scanner-7LAZYPWZ.js.map} +0 -0
  134. /package/dist/{static-viewer-DUVC4UIM.js.map → service-FHQU7YS7.js.map} +0 -0
  135. /package/dist/{snapshot-C5DYIGIV.js.map → snapshot-KQEQ6XHL.js.map} +0 -0
@@ -0,0 +1,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.fragment.tsx"),
273
+ join(tmpDir, "components", "Button", "Button.contract.json"),
272
274
  "utf-8"
273
275
  );
274
276
 
275
- // Check import uses @fragments-sdk/core
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 TODO markers exist
286
- expect(content).toContain("// TODO:");
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(content).toContain("A primary button for user interactions.");
284
+ expect(contract.description).toContain("A primary button for user interactions.");
293
285
 
294
286
  // Check category was inferred (Button → Actions)
295
- expect(content).toContain("category: 'Actions'");
287
+ expect(contract.category).toBe("Actions");
296
288
 
297
- // Check props block has content
298
- expect(content).toContain("children:");
299
- expect(content).toContain("variant:");
289
+ // Check props have content
290
+ expect(contract.props).toHaveProperty("variant");
300
291
 
301
- // Check _generated provenance block
302
- expect(content).toContain("_generated:");
303
- expect(content).toContain("source: 'ai'");
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.fragment.tsx"),
320
+ join(tmpDir, "components", "Button", "Button.contract.json"),
330
321
  "utf-8"
331
322
  );
332
323
 
333
- // Check contract block exists
334
- expect(content).toContain("contract:");
335
- expect(content).toContain("propsSummary:");
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
- // Check propsSummary has entries for local props
338
- expect(content).toContain("variant:");
339
- expect(content).toContain("disabled:");
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.fragment.tsx"),
533
+ join(tmpDir, "components", "Card", "Card.contract.json"),
397
534
  "utf-8"
398
535
  );
399
536
 
400
- // Should have contract with compoundChildren
401
- expect(content).toContain("contract:");
402
- expect(content).toContain("compoundChildren:");
403
- expect(content).toContain("Header:");
404
- expect(content).toContain("Body:");
405
- expect(content).toContain("Footer:");
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.fragment.tsx"),
831
+ join(tmpDir, "components", "Badge", "Badge.contract.json"),
701
832
  "utf-8"
702
833
  );
703
834
 
704
- // Enriched usage fields should be present (not TODO markers)
705
- expect(content).toContain("'Showing status counts'");
706
- expect(content).toContain("'Labeling items'");
707
- expect(content).toContain("'Long text content — use a Tag instead'");
708
- expect(content).toContain("guidelines:");
709
- expect(content).toContain("'Keep text short (1-3 words)'");
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.fragment.tsx"),
868
+ join(tmpDir, "components", "Badge", "Badge.contract.json"),
751
869
  "utf-8"
752
870
  );
753
871
 
754
- // Should have TODO markers (no enrichment)
755
- expect(content).toContain("// TODO: Describe when to use Badge");
756
- expect(content).toContain("// TODO: Describe when NOT to use Badge");
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
+ }