canary-test-cli 6.5.0 → 6.7.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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * GitHub REST pagination for the guardian's read paths (#528).
3
+ *
4
+ * Both guardian clients used to call GitHub's list endpoints bare, which caps
5
+ * silently at the API default of 30 rows. That is the worst shape a denominator
6
+ * can take: a zero would look wrong, but "30 of 30" reads as a complete sample.
7
+ * A PR past 30 comments hid the sticky comment; a sticky past 30 reactions
8
+ * biased the precision tally toward whichever verdicts sorted first.
9
+ *
10
+ * The loop lives here, behind an injected {@link PageReader}, so it is unit
11
+ * tested while the network stays quarantined in the clients that construct the
12
+ * real reader.
13
+ */
14
+ /** GitHub's maximum page size for list endpoints. */
15
+ export const DEFAULT_PER_PAGE = 100;
16
+ /**
17
+ * Page ceiling ({@link DEFAULT_PER_PAGE} * this = 2000 rows). Crossing it
18
+ * throws rather than returning what was read so far: a partial list that the
19
+ * caller cannot distinguish from a complete one is the defect this module
20
+ * exists to remove, and rebuilding it inside the fix would be worse than the
21
+ * original.
22
+ */
23
+ export const MAX_PAGES = 20;
24
+ /**
25
+ * `<url>; rel="next"` entries in a `Link` header. The angle brackets are
26
+ * required — a header with a bare `rel="next"` and no URL yields no match, so
27
+ * a malformed header degrades to "no next page" instead of a guessed URL.
28
+ */
29
+ const LINK_ENTRY = /<([^>]+)>\s*;\s*rel\s*=\s*"?([a-zA-Z]+)"?/g;
30
+ /** The `rel="next"` URL from a `Link` header, or null if there is no next. */
31
+ export function parseNextLink(header) {
32
+ if (header === null || header.trim() === '')
33
+ return null;
34
+ for (const match of header.matchAll(LINK_ENTRY)) {
35
+ if (match[2].toLowerCase() !== 'next')
36
+ continue;
37
+ const url = match[1].trim();
38
+ // Only absolute http(s) — never relay a relative or garbage target.
39
+ return /^https?:\/\//i.test(url) ? url : null;
40
+ }
41
+ return null;
42
+ }
43
+ /**
44
+ * Request the largest page GitHub will serve. An explicit `per_page` already on
45
+ * the URL wins, so a caller can still ask for a small page deliberately.
46
+ */
47
+ export function withPerPage(url, perPage = DEFAULT_PER_PAGE) {
48
+ const parsed = new URL(url);
49
+ if (!parsed.searchParams.has('per_page')) {
50
+ parsed.searchParams.set('per_page', String(perPage));
51
+ }
52
+ return parsed.toString();
53
+ }
54
+ /**
55
+ * Read every page from `startUrl`, following `Link: rel="next"`.
56
+ *
57
+ * Throws on a `Link` cycle or past {@link MAX_PAGES}; a non-array body counts
58
+ * as zero rows (matching the clients' prior defensive shape for an error
59
+ * payload). Never returns a short list quietly.
60
+ */
61
+ export async function readAllPages(startUrl, read) {
62
+ const rows = [];
63
+ const visited = new Set();
64
+ let url = withPerPage(startUrl);
65
+ let pages = 0;
66
+ while (url !== null) {
67
+ if (visited.has(url)) {
68
+ throw new Error(`GitHub paging cycle: ${url} was already read`);
69
+ }
70
+ visited.add(url);
71
+ pages += 1;
72
+ if (pages > MAX_PAGES) {
73
+ throw new Error(`GitHub paging exceeded ${MAX_PAGES} pages (${MAX_PAGES * DEFAULT_PER_PAGE}+ rows) ` +
74
+ `starting at ${startUrl} -- refusing to report a truncated read`);
75
+ }
76
+ const page = await read(url);
77
+ if (Array.isArray(page.body))
78
+ rows.push(...page.body);
79
+ url = parseNextLink(page.linkHeader);
80
+ }
81
+ return rows;
82
+ }
83
+ /**
84
+ * A {@link PageReader} over `fetch`. The ONLY place in this module that touches
85
+ * the network; `onError` lets each client keep its own status mapping (the
86
+ * comment client distinguishes 403 as a permission error, the reactions client
87
+ * does not).
88
+ */
89
+ export function restPageReader(headers, onError) {
90
+ return async (url) => {
91
+ const resp = await fetch(url, { method: 'GET', headers });
92
+ if (!resp.ok)
93
+ throw onError(resp.status, url);
94
+ return { body: await resp.json(), linkHeader: resp.headers.get('link') };
95
+ };
96
+ }
97
+ //# sourceMappingURL=github-paging.js.map
@@ -28,7 +28,7 @@ import { readFileSync } from 'node:fs';
28
28
  import { extname, join } from 'node:path';
