@mmerterden/multi-agent-toolkit-mcp 3.13.1 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +192 -5
  2. package/README.md +80 -8
  3. package/README.tr.md +104 -8
  4. package/index.js +526 -162
  5. package/package.json +4 -4
  6. package/tools/context/index.js +34 -18
  7. package/tools/design-check/component-walk.js +359 -0
  8. package/tools/design-check/content-cardinality.js +3 -3
  9. package/tools/design-check/index.js +78 -11
  10. package/tools/design-check/report.js +152 -10
  11. package/tools/design-check/scan.js +1 -1
  12. package/tools/design-check/scenario-inventory.js +404 -50
  13. package/tools/design-check/visual-compare.js +17 -2
  14. package/tools/ios-app-store-audit/context.js +3 -3
  15. package/tools/ios-app-store-audit/exec.js +17 -0
  16. package/tools/ios-app-store-audit/index.js +0 -15
  17. package/tools/ios-app-store-audit/rules/code-signing.js +2 -2
  18. package/tools/ios-app-store-audit/rules/dead-reference.js +2 -2
  19. package/tools/ios-app-store-audit/rules/debug-tool-leak.js +2 -2
  20. package/tools/ios-app-store-audit/rules/embedded-sdk.js +3 -3
  21. package/tools/ios-app-store-audit/rules/extension-signing.js +2 -2
  22. package/tools/ios-app-store-audit/rules/ipv6-compliance.js +2 -2
  23. package/tools/ios-app-store-audit/rules/production-hygiene.js +2 -2
  24. package/tools/ios-app-store-audit/rules/provisioning-profile.js +3 -3
  25. package/tools/ios-app-store-audit/rules/required-reason-api.js +2 -2
  26. package/tools/offload/index.js +7 -3
  27. package/tools/policy/egress-proxy.js +268 -0
  28. package/tools/policy/index.js +283 -0
  29. package/tools/security/cvss.js +108 -0
  30. package/tools/security/deps.js +0 -0
  31. package/tools/security/index.js +115 -0
  32. package/tools/spawn-collect/index.js +141 -0
@@ -12,7 +12,8 @@
12
12
  * comes from a source signal with file+line evidence:
13
13
  *
14
14
  * launch-arg "-MockSomething" argument literals (iOS) / boolean intent extras (Android)
15
- * scenario-case cases of enums named *Scenario / *Outcome / *MockCase / *MockState
15
+ * scenario-case cases of enums named *Scenario / *Outcome / *MockCase / *MockState,
16
+ * only when debug evidence backs the name (see resolveSelectors)
16
17
  * code-scenario short uppercase switch codes ( case "AGN" ) used as scenario triggers
17
18
  * fixture MockData/**\/*.json response fixtures
18
19
  * deep-link custom-scheme URL literals
@@ -23,6 +24,8 @@
23
24
  * targets: [ { id, kind, label, screen, driver, evidence:{file,line,snippet} } ],
24
25
  * groups: [ { group, kind, ids[] } ],
25
26
  * byKind: { <kind>: n },
27
+ * acceptedSelectors: [ { type, file, line, basis } ],
28
+ * rejectedSelectors: [ { type, file, line, reason, productionReferences[] } ],
26
29
  * truncated: bool
27
30
  * }
28
31
  */
