@pedroab0/skills 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +194 -2
  3. package/bin/cli.js +748 -0
  4. package/figma-extractor/README.md +180 -0
  5. package/figma-extractor/SKILL.md +199 -0
  6. package/figma-extractor/demo/desktop_home.json +2434 -0
  7. package/figma-extractor/demo/dt_money_desktop_home.png +0 -0
  8. package/figma-extractor/demo/dt_money_preview.png +0 -0
  9. package/figma-extractor/demo/dt_money_selection.json +472 -0
  10. package/figma-extractor/demo/index.html +188 -0
  11. package/figma-extractor/demo/style.css +360 -0
  12. package/figma-extractor/figma-plugin/README.md +81 -0
  13. package/figma-extractor/figma-plugin/assets/cover-1920.png +0 -0
  14. package/figma-extractor/figma-plugin/assets/cover.jpg +0 -0
  15. package/figma-extractor/figma-plugin/assets/cover.png +0 -0
  16. package/figma-extractor/figma-plugin/assets/icon-128.png +0 -0
  17. package/figma-extractor/figma-plugin/assets/icon.png +0 -0
  18. package/figma-extractor/figma-plugin/code.js +407 -0
  19. package/figma-extractor/figma-plugin/manifest.json +16 -0
  20. package/figma-extractor/figma-plugin/ui.html +470 -0
  21. package/figma-extractor/scripts/fetch_figma.js +1314 -0
  22. package/figma-extractor/scripts/test_fetch_figma.js +601 -0
  23. package/figma-node-builder/README.md +107 -0
  24. package/figma-node-builder/SKILL.md +113 -0
  25. package/figma-node-builder/scripts/inspect_spec.js +514 -0
  26. package/figma-node-builder/scripts/test_inspect_spec.js +148 -0
  27. package/figma-to-code/README.md +160 -0
  28. package/figma-to-code/SKILL.md +211 -0
  29. package/figma-to-code/scripts/fetch_figma.js +1251 -0
  30. package/figma-to-code/scripts/test_fetch_figma.js +581 -0
  31. package/package.json +40 -4
