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.
- package/figma-cache/docs/README.md +13 -23
- 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 +19 -3
- 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 +32 -4
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MCP raw → raw.json 派生字段的纯函数(无 fs),供 entry-files hydrate 与 CLI merge 脚本复用。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
function mergeLayoutMetricsFromGeometry(raw, geometry) {
|
|
8
|
+
if (!raw || typeof raw !== "object") {
|
|
9
|
+
return raw;
|
|
10
|
+
}
|
|
11
|
+
if (!geometry || typeof geometry !== "object" || !Array.isArray(geometry.metrics)) {
|
|
12
|
+
return raw;
|
|
13
|
+
}
|
|
14
|
+
if (!geometry.metrics.length) {
|
|
15
|
+
return raw;
|
|
16
|
+
}
|
|
17
|
+
const existing = Array.isArray(raw.layoutMetrics) ? raw.layoutMetrics : [];
|
|
18
|
+
const byId = new Map(
|
|
19
|
+
existing.map((m) => [String(m && m.id ? m.id : "").trim(), m]).filter(([k]) => k)
|
|
20
|
+
);
|
|
21
|
+
geometry.metrics.forEach((m) => {
|
|
22
|
+
const id = String(m && m.id ? m.id : "").trim();
|
|
23
|
+
if (!id) return;
|
|
24
|
+
byId.set(id, m);
|
|
25
|
+
});
|
|
26
|
+
raw.layoutMetrics = Array.from(byId.values()).sort((a, b) =>
|
|
27
|
+
String(a.id).localeCompare(String(b.id))
|
|
28
|
+
);
|
|
29
|
+
return raw;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildEvidenceSummary(input) {
|
|
33
|
+
const {
|
|
34
|
+
designContextText = "",
|
|
35
|
+
metadataText = "",
|
|
36
|
+
variableDefs = null,
|
|
37
|
+
nodeId = "",
|
|
38
|
+
geometryFilePresent = false,
|
|
39
|
+
iconMetricsCount = 0,
|
|
40
|
+
layoutMetricsCount = 0,
|
|
41
|
+
} = input || {};
|
|
42
|
+
|
|
43
|
+
const dc = String(designContextText);
|
|
44
|
+
const meta = String(metadataText);
|
|
45
|
+
const designContextBytes = Buffer.byteLength(dc, "utf8");
|
|
46
|
+
const metadataBytes = Buffer.byteLength(meta, "utf8");
|
|
47
|
+
const dataNodeIdRefs = (dc.match(/data-node-id="/g) || []).length;
|
|
48
|
+
const scopeNodeId = String(nodeId || "").trim();
|
|
49
|
+
const dataNodeIdContainsScope =
|
|
50
|
+
scopeNodeId && dc.length ? dc.includes(`data-node-id="${scopeNodeId}"`) : null;
|
|
51
|
+
const designContextImgConstDefinitions = (
|
|
52
|
+
dc.match(/\bconst\s+img[A-Za-z0-9_]*\s*=\s*"https:\/\/www\.figma\.com\/api\/mcp\/asset\//g) || []
|
|
53
|
+
).length;
|
|
54
|
+
const imgTagOccurrences = (dc.match(/<img\b/gi) || []).length;
|
|
55
|
+
const figmaAssetUrlOccurrences = (dc.match(/https:\/\/www\.figma\.com\/api\/mcp\/asset\//g) || []).length;
|
|
56
|
+
const approximateHexColorLiterals = (
|
|
57
|
+
dc.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g) || []
|
|
58
|
+
).length;
|
|
59
|
+
const variableDefKeys =
|
|
60
|
+
variableDefs && typeof variableDefs === "object" && !Array.isArray(variableDefs)
|
|
61
|
+
? Object.keys(variableDefs).length
|
|
62
|
+
: 0;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
version: 1,
|
|
66
|
+
generatedAt: new Date().toISOString(),
|
|
67
|
+
designContextBytes,
|
|
68
|
+
metadataBytes,
|
|
69
|
+
dataNodeIdRefs,
|
|
70
|
+
dataNodeIdContainsScope,
|
|
71
|
+
designContextImgConstDefinitions,
|
|
72
|
+
imgTagOccurrences,
|
|
73
|
+
figmaAssetUrlOccurrences,
|
|
74
|
+
approximateHexColorLiterals,
|
|
75
|
+
variableDefKeys,
|
|
76
|
+
geometryFilePresent,
|
|
77
|
+
iconMetricsCount,
|
|
78
|
+
layoutMetricsCount,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
mergeLayoutMetricsFromGeometry,
|
|
84
|
+
buildEvidenceSummary,
|
|
85
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Derive peer cacheKeys from index.json flows: undirected edges whose type starts with "related"
|
|
5
|
+
* (e.g. related_auto, related_confirmed).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function itemCacheKeyFromItem(item) {
|
|
9
|
+
if (!item || !item.fileKey) {
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
if (item.scope === "file" || !item.nodeId) {
|
|
13
|
+
return `${item.fileKey}#__FILE__`;
|
|
14
|
+
}
|
|
15
|
+
return `${item.fileKey}#${item.nodeId}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function collectRelatedNeighborMap(index) {
|
|
19
|
+
const flows = index && typeof index.flows === "object" ? index.flows : {};
|
|
20
|
+
/** @type {Map<string, Set<string>>} */
|
|
21
|
+
const neighbors = new Map();
|
|
22
|
+
function link(a, b) {
|
|
23
|
+
const x = String(a || "").trim();
|
|
24
|
+
const y = String(b || "").trim();
|
|
25
|
+
if (!x || !y || x === y) return;
|
|
26
|
+
if (!neighbors.has(x)) neighbors.set(x, new Set());
|
|
27
|
+
if (!neighbors.has(y)) neighbors.set(y, new Set());
|
|
28
|
+
neighbors.get(x).add(y);
|
|
29
|
+
neighbors.get(y).add(x);
|
|
30
|
+
}
|
|
31
|
+
Object.values(flows).forEach((flow) => {
|
|
32
|
+
if (!flow || !Array.isArray(flow.edges)) return;
|
|
33
|
+
flow.edges.forEach((edge) => {
|
|
34
|
+
if (!edge || !edge.from || !edge.to) return;
|
|
35
|
+
const t = String(edge.type || "").trim().toLowerCase();
|
|
36
|
+
if (!t.startsWith("related")) return;
|
|
37
|
+
link(edge.from, edge.to);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
return neighbors;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getRelatedCacheKeysFromIndex(cacheKey, index) {
|
|
44
|
+
const key = String(cacheKey || "").trim();
|
|
45
|
+
if (!key) return [];
|
|
46
|
+
const neighbors = collectRelatedNeighborMap(index);
|
|
47
|
+
const set = neighbors.get(key);
|
|
48
|
+
if (!set || !set.size) return [];
|
|
49
|
+
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
itemCacheKeyFromItem,
|
|
54
|
+
getRelatedCacheKeysFromIndex,
|
|
55
|
+
collectRelatedNeighborMap,
|
|
56
|
+
};
|
|
@@ -67,31 +67,42 @@ function isPlaceholderText(input) {
|
|
|
67
67
|
function extractSpecFacts(specText) {
|
|
68
68
|
const textFacts = [];
|
|
69
69
|
const tokenFacts = [];
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
70
|
+
/** 同一行内可多次出现 name:#hex,每行重置 lastIndex,避免 /g 状态串行污染 */
|
|
71
|
+
const tokenPairRe = /-\s*([^:\n]+?)\s*:\s*(#[0-9a-fA-F]{3,8})/g;
|
|
72
|
+
const lines = String(specText || "").split(/\r?\n/);
|
|
73
|
+
let inTextSection = false;
|
|
74
|
+
let inTokenSection = false;
|
|
75
|
+
|
|
76
|
+
lines.forEach((rawLine) => {
|
|
77
|
+
const t = rawLine.trim();
|
|
78
|
+
if (/^##\s+/i.test(t)) {
|
|
79
|
+
inTextSection = /(Text|文案)/i.test(t);
|
|
80
|
+
inTokenSection = /(Tokens|Token|变量|样式)/i.test(t);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!t.startsWith("-")) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
tokenPairRe.lastIndex = 0;
|
|
87
|
+
let hasTokenPair = false;
|
|
88
|
+
let match = null;
|
|
89
|
+
while ((match = tokenPairRe.exec(t)) !== null) {
|
|
90
|
+
hasTokenPair = true;
|
|
91
|
+
if (inTokenSection) {
|
|
82
92
|
tokenFacts.push({
|
|
83
93
|
name: String(match[1] || "").trim(),
|
|
84
94
|
value: String(match[2] || "").trim(),
|
|
85
95
|
source: "spec.md",
|
|
86
96
|
});
|
|
87
97
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
}
|
|
99
|
+
if (inTextSection && !hasTokenPair) {
|
|
100
|
+
const text = t.replace(/^-+\s*/, "").trim();
|
|
101
|
+
if (text) {
|
|
102
|
+
textFacts.push(text);
|
|
93
103
|
}
|
|
94
|
-
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
95
106
|
return {
|
|
96
107
|
textFacts: dedupeStrings(textFacts),
|
|
97
108
|
tokenFacts: dedupeTokens(tokenFacts),
|
|
@@ -8,12 +8,77 @@ function isTodoLike(value) {
|
|
|
8
8
|
function hasTruncatedMarker(value) {
|
|
9
9
|
const text = String(value || "");
|
|
10
10
|
return (
|
|
11
|
+
/\btruncated\b/i.test(text) ||
|
|
11
12
|
/omitted\s+for\s+brevity/i.test(text) ||
|
|
12
13
|
/省略|截断|已截短|摘要版/i.test(text) ||
|
|
14
|
+
/证据占位|占位证据|evidence\s+placeholder|used\s+as\s+evidence/i.test(text) ||
|
|
15
|
+
/omitted\s+here/i.test(text) ||
|
|
16
|
+
/for\s+brevity\s+in\s+this\s+workspace/i.test(text) ||
|
|
17
|
+
/\(\s*truncated\s*\)/i.test(text) ||
|
|
13
18
|
/\.\.\.\s*(MCP|get_design_context|response|回包|原始响应)/i.test(text)
|
|
14
19
|
);
|
|
15
20
|
}
|
|
16
21
|
|
|
22
|
+
function validateDesignContextNotSkeleton(cacheKey, fileAbs, content, errors, deps) {
|
|
23
|
+
const { normalizeSlash } = deps;
|
|
24
|
+
const minBytesRaw = Number(process.env.FIGMA_MCP_MIN_DESIGN_CONTEXT_BYTES);
|
|
25
|
+
const minBytes = Number.isFinite(minBytesRaw) && minBytesRaw >= 0 ? Math.floor(minBytesRaw) : 1500;
|
|
26
|
+
const bytes = Buffer.byteLength(String(content || ""), "utf8");
|
|
27
|
+
|
|
28
|
+
// Hard-fail if it's too small to be a real get_design_context payload.
|
|
29
|
+
// This catches "placeholder evidence" where only a wrapper/div exists.
|
|
30
|
+
if (minBytes > 0 && bytes < minBytes) {
|
|
31
|
+
errors.push(
|
|
32
|
+
`${cacheKey}: get_design_context 原始文件疑似过小(${bytes}B < ${minBytes}B),禁止省略/占位,必须直存完整回包 ${normalizeSlash(
|
|
33
|
+
fileAbs
|
|
34
|
+
)}`
|
|
35
|
+
);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Additional structural sanity checks for common placeholder patterns.
|
|
40
|
+
const text = String(content || "");
|
|
41
|
+
const nodeIdMatch = cacheKey.split("#")[1] || "";
|
|
42
|
+
if (nodeIdMatch && !text.includes(`data-node-id="${nodeIdMatch}"`)) {
|
|
43
|
+
errors.push(
|
|
44
|
+
`${cacheKey}: get_design_context 原始文件缺少目标 data-node-id="${nodeIdMatch}"(疑似非对应节点回包或被截断) ${normalizeSlash(
|
|
45
|
+
fileAbs
|
|
46
|
+
)}`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const minNodeRefsRaw = Number(process.env.FIGMA_MCP_MIN_DESIGN_CONTEXT_NODE_REFS);
|
|
51
|
+
const minNodeRefs =
|
|
52
|
+
Number.isFinite(minNodeRefsRaw) && minNodeRefsRaw >= 0 ? Math.floor(minNodeRefsRaw) : 6;
|
|
53
|
+
if (minNodeRefs > 0) {
|
|
54
|
+
const nodeRefs = (text.match(/data-node-id="/g) || []).length;
|
|
55
|
+
if (nodeRefs < minNodeRefs) {
|
|
56
|
+
errors.push(
|
|
57
|
+
`${cacheKey}: get_design_context data-node-id 引用数量过少(${nodeRefs} < ${minNodeRefs}),疑似省略/骨架模式 ${normalizeSlash(
|
|
58
|
+
fileAbs
|
|
59
|
+
)}`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const requireAssetsFlag = String(process.env.FIGMA_MCP_REQUIRE_DESIGN_CONTEXT_ASSETS || "")
|
|
65
|
+
.trim()
|
|
66
|
+
.toLowerCase();
|
|
67
|
+
const requireAssets = requireAssetsFlag ? requireAssetsFlag !== "0" && requireAssetsFlag !== "false" : true;
|
|
68
|
+
if (requireAssets) {
|
|
69
|
+
const hasAssetConstants =
|
|
70
|
+
/\bconst\s+img[A-Za-z0-9_]*\s*=\s*"https:\/\/www\.figma\.com\/api\/mcp\/asset\//.test(text);
|
|
71
|
+
const hasImgUsage = /<img\b/i.test(text);
|
|
72
|
+
if (!hasAssetConstants || !hasImgUsage) {
|
|
73
|
+
errors.push(
|
|
74
|
+
`${cacheKey}: get_design_context 缺少资产常量或 <img> 使用(疑似被省略/占位)。如节点确实无资产,可设 FIGMA_MCP_REQUIRE_DESIGN_CONTEXT_ASSETS=0 关闭该检查 ${normalizeSlash(
|
|
75
|
+
fileAbs
|
|
76
|
+
)}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
17
82
|
function getManifestFilesMap(cacheKey, item, errors, deps) {
|
|
18
83
|
const { resolveMaybeAbsolutePath, safeReadJson, normalizeSlash, path, fs } = deps;
|
|
19
84
|
if (!item || !item.paths || !item.paths.meta) {
|
|
@@ -64,6 +129,9 @@ function getManifestFilesMap(cacheKey, item, errors, deps) {
|
|
|
64
129
|
);
|
|
65
130
|
return;
|
|
66
131
|
}
|
|
132
|
+
if (toolName === "get_design_context") {
|
|
133
|
+
validateDesignContextNotSkeleton(cacheKey, fileAbs, content, errors, deps);
|
|
134
|
+
}
|
|
67
135
|
if (fileHashes && fileSizes) {
|
|
68
136
|
const expectedHash = String(fileHashes[toolName] || "").trim().toLowerCase();
|
|
69
137
|
const expectedSize = Number(fileSizes[toolName]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figma-cache-toolchain",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"description": "Figma link normalization, local cache index, validation, and Node CLI (framework-agnostic core).",
|
|
5
5
|
"homepage": "https://github.com/907086379/figma-cache-toolchain#readme",
|
|
6
6
|
"keywords": [
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"figma-cache/js/index-store.js",
|
|
29
29
|
"figma-cache/js/cursor-bootstrap-cli.js",
|
|
30
30
|
"figma-cache/js/entry-files.js",
|
|
31
|
+
"figma-cache/js/raw-derivatives.js",
|
|
32
|
+
"figma-cache/js/related-cache-keys.js",
|
|
31
33
|
"figma-cache/js/backfill-cli.js",
|
|
32
34
|
"figma-cache/js/project-config.js",
|
|
33
35
|
"figma-cache/js/contract-check-cli.js",
|
|
@@ -39,15 +41,24 @@
|
|
|
39
41
|
"scripts/ui-report-aggregate.js",
|
|
40
42
|
"scripts/ui-auto-acceptance.js",
|
|
41
43
|
"scripts/cross-project-e2e.js",
|
|
44
|
+
"scripts/forbidden-markup-check.cjs",
|
|
45
|
+
"scripts/generate-icon-insets.cjs",
|
|
46
|
+
"scripts/generate-icon-insets-from-batch.cjs",
|
|
47
|
+
"scripts/auto-link-related-from-batch.cjs",
|
|
48
|
+
"scripts/import-mcp-raw-evidence.cjs",
|
|
49
|
+
"scripts/merge-figma-geometry-metrics.cjs",
|
|
50
|
+
"scripts/apply-auto-related-suggestions.cjs",
|
|
51
|
+
"scripts/archive-artifacts-from-batch.cjs",
|
|
42
52
|
"figma-cache/adapters/recipes/*.json",
|
|
43
53
|
"figma-cache/docs/*.md",
|
|
44
|
-
"figma-cache/docs/ui-1to1-report.schema.json"
|
|
54
|
+
"figma-cache/docs/ui-1to1-report.schema.json",
|
|
55
|
+
"figma-cache/docs/raw-json-extensions.schema.json"
|
|
45
56
|
],
|
|
46
57
|
"publishConfig": {
|
|
47
58
|
"registry": "https://registry.npmjs.org/"
|
|
48
59
|
},
|
|
49
60
|
"scripts": {
|
|
50
|
-
"test": "npm run cursor:shadow:check && npm run docs:encoding:check && node tests/rules-guard.js && node tests/smoke.js",
|
|
61
|
+
"test": "npm run cursor:shadow:check && npm run docs:encoding:check && node tests/rules-guard.js && node tests/raw-derivatives.test.js && node tests/related-cache-keys.test.js && node tests/smoke.js",
|
|
51
62
|
"prepack": "npm run cursor:shadow:check && npm run docs:encoding:check && node bin/figma-cache.js validate",
|
|
52
63
|
"figma:cache:normalize": "node bin/figma-cache.js normalize",
|
|
53
64
|
"figma:cache:get": "node bin/figma-cache.js get",
|
|
@@ -78,6 +89,8 @@
|
|
|
78
89
|
"figma:ui:gate": "npm run figma:ui:preflight && npm run figma:ui:audit -- --min-score=85 && npm run figma:cache:validate && npm run cursor:shadow:check && npm test",
|
|
79
90
|
"figma:ui:gate:pr": "npm run figma:ui:preflight && npm run figma:cache:validate",
|
|
80
91
|
"figma:ui:gate:main": "npm run figma:ui:preflight && npm run figma:ui:audit -- --min-score=90 && npm run figma:ui:report:aggregate && npm run figma:cache:validate && npm run cursor:shadow:check && npm test",
|
|
92
|
+
"figma:cache:merge-geometry": "node scripts/merge-figma-geometry-metrics.cjs",
|
|
93
|
+
"figma:cache:enrich": "node bin/figma-cache.js enrich",
|
|
81
94
|
"figma:cache:contract:check": "node bin/figma-cache.js contract-check",
|
|
82
95
|
"preflight:shell": "node scripts/preflight-shell.js --warn",
|
|
83
96
|
"preflight:shell:strict": "node scripts/preflight-shell.js --strict"
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Apply auto-link suggestions into figma-cache/index.json flows.
|
|
6
|
+
*
|
|
7
|
+
* This is designed to be used after:
|
|
8
|
+
* auto-link-related-from-batch.cjs -> auto-related-suggestions.json
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/apply-auto-related-suggestions.cjs --suggestions=<path> --pairs=<from->to,from->to>
|
|
12
|
+
*
|
|
13
|
+
* Options:
|
|
14
|
+
* --index=<path> (default: figma-cache/index.json)
|
|
15
|
+
* --flow=<flowId> (default: suggestions.flowId || "auto-related")
|
|
16
|
+
* --type=<edgeType> (default: related_confirmed)
|
|
17
|
+
* --pairs=<csv> required. Each pair: <fromCacheKey>-><toCacheKey>
|
|
18
|
+
* --dry-run do not write index
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
|
|
24
|
+
const ROOT = process.cwd();
|
|
25
|
+
|
|
26
|
+
function safeReadJson(abs) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(abs, "utf8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeJson(abs, value) {
|
|
35
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
36
|
+
fs.writeFileSync(abs, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeNodeId(input) {
|
|
40
|
+
const value = String(input || "").trim();
|
|
41
|
+
if (!value) return "";
|
|
42
|
+
return value.includes(":") ? value : value.replace(/-/g, ":");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeCacheKey(input) {
|
|
46
|
+
const value = String(input || "").trim();
|
|
47
|
+
if (!value) return "";
|
|
48
|
+
const parts = value.split("#");
|
|
49
|
+
if (parts.length !== 2) return value;
|
|
50
|
+
return `${parts[0]}#${normalizeNodeId(parts[1])}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseArgs(argv) {
|
|
54
|
+
const out = {
|
|
55
|
+
suggestions: "",
|
|
56
|
+
index: path.join(ROOT, "figma-cache", "index.json"),
|
|
57
|
+
flowId: "",
|
|
58
|
+
type: "related_confirmed",
|
|
59
|
+
pairs: [],
|
|
60
|
+
dryRun: false,
|
|
61
|
+
};
|
|
62
|
+
argv.slice(2).forEach((arg) => {
|
|
63
|
+
if (arg.startsWith("--suggestions=")) out.suggestions = arg.split("=").slice(1).join("=").trim();
|
|
64
|
+
if (arg.startsWith("--index=")) out.index = arg.split("=").slice(1).join("=").trim();
|
|
65
|
+
if (arg.startsWith("--flow=")) out.flowId = arg.split("=").slice(1).join("=").trim();
|
|
66
|
+
if (arg.startsWith("--type=")) out.type = arg.split("=").slice(1).join("=").trim();
|
|
67
|
+
if (arg.startsWith("--pairs=")) {
|
|
68
|
+
out.pairs = arg
|
|
69
|
+
.split("=").slice(1).join("=")
|
|
70
|
+
.split(",")
|
|
71
|
+
.map((s) => String(s || "").trim())
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
}
|
|
74
|
+
if (arg === "--dry-run") out.dryRun = true;
|
|
75
|
+
});
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ensureFlow(index, flowId) {
|
|
80
|
+
index.flows = index.flows || {};
|
|
81
|
+
if (!index.flows[flowId]) {
|
|
82
|
+
index.flows[flowId] = {
|
|
83
|
+
id: flowId,
|
|
84
|
+
title: flowId,
|
|
85
|
+
description: "Manually confirmed auto-link suggestions",
|
|
86
|
+
createdAt: new Date().toISOString(),
|
|
87
|
+
updatedAt: new Date().toISOString(),
|
|
88
|
+
nodes: [],
|
|
89
|
+
edges: [],
|
|
90
|
+
assumptions: [],
|
|
91
|
+
openQuestions: [],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return index.flows[flowId];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function addNode(flow, cacheKey) {
|
|
98
|
+
flow.nodes = flow.nodes || [];
|
|
99
|
+
if (!flow.nodes.includes(cacheKey)) flow.nodes.push(cacheKey);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hasEdge(flow, from, to, type) {
|
|
103
|
+
const edges = Array.isArray(flow.edges) ? flow.edges : [];
|
|
104
|
+
return edges.some((e) => e && e.from === from && e.to === to && e.type === type);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function addEdge(flow, from, to, type, note) {
|
|
108
|
+
if (hasEdge(flow, from, to, type)) return false;
|
|
109
|
+
flow.edges = flow.edges || [];
|
|
110
|
+
flow.edges.push({
|
|
111
|
+
id: `${from}->${to}:${type}:${Date.now()}`,
|
|
112
|
+
from,
|
|
113
|
+
to,
|
|
114
|
+
type,
|
|
115
|
+
note: note || "",
|
|
116
|
+
createdAt: new Date().toISOString(),
|
|
117
|
+
});
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function main() {
|
|
122
|
+
const args = parseArgs(process.argv);
|
|
123
|
+
if (!args.suggestions || !args.pairs.length) {
|
|
124
|
+
console.error(
|
|
125
|
+
"Usage: node scripts/apply-auto-related-suggestions.cjs --suggestions=<path> --pairs=<from->to,from->to> [--flow=...] [--type=...] [--dry-run]"
|
|
126
|
+
);
|
|
127
|
+
process.exit(2);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const suggestionsAbs = path.isAbsolute(args.suggestions) ? args.suggestions : path.join(ROOT, args.suggestions);
|
|
131
|
+
const indexAbs = path.isAbsolute(args.index) ? args.index : path.join(ROOT, args.index);
|
|
132
|
+
const s = safeReadJson(suggestionsAbs);
|
|
133
|
+
const index = safeReadJson(indexAbs);
|
|
134
|
+
if (!s || typeof s !== "object") {
|
|
135
|
+
console.error(`[apply-auto-related-suggestions] invalid suggestions json: ${suggestionsAbs}`);
|
|
136
|
+
process.exit(2);
|
|
137
|
+
}
|
|
138
|
+
if (!index || typeof index !== "object") {
|
|
139
|
+
console.error(`[apply-auto-related-suggestions] invalid index json: ${indexAbs}`);
|
|
140
|
+
process.exit(2);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const flowId = String(args.flowId || s.flowId || "auto-related").trim();
|
|
144
|
+
const flow = ensureFlow(index, flowId);
|
|
145
|
+
|
|
146
|
+
const allowedPairs = new Set(
|
|
147
|
+
(Array.isArray(s.suggestions) ? s.suggestions : []).map((x) => `${x.from}->${x.to}`)
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
let applied = 0;
|
|
151
|
+
args.pairs.forEach((pairRaw) => {
|
|
152
|
+
const pair = String(pairRaw || "").trim();
|
|
153
|
+
const m = pair.split("->");
|
|
154
|
+
if (m.length !== 2) return;
|
|
155
|
+
const from = normalizeCacheKey(m[0]);
|
|
156
|
+
const to = normalizeCacheKey(m[1]);
|
|
157
|
+
if (!from || !to) return;
|
|
158
|
+
|
|
159
|
+
// Safety: only apply pairs that are actually in the suggestions list.
|
|
160
|
+
if (!allowedPairs.has(`${from}->${to}`)) {
|
|
161
|
+
console.error(`[apply-auto-related-suggestions] skipped (not suggested): ${from}->${to}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
addNode(flow, from);
|
|
166
|
+
addNode(flow, to);
|
|
167
|
+
if (addEdge(flow, from, to, args.type, "confirmed from suggestions")) applied += 1;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
flow.updatedAt = new Date().toISOString();
|
|
171
|
+
index.updatedAt = new Date().toISOString();
|
|
172
|
+
|
|
173
|
+
if (!args.dryRun) writeJson(indexAbs, index);
|
|
174
|
+
console.log(
|
|
175
|
+
`[apply-auto-related-suggestions] ok applied=${applied} flow=${flowId} dryRun=${args.dryRun ? "1" : "0"}`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
main();
|
|
180
|
+
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Archive per-case figma-cache evidence into a dedicated artifacts root
|
|
6
|
+
* (defaults to <projectRoot>/figma-cache/artifacts/by-target/), to avoid
|
|
7
|
+
* polluting source directories.
|
|
8
|
+
*
|
|
9
|
+
* Layout (default):
|
|
10
|
+
* <outRoot>/<targetRelDir>/figma-cache/<cacheKeySanitized>/
|
|
11
|
+
* - meta.json
|
|
12
|
+
* - raw.json
|
|
13
|
+
* - spec.md
|
|
14
|
+
* - state-map.md
|
|
15
|
+
* - mcp-raw/...
|
|
16
|
+
*
|
|
17
|
+
* And also copies shared runtime reports into:
|
|
18
|
+
* <outRoot>/<targetRelDir>/reports/runtime/
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* node scripts/archive-artifacts-from-batch.cjs --batch=./figma-e2e-batch.json
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require("fs");
|
|
25
|
+
const path = require("path");
|
|
26
|
+
|
|
27
|
+
const ROOT = process.cwd();
|
|
28
|
+
|
|
29
|
+
function normalizeNodeId(input) {
|
|
30
|
+
const value = String(input || "").trim();
|
|
31
|
+
if (!value) return "";
|
|
32
|
+
return value.includes(":") ? value : value.replace(/-/g, ":");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cacheKeyFromItem(item) {
|
|
36
|
+
const fileKey = String(item && item.fileKey ? item.fileKey : "").trim();
|
|
37
|
+
const nodeId = String(item && item.nodeId ? item.nodeId : "").trim();
|
|
38
|
+
if (!fileKey || !nodeId) return "";
|
|
39
|
+
return `${fileKey}#${normalizeNodeId(nodeId)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveTargetAbs(rawTarget) {
|
|
43
|
+
const trimmed = String(rawTarget || "").trim();
|
|
44
|
+
if (!trimmed) return "";
|
|
45
|
+
return path.isAbsolute(trimmed) ? path.normalize(trimmed) : path.join(ROOT, trimmed);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sanitizeForPath(input) {
|
|
49
|
+
return String(input || "")
|
|
50
|
+
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_")
|
|
51
|
+
.replace(/\s+/g, "_");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ensureDir(dir) {
|
|
55
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function copyFileIfExists(src, dst) {
|
|
59
|
+
if (!fs.existsSync(src)) return false;
|
|
60
|
+
ensureDir(path.dirname(dst));
|
|
61
|
+
fs.copyFileSync(src, dst);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function copyDirIfExists(srcDir, dstDir) {
|
|
66
|
+
if (!fs.existsSync(srcDir)) return false;
|
|
67
|
+
ensureDir(dstDir);
|
|
68
|
+
fs.cpSync(srcDir, dstDir, { recursive: true });
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseArgs(argv) {
|
|
73
|
+
const out = {
|
|
74
|
+
batch: path.join(ROOT, "figma-e2e-batch.json"),
|
|
75
|
+
cacheRoot: path.join(ROOT, "figma-cache"),
|
|
76
|
+
reportsRuntime: path.join(ROOT, "figma-cache", "reports", "runtime"),
|
|
77
|
+
outRoot:
|
|
78
|
+
process.env.FIGMA_UI_ARTIFACTS_ROOT ||
|
|
79
|
+
path.join(ROOT, "figma-cache", "artifacts", "by-target"),
|
|
80
|
+
};
|
|
81
|
+
argv.slice(2).forEach((arg) => {
|
|
82
|
+
if (arg.startsWith("--batch=")) out.batch = arg.split("=").slice(1).join("=").trim();
|
|
83
|
+
if (arg.startsWith("--cache-root=")) out.cacheRoot = arg.split("=").slice(1).join("=").trim();
|
|
84
|
+
if (arg.startsWith("--out-root=")) out.outRoot = arg.split("=").slice(1).join("=").trim();
|
|
85
|
+
});
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function nodeDirFromCacheKey(cacheRootAbs, cacheKey) {
|
|
90
|
+
const [fileKey, nodeId] = String(cacheKey).split("#");
|
|
91
|
+
const safeNodeDir = String(nodeId || "").replace(/:/g, "-");
|
|
92
|
+
return path.join(cacheRootAbs, "files", fileKey, "nodes", safeNodeDir);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function main() {
|
|
96
|
+
const args = parseArgs(process.argv);
|
|
97
|
+
const batchAbs = path.isAbsolute(args.batch) ? args.batch : path.join(ROOT, args.batch);
|
|
98
|
+
const cacheRootAbs = path.isAbsolute(args.cacheRoot) ? args.cacheRoot : path.join(ROOT, args.cacheRoot);
|
|
99
|
+
const reportsRuntimeAbs = path.isAbsolute(args.reportsRuntime) ? args.reportsRuntime : path.join(ROOT, args.reportsRuntime);
|
|
100
|
+
|
|
101
|
+
if (!fs.existsSync(batchAbs)) {
|
|
102
|
+
console.error(`[archive-artifacts-from-batch] batch not found: ${batchAbs}`);
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
if (!fs.existsSync(cacheRootAbs)) {
|
|
106
|
+
console.error(`[archive-artifacts-from-batch] cache root not found: ${cacheRootAbs}`);
|
|
107
|
+
process.exit(2);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const payload = JSON.parse(fs.readFileSync(batchAbs, "utf8"));
|
|
111
|
+
if (!Array.isArray(payload) || payload.length === 0) {
|
|
112
|
+
console.error("[archive-artifacts-from-batch] batch must be a non-empty array");
|
|
113
|
+
process.exit(2);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let copiedCases = 0;
|
|
117
|
+
payload.forEach((item, idx) => {
|
|
118
|
+
const cacheKey = String(item && (item.cacheKey || cacheKeyFromItem(item)) || "").trim();
|
|
119
|
+
const targetAbs = resolveTargetAbs(item && item.target);
|
|
120
|
+
if (!cacheKey) {
|
|
121
|
+
console.error(`[archive-artifacts-from-batch] case[${idx}] missing cacheKey or (fileKey+nodeId)`);
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
124
|
+
if (!targetAbs) {
|
|
125
|
+
console.error(`[archive-artifacts-from-batch] case[${idx}] missing target`);
|
|
126
|
+
process.exit(2);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const outRootAbs = path.isAbsolute(args.outRoot) ? args.outRoot : path.join(ROOT, args.outRoot);
|
|
130
|
+
const targetRelDir = sanitizeForPath(
|
|
131
|
+
path.relative(ROOT, path.dirname(targetAbs)).replace(/\\/g, "/")
|
|
132
|
+
);
|
|
133
|
+
const artifactsRoot = path.join(outRootAbs, targetRelDir);
|
|
134
|
+
const caseDir = path.join(artifactsRoot, "figma-cache", sanitizeForPath(cacheKey));
|
|
135
|
+
|
|
136
|
+
const nodeDir = nodeDirFromCacheKey(cacheRootAbs, cacheKey);
|
|
137
|
+
if (!fs.existsSync(nodeDir)) {
|
|
138
|
+
console.error(`[archive-artifacts-from-batch] node dir not found for ${cacheKey}: ${nodeDir}`);
|
|
139
|
+
process.exit(2);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
copyFileIfExists(path.join(nodeDir, "meta.json"), path.join(caseDir, "meta.json"));
|
|
143
|
+
copyFileIfExists(path.join(nodeDir, "raw.json"), path.join(caseDir, "raw.json"));
|
|
144
|
+
copyFileIfExists(path.join(nodeDir, "spec.md"), path.join(caseDir, "spec.md"));
|
|
145
|
+
copyFileIfExists(path.join(nodeDir, "state-map.md"), path.join(caseDir, "state-map.md"));
|
|
146
|
+
copyDirIfExists(path.join(nodeDir, "mcp-raw"), path.join(caseDir, "mcp-raw"));
|
|
147
|
+
|
|
148
|
+
// Shared runtime reports snapshot
|
|
149
|
+
if (fs.existsSync(reportsRuntimeAbs)) {
|
|
150
|
+
copyDirIfExists(reportsRuntimeAbs, path.join(artifactsRoot, "reports", "runtime"));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
copiedCases += 1;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
console.log(`[archive-artifacts-from-batch] ok (${copiedCases} cases)`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main();
|
|
160
|
+
|