29
29
  import { readJsonWithWarning } from '../core/config-validation.js';
30
30
  import { isAssertionFreeTest } from '../core/quality-scorer.js';
31
- import { Fidelity, isSourcePath, isTestPath, } from './coverage.js';
31
+ import { Fidelity, coverageDegradedNotice, coverageStatus, isSourcePath, isTestPath, isTestSupportPath, isTypeOnlyModule, } from './coverage.js';
32
32
  import { Severity, severitySortKey } from './impact-mapper.js';
33
33
  const HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
34
34
  // Suppression annotation: `// canary:allow-untested <reason>` or the `#`
@@ -359,6 +359,58 @@ export function filterTestUnits(units) {
359
359
  }
360
360
  return [kept, testUnits];
361
361
  }
362
+ /**
363
+ * Partition `units` into `[kept, supportUnits]` by test-support name (#565).
364
+ *
365
+ * The sibling of {@link filterTestUnits} for files that are test infrastructure
366
+ * by *filename idiom* rather than by test-path convention — a pytest
367
+ * `conftest`, a Playwright fixture module. See {@link isTestSupportPath} for
368
+ * why the match is component-scoped and why it stays out of `isTestPath`.
369
+ *
370
+ * Runs before coverage is resolved, so the suppression holds at **every**
371
+ * fidelity tier — matching where the existing `fixtures/` convention already
372
+ * sits in {@link DEFAULT_SKIP_GLOBS}. An lcov row proving a fixture's lines are
373
+ * uncovered is true and still cannot make "this needs a test" satisfiable.
374
+ *
375
+ * Partitioned separately from `testUnits` rather than folded into it: those
376
+ * feed {@link buildWeakTestFindings}, and a conftest asserts nothing by design,
377
+ * so folding would swap one bogus finding for another. Order-preserving in both.
378
+ */
379
+ export function filterTestSupportUnits(units) {
380
+ const kept = [];
381
+ const supportUnits = [];
382
+ for (const unit of units) {
383
+ if (isTestSupportPath(unit.path))
384
+ supportUnits.push(unit);
385
+ else
386
+ kept.push(unit);
387
+ }
388
+ return [kept, supportUnits];
389
+ }
390
+ /**
391
+ * Partition `units` into `[kept, typeOnlyUnits]` by type-only content (#562).
392
+ *
393
+ * The sibling of {@link filterTestSupportUnits} for modules that contain no
394
+ * runtime code at all. Runs pre-resolution for the same reason: a type
395
+ * declaration has no runtime existence at ANY tier, so a suppression scoped to
396
+ * one tier would leave the measured false positives in place — which is
397
+ * precisely how #413 missed this class. See {@link isTypeOnlyModule} for why
398
+ * content, not the filename, is the evidence.
399
+ *
400
+ * `repoRoot` is needed because the decision requires reading the file; a unit
401
+ * whose file cannot be read keeps its finding. Order-preserving in both.
402
+ */
403
+ export function filterTypeOnlyUnits(units, repoRoot) {
404
+ const kept = [];
405
+ const typeOnlyUnits = [];
406
+ for (const unit of units) {
407
+ if (isTypeOnlyModule(unit.path, repoRoot))
408
+ typeOnlyUnits.push(unit);
409
+ else
410
+ kept.push(unit);
411
+ }
412
+ return [kept, typeOnlyUnits];
413
+ }
362
414
  /**
363
415
  * Default glob layer over the {@link isSourcePath} extension floor (#413).
364
416
  *
@@ -433,6 +485,14 @@ export class Finding {
433
485
  // the changed lines (FIX 1). Empty → suppression falls back to a whole-file
434
486
  // scan (still comment-leader gated).
435
487
  added_ranges;
488
+ // The specific 1-based line numbers the coverage run proved unhit.
489
+ //
490
+ // Only the COVERAGE_VERIFIED tier can supply these — the graph and heuristic
491
+ // tiers answer "is this reached at all?", not "which lines ran" — so an empty
492
+ // array means "this tier does not know", NEVER "nothing is uncovered". The
493
+ // renderer must therefore omit the detail rather than print an empty list,
494
+ // which would read as a measurement that came back clean.
495
+ uncovered_lines;
436
496
  constructor(init) {
437
497
  this.path = init.path;
438
498
  this.unit = init.unit;
@@ -444,17 +504,113 @@ export class Finding {
444
504
  this.suppressed = init.suppressed ?? false;
445
505
  this.suppression_reason = init.suppression_reason ?? null;
446
506
  this.added_ranges = init.added_ranges ?? [];
507
+ this.uncovered_lines = init.uncovered_lines ?? [];
447
508
  }
448
509
  }
510
+ /** Basename minus its extension — the token a heuristic test-file match uses. */
511
+ function pathStem(path) {
512
+ const base = path.split('/').pop() ?? path;
513
+ const dot = base.indexOf('.');
514
+ return dot > 0 ? base.slice(0, dot) : base;
515
+ }
516
+ /**
517
+ * Render ranges as `44-49, 52-56, 58`, capped at `max` with a `+N more` tail.
518
+ *
519
+ * The cap is a budget guard, not cosmetics: a file with hundreds of scattered
520
+ * uncovered lines would otherwise produce a single table cell long enough to
521
+ * push other findings out of the comment entirely (#457).
522
+ */
523
+ function rangeList(ranges, max = 6) {
524
+ const shown = ranges
525
+ .slice(0, max)
526
+ .map(([start, end]) => (start === end ? `${start}` : `${start}-${end}`));
527
+ const rest = ranges.length - shown.length;
528
+ return rest > 0 ? `${shown.join(', ')} +${rest} more` : shown.join(', ');
529
+ }
530
+ /**
531
+ * The deterministic next action for a finding.
532
+ *
533
+ * Every branch states only what its tier actually established, because a
534
+ * suggestion that guesses (naming a test file that does not exist, say) is
535
+ * worse than none: it sends the reader somewhere before they distrust it. The
536
+ * coverage tier can name lines; the graph tier can name a symbol; the heuristic
537
+ * tier knows only that no filename matched, and says exactly that.
538
+ */
539
+ function suggestionFor(path, unit, fidelity, uncovered) {
540
+ const symbol = unit && unit !== path ? unit : pathStem(path);
541
+ if (fidelity === Fidelity.CoverageVerified && uncovered.length > 0) {
542
+ // The path is deliberately not repeated: the record carries it as a
543
+ // sibling field, and the comment already shows it in the File column.
544
+ return `extend a test to execute lines ${rangeList(mergeLines(uncovered))}.`;
545
+ }
546
+ if (fidelity === Fidelity.GraphVerified) {
547
+ return `add a test that calls \`${symbol}\`, directly or through a caller.`;
548
+ }
549
+ if (fidelity === Fidelity.Heuristic) {
550
+ return `no test file mentions \`${pathStem(path)}\` — name a test after it, or reference it from an existing test.`;
551
+ }
552
+ return `add a test covering \`${symbol}\`.`;
553
+ }
554
+ // Thresholds for the coverage-verified severity split (#553). Volume is a line
555
+ // count; share is the fraction of the unit's ADDED lines that came back unhit.
556
+ //
557
+ // The pair matters more than either number: volume alone ranks a 30-line
558
+ // function that is 10% uncovered above a 3-line function nothing executes, and
559
+ // share alone ranks a one-line addition alongside a 132-line one.
560
+ const CRITICAL_UNCOVERED_LINES = 20;
561
+ const CRITICAL_UNCOVERED_SHARE = 0.8;
562
+ const HIGH_UNCOVERED_LINES = 5;
563
+ const HIGH_UNCOVERED_SHARE = 0.5;
564
+ /** Total lines a diff added for a unit, summed over its inclusive ranges. */
565
+ function addedLineCount(ranges) {
566
+ return ranges.reduce((total, [start, end]) => total + (end - start + 1), 0);
567
+ }
568
+ /**
569
+ * Severity for an uncovered **coverage-verified** result (#553).
570
+ *
571
+ * Only this tier can be graded, because only this tier knows *which* lines ran
572
+ * (see the `uncovered_lines` comment on {@link Finding}). The grade combines
573
+ * how much is unhit with how much of the change that represents:
574
+ *
575
+ * - `CRITICAL` — a large block (>= 20 lines) that is essentially untouched
576
+ * (>= 80% unhit). Nothing executes it; this is what a gate should stop.
577
+ * - `HIGH` — a meaningful volume (>= 5 lines) *or* a concentrated gap
578
+ * (>= 50% unhit).
579
+ * - `MEDIUM` — a handful of lines inside a change that is otherwise tested.
580
+ * Real, still reported, not worth blocking a PR over.
581
+ *
582
+ * Both unknowns escalate rather than downgrade. No line detail means the tier
583
+ * could not say which lines were unhit, not that few were; no added-line count
584
+ * means the share denominator is unknown. An absent measurement must never read
585
+ * as a low score (ADR 0010) — that is the same silent-abstention shape this
586
+ * whole module exists to avoid.
587
+ */
588
+ function coverageVerifiedSeverity(result) {
589
+ const uncovered = result.uncovered_lines?.length ?? 0;
590
+ if (uncovered === 0)
591
+ return Severity.HIGH;
592
+ const added = addedLineCount(result.unit.added_ranges);
593
+ const share = added > 0 ? uncovered / added : 1;
594
+ if (uncovered >= CRITICAL_UNCOVERED_LINES &&
595
+ share >= CRITICAL_UNCOVERED_SHARE)
596
+ return Severity.CRITICAL;
597
+ if (uncovered >= HIGH_UNCOVERED_LINES || share >= HIGH_UNCOVERED_SHARE)
598
+ return Severity.HIGH;
599
+ return Severity.MEDIUM;
600
+ }
449
601
  /**
450
602
  * Turn uncovered coverage results into fidelity-labeled findings.
451
603
  *
452
- * Only **uncovered** results become findings. Severity policy (Phase 1):
604
+ * Only **uncovered** results become findings. Severity policy:
453
605
  *
454
- * - `COVERAGE_VERIFIED` uncovered → `HIGH`
606
+ * - `COVERAGE_VERIFIED` uncovered → graded by
607
+ * {@link coverageVerifiedSeverity} (`CRITICAL` / `HIGH` / `MEDIUM`)
455
608
  * - `GRAPH_VERIFIED` uncovered → `HIGH`
456
609
  * - `HEURISTIC` uncovered → `MEDIUM` (lower confidence)
457
610
  *
611
+ * The graph and heuristic tiers stay flat on purpose: neither can measure how
612
+ * much of a unit is unhit, so any spread across them would be invented.
613
+ *
458
614
  * Results are sorted by severity sort-key (critical → low).
459
615
  */
