@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
@@ -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
+ }