@fragments-sdk/cli 0.11.1 → 0.12.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.
- package/dist/ai-client-I6MDWNYA.js +21 -0
- package/dist/bin.js +275 -368
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-PW7QTQA6.js → chunk-4OC7FTJB.js} +2 -2
- package/dist/{chunk-HRFUSSZI.js → chunk-AM4MRTMN.js} +2 -2
- package/dist/{chunk-5G3VZH43.js → chunk-GVDSFQ4E.js} +281 -351
- package/dist/chunk-GVDSFQ4E.js.map +1 -0
- package/dist/chunk-JJ2VRTBU.js +626 -0
- package/dist/chunk-JJ2VRTBU.js.map +1 -0
- package/dist/{chunk-D5PYOXEI.js → chunk-LVWFOLUZ.js} +148 -13
- package/dist/{chunk-D5PYOXEI.js.map → chunk-LVWFOLUZ.js.map} +1 -1
- package/dist/{chunk-WXSR2II7.js → chunk-OQKMEFOS.js} +58 -6
- package/dist/chunk-OQKMEFOS.js.map +1 -0
- package/dist/chunk-SXTKFDCR.js +104 -0
- package/dist/chunk-SXTKFDCR.js.map +1 -0
- package/dist/chunk-T5OMVL7E.js +443 -0
- package/dist/chunk-T5OMVL7E.js.map +1 -0
- package/dist/{chunk-ZM4ZQZWZ.js → chunk-TPWGL2XS.js} +39 -37
- package/dist/chunk-TPWGL2XS.js.map +1 -0
- package/dist/{chunk-OQO55NKV.js → chunk-WFS63PCW.js} +85 -11
- package/dist/chunk-WFS63PCW.js.map +1 -0
- package/dist/core/index.js +9 -1
- package/dist/{discovery-NEOY4MPN.js → discovery-ZJQSXF56.js} +3 -3
- package/dist/{generate-FBHSXR3D.js → generate-RJFS2JWA.js} +4 -4
- package/dist/index.js +7 -6
- package/dist/index.js.map +1 -1
- package/dist/init-ZSX3NRCZ.js +636 -0
- package/dist/init-ZSX3NRCZ.js.map +1 -0
- package/dist/mcp-bin.js +2 -2
- package/dist/{scan-CJF2DOQW.js → scan-3PMCJ4RB.js} +6 -6
- package/dist/scan-generate-SYU4PYZD.js +1115 -0
- package/dist/scan-generate-SYU4PYZD.js.map +1 -0
- package/dist/{service-TQYWY65E.js → service-VMGNJZ42.js} +3 -3
- package/dist/{snapshot-SV2JOFZH.js → snapshot-XOISO2IS.js} +2 -2
- package/dist/{static-viewer-NUBFPKWH.js → static-viewer-5GXH2MGE.js} +3 -3
- package/dist/static-viewer-5GXH2MGE.js.map +1 -0
- package/dist/{test-Z5LVO724.js → test-SI4NSHQX.js} +4 -4
- package/dist/{tokens-CE46OTMD.js → tokens-T6SIVUT5.js} +5 -5
- package/dist/{viewer-DLLJIMCK.js → viewer-7ZEAFBVN.js} +13 -13
- package/package.json +4 -4
- package/src/ai-client.ts +156 -0
- package/src/bin.ts +44 -2
- package/src/build.ts +95 -33
- package/src/commands/__tests__/drift-sync.test.ts +252 -0
- package/src/commands/__tests__/scan-generate.test.ts +497 -45
- package/src/commands/enhance.ts +11 -35
- package/src/commands/init.ts +288 -260
- package/src/commands/scan-generate.ts +740 -139
- package/src/commands/scan.ts +37 -32
- package/src/commands/setup.ts +143 -52
- package/src/commands/sync.ts +357 -0
- package/src/commands/validate.ts +43 -1
- package/src/core/component-extractor.test.ts +282 -0
- package/src/core/component-extractor.ts +1030 -0
- package/src/core/discovery.ts +93 -7
- package/src/service/enhance/props-extractor.ts +235 -13
- package/src/validators.ts +236 -0
- package/dist/chunk-5G3VZH43.js.map +0 -1
- package/dist/chunk-OQO55NKV.js.map +0 -1
- package/dist/chunk-WXSR2II7.js.map +0 -1
- package/dist/chunk-ZM4ZQZWZ.js.map +0 -1
- package/dist/init-UFGK5TCN.js +0 -867
- package/dist/init-UFGK5TCN.js.map +0 -1
- package/dist/scan-generate-SJAN5MVI.js +0 -691
- package/dist/scan-generate-SJAN5MVI.js.map +0 -1
- package/src/ai.ts +0 -266
- package/src/commands/init-framework.ts +0 -414
- package/src/mcp/bin.ts +0 -36
- package/src/migrate/bin.ts +0 -114
- package/src/theme/index.ts +0 -77
- package/src/viewer/bin.ts +0 -86
- package/src/viewer/cli/health.ts +0 -256
- package/src/viewer/cli/index.ts +0 -33
- package/src/viewer/cli/scan.ts +0 -124
- package/src/viewer/cli/utils.ts +0 -174
- /package/dist/{discovery-NEOY4MPN.js.map → ai-client-I6MDWNYA.js.map} +0 -0
- /package/dist/{chunk-PW7QTQA6.js.map → chunk-4OC7FTJB.js.map} +0 -0
- /package/dist/{chunk-HRFUSSZI.js.map → chunk-AM4MRTMN.js.map} +0 -0
- /package/dist/{scan-CJF2DOQW.js.map → discovery-ZJQSXF56.js.map} +0 -0
- /package/dist/{generate-FBHSXR3D.js.map → generate-RJFS2JWA.js.map} +0 -0
- /package/dist/{service-TQYWY65E.js.map → scan-3PMCJ4RB.js.map} +0 -0
- /package/dist/{static-viewer-NUBFPKWH.js.map → service-VMGNJZ42.js.map} +0 -0
- /package/dist/{snapshot-SV2JOFZH.js.map → snapshot-XOISO2IS.js.map} +0 -0
- /package/dist/{test-Z5LVO724.js.map → test-SI4NSHQX.js.map} +0 -0
- /package/dist/{tokens-CE46OTMD.js.map → tokens-T6SIVUT5.js.map} +0 -0
- /package/dist/{viewer-DLLJIMCK.js.map → viewer-7ZEAFBVN.js.map} +0 -0
package/src/bin.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { graph } from './commands/graph.js';
|
|
|
39
39
|
import { perf } from './commands/perf.js';
|
|
40
40
|
import { doctor } from './commands/doctor.js';
|
|
41
41
|
import { setup } from './commands/setup.js';
|
|
42
|
+
import { sync } from './commands/sync.js';
|
|
42
43
|
|
|
43
44
|
// Import existing commands that were already extracted
|
|
44
45
|
import { runScreenshotCommand } from './screenshot.js';
|
|
@@ -62,6 +63,8 @@ program
|
|
|
62
63
|
.option('--schema', 'Validate fragment schema only')
|
|
63
64
|
.option('--coverage', 'Validate coverage only')
|
|
64
65
|
.option('--snippets', 'Validate snippet/render policy only')
|
|
66
|
+
.option('--drift', 'Detect metadata drift between source and fragments')
|
|
67
|
+
.option('--tsconfig <path>', 'Path to tsconfig.json (for drift detection)')
|
|
65
68
|
.option('--snippet-mode <mode>', 'Override snippet policy mode (warn|error)')
|
|
66
69
|
.option('--component-start <name>', 'Start component name for alphabetical snippet batch validation')
|
|
67
70
|
.option('--component-limit <n>', 'Component count for alphabetical snippet batch validation', (value) => Number.parseInt(value, 10))
|
|
@@ -77,6 +80,33 @@ program
|
|
|
77
80
|
}
|
|
78
81
|
});
|
|
79
82
|
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// SYNC COMMAND
|
|
85
|
+
// ============================================================================
|
|
86
|
+
program
|
|
87
|
+
.command('sync')
|
|
88
|
+
.description('Auto-update fragment files from component source')
|
|
89
|
+
.option('-c, --config <path>', 'Path to config file')
|
|
90
|
+
.option('--tsconfig <path>', 'Path to tsconfig.json')
|
|
91
|
+
.option('--dry-run', 'Preview changes without writing')
|
|
92
|
+
.option('--component <name>', 'Sync specific component only')
|
|
93
|
+
.action(async (options) => {
|
|
94
|
+
try {
|
|
95
|
+
const result = await sync({
|
|
96
|
+
config: options.config,
|
|
97
|
+
tsconfig: options.tsconfig,
|
|
98
|
+
dryRun: options.dryRun,
|
|
99
|
+
component: options.component,
|
|
100
|
+
});
|
|
101
|
+
if (!result.success) {
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error(pc.red('Error:'), error instanceof Error ? error.message : error);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
80
110
|
// ============================================================================
|
|
81
111
|
// BUILD COMMAND
|
|
82
112
|
// ============================================================================
|
|
@@ -794,10 +824,16 @@ program
|
|
|
794
824
|
// ============================================================================
|
|
795
825
|
program
|
|
796
826
|
.command('init')
|
|
797
|
-
.description('Initialize fragments in a project (
|
|
827
|
+
.description('Initialize fragments in a project (zero-config by default)')
|
|
798
828
|
.option('--force', 'Overwrite existing config')
|
|
799
|
-
.option('-y, --yes', 'Non-interactive mode
|
|
829
|
+
.option('-y, --yes', 'Non-interactive mode (now the default)')
|
|
830
|
+
.option('--configure', 'Interactive mode for theme seeds, snapshots, etc.')
|
|
800
831
|
.option('--scan <path>', 'Scan a TypeScript component directory and generate fragment files')
|
|
832
|
+
.option('--enrich', 'Use AI to fill knowledge fields during --scan (requires API key)')
|
|
833
|
+
.option('--dry-run', 'Show what --enrich would generate without calling API')
|
|
834
|
+
.option('--provider <provider>', 'AI provider for enrichment: anthropic or openai')
|
|
835
|
+
.option('--api-key <key>', 'API key for AI enrichment')
|
|
836
|
+
.option('--model <model>', 'Override AI model for enrichment')
|
|
801
837
|
.action(async (options) => {
|
|
802
838
|
try {
|
|
803
839
|
const { init } = await import('./commands/init.js');
|
|
@@ -806,6 +842,12 @@ program
|
|
|
806
842
|
force: options.force,
|
|
807
843
|
yes: options.scan ? true : options.yes,
|
|
808
844
|
scan: options.scan,
|
|
845
|
+
configure: options.configure,
|
|
846
|
+
enrich: options.enrich,
|
|
847
|
+
dryRun: options.dryRun,
|
|
848
|
+
provider: options.provider,
|
|
849
|
+
apiKey: options.apiKey,
|
|
850
|
+
model: options.model,
|
|
809
851
|
});
|
|
810
852
|
|
|
811
853
|
if (!result.success) {
|
package/src/build.ts
CHANGED
|
@@ -21,10 +21,13 @@ import {
|
|
|
21
21
|
generateContextMd,
|
|
22
22
|
} from "./core/node.js";
|
|
23
23
|
import {
|
|
24
|
-
extractCustomPropsFromComponentFile,
|
|
25
24
|
resolveComponentSourcePath,
|
|
26
|
-
type AutoDetectedPropDefinition,
|
|
27
25
|
} from "./core/auto-props.js";
|
|
26
|
+
import {
|
|
27
|
+
createComponentExtractor,
|
|
28
|
+
type PropMeta,
|
|
29
|
+
type ComponentMeta,
|
|
30
|
+
} from "./core/component-extractor.js";
|
|
28
31
|
import { buildComponentGraph } from "./core/graph-extractor.js";
|
|
29
32
|
import { serializeGraph } from "@fragments-sdk/context/graph";
|
|
30
33
|
import { resolvePerformanceConfig } from "./core/index.js";
|
|
@@ -53,17 +56,21 @@ function normalizeParsedProps(
|
|
|
53
56
|
|
|
54
57
|
function mergeDocumentedAndAutoProps(
|
|
55
58
|
documentedProps: Record<string, CompiledProp>,
|
|
56
|
-
autoProps: Record<string,
|
|
59
|
+
autoProps: Record<string, PropMeta>
|
|
57
60
|
): Record<string, CompiledProp> {
|
|
58
61
|
return Object.fromEntries(
|
|
59
|
-
Object.keys(autoProps)
|
|
62
|
+
Object.keys(autoProps)
|
|
63
|
+
// Strip inherited HTML/React props — they're identical across all components
|
|
64
|
+
// and bloat fragments.json. MCP consumers know these exist implicitly.
|
|
65
|
+
.filter((name) => autoProps[name].source === 'local' || name in documentedProps)
|
|
66
|
+
.map((name) => {
|
|
60
67
|
const documented = documentedProps[name];
|
|
61
68
|
const auto = autoProps[name];
|
|
62
69
|
|
|
63
70
|
return [
|
|
64
71
|
name,
|
|
65
72
|
{
|
|
66
|
-
type: auto.
|
|
73
|
+
type: auto.typeKind,
|
|
67
74
|
description: documented?.description ?? auto.description ?? "",
|
|
68
75
|
default: auto.default !== undefined ? auto.default : documented?.default,
|
|
69
76
|
required: auto.required,
|
|
@@ -75,6 +82,30 @@ function mergeDocumentedAndAutoProps(
|
|
|
75
82
|
);
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Auto-compile a propsSummary for the contract from extracted props.
|
|
87
|
+
* Format: "variant: primary|secondary|ghost (required)"
|
|
88
|
+
*/
|
|
89
|
+
function compilePropsSummary(props: Record<string, PropMeta>): string[] {
|
|
90
|
+
return Object.entries(props)
|
|
91
|
+
.filter(([_, p]) => p.source === 'local')
|
|
92
|
+
.map(([name, prop]) => {
|
|
93
|
+
let summary = name + ': ';
|
|
94
|
+
if (prop.values && prop.values.length > 0) {
|
|
95
|
+
summary += prop.values.join('|');
|
|
96
|
+
} else {
|
|
97
|
+
summary += prop.typeKind;
|
|
98
|
+
}
|
|
99
|
+
if (prop.default !== undefined) {
|
|
100
|
+
summary += ` (default: ${prop.default})`;
|
|
101
|
+
}
|
|
102
|
+
if (prop.required) {
|
|
103
|
+
summary += ' (required)';
|
|
104
|
+
}
|
|
105
|
+
return summary;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
78
109
|
export interface BuildResult {
|
|
79
110
|
success: boolean;
|
|
80
111
|
outputPath: string;
|
|
@@ -98,6 +129,15 @@ export async function buildFragments(
|
|
|
98
129
|
const warnings: Array<{ file: string; warning: string }> = [];
|
|
99
130
|
const fragments: CompiledFragmentsFile["fragments"] = {};
|
|
100
131
|
|
|
132
|
+
// Create a persistent extractor — shared LanguageService across all fragments
|
|
133
|
+
// Try to find a tsconfig.json in the config directory
|
|
134
|
+
const tsconfigCandidates = [
|
|
135
|
+
resolve(configDir, 'tsconfig.json'),
|
|
136
|
+
resolve(configDir, '..', 'tsconfig.json'),
|
|
137
|
+
];
|
|
138
|
+
const tsconfigPath = tsconfigCandidates.find((p) => existsSync(p));
|
|
139
|
+
const extractor = createComponentExtractor(tsconfigPath);
|
|
140
|
+
|
|
101
141
|
for (const file of files) {
|
|
102
142
|
try {
|
|
103
143
|
// Read file content as text
|
|
@@ -139,38 +179,38 @@ export async function buildFragments(
|
|
|
139
179
|
parsed.componentImport
|
|
140
180
|
);
|
|
141
181
|
|
|
182
|
+
// Extract full component metadata using persistent LanguageService
|
|
183
|
+
let extractedMeta: ComponentMeta | null = null;
|
|
142
184
|
if (componentExportName && componentSourcePath) {
|
|
143
|
-
|
|
144
|
-
componentSourcePath,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
for (const warning of autoPropsResult.warnings) {
|
|
149
|
-
warnings.push({ file: file.relativePath, warning });
|
|
185
|
+
try {
|
|
186
|
+
extractedMeta = extractor.extract(componentSourcePath, componentExportName);
|
|
187
|
+
} catch {
|
|
188
|
+
// Extraction failure is non-fatal — fall back to documented props
|
|
150
189
|
}
|
|
151
190
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
(propName) => !(propName in autoPropsResult.props)
|
|
156
|
-
);
|
|
191
|
+
if (extractedMeta) {
|
|
192
|
+
const autoProps = extractedMeta.props;
|
|
193
|
+
const hasAutoProps = Object.keys(autoProps).length > 0;
|
|
157
194
|
|
|
158
|
-
if (
|
|
195
|
+
if (hasAutoProps) {
|
|
196
|
+
const removedDocumentedProps = Object.keys(documentedProps).filter(
|
|
197
|
+
(propName) => !(propName in autoProps)
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
if (removedDocumentedProps.length > 0) {
|
|
201
|
+
warnings.push({
|
|
202
|
+
file: file.relativePath,
|
|
203
|
+
warning: `Removed ${removedDocumentedProps.length} documented props not present in source API: ${removedDocumentedProps.join(", ")}`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
mergedProps = mergeDocumentedAndAutoProps(documentedProps, autoProps);
|
|
208
|
+
} else if (Object.keys(documentedProps).length > 0) {
|
|
159
209
|
warnings.push({
|
|
160
210
|
file: file.relativePath,
|
|
161
|
-
warning:
|
|
211
|
+
warning: "Auto-props extraction returned no props; falling back to documented props",
|
|
162
212
|
});
|
|
163
213
|
}
|
|
164
|
-
|
|
165
|
-
mergedProps = mergeDocumentedAndAutoProps(
|
|
166
|
-
documentedProps,
|
|
167
|
-
autoPropsResult.props
|
|
168
|
-
);
|
|
169
|
-
} else if (autoPropsResult.resolved && !hasAutoProps && Object.keys(documentedProps).length > 0) {
|
|
170
|
-
warnings.push({
|
|
171
|
-
file: file.relativePath,
|
|
172
|
-
warning: "Auto-props extraction returned no custom props; falling back to documented props",
|
|
173
|
-
});
|
|
174
214
|
}
|
|
175
215
|
} else if (!componentExportName) {
|
|
176
216
|
warnings.push({
|
|
@@ -184,6 +224,26 @@ export async function buildFragments(
|
|
|
184
224
|
});
|
|
185
225
|
}
|
|
186
226
|
|
|
227
|
+
// Auto-compile contract if not manually authored
|
|
228
|
+
let contract = parsed.contract;
|
|
229
|
+
if (!contract?.propsSummary && extractedMeta) {
|
|
230
|
+
const summary = compilePropsSummary(extractedMeta.props);
|
|
231
|
+
if (summary.length > 0) {
|
|
232
|
+
contract = { ...contract, propsSummary: summary };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Auto-enrich AI metadata from extractor's composition data
|
|
237
|
+
let ai = parsed.ai;
|
|
238
|
+
if (extractedMeta?.composition) {
|
|
239
|
+
const comp = extractedMeta.composition;
|
|
240
|
+
ai = {
|
|
241
|
+
compositionPattern: comp.pattern,
|
|
242
|
+
subComponents: comp.parts.map((p) => p.name),
|
|
243
|
+
...ai, // Manually authored ai fields take precedence
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
187
247
|
// Build compiled fragment from parsed metadata
|
|
188
248
|
const compiled: CompiledFragment = {
|
|
189
249
|
filePath: file.relativePath,
|
|
@@ -221,10 +281,10 @@ export async function buildFragments(
|
|
|
221
281
|
...(v.figma && { figma: v.figma }),
|
|
222
282
|
...(v.args && { args: v.args }),
|
|
223
283
|
})),
|
|
224
|
-
// Include AI metadata
|
|
225
|
-
...(
|
|
226
|
-
// Include contract metadata
|
|
227
|
-
...(
|
|
284
|
+
// Include AI metadata (auto-enriched or manual)
|
|
285
|
+
...(ai && { ai }),
|
|
286
|
+
// Include contract metadata (auto-compiled or manual)
|
|
287
|
+
...(contract && { contract }),
|
|
228
288
|
};
|
|
229
289
|
|
|
230
290
|
fragments[parsed.meta.name] = compiled;
|
|
@@ -236,6 +296,8 @@ export async function buildFragments(
|
|
|
236
296
|
}
|
|
237
297
|
}
|
|
238
298
|
|
|
299
|
+
extractor.dispose();
|
|
300
|
+
|
|
239
301
|
// Discover and compile block files
|
|
240
302
|
const blocks: Record<string, CompiledBlock> = {};
|
|
241
303
|
try {
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { mkdtemp, writeFile, mkdir, rm, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { diffProps, validateDrift, type DriftItem } from '../../validators.js';
|
|
6
|
+
import type { PropMeta } from '../../core/component-extractor.js';
|
|
7
|
+
import type { FragmentsConfig } from '@fragments-sdk/core';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Helper: build a PropMeta for tests
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
function makeProp(overrides: Partial<PropMeta> & { name: string }): PropMeta {
|
|
14
|
+
return {
|
|
15
|
+
type: overrides.type ?? 'string',
|
|
16
|
+
typeKind: overrides.typeKind ?? 'string',
|
|
17
|
+
required: overrides.required ?? false,
|
|
18
|
+
source: overrides.source ?? 'local',
|
|
19
|
+
name: overrides.name,
|
|
20
|
+
...(overrides.values && { values: overrides.values }),
|
|
21
|
+
...(overrides.default !== undefined && { default: overrides.default }),
|
|
22
|
+
...(overrides.description && { description: overrides.description }),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Unit tests: diffProps
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
describe('diffProps', () => {
|
|
31
|
+
it('detects added props (in source but not in fragment)', () => {
|
|
32
|
+
const fragmentProps = {
|
|
33
|
+
children: { type: 'node', required: true },
|
|
34
|
+
};
|
|
35
|
+
const sourceProps: Record<string, PropMeta> = {
|
|
36
|
+
children: makeProp({ name: 'children', typeKind: 'node', required: true }),
|
|
37
|
+
variant: makeProp({ name: 'variant', typeKind: 'enum', values: ['primary', 'secondary'] }),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const drifts = diffProps(fragmentProps, sourceProps);
|
|
41
|
+
const added = drifts.filter(d => d.kind === 'added');
|
|
42
|
+
expect(added.length).toBe(1);
|
|
43
|
+
expect(added[0].prop).toBe('variant');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('detects removed props (in fragment but not in source)', () => {
|
|
47
|
+
const fragmentProps = {
|
|
48
|
+
children: { type: 'node', required: true },
|
|
49
|
+
loading: { type: 'boolean', description: 'Show loading' },
|
|
50
|
+
};
|
|
51
|
+
const sourceProps: Record<string, PropMeta> = {
|
|
52
|
+
children: makeProp({ name: 'children', typeKind: 'node', required: true }),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const drifts = diffProps(fragmentProps, sourceProps);
|
|
56
|
+
const removed = drifts.filter(d => d.kind === 'removed');
|
|
57
|
+
expect(removed.length).toBe(1);
|
|
58
|
+
expect(removed[0].prop).toBe('loading');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('detects type changes', () => {
|
|
62
|
+
const fragmentProps = {
|
|
63
|
+
value: { type: 'string', required: false },
|
|
64
|
+
};
|
|
65
|
+
const sourceProps: Record<string, PropMeta> = {
|
|
66
|
+
value: makeProp({ name: 'value', typeKind: 'number' }),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const drifts = diffProps(fragmentProps, sourceProps);
|
|
70
|
+
const typeChanged = drifts.filter(d => d.kind === 'type_changed');
|
|
71
|
+
expect(typeChanged.length).toBe(1);
|
|
72
|
+
expect(typeChanged[0].source).toBe('number');
|
|
73
|
+
expect(typeChanged[0].fragment).toBe('string');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('detects required status changes', () => {
|
|
77
|
+
const fragmentProps = {
|
|
78
|
+
name: { type: 'string', required: false },
|
|
79
|
+
};
|
|
80
|
+
const sourceProps: Record<string, PropMeta> = {
|
|
81
|
+
name: makeProp({ name: 'name', typeKind: 'string', required: true }),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const drifts = diffProps(fragmentProps, sourceProps);
|
|
85
|
+
const reqChanged = drifts.filter(d => d.kind === 'required_changed');
|
|
86
|
+
expect(reqChanged.length).toBe(1);
|
|
87
|
+
expect(reqChanged[0].source).toBe('true');
|
|
88
|
+
expect(reqChanged[0].fragment).toBe('false');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('detects enum value changes', () => {
|
|
92
|
+
const fragmentProps = {
|
|
93
|
+
variant: { type: 'enum', values: ['primary', 'secondary'] as const },
|
|
94
|
+
};
|
|
95
|
+
const sourceProps: Record<string, PropMeta> = {
|
|
96
|
+
variant: makeProp({
|
|
97
|
+
name: 'variant',
|
|
98
|
+
typeKind: 'enum',
|
|
99
|
+
values: ['primary', 'secondary', 'ghost'],
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const drifts = diffProps(fragmentProps, sourceProps);
|
|
104
|
+
const valChanged = drifts.filter(d => d.kind === 'values_changed');
|
|
105
|
+
expect(valChanged.length).toBe(1);
|
|
106
|
+
expect(valChanged[0].source).toContain('ghost');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('detects default value changes', () => {
|
|
110
|
+
const fragmentProps = {
|
|
111
|
+
size: { type: 'enum', default: 'md' },
|
|
112
|
+
};
|
|
113
|
+
const sourceProps: Record<string, PropMeta> = {
|
|
114
|
+
size: makeProp({ name: 'size', typeKind: 'enum', default: 'lg' }),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const drifts = diffProps(fragmentProps, sourceProps);
|
|
118
|
+
const defChanged = drifts.filter(d => d.kind === 'default_changed');
|
|
119
|
+
expect(defChanged.length).toBe(1);
|
|
120
|
+
expect(defChanged[0].source).toBe('lg');
|
|
121
|
+
expect(defChanged[0].fragment).toBe('md');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('filters out inherited source props', () => {
|
|
125
|
+
const fragmentProps = {};
|
|
126
|
+
const sourceProps: Record<string, PropMeta> = {
|
|
127
|
+
className: makeProp({ name: 'className', typeKind: 'string', source: 'inherited' }),
|
|
128
|
+
style: makeProp({ name: 'style', typeKind: 'object', source: 'inherited' }),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const drifts = diffProps(fragmentProps, sourceProps);
|
|
132
|
+
expect(drifts.length).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('returns empty array when in sync', () => {
|
|
136
|
+
const fragmentProps = {
|
|
137
|
+
children: { type: 'node', required: true },
|
|
138
|
+
variant: { type: 'enum', values: ['a', 'b'] as const },
|
|
139
|
+
};
|
|
140
|
+
const sourceProps: Record<string, PropMeta> = {
|
|
141
|
+
children: makeProp({ name: 'children', typeKind: 'node', required: true }),
|
|
142
|
+
variant: makeProp({ name: 'variant', typeKind: 'enum', values: ['a', 'b'] }),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const drifts = diffProps(fragmentProps, sourceProps);
|
|
146
|
+
expect(drifts.length).toBe(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('detects multiple drift types simultaneously', () => {
|
|
150
|
+
const fragmentProps = {
|
|
151
|
+
children: { type: 'node', required: true },
|
|
152
|
+
loading: { type: 'boolean' },
|
|
153
|
+
variant: { type: 'enum', values: ['a'] as const },
|
|
154
|
+
};
|
|
155
|
+
const sourceProps: Record<string, PropMeta> = {
|
|
156
|
+
children: makeProp({ name: 'children', typeKind: 'node', required: true }),
|
|
157
|
+
size: makeProp({ name: 'size', typeKind: 'enum', values: ['sm', 'md'] }),
|
|
158
|
+
variant: makeProp({ name: 'variant', typeKind: 'enum', values: ['a', 'b'] }),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const drifts = diffProps(fragmentProps, sourceProps);
|
|
162
|
+
expect(drifts.length).toBe(3);
|
|
163
|
+
|
|
164
|
+
const kinds = drifts.map(d => d.kind);
|
|
165
|
+
expect(kinds).toContain('added'); // size
|
|
166
|
+
expect(kinds).toContain('removed'); // loading
|
|
167
|
+
expect(kinds).toContain('values_changed'); // variant
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Integration: validateDrift with real files
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
describe('validateDrift integration', () => {
|
|
176
|
+
let tmpDir: string;
|
|
177
|
+
|
|
178
|
+
beforeAll(async () => {
|
|
179
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'drift-int-'));
|
|
180
|
+
|
|
181
|
+
const compDir = join(tmpDir, 'components', 'Tag');
|
|
182
|
+
await mkdir(compDir, { recursive: true });
|
|
183
|
+
|
|
184
|
+
// Simple component — no JSX to avoid esbuild issues
|
|
185
|
+
await writeFile(
|
|
186
|
+
join(compDir, 'index.tsx'),
|
|
187
|
+
`export interface TagProps {
|
|
188
|
+
/** Tag label text */
|
|
189
|
+
label: string;
|
|
190
|
+
/** Color variant */
|
|
191
|
+
color?: 'blue' | 'green' | 'red';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function Tag({ label, color = 'blue' }: TagProps) {
|
|
195
|
+
return label;
|
|
196
|
+
}
|
|
197
|
+
`,
|
|
198
|
+
'utf-8'
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Fragment with missing 'color' prop and stale 'icon' prop
|
|
202
|
+
await writeFile(
|
|
203
|
+
join(compDir, 'Tag.fragment.tsx'),
|
|
204
|
+
`import { defineFragment } from '@fragments-sdk/core';
|
|
205
|
+
import { Tag } from './index';
|
|
206
|
+
|
|
207
|
+
export default defineFragment({
|
|
208
|
+
component: Tag,
|
|
209
|
+
meta: { name: 'Tag', description: 'A tag component', category: 'Display', status: 'stable' },
|
|
210
|
+
usage: { when: ['Label items'], whenNot: ['Navigation'] },
|
|
211
|
+
props: {
|
|
212
|
+
label: { type: 'string', description: 'Tag label text', required: true },
|
|
213
|
+
icon: { type: 'node', description: 'Leading icon' },
|
|
214
|
+
},
|
|
215
|
+
variants: [],
|
|
216
|
+
});
|
|
217
|
+
`,
|
|
218
|
+
'utf-8'
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
afterAll(async () => {
|
|
223
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('detects drift in real fragment files', async () => {
|
|
227
|
+
const config: FragmentsConfig = {
|
|
228
|
+
include: ['components/**/*.fragment.tsx'],
|
|
229
|
+
exclude: [],
|
|
230
|
+
components: ['components/**/index.tsx'],
|
|
231
|
+
outFile: 'fragments.json',
|
|
232
|
+
dataDir: '.fragments',
|
|
233
|
+
} as FragmentsConfig;
|
|
234
|
+
|
|
235
|
+
const result = await validateDrift(config, tmpDir);
|
|
236
|
+
|
|
237
|
+
// Should find drift: 'color' added (in source, not in fragment), 'icon' removed (in fragment, not in source)
|
|
238
|
+
expect(result.reports.length).toBe(1);
|
|
239
|
+
expect(result.reports[0].component).toBe('Tag');
|
|
240
|
+
|
|
241
|
+
const driftKinds = result.reports[0].drifts.map(d => d.kind);
|
|
242
|
+
expect(driftKinds).toContain('added');
|
|
243
|
+
expect(driftKinds).toContain('removed');
|
|
244
|
+
|
|
245
|
+
// 'icon' removal should be an error (breaks existing code)
|
|
246
|
+
expect(result.valid).toBe(false);
|
|
247
|
+
expect(result.errors.some(e => e.message.includes('icon'))).toBe(true);
|
|
248
|
+
|
|
249
|
+
// 'color' addition should be a warning (new undocumented prop)
|
|
250
|
+
expect(result.warnings.some(w => w.message.includes('color'))).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
});
|