460
616
  export function buildFindings(results) {
@@ -462,15 +618,24 @@ export function buildFindings(results) {
462
618
  for (const result of results) {
463
619
  if (result.covered)
464
620
  continue;
465
- const severity = result.fidelity === Fidelity.Heuristic ? Severity.MEDIUM : Severity.HIGH;
621
+ let severity;
622
+ if (result.fidelity === Fidelity.CoverageVerified)
623
+ severity = coverageVerifiedSeverity(result);
624
+ else if (result.fidelity === Fidelity.Heuristic)
625
+ severity = Severity.MEDIUM;
626
+ else
627
+ severity = Severity.HIGH;
466
628
  const unit = result.unit;
629
+ const uncovered = [...(result.uncovered_lines ?? [])];
467
630
  findings.push(new Finding({
468
631
  path: unit.path,
469
632
  unit: unit.symbol || unit.path,
470
633
  fidelity: result.fidelity,
471
634
  severity,
472
635
  evidence: result.evidence,
636
+ suggestion: suggestionFor(unit.path, unit.symbol || unit.path, result.fidelity, uncovered),
473
637
  added_ranges: [...unit.added_ranges],
638
+ uncovered_lines: uncovered,
474
639
  }));
475
640
  }
476
641
  return [...findings].sort((a, b) => severitySortKey(a.severity) - severitySortKey(b.severity));
@@ -551,6 +716,7 @@ export function buildWeakTestFindings(testUnits, diffText) {
551
716
  fidelity: Fidelity.Heuristic,
552
717
  severity: Severity.LOW,
553
718
  evidence: 'added test asserts nothing (advisory — never blocks the gate)',
719
+ suggestion: 'add at least one assertion, or delete the test if it is a placeholder.',
554
720
  added_ranges: [...unit.added_ranges],
555
721
  }));
