figma-cache-toolchain 2.0.3 → 2.0.5

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 +197 -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 +323 -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 +594 -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
@@ -1,562 +1,639 @@
1
- #!/usr/bin/env node
2
- /* eslint-disable no-console */
3
- const fs = require("fs");
4
- const path = require("path");
5
- const { createRequire } = require("module");
6
- const { URL } = require("url");
7
- const { handleFlowCommand } = require("./js/flow-cli");
8
- const { validateMcpRawEvidence, validateIndex } = require("./js/validate-cli");
9
- const { buildBudgetReport } = require("./js/budget-cli");
10
- const { createIndexStore } = require("./js/index-store");
11
- const { copyCursorBootstrap } = require("./js/cursor-bootstrap-cli");
12
- const { createEntryFilesService } = require("./js/entry-files");
13
- const { backfillFromIterations } = require("./js/backfill-cli");
14
- const { createUpsertService } = require("./js/upsert-core");
15
- const { createProjectConfigService } = require("./js/project-config");
16
-
17
- const ROOT = process.cwd();
18
- const NORMALIZATION_VERSION = 1;
19
- const SCHEMA_VERSION = 2;
20
- const DEFAULT_COMPLETENESS = Object.freeze([
21
- "layout",
22
- "text",
23
- "tokens",
24
- "interactions",
25
- "states",
26
- "accessibility",
27
- ]);
28
- const COMPLETENESS_ALL_DIMENSIONS = Object.freeze([
29
- "layout",
30
- "text",
31
- "tokens",
32
- "interactions",
33
- "states",
34
- "accessibility",
35
- "flow",
36
- "assets",
37
- ]);
38
- const COMPLETENESS_TOOL_REQUIREMENTS = Object.freeze({
39
- layout: Object.freeze([
40
- Object.freeze(["get_metadata", "get_design_context"]),
41
- ]),
42
- text: Object.freeze([Object.freeze(["get_design_context"])]),
43
- tokens: Object.freeze([Object.freeze(["get_variable_defs"])]),
44
- interactions: Object.freeze([Object.freeze(["get_design_context"])]),
45
- states: Object.freeze([Object.freeze(["get_design_context"])]),
46
- accessibility: Object.freeze([Object.freeze(["get_design_context"])]),
47
- flow: Object.freeze([Object.freeze(["get_design_context"])]),
48
- assets: Object.freeze([Object.freeze(["get_design_context"])]),
49
- });
50
-
51
- function parsePositiveInt(input, fallback) {
52
- const n = Number(input);
53
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
54
- }
55
-
56
- function normalizeSlash(input) {
57
- return input.replace(/\\/g, "/");
58
- }
59
-
60
- function resolveMaybeAbsolutePath(input) {
61
- if (path.isAbsolute(input)) {
62
- return path.normalize(input);
63
- }
64
- return path.join(ROOT, input);
65
- }
66
-
67
- function toProjectRelativeOrAbsolute(absPath) {
68
- const relative = path.relative(ROOT, absPath);
69
- if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
70
- return normalizeSlash(relative);
71
- }
72
- return normalizeSlash(absPath);
73
- }
74
-
75
- const CACHE_DIR_INPUT = process.env.FIGMA_CACHE_DIR || "figma-cache";
76
- const ITERATIONS_DIR_INPUT =
77
- process.env.FIGMA_ITERATIONS_DIR || "library/figma-iterations";
78
- const INDEX_FILE_NAME = process.env.FIGMA_CACHE_INDEX_FILE || "index.json";
79
- const DEFAULT_FLOW_ID = process.env.FIGMA_DEFAULT_FLOW || "";
80
- const DEFAULT_STALE_DAYS = parsePositiveInt(
81
- process.env.FIGMA_CACHE_STALE_DAYS,
82
- 14,
83
- );
84
-
85
- const CACHE_DIR = resolveMaybeAbsolutePath(CACHE_DIR_INPUT);
86
- /** 涓?`figma-cache/figma-cache.js` 鍚岀骇鐨?`cursor-bootstrap/`锛堥殢 npm 鍖呭垎鍙戯級 */
87
- const CURSOR_BOOTSTRAP_DIR = path.join(__dirname, "..", "cursor-bootstrap");
88
- const ITERATIONS_DIR = resolveMaybeAbsolutePath(ITERATIONS_DIR_INPUT);
89
-
90
- /** 褰撳墠瀹夎鍖呭湪 package.json 涓殑 name锛堢敤浜庡啓鍏?AGENT-SETUP-PROMPT.md锛?*/
91
- function readSelfNpmPackageName() {
92
- try {
93
- const pkgPath = path.join(__dirname, "..", "package.json");
94
- const raw = fs.readFileSync(pkgPath, "utf8");
95
- const j = JSON.parse(raw);
96
- return j && j.name ? String(j.name) : "figma-cache-toolchain";
97
- } catch {
98
- return "figma-cache-toolchain";
99
- }
100
- }
101
- const INDEX_PATH = path.isAbsolute(INDEX_FILE_NAME)
102
- ? INDEX_FILE_NAME
103
- : path.join(CACHE_DIR, INDEX_FILE_NAME);
104
- const CACHE_BASE_FOR_STORAGE = toProjectRelativeOrAbsolute(CACHE_DIR);
105
-
106
- const indexStore = createIndexStore({
107
- fs,
108
- CACHE_DIR,
109
- INDEX_PATH,
110
- SCHEMA_VERSION,
111
- NORMALIZATION_VERSION,
112
- });
113
- const {
114
- ensureCacheDir,
115
- buildEmptyIndex,
116
- normalizeIndexShape,
117
- readIndex,
118
- writeIndex,
119
- getItem,
120
- } = indexStore;
121
-
122
- const projectConfigService = createProjectConfigService({
123
- fs,
124
- path,
125
- ROOT,
126
- createRequire,
127
- resolveMaybeAbsolutePath,
128
- normalizeSlash,
129
- });
130
- const { loadProjectConfig, runPostEnsureHook, getProjectConfigPath } =
131
- projectConfigService;
132
-
133
- const upsertService = createUpsertService({
134
- URL,
135
- NORMALIZATION_VERSION,
136
- CACHE_BASE_FOR_STORAGE,
137
- DEFAULT_COMPLETENESS,
138
- normalizeCompletenessList,
139
- normalizeIndexShape,
140
- readIndex,
141
- getItem,
142
- writeIndex,
143
- });
144
- const { normalizeFigmaUrl, previewUpsertByUrl, upsertByUrl } = upsertService;
145
-
146
- function resolveFlowIdFromArgs(rest) {
147
- const flowArg = rest.find((x) => x.startsWith("--flow="));
148
- if (flowArg) {
149
- return flowArg.split("=")[1];
150
- }
151
- if (DEFAULT_FLOW_ID) {
152
- return DEFAULT_FLOW_ID;
153
- }
154
- return "";
155
- }
156
-
157
- function normalizeCompletenessList(input) {
158
- if (!Array.isArray(input)) {
159
- return [];
160
- }
161
- const seen = new Set();
162
- const output = [];
163
- input.forEach((entry) => {
164
- const value = String(entry || "").trim();
165
- if (!value || seen.has(value)) {
166
- return;
167
- }
168
- seen.add(value);
169
- output.push(value);
170
- });
171
- return output;
172
- }
173
-
174
- const entryFilesService = createEntryFilesService({
175
- fs,
176
- path,
177
- resolveMaybeAbsolutePath,
178
- normalizeCompletenessList,
179
- completenessAllDimensions: COMPLETENESS_ALL_DIMENSIONS,
180
- runPostEnsureHook,
181
- });
182
- const { ensureEntryFilesAndHook } = entryFilesService;
183
-
184
- function parseCompletenessFromArgs(args) {
185
- const completenessArg = args.find((x) => x.startsWith("--completeness="));
186
- if (!completenessArg) {
187
- return {
188
- completeness: [...DEFAULT_COMPLETENESS],
189
- fromCliArg: false,
190
- };
191
- }
192
- return {
193
- completeness: normalizeCompletenessList(
194
- completenessArg.split("=").slice(1).join("=").split(","),
195
- ),
196
- fromCliArg: true,
197
- };
198
- }
199
-
200
- /** @returns {string} */
201
- function inferCliExample() {
202
- const n = normalizeSlash(String(process.argv[1] || ""));
203
- if (/\/bin\/figma-cache\.js$/i.test(n)) {
204
- return "node bin/figma-cache.js";
205
- }
206
- if (/\/figma-cache\/figma-cache\.js$/i.test(n)) {
207
- return "node figma-cache/figma-cache.js";
208
- }
209
- return "figma-cache";
210
- }
211
-
212
- function printStale(days) {
213
- const index = readIndex();
214
- const now = Date.now();
215
- const threshold = days * 24 * 60 * 60 * 1000;
216
- const keys = Object.keys(index.items || {});
217
- const stale = keys.filter((cacheKey) => {
218
- const item = index.items[cacheKey];
219
- const ts = item.syncedAt ? Date.parse(item.syncedAt) : NaN;
220
- if (Number.isNaN(ts)) {
221
- return true;
222
- }
223
- return now - ts > threshold;
224
- });
225
- if (!stale.length) {
226
- console.log(`No stale entries (>${days}d).`);
227
- return;
228
- }
229
- console.log(`Stale entries (>${days}d):`);
230
- stale.forEach((key) => {
231
- console.log(`- ${key}`);
232
- });
233
- }
234
-
235
- function safeReadJson(absPath) {
236
- try {
237
- return JSON.parse(fs.readFileSync(absPath, "utf8"));
238
- } catch {
239
- return null;
240
- }
241
- }
242
-
243
- function safeFileSize(absPath) {
244
- try {
245
- return fs.statSync(absPath).size;
246
- } catch {
247
- return 0;
248
- }
249
- }
250
-
251
- function runUpsertLikeCommand(commandName, args, shouldEnsureFiles) {
252
- const url = args[0];
253
- const sourceArg = args.find((x) => x.startsWith("--source="));
254
- const source = sourceArg ? sourceArg.split("=")[1] : "manual";
255
- const allowSkeletonWithFigmaMcp = args.includes(
256
- "--allow-skeleton-with-figma-mcp",
257
- );
258
- const { completeness } = parseCompletenessFromArgs(args);
259
-
260
- const preview = previewUpsertByUrl(url, { source, completeness });
261
- if (source === "figma-mcp") {
262
- const mcpErrors = validateMcpRawEvidence(
263
- preview.normalized.cacheKey,
264
- preview.item,
265
- completeness,
266
- { allowSkeletonWithFigmaMcp },
267
- {
268
- fs,
269
- path,
270
- resolveMaybeAbsolutePath,
271
- safeReadJson,
272
- normalizeSlash,
273
- normalizeCompletenessList,
274
- completenessToolRequirements: COMPLETENESS_TOOL_REQUIREMENTS,
275
- },
276
- );
277
- if (mcpErrors.length) {
278
- console.error(
279
- `${commandName} failed: source=figma-mcp but MCP raw evidence is incomplete`,
280
- );
281
- mcpErrors.forEach((err) => console.error(`- ${err}`));
282
- process.exit(2);
283
- }
284
- }
285
-
286
- const result = upsertByUrl(url, { source, completeness });
287
- if (shouldEnsureFiles) {
288
- ensureEntryFilesAndHook(result.normalized.cacheKey, result.item);
289
- console.log(
290
- JSON.stringify(
291
- {
292
- cacheKey: result.normalized.cacheKey,
293
- ensured: true,
294
- paths: result.item.paths,
295
- },
296
- null,
297
- 2,
298
- ),
299
- );
300
- return;
301
- }
302
-
303
- console.log(
304
- JSON.stringify(
305
- {
306
- cacheKey: result.normalized.cacheKey,
307
- scope: result.item.scope,
308
- syncedAt: result.item.syncedAt,
309
- },
310
- null,
311
- 2,
312
- ),
313
- );
314
- }
315
- function run() {
316
- const [, , cmd, ...args] = process.argv;
317
- if (!cmd) {
318
- const ex = inferCliExample();
319
- const defaultCompletenessText = DEFAULT_COMPLETENESS.join(",");
320
- console.log("Usage:");
321
- console.log(
322
- ` (invoke examples: ${ex} | node bin/figma-cache.js | node figma-cache/figma-cache.js)`,
323
- );
324
- console.log(` ${ex} normalize <figmaUrl>`);
325
- console.log(` ${ex} get <figmaUrl>`);
326
- console.log(
327
- ` ${ex} upsert <figmaUrl> [--source=manual] [--completeness=a,b] [--allow-skeleton-with-figma-mcp] (default completeness=${defaultCompletenessText})`,
328
- );
329
- console.log(` ${ex} validate`);
330
- console.log(` ${ex} stale [--days=14]`);
331
- console.log(` ${ex} backfill`);
332
- console.log(
333
- ` ${ex} budget [--mcp-only] [--cacheKey=<fileKey#nodeId>] [--limit=50]`,
334
- );
335
- console.log(
336
- ` ${ex} ensure <figmaUrl> [--source=manual] [--completeness=a,b] [--allow-skeleton-with-figma-mcp] (default completeness=${defaultCompletenessText})`,
337
- );
338
- console.log(` ${ex} init`);
339
- console.log(` ${ex} config`);
340
- console.log(
341
- " (optional) figma-cache.config.js | .figmacacherc.js | FIGMA_CACHE_PROJECT_CONFIG 鈥?hooks.postEnsure after ensure",
342
- );
343
- console.log(` ${ex} flow init --id=<flowId> [--title=...]`);
344
- console.log(
345
- `${ex} flow add-node --flow=<flowId> <figmaUrl> [--ensure] [--source=manual] [--completeness=a,b]`,
346
- );
347
- console.log(
348
- `${ex} flow link --flow=<flowId> <fromUrl> <toUrl> --type=next_step [--note=...]`,
349
- );
350
- console.log(
351
- `${ex} flow chain --flow=<flowId> <url1> <url2> ... [--type=next_step|related]`,
352
- );
353
- console.log(` ${ex} flow show --flow=<flowId>`);
354
- console.log(` ${ex} flow mermaid --flow=<flowId>`);
355
- console.log(
356
- `${ex} cursor init [--overwrite] [--force] # default safe mode; --overwrite forces replacement; --force keeps legacy behavior (no overwrite)`,
357
- );
358
- process.exit(1);
359
- }
360
-
361
- if (cmd === "cursor") {
362
- const sub = args[0];
363
- if (sub !== "init") {
364
- console.error(
365
- "Usage: figma-cache cursor init [--overwrite] [--force] # --overwrite replaces existing templates; --force keeps legacy no-overwrite behavior",
366
- );
367
- process.exit(1);
368
- }
369
- const hasOverwrite = args.includes("--overwrite");
370
- const hasForce = args.includes("--force");
371
- if (hasOverwrite && hasForce) {
372
- console.error("Do not use --overwrite and --force together. Choose one mode.");
373
- process.exit(1);
374
- }
375
- const overwrite = hasOverwrite;
376
- copyCursorBootstrap({ overwrite, legacyForce: hasForce }, {
377
- fs,
378
- path,
379
- ROOT,
380
- CACHE_DIR,
381
- CURSOR_BOOTSTRAP_DIR,
382
- normalizeSlash,
383
- readSelfNpmPackageName,
384
- packageDir: __dirname,
385
- });
386
- return;
387
- }
388
-
389
- if (cmd === "normalize") {
390
- const url = args[0];
391
- const normalized = normalizeFigmaUrl(url);
392
- console.log(JSON.stringify(normalized, null, 2));
393
- return;
394
- }
395
-
396
- if (cmd === "get") {
397
- const url = args[0];
398
- const normalized = normalizeFigmaUrl(url);
399
- const index = readIndex();
400
- const item = getItem(index, normalized.cacheKey);
401
- console.log(
402
- JSON.stringify(
403
- {
404
- found: !!item,
405
- cacheKey: normalized.cacheKey,
406
- item: item || null,
407
- },
408
- null,
409
- 2,
410
- ),
411
- );
412
- return;
413
- }
414
-
415
- if (cmd === "upsert") {
416
- runUpsertLikeCommand("upsert", args, false);
417
- return;
418
- }
419
-
420
- if (cmd === "ensure") {
421
- runUpsertLikeCommand("ensure", args, true);
422
- return;
423
- }
424
- if (cmd === "validate") {
425
- const index = readIndex();
426
- const errors = validateIndex(index, {
427
- fs,
428
- path,
429
- normalizeIndexShape,
430
- normalizeCompletenessList,
431
- resolveMaybeAbsolutePath,
432
- safeReadJson,
433
- normalizeSlash,
434
- completenessToolRequirements: COMPLETENESS_TOOL_REQUIREMENTS,
435
- });
436
- if (!errors.length) {
437
- console.log("Validation passed.");
438
- return;
439
- }
440
- console.error("Validation failed:");
441
- errors.forEach((err) => console.error(`- ${err}`));
442
- process.exit(2);
443
- }
444
-
445
- if (cmd === "stale") {
446
- const daysArg = args.find((x) => x.startsWith("--days="));
447
- const days = daysArg ? Number(daysArg.split("=")[1]) : DEFAULT_STALE_DAYS;
448
- printStale(days);
449
- return;
450
- }
451
-
452
- if (cmd === "backfill") {
453
- backfillFromIterations(
454
- { iterationsDir: ITERATIONS_DIR },
455
- {
456
- fs,
457
- path,
458
- upsertByUrl,
459
- },
460
- );
461
- return;
462
- }
463
-
464
- if (cmd === "budget") {
465
- const mcpOnly = args.includes("--mcp-only");
466
- const cacheKeyArg = args.find((x) => x.startsWith("--cacheKey="));
467
- const limitArg = args.find((x) => x.startsWith("--limit="));
468
- const cacheKey = cacheKeyArg
469
- ? cacheKeyArg.split("=").slice(1).join("=")
470
- : "";
471
- const limit = limitArg ? limitArg.split("=")[1] : "";
472
- const report = buildBudgetReport(
473
- { mcpOnly, cacheKey, limit },
474
- {
475
- path,
476
- normalizeIndexShape,
477
- readIndex,
478
- resolveMaybeAbsolutePath,
479
- safeReadJson,
480
- safeFileSize,
481
- },
482
- );
483
- console.log(JSON.stringify(report, null, 2));
484
- return;
485
- }
486
-
487
- if (cmd === "config") {
488
- const cfg = loadProjectConfig();
489
- const hooks = cfg && cfg.hooks;
490
- console.log(
491
- JSON.stringify(
492
- {
493
- root: normalizeSlash(ROOT),
494
- cacheDir: normalizeSlash(CACHE_DIR),
495
- indexPath: normalizeSlash(INDEX_PATH),
496
- iterationsDir: normalizeSlash(ITERATIONS_DIR),
497
- staleDays: DEFAULT_STALE_DAYS,
498
- defaultFlowId: DEFAULT_FLOW_ID || null,
499
- defaultCompleteness: [...DEFAULT_COMPLETENESS],
500
- normalizationVersion: NORMALIZATION_VERSION,
501
- projectConfigPath: getProjectConfigPath(),
502
- hooks: {
503
- postEnsure: !!(hooks && typeof hooks.postEnsure === "function"),
504
- },
505
- },
506
- null,
507
- 2,
508
- ),
509
- );
510
- return;
511
- }
512
-
513
- if (cmd === "init") {
514
- ensureCacheDir();
515
- if (fs.existsSync(INDEX_PATH)) {
516
- console.log(
517
- JSON.stringify(
518
- {
519
- created: false,
520
- reason: "index_exists",
521
- indexPath: normalizeSlash(INDEX_PATH),
522
- },
523
- null,
524
- 2,
525
- ),
526
- );
527
- return;
528
- }
529
- writeIndex(buildEmptyIndex());
530
- console.log(
531
- JSON.stringify(
532
- {
533
- created: true,
534
- indexPath: normalizeSlash(INDEX_PATH),
535
- },
536
- null,
537
- 2,
538
- ),
539
- );
540
- return;
541
- }
542
-
543
- if (cmd === "flow") {
544
- handleFlowCommand(args, {
545
- resolveFlowIdFromArgs,
546
- parseCompletenessFromArgs,
547
- normalizeIndexShape,
548
- readIndex,
549
- writeIndex,
550
- normalizeFigmaUrl,
551
- getItem,
552
- upsertByUrl,
553
- ensureEntryFilesAndHook,
554
- });
555
- return;
556
- }
557
-
558
- console.error(`Unknown command: ${cmd}`);
559
- process.exit(1);
560
- }
561
-
562
- run();
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { createRequire } = require("module");
6
+ const { URL } = require("url");
7
+ const { handleFlowCommand } = require("./js/flow-cli");
8
+ const { validateMcpRawEvidence, validateIndex } = require("./js/validate-cli");
9
+ const { buildBudgetReport } = require("./js/budget-cli");
10
+ const { createIndexStore } = require("./js/index-store");
11
+ const { copyCursorBootstrap } = require("./js/cursor-bootstrap-cli");
12
+ const { createEntryFilesService } = require("./js/entry-files");
13
+ const { backfillFromIterations } = require("./js/backfill-cli");
14
+ const { createUpsertService } = require("./js/upsert-core");
15
+ const { createProjectConfigService } = require("./js/project-config");
16
+ const { buildContractCheckReport } = require("./js/contract-check-cli");
17
+
18
+ const ROOT = process.cwd();
19
+ const NORMALIZATION_VERSION = 1;
20
+ const SCHEMA_VERSION = 2;
21
+ const DEFAULT_COMPLETENESS = Object.freeze([
22
+ "layout",
23
+ "text",
24
+ "tokens",
25
+ "interactions",
26
+ "states",
27
+ "accessibility",
28
+ ]);
29
+ const COMPLETENESS_ALL_DIMENSIONS = Object.freeze([
30
+ "layout",
31
+ "text",
32
+ "tokens",
33
+ "interactions",
34
+ "states",
35
+ "accessibility",
36
+ "flow",
37
+ "assets",
38
+ ]);
39
+ const COMPLETENESS_TOOL_REQUIREMENTS = Object.freeze({
40
+ layout: Object.freeze([
41
+ Object.freeze(["get_metadata", "get_design_context"]),
42
+ ]),
43
+ text: Object.freeze([Object.freeze(["get_design_context"])]),
44
+ tokens: Object.freeze([Object.freeze(["get_variable_defs"])]),
45
+ interactions: Object.freeze([Object.freeze(["get_design_context"])]),
46
+ states: Object.freeze([Object.freeze(["get_design_context"])]),
47
+ accessibility: Object.freeze([Object.freeze(["get_design_context"])]),
48
+ flow: Object.freeze([Object.freeze(["get_design_context"])]),
49
+ assets: Object.freeze([Object.freeze(["get_design_context"])]),
50
+ });
51
+
52
+ function parsePositiveInt(input, fallback) {
53
+ const n = Number(input);
54
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
55
+ }
56
+
57
+ function normalizeSlash(input) {
58
+ return input.replace(/\\/g, "/");
59
+ }
60
+
61
+ function resolveMaybeAbsolutePath(input) {
62
+ if (path.isAbsolute(input)) {
63
+ return path.normalize(input);
64
+ }
65
+ return path.join(ROOT, input);
66
+ }
67
+
68
+ function toProjectRelativeOrAbsolute(absPath) {
69
+ const relative = path.relative(ROOT, absPath);
70
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
71
+ return normalizeSlash(relative);
72
+ }
73
+ return normalizeSlash(absPath);
74
+ }
75
+
76
+ const CACHE_DIR_INPUT = process.env.FIGMA_CACHE_DIR || "figma-cache";
77
+ const ITERATIONS_DIR_INPUT =
78
+ process.env.FIGMA_ITERATIONS_DIR || "library/figma-iterations";
79
+ const INDEX_FILE_NAME = process.env.FIGMA_CACHE_INDEX_FILE || "index.json";
80
+ const DEFAULT_FLOW_ID = process.env.FIGMA_DEFAULT_FLOW || "";
81
+ const DEFAULT_STALE_DAYS = parsePositiveInt(
82
+ process.env.FIGMA_CACHE_STALE_DAYS,
83
+ 14,
84
+ );
85
+
86
+ const CACHE_DIR = resolveMaybeAbsolutePath(CACHE_DIR_INPUT);
87
+ /** `figma-cache/figma-cache.js` 同级的 `cursor-bootstrap/`(随 npm 包分发) */
88
+ const CURSOR_BOOTSTRAP_DIR = path.join(__dirname, "..", "cursor-bootstrap");
89
+ const ITERATIONS_DIR = resolveMaybeAbsolutePath(ITERATIONS_DIR_INPUT);
90
+
91
+ /** 当前安装包在 package.json 里的 name(用于写入 AGENT-SETUP-PROMPT.md) */
92
+ function readSelfNpmPackageName() {
93
+ try {
94
+ const pkgPath = path.join(__dirname, "..", "package.json");
95
+ const raw = fs.readFileSync(pkgPath, "utf8");
96
+ const j = JSON.parse(raw);
97
+ return j && j.name ? String(j.name) : "figma-cache-toolchain";
98
+ } catch {
99
+ return "figma-cache-toolchain";
100
+ }
101
+ }
102
+ const INDEX_PATH = path.isAbsolute(INDEX_FILE_NAME)
103
+ ? INDEX_FILE_NAME
104
+ : path.join(CACHE_DIR, INDEX_FILE_NAME);
105
+ const CACHE_BASE_FOR_STORAGE = toProjectRelativeOrAbsolute(CACHE_DIR);
106
+
107
+ const indexStore = createIndexStore({
108
+ fs,
109
+ CACHE_DIR,
110
+ INDEX_PATH,
111
+ SCHEMA_VERSION,
112
+ NORMALIZATION_VERSION,
113
+ });
114
+ const {
115
+ ensureCacheDir,
116
+ buildEmptyIndex,
117
+ normalizeIndexShape,
118
+ readIndex,
119
+ writeIndex,
120
+ getItem,
121
+ } = indexStore;
122
+
123
+ const projectConfigService = createProjectConfigService({
124
+ fs,
125
+ path,
126
+ ROOT,
127
+ createRequire,
128
+ resolveMaybeAbsolutePath,
129
+ normalizeSlash,
130
+ });
131
+ const { loadProjectConfig, runPostEnsureHook, getProjectConfigPath } =
132
+ projectConfigService;
133
+
134
+ const upsertService = createUpsertService({
135
+ URL,
136
+ NORMALIZATION_VERSION,
137
+ CACHE_BASE_FOR_STORAGE,
138
+ DEFAULT_COMPLETENESS,
139
+ normalizeCompletenessList,
140
+ normalizeIndexShape,
141
+ readIndex,
142
+ getItem,
143
+ writeIndex,
144
+ });
145
+ const { normalizeFigmaUrl, previewUpsertByUrl, upsertByUrl } = upsertService;
146
+
147
+ function resolveFlowIdFromArgs(rest) {
148
+ const flowArg = rest.find((x) => x.startsWith("--flow="));
149
+ if (flowArg) {
150
+ return flowArg.split("=")[1];
151
+ }
152
+ if (DEFAULT_FLOW_ID) {
153
+ return DEFAULT_FLOW_ID;
154
+ }
155
+ return "";
156
+ }
157
+
158
+ function normalizeCompletenessList(input) {
159
+ if (!Array.isArray(input)) {
160
+ return [];
161
+ }
162
+ const seen = new Set();
163
+ const output = [];
164
+ input.forEach((entry) => {
165
+ const value = String(entry || "").trim();
166
+ if (!value || seen.has(value)) {
167
+ return;
168
+ }
169
+ seen.add(value);
170
+ output.push(value);
171
+ });
172
+ return output;
173
+ }
174
+
175
+ const entryFilesService = createEntryFilesService({
176
+ fs,
177
+ path,
178
+ resolveMaybeAbsolutePath,
179
+ normalizeCompletenessList,
180
+ completenessAllDimensions: COMPLETENESS_ALL_DIMENSIONS,
181
+ runPostEnsureHook,
182
+ });
183
+ const { ensureEntryFilesAndHook } = entryFilesService;
184
+
185
+ function parseCompletenessFromArgs(args) {
186
+ const completenessArg = args.find((x) => x.startsWith("--completeness="));
187
+ if (!completenessArg) {
188
+ return {
189
+ completeness: [...DEFAULT_COMPLETENESS],
190
+ fromCliArg: false,
191
+ };
192
+ }
193
+ return {
194
+ completeness: normalizeCompletenessList(
195
+ completenessArg.split("=").slice(1).join("=").split(","),
196
+ ),
197
+ fromCliArg: true,
198
+ };
199
+ }
200
+
201
+ /** @returns {string} */
202
+ function inferCliExample() {
203
+ const n = normalizeSlash(String(process.argv[1] || ""));
204
+ if (/\/bin\/figma-cache\.js$/i.test(n)) {
205
+ return "node bin/figma-cache.js";
206
+ }
207
+ if (/\/figma-cache\/figma-cache\.js$/i.test(n)) {
208
+ return "node figma-cache/figma-cache.js";
209
+ }
210
+ return "figma-cache";
211
+ }
212
+
213
+ function printStale(days) {
214
+ const index = readIndex();
215
+ const now = Date.now();
216
+ const threshold = days * 24 * 60 * 60 * 1000;
217
+ const keys = Object.keys(index.items || {});
218
+ const stale = keys.filter((cacheKey) => {
219
+ const item = index.items[cacheKey];
220
+ const ts = item.syncedAt ? Date.parse(item.syncedAt) : NaN;
221
+ if (Number.isNaN(ts)) {
222
+ return true;
223
+ }
224
+ return now - ts > threshold;
225
+ });
226
+ if (!stale.length) {
227
+ console.log(`No stale entries (>${days}d).`);
228
+ return;
229
+ }
230
+ console.log(`Stale entries (>${days}d):`);
231
+ stale.forEach((key) => {
232
+ console.log(`- ${key}`);
233
+ });
234
+ }
235
+
236
+ function safeReadJson(absPath) {
237
+ try {
238
+ return JSON.parse(fs.readFileSync(absPath, "utf8"));
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ function safeReadText(absPath) {
245
+ try {
246
+ return fs.readFileSync(absPath, "utf8");
247
+ } catch {
248
+ return "";
249
+ }
250
+ }
251
+
252
+ function safeFileSize(absPath) {
253
+ try {
254
+ return fs.statSync(absPath).size;
255
+ } catch {
256
+ return 0;
257
+ }
258
+ }
259
+
260
+ function runUpsertLikeCommand(commandName, args, shouldEnsureFiles) {
261
+ const url = args[0];
262
+ const sourceArg = args.find((x) => x.startsWith("--source="));
263
+ const source = sourceArg ? sourceArg.split("=")[1] : "manual";
264
+ const allowSkeletonWithFigmaMcp = args.includes(
265
+ "--allow-skeleton-with-figma-mcp",
266
+ );
267
+ const { completeness } = parseCompletenessFromArgs(args);
268
+
269
+ const preview = previewUpsertByUrl(url, { source, completeness });
270
+ if (source === "figma-mcp") {
271
+ const mcpErrors = validateMcpRawEvidence(
272
+ preview.normalized.cacheKey,
273
+ preview.item,
274
+ completeness,
275
+ { allowSkeletonWithFigmaMcp },
276
+ {
277
+ fs,
278
+ path,
279
+ resolveMaybeAbsolutePath,
280
+ safeReadJson,
281
+ normalizeSlash,
282
+ normalizeCompletenessList,
283
+ completenessToolRequirements: COMPLETENESS_TOOL_REQUIREMENTS,
284
+ },
285
+ );
286
+ if (mcpErrors.length) {
287
+ console.error(
288
+ `${commandName} failed: source=figma-mcp but MCP raw evidence is incomplete`,
289
+ );
290
+ mcpErrors.forEach((err) => console.error(`- ${err}`));
291
+ process.exit(2);
292
+ }
293
+ }
294
+
295
+ const result = upsertByUrl(url, { source, completeness });
296
+ if (shouldEnsureFiles) {
297
+ ensureEntryFilesAndHook(result.normalized.cacheKey, result.item);
298
+ console.log(
299
+ JSON.stringify(
300
+ {
301
+ cacheKey: result.normalized.cacheKey,
302
+ ensured: true,
303
+ paths: result.item.paths,
304
+ },
305
+ null,
306
+ 2,
307
+ ),
308
+ );
309
+ return;
310
+ }
311
+
312
+ console.log(
313
+ JSON.stringify(
314
+ {
315
+ cacheKey: result.normalized.cacheKey,
316
+ scope: result.item.scope,
317
+ syncedAt: result.item.syncedAt,
318
+ },
319
+ null,
320
+ 2,
321
+ ),
322
+ );
323
+ }
324
+
325
+ function parseContractCheckArgs(args) {
326
+ return {
327
+ cacheKey: (() => {
328
+ const item = args.find((x) => x.startsWith("--cacheKey="));
329
+ return item ? item.split("=").slice(1).join("=").trim() : "";
330
+ })(),
331
+ warnUnmappedTokens: args.includes("--warn-unmapped-tokens"),
332
+ warnUnmappedStates: args.includes("--warn-unmapped-states"),
333
+ };
334
+ }
335
+
336
+ function runContractCheck(args) {
337
+ const options = parseContractCheckArgs(args);
338
+ const contractPath = resolveMaybeAbsolutePath(
339
+ process.env.FIGMA_CACHE_ADAPTER_CONTRACT ||
340
+ "figma-cache/adapters/ui-adapter.contract.json",
341
+ );
342
+
343
+ const report = buildContractCheckReport(
344
+ {
345
+ ...options,
346
+ contractPath,
347
+ },
348
+ {
349
+ index: readIndex(),
350
+ contract: safeReadJson(contractPath),
351
+ readJsonOrNull: safeReadJson,
352
+ readTextOrEmpty: safeReadText,
353
+ resolveMaybeAbsolutePath,
354
+ normalizeSlash,
355
+ },
356
+ );
357
+
358
+ if (!report.ok) {
359
+ console.error("contract-check failed:");
360
+ report.hardErrors.forEach((error) => console.error(`- ${error}`));
361
+ if (report.warnings.length) {
362
+ console.error("\nWarnings:");
363
+ report.warnings.forEach((warning) => console.error(`- ${warning}`));
364
+ }
365
+ process.exit(2);
366
+ }
367
+
368
+ console.log(
369
+ JSON.stringify(
370
+ {
371
+ ok: true,
372
+ contract: normalizeSlash(contractPath),
373
+ checkedItems: report.checkedItems,
374
+ checkedCacheKeys: report.checkedCacheKeys,
375
+ warnings: report.warnings,
376
+ },
377
+ null,
378
+ 2,
379
+ ),
380
+ );
381
+ }
382
+
383
+ function run() {
384
+ const [, , cmd, ...args] = process.argv;
385
+ if (!cmd) {
386
+ const ex = inferCliExample();
387
+ const defaultCompletenessText = DEFAULT_COMPLETENESS.join(",");
388
+ console.log("Usage:");
389
+ console.log(
390
+ ` (invoke examples: ${ex} | node bin/figma-cache.js | node figma-cache/figma-cache.js)`,
391
+ );
392
+ console.log(` ${ex} normalize <figmaUrl>`);
393
+ console.log(` ${ex} get <figmaUrl>`);
394
+ console.log(
395
+ ` ${ex} upsert <figmaUrl> [--source=manual] [--completeness=a,b] [--allow-skeleton-with-figma-mcp] (default completeness=${defaultCompletenessText})`,
396
+ );
397
+ console.log(` ${ex} validate`);
398
+ console.log(` ${ex} stale [--days=14]`);
399
+ console.log(` ${ex} backfill`);
400
+ console.log(
401
+ ` ${ex} budget [--mcp-only] [--cacheKey=<fileKey#nodeId>] [--limit=50]`,
402
+ );
403
+ console.log(
404
+ ` ${ex} ensure <figmaUrl> [--source=manual] [--completeness=a,b] [--allow-skeleton-with-figma-mcp] (default completeness=${defaultCompletenessText})`,
405
+ );
406
+ console.log(` ${ex} init`);
407
+ console.log(` ${ex} config`);
408
+ console.log(
409
+ ` ${ex} contract-check [--cacheKey=<fileKey#nodeId>] [--warn-unmapped-tokens] [--warn-unmapped-states]`,
410
+ );
411
+ console.log(
412
+ " (optional) figma-cache.config.js | .figmacacherc.js | FIGMA_CACHE_PROJECT_CONFIG -> hooks.postEnsure after ensure",
413
+ );
414
+ console.log(` ${ex} flow init --id=<flowId> [--title=...]`);
415
+ console.log(
416
+ `${ex} flow add-node --flow=<flowId> <figmaUrl> [--ensure] [--source=manual] [--completeness=a,b]`,
417
+ );
418
+ console.log(
419
+ `${ex} flow link --flow=<flowId> <fromUrl> <toUrl> --type=next_step [--note=...]`,
420
+ );
421
+ console.log(
422
+ `${ex} flow chain --flow=<flowId> <url1> <url2> ... [--type=next_step|related]`,
423
+ );
424
+ console.log(` ${ex} flow show --flow=<flowId>`);
425
+ console.log(` ${ex} flow mermaid --flow=<flowId>`);
426
+ console.log(
427
+ `${ex} cursor init [--overwrite] [--force] # default safe mode; --overwrite forces replacement; --force keeps legacy behavior (no overwrite)`,
428
+ );
429
+ process.exit(1);
430
+ }
431
+
432
+ if (cmd === "cursor") {
433
+ const sub = args[0];
434
+ if (sub !== "init") {
435
+ console.error(
436
+ "Usage: figma-cache cursor init [--overwrite] [--force] # --overwrite replaces existing templates; --force keeps legacy no-overwrite behavior",
437
+ );
438
+ process.exit(1);
439
+ }
440
+ const hasOverwrite = args.includes("--overwrite");
441
+ const hasForce = args.includes("--force");
442
+ if (hasOverwrite && hasForce) {
443
+ console.error("Do not use --overwrite and --force together. Choose one mode.");
444
+ process.exit(1);
445
+ }
446
+ const overwrite = hasOverwrite;
447
+ copyCursorBootstrap({ overwrite, legacyForce: hasForce }, {
448
+ fs,
449
+ path,
450
+ ROOT,
451
+ CACHE_DIR,
452
+ CURSOR_BOOTSTRAP_DIR,
453
+ normalizeSlash,
454
+ readSelfNpmPackageName,
455
+ packageDir: __dirname,
456
+ });
457
+ return;
458
+ }
459
+
460
+ if (cmd === "normalize") {
461
+ const url = args[0];
462
+ const normalized = normalizeFigmaUrl(url);
463
+ console.log(JSON.stringify(normalized, null, 2));
464
+ return;
465
+ }
466
+
467
+ if (cmd === "get") {
468
+ const url = args[0];
469
+ const normalized = normalizeFigmaUrl(url);
470
+ const index = readIndex();
471
+ const item = getItem(index, normalized.cacheKey);
472
+ console.log(
473
+ JSON.stringify(
474
+ {
475
+ found: !!item,
476
+ cacheKey: normalized.cacheKey,
477
+ item: item || null,
478
+ },
479
+ null,
480
+ 2,
481
+ ),
482
+ );
483
+ return;
484
+ }
485
+
486
+ if (cmd === "upsert") {
487
+ runUpsertLikeCommand("upsert", args, false);
488
+ return;
489
+ }
490
+
491
+ if (cmd === "ensure") {
492
+ runUpsertLikeCommand("ensure", args, true);
493
+ return;
494
+ }
495
+
496
+ if (cmd === "validate") {
497
+ const index = readIndex();
498
+ const errors = validateIndex(index, {
499
+ fs,
500
+ path,
501
+ normalizeIndexShape,
502
+ normalizeCompletenessList,
503
+ resolveMaybeAbsolutePath,
504
+ safeReadJson,
505
+ normalizeSlash,
506
+ completenessToolRequirements: COMPLETENESS_TOOL_REQUIREMENTS,
507
+ });
508
+ if (!errors.length) {
509
+ console.log("Validation passed.");
510
+ return;
511
+ }
512
+ console.error("Validation failed:");
513
+ errors.forEach((err) => console.error(`- ${err}`));
514
+ process.exit(2);
515
+ }
516
+
517
+ if (cmd === "contract-check") {
518
+ runContractCheck(args);
519
+ return;
520
+ }
521
+
522
+ if (cmd === "stale") {
523
+ const daysArg = args.find((x) => x.startsWith("--days="));
524
+ const days = daysArg ? Number(daysArg.split("=")[1]) : DEFAULT_STALE_DAYS;
525
+ printStale(days);
526
+ return;
527
+ }
528
+
529
+ if (cmd === "backfill") {
530
+ backfillFromIterations(
531
+ { iterationsDir: ITERATIONS_DIR },
532
+ {
533
+ fs,
534
+ path,
535
+ upsertByUrl,
536
+ },
537
+ );
538
+ return;
539
+ }
540
+
541
+ if (cmd === "budget") {
542
+ const mcpOnly = args.includes("--mcp-only");
543
+ const cacheKeyArg = args.find((x) => x.startsWith("--cacheKey="));
544
+ const limitArg = args.find((x) => x.startsWith("--limit="));
545
+ const cacheKey = cacheKeyArg
546
+ ? cacheKeyArg.split("=").slice(1).join("=")
547
+ : "";
548
+ const limit = limitArg ? limitArg.split("=")[1] : "";
549
+ const report = buildBudgetReport(
550
+ { mcpOnly, cacheKey, limit },
551
+ {
552
+ path,
553
+ normalizeIndexShape,
554
+ readIndex,
555
+ resolveMaybeAbsolutePath,
556
+ safeReadJson,
557
+ safeFileSize,
558
+ },
559
+ );
560
+ console.log(JSON.stringify(report, null, 2));
561
+ return;
562
+ }
563
+
564
+ if (cmd === "config") {
565
+ const cfg = loadProjectConfig();
566
+ const hooks = cfg && cfg.hooks;
567
+ console.log(
568
+ JSON.stringify(
569
+ {
570
+ root: normalizeSlash(ROOT),
571
+ cacheDir: normalizeSlash(CACHE_DIR),
572
+ indexPath: normalizeSlash(INDEX_PATH),
573
+ iterationsDir: normalizeSlash(ITERATIONS_DIR),
574
+ staleDays: DEFAULT_STALE_DAYS,
575
+ defaultFlowId: DEFAULT_FLOW_ID || null,
576
+ defaultCompleteness: [...DEFAULT_COMPLETENESS],
577
+ normalizationVersion: NORMALIZATION_VERSION,
578
+ projectConfigPath: getProjectConfigPath(),
579
+ hooks: {
580
+ postEnsure: !!(hooks && typeof hooks.postEnsure === "function"),
581
+ },
582
+ },
583
+ null,
584
+ 2,
585
+ ),
586
+ );
587
+ return;
588
+ }
589
+
590
+ if (cmd === "init") {
591
+ ensureCacheDir();
592
+ if (fs.existsSync(INDEX_PATH)) {
593
+ console.log(
594
+ JSON.stringify(
595
+ {
596
+ created: false,
597
+ reason: "index_exists",
598
+ indexPath: normalizeSlash(INDEX_PATH),
599
+ },
600
+ null,
601
+ 2,
602
+ ),
603
+ );
604
+ return;
605
+ }
606
+ writeIndex(buildEmptyIndex());
607
+ console.log(
608
+ JSON.stringify(
609
+ {
610
+ created: true,
611
+ indexPath: normalizeSlash(INDEX_PATH),
612
+ },
613
+ null,
614
+ 2,
615
+ ),
616
+ );
617
+ return;
618
+ }
619
+
620
+ if (cmd === "flow") {
621
+ handleFlowCommand(args, {
622
+ resolveFlowIdFromArgs,
623
+ parseCompletenessFromArgs,
624
+ normalizeIndexShape,
625
+ readIndex,
626
+ writeIndex,
627
+ normalizeFigmaUrl,
628
+ getItem,
629
+ upsertByUrl,
630
+ ensureEntryFilesAndHook,
631
+ });
632
+ return;
633
+ }
634
+
635
+ console.error(`Unknown command: ${cmd}`);
636
+ process.exit(1);
637
+ }
638
+
639
+ run();