scenescout 3.23.3 → 3.23.4

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 (57) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +20 -3
  3. package/dist/browsers.js +7 -6
  4. package/dist/ci-run.js +3 -2
  5. package/dist/cli.js +2 -1
  6. package/dist/clients.js +3 -3
  7. package/dist/code-routes.js +2 -2
  8. package/dist/commands.js +4 -4
  9. package/dist/engine/args.js +21 -0
  10. package/dist/engine/bench.js +1 -1
  11. package/dist/engine/brief.js +1 -1
  12. package/dist/engine/browser.js +59 -15
  13. package/dist/engine/capture.js +4 -4
  14. package/dist/engine/check-replay.js +82 -8
  15. package/dist/engine/check-report.js +8 -8
  16. package/dist/engine/check.js +15 -15
  17. package/dist/engine/ci.js +20 -20
  18. package/dist/engine/claims.js +4 -4
  19. package/dist/engine/collector.js +7 -7
  20. package/dist/engine/crawl.js +1 -1
  21. package/dist/engine/dedup.js +4 -4
  22. package/dist/engine/design.js +7 -7
  23. package/dist/engine/expiry.js +1 -1
  24. package/dist/engine/export.js +7 -7
  25. package/dist/engine/flow.js +10 -10
  26. package/dist/engine/forms.js +1 -1
  27. package/dist/engine/from-run.js +6 -6
  28. package/dist/engine/injection.js +1 -1
  29. package/dist/engine/lane.js +1 -1
  30. package/dist/engine/limits.js +2 -2
  31. package/dist/engine/live.js +30 -0
  32. package/dist/engine/memory.js +24 -7
  33. package/dist/engine/open.js +1 -1
  34. package/dist/engine/oracles.js +2 -2
  35. package/dist/engine/plain.js +1 -1
  36. package/dist/engine/png.js +2 -2
  37. package/dist/engine/policy.js +2 -2
  38. package/dist/engine/postmessage.js +7 -7
  39. package/dist/engine/project-folder.js +1 -1
  40. package/dist/engine/provider.js +1 -1
  41. package/dist/engine/replay.js +3 -1
  42. package/dist/engine/report.js +3 -3
  43. package/dist/engine/sarif.js +2 -2
  44. package/dist/engine/script-literal.js +11 -0
  45. package/dist/engine/sensitive.js +254 -0
  46. package/dist/engine/signed-in.js +2 -2
  47. package/dist/engine/status-pane-page.js +4 -3
  48. package/dist/engine/task.js +1 -1
  49. package/dist/engine/teardown.js +2 -0
  50. package/dist/engine/tickets.js +6 -6
  51. package/dist/engine/verify.js +3 -3
  52. package/dist/export-run.js +1 -1
  53. package/dist/installer.js +4 -4
  54. package/dist/login-run.js +2 -2
  55. package/dist/mcp-server.js +6 -9
  56. package/dist/playbook.js +1 -1
  57. package/package.json +2 -2
@@ -13,13 +13,14 @@
13
13
  import { createHash } from "node:crypto";
14
14
  import { BROWSER_ENGINES } from "../browsers.js";
15
15
  import { BASELINE_MODES, baselineEvidence, baselineFingerprint, countStatuses, describeChange, parseThreshold, VISUAL_RULE, } from "./baseline.js";
16
+ import { readFlagValue } from "./args.js";
16
17
  import { isNonPageResource } from "./crawl.js";
17
18
  import { flowStepEvidence } from "./flow.js";
18
19
  import { parseLimitFlag } from "./limits.js";
19
20
  import { redactRoute, redactSecrets } from "./memory.js";
20
21
  import { SARIF_ANCHOR_FALLBACKS, checkSarifAnchor, sarifFileFor, sarifLocation } from "./sarif.js";
21
22
  import { httpErrorDetail, httpStatusOf } from "./oracles.js";
22
- export const CHECK_SEVERITIES = ["high", "medium", "low"];
23
+ const CHECK_SEVERITIES = ["high", "medium", "low"];
23
24
  export const FAIL_ON = [...CHECK_SEVERITIES, "never"];
24
25
  /**
25
26
  * The routes that answered as pages, and those that answered as something
@@ -154,8 +155,8 @@ export const WORTH_A_LOOK_RULES = {
154
155
  convention: "row actions and other controls that stay in view without a sideways scroll of their container at this viewport width",
155
156
  },
156
157
  };
157
- export const CHECK_RULE_IDS = [...Object.keys(CHECK_RULES), ...Object.keys(WORTH_A_LOOK_RULES)];
158
- export function isWorthALookRule(rule) {
158
+ const CHECK_RULE_IDS = [...Object.keys(CHECK_RULES), ...Object.keys(WORTH_A_LOOK_RULES)];
159
+ function isWorthALookRule(rule) {
159
160
  return Object.prototype.hasOwnProperty.call(WORTH_A_LOOK_RULES, rule);
160
161
  }
161
162
  export const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
@@ -371,7 +372,7 @@ export function issuesFromRoutes(routes, origin, ignore = [], flows = [], ignore
371
372
  * Fewest controls a start page must show for a check that measured nothing
372
373
  * else to count as having measured the app (unmeasuredReason).
373
374
  */
374
- export const START_PAGE_MIN_CONTROLS = 2;
375
+ const START_PAGE_MIN_CONTROLS = 2;
375
376
  /**
376
377
  * Why a check has nothing to give a verdict on, or null when it measured the
377
378
  * app. Neither case is a pass: a gate that goes green on a login page or a
@@ -470,9 +471,9 @@ export const EXIT = { pass: 0, gateFailed: 1, error: 2 };
470
471
  * pages. Each default is the one that does the least harm on an unfamiliar
471
472
  * project: it never writes and never silently hides a result (ADR 12).
472
473
  */
