@fragments-sdk/cli 0.14.2 → 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,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
|
+
});
|
|
@@ -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
|
});
|
|
@@ -128,6 +128,24 @@ export function createComponentExtractor(tsconfigPath?: string): ComponentExtrac
|
|
|
128
128
|
readDirectory: ts.sys.readDirectory,
|
|
129
129
|
directoryExists: ts.sys.directoryExists,
|
|
130
130
|
getDirectories: ts.sys.getDirectories,
|
|
131
|
+
resolveModuleNames: (moduleNames, containingFile) =>
|
|
132
|
+
moduleNames.map((moduleName) => {
|
|
133
|
+
const resolved = ts.resolveModuleName(
|
|
134
|
+
moduleName,
|
|
135
|
+
containingFile,
|
|
136
|
+
parsedCommandLine.options,
|
|
137
|
+
{
|
|
138
|
+
fileExists: ts.sys.fileExists,
|
|
139
|
+
readFile: ts.sys.readFile,
|
|
140
|
+
realpath: ts.sys.realpath,
|
|
141
|
+
directoryExists: ts.sys.directoryExists,
|
|
142
|
+
getDirectories: ts.sys.getDirectories,
|
|
143
|
+
getCurrentDirectory: () => rootDir,
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return resolved.resolvedModule;
|
|
148
|
+
}),
|
|
131
149
|
};
|
|
132
150
|
|
|
133
151
|
const languageService = ts.createLanguageService(host, ts.createDocumentRegistry());
|
|
@@ -605,7 +623,27 @@ function extractPropsFromFunctionLike(
|
|
|
605
623
|
|
|
606
624
|
// If there's a type annotation, use it
|
|
607
625
|
if (firstParam.type) {
|
|
608
|
-
|
|
626
|
+
const directType = checker.getTypeFromTypeNode(firstParam.type);
|
|
627
|
+
if (checker.getPropertiesOfType(directType).length > 0) {
|
|
628
|
+
return directType;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const apparentType = checker.getApparentType(directType);
|
|
632
|
+
if (checker.getPropertiesOfType(apparentType).length > 0) {
|
|
633
|
+
return apparentType;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const paramType = checker.getTypeAtLocation(firstParam);
|
|
637
|
+
if (checker.getPropertiesOfType(paramType).length > 0) {
|
|
638
|
+
return paramType;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const constrainedType = checker.getBaseConstraintOfType(directType);
|
|
642
|
+
if (constrainedType && checker.getPropertiesOfType(constrainedType).length > 0) {
|
|
643
|
+
return constrainedType;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return directType;
|
|
609
647
|
}
|
|
610
648
|
|
|
611
649
|
// Otherwise try to infer from the parameter's symbol
|
|
@@ -633,6 +671,29 @@ function buildComponentMeta(
|
|
|
633
671
|
|
|
634
672
|
if (component.propsType) {
|
|
635
673
|
extractPropsFromType(checker, component.propsType, props, sourceFilePath);
|
|
674
|
+
if (Object.keys(props).length === 0) {
|
|
675
|
+
const apparentPropsType = checker.getApparentType(component.propsType);
|
|
676
|
+
if (apparentPropsType !== component.propsType) {
|
|
677
|
+
extractPropsFromType(checker, apparentPropsType, props, sourceFilePath);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (Object.keys(props).length === 0) {
|
|
681
|
+
const constrainedPropsType = checker.getBaseConstraintOfType(component.propsType);
|
|
682
|
+
if (constrainedPropsType) {
|
|
683
|
+
extractPropsFromType(checker, constrainedPropsType, props, sourceFilePath);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Promote explicitly destructured props to "local" so wrapper components like
|
|
689
|
+
// shadcn Input surface the props they actively customize from inherited DOM types.
|
|
690
|
+
const destructuredProps = component.componentNode
|
|
691
|
+
? extractDestructuredPropNames(component.componentNode)
|
|
692
|
+
: new Set<string>();
|
|
693
|
+
for (const propName of destructuredProps) {
|
|
694
|
+
if (props[propName]) {
|
|
695
|
+
props[propName].source = 'local';
|
|
696
|
+
}
|
|
636
697
|
}
|
|
637
698
|
|
|
638
699
|
// Extract defaults from the component function body
|
|
@@ -876,6 +937,36 @@ function extractDefaultValues(node: ts.Node): Record<string, string> {
|
|
|
876
937
|
return defaults;
|
|
877
938
|
}
|
|
878
939
|
|
|
940
|
+
function extractDestructuredPropNames(node: ts.Node): Set<string> {
|
|
941
|
+
const names = new Set<string>();
|
|
942
|
+
|
|
943
|
+
let funcNode: ts.FunctionLikeDeclaration | null = null;
|
|
944
|
+
if (ts.isFunctionDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
945
|
+
funcNode = node;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if (!funcNode?.parameters?.length) return names;
|
|
949
|
+
|
|
950
|
+
const firstParam = funcNode.parameters[0];
|
|
951
|
+
if (!ts.isObjectBindingPattern(firstParam.name)) return names;
|
|
952
|
+
|
|
953
|
+
for (const element of firstParam.name.elements) {
|
|
954
|
+
if (element.dotDotDotToken || !ts.isIdentifier(element.name)) continue;
|
|
955
|
+
|
|
956
|
+
const propName = element.propertyName
|
|
957
|
+
? element.propertyName.getText()
|
|
958
|
+
: element.name.text;
|
|
959
|
+
|
|
960
|
+
if (propName === 'className' || propName === 'children' || propName === 'ref') {
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
names.add(propName);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
return names;
|
|
968
|
+
}
|
|
969
|
+
|
|
879
970
|
function readLiteralValue(expression: ts.Expression): string | undefined {
|
|
880
971
|
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
|
|
881
972
|
return expression.text;
|
package/src/core/config.ts
CHANGED
|
@@ -13,7 +13,8 @@ const STORYBOOK_FILTER_DEFAULTS: StorybookFilterConfig = {
|
|
|
13
13
|
|
|
14
14
|
const DEFAULT_CONFIG: FragmentsConfig = {
|
|
15
15
|
include: [
|
|
16
|
-
`src/**/*${BRAND.fileExtension}`, // *.
|
|
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/**'],
|
package/src/core/discovery.ts
CHANGED
|
@@ -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
|
-
|
|
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 './component-extractor.js';
|
|
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
|
+
}
|