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.
Files changed (59) hide show
  1. package/agents/skills/README.md +23 -4
  2. package/agents/skills/claude-code/canary-batwoman/SKILL.md +119 -0
  3. package/agents/skills/claude-code/canary-cassandra/SKILL.md +23 -16
  4. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +3 -1
  5. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +20 -3
  6. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +1 -0
  7. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +15 -0
  8. package/agents/skills/claude-code/canary-screech/SKILL.md +109 -0
  9. package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
  10. package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
  11. package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
  12. package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
  13. package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -0
  14. package/agents/skills/lib/parse-args.mjs +200 -139
  15. package/dist/engine/analysis/batwoman/audit.js +39 -0
  16. package/dist/engine/analysis/batwoman/closure.js +159 -0
  17. package/dist/engine/analysis/batwoman/gh-history.js +119 -0
  18. package/dist/engine/analysis/batwoman/probes.js +195 -0
  19. package/dist/engine/analysis/batwoman/registry.js +142 -0
  20. package/dist/engine/analysis/batwoman/render.js +194 -0
  21. package/dist/engine/analysis/batwoman/run-window.js +122 -0
  22. package/dist/engine/analysis/batwoman/text.js +84 -0
  23. package/dist/engine/analysis/batwoman/triggers.js +122 -0
  24. package/dist/engine/analysis/batwoman/verdict.js +64 -0
  25. package/dist/engine/analysis/cli.js +47 -14
  26. package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
  27. package/dist/engine/batwoman-cli.js +119 -0
  28. package/dist/engine/ci-ready-cli.js +71 -0
  29. package/dist/engine/cli-commands.js +46 -7
  30. package/dist/engine/cli.core.js +16 -0
  31. package/dist/engine/company-knowledge-cli.js +10 -2
  32. package/dist/engine/core/ci-ready.js +112 -0
  33. package/dist/engine/core/company-knowledge.js +8 -0
  34. package/dist/engine/core/migrator.js +147 -20
  35. package/dist/engine/core/permission-matrix.js +219 -0
  36. package/dist/engine/core/quality-scorer.js +13 -18
  37. package/dist/engine/core/scaling-curve.js +143 -0
  38. package/dist/engine/core/string-literals.js +3 -1
  39. package/dist/engine/core/vacuity-scanner.js +151 -6
  40. package/dist/engine/core/workflow-discovery.js +41 -23
  41. package/dist/engine/guardian/adjudication-github.js +136 -0
  42. package/dist/engine/guardian/adjudication.js +119 -340
  43. package/dist/engine/guardian/cli.js +180 -264
  44. package/dist/engine/guardian/coverage.js +2 -1
  45. package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
  46. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
  47. package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
  48. package/dist/engine/guardian/diff-coverage/paths.js +5 -9
  49. package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
  50. package/dist/engine/guardian/diff-extractor.js +31 -32
  51. package/dist/engine/guardian/pr-check.js +262 -430
  52. package/dist/engine/guardian/pr-comment.js +35 -58
  53. package/dist/engine/guardian/weak-test.js +236 -0
  54. package/dist/engine/mcp-server.js +67 -4
  55. package/dist/engine/permission-matrix-cli.js +51 -0
  56. package/dist/engine/scaling-curve-cli.js +147 -0
  57. package/dist/engine/skills-cli.js +48 -32
  58. package/dist/engine/workflow-cli.js +85 -65
  59. 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 { extname, join } from 'node:path';
28
+ import { join } from 'node:path';
29
29
  import { readJsonWithWarning } from '../core/config-validation.js';
30
- import { isAssertionFreeTest } from '../core/quality-scorer.js';
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
- let currentPath = null;
73
- let newLineno = 0;
74
- let skipCurrent = false;
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
- // New file block begins → leave any prior hunk body; path is set by the
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
- currentPath = null;
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
- const target = line.slice(4).trim();
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
- if (!inHunk && line.startsWith('--- ')) {
103
- // Old-file header; ignored (path comes from +++).
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
- newLineno = Number.parseInt(hunk[1], 10);
197
+ lineno = Number.parseInt(hunk[1], 10);
109
198
  inHunk = true;
110
199
  continue;
111
200
  }
