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,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Auto-link related cacheKeys into figma-cache/index.json flows using
|
|
6
|
+
* high-confidence heuristics based on raw.json.iconMetrics overlaps.
|
|
7
|
+
*
|
|
8
|
+
* Goal: reduce manual relatedCacheKeys maintenance and support "agent only mentions nodeId".
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/auto-link-related-from-batch.cjs --batch=./figma-e2e-batch.json
|
|
12
|
+
*
|
|
13
|
+
* Options:
|
|
14
|
+
* --flow=<flowId> (default: env FIGMA_DEFAULT_FLOW || "auto-related")
|
|
15
|
+
* --min-shared=<n> (default: 3) min shared iconMetrics nodeIds
|
|
16
|
+
* --min-shared-instance=<n> (default: 2) min shared instance-path nodeIds (contain ';')
|
|
17
|
+
* --min-jaccard=<ratio> (default: 0.35)
|
|
18
|
+
* --suggest-min-shared=<n> (default: 1) suggestion threshold (won't auto-link)
|
|
19
|
+
* --suggest-min-shared-instance=<n> (default: 1)
|
|
20
|
+
* --suggest-min-jaccard=<ratio> (default: 0.12)
|
|
21
|
+
* --suggest-out=<path> (default: figma-cache/reports/runtime/auto-related-suggestions.json)
|
|
22
|
+
* --promote-min-jaccard=<ratio> (default: 0.55) if met (with shared instance), promote to auto-link
|
|
23
|
+
* --promote-min-shared-instance=<n> (default: 2)
|
|
24
|
+
* --dry-run don't write index.json
|
|
25
|
+
*
|
|
26
|
+
* Strict mode:
|
|
27
|
+
* FIGMA_UI_AUTOLINK_STRICT=1 will exit(3) if suggestions are present.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require("fs");
|
|
31
|
+
const path = require("path");
|
|
32
|
+
|
|
33
|
+
const ROOT = process.cwd();
|
|
34
|
+
const INDEX_ABS = path.join(ROOT, "figma-cache", "index.json");
|
|
35
|
+
const DEFAULT_SUGGEST_OUT = path.join(
|
|
36
|
+
ROOT,
|
|
37
|
+
"figma-cache",
|
|
38
|
+
"reports",
|
|
39
|
+
"runtime",
|
|
40
|
+
"auto-related-suggestions.json"
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
function safeReadJson(abs) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(fs.readFileSync(abs, "utf8"));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeJson(abs, value) {
|
|
52
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
53
|
+
fs.writeFileSync(abs, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeNodeId(input) {
|
|
57
|
+
const value = String(input || "").trim();
|
|
58
|
+
if (!value) return "";
|
|
59
|
+
return value.includes(":") ? value : value.replace(/-/g, ":");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeCacheKey(input) {
|
|
63
|
+
const value = String(input || "").trim();
|
|
64
|
+
if (!value) return "";
|
|
65
|
+
const parts = value.split("#");
|
|
66
|
+
if (parts.length !== 2) return value;
|
|
67
|
+
return `${parts[0]}#${normalizeNodeId(parts[1])}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cacheKeyFromItem(item) {
|
|
71
|
+
const fileKey = String(item && item.fileKey ? item.fileKey : "").trim();
|
|
72
|
+
const nodeId = String(item && item.nodeId ? item.nodeId : "").trim();
|
|
73
|
+
if (!fileKey || !nodeId) return "";
|
|
74
|
+
return `${fileKey}#${normalizeNodeId(nodeId)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseArgs(argv) {
|
|
78
|
+
const out = {
|
|
79
|
+
batch: path.join(ROOT, "figma-e2e-batch.json"),
|
|
80
|
+
flowId: process.env.FIGMA_DEFAULT_FLOW || "auto-related",
|
|
81
|
+
minShared: 2,
|
|
82
|
+
minSharedInstance: 1,
|
|
83
|
+
minJaccard: 0.2,
|
|
84
|
+
suggestMinShared: 1,
|
|
85
|
+
suggestMinSharedInstance: 1,
|
|
86
|
+
suggestMinJaccard: 0.12,
|
|
87
|
+
suggestOut: process.env.FIGMA_UI_AUTOLINK_SUGGEST_OUT || DEFAULT_SUGGEST_OUT,
|
|
88
|
+
promoteMinJaccard: 0.55,
|
|
89
|
+
promoteMinSharedInstance: 2,
|
|
90
|
+
dryRun: false,
|
|
91
|
+
};
|
|
92
|
+
argv.slice(2).forEach((arg) => {
|
|
93
|
+
if (arg.startsWith("--batch=")) out.batch = arg.split("=").slice(1).join("=").trim();
|
|
94
|
+
if (arg.startsWith("--flow=")) out.flowId = arg.split("=").slice(1).join("=").trim();
|
|
95
|
+
if (arg.startsWith("--min-shared=")) out.minShared = Number(arg.split("=").slice(1).join("=").trim());
|
|
96
|
+
if (arg.startsWith("--min-shared-instance="))
|
|
97
|
+
out.minSharedInstance = Number(arg.split("=").slice(1).join("=").trim());
|
|
98
|
+
if (arg.startsWith("--min-jaccard=")) out.minJaccard = Number(arg.split("=").slice(1).join("=").trim());
|
|
99
|
+
if (arg.startsWith("--suggest-min-shared="))
|
|
100
|
+
out.suggestMinShared = Number(arg.split("=").slice(1).join("=").trim());
|
|
101
|
+
if (arg.startsWith("--suggest-min-shared-instance="))
|
|
102
|
+
out.suggestMinSharedInstance = Number(arg.split("=").slice(1).join("=").trim());
|
|
103
|
+
if (arg.startsWith("--suggest-min-jaccard="))
|
|
104
|
+
out.suggestMinJaccard = Number(arg.split("=").slice(1).join("=").trim());
|
|
105
|
+
if (arg.startsWith("--suggest-out=")) out.suggestOut = arg.split("=").slice(1).join("=").trim();
|
|
106
|
+
if (arg.startsWith("--promote-min-jaccard="))
|
|
107
|
+
out.promoteMinJaccard = Number(arg.split("=").slice(1).join("=").trim());
|
|
108
|
+
if (arg.startsWith("--promote-min-shared-instance="))
|
|
109
|
+
out.promoteMinSharedInstance = Number(arg.split("=").slice(1).join("=").trim());
|
|
110
|
+
if (arg === "--dry-run") out.dryRun = true;
|
|
111
|
+
});
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function setFromIconMetrics(raw) {
|
|
116
|
+
const list = raw && Array.isArray(raw.iconMetrics) ? raw.iconMetrics : [];
|
|
117
|
+
const all = new Set();
|
|
118
|
+
const instance = new Set();
|
|
119
|
+
list.forEach((m) => {
|
|
120
|
+
const id = m && m.nodeId ? String(m.nodeId).trim() : "";
|
|
121
|
+
if (!id) return;
|
|
122
|
+
all.add(id);
|
|
123
|
+
if (id.includes(";")) instance.add(id);
|
|
124
|
+
});
|
|
125
|
+
return { all, instance };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function intersectionSize(a, b) {
|
|
129
|
+
let c = 0;
|
|
130
|
+
a.forEach((x) => {
|
|
131
|
+
if (b.has(x)) c += 1;
|
|
132
|
+
});
|
|
133
|
+
return c;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function jaccard(a, b) {
|
|
137
|
+
const inter = intersectionSize(a, b);
|
|
138
|
+
const union = a.size + b.size - inter;
|
|
139
|
+
return union <= 0 ? 0 : inter / union;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function ensureFlow(index, flowId) {
|
|
143
|
+
index.flows = index.flows || {};
|
|
144
|
+
if (!index.flows[flowId]) {
|
|
145
|
+
index.flows[flowId] = {
|
|
146
|
+
id: flowId,
|
|
147
|
+
title: flowId,
|
|
148
|
+
description: "Auto-linked by iconMetrics overlap heuristics",
|
|
149
|
+
createdAt: new Date().toISOString(),
|
|
150
|
+
updatedAt: new Date().toISOString(),
|
|
151
|
+
nodes: [],
|
|
152
|
+
edges: [],
|
|
153
|
+
assumptions: [],
|
|
154
|
+
openQuestions: [],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return index.flows[flowId];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function addNode(flow, cacheKey) {
|
|
161
|
+
flow.nodes = flow.nodes || [];
|
|
162
|
+
if (!flow.nodes.includes(cacheKey)) flow.nodes.push(cacheKey);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function hasEdge(flow, from, to, type) {
|
|
166
|
+
const edges = Array.isArray(flow.edges) ? flow.edges : [];
|
|
167
|
+
return edges.some((e) => e && e.from === from && e.to === to && e.type === type);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function addEdge(flow, from, to, type, note) {
|
|
171
|
+
flow.edges = flow.edges || [];
|
|
172
|
+
if (hasEdge(flow, from, to, type)) return false;
|
|
173
|
+
flow.edges.push({
|
|
174
|
+
id: `${from}->${to}:${type}:${Date.now()}`,
|
|
175
|
+
from,
|
|
176
|
+
to,
|
|
177
|
+
type,
|
|
178
|
+
note: note || "",
|
|
179
|
+
createdAt: new Date().toISOString(),
|
|
180
|
+
});
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function main() {
|
|
185
|
+
const args = parseArgs(process.argv);
|
|
186
|
+
const batchAbs = path.isAbsolute(args.batch) ? args.batch : path.join(ROOT, args.batch);
|
|
187
|
+
const index = safeReadJson(INDEX_ABS);
|
|
188
|
+
if (!index || !index.items) {
|
|
189
|
+
console.error(`[auto-link-related-from-batch] missing/invalid index: ${INDEX_ABS}`);
|
|
190
|
+
process.exit(2);
|
|
191
|
+
}
|
|
192
|
+
if (!fs.existsSync(batchAbs)) {
|
|
193
|
+
console.error(`[auto-link-related-from-batch] batch not found: ${batchAbs}`);
|
|
194
|
+
process.exit(2);
|
|
195
|
+
}
|
|
196
|
+
const batch = JSON.parse(fs.readFileSync(batchAbs, "utf8"));
|
|
197
|
+
if (!Array.isArray(batch) || batch.length === 0) {
|
|
198
|
+
console.error("[auto-link-related-from-batch] batch must be a non-empty array");
|
|
199
|
+
process.exit(2);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const flow = ensureFlow(index, args.flowId);
|
|
203
|
+
let links = 0;
|
|
204
|
+
const suggestions = [];
|
|
205
|
+
|
|
206
|
+
batch.forEach((b) => {
|
|
207
|
+
const primary = normalizeCacheKey(String(b && (b.cacheKey || cacheKeyFromItem(b)) || ""));
|
|
208
|
+
if (!primary) return;
|
|
209
|
+
const primaryItem = index.items[primary];
|
|
210
|
+
if (!primaryItem || !primaryItem.paths || !primaryItem.paths.raw) return;
|
|
211
|
+
const primaryRawAbs = path.isAbsolute(primaryItem.paths.raw)
|
|
212
|
+
? primaryItem.paths.raw
|
|
213
|
+
: path.join(ROOT, primaryItem.paths.raw);
|
|
214
|
+
const primaryRaw = safeReadJson(primaryRawAbs);
|
|
215
|
+
const primarySet = setFromIconMetrics(primaryRaw);
|
|
216
|
+
if (primarySet.all.size === 0) return;
|
|
217
|
+
|
|
218
|
+
const fileKey = String(primaryItem.fileKey || "").trim();
|
|
219
|
+
const candidates = Object.keys(index.items)
|
|
220
|
+
.filter((k) => k !== primary && index.items[k] && index.items[k].fileKey === fileKey)
|
|
221
|
+
.map((k) => ({ key: k, item: index.items[k] }));
|
|
222
|
+
|
|
223
|
+
candidates.forEach((cand) => {
|
|
224
|
+
const rawPath = cand.item && cand.item.paths ? cand.item.paths.raw : "";
|
|
225
|
+
if (!rawPath) return;
|
|
226
|
+
const rawAbs = path.isAbsolute(rawPath) ? rawPath : path.join(ROOT, rawPath);
|
|
227
|
+
const raw = safeReadJson(rawAbs);
|
|
228
|
+
const set = setFromIconMetrics(raw);
|
|
229
|
+
if (set.all.size === 0) return;
|
|
230
|
+
|
|
231
|
+
const shared = intersectionSize(primarySet.all, set.all);
|
|
232
|
+
const sharedInstance = intersectionSize(primarySet.instance, set.instance);
|
|
233
|
+
const score = jaccard(primarySet.all, set.all);
|
|
234
|
+
|
|
235
|
+
const promote =
|
|
236
|
+
sharedInstance >= args.promoteMinSharedInstance &&
|
|
237
|
+
score >= args.promoteMinJaccard;
|
|
238
|
+
|
|
239
|
+
const ok =
|
|
240
|
+
promote ||
|
|
241
|
+
(shared >= args.minShared &&
|
|
242
|
+
sharedInstance >= args.minSharedInstance &&
|
|
243
|
+
score >= args.minJaccard);
|
|
244
|
+
if (!ok) {
|
|
245
|
+
const suggestOk =
|
|
246
|
+
shared >= args.suggestMinShared &&
|
|
247
|
+
sharedInstance >= args.suggestMinSharedInstance &&
|
|
248
|
+
score >= args.suggestMinJaccard;
|
|
249
|
+
if (suggestOk) {
|
|
250
|
+
suggestions.push({
|
|
251
|
+
from: primary,
|
|
252
|
+
to: cand.key,
|
|
253
|
+
fileKey,
|
|
254
|
+
shared,
|
|
255
|
+
sharedInstance,
|
|
256
|
+
jaccard: Number(score.toFixed(4)),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
addNode(flow, primary);
|
|
263
|
+
addNode(flow, cand.key);
|
|
264
|
+
const note = `auto: shared=${shared}, sharedInstance=${sharedInstance}, jaccard=${Number(
|
|
265
|
+
score.toFixed(3)
|
|
266
|
+
)}`;
|
|
267
|
+
if (addEdge(flow, primary, cand.key, "related_auto", note)) {
|
|
268
|
+
links += 1;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
flow.updatedAt = new Date().toISOString();
|
|
274
|
+
index.updatedAt = new Date().toISOString();
|
|
275
|
+
|
|
276
|
+
if (!args.dryRun) {
|
|
277
|
+
writeJson(INDEX_ABS, index);
|
|
278
|
+
}
|
|
279
|
+
const suggestOutAbs = path.isAbsolute(args.suggestOut) ? args.suggestOut : path.join(ROOT, args.suggestOut);
|
|
280
|
+
writeJson(suggestOutAbs, {
|
|
281
|
+
generatedAt: new Date().toISOString(),
|
|
282
|
+
flowId: args.flowId,
|
|
283
|
+
strict: process.env.FIGMA_UI_AUTOLINK_STRICT === "1",
|
|
284
|
+
thresholds: {
|
|
285
|
+
auto: { minShared: args.minShared, minSharedInstance: args.minSharedInstance, minJaccard: args.minJaccard },
|
|
286
|
+
promote: {
|
|
287
|
+
minSharedInstance: args.promoteMinSharedInstance,
|
|
288
|
+
minJaccard: args.promoteMinJaccard,
|
|
289
|
+
},
|
|
290
|
+
suggest: {
|
|
291
|
+
minShared: args.suggestMinShared,
|
|
292
|
+
minSharedInstance: args.suggestMinSharedInstance,
|
|
293
|
+
minJaccard: args.suggestMinJaccard,
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
links,
|
|
297
|
+
suggestions,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
console.log(
|
|
301
|
+
`[auto-link-related-from-batch] ok links=${links} suggestions=${suggestions.length} flow=${args.flowId} dryRun=${args.dryRun ? "1" : "0"} suggestOut=${suggestOutAbs}`
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
if (process.env.FIGMA_UI_AUTOLINK_STRICT === "1" && suggestions.length) {
|
|
305
|
+
process.exit(3);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
main();
|
|
310
|
+
|
|
@@ -16,6 +16,21 @@ function resolveMaybeAbsolutePath(input) {
|
|
|
16
16
|
return path.isAbsolute(input) ? path.normalize(input) : path.join(ROOT, input);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** Batch / CLI `target` paths are relative to the target business project root (not toolchain cwd). */
|
|
20
|
+
function resolveTargetInProject(rawTarget, targetProject) {
|
|
21
|
+
if (!rawTarget) {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
const trimmed = String(rawTarget).trim();
|
|
25
|
+
if (!trimmed) {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
if (path.isAbsolute(trimmed)) {
|
|
29
|
+
return path.normalize(trimmed);
|
|
30
|
+
}
|
|
31
|
+
return path.join(targetProject, trimmed);
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
function parseArgs(argv) {
|
|
20
35
|
const options = {
|
|
21
36
|
targetProject: "",
|
|
@@ -31,7 +46,8 @@ function parseArgs(argv) {
|
|
|
31
46
|
autoBootstrapContract: true,
|
|
32
47
|
autoEnsureOnMiss: false,
|
|
33
48
|
allowSkeletonWithFigmaMcp: false,
|
|
34
|
-
|
|
49
|
+
// Default to "best effort" evidence to support 1:1 audits out-of-the-box.
|
|
50
|
+
completeness: "layout,text,tokens,assets,interactions,states,accessibility",
|
|
35
51
|
batchFile: "",
|
|
36
52
|
fixLoop: 0,
|
|
37
53
|
emitAgentTaskOnFail: false,
|
|
@@ -328,7 +344,7 @@ function runSingleCase(input, context) {
|
|
|
328
344
|
if (!cacheKey) {
|
|
329
345
|
throw new Error("batch item missing cacheKey or (fileKey + nodeId)");
|
|
330
346
|
}
|
|
331
|
-
const targetPath = item.target ?
|
|
347
|
+
const targetPath = item.target ? resolveTargetInProject(item.target, targetProject) : "";
|
|
332
348
|
if (!targetPath) {
|
|
333
349
|
throw new Error(`batch item ${cacheKey} missing target path`);
|
|
334
350
|
}
|
|
@@ -426,7 +442,7 @@ function run() {
|
|
|
426
442
|
}
|
|
427
443
|
const isBatchMode = !!options.batchFile;
|
|
428
444
|
if (!isBatchMode) {
|
|
429
|
-
const targetPath = options.target ?
|
|
445
|
+
const targetPath = options.target ? resolveTargetInProject(options.target, targetProject) : "";
|
|
430
446
|
if (!targetPath) {
|
|
431
447
|
console.error("cross-project-e2e failed: --target is required for real component validation");
|
|
432
448
|
process.exit(FAIL_EXIT_CODE);
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Toolchain-provided forbidden markup check.
|
|
6
|
+
* Projects can call this script directly from node_modules to hard-fail on disallowed UI patterns.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
|
|
12
|
+
const ROOT = process.cwd();
|
|
13
|
+
|
|
14
|
+
const DEFAULT_CONSTRAINTS_PATH = path.join(ROOT, "ui-hard-constraints.json");
|
|
15
|
+
const DEFAULT_POLICY_PATH = path.join(ROOT, "ui-policy.json");
|
|
16
|
+
const DEFAULT_PLATFORM = "web-vue";
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const out = {
|
|
20
|
+
batch: path.join(ROOT, "figma-e2e-batch.json"),
|
|
21
|
+
files: [],
|
|
22
|
+
constraints: DEFAULT_CONSTRAINTS_PATH,
|
|
23
|
+
policy: DEFAULT_POLICY_PATH,
|
|
24
|
+
platform: DEFAULT_PLATFORM,
|
|
25
|
+
adapter: "",
|
|
26
|
+
};
|
|
27
|
+
argv.slice(2).forEach((arg) => {
|
|
28
|
+
if (arg.startsWith("--batch=")) {
|
|
29
|
+
out.batch = arg.split("=").slice(1).join("=").trim();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (arg.startsWith("--file=")) {
|
|
33
|
+
out.files.push(arg.split("=").slice(1).join("=").trim());
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (arg.startsWith("--constraints=")) {
|
|
37
|
+
out.constraints = arg.split("=").slice(1).join("=").trim();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (arg.startsWith("--policy=")) {
|
|
41
|
+
out.policy = arg.split("=").slice(1).join("=").trim();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (arg.startsWith("--platform=")) {
|
|
45
|
+
out.platform = arg.split("=").slice(1).join("=").trim();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (arg.startsWith("--adapter=")) {
|
|
49
|
+
out.adapter = arg.split("=").slice(1).join("=").trim();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readJsonIfExists(absPath) {
|
|
57
|
+
if (!absPath) return null;
|
|
58
|
+
if (!fs.existsSync(absPath)) return null;
|
|
59
|
+
return JSON.parse(fs.readFileSync(absPath, "utf8"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveAdapterPath(platform, adapterArg) {
|
|
63
|
+
if (adapterArg) return adapterArg;
|
|
64
|
+
const file = `ui-adapter.${platform}.json`;
|
|
65
|
+
return path.join(ROOT, file);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizePrimitiveDetectors(raw) {
|
|
69
|
+
if (!raw)
|
|
70
|
+
return { forbiddenTags: [], forbiddenAttrPrefixes: [], forbiddenAttrNames: [], forbiddenPatterns: [] };
|
|
71
|
+
const forbiddenTags = Array.isArray(raw.forbiddenTags) ? raw.forbiddenTags.map(String) : [];
|
|
72
|
+
const forbiddenAttrPrefixes = Array.isArray(raw.forbiddenAttrPrefixes)
|
|
73
|
+
? raw.forbiddenAttrPrefixes.map(String)
|
|
74
|
+
: [];
|
|
75
|
+
const forbiddenAttrNames = Array.isArray(raw.forbiddenAttrNames) ? raw.forbiddenAttrNames.map(String) : [];
|
|
76
|
+
const forbiddenPatterns = Array.isArray(raw.forbiddenPatterns) ? raw.forbiddenPatterns : [];
|
|
77
|
+
return { forbiddenTags, forbiddenAttrPrefixes, forbiddenAttrNames, forbiddenPatterns };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function compileConstraintsFromPolicy(policyRaw, adapterRaw) {
|
|
81
|
+
const policy = policyRaw || {};
|
|
82
|
+
const forbiddenPrimitives = Array.isArray(policy.forbiddenPrimitives)
|
|
83
|
+
? policy.forbiddenPrimitives.map(String)
|
|
84
|
+
: [];
|
|
85
|
+
const primitives = (adapterRaw && adapterRaw.primitives) || {};
|
|
86
|
+
|
|
87
|
+
const acc = {
|
|
88
|
+
forbiddenTags: [],
|
|
89
|
+
forbiddenAttrPrefixes: [],
|
|
90
|
+
forbiddenAttrNames: [],
|
|
91
|
+
forbiddenPatterns: [],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
forbiddenPrimitives.forEach((primitive) => {
|
|
95
|
+
const detectors = normalizePrimitiveDetectors(primitives[primitive]);
|
|
96
|
+
acc.forbiddenTags.push(...detectors.forbiddenTags);
|
|
97
|
+
acc.forbiddenAttrPrefixes.push(...detectors.forbiddenAttrPrefixes);
|
|
98
|
+
acc.forbiddenAttrNames.push(...detectors.forbiddenAttrNames);
|
|
99
|
+
acc.forbiddenPatterns.push(...detectors.forbiddenPatterns);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
acc.forbiddenTags = Array.from(new Set(acc.forbiddenTags));
|
|
103
|
+
acc.forbiddenAttrPrefixes = Array.from(new Set(acc.forbiddenAttrPrefixes));
|
|
104
|
+
acc.forbiddenAttrNames = Array.from(new Set(acc.forbiddenAttrNames));
|
|
105
|
+
acc.forbiddenPatterns = acc.forbiddenPatterns.filter(Boolean);
|
|
106
|
+
|
|
107
|
+
return acc;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeConstraints(raw) {
|
|
111
|
+
const g = (raw && raw.global) || raw || {};
|
|
112
|
+
const forbiddenTags = Array.isArray(g.forbiddenTags) ? g.forbiddenTags.map(String) : [];
|
|
113
|
+
const forbiddenAttrPrefixes = Array.isArray(g.forbiddenAttrPrefixes)
|
|
114
|
+
? g.forbiddenAttrPrefixes.map(String)
|
|
115
|
+
: [];
|
|
116
|
+
const forbiddenAttrNames = Array.isArray(g.forbiddenAttrNames) ? g.forbiddenAttrNames.map(String) : [];
|
|
117
|
+
const forbiddenPatterns = Array.isArray(g.forbiddenPatterns) ? g.forbiddenPatterns : [];
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
forbiddenTags,
|
|
121
|
+
forbiddenAttrPrefixes,
|
|
122
|
+
forbiddenAttrNames,
|
|
123
|
+
forbiddenPatterns: forbiddenPatterns
|
|
124
|
+
.map((x) => ({
|
|
125
|
+
id: String(x && x.id ? x.id : "pattern"),
|
|
126
|
+
re: new RegExp(String(x && x.pattern ? x.pattern : ""), "i"),
|
|
127
|
+
}))
|
|
128
|
+
.filter((x) => x.re && String(x.re) !== String(/(?:)/i)),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function mergeConstraints(base, override) {
|
|
133
|
+
if (!override) return base;
|
|
134
|
+
const next = { ...base };
|
|
135
|
+
if (Array.isArray(override.forbiddenTags)) next.forbiddenTags = override.forbiddenTags.map(String);
|
|
136
|
+
if (Array.isArray(override.forbiddenAttrPrefixes))
|
|
137
|
+
next.forbiddenAttrPrefixes = override.forbiddenAttrPrefixes.map(String);
|
|
138
|
+
if (Array.isArray(override.forbiddenAttrNames)) next.forbiddenAttrNames = override.forbiddenAttrNames.map(String);
|
|
139
|
+
if (Array.isArray(override.forbiddenPatterns)) {
|
|
140
|
+
next.forbiddenPatterns = override.forbiddenPatterns
|
|
141
|
+
.map((x) => ({
|
|
142
|
+
id: String(x && x.id ? x.id : "pattern"),
|
|
143
|
+
re: new RegExp(String(x && x.pattern ? x.pattern : ""), "i"),
|
|
144
|
+
}))
|
|
145
|
+
.filter((x) => x.re && String(x.re) !== String(/(?:)/i));
|
|
146
|
+
}
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readBatchTargets(batchPath) {
|
|
151
|
+
const abs = path.isAbsolute(batchPath) ? batchPath : path.join(ROOT, batchPath);
|
|
152
|
+
if (!fs.existsSync(abs)) throw new Error(`batch file missing: ${abs}`);
|
|
153
|
+
const payload = JSON.parse(fs.readFileSync(abs, "utf8"));
|
|
154
|
+
if (!Array.isArray(payload) || payload.length === 0) {
|
|
155
|
+
throw new Error("batch file must contain at least one item");
|
|
156
|
+
}
|
|
157
|
+
return payload.map((item, idx) => {
|
|
158
|
+
const target = String(item && item.target ? item.target : "").trim();
|
|
159
|
+
if (!target) throw new Error(`case[${idx}] missing target`);
|
|
160
|
+
const absTarget = path.isAbsolute(target) ? path.normalize(target) : path.join(ROOT, target);
|
|
161
|
+
const constraintsOverride = item && item.constraints ? item.constraints : null;
|
|
162
|
+
const policyOverride = item && item.policy ? item.policy : null;
|
|
163
|
+
return { absTarget, constraintsOverride, policyOverride };
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function applyPolicyOverride(basePolicy, policyOverride) {
|
|
168
|
+
if (!policyOverride) return basePolicy;
|
|
169
|
+
const next = { ...(basePolicy || {}) };
|
|
170
|
+
const baseForbidden = Array.isArray(next.forbiddenPrimitives) ? next.forbiddenPrimitives.map(String) : [];
|
|
171
|
+
const addForbidden = Array.isArray(policyOverride.forbiddenPrimitives)
|
|
172
|
+
? policyOverride.forbiddenPrimitives.map(String)
|
|
173
|
+
: [];
|
|
174
|
+
const allow = Array.isArray(policyOverride.allowPrimitives) ? policyOverride.allowPrimitives.map(String) : [];
|
|
175
|
+
|
|
176
|
+
const merged = Array.from(new Set([...baseForbidden, ...addForbidden]));
|
|
177
|
+
next.forbiddenPrimitives = merged.filter((x) => !allow.includes(x));
|
|
178
|
+
return next;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function scanFile(absPath, constraints) {
|
|
182
|
+
const content = fs.readFileSync(absPath, "utf8");
|
|
183
|
+
const violations = [];
|
|
184
|
+
|
|
185
|
+
constraints.forbiddenTags.forEach((tag) => {
|
|
186
|
+
const re = new RegExp(`<\\s*${tag}(\\s|>|/)`, "gi");
|
|
187
|
+
if (re.test(content)) violations.push(`forbidden tag: <${tag}>`);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
constraints.forbiddenAttrPrefixes.forEach((prefix) => {
|
|
191
|
+
const re = new RegExp(`\\s${prefix}[a-z0-9_-]+\\s*=`, "gi");
|
|
192
|
+
if (re.test(content)) violations.push(`forbidden attr prefix: ${prefix}*`);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
constraints.forbiddenAttrNames.forEach((name) => {
|
|
196
|
+
const re = new RegExp(`\\s${name}\\s*=`, "gi");
|
|
197
|
+
if (re.test(content)) violations.push(`forbidden attr: ${name}`);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
constraints.forbiddenPatterns.forEach((item) => {
|
|
201
|
+
if (item.re.test(content)) violations.push(`forbidden pattern: ${item.id}`);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Require cursor-pointer on interactive elements (Vue template heuristic).
|
|
205
|
+
const clickLike =
|
|
206
|
+
/<([a-zA-Z][\w-]*)([^>]*)(@click|@pointerdown|@mousedown|@mouseup)\s*=\s*["'][^"']+["']([^>]*)>/g;
|
|
207
|
+
let match = null;
|
|
208
|
+
while ((match = clickLike.exec(content))) {
|
|
209
|
+
const tag = String(match[1] || "");
|
|
210
|
+
const attrs = `${match[2] || ""}${match[4] || ""}`;
|
|
211
|
+
if (!/cursor-pointer/.test(attrs)) {
|
|
212
|
+
violations.push(`missing cursor-pointer on interactive <${tag}>`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Avoid icon distortion: forbid icon-like img stretching combo.
|
|
217
|
+
const imgSizeFullIconLike =
|
|
218
|
+
/<img[^>]*class\s*=\s*["'][^"']*\bmax-w-none\b[^"']*\binset-0\b[^"']*\bsize-full\b[^"']*["'][^>]*>/gi;
|
|
219
|
+
if (imgSizeFullIconLike.test(content)) {
|
|
220
|
+
violations.push("forbidden icon img classes: max-w-none + inset-0 + size-full (causes icon stretching)");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return violations;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function main() {
|
|
227
|
+
const args = parseArgs(process.argv);
|
|
228
|
+
|
|
229
|
+
const policyPath = path.isAbsolute(args.policy) ? args.policy : path.join(ROOT, args.policy);
|
|
230
|
+
const adapterPathRaw = resolveAdapterPath(args.platform, args.adapter);
|
|
231
|
+
const adapterPath = path.isAbsolute(adapterPathRaw) ? adapterPathRaw : path.join(ROOT, adapterPathRaw);
|
|
232
|
+
|
|
233
|
+
const policyRaw = readJsonIfExists(policyPath);
|
|
234
|
+
const adapterRaw = readJsonIfExists(adapterPath);
|
|
235
|
+
const canUsePolicy = Boolean(policyRaw && adapterRaw);
|
|
236
|
+
|
|
237
|
+
const constraintsPath = path.isAbsolute(args.constraints) ? args.constraints : path.join(ROOT, args.constraints);
|
|
238
|
+
const legacyConstraintsRaw = readJsonIfExists(constraintsPath) || {
|
|
239
|
+
global: {
|
|
240
|
+
forbiddenTags: ["button", "p", "ul", "li"],
|
|
241
|
+
forbiddenAttrPrefixes: ["aria-"],
|
|
242
|
+
forbiddenAttrNames: ["role", "tabindex"],
|
|
243
|
+
forbiddenPatterns: [
|
|
244
|
+
{ id: "custom scrollbar wrapper: scrollbar-hint", pattern: "\\bscrollbar-hint\\b" },
|
|
245
|
+
{ id: "custom scrollbar wrapper: hide-native-scrollbar", pattern: "\\bhide-native-scrollbar\\b" },
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const globalConstraints = canUsePolicy
|
|
251
|
+
? normalizeConstraints(compileConstraintsFromPolicy(policyRaw, adapterRaw))
|
|
252
|
+
: normalizeConstraints(legacyConstraintsRaw);
|
|
253
|
+
|
|
254
|
+
const explicitFiles = (args.files || [])
|
|
255
|
+
.map((p) => (path.isAbsolute(p) ? p : path.join(ROOT, p)))
|
|
256
|
+
.filter(Boolean);
|
|
257
|
+
|
|
258
|
+
const batchItems = explicitFiles.length
|
|
259
|
+
? explicitFiles.map((absTarget) => ({ absTarget, constraintsOverride: null, policyOverride: null }))
|
|
260
|
+
: readBatchTargets(args.batch);
|
|
261
|
+
|
|
262
|
+
const missing = batchItems.map((x) => x.absTarget).filter((p) => !fs.existsSync(p));
|
|
263
|
+
if (missing.length) {
|
|
264
|
+
console.error("[forbidden-markup-check] missing target files:");
|
|
265
|
+
missing.forEach((p) => console.error(`- ${p}`));
|
|
266
|
+
process.exit(2);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const allViolations = [];
|
|
270
|
+
batchItems.forEach((item) => {
|
|
271
|
+
const compiledForCase = canUsePolicy
|
|
272
|
+
? normalizeConstraints(compileConstraintsFromPolicy(applyPolicyOverride(policyRaw, item.policyOverride), adapterRaw))
|
|
273
|
+
: globalConstraints;
|
|
274
|
+
const effective = mergeConstraints(compiledForCase, item.constraintsOverride);
|
|
275
|
+
const violations = scanFile(item.absTarget, effective);
|
|
276
|
+
if (violations.length) {
|
|
277
|
+
allViolations.push({ file: item.absTarget, violations });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (allViolations.length) {
|
|
282
|
+
console.error("[forbidden-markup-check] FAILED. Found forbidden markup.");
|
|
283
|
+
console.error("Rules:");
|
|
284
|
+
console.error(`- tags: ${globalConstraints.forbiddenTags.map((t) => `<${t}>`).join(", ")}`);
|
|
285
|
+
console.error(
|
|
286
|
+
`- attrs: ${globalConstraints.forbiddenAttrNames.join(", ")}, ${globalConstraints.forbiddenAttrPrefixes.join("")}*`
|
|
287
|
+
);
|
|
288
|
+
console.error("");
|
|
289
|
+
allViolations.forEach((item) => {
|
|
290
|
+
console.error(item.file);
|
|
291
|
+
item.violations.forEach((v) => console.error(` - ${v}`));
|
|
292
|
+
});
|
|
293
|
+
process.exit(2);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
console.log("[forbidden-markup-check] ok");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
main();
|
|
300
|
+
|