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.
@@ -1,15 +1,17 @@
1
1
  /**
2
- * Runs `scenescout export`: reads the project's findings, asks the tracker
3
- * which of them it already holds, and files the rest. The rules (which
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
- * It writes nothing to the project, and the credentials it sends are never
7
- * printed: every line goes out through a redactor that knows them.
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: search is
130
- // indexed with a delay, so an export run right after another would not
131
- // see the issues it had just filed and would file them again.
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=${includeClosed ? "all" : "open"}&per_page=100&page=${page}`,
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 issue of body) {
145
- // The issues API lists pull requests too.
146
- if (isRecord(issue) && issue.pull_request)
147
- continue;
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 (!names.includes(MARKER_LABEL))
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: ["summary", "description", "status", "attachment", "issuelinks"], maxResults: 100, ...(nextPageToken ? { nextPageToken } : {}) },
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 issue of body.issues) {
264
- const key = isRecord(issue) ? String(issue.key ?? "") : "";
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, { ref: key, number, url: `${baseUrl}/browse/${key}`, open: status !== "done", jira });
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 pass --project`);
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
- const memoryPath = path.join(memoryDir, "memory.json");
435
- const memory = readMemory(memoryPath);
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 ${memoryPath}: ${c.open} open, ${c.resolved} resolved${selection.unreadable.length ? `, ${selection.unreadable.length} unreadable` : ""}. ` +
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
- plan = planExport(selection.candidates, existing, o.maxIssues);
477
- const steps = o.screenshots ? framedSteps(memoryDir) : { steps: [], unreadable: 0 };
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 = o.screenshots ? framesOf(f, steps.steps, memoryDir) : { frames: [], dropped: 0 };
646
+ const found = screenshots ? framesOf(f, steps.steps, memoryDir) : { frames: [], dropped: 0 };
482
647
  droppedFrames += found.dropped;
483
- const picture = o.screenshots ? pictureOf(f, memoryDir) : null;
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: o.screenshots ? found.frames.map((x) => x.rel) : "off",
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
- if (o.to !== "jira" || !o.jira.update || !issue.open || !issue.jira || !tracker?.update) {
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" && o.screenshots ? contextOf(f).ctx : null;
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
- const done = await fileOne(tracker, f, ctx, extras, includeClosed, http);
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
- ? "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."
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); in Jira, whose
608
- * search lags, the export stops instead.
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._`);