@setsquare/review-sdk 0.1.0 → 0.2.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.
Files changed (73) hide show
  1. package/README.md +119 -16
  2. package/dist/autoscan.d.ts +9 -1
  3. package/dist/autoscan.d.ts.map +1 -1
  4. package/dist/autoscan.js +57 -7
  5. package/dist/autoscan.js.map +1 -1
  6. package/dist/axe.d.ts.map +1 -1
  7. package/dist/axe.js +25 -3
  8. package/dist/axe.js.map +1 -1
  9. package/dist/checks/context-change.d.ts.map +1 -1
  10. package/dist/checks/context-change.js +3 -0
  11. package/dist/checks/context-change.js.map +1 -1
  12. package/dist/checks/dynamic.d.ts.map +1 -1
  13. package/dist/checks/dynamic.js +3 -0
  14. package/dist/checks/dynamic.js.map +1 -1
  15. package/dist/checks/focus.d.ts +25 -5
  16. package/dist/checks/focus.d.ts.map +1 -1
  17. package/dist/checks/focus.js +203 -15
  18. package/dist/checks/focus.js.map +1 -1
  19. package/dist/checks/heuristics.d.ts +6 -5
  20. package/dist/checks/heuristics.d.ts.map +1 -1
  21. package/dist/checks/heuristics.js +37 -1
  22. package/dist/checks/heuristics.js.map +1 -1
  23. package/dist/checks/keyboard.js +12 -0
  24. package/dist/checks/keyboard.js.map +1 -1
  25. package/dist/checks/reflow.d.ts.map +1 -1
  26. package/dist/checks/reflow.js +4 -0
  27. package/dist/checks/reflow.js.map +1 -1
  28. package/dist/checks/shortcuts.d.ts.map +1 -1
  29. package/dist/checks/shortcuts.js +3 -0
  30. package/dist/checks/shortcuts.js.map +1 -1
  31. package/dist/colour-shim.d.ts +77 -0
  32. package/dist/colour-shim.d.ts.map +1 -0
  33. package/dist/colour-shim.js +192 -0
  34. package/dist/colour-shim.js.map +1 -0
  35. package/dist/contract.d.ts +14 -0
  36. package/dist/contract.d.ts.map +1 -1
  37. package/dist/envelope.d.ts.map +1 -1
  38. package/dist/envelope.js +78 -16
  39. package/dist/envelope.js.map +1 -1
  40. package/dist/findings.d.ts.map +1 -1
  41. package/dist/findings.js +57 -0
  42. package/dist/findings.js.map +1 -1
  43. package/dist/fixture.d.ts.map +1 -1
  44. package/dist/fixture.js +21 -4
  45. package/dist/fixture.js.map +1 -1
  46. package/dist/html-report.d.ts.map +1 -1
  47. package/dist/html-report.js +54 -12
  48. package/dist/html-report.js.map +1 -1
  49. package/dist/insights.d.ts.map +1 -1
  50. package/dist/insights.js +9 -12
  51. package/dist/insights.js.map +1 -1
  52. package/dist/options.d.ts +20 -9
  53. package/dist/options.d.ts.map +1 -1
  54. package/dist/options.js +23 -1
  55. package/dist/options.js.map +1 -1
  56. package/dist/reporter.js +2 -2
  57. package/dist/reporter.js.map +1 -1
  58. package/dist/screens.d.ts.map +1 -1
  59. package/dist/screens.js +34 -1
  60. package/dist/screens.js.map +1 -1
  61. package/dist/settle.d.ts +35 -0
  62. package/dist/settle.d.ts.map +1 -1
  63. package/dist/settle.js +43 -2
  64. package/dist/settle.js.map +1 -1
  65. package/dist/summary.d.ts +33 -1
  66. package/dist/summary.d.ts.map +1 -1
  67. package/dist/summary.js +271 -45
  68. package/dist/summary.js.map +1 -1
  69. package/dist/unscannable-reasons.d.ts +30 -0
  70. package/dist/unscannable-reasons.d.ts.map +1 -0
  71. package/dist/unscannable-reasons.js +53 -0
  72. package/dist/unscannable-reasons.js.map +1 -0
  73. package/package.json +4 -4
