@mmerterden/multi-agent-toolkit-mcp 3.0.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 +871 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/README.tr.md +358 -0
- package/index.js +1725 -0
- package/package.json +89 -0
- package/tools/crash-logs/index.js +29 -0
- package/tools/design-check/content-cardinality.js +204 -0
- package/tools/design-check/geometry.js +140 -0
- package/tools/design-check/index.js +219 -0
- package/tools/design-check/mock-detect.js +213 -0
- package/tools/design-check/report.js +596 -0
- package/tools/design-check/scan.js +91 -0
- package/tools/design-check/scenario-inventory.js +598 -0
- package/tools/design-check/visual-compare.js +961 -0
- package/tools/ios-app-store-audit/context.js +181 -0
- package/tools/ios-app-store-audit/data/apple-required-sdks.json +32 -0
- package/tools/ios-app-store-audit/data/debug-tools-blocklist.json +133 -0
- package/tools/ios-app-store-audit/index.js +164 -0
- package/tools/ios-app-store-audit/models.js +57 -0
- package/tools/ios-app-store-audit/rules/asset-validation.js +72 -0
- package/tools/ios-app-store-audit/rules/binary-size.js +70 -0
- package/tools/ios-app-store-audit/rules/code-signing.js +95 -0
- package/tools/ios-app-store-audit/rules/dead-reference.js +131 -0
- package/tools/ios-app-store-audit/rules/debug-tool-leak.js +185 -0
- package/tools/ios-app-store-audit/rules/duplicate-resource.js +130 -0
- package/tools/ios-app-store-audit/rules/embedded-sdk.js +126 -0
- package/tools/ios-app-store-audit/rules/entitlement.js +105 -0
- package/tools/ios-app-store-audit/rules/extension-signing.js +105 -0
- package/tools/ios-app-store-audit/rules/info-plist.js +158 -0
- package/tools/ios-app-store-audit/rules/ipv6-compliance.js +101 -0
- package/tools/ios-app-store-audit/rules/privacy-manifest.js +121 -0
- package/tools/ios-app-store-audit/rules/production-hygiene.js +237 -0
- package/tools/ios-app-store-audit/rules/provisioning-profile.js +127 -0
- package/tools/ios-app-store-audit/rules/required-reason-api.js +123 -0
- package/tools/ios-app-store-audit/rules/sdk-floor.js +104 -0
- package/tools/ios-app-store-audit/rules/swift-abi.js +64 -0
- package/tools/ios-app-store-audit/rules/team-id.js +62 -0
- package/tools/ios-testflight/index.js +489 -0
- package/tools/ui-inspect/index.js +57 -0
- package/ui-tree-dumper.swift +122 -0
|
@@ -0,0 +1,219 @@
|
|
|
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
|
+
// Single-quote for POSIX sh; the swift dumper path is interpolated into a shell
|
|
26
|
+
// command via ctx.run. Kept local so this module stays self-contained.
|
|
27
|
+
function shq(value) {
|
|
28
|
+
return `'${String(value ?? "").replace(/'/g, "'\\''")}'`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DESIGN_TOOLS = [
|
|
32
|
+
{
|
|
33
|
+
name: "design_mock_detect",
|
|
34
|
+
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.",
|
|
35
|
+
inputSchema: { type: "object", properties: {
|
|
36
|
+
repo_path: { type: "string", description: "Absolute path to the repo or module to scan" },
|
|
37
|
+
platform: { type: "string", enum: ["ios", "android"], description: "Optional; auto-detected if omitted" },
|
|
38
|
+
extra_keys: { type: "array", items: { type: "string" }, description: "Project-specific mock switch keys to also look for" },
|
|
39
|
+
}, required: ["repo_path"] },
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "design_scenario_inventory",
|
|
43
|
+
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.",
|
|
44
|
+
inputSchema: { type: "object", properties: {
|
|
45
|
+
repo_path: { type: "string", description: "Absolute path to the repo or module to scan" },
|
|
46
|
+
platform: { type: "string", enum: ["ios", "android"], description: "Optional; auto-detected if omitted" },
|
|
47
|
+
extra_launch_args: { type: "array", items: { type: "string" }, description: "Project-specific launch args no signal reveals" },
|
|
48
|
+
extra_targets: { type: "array", items: { type: "object" }, description: "Config-declared targets [{id,kind,label,screen,driver}]" },
|
|
49
|
+
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." },
|
|
50
|
+
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." },
|
|
51
|
+
}, required: ["repo_path"] },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "design_mock_launch",
|
|
55
|
+
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.",
|
|
56
|
+
inputSchema: { type: "object", properties: {
|
|
57
|
+
platform: { type: "string", enum: ["ios", "android"] },
|
|
58
|
+
bundle_id: { type: "string", description: "iOS bundle id" },
|
|
59
|
+
package_name: { type: "string", description: "Android package name" },
|
|
60
|
+
activity: { type: "string", description: "Android launcher activity (optional)" },
|
|
61
|
+
launch_arg: { type: "string", description: "iOS launch argument string e.g. '-debugMockMode YES'" },
|
|
62
|
+
intent_extra: { type: "string", description: "Android intent extra e.g. '--ez mockEnabled true'" },
|
|
63
|
+
device_id: { type: "string" },
|
|
64
|
+
}, required: ["platform"] },
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "design_ui_geometry",
|
|
68
|
+
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.",
|
|
69
|
+
inputSchema: { type: "object", properties: {
|
|
70
|
+
platform: { type: "string", enum: ["ios", "android"] },
|
|
71
|
+
device_id: { type: "string" },
|
|
72
|
+
max_depth: { type: "number", description: "iOS AX tree depth (default 12)" },
|
|
73
|
+
}, required: ["platform"] },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "design_visual_compare",
|
|
77
|
+
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.",
|
|
78
|
+
inputSchema: { type: "object", properties: {
|
|
79
|
+
figma_png: { type: "string", description: "Path to the Figma render PNG (design truth)" },
|
|
80
|
+
live_png: { type: "string", description: "Path to the live device screenshot" },
|
|
81
|
+
out_dir: { type: "string", description: "Directory to write comparison images into" },
|
|
82
|
+
label: { type: "string", description: "Screen/variant label (used in filenames)" },
|
|
83
|
+
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." },
|
|
84
|
+
crop_top_figma: { type: "number", description: "Pixels to crop from top of Figma render" },
|
|
85
|
+
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." },
|
|
86
|
+
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." },
|
|
87
|
+
figma_spec: { type: "array", items: { type: "object" }, description: "Flat Figma elements (from flattenFigmaNode): {x,y,w,h,label,text,color,fontSize,...}" },
|
|
88
|
+
live_geometry: { type: "array", items: { type: "object" }, description: "Flat live elements from design_ui_geometry" },
|
|
89
|
+
pairs: { type: "array", items: { type: "object" }, description: "Optional explicit [{figma, live}] matches; auto-matched by text/label if omitted" },
|
|
90
|
+
figma_frame: { type: "object", description: "{w,h} of the Figma frame the spec coords are in" },
|
|
91
|
+
live_screen: { type: "object", description: "{w,h} of the live screen the geometry coords are in" },
|
|
92
|
+
tolerance_px: { type: "number", description: "Ignore deltas ≤ this many px (default 2)" },
|
|
93
|
+
tolerance_pt: { type: "number", description: "Ignore inset/gap/position deltas ≤ this many POINTS (defaults to tolerance_px)" },
|
|
94
|
+
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." },
|
|
95
|
+
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." },
|
|
96
|
+
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." },
|
|
97
|
+
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." },
|
|
98
|
+
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." },
|
|
99
|
+
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." },
|
|
100
|
+
color_tolerance: { type: "number", description: "Ignore color ΔE ≤ this (default 3)" },
|
|
101
|
+
perceptual_precision: { type: "number", description: "pixelmatch threshold 0..1 (default 0.1)" },
|
|
102
|
+
max_diff_pct: { type: "number", description: "Perceptual diff pass ceiling (default 1.0)" },
|
|
103
|
+
}, required: ["figma_png", "live_png", "out_dir"] },
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "design_report",
|
|
107
|
+
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.",
|
|
108
|
+
inputSchema: { type: "object", properties: {
|
|
109
|
+
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}] }] }" },
|
|
110
|
+
out_dir: { type: "string", description: "Directory to write report into" },
|
|
111
|
+
formats: { type: "array", items: { type: "string", enum: ["html", "pdf", "confluence"] }, description: "Default ['html']" },
|
|
112
|
+
}, required: ["report", "out_dir"] },
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
function ensureDir(d) { if (d && !existsSync(d)) mkdirSync(d, { recursive: true }); return d; }
|
|
117
|
+
|
|
118
|
+
// Guard shell interpolation: bundle ids / package names / activities are strict
|
|
119
|
+
// identifiers; launch args / intent extras may not contain shell metacharacters.
|
|
120
|
+
function safeId(x, label) {
|
|
121
|
+
if (!x) return x;
|
|
122
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(x)) throw new Error(`Invalid ${label}: ${x}`);
|
|
123
|
+
return x;
|
|
124
|
+
}
|
|
125
|
+
function safeArg(x, label) {
|
|
126
|
+
if (!x) return "";
|
|
127
|
+
if (/[;&|`$(){}<>\n\r\\"']/.test(x)) throw new Error(`Unsafe ${label} (shell metacharacters): ${x}`);
|
|
128
|
+
return x;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ctx: { run, iosDevice, adbFlag, dumperScript }
|
|
132
|
+
export async function handleDesign(name, args, ctx) {
|
|
133
|
+
switch (name) {
|
|
134
|
+
case "design_mock_detect":
|
|
135
|
+
return JSON.stringify(detectMock({ repoPath: args.repo_path, platform: args.platform, extraKeys: args.extra_keys || [] }), null, 2);
|
|
136
|
+
|
|
137
|
+
case "design_scenario_inventory":
|
|
138
|
+
return JSON.stringify(inventoryScenarios({
|
|
139
|
+
repoPath: args.repo_path, platform: args.platform,
|
|
140
|
+
extraLaunchArgs: args.extra_launch_args || [], extraTargets: args.extra_targets || [],
|
|
141
|
+
ignoreTargets: args.ignore_targets || [], summary: args.summary === true,
|
|
142
|
+
}), null, 2);
|
|
143
|
+
|
|
144
|
+
case "design_mock_launch": {
|
|
145
|
+
if (args.platform === "ios") {
|
|
146
|
+
const d = ctx.iosDevice(args.device_id);
|
|
147
|
+
if (!args.bundle_id) return "ERROR: bundle_id required for iOS";
|
|
148
|
+
const bid = safeId(args.bundle_id, "bundle_id");
|
|
149
|
+
const arg = args.launch_arg ? ` ${safeArg(args.launch_arg, "launch_arg")}` : "";
|
|
150
|
+
return ctx.run(`xcrun simctl launch ${d} ${bid}${arg}`) || `Launched ${bid} (mock)`;
|
|
151
|
+
}
|
|
152
|
+
const df = ctx.adbFlag(args.device_id);
|
|
153
|
+
if (!args.package_name) return "ERROR: package_name required for Android";
|
|
154
|
+
const pkg = safeId(args.package_name, "package_name");
|
|
155
|
+
const comp = args.activity ? `${pkg}/${safeId(args.activity, "activity")}` : `$(adb ${df} shell cmd package resolve-activity --brief ${pkg} | tail -1)`;
|
|
156
|
+
const extra = args.intent_extra ? ` ${safeArg(args.intent_extra, "intent_extra")}` : "";
|
|
157
|
+
return ctx.run(`adb ${df} shell am start -n ${comp}${extra}`) || `Launched ${pkg} (mock)`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case "design_ui_geometry": {
|
|
161
|
+
if (args.platform === "ios") {
|
|
162
|
+
const d = ctx.iosDevice(args.device_id);
|
|
163
|
+
// Prefer idb describe-all (headless, no Simulator GUI). Fall back to the AX dumper.
|
|
164
|
+
if (ctx.hasIdb) {
|
|
165
|
+
const raw = ctx.idb(`ui describe-all --udid ${d} --json`, { timeout: 20000 });
|
|
166
|
+
const elements = flattenIdbDescribeAll(raw);
|
|
167
|
+
if (elements.length) {
|
|
168
|
+
const app = elements.find((e) => (e.role || "").includes("Application"));
|
|
169
|
+
let sw = 0, sh = 0;
|
|
170
|
+
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 {}
|
|
171
|
+
return JSON.stringify({ platform: "ios", unit: "points", source: "idb", screen: { w: sw, h: sh }, count: elements.length, elements }, null, 2);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const depth = Number(args.max_depth) || 12;
|
|
175
|
+
const raw = ctx.run(`swift ${shq(ctx.dumperScript)} ${depth}`, { timeout: 15000 });
|
|
176
|
+
let tree; try { tree = JSON.parse(raw); } catch { return `ERROR: idb unavailable and AX dumper failed: ${String(raw).slice(0, 200)}`; }
|
|
177
|
+
const elements = flattenIosAxTree(tree);
|
|
178
|
+
const rf = tree.frame || {};
|
|
179
|
+
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);
|
|
180
|
+
}
|
|
181
|
+
const df = ctx.adbFlag(args.device_id);
|
|
182
|
+
ctx.run(`adb ${df} shell uiautomator dump /sdcard/_design_ui.xml`);
|
|
183
|
+
const xml = ctx.run(`adb ${df} shell cat /sdcard/_design_ui.xml`);
|
|
184
|
+
const elements = flattenAndroidUiXml(xml);
|
|
185
|
+
const sizeRaw = ctx.run(`adb ${df} shell wm size`);
|
|
186
|
+
const sm = /(\d+)x(\d+)/.exec(sizeRaw || "");
|
|
187
|
+
return JSON.stringify({ platform: "android", unit: "pixels", screen: sm ? { w: +sm[1], h: +sm[2] } : null, count: elements.length, elements }, null, 2);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case "design_visual_compare": {
|
|
191
|
+
ensureDir(args.out_dir);
|
|
192
|
+
const res = await compareVisual({
|
|
193
|
+
figmaPng: args.figma_png, livePng: args.live_png, outDir: args.out_dir, label: args.label,
|
|
194
|
+
cropTopLive: args.crop_top_live || 0, cropTopFigma: args.crop_top_figma || 0,
|
|
195
|
+
figmaSpec: args.figma_spec || [], liveGeometry: args.live_geometry || [], pairs: args.pairs || null,
|
|
196
|
+
figmaFrame: args.figma_frame || null, liveScreen: args.live_screen || null,
|
|
197
|
+
liveRegion: args.live_region || null, expectedRegion: args.expected_region || null,
|
|
198
|
+
tolerancePx: args.tolerance_px, colorTolerance: args.color_tolerance,
|
|
199
|
+
tolerancePt: args.tolerance_pt != null ? args.tolerance_pt : null,
|
|
200
|
+
responsive: args.responsive !== false,
|
|
201
|
+
contentCardinality: args.content_cardinality !== false,
|
|
202
|
+
relations: args.relations !== false,
|
|
203
|
+
edges: args.edges !== false,
|
|
204
|
+
designCopy: args.design_copy || null,
|
|
205
|
+
verifyFontFamily: args.verify_font_family === true,
|
|
206
|
+
perceptualPrecision: args.perceptual_precision, maxDiffPct: args.max_diff_pct,
|
|
207
|
+
});
|
|
208
|
+
return JSON.stringify(res, null, 2);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case "design_report": {
|
|
212
|
+
ensureDir(args.out_dir);
|
|
213
|
+
const out = await writeReport({ report: args.report, outDir: args.out_dir, formats: args.formats || ["html"] });
|
|
214
|
+
return JSON.stringify(out, null, 2);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
default: return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design_mock_detect - decide whether a mobile project can be launched in a
|
|
3
|
+
* mock/stub mode so its screens can be captured deterministically.
|
|
4
|
+
*
|
|
5
|
+
* Fully generic: no project, owner, or repo name is hardcoded. Detection is
|
|
6
|
+
* signal-based (naming conventions + DI patterns + well-known switch NAMES),
|
|
7
|
+
* and the caller may inject project-specific switch keys via opts.extraKeys.
|
|
8
|
+
* Scanning uses ripgrep when available (fast, .gitignore-aware) and falls back
|
|
9
|
+
* to a bounded node walk otherwise.
|
|
10
|
+
*
|
|
11
|
+
* returns:
|
|
12
|
+
* {
|
|
13
|
+
* supported: true | false | "debug-only",
|
|
14
|
+
* platform: "ios" | "android" | "unknown",
|
|
15
|
+
* mechanism: "userdefaults-launcharg" | "interceptor" | "debug-di" | null,
|
|
16
|
+
* activation: { kind, key?, value?, launchArg?/intentExtra?, buildConfig },
|
|
17
|
+
* variantsHint: [ ... ],
|
|
18
|
+
* evidence: [ { signal, file, line, snippet } ],
|
|
19
|
+
* reason: <human string when unsupported>
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync, existsSync } from "fs";
|
|
24
|
+
import { basename } from "path";
|
|
25
|
+
|
|
26
|
+
import { hasRg, rg, walk, rel, detectPlatform, SRC_EXT } from "./scan.js";
|
|
27
|
+
|
|
28
|
+
const KNOWN_MOCK_KEYS = [
|
|
29
|
+
"debugMockMode", "mockServiceIsActive", "mock-service-is-active",
|
|
30
|
+
"traceweave.mock.enabled", "isMockEnabled", "isMockMode", "useMockData",
|
|
31
|
+
"useMockServices", "MOCK_MODE", "UITEST_MOCK", "mockEnabled",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function hasLaunchableAndroidApp(repoPath) {
|
|
35
|
+
if (hasRg()) {
|
|
36
|
+
try {
|
|
37
|
+
const app = rg("com\\.android\\.application|applicationId\\s+[\"']", repoPath,
|
|
38
|
+
{ globs: ["-g", "*.gradle", "-g", "*.gradle.kts"] });
|
|
39
|
+
if (app.length) return true;
|
|
40
|
+
const launcher = rg("android\\.intent\\.category\\.LAUNCHER", repoPath, { globs: ["-g", "AndroidManifest.xml"] });
|
|
41
|
+
return launcher.length > 0;
|
|
42
|
+
} catch { /* fall through */ }
|
|
43
|
+
}
|
|
44
|
+
let launchable = false;
|
|
45
|
+
walk(repoPath, (f) => {
|
|
46
|
+
const b = basename(f);
|
|
47
|
+
if (b === "build.gradle" || b === "build.gradle.kts" || b === "AndroidManifest.xml") {
|
|
48
|
+
let t = ""; try { t = readFileSync(f, "utf-8"); } catch { return; }
|
|
49
|
+
if (/com\.android\.application|applicationId\s+["']|android\.intent\.category\.LAUNCHER/.test(t)) launchable = true;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return launchable;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Core signal scan. Populates `state` with runtimeKey/interceptor/debugDI/etc.
|
|
56
|
+
function scanWithRg(repoPath, keys, state) {
|
|
57
|
+
const keyAlt = keys.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
58
|
+
// 1. known/injected runtime switch keys
|
|
59
|
+
for (const hit of rg(`["'](?:${keyAlt})["']`, repoPath)) {
|
|
60
|
+
const km = /["']([^"']+)["']/.exec(hit.text);
|
|
61
|
+
const k = km ? km[1] : "?";
|
|
62
|
+
if (/traceweave\.mock|URLProtocol|interceptor/i.test(hit.text) || k.includes("traceweave")) state.interceptor = true;
|
|
63
|
+
state.runtimeKey = state.runtimeKey || k;
|
|
64
|
+
state.push(`runtime-switch:${k}`, hit, repoPath);
|
|
65
|
+
}
|
|
66
|
+
// 2. generic AppStorage/UserDefaults "mock" bool key
|
|
67
|
+
if (!state.runtimeKey) {
|
|
68
|
+
for (const hit of rg(`(?:@AppStorage|forKey|defaults\\.\\w+)\\s*\\(?\\s*["'][A-Za-z0-9_.\\-]*[Mm]ock[A-Za-z0-9_.\\-]*["']`, repoPath)) {
|
|
69
|
+
const km = /["']([A-Za-z0-9_.\-]*[Mm]ock[A-Za-z0-9_.\-]*)["']/.exec(hit.text);
|
|
70
|
+
if (km) { state.runtimeKey = km[1]; state.push(`runtime-switch:${km[1]}`, hit, repoPath); break; }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// 2b. launch-argument mock (ProcessInfo.processInfo.arguments.contains) + arg constants
|
|
74
|
+
if (!state.runtimeKey) {
|
|
75
|
+
const la = rg("ProcessInfo\\.processInfo\\.arguments\\.contains", repoPath);
|
|
76
|
+
if (la.length) {
|
|
77
|
+
const argHits = rg("static let \\w+\\s*=\\s*\"(-[A-Za-z]*[Mm]ock[A-Za-z0-9]*)\"", repoPath);
|
|
78
|
+
const am = argHits.length ? /"(-[A-Za-z0-9]+)"/.exec(argHits[0].text) : null;
|
|
79
|
+
state.runtimeKey = am ? am[1] : "-Mock";
|
|
80
|
+
state.launchArg = true;
|
|
81
|
+
state.push("runtime-launcharg", la[0], repoPath);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// 2c. constant-defined UserDefaults mock key (string literal with 'mock', not inline @AppStorage)
|
|
85
|
+
if (!state.runtimeKey) {
|
|
86
|
+
const ck = rg("static let \\w+\\s*=\\s*\"[A-Za-z0-9_.\\-]*[Mm]ock[A-Za-z0-9_.\\-]*\"", repoPath);
|
|
87
|
+
if (ck.length) {
|
|
88
|
+
const cm = /"([A-Za-z0-9_.\-]*[Mm]ock[A-Za-z0-9_.\-]*)"/.exec(ck[0].text);
|
|
89
|
+
if (cm) { state.runtimeKey = cm[1]; state.push(`runtime-switch:${cm[1]}`, ck[0], repoPath); }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// 3. mock type declarations
|
|
93
|
+
const typeHits = rg(`\\b(?:class|struct|final class|object|enum)\\s+(?:Mock[A-Za-z0-9]+|[A-Za-z0-9]+Mock(?:Service|NetworkService|Repository|Provider|DataSource))\\b`, repoPath);
|
|
94
|
+
state.mockTypeCount = typeHits.length;
|
|
95
|
+
typeHits.slice(0, 8).forEach((h) => {
|
|
96
|
+
const tm = /\s(Mock[A-Za-z0-9]+|[A-Za-z0-9]+Mock(?:Service|NetworkService|Repository|Provider|DataSource))\b/.exec(h.text);
|
|
97
|
+
state.push(`mock-type:${tm ? tm[1] : "Mock"}`, h, repoPath);
|
|
98
|
+
});
|
|
99
|
+
// 4. #if DEBUG return Mock (compile-time DI)
|
|
100
|
+
const diHits = rg(`#if\\s+DEBUG.{0,400}?return\\s+Mock[A-Za-z0-9]+`, repoPath, { multiline: true });
|
|
101
|
+
if (diHits.length) { state.debugDI = true; state.push("debug-di", diHits[0], repoPath); }
|
|
102
|
+
// 5. MockData fixtures → variant hints
|
|
103
|
+
for (const f of rg("**/MockData/**/*.json", repoPath, { files: true, globs: [] })) {
|
|
104
|
+
const b = basename(f.file);
|
|
105
|
+
const vm = /([A-Za-z0-9]+)_mock\.json$|mock_([A-Za-z0-9]+)\.json$/i.exec(b);
|
|
106
|
+
state.variants.add((vm ? (vm[1] || vm[2]) : b.replace(/\.json$/i, "")).toLowerCase());
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function scanWithWalk(repoPath, keys, state) {
|
|
111
|
+
const keyRegexes = keys.map((k) => ({ k, re: new RegExp(`["']${k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`) }));
|
|
112
|
+
const genericRe = /(?:@AppStorage|forKey|defaults\.\w+)\s*\(?\s*["']([A-Za-z0-9_.\-]*[Mm]ock[A-Za-z0-9_.\-]*)["']/;
|
|
113
|
+
const typeRe = /\b(?:class|struct|final class|object|enum)\s+(Mock[A-Za-z0-9]+|[A-Za-z0-9]+Mock(?:Service|NetworkService|Repository|Provider|DataSource))\b/;
|
|
114
|
+
const diRe = /#if\s+DEBUG[\s\S]{0,400}?\breturn\s+Mock[A-Za-z0-9]+/;
|
|
115
|
+
const varRe = /([A-Za-z0-9]+)_mock\.json$|mock_([A-Za-z0-9]+)\.json$/i;
|
|
116
|
+
const laUseRe = /ProcessInfo\.processInfo\.arguments\.contains/;
|
|
117
|
+
const laConstRe = /static let \w+\s*=\s*"(-[A-Za-z]*[Mm]ock[A-Za-z0-9]*)"/;
|
|
118
|
+
const constKeyRe = /static let \w+\s*=\s*"([A-Za-z0-9_.\-]*[Mm]ock[A-Za-z0-9_.\-]*)"/;
|
|
119
|
+
walk(repoPath, (file) => {
|
|
120
|
+
const b = basename(file);
|
|
121
|
+
const ext = "." + (b.split(".").pop() || "");
|
|
122
|
+
if (ext === ".json" && /\/MockData\//.test(file)) {
|
|
123
|
+
const m = varRe.exec(b);
|
|
124
|
+
state.variants.add((m ? (m[1] || m[2]) : b.replace(/\.json$/i, "")).toLowerCase());
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!SRC_EXT.has(ext)) return;
|
|
128
|
+
let txt = ""; try { txt = readFileSync(file, "utf-8"); } catch { return; }
|
|
129
|
+
if (!/[Mm]ock/.test(txt)) return;
|
|
130
|
+
const mkHit = (idx) => { const line = txt.slice(0, idx).split("\n").length; return { file, line, text: (txt.split("\n")[line - 1] || "").trim() }; };
|
|
131
|
+
for (const { k, re } of keyRegexes) {
|
|
132
|
+
const m = re.exec(txt);
|
|
133
|
+
if (m) { if (/traceweave\.mock|URLProtocol|interceptor/i.test(txt) || k.includes("traceweave")) state.interceptor = true; state.runtimeKey = state.runtimeKey || k; state.push(`runtime-switch:${k}`, mkHit(m.index), repoPath); }
|
|
134
|
+
}
|
|
135
|
+
if (!state.runtimeKey) { const gm = genericRe.exec(txt); if (gm) { state.runtimeKey = gm[1]; state.push(`runtime-switch:${gm[1]}`, mkHit(gm.index), repoPath); } }
|
|
136
|
+
// launch-argument mock (ProcessInfo.arguments.contains) + arg constant (may be in different files)
|
|
137
|
+
if (laUseRe.test(txt)) state.launchArgUsed = true;
|
|
138
|
+
if (!state.launchArgConst) { const lm = laConstRe.exec(txt); if (lm) { state.launchArgConst = lm[1]; state.launchArgHit = mkHit(lm.index); } }
|
|
139
|
+
if (!state.constKeyCand) { const cm = constKeyRe.exec(txt); if (cm && !cm[1].startsWith("-")) { state.constKeyCand = cm[1]; state.constKeyHit = mkHit(cm.index); } }
|
|
140
|
+
const dm = diRe.exec(txt); if (dm) { state.debugDI = true; state.push("debug-di", mkHit(dm.index), repoPath); }
|
|
141
|
+
const tm = typeRe.exec(txt); if (tm) { state.mockTypeCount++; if (state.mockTypeCount <= 8) state.push(`mock-type:${tm[1]}`, mkHit(tm.index), repoPath); }
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function detectMock({ repoPath, platform, extraKeys = [] } = {}) {
|
|
146
|
+
if (!repoPath || !existsSync(repoPath)) {
|
|
147
|
+
return { supported: false, platform: "unknown", mechanism: null, reason: `repoPath not found: ${repoPath}`, evidence: [] };
|
|
148
|
+
}
|
|
149
|
+
const plat = platform || detectPlatform(repoPath);
|
|
150
|
+
const keys = [...new Set([...KNOWN_MOCK_KEYS, ...extraKeys])];
|
|
151
|
+
|
|
152
|
+
const evidence = [];
|
|
153
|
+
const state = {
|
|
154
|
+
runtimeKey: null, launchArg: false, interceptor: false, debugDI: false, mockTypeCount: 0,
|
|
155
|
+
variants: new Set(),
|
|
156
|
+
push(signal, hit, rp) { evidence.push({ signal, file: rel(rp, hit.file), line: hit.line, snippet: (hit.text || "").slice(0, 160) }); },
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
let scanned = false;
|
|
160
|
+
if (hasRg()) { try { scanWithRg(repoPath, keys, state); scanned = true; } catch { /* fall back */ } }
|
|
161
|
+
if (!scanned) scanWithWalk(repoPath, keys, state);
|
|
162
|
+
|
|
163
|
+
// Resolve launch-argument / constant-defined mock switches (either scan sets the flags).
|
|
164
|
+
if (!state.runtimeKey) {
|
|
165
|
+
if (state.launchArgUsed && state.launchArgConst) {
|
|
166
|
+
state.runtimeKey = state.launchArgConst; state.launchArg = true;
|
|
167
|
+
if (state.launchArgHit) state.push(`runtime-launcharg:${state.launchArgConst}`, state.launchArgHit, repoPath);
|
|
168
|
+
} else if (state.constKeyCand) {
|
|
169
|
+
state.runtimeKey = state.constKeyCand;
|
|
170
|
+
if (state.constKeyHit) state.push(`runtime-switch:${state.constKeyCand}`, state.constKeyHit, repoPath);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const variantsHint = [...state.variants].slice(0, 40);
|
|
175
|
+
|
|
176
|
+
// If platform is still unknown, infer it from the evidence file extensions so
|
|
177
|
+
// the activation (iOS launch arg vs Android intent extra) is never mis-picked.
|
|
178
|
+
let effPlat = plat;
|
|
179
|
+
if (effPlat === "unknown") {
|
|
180
|
+
const exts = evidence.map((e) => (e.file || "").split(".").pop());
|
|
181
|
+
if (exts.includes("swift")) effPlat = "ios";
|
|
182
|
+
else if (exts.includes("kt") || exts.includes("kts")) effPlat = "android";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (effPlat === "android" && !hasLaunchableAndroidApp(repoPath)) {
|
|
186
|
+
return { supported: false, platform: effPlat, mechanism: null,
|
|
187
|
+
reason: "No launchable Android application module found (library set only) - nothing to run in mock mode.",
|
|
188
|
+
variantsHint, evidence };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (state.runtimeKey) {
|
|
192
|
+
const isFlag = state.launchArg || state.runtimeKey.startsWith("-");
|
|
193
|
+
const kind = state.interceptor ? "interceptor" : (isFlag ? "launch-argument" : "userdefaults-launcharg");
|
|
194
|
+
return {
|
|
195
|
+
supported: true, platform: effPlat, mechanism: kind,
|
|
196
|
+
activation: effPlat === "android"
|
|
197
|
+
? { kind, key: state.runtimeKey, value: "true", intentExtra: `--ez ${state.runtimeKey.replace(/^-/, "")} true`, buildConfig: "debug" }
|
|
198
|
+
: { kind, key: state.runtimeKey, value: isFlag ? "(flag)" : "YES", launchArg: isFlag ? state.runtimeKey : `-${state.runtimeKey} YES`, buildConfig: "Debug" },
|
|
199
|
+
variantsHint, evidence,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (state.debugDI || state.mockTypeCount > 0) {
|
|
203
|
+
return {
|
|
204
|
+
supported: "debug-only", platform: effPlat, mechanism: "debug-di",
|
|
205
|
+
activation: { kind: "build-config", buildConfig: effPlat === "android" ? "debug" : "Debug" },
|
|
206
|
+
note: "Mocks are compiled into the Debug build via #if DEBUG DI. No runtime switch → variants cannot be toggled at launch; only the default Debug state is comparable.",
|
|
207
|
+
variantsHint, evidence,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return { supported: false, platform: effPlat, mechanism: null,
|
|
211
|
+
reason: "No mock switch, mock service naming, or MockData fixtures detected - project has no mock support this tool can drive.",
|
|
212
|
+
variantsHint, evidence };
|
|
213
|
+
}
|