@fragments-sdk/cli 0.14.3 → 0.15.1

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 (181) hide show
  1. package/README.md +0 -3
  2. package/dist/{ai-client-I6MDWNYA.js → ai-client-LSLQGOMM.js} +1 -2
  3. package/dist/bin.js +4745 -3817
  4. package/dist/bin.js.map +1 -1
  5. package/dist/{chunk-TXFCEDOC.js → chunk-2WXKALIG.js} +2 -2
  6. package/dist/{chunk-I34BC3CU.js → chunk-32LIWN2P.js} +1006 -3
  7. package/dist/chunk-32LIWN2P.js.map +1 -0
  8. package/dist/chunk-5JF26E55.js +1255 -0
  9. package/dist/chunk-5JF26E55.js.map +1 -0
  10. package/dist/{chunk-APTQIBS5.js → chunk-6SQPP47U.js} +153 -1342
  11. package/dist/chunk-6SQPP47U.js.map +1 -0
  12. package/dist/chunk-7DZC4YEV.js +294 -0
  13. package/dist/chunk-7DZC4YEV.js.map +1 -0
  14. package/dist/{chunk-PJT5IZ37.js → chunk-BJE3425I.js} +19 -52
  15. package/dist/{chunk-PJT5IZ37.js.map → chunk-BJE3425I.js.map} +1 -1
  16. package/dist/{chunk-55KERLWL.js → chunk-HQ6A6DTV.js} +1587 -1073
  17. package/dist/chunk-HQ6A6DTV.js.map +1 -0
  18. package/dist/chunk-MHIBEEW4.js +511 -0
  19. package/dist/chunk-MHIBEEW4.js.map +1 -0
  20. package/dist/{chunk-5A6X2Y73.js → chunk-ONUP6Z4W.js} +25 -13
  21. package/dist/chunk-ONUP6Z4W.js.map +1 -0
  22. package/dist/chunk-QCN35LJU.js +630 -0
  23. package/dist/chunk-QCN35LJU.js.map +1 -0
  24. package/dist/chunk-T47OLCSF.js +36 -0
  25. package/dist/chunk-T47OLCSF.js.map +1 -0
  26. package/dist/codebase-scanner-MQHUZC2G.js +21 -0
  27. package/dist/converter-7XM3Y6NJ.js +33 -0
  28. package/dist/converter-7XM3Y6NJ.js.map +1 -0
  29. package/dist/core/index.js +43 -2
  30. package/dist/create-IH4R45GE.js +806 -0
  31. package/dist/create-IH4R45GE.js.map +1 -0
  32. package/dist/{generate-RYWIPDN2.js → generate-PVOLUAAC.js} +4 -6
  33. package/dist/{generate-RYWIPDN2.js.map → generate-PVOLUAAC.js.map} +1 -1
  34. package/dist/govern-scan-OYFZYOQW.js +413 -0
  35. package/dist/govern-scan-OYFZYOQW.js.map +1 -0
  36. package/dist/index.d.ts +4 -23
  37. package/dist/index.js +15 -14
  38. package/dist/index.js.map +1 -1
  39. package/dist/{init-WRUSW7R5.js → init-SSGUSP7Z.js} +131 -129
  40. package/dist/init-SSGUSP7Z.js.map +1 -0
  41. package/dist/{init-cloud-REQ3XLHO.js → init-cloud-3DNKPWFB.js} +30 -5
  42. package/dist/{init-cloud-REQ3XLHO.js.map → init-cloud-3DNKPWFB.js.map} +1 -1
  43. package/dist/mcp-bin.js +5 -37
  44. package/dist/mcp-bin.js.map +1 -1
  45. package/dist/node-37AUE74M.js +65 -0
  46. package/dist/push-contracts-WY32TFP6.js +84 -0
  47. package/dist/push-contracts-WY32TFP6.js.map +1 -0
  48. package/dist/scan-PKSYSTRR.js +15 -0
  49. package/dist/{scan-generate-TFZVL3BT.js → scan-generate-VY27PIOX.js} +340 -52
  50. package/dist/scan-generate-VY27PIOX.js.map +1 -0
  51. package/dist/scanner-4KZNOXAK.js +12 -0
  52. package/dist/{service-HKJ6B7P7.js → service-QJGWUIVL.js} +41 -30
  53. package/dist/{snapshot-C5DYIGIV.js → snapshot-WIJMEIFT.js} +2 -3
  54. package/dist/{snapshot-C5DYIGIV.js.map → snapshot-WIJMEIFT.js.map} +1 -1
  55. package/dist/{static-viewer-DUVC4UIM.js → static-viewer-7QIBQZRC.js} +3 -4
  56. package/dist/static-viewer-7QIBQZRC.js.map +1 -0
  57. package/dist/{test-JW7JIDFG.js → test-64Z5BKBA.js} +4 -7
  58. package/dist/{test-JW7JIDFG.js.map → test-64Z5BKBA.js.map} +1 -1
  59. package/dist/token-normalizer-TEPOVBPV.js +312 -0
  60. package/dist/token-normalizer-TEPOVBPV.js.map +1 -0
  61. package/dist/token-parser-32KOIOFN.js +22 -0
  62. package/dist/token-parser-32KOIOFN.js.map +1 -0
  63. package/dist/{tokens-KE73G5JC.js → tokens-NZWFQIAB.js} +10 -9
  64. package/dist/{tokens-KE73G5JC.js.map → tokens-NZWFQIAB.js.map} +1 -1
  65. package/dist/tokens-generate-5JQSJ27E.js +85 -0
  66. package/dist/tokens-generate-5JQSJ27E.js.map +1 -0
  67. package/dist/tokens-push-HY3KO36V.js +148 -0
  68. package/dist/tokens-push-HY3KO36V.js.map +1 -0
  69. package/package.json +8 -6
  70. package/src/bin.ts +300 -48
  71. package/src/commands/__fixtures__/shadcn-label-wrapper/package.json +7 -0
  72. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.contract.json +42 -0
  73. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.tsx +11 -0
  74. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.contract.json +20 -0
  75. package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.tsx +14 -0
  76. package/src/commands/__fixtures__/shadcn-label-wrapper/tsconfig.app.json +23 -0
  77. package/src/commands/__tests__/build-freshness.test.ts +231 -0
  78. package/src/commands/__tests__/create.test.ts +71 -0
  79. package/src/commands/__tests__/drift-sync.test.ts +1 -1
  80. package/src/commands/__tests__/govern.test.ts +258 -0
  81. package/src/commands/__tests__/init.test.ts +113 -0
  82. package/src/commands/__tests__/scan-generate.test.ts +189 -70
  83. package/src/commands/__tests__/verify.test.ts +91 -0
  84. package/src/commands/build.ts +54 -1
  85. package/src/commands/context.ts +1 -1
  86. package/src/commands/create.ts +536 -0
  87. package/src/commands/discover.ts +151 -0
  88. package/src/commands/doctor.ts +3 -2
  89. package/src/commands/enhance.ts +3 -1
  90. package/src/commands/govern-scan.ts +565 -0
  91. package/src/commands/govern.ts +67 -4
  92. package/src/commands/init-cloud.ts +32 -4
  93. package/src/commands/init.ts +152 -28
  94. package/src/commands/inspect.ts +290 -0
  95. package/src/commands/migrate-contract.ts +85 -0
  96. package/src/commands/push-contracts.ts +112 -0
  97. package/src/commands/scan-generate.ts +439 -51
  98. package/src/commands/scan.ts +14 -0
  99. package/src/commands/setup.ts +27 -50
  100. package/src/commands/sync.ts +2 -2
  101. package/src/commands/tokens-generate.ts +113 -0
  102. package/src/commands/tokens-push.ts +199 -0
  103. package/src/commands/verify.ts +195 -1
  104. package/src/core/__fixtures__/shadcn-input/input.tsx +7 -0
  105. package/src/core/__fixtures__/shadcn-input/tsconfig.json +14 -0
  106. package/src/core/__fixtures__/shadcn-label/label.tsx +11 -0
  107. package/src/core/__fixtures__/shadcn-label/primitive.tsx +14 -0
  108. package/src/core/__fixtures__/shadcn-label/tsconfig.json +14 -0
  109. package/src/core/__fixtures__/shadcn-radix-label/label.tsx +11 -0
  110. package/src/core/__fixtures__/shadcn-radix-label/node_modules/radix-ui/index.d.ts +12 -0
  111. package/src/core/__fixtures__/shadcn-radix-label/tsconfig.json +14 -0
  112. package/src/core/__tests__/contract-parity.test.ts +316 -0
  113. package/src/core/__tests__/token-resolver.test.ts +1 -1
  114. package/src/core/component-extractor.test.ts +40 -1
  115. package/src/core/config.ts +2 -1
  116. package/src/core/discovery.ts +13 -2
  117. package/src/core/drift-verifier.ts +123 -0
  118. package/src/core/extractor-adapter.ts +80 -0
  119. package/src/index.ts +3 -3
  120. package/src/mcp/__tests__/projectFields.test.ts +1 -1
  121. package/src/mcp/utils.ts +1 -50
  122. package/src/migrate/converter.ts +3 -3
  123. package/src/migrate/fragment-to-contract.ts +253 -0
  124. package/src/migrate/report.ts +1 -1
  125. package/src/scripts/token-benchmark.ts +121 -0
  126. package/src/service/__tests__/props-extractor.test.ts +94 -0
  127. package/src/service/__tests__/token-normalizer.test.ts +690 -0
  128. package/src/service/ast-utils.ts +4 -23
  129. package/src/service/babel-config.ts +23 -0
  130. package/src/service/enhance/converter.ts +61 -0
  131. package/src/service/enhance/props-extractor.ts +25 -8
  132. package/src/service/enhance/scanner.ts +5 -24
  133. package/src/service/index.ts +8 -0
  134. package/src/service/snippet-validation.ts +9 -3
  135. package/src/service/tailwind-v4-parser.ts +314 -0
  136. package/src/service/token-normalizer.ts +510 -0
  137. package/src/service/token-parser.ts +56 -0
  138. package/src/setup.ts +10 -39
  139. package/src/shared/index.ts +1 -0
  140. package/src/shared/project-fields.ts +46 -0
  141. package/src/theme/__tests__/component-contrast.test.ts +2 -2
  142. package/src/theme/__tests__/serializer.test.ts +1 -1
  143. package/src/theme/generator.ts +16 -1
  144. package/src/theme/schema.ts +8 -0
  145. package/src/theme/serializer.ts +13 -9
  146. package/src/theme/types.ts +8 -0
  147. package/src/validators.ts +1 -2
  148. package/src/viewer/__tests__/viewer-integration.test.ts +8 -8
  149. package/src/viewer/style-utils.ts +27 -412
  150. package/src/viewer/vite-plugin.ts +2 -2
  151. package/dist/chunk-55KERLWL.js.map +0 -1
  152. package/dist/chunk-5A6X2Y73.js.map +0 -1
  153. package/dist/chunk-APTQIBS5.js.map +0 -1
  154. package/dist/chunk-EYXVAMEX.js +0 -626
  155. package/dist/chunk-EYXVAMEX.js.map +0 -1
  156. package/dist/chunk-I34BC3CU.js.map +0 -1
  157. package/dist/chunk-LOYS64QS.js +0 -2453
  158. package/dist/chunk-LOYS64QS.js.map +0 -1
  159. package/dist/chunk-Z7EY4VHE.js +0 -50
  160. package/dist/chunk-ZKTFKHWN.js +0 -324
  161. package/dist/chunk-ZKTFKHWN.js.map +0 -1
  162. package/dist/discovery-VDANZAJ2.js +0 -28
  163. package/dist/init-WRUSW7R5.js.map +0 -1
  164. package/dist/sass.node-4XJK6YBF.js +0 -130708
  165. package/dist/sass.node-4XJK6YBF.js.map +0 -1
  166. package/dist/scan-YJHQIRKG.js +0 -14
  167. package/dist/scan-generate-TFZVL3BT.js.map +0 -1
  168. package/dist/viewer-2TZS3NDL.js +0 -2730
  169. package/dist/viewer-2TZS3NDL.js.map +0 -1
  170. package/src/build.ts +0 -612
  171. package/src/commands/dev.ts +0 -107
  172. package/src/core/auto-props.ts +0 -464
  173. package/src/core/component-extractor.ts +0 -1030
  174. package/src/core/token-resolver.ts +0 -155
  175. /package/dist/{ai-client-I6MDWNYA.js.map → ai-client-LSLQGOMM.js.map} +0 -0
  176. /package/dist/{chunk-TXFCEDOC.js.map → chunk-2WXKALIG.js.map} +0 -0
  177. /package/dist/{chunk-Z7EY4VHE.js.map → codebase-scanner-MQHUZC2G.js.map} +0 -0
  178. /package/dist/{discovery-VDANZAJ2.js.map → node-37AUE74M.js.map} +0 -0
  179. /package/dist/{scan-YJHQIRKG.js.map → scan-PKSYSTRR.js.map} +0 -0
  180. /package/dist/{service-HKJ6B7P7.js.map → scanner-4KZNOXAK.js.map} +0 -0
  181. /package/dist/{static-viewer-DUVC4UIM.js.map → service-QJGWUIVL.js.map} +0 -0
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "allowSyntheticDefaultImports": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true,
11
+ "types": ["react", "react-dom"]
12
+ },
13
+ "include": ["input.tsx"]
14
+ }
@@ -0,0 +1,11 @@
1
+ import * as React from 'react';
2
+ import { Primitive } from './primitive';
3
+
4
+ function Label({
5
+ className,
6
+ ...props
7
+ }: React.ComponentProps<typeof Primitive.Root>) {
8
+ return <Primitive.Root data-class={className} {...props} />;
9
+ }
10
+
11
+ export { Label };
@@ -0,0 +1,14 @@
1
+ import * as React from 'react';
2
+
3
+ export interface PrimitiveLabelProps {
4
+ className?: string;
5
+ htmlFor?: string;
6
+ form?: string;
7
+ children?: React.ReactNode;
8
+ }
9
+
10
+ function Root(props: PrimitiveLabelProps) {
11
+ return <label {...props} />;
12
+ }
13
+
14
+ export const Primitive = { Root };
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "allowSyntheticDefaultImports": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true,
11
+ "types": ["react", "react-dom"]
12
+ },
13
+ "include": ["*.tsx"]
14
+ }
@@ -0,0 +1,11 @@
1
+ import * as React from 'react';
2
+ import { Label as LabelPrimitive } from 'radix-ui';
3
+
4
+ function Label({
5
+ className,
6
+ ...props
7
+ }: React.ComponentProps<typeof LabelPrimitive.Root>) {
8
+ return <LabelPrimitive.Root data-class={className} {...props} />;
9
+ }
10
+
11
+ export { Label };
@@ -0,0 +1,12 @@
1
+ import * as React from 'react';
2
+
3
+ export interface LabelRootProps {
4
+ className?: string;
5
+ htmlFor?: string;
6
+ form?: string;
7
+ children?: React.ReactNode;
8
+ }
9
+
10
+ export const Label: {
11
+ Root: (props: LabelRootProps) => JSX.Element;
12
+ };
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "allowSyntheticDefaultImports": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true,
11
+ "types": ["react", "react-dom"]
12
+ },
13
+ "include": ["*.tsx", "node_modules/**/*.d.ts"]
14
+ }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Contract Parity Tests
3
+ *
4
+ * Verifies that building from .contract.json produces structurally identical
5
+ * CompiledFragment output to building from .fragment.tsx (via the parser).
6
+ *
7
+ * These are "golden" tests — if both paths produce the same CompiledFragment,
8
+ * the migration from TSX to JSON is lossless.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import { parseComponentContract } from '@fragments-sdk/core';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Fixture: Button contract (simple component)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const buttonContract = JSON.stringify({
19
+ $schema: 'https://usefragments.com/schemas/contract.v1.json',
20
+ name: 'Button',
21
+ description: 'A clickable button component',
22
+ category: 'actions',
23
+ tags: ['interactive', 'form'],
24
+ status: 'stable',
25
+ sourcePath: 'src/components/Button/index.tsx',
26
+ exportName: 'Button',
27
+ propsSummary: [
28
+ 'variant: primary|secondary|ghost (default: primary)',
29
+ 'size: sm|md|lg (default: md)',
30
+ 'disabled: boolean (default: false)',
31
+ ],
32
+ props: {
33
+ variant: {
34
+ type: 'enum',
35
+ values: ['primary', 'secondary', 'ghost'],
36
+ default: 'primary',
37
+ description: 'Visual style variant',
38
+ },
39
+ size: {
40
+ type: 'enum',
41
+ values: ['sm', 'md', 'lg'],
42
+ default: 'md',
43
+ description: 'Button size',
44
+ },
45
+ disabled: {
46
+ type: 'boolean',
47
+ default: false,
48
+ description: 'Whether the button is disabled',
49
+ },
50
+ },
51
+ usage: {
52
+ when: ['User needs to trigger an action', 'Form submissions'],
53
+ whenNot: ['Navigation — use Link instead'],
54
+ guidelines: ['Always provide visible text or aria-label'],
55
+ accessibility: ['Must have accessible name via text content or aria-label'],
56
+ },
57
+ examples: [
58
+ {
59
+ name: 'Primary',
60
+ description: 'Default primary button',
61
+ code: '<Button variant="primary">Click me</Button>',
62
+ },
63
+ {
64
+ name: 'Disabled',
65
+ description: 'Disabled state',
66
+ code: '<Button disabled>Cannot click</Button>',
67
+ args: { disabled: true },
68
+ },
69
+ ],
70
+ relations: [
71
+ { component: 'Link', relationship: 'alternative', note: 'Use Link for navigation' },
72
+ { component: 'IconButton', relationship: 'sibling', note: 'Icon-only variant' },
73
+ ],
74
+ contract: {
75
+ scenarioTags: ['form.submit', 'action.primary'],
76
+ a11yRules: ['A11Y_BTN_LABEL'],
77
+ bans: [{ pattern: '<Button onClick> without type', message: 'Always specify type="button" or type="submit"' }],
78
+ },
79
+ provenance: {
80
+ source: 'migrated',
81
+ verified: false,
82
+ frameworkSupport: 'native',
83
+ extractedAt: '2026-03-13T00:00:00Z',
84
+ },
85
+ });
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Fixture: Card contract (compound component)
89
+ // ---------------------------------------------------------------------------
90
+
91
+ const cardContract = JSON.stringify({
92
+ $schema: 'https://usefragments.com/schemas/contract.v1.json',
93
+ name: 'Card',
94
+ description: 'A container with header, body, and footer slots',
95
+ category: 'layout',
96
+ tags: ['container', 'surface'],
97
+ status: 'stable',
98
+ sourcePath: 'src/components/Card/index.tsx',
99
+ exportName: 'Card',
100
+ propsSummary: [
101
+ 'variant: elevated|outlined|flat (default: elevated)',
102
+ 'padding: boolean (default: true)',
103
+ ],
104
+ props: {
105
+ variant: {
106
+ type: 'enum',
107
+ values: ['elevated', 'outlined', 'flat'],
108
+ default: 'elevated',
109
+ description: 'Card visual style',
110
+ },
111
+ padding: {
112
+ type: 'boolean',
113
+ default: true,
114
+ description: 'Whether card has internal padding',
115
+ },
116
+ },
117
+ usage: {
118
+ when: ['Grouping related content with a visual boundary'],
119
+ whenNot: ['Simple text blocks without visual separation'],
120
+ },
121
+ examples: [
122
+ {
123
+ name: 'With header and body',
124
+ description: 'Common card layout',
125
+ code: '<Card><Card.Header>Title</Card.Header><Card.Body>Content</Card.Body></Card>',
126
+ },
127
+ ],
128
+ ai: {
129
+ compositionPattern: 'compound',
130
+ subComponents: ['Header', 'Body', 'Footer'],
131
+ requiredChildren: ['Body'],
132
+ commonPatterns: ['<Card><Card.Header /><Card.Body /></Card>'],
133
+ },
134
+ contract: {
135
+ compoundChildren: {
136
+ Header: { required: false, description: 'Card title area' },
137
+ Body: { required: true, description: 'Main card content' },
138
+ Footer: { required: false, accepts: ['Button'], description: 'Card action area' },
139
+ },
140
+ canonicalUsage: [
141
+ '<Card variant="elevated"><Card.Header>Title</Card.Header><Card.Body>Content</Card.Body></Card>',
142
+ ],
143
+ },
144
+ provenance: {
145
+ source: 'migrated',
146
+ verified: false,
147
+ },
148
+ });
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Fixture: Input contract (complex props)
152
+ // ---------------------------------------------------------------------------
153
+
154
+ const inputContract = JSON.stringify({
155
+ $schema: 'https://usefragments.com/schemas/contract.v1.json',
156
+ name: 'Input',
157
+ description: 'Text input field with validation support',
158
+ category: 'forms',
159
+ sourcePath: 'src/components/Input/index.tsx',
160
+ exportName: 'Input',
161
+ propsSummary: [
162
+ 'value: string',
163
+ 'onChange: function (required)',
164
+ 'placeholder: string',
165
+ 'error: string',
166
+ 'type: text|password|email|number (default: text)',
167
+ ],
168
+ props: {
169
+ value: { type: 'string', description: 'Input value' },
170
+ onChange: { type: 'function', description: 'Change handler', required: true },
171
+ placeholder: { type: 'string', description: 'Placeholder text' },
172
+ error: { type: 'string', description: 'Error message' },
173
+ type: {
174
+ type: 'enum',
175
+ values: ['text', 'password', 'email', 'number'],
176
+ default: 'text',
177
+ description: 'Input type',
178
+ },
179
+ },
180
+ usage: {
181
+ when: ['Collecting user text input'],
182
+ whenNot: ['Long-form text — use Textarea'],
183
+ },
184
+ provenance: { source: 'manual', verified: true, frameworkSupport: 'native' },
185
+ });
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Tests
189
+ // ---------------------------------------------------------------------------
190
+
191
+ describe('contract-parity', () => {
192
+ describe('Button (simple component)', () => {
193
+ const compiled = parseComponentContract(buttonContract, 'Button.contract.json');
194
+
195
+ it('has correct meta', () => {
196
+ expect(compiled.meta.name).toBe('Button');
197
+ expect(compiled.meta.category).toBe('actions');
198
+ expect(compiled.meta.status).toBe('stable');
199
+ expect(compiled.meta.tags).toEqual(['interactive', 'form']);
200
+ });
201
+
202
+ it('has correct props', () => {
203
+ expect(Object.keys(compiled.props)).toHaveLength(3);
204
+ expect(compiled.props.variant.type).toBe('enum');
205
+ expect(compiled.props.variant.values).toEqual(['primary', 'secondary', 'ghost']);
206
+ });
207
+
208
+ it('has correct usage', () => {
209
+ expect(compiled.usage.when).toHaveLength(2);
210
+ expect(compiled.usage.whenNot).toHaveLength(1);
211
+ expect(compiled.usage.guidelines).toHaveLength(1);
212
+ expect(compiled.usage.accessibility).toHaveLength(1);
213
+ });
214
+
215
+ it('has correct variants from examples', () => {
216
+ expect(compiled.variants).toHaveLength(2);
217
+ expect(compiled.variants[0].name).toBe('Primary');
218
+ expect(compiled.variants[0].code).toContain('<Button');
219
+ expect(compiled.variants[1].args).toEqual({ disabled: true });
220
+ });
221
+
222
+ it('has correct relations', () => {
223
+ expect(compiled.relations).toHaveLength(2);
224
+ expect(compiled.relations![0].component).toBe('Link');
225
+ expect(compiled.relations![0].relationship).toBe('alternative');
226
+ });
227
+
228
+ it('has contract metadata', () => {
229
+ expect(compiled.contract?.scenarioTags).toEqual(['form.submit', 'action.primary']);
230
+ expect(compiled.contract?.bans).toHaveLength(1);
231
+ });
232
+
233
+ it('has provenance', () => {
234
+ expect(compiled.provenance?.source).toBe('migrated');
235
+ expect(compiled.provenance?.verified).toBe(false);
236
+ });
237
+
238
+ it('has sourcePath and exportName', () => {
239
+ expect(compiled.sourcePath).toBe('src/components/Button/index.tsx');
240
+ expect(compiled.exportName).toBe('Button');
241
+ });
242
+
243
+ it('has propsSummary', () => {
244
+ expect(compiled.propsSummary).toHaveLength(3);
245
+ expect(compiled.propsSummary![0]).toContain('variant');
246
+ });
247
+ });
248
+
249
+ describe('Card (compound component)', () => {
250
+ const compiled = parseComponentContract(cardContract, 'Card.contract.json');
251
+
252
+ it('has AI metadata for compound composition', () => {
253
+ expect(compiled.ai?.compositionPattern).toBe('compound');
254
+ expect(compiled.ai?.subComponents).toEqual(['Header', 'Body', 'Footer']);
255
+ expect(compiled.ai?.requiredChildren).toEqual(['Body']);
256
+ });
257
+
258
+ it('has contract with compound children', () => {
259
+ expect(compiled.contract?.compoundChildren).toBeDefined();
260
+ expect(compiled.contract?.compoundChildren?.Body?.required).toBe(true);
261
+ expect(compiled.contract?.compoundChildren?.Footer?.accepts).toEqual(['Button']);
262
+ });
263
+
264
+ it('has canonical usage', () => {
265
+ expect(compiled.contract?.canonicalUsage).toHaveLength(1);
266
+ });
267
+ });
268
+
269
+ describe('Input (complex props)', () => {
270
+ const compiled = parseComponentContract(inputContract, 'Input.contract.json');
271
+
272
+ it('has required prop flagged', () => {
273
+ expect(compiled.props.onChange.required).toBe(true);
274
+ });
275
+
276
+ it('has verified provenance', () => {
277
+ expect(compiled.provenance?.verified).toBe(true);
278
+ });
279
+
280
+ it('has correct prop count', () => {
281
+ expect(Object.keys(compiled.props)).toHaveLength(5);
282
+ });
283
+ });
284
+
285
+ describe('structural invariants', () => {
286
+ const contracts = [
287
+ { name: 'Button', json: buttonContract },
288
+ { name: 'Card', json: cardContract },
289
+ { name: 'Input', json: inputContract },
290
+ ];
291
+
292
+ for (const { name, json } of contracts) {
293
+ it(`${name}: output has all required CompiledFragment fields`, () => {
294
+ const compiled = parseComponentContract(json, `${name}.contract.json`);
295
+
296
+ // Required structural fields
297
+ expect(compiled).toHaveProperty('filePath');
298
+ expect(compiled).toHaveProperty('meta');
299
+ expect(compiled).toHaveProperty('usage');
300
+ expect(compiled).toHaveProperty('props');
301
+ expect(compiled).toHaveProperty('variants');
302
+
303
+ // Meta required fields
304
+ expect(compiled.meta.name).toBeTruthy();
305
+ expect(compiled.meta.description).toBeTruthy();
306
+ expect(compiled.meta.category).toBeTruthy();
307
+
308
+ // V2 fields
309
+ expect(compiled).toHaveProperty('propsSummary');
310
+ expect(compiled).toHaveProperty('provenance');
311
+ expect(compiled).toHaveProperty('sourcePath');
312
+ expect(compiled).toHaveProperty('exportName');
313
+ });
314
+ }
315
+ });
316
+ });
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { resolveTokensWithSass } from '../token-resolver.js';
4
+ import { resolveTokensWithSass } from '@fragments-sdk/extract';
5
5
 
