@mmerterden/multi-agent-toolkit-mcp 3.13.1 → 3.15.0
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/CHANGELOG.md +192 -5
- package/README.md +80 -8
- package/README.tr.md +104 -8
- package/index.js +526 -162
- package/package.json +4 -4
- package/tools/context/index.js +34 -18
- package/tools/design-check/component-walk.js +359 -0
- package/tools/design-check/content-cardinality.js +3 -3
- package/tools/design-check/index.js +78 -11
- package/tools/design-check/report.js +152 -10
- package/tools/design-check/scan.js +1 -1
- package/tools/design-check/scenario-inventory.js +404 -50
- package/tools/design-check/visual-compare.js +17 -2
- package/tools/ios-app-store-audit/context.js +3 -3
- package/tools/ios-app-store-audit/exec.js +17 -0
- package/tools/ios-app-store-audit/index.js +0 -15
- package/tools/ios-app-store-audit/rules/code-signing.js +2 -2
- package/tools/ios-app-store-audit/rules/dead-reference.js +2 -2
- package/tools/ios-app-store-audit/rules/debug-tool-leak.js +2 -2
- package/tools/ios-app-store-audit/rules/embedded-sdk.js +3 -3
- package/tools/ios-app-store-audit/rules/extension-signing.js +2 -2
- package/tools/ios-app-store-audit/rules/ipv6-compliance.js +2 -2
- package/tools/ios-app-store-audit/rules/production-hygiene.js +2 -2
- package/tools/ios-app-store-audit/rules/provisioning-profile.js +3 -3
- package/tools/ios-app-store-audit/rules/required-reason-api.js +2 -2
- package/tools/offload/index.js +7 -3
- package/tools/policy/egress-proxy.js +268 -0
- package/tools/policy/index.js +283 -0
- package/tools/security/cvss.js +108 -0
- package/tools/security/deps.js +0 -0
- package/tools/security/index.js +115 -0
- package/tools/spawn-collect/index.js +141 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-toolkit-mcp",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "MCP server for iOS Simulator, Android Emulator and headless web control.
|
|
3
|
+
"version": "3.15.0",
|
|
4
|
+
"description": "MCP server for iOS Simulator, Android Emulator and headless web control. 118 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance, offline CVSS scoring + dependency inventory. Runs standalone over stdio with any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"start": "node index.js",
|
|
13
|
-
"test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/code-intel/__tests__/code-intel.test.mjs tools/pass-kit/__tests__/pass-kit.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/a11y/__tests__/a11y.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/memory/__tests__/memory.test.mjs tools/offload/__tests__/offload.test.mjs tools/context/__tests__/context.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
|
|
13
|
+
"test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/design-check/__tests__/design-targets.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/code-intel/__tests__/code-intel.test.mjs tools/pass-kit/__tests__/pass-kit.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/a11y/__tests__/a11y.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/memory/__tests__/memory.test.mjs tools/offload/__tests__/offload.test.mjs tools/context/__tests__/context.test.mjs tools/security/__tests__/security.test.mjs tools/policy/__tests__/policy.test.mjs tools/spawn-collect/__tests__/spawn-collect.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs __tests__/hardening.test.mjs",
|
|
14
14
|
"gates": "bash scripts/gates.sh"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"url": "https://github.com/mmerterden/multi-agent-toolkit-mcp/issues"
|
|
63
63
|
},
|
|
64
64
|
"engines": {
|
|
65
|
-
"node": ">=
|
|
65
|
+
"node": ">=22.13.0"
|
|
66
66
|
},
|
|
67
67
|
"files": [
|
|
68
68
|
"index.js",
|
package/tools/context/index.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// right 60 lines and reading the first 60 that happen to contain the word.
|
|
9
9
|
//
|
|
10
10
|
// So the same file is also indexed: chunked, stored in SQLite FTS5, and ranked
|
|
11
|
-
// with bm25(). node:sqlite ships with Node, so this costs
|
|
11
|
+
// with bm25(). node:sqlite ships with Node (unflagged from 22.13), so this costs
|
|
12
|
+
// no dependency.
|
|
12
13
|
//
|
|
13
14
|
// Three layers, cheapest first:
|
|
14
15
|
// context_search ranked snippets, ~50-100 tokens, enough to decide
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
|
|
21
22
|
import { DatabaseSync } from "node:sqlite";
|
|
22
23
|
import { createHash } from "node:crypto";
|
|
23
|
-
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
24
25
|
import { dirname, join } from "node:path";
|
|
25
26
|
import { homedir } from "node:os";
|
|
26
27
|
|
|
@@ -41,8 +42,13 @@ function dbPathFor(dir) {
|
|
|
41
42
|
return join(dir, "context.db");
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
// Owner-only, like the offload directory whose payloads it indexes. A
|
|
46
|
+
// directory that already exists is narrowed as well: mkdir's mode applies only
|
|
47
|
+
// to what it creates, and an index left 0755 by an older install or another
|
|
48
|
+
// tool would expose every indexed passage to other accounts.
|
|
44
49
|
export function openIndex(dir = INDEX_DIR) {
|
|
45
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
50
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
51
|
+
chmodSync(dir, 0o700);
|
|
46
52
|
const db = new DatabaseSync(dbPathFor(dir));
|
|
47
53
|
db.exec(`
|
|
48
54
|
create table if not exists docs (
|
|
@@ -86,22 +92,32 @@ export function indexFile(db, path) {
|
|
|
86
92
|
const n = db.prepare("select count(*) as n from chunks where doc_id = ?").get(existing.id);
|
|
87
93
|
return { indexed: false, reason: "unchanged", docId: existing.id, chunks: n?.n ?? 0 };
|
|
88
94
|
}
|
|
89
|
-
if (existing) {
|
|
90
|
-
db.prepare("delete from chunks where doc_id = ?").run(existing.id);
|
|
91
|
-
db.prepare("delete from docs where id = ?").run(existing.id);
|
|
92
|
-
}
|
|
93
|
-
db.prepare("insert into docs (path, mtime, size) values (?, ?, ?)").run(
|
|
94
|
-
path,
|
|
95
|
-
Math.floor(st.mtimeMs),
|
|
96
|
-
st.size,
|
|
97
|
-
);
|
|
98
|
-
const docId = db.prepare("select id from docs where path = ?").get(path).id;
|
|
99
|
-
const insert = db.prepare(
|
|
100
|
-
"insert into chunks (body, doc_id, first_line, last_line) values (?, ?, ?, ?)",
|
|
101
|
-
);
|
|
102
95
|
const pieces = chunk(readFileSync(path, "utf8"));
|
|
103
|
-
for
|
|
104
|
-
|
|
96
|
+
// One transaction for the doc row and all of its chunks. Committed row by
|
|
97
|
+
// row, a failure partway leaves a doc row whose (mtime, size) matches the
|
|
98
|
+
// file, and every later call answers "unchanged" over a partial set of chunks.
|
|
99
|
+
db.exec("begin");
|
|
100
|
+
try {
|
|
101
|
+
if (existing) {
|
|
102
|
+
db.prepare("delete from chunks where doc_id = ?").run(existing.id);
|
|
103
|
+
db.prepare("delete from docs where id = ?").run(existing.id);
|
|
104
|
+
}
|
|
105
|
+
db.prepare("insert into docs (path, mtime, size) values (?, ?, ?)").run(
|
|
106
|
+
path,
|
|
107
|
+
Math.floor(st.mtimeMs),
|
|
108
|
+
st.size,
|
|
109
|
+
);
|
|
110
|
+
const docId = db.prepare("select id from docs where path = ?").get(path).id;
|
|
111
|
+
const insert = db.prepare(
|
|
112
|
+
"insert into chunks (body, doc_id, first_line, last_line) values (?, ?, ?, ?)",
|
|
113
|
+
);
|
|
114
|
+
for (const p of pieces) insert.run(p.body, docId, p.firstLine, p.lastLine);
|
|
115
|
+
db.exec("commit");
|
|
116
|
+
return { indexed: true, docId, chunks: pieces.length };
|
|
117
|
+
} catch (e) {
|
|
118
|
+
db.exec("rollback");
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
// FTS5 treats a bare `-` or an unbalanced quote as syntax, and a caller typing
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design_component_variants - resolve a design node to the component set it
|
|
3
|
+
* belongs to and list that set's variants, so a live component can be compared
|
|
4
|
+
* against its own variant instead of against a screen frame.
|
|
5
|
+
*
|
|
6
|
+
* A screen frame carries whatever content its mock fixture had (five rows in the
|
|
7
|
+
* design, three in the app), so every container height and every position below
|
|
8
|
+
* it differs for a reason that is not a defect. A component set already models
|
|
9
|
+
* those states as variants (`Rows=3`, `State=Error`), and comparing the live
|
|
10
|
+
* component region against the matching variant removes the fixture dimension.
|
|
11
|
+
*
|
|
12
|
+
* Nothing is fetched here. The caller supplies what it already fetched from
|
|
13
|
+
* Figma, per file:
|
|
14
|
+
* - `nodes` a REST GET /v1/files/:key/nodes response
|
|
15
|
+
* ({ nodes: { <id>: { document, components, componentSets } } }),
|
|
16
|
+
* - `metadata` or MCP get_metadata XML, whose x/y are relative to the parent
|
|
17
|
+
* plus, optionally, the library's GET /v1/files/:key/component_sets listing,
|
|
18
|
+
* which is how a set living in another file is located. When a node the walk
|
|
19
|
+
* needs is not in any supplied source, it is returned in `needs[]` for the
|
|
20
|
+
* caller to fetch, rather than guessed.
|
|
21
|
+
*
|
|
22
|
+
* Output per requested node:
|
|
23
|
+
* { requested, resolvedVia: "instance"|"component"|"component-set", instanceOf?,
|
|
24
|
+
* component, componentSet: { nodeId, name, fileKey }, codeConnect,
|
|
25
|
+
* properties: { <prop>: [values] },
|
|
26
|
+
* variants: [ { name, nodeId, fileKey, properties, frame:{w,h}, spec[],
|
|
27
|
+
* render:{ fileKey, nodeId, format, scale }, image } ],
|
|
28
|
+
* matches: [ { stateId, variant, nodeId, score } ], unmatchedStates: [...] }
|
|
29
|
+
* plus top-level `needs[]`, `rendersNeeded[]` and `unresolved[]`.
|
|
30
|
+
*
|
|
31
|
+
* `spec` is geometry only (role, label, text, identifier, x, y, w, h) relative
|
|
32
|
+
* to the variant's own origin: it is the `figma_spec` for design_visual_compare
|
|
33
|
+
* with `figma_frame` = the variant's frame. Colour and typography expectations
|
|
34
|
+
* are deliberately not emitted.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { flattenFigmaNode } from "./geometry.js";
|
|
38
|
+
|
|
39
|
+
const VARIANT_NAME_RE = /^\s*[^=,]+=[^=,]*(\s*,\s*[^=,]+=[^=,]*)*\s*$/;
|
|
40
|
+
const MATCH_THRESHOLD = 0.5;
|
|
41
|
+
|
|
42
|
+
export function variantProperties(name) {
|
|
43
|
+
if (!VARIANT_NAME_RE.test(String(name || ""))) return {};
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const part of String(name).split(",")) {
|
|
46
|
+
const [k, ...v] = part.split("=");
|
|
47
|
+
out[k.trim()] = v.join("=").trim();
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function decodeXml(s) {
|
|
53
|
+
return String(s).replace(/&(amp|lt|gt|quot|apos|#39);/g, (_, e) => (
|
|
54
|
+
{ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", "#39": "'" }[e]
|
|
55
|
+
));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const XML_TYPES = { instance: "INSTANCE", text: "TEXT", frame: "FRAME", group: "GROUP", section: "SECTION" };
|
|
59
|
+
|
|
60
|
+
// MCP get_metadata XML -> REST-shaped documents with absolute bounding boxes.
|
|
61
|
+
function parseMetadataXml(xml) {
|
|
62
|
+
const roots = [];
|
|
63
|
+
const stack = [];
|
|
64
|
+
const tagRe = /<(\/?)([A-Za-z][\w-]*)((?:\s+[\w:-]+\s*=\s*"[^"]*")*)\s*(\/?)>/g;
|
|
65
|
+
for (let m; (m = tagRe.exec(xml)); ) {
|
|
66
|
+
const [, closing, tag, attrText, selfClosing] = m;
|
|
67
|
+
if (closing) { stack.pop(); continue; }
|
|
68
|
+
const attrs = {};
|
|
69
|
+
for (const a of attrText.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) attrs[a[1]] = decodeXml(a[2]);
|
|
70
|
+
const parent = stack[stack.length - 1] || null;
|
|
71
|
+
const px = parent ? parent.absoluteBoundingBox.x : 0;
|
|
72
|
+
const py = parent ? parent.absoluteBoundingBox.y : 0;
|
|
73
|
+
const node = {
|
|
74
|
+
id: attrs.id, name: attrs.name || "", tag,
|
|
75
|
+
type: XML_TYPES[tag] || tag.toUpperCase(),
|
|
76
|
+
absoluteBoundingBox: {
|
|
77
|
+
x: px + Number(attrs.x || 0), y: py + Number(attrs.y || 0),
|
|
78
|
+
width: Number(attrs.width || 0), height: Number(attrs.height || 0),
|
|
79
|
+
},
|
|
80
|
+
children: [],
|
|
81
|
+
};
|
|
82
|
+
if (node.type === "TEXT") node.characters = node.name;
|
|
83
|
+
(parent ? parent.children : roots).push(node);
|
|
84
|
+
if (!selfClosing) stack.push(node);
|
|
85
|
+
}
|
|
86
|
+
// A symbol is a component; one whose children are all variant-named is the set.
|
|
87
|
+
const settle = (n) => {
|
|
88
|
+
n.children.forEach(settle);
|
|
89
|
+
if (n.tag === "symbol") {
|
|
90
|
+
const variantKids = n.children.length && n.children.every((c) => c.tag === "symbol" && VARIANT_NAME_RE.test(c.name));
|
|
91
|
+
n.type = variantKids ? "COMPONENT_SET" : "COMPONENT";
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
roots.forEach(settle);
|
|
95
|
+
return roots;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function indexSource(src, i) {
|
|
99
|
+
if (!src || typeof src !== "object") throw new Error(`sources[${i}] must be an object`);
|
|
100
|
+
if (typeof src.fileKey !== "string" || !src.fileKey) throw new Error(`sources[${i}].fileKey is required`);
|
|
101
|
+
const docs = [];
|
|
102
|
+
const components = {}, componentSets = {};
|
|
103
|
+
if (src.nodes && typeof src.nodes === "object") {
|
|
104
|
+
for (const entry of Object.values(src.nodes.nodes || {})) {
|
|
105
|
+
if (entry && entry.document) docs.push(entry.document);
|
|
106
|
+
Object.assign(components, entry?.components || {});
|
|
107
|
+
Object.assign(componentSets, entry?.componentSets || {});
|
|
108
|
+
}
|
|
109
|
+
} else if (typeof src.metadata === "string") {
|
|
110
|
+
docs.push(...parseMetadataXml(src.metadata));
|
|
111
|
+
} else {
|
|
112
|
+
throw new Error(`sources[${i}] needs nodes or metadata`);
|
|
113
|
+
}
|
|
114
|
+
const byId = new Map(), parentOf = new Map();
|
|
115
|
+
const visit = (n, parent) => {
|
|
116
|
+
if (!n || typeof n !== "object" || !n.id) return;
|
|
117
|
+
if (!byId.has(n.id)) byId.set(n.id, n);
|
|
118
|
+
if (parent) parentOf.set(n.id, parent);
|
|
119
|
+
for (const c of n.children || []) visit(c, n);
|
|
120
|
+
};
|
|
121
|
+
docs.forEach((d) => visit(d, null));
|
|
122
|
+
return { fileKey: src.fileKey, byId, parentOf, components, componentSets };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function componentSetListing(componentSets) {
|
|
126
|
+
const list = Array.isArray(componentSets) ? componentSets
|
|
127
|
+
: (componentSets && componentSets.meta && Array.isArray(componentSets.meta.component_sets)) ? componentSets.meta.component_sets
|
|
128
|
+
: [];
|
|
129
|
+
return list.filter((e) => e && e.node_id && e.file_key);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Parses https://www.figma.com/design/<key>/...?node-id=1-2 (also /file/).
|
|
133
|
+
// The query is decoded first: a link copied from a browser or a chat client
|
|
134
|
+
// often arrives as node-id=1%3A2, which is the same node.
|
|
135
|
+
export function parseFigmaUrl(url) {
|
|
136
|
+
const raw = String(url || "");
|
|
137
|
+
const q = raw.indexOf("?");
|
|
138
|
+
let decoded = raw;
|
|
139
|
+
if (q >= 0) {
|
|
140
|
+
try {
|
|
141
|
+
decoded = raw.slice(0, q + 1) + decodeURIComponent(raw.slice(q + 1));
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const m = /figma\.com\/(?:design|file)\/([A-Za-z0-9]+)(?:\/branch\/([A-Za-z0-9]+))?[^?]*\?(?:[^#]*&)?node-id=([0-9]+[-:][0-9]+)/.exec(decoded);
|
|
147
|
+
if (!m) return null;
|
|
148
|
+
return { fileKey: m[2] || m[1], nodeId: m[3].replace("-", ":") };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function codeConnectIndex(entries) {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const e of entries || []) {
|
|
154
|
+
if (!e || !e.component) continue;
|
|
155
|
+
const ref = e.url ? parseFigmaUrl(e.url) : (e.nodeId ? { fileKey: e.fileKey || null, nodeId: String(e.nodeId).replace("-", ":") } : null);
|
|
156
|
+
if (ref) out.push({ ...ref, component: String(e.component), source: e.source || null });
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const tokens = (s) => new Set(String(s || "")
|
|
162
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
163
|
+
.toLowerCase()
|
|
164
|
+
.split(/[^a-z0-9]+/)
|
|
165
|
+
.filter(Boolean));
|
|
166
|
+
|
|
167
|
+
// How much of the state's wording the variant name covers: |state ∩ variant| /
|
|
168
|
+
// |state tokens|. Coverage rather than Jaccard, because dividing by the union
|
|
169
|
+
// lets the property names and the other properties of a multi-property variant
|
|
170
|
+
// dilute the score: a one-word state such as "Error" would score 1/4 against
|
|
171
|
+
// "State=Error, Size=L" and never clear the threshold. Jaccard is the second
|
|
172
|
+
// key, so between two variants that cover the state equally the tighter name
|
|
173
|
+
// wins.
|
|
174
|
+
function similarity(a, b) {
|
|
175
|
+
const x = tokens(a), y = tokens(b);
|
|
176
|
+
if (!x.size || !y.size) return { coverage: 0, jaccard: 0 };
|
|
177
|
+
let inter = 0;
|
|
178
|
+
for (const t of x) if (y.has(t)) inter++;
|
|
179
|
+
return { coverage: inter / x.size, jaccard: inter / (x.size + y.size - inter) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Best variant per state, accepted only when it clears the threshold and no
|
|
183
|
+
// other variant ties it on both keys: an ambiguous state is reported, never
|
|
184
|
+
// assigned.
|
|
185
|
+
function matchStates(states, variants) {
|
|
186
|
+
const matches = [], unmatched = [];
|
|
187
|
+
for (const s of states || []) {
|
|
188
|
+
if (!s || !s.id) continue;
|
|
189
|
+
const text = s.label || s.id;
|
|
190
|
+
const scored = variants.map((v) => ({ v, ...similarity(text, v.name) }))
|
|
191
|
+
.sort((a, b) => b.coverage - a.coverage || b.jaccard - a.jaccard || a.v.nodeId.localeCompare(b.v.nodeId));
|
|
192
|
+
const best = scored[0];
|
|
193
|
+
const tie = scored[1] && best && scored[1].coverage === best.coverage && scored[1].jaccard === best.jaccard;
|
|
194
|
+
if (best && best.coverage >= MATCH_THRESHOLD && !tie) {
|
|
195
|
+
matches.push({ stateId: String(s.id), variant: best.v.name, nodeId: best.v.nodeId, score: Math.round(best.coverage * 100) / 100 });
|
|
196
|
+
} else {
|
|
197
|
+
unmatched.push({
|
|
198
|
+
stateId: String(s.id), label: text,
|
|
199
|
+
reason: !best || best.coverage < MATCH_THRESHOLD ? "no variant name is close enough" : "two or more variants match equally",
|
|
200
|
+
candidates: scored.filter((x) => x.coverage > 0).slice(0, 3).map((x) => x.v.name),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const byId = (a, b) => a.stateId.localeCompare(b.stateId);
|
|
205
|
+
return { matches: matches.sort(byId), unmatched: unmatched.sort(byId) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function geometrySpec(doc) {
|
|
209
|
+
const bb = doc.absoluteBoundingBox || { x: 0, y: 0 };
|
|
210
|
+
return flattenFigmaNode(doc, bb.x, bb.y).map(({ role, label, text, identifier, x, y, w, h }) => ({ role, label, text, identifier, x, y, w, h }));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function walkComponents({ sources, componentSets = null, nodeIds = null, codeConnect = [], states = [], renders = {} } = {}) {
|
|
214
|
+
if (!Array.isArray(sources) || !sources.length) throw new Error("sources must be a non-empty array of { fileKey, nodes | metadata }");
|
|
215
|
+
const idx = sources.map(indexSource);
|
|
216
|
+
const listing = componentSetListing(componentSets);
|
|
217
|
+
const cc = codeConnectIndex(codeConnect);
|
|
218
|
+
|
|
219
|
+
const find = (nodeId, fileKey = null) => {
|
|
220
|
+
for (const s of idx) {
|
|
221
|
+
if (fileKey && s.fileKey !== fileKey) continue;
|
|
222
|
+
const n = s.byId.get(nodeId);
|
|
223
|
+
if (n) return { node: n, src: s };
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
};
|
|
227
|
+
// Node ids are only unique within a file, so a reference found in one source
|
|
228
|
+
// is looked up there first; another source is a fallback, not a peer.
|
|
229
|
+
const findNear = (nodeId, src) => find(nodeId, src.fileKey) || find(nodeId);
|
|
230
|
+
|
|
231
|
+
// A requested id may be `fileKey/nodeId` to pick one of several files that
|
|
232
|
+
// use the same id.
|
|
233
|
+
const parseRequested = (raw) => {
|
|
234
|
+
const slash = raw.lastIndexOf("/");
|
|
235
|
+
return slash > 0 ? { fileKey: raw.slice(0, slash), nodeId: raw.slice(slash + 1) } : { fileKey: null, nodeId: raw };
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const requested = nodeIds && nodeIds.length ? nodeIds.map(String) : [...idx[0].byId.keys()].filter((id) => !idx[0].parentOf.has(id));
|
|
239
|
+
const components = [], needs = [], unresolved = [];
|
|
240
|
+
const needKeys = new Set();
|
|
241
|
+
const need = (fileKey, nodeId, why) => {
|
|
242
|
+
const k = `${fileKey}/${nodeId}`;
|
|
243
|
+
if (!needKeys.has(k)) { needKeys.add(k); needs.push({ fileKey, nodeId, why }); }
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Where the set with this local id / key lives: the same source first, then
|
|
247
|
+
// the library listing, which carries the owning file.
|
|
248
|
+
const locateSet = (src, setId, why) => {
|
|
249
|
+
const local = src.byId.get(setId);
|
|
250
|
+
if (local && local.type === "COMPONENT_SET") return { node: local, src };
|
|
251
|
+
const meta = src.componentSets[setId];
|
|
252
|
+
const listed = listing.find((e) => (meta && meta.key && e.key === meta.key) || e.node_id === setId);
|
|
253
|
+
if (listed) {
|
|
254
|
+
const hit = find(listed.node_id, listed.file_key);
|
|
255
|
+
if (hit) return hit;
|
|
256
|
+
need(listed.file_key, listed.node_id, why(meta ? meta.name : listed.name));
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
need(src.fileKey, setId, why(meta ? meta.name : setId));
|
|
260
|
+
return null;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
for (const id of requested) {
|
|
264
|
+
const ref = parseRequested(id);
|
|
265
|
+
const hit = find(ref.nodeId, ref.fileKey);
|
|
266
|
+
if (!hit) { unresolved.push({ nodeId: id, reason: "not present in any supplied source" }); continue; }
|
|
267
|
+
const { node, src } = hit;
|
|
268
|
+
let resolvedVia, set = null, single = null, instanceOf = null;
|
|
269
|
+
if (node.type === "COMPONENT_SET") {
|
|
270
|
+
resolvedVia = "component-set"; set = hit;
|
|
271
|
+
} else if (node.type === "COMPONENT") {
|
|
272
|
+
resolvedVia = "component";
|
|
273
|
+
const parent = src.parentOf.get(node.id);
|
|
274
|
+
if (parent && parent.type === "COMPONENT_SET") set = { node: parent, src };
|
|
275
|
+
else if (src.components[node.id] && src.components[node.id].componentSetId) {
|
|
276
|
+
set = locateSet(src, src.components[node.id].componentSetId, (n) => `component set of ${id} (${n})`);
|
|
277
|
+
if (!set) continue;
|
|
278
|
+
} else single = hit;
|
|
279
|
+
} else if (node.type === "INSTANCE" && node.componentId) {
|
|
280
|
+
resolvedVia = "instance"; instanceOf = node.componentId;
|
|
281
|
+
const meta = src.components[node.componentId];
|
|
282
|
+
if (meta && meta.componentSetId) {
|
|
283
|
+
set = locateSet(src, meta.componentSetId, (n) => `component set of ${id} (${n})`);
|
|
284
|
+
if (!set) continue;
|
|
285
|
+
} else {
|
|
286
|
+
const comp = findNear(node.componentId, src);
|
|
287
|
+
if (!comp) { need(src.fileKey, node.componentId, `main component of ${id}`); continue; }
|
|
288
|
+
single = comp;
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
unresolved.push({ nodeId: id, reason: `a ${node.type || "node"} that is not a component, variant or instance` });
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const owner = set || single;
|
|
296
|
+
const variantDocs = set ? (owner.node.children || []).filter((c) => c.type === "COMPONENT" || VARIANT_NAME_RE.test(c.name)) : [owner.node];
|
|
297
|
+
const variants = variantDocs.map((doc) => {
|
|
298
|
+
const bb = doc.absoluteBoundingBox || { width: 0, height: 0 };
|
|
299
|
+
const image = renders[`${owner.src.fileKey}/${doc.id}`] || renders[doc.id] || null;
|
|
300
|
+
return {
|
|
301
|
+
name: doc.name, nodeId: doc.id, fileKey: owner.src.fileKey,
|
|
302
|
+
properties: variantProperties(doc.name),
|
|
303
|
+
frame: { w: Math.round(bb.width), h: Math.round(bb.height) },
|
|
304
|
+
spec: geometrySpec(doc),
|
|
305
|
+
render: { fileKey: owner.src.fileKey, nodeId: doc.id, format: "png", scale: 2 },
|
|
306
|
+
image,
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
const properties = {};
|
|
310
|
+
for (const v of variants) {
|
|
311
|
+
for (const [k, val] of Object.entries(v.properties)) (properties[k] = properties[k] || new Set()).add(val);
|
|
312
|
+
}
|
|
313
|
+
for (const k of Object.keys(properties)) properties[k] = [...properties[k]].sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
|
|
314
|
+
|
|
315
|
+
const ownerRef = { nodeId: owner.node.id, name: owner.node.name, fileKey: owner.src.fileKey };
|
|
316
|
+
const mapping = cc.find((e) => e.nodeId === ownerRef.nodeId && (!e.fileKey || e.fileKey === ownerRef.fileKey))
|
|
317
|
+
|| cc.find((e) => variants.some((v) => v.nodeId === e.nodeId && (!e.fileKey || e.fileKey === v.fileKey)))
|
|
318
|
+
|| null;
|
|
319
|
+
const { matches, unmatched } = matchStates(states, variants);
|
|
320
|
+
components.push({
|
|
321
|
+
requested: id, resolvedVia, ...(instanceOf ? { instanceOf } : {}),
|
|
322
|
+
component: mapping ? mapping.component : ownerRef.name,
|
|
323
|
+
componentSet: set ? ownerRef : null,
|
|
324
|
+
...(set ? {} : { mainComponent: ownerRef }),
|
|
325
|
+
codeConnect: mapping ? { component: mapping.component, source: mapping.source } : null,
|
|
326
|
+
properties, variants, matches, unmatchedStates: unmatched,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const rendersNeeded = [];
|
|
331
|
+
const seenRender = new Set();
|
|
332
|
+
for (const c of components) {
|
|
333
|
+
for (const v of c.variants) {
|
|
334
|
+
const k = `${v.fileKey}/${v.nodeId}`;
|
|
335
|
+
if (v.image || seenRender.has(k)) continue;
|
|
336
|
+
seenRender.add(k);
|
|
337
|
+
rendersNeeded.push({ fileKey: v.fileKey, nodeId: v.nodeId, variant: v.name });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return { components, needs, rendersNeeded, unresolved };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The per-finding component fields design_report renders: the main component
|
|
345
|
+
* link plus one thumbnail per variant. `variantNodeId` names the variant the
|
|
346
|
+
* capture was compared against.
|
|
347
|
+
*/
|
|
348
|
+
export function componentFindingFields(entry, variantNodeId = null) {
|
|
349
|
+
if (!entry) return {};
|
|
350
|
+
const owner = entry.componentSet || entry.mainComponent || {};
|
|
351
|
+
const chosen = entry.variants.find((v) => v.nodeId === variantNodeId) || null;
|
|
352
|
+
return {
|
|
353
|
+
component: entry.component,
|
|
354
|
+
componentNode: owner.nodeId || null,
|
|
355
|
+
componentFileKey: owner.fileKey || null,
|
|
356
|
+
...(chosen ? { componentVariant: chosen.name } : {}),
|
|
357
|
+
componentVariants: entry.variants.map((v) => ({ name: v.name, node: v.nodeId, fileKey: v.fileKey, image: v.image })),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The problem (docs/design-check-gaps.md section 1). A live capture is compared
|
|
6
6
|
* against a screen frame, and a screen frame carries whatever the mock fixture
|
|
7
|
-
* produced. A design showing 5
|
|
7
|
+
* produced. A design showing 5 item rows against an app rendering 3 makes
|
|
8
8
|
* every container height and every downstream position differ for a reason that
|
|
9
9
|
* is not a defect. On a real 109-target module that noise dominated the report.
|
|
10
10
|
*
|
|
@@ -65,8 +65,8 @@ const NEVER_DEMOTE_TYPES = new Set([
|
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
67
|
* Strip the varying tail from an element name so repeated siblings collapse to
|
|
68
|
-
* one group key: `
|
|
69
|
-
* become `
|
|
68
|
+
* one group key: `itemRow_2` / `item row 3` / `ItemRow-4` all
|
|
69
|
+
* become `itemrow`.
|
|
70
70
|
*
|
|
71
71
|
* @param {string} name
|
|
72
72
|
* @returns {string}
|