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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/dist/cli.js +269 -0
- package/dist/engine/authloss.js +125 -0
- package/dist/engine/browser.js +1954 -0
- package/dist/engine/collector.js +266 -0
- package/dist/engine/design.js +716 -0
- package/dist/engine/dispatch.js +100 -0
- package/dist/engine/fingerprint.js +100 -0
- package/dist/engine/fixtures.js +162 -0
- package/dist/engine/journey.js +71 -0
- package/dist/engine/launch.js +25 -0
- package/dist/engine/memory.js +1116 -0
- package/dist/engine/oracles.js +187 -0
- package/dist/engine/ownership.js +223 -0
- package/dist/engine/policy.js +84 -0
- package/dist/engine/probes.js +293 -0
- package/dist/engine/reaper.js +72 -0
- package/dist/engine/report.js +515 -0
- package/dist/engine/uploads.js +74 -0
- package/dist/installer.js +315 -0
- package/dist/mcp-server.js +810 -0
- package/dist/scan.js +335 -0
- package/package.json +86 -0
- package/skills/scenescout/SKILL.md +96 -0
|
@@ -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
|
+
}
|