112
- if (skipCurrent || currentPath === null)
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
- else {
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
- * Mirrors {@link scopeDiff}'s parser but captures the added-line *text* (the
177
- * `+` stripped) rather than line numbers. Deleted files (`+++ /dev/null`) are
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
- let currentPath = null;
184
- let skipCurrent = false;
185
- let inHunk = false;
186
- for (const line of splitLines(diffText)) {
187
- if (line.startsWith('diff --git')) {
188
- inHunk = false;
189
- currentPath = null;
190
- skipCurrent = false;
191
- continue;
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
- * The declaration line of a single test, per framework family (#747).
702
+ * Percentage-point bands for a coverage **regression** (#606).
705
703
  *
706
- * Narrower than {@link TEST_SIGNATURE_RE} on purpose: `describe(` opens a
707
- * *group*, and judging assertion presence over a whole describe block would
708
- * suppress a genuinely empty test sitting beside an asserting sibling. A
709
- * modifier chain (`it.only`, `test.each`) still opens one test, so it counts.
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 TEST_DECL_PY = /^\s*(?:async\s+)?def\s+test\w*\s*\(/;
712
- const TEST_DECL_JS = /^\s*(?:async\s+)?(?:it|test)(?:\.\w+)*\s*\(/;
713
- function testDeclRe(framework) {
714
- return framework === 'pytest' ? TEST_DECL_PY : TEST_DECL_JS;
715
- }
716
- /** Indentation width of `line`, counting a tab as one column. */
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
- * Consume a `diff --git` / `+++` / `---` / `@@` line, returning whether the
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
- * Walks up through the CONTIGUOUS visible run only: a gap between hunks means
795
- * the lines between are unknown, so a declaration on the far side of it is not
796
- * evidence about this line. Returning `null` is the abstention — a changed line
797
- * whose enclosing test cannot be resolved (a Playwright `setup(...)` fixture, a
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
- * A block qualifies for judgement only when it is resolvable AND at least one
854
- * of its own added lines is a real body line — the FP-3 rename guard, applied
855
- * per block rather than per file so a rename in one test cannot excuse an empty
856
- * one elsewhere in the same diff. Blocks are visited once each.
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 weakBlockIn(file, unit, framework) {
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 unit of testUnits) {
908
- const added = addedByPath.get(unit.path);
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
- if (weakBlockIn(file, unit, framework)) {
920
- findings.push(new GuardianFinding({
921
- path: unit.path,
922
- unit: unit.path,
923
- kind: 'weak-test',
924
- fidelity: Fidelity.Heuristic,
925
- severity: Severity.LOW,
926
- evidence: 'added test asserts nothing (advisory — never blocks the gate)',
927
- suggestion: 'add at least one assertion, or delete the test if it is a placeholder.',
928
- added_ranges: [...unit.added_ranges],
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
- * GitHub rejects an issue/PR comment body over **65,536** characters. The post
1051
- * path reports that as "could not post", so an over-long body means the gate
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 it produced is a
1139
- * naming-heuristic guess (#761) — an abstention, not a result.
1140
- *
1141
- * Guardian's existing abstention keys off the *findings-eligible* count, which
1142
- * is the wrong denominator: a run can have plenty of eligible units and still
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
- * The comment body for a run with zero active findings.
1187
- *
1188
- * #554: the ✅ all-clear headline is reserved for a run whose coverage report
1189
- * spoke to every changed file. Anything less says so in the body — a `<sub>`
1190
- * footer under a green headline is read as boilerplate, and this is the exact
1191
- * shape that let 43 coverage-blind PRs read as covered.
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 headline = abstained
1196
- ? abstentionHeadline(checked)
1197
- : notice
1198
- ? `${WARNING} no gaps found, but coverage was ${coverageStatus(coverageState)}`
1199
- : `${WHITE_CHECK} no test-coverage gaps`;
1200
- const lines = [`## ${BABY_CHICK} Canary PR Guardian ${EM_DASH} ${headline}`];
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
- const notice = combineNotices(degradedNotice, coverageNotice);
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
- const footerLine = `<sub>${CONFIDENCE_NOTE}${notice ? ` ${EM_DASH} ${notice}` : ''}</sub>`;
1290
- // #554: a coverage-blind run must not present as a run that checked and found
1291
- // nothing. The notice goes in the BODY, not only the footer — a `<sub>` line
1292
- // under a green headline is read as boilerplate.
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
- const cleanHeadline = coverageNotice
1363
- ? // #554: same rule as the comment surface — a blind run never claims clean.
1364
- `Canary PR Guardian — no gaps found, but coverage was ${coverageStatus(coverageState)}`
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;