473
- export const FLOW_WRITES = ["never", "allow"];
474
- export const ON_REFUSED_STEP = ["report", "stop"];
475
- export const GATE_RETESTS = ["never", "high", "all"];
474
+ const FLOW_WRITES = ["never", "allow"];
475
+ const ON_REFUSED_STEP = ["report", "stop"];
476
+ const GATE_RETESTS = ["never", "high", "all"];
476
477
  export const DEFAULT_SETTINGS = { flowWrites: "never", onRefusedStep: "report", gateRetests: "high", retest: true };
477
478
  /**
478
479
  * Every `--option` `scenescout check` accepts. The GitHub Action at the
@@ -512,7 +513,7 @@ const SWITCH_OFF = ["off", "false", "0"];
512
513
  export const MAX_CHECK_ROUTES = 150;
513
514
  /** Link discovery rounds: each crawl reveals the routes its pages link to. Past a few, a site is paginating rather than revealing. */
514
515
  export const MAX_DISCOVERY_ROUNDS = 6;
515
- export const DEFAULT_CHECK_ROUTES = 50;
516
+ const DEFAULT_CHECK_ROUTES = 50;
516
517
  /** A path given on the command line, resolved from the directory the command runs in; the same on every platform. */
517
518
  export function resolveArgPath(cwd, p) {
518
519
  return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) ? p : `${cwd.replace(/[\\/]$/, "")}/${p}`;
@@ -537,12 +538,11 @@ export function parseCheckArgs(args, cwd) {
537
538
  flags.set(name, next !== undefined && [...SWITCH_ON, ...SWITCH_OFF].includes(next) ? next : "on");
538
539
  continue;
539
540
  }
540
- const value = eq > 0 ? a.slice(eq + 1) : args[i + 1];
541
- if (value === undefined || (eq < 0 && value.startsWith("--")))
542
- return { ok: false, error: `--${name} needs a value` };
543
- if (eq < 0)
544
- i += 1;
545
- flags.set(name, value);
541
+ const read = readFlagValue(args, i);
542
+ if (!read.ok)
543
+ return read;
544
+ i = read.last;
545
+ flags.set(name, read.value);
546
546
  }
547
547
  const known = new Set(CHECK_OPTION_NAMES);
548
548
  for (const name of flags.keys())
@@ -1038,7 +1038,7 @@ export function needsSignInText(count, cover, noun = "route") {
1038
1038
  return `${count} ${noun}${count === 1 ? " needs" : "s need"} sign-in; give a role to cover them: ${cover}.`;
1039
1039
  }
1040
1040
  /** The routes that sent a check with no session to sign-in, as their own section; nothing when there were none. */