556
722
  }
@@ -694,13 +860,21 @@ function overflowNote(omitted) {
694
860
  }
695
861
  const EM_DASH = '\u{2014}';
696
862
  const RED_CIRCLE = '\u{1F534}';
863
+ const ORANGE_CIRCLE = '\u{1F7E0}';
697
864
  const YELLOW_CIRCLE = '\u{1F7E1}';
698
865
  const WHITE_CIRCLE = '\u{26AA}';
699
866
  const BABY_CHICK = '\u{1F424}';
867
+ const ARROW = '\u{2192}';
700
868
  const WHITE_CHECK = '\u{2705}';
869
+ const WARNING = '\u{26A0}\u{FE0F}';
870
+ // `CRITICAL` and `HIGH` shared the red circle for as long as `CRITICAL` was
871
+ // unreachable (#553) — a collision with nothing to collide with. Now that the
872
+ // two are distinguishable, the icon column has to distinguish them, or the
873
+ // ranking exists only in the text of a cell nobody scans. Matches
874
+ // `summary-emitter.ts`, which has always used the four-color scale.
701
875
  const SEVERITY_ICON = {
702
876
  [Severity.CRITICAL]: RED_CIRCLE,
703
- [Severity.HIGH]: RED_CIRCLE,
877
+ [Severity.HIGH]: ORANGE_CIRCLE,
704
878
  [Severity.MEDIUM]: YELLOW_CIRCLE,
705
879
  [Severity.LOW]: WHITE_CIRCLE,
706
880
  };