6
6
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
7
7
 
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest';
2
2
  import { resolve, join } from 'node:path';
3
- import { createComponentExtractor, type ComponentExtractor, type ComponentMeta } from './component-extractor.js';
3
+ import { createComponentExtractor, type ComponentExtractor, type ComponentMeta } from '@fragments-sdk/extract';
4
4
 
5
5
  // ---------------------------------------------------------------------------
6
6
  // Setup
@@ -279,4 +279,43 @@ describe('source attribution', () => {
279
279
  expect(meta!.props.className.source).toBe('inherited');
280
280
  }
281
281
  });
282
+
283
+ it('marks destructured inherited props as local for wrapper components', async () => {
284
+ const fixtureDir = join(__dirname, '__fixtures__', 'shadcn-input');
285
+ const filePath = join(fixtureDir, 'input.tsx');
286
+ const tsconfigPath = join(fixtureDir, 'tsconfig.json');
287
+
288
+ extractor = createComponentExtractor(tsconfigPath);
289
+ const meta = extractor.extract(filePath, 'Input');
290
+
291
+ expect(meta).not.toBeNull();
292
+ expect(meta!.props.type).toBeDefined();
293
+ expect(meta!.props.type.source).toBe('local');
294
+ });
295
+
296
+ it('extracts inherited props from ComponentProps<typeof Primitive.Root> wrappers', async () => {
297
+ const fixtureDir = join(__dirname, '__fixtures__', 'shadcn-label');
298
+ const filePath = join(fixtureDir, 'label.tsx');
299
+ const tsconfigPath = join(fixtureDir, 'tsconfig.json');
300
+
301
+ extractor = createComponentExtractor(tsconfigPath);
302
+ const meta = extractor.extract(filePath, 'Label');
303
+
304
+ expect(meta).not.toBeNull();
305
+ expect(meta!.props.htmlFor).toBeDefined();
306
+ expect(meta!.props.form).toBeDefined();
307
+ });
308
+
309
+ it('extracts props from package-backed Primitive.Root wrappers', async () => {
310
+ const fixtureDir = join(__dirname, '__fixtures__', 'shadcn-radix-label');
311
+ const filePath = join(fixtureDir, 'label.tsx');
312
+ const tsconfigPath = join(fixtureDir, 'tsconfig.json');
313
+
314
+ extractor = createComponentExtractor(tsconfigPath);
315
+ const meta = extractor.extract(filePath, 'Label');
316
+
317
+ expect(meta).not.toBeNull();
318
+ expect(meta!.props.htmlFor).toBeDefined();
319
+ expect(meta!.props.form).toBeDefined();
320
+ });
282
321
  });
