figma-cache-toolchain 2.0.3 → 2.0.4

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 (35) hide show
  1. package/README.md +201 -170
  2. package/cursor-bootstrap/AGENT-SETUP-PROMPT.md +75 -43
  3. package/cursor-bootstrap/examples/README.md +26 -15
  4. package/cursor-bootstrap/examples/ui-adapter.contract.template.json +90 -0
  5. package/cursor-bootstrap/examples/ui-execution-template.fast.md +11 -0
  6. package/cursor-bootstrap/examples/ui-execution-template.strict.md +13 -0
  7. package/cursor-bootstrap/examples/ui-override.template.json +26 -0
  8. package/cursor-bootstrap/figma-cache.config.example.js +51 -9
  9. package/cursor-bootstrap/managed-files.json +40 -40
  10. package/cursor-bootstrap/skills/figma-ui-dual-mode-execution/SKILL.md +55 -37
  11. package/figma-cache/adapters/recipes/button.recipe.json +24 -0
  12. package/figma-cache/adapters/recipes/card.recipe.json +24 -0
  13. package/figma-cache/adapters/recipes/checkbox.recipe.json +24 -0
  14. package/figma-cache/adapters/recipes/input.recipe.json +24 -0
  15. package/figma-cache/adapters/recipes/modal.recipe.json +25 -0
  16. package/figma-cache/adapters/recipes/radio.recipe.json +23 -0
  17. package/figma-cache/adapters/recipes/select.recipe.json +24 -0
  18. package/figma-cache/adapters/recipes/table.recipe.json +25 -0
  19. package/figma-cache/adapters/recipes/tabs.recipe.json +24 -0
  20. package/figma-cache/adapters/recipes/tooltip.recipe.json +24 -0
  21. package/figma-cache/docs/README.md +322 -237
  22. package/figma-cache/docs/p0-ui-preflight-handoff.md +207 -0
  23. package/figma-cache/docs/ui-1to1-optimization-roadmap.md +182 -0
  24. package/figma-cache/docs/ui-1to1-report.schema.json +104 -0
  25. package/figma-cache/figma-cache.js +639 -562
  26. package/figma-cache/js/contract-check-cli.js +466 -0
  27. package/figma-cache/js/cursor-bootstrap-cli.js +22 -0
  28. package/figma-cache/js/ui-facts-normalizer.js +233 -0
  29. package/package.json +93 -73
  30. package/scripts/cross-project-e2e.js +453 -0
  31. package/scripts/ui-1to1-audit.js +431 -0
  32. package/scripts/ui-auto-acceptance.js +248 -0
  33. package/scripts/ui-preflight.js +289 -0
  34. package/scripts/ui-profile.js +46 -0
  35. package/scripts/ui-report-aggregate.js +124 -0
