figma-cache-toolchain 2.0.5 → 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.
@@ -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,9 +81,18 @@ 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
- - `npm run figma:ui:preflight`:读取 `index.json`、adapter contract 与节点关键文件,输出结构化报告到 `figma-cache/reports/runtime/ui-preflight-report.json`
95
+ - `npm run figma:ui:preflight`:读取 `index.json`、adapter contract 与节点关键文件,输出结构化报告到 `figma-cache/reports/ui-preflight-report.json`
87
96
  - 支持参数:`--cacheKey=<fileKey#nodeId>`、`--contract=<path>`、`--report=<path>`、`--allow-warn`
88
97
  - 阻断项返回退出码 `2`:包括 cacheKey 不存在、关键文件缺失、coverage evidence 不完整、contract 缺失或映射为空、`source=figma-mcp` 时缺失 `mcp-raw-manifest.json`
89
98
  - warning 项(不阻断)会提示 `spec.md`/`state-map.md` 中的 TODO 占位
@@ -97,7 +106,7 @@ npm run figma:cache:config
97
106
  ### UI 1:1 audit(P1 质量评分)
98
107
 
99
108
  - `npm run figma:ui:audit -- --cacheKey=<fileKey#nodeId> --target=<componentPath> --min-score=85`
100
- - 默认报告:`figma-cache/reports/runtime/ui-1to1-report.json`
109
+ - 默认报告:`figma-cache/reports/ui-1to1-report.json`
101
110
  - 报告结构遵循:`figma-cache/docs/ui-1to1-report.schema.json`
102
111
  - 评分字段:`score.total/layout/text/token/state/interaction`
103
112
  - `score.total` 低于 `--min-score` 会返回退出码 `2`(可用于 CI 门禁)
@@ -128,7 +137,7 @@ npm run figma:cache:config
128
137
  - `standard`:audit 默认阈值 85
129
138
  - `strict`:preflight warning 计入阻断、audit 默认阈值 92 且要求 `--target`
130
139
  - 报告聚合:`npm run figma:ui:report:aggregate`
131
- - 输出:`figma-cache/reports/runtime/ui-quality-summary.json`
140
+ - 输出:`figma-cache/reports/ui-quality-summary.json`
132
141
 
133
142
  ### 一键自动验收(效果导向)
134
143
 
@@ -140,25 +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
- - `--emit-agent-task-on-fail`:失败自动产出 `agent-task.md`,用于交给 Cursor Agent 接力修复
161
-
162
152
  ### 严格 validate 规则(默认)
163
153
 
164
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.`);