figma-cache-toolchain 2.0.4 → 2.0.7

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.
package/README.md CHANGED
@@ -125,7 +125,6 @@ npm run figma:ui:preflight
125
125
  npm run figma:ui:audit -- --min-score=85
126
126
  npm run figma:ui:report:aggregate
127
127
  npm run figma:ui:accept -- --target=src/components/YourComponent.tsx
128
- npm run figma:ui:e2e:cross -- --target-project=E:/Work/vue-demo --fileKey=<fileKey> --nodeId=9277-28772 --target=E:/Work/vue-demo/src/components/YourComponent.vue
129
128
  npm run figma:ui:gate
130
129
  npm run figma:ui:gate:pr
131
130
  npm run figma:ui:gate:main
@@ -144,11 +143,8 @@ UI preflight/gate 说明:
144
143
  - `figma:ui:gate` 会先跑 preflight + audit(默认阈值 `85`),再串联 `validate`、`cursor:shadow:check` 与 `npm test`
145
144
  - `figma:ui:report:aggregate` 会聚合 preflight + audit 报告,输出 `figma-cache/reports/ui-quality-summary.json`
146
145
  - `figma:ui:accept` 是一键自动验收:自动跑 preflight + audit + aggregate,并按效果阈值直接返回 pass/fail(退出码)
147
- - `figma:ui:e2e:cross` 是跨项目联调:自动 `npm pack` 当前包 -> 安装到目标项目 -> 执行自动验收 -> 回收报告路径与摘要
148
- - 支持 `--auto-ensure-on-miss`:cache miss 时自动尝试 `--source=figma-mcp ensure`
149
- - 支持 `--batch-file=<json>`:批量节点联调并汇总结果(单条失败即整体失败)
150
- - 支持 `--fix-loop=<N>`:失败后自动执行自修复重试(补 contract / 刷新缓存后重跑)
151
146
  - CI 建议矩阵:`figma:ui:gate:pr`(PR 最低门槛)与 `figma:ui:gate:main`(主干严格门槛)
147
+ - `figma:ui:e2e:cross` 现默认启用“真实组件链路保护”:`--target` 不存在或验收出现 `code-level comparison skipped` 会直接失败,避免“未绑定真实组件但通过”的假阳性;如需兼容历史流程可显式传 `--allow-skipped-code-level-comparison`
152
148
 
153
149
  UI profile 分层(P3):
154
150
 
@@ -70,7 +70,7 @@ npm run figma:cache:config
70
70
  - `npm run figma:ui:audit -- --min-score=85`
71
71
  - `npm run figma:ui:report:aggregate`
72
72
  - `npm run figma:ui:accept -- --target=<componentPath>`
73
- - `npm run figma:ui:e2e:cross -- --target-project=E:/Work/vue-demo --fileKey=<fileKey> --nodeId=9277-28772 --target=E:/Work/vue-demo/src/components/YourComponent.vue`
73
+ - `npm run figma:ui:e2e:cross -- --target-project=../vue-demo --fileKey=<fileKey> --nodeId=9277-28772 --target=./src/pages/main/components/AudioSettingsPanel.vue`(路径均相对于各自根目录;`../vue-demo` 表示与 toolchain 并列)
74
74
  - `npm run figma:ui:gate`
75
75
  - `npm run figma:ui:gate:pr`
76
76
  - `npm run figma:ui:gate:main`
@@ -81,6 +81,15 @@ npm run figma:cache:config
81
81
  > 当 `upsert/ensure` 传 `--source=figma-mcp` 且未显式允许骨架模式时,CLI 会先执行 MCP 原始证据门禁(缺失即失败,退出码 2)。
82
82
  > 正确流程是先由 Agent/Figma MCP 获取最小调用集并写入 `mcp-raw/`,再执行 `upsert/ensure` 与 `validate`。
83
83
 
84
+ ### Fresh 重生成回归(推荐)
85
+
86
+ - 目标项目推荐一条命令:`npm run figma:workflow:fresh:one-shot`(删 -> 等文件 -> 验收 + build)
87
+ - 备选拆分:
88
+ - `npm run figma:workflow:fresh:start`(删除 target,并要求“缺失目标失败”)
89
+ - `npm run figma:workflow:fresh:verify`(Agent 重生成后验收通过)
90
+ - `npm run figma:workflow:fresh:wait-verify`(仅等待 target 出现后自动验收)
91
+ - `cross-project-e2e` 默认开启真实组件链路保护:`target` 缺失或出现 `code-level comparison skipped` 会直接失败
92
+
84
93
  ### UI preflight(P0 门禁)