package/dist/summary.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { conformanceGrid, conformanceSummary, ruleSurfaceShare, stateCoverage, } from '@setsquare/schema/conformance';
2
- import { PLATFORM_URL, ruleHelpUrl } from './links.js';
2
+ import { ruleHelpUrl } from './links.js';
3
3
  import { continuationLines, createStyle, figureBlock, figureLine, heading, } from './style.js';
4
4
  import { flakeNotes, insightBlocks } from './insights.js';
5
5
  /**
@@ -120,6 +120,7 @@ export function groupFindings(envelope) {
120
120
  ruleId: finding.ruleId,
121
121
  impact: finding.impact ?? 'unknown',
122
122
  status: finding.status,
123
+ ...(finding.undecidable ? { undecidable: finding.undecidable } : {}),
123
124
  nodes: finding.targets.nodeCount,
124
125
  contexts: [label],
125
126
  contextNodes: [{ label, nodes: finding.targets.nodeCount }],
@@ -230,6 +231,8 @@ export function evidenceQuality(envelope) {
230
231
  let quiet = 0;
231
232
  let teardown = 0;
232
233
  let ceiling = 0;
234
+ let ceilingWaitedMs = 0;
235
+ let ceilingMutations = 0;
233
236
  for (const scan of envelope.scans) {
234
237
  for (const pass of scan.passes ?? [])
235
238
  rules.add(pass.ruleId);
@@ -246,12 +249,17 @@ export function evidenceQuality(envelope) {
246
249
  break;
247
250
  case 'ceiling':
248
251
  ceiling += 1;
252
+ // The worst case rather than an average: one page pinned at the ceiling
253
+ // is the thing worth knowing about, and a mean over a healthy run would
254
+ // hide it.
255
+ ceilingWaitedMs = Math.max(ceilingWaitedMs, scan.settle.sinceNavMs);
256
+ ceilingMutations = Math.max(ceilingMutations, scan.settle.mutations);
249
257
  break;
250
258
  default:
251
259
  break;
252
260
  }
253
261
  }
254
- return { rulesAssessed: rules.size, quiet, teardown, ceiling };
262
+ return { rulesAssessed: rules.size, quiet, teardown, ceiling, ceilingWaitedMs, ceilingMutations };
255
263
  }
256
264
  /*
257
265
  * Google's "good" thresholds for the two Core Web Vitals we can measure in a lab.
@@ -366,6 +374,8 @@ export function totals(envelope) {
366
374
  const themes = new Set();
367
375
  let violations = 0;
368
376
  let incomplete = 0;
377
+ let judgement = 0;
378
+ let unmeasured = 0;
369
379
  let passes = 0;
370
380
  let clean = 0;
371
381
  for (const scan of envelope.scans) {
@@ -374,9 +384,17 @@ export function totals(envelope) {
374
384
  themes.add(scan.context.theme);
375
385
  const scanViolations = scan.findings.filter((f) => f.status === 'violation');
376
386
  violations += scanViolations.reduce((sum, f) => sum + f.targets.nodeCount, 0);
377
- incomplete += scan.findings
378
- .filter((f) => f.status === 'incomplete')
379
- .reduce((sum, f) => sum + f.targets.nodeCount, 0);
387
+ for (const f of scan.findings) {
388
+ if (f.status !== 'incomplete')
389
+ continue;
390
+ incomplete += f.targets.nodeCount;
391
+ // Unclassified rows count as a judgement: absence is not "measurement",
392
+ // and inflating our own debt figure would make it useless as a measure.
393
+ if (f.undecidable === 'measurement')
394
+ unmeasured += f.targets.nodeCount;
395
+ else
396
+ judgement += f.targets.nodeCount;
397
+ }
380
398
  passes += scan.passes.reduce((sum, p) => sum + p.nodeCount, 0);
381
399
  if (scanViolations.length === 0)
382
400
  clean += 1;
@@ -387,6 +405,8 @@ export function totals(envelope) {
387
405
  scans: envelope.scans.length,
388
406
  violations,
389
407
  incomplete,
408
+ judgement,
409
+ unmeasured,
390
410
  passes,
391
411
  clean,
392
412
  };
@@ -448,14 +468,31 @@ export function renderText(input, columns) {
448
468
  * strip before a word is read.
449
469
  */
