@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
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fragments sync — Auto-update fragment files from component source.
|
|
3
|
+
*
|
|
4
|
+
* Detects drift between component source and fragment documentation,
|
|
5
|
+
* then updates fragment files to match. Preserves human-authored fields
|
|
6
|
+
* (usage, description, variants) while updating machine-derivable fields
|
|
7
|
+
* (props, composition, contract).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import pc from 'picocolors';
|
|
11
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { BRAND } from '../core/index.js';
|
|
13
|
+
import { loadConfig } from '../core/node.js';
|
|
14
|
+
import { discoverFragmentFiles, loadFragmentFile } from '../core/node.js';
|
|
15
|
+
import { parseFragmentFile } from '../core/parser.js';
|
|
16
|
+
import { resolveComponentSourcePath } from '../core/auto-props.js';
|
|
17
|
+
import { createComponentExtractor, type PropMeta, type CompositionMeta } from '../core/component-extractor.js';
|
|
18
|
+
import type { FragmentsConfig } from '@fragments-sdk/core';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Public types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export interface SyncOptions {
|
|
25
|
+
/** Path to config file */
|
|
26
|
+
config?: string;
|
|
27
|
+
/** Path to tsconfig.json */
|
|
28
|
+
tsconfig?: string;
|
|
29
|
+
/** Preview changes without writing */
|
|
30
|
+
dryRun?: boolean;
|
|
31
|
+
/** Sync specific component only */
|
|
32
|
+
component?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SyncedComponent {
|
|
36
|
+
name: string;
|
|
37
|
+
file: string;
|
|
38
|
+
changes: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SyncResult {
|
|
42
|
+
success: boolean;
|
|
43
|
+
updated: SyncedComponent[];
|
|
44
|
+
skipped: Array<{ name: string; reason: string }>;
|
|
45
|
+
errors: Array<{ file: string; message: string }>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Command
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export async function sync(options: SyncOptions = {}): Promise<SyncResult> {
|
|
53
|
+
const { config, configDir } = await loadConfig(options.config);
|
|
54
|
+
|
|
55
|
+
console.log(pc.cyan(`\n${BRAND.name} Sync\n`));
|
|
56
|
+
|
|
57
|
+
if (options.dryRun) {
|
|
58
|
+
console.log(pc.dim('Dry run — no files will be modified.\n'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const result = await runSync(config, configDir, options);
|
|
62
|
+
|
|
63
|
+
// Print updated
|
|
64
|
+
if (result.updated.length > 0) {
|
|
65
|
+
const verb = options.dryRun ? 'Would update' : 'Updated';
|
|
66
|
+
console.log(pc.bold(`${verb} ${result.updated.length} fragment(s):\n`));
|
|
67
|
+
for (const comp of result.updated) {
|
|
68
|
+
console.log(` ${pc.green('✓')} ${pc.bold(comp.name)} ${pc.dim(`(${comp.file})`)}`);
|
|
69
|
+
for (const change of comp.changes) {
|
|
70
|
+
console.log(` ${pc.dim('•')} ${change}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
console.log();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Print skipped
|
|
77
|
+
if (result.skipped.length > 0) {
|
|
78
|
+
console.log(pc.dim(`Skipped ${result.skipped.length}: ${result.skipped.map(s => s.name).join(', ')}\n`));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Print errors
|
|
82
|
+
if (result.errors.length > 0) {
|
|
83
|
+
console.log(pc.red(pc.bold('Errors:')));
|
|
84
|
+
for (const err of result.errors) {
|
|
85
|
+
console.log(` ${pc.red('✗')} ${pc.bold(err.file)}: ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
console.log();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Summary
|
|
91
|
+
if (result.updated.length === 0 && result.errors.length === 0) {
|
|
92
|
+
console.log(pc.green('All fragments are in sync — nothing to update.\n'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Core sync logic
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
async function runSync(
|
|
103
|
+
config: FragmentsConfig,
|
|
104
|
+
configDir: string,
|
|
105
|
+
options: SyncOptions
|
|
106
|
+
): Promise<SyncResult> {
|
|
107
|
+
const fragmentFiles = await discoverFragmentFiles(config, configDir);
|
|
108
|
+
const updated: SyncedComponent[] = [];
|
|
109
|
+
const skipped: Array<{ name: string; reason: string }> = [];
|
|
110
|
+
const errors: Array<{ file: string; message: string }> = [];
|
|
111
|
+
|
|
112
|
+
if (fragmentFiles.length === 0) {
|
|
113
|
+
return { success: true, updated, skipped, errors };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const extractor = createComponentExtractor(options.tsconfig);
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
for (const file of fragmentFiles) {
|
|
120
|
+
try {
|
|
121
|
+
const fragment = await loadFragmentFile(file.absolutePath);
|
|
122
|
+
if (!fragment?.meta?.name) continue;
|
|
123
|
+
|
|
124
|
+
// Filter to specific component if requested
|
|
125
|
+
if (options.component && fragment.meta.name !== options.component) continue;
|
|
126
|
+
|
|
127
|
+
// Parse fragment source to find component import
|
|
128
|
+
const fileContent = await readFile(file.absolutePath, 'utf-8');
|
|
129
|
+
const parsed = parseFragmentFile(fileContent, file.absolutePath);
|
|
130
|
+
if (!parsed.componentImport) {
|
|
131
|
+
skipped.push({ name: fragment.meta.name, reason: 'No component import found' });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Resolve source path
|
|
136
|
+
const sourcePath = resolveComponentSourcePath(file.absolutePath, parsed.componentImport);
|
|
137
|
+
if (!sourcePath) {
|
|
138
|
+
skipped.push({ name: fragment.meta.name, reason: 'Cannot resolve component source' });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Extract current state from source
|
|
143
|
+
const meta = extractor.extract(sourcePath, fragment.meta.name);
|
|
144
|
+
if (!meta) {
|
|
145
|
+
skipped.push({ name: fragment.meta.name, reason: 'Extraction returned null' });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Compute the patch
|
|
150
|
+
const patch = computePatch(fileContent, fragment, meta);
|
|
151
|
+
if (patch.changes.length === 0) {
|
|
152
|
+
skipped.push({ name: fragment.meta.name, reason: 'Already in sync' });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!options.dryRun) {
|
|
157
|
+
await writeFile(file.absolutePath, patch.updatedContent, 'utf-8');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
updated.push({
|
|
161
|
+
name: fragment.meta.name,
|
|
162
|
+
file: file.relativePath,
|
|
163
|
+
changes: patch.changes,
|
|
164
|
+
});
|
|
165
|
+
} catch (err) {
|
|
166
|
+
errors.push({
|
|
167
|
+
file: file.relativePath,
|
|
168
|
+
message: err instanceof Error ? err.message : String(err),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
extractor.dispose();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
success: errors.length === 0,
|
|
178
|
+
updated,
|
|
179
|
+
skipped,
|
|
180
|
+
errors,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Patch computation — text-level updates to fragment file
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
interface PatchResult {
|
|
189
|
+
updatedContent: string;
|
|
190
|
+
changes: string[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Compute text-level patches for a fragment file based on extracted metadata.
|
|
195
|
+
*
|
|
196
|
+
* Strategy: find the `props: { ... }` block in the source and replace it with
|
|
197
|
+
* an updated version that includes new props. Preserves human-authored prop
|
|
198
|
+
* entries (descriptions, constraints) while adding missing props and removing
|
|
199
|
+
* stale ones.
|
|
200
|
+
*/
|
|
201
|
+
function computePatch(
|
|
202
|
+
fileContent: string,
|
|
203
|
+
fragment: { meta: { name: string }; props: Record<string, unknown>; ai?: { compositionPattern?: string; subComponents?: string[] } },
|
|
204
|
+
meta: { props: Record<string, PropMeta>; composition: CompositionMeta | null }
|
|
205
|
+
): PatchResult {
|
|
206
|
+
const changes: string[] = [];
|
|
207
|
+
let content = fileContent;
|
|
208
|
+
|
|
209
|
+
// Filter to local props only
|
|
210
|
+
const localSourceProps = Object.fromEntries(
|
|
211
|
+
Object.entries(meta.props).filter(([_, p]) => p.source === 'local')
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Detect added props
|
|
215
|
+
const addedProps: string[] = [];
|
|
216
|
+
for (const name of Object.keys(localSourceProps)) {
|
|
217
|
+
if (!(name in fragment.props)) {
|
|
218
|
+
addedProps.push(name);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Detect removed props
|
|
223
|
+
const removedProps: string[] = [];
|
|
224
|
+
for (const name of Object.keys(fragment.props)) {
|
|
225
|
+
if (!(name in localSourceProps)) {
|
|
226
|
+
removedProps.push(name);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Apply prop additions — insert before the closing brace of the props block
|
|
231
|
+
if (addedProps.length > 0) {
|
|
232
|
+
const propsBlockEnd = findPropsBlockEnd(content);
|
|
233
|
+
if (propsBlockEnd !== -1) {
|
|
234
|
+
const newEntries = addedProps.map(name => {
|
|
235
|
+
const prop = localSourceProps[name];
|
|
236
|
+
return formatPropEntry(name, prop);
|
|
237
|
+
}).join('\n');
|
|
238
|
+
|
|
239
|
+
content = content.slice(0, propsBlockEnd) + newEntries + '\n ' + content.slice(propsBlockEnd);
|
|
240
|
+
changes.push(`Added props: ${addedProps.join(', ')}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Apply prop removals — comment out removed props
|
|
245
|
+
if (removedProps.length > 0) {
|
|
246
|
+
for (const name of removedProps) {
|
|
247
|
+
// Match the prop entry line(s) — find `propName: {` and its block
|
|
248
|
+
const propRegex = new RegExp(`([ \\t]*)${escapeRegex(name)}:\\s*\\{`, 'g');
|
|
249
|
+
const match = propRegex.exec(content);
|
|
250
|
+
if (match) {
|
|
251
|
+
const indent = match[1];
|
|
252
|
+
const startIdx = match.index;
|
|
253
|
+
// Find the matching closing brace
|
|
254
|
+
const endIdx = findMatchingBrace(content, match.index + match[0].length - 1);
|
|
255
|
+
if (endIdx !== -1) {
|
|
256
|
+
// Find the end of the line after the closing brace (include trailing comma)
|
|
257
|
+
let lineEnd = endIdx + 1;
|
|
258
|
+
if (content[lineEnd] === ',') lineEnd++;
|
|
259
|
+
if (content[lineEnd] === '\n') lineEnd++;
|
|
260
|
+
|
|
261
|
+
const removedBlock = content.slice(startIdx, lineEnd);
|
|
262
|
+
const commented = removedBlock
|
|
263
|
+
.split('\n')
|
|
264
|
+
.map(line => line ? `${indent}// [drift:removed] ${line.trimStart()}` : '')
|
|
265
|
+
.join('\n');
|
|
266
|
+
content = content.slice(0, startIdx) + commented + content.slice(lineEnd);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
changes.push(`Removed props: ${removedProps.join(', ')}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Sync composition in ai block
|
|
274
|
+
if (meta.composition && !fragment.ai?.compositionPattern) {
|
|
275
|
+
changes.push(`Composition: "${meta.composition.pattern}" pattern detected`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { updatedContent: content, changes };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Helpers
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/** Find the closing `}` of the top-level `props: { ... }` block */
|
|
286
|
+
function findPropsBlockEnd(content: string): number {
|
|
287
|
+
const propsStart = content.search(/\bprops:\s*\{/);
|
|
288
|
+
if (propsStart === -1) return -1;
|
|
289
|
+
const braceStart = content.indexOf('{', propsStart);
|
|
290
|
+
return findMatchingBrace(content, braceStart);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Find the matching closing brace for an opening brace at `start` */
|
|
294
|
+
function findMatchingBrace(content: string, start: number): number {
|
|
295
|
+
let depth = 0;
|
|
296
|
+
let inString: string | null = null;
|
|
297
|
+
let escaped = false;
|
|
298
|
+
|
|
299
|
+
for (let i = start; i < content.length; i++) {
|
|
300
|
+
const ch = content[i];
|
|
301
|
+
|
|
302
|
+
if (escaped) {
|
|
303
|
+
escaped = false;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (ch === '\\') {
|
|
308
|
+
escaped = true;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (inString) {
|
|
313
|
+
if (ch === inString) inString = null;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
318
|
+
inString = ch;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (ch === '{') depth++;
|
|
323
|
+
else if (ch === '}') {
|
|
324
|
+
depth--;
|
|
325
|
+
if (depth === 0) return i;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return -1;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Format a PropMeta into a fragment prop entry string */
|
|
333
|
+
function formatPropEntry(name: string, prop: PropMeta): string {
|
|
334
|
+
const lines: string[] = [];
|
|
335
|
+
lines.push(` ${name}: {`);
|
|
336
|
+
lines.push(` type: '${prop.typeKind}',`);
|
|
337
|
+
if (prop.description) {
|
|
338
|
+
lines.push(` description: ${JSON.stringify(prop.description)},`);
|
|
339
|
+
} else {
|
|
340
|
+
lines.push(` description: '', // TODO: add description`);
|
|
341
|
+
}
|
|
342
|
+
if (prop.required) {
|
|
343
|
+
lines.push(` required: true,`);
|
|
344
|
+
}
|
|
345
|
+
if (prop.values && prop.values.length > 0) {
|
|
346
|
+
lines.push(` values: [${prop.values.map(v => `'${v}'`).join(', ')}],`);
|
|
347
|
+
}
|
|
348
|
+
if (prop.default !== undefined) {
|
|
349
|
+
lines.push(` default: '${prop.default}',`);
|
|
350
|
+
}
|
|
351
|
+
lines.push(` },`);
|
|
352
|
+
return lines.join('\n');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function escapeRegex(str: string): string {
|
|
356
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
357
|
+
}
|
package/src/commands/validate.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import pc from 'picocolors';
|
|
6
6
|
import { BRAND } from '../core/index.js';
|
|
7
7
|
import { loadConfig } from '../core/node.js';
|
|
8
|
-
import { validateSchema, validateCoverage, validateAll, validateSnippets } from '../validators.js';
|
|
8
|
+
import { validateSchema, validateCoverage, validateAll, validateSnippets, validateDrift } from '../validators.js';
|
|
9
|
+
import type { DriftValidationResult } from '../validators.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Options for validate command
|
|
@@ -19,6 +20,10 @@ export interface ValidateOptions {
|
|
|
19
20
|
coverage?: boolean;
|
|
20
21
|
/** Validate snippet/render policy only */
|
|
21
22
|
snippets?: boolean;
|
|
23
|
+
/** Detect metadata drift between source and fragments */
|
|
24
|
+
drift?: boolean;
|
|
25
|
+
/** Path to tsconfig.json for drift detection */
|
|
26
|
+
tsconfig?: string;
|
|
22
27
|
/** Override snippet policy mode for this run */
|
|
23
28
|
snippetMode?: 'warn' | 'error';
|
|
24
29
|
/** Start component name for alphabetical snippet batch validation */
|
|
@@ -68,6 +73,11 @@ export async function validate(options: ValidateOptions = {}): Promise<ValidateR
|
|
|
68
73
|
componentStart: options.componentStart,
|
|
69
74
|
componentLimit,
|
|
70
75
|
});
|
|
76
|
+
} else if (options.drift) {
|
|
77
|
+
console.log(pc.dim('Running drift detection...\n'));
|
|
78
|
+
const driftResult = await validateDrift(config, configDir, { tsconfig: options.tsconfig });
|
|
79
|
+
result = driftResult;
|
|
80
|
+
printDriftReport(driftResult);
|
|
71
81
|
} else {
|
|
72
82
|
console.log(pc.dim('Running all validations...\n'));
|
|
73
83
|
result = await validateAll(config, configDir, {
|
|
@@ -113,3 +123,35 @@ export async function validate(options: ValidateOptions = {}): Promise<ValidateR
|
|
|
113
123
|
|
|
114
124
|
return result;
|
|
115
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Print a structured drift report grouped by component
|
|
129
|
+
*/
|
|
130
|
+
function printDriftReport(result: DriftValidationResult): void {
|
|
131
|
+
if (result.reports.length === 0) {
|
|
132
|
+
console.log(pc.green('No drift detected — fragments are in sync with source.\n'));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.log(pc.bold(`Drift detected in ${result.reports.length} component(s):\n`));
|
|
137
|
+
|
|
138
|
+
for (const report of result.reports) {
|
|
139
|
+
console.log(` ${pc.bold(report.component)} ${pc.dim(`(${report.file})`)}`);
|
|
140
|
+
|
|
141
|
+
for (const drift of report.drifts) {
|
|
142
|
+
const icon = drift.kind === 'removed' ? pc.red('−') :
|
|
143
|
+
drift.kind === 'added' ? pc.green('+') : pc.yellow('~');
|
|
144
|
+
const label = drift.kind.replace('_', ' ');
|
|
145
|
+
console.log(` ${icon} ${drift.prop}: ${label}`);
|
|
146
|
+
if (drift.kind !== 'added' && drift.kind !== 'removed') {
|
|
147
|
+
console.log(pc.dim(` fragment: ${drift.fragment}`));
|
|
148
|
+
console.log(pc.dim(` source: ${drift.source}`));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (report.compositionDrift) {
|
|
153
|
+
console.log(` ${pc.yellow('~')} composition: ${report.compositionDrift}`);
|
|
154
|
+
}
|
|
155
|
+
console.log();
|
|
156
|
+
}
|
|
157
|
+
}
|