scenescout 1.0.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,515 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { SHARED_CHROME_ROUTE } from "./memory.js";
4
+ function playwrightSkeleton(f) {
5
+ const routeClass = f.state.split("#")[0].split("?")[0];
6
+ let gotoPath = routeClass;
7
+ let routeComment = "";
8
+ try {
9
+ gotoPath = new URL(f.url).pathname + new URL(f.url).search;
10
+ }
11
+ catch {
12
+ /* keep normalized route */
13
+ }
14
+ // A session-specific id in the path (chat ids, record uuids) will not exist
15
+ // in any future environment โ€” goto the route's stable parent instead and
16
+ // tell the test author to create/pick a concrete instance.
17
+ if (routeClass.includes(":id")) {
18
+ gotoPath = routeClass.split("/:id")[0] || "/";
19
+ routeComment = `\n // route class ${routeClass} โ€” navigate to a concrete instance from here (the recorded id was session-specific)`;
20
+ }
21
+ const steps = f.repro.map((s) => ` // ${s}`).join("\n");
22
+ return `test(${JSON.stringify(`regression: ${f.title}`)}, async ({ page }) => {
23
+ await page.goto(${JSON.stringify(gotoPath)});${routeComment}
24
+ ${steps}
25
+ // TODO: replay the steps above with page.getByTestId()/getByRole(), then assert the fix:
26
+ // expect(consoleErrors).toHaveLength(0);
27
+ });`;
28
+ }
29
+ /** Aggregate raw oracle events into a top-offenders table โ€” 250 raw events are unreadable; 10 grouped signatures are actionable. */
30
+ function violationRollup(oracleLog) {
31
+ if (oracleLog.length === 0)
32
+ return [];
33
+ const groups = new Map();
34
+ for (const v of oracleLog) {
35
+ // Signature: kind + detail with ids/hashes collapsed so the same failing
36
+ // endpoint groups across records.
37
+ const sig = `${v.kind}: ${v.detail
38
+ .replace(/\b\d+\b/g, ":n")
39
+ .replace(/[0-9a-f]{8,}/gi, ":h")
40
+ .slice(0, 140)}`;
41
+ const g = groups.get(sig);
42
+ if (g)
43
+ g.count += 1;
44
+ else
45
+ groups.set(sig, { count: 1, sample: v.detail.slice(0, 160) });
46
+ }
47
+ const top = [...groups.entries()].sort((a, b) => b[1].count - a[1].count).slice(0, 12);
48
+ return [
49
+ `## Oracle violation rollup (${oracleLog.length} events, ${groups.size} distinct signatures)`,
50
+ ``,
51
+ `| Count | Signature |`,
52
+ `|---|---|`,
53
+ ...top.map(([sig, g]) => `| ${g.count} | \`${escapeTableCell(sig)}\` |`),
54
+ ``,
55
+ ];
56
+ }
57
+ const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
58
+ const SEVERITY_ICON = { high: "๐Ÿ”ด", medium: "๐ŸŸ ", low: "๐ŸŸก" };
59
+ /**
60
+ * Split an element key into words. Keys are `tid:some-test-id` or `role:name`,
61
+ * and testids come in every casing convention there is โ€” a `\b`-anchored regex
62
+ * does NOT fire around an underscore, so `tid:widget_submit_btn` read as having
63
+ * no submit control and the whole form was dropped from the ledger. Splitting
64
+ * on separators AND camelCase boundaries makes the three conventions equal.
65
+ */
66
+ function keyTokens(key) {
67
+ return key
68
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
69
+ .split(/[^A-Za-z0-9]+/)
70
+ .filter(Boolean)
71
+ .map((t) => t.toLowerCase());
72
+ }
73
+ /**
74
+ * Words naming a FILTER rather than a form field. Typing into a register's
75
+ * search box exercises the control correctly; it is not a form left
76
+ * unsubmitted. Matched per TOKEN, never as a substring โ€” "search" inside
77
+ * "research-title-input" is not a filter, and classing it as one hid a real
78
+ * field.
79
+ */
80
+ const FILTER_TOKENS = new Set(["search", "filter", "filters", "query", "lookup", "sort", "facet", "q"]);
81
+ /**
82
+ * Words that offer a SUBMIT. A page with no such control cannot have an
83
+ * unsubmitted form on it โ€” whatever was typed there went into a filter.
84
+ * Deliberately wide: a wizard's "Next" commits its step, and this list is the
85
+ * difference between reporting a real gap and silently dropping it.
86
+ */
87
+ const SUBMIT_TOKENS = new Set([
88
+ "submit",
89
+ "save",
90
+ "create",
91
+ "send",
92
+ "apply",
93
+ "register",
94
+ "post",
95
+ "upload",
96
+ "add",
97
+ "confirm",
98
+ "continue",
99
+ "next",
100
+ "finish",
101
+ "generate",
102
+ "assign",
103
+ "approve",
104
+ "report",
105
+ "request",
106
+ "update",
107
+ "publish",
108
+ "login",
109
+ "signin",
110
+ "signup",
111
+ "proceed",
112
+ "done",
113
+ "ok",
114
+ "go",
115
+ "pay",
116
+ "checkout",
117
+ "subscribe",
118
+ "place",
119
+ "sign",
120
+ "start",
121
+ "invite",
122
+ "import",
123
+ ]);
124
+ /** An element key naming a filter control (a search box, a facet, a sort). */
125
+ function isFilterKey(key) {
126
+ return keyTokens(key).some((t) => FILTER_TOKENS.has(t));
127
+ }
128
+ /**
129
+ * Does this state offer something to submit? A key that ALSO reads as a filter
130
+ * does not count โ€” `tid:report-filter` names a filter, not a "report" action.
131
+ */
132
+ function offersSubmit(elements) {
133
+ return Object.keys(elements).some((key) => {
134
+ const toks = keyTokens(key);
135
+ return toks.some((t) => SUBMIT_TOKENS.has(t)) && !toks.some((t) => FILTER_TOKENS.has(t));
136
+ });
137
+ }
138
+ /**
139
+ * The collector stops at 150 elements, so on a dense page the submit control
140
+ * may simply not be in the element list. "No submit found" then means "we did
141
+ * not look far enough", not "there is nothing to submit" โ€” never suppress on
142
+ * that basis.
143
+ */
144
+ const COLLECTOR_CAP = 150;
145
+ /**
146
+ * Make app-controlled text safe inside a Markdown table cell. The backslash
147
+ * must be escaped FIRST: escaping only the pipe turns an input of `\|` into
148
+ * `\\|`, which Markdown reads as a literal backslash followed by a live
149
+ * column separator โ€” the app's own error text could then break the table, or
150
+ * forge an extra column in a report people trust. Newlines end a row, so they
151
+ * are flattened too.
152
+ */
153
+ export function escapeTableCell(text) {
154
+ return text.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
155
+ }
156
+ /**
157
+ * Split routes where something was typed/picked/attached into the ones that
158
+ * genuinely look like an unsubmitted FORM, and the ones the heuristic declined
159
+ * to judge because it found no submit control.
160
+ *
161
+ * The second list is reported too โ€” as a DISCLOSURE, not a gap. Suppressing a
162
+ * would-be entry silently is the failure mode this whole rule risks: filters
163
+ * and read-only views look exactly like a form whose submit button is unlabeled
164
+ * or icon-only, and the ledger's claim is that it enumerates what was not
165
+ * tested. It must not gate `extensive`, though: a register search box exists on
166
+ * nearly every app, and gating on it would make the contract unsatisfiable โ€”
167
+ * which is what the original false positive did.
168
+ */
169
+ export function classifyFilledStates(memory, facts) {
170
+ const unsubmitted = new Set();
171
+ const noSubmitControl = new Set();
172
+ for (const st of Object.values(memory.states)) {
173
+ const keys = Object.keys(st.elements);
174
+ const filledForReal = keys.some((key) => st.elements[key].exercised && /^(type|select|upload|plan:(type|select|upload))/.test(st.elements[key].lastAction ?? "") && !isFilterKey(key));
175
+ if (!filledForReal)
176
+ continue;
177
+ if (facts[st.route]?.mutated || mutatedSiblingStep(st.route, facts))
178
+ continue;
179
+ if (offersSubmit(st.elements) || keys.length >= COLLECTOR_CAP)
180
+ unsubmitted.add(st.route);
181
+ else
182
+ noSubmitControl.add(st.route);
183
+ }
184
+ // A route with several states counts as testable if ANY of them offered a
185
+ // submit โ€” the modal holding the form is a different state from the page
186
+ // behind it.
187
+ for (const r of unsubmitted)
188
+ noSubmitControl.delete(r);
189
+ return { unsubmitted: [...unsubmitted], noSubmitControl: [...noSubmitControl] };
190
+ }
191
+ /** Only a STEP param marks a multi-URL form. `tab=`/`section=` are distinct screens, not stages of one form. */
192
+ const STEP_PARAM_RE = /(^|&)step=/i;
193
+ /**
194
+ * Did a SIBLING step of the same multi-URL form actually submit? A wizard keeps
195
+ * one form across `/x?step=1..n` (and `/x` itself); the POST lands on whichever
196
+ * URL is last, so every earlier step reads as abandoned.
197
+ *
198
+ * Deliberately narrow to `step=`. Grouping on the path alone let ANY
199
+ * query-bearing sibling clear another: a genuinely abandoned form on
200
+ * `/settings?tab=profile` was silenced by an unrelated save on
201
+ * `/settings?tab=billing`. The engine treats `tab=`/`section=` as separate
202
+ * screens everywhere else, so those are exactly the routes the ledger exists
203
+ * to report โ€” suppressing them there would hide true positives.
204
+ */
205
+ function mutatedSiblingStep(route, facts) {
206
+ const [path, query = ""] = route.split("?");
207
+ if (!STEP_PARAM_RE.test(query))
208
+ return false;
209
+ return Object.entries(facts).some(([other, f]) => {
210
+ if (!f?.mutated)
211
+ return false;
212
+ const [otherPath, otherQuery = ""] = other.split("?");
213
+ // The submitting sibling is another step, or the wizard's bare entry URL.
214
+ return otherPath === path && (STEP_PARAM_RE.test(otherQuery) || otherQuery === "");
215
+ });
216
+ }
217
+ /**
218
+ * The route line of scout_coverage.
219
+ *
220
+ * It must count the SAME route set the report enforces โ€” scanned routes plus
221
+ * link-discovered ones. It used to count scanned routes only while taking the
222
+ * unvisited list from the full set: a project with no scannable routes (any
223
+ * code-routed framework, or a remote URL with no source at all) was told "no
224
+ * enumerable route list" while dozens of discovered routes sat unvisited, and
225
+ * a project with a few scanned routes and many discovered ones got a negative
226
+ * visited count.
227
+ */
228
+ export function formatRouteCoverage(allRoutes, unvisited) {
229
+ if (allRoutes.length === 0) {
230
+ return "No routes known yet โ€” none were found in source and no links have been harvested. Snapshot the landing page and main navigation to discover them.";
231
+ }
232
+ const visited = allRoutes.length - unvisited.length;
233
+ return (`Routes visited: ${visited}/${allRoutes.length}` +
234
+ (unvisited.length > 0
235
+ ? ` โ€” UNVISITED: ${unvisited.slice(0, 25).join(", ")}${unvisited.length > 25 ? " โ€ฆ" : ""} (scout_crawl covers these in one call)`
236
+ : " โœ“"));
237
+ }
238
+ /**
239
+ * The GAP LEDGER โ€” an explicit enumeration of what was NOT tested. This is
240
+ * what turns "extensive" from a vibe into a verifiable claim: a run is only
241
+ * as trustworthy as its list of known gaps, and an empty ledger is the only
242
+ * honest way to say "nothing was left untested".
243
+ */
244
+ export function computeGaps(memory, extras) {
245
+ const gaps = [];
246
+ const cov = memory.coverage();
247
+ const facts = memory.routeFacts;
248
+ const visitedRoutes = [...new Set(Object.values(memory.states).map((st) => st.route))];
249
+ if (extras?.unvisitedRoutes?.length) {
250
+ gaps.push(`${extras.unvisitedRoutes.length} route(s) never visited: ${extras.unvisitedRoutes.slice(0, 10).join(", ")}${extras.unvisitedRoutes.length > 10 ? " โ€ฆ" : ""}`);
251
+ }
252
+ // `total` comes from coverage() so it is the SAME deduped, chrome-stripped
253
+ // denominator that `keys` is a subset of. Recomputing it from raw state
254
+ // elements (as this once did) counts every state's copy of a shared element,
255
+ // so any route with two states had total > keys.length, the equality never
256
+ // held, and a genuinely untouched route vanished from the ledger.
257
+ // SHARED_CHROME_ROUTE is a pseudo-route: no state carries it, so it cannot be
258
+ // "visited" and there is nothing to navigate to in order to clear it. Before
259
+ // coverage() reported a total for it, it fell out of this filter by accident
260
+ // (total === 0); excluding it explicitly keeps the ledger to entries a tester
261
+ // can actually act on.
262
+ const untouched = cov.unexercised.filter((u) => u.state !== SHARED_CHROME_ROUTE && u.total > 0 && u.keys.length === u.total);
263
+ if (untouched.length > 0) {
264
+ gaps.push(`${untouched.length} route(s) visited but NOTHING exercised (looked at, never touched): ${untouched
265
+ .slice(0, 8)
266
+ .map((u) => u.state)
267
+ .join(", ")}${untouched.length > 8 ? " โ€ฆ" : ""}`);
268
+ }
269
+ const unaudited = visitedRoutes.filter((r) => !facts[r]?.audited);
270
+ if (unaudited.length > 0) {
271
+ gaps.push(`${unaudited.length}/${visitedRoutes.length} visited route(s) never design-audited: ${unaudited.slice(0, 8).join(", ")}${unaudited.length > 8 ? " โ€ฆ" : ""}`);
272
+ }
273
+ // Filled in, never committed. `mutated` records that a state-changing request
274
+ // actually left the page; a route where someone typed, picked an option or
275
+ // attached a file but nothing was ever submitted is a form that was looked
276
+ // at, not tested. This is the only consumer of `mutated` โ€” without it the
277
+ // flag was write-only.
278
+ //
279
+ // Three shapes are NOT unsubmitted forms, and each used to be reported as one
280
+ // on every run โ€” noise a reader learns to skip, which is worse than silence:
281
+ // 1. A register's SEARCH/FILTER box. Typing into it is the control working
282
+ // as designed; there is no submit on that route at all.
283
+ // 2. A read-only page whose only inputs are filters (an audit-trail view,
284
+ // a usage dashboard). Same shape: nothing to submit.
285
+ // 3. A WIZARD's intermediate step. The POST fires on the final step's URL,
286
+ // so the earlier `?step=` routes look abandoned even when the wizard
287
+ // completed โ€” the form spans several URLs but is one form.
288
+ // So: ignore filter-ish fields, require the state to actually offer a submit,
289
+ // and let a mutation anywhere in a wizard clear its sibling steps.
290
+ const { unsubmitted } = classifyFilledStates(memory, facts);
291
+ if (unsubmitted.length > 0) {
292
+ gaps.push(`${unsubmitted.length} route(s) had a form filled but NEVER submitted (no state-changing request left the page): ${unsubmitted.slice(0, 8).join(", ")}${unsubmitted.length > 8 ? " โ€ฆ" : ""}`);
293
+ }
294
+ const journeyTotal = Object.values(facts).reduce((a, f) => a + (f.journeysCompleted ?? 0), 0);
295
+ if (journeyTotal === 0) {
296
+ gaps.push(`no COMPLETED scout_journey measurements โ€” task EASE is untested. An abandoned journey is a finding, not coverage: it proves a task is blocked, not that it was measured.`);
297
+ }
298
+ const roles = Object.keys(memory.roleAccess);
299
+ if (roles.length < 2)
300
+ gaps.push(`single-role run (${roles.join(", ") || "no role recorded"}) โ€” permission boundaries and role capability gaps are untested`);
301
+ return gaps;
302
+ }
303
+ export function generateReport(memory, oracleLog, extras) {
304
+ const cov = memory.coverage();
305
+ const findings = [...memory.findings].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
306
+ const lines = [];
307
+ const resolved = findings.filter((f) => f.status === "resolved");
308
+ const open = findings.filter((f) => f.status !== "resolved");
309
+ const current = open.filter((f) => f.foundAt >= memory.sessionStart);
310
+ const historical = open.filter((f) => f.foundAt < memory.sessionStart);
311
+ lines.push(`# SceneScout Report`);
312
+ lines.push(``);
313
+ lines.push(`Generated: ${new Date().toISOString()}`);
314
+ lines.push(``);
315
+ lines.push(`## Summary`);
316
+ lines.push(``);
317
+ lines.push(`| Metric | Value |`);
318
+ lines.push(`|---|---|`);
319
+ lines.push(`| Open findings | ${open.length} (${open.filter((f) => f.severity === "high").length} high) โ€” ${current.length} seen this session, ${historical.length} historical${resolved.length ? `, ${resolved.length} resolved (listed at the bottom)` : ""} |`);
320
+ if (extras && extras.routesTotal > 0)
321
+ lines.push(`| Route coverage | ${extras.routesVisited}/${extras.routesTotal} |`);
322
+ lines.push(`| States explored | ${cov.states} |`);
323
+ if (extras)
324
+ lines.push(`| Design audits this session | ${extras.designAudits} |`);
325
+ lines.push(`| Oracle violations this session | ${oracleLog.length} |`);
326
+ if (extras?.policyAttributed) {
327
+ lines.push(`| Errors caused by the tester's own write-policy blocks (not counted above) | ${extras.policyAttributed} |`);
328
+ }
329
+ lines.push(`| Elements exercised (informational โ€” denominator grows with every state) | ${cov.elementsExercised}/${cov.elementsTotal} |`);
330
+ lines.push(``);
331
+ // ---- Page quality scores, worst first โ€” the cross-page comparator. ----
332
+ // Scores persist across runs, so a table sorted purely by number ranks
333
+ // yesterday's un-fixed measurement above today's re-audit and reports a page
334
+ // as "worst" when it simply wasn't re-measured. Mark anything not refreshed
335
+ // this session so the ranking is read with its age attached.
336
+ const scores = Object.entries(memory.pageScores).sort((a, b) => a[1].overall - b[1].overall);
337
+ const isFresh = (sc) => sc.at >= memory.sessionStart;
338
+ if (scores.length > 0) {
339
+ const staleCount = scores.filter(([, sc]) => !isFresh(sc)).length;
340
+ lines.push(`## Page quality scores (worst first)`);
341
+ lines.push(``);
342
+ if (staleCount > 0) {
343
+ lines.push(`โณ ${staleCount} of ${scores.length} scores are from an EARLIER run (marked stale). They rank against this run's numbers but were not re-measured โ€” re-audit before treating a stale row as the worst page.`);
344
+ lines.push(``);
345
+ }
346
+ lines.push(`| Route | Overall | A11y | Craft | Consistency | Task clarity | Audited |`);
347
+ lines.push(`|---|---|---|---|---|---|---|`);
348
+ for (const [route, sc] of scores.slice(0, 15)) {
349
+ const age = isFresh(sc) ? sc.at.slice(0, 10) : `${sc.at.slice(0, 10)} โณ stale`;
350
+ lines.push(`| \`${route}\` | **${sc.overall}** | ${sc.a11y} | ${sc.craft} | ${sc.consistency} | ${sc.clarity} | ${age} |`);
351
+ }
352
+ lines.push(``);
353
+ }
354
+ // ---- Role capability matrix โ€” what each role could/couldn't reach. ----
355
+ const roleAccess = memory.roleAccess;
356
+ const roles = Object.keys(roleAccess);
357
+ if (roles.length >= 2) {
358
+ const allRoutes = [...new Set(roles.flatMap((r) => Object.keys(roleAccess[r])))].sort();
359
+ // A role with NO entry for a route simply never went there โ€” that is a
360
+ // coverage gap, not a permission boundary. Comparing "absent" against
361
+ // "reached" produced rows like "/ : admin โœ“, qa โ€”" for a route every role
362
+ // can obviously reach, which reads as a denial and invites false findings.
363
+ // Only compare roles that actually attempted the route, and only when at
364
+ // least two did.
365
+ const attemptedBy = (rt) => roles.filter((r) => roleAccess[r][rt] !== undefined);
366
+ const differing = allRoutes.filter((rt) => {
367
+ const tried = attemptedBy(rt);
368
+ return tried.length >= 2 && new Set(tried.map((r) => roleAccess[r][rt])).size > 1;
369
+ });
370
+ const oneRoleOnly = allRoutes.filter((rt) => attemptedBy(rt).length === 1).length;
371
+ lines.push(`## Role capability matrix (${roles.length} roles)`);
372
+ lines.push(``);
373
+ lines.push(`Routes where roles that BOTH tried it diverged โ€” each row is either a correct permission boundary or a gap ("should this role be able to do this?"). โœ“ reached ยท โœ— redirected/denied ยท โ€” not attempted by that role.`);
374
+ lines.push(``);
375
+ if (oneRoleOnly > 0) {
376
+ lines.push(`${oneRoleOnly} route(s) were visited by only ONE role and are omitted โ€” with nothing to compare against they say nothing about permissions, only about coverage.`);
377
+ lines.push(``);
378
+ }
379
+ lines.push(`| Route | ${roles.join(" | ")} |`);
380
+ lines.push(`|---|${roles.map(() => "---").join("|")}|`);
381
+ for (const rt of differing.slice(0, 25)) {
382
+ lines.push(`| \`${rt}\` | ${roles
383
+ .map((r) => {
384
+ const o = roleAccess[r][rt];
385
+ return o === "reached" ? "โœ“" : o ? `โœ— ${o.replace(/\|/g, "/")}` : "โ€”";
386
+ })
387
+ .join(" | ")} |`);
388
+ }
389
+ if (differing.length === 0)
390
+ lines.push(`(no divergence recorded โ€” all roles saw the same surface)`);
391
+ lines.push(``);
392
+ }
393
+ // ---- GAP LEDGER โ€” what this run did NOT test. ----
394
+ const gaps = computeGaps(memory, extras);
395
+ lines.push(`## Gap ledger โ€” what was NOT tested`);
396
+ lines.push(``);
397
+ if (gaps.length === 0) {
398
+ lines.push(`Empty โ€” every known route visited, exercised, audited; journeys measured; multi-role compared. This is what a complete extensive run looks like.`);
399
+ }
400
+ else {
401
+ for (const g of gaps)
402
+ lines.push(`- โš  ${g}`);
403
+ }
404
+ // Non-gating disclosures: what the ledger's heuristics declined to judge.
405
+ // Printed so a suppressed entry is visible rather than absent, but kept out
406
+ // of computeGaps so an ordinary search box cannot make `extensive`
407
+ // unsatisfiable.
408
+ const { noSubmitControl } = classifyFilledStates(memory, memory.routeFacts);
409
+ if (noSubmitControl.length > 0) {
410
+ lines.push(``);
411
+ lines.push(`- โ„น ${noSubmitControl.length} route(s) had inputs filled but NO recognizable submit control, so they are NOT counted as unsubmitted forms above. ` +
412
+ `Filters and read-only views look like this โ€” but so does a real form whose submit is icon-only or unlabeled, so scan the list: ${noSubmitControl.slice(0, 10).join(", ")}${noSubmitControl.length > 10 ? " โ€ฆ" : ""}`);
413
+ }
414
+ lines.push(``);
415
+ const renderFinding = (f, complete = false) => {
416
+ lines.push(complete ? `### ๐ŸŸข [COMPLETE] ${f.title}` : `### ${SEVERITY_ICON[f.severity]} [${f.severity.toUpperCase()}] ${f.title}`);
417
+ lines.push(``);
418
+ if (complete)
419
+ lines.push(`- **Resolved** (was ${f.severity})`);
420
+ if (!complete && f.regressedAt)
421
+ lines.push(`- **โŸณ REGRESSED:** previously marked resolved, re-found ${f.regressedAt} โ€” the fix did not hold`);
422
+ lines.push(`- **Id:** \`${f.id}\` ยท **Category:** ${f.category}`);
423
+ if (f.evidence)
424
+ lines.push(`- **Evidence:** \`${f.evidence}\``);
425
+ lines.push(`- **Where:** \`${f.state}\` (${f.url})`);
426
+ lines.push(`- **Seen in runs:** ${f.runs}`);
427
+ lines.push(``);
428
+ lines.push(f.detail);
429
+ lines.push(``);
430
+ if (f.repro.length > 0) {
431
+ lines.push(`<details><summary>Repro trace (last actions before finding)</summary>`);
432
+ lines.push(``);
433
+ f.repro.forEach((step, i) => lines.push(`${i + 1}. ${step}`));
434
+ lines.push(``);
435
+ lines.push(`</details>`);
436
+ lines.push(``);
437
+ }
438
+ lines.push("```ts");
439
+ lines.push(playwrightSkeleton(f));
440
+ lines.push("```");
441
+ lines.push(``);
442
+ };
443
+ lines.push(`## Findings โ€” seen this session (${current.length})`);
444
+ lines.push(``);
445
+ if (current.length === 0)
446
+ lines.push(`None recorded or re-confirmed this session.`, ``);
447
+ for (const f of current)
448
+ renderFinding(f);
449
+ if (historical.length > 0) {
450
+ lines.push(`## Historical findings โ€” not re-verified this session (${historical.length})`);
451
+ lines.push(``);
452
+ lines.push(`Recorded in earlier runs and not re-confirmed. Re-test before acting; resolve fixed ones with \`scout_resolve <id>\`.`);
453
+ lines.push(``);
454
+ for (const f of historical)
455
+ renderFinding(f);
456
+ }
457
+ if (resolved.length > 0) {
458
+ lines.push(`## โœ… Resolved (${resolved.length})`);
459
+ lines.push(``);
460
+ lines.push(`Fixed and verified (or confirmed no longer reproducing). A resolved finding that is re-found reopens automatically and is flagged as a regression above.`);
461
+ lines.push(``);
462
+ for (const f of resolved)
463
+ renderFinding(f, true);
464
+ }
465
+ if (extras?.createdResources && extras.createdResources.length > 0) {
466
+ lines.push(`## Data created by this session (cleanup list)`);
467
+ lines.push(``);
468
+ lines.push(`Safe-write mode created these resources; delete them if the environment should stay pristine:`);
469
+ lines.push(``);
470
+ for (const r of extras.createdResources.slice(0, 50))
471
+ lines.push(`- \`${r}\``);
472
+ lines.push(``);
473
+ }
474
+ lines.push(...violationRollup(oracleLog));
475
+ if (cov.unexercised.length > 0) {
476
+ lines.push(`## Unexplored surface (for the next run)`);
477
+ lines.push(``);
478
+ for (const u of cov.unexercised.slice(0, 30)) {
479
+ lines.push(`- \`${u.state}\`: ${u.keys.slice(0, 8).join(", ")}${u.keys.length > 8 ? ` โ€ฆ +${u.keys.length - 8}` : ""}`);
480
+ }
481
+ lines.push(``);
482
+ }
483
+ const markdown = lines.join("\n");
484
+ const outPath = path.join(memory.dir, "report.md");
485
+ fs.writeFileSync(outPath, markdown);
486
+ // Bounded summary for the tool result: full reports have exceeded client
487
+ // token limits in real runs (66โ€“72KB observed) โ€” the wire gets the digest,
488
+ // the disk gets the document.
489
+ const summaryLines = [
490
+ `Report written to ${outPath}`,
491
+ ``,
492
+ `OPEN FINDINGS: ${open.length} (${open.filter((f) => f.severity === "high").length} high) โ€” ${current.length} this session, ${historical.length} historical${resolved.length ? `, ${resolved.length} resolved` : ""}`,
493
+ ...(extras && extras.routesTotal > 0
494
+ ? [
495
+ `COVERAGE: routes ${extras.routesVisited}/${extras.routesTotal} ยท ${cov.states} states ยท ${cov.elementsExercised}/${cov.elementsTotal} elements exercised`,
496
+ ]
497
+ : []),
498
+ ...(scores.length > 0
499
+ ? [
500
+ `WORST PAGES: ${scores
501
+ .slice(0, 3)
502
+ .map(([r, sc]) => `${r} ${sc.overall}/100`)
503
+ .join(" ยท ")}`,
504
+ ]
505
+ : []),
506
+ ``,
507
+ `Top open findings:`,
508
+ ...open.slice(0, 10).map((f) => ` ${SEVERITY_ICON[f.severity]} [${f.severity}] ${f.title} (${f.id})`),
509
+ ...(open.length > 10 ? [` โ€ฆ +${open.length - 10} more in the report`] : []),
510
+ ``,
511
+ `Gap ledger${gaps.length === 0 ? ": EMPTY โ€” nothing known left untested" : ` (${gaps.length}):`}`,
512
+ ...gaps.map((g) => ` โš  ${g}`),
513
+ ];
514
+ return { markdown, path: outPath, summary: summaryLines.join("\n") };
515
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Where an upload's bytes come from, and the fence around reading them off disk.
3
+ *
4
+ * `scout_upload {filePath}` makes the engine read a file chosen by whoever drives
5
+ * it and hand the bytes to the app under test. Unfenced, that is a way to
6
+ * exfiltrate any file the tester's account can read (an SSH key, a browser
7
+ * profile) into a web form. So disk uploads are confined to the attached
8
+ * project, by REAL path, and the rule is a pure function here so it can be
9
+ * tested with real symlinks instead of only through a browser.
10
+ */
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { FIXTURE_KINDS, isFixtureKind, mimeForName } from "./fixtures.js";
14
+ /** Renaming an upload means sending it as an in-memory payload, so it is read whole. */
15
+ export const MAX_RENAMED_UPLOAD_BYTES = 50 * 1024 * 1024;
16
+ /** A plan's upload `value`: blank โ†’ fixture inferred from accept; a kind โ†’ that fixture; anything else โ†’ a project-relative path. */
17
+ export function planUploadOptions(value) {
18
+ const spec = (value ?? "").trim();
19
+ if (spec === "")
20
+ return {};
21
+ const kind = spec.toLowerCase();
22
+ if (isFixtureKind(kind))
23
+ return { fixture: kind };
24
+ return { filePath: spec };
25
+ }
26
+ /**
27
+ * Resolve a project-relative (or absolute) path to an upload payload, refusing
28
+ * anything that lands outside the project once symlinks are followed.
29
+ * `projectDir` must already be a real path; `projectDirNote` explains, in the
30
+ * refusal, when it could not be resolved.
31
+ */
32
+ export function resolveDiskUpload(project, filePath, name) {
33
+ const { projectDir, projectDirNote = "" } = project;
34
+ if (!projectDir)
35
+ return { refused: "Not attached โ€” uploads need a session with a project." };
36
+ const resolved = path.resolve(projectDir, filePath);
37
+ // realpath, so a symlink inside the project cannot point the upload at a
38
+ // file outside it. A missing path stays as resolved and is reported below.
39
+ let real = resolved;
40
+ try {
41
+ real = fs.realpathSync(resolved);
42
+ }
43
+ catch {
44
+ /* missing โ€” the stat below says so */
45
+ }
46
+ const rel = path.relative(projectDir, real);
47
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
48
+ return {
49
+ refused: `REFUSED: filePath ${filePath} is outside the attached project (${projectDir})${projectDirNote}. Uploads are fenced to the project under test, ` +
50
+ `as navigation is fenced to its origin โ€” copy the fixture into the project (e.g. .scenescout/fixtures/) or omit filePath to upload a generated one.`,
51
+ };
52
+ }
53
+ const stat = fs.statSync(real, { throwIfNoEntry: false });
54
+ if (!stat?.isFile()) {
55
+ return {
56
+ refused: `filePath not found (or not a file): ${real}. To upload a generated file instead, omit filePath (or pass fixture: ${FIXTURE_KINDS.join(" | ")}).`,
57
+ };
58
+ }
59
+ const finalName = name ?? path.basename(real);
60
+ const mime = mimeForName(finalName);
61
+ if (!name)
62
+ return { payload: real, name: finalName, mime, bytes: stat.size, source: `from disk: ${rel}` };
63
+ // A renamed upload has to travel as an in-memory payload.
64
+ if (stat.size > MAX_RENAMED_UPLOAD_BYTES) {
65
+ return { refused: `Renaming an upload reads it into memory; ${real} is ${stat.size} bytes โ€” pass it without name, or use a smaller file.` };
66
+ }
67
+ return {
68
+ payload: { name: finalName, mimeType: mime, buffer: fs.readFileSync(real) },
69
+ name: finalName,
70
+ mime,
71
+ bytes: stat.size,
72
+ source: `from disk: ${rel}, as ${finalName}`,
73
+ };
74
+ }