@@ -0,0 +1,453 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+ "use strict";
4
+
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const { execSync } = require("child_process");
8
+
9
+ const ROOT = process.cwd();
10
+ const FAIL_EXIT_CODE = 2;
11
+
12
+ function resolveMaybeAbsolutePath(input) {
13
+ if (!input) {
14
+ return "";
15
+ }
16
+ return path.isAbsolute(input) ? path.normalize(input) : path.join(ROOT, input);
17
+ }
18
+
19
+ function parseArgs(argv) {
20
+ const options = {
21
+ targetProject: "",
22
+ cacheKey: "",
23
+ fileKey: "",
24
+ nodeId: "",
25
+ target: "",
26
+ minScore: 90,
27
+ maxWarnings: 0,
28
+ maxDiffs: 2,
29
+ profile: "",
30
+ keepPackage: false,
31
+ autoBootstrapContract: true,
32
+ autoEnsureOnMiss: false,
33
+ allowSkeletonWithFigmaMcp: false,
34
+ completeness: "layout,text,tokens,interactions,states,accessibility",
35
+ batchFile: "",
36
+ fixLoop: 0,
37
+ };
38
+
39
+ argv.forEach((arg) => {
40
+ if (arg.startsWith("--target-project=")) {
41
+ options.targetProject = arg.split("=").slice(1).join("=").trim();
42
+ return;
43
+ }
44
+ if (arg.startsWith("--cacheKey=")) {
45
+ options.cacheKey = arg.split("=").slice(1).join("=").trim();
46
+ return;
47
+ }
48
+ if (arg.startsWith("--fileKey=")) {
49
+ options.fileKey = arg.split("=").slice(1).join("=").trim();
50
+ return;
51
+ }
52
+ if (arg.startsWith("--nodeId=")) {
53
+ options.nodeId = arg.split("=").slice(1).join("=").trim();
54
+ return;
55
+ }
56
+ if (arg.startsWith("--target=")) {
57
+ options.target = arg.split("=").slice(1).join("=").trim();
58
+ return;
59
+ }
60
+ if (arg.startsWith("--min-score=")) {
61
+ const n = Number(arg.split("=").slice(1).join("=").trim());
62
+ options.minScore = Number.isFinite(n) ? n : options.minScore;
63
+ return;
64
+ }
65
+ if (arg.startsWith("--max-warnings=")) {
66
+ const n = Number(arg.split("=").slice(1).join("=").trim());
67
+ options.maxWarnings = Number.isFinite(n) ? n : options.maxWarnings;
68
+ return;
69
+ }
70
+ if (arg.startsWith("--max-diffs=")) {
71
+ const n = Number(arg.split("=").slice(1).join("=").trim());
72
+ options.maxDiffs = Number.isFinite(n) ? n : options.maxDiffs;
73
+ return;
74
+ }
75
+ if (arg.startsWith("--profile=")) {
76
+ options.profile = arg.split("=").slice(1).join("=").trim();
77
+ return;
78
+ }
79
+ if (arg === "--keep-package") {
80
+ options.keepPackage = true;
81
+ return;
82
+ }
83
+ if (arg === "--no-auto-bootstrap-contract") {
84
+ options.autoBootstrapContract = false;
85
+ return;
86
+ }
87
+ if (arg === "--auto-ensure-on-miss") {
88
+ options.autoEnsureOnMiss = true;
89
+ return;
90
+ }
91
+ if (arg === "--allow-skeleton-with-figma-mcp") {
92
+ options.allowSkeletonWithFigmaMcp = true;
93
+ return;
94
+ }
95
+ if (arg.startsWith("--completeness=")) {
96
+ options.completeness = arg.split("=").slice(1).join("=").trim() || options.completeness;
97
+ return;
98
+ }
99
+ if (arg.startsWith("--batch-file=")) {
100
+ options.batchFile = arg.split("=").slice(1).join("=").trim();
101
+ return;
102
+ }
103
+ if (arg.startsWith("--fix-loop=")) {
104
+ const n = Number(arg.split("=").slice(1).join("=").trim());
105
+ options.fixLoop = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
106
+ }
107
+ });
108
+
109
+ return options;
110
+ }
111
+
112
+ function runCommand(command, cwd, extraEnv) {
113
+ return execSync(command, {
114
+ cwd,
115
+ encoding: "utf8",
116
+ stdio: ["pipe", "pipe", "pipe"],
117
+ env: {
118
+ ...process.env,
119
+ ...(extraEnv || {}),
120
+ },
121
+ });
122
+ }
123
+
124
+ function normalizeNodeId(input) {
125
+ const value = String(input || "").trim();
126
+ if (!value) {
127
+ return "";
128
+ }
129
+ return value.includes(":") ? value : value.replace(/-/g, ":");
130
+ }
131
+
132
+ function resolveCacheKey(options) {
133
+ if (options.cacheKey) {
134
+ return options.cacheKey;
135
+ }
136
+ if (options.fileKey && options.nodeId) {
137
+ return `${options.fileKey}#${normalizeNodeId(options.nodeId)}`;
138
+ }
139
+ return "";
140
+ }
141
+
142
+ function npmPackAndGetTarball() {
143
+ const raw = runCommand("npm pack", ROOT);
144
+ const lines = raw
145
+ .split(/\r?\n/)
146
+ .map((line) => line.trim())
147
+ .filter(Boolean);
148
+ const fileName = [...lines].reverse().find((line) => /\.tgz$/i.test(line));
149
+ if (!fileName) {
150
+ throw new Error("npm pack returned no tarball name");
151
+ }
152
+ return path.join(ROOT, fileName);
153
+ }
154
+
155
+ function readJsonOrNull(absPath) {
156
+ try {
157
+ return JSON.parse(fs.readFileSync(absPath, "utf8"));
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ function normalizeSlash(input) {
164
+ return String(input || "").replace(/\\/g, "/");
165
+ }
166
+
167
+ function parseCacheKey(cacheKey) {
168
+ const value = String(cacheKey || "").trim();
169
+ const [fileKey, nodeId] = value.split("#");
170
+ if (!fileKey || !nodeId) {
171
+ return null;
172
+ }
173
+ return {
174
+ fileKey,
175
+ nodeId,
176
+ };
177
+ }
178
+
179
+ function buildFigmaUrl(fileKey, nodeId) {
180
+ const normalizedNodeId = String(nodeId || "").replace(/:/g, "-");
181
+ return `https://www.figma.com/file/${fileKey}/auto-e2e?node-id=${normalizedNodeId}`;
182
+ }
183
+
184
+ function readTargetIndexItem(targetProject, cacheKey) {
185
+ const indexPath = path.join(targetProject, "figma-cache", "index.json");
186
+ const index = readJsonOrNull(indexPath);
187
+ const items = index && index.items && typeof index.items === "object" ? index.items : {};
188
+ return items[cacheKey] || null;
189
+ }
190
+
191
+ function ensureCacheViaFigmaMcp(targetProject, cacheKey, options) {
192
+ const parsed = parseCacheKey(cacheKey);
193
+ if (!parsed) {
194
+ throw new Error(`invalid cacheKey for auto ensure: ${cacheKey}`);
195
+ }
196
+ const cliPath = path.join(
197
+ targetProject,
198
+ "node_modules",
199
+ "figma-cache-toolchain",
200
+ "bin",
201
+ "figma-cache.js"
202
+ );
203
+ if (!fs.existsSync(cliPath)) {
204
+ throw new Error("figma-cache cli not found in target project node_modules");
205
+ }
206
+ const figmaUrl = buildFigmaUrl(parsed.fileKey, parsed.nodeId);
207
+ const args = [
208
+ `node "${cliPath}"`,
209
+ "ensure",
210
+ `"${figmaUrl}"`,
211
+ "--source=figma-mcp",
212
+ `--completeness=${options.completeness}`,
213
+ ];
214
+ if (options.allowSkeletonWithFigmaMcp) {
215
+ args.push("--allow-skeleton-with-figma-mcp");
216
+ }
217
+ runCommand(args.join(" "), targetProject);
218
+ }
219
+
220
+ function bootstrapContractIfNeeded(targetProject, options) {
221
+ if (!options.autoBootstrapContract) {
222
+ return;
223
+ }
224
+ const contractPath = path.join(targetProject, "figma-cache", "adapters", "ui-adapter.contract.json");
225
+ if (fs.existsSync(contractPath)) {
226
+ return;
227
+ }
228
+ const templatePath = path.join(
229
+ targetProject,
230
+ "node_modules",
231
+ "figma-cache-toolchain",
232
+ "cursor-bootstrap",
233
+ "examples",
234
+ "ui-adapter.contract.template.json"
235
+ );
236
+ if (fs.existsSync(templatePath)) {
237
+ fs.mkdirSync(path.dirname(contractPath), { recursive: true });
238
+ fs.copyFileSync(templatePath, contractPath);
239
+ }
240
+ }
241
+
242
+ function runSingleCase(input, context) {
243
+ const { targetProject, acceptScriptPath, options } = context;
244
+ const item = {
245
+ cacheKey: input.cacheKey || "",
246
+ fileKey: input.fileKey || "",
247
+ nodeId: input.nodeId || "",
248
+ target: input.target || options.target || "",
249
+ minScore: Number.isFinite(Number(input.minScore)) ? Number(input.minScore) : options.minScore,
250
+ maxWarnings: Number.isFinite(Number(input.maxWarnings))
251
+ ? Number(input.maxWarnings)
252
+ : options.maxWarnings,
253
+ maxDiffs: Number.isFinite(Number(input.maxDiffs)) ? Number(input.maxDiffs) : options.maxDiffs,
254
+ };
255
+ const cacheKey = item.cacheKey || resolveCacheKey(item);
256
+ if (!cacheKey) {
257
+ throw new Error("batch item missing cacheKey or (fileKey + nodeId)");
258
+ }
259
+ const targetPath = item.target ? resolveMaybeAbsolutePath(item.target) : "";
260
+ if (!targetPath) {
261
+ throw new Error(`batch item ${cacheKey} missing target path`);
262
+ }
263
+
264
+ const acceptArgs = [
265
+ `--cacheKey=${cacheKey}`,
266
+ `--target=${targetPath}`,
267
+ `--min-score=${item.minScore}`,
268
+ `--max-warnings=${item.maxWarnings}`,
269
+ `--max-diffs=${item.maxDiffs}`,
270
+ ];
271
+ const env = {};
272
+ if (options.profile) {
273
+ env.FIGMA_UI_PROFILE = options.profile;
274
+ }
275
+ let attempt = 0;
276
+ let lastError = "";
277
+ let acceptanceJson = null;
278
+ const attemptLogs = [];
279
+ const maxAttempts = 1 + options.fixLoop;
280
+ while (attempt < maxAttempts) {
281
+ attempt += 1;
282
+ let itemExists = !!readTargetIndexItem(targetProject, cacheKey);
283
+ if (!itemExists && options.autoEnsureOnMiss) {
284
+ ensureCacheViaFigmaMcp(targetProject, cacheKey, options);
285
+ itemExists = !!readTargetIndexItem(targetProject, cacheKey);
286
+ }
287
+ if (!itemExists) {
288
+ lastError = `cacheKey miss: ${cacheKey}. try --auto-ensure-on-miss or pre-populate cache`;
289
+ attemptLogs.push({ attempt, ok: false, reason: lastError });
290
+ if (attempt >= maxAttempts) {
291
+ break;
292
+ }
293
+ continue;
294
+ }
295
+ try {
296
+ const acceptanceOutput = runCommand(
297
+ `node "${acceptScriptPath}" ${acceptArgs.join(" ")}`,
298
+ targetProject,
299
+ env
300
+ );
301
+ try {
302
+ acceptanceJson = JSON.parse(acceptanceOutput);
303
+ } catch {}
304
+ attemptLogs.push({ attempt, ok: true });
305
+ return {
306
+ ok: true,
307
+ cacheKey,
308
+ targetPath: normalizeSlash(targetPath),
309
+ acceptance: acceptanceJson,
310
+ attempts: attempt,
311
+ attemptLogs,
312
+ };
313
+ } catch (error) {
314
+ lastError = error && error.message ? error.message : "unknown acceptance error";
315
+ attemptLogs.push({ attempt, ok: false, reason: lastError });
316
+ if (attempt >= maxAttempts) {
317
+ break;
318
+ }
319
+ // self-healing retry: re-bootstrap contract + refresh cache evidence when enabled
320
+ bootstrapContractIfNeeded(targetProject, options);
321
+ if (options.autoEnsureOnMiss) {
322
+ try {
323
+ ensureCacheViaFigmaMcp(targetProject, cacheKey, options);
324
+ } catch {}
325
+ }
326
+ }
327
+ }
328
+ throw new Error(
329
+ `acceptance failed after ${maxAttempts} attempts for ${cacheKey}: ${lastError}`
330
+ );
331
+ }
332
+
333
+ function run() {
334
+ const options = parseArgs(process.argv.slice(2));
335
+ const targetProject = resolveMaybeAbsolutePath(options.targetProject);
336
+ if (!targetProject || !fs.existsSync(targetProject)) {
337
+ console.error("cross-project-e2e failed: --target-project is required and must exist");
338
+ process.exit(FAIL_EXIT_CODE);
339
+ }
340
+ const isBatchMode = !!options.batchFile;
341
+ if (!isBatchMode) {
342
+ const targetPath = options.target ? resolveMaybeAbsolutePath(options.target) : "";
343
+ if (!targetPath) {
344
+ console.error("cross-project-e2e failed: --target is required for real component validation");
345
+ process.exit(FAIL_EXIT_CODE);
346
+ }
347
+ const cacheKey = resolveCacheKey(options);
348
+ if (!cacheKey) {
349
+ console.error("cross-project-e2e failed: provide --cacheKey or (--fileKey + --nodeId)");
350
+ process.exit(FAIL_EXIT_CODE);
351
+ }
352
+ }
353
+
354
+ let tarballPath = "";
355
+ try {
356
+ tarballPath = npmPackAndGetTarball();
357
+ runCommand(`npm i -D "${tarballPath}"`, targetProject);
358
+
359
+ const acceptScript = path.join(
360
+ targetProject,
361
+ "node_modules",
362
+ "figma-cache-toolchain",
363
+ "scripts",
364
+ "ui-auto-acceptance.js"
365
+ );
366
+ if (!fs.existsSync(acceptScript)) {
367
+ throw new Error("ui-auto-acceptance.js not found in installed package; check package files field");
368
+ }
369
+ bootstrapContractIfNeeded(targetProject, options);
370
+
371
+ const cases = isBatchMode
372
+ ? (() => {
373
+ const batchPath = resolveMaybeAbsolutePath(options.batchFile);
374
+ const payload = readJsonOrNull(batchPath);
375
+ if (!Array.isArray(payload) || !payload.length) {
376
+ throw new Error(`invalid batch file: ${batchPath}`);
377
+ }
378
+ return payload;
379
+ })()
380
+ : [
381
+ {
382
+ cacheKey: options.cacheKey,
383
+ fileKey: options.fileKey,
384
+ nodeId: options.nodeId,
385
+ target: options.target,
386
+ minScore: options.minScore,
387
+ maxWarnings: options.maxWarnings,
388
+ maxDiffs: options.maxDiffs,
389
+ },
390
+ ];
391
+
392
+ const caseResults = [];
393
+ const caseFailures = [];
394
+ cases.forEach((entry, indexNo) => {
395
+ try {
396
+ caseResults.push(
397
+ runSingleCase(entry, {
398
+ targetProject,
399
+ acceptScriptPath: acceptScript,
400
+ options,
401
+ })
402
+ );
403
+ } catch (error) {
404
+ caseFailures.push({
405
+ index: indexNo,
406
+ cacheKey: entry && (entry.cacheKey || resolveCacheKey(entry)),
407
+ reason: error.message,
408
+ });
409
+ }
410
+ });
411
+ if (caseFailures.length) {
412
+ throw new Error(`batch cases failed: ${JSON.stringify(caseFailures)}`);
413
+ }
414
+
415
+ const reportBase = path.join(targetProject, "figma-cache", "reports");
416
+ const output = {
417
+ ok: true,
418
+ generatedAt: new Date().toISOString(),
419
+ targetProject,
420
+ mode: isBatchMode ? "batch" : "single",
421
+ profile: options.profile || null,
422
+ autoEnsureOnMiss: options.autoEnsureOnMiss,
423
+ allowSkeletonWithFigmaMcp: options.allowSkeletonWithFigmaMcp,
424
+ completeness: options.completeness,
425
+ tarballPath,
426
+ reports: {
427
+ preflight: path.join(reportBase, "ui-preflight-report.json"),
428
+ audit: path.join(reportBase, "ui-1to1-report.json"),
429
+ summary: path.join(reportBase, "ui-quality-summary.json"),
430
+ },
431
+ cases: caseResults,
432
+ };
433
+
434
+ const summary = readJsonOrNull(output.reports.summary);
435
+ if (summary) {
436
+ output.summaryMetrics = summary.metrics || null;
437
+ output.summaryStatus = summary.trend && summary.trend.status;
438
+ }
439
+ console.log(JSON.stringify(output, null, 2));
440
+ } catch (error) {
441
+ console.error("cross-project-e2e failed:");
442
+ console.error(`- ${error.message}`);
443
+ process.exit(FAIL_EXIT_CODE);
444
+ } finally {
445
+ if (tarballPath && !options.keepPackage) {
446
+ try {
447
+ fs.unlinkSync(tarballPath);
448
+ } catch {}
449
+ }
450
+ }
451
+ }
452
+
453
+ run();