@mmerterden/multi-agent-toolkit-mcp 3.14.0 → 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.
- package/CHANGELOG.md +192 -18
- package/README.md +78 -7
- package/README.tr.md +102 -7
- package/index.js +520 -160
- package/package.json +4 -4
- package/tools/context/index.js +34 -18
- package/tools/design-check/component-walk.js +359 -0
- package/tools/design-check/content-cardinality.js +3 -3
- package/tools/design-check/index.js +78 -11
- package/tools/design-check/report.js +152 -10
- package/tools/design-check/scan.js +1 -1
- package/tools/design-check/scenario-inventory.js +404 -50
- package/tools/design-check/visual-compare.js +17 -2
- package/tools/ios-app-store-audit/context.js +3 -3
- package/tools/ios-app-store-audit/exec.js +17 -0
- package/tools/ios-app-store-audit/index.js +0 -15
- package/tools/ios-app-store-audit/rules/code-signing.js +2 -2
- package/tools/ios-app-store-audit/rules/dead-reference.js +2 -2
- package/tools/ios-app-store-audit/rules/debug-tool-leak.js +2 -2
- package/tools/ios-app-store-audit/rules/embedded-sdk.js +3 -3
- package/tools/ios-app-store-audit/rules/extension-signing.js +2 -2
- package/tools/ios-app-store-audit/rules/ipv6-compliance.js +2 -2
- package/tools/ios-app-store-audit/rules/production-hygiene.js +2 -2
- package/tools/ios-app-store-audit/rules/provisioning-profile.js +3 -3
- package/tools/ios-app-store-audit/rules/required-reason-api.js +2 -2
- package/tools/offload/index.js +7 -3
- package/tools/policy/egress-proxy.js +268 -0
- package/tools/policy/index.js +283 -0
- 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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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)
|
|
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 ("
|
|
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.
|
|
342
|
-
//
|
|
343
|
-
//
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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.
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
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,
|
|
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,
|
|
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}`,
|