@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.
- package/CHANGELOG.md +708 -0
- package/LICENSE +21 -0
- package/README.md +387 -0
- package/index.js +1277 -0
- package/package.json +80 -0
- package/tools/design-check/content-cardinality.js +204 -0
- package/tools/design-check/geometry.js +140 -0
- package/tools/design-check/index.js +213 -0
- package/tools/design-check/mock-detect.js +213 -0
- package/tools/design-check/report.js +590 -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 +180 -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 +47 -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 +479 -0
- package/ui-tree-dumper.swift +122 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design_scenario_inventory - enumerate every state driver a mock-mode build
|
|
3
|
+
* exposes, so a design audit has a countable target set instead of "walk the UI
|
|
4
|
+
* until it feels done".
|
|
5
|
+
*
|
|
6
|
+
* The gap this closes: a screen reachable only by relaunching with a different
|
|
7
|
+
* launch argument, picking a different debug scenario case, or seeding a coded
|
|
8
|
+
* fixture is invisible to UI-walking. An auditor that only taps captures the
|
|
9
|
+
* linear happy path and silently drops the rest.
|
|
10
|
+
*
|
|
11
|
+
* Fully generic - no project, repo, or screen name is hardcoded. Every target
|
|
12
|
+
* comes from a source signal with file+line evidence:
|
|
13
|
+
*
|
|
14
|
+
* launch-arg "-MockSomething" argument literals (iOS) / boolean intent extras (Android)
|
|
15
|
+
* scenario-case cases of enums named *Scenario / *Outcome / *MockCase / *MockState
|
|
16
|
+
* code-scenario short uppercase switch codes ( case "AGN" ) used as scenario triggers
|
|
17
|
+
* fixture MockData/**\/*.json response fixtures
|
|
18
|
+
* deep-link custom-scheme URL literals
|
|
19
|
+
*
|
|
20
|
+
* returns:
|
|
21
|
+
* {
|
|
22
|
+
* platform, targetCount,
|
|
23
|
+
* targets: [ { id, kind, label, screen, driver, evidence:{file,line,snippet} } ],
|
|
24
|
+
* groups: [ { group, kind, ids[] } ],
|
|
25
|
+
* byKind: { <kind>: n },
|
|
26
|
+
* truncated: bool
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync, existsSync } from "fs";
|
|
31
|
+
import { basename, dirname, sep } from "path";
|
|
32
|
+
|
|
33
|
+
import { hasRg, rg, walk, rel, detectPlatform, SRC_EXT } from "./scan.js";
|
|
34
|
+
|
|
35
|
+
const MAX_TARGETS = 400;
|
|
36
|
+
|
|
37
|
+
// Path segments that describe plumbing, not a screen.
|
|
38
|
+
const INFRA_DIRS = new Set([
|
|
39
|
+
"sources", "source", "src", "classes", "common", "core", "shared", "base",
|
|
40
|
+
"debug", "mock", "mocks", "mockdata", "fixtures", "resources", "assets",
|
|
41
|
+
"helpers", "utils", "utilities", "extensions", "models", "model", "network",
|
|
42
|
+
"networking", "services", "service", "managers", "manager", "main", "app",
|
|
43
|
+
"java", "kotlin", "swift", "test", "tests",
|
|
44
|
+
"constants", "entities", "entity", "domain", "data", "presentation", "scenes",
|
|
45
|
+
"views", "view", "viewmodels", "repositories", "repository", "stores", "store",
|
|
46
|
+
"usecases", "screens", "features", "modules",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
// Suffixes that mark a type as a debug state selector rather than a domain type.
|
|
50
|
+
const SCENARIO_SUFFIX = /(Scenario|Outcome|MockCase|MockState|DebugCase|MockVariant)$/;
|
|
51
|
+
|
|
52
|
+
// A selector case meaning "don't override" produces no distinct visual state.
|
|
53
|
+
const PASSTHROUGH_CASE = /^(useDefault|default|none|unset|disabled|off|live)$/i;
|
|
54
|
+
|
|
55
|
+
const SCENARIO_ENUM_RE = /\b(?:enum|sealed class|sealed interface)\s+([A-Za-z0-9_]*(?:Scenario|Outcome|MockCase|MockState|DebugCase|MockVariant))\b/g;
|
|
56
|
+
const LAUNCH_ARG_RE = /"(-[A-Za-z][A-Za-z0-9]*[Mm]ock[A-Za-z0-9]*|-[Mm]ock[A-Za-z0-9]*)"/g;
|
|
57
|
+
const INTENT_EXTRA_RE = /(?:getBooleanExtra|hasExtra|putExtra)\s*\(\s*"([A-Za-z0-9_.]*[Mm]ock[A-Za-z0-9_.]*)"/g;
|
|
58
|
+
// Coded scenario triggers appear as switch cases ( case "AGN" ) and as constants
|
|
59
|
+
// ( let network = "NET" ). Both are trusted ONLY inside debug/mock support code:
|
|
60
|
+
// elsewhere an uppercase string literal is far more likely a currency, locale,
|
|
61
|
+
// country, or HTTP-verb constant, and a false target makes the coverage gate
|
|
62
|
+
// unreachable. A scenario code living outside debug code is better recovered via
|
|
63
|
+
// the config's extraTargets than by guessing here.
|
|
64
|
+
const CODE_CASE_RE = /case\s+"([A-Z][A-Z0-9]{1,7})"/g;
|
|
65
|
+
const CODE_CONST_RE = /(?:static\s+)?(?:let|val|const)\s+\w+\s*(?::\s*String)?\s*=\s*"([A-Z][A-Z0-9]{1,7})"/g;
|
|
66
|
+
// The dominant variant mechanism in booking-style apps: a mock repository branches
|
|
67
|
+
// on the prefix of the reference the user types, so each prefix is a distinct
|
|
68
|
+
// screen state. These carry no enum and no constant - only a string test - which
|
|
69
|
+
// is why an inventory that looks for selectors alone misses most of the matrix.
|
|
70
|
+
const PREFIX_CODE_RE = /\.\s*has(?:Prefix|Suffix)\(\s*"([A-Z][A-Z0-9]{1,9})"\s*\)/g;
|
|
71
|
+
// Debug support DIRECTORIES only. Deliberately not widened to Mock*-named files:
|
|
72
|
+
// the per-screen mock repositories are already opened by their text signal, and
|
|
73
|
+
// prefix detection below is not gated on this, so widening it would only enlarge
|
|
74
|
+
// the false-positive surface for the uppercase-constant patterns.
|
|
75
|
+
const DEBUG_PATH_RE = /(^|[/\\])(Debug|Mock[A-Za-z]*|DevSupport)([/\\]|$)/i;
|
|
76
|
+
const DEEPLINK_RE = /"([a-z][a-z0-9+.-]{2,}):\/\/[A-Za-z0-9_\-./?=&{}]*"/g;
|
|
77
|
+
// Schemes that are transport or platform plumbing, never an app entry point.
|
|
78
|
+
const NON_APP_SCHEME = /^(https?|file|mailto|tel|sms|data|ftp|ws|wss|content|android\.resource|market|package|intent|jdbc|about|blob|javascript)$/i;
|
|
79
|
+
|
|
80
|
+
function slug(s) {
|
|
81
|
+
return String(s || "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function titleize(s) {
|
|
85
|
+
return String(s || "")
|
|
86
|
+
.replace(/^-+/, "")
|
|
87
|
+
.replace(/[_-]+/g, " ")
|
|
88
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
89
|
+
.replace(/\s+/g, " ")
|
|
90
|
+
.trim()
|
|
91
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Best-effort screen/feature name for a target: the most specific non-infra
|
|
95
|
+
// directory on the path, falling back to the declaring symbol's own name.
|
|
96
|
+
function screenFor(relPath, symbol) {
|
|
97
|
+
const parts = dirname(relPath || "").split(sep).filter(Boolean);
|
|
98
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
99
|
+
const p = parts[i];
|
|
100
|
+
if (!INFRA_DIRS.has(p.toLowerCase()) && !/^[.@]/.test(p)) return titleize(p);
|
|
101
|
+
}
|
|
102
|
+
if (symbol) return titleize(String(symbol).replace(SCENARIO_SUFFIX, ""));
|
|
103
|
+
const b = basename(relPath || "", ".swift").replace(/\.(kt|kts|json)$/, "");
|
|
104
|
+
return titleize(b) || "Unknown";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The screen a launch argument drives, read from the argument itself - far more
|
|
108
|
+
// specific than its declaration site, which is usually a shared constants file.
|
|
109
|
+
function screenFromArg(arg) {
|
|
110
|
+
const core = String(arg).replace(/^-+/, "").replace(/^[Mm]ock/, "");
|
|
111
|
+
return titleize(core) || "Unknown";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Blank out line comments and string literals so brace counting cannot be thrown
|
|
115
|
+
// off by a `{` inside a doc comment or a raw-value string ( case open = "{" ).
|
|
116
|
+
// Length is preserved, so indices into the sanitized copy still address the
|
|
117
|
+
// original text.
|
|
118
|
+
function maskLiteralsAndComments(text) {
|
|
119
|
+
let out = "";
|
|
120
|
+
let i = 0;
|
|
121
|
+
while (i < text.length) {
|
|
122
|
+
const c = text[i];
|
|
123
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
124
|
+
const nl = text.indexOf("\n", i);
|
|
125
|
+
const end = nl < 0 ? text.length : nl;
|
|
126
|
+
out += " ".repeat(end - i);
|
|
127
|
+
i = end;
|
|
128
|
+
} else if (c === "/" && text[i + 1] === "*") {
|
|
129
|
+
const close = text.indexOf("*/", i + 2);
|
|
130
|
+
const end = close < 0 ? text.length : close + 2;
|
|
131
|
+
for (let k = i; k < end; k++) out += text[k] === "\n" ? "\n" : " ";
|
|
132
|
+
i = end;
|
|
133
|
+
} else if (c === '"') {
|
|
134
|
+
let k = i + 1;
|
|
135
|
+
while (k < text.length && text[k] !== '"' && text[k] !== "\n") {
|
|
136
|
+
if (text[k] === "\\") k++;
|
|
137
|
+
k++;
|
|
138
|
+
}
|
|
139
|
+
const end = Math.min(k + 1, text.length);
|
|
140
|
+
out += " ".repeat(end - i);
|
|
141
|
+
i = end;
|
|
142
|
+
} else {
|
|
143
|
+
out += c;
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Cases of the enum whose declaration starts at `declIdx`. Brace-balanced, and
|
|
151
|
+
// only cases at the enum's own nesting level count - a nested enum's cases
|
|
152
|
+
// belong to that nested enum, which gets its own targets.
|
|
153
|
+
function enumCases(text, declIdx) {
|
|
154
|
+
const masked = maskLiteralsAndComments(text);
|
|
155
|
+
const open = masked.indexOf("{", declIdx);
|
|
156
|
+
if (open < 0) return [];
|
|
157
|
+
let depth = 0, end = -1;
|
|
158
|
+
for (let i = open; i < masked.length; i++) {
|
|
159
|
+
const c = masked[i];
|
|
160
|
+
if (c === "{") depth++;
|
|
161
|
+
else if (c === "}") { depth--; if (depth === 0) { end = i; break; } }
|
|
162
|
+
}
|
|
163
|
+
const stop = end < 0 ? masked.length : end;
|
|
164
|
+
const body = text.slice(open + 1, stop);
|
|
165
|
+
const maskedBody = masked.slice(open + 1, stop);
|
|
166
|
+
const lines = body.split("\n");
|
|
167
|
+
const maskedLines = maskedBody.split("\n");
|
|
168
|
+
const out = [];
|
|
169
|
+
let level = 0;
|
|
170
|
+
for (let n = 0; n < lines.length; n++) {
|
|
171
|
+
if (level === 0) {
|
|
172
|
+
const m = /^\s*case\s+([A-Za-z_][\w]*(?:\s*,\s*[A-Za-z_][\w]*)*)/.exec(lines[n]);
|
|
173
|
+
if (m) {
|
|
174
|
+
for (const raw of m[1].split(",")) {
|
|
175
|
+
const name = raw.trim();
|
|
176
|
+
if (name && !out.includes(name)) out.push(name);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const ch of maskedLines[n] || "") {
|
|
181
|
+
if (ch === "{") level++;
|
|
182
|
+
else if (ch === "}") level = Math.max(0, level - 1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// What it costs to put the app into this state. The single biggest reason a design
|
|
189
|
+
// audit under-covers a module is treating every state as equally expensive: a
|
|
190
|
+
// scenario case flips inside the running app, while a launch argument costs a full
|
|
191
|
+
// relaunch plus re-navigation. Ordering by cost is what makes full coverage
|
|
192
|
+
// affordable in one run.
|
|
193
|
+
const DRIVER_COST = {
|
|
194
|
+
"launch-arg": "relaunch",
|
|
195
|
+
"intent-extra": "relaunch",
|
|
196
|
+
"deeplink": "relaunch",
|
|
197
|
+
"scenario": "in-app",
|
|
198
|
+
"code": "in-app",
|
|
199
|
+
"fixture": "unknown",
|
|
200
|
+
"manual": "unknown",
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const screenKey = (s) => String(s || "").replace(/[^A-Za-z0-9]/g, "").toLowerCase();
|
|
204
|
+
|
|
205
|
+
function makeAdder(targets, seen) {
|
|
206
|
+
return function add(kind, key, { label, screen, driver, file, line, snippet }) {
|
|
207
|
+
const id = `${kind}:${slug(key)}`;
|
|
208
|
+
if (seen.has(id) || targets.length >= MAX_TARGETS) return false;
|
|
209
|
+
seen.add(id);
|
|
210
|
+
targets.push({
|
|
211
|
+
id, kind,
|
|
212
|
+
label: label || titleize(key),
|
|
213
|
+
screen: screen || "Unknown",
|
|
214
|
+
driver,
|
|
215
|
+
cost: DRIVER_COST[driver && driver.type] || "unknown",
|
|
216
|
+
evidence: { file, line: line || 0, snippet: (snippet || "").slice(0, 160) },
|
|
217
|
+
});
|
|
218
|
+
return true;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Order the targets into launch batches so one relaunch serves as many targets as
|
|
224
|
+
* possible: each relaunch target opens a screen, and every in-app target for that
|
|
225
|
+
* same screen is flipped while the app is already sitting there. In-app targets
|
|
226
|
+
* with no matching relaunch target go in a final default-launch batch.
|
|
227
|
+
*
|
|
228
|
+
* The batch count IS the relaunch count, which is the run's real cost.
|
|
229
|
+
*/
|
|
230
|
+
/**
|
|
231
|
+
* Group targets into launch batches.
|
|
232
|
+
*
|
|
233
|
+
* Exported for the determinism test: target discovery walks the filesystem and
|
|
234
|
+
* that read order is not stable across runs, so this function has to produce the
|
|
235
|
+
* same plan for any permutation of its input. Proving that by construction is
|
|
236
|
+
* worth more than a suite that happens to pass, because the failure it guards
|
|
237
|
+
* against showed up as an intermittently red gate rather than a wrong answer.
|
|
238
|
+
*/
|
|
239
|
+
export function buildPlan(targets) {
|
|
240
|
+
const relaunch = targets.filter((t) => t.cost === "relaunch");
|
|
241
|
+
const inApp = targets.filter((t) => t.cost !== "relaunch");
|
|
242
|
+
const claimed = new Set();
|
|
243
|
+
const batches = [];
|
|
244
|
+
|
|
245
|
+
// Relaunch order is canonical, not discovery order. Target discovery walks the
|
|
246
|
+
// filesystem and that order is not stable across runs, so a first-come rider
|
|
247
|
+
// claim made the whole plan nondeterministic: identical input produced
|
|
248
|
+
// different batches between runs of the same audit.
|
|
249
|
+
const orderedRelaunch = [...relaunch].sort(
|
|
250
|
+
(a, b) =>
|
|
251
|
+
screenKey(a.screen).localeCompare(screenKey(b.screen)) ||
|
|
252
|
+
String(a.id).localeCompare(String(b.id)),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Riders are assigned by BEST match, not by whoever iterates first.
|
|
256
|
+
//
|
|
257
|
+
// Screen names rarely spell identically ("Apis Form" vs "APIS"), so a substring
|
|
258
|
+
// match still counts - but it must not outrank an exact one, and among equal
|
|
259
|
+
// matches the relaunch that carries an actual launch argument has to win: a
|
|
260
|
+
// relaunch with no argument cannot put the app into the mock state its riders
|
|
261
|
+
// need, so parking them there produces a plan that cannot be driven. Both used
|
|
262
|
+
// to be decided by iteration order alone.
|
|
263
|
+
//
|
|
264
|
+
// Score is a tuple, compared high to low:
|
|
265
|
+
// screen match exact (2) > substring (1) > none (0)
|
|
266
|
+
// drivability has a launch argument (1) > does not (0)
|
|
267
|
+
// canonical order first wins, so the result never depends on discovery order
|
|
268
|
+
const matchScore = (relaunchScreen, riderScreen) => {
|
|
269
|
+
const x = screenKey(relaunchScreen);
|
|
270
|
+
const y = screenKey(riderScreen);
|
|
271
|
+
if (!x || !y) return 0;
|
|
272
|
+
if (x === y) return 2;
|
|
273
|
+
if (x.includes(y) || y.includes(x)) return 1;
|
|
274
|
+
return 0;
|
|
275
|
+
};
|
|
276
|
+
const drivable = (t) => (t.driver && t.driver.launchArg ? 1 : 0);
|
|
277
|
+
|
|
278
|
+
const ridersFor = new Map(orderedRelaunch.map((t) => [t.id, []]));
|
|
279
|
+
for (const rider of inApp) {
|
|
280
|
+
let best = null;
|
|
281
|
+
let bestKey = [0, 0];
|
|
282
|
+
for (const t of orderedRelaunch) {
|
|
283
|
+
const key = [matchScore(t.screen, rider.screen), drivable(t)];
|
|
284
|
+
if (key[0] === 0) continue;
|
|
285
|
+
// Strictly greater on the tuple: ties fall to canonical order.
|
|
286
|
+
if (key[0] > bestKey[0] || (key[0] === bestKey[0] && key[1] > bestKey[1])) {
|
|
287
|
+
bestKey = key;
|
|
288
|
+
best = t;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (best) {
|
|
292
|
+
ridersFor.get(best.id).push(rider);
|
|
293
|
+
claimed.add(rider.id);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const byId = (a, b) => String(a).localeCompare(String(b));
|
|
298
|
+
for (const t of orderedRelaunch) {
|
|
299
|
+
const riders = ridersFor.get(t.id) || [];
|
|
300
|
+
batches.push({
|
|
301
|
+
launch: t.driver,
|
|
302
|
+
screen: t.screen,
|
|
303
|
+
// Rider ids are sorted too: which relaunch owns a rider is decided above,
|
|
304
|
+
// but the order they are listed in still followed discovery order, so the
|
|
305
|
+
// plan was not yet byte-identical between runs. Batch membership is a set.
|
|
306
|
+
targetIds: [t.id, ...riders.map((x) => x.id).sort(byId)],
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const orphans = inApp.filter((x) => !claimed.has(x.id));
|
|
311
|
+
if (orphans.length) {
|
|
312
|
+
batches.push({
|
|
313
|
+
launch: null,
|
|
314
|
+
screen: "(default launch)",
|
|
315
|
+
targetIds: orphans.map((x) => x.id).sort(byId),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return batches;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function collectFromText({ text, absFile, relPath, platform, add }) {
|
|
322
|
+
const lineOf = (idx) => text.slice(0, idx).split("\n").length;
|
|
323
|
+
const lineText = (n) => (text.split("\n")[n - 1] || "").trim();
|
|
324
|
+
|
|
325
|
+
// 1. launch arguments / intent extras - each is a distinct relaunch target.
|
|
326
|
+
const argRe = platform === "android" ? INTENT_EXTRA_RE : LAUNCH_ARG_RE;
|
|
327
|
+
argRe.lastIndex = 0;
|
|
328
|
+
for (let m; (m = argRe.exec(text)); ) {
|
|
329
|
+
const arg = m[1];
|
|
330
|
+
const ln = lineOf(m.index);
|
|
331
|
+
add("launch-arg", arg, {
|
|
332
|
+
label: titleize(arg),
|
|
333
|
+
screen: screenFromArg(arg),
|
|
334
|
+
driver: platform === "android"
|
|
335
|
+
? { type: "intent-extra", intentExtra: `--ez ${arg} true` }
|
|
336
|
+
: { type: "launch-arg", launchArg: arg },
|
|
337
|
+
file: relPath, line: ln, snippet: lineText(ln),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 2. debug scenario enums - one target per case (per-screen outcome pickers).
|
|
342
|
+
// Every declaration in the file counts: a debug store commonly nests one
|
|
343
|
+
// outcome enum per screen inside a single namespace type.
|
|
344
|
+
SCENARIO_ENUM_RE.lastIndex = 0;
|
|
345
|
+
for (let em; (em = SCENARIO_ENUM_RE.exec(text)); ) {
|
|
346
|
+
const enumName = em[1];
|
|
347
|
+
const ln = lineOf(em.index);
|
|
348
|
+
for (const c of enumCases(text, em.index)) {
|
|
349
|
+
if (PASSTHROUGH_CASE.test(c)) continue;
|
|
350
|
+
add("scenario-case", `${enumName}-${c}`, {
|
|
351
|
+
label: `${titleize(enumName)} → ${titleize(c)}`,
|
|
352
|
+
screen: titleize(enumName.replace(SCENARIO_SUFFIX, "")) || screenFor(relPath, enumName),
|
|
353
|
+
driver: { type: "scenario", enum: enumName, case: c },
|
|
354
|
+
file: relPath, line: ln, snippet: lineText(ln),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 3a. reference-prefix variants: the mock repository that branches on them names
|
|
360
|
+
// the screen they belong to, so they group with that screen rather than into a
|
|
361
|
+
// cross-cutting bucket.
|
|
362
|
+
PREFIX_CODE_RE.lastIndex = 0;
|
|
363
|
+
for (let m; (m = PREFIX_CODE_RE.exec(text)); ) {
|
|
364
|
+
const code = m[1];
|
|
365
|
+
const ln = lineOf(m.index);
|
|
366
|
+
const screen = screenFor(relPath, basename(relPath || "").replace(/^Mock|Repository\.\w+$/g, ""));
|
|
367
|
+
add("prefix-code", `${screen}-${code}`, {
|
|
368
|
+
label: `${code} → ${screen}`,
|
|
369
|
+
screen,
|
|
370
|
+
driver: { type: "code", code, appliesTo: screen },
|
|
371
|
+
file: relPath, line: ln, snippet: lineText(ln),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 3b. short uppercase scenario codes declared as switch cases or constants, read
|
|
376
|
+
// only from debug/mock support code - see NON_APP_SCHEME's neighbours for why.
|
|
377
|
+
// Unlike 3a these carry no owning screen, so they stay in a shared bucket.
|
|
378
|
+
const codeSources = DEBUG_PATH_RE.test(relPath || "") ? [CODE_CASE_RE, CODE_CONST_RE] : [];
|
|
379
|
+
for (const re of codeSources) {
|
|
380
|
+
re.lastIndex = 0;
|
|
381
|
+
for (let m; (m = re.exec(text)); ) {
|
|
382
|
+
const code = m[1];
|
|
383
|
+
const ln = lineOf(m.index);
|
|
384
|
+
add("code-scenario", code, {
|
|
385
|
+
label: code,
|
|
386
|
+
// Codes are typed at an entry field and steer whichever screen follows -
|
|
387
|
+
// pinning them to their declaration site's folder would mislead.
|
|
388
|
+
screen: "Scenario codes",
|
|
389
|
+
driver: { type: "code", code },
|
|
390
|
+
file: relPath, line: ln, snippet: lineText(ln),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 4. custom-scheme deep links - direct entry points to otherwise deep screens.
|
|
396
|
+
DEEPLINK_RE.lastIndex = 0;
|
|
397
|
+
for (let m; (m = DEEPLINK_RE.exec(text)); ) {
|
|
398
|
+
const url = m[0].slice(1, -1);
|
|
399
|
+
if (NON_APP_SCHEME.test(m[1])) continue;
|
|
400
|
+
const ln = lineOf(m.index);
|
|
401
|
+
add("deep-link", url, {
|
|
402
|
+
label: url,
|
|
403
|
+
screen: screenFor(relPath, null),
|
|
404
|
+
driver: { type: "deeplink", url },
|
|
405
|
+
file: relPath, line: ln, snippet: lineText(ln),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// rg narrows the file set; the walk fallback opens every source file. Both must
|
|
411
|
+
// admit exactly the same files, or the inventory - and therefore the coverage
|
|
412
|
+
// denominator - would depend on whether ripgrep happens to be on PATH.
|
|
413
|
+
//
|
|
414
|
+
// Content probes mirror TEXT_SIGNAL_RE plus the two literal shapes that carry no
|
|
415
|
+
// mock/scenario word of their own (uppercase switch cases and uppercase constants).
|
|
416
|
+
export const RG_PROBES = [
|
|
417
|
+
`[Mm]ock`,
|
|
418
|
+
`(?:enum|sealed class|sealed interface)\\s+[A-Za-z0-9_]*(?:Scenario|Outcome|MockCase|MockState|DebugCase|MockVariant)\\b`,
|
|
419
|
+
`case\\s+"[A-Z][A-Z0-9]{1,7}"`,
|
|
420
|
+
`(?:let|val|const)\\s+\\w+\\s*(?::\\s*String)?\\s*=\\s*"[A-Z][A-Z0-9]{1,7}"`,
|
|
421
|
+
`has(?:Prefix|Suffix)\\(\\s*"[A-Z][A-Z0-9]{1,9}"`,
|
|
422
|
+
`"[a-z][a-z0-9+.-]{2,}://`,
|
|
423
|
+
];
|
|
424
|
+
// Path probes mirror the DEBUG_PATH_RE half of shouldOpen: a debug constants file
|
|
425
|
+
// whose whole content is `let agency = "AGN"` matches no content probe, and the
|
|
426
|
+
// walk would open it on its path alone. Without these globs the rg path would
|
|
427
|
+
// silently miss exactly the scenario codes this tool exists to find.
|
|
428
|
+
export const RG_PATH_GLOBS = [
|
|
429
|
+
"**/Debug/**/*.swift", "**/Debug/**/*.kt", "**/Debug/**/*.kts",
|
|
430
|
+
"**/Mock*/**/*.swift", "**/Mock*/**/*.kt", "**/Mock*/**/*.kts",
|
|
431
|
+
"**/DevSupport/**/*.swift", "**/DevSupport/**/*.kt", "**/DevSupport/**/*.kts",
|
|
432
|
+
"**/*Mock*.swift", "**/*Mock*.kt", "**/*Mock*.kts",
|
|
433
|
+
];
|
|
434
|
+
const TEXT_SIGNAL_RE = /[Mm]ock|Scenario|Outcome|:\/\//;
|
|
435
|
+
// Debug/mock support code is admitted on its path alone: a constants file holding
|
|
436
|
+
// nothing but scenario codes carries no other signal.
|
|
437
|
+
const shouldOpen = (relPath, text) => DEBUG_PATH_RE.test(relPath || "") || TEXT_SIGNAL_RE.test(text);
|
|
438
|
+
|
|
439
|
+
export function inventoryScenarios({ repoPath, platform, extraLaunchArgs = [], extraTargets = [], ignoreTargets = [], strategy = "auto", summary = false } = {}) {
|
|
440
|
+
if (!repoPath || !existsSync(repoPath)) {
|
|
441
|
+
return { platform: "unknown", targetCount: 0, targets: [], groups: [], byKind: {}, byCost: {},
|
|
442
|
+
plan: [], relaunchCount: 0, truncated: false, ignored: [], scanStrategy: "none",
|
|
443
|
+
reason: `repoPath not found: ${repoPath}` };
|
|
444
|
+
}
|
|
445
|
+
const plat = platform || detectPlatform(repoPath);
|
|
446
|
+
const targets = [];
|
|
447
|
+
const seen = new Set();
|
|
448
|
+
const add = makeAdder(targets, seen);
|
|
449
|
+
const useRg = strategy === "rg" || (strategy === "auto" && hasRg());
|
|
450
|
+
|
|
451
|
+
const candidates = new Set();
|
|
452
|
+
if (useRg) {
|
|
453
|
+
try {
|
|
454
|
+
for (const p of RG_PROBES) for (const hit of rg(p, repoPath)) candidates.add(hit.file);
|
|
455
|
+
for (const g of RG_PATH_GLOBS) for (const hit of rg(g, repoPath, { files: true, globs: [] })) candidates.add(hit.file);
|
|
456
|
+
} catch { candidates.clear(); }
|
|
457
|
+
}
|
|
458
|
+
const consider = (absFile) => {
|
|
459
|
+
let text = ""; try { text = readFileSync(absFile, "utf-8"); } catch { return; }
|
|
460
|
+
const relPath = rel(repoPath, absFile);
|
|
461
|
+
if (!shouldOpen(relPath, text)) return;
|
|
462
|
+
collectFromText({ text, absFile, relPath, platform: plat, add });
|
|
463
|
+
};
|
|
464
|
+
let scanStrategy;
|
|
465
|
+
if (candidates.size) {
|
|
466
|
+
scanStrategy = "rg";
|
|
467
|
+
for (const absFile of candidates) consider(absFile);
|
|
468
|
+
} else {
|
|
469
|
+
// Reached when rg is absent, unusable, or genuinely found nothing - in the last
|
|
470
|
+
// case the walk confirms the empty result rather than trusting a silent zero.
|
|
471
|
+
scanStrategy = "walk";
|
|
472
|
+
walk(repoPath, (absFile) => {
|
|
473
|
+
const ext = "." + (basename(absFile).split(".").pop() || "");
|
|
474
|
+
if (!SRC_EXT.has(ext)) return;
|
|
475
|
+
consider(absFile);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// 5. response fixtures - a fixture that no launch arg or scenario case names is
|
|
480
|
+
// still a distinct state someone has to look at.
|
|
481
|
+
const fixtures = [];
|
|
482
|
+
if (useRg) {
|
|
483
|
+
try { for (const f of rg("**/MockData/**/*.json", repoPath, { files: true, globs: [] })) fixtures.push(f.file); } catch { /* fall through */ }
|
|
484
|
+
}
|
|
485
|
+
if (!fixtures.length) {
|
|
486
|
+
walk(repoPath, (f) => { if (/\/MockData\//.test(f) && f.endsWith(".json")) fixtures.push(f); });
|
|
487
|
+
}
|
|
488
|
+
for (const absFile of fixtures) {
|
|
489
|
+
const relPath = rel(repoPath, absFile);
|
|
490
|
+
const name = basename(absFile, ".json");
|
|
491
|
+
add("fixture", relPath, {
|
|
492
|
+
label: titleize(name.replace(/_?mock_?/i, "")) || titleize(name),
|
|
493
|
+
screen: screenFor(relPath, name),
|
|
494
|
+
driver: { type: "fixture", file: relPath },
|
|
495
|
+
file: relPath, line: 0, snippet: "",
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Caller-supplied additions: project config knows states no signal can reveal.
|
|
500
|
+
for (const a of extraLaunchArgs) {
|
|
501
|
+
add("launch-arg", a, {
|
|
502
|
+
label: titleize(a), screen: "Config",
|
|
503
|
+
driver: plat === "android" ? { type: "intent-extra", intentExtra: `--ez ${a} true` } : { type: "launch-arg", launchArg: a },
|
|
504
|
+
file: "design-check-config.json", line: 0, snippet: "",
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
for (const t of extraTargets) {
|
|
508
|
+
if (!t || !t.id) continue;
|
|
509
|
+
const id = String(t.id);
|
|
510
|
+
if (seen.has(id) || targets.length >= MAX_TARGETS) continue;
|
|
511
|
+
seen.add(id);
|
|
512
|
+
targets.push({
|
|
513
|
+
id, kind: t.kind || "config", label: t.label || titleize(id),
|
|
514
|
+
screen: t.screen || "Config", driver: t.driver || { type: "manual" },
|
|
515
|
+
evidence: { file: "design-check-config.json", line: 0, snippet: "" },
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Config-declared drops: dead drivers, or states retired in code but still
|
|
520
|
+
// referenced. Unlike a skip these never reach the coverage gate, so they are
|
|
521
|
+
// reported back explicitly rather than disappearing.
|
|
522
|
+
const ignore = new Set(ignoreTargets.map(String));
|
|
523
|
+
const kept = ignore.size ? targets.filter((t) => !ignore.has(t.id)) : targets;
|
|
524
|
+
const ignored = ignore.size ? targets.filter((t) => ignore.has(t.id)).map((t) => t.id) : [];
|
|
525
|
+
|
|
526
|
+
const byScreen = new Map();
|
|
527
|
+
const byKindRaw = {};
|
|
528
|
+
for (const t of kept) {
|
|
529
|
+
byKindRaw[t.kind] = (byKindRaw[t.kind] || 0) + 1;
|
|
530
|
+
const k = `${t.screen}`;
|
|
531
|
+
if (!byScreen.has(k)) byScreen.set(k, []);
|
|
532
|
+
byScreen.get(k).push(t.id);
|
|
533
|
+
}
|
|
534
|
+
const groups = [...byScreen.entries()]
|
|
535
|
+
.map(([group, ids]) => ({ group, ids }))
|
|
536
|
+
// Tie-break on the group name: without it, two screens with the same target
|
|
537
|
+
// count keep whatever relative order target discovery happened to produce,
|
|
538
|
+
// and that order is not stable across runs.
|
|
539
|
+
.sort((a, b) => b.ids.length - a.ids.length || a.group.localeCompare(b.group));
|
|
540
|
+
|
|
541
|
+
const plan = buildPlan(kept);
|
|
542
|
+
const byCostRaw = {};
|
|
543
|
+
for (const t of kept) byCostRaw[t.cost] = (byCostRaw[t.cost] || 0) + 1;
|
|
544
|
+
|
|
545
|
+
// Count maps are emitted with sorted keys. Target discovery walks the
|
|
546
|
+
// filesystem and directory read order is not stable across runs, so insertion
|
|
547
|
+
// order was too: two calls on identical input produced identical counts whose
|
|
548
|
+
// `JSON.stringify` differed, which is what made the inventory tests flaky and
|
|
549
|
+
// would defeat any diff or golden-file comparison of a report. A count map has
|
|
550
|
+
// no meaningful order, so canonicalising it changes no behaviour.
|
|
551
|
+
const sortedKeys = (o) =>
|
|
552
|
+
Object.fromEntries(Object.entries(o).sort(([a], [b]) => a.localeCompare(b)));
|
|
553
|
+
const byKind = sortedKeys(byKindRaw);
|
|
554
|
+
const byCost = sortedKeys(byCostRaw);
|
|
555
|
+
|
|
556
|
+
const full = {
|
|
557
|
+
platform: plat,
|
|
558
|
+
targetCount: kept.length,
|
|
559
|
+
targets: kept,
|
|
560
|
+
groups,
|
|
561
|
+
byKind,
|
|
562
|
+
byCost,
|
|
563
|
+
scanStrategy,
|
|
564
|
+
// Relaunch count == batch count. Drive the batches in order and full coverage
|
|
565
|
+
// costs this many relaunches, not one per target.
|
|
566
|
+
plan,
|
|
567
|
+
relaunchCount: plan.filter((b) => b.launch).length,
|
|
568
|
+
ignored,
|
|
569
|
+
// The cap is a runaway guard, not a sampling strategy: a truncated inventory
|
|
570
|
+
// means the coverage gate is measuring an incomplete target set, and the run
|
|
571
|
+
// must say so rather than report a clean percentage of a partial denominator.
|
|
572
|
+
truncated: targets.length >= MAX_TARGETS,
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
if (!summary) return full;
|
|
576
|
+
|
|
577
|
+
// Summary mode. The full payload for a 109-target module measured 71,196
|
|
578
|
+
// characters and blew the host's tool-result cap on all seven runs of a real
|
|
579
|
+
// audit, forcing a write-to-file round trip every time. targets[] is nearly
|
|
580
|
+
// all of that: one entry per target carrying label, screen, driver and a
|
|
581
|
+
// file+line+snippet evidence block.
|
|
582
|
+
//
|
|
583
|
+
// Dropping it costs nothing structural, because `plan` already carries every
|
|
584
|
+
// target id in the order they should be driven, and `groups` carries the
|
|
585
|
+
// per-screen id lists. What is lost is the per-target label and evidence,
|
|
586
|
+
// which a caller can fetch by asking for the full payload once it knows which
|
|
587
|
+
// targets it cares about.
|
|
588
|
+
//
|
|
589
|
+
// targetsOmitted is stated rather than implied: a consumer must be able to see
|
|
590
|
+
// that the target list was withheld on purpose, not that the scan found none.
|
|
591
|
+
const { targets: _omitted, ...rest } = full;
|
|
592
|
+
return {
|
|
593
|
+
...rest,
|
|
594
|
+
summary: true,
|
|
595
|
+
targetsOmitted: kept.length,
|
|
596
|
+
hint: "targets[] withheld for size; ids are in plan[].targetIds and groups[].ids. Call again with summary:false for labels and file+line evidence.",
|
|
597
|
+
};
|
|
598
|
+
}
|