@@ -0,0 +1,514 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Spec Inspector CLI for figma-node-builder
5
+ * https://github.com/pedroab0/skills
6
+ *
7
+ * Inspects extracted Figma node specifications (.specs/*.json) and generates
8
+ * a compact, design-system-grounded report (layout, tokens, components, gaps)
9
+ * without requiring the agent to load the entire JSON AST into context.
10
+ *
11
+ * Usage:
12
+ * node inspect_spec.js .specs/card.json
13
+ * node inspect_spec.js card [--markdown] [--json]
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ // ANSI colors & styling
20
+ const isColorSupported = !process.env.NO_COLOR && (process.stdout.isTTY || process.env.FORCE_COLOR);
21
+ const style = {
22
+ bold: (t) => (isColorSupported ? `\x1b[1m${t}\x1b[0m` : t),
23
+ dim: (t) => (isColorSupported ? `\x1b[2m${t}\x1b[0m` : t),
24
+ green: (t) => (isColorSupported ? `\x1b[32m${t}\x1b[0m` : t),
25
+ cyan: (t) => (isColorSupported ? `\x1b[36m${t}\x1b[0m` : t),
26
+ yellow: (t) => (isColorSupported ? `\x1b[33m${t}\x1b[0m` : t),
27
+ magenta: (t) => (isColorSupported ? `\x1b[35m${t}\x1b[0m` : t),
28
+ };
29
+
30
+ // -----------------------------------------------------------------------------
31
+ // Path Resolution
32
+ // -----------------------------------------------------------------------------
33
+
34
+ function resolveSpecPath(inputPath) {
35
+ if (!inputPath || typeof inputPath !== 'string') {
36
+ throw new Error('Please specify a specification file (e.g. .specs/card.json or card)');
37
+ }
38
+
39
+ const raw = inputPath.trim();
40
+ const candidates = [
41
+ raw,
42
+ raw.endsWith('.json') ? raw : `${raw}.json`,
43
+ path.join('.specs', raw),
44
+ path.join('.specs', raw.endsWith('.json') ? raw : `${raw}.json`),
45
+ path.join(process.cwd(), '.specs', raw.endsWith('.json') ? raw : `${raw}.json`),
46
+ ];
47
+
48
+ for (const c of candidates) {
49
+ const resolved = path.resolve(c);
50
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
51
+ return resolved;
52
+ }
53
+ }
54
+
55
+ throw new Error(`Spec file not found for "${inputPath}". Looked in:\n • ${candidates.slice(0, 3).join('\n • ')}`);
56
+ }
57
+
58
+ // -----------------------------------------------------------------------------
59
+ // AST Traversal & Analysis
60
+ // -----------------------------------------------------------------------------
61
+
62
+ function inspectSpec(specData, specFilePath = '') {
63
+ // 1. Locate root node
64
+ let root = specData;
65
+ if (specData && specData.node) {
66
+ root = specData.node;
67
+ } else if (specData && specData.document) {
68
+ root = specData.document;
69
+ }
70
+
71
+ const stats = {
72
+ totalNodes: 0,
73
+ maxDepth: 0,
74
+ typesCount: {},
75
+ };
76
+
77
+ const tokens = {
78
+ colors: new Map(), // token or hex -> count
79
+ spacing: new Map(), // token or value -> count
80
+ radii: new Map(), // token or value -> count
81
+ totalTokensFound: 0,
82
+ };
83
+
84
+ const instances = new Map(); // componentName -> Array<props>
85
+ const typography = {
86
+ fontFamilies: new Set(),
87
+ fontSizes: new Set(),
88
+ fontWeights: new Set(),
89
+ textSamples: [],
90
+ totalTextLayers: 0,
91
+ };
92
+
93
+ function addToken(type, nameOrValue, detail = '') {
94
+ if (!nameOrValue) return;
95
+ tokens.totalTokensFound++;
96
+ const key = String(nameOrValue);
97
+ const existing = tokens[type].get(key);
98
+ if (existing) {
99
+ existing.count++;
100
+ } else {
101
+ tokens[type].set(key, { count: 1, detail });
102
+ }
103
+ }
104
+
105
+ // Recursive AST traversal
106
+ function walk(node, depth = 1) {
107
+ if (!node || typeof node !== 'object') return;
108
+
109
+ stats.totalNodes++;
110
+ if (depth > stats.maxDepth) stats.maxDepth = depth;
111
+
112
+ const type = node.type || 'UNKNOWN';
113
+ stats.typesCount[type] = (stats.typesCount[type] || 0) + 1;
114
+
115
+ // Component Instances
116
+ if (type === 'INSTANCE' || (node.component && node.component.name)) {
117
+ const compName = (node.component && node.component.name) || node.name || 'Component';
118
+ const props = node.props || {};
119
+ if (!instances.has(compName)) {
120
+ instances.set(compName, []);
121
+ }
122
+ instances.get(compName).push(props);
123
+ }
124
+
125
+ // Layout Tokens
126
+ if (node.itemSpacingToken) {
127
+ addToken('spacing', node.itemSpacingToken, `${node.itemSpacing}px`);
128
+ }
129
+ if (node.paddingToken) {
130
+ addToken('spacing', node.paddingToken);
131
+ }
132
+ if (node.cornerRadiusToken) {
133
+ addToken('radii', node.cornerRadiusToken, `${node.cornerRadius}px`);
134
+ }
135
+
136
+ // Color Fills
137
+ if (Array.isArray(node.fills)) {
138
+ for (const fill of node.fills) {
139
+ if (fill.token) {
140
+ addToken('colors', fill.token, fill.color || '');
141
+ } else if (fill.color) {
142
+ addToken('colors', fill.color, 'raw hex');
143
+ }
144
+ }
145
+ }
146
+
147
+ // Strokes
148
+ if (Array.isArray(node.strokes)) {
149
+ for (const stroke of node.strokes) {
150
+ if (stroke.token) {
151
+ addToken('colors', stroke.token, stroke.color || '');
152
+ } else if (stroke.color) {
153
+ addToken('colors', stroke.color, 'raw hex');
154
+ }
155
+ }
156
+ }
157
+
158
+ // Typography & Text
159
+ if (type === 'TEXT') {
160
+ typography.totalTextLayers++;
161
+ if (node.characters && typography.textSamples.length < 5) {
162
+ const cleanText = node.characters.trim().replace(/\s+/g, ' ');
163
+ if (cleanText && !typography.textSamples.includes(cleanText)) {
164
+ typography.textSamples.push(cleanText.length > 40 ? cleanText.slice(0, 37) + '...' : cleanText);
165
+ }
166
+ }
167
+
168
+ const styleObj = node.style || node.typography || {};
169
+ if (styleObj.fontFamily) typography.fontFamilies.add(styleObj.fontFamily);
170
+ if (styleObj.fontSize) typography.fontSizes.add(Math.round(styleObj.fontSize));
171
+ if (styleObj.fontWeight) typography.fontWeights.add(styleObj.fontWeight);
172
+ }
173
+
174
+ // Traverse children
175
+ if (Array.isArray(node.children)) {
176
+ for (const child of node.children) {
177
+ walk(child, depth + 1);
178
+ }
179
+ }
180
+ }
181
+
182
+ walk(root, 1);
183
+
184
+ // Companion Preview Image Resolution
185
+ let companionImage = null;
186
+ if (specFilePath) {
187
+ const dir = path.dirname(specFilePath);
188
+ const baseName = path.basename(specFilePath, '.json');
189
+ const companionPngPath = path.join(dir, `${baseName}.png`);
190
+ const exists = fs.existsSync(companionPngPath);
191
+ companionImage = {
192
+ path: companionPngPath,
193
+ exists,
194
+ };
195
+ } else if (specData && specData.image && specData.image.path) {
196
+ companionImage = {
197
+ path: specData.image.path,
198
+ exists: fs.existsSync(specData.image.path),
199
+ };
200
+ }
201
+
202
+ // Extract Root Layout Info
203
+ const rootLayout = {
204
+ name: root.name || 'Root',
205
+ type: root.type || 'FRAME',
206
+ width: root.bounds ? root.bounds.width : null,
207
+ height: root.bounds ? root.bounds.height : null,
208
+ layoutMode: root.layoutMode || 'NONE',
209
+ itemSpacing: root.itemSpacing || 0,
210
+ itemSpacingToken: root.itemSpacingToken || null,
211
+ padding: root.padding || null,
212
+ paddingToken: root.paddingToken || null,
213
+ cornerRadius: root.cornerRadius || null,
214
+ cornerRadiusToken: root.cornerRadiusToken || null,
215
+ };
216
+
217
+ return {
218
+ specFilePath,
219
+ rootLayout,
220
+ stats,
221
+ tokens,
222
+ instances,
223
+ typography,
224
+ companionImage,
225
+ };
226
+ }
227
+
228
+ // -----------------------------------------------------------------------------
229
+ // Terminal Report Formatter
230
+ // -----------------------------------------------------------------------------
231
+
232
+ function formatTerminalReport(data) {
233
+ const lines = [];
234
+ const { rootLayout, tokens, instances, typography, companionImage, specFilePath } = data;
235
+
236
+ const fileSize = specFilePath && fs.existsSync(specFilePath)
237
+ ? `(${ (fs.statSync(specFilePath).size / 1024).toFixed(1) } KB)`
238
+ : '';
239
+
240
+ lines.push(`\n${style.bold(style.cyan('◆'))} ${style.bold('Figma Spec Inspector')}`);
241
+ lines.push(`${style.dim('│')} ${style.bold('Spec:')} ${specFilePath || 'stdin'} ${style.dim(fileSize)}`);
242
+
243
+ if (companionImage) {
244
+ const status = companionImage.exists ? style.green('[Found]') : style.yellow('[Missing preview image]');
245
+ lines.push(`${style.dim('│')} ${style.bold('Preview:')} ${companionImage.path} ${status}`);
246
+ }
247
+ lines.push(style.dim('│'));
248
+
249
+ // 1. Root Dimensions & Layout
250
+ lines.push(`${style.cyan('◇')} ${style.bold('Root Dimensions & Layout:')}`);
251
+ lines.push(`${style.dim('│')} • ${style.bold('Component:')} "${rootLayout.name}" ${style.dim(`(${rootLayout.type})`)}`);
252
+
253
+ const dimStr = rootLayout.width && rootLayout.height ? `${rootLayout.width} × ${rootLayout.height} px` : 'Fluid / Unspecified';
254
+ lines.push(`${style.dim('│')} • ${style.bold('Dimensions:')} ${style.cyan(dimStr)}`);
255
+
256
+ let modeStr = 'Absolute / Static';
257
+ if (rootLayout.layoutMode === 'VERTICAL') modeStr = 'Vertical Flex (Column)';
258
+ else if (rootLayout.layoutMode === 'HORIZONTAL') modeStr = 'Horizontal Flex (Row)';
259
+ lines.push(`${style.dim('│')} • ${style.bold('Layout Mode:')} ${modeStr}`);
260
+
261
+ if (rootLayout.padding) {
262
+ const p = rootLayout.padding;
263
+ const tokenStr = rootLayout.paddingToken ? ` ${style.cyan(`(${rootLayout.paddingToken})`)}` : '';
264
+ lines.push(`${style.dim('│')} • ${style.bold('Padding:')} T:${p.top} R:${p.right} B:${p.bottom} L:${p.left} px${tokenStr}`);
265
+ }
266
+
267
+ if (rootLayout.itemSpacing) {
268
+ const tokenStr = rootLayout.itemSpacingToken ? ` ${style.cyan(`(${rootLayout.itemSpacingToken})`)}` : '';
269
+ lines.push(`${style.dim('│')} • ${style.bold('Item Spacing (Gap):')} ${rootLayout.itemSpacing} px${tokenStr}`);
270
+ }
271
+
272
+ if (rootLayout.cornerRadius) {
273
+ const tokenStr = rootLayout.cornerRadiusToken ? ` ${style.cyan(`(${rootLayout.cornerRadiusToken})`)}` : '';
274
+ lines.push(`${style.dim('│')} • ${style.bold('Corner Radius:')} ${rootLayout.cornerRadius} px${tokenStr}`);
275
+ }
276
+ lines.push(style.dim('│'));
277
+
278
+ // 2. Design Tokens Used
279
+ const colorCount = tokens.colors.size;
280
+ const spacingCount = tokens.spacing.size;
281
+ const radiiCount = tokens.radii.size;
282
+ const totalTokens = colorCount + spacingCount + radiiCount;
283
+
284
+ lines.push(`${style.cyan('◇')} ${style.bold(`Design Tokens & Colors (${totalTokens} total):`)}`);
285
+ if (colorCount > 0) {
286
+ const colorItems = Array.from(tokens.colors.entries())
287
+ .slice(0, 5)
288
+ .map(([k, v]) => `${k}${v.detail && v.detail !== 'raw hex' ? ` (${v.detail})` : ''}`)
289
+ .join(', ');
290
+ const more = colorCount > 5 ? style.dim(` +${colorCount - 5} more`) : '';
291
+ lines.push(`${style.dim('│')} • ${style.bold('Colors')} (${colorCount}): ${colorItems}${more}`);
292
+ }
293
+ if (spacingCount > 0) {
294
+ const spacingItems = Array.from(tokens.spacing.keys()).join(', ');
295
+ lines.push(`${style.dim('│')} • ${style.bold('Spacing')} (${spacingCount}): ${spacingItems}`);
296
+ }
297
+ if (radiiCount > 0) {
298
+ const radiiItems = Array.from(tokens.radii.keys()).join(', ');
299
+ lines.push(`${style.dim('│')} • ${style.bold('Radii')} (${radiiCount}): ${radiiItems}`);
300
+ }
301
+ if (totalTokens === 0) {
302
+ lines.push(`${style.dim('│')} • ${style.dim('No bound tokens found (raw inline values used)')}`);
303
+ }
304
+ lines.push(style.dim('│'));
305
+
306
+ // 3. Component Instances
307
+ const instanceTotal = Array.from(instances.values()).reduce((acc, arr) => acc + arr.length, 0);
308
+ lines.push(`${style.cyan('◇')} ${style.bold(`Component Instances (${instanceTotal} instance${instanceTotal === 1 ? '' : 's'} across ${instances.size} component${instances.size === 1 ? '' : 's'}):`)}`);
309
+
310
+ if (instances.size > 0) {
311
+ for (const [compName, usages] of instances.entries()) {
312
+ lines.push(`${style.dim('│')} • ${style.bold(style.cyan(`<${compName} />`))} ${style.dim(`(${usages.length}x)`)}`);
313
+ // Display sample distinct prop shapes
314
+ const propSamples = usages.slice(0, 2);
315
+ for (const p of propSamples) {
316
+ const propKeys = Object.keys(p);
317
+ if (propKeys.length > 0) {
318
+ const formattedProps = propKeys
319
+ .map((k) => `${k}=${JSON.stringify(p[k])}`)
320
+ .join(' ');
321
+ lines.push(`${style.dim('│')} ${style.dim('↳ Props:')} ${formattedProps}`);
322
+ }
323
+ }
324
+ }
325
+ } else {
326
+ lines.push(`${style.dim('│')} • ${style.dim('None (atomic frame / layout without external component instances)')}`);
327
+ }
328
+ lines.push(style.dim('│'));
329
+
330
+ // 4. Component Gaps (Action Items for Host Codebase)
331
+ lines.push(`${style.cyan('◇')} ${style.bold('Component Gaps & Codebase Checklist:')}`);
332
+ if (instances.size > 0) {
333
+ lines.push(`${style.dim('│')} ${style.dim('Verify these primitives exist in your project before synthesizing:')}`);
334
+ for (const [compName, usages] of instances.entries()) {
335
+ lines.push(`${style.dim('│')} [ ] ${style.bold(`<${compName} />`)} ${style.dim(`(used ${usages.length} time${usages.length === 1 ? '' : 's'})`)}`);
336
+ }
337
+ } else {
338
+ lines.push(`${style.dim('│')} ${style.green('✓')} No external component dependencies required.`);
339
+ }
340
+ lines.push(style.dim('│'));
341
+
342
+ // 5. Typography & Text
343
+ if (typography.totalTextLayers > 0) {
344
+ lines.push(`${style.cyan('◇')} ${style.bold(`Typography & Text (${typography.totalTextLayers} layer${typography.totalTextLayers === 1 ? '' : 's'}):`)}`);
345
+ const fonts = Array.from(typography.fontFamilies).join(', ') || 'Default';
346
+ const sizes = Array.from(typography.fontSizes).sort((a, b) => a - b).map((s) => `${s}px`).join(', ') || 'N/A';
347
+ const weights = Array.from(typography.fontWeights).sort().join(', ') || 'Regular';
348
+ lines.push(`${style.dim('│')} • ${style.bold('Fonts:')} ${fonts} ${style.dim(`| Sizes: ${sizes} | Weights: ${weights}`)}`);
349
+
350
+ if (typography.textSamples.length > 0) {
351
+ lines.push(`${style.dim('│')} • ${style.bold('Content Samples:')} ${typography.textSamples.map((s) => `"${s}"`).join(', ')}`);
352
+ }
353
+ lines.push(style.dim('│'));
354
+ }
355
+
356
+ lines.push(`${style.green('✔')} ${style.bold('Inspection complete.')} ${style.dim(`Analyzed ${data.stats.totalNodes} nodes down to depth ${data.stats.maxDepth}.`)}`);
357
+ lines.push(`${style.dim('└')} ${style.dim('Ready for Phase 3 (Grounded Synthesis) with figma-node-builder.')}\n`);
358
+
359
+ return lines.join('\n');
360
+ }
361
+
362
+ // -----------------------------------------------------------------------------
363
+ // Markdown Report Formatter (For agent plans & artifacts)
364
+ // -----------------------------------------------------------------------------
365
+
366
+ function formatMarkdownReport(data) {
367
+ const { rootLayout, tokens, instances, typography, companionImage, specFilePath } = data;
368
+ const lines = [];
369
+
370
+ const specName = path.basename(specFilePath || 'spec.json');
371
+ lines.push(`### 📐 Figma Spec Summary: \`${specName}\`\n`);
372
+
373
+ // Overview Table
374
+ lines.push(`| Metric | Specification |`);
375
+ lines.push(`| :--- | :--- |`);
376
+ lines.push(`| **Component Name** | \`${rootLayout.name}\` (${rootLayout.type}) |`);
377
+ const dimStr = rootLayout.width && rootLayout.height ? `${rootLayout.width}×${rootLayout.height}px` : 'Fluid';
378
+ lines.push(`| **Root Dimensions** | \`${dimStr}\` |`);
379
+
380
+ let modeStr = 'Absolute / Static';
381
+ if (rootLayout.layoutMode === 'VERTICAL') modeStr = 'Vertical Flex (Column)';
382
+ else if (rootLayout.layoutMode === 'HORIZONTAL') modeStr = 'Horizontal Flex (Row)';
383
+ lines.push(`| **Layout Mode** | ${modeStr} |`);
384
+
385
+ if (rootLayout.itemSpacing) {
386
+ const token = rootLayout.itemSpacingToken ? ` (\`${rootLayout.itemSpacingToken}\`)` : '';
387
+ lines.push(`| **Item Spacing (Gap)** | ${rootLayout.itemSpacing}px${token} |`);
388
+ }
389
+
390
+ if (companionImage) {
391
+ const status = companionImage.exists ? '✅ Available' : '⚠️ Missing';
392
+ lines.push(`| **Companion Image** | \`${path.basename(companionImage.path)}\` (${status}) |`);
393
+ }
394
+ lines.push('');
395
+
396
+ // Design Tokens
397
+ lines.push(`#### 🎨 Design Tokens & Styles`);
398
+ if (tokens.colors.size > 0) {
399
+ const colors = Array.from(tokens.colors.keys()).slice(0, 6).map((c) => `\`${c}\``).join(', ');
400
+ lines.push(`- **Colors**: ${colors}`);
401
+ }
402
+ if (tokens.spacing.size > 0) {
403
+ lines.push(`- **Spacing**: ${Array.from(tokens.spacing.keys()).map((s) => `\`${s}\``).join(', ')}`);
404
+ }
405
+ if (tokens.radii.size > 0) {
406
+ lines.push(`- **Radii**: ${Array.from(tokens.radii.keys()).map((r) => `\`${r}\``).join(', ')}`);
407
+ }
408
+ lines.push('');
409
+
410
+ // Component Checklist
411
+ lines.push(`#### 🧩 Sub-Component Checklist`);
412
+ if (instances.size > 0) {
413
+ lines.push(`| Component | Occurrences | Sample Props | Status |`);
414
+ lines.push(`| :--- | :--- | :--- | :--- |`);
415
+ for (const [compName, usages] of instances.entries()) {
416
+ const firstProps = usages[0] ? Object.keys(usages[0]).map((k) => `${k}=${JSON.stringify(usages[0][k])}`).join(' ') : 'none';
417
+ lines.push(`| \`<${compName} />\` | ${usages.length} | \`${firstProps.slice(0, 45)}\` | \`[ ] Verify in codebase\` |`);
418
+ }
419
+ } else {
420
+ lines.push(`- No external component instances detected.`);
421
+ }
422
+ lines.push('');
423
+
424
+ // Typography
425
+ if (typography.totalTextLayers > 0) {
426
+ lines.push(`#### 🔤 Typography`);
427
+ const fonts = Array.from(typography.fontFamilies).join(', ') || 'System default';
428
+ const sizes = Array.from(typography.fontSizes).sort((a, b) => a - b).map((s) => `${s}px`).join(', ');
429
+ lines.push(`- **Families**: ${fonts}`);
430
+ lines.push(`- **Font Sizes**: ${sizes}`);
431
+ lines.push('');
432
+ }
433
+
434
+ return lines.join('\n');
435
+ }
436
+
437
+ // -----------------------------------------------------------------------------
438
+ // CLI Execution
439
+ // -----------------------------------------------------------------------------
440
+
441
+ function printUsage() {
442
+ console.log(`
443
+ Spec Inspector CLI - figma-node-builder
444
+ Summarize extracted Figma node hierarchies without loading raw JSON into agent context.
445
+
446
+ Usage:
447
+ node inspect_spec.js <path-or-name> [options]
448
+
449
+ Arguments:
450
+ <path-or-name> Path to JSON spec (e.g. .specs/card.json) or shorthand (e.g. card)
451
+
452
+ Options:
453
+ --markdown, -m Output clean GitHub Flavored Markdown (for plans & walkthroughs)
454
+ --json, -j Output structured JSON summary
455
+ --help, -h Show this help message
456
+
457
+ Examples:
458
+ node inspect_spec.js .specs/card.json
459
+ node inspect_spec.js card
460
+ node inspect_spec.js card --markdown
461
+ `);
462
+ }
463
+
464
+ function main() {
465
+ const args = process.argv.slice(2);
466
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
467
+ printUsage();
468
+ process.exit(0);
469
+ }
470
+
471
+ const isMarkdown = args.includes('--markdown') || args.includes('-m');
472
+ const isJson = args.includes('--json') || args.includes('-j');
473
+ const positional = args.filter((a) => !a.startsWith('-'));
474
+
475
+ if (positional.length === 0) {
476
+ console.error('Error: Missing spec file argument.');
477
+ printUsage();
478
+ process.exit(1);
479
+ }
480
+
481
+ try {
482
+ const resolvedPath = resolveSpecPath(positional[0]);
483
+ const rawContent = fs.readFileSync(resolvedPath, 'utf8');
484
+ const parsedData = JSON.parse(rawContent);
485
+
486
+ const inspection = inspectSpec(parsedData, resolvedPath);
487
+
488
+ if (isJson) {
489
+ console.log(JSON.stringify(inspection, (key, value) => {
490
+ if (value instanceof Set) return Array.from(value);
491
+ if (value instanceof Map) return Object.fromEntries(value);
492
+ return value;
493
+ }, 2));
494
+ } else if (isMarkdown) {
495
+ console.log(formatMarkdownReport(inspection));
496
+ } else {
497
+ console.log(formatTerminalReport(inspection));
498
+ }
499
+ } catch (err) {
500
+ console.error(`\n✖ ${err.message}\n`);
501
+ process.exit(1);
502
+ }
503
+ }
504
+
505
+ if (require.main === module) {
506
+ main();
507
+ }
508
+
509
+ module.exports = {
510
+ resolveSpecPath,
511
+ inspectSpec,
512
+ formatTerminalReport,
513
+ formatMarkdownReport,
514
+ };
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+
3
+ const assert = require('assert');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const {
8
+ resolveSpecPath,
9
+ inspectSpec,
10
+ formatTerminalReport,
11
+ formatMarkdownReport,
12
+ } = require('./inspect_spec.js');
13
+
14
+ console.log('--- Running Spec Inspector Unit Tests ---');
15
+
16
+ // Mock Node AST tree
17
+ const mockSpec = {
18
+ name: 'Product Card',
19
+ type: 'FRAME',
20
+ bounds: { width: 360, height: 480 },
21
+ layoutMode: 'VERTICAL',
22
+ itemSpacing: 16,
23
+ itemSpacingToken: 'spacing/md',
24
+ padding: { top: 24, right: 24, bottom: 24, left: 24 },
25
+ paddingToken: 'spacing/lg',
26
+ cornerRadius: 12,
27
+ cornerRadiusToken: 'radii/lg',
28
+ fills: [{ type: 'SOLID', color: '#FFFFFF', token: 'color/surface' }],
29
+ children: [
30
+ {
31
+ name: 'Badge Instance',
32
+ type: 'INSTANCE',
33
+ component: { name: 'Badge' },
34
+ props: { variant: 'success', label: 'In Stock' },
35
+ },
36
+ {
37
+ name: 'Title',
38
+ type: 'TEXT',
39
+ characters: 'Wireless Noise-Canceling Headphones',
40
+ style: {
41
+ fontFamily: 'Inter',
42
+ fontSize: 20,
43
+ fontWeight: 700,
44
+ },
45
+ fills: [{ type: 'SOLID', color: '#111827', token: 'color/text-primary' }],
46
+ },
47
+ {
48
+ name: 'Description',
49
+ type: 'TEXT',
50
+ characters: 'Premium audio experience with active noise cancellation.',
51
+ style: {
52
+ fontFamily: 'Inter',
53
+ fontSize: 14,
54
+ fontWeight: 400,
55
+ },
56
+ },
57
+ {
58
+ name: 'Action Button',
59
+ type: 'INSTANCE',
60
+ component: { name: 'Button' },
61
+ props: { variant: 'primary', size: 'md', hasIcon: true },
62
+ },
63
+ {
64
+ name: 'Secondary Button',
65
+ type: 'INSTANCE',
66
+ component: { name: 'Button' },
67
+ props: { variant: 'outline', size: 'md' },
68
+ },
69
+ ],
70
+ };
71
+
72
+ // 1. inspectSpec: dimensions and layout
73
+ const report = inspectSpec(mockSpec, '/mock/.specs/card.json');
74
+ assert.strictEqual(report.rootLayout.name, 'Product Card');
75
+ assert.strictEqual(report.rootLayout.width, 360);
76
+ assert.strictEqual(report.rootLayout.height, 480);
77
+ assert.strictEqual(report.rootLayout.layoutMode, 'VERTICAL');
78
+ assert.strictEqual(report.rootLayout.itemSpacing, 16);
79
+ assert.strictEqual(report.rootLayout.itemSpacingToken, 'spacing/md');
80
+ assert.strictEqual(report.rootLayout.paddingToken, 'spacing/lg');
81
+ console.log(' ✓ inspectSpec: correctly extracts root dimensions and auto-layout metrics');
82
+
83
+ // 2. inspectSpec: tokens
84
+ assert.ok(report.tokens.colors.has('color/surface'), 'Includes surface color token');
85
+ assert.ok(report.tokens.colors.has('color/text-primary'), 'Includes text color token');
86
+ assert.ok(report.tokens.spacing.has('spacing/md'), 'Includes spacing/md token');
87
+ assert.ok(report.tokens.radii.has('radii/lg'), 'Includes radii/lg token');
88
+ console.log(' ✓ inspectSpec: aggregates design tokens across colors, spacing, and radii');
89
+
90
+ // 3. inspectSpec: component instances & gaps
91
+ assert.strictEqual(report.instances.size, 2, 'Detects 2 distinct components');
92
+ assert.strictEqual(report.instances.get('Button').length, 2, 'Detects 2 Button instances');
93
+ assert.strictEqual(report.instances.get('Badge').length, 1, 'Detects 1 Badge instance');
94
+ assert.strictEqual(report.instances.get('Button')[0].variant, 'primary');
95
+ assert.strictEqual(report.instances.get('Button')[1].variant, 'outline');
96
+ console.log(' ✓ inspectSpec: aggregates component instances and variant prop shapes');
97
+
98
+ // 4. inspectSpec: typography
99
+ assert.strictEqual(report.typography.totalTextLayers, 2);
100
+ assert.ok(report.typography.fontFamilies.has('Inter'));
101
+ assert.ok(report.typography.fontSizes.has(20));
102
+ assert.ok(report.typography.fontSizes.has(14));
103
+ assert.ok(report.typography.fontWeights.has(700));
104
+ console.log(' ✓ inspectSpec: extracts font families, sizes, weights, and text samples');
105
+
106
+ // 5. formatTerminalReport & formatMarkdownReport
107
+ const terminalText = formatTerminalReport(report);
108
+ assert.ok(terminalText.includes('Product Card'));
109
+ assert.ok(terminalText.includes('Vertical Flex (Column)'));
110
+ assert.ok(terminalText.includes('<Button />'));
111
+ assert.ok(terminalText.includes('<Badge />'));
112
+ assert.ok(terminalText.includes('Component Gaps & Codebase Checklist'));
113
+ console.log(' ✓ formatTerminalReport: formats clean box-drawing terminal report');
114
+
115
+ const markdownText = formatMarkdownReport(report);
116
+ assert.ok(markdownText.includes('### 📐 Figma Spec Summary: `card.json`'));
117
+ assert.ok(markdownText.includes('`<Button />`'));
118
+ assert.ok(markdownText.includes('`spacing/md`'));
119
+ console.log(' ✓ formatMarkdownReport: formats Markdown table and component checklist');
120
+
121
+ // 6. resolveSpecPath: resolution from disk
122
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-test-'));
123
+ try {
124
+ const specsSubdir = path.join(tmpDir, '.specs');
125
+ fs.mkdirSync(specsSubdir, { recursive: true });
126
+ const testFile = path.join(specsSubdir, 'product.json');
127
+ fs.writeFileSync(testFile, JSON.stringify(mockSpec), 'utf8');
128
+
129
+ // Direct path
130
+ assert.strictEqual(resolveSpecPath(testFile), testFile);
131
+
132
+ // Shorthand in cwd
133
+ const origCwd = process.cwd();
134
+ try {
135
+ process.chdir(tmpDir);
136
+ const expected = fs.realpathSync(path.resolve(specsSubdir, 'product.json'));
137
+ assert.strictEqual(fs.realpathSync(resolveSpecPath('product')), expected);
138
+ assert.strictEqual(fs.realpathSync(resolveSpecPath('.specs/product')), expected);
139
+ assert.strictEqual(fs.realpathSync(resolveSpecPath('.specs/product.json')), expected);
140
+ console.log(' ✓ resolveSpecPath: resolves direct files, shorthand names, and .specs/ subdirectories');
141
+ } finally {
142
+ process.chdir(origCwd);
143
+ }
144
+ } finally {
145
+ fs.rmSync(tmpDir, { recursive: true, force: true });
146
+ }
147
+
148
+ console.log('\nResults: 6/6 Spec Inspector tests passed.\n');