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 +1 -5
- package/figma-cache/docs/README.md +10 -19
- package/figma-cache/docs/raw-json-extensions.schema.json +44 -0
- package/figma-cache/figma-cache.js +111 -0
- package/figma-cache/js/entry-files.js +199 -26
- package/figma-cache/js/flow-cli.js +51 -6
- package/figma-cache/js/raw-derivatives.js +85 -0
- package/figma-cache/js/related-cache-keys.js +56 -0
- package/figma-cache/js/ui-facts-normalizer.js +29 -18
- package/figma-cache/js/validate-cli.js +68 -0
- package/package.json +16 -3
- package/scripts/apply-auto-related-suggestions.cjs +180 -0
- package/scripts/archive-artifacts-from-batch.cjs +160 -0
- package/scripts/auto-link-related-from-batch.cjs +310 -0
- package/scripts/cross-project-e2e.js +163 -6
- package/scripts/forbidden-markup-check.cjs +300 -0
- package/scripts/generate-icon-insets-from-batch.cjs +194 -0
- package/scripts/generate-icon-insets.cjs +112 -0
- package/scripts/import-mcp-raw-evidence.cjs +141 -0
- package/scripts/merge-figma-geometry-metrics.cjs +81 -0
- package/scripts/ui-1to1-audit.js +33 -5
- package/scripts/ui-auto-acceptance.js +3 -3
- package/scripts/ui-preflight.js +1 -1
- package/scripts/ui-report-aggregate.js +3 -3
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
|
|
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[
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
144
|
-
const to =
|
|
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) =>
|
|
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.`);
|