scenescout 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.
@@ -0,0 +1,577 @@
1
+ /**
2
+ * `scenescout <url>`: a first look, with no setup.
3
+ *
4
+ * It is `scenescout check`'s engine (check-run.ts) run in observe mode unless
5
+ * told otherwise, with a cap on pages and one on time, and presented as a
6
+ * first look rather than a gate:
7
+ * once it has looked it exits 0 whatever it found, and its summary and report
8
+ * open with the three issues to look at first.
9
+ *
10
+ * It installs nothing but the one browser build a headless check launches, and
11
+ * only when that is missing: no skill, no MCP registration, no command on
12
+ * PATH. It reads nothing from the folder it runs in and writes only its report
13
+ * folder there, and only into a folder that is new, empty or an earlier first
14
+ * look's.
15
+ *
16
+ * The rules live here so they can be table-tested: install-test covers the
17
+ * command line, the browser download and where the report is written,
18
+ * check-test what the summary and the report say. cli.ts drives the browser,
19
+ * and scripts/smoke/first-run.ts runs the built CLI against the demo app with
20
+ * a throwaway home directory.
21
+ */
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { APPROX_DISK_MB, launchTarget } from "./browsers.js";
25
+ import { hasScheme, hostOf } from "./commands.js";
26
+ import { CHECK_OPTION_NAMES, CHECK_RULES, countBySeverity, issueLine, issueSections, MAX_CHECK_ROUTES, MAX_DISCOVERY_ROUNDS, resolveArgPath, routesTable, SEVERITY_RANK, SHARED_CHROME_ROUTE, unauditedRoutes, unvisitedLine, worthALookSection, } from "./engine/check.js";
27
+ import { httpStatusOf } from "./engine/oracles.js";
28
+ /** Every `--option` a first look accepts. Anything more is `scenescout check`'s. */
29
+ export const FIRST_RUN_OPTION_NAMES = ["max-routes", "max-minutes", "mode", "out"];
30
+ /**
31
+ * The write modes a first look runs in. Observe by default: a first look is
32
+ * often pointed at a live app nobody has said may be written to, and observe
33
+ * lets nothing but reads leave the page, where read-only lets plain POSTs through.
34
+ */
35
+ const FIRST_RUN_MODES = ["observe", "read-only"];
36
+ /** Small enough to finish in a few minutes on most apps, large enough to reach past the start page's own links. */
37
+ export const FIRST_RUN_DEFAULTS = { maxRoutes: 20, maxMinutes: 3, mode: "observe" };
38
+ export const MAX_FIRST_RUN_MINUTES = 30;
39
+ /** The folder the report goes to, in the directory the command runs in. */
40
+ export const FIRST_RUN_DIRNAME = "scenescout-report";
41
+ export const GUIDE_URL = "https://github.com/brunoboto96/SceneScout/wiki/Start-here";
42
+ const SAFETY_URL = "https://github.com/brunoboto96/SceneScout/wiki/Safety-model";
43
+ /** Exit codes: a first look is not a gate, so findings never change them. */
44
+ export const EXIT_FIRST_RUN = { ran: 0, couldNotRun: 2 };
45
+ /** The browser a first look drives, whatever SCENESCOUT_BROWSER says: it is the one it downloads when missing. */
46
+ const FIRST_RUN_ENGINE = "chromium";
47
+ /** A command-line argument as it can be pasted into a shell: quoted when it holds a character the shell would act on, such as `?` or `&`. */
48
+ export function shellArg(text) {
49
+ return /^[A-Za-z0-9_/.:=@%+,~-]+$/.test(text) ? text : `'${text.replace(/'/g, "'\\''")}'`;
50
+ }
51
+ /** Hosts a dev server answers on, where the scheme to suggest is plain http. */
52
+ const LOCAL_HOST = /^(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1?\])(?::\d+)?$/i;
53
+ /**
54
+ * Parse a first look's command line, the address first, as the dispatcher
55
+ * hands it over. Every mistake is a sentence, never a half-configured run.
56
+ */
57
+ export function parseFirstRunArgs(args, cwd) {
58
+ const positional = [];
59
+ const flags = new Map();
60
+ const takes = `a first look takes only ${FIRST_RUN_OPTION_NAMES.map((n) => `--${n}`).join(", ")}`;
61
+ for (let i = 0; i < args.length; i++) {
62
+ const a = args[i];
63
+ if (!a.startsWith("-")) {
64
+ positional.push(a);
65
+ continue;
66
+ }
67
+ if (!a.startsWith("--"))
68
+ return { ok: false, error: `unknown option ${a}: ${takes}` };
69
+ const eq = a.indexOf("=");
70
+ const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
71
+ if (!FIRST_RUN_OPTION_NAMES.includes(name)) {
72
+ return {
73
+ ok: false,
74
+ error: CHECK_OPTION_NAMES.includes(name)
75
+ ? `--${name} is an option of scenescout check, not of a first look: scenescout check <url> --${name} …`
76
+ : `unknown option --${name}: ${takes}`,
77
+ };
78
+ }
79
+ const value = eq > 0 ? a.slice(eq + 1) : args[i + 1];
80
+ if (value === undefined || value.trim() === "" || (eq < 0 && value.startsWith("--")))
81
+ return { ok: false, error: `--${name} needs a value` };
82
+ if (eq < 0)
83
+ i += 1;
84
+ flags.set(name, value);
85
+ }
86
+ if (positional.length === 0)
87
+ return { ok: false, error: "give the address to look at, e.g. scenescout http://localhost:3000" };
88
+ if (positional.length > 1)
89
+ return { ok: false, error: `give one address, not ${positional.length}: ${positional.join(" ")}` };
90
+ const raw = positional[0];
91
+ if (!hasScheme(raw)) {
92
+ // Not guessed: http and https are different servers, and a wrong guess reads as "the app is down".
93
+ return {
94
+ ok: false,
95
+ error: `write the address in full, with its scheme: scenescout ${shellArg(`${LOCAL_HOST.test(hostOf(raw)) ? "http" : "https"}://${raw}`)}`,
96
+ };
97
+ }
98
+ let url;
99
+ try {
100
+ url = new URL(raw);
101
+ }
102
+ catch {
103
+ return { ok: false, error: `not a URL: ${raw}` };
104
+ }
105
+ if (url.protocol !== "http:" && url.protocol !== "https:")
106
+ return { ok: false, error: `only http and https addresses can be looked at (got ${url.protocol})` };
107
+ // It would be copied into the report and the summary.
108
+ if (url.username || url.password) {
109
+ return { ok: false, error: "put no credentials in the address: they would be written into the report. For pages behind a sign-in, see scenescout login" };
110
+ }
111
+ const whole = (name, lo, hi, fallback) => {
112
+ const raw = flags.get(name);
113
+ if (raw === undefined)
114
+ return fallback;
115
+ const n = Number(raw);
116
+ return Number.isInteger(n) && n >= lo && n <= hi ? n : `--${name} must be a whole number from ${lo} to ${hi}`;
117
+ };
118
+ const maxRoutes = whole("max-routes", 1, MAX_CHECK_ROUTES, FIRST_RUN_DEFAULTS.maxRoutes);
119
+ if (typeof maxRoutes === "string")
120
+ return { ok: false, error: maxRoutes };
121
+ const maxMinutes = whole("max-minutes", 1, MAX_FIRST_RUN_MINUTES, FIRST_RUN_DEFAULTS.maxMinutes);
122
+ if (typeof maxMinutes === "string")
123
+ return { ok: false, error: maxMinutes };
124
+ const mode = flags.get("mode") ?? FIRST_RUN_DEFAULTS.mode;
125
+ if (!FIRST_RUN_MODES.includes(mode)) {
126
+ return { ok: false, error: `--mode must be observe (the default) or read-only: a first look never writes on purpose, whatever the mode` };
127
+ }
128
+ const out = flags.get("out");
129
+ return {
130
+ ok: true,
131
+ options: { url: url.toString(), maxRoutes, maxMinutes, mode: mode, ...(out !== undefined ? { outDir: resolveArgPath(cwd, out) } : {}) },
132
+ };
133
+ }
134
+ /**
135
+ * The check a first look is: in its mode, never gated, its caps, and nothing
136
+ * read from a project. `projectDir` is an empty folder of its own, so no saved
137
+ * flow, memory or source route of whatever folder it runs in is used.
138
+ */
139
+ export function firstRunCheckOptions(o, projectDir) {
140
+ return {
141
+ url: o.url,
142
+ projectDir,
143
+ failOn: "never",
144
+ mode: o.mode,
145
+ browser: FIRST_RUN_ENGINE,
146
+ maxRoutes: o.maxRoutes,
147
+ timeBudgetMs: o.maxMinutes * 60_000,
148
+ ignore: [],
149
+ flows: "off",
150
+ retest: false,
151
+ flowWrites: "never",
152
+ onRefusedStep: "report",
153
+ gateRetests: "never",
154
+ };
155
+ }
156
+ /** What a first look downloads: the one build a headless Chromium launch needs, when it is not on disk, and nothing else. */
157
+ export function firstRunDownloads(presence) {
158
+ const target = launchTarget(FIRST_RUN_ENGINE, false);
159
+ return presence[target].installed ? [] : [target];
160
+ }
161
+ /** The progress line before the download. */
162
+ export function downloadLine(targets) {
163
+ const mb = targets.reduce((sum, t) => sum + APPROX_DISK_MB[t], 0);
164
+ return `· Chromium is not on this machine yet. Downloading it once (${targets.join(", ")}, about ${mb} MB on disk)…`;
165
+ }
166
+ /** Why the address could not be looked at, or null when a page loaded. Nothing loaded means there is nothing to report. */
167
+ export function unreachableReason(routes) {
168
+ if (routes.some((r) => r.loadError === undefined))
169
+ return null;
170
+ return routes[0]?.loadError ?? "no page loaded";
171
+ }
172
+ /** What each mode lets out of the page, in one sentence: for the line before a look and for the report. */
173
+ export function modeSentence(mode) {
174
+ return mode === "observe"
175
+ ? "In observe mode nothing but GET, HEAD and OPTIONS requests leaves the page, apart from signing in, signing out and refreshing a token: every other request a page sends is refused."
176
+ : "In read-only mode a PUT, PATCH or DELETE a page sends, or a POST that looks destructive, is refused, while a plain POST the page's own scripts send goes through.";
177
+ }
178
+ const SELF_IGNORE = "# A SceneScout first-look report. It ignores itself, so a `git add -A` here never commits it.\n*\n";
179
+ /** The file that makes a folder a first look's: a folder is written into again only when it holds this, is empty or is new. */
180
+ export const FIRST_LOOK_MARKER = ".scenescout-first-look";
181
+ const MARKER_TEXT = "This folder holds a SceneScout first-look report. A later `scenescout <url>` replaces report.md and check.json here, and nothing else.\n";
182
+ /**
183
+ * How each file a first look writes begins. A file is replaced only when it is
184
+ * a regular file that begins this way: its name proves nothing on a file
185
+ * system that ignores case, where someone's Report.md answers to report.md,
186
+ * and a link would carry the write somewhere else.
187
+ */
188
+ const OWN_FILE_START = {
189
+ "report.md": "# SceneScout first look\n",
190
+ "check.json": '{\n "tool": "scenescout-check",',
191
+ };
192
+ const FIRST_LOOK_FILES = Object.keys(OWN_FILE_START);
193
+ /** Whether `text`, the start of a file named `name`, is how a first look writes that file. */
194
+ export function writtenByFirstLook(name, text) {
195
+ return text.startsWith(OWN_FILE_START[name]);
196
+ }
197
+ /** Why a write failed, in a word where the system gives one. */
198
+ const writeFailure = (err) => err.code ?? (err instanceof Error ? err.message : String(err));
199
+ /** What is at `p`, its last part not followed if it is a link: nothing, a regular file, a folder, or something else (a link, a device). */
200
+ function entryAt(p) {
201
+ let stat;
202
+ try {
203
+ stat = fs.lstatSync(p, { throwIfNoEntry: false });
204
+ }
205
+ catch (err) {
206
+ // A path under a file has no entry: the file above it is what is there, and the caller names it.
207
+ if (err.code === "ENOTDIR")
208
+ return "none";
209
+ throw err;
210
+ }
211
+ if (!stat)
212
+ return "none";
213
+ return stat.isFile() ? "file" : stat.isDirectory() ? "folder" : "other";
214
+ }
215
+ /** How a file begins: enough of it to tell whether a first look wrote it. */
216
+ function startOf(p) {
217
+ const fd = fs.openSync(p, "r");
218
+ try {
219
+ const buf = Buffer.alloc(64);
220
+ return buf.toString("utf8", 0, fs.readSync(fd, buf, 0, buf.length, 0));
221
+ }
222
+ finally {
223
+ fs.closeSync(fd);
224
+ }
225
+ }
226
+ /** The names a first look writes under which `dir` holds something it did not write: another kind of entry, or a file that begins otherwise. */
227
+ function entriesNotItsOwn(dir) {
228
+ const theirs = FIRST_LOOK_FILES.filter((name) => {
229
+ const at = entryAt(path.join(dir, name));
230
+ return at !== "none" && !(at === "file" && writtenByFirstLook(name, startOf(path.join(dir, name))));
231
+ });
232
+ const marker = entryAt(path.join(dir, FIRST_LOOK_MARKER));
233
+ if (marker !== "none" && marker !== "file")
234
+ theirs.push(FIRST_LOOK_MARKER);
235
+ return theirs;
236
+ }
237
+ /**
238
+ * Why a first look may not write its report to `dir`, or null when it may.
239
+ * Nothing is created or changed here, so it can be asked before the look, and
240
+ * it is asked again just before writing.
241
+ *
242
+ * - The default folder (`chosen` false) is written only when it is new, empty
243
+ * or holds the marker of an earlier first look: anything else there is
244
+ * someone's, and nothing in it is replaced. A default folder that cannot be
245
+ * written is not a reason: the report then goes to a temporary folder.
246
+ * - A folder named with --out (`chosen` true) may hold other files.
247
+ * - In either, a report.md or check.json is replaced only when a first look
248
+ * wrote it, and the folder must be a folder, not a link to one. An --out
249
+ * folder must be writable, or creatable under a folder that is; a
250
+ * permission this cannot see shows when the report is written.
251
+ */
252
+ export function reportFolderProblem(dir, chosen) {
253
+ const elsewhere = chosen ? "Name another folder with --out" : "Pass --out <folder> to put the report somewhere else";
254
+ const named = chosen ? `--out ${dir}` : dir;
255
+ try {
256
+ const at = entryAt(dir);
257
+ if (at === "file" || at === "other") {
258
+ return chosen
259
+ ? `--out ${dir} is ${at === "file" ? "a file" : "a link or a special file"}, not a folder.`
260
+ : `${dir} already exists and is not a folder, so it is left alone. ${elsewhere}.`;
261
+ }
262
+ if (at === "folder") {
263
+ const marked = entryAt(path.join(dir, FIRST_LOOK_MARKER)) === "file";
264
+ if (!chosen && !marked && fs.readdirSync(dir).length > 0) {
265
+ return `${dir} already exists and holds files a first look did not write, so nothing in it is touched. ${elsewhere}.`;
266
+ }
267
+ const theirs = entriesNotItsOwn(dir);
268
+ if (theirs.length > 0)
269
+ return `${named} holds a ${theirs.join(" and ")} a first look did not write, so nothing there is replaced. ${elsewhere}.`;
270
+ }
271
+ }
272
+ catch (err) {
273
+ return `${named} cannot be read (${writeFailure(err)}), so nothing is written there. ${elsewhere}.`;
274
+ }
275
+ if (!chosen)
276
+ return null;
277
+ // The folder itself when it exists, else the nearest folder above it that does: the one the new folders go under.
278
+ let existing = dir;
279
+ while (!fs.existsSync(existing) && path.dirname(existing) !== existing)
280
+ existing = path.dirname(existing);
281
+ try {
282
+ if (!fs.statSync(existing).isDirectory())
283
+ return `--out ${dir} cannot be created: ${existing} is a file.`;
284
+ fs.accessSync(existing, fs.constants.W_OK);
285
+ }
286
+ catch (err) {
287
+ return `--out ${dir} cannot be written (${writeFailure(err)}).`;
288
+ }
289
+ return null;
290
+ }
291
+ /**
292
+ * Write a first look's report files, each folder's marker first, so a write
293
+ * that fails partway still leaves a folder a later look may write into.
294
+ * Without `outDir` they go to `scenescout-report/` in `cwd`, whose `.gitignore`
295
+ * comes next, so nothing there is left for git to pick up; when that folder
296
+ * cannot be written they go to a new folder under `tmpdir`, and `note` says
297
+ * why. A folder named with --out is used as given or not at all. Throws with
298
+ * the reason when the folder is someone else's (`reportFolderProblem`), and
299
+ * with every reason when nowhere could be written.
300
+ */
301
+ export function writeFirstRunReport(files, where) {
302
+ const write = (dir, selfIgnore) => {
303
+ fs.mkdirSync(dir, { recursive: true });
304
+ fs.writeFileSync(path.join(dir, FIRST_LOOK_MARKER), MARKER_TEXT);
305
+ const ignore = path.join(dir, ".gitignore");
306
+ // Never over anything already there, a link included: a .gitignore someone wrote stays theirs.
307
+ if (selfIgnore && entryAt(ignore) === "none")
308
+ fs.writeFileSync(ignore, SELF_IGNORE);
309
+ for (const name of FIRST_LOOK_FILES)
310
+ fs.writeFileSync(path.join(dir, name), files[name]);
311
+ };
312
+ if (where.outDir !== undefined) {
313
+ const problem = reportFolderProblem(where.outDir, true);
314
+ if (problem)
315
+ throw new Error(problem);
316
+ try {
317
+ write(where.outDir, false);
318
+ }
319
+ catch (err) {
320
+ throw new Error(`${where.outDir} could not be written (${writeFailure(err)})`);
321
+ }
322
+ return { dir: where.outDir };
323
+ }
324
+ const here = path.join(where.cwd, FIRST_RUN_DIRNAME);
325
+ // Someone else's folder is never written into, and never swapped for a temporary one: the person decides with --out.
326
+ const problem = reportFolderProblem(here, false);
327
+ if (problem)
328
+ throw new Error(problem);
329
+ let first;
330
+ try {
331
+ write(here, true);
332
+ return { dir: here };
333
+ }
334
+ catch (err) {
335
+ first = writeFailure(err);
336
+ }
337
+ try {
338
+ const elsewhere = fs.mkdtempSync(path.join(where.tmpdir, `${FIRST_RUN_DIRNAME}-`));
339
+ write(elsewhere, false);
340
+ return { dir: elsewhere, note: `· ${here} could not be written (${first}), so the report is in a temporary folder.` };
341
+ }
342
+ catch (err) {
343
+ throw new Error(`${here} could not be written (${first}), and neither could a temporary folder (${writeFailure(err)})`);
344
+ }
345
+ }
346
+ /** Declaration order puts causes first: a failed request before the broken image it leaves. */
347
+ const RULE_ORDER = Object.keys(CHECK_RULES);
348
+ /** Pages an issue was seen on. The app's shared shell is on every page looked at. */
349
+ export function pagesAffected(issue, pagesLookedAt) {
350
+ return issue.routes.includes(SHARED_CHROME_ROUTE) ? Math.max(pagesLookedAt, issue.routes.length) : issue.routes.length;
351
+ }
352
+ /** Evidence without the note redaction appends to it ("[1 secret redacted]"), which would otherwise end every line it touched. */
353
+ const withoutRedactionNote = (evidence) => evidence.replace(/ \[\d+ secrets? redacted\]$/, "");
354
+ /**
355
+ * The address an issue is about, when it is a request or an image. An image
356
+ * that answers 404 is filed twice, as a failed request and as a broken image,
357
+ * and it is one thing to look at.
358
+ */
359
+ export function resourceOf(issue) {
360
+ const evidence = withoutRedactionNote(issue.evidence);
361
+ switch (issue.rule) {
362
+ case "client-error":
363
+ case "server-error":
364
+ case "request-failed":
365
+ return /^[A-Z]+ (\S+)/.exec(evidence)?.[1] ?? null;
366
+ case "broken-image":
367
+ return / FAILED TO LOAD — (\S+)$/.exec(evidence)?.[1] ?? null;
368
+ default:
369
+ return null;
370
+ }
371
+ }
372
+ /** Below this, a shorter address that starts a longer one is a different address, not the same one cut short. */
373
+ const CUT_ADDRESS_MIN = 80;
374
+ /**
375
+ * Whether two addresses name the same resource. An image's address is cut at
376
+ * 160 characters and a request's at 200, so a long one is compared on the
377
+ * part both kept.
378
+ */
379
+ export function sameAddress(a, b) {
380
+ if (a === b)
381
+ return true;
382
+ const [short, long] = a.length <= b.length ? [a, b] : [b, a];
383
+ return short.length >= CUT_ADDRESS_MIN && long.startsWith(short);
384
+ }
385
+ /** The browser's own console line for a load that failed: it names the status or the network error, never the address. */
386
+ const LOAD_ECHO = /^Failed to load resource: (?:the server responded with a status of (\d{3})\b|(net::[A-Z_]+))/;
387
+ /**
388
+ * Whether a console error is the browser's own line about a failed request the
389
+ * check lists as an issue anyway: "Failed to load resource" with the same
390
+ * status (or network error) as a failed request on one of the same pages. The
391
+ * line has no address to compare, so the page and the status are what it is
392
+ * matched on. One such line is filed once for every page it appeared on, so
393
+ * it can outrank each request it echoes; the request is the thing to look at.
394
+ */
395
+ export function echoesListedRequest(issue, issues) {
396
+ if (issue.rule !== "console-error")
397
+ return false;
398
+ const m = LOAD_ECHO.exec(issue.evidence);
399
+ if (!m)
400
+ return false;
401
+ return issues.some((c) => {
402
+ if (!c.routes.some((r) => issue.routes.includes(r)))
403
+ return false;
404
+ if (m[1] !== undefined)
405
+ return (c.rule === "client-error" || c.rule === "server-error") && httpStatusOf(c.evidence) === Number(m[1]);
406
+ return c.rule === "request-failed" && withoutRedactionNote(c.evidence).endsWith(`→ ${m[2]}`);
407
+ });
408
+ }
409
+ /**
410
+ * The issues to look at first: the highest severity, then the most pages
411
+ * affected. Within those, a console error comes last, since its cause usually
412
+ * shows as its own issue, and then the order the rules are declared in. One
413
+ * failure seen several ways takes one slot: an issue about an address already
414
+ * chosen is skipped (a missing image is a failed request and a broken image),
415
+ * and so is the browser's console line about a failed request that is listed.
416
+ */
417
+ export function firstLook(issues, pagesLookedAt, count = 3) {
418
+ const ordered = [...issues].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||
419
+ pagesAffected(b, pagesLookedAt) - pagesAffected(a, pagesLookedAt) ||
420
+ Number(a.rule === "console-error") - Number(b.rule === "console-error") ||
421
+ RULE_ORDER.indexOf(a.rule) - RULE_ORDER.indexOf(b.rule));
422
+ const picked = [];
423
+ const addresses = [];
424
+ for (const i of ordered) {
425
+ if (picked.length === count)
426
+ break;
427
+ if (echoesListedRequest(i, issues))
428
+ continue;
429
+ const address = resourceOf(i);
430
+ if (address !== null) {
431
+ if (addresses.some((a) => sameAddress(a, address)))
432
+ continue;
433
+ addresses.push(address);
434
+ }
435
+ picked.push(i);
436
+ }
437
+ return picked;
438
+ }
439
+ /**
440
+ * Why pages were found and not looked at: the time limit, the page limit, or
441
+ * the link discovery rounds running out. Null when nothing found was left.
442
+ */
443
+ export function stopReason(result, maxRoutes) {
444
+ if (result.unvisited.length === 0)
445
+ return null;
446
+ if (result.timeBudget?.reached)
447
+ return "time";
448
+ if (result.routes.length >= maxRoutes)
449
+ return "pages";
450
+ return "rounds";
451
+ }
452
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
453
+ /** Whole seconds first, so 119.7 s reads "2 min 0 s" and never "1 min 60 s". */
454
+ const seconds = (ms) => {
455
+ const s = Math.max(1, Math.round(ms / 1000));
456
+ return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${s % 60} s`;
457
+ };
458
+ const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
459
+ /**
460
+ * Evidence as one terminal line. It quotes the page (an exception's message, a
461
+ * console line), so a line break would split the list, and a control character,
462
+ * an escape sequence among them, would reach the terminal from the app.
463
+ */
464
+ const oneLine = (text) => text
465
+ .replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ")
466
+ .replace(/ {2,}/g, " ")
467
+ .trim();
468
+ /** Where an issue was seen, in a few words. */
469
+ function seenOn(i, pagesLookedAt) {
470
+ if (i.routes.includes(SHARED_CHROME_ROUTE))
471
+ return "on every page";
472
+ if (i.routes.length === 1)
473
+ return `on ${i.routes[0]}`;
474
+ const shown = i.routes.slice(0, 2).join(", ");
475
+ return `on ${pagesAffected(i, pagesLookedAt)} pages: ${shown}${i.routes.length > 2 ? ` and ${i.routes.length - 2} more` : ""}`;
476
+ }
477
+ function countsText(result) {
478
+ const c = countBySeverity(result.issues);
479
+ const unaudited = unauditedRoutes(result.routes).length;
480
+ return (`${c.high} high · ${c.medium} medium · ${c.low} low` +
481
+ (result.worthALook.length > 0 ? ` · ${result.worthALook.length} worth a look, never counted` : "") +
482
+ (unaudited > 0 ? ` · design not measured on ${plural(unaudited, "page")}` : ""));
483
+ }
484
+ /** The origin a page ended up on, when it is not the one asked for; null when it is, or cannot be read. */
485
+ function movedTo(asked, landed) {
486
+ try {
487
+ const to = new URL(landed).origin;
488
+ return to !== new URL(asked).origin ? to : null;
489
+ }
490
+ catch {
491
+ // A route's address that does not parse is reported as it is elsewhere; it is no evidence of a move.
492
+ return null;
493
+ }
494
+ }
495
+ /** What limited the look, in sentences. `code` formats a command: plain in a terminal, a code span in markdown. */
496
+ function notes({ result, options }, code) {
497
+ const out = [];
498
+ const start = result.routes[0];
499
+ if (start?.loginRedirect) {
500
+ out.push(`The start page sent the browser to a sign-in page, so this is what a visitor who is not signed in sees. ${code(`npx -y scenescout login ${shellArg(result.url)} --role <name>`)} saves a sign-in, so an agent run can look at the rest.`);
501
+ }
502
+ else if (start && start.loadError === undefined) {
503
+ const to = movedTo(result.url, start.url);
504
+ if (to) {
505
+ out.push(`The start page moved to ${to}, so its links count as another site's and were not followed. To look further, run it there: ${code(`npx -y scenescout ${shellArg(`${to}/`)}`)}.`);
506
+ }
507
+ }
508
+ const left = result.unvisited.length;
509
+ switch (stopReason(result, options.maxRoutes)) {
510
+ case "time":
511
+ out.push(`Stopped after ${plural(Math.round((result.timeBudget?.ms ?? 0) / 60_000), "minute")}, the first look's time limit, with ${plural(left, "more page")} found and not looked at. ${code("--max-minutes")} raises it.`);
512
+ break;
513
+ case "pages":
514
+ out.push(`Stopped at ${plural(options.maxRoutes, "page")}, the first look's limit, with ${plural(left, "more page")} found and not looked at. ${code("--max-routes")} raises it, up to ${MAX_CHECK_ROUTES}.`);
515
+ break;
516
+ case "rounds":
517
+ out.push(`${plural(left, "more page")} found and not looked at: links are followed ${MAX_DISCOVERY_ROUNDS} steps from the start page, since past that a site is usually paginating rather than showing new pages. ${code(`npx -y scenescout check ${shellArg(result.url)} --paths /a,/b`)} measures pages by name.`);
518
+ break;
519
+ }
520
+ return out;
521
+ }
522
+ /** Why the routes left were not visited, for the report's list of them. */
523
+ const UNVISITED_WHY = {
524
+ time: "the time limit ran out",
525
+ pages: "over --max-routes",
526
+ rounds: "past the link steps followed",
527
+ };
528
+ const NEXT_LINE = "Next: let your coding agent explore it and file what it finds (npx -y scenescout install), gate pull requests with npx -y scenescout check in CI, " +
529
+ `and sign in for the pages behind a login (npx -y scenescout login <url> --role <name>). Guide: ${GUIDE_URL}`;
530
+ /**
531
+ * The terminal summary, after the look: the three issues to look at first, the
532
+ * counts, what limited the look, where the report is, and what to try next.
533
+ * `report` finishes the line "Report: …": the file's path, or why it was not written.
534
+ */
535
+ export function firstRunSummary(facts, report) {
536
+ const { result, elapsedMs } = facts;
537
+ const pages = result.routes.length;
538
+ const top = firstLook(result.issues, pages);
539
+ const lines = [];
540
+ if (top.length === 0)
541
+ lines.push(`No issues found on the ${plural(pages, "page")} looked at.`);
542
+ else {
543
+ lines.push("Look at these first:");
544
+ top.forEach((i, n) => lines.push(` ${n + 1}. [${i.severity}] ${CHECK_RULES[i.rule].title}: ${clip(oneLine(i.evidence), 160)} (${seenOn(i, pages)})`));
545
+ }
546
+ lines.push("", `${plural(pages, "page")} looked at in ${seconds(elapsedMs)} in ${result.mode} mode: ${countsText(result)}.`);
547
+ lines.push(...notes(facts, (s) => s));
548
+ lines.push(`Report: ${report}`, "", NEXT_LINE);
549
+ return lines;
550
+ }
551
+ /** The report a first look writes: the same measurements as a check's, opening with what to look at first and closing with what to try next. */
552
+ export function formatFirstRun(facts) {
553
+ const { result, elapsedMs } = facts;
554
+ const pages = result.routes.length;
555
+ const top = firstLook(result.issues, pages);
556
+ const code = (s) => `\`${s}\``;
557
+ const lines = [
558
+ "# SceneScout first look",
559
+ "",
560
+ `${result.url} · ${plural(pages, "page")} looked at in ${seconds(elapsedMs)} · ${result.mode} mode · ${result.generatedAt}`,
561
+ ];
562
+ lines.push("", "## Look at these first", "");
563
+ if (top.length === 0)
564
+ lines.push(`No issues found on the ${plural(pages, "page")} looked at.`);
565
+ else
566
+ top.forEach((i, n) => lines.push(`${n + 1}. [${i.severity}] ${issueLine(i)}`));
567
+ lines.push("", countsText(result));
568
+ for (const note of notes(facts, code))
569
+ lines.push("", note);
570
+ lines.push(...issueSections(result.issues));
571
+ lines.push(...worthALookSection(result.worthALook, false));
572
+ lines.push(...routesTable(result));
573
+ const why = stopReason(result, facts.options.maxRoutes);
574
+ lines.push(...unvisitedLine(result.unvisited, why ? UNVISITED_WHY[why] : "not reached"));
575
+ lines.push("", "## What to try next", "", `- **Explore it with your coding agent.** ${code("npx -y scenescout install")} sets up the skill and the MCP server; then ask your agent to use SceneScout to test ${result.url}. It clicks, fills forms and compares roles, files what it finds and reports what it did not test.`, `- **Gate pull requests.** ${code(`npx -y scenescout check ${shellArg(result.url)}`)} is this look as a pass or a fail, with SARIF for code scanning; it also runs as a GitHub Action.`, `- **Pages behind a sign-in.** ${code(`npx -y scenescout login ${shellArg(result.url)} --role <name>`)} saves a sign-in once, and an agent run then attaches as that role.`, "", `The guide: ${GUIDE_URL}`, "", `_A first look opens pages and measures what loads, and fills and submits no form. ${modeSentence(result.mode)} (${SAFETY_URL}) It does not click through flows or compare roles; an exploratory run does that._`);
576
+ return lines.join("\n") + "\n";
577
+ }