@fragments-sdk/cli 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/ai-client-I6MDWNYA.js +21 -0
  2. package/dist/bin.js +419 -410
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-HRFUSSZI.js → chunk-3SOAPJDX.js} +2 -2
  5. package/dist/{chunk-D5PYOXEI.js → chunk-4K7EAQ5L.js} +148 -13
  6. package/dist/{chunk-D5PYOXEI.js.map → chunk-4K7EAQ5L.js.map} +1 -1
  7. package/dist/chunk-DXX6HADE.js +443 -0
  8. package/dist/chunk-DXX6HADE.js.map +1 -0
  9. package/dist/chunk-EYXVAMEX.js +626 -0
  10. package/dist/chunk-EYXVAMEX.js.map +1 -0
  11. package/dist/{chunk-ZM4ZQZWZ.js → chunk-FO6EBJWP.js} +39 -37
  12. package/dist/chunk-FO6EBJWP.js.map +1 -0
  13. package/dist/{chunk-OQO55NKV.js → chunk-QM7SVOGF.js} +120 -12
  14. package/dist/chunk-QM7SVOGF.js.map +1 -0
  15. package/dist/{chunk-5G3VZH43.js → chunk-RF3C6LGA.js} +281 -351
  16. package/dist/chunk-RF3C6LGA.js.map +1 -0
  17. package/dist/{chunk-WXSR2II7.js → chunk-SM674YAS.js} +58 -6
  18. package/dist/chunk-SM674YAS.js.map +1 -0
  19. package/dist/chunk-SXTKFDCR.js +104 -0
  20. package/dist/chunk-SXTKFDCR.js.map +1 -0
  21. package/dist/{chunk-PW7QTQA6.js → chunk-UV5JQV3R.js} +2 -2
  22. package/dist/core/index.js +13 -1
  23. package/dist/{discovery-NEOY4MPN.js → discovery-VSGC76JN.js} +3 -3
  24. package/dist/{generate-FBHSXR3D.js → generate-QZXOXYFW.js} +4 -4
  25. package/dist/index.js +7 -6
  26. package/dist/index.js.map +1 -1
  27. package/dist/init-XK6PRUE5.js +636 -0
  28. package/dist/init-XK6PRUE5.js.map +1 -0
  29. package/dist/mcp-bin.js +2 -2
  30. package/dist/{scan-CJF2DOQW.js → scan-CHQHXWVD.js} +6 -6
  31. package/dist/scan-generate-U3RFVDTX.js +1115 -0
  32. package/dist/scan-generate-U3RFVDTX.js.map +1 -0
  33. package/dist/{service-TQYWY65E.js → service-MMEKG4MZ.js} +3 -3
  34. package/dist/{snapshot-SV2JOFZH.js → snapshot-53TUR3HW.js} +2 -2
  35. package/dist/{static-viewer-NUBFPKWH.js → static-viewer-KKCR4KXR.js} +3 -3
  36. package/dist/static-viewer-KKCR4KXR.js.map +1 -0
  37. package/dist/{test-Z5LVO724.js → test-5UCKXYSC.js} +4 -4
  38. package/dist/{tokens-CE46OTMD.js → tokens-L46MK5AW.js} +5 -5
  39. package/dist/{viewer-DLLJIMCK.js → viewer-M2EQQSGE.js} +14 -14
  40. package/dist/viewer-M2EQQSGE.js.map +1 -0
  41. package/package.json +11 -9
  42. package/src/ai-client.ts +156 -0
  43. package/src/bin.ts +99 -2
  44. package/src/build.ts +95 -33
  45. package/src/commands/__tests__/drift-sync.test.ts +252 -0
  46. package/src/commands/__tests__/scan-generate.test.ts +497 -45
  47. package/src/commands/enhance.ts +11 -35
  48. package/src/commands/govern.ts +122 -0
  49. package/src/commands/init.ts +288 -260
  50. package/src/commands/scan-generate.ts +740 -139
  51. package/src/commands/scan.ts +37 -32
  52. package/src/commands/setup.ts +143 -52
  53. package/src/commands/sync.ts +357 -0
  54. package/src/commands/validate.ts +43 -1
  55. package/src/core/component-extractor.test.ts +282 -0
  56. package/src/core/component-extractor.ts +1030 -0
  57. package/src/core/discovery.ts +93 -7
  58. package/src/service/enhance/props-extractor.ts +235 -13
  59. package/src/validators.ts +236 -0
  60. package/src/viewer/vite-plugin.ts +1 -1
  61. package/dist/chunk-5G3VZH43.js.map +0 -1
  62. package/dist/chunk-OQO55NKV.js.map +0 -1
  63. package/dist/chunk-WXSR2II7.js.map +0 -1
  64. package/dist/chunk-ZM4ZQZWZ.js.map +0 -1
  65. package/dist/init-UFGK5TCN.js +0 -867
  66. package/dist/init-UFGK5TCN.js.map +0 -1
  67. package/dist/scan-generate-SJAN5MVI.js +0 -691
  68. package/dist/scan-generate-SJAN5MVI.js.map +0 -1
  69. package/dist/viewer-DLLJIMCK.js.map +0 -1
  70. package/src/ai.ts +0 -266
  71. package/src/commands/init-framework.ts +0 -414
  72. package/src/mcp/bin.ts +0 -36
  73. package/src/migrate/bin.ts +0 -114
  74. package/src/theme/index.ts +0 -77
  75. package/src/viewer/bin.ts +0 -86
  76. package/src/viewer/cli/health.ts +0 -256
  77. package/src/viewer/cli/index.ts +0 -33
  78. package/src/viewer/cli/scan.ts +0 -124
  79. package/src/viewer/cli/utils.ts +0 -174
  80. /package/dist/{discovery-NEOY4MPN.js.map → ai-client-I6MDWNYA.js.map} +0 -0
  81. /package/dist/{chunk-HRFUSSZI.js.map → chunk-3SOAPJDX.js.map} +0 -0
  82. /package/dist/{chunk-PW7QTQA6.js.map → chunk-UV5JQV3R.js.map} +0 -0
  83. /package/dist/{scan-CJF2DOQW.js.map → discovery-VSGC76JN.js.map} +0 -0
  84. /package/dist/{generate-FBHSXR3D.js.map → generate-QZXOXYFW.js.map} +0 -0
  85. /package/dist/{service-TQYWY65E.js.map → scan-CHQHXWVD.js.map} +0 -0
  86. /package/dist/{static-viewer-NUBFPKWH.js.map → service-MMEKG4MZ.js.map} +0 -0
  87. /package/dist/{snapshot-SV2JOFZH.js.map → snapshot-53TUR3HW.js.map} +0 -0
  88. /package/dist/{test-Z5LVO724.js.map → test-5UCKXYSC.js.map} +0 -0
  89. /package/dist/{tokens-CE46OTMD.js.map → tokens-L46MK5AW.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
+ }
@@ -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
+ }