@mmerterden/dev-toolkit-mcp 2.24.1

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +708 -0
  2. package/LICENSE +21 -0
  3. package/README.md +387 -0
  4. package/index.js +1277 -0
  5. package/package.json +80 -0
  6. package/tools/design-check/content-cardinality.js +204 -0
  7. package/tools/design-check/geometry.js +140 -0
  8. package/tools/design-check/index.js +213 -0
  9. package/tools/design-check/mock-detect.js +213 -0
  10. package/tools/design-check/report.js +590 -0
  11. package/tools/design-check/scan.js +91 -0
  12. package/tools/design-check/scenario-inventory.js +598 -0
  13. package/tools/design-check/visual-compare.js +961 -0
  14. package/tools/ios-app-store-audit/context.js +180 -0
  15. package/tools/ios-app-store-audit/data/apple-required-sdks.json +32 -0
  16. package/tools/ios-app-store-audit/data/debug-tools-blocklist.json +133 -0
  17. package/tools/ios-app-store-audit/index.js +164 -0
  18. package/tools/ios-app-store-audit/models.js +47 -0
  19. package/tools/ios-app-store-audit/rules/asset-validation.js +72 -0
  20. package/tools/ios-app-store-audit/rules/binary-size.js +70 -0
  21. package/tools/ios-app-store-audit/rules/code-signing.js +95 -0
  22. package/tools/ios-app-store-audit/rules/dead-reference.js +131 -0
  23. package/tools/ios-app-store-audit/rules/debug-tool-leak.js +185 -0
  24. package/tools/ios-app-store-audit/rules/duplicate-resource.js +130 -0
  25. package/tools/ios-app-store-audit/rules/embedded-sdk.js +126 -0
  26. package/tools/ios-app-store-audit/rules/entitlement.js +105 -0
  27. package/tools/ios-app-store-audit/rules/extension-signing.js +105 -0
  28. package/tools/ios-app-store-audit/rules/info-plist.js +158 -0
  29. package/tools/ios-app-store-audit/rules/ipv6-compliance.js +101 -0
  30. package/tools/ios-app-store-audit/rules/privacy-manifest.js +121 -0
  31. package/tools/ios-app-store-audit/rules/production-hygiene.js +237 -0
  32. package/tools/ios-app-store-audit/rules/provisioning-profile.js +127 -0
  33. package/tools/ios-app-store-audit/rules/required-reason-api.js +123 -0
  34. package/tools/ios-app-store-audit/rules/sdk-floor.js +104 -0
  35. package/tools/ios-app-store-audit/rules/swift-abi.js +64 -0
  36. package/tools/ios-app-store-audit/rules/team-id.js +62 -0
  37. package/tools/ios-testflight/index.js +479 -0
  38. package/ui-tree-dumper.swift +122 -0
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@mmerterden/dev-toolkit-mcp",
3
+ "version": "2.24.1",
4
+ "description": "MCP server for iOS Simulator, Android Emulator, and headless web control. 80 tools: device automation (iOS tap/swipe/type via idb), accessibility audit, deep App Store compliance audit (18-rule ios_app_store_audit), TestFlight pre-submission validation via altool, xcodebuild progressive disclosure, visual diff, mock-mode vs Figma design audit with a scenario inventory + coverage gate (design-check family), status bar presets, web automation (Playwright), and batch DSL execution.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "dev-toolkit-mcp": "index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node index.js",
12
+ "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",
13
+ "gates": "bash scripts/gates.sh"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "ios",
19
+ "android",
20
+ "simulator",
21
+ "emulator",
22
+ "xcrun",
23
+ "simctl",
24
+ "adb",
25
+ "testing",
26
+ "screenshot",
27
+ "accessibility",
28
+ "ui-testing",
29
+ "mobile",
30
+ "claude",
31
+ "claude-code",
32
+ "copilot",
33
+ "cursor",
34
+ "antigravity",
35
+ "playwright",
36
+ "web-automation",
37
+ "browser-automation",
38
+ "agent-dsl",
39
+ "batch-execution"
40
+ ],
41
+ "author": "Mert Erden",
42
+ "license": "MIT",
43
+ "publishConfig": {
44
+ "registry": "https://registry.npmjs.org",
45
+ "access": "public"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/mmerterden/dev-toolkit-mcp.git"
50
+ },
51
+ "homepage": "https://github.com/mmerterden/dev-toolkit-mcp#readme",
52
+ "bugs": {
53
+ "url": "https://github.com/mmerterden/dev-toolkit-mcp/issues"
54
+ },
55
+ "engines": {
56
+ "node": ">=20.0.0"
57
+ },
58
+ "files": [
59
+ "index.js",
60
+ "ui-tree-dumper.swift",
61
+ "tools/",
62
+ "!tools/**/__tests__",
63
+ "README.md",
64
+ "CHANGELOG.md",
65
+ "LICENSE"
66
+ ],
67
+ "dependencies": {
68
+ "@modelcontextprotocol/sdk": "^1.29.0",
69
+ "pixelmatch": "^6.0.0",
70
+ "pngjs": "^7.0.0"
71
+ },
72
+ "peerDependencies": {
73
+ "playwright": ">=1.60.0 <2"
74
+ },
75
+ "peerDependenciesMeta": {
76
+ "playwright": {
77
+ "optional": true
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * content-cardinality.js - suppress the geometry noise a fixture-count
3
+ * difference creates, WITHOUT suppressing a real defect.
4
+ *
5
+ * The problem (docs/design-check-gaps.md section 1). A live capture is compared
6
+ * against a screen frame, and a screen frame carries whatever the mock fixture
7
+ * produced. A design showing 5 passenger rows against an app rendering 3 makes
8
+ * every container height and every downstream position differ for a reason that
9
+ * is not a defect. On a real 109-target module that noise dominated the report.
10
+ *
11
+ * Why this is written defensively. The dangerous direction is not noise, it is a
12
+ * hidden defect: demote too eagerly and the engine silently stops reporting real
13
+ * deviations, in a report whose whole purpose is to surface them, and the mistake
14
+ * is invisible precisely because the finding is gone. So the rules below are
15
+ * deliberately narrow, and every one of them is a test in
16
+ * `__tests__/design-check.test.mjs`:
17
+ *
18
+ * 1. Nothing is ever DELETED. A demoted finding stays in the report with
19
+ * `advisory: true` and a `rootCause`, so it renders under the existing
20
+ * "DO NOT CHANGE (advisory / not a defect)" group with its reason attached.
21
+ * `demoted` is returned as a count so a run can state what it demoted.
22
+ * 2. Only GEOMETRY findings are eligible. Copy, colour, typography, font-family
23
+ * and tap-target findings are never touched: a row-count difference cannot
24
+ * make a colour wrong or a string mistranslated, so it can never be the
25
+ * explanation for one.
26
+ * 3. Nothing is demoted unless a mismatch is actually detected, from evidence,
27
+ * in a repeated group. No mismatch -> the findings array is returned
28
+ * unchanged, same objects.
29
+ * 4. Only findings AT OR BELOW the mismatched group are eligible. A count
30
+ * difference shifts what follows it; it cannot explain a deviation above it.
31
+ * 5. A finding already marked advisory is left alone (idempotent), and a
32
+ * `verified` finding is never demoted.
33
+ *
34
+ * FIELD VALIDATION STILL PENDING: the acceptance criterion in the gaps doc ("a
35
+ * screen whose fixture returns a different row count produces zero geometry
36
+ * findings") needs the real module, a live capture and Figma design context to
37
+ * confirm end to end. What is proven here is the safe direction - the rules above
38
+ * hold on synthetic fixtures in both directions. Treat the tuning constants as
39
+ * provisional until a real run is measured.
40
+ */
41
+
42
+ /** Finding categories a cardinality difference can plausibly explain. */
43
+ const GEOMETRY_CATEGORIES = new Set(["spacing", "size", "position", "alignment"]);
44
+
45
+ /** Finding types it can explain. Narrower than the category set on purpose. */
46
+ const GEOMETRY_TYPES = new Set(["spacing", "height", "inset", "gap", "position", "offset"]);
47
+
48
+ /**
49
+ * Types that must NEVER be demoted, whatever their category says. Listed
50
+ * explicitly rather than relying on the allow-list above, so a future category
51
+ * rename cannot quietly make one of these eligible.
52
+ */
53
+ const NEVER_DEMOTE_TYPES = new Set([
54
+ "copy",
55
+ "color",
56
+ "colour",
57
+ "font-size",
58
+ "font-family",
59
+ "typography",
60
+ "tap-target",
61
+ "icon-size",
62
+ "missing",
63
+ "extra",
64
+ ]);
65
+
66
+ /**
67
+ * Strip the varying tail from an element name so repeated siblings collapse to
68
+ * one group key: `passengerRow_2` / `passenger row 3` / `PassengerRow-4` all
69
+ * become `passengerrow`.
70
+ *
71
+ * @param {string} name
72
+ * @returns {string}
73
+ */
74
+ export function groupKey(name) {
75
+ return String(name || "")
76
+ .trim()
77
+ .toLowerCase()
78
+ .replace(/[\s_-]+/g, "")
79
+ .replace(/\d+$/, "")
80
+ .replace(/(item|row|cell|card)$/, "$1");
81
+ }
82
+
83
+ /**
84
+ * Count repeated siblings per group in a flat element list.
85
+ *
86
+ * @param {Array<object>} elements
87
+ * @returns {Map<string, {count: number, minY: number}>}
88
+ */
89
+ function groupCounts(elements) {
90
+ const out = new Map();
91
+ for (const el of elements || []) {
92
+ const raw = el?.label ?? el?.text ?? el?.identifier ?? el?.name;
93
+ const key = groupKey(raw);
94
+ if (!key) continue;
95
+ const y = Number.isFinite(el?.y) ? el.y : Infinity;
96
+ const cur = out.get(key);
97
+ if (cur) {
98
+ cur.count += 1;
99
+ cur.minY = Math.min(cur.minY, y);
100
+ } else {
101
+ out.set(key, { count: 1, minY: y });
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Detect a repeated-group cardinality difference between design and live.
109
+ *
110
+ * Requires a group to be genuinely REPEATED on at least one side (>= 2), because
111
+ * a 1-vs-0 difference is a missing element - a real defect, and exactly what this
112
+ * must not explain away.
113
+ *
114
+ * @param {Array<object>} figmaSpec
115
+ * @param {Array<object>} liveGeometry
116
+ * @param {{minRepeat?: number}} [opts]
117
+ * @returns {{mismatch: boolean, groups: Array<object>, boundaryY: number|null}}
118
+ */
119
+ export function detectCardinalityMismatch(figmaSpec, liveGeometry, opts = {}) {
120
+ const minRepeat = opts.minRepeat ?? 2;
121
+ const design = groupCounts(figmaSpec);
122
+ const live = groupCounts(liveGeometry);
123
+ const groups = [];
124
+
125
+ for (const [key, d] of design) {
126
+ const l = live.get(key);
127
+ if (!l) continue; // absent entirely is a missing-element defect, not a count difference
128
+ if (d.count === l.count) continue;
129
+ if (Math.max(d.count, l.count) < minRepeat) continue; // 1 vs 0 is not a repeat
130
+ groups.push({
131
+ key,
132
+ designCount: d.count,
133
+ liveCount: l.count,
134
+ // The topmost occurrence on either side: everything at or below it can be
135
+ // shifted by the difference.
136
+ firstY: Math.min(d.minY, l.minY),
137
+ });
138
+ }
139
+
140
+ const boundaryY = groups.length
141
+ ? Math.min(...groups.map((g) => (Number.isFinite(g.firstY) ? g.firstY : Infinity)))
142
+ : null;
143
+
144
+ return {
145
+ mismatch: groups.length > 0,
146
+ groups,
147
+ boundaryY: Number.isFinite(boundaryY) ? boundaryY : null,
148
+ };
149
+ }
150
+
151
+ /** Is this finding the kind a cardinality difference could explain? */
152
+ function isGeometryFinding(f) {
153
+ const type = String(f?.type || "").toLowerCase();
154
+ const category = String(f?.category || "").toLowerCase();
155
+ if (NEVER_DEMOTE_TYPES.has(type)) return false;
156
+ return GEOMETRY_TYPES.has(type) || GEOMETRY_CATEGORIES.has(category);
157
+ }
158
+
159
+ /** The finding's vertical position, or null when it cannot be established. */
160
+ function findingY(f) {
161
+ const y = f?.region?.y ?? f?.y;
162
+ return Number.isFinite(y) ? y : null;
163
+ }
164
+
165
+ /**
166
+ * Demote the geometry findings a detected cardinality difference explains.
167
+ *
168
+ * @param {Array<object>} findings
169
+ * @param {{mismatch: boolean, groups: Array<object>, boundaryY: number|null}} detection
170
+ * @returns {{findings: Array<object>, demoted: number, rootCause: string|null}}
171
+ */
172
+ export function demoteCardinalityNoise(findings, detection) {
173
+ const list = Array.isArray(findings) ? findings : [];
174
+ if (!detection?.mismatch) return { findings: list, demoted: 0, rootCause: null };
175
+
176
+ const summary = detection.groups
177
+ .map((g) => `${g.key}: design ${g.designCount} vs live ${g.liveCount}`)
178
+ .join("; ");
179
+ const rootCause =
180
+ `content count differs between the design fixture and the running app (${summary}). ` +
181
+ `Container heights and everything below the first differing group shift for that reason, ` +
182
+ `not because the layout is wrong. Re-run against a fixture with matching counts to measure these.`;
183
+
184
+ const boundary = detection.boundaryY;
185
+ let demoted = 0;
186
+
187
+ const out = list.map((f) => {
188
+ if (!f || f.advisory || f.status === "verified") return f;
189
+ if (!isGeometryFinding(f)) return f;
190
+
191
+ // Rule 4: only at or below the first differing group. An unknown position
192
+ // is NOT assumed to be below it - unplaceable findings keep full severity,
193
+ // because demoting on a guess is the failure mode this module must not have.
194
+ if (boundary !== null) {
195
+ const y = findingY(f);
196
+ if (y === null || y < boundary) return f;
197
+ }
198
+
199
+ demoted += 1;
200
+ return { ...f, advisory: true, rootCause, demotedBy: "content-cardinality" };
201
+ });
202
+
203
+ return { findings: out, demoted, rootCause };
204
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * design_ui_geometry helpers - turn a live UI hierarchy into a flat list of
3
+ * element bounding boxes, so spacing/position deltas against a Figma spec can
4
+ * be measured in pixels. Generic: works off the iOS AX JSON dumper output and
5
+ * the Android uiautomator XML dump.
6
+ *
7
+ * Element shape: { role, label, text, identifier, x, y, w, h }
8
+ * Coordinates are in device points/pixels as reported by the platform.
9
+ */
10
+
11
+ // iOS: parse the ui-tree-dumper.swift JSON (nested AXNode with frame {x,y,w,h}).
12
+ export function flattenIosAxTree(json) {
13
+ let root;
14
+ try { root = typeof json === "string" ? JSON.parse(json) : json; } catch { return []; }
15
+ const out = [];
16
+ const visit = (node) => {
17
+ if (!node || typeof node !== "object") return;
18
+ const f = node.frame || {};
19
+ if (typeof f.w === "number" && typeof f.h === "number" && f.w > 0 && f.h > 0) {
20
+ out.push({
21
+ role: node.role || "",
22
+ label: node.title || node.description || "",
23
+ text: node.value || "",
24
+ identifier: node.identifier || "",
25
+ x: Math.round(f.x || 0), y: Math.round(f.y || 0),
26
+ w: Math.round(f.w), h: Math.round(f.h),
27
+ });
28
+ }
29
+ (node.children || []).forEach(visit);
30
+ };
31
+ visit(root);
32
+ return out;
33
+ }
34
+
35
+ // iOS via idb: parse `idb ui describe-all --json` output (array of AX elements,
36
+ // each with frame {x,y,width,height} in points). Works headless (no Simulator GUI).
37
+ export function flattenIdbDescribeAll(json) {
38
+ let arr;
39
+ try {
40
+ const raw = typeof json === "string" ? json.trim() : json;
41
+ arr = typeof raw === "string"
42
+ ? (raw.startsWith("[") ? JSON.parse(raw) : raw.split("\n").filter(Boolean).map((l) => JSON.parse(l)))
43
+ : raw;
44
+ } catch { return []; }
45
+ if (!Array.isArray(arr)) return [];
46
+ const out = [];
47
+ for (const e of arr) {
48
+ const f = e && e.frame;
49
+ if (!f || typeof f.width !== "number") continue;
50
+ if (f.width <= 0 || f.height <= 0) continue;
51
+ if ((e.type || e.role_description) === "Application") continue;
52
+ out.push({
53
+ role: e.role || e.type || "",
54
+ label: e.AXLabel || "",
55
+ text: e.AXValue || e.title || "",
56
+ identifier: e.AXUniqueId || "",
57
+ x: Math.round(f.x), y: Math.round(f.y),
58
+ w: Math.round(f.width), h: Math.round(f.height),
59
+ });
60
+ }
61
+ return out;
62
+ }
63
+
64
+ // Android: parse uiautomator XML. bounds="[x1,y1][x2,y2]".
65
+ export function flattenAndroidUiXml(xml) {
66
+ if (!xml || typeof xml !== "string") return [];
67
+ const out = [];
68
+ const nodeRe = /<node\b([^>]*)\/?>/g;
69
+ const attr = (s, name) => {
70
+ const m = new RegExp(`${name}="([^"]*)"`).exec(s);
71
+ return m ? m[1] : "";
72
+ };
73
+ let m;
74
+ while ((m = nodeRe.exec(xml)) !== null) {
75
+ const a = m[1];
76
+ const bounds = attr(a, "bounds");
77
+ const bm = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(bounds);
78
+ if (!bm) continue;
79
+ const x1 = +bm[1], y1 = +bm[2], x2 = +bm[3], y2 = +bm[4];
80
+ const w = x2 - x1, h = y2 - y1;
81
+ if (w <= 0 || h <= 0) continue;
82
+ out.push({
83
+ role: attr(a, "class").split(".").pop() || "",
84
+ label: attr(a, "content-desc"),
85
+ text: attr(a, "text"),
86
+ identifier: attr(a, "resource-id"),
87
+ x: x1, y: y1, w, h,
88
+ });
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // Normalize a Figma node subtree (from get_design_context / metadata) into the
94
+ // same flat element shape, carrying style spec where present.
95
+ // Accepts a node with absoluteBoundingBox / children / style / fills.
96
+ export function flattenFigmaNode(node, originX = 0, originY = 0) {
97
+ const out = [];
98
+ const visit = (n) => {
99
+ if (!n || typeof n !== "object") return;
100
+ const bb = n.absoluteBoundingBox || n.boundingBox;
101
+ if (bb && typeof bb.width === "number") {
102
+ const style = n.style || {};
103
+ const fill = firstSolidColor(n.fills) || (style.fills && firstSolidColor(style.fills));
104
+ out.push({
105
+ role: n.type || "",
106
+ label: n.name || "",
107
+ text: n.characters || "",
108
+ identifier: n.id || "",
109
+ x: Math.round(bb.x - originX), y: Math.round(bb.y - originY),
110
+ w: Math.round(bb.width), h: Math.round(bb.height),
111
+ fontSize: style.fontSize || null,
112
+ fontFamily: style.fontFamily || style.fontPostScriptName || null,
113
+ color: fill || null,
114
+ itemSpacing: typeof n.itemSpacing === "number" ? n.itemSpacing : null,
115
+ paddings: {
116
+ top: n.paddingTop, right: n.paddingRight, bottom: n.paddingBottom, left: n.paddingLeft,
117
+ },
118
+ });
119
+ }
120
+ (n.children || []).forEach(visit);
121
+ };
122
+ visit(node);
123
+ return out;
124
+ }
125
+
126
+ function firstSolidColor(fills) {
127
+ if (!Array.isArray(fills)) return null;
128
+ for (const f of fills) {
129
+ if (f && f.type === "SOLID" && f.color) {
130
+ const { r, g, b } = f.color;
131
+ return rgbToHex(r * 255, g * 255, b * 255);
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ export function rgbToHex(r, g, b) {
138
+ const h = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
139
+ return `#${h(r)}${h(g)}${h(b)}`.toUpperCase();
140
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * design-check tool family - mock-mode vs Figma design audit primitives.
3
+ *
4
+ * Generic and platform-agnostic. The heavy orchestration (Figma fetch, variant
5
+ * matching, driving the app screen-to-screen, report assembly) lives in the
6
+ * multi-agent pipeline; this module exposes the mechanical steps:
7
+ *
8
+ * design_mock_detect - can this project be run in a mock mode?
9
+ * design_scenario_inventory - every state driver the mock build exposes (audit target set)
10
+ * design_mock_launch - launch the app in mock mode (UserDefaults launch arg / intent extra)
11
+ * design_ui_geometry - flat element bounding boxes of the current screen
12
+ * design_visual_compare - normalize + pixel/geometry/color/type diff vs a Figma render
13
+ * design_report - render HTML (+ optional PDF) from a report object
14
+ */
15
+
16
+ import { join } from "path";
17
+ import { existsSync, mkdirSync } from "fs";
18
+
19
+ import { detectMock } from "./mock-detect.js";
20
+ import { inventoryScenarios } from "./scenario-inventory.js";
21
+ import { flattenIosAxTree, flattenAndroidUiXml, flattenIdbDescribeAll } from "./geometry.js";
22
+ import { compareVisual } from "./visual-compare.js";
23
+ import { writeReport } from "./report.js";
24
+
25
+ export const DESIGN_TOOLS = [
26
+ {
27
+ name: "design_mock_detect",
28
+ description: "Detect whether a mobile project (iOS/Android) can be launched in a mock/stub mode for deterministic screen capture. Returns { supported: true|false|'debug-only', mechanism, activation, variantsHint[], evidence[] }. Signal-based and generic: naming conventions (*MockService), #if DEBUG DI branches, MockData fixtures, and well-known runtime switch keys (extendable via extra_keys). Use at the repo/module pick step to gate the audit.",
29
+ inputSchema: { type: "object", properties: {
30
+ repo_path: { type: "string", description: "Absolute path to the repo or module to scan" },
31
+ platform: { type: "string", enum: ["ios", "android"], description: "Optional; auto-detected if omitted" },
32
+ extra_keys: { type: "array", items: { type: "string" }, description: "Project-specific mock switch keys to also look for" },
33
+ }, required: ["repo_path"] },
34
+ },
35
+ {
36
+ name: "design_scenario_inventory",
37
+ description: "Enumerate every state driver a mock-mode build exposes, so a design audit has a countable target set instead of walking the UI until it feels done. Returns { targets:[{id,kind,label,screen,driver,cost,evidence}], plan[], relaunchCount, groups[], byKind, byCost, targetCount, ignored[], truncated }. Kinds: launch-arg (relaunch flags / boolean intent extras), scenario-case (cases of *Scenario / *Outcome / *MockCase enums), code-scenario (short uppercase codes typed at an entry field, read only from debug/mock code), fixture (MockData JSON), deep-link (custom-scheme URLs). Each target carries a cost ('relaunch' vs 'in-app') and `plan` batches them so ONE relaunch serves every in-app target on that screen - relaunchCount, not targetCount, is the real cost of full coverage. Signal-based and generic; every target has file+line evidence. Run right after design_mock_detect; feed targets + truncated into the design_report coverage gate.",
38
+ inputSchema: { type: "object", properties: {
39
+ repo_path: { type: "string", description: "Absolute path to the repo or module to scan" },
40
+ platform: { type: "string", enum: ["ios", "android"], description: "Optional; auto-detected if omitted" },
41
+ extra_launch_args: { type: "array", items: { type: "string" }, description: "Project-specific launch args no signal reveals" },
42
+ extra_targets: { type: "array", items: { type: "object" }, description: "Config-declared targets [{id,kind,label,screen,driver}]" },
43
+ ignore_targets: { type: "array", items: { type: "string" }, description: "Target ids to drop entirely (dead drivers, states retired in code). Returned in `ignored` - unlike a skip these never reach the coverage gate, so prefer a skip with a reason." },
44
+ summary: { type: "boolean", description: "Return counts, groups and plan without the per-target list. The full payload measured 71k characters for a 109-target module and exceeded the tool-result cap on every run; plan[].targetIds and groups[].ids still carry every id, so a run can be driven from the summary and fetch labels + file/line evidence later with summary:false." },
45
+ }, required: ["repo_path"] },
46
+ },
47
+ {
48
+ name: "design_mock_launch",
49
+ description: "Launch an already-installed app in mock mode using the activation returned by design_mock_detect. iOS: sets a UserDefaults key via simctl launch argument (Debug build required). Android: passes an intent boolean extra. For 'debug-only' projects there is no runtime switch - just launch the Debug build.",
50
+ inputSchema: { type: "object", properties: {
51
+ platform: { type: "string", enum: ["ios", "android"] },
52
+ bundle_id: { type: "string", description: "iOS bundle id" },
53
+ package_name: { type: "string", description: "Android package name" },
54
+ activity: { type: "string", description: "Android launcher activity (optional)" },
55
+ launch_arg: { type: "string", description: "iOS launch argument string e.g. '-debugMockMode YES'" },
56
+ intent_extra: { type: "string", description: "Android intent extra e.g. '--ez mockEnabled true'" },
57
+ device_id: { type: "string" },
58
+ }, required: ["platform"] },
59
+ },
60
+ {
61
+ name: "design_ui_geometry",
62
+ description: "Dump the current on-screen element bounding boxes as a flat list ({role,label,text,identifier,x,y,w,h}) plus the screen size, for measuring pixel-level spacing/position deltas against a Figma spec. iOS via the AX tree (points), Android via uiautomator (pixels). Use when a live screen has to be measured against a design. CAVEAT: these are accessibility boxes - glyph runs and hit areas - not layout containers, so a text element reports the ink extent and not the box it sits in. A 247pt design text container compared against its 78pt glyph box produces phantom findings; measure insets and gaps from the pixels (design_visual_compare) rather than differencing these boxes against design frames.",
63
+ inputSchema: { type: "object", properties: {
64
+ platform: { type: "string", enum: ["ios", "android"] },
65
+ device_id: { type: "string" },
66
+ max_depth: { type: "number", description: "iOS AX tree depth (default 12)" },
67
+ }, required: ["platform"] },
68
+ },
69
+ {
70
+ name: "design_visual_compare",
71
+ description: "Compare a Figma render PNG against a live device screenshot WITHOUT failing on size mismatch. RESPONSIVE BY DEFAULT: because the device and the Figma frame are usually different widths (e.g. 402pt vs 375pt), raw width/height deltas are meaningless - so it measures what a designer actually specs, in POINTS: per-element edge insets (left/right), the GAPS between consecutive elements (text <-> divider spacing), vertical placement, font size/family and text colour (sampled from the text ink, not the box average). Height is still reported but flagged advisory, since a content-hugging container changes height whenever the fixture differs; advisories never decide pass/fail. Pass responsive:false for the legacy absolute-delta behaviour. Writes figma/live/diff/overlay/side-by-side PNGs. Returns findings + image paths. For a BOTTOM SHEET / modal / partial overlay pass live_region (the sheet's bounds from design_ui_geometry): the Figma frame covers only the sheet while the capture is the whole screen, and stretching one onto the other misplaces every element inside it. Add expected_region to have the sheet's own edge insets reported as findings - a design showing a sheet flush to the edges is not satisfied by one floating inset from them.",
72
+ inputSchema: { type: "object", properties: {
73
+ figma_png: { type: "string", description: "Path to the Figma render PNG (design truth)" },
74
+ live_png: { type: "string", description: "Path to the live device screenshot" },
75
+ out_dir: { type: "string", description: "Directory to write comparison images into" },
76
+ label: { type: "string", description: "Screen/variant label (used in filenames)" },
77
+ crop_top_live: { type: "number", description: "Pixels to crop from top of live capture (status bar). Ignored when live_region is given, since the region crop already excludes chrome." },
78
+ crop_top_figma: { type: "number", description: "Pixels to crop from top of Figma render" },
79
+ live_region: { type: "object", description: "{x,y,w,h} of the region to compare, in live_screen units (iOS points / Android px) - e.g. a bottom sheet's container bounds from design_ui_geometry. Live coordinates are rebased onto this box." },
80
+ expected_region: { type: "object", description: "{x,y,w,h} where the design says that region should sit, same units. Enables 'inset' findings for each edge (left/right/top/bottom) beyond tolerance_px." },
81
+ figma_spec: { type: "array", items: { type: "object" }, description: "Flat Figma elements (from flattenFigmaNode): {x,y,w,h,label,text,color,fontSize,...}" },
82
+ live_geometry: { type: "array", items: { type: "object" }, description: "Flat live elements from design_ui_geometry" },
83
+ pairs: { type: "array", items: { type: "object" }, description: "Optional explicit [{figma, live}] matches; auto-matched by text/label if omitted" },
84
+ figma_frame: { type: "object", description: "{w,h} of the Figma frame the spec coords are in" },
85
+ live_screen: { type: "object", description: "{w,h} of the live screen the geometry coords are in" },
86
+ tolerance_px: { type: "number", description: "Ignore deltas ≤ this many px (default 2)" },
87
+ tolerance_pt: { type: "number", description: "Ignore inset/gap/position deltas ≤ this many POINTS (defaults to tolerance_px)" },
88
+ design_copy: { type: "array", items: { type: "string" }, description: "The design's source-of-truth UX-writing strings for this screen (from the Figma node's UX-writing annotations, in the run's language). Compared against the live text: an exact match verifies the string, a near match is reported as a copy defect (a truncated or stale translation), and a string with no counterpart is advisory because the capture may not show its state." },
89
+ edges: { type: "boolean", description: "Default true. For every element report its four LOCAL distances (to the nearest overlapping sibling on each side, else the frame edge) - the same measurement an on-device layout inspector shows. Computed identically on the design and live box lists, so a row displaced by a taller fixture above it stays silent while a genuinely changed padding surfaces on the exact edge, naming what it is measured against." },
90
+ relations: { type: "boolean", description: "Default true. Emit content-independent relational checks - centred-in-frame, left/right margin symmetry, shared centre lines (is the close button aligned with the title), and icon/control sizing. These are what actually answer 'is it built 1:1', because absolute Y moves with the mock content above it." },
91
+ responsive: { type: "boolean", description: "Default true. Measure edge insets + inter-element gaps in points instead of raw width/height in the stretched compare space. Requires figma_frame + live_screen. Turn off only to get legacy absolute deltas." },
92
+ verify_font_family: { type: "boolean", description: "Emit an advisory per text element naming the design's font family (not readable from the device, so it is a code-review item, never a measured delta). Default false." },
93
+ content_cardinality: { type: "boolean", description: "Demote the geometry findings a fixture-count difference explains (5 design rows vs 3 live rows shifts every container height and everything below it). Default true. Demoted findings are NOT removed - they become advisory with a shared root cause and are reported in `contentCardinality.demoted`. Copy, colour, typography and tap-target findings, and anything above the first differing group, are never demoted. Pass false to see the raw findings." },
94
+ color_tolerance: { type: "number", description: "Ignore color ΔE ≤ this (default 3)" },
95
+ perceptual_precision: { type: "number", description: "pixelmatch threshold 0..1 (default 0.1)" },
96
+ max_diff_pct: { type: "number", description: "Perceptual diff pass ceiling (default 1.0)" },
97
+ }, required: ["figma_png", "live_png", "out_dir"] },
98
+ },
99
+ {
100
+ name: "design_report",
101
+ description: "Render a design-audit report object into a self-contained HTML file (base64 images, CSS-positioned annotations over the live capture) and optionally PDF. Enforces a COVERAGE GATE: pass report.coverage = { targets, covered, skipped:[{id,group,reason}], floor? } and every target must be audited or skipped WITH a reason - otherwise the returned object carries coverage.gate='fail' + coverageError and the report renders a red gate banner. Human-facing wording comes from report.lang ('en' default, 'tr' built in) and per-key report.labels overrides. Returns written file paths + the coverage verdict. Confluence upload is handled by the caller.",
102
+ inputSchema: { type: "object", properties: {
103
+ report: { type: "object", description: "{ project, module, platform, figmaUrl, timestamp, lang, labels, coverage:{targets,covered,skipped:[{id,group,reason}],floor}, variants:[{name, figmaNodeId, perceptualPct, passed, compareSize, images:{figma,live,diff,overlay,sideBySide}, findings:[...], fixPrompt, componentRefs:[{name,image}] }] }" },
104
+ out_dir: { type: "string", description: "Directory to write report into" },
105
+ formats: { type: "array", items: { type: "string", enum: ["html", "pdf", "confluence"] }, description: "Default ['html']" },
106
+ }, required: ["report", "out_dir"] },
107
+ },
108
+ ];
109
+
110
+ function ensureDir(d) { if (d && !existsSync(d)) mkdirSync(d, { recursive: true }); return d; }
111
+
112
+ // Guard shell interpolation: bundle ids / package names / activities are strict
113
+ // identifiers; launch args / intent extras may not contain shell metacharacters.
114
+ function safeId(x, label) {
115
+ if (!x) return x;
116
+ if (!/^[A-Za-z0-9._/-]+$/.test(x)) throw new Error(`Invalid ${label}: ${x}`);
117
+ return x;
118
+ }
119
+ function safeArg(x, label) {
120
+ if (!x) return "";
121
+ if (/[;&|`$(){}<>\n\r\\"']/.test(x)) throw new Error(`Unsafe ${label} (shell metacharacters): ${x}`);
122
+ return x;
123
+ }
124
+
125
+ // ctx: { run, iosDevice, adbFlag, dumperScript }
126
+ export async function handleDesign(name, args, ctx) {
127
+ switch (name) {
128
+ case "design_mock_detect":
129
+ return JSON.stringify(detectMock({ repoPath: args.repo_path, platform: args.platform, extraKeys: args.extra_keys || [] }), null, 2);
130
+
131
+ case "design_scenario_inventory":
132
+ return JSON.stringify(inventoryScenarios({
133
+ repoPath: args.repo_path, platform: args.platform,
134
+ extraLaunchArgs: args.extra_launch_args || [], extraTargets: args.extra_targets || [],
135
+ ignoreTargets: args.ignore_targets || [], summary: args.summary === true,
136
+ }), null, 2);
137
+
138
+ case "design_mock_launch": {
139
+ if (args.platform === "ios") {
140
+ const d = ctx.iosDevice(args.device_id);
141
+ if (!args.bundle_id) return "ERROR: bundle_id required for iOS";
142
+ const bid = safeId(args.bundle_id, "bundle_id");
143
+ const arg = args.launch_arg ? ` ${safeArg(args.launch_arg, "launch_arg")}` : "";
144
+ return ctx.run(`xcrun simctl launch ${d} ${bid}${arg}`) || `Launched ${bid} (mock)`;
145
+ }
146
+ const df = ctx.adbFlag(args.device_id);
147
+ if (!args.package_name) return "ERROR: package_name required for Android";
148
+ const pkg = safeId(args.package_name, "package_name");
149
+ const comp = args.activity ? `${pkg}/${safeId(args.activity, "activity")}` : `$(adb ${df} shell cmd package resolve-activity --brief ${pkg} | tail -1)`;
150
+ const extra = args.intent_extra ? ` ${safeArg(args.intent_extra, "intent_extra")}` : "";
151
+ return ctx.run(`adb ${df} shell am start -n ${comp}${extra}`) || `Launched ${pkg} (mock)`;
152
+ }
153
+
154
+ case "design_ui_geometry": {
155
+ if (args.platform === "ios") {
156
+ const d = ctx.iosDevice(args.device_id);
157
+ // Prefer idb describe-all (headless, no Simulator GUI). Fall back to the AX dumper.
158
+ if (ctx.hasIdb) {
159
+ const raw = ctx.idb(`ui describe-all --udid ${d} --json`, { timeout: 20000 });
160
+ const elements = flattenIdbDescribeAll(raw);
161
+ if (elements.length) {
162
+ const app = elements.find((e) => (e.role || "").includes("Application"));
163
+ let sw = 0, sh = 0;
164
+ try { const arr = JSON.parse(raw.trim().startsWith("[") ? raw : "[" + raw.trim().split("\n").join(",") + "]"); const a = arr.find((x) => (x.type === "Application")); if (a && a.frame) { sw = Math.round(a.frame.width); sh = Math.round(a.frame.height); } } catch {}
165
+ return JSON.stringify({ platform: "ios", unit: "points", source: "idb", screen: { w: sw, h: sh }, count: elements.length, elements }, null, 2);
166
+ }
167
+ }
168
+ const depth = args.max_depth || 12;
169
+ const raw = ctx.run(`swift "${ctx.dumperScript}" ${depth}`, { timeout: 15000 });
170
+ let tree; try { tree = JSON.parse(raw); } catch { return `ERROR: idb unavailable and AX dumper failed: ${String(raw).slice(0, 200)}`; }
171
+ const elements = flattenIosAxTree(tree);
172
+ const rf = tree.frame || {};
173
+ return JSON.stringify({ platform: "ios", unit: "points", source: "ax-dumper", screen: { w: Math.round(rf.w || 0), h: Math.round(rf.h || 0) }, count: elements.length, elements }, null, 2);
174
+ }
175
+ const df = ctx.adbFlag(args.device_id);
176
+ ctx.run(`adb ${df} shell uiautomator dump /sdcard/_design_ui.xml`);
177
+ const xml = ctx.run(`adb ${df} shell cat /sdcard/_design_ui.xml`);
178
+ const elements = flattenAndroidUiXml(xml);
179
+ const sizeRaw = ctx.run(`adb ${df} shell wm size`);
180
+ const sm = /(\d+)x(\d+)/.exec(sizeRaw || "");
181
+ return JSON.stringify({ platform: "android", unit: "pixels", screen: sm ? { w: +sm[1], h: +sm[2] } : null, count: elements.length, elements }, null, 2);
182
+ }
183
+
184
+ case "design_visual_compare": {
185
+ ensureDir(args.out_dir);
186
+ const res = await compareVisual({
187
+ figmaPng: args.figma_png, livePng: args.live_png, outDir: args.out_dir, label: args.label,
188
+ cropTopLive: args.crop_top_live || 0, cropTopFigma: args.crop_top_figma || 0,
189
+ figmaSpec: args.figma_spec || [], liveGeometry: args.live_geometry || [], pairs: args.pairs || null,
190
+ figmaFrame: args.figma_frame || null, liveScreen: args.live_screen || null,
191
+ liveRegion: args.live_region || null, expectedRegion: args.expected_region || null,
192
+ tolerancePx: args.tolerance_px, colorTolerance: args.color_tolerance,
193
+ tolerancePt: args.tolerance_pt != null ? args.tolerance_pt : null,
194
+ responsive: args.responsive !== false,
195
+ contentCardinality: args.content_cardinality !== false,
196
+ relations: args.relations !== false,
197
+ edges: args.edges !== false,
198
+ designCopy: args.design_copy || null,
199
+ verifyFontFamily: args.verify_font_family === true,
200
+ perceptualPrecision: args.perceptual_precision, maxDiffPct: args.max_diff_pct,
201
+ });
202
+ return JSON.stringify(res, null, 2);
203
+ }
204
+
205
+ case "design_report": {
206
+ ensureDir(args.out_dir);
207
+ const out = await writeReport({ report: args.report, outDir: args.out_dir, formats: args.formats || ["html"] });
208
+ return JSON.stringify(out, null, 2);
209
+ }
210
+
211
+ default: return null;
212
+ }
213
+ }