85
94
 
86
95
  - `npm run figma:ui:preflight`:读取 `index.json`、adapter contract 与节点关键文件,输出结构化报告到 `figma-cache/reports/ui-preflight-report.json`
@@ -140,24 +149,6 @@ npm run figma:cache:config
140
149
  - 必须提供并命中 `targetPath`
141
150
  - warning/diff 需在阈值内(可通过 `--max-warnings`、`--max-diffs` 调整)
142
151
 
143
- ### 跨项目联调(toolchain -> 业务项目)
144
-
145
- - 命令:`npm run figma:ui:e2e:cross`
146
- - 自动流程:
147
- 1) 在当前 toolchain 项目执行 `npm pack`
148
- 2) 在目标项目安装本地 tgz
149
- 3) 在目标项目执行 `ui-auto-acceptance`
150
- 4) 输出目标项目报告路径与汇总指标
151
- - 推荐参数:
152
- - `--target-project=E:/Work/vue-demo`
153
- - `--fileKey=<fileKey>` + `--nodeId=9277-28772`(会自动标准化为 `9277:28772`)
154
- - 或直接 `--cacheKey=<fileKey#9277:28772>`
155
- - 可选增强:
156
- - `--auto-ensure-on-miss`:cache miss 时自动触发 figma-mcp ensure
157
- - `--allow-skeleton-with-figma-mcp`:允许 skeleton 写入(仅建议应急)
158
- - `--batch-file=<json>`:批量执行,多节点一次联调
159
- - `--fix-loop=<N>`:失败自动重试 N 轮(重试前会补 contract 并刷新缓存)
160
-
161
152
  ### 严格 validate 规则(默认)
162
153
 
163
154
  - `validate` 会检查 `raw.json.coverageSummary.evidence` 与 `completeness` 是否一致。
@@ -0,0 +1,44 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://figma-cache-toolchain/raw-json-extensions",
4
+ "title": "figma-cache raw.json optional extensions",
5
+ "description": "Loose schema for machine-derived fields merged during MCP hydrate. Core raw.json fields are not fully specified here.",
6
+ "type": "object",
7
+ "properties": {
8
+ "iconMetrics": {
9
+ "type": "array",
10
+ "description": "Icon box + inset metrics extracted from get_design_context (heuristic)."
11
+ },
12
+ "layoutMetrics": {
13
+ "type": "array",
14
+ "description": "Layout numbers merged from mcp-raw/figma-geometry-metrics.json (e.g. spacer_between_nodes_y)."
15
+ },
16
+ "relatedCacheKeys": {
17
+ "type": "array",
18
+ "description": "Peer cacheKeys linked via index.json flows (edge.type starts with \"related\", e.g. related_auto). Hydrate refresh overwrites from current index.",
19
+ "items": { "type": "string" }
20
+ },
21
+ "evidenceSummary": {
22
+ "type": "object",
23
+ "description": "Non-gating observability counts after hydrate (design context size, refs, geometry sidecar presence).",
24
+ "properties": {
25
+ "version": { "type": "integer" },
26
+ "generatedAt": { "type": "string" },
27
+ "designContextBytes": { "type": "integer", "minimum": 0 },
28
+ "metadataBytes": { "type": "integer", "minimum": 0 },
29
+ "dataNodeIdRefs": { "type": "integer", "minimum": 0 },
30
+ "dataNodeIdContainsScope": { "type": ["boolean", "null"] },
31
+ "designContextImgConstDefinitions": { "type": "integer", "minimum": 0 },
32
+ "imgTagOccurrences": { "type": "integer", "minimum": 0 },
33
+ "figmaAssetUrlOccurrences": { "type": "integer", "minimum": 0 },
34
+ "approximateHexColorLiterals": { "type": "integer", "minimum": 0 },
35
+ "variableDefKeys": { "type": "integer", "minimum": 0 },
36
+ "geometryFilePresent": { "type": "boolean" },
37
+ "iconMetricsCount": { "type": "integer", "minimum": 0 },
38
+ "layoutMetricsCount": { "type": "integer", "minimum": 0 }
39
+ },
40
+ "additionalProperties": true
41
+ }
42
+ },
43
+ "additionalProperties": true
44
+ }
@@ -10,6 +10,7 @@ const { buildBudgetReport } = require("./js/budget-cli");
10
10
  const { createIndexStore } = require("./js/index-store");
