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,289 @@
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 { getUiProfileConfig } = require("./ui-profile");
8
+
9
+ const ROOT = process.cwd();
10
+ const CACHE_DIR_INPUT = process.env.FIGMA_CACHE_DIR || "figma-cache";
11
+ const INDEX_FILE_NAME = process.env.FIGMA_CACHE_INDEX_FILE || "index.json";
12
+ const DEFAULT_CONTRACT_PATH = "figma-cache/adapters/ui-adapter.contract.json";
13
+ const DEFAULT_REPORT_PATH = "figma-cache/reports/ui-preflight-report.json";
14
+ const BLOCKING_EXIT_CODE = 2;
15
+
16
+ function normalizeSlash(input) {
17
+ return String(input || "").replace(/\\/g, "/");
18
+ }
19
+
20
+ function resolveMaybeAbsolutePath(input) {
21
+ if (!input) {
22
+ return "";
23
+ }
24
+ return path.isAbsolute(input) ? path.normalize(input) : path.join(ROOT, input);
25
+ }
26
+
27
+ function readJsonOrNull(absPath) {
28
+ try {
29
+ return JSON.parse(fs.readFileSync(absPath, "utf8"));
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function readTextOrEmpty(absPath) {
36
+ try {
37
+ return fs.readFileSync(absPath, "utf8");
38
+ } catch {
39
+ return "";
40
+ }
41
+ }
42
+
43
+ function fileExists(absPath) {
44
+ try {
45
+ return fs.existsSync(absPath);
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function ensureParentDir(absPath) {
52
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
53
+ }
54
+
55
+ function parseArgs(argv) {
56
+ const options = {
57
+ cacheKey: "",
58
+ contractPath: DEFAULT_CONTRACT_PATH,
59
+ reportPath: DEFAULT_REPORT_PATH,
60
+ allowWarn: false,
61
+ hasUnknownArgs: false,
62
+ unknownArgs: [],
63
+ };
64
+
65
+ argv.forEach((arg) => {
66
+ if (arg.startsWith("--cacheKey=")) {
67
+ options.cacheKey = arg.split("=").slice(1).join("=").trim();
68
+ return;
69
+ }
70
+ if (arg.startsWith("--contract=")) {
71
+ options.contractPath = arg.split("=").slice(1).join("=").trim() || DEFAULT_CONTRACT_PATH;
72
+ return;
73
+ }
74
+ if (arg.startsWith("--report=")) {
75
+ options.reportPath = arg.split("=").slice(1).join("=").trim() || DEFAULT_REPORT_PATH;
76
+ return;
77
+ }
78
+ if (arg === "--allow-warn") {
79
+ options.allowWarn = true;
80
+ return;
81
+ }
82
+ options.hasUnknownArgs = true;
83
+ options.unknownArgs.push(arg);
84
+ });
85
+
86
+ return options;
87
+ }
88
+
89
+ function hasTodoPlaceholder(text) {
90
+ return /(TODO|待补充|待完善|待确认|占位)/i.test(String(text || ""));
91
+ }
92
+
93
+ function hasMappingEntries(contract, key) {
94
+ if (!contract || typeof contract !== "object") {
95
+ return false;
96
+ }
97
+ const value = contract[key];
98
+ if (Array.isArray(value)) {
99
+ return value.length > 0;
100
+ }
101
+ if (value && typeof value === "object") {
102
+ return Object.keys(value).length > 0;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ function checkCoverageEvidence(item, rawJson) {
108
+ const completeness = Array.isArray(item.completeness) ? item.completeness : [];
109
+ const evidence =
110
+ rawJson &&
111
+ rawJson.coverageSummary &&
112
+ rawJson.coverageSummary.evidence &&
113
+ typeof rawJson.coverageSummary.evidence === "object"
114
+ ? rawJson.coverageSummary.evidence
115
+ : null;
116
+ if (!evidence) {
117
+ return false;
118
+ }
119
+ return completeness.every((dimension) => {
120
+ const list = evidence[dimension];
121
+ return Array.isArray(list) && list.length > 0;
122
+ });
123
+ }
124
+
125
+ function validateMcpManifest(nodeDir) {
126
+ const manifestPath = path.join(nodeDir, "mcp-raw", "mcp-raw-manifest.json");
127
+ const manifest = readJsonOrNull(manifestPath);
128
+ if (!manifest || typeof manifest !== "object") {
129
+ return { ok: false, reason: "mcp-raw manifest missing or invalid" };
130
+ }
131
+ if (!manifest.files || typeof manifest.files !== "object") {
132
+ return { ok: false, reason: "mcp-raw manifest.files missing" };
133
+ }
134
+ return { ok: true };
135
+ }
136
+
137
+ function buildItemReport(cacheKey, item, contractReady) {
138
+ const blocking = [];
139
+ const warnings = [];
140
+ const checks = {
141
+ cacheItemExists: !!item,
142
+ entryFilesExist: false,
143
+ coverageEvidenceReady: false,
144
+ contractExists: contractReady.exists,
145
+ tokenMappingReady: contractReady.tokenMappingsReady,
146
+ stateMappingReady: contractReady.stateMappingsReady,
147
+ mcpRawReady: true,
148
+ };
149
+
150
+ if (!item) {
151
+ blocking.push("cache item not found");
152
+ return {
153
+ cacheKey,
154
+ source: "unknown",
155
+ blocking,
156
+ warnings,
157
+ checks,
158
+ };
159
+ }
160
+
161
+ const paths = item.paths && typeof item.paths === "object" ? item.paths : {};
162
+ const absMeta = paths.meta ? resolveMaybeAbsolutePath(paths.meta) : "";
163
+ const absSpec = paths.spec ? resolveMaybeAbsolutePath(paths.spec) : "";
164
+ const absStateMap = paths.stateMap ? resolveMaybeAbsolutePath(paths.stateMap) : "";
165
+ const absRaw = paths.raw ? resolveMaybeAbsolutePath(paths.raw) : "";
166
+
167
+ const requiredFiles = [absMeta, absSpec, absStateMap, absRaw];
168
+ checks.entryFilesExist = requiredFiles.every((absPath) => !!absPath && fileExists(absPath));
169
+ if (!checks.entryFilesExist) {
170
+ blocking.push("entry file path missing or file not found (meta/spec/state-map/raw)");
171
+ }
172
+
173
+ const rawJson = absRaw ? readJsonOrNull(absRaw) : null;
174
+ checks.coverageEvidenceReady = checkCoverageEvidence(item, rawJson);
175
+ if (!checks.coverageEvidenceReady) {
176
+ blocking.push("raw.coverageSummary.evidence missing or incomplete");
177
+ }
178
+
179
+ if (!checks.contractExists) {
180
+ blocking.push("adapter contract missing or invalid JSON");
181
+ }
182
+ if (!checks.tokenMappingReady) {
183
+ blocking.push("contract tokenMappings is empty");
184
+ }
185
+ if (!checks.stateMappingReady) {
186
+ blocking.push("contract stateMappings is empty");
187
+ }
188
+
189
+ const specText = absSpec ? readTextOrEmpty(absSpec) : "";
190
+ const stateMapText = absStateMap ? readTextOrEmpty(absStateMap) : "";
191
+ if (hasTodoPlaceholder(specText)) {
192
+ warnings.push("spec.md contains TODO placeholder");
193
+ }
194
+ if (hasTodoPlaceholder(stateMapText)) {
195
+ warnings.push("state-map.md contains TODO placeholder");
196
+ }
197
+
198
+ if (item.source === "figma-mcp") {
199
+ const nodeDir = absMeta ? path.dirname(absMeta) : "";
200
+ const manifestStatus = validateMcpManifest(nodeDir);
201
+ checks.mcpRawReady = manifestStatus.ok;
202
+ if (!manifestStatus.ok) {
203
+ blocking.push(manifestStatus.reason);
204
+ }
205
+ }
206
+
207
+ return {
208
+ cacheKey,
209
+ source: item.source || "manual",
210
+ blocking,
211
+ warnings,
212
+ checks,
213
+ };
214
+ }
215
+
216
+ function run() {
217
+ const options = parseArgs(process.argv.slice(2));
218
+ const profileConfig = getUiProfileConfig();
219
+ if (options.hasUnknownArgs) {
220
+ console.error(`Unknown args: ${options.unknownArgs.join(", ")}`);
221
+ process.exit(BLOCKING_EXIT_CODE);
222
+ }
223
+
224
+ const cacheDir = resolveMaybeAbsolutePath(CACHE_DIR_INPUT);
225
+ const indexPath = path.isAbsolute(INDEX_FILE_NAME)
226
+ ? INDEX_FILE_NAME
227
+ : path.join(cacheDir, INDEX_FILE_NAME);
228
+ const contractPath = resolveMaybeAbsolutePath(options.contractPath);
229
+ const reportPath = resolveMaybeAbsolutePath(options.reportPath);
230
+
231
+ const index = readJsonOrNull(indexPath);
232
+ const contract = readJsonOrNull(contractPath);
233
+ const contractReady = {
234
+ exists: !!contract && typeof contract === "object",
235
+ tokenMappingsReady: hasMappingEntries(contract, "tokenMappings"),
236
+ stateMappingsReady: hasMappingEntries(contract, "stateMappings"),
237
+ };
238
+
239
+ const items = index && index.items && typeof index.items === "object" ? index.items : {};
240
+ const targetCacheKeys = options.cacheKey ? [options.cacheKey] : Object.keys(items);
241
+
242
+ const reportItems = targetCacheKeys.map((cacheKey) =>
243
+ buildItemReport(cacheKey, items[cacheKey], contractReady)
244
+ );
245
+
246
+ const blockingCount = reportItems.reduce((acc, item) => acc + item.blocking.length, 0);
247
+ const warningCount = reportItems.reduce((acc, item) => acc + item.warnings.length, 0);
248
+ const warningBlockingCount = profileConfig.preflightTreatWarningsAsBlocking ? warningCount : 0;
249
+ const hasBlocking = blockingCount + warningBlockingCount > 0;
250
+
251
+ const report = {
252
+ ok: !hasBlocking,
253
+ generatedAt: new Date().toISOString(),
254
+ summary: {
255
+ checkedItems: reportItems.length,
256
+ blockingCount,
257
+ warningCount,
258
+ warningBlockingCount,
259
+ allowWarn: options.allowWarn,
260
+ profile: profileConfig.profile,
261
+ },
262
+ options: {
263
+ cacheKey: options.cacheKey || null,
264
+ contractPath: normalizeSlash(contractPath),
265
+ reportPath: normalizeSlash(reportPath),
266
+ },
267
+ items: reportItems,
268
+ };
269
+
270
+ ensureParentDir(reportPath);
271
+ fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
272
+
273
+ if (report.ok || (options.allowWarn && blockingCount === 0 && warningBlockingCount === 0)) {
274
+ console.log(JSON.stringify(report, null, 2));
275
+ return;
276
+ }
277
+
278
+ console.error("ui-preflight failed:");
279
+ reportItems.forEach((item) => {
280
+ if (!item.blocking.length) {
281
+ return;
282
+ }
283
+ console.error(`- ${item.cacheKey}`);
284
+ item.blocking.forEach((err) => console.error(` * ${err}`));
285
+ });
286
+ process.exit(BLOCKING_EXIT_CODE);
287
+ }
288
+
289
+ run();
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+
3
+ const DEFAULT_UI_PROFILE = "standard";
4
+
5
+ const PROFILE_CONFIG = Object.freeze({
6
+ fast: Object.freeze({
7
+ preflightTreatWarningsAsBlocking: false,
8
+ auditDefaultMinScore: 70,
9
+ auditRequireTargetPath: false,
10
+ }),
11
+ standard: Object.freeze({
12
+ preflightTreatWarningsAsBlocking: false,
13
+ auditDefaultMinScore: 85,
14
+ auditRequireTargetPath: false,
15
+ }),
16
+ strict: Object.freeze({
17
+ preflightTreatWarningsAsBlocking: true,
18
+ auditDefaultMinScore: 92,
19
+ auditRequireTargetPath: true,
20
+ }),
21
+ });
22
+
23
+ function resolveUiProfile(input) {
24
+ const raw = String(input || process.env.FIGMA_UI_PROFILE || DEFAULT_UI_PROFILE)
25
+ .trim()
26
+ .toLowerCase();
27
+ if (raw && PROFILE_CONFIG[raw]) {
28
+ return raw;
29
+ }
30
+ return DEFAULT_UI_PROFILE;
31
+ }
32
+
33
+ function getUiProfileConfig(input) {
34
+ const profile = resolveUiProfile(input);
35
+ return {
36
+ profile,
37
+ ...PROFILE_CONFIG[profile],
38
+ };
39
+ }
40
+
41
+ module.exports = {
42
+ DEFAULT_UI_PROFILE,
43
+ PROFILE_CONFIG,
44
+ resolveUiProfile,
45
+ getUiProfileConfig,
46
+ };
@@ -0,0 +1,124 @@
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 { getUiProfileConfig } = require("./ui-profile");
8
+
9
+ const ROOT = process.cwd();
10
+ const CACHE_DIR_INPUT = process.env.FIGMA_CACHE_DIR || "figma-cache";
11
+ const DEFAULT_OUTPUT_PATH = "figma-cache/reports/ui-quality-summary.json";
12
+
13
+ function resolveMaybeAbsolutePath(input) {
14
+ if (!input) {
15
+ return "";
16
+ }
17
+ return path.isAbsolute(input) ? path.normalize(input) : path.join(ROOT, input);
18
+ }
19
+
20
+ function readJsonOrNull(absPath) {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(absPath, "utf8"));
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function parseArgs(argv) {
29
+ const options = {
30
+ preflightReport: "",
31
+ auditReport: "",
32
+ output: DEFAULT_OUTPUT_PATH,
33
+ };
34
+ argv.forEach((arg) => {
35
+ if (arg.startsWith("--preflight-report=")) {
36
+ options.preflightReport = arg.split("=").slice(1).join("=").trim();
37
+ return;
38
+ }
39
+ if (arg.startsWith("--audit-report=")) {
40
+ options.auditReport = arg.split("=").slice(1).join("=").trim();
41
+ return;
42
+ }
43
+ if (arg.startsWith("--output=")) {
44
+ options.output = arg.split("=").slice(1).join("=").trim() || DEFAULT_OUTPUT_PATH;
45
+ }
46
+ });
47
+ return options;
48
+ }
49
+
50
+ function ensureParentDir(absPath) {
51
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
52
+ }
53
+
54
+ function ratio(numerator, denominator) {
55
+ if (!denominator) {
56
+ return 0;
57
+ }
58
+ return Number((numerator / denominator).toFixed(4));
59
+ }
60
+
61
+ function run() {
62
+ const options = parseArgs(process.argv.slice(2));
63
+ const cacheDir = resolveMaybeAbsolutePath(CACHE_DIR_INPUT);
64
+ const preflightPath = resolveMaybeAbsolutePath(
65
+ options.preflightReport || path.join(cacheDir, "reports", "ui-preflight-report.json")
66
+ );
67
+ const auditPath = resolveMaybeAbsolutePath(
68
+ options.auditReport || path.join(cacheDir, "reports", "ui-1to1-report.json")
69
+ );
70
+ const outputPath = resolveMaybeAbsolutePath(options.output);
71
+ const profileConfig = getUiProfileConfig();
72
+
73
+ const preflight = readJsonOrNull(preflightPath) || {};
74
+ const audit = readJsonOrNull(auditPath) || {};
75
+ const preflightItems = Array.isArray(preflight.items) ? preflight.items : [];
76
+ const auditItems = Array.isArray(audit.items) ? audit.items : [];
77
+
78
+ const preflightBlockingItems = preflightItems.filter(
79
+ (item) => Array.isArray(item.blocking) && item.blocking.length > 0
80
+ ).length;
81
+ const auditPassItems = auditItems.filter(
82
+ (item) => item && item.score && Number(item.score.total || 0) >= Number(audit.summary && audit.summary.minScore || 0)
83
+ ).length;
84
+
85
+ const summary = {
86
+ generatedAt: new Date().toISOString(),
87
+ profile: profileConfig.profile,
88
+ inputs: {
89
+ preflightReport: preflightPath,
90
+ auditReport: auditPath,
91
+ },
92
+ metrics: {
93
+ checkedItems: preflightItems.length || auditItems.length,
94
+ preflightBlockingRate: ratio(preflightBlockingItems, preflightItems.length),
95
+ auditPassRate: ratio(auditPassItems, auditItems.length),
96
+ firstPassAcceptedRate: ratio(auditPassItems, auditItems.length),
97
+ averageAuditScore: Number(
98
+ (
99
+ auditItems.reduce((acc, item) => acc + Number(item && item.score && item.score.total || 0), 0) /
100
+ Math.max(1, auditItems.length)
101
+ ).toFixed(2)
102
+ ),
103
+ reworkRoundsEstimate: Number(
104
+ (1 - ratio(auditPassItems, Math.max(1, auditItems.length))).toFixed(2)
105
+ ),
106
+ },
107
+ trend: {
108
+ status:
109
+ preflightBlockingItems === 0 && auditPassItems === auditItems.length
110
+ ? "healthy"
111
+ : "needs-attention",
112
+ notes: [
113
+ "Use this file as weekly baseline input for team QA review.",
114
+ "Track blockingRate and averageAuditScore trend in CI artifacts.",
115
+ ],
116
+ },
117
+ };
118
+
119
+ ensureParentDir(outputPath);
120
+ fs.writeFileSync(outputPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
121
+ console.log(JSON.stringify(summary, null, 2));
122
+ }
123
+
124
+ run();