450
470
  /**
451
- * Where the run's own artefacts are, and — usually — the closing block.
471
+ * Where the run's own artefacts are, and the closing block.
472
+ *
473
+ * There used to be a five-line pitch here, on by default, switchable off with
474
+ * `promo: false`. It is gone, and the reasoning is worth keeping because it
475
+ * will be proposed again.
476
+ *
477
+ * The design answered two questions at once and could not be right about both.
478
+ * It read as an advert — a product name, a benefit list, a URL — but every
479
+ * line of it was TRUE ABOUT THEIR RUN, and it was switchable, which only makes
480
+ * sense for an advert. A disclosure is not optional; you cannot turn off a
481
+ * limitation.
452
482
  *
453
- * `promo` is a parameter because of one case: a run that scanned nothing must
454
- * not be followed by a pitch. The numbers it would quote are all zero, so it
455
- * would read as an advert bolted onto a failure, and the closing block's whole
456
- * licence to exist is that it is derived from the run's own figures.
483
+ * This repository's own rule about `doctor`'s exit code settled it: *we do not
484
+ * fail a customer's build, and the promise is weaker if it has an opt-out*.
485
+ * "We do not advertise in your build output" is a stronger promise than "we
486
+ * advertise unless you ask us not to" and the flag was a fiction anyway,
487
+ * governing one of three places the pitch appeared while `verify` and the
488
+ * SARIF caveat carried it regardless, the second of those into the customer's
489
+ * own code-scanning UI.
490
+ *
491
+ * What survives is the half that was a fact about their run, as a caveat
492
+ * rather than a pitch: no product name, no URL, and not switchable, because
493
+ * "this run only" is a real limit on what the reader is holding.
457
494
  */