11
11
  const { copyCursorBootstrap } = require("./js/cursor-bootstrap-cli");
12
12
  const { createEntryFilesService } = require("./js/entry-files");
13
+ const { getRelatedCacheKeysFromIndex } = require("./js/related-cache-keys");
13
14
  const { backfillFromIterations } = require("./js/backfill-cli");
14
15
  const { createUpsertService } = require("./js/upsert-core");
15
16
  const { createProjectConfigService } = require("./js/project-config");
@@ -179,6 +180,8 @@ const entryFilesService = createEntryFilesService({
179
180
  normalizeCompletenessList,
180
181
  completenessAllDimensions: COMPLETENESS_ALL_DIMENSIONS,
181
182
  runPostEnsureHook,
183
+ getRelatedCacheKeys: (cacheKey) =>
184
+ getRelatedCacheKeysFromIndex(cacheKey, normalizeIndexShape(readIndex())),
182
185
  });
183
186
  const { ensureEntryFilesAndHook } = entryFilesService;
184
187
 
@@ -403,6 +406,12 @@ function run() {
403
406
  console.log(
404
407
  ` ${ex} ensure <figmaUrl> [--source=manual] [--completeness=a,b] [--allow-skeleton-with-figma-mcp] (default completeness=${defaultCompletenessText})`,
405
408
  );
409
+ console.log(
410
+ ` ${ex} enrich <figmaUrl> [--allow-skeleton-with-figma-mcp] # re-run entry hydrate from index + mcp-raw (no index upsert)`,
411
+ );
412
+ console.log(
413
+ ` ${ex} enrich --all [--allow-skeleton-with-figma-mcp] # same for every figma-mcp item in index.json`,
414
+ );
406
415
  console.log(` ${ex} init`);
407
416
  console.log(` ${ex} config`);
408
417
  console.log(
@@ -493,6 +502,108 @@ function run() {
493
502
  return;
494
503
  }
495
504
 
505
+ if (cmd === "enrich") {
506
+ const allowSkeletonWithFigmaMcp = args.includes("--allow-skeleton-with-figma-mcp");
507
+ const enrichAll = args.includes("--all");
508
+ const validateDeps = {
509
+ fs,
510
+ path,
511
+ resolveMaybeAbsolutePath,
512
+ safeReadJson,
513
+ normalizeSlash,
514
+ normalizeCompletenessList,
515
+ completenessToolRequirements: COMPLETENESS_TOOL_REQUIREMENTS,
516
+ };
517
+ if (enrichAll) {
518
+ const index = normalizeIndexShape(readIndex());
519
+ const failures = [];
520
+ const successes = [];
521
+ Object.entries(index.items || {}).forEach(([cacheKey, item]) => {
522
+ if (!item || item.source !== "figma-mcp") {
523
+ return;
524
+ }
525
+ const completeness = normalizeCompletenessList(item.completeness);
526
+ const mcpErrors = validateMcpRawEvidence(
527
+ cacheKey,
528
+ item,
529
+ completeness,
530
+ { allowSkeletonWithFigmaMcp },
531
+ validateDeps,
532
+ );
533
+ if (mcpErrors.length) {
534
+ failures.push({ cacheKey, errors: mcpErrors });
535
+ return;
536
+ }
537
+ ensureEntryFilesAndHook(cacheKey, item);
538
+ successes.push(cacheKey);
539
+ });
540
+ console.log(
541
+ JSON.stringify(
542
+ {
543
+ ok: failures.length === 0,
544
+ enriched: successes.length,
545
+ cacheKeys: successes,
546
+ failures,
547
+ },
548
+ null,
549
+ 2,
550
+ ),
551
+ );
552
+ if (failures.length) {
553
+ process.exit(2);
554
+ }
555
+ return;
556
+ }
557
+ const positional = args.filter(
558
+ (x) =>
559
+ x !== "--all" &&
560
+ !x.startsWith("--allow-skeleton-with-figma-mcp") &&
561
+ !x.startsWith("--"),
562
+ );
563
+ const url = positional[0];
564
+ if (!url) {
565
+ console.error(
566
+ "Usage: figma-cache enrich <figmaUrl> [--allow-skeleton-with-figma-mcp]\n figma-cache enrich --all [--allow-skeleton-with-figma-mcp]",
567
+ );
568
+ process.exit(1);
569
+ }
570
+ const normalized = normalizeFigmaUrl(url);
571
+ const index = normalizeIndexShape(readIndex());
572
+ const item = getItem(index, normalized.cacheKey);
573
+ if (!item) {
574
+ console.error(`enrich failed: cacheKey not found in index: ${normalized.cacheKey}`);
575
+ process.exit(2);
576
+ }
577
+ if (item.source === "figma-mcp") {
578
+ const completeness = normalizeCompletenessList(item.completeness);
579
+ const mcpErrors = validateMcpRawEvidence(
580
+ normalized.cacheKey,
581
+ item,
582
+ completeness,
583
+ { allowSkeletonWithFigmaMcp },
584
+ validateDeps,
585
+ );
586
+ if (mcpErrors.length) {
587
+ console.error("enrich failed: source=figma-mcp but MCP raw evidence is incomplete");
588
+ mcpErrors.forEach((err) => console.error(`- ${err}`));
589
+ process.exit(2);
590
+ }
591
+ }
592
+ ensureEntryFilesAndHook(normalized.cacheKey, item);
593
+ console.log(
594
+ JSON.stringify(
595
+ {
596
+ cacheKey: normalized.cacheKey,
597
+ enriched: true,
598
+ paths: item.paths,
599
+ },
600
+ null,
601
+ 2,
602
+ ),
603
+ );
604
+ return;
605
+ }
606
+
496
607
  if (cmd === "validate") {
497
608
  const index = readIndex();
498
609
  const errors = validateIndex(index, {
@@ -1,5 +1,8 @@
1
1
  /* eslint-disable no-console */
2
2
 
3
+ const { mergeLayoutMetricsFromGeometry, buildEvidenceSummary } = require("./raw-derivatives");
4
+ const { itemCacheKeyFromItem } = require("./related-cache-keys");
5
+
3
6
  function createEntryFilesService(deps) {
4
7
  const {
5
8
  fs,
@@ -8,6 +11,7 @@ function createEntryFilesService(deps) {
8
11
  normalizeCompletenessList,
9
12
  completenessAllDimensions,
10
13
  runPostEnsureHook,
14
+ getRelatedCacheKeys,
11
15
  } = deps;
12
16
 
13
17
  function ensureFileWithDefault(relativePath, fallbackContent) {
@@ -37,6 +41,18 @@ function createEntryFilesService(deps) {
37
41
  }
38
42
  }
39
43
 
44
+ function writeJson(absPath, value) {
45
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
46
+ fs.writeFileSync(absPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
47
+ }
48
+
49
+ function upsertJsonFile(absPath, buildDefault, mutate) {
50
+ const current = safeReadJson(absPath);
51
+ const next = current && typeof current === "object" ? current : buildDefault();
52
+ const mutated = mutate(next) || next;
53
+ writeJson(absPath, mutated);
54
+ }
55
+
40
56
  function isPlaceholderText(input) {
41
57
  const text = String(input || "");
42
58
  return /(TODO|TBD|待补充|待完善|待确认|占位)/i.test(text);
@@ -83,6 +99,92 @@ function createEntryFilesService(deps) {
83
99
  };
84
100
  }
85
101
 
102
+ function parseInsetShorthand(input) {
103
+ const text = String(input || "").trim();
104
+ if (!text) return null;
105
+ const normalized = text.replace(/^\[|\]$/g, "");
106
+ const parts = normalized
107
+ .split("_")
108
+ .map((x) => String(x || "").trim())
109
+ .filter(Boolean);
110
+ if (!parts.length) return null;
111
+ const values = parts.map((p) => {
112
+ const m = p.match(/^(-?\d+(?:\.\d+)?)%$/);
113
+ return m ? Number(m[1]) : NaN;
114
+ });
115
+ if (values.some((n) => !Number.isFinite(n))) return null;
116
+ if (values.length === 1) {
117
+ return { top: values[0], right: values[0], bottom: values[0], left: values[0] };
118
+ }
119
+ if (values.length === 2) {
120
+ return { top: values[0], right: values[1], bottom: values[0], left: values[1] };
121
+ }
122
+ if (values.length === 3) {
123
+ return { top: values[0], right: values[1], bottom: values[2], left: values[1] };
124
+ }
125
+ return { top: values[0], right: values[1], bottom: values[2], left: values[3] };
126
+ }
127
+
128
+ function percentToPx(percent, boxPx) {
129
+ return (Number(percent) / 100) * Number(boxPx);
130
+ }
131
+
132
+ function extractIconMetricsFromDesignContext(designContextText) {
133
+ const text = String(designContextText || "");
134
+ if (!text) return [];
135
+
136
+ // Heuristic: icon outer container has size-[Npx] + data-node-id + data-name.
137
+ // The immediate inner vector wrapper often uses absolute inset-[..%..] specifying padding.
138
+ const outerRe =
139
+ /<div[^>]*className="[^"]*?\bsize-\[(\d+)px\][^"]*?"[^>]*data-node-id="([^"]+)"[^>]*data-name="([^"]+)"[^>]*>/gi;
140
+ const insetRe = /\babsolute\b[^"]*?\binset-\[([^\]]+)\]/i;
141
+
142
+ const metrics = [];
143
+ let outerMatch = null;
144
+ while ((outerMatch = outerRe.exec(text))) {
145
+ const box = Number(outerMatch[1]);
146
+ const outerNodeId = String(outerMatch[2] || "").trim();
147
+ const outerName = String(outerMatch[3] || "").trim();
148
+ if (!Number.isFinite(box) || box <= 0) continue;
149
+ const searchStart = outerRe.lastIndex;
150
+ const window = text.slice(searchStart, Math.min(text.length, searchStart + 900));
151
+ const insetMatch = window.match(insetRe);
152
+ if (!insetMatch) continue;
153
+ const insetRaw = `[${String(insetMatch[1] || "").trim()}]`;
154
+ const parsed = parseInsetShorthand(insetRaw);
155
+ if (!parsed) continue;
156
+
157
+ const topPx = percentToPx(parsed.top, box);
158
+ const rightPx = percentToPx(parsed.right, box);
159
+ const bottomPx = percentToPx(parsed.bottom, box);
160
+ const leftPx = percentToPx(parsed.left, box);
161
+ const glyphW = box - leftPx - rightPx;
162
+ const glyphH = box - topPx - bottomPx;
163
+
164
+ metrics.push({
165
+ nodeId: outerNodeId,
166
+ name: outerName,
167
+ boxPx: box,
168
+ insetPercent: { ...parsed },
169
+ insetPx: {
170
+ top: Number(topPx.toFixed(4)),
171
+ right: Number(rightPx.toFixed(4)),
172
+ bottom: Number(bottomPx.toFixed(4)),
173
+ left: Number(leftPx.toFixed(4)),
174
+ },
175
+ glyphPx: {
176
+ width: Number(glyphW.toFixed(4)),
177
+ height: Number(glyphH.toFixed(4)),
178
+ },
179
+ source: {
180
+ kind: "design_context_inset_percent",
181
+ insetRaw,
182
+ },
183
+ });
184
+ }
185
+ return metrics;
186
+ }
187
+
86
188
  function extractLayoutSummary(metadataText, fallbackName) {
87
189
  const text = String(metadataText || "");
88
190
  const idMatch = text.match(/id="([^"]+)"/);
@@ -100,11 +202,11 @@ function createEntryFilesService(deps) {
100
202
 
101
203
  function extractTextCandidates(designContextText) {
102
204
  const text = String(designContextText || "");
103
- const regex = /<p[^>]*>\s*([^<\n][^<]{0,120})\s*<\/p>/g;
205
+ const regex = /<(p|div|span)[^>]*>\s*([^<\n][^<]{0,120})\s*<\/(p|div|span)>/g;
104
206
  const output = [];
105
207
  let match = null;
106
208
  while ((match = regex.exec(text))) {
107
- const value = String(match[1] || "").replace(/\s+/g, " ").trim();
209
+ const value = String(match[2] || "").replace(/\s+/g, " ").trim();
108
210
  if (!value) {
109
211
  continue;
110
212
  }
@@ -227,16 +329,58 @@ function createEntryFilesService(deps) {
227
329
 
228
330
  const specAbs = resolveMaybeAbsolutePath(item.paths.spec);
229
331
  const stateMapAbs = resolveMaybeAbsolutePath(item.paths.stateMap);
230
- const specText = safeReadText(specAbs);
231
- const stateMapText = safeReadText(stateMapAbs);
232
-
233
- if (isPlaceholderText(specText)) {
234
- fs.writeFileSync(specAbs, buildMcpHydratedSpecContent(item, evidence), "utf8");
235
- }
236
- if (isPlaceholderText(stateMapText)) {
237
- fs.writeFileSync(stateMapAbs, buildMcpHydratedStateMapContent(item), "utf8");
238
- }
332
+ // Always refresh mcp-hydrated entry files to avoid stale evidence summaries
333
+ // when completeness changes or when earlier runs wrote placeholder content.
334
+ fs.writeFileSync(specAbs, buildMcpHydratedSpecContent(item, evidence), "utf8");
335
+ fs.writeFileSync(stateMapAbs, buildMcpHydratedStateMapContent(item), "utf8");
239
336
  hydrateRawTodoNotesIfNeeded(item, evidence);
337
+
338
+ // Persist machine-friendly icon metrics for 1:1 icon glyph sizing,
339
+ // optional layoutMetrics from mcp-raw/figma-geometry-metrics.json (Figma Plugin API / bounding boxes),
340
+ // and evidenceSummary (observability only; not used for validate gates).
341
+ try {
342
+ const iconMetrics = extractIconMetricsFromDesignContext(evidence.designContextText);
343
+ const rawAbs = resolveMaybeAbsolutePath(item.paths.raw);
344
+ const nodeDir = findNodeDirByItem(item);
345
+ const geometryAbs = nodeDir
346
+ ? path.join(nodeDir, "mcp-raw", "figma-geometry-metrics.json")
347
+ : "";
348
+ const geometry = geometryAbs ? safeReadJson(geometryAbs) : null;
349
+ const geometryFilePresent = !!(geometryAbs && fs.existsSync(geometryAbs));
350
+ upsertJsonFile(
351
+ rawAbs,
352
+ () => JSON.parse(buildDefaultRawContent(item)),
353
+ (next) => {
354
+ next.iconMetrics = iconMetrics;
355
+ mergeLayoutMetricsFromGeometry(next, geometry);
356
+ const iconN = Array.isArray(next.iconMetrics) ? next.iconMetrics.length : 0;
357
+ const layoutN = Array.isArray(next.layoutMetrics) ? next.layoutMetrics.length : 0;
358
+ next.evidenceSummary = buildEvidenceSummary({
359
+ designContextText: evidence.designContextText,
360
+ metadataText: evidence.metadataText,
361
+ variableDefs: evidence.variableDefs,
362
+ nodeId: item.nodeId || "",
363
+ geometryFilePresent,
364
+ iconMetricsCount: iconN,
365
+ layoutMetricsCount: layoutN,
366
+ });
367
+ let relatedCacheKeys = [];
368
+ if (typeof getRelatedCacheKeys === "function") {
369
+ try {
370
+ relatedCacheKeys = getRelatedCacheKeys(itemCacheKeyFromItem(item)) || [];
371
+ } catch {
372
+ relatedCacheKeys = [];
373
+ }
374
+ }
375
+ if (Array.isArray(relatedCacheKeys) && relatedCacheKeys.length) {
376
+ next.relatedCacheKeys = relatedCacheKeys;
377
+ } else {
378
+ delete next.relatedCacheKeys;
379
+ }
380
+ return next;
381
+ }
382
+ );
383
+ } catch {}
240
384
  }
241
385
 
242
386
  function buildCoverageSummary(completeness) {
@@ -254,6 +398,8 @@ function createEntryFilesService(deps) {
254
398
  accessibility: covered.includes("accessibility")
255
399
  ? ["state-map.md#accessibility"]
256
400
  : [],
401
+ flow: covered.includes("flow") ? ["spec.md#flow"] : [],
402
+ assets: covered.includes("assets") ? ["mcp-raw/get_design_context#assets"] : [],
257
403
  },
258
404
  };
259
405
  }
@@ -332,24 +478,51 @@ function createEntryFilesService(deps) {
332
478
  }
333
479
 
334
480
  function ensureEntryFiles(item) {
335
- ensureFileWithDefault(
336
- item.paths.meta,
337
- `${JSON.stringify(
338
- {
339
- fileKey: item.fileKey,
340
- nodeId: item.nodeId,
341
- scope: item.scope,
342
- source: item.source,
343
- syncedAt: item.syncedAt,
344
- completeness: normalizeCompletenessList(item.completeness),
345
- },
346
- null,
347
- 2
348
- )}\n`
481
+ const metaAbs = resolveMaybeAbsolutePath(item.paths.meta);
482
+ const rawAbs = resolveMaybeAbsolutePath(item.paths.raw);
483
+ const completeness = normalizeCompletenessList(item.completeness);
484
+
485
+ // Always keep meta/raw in sync with latest ensure/upsert, even if files already exist.
486
+ upsertJsonFile(
487
+ metaAbs,
488
+ () => ({
489
+ fileKey: item.fileKey,
490
+ nodeId: item.nodeId,
491
+ scope: item.scope,
492
+ source: item.source,
493
+ syncedAt: item.syncedAt,
494
+ completeness,
495
+ }),
496
+ (next) => {
497
+ next.fileKey = item.fileKey;
498
+ next.nodeId = item.nodeId;
499
+ next.scope = item.scope;
500
+ next.source = item.source;
501
+ next.syncedAt = item.syncedAt;
502
+ next.completeness = completeness;
503
+ return next;
504
+ }
349
505
  );
506
+
350
507
  ensureFileWithDefault(item.paths.spec, buildDefaultSpecContent(item));
351
508
  ensureFileWithDefault(item.paths.stateMap, buildDefaultStateMapContent(item));
352
- ensureFileWithDefault(item.paths.raw, buildDefaultRawContent(item));
509
+ if (!fs.existsSync(rawAbs)) {
510
+ ensureFileWithDefault(item.paths.raw, buildDefaultRawContent(item));
511
+ }
512
+ upsertJsonFile(
513
+ rawAbs,
514
+ () => JSON.parse(buildDefaultRawContent(item)),
515
+ (next) => {
516
+ next.source = item.source;
517
+ next.fileKey = item.fileKey;
518
+ next.nodeId = item.nodeId;
519
+ next.scope = item.scope;
520
+ next.syncedAt = item.syncedAt;
521
+ next.completeness = completeness;
522
+ next.coverageSummary = buildCoverageSummary(completeness);
523
+ return next;
524
+ }
525
+ );
353
526
  hydrateMcpEntryFilesIfNeeded(item);
354
527
  }
355
528
 
@@ -9,6 +9,41 @@ function slugifyFlowId(name) {
9
9
  return raw || `flow-${Date.now()}`;
10
10
  }
11
11
 
12
+ function normalizeNodeId(input) {
13
+ const value = String(input || "").trim();
14
+ if (!value) return "";
15
+ return value.includes(":") ? value : value.replace(/-/g, ":");
16
+ }
17
+
18
+ function normalizeCacheKey(input) {
19
+ const value = String(input || "").trim();
20
+ if (!value) return "";
21
+ const parts = value.split("#");
22
+ if (parts.length !== 2) return value;
23
+ return `${parts[0]}#${normalizeNodeId(parts[1])}`;
24
+ }
25
+
26
+ function resolveCacheKeyOrUrl(input, deps) {
27
+ const text = String(input || "").trim();
28
+ if (!text) return { kind: "empty", cacheKey: "" };
29
+
30
+ // Shorthand: <fileKey>#<nodeId>
31
+ if (text.includes("#")) {
32
+ return { kind: "cacheKey", cacheKey: normalizeCacheKey(text) };
33
+ }
34
+
35
+ // Shorthand: <nodeId> with default fileKey
36
+ if (/^(?:-?\d+[:-]-?\d+)$/.test(text)) {
37
+ const fileKey = process.env.FIGMA_DEFAULT_FILEKEY || "";
38
+ if (fileKey) {
39
+ return { kind: "cacheKey", cacheKey: `${fileKey}#${normalizeNodeId(text)}` };
40
+ }
41
+ }
42
+
43
+ // Fallback: treat as URL
44
+ return { kind: "url", cacheKey: deps.normalizeFigmaUrl(text).cacheKey, url: text };
45
+ }
46
+
12
47
  function ensureFlow(index, flowId, meta, normalizeIndexShape) {
13
48
  const normalized = normalizeIndexShape(index);
14
49
  normalized.flows = normalized.flows || {};
@@ -91,13 +126,14 @@ function handleFlowCommand(args, deps) {
91
126
  console.error("Missing --flow=<flowId> or env FIGMA_DEFAULT_FLOW");
92
127
  process.exit(1);
93
128
  }
94
- const url = rest.find((x) => !x.startsWith("--"));
129
+ const input = rest.find((x) => !x.startsWith("--"));
95
130
  const ensureArg = rest.includes("--ensure");
96
131
  const sourceArg = rest.find((x) => x.startsWith("--source="));
97
132
  const source = sourceArg ? sourceArg.split("=")[1] : "manual";
98
133
  const { completeness } = parseCompletenessFromArgs(rest);
99
134
  const index = normalizeIndexShape(readIndex());
100
- const normalized = normalizeFigmaUrl(url);
135
+ const resolved = resolveCacheKeyOrUrl(input, { normalizeFigmaUrl });
136
+ const normalized = { cacheKey: resolved.cacheKey };
101
137
  if (!ensureArg && !getItem(index, normalized.cacheKey)) {
102
138
  console.error(
103
139
  `Missing cache item for ${normalized.cacheKey}. Run figma:cache:ensure first, or pass --ensure.`
@@ -105,7 +141,16 @@ function handleFlowCommand(args, deps) {
105
141
  process.exit(2);
106
142
  }
107
143
  if (ensureArg) {
108
- upsertByUrl(url, { source, completeness });
144
+ if (resolved.kind !== "url" || !resolved.url) {
145
+ console.error(
146
+ `flow add-node --ensure requires a Figma URL. Got shorthand "${String(input || "").trim()}".`
147
+ );
148
+ console.error(
149
+ `Tip: set FIGMA_DEFAULT_FILEKEY and run figma:cache:ensure for this node first, then re-run flow add-node without --ensure.`
150
+ );
151
+ process.exit(2);
152
+ }
153
+ upsertByUrl(resolved.url, { source, completeness });
109
154
  const refreshed = normalizeIndexShape(readIndex());
110
155
  const item = getItem(refreshed, normalized.cacheKey);
111
156
  if (item) {
@@ -140,8 +185,8 @@ function handleFlowCommand(args, deps) {
140
185
  }
141
186
  const type = typeArg ? typeArg.split("=")[1] : "related";
142
187
  const note = noteArg ? noteArg.split("=").slice(1).join("=") : "";
143
- const from = normalizeFigmaUrl(urls[0]).cacheKey;
144
- const to = normalizeFigmaUrl(urls[1]).cacheKey;
188
+ const from = resolveCacheKeyOrUrl(urls[0], { normalizeFigmaUrl }).cacheKey;
189
+ const to = resolveCacheKeyOrUrl(urls[1], { normalizeFigmaUrl }).cacheKey;
145
190
  const index = normalizeIndexShape(readIndex());
146
191
  if (!getItem(index, from) || !getItem(index, to)) {
147
192
  console.error("Missing cache item for from/to. Cache urls first with ensure/upsert.");
@@ -169,7 +214,7 @@ function handleFlowCommand(args, deps) {
169
214
  process.exit(1);
170
215
  }
171
216
  const index = normalizeIndexShape(readIndex());
172
- const keys = urls.map((u) => normalizeFigmaUrl(u).cacheKey);
217
+ const keys = urls.map((u) => resolveCacheKeyOrUrl(u, { normalizeFigmaUrl }).cacheKey);
173
218
  keys.forEach((k) => {
174
219
  if (!getItem(index, k)) {
175
220
  console.error(`Missing cache item for ${k}. Ensure each url is cached first.`);