1041
- export function needsSignInSection(result) {
1041
+ function needsSignInSection(result) {
1042
1042
  const routes = result.needsSignIn ?? [];
1043
1043
  if (routes.length === 0)
1044
1044
  return [];
package/dist/engine/ci.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { createHash } from "node:crypto";
12
12
  import path from "node:path";
13
13
  import { BROWSER_ENGINES } from "../browsers.js";
14
+ import { readFlagValue } from "./args.js";
14
15
  import { MAX_LANES } from "./brief.js";
15
16
  import { markdownCell } from "./check.js";
16
17
  import { parseLimitFlag } from "./limits.js";
@@ -18,18 +19,18 @@ import { isWorthALook, redactSecrets } from "./memory.js";
18
19
  import { SARIF_ANCHOR_FALLBACKS, checkSarifAnchor, sarifLocation } from "./sarif.js";
19
20
  import { fromRunLine, resolveFromRun } from "./from-run.js";
20
21
  // ── options ─────────────────────────────────────────────────────────────────
21
- export const PROVIDERS = ["anthropic", "openai"];
22
+ const PROVIDERS = ["anthropic", "openai"];
22
23
  /** Where each provider's key is read from. Nothing else is: no flag, no file, no input. */
23
24
  export const KEY_ENV = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY" };
24
- export const DEFAULT_MODEL = { anthropic: "claude-sonnet-5", openai: "gpt-6-luna" };
25
+ const DEFAULT_MODEL = { anthropic: "claude-sonnet-5", openai: "gpt-6-luna" };
25
26
  /** The API root each request path is joined to. `--base-url` replaces it, for a compatible endpoint. */
26
- export const DEFAULT_BASE_URL = { anthropic: "https://api.anthropic.com/v1", openai: "https://api.openai.com/v1" };
27
+ const DEFAULT_BASE_URL = { anthropic: "https://api.anthropic.com/v1", openai: "https://api.openai.com/v1" };
27
28
  /** Reasoning effort each API accepts. The Messages API has no "none": thinking is lowered, not switched off. */
28
- export const EFFORTS = {
29
+ const EFFORTS = {
29
30
  anthropic: ["low", "medium", "high", "xhigh", "max"],
30
31
  openai: ["none", "low", "medium", "high", "xhigh", "max"],
31
32
  };
32
- export const DEFAULT_EFFORT = "low";
33
+ const DEFAULT_EFFORT = "low";
33
34
  /**
34
35
  * The write policies a CI run may use. `destructive` lets the model send any
35
36
  * request, deletes of records it did not create included, so it is taken only
@@ -37,8 +38,8 @@ export const DEFAULT_EFFORT = "low";
37
38
  * workflow or typed by an agent, never enables it. What CI may do is the
38
39
  * developer's decision; the default stays read-only (ADR 14).
39
40
  */
40
- export const CI_MODES = ["observe", "read-only", "safe-write", "destructive"];
41
- export const CI_LEVELS = ["minimal", "medium", "extensive"];
41
+ const CI_MODES = ["observe", "read-only", "safe-write", "destructive"];
42
+ const CI_LEVELS = ["minimal", "medium", "extensive"];
42
43
  /**
43
44
  * How a filed finding is told apart from one already stored. `rule`: the
44
45
  * store's rule alone (memory.ts isDuplicateFinding). `judge`: the rule first,
@@ -49,11 +50,11 @@ export const CI_LEVELS = ["minimal", "medium", "extensive"];
49
50
  * judges only when asked to (DEDUP_ENV or scout_attach {dedup}).
50
51
  */
51
52
  export const DEDUP_MODES = ["rule", "judge"];
52
- export const DEFAULT_CI_DEDUP = "judge";
53
+ const DEFAULT_CI_DEDUP = "judge";
53
54
  /** Turns the dedup judge on for the MCP server: `rule` (the default) or `judge`. An attach's `dedup` wins over it. */
54
55
  export const DEDUP_ENV = "SCENESCOUT_DEDUP";
55
56
  /** Which provider the MCP server's dedup judge uses when both keys are in its environment. */
56
- export const DEDUP_PROVIDER_ENV = "SCENESCOUT_DEDUP_PROVIDER";
57
+ const DEDUP_PROVIDER_ENV = "SCENESCOUT_DEDUP_PROVIDER";
57
58
  /** Why these are the defaults: docs/benchmark.md, "Choosing the defaults (issue 419)". */
58
59
  export const DEFAULT_CAPS = { turns: 80, tokens: 3_000_000, wallMs: 20 * 60_000 };
59
60
  const CAP_BOUNDS = { turns: [1, 500], tokens: [1_000, 20_000_000], minutes: [1, 360] };
@@ -66,7 +67,7 @@ const CAP_BOUNDS = { turns: [1, 500], tokens: [1_000, 20_000_000], minutes: [1,
66
67
  * lanes) runs as one loop rather than failing; --lanes given explicitly is
67
68
  * held to those rules.
68
69
  */
69
- export const DEFAULT_LANES = 2;
70
+ const DEFAULT_LANES = 2;
70
71
  export const MAX_CI_LANES = MAX_LANES;
71
72
  /** Every option `scenescout ci` accepts; the ci action's inputs are these names (ci-test holds them equal). */
72
73
  export const CI_OPTION_NAMES = [
@@ -100,7 +101,7 @@ export const CI_OPTION_NAMES = [
100
101
  "sarif-file-anchor",
101
102
  ];
102
103
  /** The longest --show description: it becomes a line of the model's prompt. */
103
- export const MAX_SHOW = 200;
104
+ const MAX_SHOW = 200;
104
105
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
105
106
  /** A base URL a key may be sent to: https, or plain http only to this machine. */
106
107
  export function checkBaseUrl(raw) {
@@ -136,12 +137,11 @@ export function parseCiArgs(args, cwd, env = {}) {
136
137
  flags.set(name, "true");
137
138
  continue;
138
139
  }
139
- const value = eq > 0 ? a.slice(eq + 1) : args[i + 1];
140
- if (value === undefined || (eq < 0 && value.startsWith("--")))
141
- return { ok: false, error: `--${name} needs a value` };
142
- if (eq < 0)
143
- i += 1;
144
- flags.set(name, value);
140
+ const read = readFlagValue(args, i);
141
+ if (!read.ok)
142
+ return read;
143
+ i = read.last;
144
+ flags.set(name, read.value);
145
145
  }
146
146
  const known = new Set(CI_OPTION_NAMES);
147
147
  for (const name of flags.keys()) {
@@ -522,7 +522,7 @@ export function describeStop(reason, caps, detail) {
522
522
  return `could not start${detail ? `: ${detail}` : ""}`;
523
523
  }
524
524
  }
525
- export const PRICES = {
525
+ const PRICES = {
526
526
  "gpt-6-luna": { input: 0.1, cachedInput: 0.01, output: 0.5 },
527
527
  "gpt-5.6-luna": { input: 0.2, cachedInput: 0.02, output: 1.2 },
528
528
  "claude-sonnet-5": { input: 2, cachedInput: 0.2, cacheWrite: 2.5, output: 10 },
@@ -666,7 +666,7 @@ export function guardToolArgs(name, args, projectDir) {
666
666
  return { ok: true, args: { ...args, projectPath: path.resolve(projectDir) } };
667
667
  }
668
668
  /** The longest tool result handed back to the model, in characters. The report keeps everything; this only bounds the context. */
669
- export const TOOL_RESULT_MAX_CHARS = 16_000;
669
+ const TOOL_RESULT_MAX_CHARS = 16_000;
670
670
  /** One tool result as text: images named rather than sent, and the length bounded. */
671
671
  export function toolResultText(content, max = TOOL_RESULT_MAX_CHARS) {
672
672
  const parts = (content ?? []).map((c) => (c.type === "text" ? (c.text ?? "") : `[${c.type} omitted: this run is text-only]`));
@@ -737,7 +737,7 @@ export function findingsThisRun(before, after) {
737
737
  return after.filter((f) => f.status !== "resolved" && (!runsBefore.has(f.id) || f.runs > (runsBefore.get(f.id) ?? 0)));
738
738
  }
739
739
  /** One line on how a run deduplicated, for the summary. */
740
- export function dedupLine(d) {
740
+ function dedupLine(d) {
741
741
  if (d.by === "rule")
742
742
  return "the rule alone";
743
743
  const tokens = d.usage.input + d.usage.output;
@@ -282,7 +282,7 @@ function pathNames(req, subject) {
282
282
  * sentence naming what they fetched is a genuinely empty section rather than
283
283
  * a refusal hidden behind an empty state.
284
284
  */
285
- export function pairEmptyState(text, refusedReads, loaded) {
285
+ function pairEmptyState(text, refusedReads, loaded) {
286
286
  const subject = emptyStateSubject(text);
287
287
  if (subject.length === 0)
288
288
  return { certain: true, read: refusedReads[0] };
@@ -446,7 +446,7 @@ export const COUNTER_RE = /(?<![\d/.,:])(?:\d{1,4} ?(?:\/|of) ?\d{1,4}|\d{1,3} ?
446
446
  /** The containers a counter must share with the control to count as its counter. */
447
447
  const CONTROL_CONTAINER_SEL = 'dialog, [role="dialog"], [role="alertdialog"], fieldset, form, [role="group"], [role="radiogroup"], [role="toolbar"], [role="region"], section, aside, [role="complementary"]';
448
448
  /** Most counters read for one control. */
449
- export const MAX_COUNTERS = 20;
449
+ const MAX_COUNTERS = 20;
450
450
  /**
451
451
  * Page-side reader of an acted-on control: `(${ACTED_CONTROL_SRC})(node)`
452
452
  * returns an ActedControl, or null for no node. A label stands for the box it
@@ -560,9 +560,9 @@ export function saidSince(text, baseline) {
560
560
  return true;
561
561
  }
562
562
  /** Most announced regions read from one page. */
563
- export const MAX_ANNOUNCED = 40;
563
+ const MAX_ANNOUNCED = 40;
564
564
  /** Most pieces of text read from one page. A page with more than this has nothing useful to say in the extra ones. */
565
- export const MAX_CLAIM_TEXTS = 120;
565
+ const MAX_CLAIM_TEXTS = 120;
566
566
  /**
567
567
  * Page-side reader for the two signals `findContradictions` needs. Shipped as
568
568
  * a STRING expression for the same reason the collector is: loader transforms
@@ -48,7 +48,7 @@ export const XPATH_OF_SRC = `(el) => {
48
48
  * says nothing. Kept apart from NAME_FACTS_SRC so the order can be
49
49
  * table-tested without a browser (contentSays).
50
50
  */
51
- export const CONTENT_SAYS_SRC = `(d) => {
51
+ const CONTENT_SAYS_SRC = `(d) => {
52
52
  if (d.ariaLabel && d.ariaLabel.trim()) return d.ariaLabel;
53
53
  if (d.tag === "img") return d.alt && d.alt.trim() ? d.alt : d.title;
54
54
  if (d.tag === "svg") return d.svgTitle;
@@ -75,7 +75,7 @@ export function contentSays(facts) {
75
75
  * an <img>'s alt and an <svg>'s or role="img"'s aria-label or <title>: it is
76
76
  * read so a control renamed since keeps its coverage.
77
77
  */
78
- export const NAME_FACTS_SRC = `(el) => {
78
+ const NAME_FACTS_SRC = `(el) => {
79
79
  const tag = el.tagName.toLowerCase();
80
80
  const role = el.getAttribute("role") || "";
81
81
  const inputType = tag === "input" ? el.type : "";
@@ -156,7 +156,7 @@ export const NAME_FACTS_SRC = `(el) => {
156
156
  * - A whitespace-only aria-label ends the name with nothing, as it always has
157
157
  * here: the field it hides is reported as unnamed.
158
158
  */
159
- export const PICK_NAME_SRC = `(f) => {
159
+ const PICK_NAME_SRC = `(f) => {
160
160
  const clean = (s) => (s || "").replace(/\\s+/g, " ").trim();
161
161
  const named = (s) => ({ name: s.slice(0, 80), from: null });
162
162
  const field = f.tag === "input" || f.tag === "textarea" || f.tag === "select";
@@ -300,9 +300,9 @@ export const FOCUS_MOVES_PROPS = [
300
300
  /** The most elements a snapshot lists in full; past it, what is left is counted (cutSummary). */
301
301
  export const COLLECTOR_CAP = 150;
302
302
  /** Pager and "load more" controls listed past the cap, so a lane can still reach the rest of a long list. */
303
- export const MAX_KEPT_PAST_CAP = 10;
303
+ const MAX_KEPT_PAST_CAP = 10;
304
304
  /** How many elements past the cap are looked at, to keep them or count them, before the rest goes uncounted. */
305
- export const MAX_SCANNED_PAST_CAP = 2000;
305
+ const MAX_SCANNED_PAST_CAP = 2000;
306
306
  /**
307
307
  * A control that pages a list or loads more of it, by its name: "Next",
308
308
  * "Previous page", "Page 3", "»", "Load more", "Show 20 more", "More results".
@@ -906,7 +906,7 @@ export function cutSummary(cut, groups = 8) {
906
906
  return named.join(", ");
907
907
  }
908
908
  /** A key without the ordinal the collector adds to the second and later elements sharing it ("tid:row~3" → "tid:row"). */
909
- export function keyFamily(key) {
909
+ function keyFamily(key) {
910
910
  return key.replace(/~\d+$/, "");
911
911
  }
912
912
  /**
@@ -1315,7 +1315,7 @@ export function frameElementKey(baseKey, frame) {
1315
1315
  return `frame:${where}|${baseKey}`;
1316
1316
  }
1317
1317
  /** The longest name kept for a control in another site's frame: a chat that renders messages as buttons would otherwise print them whole. */
1318
- export const MAX_FOREIGN_NAME = 40;
1318
+ const MAX_FOREIGN_NAME = 40;
1319
1319
  /** A control's name from another site's frame, cut to MAX_FOREIGN_NAME. */
1320
1320
  export function capForeignName(name) {
1321
1321
  return name.length > MAX_FOREIGN_NAME ? `${name.slice(0, MAX_FOREIGN_NAME)}…` : name;
@@ -48,7 +48,7 @@ export function crawlLine(o, counts, flags) {
48
48
  (allFlags.length ? ` · ${allFlags.join(" ")}` : ""));
49
49
  }
50
50
  /** The media types a browser renders as a page: anything else a route answers with is a file, a feed or data. */
51
- export const PAGE_MEDIA_TYPES = ["text/html", "application/xhtml+xml"];
51
+ const PAGE_MEDIA_TYPES = ["text/html", "application/xhtml+xml"];
52
52
  /**
53
53
  * A Content-Type header's media type, lowercased and without its parameters
54
54
  * ("text/HTML; charset=utf-8" → "text/html"); undefined when there is none.
@@ -150,7 +150,7 @@ export const JUDGE_SYSTEM = "You decide whether two findings filed by explorator
150
150
  "never below 0.5: near 0.5 when the text can barely tell, near 1 only when it plainly can. " +
151
151
  "Example: verdict different with confidence 0.9 means a 10% chance they are one defect. Use unsure only when the findings give too little to go on.";
152
152
  /** What the judge is shown: the fields a tester filed, nothing from the key. */
153
- export function judgeKickoff(route, a, b) {
153
+ function judgeKickoff(route, a, b) {
154
154
  const show = (f) => ({ title: f.title, category: f.category, evidence: f.evidence ?? "" });
155
155
  return `Both findings were filed on the page ${route}.\n\nFinding A: ${JSON.stringify(show(a))}\nFinding B: ${JSON.stringify(show(b))}`;
156
156
  }
@@ -223,7 +223,7 @@ export async function decideDuplicate(p, ask, ruleVerdict) {
223
223
  }
224
224
  // ── the judge the store asks ────────────────────────────────────────────────
225
225
  /** Calls made about one filing at most: its most alike open findings on the page. */
226
- export const JUDGE_MAX_CALLS = 3;
226
+ const JUDGE_MAX_CALLS = 3;
227
227
  /** The longest one judge call may take. */
228
228
  export const JUDGE_CALL_MS = 15_000;
229
229
  /**
@@ -231,9 +231,9 @@ export const JUDGE_CALL_MS = 15_000;
231
231
  * scout_finding's 60-second watchdog, so a slow provider costs a filing its
232
232
  * judge and never the filing.
233
233
  */
234
- export const JUDGE_FILING_MS = 40_000;
234
+ const JUDGE_FILING_MS = 40_000;
235
235
  /** Failed calls in a row after which the judge is switched off for the rest of the run. */
236
- export const JUDGE_MAX_FAILURES = 3;
236
+ const JUDGE_MAX_FAILURES = 3;
237
237
  /** The most output one judge call may produce: the answer is one short tool call (24 to 26 tokens when measured). */
238
238
  export const JUDGE_MAX_OUTPUT_TOKENS = 2_000;
239
239
  /**
@@ -53,7 +53,7 @@ export function focusedControl(doc) {
53
53
  * words, split at camelCase and punctuation, is filter(s) or facet(s). Whole
54
54
  * words only, so "filterable-list" or "unfiltered" says nothing.
55
55
  */
56
- export const FILTER_WORDS_SRC = `(s) => (s || "").replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z]+/).some((w) => w === "filter" || w === "filters" || w === "facet" || w === "facets")`;
56
+ const FILTER_WORDS_SRC = `(s) => (s || "").replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z]+/).some((w) => w === "filter" || w === "filters" || w === "facet" || w === "facets")`;
57
57
  /** FILTER_WORDS_SRC run outside a page, from the same source, so a table test exercises the rule the collector ships. */
58
58
  export function namesFilter(text) {
59
59
  return new Function(`return (${FILTER_WORDS_SRC});`)()(text);
@@ -435,7 +435,7 @@ function gridValues(top) {
435
435
  .join(", ");
436
436
  }
437
437
  /** The box a pointer user hits to operate the control: its label or drop zone where one operates it, else its own. */
438
- export function targetBox(r) {
438
+ function targetBox(r) {
439
439
  return r.target ?? r.rect;
440
440
  }
441
441
  /** The WCAG 2.2 target-size minimum, in CSS pixels. */
@@ -536,7 +536,7 @@ export function distinguishingLayer(key, others) {
536
536
  * stone), the shades text and surfaces are built from. Near-white and
537
537
  * near-black are left out, as before: every page has them.
538
538
  */
539
- export function grayOf(rgb) {
539
+ function grayOf(rgb) {
540
540
  const avg = (rgb[0] + rgb[1] + rgb[2]) / 3;
541
541
  return hueOf(rgb) === null && avg > 20 && avg < 245;
542
542
  }
@@ -574,7 +574,7 @@ const GRAY_STEP = 4;
574
574
  * the audit and the snapshot agree on a page. Records with no name read are
575
575
  * left out rather than guessed.
576
576
  */
577
- export function unnamedControls(records) {
577
+ function unnamedControls(records) {
578
578
  const named = records.filter((r) => r.interactive && typeof r.name === "string");
579
579
  const asSnapshot = (r) => ({
580
580
  role: r.role,
@@ -606,7 +606,7 @@ function distanceToBox([px, py], b) {
606
606
  * target's circle. `others` is every interactive record on the page, shell
607
607
  * included, since a neighbour in the shell is as easy to hit by mistake.
608
608
  */
609
- export function spacedApart(r, others) {
609
+ function spacedApart(r, others) {
610
610
  const c = centreOf(targetBox(r));
611
611
  for (const o of others) {
612
612
  if (o === r || !o.interactive)
@@ -628,11 +628,11 @@ export function spacedApart(r, others) {
628
628
  * A target that fails WCAG 2.5.8: under 24px, measured on what the user hits
629
629
  * (targetBox), and not saved by the spacing exception.
630
630
  */
631
- export function tooSmall(r, all) {
631
+ function tooSmall(r, all) {
632
632
  return undersized(r) && !spacedApart(r, all);
633
633
  }
634
634
  /** Past this, a stretched image reads as distorted rather than as rounding. */
635
- export const ASPECT_TOLERANCE = 0.12;
635
+ const ASPECT_TOLERANCE = 0.12;
636
636
  /**
637
637
  * How far an image is stretched out of its proportions, or null when it is not.
638
638
  * Only `fill`, the default, stretches the picture to its box: `cover` and `contain`
@@ -154,7 +154,7 @@ export function readLifetime(state, opts = {}) {
154
154
  return { credentials, dated, undated: credentials.length - dated.length, elsewhere, unreadableTokens };
155
155
  }
156
156
  /** A span as a person says it; days past two, since "73h00m" reads badly. */
157
- export function sayLifetime(ms) {
157
+ function sayLifetime(ms) {
158
158
  const days = Math.floor(ms / 86_400_000);
159
159
  return days >= 2 ? `${days} days` : sayDuration(ms);
160
160
  }
@@ -12,6 +12,7 @@
12
12
  * made inert before it goes into an issue.
13
13
  */
14
14
  import { createHash } from "node:crypto";
15
+ import { readFlagValue } from "./args.js";
15
16
  import { readFindingPicture } from "./capture.js";
16
17
  import { CHECK_RULES, WORTH_A_LOOK_RULES } from "./check.js";
17
18
  import { readFindings } from "./ci.js";
@@ -20,7 +21,7 @@ import { retryAfterMs } from "./provider.js";
20
21
  import { answerTicket } from "./tickets.js";
21
22
  const TRACKERS = ["github", "jira"];
22
23
  export const TRACKER_LABEL = { github: "GitHub", jira: "Jira" };
23
- export const SEVERITIES = ["high", "medium", "low"];
24
+ const SEVERITIES = ["high", "medium", "low"];
24
25
  const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
25
26
  /** Every option `scenescout export` accepts. guide-test holds the configuration reference equal to it. */
26
27
  export const EXPORT_OPTION_NAMES = [
@@ -183,12 +184,11 @@ export function parseExportArgs(args, cwd, env) {
183
184
  flags.set(name, "true");
184
185
  continue;
185
186
  }
186
- const value = eq > 0 ? a.slice(eq + 1) : args[i + 1];
187
- if (value === undefined || (eq < 0 && value.startsWith("--")))
188
- return { ok: false, error: `--${name} needs a value` };
189
- if (eq < 0)
190
- i += 1;
191
- flags.set(name, value);
187
+ const read = readFlagValue(args, i);
188
+ if (!read.ok)
189
+ return read;
190
+ i = read.last;
191
+ flags.set(name, read.value);
192
192
  }
193
193
  const to = flags.get("to");
194
194
  if (to === undefined)
@@ -25,23 +25,23 @@ import { FIXTURE_KINDS } from "./fixtures.js";
25
25
  import { MEMORY_DIRNAME, SELF_IGNORE_KEEP } from "./memory.js";
26
26
  import { validateRoleName } from "./profiles.js";
27
27
  /** Most steps one flow may hold. A longer flow is several flows, and each would fail more usefully on its own. */
28
- export const MAX_FLOW_STEPS = 50;
28
+ const MAX_FLOW_STEPS = 50;
29
29
  /** Most flows one check replays. */
30
- export const MAX_FLOWS = 50;
30
+ const MAX_FLOWS = 50;
31
31
  /**
32
32
  * How long a flow waits after its last step for a write that step set off
33
33
  * late (a save on a timer, a debounced autosave), before it counts as passed.
34
34
  */
35
35
  export const FLOW_AFTER_LAST_STEP_MS = 750;
36
36
  /** Where a project keeps its flows, relative to the project directory. */
37
- export const FLOWS_SUBDIR = path.join(MEMORY_DIRNAME, SELF_IGNORE_KEEP);
37
+ const FLOWS_SUBDIR = path.join(MEMORY_DIRNAME, SELF_IGNORE_KEEP);
38
38
  export const TARGET_HELP = 'testid=…, text=…, label=… or role=<role>[name="…"]';
39
39
  /**
40
40
  * The ARIA roles a `role=` target may name (WAI-ARIA 1.2, as Playwright's
41
41
  * getByRole accepts them). A misspelt role matches nothing, so a flow using
42
42
  * one would only ever fail at replay; it is refused when the file is read.
43
43
  */
44
- export const ARIA_ROLE_LIST = [
44
+ const ARIA_ROLE_LIST = [
45
45
  "alert",
46
46
  "alertdialog",
47
47
  "application",
@@ -126,7 +126,7 @@ export const ARIA_ROLE_LIST = [
126
126
  "treeitem",
127
127
  ];
128
128
  export const ARIA_ROLES = new Set(ARIA_ROLE_LIST);
129
- export const ARIA_ROLES_MATCH_PLAYWRIGHT = ARIA_ROLE_LIST;
129
+ const ARIA_ROLES_MATCH_PLAYWRIGHT = ARIA_ROLE_LIST;
130
130
  /** Parse a target string, or null when it is not one. */
131
131
  export function parseTarget(target) {
132
132
  for (const by of ["testid", "text", "label"]) {
@@ -159,7 +159,7 @@ export function notActionable(action, facts) {
159
159
  * when nothing matches: "the receipt was never shown" and "the dialog closed"
160
160
  * are both the absence of a visible element.
161
161
  */
162
- export const ELEMENT_STATES = ["visible", "hidden", "enabled", "disabled", "checked", "unchecked"];
162
+ const ELEMENT_STATES = ["visible", "hidden", "enabled", "disabled", "checked", "unchecked"];
163
163
  /**
164
164
  * Whether an element in the state `facts` describes satisfies `state`, and if
165
165
  * not, what is true instead, in the words the report uses. `facts` is null when
@@ -213,7 +213,7 @@ const target = z
213
213
  */
214
214
  const expected = z.string().trim().min(1, "is the result the step should bring about, in words").max(500).optional();
215
215
  /** Most times a repeat step may run its steps. A longer loop is a page that should be reached another way. */
216
- export const MAX_REPEATS = 100;
216
+ const MAX_REPEATS = 100;
217
217
  const SINGLE_STEP_SCHEMAS = [
218
218
  z.object({ action: z.literal("navigate"), expected, target: z.string().regex(/^\//, "must be a path on the app, starting with /") }).strict(),
219
219
  z.object({ action: z.literal("click"), expected, target }).strict(),
@@ -269,7 +269,7 @@ const repeatSchema = z
269
269
  .strict();
270
270
  const STEP_SCHEMAS = [...SINGLE_STEP_SCHEMAS, repeatSchema];
271
271
  const stepSchema = z.discriminatedUnion("action", [...STEP_SCHEMAS]);
272
- export const FLOW_ACTIONS = STEP_SCHEMAS.map((s) => s.shape.action.value);
272
+ const FLOW_ACTIONS = STEP_SCHEMAS.map((s) => s.shape.action.value);
273
273
  const flowSchema = z
274
274
  .object({
275
275
  name: z.string().min(1).max(100).optional(),
@@ -509,7 +509,7 @@ export function matchRequest(expect, seen) {
509
509
  * (browsers.ts `beaconResourceType`). They are fire-and-forget by design, a
510
510
  * page sends them on its own schedule, and no step waits for one.
511
511
  */
512
- export const BACKGROUND_REQUEST_TYPES = new Set(["ping", ...BROWSER_ENGINES.map(beaconResourceType)]);
512
+ const BACKGROUND_REQUEST_TYPES = new Set(["ping", ...BROWSER_ENGINES.map(beaconResourceType)]);
513
513
  /**
514
514
  * Split what the write policy refused during a step into what the step is
515
515
  * charged with and background requests, which are only listed. The rule is
@@ -534,7 +534,7 @@ export function splitRefusals(refusals, appOrigin) {
534
534
  /** A value taken from the environment when the flow runs: `${env:NAME}` in a type or select step's value. */
535
535
  const ENV_REF = /\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;
536
536
  /** Shortest substituted value masked in the check's output. Shorter ones would mask ordinary words and numbers. */
537
- export const MIN_MASKED_ENV_VALUE = 4;
537
+ const MIN_MASKED_ENV_VALUE = 4;
538
538
  /** Every step that takes a value, the steps inside a repeat included. */
539
539
  function valueSteps(steps) {
540
540
  return steps.flatMap((s) => s.action === "repeat" ? valueSteps(s.steps) : "value" in s && s.action !== "press" ? [s] : []);
@@ -135,7 +135,7 @@ export const APP_FILLED_TYPES = new Set(["date", "datetime-local", "month", "wee
135
135
  * Input types in which Enter submits the form (the HTML standard's implicit
136
136
  * submission). Not checkboxes, radios or buttons: Enter does nothing there.
137
137
  */
138
- export const IMPLICIT_SUBMIT_TYPES = new Set([...TEXT_ENTRY_TYPES, "search"]);
138
+ const IMPLICIT_SUBMIT_TYPES = new Set([...TEXT_ENTRY_TYPES, "search"]);
139
139
  /** Whether a field takes typed text and a user could type into it right now. */
140
140
  export function isTextEntry(f) {
141
141
  if (!f.visible || f.disabled || f.readOnly)
@@ -27,7 +27,7 @@ export const FROM_RUN_ENV = "SCENESCOUT_FROM_RUN";
27
27
  /** How to use it: `continue` (the default) or `replay`. */
28
28
  export const FROM_RUN_MODE_ENV = "SCENESCOUT_FROM_RUN_MODE";
29
29
  /** How many model turns a continued run budgets for each page it takes on: what sets its page cap (pageCap). */
30
- export const FROM_RUN_TURNS_PER_PAGE_ENV = "SCENESCOUT_FROM_RUN_TURNS_PER_PAGE";
30
+ const FROM_RUN_TURNS_PER_PAGE_ENV = "SCENESCOUT_FROM_RUN_TURNS_PER_PAGE";
31
31
  /**
32
32
  * Turns a continued run spends on one page by default: enough to exercise its
33
33
  * controls, submit its forms and try its options, measured against what a run
@@ -36,13 +36,13 @@ export const FROM_RUN_TURNS_PER_PAGE_ENV = "SCENESCOUT_FROM_RUN_TURNS_PER_PAGE";
36
36
  */
37
37
  export const DEFAULT_TURNS_PER_PAGE = 7;
38
38
  /** The bounds of the setting. */
39
- export const TURNS_PER_PAGE_RANGE = { min: 1, max: 200 };
39
+ const TURNS_PER_PAGE_RANGE = { min: 1, max: 200 };
40
40
  /**
41
41
  * `continue`: start where the earlier run left off. `replay`: follow its route
42
42
  * and step order again.
43
43
  */
44
44
  export const FROM_RUN_MODES = ["continue", "replay"];
45
- export const DEFAULT_FROM_RUN_MODE = "continue";
45
+ const DEFAULT_FROM_RUN_MODE = "continue";
46
46
  /**
47
47
  * The earlier run a run starts from: the path option, else the environment
48
48
  * variable, else none; the mode option, else its variable, else `continue`.
@@ -86,11 +86,11 @@ export function pageCap(turns, turnsPerPage) {
86
86
  return Math.max(1, Math.floor(turns / Math.max(1, turnsPerPage)));
87
87
  }
88
88
  /** The most steps a record keeps: a replay of more would not fit a model's first message anyway. */
89
- export const MAX_RECORD_STEPS = 400;
89
+ const MAX_RECORD_STEPS = 400;
90
90
  /** The most routes a record lists in any one list. */
91
- export const MAX_RECORD_ROUTES = 300;
91
+ const MAX_RECORD_ROUTES = 300;
92
92
  /** The most controls, forms or options a record keeps per route. */
93
- export const MAX_RECORD_KEYS = 40;
93
+ const MAX_RECORD_KEYS = 40;
94
94
  /** The most records a project's memory keeps; the oldest go first. */
95
95
  export const MAX_RECORDS = 20;
96
96
  /** A route's identity for matching: two spellings of one page are one route. */
@@ -22,7 +22,7 @@ export const MAX_PROBES = 60;
22
22
  /** Text is compared on this many characters, on both sides, so a long fuzz value still matches its element. */
23
23
  export const INJECTION_TEXT_MAX = 200;
24
24
  /** Most matching elements reported per probe per page. */
25
- export const MAX_HITS = 20;
25
+ const MAX_HITS = 20;
26
26
  const OPEN_RE = /<([a-zA-Z][a-zA-Z0-9-]*)/;
27
27
  // One attribute at a time, anchored where the last one ended: a slash between
28
28
  // attributes is the same as a space to a browser (`<svg/onload=…>`). Each
@@ -380,7 +380,7 @@ function pathSegments(path) {
380
380
  * `order-{id}` is a literal, and so is `**`, which some routers read as "any
381
381
  * number of segments": a template here stands for exactly one.
382
382
  */
383
- export function isTemplateSegment(seg) {
383
+ function isTemplateSegment(seg) {
384
384
  return seg === "*" || /^\{[^/{}]+\}$/.test(seg) || /^:[a-z_][a-z0-9_-]*$/i.test(seg);
385
385
  }
386
386
  /**
@@ -18,7 +18,7 @@ export const DEFAULT_NAV_TIMEOUT_MS = 20000;
18
18
  /** The crawl has always given each route less than a deliberate navigation. */
19
19
  export const DEFAULT_CRAWL_NAV_TIMEOUT_MS = 15000;
20
20
  /** Going back and a popup's load have always had the shortest wait. */
21
- export const DEFAULT_BACK_NAV_TIMEOUT_MS = 10000;
21
+ const DEFAULT_BACK_NAV_TIMEOUT_MS = 10000;
22
22
  export const DEFAULT_TIME_LIMITS = {
23
23
  actionMs: DEFAULT_ACTION_TIMEOUT_MS,
24
24
  navMs: DEFAULT_NAV_TIMEOUT_MS,
@@ -42,7 +42,7 @@ function boundsText(kind) {
42
42
  return `a whole number of milliseconds from ${min} to ${max}`;
43
43
  }
44
44
  /** Check one value against its bounds. `source` names where it came from, so the error says what to fix. */
45
- export function checkLimit(kind, value, source) {
45
+ function checkLimit(kind, value, source) {
46
46
  const { min, max } = LIMIT_BOUNDS[kind];
47
47
  if (!Number.isInteger(value) || value < min || value > max) {
48
48
  throw new Error(`${source} must be ${boundsText(kind)} (got ${value}).`);
@@ -35,6 +35,36 @@ export const LIVE_TOKEN_FILE = "live-token";
35
35
  export function liveTokenFileName(pid) {
36
36
  return `${LIVE_TOKEN_FILE}.${pid}`;
37
37
  }
38
+ /**
39
+ * Hand the live view's token to `scenescout watch`: this engine's own file,
40
+ * then the shared name. Each is born owner-only under a fresh temporary name
41
+ * and renamed into place. Writing to the name itself would keep the bits of a
42
+ * file already there (`mode` applies only on creation) and would follow a link
43
+ * left at it, sending the token into whatever file the link points at; a
44
+ * rename replaces the entry, link or file, and never writes through it.
45
+ */
46
+ export async function writeLiveToken(dir, pid, token) {
47
+ for (const name of [liveTokenFileName(pid), LIVE_TOKEN_FILE]) {
48
+ const file = path.join(dir, name);
49
+ const temp = `${file}.${pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
50
+ // "wx": created here and now, so the mode given is the one it is born with, and never a link's target.
51
+ const handle = await fs.promises.open(temp, "wx", 0o600);
52
+ try {
53
+ try {
54
+ await handle.writeFile(token);
55
+ }
56
+ finally {
57
+ await handle.close();
58
+ }
59
+ await fs.promises.rename(temp, file);
60
+ }
61
+ catch (err) {
62
+ // The temporary file holds the token too: nothing that failed may leave it behind.
63
+ await fs.promises.rm(temp, { force: true }).catch(() => { });
64
+ throw err;
65
+ }
66
+ }
67
+ }
38
68
  /** `SCENESCOUT_LIVE=off` keeps the engine from opening the live view's port at all. */
39
69
  export const LIVE_ENV = "SCENESCOUT_LIVE";
40
70
  /**