@@ -727,53 +901,134 @@ function findingDict(finding) {
727
901
  suggestion: finding.suggestion,
728
902
  suppressed: finding.suppressed,
729
903
  suppression_reason: finding.suppression_reason,
904
+ uncovered_lines: finding.uncovered_lines,
730
905
  };
731
906
  }
732
- export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta = null) {
907
+ /**
908
+ * Join every degradation notice this run produced into one line, dropping the
909
+ * empty ones. Notices are independent (the agent tier and the coverage input
910
+ * degrade for unrelated reasons), so a reader must see both or neither (#554).
911
+ */
912
+ export function combineNotices(...notices) {
913
+ const kept = notices.filter((n) => Boolean(n));
914
+ return kept.length > 0 ? kept.join('; ') : null;
915
+ }
916
+ /** The `coverage` block the json/analysis surfaces carry (#554). */
917
+ export function coverageBlock(state) {
918
+ return { status: coverageStatus(state), ...state };
919
+ }
920
+ /**
921
+ * The comment body for a run with zero active findings.
922
+ *
923
+ * #554: the ✅ all-clear headline is reserved for a run whose coverage report
924
+ * spoke to every changed file. Anything less says so in the body — a `<sub>`
925
+ * footer under a green headline is read as boilerplate, and this is the exact
926
+ * shape that let 43 coverage-blind PRs read as covered.
927
+ */
928
+ function noGapsLines(coverageState, suppressedCount) {
929
+ const notice = coverageState ? coverageDegradedNotice(coverageState) : null;
930
+ const headline = notice
931
+ ? `${WARNING} no gaps found, but coverage was ${coverageStatus(coverageState)}`
932
+ : `${WHITE_CHECK} no test-coverage gaps`;
933
+ const lines = [`## ${BABY_CHICK} Canary PR Guardian ${EM_DASH} ${headline}`];
934
+ if (notice) {
935
+ lines.push(`> **${notice}**`, '', 'Zero files matched is an abstention, not a pass — nothing here is ' +
936
+ 'evidence that the changed lines are covered.');
937
+ }
938
+ if (suppressedCount) {
939
+ lines.push(`_${suppressedCount} finding(s) suppressed as intentional._`);
940
+ }
941
+ return lines;
942
+ }
943
+ export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta = null, blobBase = null) {
733
944
  const ordered = [...findings].sort((a, b) => severitySortKey(a.severity) - severitySortKey(b.severity));
945
+ // #554: the coverage ladder's own degradation, stated alongside the tier's.
946
+ const coverageState = gateMeta?.coverage ?? null;
947
+ const coverageNotice = coverageState
948
+ ? coverageDegradedNotice(coverageState)
949
+ : null;
950
+ const notice = combineNotices(degradedNotice, coverageNotice);
734
951
  if (fmt === 'json') {
735
952
  const payload = {
736
953
  findings: ordered.map(findingDict),
737
954
  tier,
738
955
  };
739
- if (degradedNotice)
740
- payload['degraded_notice'] = degradedNotice;
956
+ if (notice)
957
+ payload['degraded_notice'] = notice;
741
958
  if (gateMeta !== null) {
742
959
  payload['checked'] = gateMeta.checked;
743
960
  payload['abstained'] = gateMeta.abstained;
961
+ if (coverageState)
962
+ payload['coverage'] = coverageBlock(coverageState);
744
963
  }
745
964
  return ensureAscii(JSON.stringify(payload, null, 2));
746
965
  }
747
966
  const active = ordered.filter((f) => !f.suppressed);
748
967
  const suppressed = ordered.filter((f) => f.suppressed);
749
- // A finding's file label shows the path once, appending the unit only when
750
- // it is a distinct symbol within the file (never `path (path)`).
751
- const fileLabel = (f) => f.unit && f.unit !== f.path
752
- ? `\`${f.path}\` → \`${f.unit}\``
753
- : `\`${f.path}\``;
754
968
  const cell = (s) => s.replace(/\|/g, '\\|');
969
+ // The line range a permalink should open at: the first range the coverage run
970
+ // proved unhit, else the first range the diff added. Empty when neither is
971
+ // known, so the link degrades to the file rather than to a wrong line.
972
+ const linkAnchor = (f) => {
973
+ const ranges = f.uncovered_lines.length
974
+ ? mergeLines(f.uncovered_lines)
975
+ : f.added_ranges;
976
+ const first = ranges[0];
977
+ if (!first)
978
+ return '';
979
+ return first[0] === first[1]
980
+ ? `#L${first[0]}`
981
+ : `#L${first[0]}-L${first[1]}`;
982
+ };
983
+ // A finding's file label shows the path once, appending the unit only when
984
+ // it is a distinct symbol within the file (never `path (path)`). The path
985
+ // becomes a permalink when a blob base is resolvable; with no base it stays
986
+ // plain code text, because a dead link reads as actionable and is not.
987
+ // Parentheses must be percent-encoded inside a markdown link target: a bare
988
+ // `)` closes the link early, so a Next.js route group (`app/(marketing)/…`)
989
+ // or any parenthesized directory would render as broken markup.
990
+ const urlPath = (path) => path.replace(/\(/g, '%28').replace(/\)/g, '%29');
991
+ const fileLabel = (f) => {
992
+ const shown = `\`${f.path}\``;
993
+ const linked = blobBase
994
+ ? `[${shown}](${blobBase}/${urlPath(f.path)}${linkAnchor(f)})`
995
+ : shown;
996
+ return f.unit && f.unit !== f.path ? `${linked} → \`${f.unit}\`` : linked;
997
+ };
998
+ // Evidence, then the suggested action on its own line. The specific uncovered
999
+ // lines live in the suggestion rather than in a second parenthetical, so the
1000
+ // list is stated exactly once. A finding with no suggestion (a hand-built or
1001
+ // pre-existing record) renders evidence alone — never a dangling arrow.
1002
+ const whatCell = (f) => {
1003
+ const action = f.suggestion
1004
+ ? `<br><sub>${ARROW} ${cell(f.suggestion)}</sub>`
1005
+ : '';
1006
+ return `${cell(f.evidence)}${action}`;
1007
+ };
755
1008
  const CONFIDENCE_NOTE = 'Confidence — **coverage-verified**: measured from a real coverage run · ' +
756
1009
  '**graph-verified**: inferred from the call graph · **heuristic**: filename ' +
757
1010
  `guess (lowest). tier ${tier}: deterministic check, no LLM.`;
758
- const footerLine = `<sub>${CONFIDENCE_NOTE}${degradedNotice ? ` ${EM_DASH} ${degradedNotice}` : ''}</sub>`;
1011
+ const footerLine = `<sub>${CONFIDENCE_NOTE}${notice ? ` ${EM_DASH} ${notice}` : ''}</sub>`;
1012
+ // #554: a coverage-blind run must not present as a run that checked and found
1013
+ // nothing. The notice goes in the BODY, not only the footer — a `<sub>` line
1014
+ // under a green headline is read as boilerplate.
1015
+ const coverageLine = coverageNotice ? `> **${coverageNotice}**` : null;
759
1016
  if (fmt === 'comment') {
760
1017
  const fileCount = new Set(active.map((f) => f.path)).size;
761
1018
  const lines = [STICKY_MARKER];
762
1019
  if (active.length === 0) {
763
- lines.push(`## ${BABY_CHICK} Canary PR Guardian ${EM_DASH} ` +
764
- `${WHITE_CHECK} no test-coverage gaps`);
765
- if (suppressed.length) {
766
- lines.push(`_${suppressed.length} finding(s) suppressed as intentional._`);
767
- }
1020
+ lines.push(...noGapsLines(coverageState, suppressed.length));
768
1021
  }
769
1022
  else {
770
1023
  const noun = fileCount === 1 ? 'file needs' : 'files need';
771
1024
  lines.push(`## ${BABY_CHICK} Canary PR Guardian ${EM_DASH} ` +
772
1025
  `${fileCount} ${noun} test coverage`);
773
1026
  lines.push('These lines were changed by this PR but no test exercises them. Add or ' +
774
- 'extend a test that covers them, or reply ' +
775
- '`/guardian suppress <file> <reason>` if they are intentionally untested.');
776
- lines.push('', '| Sev | File | What is uncovered | Confidence |', '| --- | --- | --- | --- |');
1027
+ 'extend a test that covers them, or mark the line ' +
1028
+ '`// canary:allow-untested <reason>` if it is intentionally untested.');
1029
+ if (coverageLine)
1030
+ lines.push('', coverageLine);
1031
+ lines.push('', '| Sev | File | What is uncovered, and what to do | Confidence |', '| --- | --- | --- | --- |');
777
1032
  // #457: fill rows against a character budget instead of emitting all of
778
1033
  // them. `active` is already severity-ordered, so the rows that survive
779
1034
  // are the most severe -- a critical finding is never dropped to make room
@@ -790,7 +1045,7 @@ export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta
790
1045
  let used = lines.join('\n').length;
791
1046
  let shown = 0;
792
1047
  for (const f of active) {
793
- const row = `| ${SEVERITY_ICON[f.severity] ?? ''} ${f.severity} | ${cell(fileLabel(f))} | ${cell(f.evidence)} | ${f.fidelity} |`;
1048
+ const row = `| ${SEVERITY_ICON[f.severity] ?? ''} ${f.severity} | ${cell(fileLabel(f))} | ${whatCell(f)} | ${f.fidelity} |`;
794
1049
  if (used + row.length + 1 + reserve > COMMENT_CHAR_BUDGET)
795
1050
  break;
796
1051
  lines.push(row);
@@ -808,9 +1063,13 @@ export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta
808
1063
  return lines.join('\n');
809
1064
  }
810
1065
  // fmt == "text" (default fallback): plain, no markdown/HTML.
1066
+ const cleanHeadline = coverageNotice
1067
+ ? // #554: same rule as the comment surface — a blind run never claims clean.
1068
+ `Canary PR Guardian — no gaps found, but coverage was ${coverageStatus(coverageState)}`
1069
+ : 'Canary PR Guardian — no test-coverage gaps';
811
1070
  const lines = [
812
1071
  active.length === 0
813
- ? 'Canary PR Guardian — no test-coverage gaps'
1072
+ ? cleanHeadline
814
1073
  : `Canary PR Guardian — ${new Set(active.map((f) => f.path)).size} file(s) need test coverage`,
815
1074
  ];
816
1075
  for (const finding of ordered) {
@@ -819,8 +1078,8 @@ export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta
819
1078
  lines.push(`[${finding.severity}] ${finding.path}${unit} — ${finding.evidence} (${finding.fidelity})${mark}`);
820
1079
  }
821
1080
  let footer = `tier ${tier}: deterministic check, no LLM`;
822
- if (degradedNotice)
823
- footer += ` - ${degradedNotice}`;
1081
+ if (notice)
1082
+ footer += ` - ${notice}`;
824
1083
  lines.push(footer);
825
1084
  return lines.join('\n');
826
1085
  }
@@ -15,8 +15,9 @@
15
15
  * test — **no network**. It can simulate a fork read-only token via
16
16
  * `deny_writes=true` (writes reject with {@link GitHubPermissionError}).
17
17
  * - {@link RestGitHubClient} (Python's private `_RestGitHubClient`) is the thin
18
- * real client. Network lives **only** here and is never exercised in unit
19
- * tests.
18
+ * real client. Network lives **only** here; `guardian-rest-clients.test.ts`
19
+ * drives it through a stubbed global `fetch`, so the URL, headers, error
20
+ * mapping, and #528 pagination are covered without a socket.
20
21
  *
21
22
  * Python→TS nuances:
22
23
  * - **async**: Python's `urllib` client is synchronous; Node's global `fetch`
@@ -30,6 +31,7 @@
30
31
  * rather than off a raised `HTTPError`. As in the oracle, ONLY 403 maps to a
31
32
  * permission error here; any other non-2xx propagates as a generic error.
32
33
  */
34
+ import { readAllPages, restPageReader } from './github-paging.js';
33
35
  // Single source of truth for the sticky-comment marker. `pr_check.render`
34
36
  // emits the identical literal at the head of a `comment`-format body so
35
37
  // `findSticky` can locate the guardian comment for in-place upsert.
@@ -46,6 +48,17 @@ export class GitHubPermissionError extends Error {
46
48
  this.name = 'GitHubPermissionError';
47
49
  }
48
50
  }
51
+ /**
52
+ * Map a non-2xx status to an error. As in the Python reference, ONLY 403 is a
53
+ * permission error; every other non-2xx propagates (the analog of urllib's
54
+ * HTTPError re-raise). Shared by the write path and the paged read path so the
55
+ * two cannot drift.
56
+ */
57
+ function toGitHubError(status, url) {
58
+ return status === 403
59
+ ? new GitHubPermissionError(`GitHub API 403 (read-only token / fork PR?): ${url}`)
60
+ : new Error(`GitHub API ${status}: ${url}`);
61
+ }
49
62
  /**
50
63
  * In-memory {@link GitHubClient} for unit tests — no network.
51
64
  *
@@ -135,8 +148,10 @@ export function degradationAnnotation(notice) {
135
148
  * Thin real {@link GitHubClient} over the GitHub REST API (`fetch`).
136
149
  *
137
150
  * Python's private `_RestGitHubClient`, exported here (public, like
138
- * {@link RestBranchProtectionClient}). Network lives ONLY here; **no unit test
139
- * exercises this class**. A 403 (fork read-only token) surfaces as
151
+ * {@link RestBranchProtectionClient}). Network lives ONLY in `request` and in
152
+ * the default {@link restPageReader}; the write paths have no unit test by
153
+ * design, while the paged read path is covered through the injected `read`
154
+ * seam (#528). A 403 (fork read-only token) surfaces as
140
155
  * {@link GitHubPermissionError} so the caller degrades loudly rather than
141
156
  * crashing.
142
157
  *
@@ -148,10 +163,16 @@ export class RestGitHubClient {
148
163
  prNumber;
149
164
  token;
150
165
  static API = 'https://api.github.com';
151
- constructor(repo, prNumber, token) {
166
+ read;
167
+ constructor(repo, prNumber, token, read) {
152
168
  this.repo = repo;
153
169
  this.prNumber = prNumber;
154
170
  this.token = token;
171
+ // The `read` seam exists so #528's paging is testable without a socket;
172
+ // production callers pass three arguments and get the real reader. It
173
+ // shares `request`'s 403 mapping so a fork PR degrades identically on a
174
+ // paged read and an unpaged write.
175
+ this.read = read ?? restPageReader(this.headers(), toGitHubError);
155
176
  }
156
177
  headers() {
157
178
  return {
@@ -168,20 +189,13 @@ export class RestGitHubClient {
168
189
  init.body = JSON.stringify(payload);
169
190
  }
170
191
  const resp = await fetch(url, init);
171
- if (!resp.ok) {
172
- // As in the oracle, ONLY 403 maps to a permission error; any other
173
- // non-2xx propagates (the analog of urllib's HTTPError re-raise).
174
- if (resp.status === 403) {
175
- throw new GitHubPermissionError(`GitHub API 403 (read-only token / fork PR?): ${url}`);
176
- }
177
- throw new Error(`GitHub API ${resp.status}: ${url}`);
178
- }
192
+ if (!resp.ok)
193
+ throw toGitHubError(resp.status, url);
179
194
  return resp.json();
180
195
  }
181
196
  async listComments() {
182
197
  const url = `${RestGitHubClient.API}/repos/${this.repo}/issues/${this.prNumber}/comments`;
183
- const result = await this.request('GET', url);
184
- return Array.isArray(result) ? result : [];
198
+ return (await readAllPages(url, this.read));
185
199
  }
186
200
  async createComment(body) {
187
201
  const url = `${RestGitHubClient.API}/repos/${this.repo}/issues/${this.prNumber}/comments`;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The async history-store contract.
3
+ *
4
+ * Extracted from `store.ts` to break a circular dependency (#543): the store
5
+ * factory has to import every concrete backend it can return, while each
6
+ * backend has to import the interface it implements. Both now point at this
7
+ * leaf and neither points at the other. `store.ts` re-exports the type, so
8
+ * existing importers are unaffected.
9
+ *
10
+ * The dependency was type-only, and type-only imports are erased before
11
+ * anything runs — so this was never a runtime hazard. It is fixed anyway
12
+ * because `harness check-deps` counts it, and a cycle the gate reports is a
13
+ * cycle whatever the emitted JavaScript does.
14
+ *
15
+ * The contract itself is async (unlike the synchronous Python `HistoryStore`
16
+ * ABC) because `@supabase/supabase-js` is Promise-based; see `store.ts` for the
17
+ * full boundary note.
18
+ */
19
+ export {};
20
+ //# sourceMappingURL=async-store.js.map