458
- const trailer = (promo = true) => [
495
+ const trailer = (scanned = true) => [
459
496
  ...(input.warnings.length > 0 ? [''] : []),
460
497
  ...warningLines(input.warnings, width, evidenceQuality(envelope), envelope),
461
498
  '',
@@ -465,7 +502,21 @@ export function renderText(input, columns) {
465
502
  ? [figureLine(style, '', `${input.htmlReportPath} ${style.dim('(open in a browser)')}`)]
466
503
  : []),
467
504
  ...(input.screensPath
468
- ? [figureLine(style, '', `${input.screensPath} ${style.dim('(screenshots, one page)')}`)]
505
+ ? [
506
+ figureLine(style, '', `${input.screensPath} ${style.dim('(screenshots, one page)')}`),
507
+ /*
508
+ * Said where the path is, because that is where someone decides
509
+ * whether to keep it — and measured rather than assumed: Playwright
510
+ * clears its whole output directory at the start of a run, including
511
+ * captures belonging to tests that run does not execute.
512
+ *
513
+ * The sheet indexes those images rather than copying them, which is a
514
+ * privacy guarantee and not tidiness: Playwright's own captures are
515
+ * unmasked, and this package writes no images at all.
516
+ */
517
+ ...continuationLines(style, "That page links to Playwright's images rather than copying them, and Playwright " +
518
+ 'clears its output directory on its next run — so copy it now if this run matters.', width),
519
+ ]
469
520
  : []),
470
521
  ...(input.reportPath
471
522
  ? [figureLine(style, '', `${input.reportPath} ${style.dim('(Playwright report)')}`)]
@@ -477,7 +528,13 @@ export function renderText(input, columns) {
477
528
  ...groupLines(input.group).flatMap((line) => wrap(line, Math.max(40, width - 8), '').map((wrapped) => figureLine(style, '', wrapped))),
478
529
  ]
479
530
  : []),
480
- ...(promo ? promoLines(input, count, width) : ['']),
531
+ /*
532
+ * A run that scanned nothing gets no caveat, for the reason the pitch used
533
+ * to be suppressed there: the figures are all zero, and a line about what
534
+ * this run does not cover reads as noise bolted onto a failure. The block
535
+ * above already says nothing was looked at, which is the stronger statement.
536
+ */
537
+ ...(scanned ? historyCaveat(input, style, width) : ['']),
481
538
  ];
482
539
  lines.push('');
483
540
  lines.push(` ${style.accent('Setsquare')} — accessibility evidence`);
@@ -533,8 +590,27 @@ export function renderText(input, columns) {
533
590
  */
534
591
  lines.push(heading(style, 'Accessibility'));
535
592
  lines.push(figureLine(style, 0, 'surfaces scanned'));
536
- lines.push(...continuationLines(style, 'This is not a clean result — it means nothing was looked at. Usually the ' +
537
- 'suite never opened a page, or every test finished before its page settled.', width));
593
+ /*
594
+ * Name the cause when the run knows it, and only then.
595
+ *
596
+ * "Usually the suite never opened a page, or every test finished before its
597
+ * page settled" is a guess offered in place of a fact the envelope is
598
+ * holding. On a lane run with `SETSQUARE_AUTOSCAN=0` both halves of that
599
+ * sentence are false and both describe a broken suite, which is how a
600
+ * perfectly healthy functional lane came to read as a failure.
601
+ *
602
+ * "This is not a clean result" stays either way. Nothing was looked at, and
603
+ * that is true whether or not the reason is reassuring.
604
+ */
605
+ const offRows = (envelope.unscanned ?? []).filter((row) => row.reason === 'autoscan-disabled');
606
+ const allOff = offRows.length > 0 && offRows.length === (envelope.unscanned ?? []).length;
607
+ lines.push(...continuationLines(style, allOff
608
+ ? 'This is not a clean result — it means nothing was looked at. Automatic ' +
609
+ 'scanning was off for this run (SETSQUARE_AUTOSCAN=0), so the surfaces ' +
610
+ 'below were visited and never scanned. That is the configuration working ' +
611
+ 'as asked; it is not a fault in the suite.'
612
+ : 'This is not a clean result — it means nothing was looked at. Usually the ' +
613
+ 'suite never opened a page, or every test finished before its page settled.', width));
538
614
  lines.push(...trailer(false));
539
615
  return join(lines);
540
616
  }
@@ -581,10 +657,54 @@ export function renderText(input, columns) {
581
657
  lines.push(figureLine(style, failingRules, `rule${failingRules === 1 ? '' : 's'} failing, on ${n(count.violations)} ` +
582
658
  `element${count.violations === 1 ? '' : 's'}`));
583
659
  if (count.incomplete > 0) {
584
- lines.push(figureLine(style, count.incomplete, 'needing review — axe could not decide'));
660
+ /*
661
+ * Two figures, because two different things were sharing one word — and the
662
+ * old line credited axe for both, which stopped being true when most of the
663
+ * undecided rows came from our own checks.
664
+ */
665
+ if (count.judgement > 0) {
666
+ lines.push(figureLine(style, count.judgement, 'needing a judgement — a person decides'));
667
+ }
668
+ if (count.unmeasured > 0) {
669
+ lines.push(figureLine(style, count.unmeasured, 'we could not measure — our debt, not yours'));
670
+ }
585
671
  }
586
672
  lines.push(figureLine(style, count.passes, style.dim(`checks passed, across ${quality.rulesAssessed} distinct rules`)));
673
+ /*
674
+ * The element figure holds still only in the sense that the rule count above it
675
+ * does. Said once, here, where someone is about to quote a number.
676
+ */
677
+ if (count.violations > 0) {
678
+ lines.push(...continuationLines(style, 'Rule and surface counts are the stable pair. Element counts follow how much data ' +
679
+ 'is on the page, so they move between runs of unchanged code — measured at 1,712 ' +
680
+ 'and then 1,626 on two runs of one commit, while rules × surfaces held at 230.', width));
681
+ }
587
682
  lines.push(...conformanceLines(input, style, width));
683
+ /*
684
+ * THE CUT. Everything below is the report's job unless asked for.
685
+ *
686
+ * Friction record run 4 measured 1,080 lines on Eos, and said the useful part
687
+ * was the first forty. Both halves of that matter: a summary nobody reads is
688
+ * not merely long, it takes the part that WAS worth reading down with it,
689
+ * because by the end of a run those lines have scrolled away.
690
+ *
691
+ * Measured on the fixture suite at 26 surfaces — where the enumerations are
692
+ * the bulk, and the criteria table is the last thing a person reads:
693
+ *
694
+ * RULES FAILING 152 · NEEDS A JUDGEMENT 82 · CONFORMANCE 62 · SURFACES 36
695
+ *
696
+ * What still prints below the cut is anything that QUALIFIES the figures
697
+ * above — what could not be checked, what is worth knowing. Dropping those
698
+ * would make the short summary a MORE confident one than the long summary,
699
+ * which is exactly the wrong direction for an evidence product: the reader
700
+ * would be left with the numbers and without the reasons to distrust them.
701
+ */
702
+ if (!input.verbose) {
703
+ lines.push(...qualifyingBlocks(input, style, width));
704
+ lines.push(...moreInTheReport(input, style, width));
705
+ lines.push(...trailer());
706
+ return join(lines);
707
+ }
588
708
  lines.push(...gridLines(input, style, width));
589
709
  lines.push('');
590
710
  /*
@@ -711,24 +831,46 @@ export function renderText(input, columns) {
711
831
  for (const group of failingRuleGroups)
712
832
  lines.push(...ruleEntry(group));
713
833
  }
714
- if (review.length > 0) {
834
+ /*
835
+ * TWO sections, because two different things were sharing one word.
836
+ *
837
+ * Friction record run 4 measured what that cost: roughly 273 of Eos's 374
838
+ * needs-review rows were measurements we had not taken rather than judgements
839
+ * a person must make. A reader asked to work through a bucket where most rows
840
+ * cannot be acted on without a colour picker learns to skip the bucket — and
841
+ * the rows they skip include the ones an auditor actually wants.
842
+ *
843
+ * So the judgements come first and keep the section a person is meant to read,
844
+ * and our own debt is stated separately, as a figure, under our own name.
845
+ */
846
+ const judgement = review.filter((group) => group.undecidable !== 'measurement');
847
+ const measurement = review.filter((group) => group.undecidable === 'measurement');
848
+ if (judgement.length > 0) {
715
849
  /*
716
- * A section of its own, not a parenthesis on the failure list.
717
- *
718
- * `incomplete` is axe saying "I could not decide" most often contrast over
719
- * a background image or a gradient, where the ratio is genuinely
720
- * uncomputable from the DOM. These are neither passes nor failures, and
721
- * listing them beside violations invites both of the available mistakes:
722
- * treating them as broken and panicking, or treating them as fine and
723
- * shipping. They are the one category that REQUIRES a person, which is worth
724
- * saying plainly given we sell evidence.
850
+ * A section of its own, not a parenthesis on the failure list. These are
851
+ * neither passes nor failures, and listing them beside violations invites
852
+ * both available mistakes: treating them as broken and panicking, or
853
+ * treating them as fine and shipping.
725
854
  */
726
855
  lines.push('');
727
- lines.push(heading(style, 'Needs review'));
728
- lines.push(...continuationLines(style, 'axe could not decide these by itself usually contrast over an image or a ' +
729
- 'gradient. Not passes and not failures; a person has to look.', width));
856
+ lines.push(heading(style, 'Needs a judgement'));
857
+ lines.push(...continuationLines(style, 'Measured as far as measuring goes, and what is left is not mechanical ' +
858
+ 'does this heading describe what it heads, is this error announced. A person ' +
859
+ 'settles these by looking, with no tools.', width));
860
+ lines.push('');
861
+ for (const group of judgement)
862
+ lines.push(...ruleEntry(group));
863
+ }
864
+ if (measurement.length > 0) {
865
+ const elements = measurement.reduce((sum, group) => sum + group.nodes, 0);
866
+ lines.push('');
867
+ lines.push(heading(style, 'Could not measure'));
868
+ lines.push(...continuationLines(style, `${elements} element${elements === 1 ? '' : 's'} where we did not get a number — a ` +
869
+ 'gradient with no single colour behind it, a value we could not resolve. ' +
870
+ 'This is our debt rather than your work, and it is here so it can be seen ' +
871
+ 'falling rather than sitting inside a bucket labelled as yours.', width));
730
872
  lines.push('');
731
- for (const group of review)
873
+ for (const group of measurement)
732
874
  lines.push(...ruleEntry(group));
733
875
  }
734
876
  /*
@@ -755,6 +897,52 @@ export function renderText(input, columns) {
755
897
  lines.push(...trailer());
756
898
  return join(lines);
757
899
  }
900
+ /**
901
+ * The blocks that qualify the figures, and print above the cut as well as below.
902
+ *
903
+ * Deliberately a short list rather than "the insight blocks". `Could not be
904
+ * checked` is the surfaces the run reached and could not scan, and `Worth
905
+ * knowing` carries the scans the settle ceiling cut short — both are reasons to
906
+ * trust the numbers above LESS, and a concise summary that dropped them would be
907
+ * more confident than the full one. `Page runtime`, `Across surfaces`,
908
+ * `Coverage` and `Suite timing` are additional findings rather than
909
+ * qualifications, so they wait for the report or `--verbose`.
910
+ */
911
+ const QUALIFYING = new Set(['Could not be checked', 'Worth knowing']);
912
+ function qualifyingBlocks(input, style, width) {
913
+ const lines = [];
914
+ for (const block of insightBlocks(input.envelope, {
915
+ ...(input.declaredAxes ? { declaredAxes: input.declaredAxes } : {}),
916
+ ...(input.unmeasuredAxes ? { unmeasuredAxes: input.unmeasuredAxes } : {}),
917
+ ...(input.suite ? { suite: input.suite } : {}),
918
+ ...(input.overhead ? { overhead: input.overhead } : {}),
919
+ })) {
920
+ if (!QUALIFYING.has(block.heading))
921
+ continue;
922
+ lines.push(...figureBlock(style, block.heading, block.figures, block.notes, width));
923
+ }
924
+ return lines;
925
+ }
926
+ /**
927
+ * One line naming what was left out, and how to see it.
928
+ *
929
+ * "See the report" on its own is a dead end — a reader cannot tell whether the
930
+ * thing they want is in there. So the sections are named, and so is the way to
931
+ * get them back in a terminal, because a CI log is a place where scrollback IS
932
+ * the artifact and there is no report to open.
933
+ */
934
+ function moreInTheReport(input, style, width) {
935
+ const count = totals(input.envelope);
936
+ const rules = new Set(input.envelope.scans.flatMap((scan) => scan.findings.filter((f) => f.status === 'violation').map((f) => f.ruleId))).size;
937
+ if (rules === 0 && count.incomplete === 0)
938
+ return [];
939
+ return [
940
+ '',
941
+ ...continuationLines(style, `Which rules, on which surfaces, with the selectors and the criteria grid: in the ` +
942
+ `report below. SETSQUARE_VERBOSE=1 prints all of it here instead, for a CI log with ` +
943
+ `no report to open.`, width),
944
+ ];
945
+ }
758
946
  /**
759
947
  * Join the assembled lines, collapsing any run of blank lines to one.
760
948
  *
@@ -996,7 +1184,19 @@ function conformanceLines(input, style, width) {
996
1184
  lines.push(figureLine(style, rulesPassed, 'criteria: every automated rule passed — evidence, not conformance'));
997
1185
  }
998
1186
  if (cantTell > 0) {
999
- lines.push(figureLine(style, cantTell, 'criteria need a person — automation could not decide'));
1187
+ /*
1188
+ * A criterion, not a row — so it stays one state.
1189
+ *
1190
+ * A cell rolls up many findings and can hold both kinds at once, and there is
1191
+ * no defensible precedence between them: a criterion with one unmeasured row
1192
+ * and one genuine judgement is not more one than the other. "Automation could
1193
+ * not decide" is true of the cell whichever it holds, and the two sections
1194
+ * above carry the why at the level where a row is the unit.
1195
+ *
1196
+ * The old wording — "criteria need a person" — was the one that stopped being
1197
+ * true, because a cell can now be undecided entirely because of our debt.
1198
+ */
1199
+ lines.push(figureLine(style, cantTell, 'criteria automation could not decide — see the two sections above'));
1000
1200
  }
1001
1201
  if (inapplicable > 0) {
1002
1202
  // EARL's inapplicable, spelled out: the checks RAN and the pages contain
@@ -1159,7 +1359,7 @@ function gridLines(input, style, width) {
1159
1359
  lines.push((indent + label + cells).trimEnd());
1160
1360
  }
1161
1361
  lines.push('');
1162
- lines.push(...continuationLines(style, `${glyph.failed} failed ${glyph.cantTell} needs a person ` +
1362
+ lines.push(...continuationLines(style, `${glyph.failed} failed ${glyph.cantTell} automation could not decide ` +
1163
1363
  `${glyph.rulesPassed} every automated rule passed ` +
1164
1364
  `${glyph.inapplicable} nothing to check here ` +
1165
1365
  `${glyph.assertedBySuite} covered by your own test ` +
@@ -1198,9 +1398,28 @@ function warningLines(warnings, width, quality, envelope) {
1198
1398
  * dying in the envelope.
1199
1399
  */
1200
1400
  if (quality && quality.ceiling > 0) {
1201
- notes.push(`${quality.ceiling} scan${quality.ceiling === 1 ? ' was' : 's were'} cut short after ` +
1202
- 'waiting 5s for the page to settle, so they describe a page that was still ' +
1203
- 'changing. Treat a clean result from those as weaker evidence.');
1401
+ /*
1402
+ * Say WHOSE window it was, and WHAT the page was doing in it.
1403
+ *
1404
+ * The old sentence said "after waiting 5s", which is the default — a suite
1405
+ * that raised `settleTimeoutMs` was quoted a number it had changed. And it
1406
+ * described "a page that was still changing" without the one figure that
1407
+ * tells a reader which of the two available actions is theirs: a page a few
1408
+ * mutations from quiet settles if given longer, and a page with a poller
1409
+ * never will, and no timeout helps the second.
1410
+ *
1411
+ * Both numbers are measured off the scans themselves rather than read from
1412
+ * configuration, which also means they stay true if the window is changed
1413
+ * per project.
1414
+ */
1415
+ const waited = `${(quality.ceilingWaitedMs / 1000).toFixed(1)}s`;
1416
+ notes.push(`${quality.ceiling} scan${quality.ceiling === 1 ? ' was' : 's were'} cut short by our ` +
1417
+ `settle window — the longest waited ${waited} and the page was still changing, with ` +
1418
+ `${quality.ceilingMutations.toLocaleString('en-GB')} DOM mutation` +
1419
+ `${quality.ceilingMutations === 1 ? '' : 's'} counted since it loaded. Treat a clean ` +
1420
+ 'result from those as weaker evidence. If the page settles and simply needs longer, ' +
1421
+ 'raise `settleTimeoutMs`; if something polls or animates forever, no window will help ' +
1422
+ 'and the scan is of a moving page by nature.');
1204
1423
  }
1205
1424
  if (warnings.length === 0 && notes.length === 0)
1206
1425
  return [];
@@ -1232,22 +1451,29 @@ function warningLines(warnings, width, quality, envelope) {
1232
1451
  * only things the free layer structurally cannot do. Nothing is removed from the
1233
1452
  * local tool to create the paid one, and this block must never imply otherwise.
1234
1453
  */
1235
- function promoLines(input, count, width) {
1236
- if (!input.promo)
1237
- return [];
1454
+ /**
1455
+ * What this run is, and what it is not, in one line.
1456
+ *
1457
+ * Derived from the run's own numbers, which is the only thing that licensed the
1458
+ * block it replaces. No product name and no URL: the reader is running the tool,
1459
+ * so they know where it comes from, and a limit stated as a limit does not need
1460
+ * a destination attached to be useful.
1461
+ *
1462
+ * Not switchable. "Local runs keep no history, baselines or comparison" is a
1463
+ * fact about the artefact in front of them — the dev plan holds it as a
1464
+ * permanent property of the free layer rather than a gap — so it belongs beside
1465
+ * the figures whether or not anyone would prefer it quiet.
1466
+ */
1467
+ function historyCaveat(input, style, width) {
1468
+ const count = totals(input.envelope);
1238
1469
  const failingRules = new Set(input.envelope.scans.flatMap((scan) => scan.findings.filter((f) => f.status === 'violation').map((f) => f.ruleId))).size;
1239
1470
  const subject = failingRules > 0
1240
1471
  ? `${failingRules} rule${failingRules === 1 ? '' : 's'} failing across ${count.surfaces} surface${count.surfaces === 1 ? '' : 's'}`
1241
1472
  : `${count.surfaces} clean surface${count.surfaces === 1 ? '' : 's'}`;
1242
1473
  return [
1243
1474
  '',
1244
- // Wrapped like everything else: a block that overflows the terminal is a
1245
- // block that looks broken, and this one is read on every single run.
1246
- ...wrap(`${subject}, this run only. Setsquare Cloud adds what a local tool cannot:`, width - 4, '').map((line) => ` ${line}`),
1247
- ' · is this better or worse than last week? (local runs keep no history)',
1248
- ' · which change introduced each one, on the PR that did it',
1249
- ' · the root cause behind repeats, grouped instead of repeated',
1250
- ` ${PLATFORM_URL}`,
1475
+ ...continuationLines(style, `${subject}, this run only local runs keep no history, no baselines and no ` +
1476
+ 'comparison with a previous run, by design.', width),
1251
1477
  '',
1252
1478
  ];
1253
1479
  }