scenescout 3.17.0 → 3.19.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 +37 -0
- package/README.md +9 -4
- package/dist/check-run.js +78 -15
- package/dist/cli.js +6 -4
- package/dist/engine/baseline.js +43 -0
- package/dist/engine/browser.js +318 -144
- package/dist/engine/check.js +116 -12
- package/dist/engine/collector.js +62 -21
- package/dist/engine/crawl.js +37 -0
- package/dist/engine/design.js +77 -3
- package/dist/engine/export.js +200 -1
- package/dist/engine/fingerprint.js +16 -0
- package/dist/engine/flow.js +171 -4
- package/dist/engine/memory.js +74 -20
- package/dist/engine/policy.js +31 -0
- package/dist/engine/probes.js +75 -16
- package/dist/export-run.js +231 -55
- package/dist/first-run.js +7 -1
- package/dist/intake.js +161 -0
- package/dist/mcp-server.js +41 -0
- package/dist/playbook.js +1 -1
- package/package.json +2 -2
- package/skills/scenescout/SKILL.md +2 -2
package/dist/export-run.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Runs `scenescout export`: reads the project's findings
|
|
3
|
-
*
|
|
2
|
+
* Runs `scenescout export`: reads the project's findings (or a check.json or
|
|
3
|
+
* ci.json given with --from), asks the tracker which of them it already
|
|
4
|
+
* holds, and files the rest. The rules (which
|
|
4
5
|
* findings, what an issue says, the marker, the plan) live in
|
|
5
6
|
* engine/export.ts; this file only reads the project's files and talks HTTP.
|
|
6
|
-
*
|
|
7
|
-
* printed: every line goes out
|
|
7
|
+
* The one file it writes is the record of the issues it filed, beside the
|
|
8
|
+
* memory, and the credentials it sends are never printed: every line goes out
|
|
9
|
+
* through a redactor that knows them.
|
|
8
10
|
*/
|
|
9
11
|
import fs from "node:fs";
|
|
10
12
|
import path from "node:path";
|
|
11
13
|
import { redactKeys } from "./engine/ci.js";
|
|
12
|
-
import { credentialSecrets, EXIT_EXPORT, failedCriteriaByFinding, findingIdsInGithubBody, findingIdsInJiraDescription, findingLine, findingPicture, frameIsOriginal, framesFor, githubIssue, jiraEditFields, jiraIssueFields, MARKER_LABEL, MAX_RATE_WAIT_MS, oneLine, planExport, planJiraUpdate, rateLimit, rememberFiled, selectFindings, ticketsToLink, trackerCredentials, trackerMessage, TRACKER_LABEL, uploadName, } from "./engine/export.js";
|
|
14
|
+
import { credentialSecrets, EXIT_EXPORT, failedCriteriaByFinding, findingIdsInGithubBody, findingIdsInJiraDescription, findingLine, findingPicture, frameIsOriginal, framesFor, githubIssue, jiraEditFields, jiraIssueFields, MARKER_LABEL, MAX_RATE_WAIT_MS, oneLine, parseExportRecord, planExport, planJiraUpdate, rateLimit, recordKey, recordWith, rememberFiled, selectFindings, findingsFromResult, ticketsToLink, trackerCredentials, trackerMessage, TRACKER_LABEL, uncertainHolds, uploadName, EXPORT_RECORD_FILE, UNCERTAIN_HOLD_MS, } from "./engine/export.js";
|
|
13
15
|
import { MEMORY_DIRNAME } from "./engine/memory.js";
|
|
14
16
|
import { backoffMs } from "./engine/provider.js";
|
|
15
17
|
import { resolveFrame } from "./engine/replay.js";
|
|
@@ -91,6 +93,8 @@ function client(base, headers, h) {
|
|
|
91
93
|
}
|
|
92
94
|
throw new ExportError(`${what} failed: ${failureOf(err, h.timeoutMs)}`, !r.idempotent);
|
|
93
95
|
}
|
|
96
|
+
if (r.missingOk && (res.status === 404 || res.status === 410 || (res.status >= 300 && res.status < 400)))
|
|
97
|
+
return null;
|
|
94
98
|
if (res.status >= 300 && res.status < 400)
|
|
95
99
|
throw new ExportError(`${what} was answered with a redirect (HTTP ${res.status}), which is refused: credentials are never sent on to another address. A repository or site that moved needs its new address`);
|
|
96
100
|
const said = trackerMessage(body);
|
|
@@ -123,54 +127,87 @@ function githubApi({ repo }, send) {
|
|
|
123
127
|
.split("/")
|
|
124
128
|
.map((p) => encodeURIComponent(p))
|
|
125
129
|
.join("/")}/issues`;
|
|
130
|
+
/** An issue as the API gives it, read; null for a pull request, which the issues API lists too. */
|
|
131
|
+
const readIssue = (issue) => {
|
|
132
|
+
if (isRecord(issue) && issue.pull_request)
|
|
133
|
+
return null;
|
|
134
|
+
const number = isRecord(issue) ? Number(issue.number) : NaN;
|
|
135
|
+
// An entry that cannot be read, or one without its body, could be a filed finding: guessing "not filed" files it twice.
|
|
136
|
+
if (!isRecord(issue) || !Number.isInteger(number) || !("body" in issue))
|
|
137
|
+
throw new ExportError("GitHub listed an issue without its number or its body, so which findings are filed cannot be told");
|
|
138
|
+
const labels = Array.isArray(issue.labels) ? issue.labels : [];
|
|
139
|
+
return {
|
|
140
|
+
issue: { ref: `#${number}`, number, url: oneLine(issue.html_url, 300), open: issue.state !== "closed" },
|
|
141
|
+
ids: findingIdsInGithubBody(issue.body),
|
|
142
|
+
// GitHub's label filter ignores case, so a repository's "SceneScout" label is this one.
|
|
143
|
+
labelled: labels.some((l) => (typeof l === "string" ? l : isRecord(l) ? String(l.name ?? "") : "").toLowerCase() === MARKER_LABEL),
|
|
144
|
+
};
|
|
145
|
+
};
|
|
126
146
|
return {
|
|
127
147
|
label: `GitHub ${repo}`,
|
|
128
148
|
consistentListing: true,
|
|
129
|
-
// Listed by label rather than found through the search API
|
|
130
|
-
// indexed with a delay
|
|
131
|
-
//
|
|
149
|
+
// Listed by label rather than found through the search API, which is
|
|
150
|
+
// indexed with a delay. The label filter lags behind a create too, so the
|
|
151
|
+
// newest issues are also listed with no filter, which shows an issue as
|
|
152
|
+
// soon as it is made: an export run right after another, or the check
|
|
153
|
+
// after a create that may have been made, finds the issue just filed.
|
|
132
154
|
async existing(includeClosed) {
|
|
133
155
|
const map = new Map();
|
|
156
|
+
const seen = new Set();
|
|
134
157
|
let issues = 0;
|
|
135
158
|
let unmarked = 0;
|
|
159
|
+
const take = (read) => {
|
|
160
|
+
if (seen.has(read.issue.number))
|
|
161
|
+
return;
|
|
162
|
+
seen.add(read.issue.number);
|
|
163
|
+
issues++;
|
|
164
|
+
if (read.ids.length === 0)
|
|
165
|
+
unmarked++;
|
|
166
|
+
for (const id of read.ids)
|
|
167
|
+
rememberFiled(map, id, read.issue);
|
|
168
|
+
};
|
|
169
|
+
const state = includeClosed ? "all" : "open";
|
|
170
|
+
const newest = await send({ method: "GET", path: `${root}?state=${state}&sort=created&direction=desc&per_page=100`, idempotent: true });
|
|
171
|
+
if (!Array.isArray(newest))
|
|
172
|
+
throw new ExportError("GitHub's list of issues was not a list");
|
|
173
|
+
for (const entry of newest) {
|
|
174
|
+
const read = readIssue(entry);
|
|
175
|
+
if (read?.labelled)
|
|
176
|
+
take(read);
|
|
177
|
+
}
|
|
136
178
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
137
179
|
const body = await send({
|
|
138
180
|
method: "GET",
|
|
139
|
-
path: `${root}?labels=${encodeURIComponent(MARKER_LABEL)}&state=${
|
|
181
|
+
path: `${root}?labels=${encodeURIComponent(MARKER_LABEL)}&state=${state}&per_page=100&page=${page}`,
|
|
140
182
|
idempotent: true,
|
|
141
183
|
});
|
|
142
184
|
if (!Array.isArray(body))
|
|
143
185
|
throw new ExportError("GitHub's list of issues was not a list");
|
|
144
|
-
for (const
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
const number = isRecord(issue) ? Number(issue.number) : NaN;
|
|
149
|
-
// An entry that cannot be read, or one without its body, could be a filed finding: guessing "not filed" files it twice.
|
|
150
|
-
if (!isRecord(issue) || !Number.isInteger(number) || !("body" in issue))
|
|
151
|
-
throw new ExportError("GitHub listed an issue without its number or its body, so which findings are filed cannot be told");
|
|
152
|
-
issues++;
|
|
153
|
-
const ids = findingIdsInGithubBody(issue.body);
|
|
154
|
-
if (ids.length === 0)
|
|
155
|
-
unmarked++;
|
|
156
|
-
for (const id of ids)
|
|
157
|
-
rememberFiled(map, id, { ref: `#${number}`, number, url: oneLine(issue.html_url, 300), open: issue.state !== "closed" });
|
|
186
|
+
for (const entry of body) {
|
|
187
|
+
const read = readIssue(entry);
|
|
188
|
+
if (read)
|
|
189
|
+
take(read);
|
|
158
190
|
}
|
|
159
191
|
if (body.length < 100)
|
|
160
192
|
return { map, issues, unmarked };
|
|
161
193
|
}
|
|
162
194
|
throw new ExportError(`${MAX_PAGES * 100} or more issues carry the ${MARKER_LABEL} label, too many to check them all for this export's findings`);
|
|
163
195
|
},
|
|
196
|
+
async read(_ref, number) {
|
|
197
|
+
const body = await send({ method: "GET", path: `${root}/${number}`, idempotent: true, missingOk: true });
|
|
198
|
+
if (body === null)
|
|
199
|
+
return null;
|
|
200
|
+
const read = readIssue(body);
|
|
201
|
+
return read && read.issue.number === number ? { issue: read.issue, ids: read.ids } : null;
|
|
202
|
+
},
|
|
164
203
|
async create(f, ctx) {
|
|
165
204
|
const body = await send({ method: "POST", path: root, json: githubIssue(f, ctx), idempotent: false });
|
|
166
205
|
const number = isRecord(body) ? Number(body.number) : NaN;
|
|
167
206
|
if (!isRecord(body) || !Number.isInteger(number))
|
|
168
207
|
throw new ExportError("GitHub answered the create without an issue number", true);
|
|
169
208
|
const issue = { ref: `#${number}`, number, url: oneLine(body.html_url, 300), open: true };
|
|
170
|
-
const labels = Array.isArray(body.labels) ? body.labels : [];
|
|
171
|
-
const names = labels.map((l) => (typeof l === "string" ? l : isRecord(l) ? String(l.name ?? "") : "").toLowerCase());
|
|
172
209
|
// GitHub drops the labels of an issue created by an account that cannot set them, and says nothing.
|
|
173
|
-
if (!
|
|
210
|
+
if (!readIssue({ body: null, ...body })?.labelled)
|
|
174
211
|
return {
|
|
175
212
|
issue,
|
|
176
213
|
problems: [],
|
|
@@ -241,6 +278,22 @@ function jiraApi({ baseUrl, projectKey, issueType, linkType }, send) {
|
|
|
241
278
|
};
|
|
242
279
|
const keysOf = (links) => (Array.isArray(links) ? links : []).flatMap((l) => isRecord(l) ? [l.inwardIssue, l.outwardIssue].flatMap((i) => (isRecord(i) && typeof i.key === "string" ? [i.key] : [])) : []);
|
|
243
280
|
const namesOf = (attachments) => (Array.isArray(attachments) ? attachments : []).flatMap((a) => (isRecord(a) && typeof a.filename === "string" ? [a.filename] : []));
|
|
281
|
+
const FIELDS = ["summary", "description", "status", "attachment", "issuelinks"];
|
|
282
|
+
/** An issue as the API gives it, read. */
|
|
283
|
+
const readIssue = (issue, what) => {
|
|
284
|
+
const key = isRecord(issue) ? String(issue.key ?? "") : "";
|
|
285
|
+
const number = keyNumber(key);
|
|
286
|
+
const fields = isRecord(issue) && isRecord(issue.fields) ? issue.fields : null;
|
|
287
|
+
// A field configuration can hide the description; without it, every finding would read as unfiled and be filed again.
|
|
288
|
+
if (number === null || !fields || !("description" in fields))
|
|
289
|
+
throw new ExportError(`Jira ${what} an issue without its key or its description, so which findings are filed cannot be told`);
|
|
290
|
+
const status = isRecord(fields.status) && isRecord(fields.status.statusCategory) ? fields.status.statusCategory.key : undefined;
|
|
291
|
+
const jira = { summary: fields.summary, description: fields.description, attachments: namesOf(fields.attachment), links: keysOf(fields.issuelinks) };
|
|
292
|
+
return {
|
|
293
|
+
issue: { ref: key, number, url: `${baseUrl}/browse/${key}`, open: status !== "done", jira },
|
|
294
|
+
ids: findingIdsInJiraDescription(fields.description),
|
|
295
|
+
};
|
|
296
|
+
};
|
|
244
297
|
return {
|
|
245
298
|
label: `Jira ${projectKey}`,
|
|
246
299
|
consistentListing: false,
|
|
@@ -255,26 +308,18 @@ function jiraApi({ baseUrl, projectKey, issueType, linkType }, send) {
|
|
|
255
308
|
const body = await send({
|
|
256
309
|
method: "POST",
|
|
257
310
|
path: "/rest/api/3/search/jql",
|
|
258
|
-
json: { jql, fields:
|
|
311
|
+
json: { jql, fields: FIELDS, maxResults: 100, ...(nextPageToken ? { nextPageToken } : {}) },
|
|
259
312
|
idempotent: true,
|
|
260
313
|
});
|
|
261
314
|
if (!isRecord(body) || !Array.isArray(body.issues))
|
|
262
315
|
throw new ExportError("Jira's search answered without a list of issues");
|
|
263
|
-
for (const
|
|
264
|
-
const
|
|
265
|
-
const number = keyNumber(key);
|
|
266
|
-
const fields = isRecord(issue) && isRecord(issue.fields) ? issue.fields : null;
|
|
267
|
-
// A field configuration can hide the description; without it, every finding would read as unfiled and be filed again.
|
|
268
|
-
if (number === null || !fields || !("description" in fields))
|
|
269
|
-
throw new ExportError(`Jira listed an issue without its key or its description, so which findings are filed cannot be told`);
|
|
316
|
+
for (const entry of body.issues) {
|
|
317
|
+
const { issue, ids } = readIssue(entry, "listed");
|
|
270
318
|
issues++;
|
|
271
|
-
const status = isRecord(fields.status) && isRecord(fields.status.statusCategory) ? fields.status.statusCategory.key : undefined;
|
|
272
|
-
const ids = findingIdsInJiraDescription(fields.description);
|
|
273
319
|
if (ids.length === 0)
|
|
274
320
|
unmarked++;
|
|
275
|
-
const jira = { summary: fields.summary, description: fields.description, attachments: namesOf(fields.attachment), links: keysOf(fields.issuelinks) };
|
|
276
321
|
for (const id of ids)
|
|
277
|
-
rememberFiled(map, id,
|
|
322
|
+
rememberFiled(map, id, issue);
|
|
278
323
|
}
|
|
279
324
|
nextPageToken = typeof body.nextPageToken === "string" && body.nextPageToken ? body.nextPageToken : undefined;
|
|
280
325
|
if (!nextPageToken || body.isLast === true)
|
|
@@ -282,6 +327,15 @@ function jiraApi({ baseUrl, projectKey, issueType, linkType }, send) {
|
|
|
282
327
|
}
|
|
283
328
|
throw new ExportError(`more than ${MAX_PAGES} pages of issues in ${projectKey} carry the ${MARKER_LABEL} label, too many to check them all for this export's findings`);
|
|
284
329
|
},
|
|
330
|
+
async read(ref) {
|
|
331
|
+
// Read by key, which Jira answers from the issue itself rather than from its search, so a new issue shows at once.
|
|
332
|
+
const body = await send({ method: "GET", path: `${issuePath(ref)}?fields=${FIELDS.join(",")}`, idempotent: true, missingOk: true });
|
|
333
|
+
if (body === null)
|
|
334
|
+
return null;
|
|
335
|
+
const read = readIssue(body, "answered with");
|
|
336
|
+
// An issue moved to another project answers under its new key: it is no longer this project's.
|
|
337
|
+
return read.issue.ref === ref ? read : null;
|
|
338
|
+
},
|
|
285
339
|
async create(f, ctx, extras) {
|
|
286
340
|
const body = await send({
|
|
287
341
|
method: "POST",
|
|
@@ -314,7 +368,7 @@ function jiraApi({ baseUrl, projectKey, issueType, linkType }, send) {
|
|
|
314
368
|
/** The project's memory, refused unless it is one this version reads: a file it cannot read exported nothing and said so with exit 0. */
|
|
315
369
|
function readMemory(memoryPath) {
|
|
316
370
|
if (!fs.existsSync(memoryPath))
|
|
317
|
-
throw new ExportError(`no findings to export: ${memoryPath} does not exist. Run SceneScout on this project first, or
|
|
371
|
+
throw new ExportError(`no findings to export: ${memoryPath} does not exist. Run SceneScout on this project first, pass --project, or export a check.json or ci.json with --from`);
|
|
318
372
|
let memory;
|
|
319
373
|
try {
|
|
320
374
|
memory = JSON.parse(fs.readFileSync(memoryPath, "utf8"));
|
|
@@ -328,6 +382,33 @@ function readMemory(memoryPath) {
|
|
|
328
382
|
throw new ExportError(`${memoryPath} holds no list of findings`);
|
|
329
383
|
return memory;
|
|
330
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* A check.json or a ci.json, its findings in the shape memory.json holds
|
|
387
|
+
* them. A file it cannot read, or one some other command wrote, is refused
|
|
388
|
+
* rather than exported as nothing.
|
|
389
|
+
*/
|
|
390
|
+
function readResult(file) {
|
|
391
|
+
let text;
|
|
392
|
+
let writtenAt;
|
|
393
|
+
try {
|
|
394
|
+
text = fs.readFileSync(file, "utf8");
|
|
395
|
+
writtenAt = fs.statSync(file).mtime.toISOString();
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
throw new ExportError(`could not read --from ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
399
|
+
}
|
|
400
|
+
let raw;
|
|
401
|
+
try {
|
|
402
|
+
raw = JSON.parse(text);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
throw new ExportError(`could not read --from ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
406
|
+
}
|
|
407
|
+
const parsed = findingsFromResult(raw, writtenAt);
|
|
408
|
+
if (!parsed.ok)
|
|
409
|
+
throw new ExportError(`--from ${file}: ${parsed.error}`);
|
|
410
|
+
return parsed.value;
|
|
411
|
+
}
|
|
331
412
|
/** Every step with a frame, from every session log in the memory directory. Logs and lines that cannot be read are counted, not fatal. */
|
|
332
413
|
function framedSteps(memoryDir) {
|
|
333
414
|
const steps = [];
|
|
@@ -401,6 +482,44 @@ function pictureOf(f, memoryDir) {
|
|
|
401
482
|
const found = frameFile(path.join(memoryDir, "recordings"), picture.rel);
|
|
402
483
|
return found && found.stat.size <= MAX_FRAME_BYTES ? { ...picture, file: found.file, upload: uploadName(picture.rel, picture.at) } : null;
|
|
403
484
|
}
|
|
485
|
+
/** The record of the issues earlier exports filed; none yet is an empty one, and one that cannot be read ends the export. */
|
|
486
|
+
function readRecord(recordPath) {
|
|
487
|
+
let text;
|
|
488
|
+
try {
|
|
489
|
+
text = fs.readFileSync(recordPath, "utf8");
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
if (err.code !== "ENOENT")
|
|
493
|
+
throw new ExportError(`could not read ${recordPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
494
|
+
text = null;
|
|
495
|
+
}
|
|
496
|
+
const parsed = parseExportRecord(text);
|
|
497
|
+
if (!parsed.ok)
|
|
498
|
+
throw new ExportError(`could not read ${recordPath}: ${parsed.error}. It records the issues earlier exports filed, so an export straight after another does not file them again; move it aside to start a new record`);
|
|
499
|
+
return parsed.value;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Set one finding's entry in the record, read afresh so the entries of an
|
|
503
|
+
* export that finished meanwhile are kept, and written whole or not at all.
|
|
504
|
+
* It takes no lock: two exports from one project at the same moment can each
|
|
505
|
+
* drop the other's latest entry. Exports are meant to run one at a time per
|
|
506
|
+
* project, and an entry lost that way costs only the read-back, since the
|
|
507
|
+
* listing still finds the issue once it catches up.
|
|
508
|
+
*/
|
|
509
|
+
function writeRecord(recordPath, key, id, entry) {
|
|
510
|
+
const text = recordWith(readRecord(recordPath), key, id, entry);
|
|
511
|
+
// An export --from a check or ci result may be the first thing to write in a project's .scenescout folder.
|
|
512
|
+
fs.mkdirSync(path.dirname(recordPath), { recursive: true });
|
|
513
|
+
const temp = `${recordPath}.${process.pid}.tmp`;
|
|
514
|
+
try {
|
|
515
|
+
fs.writeFileSync(temp, text, { mode: 0o600 });
|
|
516
|
+
fs.renameSync(temp, recordPath);
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
fs.rmSync(temp, { force: true });
|
|
520
|
+
throw err;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
404
523
|
// ── the export ──────────────────────────────────────────────────────────────
|
|
405
524
|
const OUTCOME_WORDS = { file: "would file", "already-filed": "already filed", "over-cap": "over the cap" };
|
|
406
525
|
export async function runExport(o, deps) {
|
|
@@ -431,20 +550,25 @@ export async function runExport(o, deps) {
|
|
|
431
550
|
};
|
|
432
551
|
try {
|
|
433
552
|
const memoryDir = path.join(o.projectDir, MEMORY_DIRNAME);
|
|
434
|
-
|
|
435
|
-
const
|
|
553
|
+
// A check.json or a ci.json names no frames, picture or tickets: its issues carry none of them.
|
|
554
|
+
const result = o.from ? readResult(o.from) : null;
|
|
555
|
+
const sourcePath = o.from ?? path.join(memoryDir, "memory.json");
|
|
556
|
+
const memory = result ? { findings: result.findings } : readMemory(sourcePath);
|
|
557
|
+
const screenshots = o.screenshots && !result;
|
|
436
558
|
const selection = selectFindings(memory, o);
|
|
437
559
|
const criteria = failedCriteriaByFinding(memory);
|
|
438
560
|
if (selection.unknownOnly.length > 0)
|
|
439
|
-
throw new ExportError(`--only names no finding of this project: ${selection.unknownOnly.join(", ")}`);
|
|
561
|
+
throw new ExportError(`--only names no finding ${result ? `in ${sourcePath}` : "of this project"}: ${selection.unknownOnly.join(", ")}`);
|
|
440
562
|
say(o.dryRun ? `SceneScout export to ${where}: a dry run, so nothing is filed. Pass --yes to file.` : `SceneScout export to ${where}.`);
|
|
441
563
|
const c = selection.counts;
|
|
442
564
|
const left = [
|
|
443
565
|
c.belowSeverity ? `${c.belowSeverity} below --min-severity ${o.minSeverity}` : "",
|
|
444
566
|
c.worthALook ? `${c.worthALook} worth a look (--include-worth-a-look exports them)` : "",
|
|
445
567
|
].filter(Boolean);
|
|
446
|
-
say(`Findings in ${
|
|
568
|
+
say(`Findings in ${sourcePath}${result ? ` (scenescout ${result.kind})` : ""}: ${c.open} open, ${c.resolved} resolved${selection.unreadable.length ? `, ${selection.unreadable.length} unreadable` : ""}. ` +
|
|
447
569
|
`${selection.candidates.length} to export${left.length ? `; left out: ${left.join(", ")}` : ""}.`);
|
|
570
|
+
if (result && o.screenshots)
|
|
571
|
+
say(` No screenshots: a ${result.kind} result names none.`);
|
|
448
572
|
if (selection.unreadable.length > 0)
|
|
449
573
|
say(` Not exported, as this version cannot read them: ${selection.unreadable.slice(0, 20).join(", ")}${selection.unreadable.length > 20 ? " …" : ""}`);
|
|
450
574
|
for (const { id, reason } of selection.leftOut)
|
|
@@ -463,30 +587,71 @@ export async function runExport(o, deps) {
|
|
|
463
587
|
// A closed issue counts as filed unless --refile-closed.
|
|
464
588
|
const includeClosed = !o.refileClosed;
|
|
465
589
|
let existing = null;
|
|
590
|
+
const recordPath = path.join(memoryDir, EXPORT_RECORD_FILE);
|
|
591
|
+
const key = recordKey(o);
|
|
592
|
+
let candidates = selection.candidates;
|
|
593
|
+
let heldBack = 0;
|
|
466
594
|
if (tracker) {
|
|
595
|
+
const recorded = readRecord(recordPath)[key] ?? {};
|
|
467
596
|
const found = await tracker.existing(includeClosed);
|
|
468
597
|
existing = found.map;
|
|
469
598
|
say(`Compared with ${tracker.label}: ${found.issues} ${includeClosed ? "" : "open "}issue(s) carry the ${MARKER_LABEL} label` +
|
|
470
599
|
`${includeClosed ? " (open or closed)" : ""}${found.unmarked ? `, ${found.unmarked} of them with no marker` : ""}` +
|
|
471
600
|
`${includeClosed ? "" : "; a finding whose issue was closed is filed again (--refile-closed)"}.`);
|
|
601
|
+
// The listing can lag behind a create, so an issue an earlier export recorded is read back by its number before its finding counts as unfiled.
|
|
602
|
+
let readBack = 0;
|
|
603
|
+
for (const f of candidates) {
|
|
604
|
+
const entry = recorded[f.id];
|
|
605
|
+
if (!entry || "uncertain" in entry || existing.has(f.id))
|
|
606
|
+
continue;
|
|
607
|
+
const read = await tracker.read(entry.ref, entry.number);
|
|
608
|
+
// Gone, moved, or no longer carrying the marker: the finding is filed again, as the listing says.
|
|
609
|
+
if (!read || !read.ids.includes(f.id) || (!includeClosed && !read.issue.open))
|
|
610
|
+
continue;
|
|
611
|
+
rememberFiled(existing, f.id, read.issue);
|
|
612
|
+
readBack++;
|
|
613
|
+
}
|
|
614
|
+
if (readBack > 0)
|
|
615
|
+
say(` ${readBack} more finding(s) found filed by the issue number recorded in ${recordPath}, as the listing does not show them yet.`);
|
|
616
|
+
// A Jira create that may have been made, and that the search does not show yet, holds its finding back for a while rather than file it twice.
|
|
617
|
+
if (!tracker.consistentListing) {
|
|
618
|
+
const held = candidates.filter((f) => !existing.has(f.id) && recorded[f.id] && uncertainHolds(recorded[f.id], http.now()));
|
|
619
|
+
for (const f of held) {
|
|
620
|
+
const until = new Date(Date.parse(recorded[f.id].at) + UNCERTAIN_HOLD_MS).toISOString();
|
|
621
|
+
say(` ${"held back".padEnd(13)} ${findingLine(f)}`);
|
|
622
|
+
// A dry run says so and files nothing anyway, so a hold is not a failure of it.
|
|
623
|
+
if (!o.dryRun)
|
|
624
|
+
problems.push(`finding ${f.id} was held back: an export at ${recorded[f.id].at} may have filed it before an error, and ${tracker.label}'s search does not show it yet. An export after ${until} files it if it is still not found`);
|
|
625
|
+
}
|
|
626
|
+
candidates = candidates.filter((f) => !held.includes(f));
|
|
627
|
+
heldBack = held.length;
|
|
628
|
+
}
|
|
472
629
|
}
|
|
473
630
|
else if (!credentials.ok) {
|
|
474
631
|
say(`Not compared with ${TRACKER_LABEL[o.to]}: ${credentials.error}. A finding filed earlier is listed below as one to file.`);
|
|
475
632
|
}
|
|
476
|
-
|
|
477
|
-
|
|
633
|
+
const record = (f, entry, ref) => {
|
|
634
|
+
try {
|
|
635
|
+
writeRecord(recordPath, key, f.id, entry);
|
|
636
|
+
}
|
|
637
|
+
catch (err) {
|
|
638
|
+
problems.push(`${ref}: not recorded in ${recordPath} (${err instanceof Error ? err.message : String(err)}), so an export straight after this one may not see it yet`);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
plan = planExport(candidates, existing, o.maxIssues);
|
|
642
|
+
const steps = screenshots ? framedSteps(memoryDir) : { steps: [], unreadable: 0 };
|
|
478
643
|
if (steps.unreadable > 0)
|
|
479
644
|
say(`Skipped ${steps.unreadable} session-log line(s) or file(s) that could not be read while looking for screenshots.`);
|
|
480
645
|
const contextOf = (f) => {
|
|
481
|
-
const found =
|
|
646
|
+
const found = screenshots ? framesOf(f, steps.steps, memoryDir) : { frames: [], dropped: 0 };
|
|
482
647
|
droppedFrames += found.dropped;
|
|
483
|
-
const picture =
|
|
648
|
+
const picture = screenshots ? pictureOf(f, memoryDir) : null;
|
|
484
649
|
const failed = criteria.get(f.id) ?? [];
|
|
485
650
|
return {
|
|
486
651
|
ctx: {
|
|
487
652
|
severityName: o.severityMap[f.severity],
|
|
488
653
|
labels: o.labels,
|
|
489
|
-
screenshots:
|
|
654
|
+
screenshots: screenshots ? found.frames.map((x) => x.rel) : "off",
|
|
490
655
|
framesLeftOut: found.dropped,
|
|
491
656
|
picture: picture?.rel ?? null,
|
|
492
657
|
...(picture?.at ? { pictureAt: picture.at } : {}),
|
|
@@ -506,7 +671,8 @@ export async function runExport(o, deps) {
|
|
|
506
671
|
const issue = entry.issue;
|
|
507
672
|
const already = ` ${OUTCOME_WORDS[entry.outcome].padEnd(13)} ${findingLine(f)} as ${issue.ref}${issue.open ? "" : " (closed)"}`;
|
|
508
673
|
// Only an open Jira issue filed earlier is brought up to date; a closed one is the team's decision and left alone.
|
|
509
|
-
|
|
674
|
+
// An export --from a result never updates: the file holds less than the memory that may have filed the issue.
|
|
675
|
+
if (o.to !== "jira" || !o.jira.update || result || !issue.open || !issue.jira || !tracker?.update) {
|
|
510
676
|
say(already);
|
|
511
677
|
continue;
|
|
512
678
|
}
|
|
@@ -551,7 +717,7 @@ export async function runExport(o, deps) {
|
|
|
551
717
|
}
|
|
552
718
|
// Listed, not filed: over the cap, or a dry run (the only way to be here with no tracker, as --yes needs credentials).
|
|
553
719
|
if (entry.outcome === "over-cap" || o.dryRun || !tracker) {
|
|
554
|
-
const shown = entry.outcome === "file" &&
|
|
720
|
+
const shown = entry.outcome === "file" && screenshots ? contextOf(f).ctx : null;
|
|
555
721
|
const shots = shown && shown.screenshots !== "off" ? shown.screenshots.length + (shown.picture ? 1 : 0) : 0;
|
|
556
722
|
say(` ${OUTCOME_WORDS[entry.outcome].padEnd(13)} ${findingLine(f)}${shots ? ` with ${shots} screenshot(s)` : ""}`);
|
|
557
723
|
continue;
|
|
@@ -559,9 +725,18 @@ export async function runExport(o, deps) {
|
|
|
559
725
|
if (writes > 0 && pauseMs > 0)
|
|
560
726
|
await http.wait(pauseMs);
|
|
561
727
|
const { ctx, extras } = contextOf(f);
|
|
562
|
-
|
|
728
|
+
let done;
|
|
729
|
+
try {
|
|
730
|
+
done = await fileOne(tracker, f, ctx, extras, includeClosed, http);
|
|
731
|
+
}
|
|
732
|
+
catch (err) {
|
|
733
|
+
if (err instanceof ExportError && err.uncertain && !tracker.consistentListing)
|
|
734
|
+
record(f, { uncertain: true, at: new Date(http.now()).toISOString() }, `finding ${f.id}`);
|
|
735
|
+
throw err;
|
|
736
|
+
}
|
|
563
737
|
writes++;
|
|
564
738
|
filed.push({ id: f.id, issue: done.issue });
|
|
739
|
+
record(f, { ref: done.issue.ref, number: done.issue.number, url: done.issue.url, at: new Date(http.now()).toISOString() }, done.issue.ref);
|
|
565
740
|
say(` ${`filed ${done.issue.ref}`.padEnd(13)} ${findingLine(f)} ${done.issue.url}${done.confirmed ? " (found by its marker after the tracker's error)" : ""}`);
|
|
566
741
|
for (const p of done.problems)
|
|
567
742
|
problems.push(`${done.issue.ref}: ${p}`);
|
|
@@ -571,7 +746,7 @@ export async function runExport(o, deps) {
|
|
|
571
746
|
reportFrames();
|
|
572
747
|
const count = (outcome) => plan.filter((e) => e.outcome === outcome).length;
|
|
573
748
|
const over = count("over-cap");
|
|
574
|
-
const overText = over ? `; ${over} over the cap of ${o.maxIssues} (--max-issues): export again to file them` : "";
|
|
749
|
+
const overText = (over ? `; ${over} over the cap of ${o.maxIssues} (--max-issues): export again to file them` : "") + (heldBack ? `; ${heldBack} held back` : "");
|
|
575
750
|
say(o.dryRun
|
|
576
751
|
? `Would file ${count("file")}; ${count("already-filed")} already filed${overText}.`
|
|
577
752
|
: `Filed ${filed.length}; ${count("already-filed")} already filed${updated.length ? `, ${updated.length} of them updated` : ""}${overText}.`);
|
|
@@ -587,7 +762,7 @@ export async function runExport(o, deps) {
|
|
|
587
762
|
complain(`scenescout export: ${err instanceof Error ? err.message : String(err)}`);
|
|
588
763
|
if (err instanceof ExportError && err.uncertain)
|
|
589
764
|
complain(o.to === "jira"
|
|
590
|
-
?
|
|
765
|
+
? `Jira may have made that issue before the error, and its search can take minutes to show a new one, so it was not sent again. Export again later: the issue is found by its marker if it exists, and filed if it does not. An export within ${UNCERTAIN_HOLD_MS / 60_000} minutes holds that finding back unless it finds the issue.`
|
|
591
766
|
: "The tracker may have made that issue before the error. The next export finds it by its marker and does not file it again.");
|
|
592
767
|
for (const p of problems)
|
|
593
768
|
complain(`scenescout export: ${p}`);
|
|
@@ -604,8 +779,9 @@ const GITHUB_HEADERS = { accept: "application/vnd.github+json", "x-github-api-ve
|
|
|
604
779
|
* on (a timeout, a 5xx, an answer cut off) is not simply sent again: the
|
|
605
780
|
* issues are listed again first, and if one already carries this finding's
|
|
606
781
|
* marker, that is the issue. Only when none does is the create repeated, and
|
|
607
|
-
* only where the listing shows a new issue at once (GitHub
|
|
608
|
-
* search lags, the
|
|
782
|
+
* only where the listing shows a new issue at once (GitHub, through its
|
|
783
|
+
* newest issues read with no label filter); in Jira, whose search lags, the
|
|
784
|
+
* export stops instead, and records the finding as held back for a while.
|
|
609
785
|
*/
|
|
610
786
|
async function fileOne(tracker, f, ctx, extras, includeClosed, http) {
|
|
611
787
|
for (let attempt = 0;; attempt++) {
|
package/dist/first-run.js
CHANGED
|
@@ -24,7 +24,7 @@ import fs from "node:fs";
|
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import { APPROX_DISK_MB, launchTarget } from "./browsers.js";
|
|
26
26
|
import { hasScheme, hostOf } from "./commands.js";
|
|
27
|
-
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 { CHECK_OPTION_NAMES, CHECK_RULES, countBySeverity, issueLine, issueSections, MAX_CHECK_ROUTES, MAX_DISCOVERY_ROUNDS, needsSignInText, resolveArgPath, resourcesTable, routesTable, SEVERITY_RANK, SHARED_CHROME_ROUTE, unauditedRoutes, unvisitedLine, worthALookSection, } from "./engine/check.js";
|
|
28
28
|
import { httpStatusOf } from "./engine/oracles.js";
|
|
29
29
|
/** Every `--option` a first look accepts. Anything more is `scenescout check`'s. */
|
|
30
30
|
export const FIRST_RUN_OPTION_NAMES = ["max-routes", "max-minutes", "mode", "out"];
|
|
@@ -510,6 +510,11 @@ function notes({ result, options }, code) {
|
|
|
510
510
|
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}/`)}`)}.`);
|
|
511
511
|
}
|
|
512
512
|
}
|
|
513
|
+
// A first look never has a session, so a page behind sign-in is a page it could not cover, not an issue.
|
|
514
|
+
const walled = result.needsSignIn?.length ?? 0;
|
|
515
|
+
if (walled > 0 && !start?.loginRedirect) {
|
|
516
|
+
out.push(needsSignInText(walled, `${code(`npx -y scenescout login ${shellArg(result.url)} --role <name>`)} saves a sign-in, and an agent run then attaches as that role`, "page"));
|
|
517
|
+
}
|
|
513
518
|
const left = result.unvisited.length;
|
|
514
519
|
switch (stopReason(result, options.maxRoutes)) {
|
|
515
520
|
case "time":
|
|
@@ -575,6 +580,7 @@ export function formatFirstRun(facts) {
|
|
|
575
580
|
lines.push(...issueSections(result.issues));
|
|
576
581
|
lines.push(...worthALookSection(result.worthALook, false));
|
|
577
582
|
lines.push(...routesTable(result));
|
|
583
|
+
lines.push(...resourcesTable(result.resources));
|
|
578
584
|
const why = stopReason(result, facts.options.maxRoutes);
|
|
579
585
|
lines.push(...unvisitedLine(result.unvisited, why ? UNVISITED_WHY[why] : "not reached"));
|
|
580
586
|
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._`);
|