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.
- package/bin/canary.js +69 -1
- package/dist/doctor-manifest.js +6 -1
- package/dist/doctor.js +7 -4
- package/dist/engine/cli-commands.js +84 -25
- package/dist/engine/cli.core.js +1 -1
- package/dist/engine/core/framework-probes.js +218 -0
- package/dist/engine/core/framework-registry.js +4 -1
- package/dist/engine/core/fs-glob.js +185 -0
- package/dist/engine/core/gate-result.js +27 -4
- package/dist/engine/core/migrator.js +240 -289
- package/dist/engine/core/scaffold-templates.js +123 -0
- package/dist/engine/core/scaffolder.js +4 -105
- package/dist/engine/core/static-linter.js +169 -17
- package/dist/engine/core/workspace-detect.js +0 -0
- package/dist/engine/guardian/adjudication.js +34 -30
- package/dist/engine/guardian/analysis-emit.js +13 -4
- package/dist/engine/guardian/cli.js +113 -16
- package/dist/engine/guardian/coverage.js +291 -9
- package/dist/engine/guardian/github-paging.js +97 -0
- package/dist/engine/guardian/pr-check.js +285 -26
- package/dist/engine/guardian/pr-comment.js +29 -15
- package/dist/engine/history/async-store.js +20 -0
- package/dist/gate-result.js +27 -4
- package/dist/overlay-commands.js +31 -3
- package/package.json +2 -2
|
@@ -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
|
|
604
|
+
* Only **uncovered** results become findings. Severity policy:
|
|
453
605
|
*
|
|
454
|
-
* - `COVERAGE_VERIFIED` uncovered →
|
|
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
|
-
|
|
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]:
|
|
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
|
-
|
|
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 (
|
|
740
|
-
payload['degraded_notice'] =
|
|
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}${
|
|
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(
|
|
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
|
|
775
|
-
'
|
|
776
|
-
|
|
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))} | ${
|
|
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
|
-
?
|
|
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 (
|
|
823
|
-
footer += ` - ${
|
|
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
|
|
19
|
-
*
|
|
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
|
|
139
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|