@@ -73,6 +76,16 @@ const PREFIX_CODE_RE = /\.\s*has(?:Prefix|Suffix)\(\s*"([A-Z][A-Z0-9]{1,9})"\s*\
73
76
  // prefix detection below is not gated on this, so widening it would only enlarge
74
77
  // the false-positive surface for the uppercase-constant patterns.
75
78
  const DEBUG_PATH_RE = /(^|[/\\])(Debug|Mock[A-Za-z]*|DevSupport)([/\\]|$)/i;
79
+ // Android build variants that never reach a release build: src/debug,
80
+ // src/mock, src/mockDebug, src/stagingDebug, ...
81
+ const DEBUG_SOURCE_SET_RE = /(^|[/\\])src[/\\](debug|mock[A-Za-z]*|[a-z][A-Za-z]*(Debug|Mock))([/\\]|$)/;
82
+ // Test targets reference production and debug types alike, so a reference from
83
+ // one says nothing about which of the two a selector is.
84
+ const TEST_PATH_RE = /(^|[/\\])(Tests?|[A-Za-z]*Tests|src[/\\](test|androidTest)[A-Za-z]*)([/\\]|$)|Tests?\.(swift|kt|kts)$/;
85
+ const MOCK_FILE_RE = /(^|[/\\])Mock[A-Za-z0-9_]*\.(swift|kt|kts)$/;
86
+ // Readers of a value the tester sets at launch or on the device: a selector
87
+ // constructed from one of these is switchable without a rebuild.
88
+ const RUNTIME_KEY_RE = /\b(UserDefaults|ProcessInfo|CommandLine\.arguments|launchArguments|launchEnvironment|getSharedPreferences|SharedPreferences|getStringExtra|getIntExtra|getBooleanExtra|getSerializableExtra|getParcelableExtra|intent\.extras|getQueryParameter)\b/;
76
89
  const DEEPLINK_RE = /"([a-z][a-z0-9+.-]{2,}):\/\/[A-Za-z0-9_\-./?=&{}]*"/g;
77
90
  // Schemes that are transport or platform plumbing, never an app entry point.
78
91
  const NON_APP_SCHEME = /^(https?|file|mailto|tel|sms|data|ftp|ws|wss|content|android\.resource|market|package|intent|jdbc|about|blob|javascript)$/i;
@@ -150,17 +163,24 @@ function maskLiteralsAndComments(text) {
150
163
  // Cases of the enum whose declaration starts at `declIdx`. Brace-balanced, and
151
164
  // only cases at the enum's own nesting level count - a nested enum's cases
152
165
  // belong to that nested enum, which gets its own targets.
153
- function enumCases(text, declIdx) {
154
- const masked = maskLiteralsAndComments(text);
166
+ // [open, stop) of the brace-balanced body that follows `declIdx`, or null.
167
+ function bodyRange(masked, declIdx) {
155
168
  const open = masked.indexOf("{", declIdx);
156
- if (open < 0) return [];
169
+ if (open < 0) return null;
157
170
  let depth = 0, end = -1;
158
171
  for (let i = open; i < masked.length; i++) {
159
172
  const c = masked[i];
160
173
  if (c === "{") depth++;
161
174
  else if (c === "}") { depth--; if (depth === 0) { end = i; break; } }
162
175
  }
163
- const stop = end < 0 ? masked.length : end;
176
+ return [open, end < 0 ? masked.length : end];
177
+ }
178
+
179
+ function enumCases(text, declIdx) {
180
+ const masked = maskLiteralsAndComments(text);
181
+ const range = bodyRange(masked, declIdx);
182
+ if (!range) return [];
183
+ const [open, stop] = range;
164
184
  const body = text.slice(open + 1, stop);
165
185
  const maskedBody = masked.slice(open + 1, stop);
166
186
  const lines = body.split("\n");
@@ -185,6 +205,117 @@ function enumCases(text, declIdx) {
185
205
  return out;
186
206
  }
187
207
 
208
+ // Per-line flag: is this line compiled only into a debug/mock build? Swift
209
+ // `#if` blocks whose condition names DEBUG or a MOCK* flag (not negated), with
210
+ // `#else` flipping and nesting honoured. Kotlin has no preprocessor, so an
211
+ // Android file answers through its source set instead.
212
+ function debugLineMask(text) {
213
+ const lines = text.split("\n");
214
+ const out = new Array(lines.length).fill(false);
215
+ const stack = [];
216
+ const debugCond = (cond) => /(^|[^!\w])(DEBUG|MOCK[A-Z0-9_]*)\b/.test(cond);
217
+ const negatedCond = (cond) => /!\s*(DEBUG|MOCK[A-Z0-9_]*)\b/.test(cond);
218
+ for (let n = 0; n < lines.length; n++) {
219
+ const t = lines[n].trim();
220
+ let m;
221
+ if ((m = /^#if\b(.*)$/.exec(t))) { stack.push({ debug: debugCond(m[1]), negated: negatedCond(m[1]) }); continue; }
222
+ if ((m = /^#elseif\b(.*)$/.exec(t)) && stack.length) { stack[stack.length - 1] = { debug: debugCond(m[1]), negated: negatedCond(m[1]) }; continue; }
223
+ if (/^#else\b/.test(t) && stack.length) { const top = stack[stack.length - 1]; stack[stack.length - 1] = { debug: top.negated, negated: false }; continue; }
224
+ if (/^#endif\b/.test(t)) { stack.pop(); continue; }
225
+ out[n] = stack.some((f) => f.debug);
226
+ }
227
+ return out;
228
+ }
229
+
230
+ function pathClass(relPath) {
231
+ const p = relPath || "";
232
+ if (TEST_PATH_RE.test(p)) return "test";
233
+ if (DEBUG_SOURCE_SET_RE.test(p)) return "debug-source-set";
234
+ if (DEBUG_PATH_RE.test(p) || MOCK_FILE_RE.test(p)) return "debug";
235
+ return "production";
236
+ }
237
+
238
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
239
+
240
+ /**
241
+ * Decide which suffix-matched selector types are real state drivers.
242
+ *
243
+ * A name like *Outcome or *Scenario is also a common production type (a service
244
+ * result, a domain state), and counting its cases puts targets in the coverage
245
+ * denominator that no debug switch can reach. A candidate becomes a target when
246
+ * one of these holds:
247
+ * a. it is declared where only a debug build sees it - inside `#if DEBUG`, or
248
+ * in an Android debug/mock source set
249
+ * b. no production code references it, and something debug does - its own
250
+ * declaration file (Debug/, Mock*, DevSupport) or a referencing file
251
+ * c. it is constructed next to a runtime key reader (launch argument,
252
+ * UserDefaults, SharedPreferences, intent extra), so a tester can switch it
253
+ * References from test targets are neutral, and a type's mentions of itself
254
+ * inside its own body are not references.
255
+ *
256
+ * `referenceFiles` is every source file mentioning at least one candidate name,
257
+ * as { relPath, text }; both scanners hand over the same set.
258
+ */
259
+ function resolveSelectors(candidates, referenceFiles) {
260
+ const byName = new Map();
261
+ for (const c of candidates) {
262
+ if (!byName.has(c.enumName)) byName.set(c.enumName, []);
263
+ byName.get(c.enumName).push({ ...c, prod: [], debugRefs: 0, runtimeKey: null });
264
+ }
265
+ if (!byName.size) return { accepted: [], rejected: [] };
266
+ const nameRe = new RegExp(`\\b(${[...byName.keys()].map(escapeRe).join("|")})\\b`, "g");
267
+
268
+ for (const { relPath, text } of referenceFiles) {
269
+ const masked = maskLiteralsAndComments(text);
270
+ const lines = masked.split("\n");
271
+ const debugLines = debugLineMask(text);
272
+ const cls = pathClass(relPath);
273
+ const lineStarts = [0];
274
+ for (let i = 0; i < masked.length; i++) if (masked[i] === "\n") lineStarts.push(i + 1);
275
+ const lineAt = (idx) => { let lo = 0, hi = lineStarts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (lineStarts[mid] <= idx) lo = mid; else hi = mid - 1; } return lo; };
276
+ nameRe.lastIndex = 0;
277
+ for (let m; (m = nameRe.exec(masked)); ) {
278
+ for (const c of byName.get(m[1])) {
279
+ const own = c.relPath === relPath;
280
+ if (own && m.index === c.nameIdx) continue;
281
+ const ln = lineAt(m.index);
282
+ const window = [lines[ln - 1], lines[ln], lines[ln + 1]].filter(Boolean).join("\n");
283
+ if (!c.runtimeKey && RUNTIME_KEY_RE.test(window)) c.runtimeKey = { file: relPath, line: ln + 1 };
284
+ if (own && c.body && m.index > c.body[0] && m.index < c.body[1]) continue;
285
+ if (cls === "test") continue;
286
+ if (cls !== "production" || debugLines[ln]) c.debugRefs++;
287
+ else c.prod.push({ file: relPath, line: ln + 1 });
288
+ }
289
+ }
290
+ }
291
+
292
+ const accepted = [], rejected = [];
293
+ for (const list of byName.values()) {
294
+ for (const c of list) {
295
+ const declClass = pathClass(c.relPath);
296
+ let basis = null;
297
+ if (c.declInDebugBlock || declClass === "debug-source-set") basis = "debug-declaration";
298
+ else if (c.runtimeKey) basis = "runtime-key";
299
+ else if (!c.prod.length && (declClass === "debug" || c.debugRefs > 0)) basis = "debug-only-references";
300
+ const head = { type: c.enumName, file: c.relPath, line: c.line };
301
+ if (basis) {
302
+ accepted.push({ ...head, basis, ...(c.runtimeKey ? { runtimeKey: c.runtimeKey } : {}), candidate: c });
303
+ } else {
304
+ const refs = c.prod.slice().sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
305
+ rejected.push({
306
+ ...head,
307
+ reason: refs.length
308
+ ? `referenced from production code (${refs.length} site${refs.length === 1 ? "" : "s"}); not a debug state selector`
309
+ : "declared in production code and referenced from no debug or mock code",
310
+ productionReferences: refs.slice(0, 5),
311
+ });
312
+ }
313
+ }
314
+ }
315
+ const order = (a, b) => a.type.localeCompare(b.type) || a.file.localeCompare(b.file);
316
+ return { accepted: accepted.sort(order), rejected: rejected.sort(order) };
317
+ }
318
+
188
319
  // What it costs to put the app into this state. The single biggest reason a design
189
320
  // audit under-covers a module is treating every state as equally expensive: a
190
321
  // scenario case flips inside the running app, while a launch argument costs a full
@@ -200,12 +331,141 @@ const DRIVER_COST = {
200
331
  "manual": "unknown",
201
332
  };
202
333
 
334
+ /**
335
+ * The target shape, shared by the scanner's own output, the MCP tool's
336
+ * `targets[]` input and a `targets_file` catalog. One definition, so a catalog
337
+ * saved from a run and a catalog written by hand are the same thing.
338
+ *
339
+ * Catalog file: either a bare array of targets, or
340
+ * { "version": 1, "platform"?: "ios"|"android", "description"?: string, "targets": [ ... ] }
341
+ */
342
+ export const TARGET_DRIVER_FIELDS = {
343
+ "launch-arg": ["launchArg"],
344
+ "intent-extra": ["intentExtra"],
345
+ "deeplink": ["url"],
346
+ "scenario": ["enum", "case"],
347
+ "code": ["code"],
348
+ "fixture": ["file"],
349
+ "manual": [],
350
+ };
351
+ const str = { type: "string" };
352
+ export const TARGET_SCHEMA = {
353
+ type: "object",
354
+ additionalProperties: false,
355
+ required: ["id", "driver"],
356
+ properties: {
357
+ id: { type: "string", minLength: 1 },
358
+ kind: str,
359
+ label: str,
360
+ screen: str,
361
+ cost: { type: "string", enum: ["relaunch", "in-app", "unknown"] },
362
+ driver: {
363
+ type: "object",
364
+ additionalProperties: false,
365
+ required: ["type"],
366
+ properties: {
367
+ type: { type: "string", enum: Object.keys(TARGET_DRIVER_FIELDS) },
368
+ launchArg: str, intentExtra: str, url: str, enum: str, case: str,
369
+ code: str, appliesTo: str, file: str, note: str,
370
+ },
371
+ },
372
+ evidence: {
373
+ type: "object",
374
+ additionalProperties: false,
375
+ properties: { file: str, line: { type: "integer", minimum: 0 }, snippet: str, basis: str },
376
+ },
377
+ },
378
+ };
379
+ export const TARGET_CATALOG_SCHEMA = {
380
+ type: "object",
381
+ additionalProperties: false,
382
+ required: ["targets"],
383
+ properties: {
384
+ version: { type: "integer", enum: [1] },
385
+ platform: { type: "string", enum: ["ios", "android"] },
386
+ description: str,
387
+ targets: { type: "array", items: TARGET_SCHEMA },
388
+ },
389
+ };
390
+
391
+ const typeOk = (v, t) => (t === "string" ? typeof v === "string"
392
+ : t === "integer" ? Number.isInteger(v)
393
+ : t === "number" ? typeof v === "number" && Number.isFinite(v)
394
+ : t === "boolean" ? typeof v === "boolean"
395
+ : t === "array" ? Array.isArray(v)
396
+ : t === "object" ? v !== null && typeof v === "object" && !Array.isArray(v)
397
+ : true);
398
+
399
+ // The JSON-Schema subset the schemas above use: type, enum, required,
400
+ // additionalProperties:false, minLength, minimum, items. Returns the first
401
+ // violation as "<path>: <problem>", or null.
402
+ export function schemaViolation(schema, value, path) {
403
+ if (schema.type && !typeOk(value, schema.type)) return `${path}: must be ${schema.type}`;
404
+ if (schema.enum && !schema.enum.includes(value)) return `${path}: must be one of ${JSON.stringify(schema.enum)}`;
405
+ if (typeof value === "string" && Number.isFinite(schema.minLength) && value.length < schema.minLength) return `${path}: must not be empty`;
406
+ if (typeof value === "number" && Number.isFinite(schema.minimum) && value < schema.minimum) return `${path}: must be >= ${schema.minimum}`;
407
+ if (Array.isArray(value) && schema.items) {
408
+ for (let i = 0; i < value.length; i++) {
409
+ const e = schemaViolation(schema.items, value[i], `${path}[${i}]`);
410
+ if (e) return e;
411
+ }
412
+ }
413
+ if (schema.type === "object" && typeOk(value, "object")) {
414
+ for (const req of schema.required || []) if (value[req] === undefined) return `${path}: missing required field: ${req}`;
415
+ const props = schema.properties || {};
416
+ if (schema.additionalProperties === false) {
417
+ const extra = Object.keys(value).find((k) => !Object.hasOwn(props, k));
418
+ if (extra) return `${path}: unexpected field: ${extra}`;
419
+ }
420
+ for (const [k, sub] of Object.entries(props)) {
421
+ if (value[k] === undefined) continue;
422
+ const e = schemaViolation(sub, value[k], `${path}.${k}`);
423
+ if (e) return e;
424
+ }
425
+ }
426
+ return null;
427
+ }
428
+
429
+ // Validates a target list against TARGET_SCHEMA plus what a schema cannot say:
430
+ // the per-driver-type fields and id uniqueness. Throws on the first problem.
431
+ export function validateTargets(list, path) {
432
+ const e = schemaViolation({ type: "array", items: TARGET_SCHEMA }, list, path);
433
+ if (e) throw new Error(`invalid target catalog: ${e}`);
434
+ const ids = new Set();
435
+ list.forEach((t, i) => {
436
+ for (const f of TARGET_DRIVER_FIELDS[t.driver.type]) {
437
+ if (typeof t.driver[f] !== "string" || !t.driver[f]) {
438
+ throw new Error(`invalid target catalog: ${path}[${i}].driver: type '${t.driver.type}' requires ${f}`);
439
+ }
440
+ }
441
+ if (ids.has(t.id)) throw new Error(`invalid target catalog: duplicate target id: ${t.id}`);
442
+ ids.add(t.id);
443
+ });
444
+ return list;
445
+ }
446
+
447
+ export function loadTargetCatalog(file) {
448
+ let raw;
449
+ try { raw = readFileSync(file, "utf-8"); } catch (err) { throw new Error(`targets_file unreadable: ${file} (${err.code || err.message})`); }
450
+ let doc;
451
+ try { doc = JSON.parse(raw); } catch (err) { throw new Error(`targets_file is not valid JSON: ${file} (${err.message})`); }
452
+ if (Array.isArray(doc)) return validateTargets(doc, "targets_file");
453
+ if (!typeOk(doc, "object")) throw new Error(`targets_file must hold an array of targets or { targets: [...] }: ${file}`);
454
+ const e = schemaViolation({ ...TARGET_CATALOG_SCHEMA, properties: { ...TARGET_CATALOG_SCHEMA.properties, targets: { type: "array" } } }, doc, "targets_file");
455
+ if (e) throw new Error(`invalid target catalog: ${e}`);
456
+ return validateTargets(doc.targets, "targets_file.targets");
457
+ }
458
+
203
459
  const screenKey = (s) => String(s || "").replace(/[^A-Za-z0-9]/g, "").toLowerCase();
204
460
 
205
- function makeAdder(targets, seen) {
206
- return function add(kind, key, { label, screen, driver, file, line, snippet }) {
461
+ // `cap` bounds what a source scan may add; `onDrop` hears about every target
462
+ // refused for reaching it, which is what `truncated` reports. Caller-declared
463
+ // targets go through an adder without a cap.
464
+ function makeAdder(targets, seen, { cap = Infinity, onDrop = () => {} } = {}) {
465
+ return function add(kind, key, { label, screen, driver, file, line, snippet, basis }) {
207
466
  const id = `${kind}:${slug(key)}`;
208
- if (seen.has(id) || targets.length >= MAX_TARGETS) return false;
467
+ if (seen.has(id)) return false;
468
+ if (targets.length >= cap) { onDrop(id); return false; }
209
469
  seen.add(id);
210
470
  targets.push({
211
471
  id, kind,
@@ -213,7 +473,7 @@ function makeAdder(targets, seen) {
213
473
  screen: screen || "Unknown",
214
474
  driver,
215
475
  cost: DRIVER_COST[driver && driver.type] || "unknown",
216
- evidence: { file, line: line || 0, snippet: (snippet || "").slice(0, 160) },
476
+ evidence: { file, line: line || 0, snippet: (snippet || "").slice(0, 160), ...(basis ? { basis } : {}) },
217
477
  });
218
478
  return true;
219
479
  };
@@ -254,7 +514,7 @@ export function buildPlan(targets) {
254
514
 
255
515
  // Riders are assigned by BEST match, not by whoever iterates first.
256
516
  //
257
- // Screen names rarely spell identically ("Apis Form" vs "APIS"), so a substring
517
+ // Screen names rarely spell identically ("Order Form" vs "ORDER"), so a substring
258
518
  // match still counts - but it must not outrank an exact one, and among equal
259
519
  // matches the relaunch that carries an actual launch argument has to win: a
260
520
  // relaunch with no argument cannot put the app into the mock state its riders
@@ -318,7 +578,7 @@ export function buildPlan(targets) {
318
578
  return batches;
319
579
  }
320
580
 
321
- function collectFromText({ text, absFile, relPath, platform, add }) {
581
+ function collectFromText({ text, absFile, relPath, platform, add, selectors }) {
322
582
  const lineOf = (idx) => text.slice(0, idx).split("\n").length;
323
583
  const lineText = (n) => (text.split("\n")[n - 1] || "").trim();
324
584
 
@@ -338,22 +598,23 @@ function collectFromText({ text, absFile, relPath, platform, add }) {
338
598
  });
339
599
  }
340
600
 
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.
601
+ // 2. scenario selector enums - collected as candidates here and admitted by
602
+ // resolveSelectors once every reference in the tree is known. Every
603
+ // declaration in the file counts: a debug store commonly nests one outcome
604
+ // enum per screen inside a single namespace type.
344
605
  SCENARIO_ENUM_RE.lastIndex = 0;
606
+ let masked = null, debugLines = null;
345
607
  for (let em; (em = SCENARIO_ENUM_RE.exec(text)); ) {
346
608
  const enumName = em[1];
347
609
  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
- }
610
+ if (masked === null) { masked = maskLiteralsAndComments(text); debugLines = debugLineMask(text); }
611
+ selectors.push({
612
+ enumName, relPath, line: ln, snippet: lineText(ln),
613
+ nameIdx: em.index + em[0].lastIndexOf(enumName),
614
+ body: bodyRange(masked, em.index),
615
+ declInDebugBlock: !!debugLines[ln - 1],
616
+ cases: enumCases(text, em.index),
617
+ });
357
618
  }
358
619
 
359
620
  // 3a. reference-prefix variants: the mock repository that branches on them names
@@ -436,16 +697,38 @@ const TEXT_SIGNAL_RE = /[Mm]ock|Scenario|Outcome|:\/\//;
436
697
  // nothing but scenario codes carries no other signal.
437
698
  const shouldOpen = (relPath, text) => DEBUG_PATH_RE.test(relPath || "") || TEXT_SIGNAL_RE.test(text);
438
699
 
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}` };
700
+ // Every source file that mentions a candidate selector name. The rg path and
701
+ // the walk path must return the same set, or the selector decision - and so the
702
+ // coverage denominator - would depend on which scanner ran.
703
+ function selectorReferenceFiles({ repoPath, names, useRg }) {
704
+ if (!names.length) return [];
705
+ const re = new RegExp(`\\b(?:${names.map(escapeRe).join("|")})\\b`);
706
+ const files = new Set();
707
+ let viaRg = false;
708
+ if (useRg) {
709
+ try {
710
+ for (const hit of rg(`\\b(?:${names.map(escapeRe).join("|")})\\b`, repoPath)) files.add(hit.file);
711
+ viaRg = true;
712
+ } catch { files.clear(); }
444
713
  }
445
- const plat = platform || detectPlatform(repoPath);
446
- const targets = [];
447
- const seen = new Set();
448
- const add = makeAdder(targets, seen);
714
+ if (!viaRg) {
715
+ walk(repoPath, (absFile) => {
716
+ const ext = "." + (basename(absFile).split(".").pop() || "");
717
+ if (SRC_EXT.has(ext)) files.add(absFile);
718
+ });
719
+ }
720
+ const out = [];
721
+ for (const absFile of [...files].sort()) {
722
+ let text = ""; try { text = readFileSync(absFile, "utf-8"); } catch { continue; }
723
+ if (!re.test(text)) continue;
724
+ out.push({ relPath: rel(repoPath, absFile), text });
725
+ }
726
+ return out;
727
+ }
728
+
729
+ // Source scan: every signal in the tree, plus the MockData fixtures. Returns
730
+ // the strategy that ran and the selector decisions, adding targets via `add`.
731
+ function scanSources({ repoPath, platform: plat, strategy, add }) {
449
732
  const useRg = strategy === "rg" || (strategy === "auto" && hasRg());
450
733
 
451
734
  const candidates = new Set();
@@ -455,11 +738,12 @@ export function inventoryScenarios({ repoPath, platform, extraLaunchArgs = [], e
455
738
  for (const g of RG_PATH_GLOBS) for (const hit of rg(g, repoPath, { files: true, globs: [] })) candidates.add(hit.file);
456
739
  } catch { candidates.clear(); }
457
740
  }
741
+ const selectors = [];
458
742
  const consider = (absFile) => {
459
743
  let text = ""; try { text = readFileSync(absFile, "utf-8"); } catch { return; }
460
744
  const relPath = rel(repoPath, absFile);
461
745
  if (!shouldOpen(relPath, text)) return;
462
- collectFromText({ text, absFile, relPath, platform: plat, add });
746
+ collectFromText({ text, absFile, relPath, platform: plat, add, selectors });
463
747
  };
464
748
  let scanStrategy;
465
749
  if (candidates.size) {
@@ -476,6 +760,21 @@ export function inventoryScenarios({ repoPath, platform, extraLaunchArgs = [], e
476
760
  });
477
761
  }
478
762
 
763
+ const selectorDecision = resolveSelectors(selectors, selectorReferenceFiles({
764
+ repoPath, names: [...new Set(selectors.map((c) => c.enumName))], useRg: scanStrategy === "rg",
765
+ }));
766
+ for (const { candidate: c, basis } of selectorDecision.accepted) {
767
+ for (const cs of c.cases) {
768
+ if (PASSTHROUGH_CASE.test(cs)) continue;
769
+ add("scenario-case", `${c.enumName}-${cs}`, {
770
+ label: `${titleize(c.enumName)} → ${titleize(cs)}`,
771
+ screen: titleize(c.enumName.replace(SCENARIO_SUFFIX, "")) || screenFor(c.relPath, c.enumName),
772
+ driver: { type: "scenario", enum: c.enumName, case: cs },
773
+ file: c.relPath, line: c.line, snippet: c.snippet, basis,
774
+ });
775
+ }
776
+ }
777
+
479
778
  // 5. response fixtures - a fixture that no launch arg or scenario case names is
480
779
  // still a distinct state someone has to look at.
481
780
  const fixtures = [];
@@ -496,6 +795,67 @@ export function inventoryScenarios({ repoPath, platform, extraLaunchArgs = [], e
496
795
  });
497
796
  }
498
797
 
798
+ return {
799
+ scanStrategy,
800
+ acceptedSelectors: selectorDecision.accepted.map(({ candidate, ...rest }) => rest),
801
+ rejectedSelectors: selectorDecision.rejected,
802
+ };
803
+ }
804
+
805
+ export function inventoryScenarios({ repoPath, platform, extraLaunchArgs = [], extraTargets = [], ignoreTargets = [], targets: catalogTargets = null, targetsFile = null, strategy = "auto", summary = false } = {}) {
806
+ // A catalog replaces the scan, so it is loaded (and refused) before anything
807
+ // else: an invalid catalog must fail the call, never degrade into a scan that
808
+ // quietly produces a different target set.
809
+ const useCatalog = catalogTargets != null || targetsFile != null;
810
+ const catalog = [];
811
+ if (targetsFile != null) catalog.push(...loadTargetCatalog(targetsFile).map((t) => ({ t, source: basename(String(targetsFile)) })));
812
+ if (catalogTargets != null) catalog.push(...validateTargets(catalogTargets, "targets").map((t) => ({ t, source: "targets[]" })));
813
+ if (useCatalog) validateTargets(catalog.map((c) => c.t), "catalog");
814
+
815
+ const repoFound = !!repoPath && existsSync(repoPath);
816
+ if (!repoFound && !useCatalog) {
817
+ return { platform: "unknown", targetCount: 0, targets: [], groups: [], byKind: {}, byCost: {},
818
+ plan: [], relaunchCount: 0, truncated: false, ignored: [], scanStrategy: "none",
819
+ acceptedSelectors: [], rejectedSelectors: [],
820
+ reason: `repoPath not found: ${repoPath}` };
821
+ }
822
+ const plat = platform || (repoFound ? detectPlatform(repoPath) : "unknown");
823
+ const targets = [];
824
+ const seen = new Set();
825
+ // The cap guards a source scan against a runaway match set. It never applies
826
+ // to what the caller declared (a catalog, config extras, extra launch args):
827
+ // those are an explicit target list, and dropping part of one silently would
828
+ // shrink the coverage denominator behind the caller's back.
829
+ let scanDropped = 0;
830
+ const scanAdd = makeAdder(targets, seen, { cap: MAX_TARGETS, onDrop: () => { scanDropped += 1; } });
831
+ const add = makeAdder(targets, seen);
832
+
833
+ // Every caller-declared target - catalog entry or config extra - enters here,
834
+ // so both get the same id dedup and cost derivation as scanned ones.
835
+ const addDeclared = (t, evidenceFile) => {
836
+ if (!t || !t.id) return;
837
+ const id = String(t.id);
838
+ if (seen.has(id)) return;
839
+ seen.add(id);
840
+ const driver = t.driver || { type: "manual" };
841
+ targets.push({
842
+ id, kind: t.kind || "config", label: t.label || titleize(id),
843
+ screen: t.screen || "Config", driver,
844
+ cost: t.cost || DRIVER_COST[driver.type] || "unknown",
845
+ evidence: t.evidence
846
+ ? { file: t.evidence.file || evidenceFile, line: t.evidence.line || 0, snippet: t.evidence.snippet || "", ...(t.evidence.basis ? { basis: t.evidence.basis } : {}) }
847
+ : { file: evidenceFile, line: 0, snippet: "" },
848
+ });
849
+ };
850
+
851
+ let scanStrategy = "catalog";
852
+ let acceptedSelectors = [], rejectedSelectors = [];
853
+ if (useCatalog) {
854
+ for (const { t, source } of catalog) addDeclared(t, source);
855
+ } else {
856
+ ({ scanStrategy, acceptedSelectors, rejectedSelectors } = scanSources({ repoPath, platform: plat, strategy, add: scanAdd }));
857
+ }
858
+
499
859
  // Caller-supplied additions: project config knows states no signal can reveal.
500
860
  for (const a of extraLaunchArgs) {
501
861
  add("launch-arg", a, {
@@ -504,17 +864,7 @@ export function inventoryScenarios({ repoPath, platform, extraLaunchArgs = [], e
504
864
  file: "design-check-config.json", line: 0, snippet: "",
505
865
  });
506
866
  }
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
- }
867
+ for (const t of extraTargets) addDeclared(t, "design-check-config.json");
518
868
 
519
869
  // Config-declared drops: dead drivers, or states retired in code but still
520
870
  // referenced. Unlike a skip these never reach the coverage gate, so they are
@@ -561,24 +911,28 @@ export function inventoryScenarios({ repoPath, platform, extraLaunchArgs = [], e
561
911
  byKind,
562
912
  byCost,
563
913
  scanStrategy,
914
+ ...(useCatalog ? { catalog: { source: targetsFile != null ? String(targetsFile) : "targets[]", count: catalog.length } } : {}),
564
915
  // Relaunch count == batch count. Drive the batches in order and full coverage
565
916
  // costs this many relaunches, not one per target.
566
917
  plan,
567
918
  relaunchCount: plan.filter((b) => b.launch).length,
568
919
  ignored,
920
+ acceptedSelectors,
921
+ rejectedSelectors,
569
922
  // The cap is a runaway guard, not a sampling strategy: a truncated inventory
570
923
  // means the coverage gate is measuring an incomplete target set, and the run
571
924
  // must say so rather than report a clean percentage of a partial denominator.
572
- truncated: targets.length >= MAX_TARGETS,
925
+ // A scan that found exactly the cap lost nothing, so only a refused target
926
+ // counts.
927
+ truncated: scanDropped > 0,
573
928
  };
574
929
 
575
930
  if (!summary) return full;
576
931
 
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.
932
+ // Summary mode. On a module with a hundred-odd targets the full payload
933
+ // exceeds a typical host tool-result cap, and targets[] is nearly all of it:
934
+ // one entry per target carrying label, screen, driver and a file+line+snippet
935
+ // evidence block.
582
936
  //
583
937
  // Dropping it costs nothing structural, because `plan` already carries every
584
938
  // target id in the order they should be driven, and `groups` carries the
@@ -378,6 +378,10 @@ export async function compareVisual(opts) {
378
378
  tolerancePt = null,
379
379
  verifyFontFamily = false,
380
380
  contentCardinality = true,
381
+ // The component this capture region belongs to, as componentFindingFields
382
+ // (component-walk.js) builds it. Stamped onto every finding that does not
383
+ // name its own, so the report can show the main component and its variants.
384
+ component = null,
381
385
  label = "screen",
382
386
  } = opts;
383
387
 
@@ -539,12 +543,16 @@ export async function compareVisual(opts) {
539
543
 
540
544
  const designWpx = figmaFrame && figmaFrame.w > 0 ? figmaFrame.w : null;
541
545
  const liveWpx = regionSize ? regionSize.w : (liveScreen && liveScreen.w > 0 ? liveScreen.w : null);
546
+ // The live PNG was cropped to the region, so its height in live units is the
547
+ // region's, not the screen's. Sampling a row at y/screenH would read a
548
+ // different row of the component.
549
+ const liveHpx = regionSize ? regionSize.h : (liveScreen ? liveScreen.h - cropUnits : null);
542
550
  for (const m of measured) {
543
551
  m.px = null;
544
552
  if (!responsive || !designWpx || !liveWpx) continue;
545
553
  if (!(m.d.w >= designWpx * 0.8 && m.l.w >= liveWpx * 0.8)) continue;
546
554
  const vf = visualRowInsets(figma, m.d.y + m.d.h / 2, designWpx, figmaFrame.h);
547
- const vl = visualRowInsets(live, m.l.y + m.l.h / 2, liveWpx, liveScreen.h);
555
+ const vl = visualRowInsets(live, m.l.y + m.l.h / 2, liveWpx, liveHpx);
548
556
  if (vf && vl) m.px = { design: vf, live: vl };
549
557
  }
550
558
 
@@ -711,7 +719,7 @@ export async function compareVisual(opts) {
711
719
  for (let i = 0; i < measured.length; i++) {
712
720
  const m = measured[i];
713
721
  const dc = edgeContext(dAll[i], dAll, designW, figmaFrame ? figmaFrame.h : null);
714
- const lc = edgeContext(lAll[i], lAll, liveW, liveScreen ? liveScreen.h : null);
722
+ const lc = edgeContext(lAll[i], lAll, liveW, liveHpx);
715
723
  for (const side of ["left", "right", "top", "bottom"]) {
716
724
  const dv = dc[side], lv = lc[side];
717
725
  if (!Number.isFinite(dv) || !Number.isFinite(lv)) continue;
@@ -928,6 +936,12 @@ export async function compareVisual(opts) {
928
936
  }
929
937
  }
930
938
 
939
+ if (component && typeof component === "object") {
940
+ for (let i = 0; i < findings.length; i++) {
941
+ if (!findings[i].component) findings[i] = { ...component, ...findings[i] };
942
+ }
943
+ }
944
+
931
945
  // Advisories (content-driven height, unmeasurable font family) must not decide
932
946
  // pass/fail - otherwise a fixture with one extra row fails a conformant screen.
933
947
  const deviations = findings.filter((f) => !f.advisory);
@@ -946,6 +960,7 @@ export async function compareVisual(opts) {
946
960
  deviationCount: deviations.length,
947
961
  advisoryCount: findings.length - deviations.length,
948
962
  findings,
963
+ ...(component ? { component } : {}),
949
964
  images: paths,
950
965
  compareSize: `${W}x${H}`,
951
966
  liveSize: `${live.width}x${live.height}`,