scenescout 3.15.0 → 3.17.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 +87 -0
- package/README.md +70 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +191 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +107 -47
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +244 -42
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +181 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/status-pane-page.js +441 -0
- package/dist/engine/status-pane.js +128 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +5 -0
- package/dist/installer.js +378 -8
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +660 -65
- package/dist/playbook.js +5 -0
- package/dist/prompts.js +106 -0
- package/package.json +8 -5
- package/skills/scenescout/SKILL.md +49 -16
package/dist/engine/report.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { baseRoute, reachedRoutes, routeIdentity, isEmbedKey, isWorthALook, judgedMergesOf, seenOnOf, } from "./memory.js";
|
|
4
4
|
import { sayVerification } from "./verify.js";
|
|
5
|
+
import { describePicture, readFindingPicture } from "./capture.js";
|
|
5
6
|
import { feedForSession } from "./live.js";
|
|
6
7
|
import { buildReplayHtml, evidenceFor } from "./replay.js";
|
|
7
8
|
import { calibrate, formatCalibration } from "./calibration.js";
|
|
8
9
|
import { formatPace, measurePace } from "./pace.js";
|
|
10
|
+
import { formatNeverSubmittedEmpty } from "./forms.js";
|
|
11
|
+
import { DEFAULT_REPORT_AUDIENCE, formatPlainSection, pictureOf } from "./plain.js";
|
|
12
|
+
import { formatTicketsPlain, formatTicketsTechnical, ticketSummaryLine } from "./tickets.js";
|
|
13
|
+
import { COLLECTOR_CAP } from "./collector.js";
|
|
9
14
|
function playwrightSkeleton(f) {
|
|
10
15
|
const routeClass = f.state.split("#")[0].split("?")[0];
|
|
11
16
|
let gotoPath = routeClass;
|
|
@@ -126,11 +131,22 @@ export function formatWorthALook(items, sessionStart) {
|
|
|
126
131
|
lines.push(`- **A defect only if** your project uses ${f.convention ?? "a convention the finding does not name"}`);
|
|
127
132
|
if (f.evidence)
|
|
128
133
|
lines.push(`- **Seen:** \`${f.evidence}\``);
|
|
129
|
-
lines.push(`- **Where:** \`${f.state}\` (${f.url})`);
|
|
134
|
+
lines.push(`- **Where:** \`${f.state}\` (${f.url})${alsoSeenOn(f)}`);
|
|
135
|
+
lines.push(...pictureLine(f));
|
|
130
136
|
lines.push(``, f.detail, ``);
|
|
131
137
|
}
|
|
132
138
|
return lines;
|
|
133
139
|
}
|
|
140
|
+
/** A finding's picture as a bullet, its path relative to report.md, or nothing when it has none. */
|
|
141
|
+
export function pictureLine(f) {
|
|
142
|
+
const p = readFindingPicture(f);
|
|
143
|
+
return p ? [`- **Picture:** \`${p.file}\` (${describePicture(p)})`] : [];
|
|
144
|
+
}
|
|
145
|
+
/** The other routes a merged finding was filed on, as the end of its Where line, or "". */
|
|
146
|
+
function alsoSeenOn(f) {
|
|
147
|
+
const routes = seenOnOf(f);
|
|
148
|
+
return routes.length > 0 ? `; also seen on ${routes.map((r) => `\`${r}\``).join(", ")}` : "";
|
|
149
|
+
}
|
|
134
150
|
/**
|
|
135
151
|
* How long ago a finding was last seen, for the historical index. A finding
|
|
136
152
|
* nobody has re-confirmed in four months is a different thing from one seen
|
|
@@ -173,9 +189,22 @@ export function findingEvidence(memory, sessions) {
|
|
|
173
189
|
// running at once, the run's whole log interleaves them, and the steps
|
|
174
190
|
// before a finding would come from whichever lane acted last.
|
|
175
191
|
const own = f.session ? sessions.find((s) => s.session === f.session) : undefined;
|
|
176
|
-
|
|
192
|
+
const picture = readFindingPicture(f);
|
|
193
|
+
return {
|
|
194
|
+
id: f.id,
|
|
195
|
+
frames: evidenceFor(own ? own.steps : all, f.foundAt),
|
|
196
|
+
...(picture
|
|
197
|
+
? {
|
|
198
|
+
picture: {
|
|
199
|
+
file: picture.file,
|
|
200
|
+
...(picture.width && picture.height ? { width: picture.width, height: picture.height } : {}),
|
|
201
|
+
caption: describePicture(picture),
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
: {}),
|
|
205
|
+
};
|
|
177
206
|
})
|
|
178
|
-
.filter((e) => e.frames.length > 0);
|
|
207
|
+
.filter((e) => e.frames.length > 0 || !!e.picture);
|
|
179
208
|
}
|
|
180
209
|
/**
|
|
181
210
|
* The whole run as one page, for the live view to serve at its own address.
|
|
@@ -278,13 +307,10 @@ function offersSubmit(elements) {
|
|
|
278
307
|
return toks.some((t) => SUBMIT_TOKENS.has(t)) && !toks.some((t) => FILTER_TOKENS.has(t));
|
|
279
308
|
});
|
|
280
309
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
* that basis.
|
|
286
|
-
*/
|
|
287
|
-
const COLLECTOR_CAP = 150;
|
|
310
|
+
// The collector stops at COLLECTOR_CAP elements, so on a dense page the
|
|
311
|
+
// submit control may simply not be in the element list. "No submit found"
|
|
312
|
+
// then means "we did not look far enough", not "there is nothing to submit" —
|
|
313
|
+
// never suppress on that basis.
|
|
288
314
|
/**
|
|
289
315
|
* Make app-controlled text safe inside a Markdown table cell. The backslash
|
|
290
316
|
* must be escaped FIRST: escaping only the pipe turns an input of `\|` into
|
|
@@ -395,6 +421,134 @@ export function formatUnchosenOptions(dropdowns) {
|
|
|
395
421
|
...(dropdowns.length > 15 ? [` … +${dropdowns.length - 15} more`] : []),
|
|
396
422
|
];
|
|
397
423
|
}
|
|
424
|
+
/**
|
|
425
|
+
* What scout_coverage prints, in one of two scopes. "session" is one lane's
|
|
426
|
+
* own remaining work: the routes it reached this run and the forms it saw.
|
|
427
|
+
* In a parallel run the project view lists every lane's gaps, including
|
|
428
|
+
* routes this lane never opened and forms its role cannot reach, and a lane
|
|
429
|
+
* reading those as its own either wastes turns on them or reports itself
|
|
430
|
+
* incomplete. "project" is everything the memory holds, each route and form
|
|
431
|
+
* tagged with the sessions that reached it this run. In the session scope
|
|
432
|
+
* each route lists only the controls on states this session recorded. `routeLine`
|
|
433
|
+
* is the route contract's line (formatRouteCoverage), labelled as the
|
|
434
|
+
* project's in the session scope.
|
|
435
|
+
*/
|
|
436
|
+
export function coverageView(memory, session, scope, routeLine) {
|
|
437
|
+
const routes = scope === "session" ? memory.routesVisitedBy(session) : null;
|
|
438
|
+
// A session's own controls are the ones on the states it recorded: another
|
|
439
|
+
// role's table on the same route is not this session's work.
|
|
440
|
+
const cov = memory.coverage(routes ? { routes, states: memory.statesVisitedBy(session) } : undefined);
|
|
441
|
+
const inScope = (route) => routes === null || routes.has(route);
|
|
442
|
+
const tag = (route) => {
|
|
443
|
+
if (routes)
|
|
444
|
+
return "";
|
|
445
|
+
const by = memory.sessionsOnRoute(route);
|
|
446
|
+
return by.length > 0 ? ` (this run: ${by.join(", ")})` : "";
|
|
447
|
+
};
|
|
448
|
+
const head = routes === null
|
|
449
|
+
? []
|
|
450
|
+
: routes.size === 0
|
|
451
|
+
? [`Scope: session ${session}, which has reached no route this run yet. scope:"project" lists every session's coverage.`]
|
|
452
|
+
: [`Scope: session ${session} — the ${routes.size} route(s) it reached this run and the forms it saw. scope:"project" lists every session's.`];
|
|
453
|
+
return [
|
|
454
|
+
...head,
|
|
455
|
+
`States known: ${cov.states} · Elements exercised: ${cov.elementsExercised}/${cov.elementsTotal}${cov.embeds.total > 0 ? ` (plus ${cov.embeds.exercised}/${cov.embeds.total} inside other sites' frames, not counted)` : ""}`,
|
|
456
|
+
// The route contract is the project's, every session's and every run's; a session's own figure is the head's.
|
|
457
|
+
routes ? `Project route contract (every session, every run): ${routeLine}` : routeLine,
|
|
458
|
+
`Unexercised elements by route:`,
|
|
459
|
+
...cov.unexercised
|
|
460
|
+
.slice(0, 25)
|
|
461
|
+
.map((u) => ` ${u.state}: ${u.keys.slice(0, 6).join(", ")}${u.keys.length > 6 ? ` … +${u.keys.length - 6}` : ""}${tag(u.state)}`),
|
|
462
|
+
...formatUnchosenOptions(memory.unchosenOptions().filter((d) => inScope(d.route))),
|
|
463
|
+
...formatNeverSubmittedEmpty(routes ? memory.formsNeverSubmittedEmpty(session).map(({ route, key }) => ({ route, key })) : memory.formsNeverSubmittedEmpty()),
|
|
464
|
+
];
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* The gap-ledger line for pages whose scripts sent a POST that observe
|
|
468
|
+
* refused, or null. Some apps read through POST (a search, a report query); a
|
|
469
|
+
* page loading its data that way cannot be tested in observe until the user
|
|
470
|
+
* names the endpoint in `readPosts`. The engine cannot tell a read from a
|
|
471
|
+
* write, so it names the endpoints and leaves the choice to the user.
|
|
472
|
+
*/
|
|
473
|
+
export function observeRefusedPostsGap(pages) {
|
|
474
|
+
const list = (pages ?? []).filter((p) => p.endpoints.length > 0);
|
|
475
|
+
if (list.length === 0)
|
|
476
|
+
return null;
|
|
477
|
+
const shown = list
|
|
478
|
+
.slice(0, 8)
|
|
479
|
+
.map((p) => `${p.route} (${p.endpoints.join(", ")})`)
|
|
480
|
+
.join("; ");
|
|
481
|
+
return (`${list.length} page(s) sent a POST that observe refused, so whatever it loads is untested: ${shown}${list.length > 8 ? " …" : ""}. ` +
|
|
482
|
+
`If one of these only reads (a search or query sent as POST), the user can name it in readPosts ("POST /path") and the page can be tested in observe; anything that writes stays refused.`);
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* The route lines of the gap ledger: never visited, visited but nothing
|
|
486
|
+
* exercised, never design-audited. All three count over ONE set, the route
|
|
487
|
+
* contract (`knownRoutes`), and say so, so their figures can be compared with
|
|
488
|
+
* each other and with the route line of scout_coverage.
|
|
489
|
+
*
|
|
490
|
+
* A state route folds into its contract route, and a UI-state query variant
|
|
491
|
+
* (`/things/:id?section=history`) into its base path: a tab clicked on the
|
|
492
|
+
* base route is recorded on the base route's state, and a variant reached by
|
|
493
|
+
* URL is the same page. A visited route the contract does not hold (a path
|
|
494
|
+
* typed by hand that does not exist) is not a route of the app.
|
|
495
|
+
*
|
|
496
|
+
* "Nothing exercised" and "never audited" judge the routes this run reached
|
|
497
|
+
* (`ledgerScope`), since a route an earlier run looked at and left says
|
|
498
|
+
* nothing about this run. Whether a control was exercised, or a route
|
|
499
|
+
* audited, still counts from any run.
|
|
500
|
+
*/
|
|
501
|
+
function routeLedger(memory, extras) {
|
|
502
|
+
const lines = [];
|
|
503
|
+
const runStates = memory.runStates;
|
|
504
|
+
const scope = extras?.ledgerScope ?? (runStates.size > 0 ? "run" : "project");
|
|
505
|
+
const states = scope === "run" ? runStates : undefined;
|
|
506
|
+
const when = scope === "run" ? "this run" : "in any run";
|
|
507
|
+
const visited = new Set();
|
|
508
|
+
for (const [fp, st] of Object.entries(memory.states))
|
|
509
|
+
if (!states || states.has(fp))
|
|
510
|
+
visited.add(routeIdentity(st.route));
|
|
511
|
+
const hasContract = (extras?.knownRoutes?.length ?? 0) > 0;
|
|
512
|
+
const contract = hasContract ? [...new Set(extras.knownRoutes)] : [...visited];
|
|
513
|
+
const of = `of ${contract.length} ${hasContract ? "known" : "visited"} route(s)`;
|
|
514
|
+
const unvisited = extras?.unvisitedRoutes ?? [];
|
|
515
|
+
if (unvisited.length > 0) {
|
|
516
|
+
lines.push(`${unvisited.length} ${hasContract ? `${of} ` : "route(s) "}never visited in any run: ${unvisited.slice(0, 10).join(", ")}${unvisited.length > 10 ? " …" : ""}`);
|
|
517
|
+
}
|
|
518
|
+
// `total` and `exercised` come from coverage() so they are the SAME deduped,
|
|
519
|
+
// chrome-stripped count the unexercised list is drawn from. Recomputing them
|
|
520
|
+
// from raw state elements (as this once did) counts every state's copy of a
|
|
521
|
+
// shared element, and a genuinely untouched route vanished from the ledger.
|
|
522
|
+
// The shared-chrome pseudo-route is no state's route, so it is never visited.
|
|
523
|
+
const cov = memory.coverage(states ? { states } : undefined);
|
|
524
|
+
const groups = new Map();
|
|
525
|
+
for (const [route, c] of cov.perRoute) {
|
|
526
|
+
if (!visited.has(route))
|
|
527
|
+
continue;
|
|
528
|
+
const g = groups.get(baseRoute(route)) ?? { total: 0, exercised: 0 };
|
|
529
|
+
g.total += c.total;
|
|
530
|
+
g.exercised += c.exercised;
|
|
531
|
+
groups.set(baseRoute(route), g);
|
|
532
|
+
}
|
|
533
|
+
const auditedBases = new Set(Object.entries(memory.routeFacts)
|
|
534
|
+
.filter(([, f]) => f.audited)
|
|
535
|
+
.map(([r]) => baseRoute(routeIdentity(r))));
|
|
536
|
+
// A contract route counts as reached when it was itself, or (being a base
|
|
537
|
+
// path) when one of its tabs or sections was.
|
|
538
|
+
const reached = contract.filter(reachedRoutes(visited));
|
|
539
|
+
const untouched = reached.filter((r) => {
|
|
540
|
+
const g = groups.get(baseRoute(routeIdentity(r)));
|
|
541
|
+
return g !== undefined && g.total > 0 && g.exercised === 0;
|
|
542
|
+
});
|
|
543
|
+
if (untouched.length > 0) {
|
|
544
|
+
lines.push(`${untouched.length} ${of} visited ${when} but NOTHING exercised (looked at, never touched): ${untouched.slice(0, 8).join(", ")}${untouched.length > 8 ? " …" : ""}`);
|
|
545
|
+
}
|
|
546
|
+
const unaudited = reached.filter((r) => !auditedBases.has(baseRoute(routeIdentity(r))));
|
|
547
|
+
if (unaudited.length > 0) {
|
|
548
|
+
lines.push(`${unaudited.length} ${of} visited ${when} and never design-audited: ${unaudited.slice(0, 8).join(", ")}${unaudited.length > 8 ? " …" : ""}`);
|
|
549
|
+
}
|
|
550
|
+
return lines;
|
|
551
|
+
}
|
|
398
552
|
/**
|
|
399
553
|
* The GAP LEDGER — an explicit enumeration of what was NOT tested. This is
|
|
400
554
|
* what turns "extensive" from a vibe into a verifiable claim: a run is only
|
|
@@ -403,33 +557,8 @@ export function formatUnchosenOptions(dropdowns) {
|
|
|
403
557
|
*/
|
|
404
558
|
export function computeGaps(memory, extras) {
|
|
405
559
|
const gaps = [];
|
|
406
|
-
const cov = memory.coverage();
|
|
407
560
|
const facts = memory.routeFacts;
|
|
408
|
-
|
|
409
|
-
if (extras?.unvisitedRoutes?.length) {
|
|
410
|
-
gaps.push(`${extras.unvisitedRoutes.length} route(s) never visited: ${extras.unvisitedRoutes.slice(0, 10).join(", ")}${extras.unvisitedRoutes.length > 10 ? " …" : ""}`);
|
|
411
|
-
}
|
|
412
|
-
// `total` comes from coverage() so it is the SAME deduped, chrome-stripped
|
|
413
|
-
// denominator that `keys` is a subset of. Recomputing it from raw state
|
|
414
|
-
// elements (as this once did) counts every state's copy of a shared element,
|
|
415
|
-
// so any route with two states had total > keys.length, the equality never
|
|
416
|
-
// held, and a genuinely untouched route vanished from the ledger.
|
|
417
|
-
// SHARED_CHROME_ROUTE is a pseudo-route: no state carries it, so it cannot be
|
|
418
|
-
// "visited" and there is nothing to navigate to in order to clear it. Before
|
|
419
|
-
// coverage() reported a total for it, it fell out of this filter by accident
|
|
420
|
-
// (total === 0); excluding it explicitly keeps the ledger to entries a tester
|
|
421
|
-
// can actually act on.
|
|
422
|
-
const untouched = cov.unexercised.filter((u) => u.state !== SHARED_CHROME_ROUTE && u.total > 0 && u.keys.length === u.total);
|
|
423
|
-
if (untouched.length > 0) {
|
|
424
|
-
gaps.push(`${untouched.length} route(s) visited but NOTHING exercised (looked at, never touched): ${untouched
|
|
425
|
-
.slice(0, 8)
|
|
426
|
-
.map((u) => u.state)
|
|
427
|
-
.join(", ")}${untouched.length > 8 ? " …" : ""}`);
|
|
428
|
-
}
|
|
429
|
-
const unaudited = visitedRoutes.filter((r) => !facts[r]?.audited);
|
|
430
|
-
if (unaudited.length > 0) {
|
|
431
|
-
gaps.push(`${unaudited.length}/${visitedRoutes.length} visited route(s) never design-audited: ${unaudited.slice(0, 8).join(", ")}${unaudited.length > 8 ? " …" : ""}`);
|
|
432
|
-
}
|
|
561
|
+
gaps.push(...routeLedger(memory, extras));
|
|
433
562
|
// Filled in, never committed. `mutated` records that a state-changing request
|
|
434
563
|
// actually left the page; a route where someone typed, picked an option or
|
|
435
564
|
// attached a file but nothing was ever submitted is a form that was looked
|
|
@@ -456,6 +585,10 @@ export function computeGaps(memory, extras) {
|
|
|
456
585
|
? ` — expected in observe mode, which blocks every form submission by design; what the server does with these forms is untested. Cover them in read-only mode against an environment where creating records is acceptable.`
|
|
457
586
|
: ""));
|
|
458
587
|
}
|
|
588
|
+
// From project memory, not a session: lanes close theirs before the report is written.
|
|
589
|
+
const refusedPosts = observeRefusedPostsGap(memory.observeRefusedPosts);
|
|
590
|
+
if (refusedPosts)
|
|
591
|
+
gaps.push(refusedPosts);
|
|
459
592
|
const journeyTotal = Object.values(facts).reduce((a, f) => a + (f.journeysCompleted ?? 0), 0);
|
|
460
593
|
if (journeyTotal === 0) {
|
|
461
594
|
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.`);
|
|
@@ -465,6 +598,40 @@ export function computeGaps(memory, extras) {
|
|
|
465
598
|
gaps.push(`single-role run (${roles.join(", ") || "no role recorded"}) — permission boundaries and role capability gaps are untested`);
|
|
466
599
|
return gaps;
|
|
467
600
|
}
|
|
601
|
+
/** The last frame on screen before each finding was filed, by id: its picture on a recorded run. */
|
|
602
|
+
function lastFrames(memory) {
|
|
603
|
+
// A finding can carry evidence with no recorded frames (its own picture
|
|
604
|
+
// only), so it has no last frame to offer.
|
|
605
|
+
return new Map(findingEvidence(memory, replaySessions(memory))
|
|
606
|
+
.filter((e) => e.frames.length > 0)
|
|
607
|
+
.map((e) => [e.id, e.frames[e.frames.length - 1].frame]));
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* The document for an audience. `technical` is the report's lines, its title
|
|
611
|
+
* and date before the first section. The plain section is built only when it
|
|
612
|
+
* is printed: "both" puts it between the date and the technical report, which
|
|
613
|
+
* gets a heading of its own, and "qa" prints it alone.
|
|
614
|
+
*/
|
|
615
|
+
export function withAudience(technical, audience, plain) {
|
|
616
|
+
if (audience === "dev")
|
|
617
|
+
return technical.join("\n");
|
|
618
|
+
// The title and date are everything before the first section.
|
|
619
|
+
const first = technical.findIndex((l) => l.startsWith("## "));
|
|
620
|
+
const split = first < 0 ? technical.length : first;
|
|
621
|
+
const head = technical.slice(0, split);
|
|
622
|
+
const body = technical.slice(split);
|
|
623
|
+
if (audience === "qa")
|
|
624
|
+
return [...head, ...plain()].join("\n");
|
|
625
|
+
return [
|
|
626
|
+
...head,
|
|
627
|
+
...plain(),
|
|
628
|
+
`## Technical detail`,
|
|
629
|
+
``,
|
|
630
|
+
`Everything below is for developers: requests, oracles, routes and ids, the gap ledger and a test skeleton for each finding.`,
|
|
631
|
+
``,
|
|
632
|
+
...body,
|
|
633
|
+
].join("\n");
|
|
634
|
+
}
|
|
468
635
|
/**
|
|
469
636
|
* The report as the run stands now. `write` is what scout_report does at the
|
|
470
637
|
* end; the live view renders the same document on request without touching
|
|
@@ -508,6 +675,10 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
508
675
|
lines.push(`| Finding dedup | ${judged.replace(/\|/g, "/")} |`);
|
|
509
676
|
lines.push(`| Elements exercised (informational — denominator grows with every state) | ${cov.elementsExercised}/${cov.elementsTotal} |`);
|
|
510
677
|
lines.push(``);
|
|
678
|
+
// ---- The tickets the run was given, criterion by criterion. ----
|
|
679
|
+
const ticketRun = memory.ticketsThisRun();
|
|
680
|
+
const ticketInput = { tickets: ticketRun.tickets, verdicts: ticketRun.verdicts, findings: memory.findings };
|
|
681
|
+
lines.push(...formatTicketsTechnical(ticketInput));
|
|
511
682
|
// ---- Page quality scores, worst first — the cross-page comparator. ----
|
|
512
683
|
// Scores persist across runs, so a table sorted purely by number ranks
|
|
513
684
|
// yesterday's un-fixed measurement above today's re-audit and reports a page
|
|
@@ -602,7 +773,8 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
602
773
|
lines.push(`- **Id:** \`${f.id}\` · **Category:** ${f.category}`);
|
|
603
774
|
if (f.evidence)
|
|
604
775
|
lines.push(`- **Evidence:** \`${f.evidence}\``);
|
|
605
|
-
lines.push(`- **Where:** \`${f.state}\` (${f.url})`);
|
|
776
|
+
lines.push(`- **Where:** \`${f.state}\` (${f.url})${alsoSeenOn(f)}`);
|
|
777
|
+
lines.push(...pictureLine(f));
|
|
606
778
|
lines.push(`- **Seen in runs:** ${f.runs}`);
|
|
607
779
|
// A merge the model made is shown with what was filed, so a wrong one can be seen, and refiled as its own defect (ADR 4).
|
|
608
780
|
for (const m of judgedMergesOf(f)) {
|
|
@@ -703,6 +875,17 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
703
875
|
lines.push(`- \`${o}\``);
|
|
704
876
|
lines.push(``);
|
|
705
877
|
}
|
|
878
|
+
if (extras?.readPosts && extras.readPosts.length > 0) {
|
|
879
|
+
lines.push(`## Read POSTs`);
|
|
880
|
+
lines.push(``);
|
|
881
|
+
lines.push(extras.mode === "observe"
|
|
882
|
+
? `Named by the user as endpoints that only read, so observe let them out unless the path or body looked destructive or the body was a GraphQL mutation:`
|
|
883
|
+
: `Named as reads, but not applied: they only count in observe mode, and this run was ${extras.mode ?? "read-only"}:`);
|
|
884
|
+
lines.push(``);
|
|
885
|
+
for (const e of extras.readPosts)
|
|
886
|
+
lines.push(`- \`${e}\``);
|
|
887
|
+
lines.push(``);
|
|
888
|
+
}
|
|
706
889
|
lines.push(...violationRollup(oracleLog.filter((v) => !v.embed)));
|
|
707
890
|
lines.push(...embedSection(oracleLog.filter((v) => v.embed), cov.embeds));
|
|
708
891
|
if (cov.unexercised.length > 0) {
|
|
@@ -725,7 +908,24 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
725
908
|
// Only on a run that used lanes, and only once enough of them have been
|
|
726
909
|
// judged for the number to mean anything; formatCalibration decides both.
|
|
727
910
|
lines.push(...formatCalibration(calibrate(memory.laneDecisions, memory.findings)));
|
|
728
|
-
const markdown = lines
|
|
911
|
+
const markdown = withAudience(lines, extras?.report ?? DEFAULT_REPORT_AUDIENCE, () => {
|
|
912
|
+
const frames = lastFrames(memory);
|
|
913
|
+
return formatPlainSection({
|
|
914
|
+
current,
|
|
915
|
+
historical: historical.length,
|
|
916
|
+
worthALook: worthALook.length,
|
|
917
|
+
violations: oracleLog.filter((v) => !v.embed),
|
|
918
|
+
routes: extras && extras.routesTotal > 0 ? { visited: extras.routesVisited, total: extras.routesTotal } : undefined,
|
|
919
|
+
gaps,
|
|
920
|
+
lastFrames: frames,
|
|
921
|
+
audience: extras?.report ?? DEFAULT_REPORT_AUDIENCE,
|
|
922
|
+
tickets: formatTicketsPlain({
|
|
923
|
+
...ticketInput,
|
|
924
|
+
problemNumber: new Map(current.map((f, i) => [f.id, i + 1])),
|
|
925
|
+
pictureOf: (f) => pictureOf(f, frames.get(f.id)),
|
|
926
|
+
}),
|
|
927
|
+
});
|
|
928
|
+
});
|
|
729
929
|
const outPath = path.join(memory.dir, "report.md");
|
|
730
930
|
const htmlPath = path.join(memory.dir, "report.html");
|
|
731
931
|
let htmlWritten = false;
|
|
@@ -774,6 +974,7 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
774
974
|
: []),
|
|
775
975
|
``,
|
|
776
976
|
`OPEN FINDINGS: ${open.length} (${open.filter((f) => f.severity === "high").length} high) — ${current.length} this session, ${historical.length} historical${resolved.length ? `, ${resolved.length} resolved` : ""}`,
|
|
977
|
+
...[ticketSummaryLine(ticketRun.tickets, ticketRun.verdicts)].filter((l) => l !== null),
|
|
777
978
|
...(extras && extras.routesTotal > 0
|
|
778
979
|
? [
|
|
779
980
|
`COVERAGE: routes ${extras.routesVisited}/${extras.routesTotal} · ${cov.states} states · ${cov.elementsExercised}/${cov.elementsTotal} elements exercised`,
|
|
@@ -796,5 +997,5 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
796
997
|
`Gap ledger${gaps.length === 0 ? ": EMPTY — nothing known left untested" : ` (${gaps.length}):`}`,
|
|
797
998
|
...gaps.map((g) => ` ⚠ ${g}`),
|
|
798
999
|
];
|
|
799
|
-
return { markdown, path: outPath, summary: summaryLines.join("\n") };
|
|
1000
|
+
return { markdown, path: outPath, summary: summaryLines.join("\n"), ...(htmlWritten ? { html: htmlPath } : {}) };
|
|
800
1001
|
}
|