canary-test-cli 6.7.1 → 6.8.1

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.
@@ -1,5 +1,5 @@
1
1
  import type { CheckResult } from './doctor.js';
2
- export declare const CHECK_TYPES: readonly ["file-exists", "url-reachable", "command-succeeds"];
2
+ declare const CHECK_TYPES: readonly ["file-exists", "url-reachable", "command-succeeds"];
3
3
  export type CheckType = (typeof CHECK_TYPES)[number];
4
4
  /** A single validated check from an overlay's `doctor.json`. */
5
5
  export interface ManifestCheck {
@@ -60,8 +60,6 @@ export declare function collectAudiences(checks: ManifestCheck[]): string[];
60
60
  * contains the tag (case-insensitive).
61
61
  */
62
62
  export declare function filterByAudience(checks: ManifestCheck[], audience: string | null): ManifestCheck[];
63
- /** Default per-check timeout for url and command checks. */
64
- export declare const DEFAULT_CHECK_TIMEOUT_MS = 10000;
65
63
  /** Probe a URL for reachability (injectable). Resolves true on a 2xx/3xx. */
66
64
  export type UrlProbe = (url: string, timeoutMs: number) => Promise<boolean>;
67
65
  /** Run a command (injectable). `ok` = exit 0; `timedOut` = killed at the timeout. */
@@ -92,3 +90,4 @@ export interface RunContext {
92
90
  * granted. Never throws.
93
91
  */
94
92
  export declare function runCheck(check: ManifestCheck, ctx: RunContext): Promise<CheckResult>;
93
+ export {};
@@ -33,7 +33,6 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DEFAULT_CHECK_TIMEOUT_MS = exports.CHECK_TYPES = void 0;
37
36
  exports.manifestPath = manifestPath;
38
37
  exports.commandSucceedsHash = commandSucceedsHash;
39
38
  exports.loadManifest = loadManifest;
@@ -54,7 +53,7 @@ const http = __importStar(require("node:http"));
54
53
  const https = __importStar(require("node:https"));
55
54
  const path = __importStar(require("node:path"));
56
55
  const node_child_process_1 = require("node:child_process");
57
- exports.CHECK_TYPES = [
56
+ const CHECK_TYPES = [
58
57
  'file-exists',
59
58
  'url-reachable',
60
59
  'command-succeeds',
@@ -109,8 +108,8 @@ function validateCommonFields(c, index) {
109
108
  return `check[${index}] is missing a string "id"`;
110
109
  }
111
110
  if (typeof c.type !== 'string' ||
112
- !exports.CHECK_TYPES.includes(c.type)) {
113
- return `check "${c.id}" has an unknown type (expected one of: ${exports.CHECK_TYPES.join(', ')})`;
111
+ !CHECK_TYPES.includes(c.type)) {
112
+ return `check "${c.id}" has an unknown type (expected one of: ${CHECK_TYPES.join(', ')})`;
114
113
  }
115
114
  if (typeof c.remedy !== 'string' || c.remedy === '') {
116
115
  return `check "${c.id}" is missing a string "remedy"`;
@@ -233,7 +232,7 @@ function filterByAudience(checks, audience) {
233
232
  c.audience.some((p) => p.toLowerCase() === want));
234
233
  }
235
234
  /** Default per-check timeout for url and command checks. */
236
- exports.DEFAULT_CHECK_TIMEOUT_MS = 10000;
235
+ const DEFAULT_CHECK_TIMEOUT_MS = 10000;
237
236
  function defaultProbeUrl(url, timeoutMs) {
238
237
  return new Promise((resolve) => {
239
238
  let mod;
@@ -336,7 +335,7 @@ function runCommandSucceeds(check, ctx, timeoutMs) {
336
335
  * granted. Never throws.
337
336
  */
338
337
  async function runCheck(check, ctx) {
339
- const timeoutMs = ctx.timeoutMs ?? exports.DEFAULT_CHECK_TIMEOUT_MS;
338
+ const timeoutMs = ctx.timeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS;
340
339
  if (check.type === 'file-exists') {
341
340
  return runFileExists(check, ctx.cloneDir);
342
341
  }
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { release, type } from 'node:os';
26
26
  /** The public issue tracker (from npm/package.json `repository`). */
27
- export const TRACKER_URL = 'https://github.com/bop-clocktower/canary';
27
+ const TRACKER_URL = 'https://github.com/bop-clocktower/canary';
28
28
  export const VALID_CATEGORIES = ['bug', 'ux', 'docs', 'idea'];
29
29
  /** Best-effort install-method label — never fails, never inspects secrets. */
30
30
  function installMethod() {
@@ -26,7 +26,7 @@ export function isFile(path) {
26
26
  }
27
27
  }
28
28
  /** Compile a single glob segment (with `*` -> `[^/]*`) to an anchored regex. */
29
- export function segGlobRegex(seg) {
29
+ function segGlobRegex(seg) {
30
30
  const body = seg
31
31
  .replace(/[.+^${}()|[\]\\?]/g, '\\$&')
32
32
  .replace(/\*/g, '[^/]*');
@@ -75,7 +75,7 @@ export function globFiles(root, pattern) {
75
75
  visit(root, 0);
76
76
  return out;
77
77
  }
78
- export function subDirs(dir) {
78
+ function subDirs(dir) {
79
79
  try {
80
80
  return readdirSync(dir, { withFileTypes: true })
81
81
  .filter((e) => e.isDirectory())
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { readFileSync } from 'node:fs';
9
9
  import { basename, extname } from 'node:path';
10
+ import { blankStringContent } from './string-literals.js';
10
11
  export function formatFinding(f) {
11
12
  return `[${f.severity.toUpperCase()}] ${f.file}:${f.line} (${f.rule})\n ${f.message}\n → ${f.suggestion}`;
12
13
  }
@@ -23,8 +24,19 @@ const LOCATOR_METHODS = /\.(locator|querySelector)\s*\(/;
23
24
  // Missing await
24
25
  const BARE_PLAYWRIGHT_CALL = /(?<!await\s)(?<!return\s)(?<!\w)(?:page|frame|locator)\.(?:click|fill|type|check|uncheck|selectOption|hover|focus|press|tap|dblclick)\s*\(/;
25
26
  // Assertion detection
26
- const TEST_FN_PY = /^(\s*)def (test_\w+)\s*\(/gm;
27
- const TEST_FN_JS = /(?:^|\s)(?:it|test)\s*\(\s*['"]([^'"]*)['"]/gm;
27
+ // Indentation is `[ \t]*`, NOT `\s*`: `\s` matches a newline, so a `^`-anchored
28
+ // `\s*` starts the match at the FIRST of any run of blank lines above the `def`
29
+ // and counts those newlines as indentation. PEP 8 mandates blank lines between
30
+ // defs, so that was the normal case, and it broke two things at once -- the
31
+ // reported line landed above the test, and the inflated indent made the
32
+ // "next def at the same indent" body boundary un-matchable, so the body ran to
33
+ // end-of-file and could borrow a later test's assert (#633).
34
+ const TEST_FN_PY = /^([ \t]*)def (test_\w+)\s*\(/gm;
35
+ // `d` (hasIndices) so the NAME can be read back out of the ORIGINAL source.
36
+ // The scanner matches against string-blanked source, where the name itself has
37
+ // been blanked away; blanking is length-preserving precisely so these offsets
38
+ // still address the untouched text (#590).
39
+ const TEST_FN_JS = /(?:^|\s)(?:it|test)\s*\(\s*['"]([^'"]*)['"]/dgm;
28
40
  const ASSERT_PY = /\bassert\b|\bpytest\.raises\b/;
29
41
  // Assertion styles a JS/TS test may use. `expect()` (jest/vitest/playwright)
30
42
  // was the only one recognized until canary was pointed at its own suites and
@@ -276,7 +288,7 @@ function scanAssertionFreePy(code, file) {
276
288
  }
277
289
  return out;
278
290
  }
279
- function scanAssertionFreeJs(code, file) {
291
+ function scanAssertionFreeJs(code, file, source) {
280
292
  const out = [];
281
293
  // The test declarations, in source order, so each body can be bounded by the
282
294
  // NEXT one -- the JS analogue of what the pytest scanner already does with
@@ -296,7 +308,16 @@ function scanAssertionFreeJs(code, file) {
296
308
  const bodyEnd = decls[i + 1]?.index ?? code.length;
297
309
  const rest = code.slice(bodyStart, bodyEnd);
298
310
  if (!ASSERT_JS.test(rest)) {
299
- out.push(mk(file, lineOf(code, start), 'LINT-006', 'warning', `Test "${m[1]}" contains no assertions.`, 'Add an expect() call; a test that never asserts always passes.'));
311
+ const name = m.indices?.[1]
312
+ ? source.slice(m.indices[1][0], m.indices[1][1])
313
+ : m[1];
314
+ // The reported coordinate comes from the NAME's offset, not the match's.
315
+ // `TEST_FN_JS` opens with `(?:^|\s)`, which CONSUMES the character before
316
+ // `it`/`test` -- for any test not on line 1 that is the newline ending the
317
+ // previous line, so `m.index` sits one line early (#633). `start` is still
318
+ // the right anchor for the body bounds; only the line moves.
319
+ const nameStart = m.indices?.[1]?.[0] ?? start;
320
+ out.push(mk(file, lineOf(code, nameStart), 'LINT-006', 'warning', `Test "${name}" contains no assertions.`, 'Add an expect() call; a test that never asserts always passes.'));
300
321
  }
301
322
  }
302
323
  return out;
@@ -356,13 +377,16 @@ export class StaticLinter {
356
377
  /** Full quality audit — all rules. */
357
378
  lint(path, framework) {
358
379
  const code = readFileSync(path, 'utf-8');
359
- // No rule may read the interior of a multi-line string as code. Blanking
360
- // preserves line count, so `scanned` re-joins to the same line numbers the
361
- // per-line scanners report -- both halves must use it, or the assertion
362
- // scanners go on mining `it(...)` declarations out of diff fixtures.
380
+ // No rule may read the interior of a string as code. The per-line rules
381
+ // keep the line-oriented blanking they were written against; the assertion
382
+ // scanners take a whole-source pass instead, because they are the only
383
+ // rules that bound one match by the offset of the NEXT one -- so a phantom
384
+ // declaration inside a fixture does not just add a finding, it truncates a
385
+ // real test's body and attributes its assertion past the end (#590).
386
+ // Both blankers preserve line numbering, so findings agree on line numbers.
363
387
  const lines = blankMultilineStrings(code.split('\n'));
364
- const scanned = lines.join('\n');
365
388
  const fw = requireFramework(path, framework);
389
+ const scanned = blankStringContent(code, { python: fw === 'pytest' });
366
390
  const findings = [
367
391
  ...scanFlakiness(lines, path),
368
392
  ...scanSelectors(lines, path),
@@ -370,7 +394,7 @@ export class StaticLinter {
370
394
  ...scanMagicNumbers(lines, path),
371
395
  ...(fw === 'pytest'
372
396
  ? scanAssertionFreePy(scanned, path)
373
- : scanAssertionFreeJs(scanned, path)),
397
+ : scanAssertionFreeJs(scanned, path, code)),
374
398
  ];
375
399
  findings.sort((a, b) => a.line - b.line || cmp(a.rule, b.rule));
376
400
  return findings;
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Offset-preserving string-literal blanking for the static linter. #590.
3
+ *
4
+ * The linter regexes over raw source, so a `test(...)` written inside a STRING
5
+ * is indistinguishable from one written in code. That is not a hypothetical:
6
+ * the files most likely to embed test source in a fixture are the linters,
7
+ * scanners and codemods -- exactly the tooling whose own tests you most want
8
+ * read. A downstream overlay on 6.7.0 got four LINT-006 findings on one suite
9
+ * and all four were data, not tests.
10
+ *
11
+ * Two narrower mechanisms already existed and #590 fell through the seam
12
+ * between them:
13
+ *
14
+ * - `blankMultilineStrings` toggles state on an ODD count of a delimiter per
15
+ * line, so a template literal that opens AND closes on the same physical
16
+ * line is skipped entirely. A `\n`-escaped fixture is one physical line.
17
+ * - The single-line `STRING_LITERAL` stripper has no backtick in its character
18
+ * class, and was never applied to the assertion scanners at all.
19
+ *
20
+ * This scanner reads the WHOLE source with a small state machine instead, so a
21
+ * literal is handled the same way whether it spans one line or twenty.
22
+ *
23
+ * ## Why blanking rather than rejecting matches
24
+ *
25
+ * The sibling helper in canary-blackhawk / canary-savant
26
+ * (`scripts/string-literals.mjs`) rejects a match whose start index falls
27
+ * inside a literal, because those scanners need the anchor token to stay
28
+ * visible -- blackhawk's BH003 matches `strftime('..%Z')` with the `%Z` inside
29
+ * the quotes on purpose. LINT-006 has the opposite need: it bounds each test
30
+ * body by the NEXT declaration's offset, so a phantom declaration inside a
31
+ * string truncates a real test's body and its assertion is attributed past the
32
+ * end. Suppressing the match is not enough; the text has to stop looking like
33
+ * a declaration. Hence blanking, and hence a separate implementation rather
34
+ * than a third copy of the `.mjs` helper.
35
+ *
36
+ * ## Invariants
37
+ *
38
+ * - Output has the SAME length and the SAME newline positions as the input, so
39
+ * every byte offset and computed line number stays valid.
40
+ * - Only literal CONTENT is replaced (with spaces); the quote characters stay,
41
+ * so a rule keying off the delimiter still sees that a string was there.
42
+ * - An UNTERMINATED literal is discarded, never applied. Blanking an unclosed
43
+ * run to end-of-file would silently disable every downstream rule from that
44
+ * point on -- the abstention shape, one layer inside the linter. This
45
+ * matches the deliberate choice already made in `blankMultilineStrings`.
46
+ *
47
+ * Fidelity limits (a state machine, not a parser):
48
+ * - A regex literal containing a quote (`/['"]/`) can open a phantom string.
49
+ * Because an unterminated run is discarded, the usual outcome is a no-op;
50
+ * the residual risk is a suppressed finding, never a fabricated one.
51
+ * - JSX text and Python f-string nesting beyond `${...}` are not modelled.
52
+ */
53
+ const SPACE = ' ';
54
+ /**
55
+ * Replace the content of every CLOSED string literal in `code` with spaces,
56
+ * preserving length, newline positions, and the quote characters themselves.
57
+ */
58
+ export function blankStringContent(code, options = {}) {
59
+ const spans = literalContentSpans(code, options.python === true);
60
+ if (spans.length === 0)
61
+ return code;
62
+ const out = [...code];
63
+ for (const [start, end] of spans) {
64
+ for (let i = start; i < end; i += 1) {
65
+ // Newlines survive so line numbering is unchanged; everything else goes.
66
+ if (out[i] !== '\n')
67
+ out[i] = SPACE;
68
+ }
69
+ }
70
+ return out.join('');
71
+ }
72
+ /**
73
+ * Walk `code` once and collect the content spans of every literal that closes.
74
+ *
75
+ * The stack holds two frame kinds: a string frame (`quote` set) whose content
76
+ * accumulates, and an interpolation frame (`interp`) for a template's
77
+ * `${...}`, which is CODE and may itself contain strings.
78
+ */
79
+ function literalContentSpans(code, python) {
80
+ const spans = [];
81
+ const stack = [];
82
+ const top = () => stack[stack.length - 1];
83
+ for (let i = 0; i < code.length; i += 1) {
84
+ const ch = code[i];
85
+ const frame = top();
86
+ if (frame?.kind === 'string') {
87
+ i = advanceInsideString(code, i, frame, spans, stack);
88
+ continue;
89
+ }
90
+ // Code context: top level, or inside a `${ ... }` interpolation.
91
+ const commentEnd = skipComment(code, i, python);
92
+ if (commentEnd !== null) {
93
+ i = commentEnd;
94
+ continue;
95
+ }
96
+ const opener = readOpener(code, i, python);
97
+ if (opener !== null) {
98
+ stack.push({ kind: 'string', quote: opener, start: i + opener.length });
99
+ i += opener.length - 1;
100
+ continue;
101
+ }
102
+ if (frame?.kind === 'interp')
103
+ advanceInsideInterp(ch, i, frame, stack);
104
+ }
105
+ // Anything still open never closed. Discard it -- see the header.
106
+ return spans;
107
+ }
108
+ /**
109
+ * Handle one character of a template's `${ ... }`. Only braces matter here:
110
+ * everything else in an interpolation is code the main loop already handles.
111
+ * Lives apart from the walk so brace bookkeeping cannot leak into it.
112
+ */
113
+ function advanceInsideInterp(ch, i, frame, stack) {
114
+ if (ch === '{') {
115
+ frame.depth += 1;
116
+ return;
117
+ }
118
+ if (ch !== '}')
119
+ return;
120
+ if (frame.depth > 0) {
121
+ frame.depth -= 1;
122
+ return;
123
+ }
124
+ stack.pop();
125
+ // The enclosing template resumes its content after the `}`.
126
+ const enclosing = stack[stack.length - 1];
127
+ if (enclosing?.kind === 'string')
128
+ enclosing.start = i + 1;
129
+ }
130
+ /**
131
+ * Handle one character while inside a string frame. Returns the index the main
132
+ * loop should continue FROM (it will be incremented), so escape pairs and
133
+ * multi-character delimiters are consumed atomically.
134
+ */
135
+ function advanceInsideString(code, i, frame, spans, stack) {
136
+ const ch = code[i];
137
+ // An escaped character is content and can never close the literal. Python
138
+ // raw strings are not modelled; treating `\` as an escape there costs at
139
+ // most a suppressed match, never a fabricated one.
140
+ if (ch === '\\')
141
+ return i + 1;
142
+ if (code.startsWith(frame.quote, i)) {
143
+ pushSpan(spans, frame.start, i);
144
+ stack.pop();
145
+ return i + frame.quote.length - 1;
146
+ }
147
+ // Template interpolation closes the current content run and opens code.
148
+ if (frame.quote === '`' && ch === '$' && code[i + 1] === '{') {
149
+ pushSpan(spans, frame.start, i);
150
+ stack.push({ kind: 'interp', depth: 0 });
151
+ return i + 1;
152
+ }
153
+ return i;
154
+ }
155
+ /** Record a content span, skipping empty ones. */
156
+ function pushSpan(spans, start, end) {
157
+ if (end > start)
158
+ spans.push([start, end]);
159
+ }
160
+ /**
161
+ * If a comment starts at `i`, return the index of its last character so the
162
+ * caller can skip it. Quotes inside a comment are prose, not delimiters: an
163
+ * apostrophe in "don't" would otherwise open a phantom literal.
164
+ */
165
+ function skipComment(code, i, python) {
166
+ if (python && code[i] === '#')
167
+ return endOfLine(code, i);
168
+ if (code[i] !== '/')
169
+ return null;
170
+ if (code[i + 1] === '/')
171
+ return endOfLine(code, i);
172
+ if (code[i + 1] === '*') {
173
+ const close = code.indexOf('*/', i + 2);
174
+ // An unterminated block comment runs to EOF; that IS how the language
175
+ // reads it, so unlike an unclosed string there is nothing to discard.
176
+ return close === -1 ? code.length - 1 : close + 1;
177
+ }
178
+ return null;
179
+ }
180
+ function endOfLine(code, i) {
181
+ const nl = code.indexOf('\n', i);
182
+ return nl === -1 ? code.length - 1 : nl - 1;
183
+ }
184
+ /**
185
+ * Return the delimiter opening at `i`, longest first so a Python triple quote
186
+ * is never mistaken for an empty string followed by an opener.
187
+ */
188
+ function readOpener(code, i, python) {
189
+ if (python) {
190
+ if (code.startsWith('"""', i))
191
+ return '"""';
192
+ if (code.startsWith("'''", i))
193
+ return "'''";
194
+ }
195
+ const ch = code[i];
196
+ if (ch === "'" || ch === '"')
197
+ return ch;
198
+ if (!python && ch === '`')
199
+ return '`';
200
+ return null;
201
+ }
202
+ //# sourceMappingURL=string-literals.js.map
@@ -35,7 +35,7 @@ import { dirname, join } from 'node:path';
35
35
  import { STICKY_MARKER, findSticky } from './pr-comment.js';
36
36
  import { readAllPages, restPageReader } from './github-paging.js';
37
37
  /** Schema tag for adjudication records (independent of the findings schema). */
38
- export const ADJUDICATION_SCHEMA_VERSION = '1.0';
38
+ const ADJUDICATION_SCHEMA_VERSION = '1.0';
39
39
  /**
40
40
  * Record `source` + filename prefix. Deliberately namespaced UNDER the
41
41
  * `canary-pr-guardian-` prefix, because harness's `AnalysisArchive` reads every
@@ -28,7 +28,7 @@ import { existsSync } from 'node:fs';
28
28
  import { Severity } from './impact-mapper.js';
29
29
  import { Finding } from './pr-check.js';
30
30
  /** Construct a {@link ReviewRequest} (a frozen dataclass in Python). */
31
- export function reviewRequest(test_paths) {
31
+ function reviewRequest(test_paths) {
32
32
  return { test_paths };
33
33
  }
34
34
  /**
@@ -86,7 +86,7 @@ const SEVERITY_BY_NAME = new Map(Object.values(Severity).map((s) => [s, s]));
86
86
  * RecordingInvoker} default) yields `[]` -- the SKILL reports its review
87
87
  * directly in-session.
88
88
  */
89
- export function parseReviewFindings(transcript) {
89
+ function parseReviewFindings(transcript) {
90
90
  const findings = [];
91
91
  for (const raw of transcript.split(/\r\n|\r|\n/)) {
92
92
  const match = REVIEW_LINE_RE.exec(raw);
@@ -37,7 +37,11 @@ import { dirname, join } from 'node:path';
37
37
  import { coverageDegradedNotice, coverageStatus, } from './coverage.js';
38
38
  import { combineNotices, render } from './pr-check.js';
39
39
  // 1.1 adds the additive `coverage` block (#554); readers of 1.0 are unaffected.
40
- export const SCHEMA_VERSION = '1.1';
40
+ // 1.2 adds the additive `skipped` list (#582). Additive again, and bumped again
41
+ // for the reason recorded in #572: a reader that pins a version must be able to
42
+ // tell which fields it can rely on being present, and silence about a new field
43
+ // is indistinguishable from the field being absent for a real reason.
44
+ export const SCHEMA_VERSION = '1.2';
41
45
  export const SOURCE = 'canary-pr-guardian';
42
46
  const REF_SAFE = /[^A-Za-z0-9._-]/g;
43
47
  const REF_MAX = 100; // cap the sanitized ref so a long branch never hits ENAMETOOLONG
@@ -120,6 +124,7 @@ export function buildAnalysisRecord(findings, args) {
120
124
  coverage: coverage === null
121
125
  ? null
122
126
  : { status: coverageStatus(coverage), ...coverage },
127
+ skipped: args.skipped ?? [],
123
128
  summary: {
124
129
  total: findings.length,
125
130
  unaddressed: active.length,
@@ -853,7 +853,7 @@ async function postStickyComment(findings, resolution, deps, gateMeta = null) {
853
853
  // D7: every filtered path stays visible as a SkipEntry, never folded
854
854
  // into "passed". One entry per path so the rendered count still equals
855
855
  // the path count the old `N path(s) skipped` line reported.
856
- function prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits = [], noisePaths = [], typeOnlyUnits = []) {
856
+ function prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits = [], typeOnlyUnits = []) {
857
857
  return [
858
858
  ...skipped.map((u) => ({ name: u.path, reason: 'skipGlobs' })),
859
859
  ...testUnits.map((u) => ({ name: u.path, reason: 'test path' })),
@@ -868,12 +868,17 @@ function prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits = [],
868
868
  name: u.path,
869
869
  reason: 're-export barrel',
870
870
  })),
871
- ...noisePaths.map((p) => ({
872
- name: p,
873
- reason: 'heuristic-ineligible',
874
- })),
875
871
  ];
876
872
  }
873
+ /**
874
+ * The heuristic-noise skip class (#413), which is only knowable AFTER the
875
+ * coverage ladder has scored each unit — so it cannot join
876
+ * {@link prCheckSkipEntries}, which must be built before the abstain exit.
877
+ * Kept a named function so the reason token has exactly one definition.
878
+ */
879
+ function heuristicSkipEntries(noisePaths) {
880
+ return noisePaths.map((p) => ({ name: p, reason: 'heuristic-ineligible' }));
881
+ }
877
882
  // Remediation is required copy (#508): say WHY the denominator
878
883
  // collapsed and the first fix step. The #456 class, now loud.
879
884
  const PR_CHECK_ABSTAIN_REMEDIATION = [
@@ -998,13 +1003,17 @@ async function prCheckCmd(opts, deps) {
998
1003
  const weakFindings = config.weak_tests
999
1004
  ? buildWeakTestFindings(testUnits, diffText)
1000
1005
  : [];
1001
- const preFilterSkipped = skipped.length +
1002
- testUnits.length +
1003
- barrelUnits.length +
1004
- supportUnits.length +
1005
- typeOnlyUnits.length;
1006
+ // #582: build the skip list ONCE, above the abstain exit, so the surviving
1007
+ // (non-abstain) path carries the same denominator the abstain payload has
1008
+ // carried since #579. The heuristic-noise class is not known until the
1009
+ // coverage ladder has run, so it is appended below rather than passed here.
1010
+ //
1011
+ // This supersedes a `preFilterSkipped` count that was computed at this point
1012
+ // and read by nothing — the fossil of an earlier attempt to surface the same
1013
+ // number on this path.
1014
+ const preCoverageSkips = prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, typeOnlyUnits);
1006
1015
  if (kept.length === 0 && weakFindings.length === 0) {
1007
- abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, [], typeOnlyUnits), opts.format, deps);
1016
+ abstainPrCheck(preCoverageSkips, opts.format, deps);
1008
1017
  }
1009
1018
  const { results, coverage } = resolveCoverageWithInput(kept, {
1010
1019
  coveragePath: opts.coverage ?? null,
@@ -1020,11 +1029,17 @@ async function prCheckCmd(opts, deps) {
1020
1029
  ...applySuppressions(buildFindings(scoredResults)),
1021
1030
  ...weakFindings,
1022
1031
  ];
1032
+ // The complete skip list for this run: the pre-coverage filters plus the
1033
+ // heuristic-noise class the ladder just revealed.
1034
+ const allSkips = [
1035
+ ...preCoverageSkips,
1036
+ ...heuristicSkipEntries(noiseResults.map((r) => r.unit.path)),
1037
+ ];
1023
1038
  // #413: if the heuristic filter consumed every scorable unit, report it as a
1024
1039
  // SKIP rather than rendering an empty "0 unaddressed" report -- an adopter
1025
1040
  // must be able to tell "nothing was judgeable" from "everything passed".
1026
1041
  if (scoredResults.length === 0 && findings.length === 0) {
1027
- abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, noiseResults.map((r) => r.unit.path), typeOnlyUnits), opts.format, deps);
1042
+ abstainPrCheck(allSkips, opts.format, deps);
1028
1043
  }
1029
1044
  // SC-5 (PR half): resolve the requested tier against actual capability. No
1030
1045
  // agent runtime exists (default NoAgentProbe), so any `pr.tier > 0` drops to
@@ -1043,6 +1058,9 @@ async function prCheckCmd(opts, deps) {
1043
1058
  checked: scoredResults.length,
1044
1059
  abstained: false,
1045
1060
  coverage,
1061
+ // #582: `checked` is the numerator of a fraction whose denominator was
1062
+ // never printed. This is the rest of it.
1063
+ skipped: allSkips,
1046
1064
  };
1047
1065
  const coverageNotice = coverageDegradedNotice(coverage);
1048
1066
  if (coverageNotice) {
@@ -1069,6 +1087,7 @@ async function prCheckCmd(opts, deps) {
1069
1087
  checked: scoredResults.length,
1070
1088
  abstained: false, // an abstained run exits before emit (see plan)
1071
1089
  coverage,
1090
+ skipped: allSkips,
1072
1091
  });
1073
1092
  if (res.action === 'emitted') {
1074
1093
  deps.out(`guardian: wrote analysis record ${RIGHT_ARROW} ${res.path}`);
@@ -171,7 +171,7 @@ function parseLcov(text) {
171
171
  // The coverage-json contract version this build understands. Bumped only on a
172
172
  // breaking change; the shape evolves additively (see
173
173
  // docs/specs/coverage-json-contract.md).
174
- export const COVERAGE_JSON_SCHEMA_VERSION = 1;
174
+ const COVERAGE_JSON_SCHEMA_VERSION = 1;
175
175
  /**
176
176
  * Parse the canary coverage-json shape into `{path: {line: hits}}`.
177
177
  *
@@ -612,48 +612,56 @@ function readReportText(reportPath) {
612
612
  * blocks).
613
613
  */
614
614
  export function resolveFromReport(units, reportPath) {
615
- const { index } = readReportIndex(reportPath);
615
+ const { index, absence } = readReportIndex(reportPath);
616
616
  if (index === null)
617
617
  return null;
618
- return matchUnitsToIndex(units, index);
618
+ return matchUnitsToIndex(units, index, absence);
619
619
  }
620
620
  /** Read + parse a coverage report, reporting each step's outcome separately. */
621
621
  function readReportIndex(reportPath) {
622
+ const unusable = (found) => ({
623
+ found,
624
+ index: null,
625
+ absence: 'not-coverable',
626
+ });
622
627
  if (!existsSync(reportPath))
623
- return { found: false, index: null };
628
+ return unusable(false);
624
629
  const text = readReportText(reportPath);
625
630
  // Present but unreadable/non-UTF-8 counts as found-and-unusable, not absent.
626
631
  if (text === null)
627
- return { found: true, index: null };
632
+ return unusable(true);
628
633
  const name = basename(reportPath).toLowerCase();
629
634
  let index;
635
+ let absence;
630
636
  if (name.endsWith('.json')) {
631
637
  let parsed;
632
638
  try {
633
639
  parsed = JSON.parse(text);
634
640
  }
635
641
  catch {
636
- return { found: true, index: null };
642
+ return unusable(true);
637
643
  }
638
644
  index = parseCoverageJson(parsed);
645
+ absence = 'uncovered';
639
646
  }
640
647
  else if (name.endsWith('.info') || name.includes('lcov')) {
641
648
  index = parseLcov(text);
649
+ absence = 'not-coverable';
642
650
  }
643
651
  else if (name.endsWith('.xml')) {
644
652
  index = parseCobertura(text);
653
+ absence = 'not-coverable';
645
654
  }
646
655
  else {
647
656
  // Unrecognized format → fall through to a lower fidelity tier.
648
- return { found: true, index: null };
657
+ return unusable(true);
649
658
  }
650
- if (index === null || Object.keys(index).length === 0) {
651
- return { found: true, index: null };
652
- }
653
- return { found: true, index };
659
+ if (index === null || Object.keys(index).length === 0)
660
+ return unusable(true);
661
+ return { found: true, index, absence };
654
662
  }
655
663
  /** Resolve every unit the report index can speak to (COVERAGE_VERIFIED). */
656
- function matchUnitsToIndex(units, index) {
664
+ function matchUnitsToIndex(units, index, absence) {
657
665
  const results = [];
658
666
  for (const unit of units) {
659
667
  const hits = matchHits(unit.path, index);
@@ -665,17 +673,30 @@ function matchUnitsToIndex(units, index) {
665
673
  continue;
666
674
  }
667
675
  const added = expandRanges(unit.added_ranges);
668
- const uncovered = added.filter((ln) => (hits[ln] ?? 0) <= 0);
676
+ // The per-line form of the check above (#655): under a format that
677
+ // enumerates instrumented lines, a changed line with no record could not
678
+ // have been executed and is scored by neither side.
679
+ const coverable = absence === 'not-coverable' ? added.filter((ln) => ln in hits) : added;
680
+ if (coverable.length === 0) {
681
+ // Every changed line is non-coverable, so this report has nothing to say
682
+ // about the unit. An abstention — never a clean pass, never a finding.
683
+ // Falls through to the graph/heuristic tier exactly as an absent path does.
684
+ continue;
685
+ }
686
+ const uncovered = coverable.filter((ln) => (hits[ln] ?? 0) <= 0);
669
687
  const covered = uncovered.length === 0;
688
+ // State the denominator: "all covered" over 20 changed lines and over the 3
689
+ // of them that were coverable are very different claims (#508).
670
690
  const evidence = covered
671
- ? `lines ${rangesStr(unit.added_ranges)}: all covered`
672
- : `lines ${rangesStr(unit.added_ranges)}: ${uncovered.length} uncovered`;
691
+ ? `lines ${rangesStr(unit.added_ranges)}: all ${coverable.length} coverable line(s) covered`
692
+ : `lines ${rangesStr(unit.added_ranges)}: ${uncovered.length} of ${coverable.length} coverable line(s) uncovered`;
673
693
  results.push(makeResult({
674
694
  unit,
675
695
  covered,
676
696
  fidelity: Fidelity.CoverageVerified,
677
697
  evidence,
678
698
  uncovered_lines: uncovered,
699
+ coverable_lines: coverable.length,
679
700
  }));
680
701
  }
681
702
  return results;
@@ -1399,7 +1420,9 @@ export function resolveCoverageWithInput(units, options = {}) {
1399
1420
  coverage.parsed = read.index !== null;
1400
1421
  coverage.filesInReport =
1401
1422
  read.index === null ? 0 : Object.keys(read.index).length;
1402
- const report = read.index === null ? null : matchUnitsToIndex(remaining, read.index);
1423
+ const report = read.index === null
1424
+ ? null
1425
+ : matchUnitsToIndex(remaining, read.index, read.absence);
1403
1426
  // An empty array (no unit matched the report) is falsy-equivalent in the
1404
1427
  // Python `if report:` guard — fall through rather than lock in nothing.
1405
1428
  if (report !== null && report.length > 0) {
@@ -21,10 +21,11 @@ export var Severity;
21
21
  Severity["LOW"] = "low";
22
22
  })(Severity || (Severity = {}));
23
23
  /**
24
- * Python: `Severity.sort_key` (ascending — CRITICAL sorts first). Imported by
25
- * other guardian modules as the canonical severity ordering.
24
+ * Python: `Severity.sort_key` (ascending — CRITICAL sorts first). The canonical
25
+ * severity ordering; reached from outside this module through
26
+ * {@link severitySortKey} rather than directly (#544).
26
27
  */
27
- export const SEVERITY_SORT_KEY = {
28
+ const SEVERITY_SORT_KEY = {
28
29
  [Severity.CRITICAL]: 0,
29
30
  [Severity.HIGH]: 1,
30
31
  [Severity.MEDIUM]: 2,
@@ -565,6 +565,22 @@ const HIGH_UNCOVERED_SHARE = 0.5;
565
565
  function addedLineCount(ranges) {
566
566
  return ranges.reduce((total, [start, end]) => total + (end - start + 1), 0);
567
567
  }
568
+ /**
569
+ * The fraction of what the report could speak to that came back unhit (#655).
570
+ *
571
+ * The denominator is the unit's **coverable** lines — added lines overstate it,
572
+ * because on a new file that is largely imports, types and blanks most changed
573
+ * lines were never instrumented at all, and dividing by them drives every share
574
+ * toward zero. Where the count is unknown (the graph and heuristic tiers, which
575
+ * are not graded by share anyway) it falls back to the added-line count, so
576
+ * grades predating that field are unchanged. An unknown denominator yields a
577
+ * full share rather than a low one — an absent measurement must never read as a
578
+ * good score (ADR 0010).
579
+ */
580
+ function uncoveredShare(result, uncovered) {
581
+ const denominator = result.coverable_lines ?? addedLineCount(result.unit.added_ranges);
582
+ return denominator > 0 ? uncovered / denominator : 1;
583
+ }
568
584
  /**
569
585
  * Severity for an uncovered **coverage-verified** result (#553).
570
586
  *
@@ -589,8 +605,7 @@ function coverageVerifiedSeverity(result) {
589
605
  const uncovered = result.uncovered_lines?.length ?? 0;
590
606
  if (uncovered === 0)
591
607
  return Severity.HIGH;
592
- const added = addedLineCount(result.unit.added_ranges);
593
- const share = added > 0 ? uncovered / added : 1;
608
+ const share = uncoveredShare(result, uncovered);
594
609
  if (uncovered >= CRITICAL_UNCOVERED_LINES &&
595
610
  share >= CRITICAL_UNCOVERED_SHARE)
596
611
  return Severity.CRITICAL;
@@ -914,7 +929,7 @@ export function combineNotices(...notices) {
914
929
  return kept.length > 0 ? kept.join('; ') : null;
915
930
  }
916
931
  /** The `coverage` block the json/analysis surfaces carry (#554). */
917
- export function coverageBlock(state) {
932
+ function coverageBlock(state) {
918
933
  return { status: coverageStatus(state), ...state };
919
934
  }
920
935
  /**
@@ -958,6 +973,11 @@ export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta
958
973
  if (gateMeta !== null) {
959
974
  payload['checked'] = gateMeta.checked;
960
975
  payload['abstained'] = gateMeta.abstained;
976
+ // #582: unconditional, and `[]` when nothing was dropped. An omitted key
977
+ // would make "this run skipped nothing" indistinguishable from "this
978
+ // producer predates #582", leaving a consumer to guess exactly the thing
979
+ // the field exists to state.
980
+ payload['skipped'] = gateMeta.skipped ?? [];
961
981
  if (coverageState)
962
982
  payload['coverage'] = coverageBlock(coverageState);
963
983
  }
@@ -104,7 +104,7 @@ function pick(src, fields) {
104
104
  return out;
105
105
  }
106
106
  /** Process-backed defaults for production. */
107
- export function defaultHistoryDeps() {
107
+ function defaultHistoryDeps() {
108
108
  return {
109
109
  out: (s) => process.stdout.write(`${s}\n`),
110
110
  err: (s) => process.stderr.write(`${s}\n`),
@@ -33,8 +33,6 @@ export declare function classifyCloneFailure(res: GitResult): string;
33
33
  export declare function add(source: string, options?: {
34
34
  ref?: string | null;
35
35
  }, deps?: CommandDeps): number;
36
- /** Count `.canary/skills/<name>/SKILL.md` entries in a clone. */
37
- export declare function skillCount(dest: string): number;
38
36
  /** Working-tree cleanliness of a clone. */
39
37
  export type CleanStatus = 'clean' | 'dirty' | 'unreadable';
40
38
  /**
@@ -35,7 +35,6 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.classifyCloneFailure = classifyCloneFailure;
37
37
  exports.add = add;
38
- exports.skillCount = skillCount;
39
38
  exports.workingTreeStatus = workingTreeStatus;
40
39
  exports.freshness = freshness;
41
40
  exports.list = list;
@@ -1,10 +1,3 @@
1
- /**
2
- * The BUNDLED migration target shapes, plus the `all` sentinel. Not a closed
3
- * set: `migrate` matches `deploy_to` against the consuming repo's resolved
4
- * `canary_shape` by plain string comparison, so downstream overlays may use
5
- * custom shapes. Lint warns (never errors) on a value outside this set (#501).
6
- */
7
- export declare const VALID_DEPLOY_TARGETS: ReadonlySet<string>;
8
1
  export interface LintFinding {
9
2
  /** Skill name, or `(overlay)` for an overlay-level finding. */
10
3
  skill: string;
@@ -33,7 +33,6 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.VALID_DEPLOY_TARGETS = void 0;
37
36
  exports.lintOverlay = lintOverlay;
38
37
  /**
39
38
  * `canary overlay lint` — validate an overlay against the authoring contract
@@ -65,7 +64,7 @@ const skill_frontmatter_js_1 = require("./skill-frontmatter.js");
65
64
  * `canary_shape` by plain string comparison, so downstream overlays may use
66
65
  * custom shapes. Lint warns (never errors) on a value outside this set (#501).
67
66
  */
68
- exports.VALID_DEPLOY_TARGETS = new Set([
67
+ const VALID_DEPLOY_TARGETS = new Set([
69
68
  'api',
70
69
  'e2e_ui',
71
70
  'frontend_unit',
@@ -116,11 +115,11 @@ function frontmatterFindings(skill, fm, parseErrors) {
116
115
  }
117
116
  }
118
117
  for (const target of (0, skill_frontmatter_js_1.listField)(fm, 'deploy_to')) {
119
- if (!exports.VALID_DEPLOY_TARGETS.has(target)) {
118
+ if (!VALID_DEPLOY_TARGETS.has(target)) {
120
119
  findings.push({
121
120
  skill,
122
121
  level: 'warning',
123
- message: `deploy_to value "${target}" is not a bundled target (${[...exports.VALID_DEPLOY_TARGETS].join(', ')}); fine if it matches a consuming repo's custom canary_shape, otherwise a typo`,
122
+ message: `deploy_to value "${target}" is not a bundled target (${[...VALID_DEPLOY_TARGETS].join(', ')}); fine if it matches a consuming repo's custom canary_shape, otherwise a typo`,
124
123
  });
125
124
  }
126
125
  }
@@ -48,7 +48,6 @@ export interface OverlayRegistry {
48
48
  export declare class RegistryError extends Error {
49
49
  constructor(message: string);
50
50
  }
51
- export declare function canaryHome(homeDir?: string): string;
52
51
  export declare function registryPath(homeDir?: string): string;
53
52
  export declare function overlaysDir(homeDir?: string): string;
54
53
  export declare function clonePath(name: string, homeDir?: string): string;
@@ -34,7 +34,6 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.RegistryError = exports.SCHEMA_VERSION = void 0;
37
- exports.canaryHome = canaryHome;
38
37
  exports.registryPath = registryPath;
39
38
  exports.overlaysDir = overlaysDir;
40
39
  exports.clonePath = clonePath;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canary-test-cli",
3
- "version": "6.7.1",
3
+ "version": "6.8.1",
4
4
  "description": "Canary — AI-powered test automation agent",
5
5
  "license": "MIT",
6
6
  "repository": {