canary-test-cli 7.2.0 → 8.0.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/agents/skills/README.md +23 -4
- package/agents/skills/claude-code/canary-batwoman/SKILL.md +119 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +23 -16
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +3 -1
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +20 -3
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +1 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +15 -0
- package/agents/skills/claude-code/canary-screech/SKILL.md +109 -0
- package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
- package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
- package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
- package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
- package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -0
- package/agents/skills/lib/parse-args.mjs +200 -139
- package/dist/engine/analysis/batwoman/audit.js +39 -0
- package/dist/engine/analysis/batwoman/closure.js +159 -0
- package/dist/engine/analysis/batwoman/gh-history.js +119 -0
- package/dist/engine/analysis/batwoman/probes.js +195 -0
- package/dist/engine/analysis/batwoman/registry.js +142 -0
- package/dist/engine/analysis/batwoman/render.js +194 -0
- package/dist/engine/analysis/batwoman/run-window.js +122 -0
- package/dist/engine/analysis/batwoman/text.js +84 -0
- package/dist/engine/analysis/batwoman/triggers.js +122 -0
- package/dist/engine/analysis/batwoman/verdict.js +64 -0
- package/dist/engine/analysis/cli.js +47 -14
- package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
- package/dist/engine/batwoman-cli.js +119 -0
- package/dist/engine/ci-ready-cli.js +71 -0
- package/dist/engine/cli-commands.js +46 -7
- package/dist/engine/cli.core.js +16 -0
- package/dist/engine/company-knowledge-cli.js +10 -2
- package/dist/engine/core/ci-ready.js +112 -0
- package/dist/engine/core/company-knowledge.js +8 -0
- package/dist/engine/core/migrator.js +147 -20
- package/dist/engine/core/permission-matrix.js +219 -0
- package/dist/engine/core/quality-scorer.js +13 -18
- package/dist/engine/core/scaling-curve.js +143 -0
- package/dist/engine/core/string-literals.js +3 -1
- package/dist/engine/core/vacuity-scanner.js +151 -6
- package/dist/engine/core/workflow-discovery.js +41 -23
- package/dist/engine/guardian/adjudication-github.js +136 -0
- package/dist/engine/guardian/adjudication.js +119 -340
- package/dist/engine/guardian/cli.js +180 -264
- package/dist/engine/guardian/coverage.js +2 -1
- package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
- package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
- package/dist/engine/guardian/diff-coverage/paths.js +5 -9
- package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
- package/dist/engine/guardian/diff-extractor.js +31 -32
- package/dist/engine/guardian/pr-check.js +262 -430
- package/dist/engine/guardian/pr-comment.js +35 -58
- package/dist/engine/guardian/weak-test.js +236 -0
- package/dist/engine/mcp-server.js +67 -4
- package/dist/engine/permission-matrix-cli.js +51 -0
- package/dist/engine/scaling-curve-cli.js +147 -0
- package/dist/engine/skills-cli.js +48 -32
- package/dist/engine/workflow-cli.js +85 -65
- package/package.json +1 -1
|
@@ -25,10 +25,9 @@
|
|
|
25
25
|
* to the later CLI wave. This module is the pure library logic.
|
|
26
26
|
*/
|
|
27
27
|
import { readFileSync } from 'node:fs';
|
|
28
|
-
import {
|
|
28
|
+
import { join } from 'node:path';
|
|
29
29
|
import { readJsonWithWarning } from '../core/config-validation.js';
|
|
30
|
-
import {
|
|
31
|
-
import { Fidelity, coverageDegradedNotice, coverageStatus, isSourcePath, isTestPath, isTestSupportPath, isTypeOnlyModule, } from './coverage.js';
|
|
30
|
+
import { Fidelity, coverageDegradedNotice, coverageDeltaNotice, coverageDeltaStatus, coverageCauses, coverageStatus, isSourcePath, isTestPath, isTestSupportPath, isTypeOnlyModule, } from './coverage.js';
|
|
32
31
|
import { Severity, severitySortKey } from './impact-mapper.js';
|
|
33
32
|
import { ensureAscii } from '../util/ensure-ascii.js';
|
|
34
33
|
const HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
@@ -69,72 +68,143 @@ function mergeLines(lines) {
|
|
|
69
68
|
*/
|
|
70
69
|
export function scopeDiff(diffText) {
|
|
71
70
|
const addedByPath = new Map();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
walkDiff(diffText, (line) => {
|
|
72
|
+
if (!line.added)
|
|
73
|
+
return;
|
|
74
|
+
const lines = addedByPath.get(line.path);
|
|
75
|
+
if (lines)
|
|
76
|
+
lines.push(line.lineno);
|
|
77
|
+
else
|
|
78
|
+
addedByPath.set(line.path, [line.lineno]);
|
|
79
|
+
});
|
|
80
|
+
const units = [];
|
|
81
|
+
for (const [path, lines] of addedByPath) {
|
|
82
|
+
units.push({ path, added_ranges: mergeLines(lines) });
|
|
83
|
+
}
|
|
84
|
+
return units;
|
|
85
|
+
}
|
|
86
|
+
// Single-character C escapes git's `quote_c_style` emits, per `sq_lookup`.
|
|
87
|
+
const C_ESCAPES = {
|
|
88
|
+
a: 0x07,
|
|
89
|
+
b: 0x08,
|
|
90
|
+
f: 0x0c,
|
|
91
|
+
n: 0x0a,
|
|
92
|
+
r: 0x0d,
|
|
93
|
+
t: 0x09,
|
|
94
|
+
v: 0x0b,
|
|
95
|
+
'"': 0x22,
|
|
96
|
+
'\\': 0x5c,
|
|
97
|
+
};
|
|
98
|
+
const UTF8_DECODER = new TextDecoder('utf-8');
|
|
99
|
+
/**
|
|
100
|
+
* Undo git's C-style path quoting (`core.quotePath`, on by default).
|
|
101
|
+
*
|
|
102
|
+
* A path with a non-ASCII (or control) byte is emitted by `git diff` wrapped in
|
|
103
|
+
* double quotes with its bytes octal-escaped:
|
|
104
|
+
* `+++ "b/caf\303\251.ts"`. Left as-is, the quotes ride along in the unit's
|
|
105
|
+
* path, so the file resolves to nothing: `extname` reads `.ts"`,
|
|
106
|
+
* {@link isSourcePath} says "not program source", and
|
|
107
|
+
* {@link filterHeuristicNoise} drops the finding — a silent false negative on
|
|
108
|
+
* every non-ASCII-named file. A value that is not quoted is returned verbatim.
|
|
109
|
+
*/
|
|
110
|
+
function unquoteCStyle(value) {
|
|
111
|
+
if (!isCQuoted(value))
|
|
112
|
+
return value;
|
|
113
|
+
const body = value.slice(1, -1);
|
|
114
|
+
const bytes = [];
|
|
115
|
+
for (let i = 0; i < body.length; i++) {
|
|
116
|
+
const ch = body[i];
|
|
117
|
+
if (ch !== '\\') {
|
|
118
|
+
// Any non-escaped character is already a decoded code point; re-encode it
|
|
119
|
+
// so the whole path decodes as one UTF-8 byte stream.
|
|
120
|
+
for (const byte of new TextEncoder().encode(ch))
|
|
121
|
+
bytes.push(byte);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const next = body[i + 1];
|
|
125
|
+
if (next === undefined)
|
|
126
|
+
return value; // trailing backslash → not quoted
|
|
127
|
+
if (next >= '0' && next <= '7') {
|
|
128
|
+
const octal = /^[0-7]{1,3}/.exec(body.slice(i + 1))[0];
|
|
129
|
+
bytes.push(Number.parseInt(octal, 8) & 0xff);
|
|
130
|
+
i += octal.length;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const mapped = C_ESCAPES[next];
|
|
134
|
+
if (mapped === undefined)
|
|
135
|
+
return value; // unknown escape → leave alone
|
|
136
|
+
bytes.push(mapped);
|
|
137
|
+
i += 1;
|
|
138
|
+
}
|
|
139
|
+
return UTF8_DECODER.decode(new Uint8Array(bytes));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* True when `value` is wrapped in the double quotes git uses for C-style path
|
|
143
|
+
* quoting. Split out of {@link unquoteCStyle} to keep its decode loop under
|
|
144
|
+
* the cyclomatic-complexity threshold.
|
|
145
|
+
*/
|
|
146
|
+
function isCQuoted(value) {
|
|
147
|
+
return value.length >= 2 && value.startsWith('"') && value.endsWith('"');
|
|
148
|
+
}
|
|
149
|
+
/** The new-side path a `+++ ` header names, or `null` for a deleted file. */
|
|
150
|
+
function headerPath(line) {
|
|
151
|
+
// A quoted path is unquoted BEFORE the `b/` strip: the quotes wrap the
|
|
152
|
+
// prefix too (`"b/caf\303\251.ts"`), so stripping first would never match.
|
|
153
|
+
const target = unquoteCStyle(line.slice(4).trim());
|
|
154
|
+
if (target === '/dev/null')
|
|
155
|
+
return null;
|
|
156
|
+
// Strip the conventional "b/" prefix.
|
|
157
|
+
return target.startsWith('b/') ? target.slice(2) : target;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Walk a unified diff, calling `emit` once per NEW-SIDE line, in file order.
|
|
161
|
+
*
|
|
162
|
+
* The single parser behind {@link scopeDiff}, {@link addedContentByPath} and
|
|
163
|
+
* `visibleLinesByPath`. Those three read different things off the same walk —
|
|
164
|
+
* added line numbers, added text, and added-plus-context text — and each used
|
|
165
|
+
* to carry its own copy of the header/hunk bookkeeping, which is three places
|
|
166
|
+
* for a diff-format edge case to be fixed in two of.
|
|
167
|
+
*
|
|
168
|
+
* Removed (`-`) lines and the `` marker are not
|
|
169
|
+
* emitted and do not advance the new-side counter, since neither exists on that
|
|
170
|
+
* side. Deleted files (`+++ /dev/null`) emit nothing at all.
|
|
171
|
+
*
|
|
172
|
+
* FIX 7: `--- `/`+++ ` are file headers ONLY before the first hunk of a file.
|
|
173
|
+
* Inside a hunk body a `+++ ...` line is ADDED CONTENT whose real text is
|
|
174
|
+
* `++ ...`, and mistaking it for a header loses the rest of the file.
|
|
175
|
+
*/
|
|
176
|
+
export function walkDiff(diffText, emit) {
|
|
177
|
+
let path = null;
|
|
178
|
+
let lineno = 0;
|
|
75
179
|
let inHunk = false;
|
|
76
180
|
for (const line of splitLines(diffText)) {
|
|
77
181
|
if (line.startsWith('diff --git')) {
|
|
78
|
-
//
|
|
79
|
-
// upcoming `+++ ` header.
|
|
182
|
+
// A new file block begins → leave any prior hunk body; the path is set
|
|
183
|
+
// by the upcoming `+++ ` header.
|
|
80
184
|
inHunk = false;
|
|
81
|
-
|
|
82
|
-
skipCurrent = false;
|
|
185
|
+
path = null;
|
|
83
186
|
continue;
|
|
84
187
|
}
|
|
85
|
-
// `--- `/`+++ ` are file headers ONLY before the first hunk of a file. Once
|
|
86
|
-
// inside a hunk body a `+++ ...` line is an ADDED content line whose real
|
|
87
|
-
// text is `++ ...` and must not be mistaken for a header (FIX 7).
|
|
88
188
|
if (!inHunk && line.startsWith('+++ ')) {
|
|
89
|
-
|
|
90
|
-
if (target === '/dev/null') {
|
|
91
|
-
skipCurrent = true;
|
|
92
|
-
currentPath = null;
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
skipCurrent = false;
|
|
96
|
-
// Strip the conventional "b/" prefix.
|
|
97
|
-
currentPath = target.startsWith('b/') ? target.slice(2) : target;
|
|
98
|
-
if (!addedByPath.has(currentPath))
|
|
99
|
-
addedByPath.set(currentPath, []);
|
|
189
|
+
path = headerPath(line);
|
|
100
190
|
continue;
|
|
101
191
|
}
|
|
102
|
-
|
|
103
|
-
|
|
192
|
+
// Old-file header; ignored (the path comes from `+++`).
|
|
193
|
+
if (!inHunk && line.startsWith('--- '))
|
|
104
194
|
continue;
|
|
105
|
-
}
|
|
106
195
|
const hunk = HUNK_RE.exec(line);
|
|
107
196
|
if (hunk) {
|
|
108
|
-
|
|
197
|
+
lineno = Number.parseInt(hunk[1], 10);
|
|
109
198
|
inHunk = true;
|
|
110
199
|
continue;
|
|
111
200
|
}
|
|
112
|
-
if (
|
|
201
|
+
if (path === null)
|
|
113
202
|
continue;
|
|
114
|
-
if (line.startsWith('
|
|
115
|
-
addedByPath.get(currentPath).push(newLineno);
|
|
116
|
-
newLineno += 1;
|
|
117
|
-
}
|
|
118
|
-
else if (line.startsWith('-')) {
|
|
119
|
-
// Removed line: does not advance the new-file counter.
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
else if (line.startsWith('\\')) {
|
|
123
|
-
// "" — metadata, ignore.
|
|
203
|
+
if (line.startsWith('-') || line.startsWith('\\'))
|
|
124
204
|
continue;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Context line (leading space) or blank — advances new-file counter.
|
|
128
|
-
newLineno += 1;
|
|
129
|
-
}
|
|
205
|
+
emit({ path, lineno, text: line.slice(1), added: line.startsWith('+') });
|
|
206
|
+
lineno += 1;
|
|
130
207
|
}
|
|
131
|
-
const units = [];
|
|
132
|
-
for (const [path, lines] of addedByPath) {
|
|
133
|
-
if (lines.length === 0)
|
|
134
|
-
continue;
|
|
135
|
-
units.push({ path, added_ranges: mergeLines(lines) });
|
|
136
|
-
}
|
|
137
|
-
return units;
|
|
138
208
|
}
|
|
139
209
|
// FIX 2 (signal-quality): a changed file whose ADDED lines are ONLY imports /
|
|
140
210
|
// re-exports (no real declarations or logic) is a barrel/index file
|
|
@@ -173,48 +243,20 @@ function isNeutralLine(stripped) {
|
|
|
173
243
|
/**
|
|
174
244
|
* Map each changed file to the CONTENT of its added (`+`) lines.
|
|
175
245
|
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* excluded; a `+++ ` line inside a hunk body is added content, not a header
|
|
179
|
-
* (same FIX 7 guard as `scopeDiff`).
|
|
246
|
+
* The same walk {@link scopeDiff} takes, reading the added-line *text* rather
|
|
247
|
+
* than its line number.
|
|
180
248
|
*/
|
|
181
|
-
function addedContentByPath(diffText) {
|
|
249
|
+
export function addedContentByPath(diffText) {
|
|
182
250
|
const added = new Map();
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
if (!inHunk && line.startsWith('+++ ')) {
|
|
194
|
-
const target = line.slice(4).trim();
|
|
195
|
-
if (target === '/dev/null') {
|
|
196
|
-
skipCurrent = true;
|
|
197
|
-
currentPath = null;
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
skipCurrent = false;
|
|
201
|
-
currentPath = target.startsWith('b/') ? target.slice(2) : target;
|
|
202
|
-
if (!added.has(currentPath))
|
|
203
|
-
added.set(currentPath, []);
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
if (!inHunk && line.startsWith('--- '))
|
|
207
|
-
continue;
|
|
208
|
-
if (HUNK_RE.test(line)) {
|
|
209
|
-
inHunk = true;
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
if (skipCurrent || currentPath === null)
|
|
213
|
-
continue;
|
|
214
|
-
if (line.startsWith('+')) {
|
|
215
|
-
added.get(currentPath).push(line.slice(1));
|
|
216
|
-
}
|
|
217
|
-
}
|
|
251
|
+
walkDiff(diffText, (line) => {
|
|
252
|
+
if (!line.added)
|
|
253
|
+
return;
|
|
254
|
+
const texts = added.get(line.path);
|
|
255
|
+
if (texts)
|
|
256
|
+
texts.push(line.text);
|
|
257
|
+
else
|
|
258
|
+
added.set(line.path, [line.text]);
|
|
259
|
+
});
|
|
218
260
|
return added;
|
|
219
261
|
}
|
|
220
262
|
/**
|
|
@@ -656,283 +698,63 @@ export function buildFindings(results) {
|
|
|
656
698
|
}
|
|
657
699
|
return [...findings].sort((a, b) => severitySortKey(a.severity) - severitySortKey(b.severity));
|
|
658
700
|
}
|
|
659
|
-
// Map a test file's extension to the framework whose assertion/test patterns
|
|
660
|
-
// the quality scorer should use. Unknown → pytest (the scorer's own fallback).
|
|
661
|
-
const TEST_FRAMEWORK_BY_EXT = {
|
|
662
|
-
'.py': 'pytest',
|
|
663
|
-
'.ts': 'vitest',
|
|
664
|
-
'.tsx': 'vitest',
|
|
665
|
-
'.js': 'vitest',
|
|
666
|
-
'.jsx': 'vitest',
|
|
667
|
-
'.mjs': 'vitest',
|
|
668
|
-
'.cjs': 'vitest',
|
|
669
|
-
};
|
|
670
|
-
function frameworkForTestPath(path) {
|
|
671
|
-
return TEST_FRAMEWORK_BY_EXT[extname(path).toLowerCase()] ?? 'pytest';
|
|
672
|
-
}
|
|
673
|
-
// A test-function signature / decorator / block-close / comment — lines that
|
|
674
|
-
// are not a test *body*. If a diff's added lines are ONLY these (e.g. a rename
|
|
675
|
-
// that adds just `def test_new():` while the asserting body stays as context),
|
|
676
|
-
// there is no added body to judge and we must not flag it.
|
|
677
|
-
const TEST_SIGNATURE_RE = /^\s*(?:async\s+)?def\s+test\w*\s*\(|^\s*(?:it|test|describe)\s*\(/;
|
|
678
|
-
const BLOCK_DELIMITERS = new Set(['})', '});', '}', ')', '{']);
|
|
679
|
-
/**
|
|
680
|
-
* True iff the added lines contain a real body line — not just a test
|
|
681
|
-
* signature, decorator, comment, or a bare block delimiter.
|
|
682
|
-
*/
|
|
683
|
-
function hasAddedTestBody(added) {
|
|
684
|
-
for (const line of added) {
|
|
685
|
-
const stripped = line.trim();
|
|
686
|
-
if (!stripped)
|
|
687
|
-
continue;
|
|
688
|
-
if (stripped.startsWith('#') ||
|
|
689
|
-
stripped.startsWith('//') ||
|
|
690
|
-
stripped.startsWith('@') ||
|
|
691
|
-
stripped.startsWith('*') ||
|
|
692
|
-
stripped.startsWith('/*')) {
|
|
693
|
-
continue;
|
|
694
|
-
}
|
|
695
|
-
if (BLOCK_DELIMITERS.has(stripped))
|
|
696
|
-
continue;
|
|
697
|
-
if (TEST_SIGNATURE_RE.test(line))
|
|
698
|
-
continue;
|
|
699
|
-
return true;
|
|
700
|
-
}
|
|
701
|
-
return false;
|
|
702
|
-
}
|
|
703
701
|
/**
|
|
704
|
-
*
|
|
702
|
+
* Percentage-point bands for a coverage **regression** (#606).
|
|
705
703
|
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
*
|
|
704
|
+
* A drop is graded by how far it fell and stops at `HIGH` — it never reaches
|
|
705
|
+
* `CRITICAL`. An uncovered new block is a fact about one artifact; a drop is a
|
|
706
|
+
* *relative* measurement across two, and guardian cannot verify that the base
|
|
707
|
+
* artifact it was handed is genuinely the base of this PR. The top of the scale
|
|
708
|
+
* is reserved for what guardian can prove on its own.
|
|
710
709
|
*/
|
|
711
|
-
const
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
function indentWidth(line) {
|
|
718
|
-
return line.length - line.trimStart().length;
|
|
710
|
+
const REGRESSION_HIGH_POINTS = 20;
|
|
711
|
+
const REGRESSION_MEDIUM_POINTS = 5;
|
|
712
|
+
/** `92.0% (23/25)` — a ratio a reviewer can check without doing the division. */
|
|
713
|
+
function ratioLabel(ratio) {
|
|
714
|
+
const pct = ((ratio.covered / ratio.coverable) * 100).toFixed(1);
|
|
715
|
+
return `${pct}% (${ratio.covered}/${ratio.coverable})`;
|
|
719
716
|
}
|
|
720
|
-
// String literals and line comments are blanked before delimiter counting, so
|
|
721
|
-
// a brace inside `'a { b'` or a trailing `// }` cannot unbalance a block.
|
|
722
|
-
const JS_STRING_OR_COMMENT = /(['"`])(?:\\.|(?!\1).)*?\1|\/\/.*$|\/\*[\s\S]*?\*\//g;
|
|
723
717
|
/**
|
|
724
|
-
*
|
|
725
|
-
* line was a header. Split out from the content handling so neither half has
|
|
726
|
-
* to carry the other's branches.
|
|
727
|
-
*/
|
|
728
|
-
function applyDiffHeader(line, cur, files) {
|
|
729
|
-
if (line.startsWith('diff --git')) {
|
|
730
|
-
cur.inHunk = false;
|
|
731
|
-
cur.current = null;
|
|
732
|
-
cur.skipCurrent = false;
|
|
733
|
-
return true;
|
|
734
|
-
}
|
|
735
|
-
if (!cur.inHunk && line.startsWith('+++ ')) {
|
|
736
|
-
const target = line.slice(4).trim();
|
|
737
|
-
if (target === '/dev/null') {
|
|
738
|
-
cur.skipCurrent = true;
|
|
739
|
-
cur.current = null;
|
|
740
|
-
return true;
|
|
741
|
-
}
|
|
742
|
-
cur.skipCurrent = false;
|
|
743
|
-
const path = target.startsWith('b/') ? target.slice(2) : target;
|
|
744
|
-
cur.current = files.get(path) ?? { text: new Map(), added: new Set() };
|
|
745
|
-
files.set(path, cur.current);
|
|
746
|
-
return true;
|
|
747
|
-
}
|
|
748
|
-
if (!cur.inHunk && line.startsWith('--- '))
|
|
749
|
-
return true;
|
|
750
|
-
const hunk = HUNK_RE.exec(line);
|
|
751
|
-
if (hunk) {
|
|
752
|
-
cur.newLineno = Number.parseInt(hunk[1], 10);
|
|
753
|
-
cur.inHunk = true;
|
|
754
|
-
return true;
|
|
755
|
-
}
|
|
756
|
-
return false;
|
|
757
|
-
}
|
|
758
|
-
/** Record one content line against the file the cursor is pointing at. */
|
|
759
|
-
function applyDiffContent(line, cur) {
|
|
760
|
-
const file = cur.current;
|
|
761
|
-
if (!file)
|
|
762
|
-
return;
|
|
763
|
-
// `-` is gone from the new file and `\` is the no-newline marker; neither
|
|
764
|
-
// occupies a line number on the `+` side.
|
|
765
|
-
if (line.startsWith('-') || line.startsWith('\\'))
|
|
766
|
-
return;
|
|
767
|
-
const added = line.startsWith('+');
|
|
768
|
-
file.text.set(cur.newLineno, line.slice(1));
|
|
769
|
-
if (added)
|
|
770
|
-
file.added.add(cur.newLineno);
|
|
771
|
-
cur.newLineno += 1;
|
|
772
|
-
}
|
|
773
|
-
function visibleLinesByPath(diffText) {
|
|
774
|
-
const files = new Map();
|
|
775
|
-
const cur = {
|
|
776
|
-
current: null,
|
|
777
|
-
newLineno: 0,
|
|
778
|
-
skipCurrent: false,
|
|
779
|
-
inHunk: false,
|
|
780
|
-
};
|
|
781
|
-
for (const line of splitLines(diffText)) {
|
|
782
|
-
if (applyDiffHeader(line, cur, files))
|
|
783
|
-
continue;
|
|
784
|
-
if (cur.skipCurrent || cur.current === null)
|
|
785
|
-
continue;
|
|
786
|
-
applyDiffContent(line, cur);
|
|
787
|
-
}
|
|
788
|
-
return files;
|
|
789
|
-
}
|
|
790
|
-
/**
|
|
791
|
-
* The line the enclosing test declaration sits on, or `null` when none is
|
|
792
|
-
* visible (#747).
|
|
718
|
+
* Turn regressed base-vs-head deltas into `coverage-regression` findings (#606).
|
|
793
719
|
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
* bare helper) is not judged at all rather than reported as assertion-free.
|
|
799
|
-
*/
|
|
800
|
-
function enclosingTestDecl(file, lineNo, declRe) {
|
|
801
|
-
for (let n = lineNo; file.text.has(n); n--) {
|
|
802
|
-
if (declRe.test(file.text.get(n)))
|
|
803
|
-
return n;
|
|
804
|
-
}
|
|
805
|
-
return null;
|
|
806
|
-
}
|
|
807
|
-
/**
|
|
808
|
-
* The last line of the test block opened at `start`, bounded by what the diff
|
|
809
|
-
* shows (#747).
|
|
810
|
-
*
|
|
811
|
-
* Python closes on the first non-blank line indented no deeper than the `def`;
|
|
812
|
-
* JS/TS closes when the delimiter depth opened by the declaration returns to
|
|
813
|
-
* zero. When neither lands inside the visible run the span is truncated at its
|
|
814
|
-
* end — the assertion search is then over less than the whole block, which can
|
|
815
|
-
* still miss an assertion further down. That residual is accepted: it is a
|
|
816
|
-
* strictly smaller window of error than scoring the added lines alone, which is
|
|
817
|
-
* what #747 measured, and widening the span past what the diff shows would mean
|
|
818
|
-
* reading the working tree, which this function deliberately does not do.
|
|
819
|
-
*/
|
|
820
|
-
function testBlockEnd(file, start, isPython) {
|
|
821
|
-
let last = start;
|
|
822
|
-
if (isPython) {
|
|
823
|
-
const declIndent = indentWidth(file.text.get(start));
|
|
824
|
-
for (let n = start + 1; file.text.has(n); n++) {
|
|
825
|
-
const text = file.text.get(n);
|
|
826
|
-
if (text.trim() && indentWidth(text) <= declIndent)
|
|
827
|
-
return n - 1;
|
|
828
|
-
last = n;
|
|
829
|
-
}
|
|
830
|
-
return last;
|
|
831
|
-
}
|
|
832
|
-
let depth = 0;
|
|
833
|
-
let opened = false;
|
|
834
|
-
for (let n = start; file.text.has(n); n++) {
|
|
835
|
-
const text = file.text.get(n).replace(JS_STRING_OR_COMMENT, '');
|
|
836
|
-
for (const ch of text) {
|
|
837
|
-
if (ch === '{' || ch === '(') {
|
|
838
|
-
depth += 1;
|
|
839
|
-
opened = true;
|
|
840
|
-
}
|
|
841
|
-
else if (ch === '}' || ch === ')')
|
|
842
|
-
depth -= 1;
|
|
843
|
-
}
|
|
844
|
-
last = n;
|
|
845
|
-
if (opened && depth <= 0)
|
|
846
|
-
return n;
|
|
847
|
-
}
|
|
848
|
-
return last;
|
|
849
|
-
}
|
|
850
|
-
/**
|
|
851
|
-
* True iff some test block touched by `unit`'s added lines asserts nothing.
|
|
720
|
+
* Only `regressed` deltas become findings — an improvement and a flat result
|
|
721
|
+
* are not news. Fidelity is `COVERAGE_VERIFIED` because both sides of the
|
|
722
|
+
* comparison were measured by a real coverage run; there is no graph or
|
|
723
|
+
* heuristic path to a delta, and a tier that cannot measure must not guess one.
|
|
852
724
|
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
725
|
+
* The evidence carries both ratios AND both raw counts on purpose: base and
|
|
726
|
+
* head can disagree about how many lines are coverable at all (a diff adds
|
|
727
|
+
* lines; two producers may instrument differently), and a bare pair of
|
|
728
|
+
* percentages would hide that.
|
|
857
729
|
*/
|
|
858
|
-
function
|
|
859
|
-
const declRe = testDeclRe(framework);
|
|
860
|
-
const isPython = framework === 'pytest';
|
|
861
|
-
const seen = new Set();
|
|
862
|
-
for (const lineNo of linesInRanges(unit.added_ranges)) {
|
|
863
|
-
const start = enclosingTestDecl(file, lineNo, declRe);
|
|
864
|
-
if (start === null || seen.has(start))
|
|
865
|
-
continue;
|
|
866
|
-
seen.add(start);
|
|
867
|
-
const end = testBlockEnd(file, start, isPython);
|
|
868
|
-
const span = [];
|
|
869
|
-
const addedInBlock = [];
|
|
870
|
-
for (let n = start; n <= end; n++) {
|
|
871
|
-
const text = file.text.get(n);
|
|
872
|
-
if (text === undefined)
|
|
873
|
-
continue;
|
|
874
|
-
span.push(text);
|
|
875
|
-
if (file.added.has(n))
|
|
876
|
-
addedInBlock.push(text);
|
|
877
|
-
}
|
|
878
|
-
if (!hasAddedTestBody(addedInBlock))
|
|
879
|
-
continue;
|
|
880
|
-
if (isAssertionFreeTest(span.join('\n'), framework))
|
|
881
|
-
return true;
|
|
882
|
-
}
|
|
883
|
-
return false;
|
|
884
|
-
}
|
|
885
|
-
/**
|
|
886
|
-
* Advisory `weak-test` findings for ADDED tests that assert nothing.
|
|
887
|
-
*
|
|
888
|
-
* Consumes the test-path units {@link filterTestUnits} sets aside (a test file
|
|
889
|
-
* needs no test of its own, but an added test that asserts nothing is itself a
|
|
890
|
-
* gap). A high-precision signal by construction: a snapshot or table-driven
|
|
891
|
-
* test still matches an assertion pattern, so it is not flagged.
|
|
892
|
-
*
|
|
893
|
-
* #747: the span scored is the ENCLOSING TEST BLOCK of each added line, not the
|
|
894
|
-
* added lines themselves. Scoring the added lines alone reported every
|
|
895
|
-
* arrange/act-only edit as assertion-free, because a test's setup is edited far
|
|
896
|
-
* more often than its `expect` — six such findings, all wrong, in the run that
|
|
897
|
-
* produced the report. A changed line whose enclosing test cannot be resolved
|
|
898
|
-
* from the diff is ABSTAINED on, never reported.
|
|
899
|
-
*
|
|
900
|
-
* These findings are `LOW`/`weak-test` and are **never** gated (see
|
|
901
|
-
* {@link computeExitCode}): they surface, never block.
|
|
902
|
-
*/
|
|
903
|
-
export function buildWeakTestFindings(testUnits, diffText) {
|
|
904
|
-
const addedByPath = addedContentByPath(diffText);
|
|
905
|
-
const visibleByPath = visibleLinesByPath(diffText);
|
|
730
|
+
export function buildRegressionFindings(deltas) {
|
|
906
731
|
const findings = [];
|
|
907
|
-
for (const
|
|
908
|
-
|
|
909
|
-
if (!added || added.length === 0)
|
|
910
|
-
continue;
|
|
911
|
-
// A rename adds only the signature line (body is unchanged context) —
|
|
912
|
-
// nothing new to judge, so don't flag it (FP guard).
|
|
913
|
-
if (!hasAddedTestBody(added))
|
|
914
|
-
continue;
|
|
915
|
-
const framework = frameworkForTestPath(unit.path);
|
|
916
|
-
const file = visibleByPath.get(unit.path);
|
|
917
|
-
if (!file)
|
|
732
|
+
for (const delta of deltas) {
|
|
733
|
+
if (!delta.regressed)
|
|
918
734
|
continue;
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
735
|
+
const points = delta.dropPoints;
|
|
736
|
+
const severity = points >= REGRESSION_HIGH_POINTS
|
|
737
|
+
? Severity.HIGH
|
|
738
|
+
: points >= REGRESSION_MEDIUM_POINTS
|
|
739
|
+
? Severity.MEDIUM
|
|
740
|
+
: Severity.LOW;
|
|
741
|
+
findings.push(new GuardianFinding({
|
|
742
|
+
path: delta.path,
|
|
743
|
+
unit: delta.path,
|
|
744
|
+
kind: 'coverage-regression',
|
|
745
|
+
fidelity: Fidelity.CoverageVerified,
|
|
746
|
+
severity,
|
|
747
|
+
evidence: `coverage fell ${points.toFixed(1)} points on a file this change ` +
|
|
748
|
+
`touches: base ${ratioLabel(delta.base)} ${ARROW} head ` +
|
|
749
|
+
`${ratioLabel(delta.head)}`,
|
|
750
|
+
suggestion: `Restore the lost coverage in \`${delta.path}\` — a test that ` +
|
|
751
|
+
'exercised this file on the base ref no longer reaches part of it.',
|
|
752
|
+
}));
|
|
931
753
|
}
|
|
932
|
-
return findings;
|
|
754
|
+
return [...findings].sort((a, b) => severitySortKey(a.severity) - severitySortKey(b.severity));
|
|
933
755
|
}
|
|
934
756
|
/** Flatten inclusive `[start, end]` ranges into a sorted list of line numbers. */
|
|
935
|
-
function linesInRanges(ranges) {
|
|
757
|
+
export function linesInRanges(ranges) {
|
|
936
758
|
const lines = new Set();
|
|
937
759
|
for (const [start, end] of ranges) {
|
|
938
760
|
for (let ln = start; ln <= end; ln++)
|
|
@@ -946,7 +768,7 @@ function linesInRanges(ranges) {
|
|
|
946
768
|
* Requires a `//`/`#` comment leader (FIX 1), strips a trailing inline-comment
|
|
947
769
|
* close (e.g. `*/`), and trims surrounding whitespace.
|
|
948
770
|
*/
|
|
949
|
-
function suppressionReason(line) {
|
|
771
|
+
export function suppressionReason(line) {
|
|
950
772
|
const match = SUPPRESS_RE.exec(line);
|
|
951
773
|
if (match === null)
|
|
952
774
|
return null;
|
|
@@ -1038,26 +860,11 @@ export function computeExitCode(findings, gate) {
|
|
|
1038
860
|
return 0;
|
|
1039
861
|
}
|
|
1040
862
|
const STICKY_MARKER = '<!-- canary-pr-guardian -->';
|
|
1041
|
-
// Severity → status icon for the sticky comment (encodes severity in form, not
|
|
1042
|
-
// just text, so the most urgent findings read at a glance).
|
|
1043
|
-
//
|
|
1044
|
-
// Written as `\u{...}` escapes, not literal glyphs: this file is `.ts`, and the
|
|
1045
|
-
// house rule keeps emitted non-ASCII out of non-Markdown source (see the
|
|
1046
|
-
// "Output data glyphs" block in `cli.ts`). They are emitted verbatim.
|
|
1047
863
|
/**
|
|
1048
|
-
* Character budget for a rendered sticky comment (#457).
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
* silently produces nothing on exactly the large PRs that need it most -- the
|
|
1053
|
-
* same silent-green failure #369 was filed for.
|
|
1054
|
-
*
|
|
1055
|
-
* 60,000 leaves ~5.5k of headroom for anything appended outside
|
|
1056
|
-
* `renderFindings` (degradation annotations, upsert wrappers) without inviting
|
|
1057
|
-
* a body that only *just* fits and then breaks when a filename grows.
|
|
1058
|
-
*
|
|
1059
|
-
* The cap applies ONLY to the comment. The `--emit-analysis` JSON record is the
|
|
1060
|
-
* authoritative complete set and is never truncated.
|
|
864
|
+
* Character budget for a rendered sticky comment (#457). GitHub rejects a body
|
|
865
|
+
* over 65,536 characters, which would silently post nothing on the largest PRs
|
|
866
|
+
* (#369); 60,000 leaves headroom for appended annotations. The comment only:
|
|
867
|
+
* the `--emit-analysis` record is complete and never truncated.
|
|
1061
868
|
*/
|
|
1062
869
|
export const COMMENT_CHAR_BUDGET = 60_000;
|
|
1063
870
|
/** The line that accounts for findings the budget could not fit (#457). */
|
|
@@ -1135,30 +942,11 @@ function coverageBlock(state) {
|
|
|
1135
942
|
return { status: coverageStatus(state), ...state };
|
|
1136
943
|
}
|
|
1137
944
|
/**
|
|
1138
|
-
* True when this run VERIFIED NO COVERAGE and every finding
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
1143
|
-
* have verified nothing, because "findings-eligible" and "coverage-verifiable"
|
|
1144
|
-
* are different counts. The measured shape is a code PR whose lcov never
|
|
1145
|
-
* reached the runner: N eligible units, zero coverage denominator, and a
|
|
1146
|
-
* confident "6 files need test coverage" headline under a green check.
|
|
1147
|
-
*
|
|
1148
|
-
* Two narrowings keep this honest rather than merely loud:
|
|
1149
|
-
*
|
|
1150
|
-
* - `unitsTotal === 0` is NOT this case. A run that judged nothing makes no
|
|
1151
|
-
* coverage claim in either direction; the eligible-count abstention owns it,
|
|
1152
|
-
* the same boundary {@link coverageDegradedNotice} already draws.
|
|
1153
|
-
* - A single coverage- or graph-verified finding disproves it. Real evidence
|
|
1154
|
-
* means the run measured something, so it is a result and must not be
|
|
1155
|
-
* downgraded to an abstention.
|
|
1156
|
-
* - A run with NO findings is left alone. It states nothing a reader can
|
|
1157
|
-
* mistake for a measurement: #554 already replaced its all-clear headline
|
|
1158
|
-
* with "no gaps found, but coverage was unavailable" plus the body line
|
|
1159
|
-
* saying that is an abstention, not a pass. The defect #761 reports is
|
|
1160
|
-
* specifically a CONFIDENT COUNT over a zero coverage denominator, so that
|
|
1161
|
-
* is what changes here.
|
|
945
|
+
* True when this run VERIFIED NO COVERAGE and every finding is a naming guess
|
|
946
|
+
* (#761): a confident count over a zero coverage denominator is an abstention.
|
|
947
|
+
* Not this case: `unitsTotal === 0` (no claim either way), any coverage- or
|
|
948
|
+
* graph-verified finding (real evidence), or no findings at all (the per-cause
|
|
949
|
+
* headline already says what was not verified, #928).
|
|
1162
950
|
*/
|
|
1163
951
|
export function isCoverageAbstention(coverage, findings) {
|
|
1164
952
|
if (!coverage || coverage.unitsTotal === 0)
|
|
@@ -1182,22 +970,62 @@ function abstentionHeadline(checked) {
|
|
|
1182
970
|
const ABSTENTION_BODY = 'No coverage report reached this run, so nothing below is a coverage ' +
|
|
1183
971
|
'verdict — every finding is a filename-level guess. A gate that verified ' +
|
|
1184
972
|
'zero items has abstained; this is not a pass.';
|
|
973
|
+
const files = (n) => `${n} file${n === 1 ? '' : 's'}`;
|
|
974
|
+
/** The unverified-unit causes present on a run, worst first (#928). */
|
|
975
|
+
function causeEntries(state) {
|
|
976
|
+
const { stale, scopeGap, nonCoverable } = coverageCauses(state);
|
|
977
|
+
const trees = (state.instrumentedTrees ?? []).map((t) => t || '.');
|
|
978
|
+
const entries = [
|
|
979
|
+
[
|
|
980
|
+
stale,
|
|
981
|
+
`${WARNING} coverage report stale: ${stale} changed ${files(stale).split(' ')[1]} missing from it`,
|
|
982
|
+
'coverage report stale (changed lines missing from it)',
|
|
983
|
+
],
|
|
984
|
+
[
|
|
985
|
+
scopeGap,
|
|
986
|
+
`${WARNING} not coverage-checked: ${files(scopeGap)} outside instrumented trees (${trees.join(', ')})`,
|
|
987
|
+
'not coverage-checked (outside instrumented trees)',
|
|
988
|
+
],
|
|
989
|
+
[
|
|
990
|
+
nonCoverable,
|
|
991
|
+
`${WHITE_CHECK} nothing to test: only non-executable lines changed (${files(nonCoverable)})`,
|
|
992
|
+
'nothing to test (only non-executable lines changed)',
|
|
993
|
+
],
|
|
994
|
+
];
|
|
995
|
+
return entries.filter(([n]) => n > 0);
|
|
996
|
+
}
|
|
1185
997
|
/**
|
|
1186
|
-
*
|
|
1187
|
-
*
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
998
|
+
* One headline per cause (#928) plus the lesser causes as count lines. ✅ only
|
|
999
|
+
* when the report spoke to every changed unit (#554): any B or C unit is ⚠️.
|
|
1000
|
+
*/
|
|
1001
|
+
function coverageHeadline(state, notice) {
|
|
1002
|
+
const clean = `${WHITE_CHECK} no test-coverage gaps`;
|
|
1003
|
+
if (state?.unitsTotal === 0) {
|
|
1004
|
+
return [`${WHITE_CHECK} nothing to test: no source files changed`, []];
|
|
1005
|
+
}
|
|
1006
|
+
if (!state?.parsed) {
|
|
1007
|
+
const blind = `${WARNING} no gaps found, but coverage was ${state ? coverageStatus(state) : ''}`;
|
|
1008
|
+
return [notice ? blind : clean, []];
|
|
1009
|
+
}
|
|
1010
|
+
const [worst, ...rest] = causeEntries(state);
|
|
1011
|
+
// Matched units plus non-executable ones: nothing is missing, so plain ✅.
|
|
1012
|
+
if (!worst || (worst[1].startsWith(WHITE_CHECK) && state.unitsMatched > 0)) {
|
|
1013
|
+
return [clean, []];
|
|
1014
|
+
}
|
|
1015
|
+
return [worst[1], rest.map(([n, , label]) => `- ${files(n)}: ${label}`)];
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* The comment body for a run with zero active findings. The notice goes in the
|
|
1019
|
+
* body, not only a `<sub>` footer, which is read as boilerplate (#554).
|
|
1192
1020
|
*/
|
|
1193
1021
|
function noGapsLines(coverageState, suppressedCount, abstained = false, checked = 0) {
|
|
1194
1022
|
const notice = coverageState ? coverageDegradedNotice(coverageState) : null;
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1023
|
+
const [cause, counts] = coverageHeadline(coverageState, notice);
|
|
1024
|
+
const headline = abstained ? abstentionHeadline(checked) : cause;
|
|
1025
|
+
const lines = [
|
|
1026
|
+
`## ${BABY_CHICK} Canary PR Guardian ${EM_DASH} ${headline}`,
|
|
1027
|
+
...counts,
|
|
1028
|
+
];
|
|
1201
1029
|
if (notice) {
|
|
1202
1030
|
lines.push(`> **${notice}**`, '', 'Zero files matched is an abstention, not a pass — nothing here is ' +
|
|
1203
1031
|
'evidence that the changed lines are covered.');
|
|
@@ -1216,7 +1044,11 @@ export function renderFindings(findings, fmt, tier = 0, degradedNotice = null, g
|
|
|
1216
1044
|
const coverageNotice = coverageState
|
|
1217
1045
|
? coverageDegradedNotice(coverageState)
|
|
1218
1046
|
: null;
|
|
1219
|
-
|
|
1047
|
+
// #606: the delta's own degradation is a THIRD independent reason a run can
|
|
1048
|
+
// be blind, so it joins the same combined notice rather than replacing one.
|
|
1049
|
+
const deltaState = gateMeta?.coverageDelta ?? null;
|
|
1050
|
+
const deltaNotice = deltaState ? coverageDeltaNotice(deltaState) : null;
|
|
1051
|
+
const notice = combineNotices(degradedNotice, coverageNotice, deltaNotice);
|
|
1220
1052
|
if (fmt === 'json') {
|
|
1221
1053
|
const payload = {
|
|
1222
1054
|
findings: ordered.map(findingDict),
|
|
@@ -1234,6 +1066,14 @@ export function renderFindings(findings, fmt, tier = 0, degradedNotice = null, g
|
|
|
1234
1066
|
payload['skipped'] = gateMeta.skipped ?? [];
|
|
1235
1067
|
if (coverageState)
|
|
1236
1068
|
payload['coverage'] = coverageBlock(coverageState);
|
|
1069
|
+
// #606: the delta's denominator, so a machine consumer can tell "no
|
|
1070
|
+
// regressions" from "never compared" without re-deriving it.
|
|
1071
|
+
if (deltaState) {
|
|
1072
|
+
payload['coverage_delta'] = {
|
|
1073
|
+
status: coverageDeltaStatus(deltaState),
|
|
1074
|
+
...deltaState,
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1237
1077
|
// #761: machine consumers need the diff's endpoints for the same reason
|
|
1238
1078
|
// humans do — every count in this payload is scoped by them.
|
|
1239
1079
|
if (gateMeta.provenance)
|
|
@@ -1286,10 +1126,10 @@ export function renderFindings(findings, fmt, tier = 0, degradedNotice = null, g
|
|
|
1286
1126
|
const CONFIDENCE_NOTE = 'Confidence — **coverage-verified**: measured from a real coverage run · ' +
|
|
1287
1127
|
'**graph-verified**: inferred from the call graph · **heuristic**: filename ' +
|
|
1288
1128
|
`guess (lowest). tier ${tier}: deterministic check, no LLM.`;
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
//
|
|
1129
|
+
// #928: the coverage notice is rendered in the body, so the footer omits it.
|
|
1130
|
+
const footerNotice = combineNotices(degradedNotice, deltaNotice);
|
|
1131
|
+
const footerLine = `<sub>${CONFIDENCE_NOTE}${footerNotice ? ` ${EM_DASH} ${footerNotice}` : ''}</sub>`;
|
|
1132
|
+
// #554: a coverage-blind run must not present as one that checked and passed.
|
|
1293
1133
|
const coverageLine = coverageNotice ? `> **${coverageNotice}**` : null;
|
|
1294
1134
|
// #761: shown on EVERY comment, clean or not. The run that motivated this was
|
|
1295
1135
|
// a findings run whose findings were all phantom, so gating the line on a
|
|
@@ -1359,10 +1199,9 @@ export function renderFindings(findings, fmt, tier = 0, degradedNotice = null, g
|
|
|
1359
1199
|
return lines.join('\n');
|
|
1360
1200
|
}
|
|
1361
1201
|
// fmt == "text" (default fallback): plain, no markdown/HTML.
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
: 'Canary PR Guardian — no test-coverage gaps';
|
|
1202
|
+
// #554/#928: the comment surface's per-cause headline, without its glyph.
|
|
1203
|
+
const [cause, causeCounts] = coverageHeadline(coverageState, coverageNotice);
|
|
1204
|
+
const cleanHeadline = `Canary PR Guardian — ${cause.replace(/^\S+ /, '')}`;
|
|
1366
1205
|
// #761: the same rule on the surface an engineer reads at their desk. The
|
|
1367
1206
|
// headline is stripped of the comment surface's markdown-era glyph so the
|
|
1368
1207
|
// terminal line stays plain text.
|
|
@@ -1374,6 +1213,7 @@ export function renderFindings(findings, fmt, tier = 0, degradedNotice = null, g
|
|
|
1374
1213
|
: active.length === 0
|
|
1375
1214
|
? cleanHeadline
|
|
1376
1215
|
: `Canary PR Guardian — ${new Set(active.map((f) => f.path)).size} file(s) need test coverage`,
|
|
1216
|
+
...(abstained || active.length > 0 ? [] : causeCounts),
|
|
1377
1217
|
];
|
|
1378
1218
|
for (const finding of ordered) {
|
|
1379
1219
|
const unit = finding.unit && finding.unit !== finding.path ? ` → ${finding.unit}` : '';
|
|
@@ -1441,16 +1281,8 @@ export const DEFAULT_SKIP_GLOBS = [
|
|
|
1441
1281
|
'**/__generated__/**',
|
|
1442
1282
|
];
|
|
1443
1283
|
/**
|
|
1444
|
-
* Parsed `canary.guardian` config block.
|
|
1445
|
-
*
|
|
1446
|
-
* Phase 1 stores every field but only `pr_*` gate/tier drive behavior.
|
|
1447
|
-
* `skip_globs` and the `precommit_*`/`coverage_paths` fields are read into the
|
|
1448
|
-
* object (scaffold) for later phases (SC-2 skip, SC-5 tier).
|
|
1449
|
-
*
|
|
1450
|
-
* `skip_globs` defaults to docs/markdown PLUS generated/dependency artifacts
|
|
1451
|
-
* (lockfiles, `dist`/`build` outputs, minified JS, snapshots — see
|
|
1452
|
-
* {@link DEFAULT_SKIP_GLOBS}) so noise-only paths skip out of the box; an
|
|
1453
|
-
* explicit `skipGlobs` in config (even `[]`) overrides it.
|
|
1284
|
+
* Parsed `canary.guardian` config block. `skip_globs` defaults to
|
|
1285
|
+
* {@link DEFAULT_SKIP_GLOBS}; an explicit `skipGlobs` (even `[]`) overrides it.
|
|
1454
1286
|
*/
|
|
1455
1287
|
export class GuardianConfig {
|
|
1456
1288
|
pr_enabled;
|