@@ -13,7 +13,8 @@ const STORYBOOK_FILTER_DEFAULTS: StorybookFilterConfig = {
13
13
 
14
14
  const DEFAULT_CONFIG: FragmentsConfig = {
15
15
  include: [
16
- `src/**/*${BRAND.fileExtension}`, // *.fragment.tsx files
16
+ `src/**/*${BRAND.fileExtension}`, // *.contract.json files (V2 canonical)
17
+ 'src/**/*.fragment.tsx', // Legacy fragment files (still supported)
17
18
  'src/**/*.stories.tsx', // Storybook stories (auto-converted)
18
19
  ],
19
20
  exclude: ['**/node_modules/**'],
@@ -110,7 +110,8 @@ export async function discoverBlockFiles(
110
110
  export const discoverRecipeFiles = discoverBlockFiles;
111
111
 
112
112
  /**
113
- * Discover fragment files matching the config patterns
113
+ * Discover fragment files matching the config patterns.
114
+ * Also discovers .contract.json files for framework-agnostic component contracts.
114
115
  */
115
116
  export async function discoverFragmentFiles(
116
117
  config: FragmentsConfig,
@@ -122,7 +123,14 @@ export async function discoverFragmentFiles(
122
123
  '**/*.test.story.*',
123
124
  '**/*.story.test.*',
124
125
  ];
125
- const files = await fg(config.include, {
126
+
127
+ // Include .contract.json files alongside configured patterns
128
+ const patterns = [
129
+ ...config.include,
130
+ '**/*.contract.json',
131
+ ];
132
+
133
+ const files = await fg(patterns, {
126
134
  cwd: configDir,
127
135
  ignore: [...defaultExcludes, ...(config.exclude ?? [])],
128
136
  absolute: false,
@@ -408,6 +416,9 @@ const DEFAULT_TOKEN_PATTERNS = [
408
416
  'src/**/styles/**/variables.css',
409
417
  'src/**/theme/**/_variables.scss',
410
418
  'src/**/theme/**/tokens.css',
419
+ // DTCG token files
420
+ '**/*.tokens.json',
421
+ '**/*.tokens',
411
422
  ];
412
423
 
413
424
  /**
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Contract Drift Verifier
3
+ *
4
+ * Detects mismatches between a .contract.json and the live component source.
5
+ * Used during build to enforce verified contracts stay in sync.
6
+ */
7
+
8
+ import type { CompiledContractOutput } from '@fragments-sdk/core';
9
+ import type { ComponentMeta } from '@fragments-sdk/extract';
10
+
11
+ export interface DriftReport {
12
+ componentName: string;
13
+ /** Props in contract but absent from source */
14
+ removedProps: string[];
15
+ /** Props in source but absent from contract */
16
+ undocumentedProps: string[];
17
+ /** Props where contract type doesn't match source type */
18
+ typeMismatches: Array<{ prop: string; contract: string; source: string }>;
19
+ /** Props where contract default doesn't match source default */
20
+ defaultMismatches: Array<{ prop: string; contract: unknown; source: unknown }>;
21
+ /** Overall clean status */
22
+ isClean: boolean;
23
+ }
24
+
25
+ /**
26
+ * Compare a parsed contract against extracted component metadata.
27
+ */
28
+ export function verifyContractDrift(
29
+ contract: CompiledContractOutput,
30
+ extracted: ComponentMeta,
31
+ ): DriftReport {
32
+ const contractPropNames = new Set(Object.keys(contract.props));
33
+ const sourcePropNames = new Set(
34
+ Object.entries(extracted.props)
35
+ .filter(([, p]) => p.source === 'local')
36
+ .map(([name]) => name),
37
+ );
38
+
39
+ const removedProps: string[] = [];
40
+ const undocumentedProps: string[] = [];
41
+ const typeMismatches: DriftReport['typeMismatches'] = [];
42
+ const defaultMismatches: DriftReport['defaultMismatches'] = [];
43
+
44
+ // Props in contract but not in source
45
+ for (const name of contractPropNames) {
46
+ if (!sourcePropNames.has(name)) {
47
+ removedProps.push(name);
48
+ }
49
+ }
50
+
51
+ // Props in source but not in contract
52
+ for (const name of sourcePropNames) {
53
+ if (!contractPropNames.has(name)) {
54
+ undocumentedProps.push(name);
55
+ }
56
+ }
57
+
58
+ // Type and default mismatches for props present in both
59
+ for (const name of contractPropNames) {
60
+ const contractProp = contract.props[name];
61
+ const sourceProp = extracted.props[name];
62
+ if (!sourceProp || sourceProp.source !== 'local') continue;
63
+
64
+ // Type comparison (contract uses simple string, source uses typeKind)
65
+ if (contractProp.type !== sourceProp.typeKind) {
66
+ typeMismatches.push({
67
+ prop: name,
68
+ contract: contractProp.type,
69
+ source: sourceProp.typeKind,
70
+ });
71
+ }
72
+
73
+ // Default comparison
74
+ const contractDefault = contractProp.default;
75
+ const sourceDefault = sourceProp.default;
76
+ if (contractDefault !== undefined && sourceDefault !== undefined) {
77
+ if (String(contractDefault) !== String(sourceDefault)) {
78
+ defaultMismatches.push({
79
+ prop: name,
80
+ contract: contractDefault,
81
+ source: sourceDefault,
82
+ });
83
+ }
84
+ }
85
+ }
86
+
87
+ const isClean =
88
+ removedProps.length === 0 &&
89
+ undocumentedProps.length === 0 &&
90
+ typeMismatches.length === 0 &&
91
+ defaultMismatches.length === 0;
92
+
93
+ return {
94
+ componentName: contract.meta.name,
95
+ removedProps,
96
+ undocumentedProps,
97
+ typeMismatches,
98
+ defaultMismatches,
99
+ isClean,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Format a DriftReport into a human-readable string.
105
+ */
106
+ export function formatDriftReport(report: DriftReport): string {
107
+ const lines: string[] = [`Drift detected for ${report.componentName}:`];
108
+
109
+ if (report.removedProps.length > 0) {
110
+ lines.push(` Removed from source: ${report.removedProps.join(', ')}`);
111
+ }
112
+ if (report.undocumentedProps.length > 0) {
113
+ lines.push(` Missing from contract: ${report.undocumentedProps.join(', ')}`);
114
+ }
115
+ for (const m of report.typeMismatches) {
116
+ lines.push(` Type mismatch: ${m.prop} (contract: ${m.contract}, source: ${m.source})`);
117
+ }
118
+ for (const m of report.defaultMismatches) {
119
+ lines.push(` Default mismatch: ${m.prop} (contract: ${String(m.contract)}, source: ${String(m.source)})`);
120
+ }
121
+
122
+ return lines.join('\n');
123
+ }