canary-test-cli 6.6.0 → 6.7.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.
- package/bin/canary.js +69 -1
- package/dist/doctor-manifest.js +6 -1
- package/dist/doctor.js +7 -4
- package/dist/engine/cli-commands.js +84 -25
- package/dist/engine/cli.core.js +1 -1
- package/dist/engine/core/framework-probes.js +218 -0
- package/dist/engine/core/fs-glob.js +185 -0
- package/dist/engine/core/gate-result.js +27 -4
- package/dist/engine/core/migrator.js +240 -289
- package/dist/engine/core/static-linter.js +44 -4
- package/dist/engine/core/workspace-detect.js +0 -0
- package/dist/engine/guardian/adjudication.js +34 -30
- package/dist/engine/guardian/analysis-emit.js +13 -4
- package/dist/engine/guardian/cli.js +113 -16
- package/dist/engine/guardian/coverage.js +291 -9
- package/dist/engine/guardian/github-paging.js +97 -0
- package/dist/engine/guardian/pr-check.js +285 -26
- package/dist/engine/guardian/pr-comment.js +29 -15
- package/dist/gate-result.js +27 -4
- package/dist/overlay-commands.js +31 -3
- package/dist/reporters/testtracker.d.ts +18 -1
- package/dist/reporters/testtracker.js +59 -1
- package/package.json +2 -2
|
@@ -301,16 +301,56 @@ function scanAssertionFreeJs(code, file) {
|
|
|
301
301
|
}
|
|
302
302
|
return out;
|
|
303
303
|
}
|
|
304
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Extensions whose contents the JS/TS scanners can actually read. ESM (`.mjs`)
|
|
306
|
+
* and CJS (`.cjs`) belong here as much as `.js` does -- omitting them is what
|
|
307
|
+
* made #566 possible.
|
|
308
|
+
*/
|
|
309
|
+
export const JS_TEST_EXTENSIONS = [
|
|
310
|
+
'.ts',
|
|
311
|
+
'.js',
|
|
312
|
+
'.mjs',
|
|
313
|
+
'.cjs',
|
|
314
|
+
'.mts',
|
|
315
|
+
'.cts',
|
|
316
|
+
];
|
|
317
|
+
const JS_EXT_SET = new Set(JS_TEST_EXTENSIONS);
|
|
318
|
+
/**
|
|
319
|
+
* The framework whose scanners can parse `path`, or `null` when no scanner
|
|
320
|
+
* understands the extension.
|
|
321
|
+
*
|
|
322
|
+
* This deliberately has no default. The previous `return 'pytest'` fallback
|
|
323
|
+
* meant an unrecognised extension was silently handed to the Python assertion
|
|
324
|
+
* scanners: over ESM JavaScript they match nothing, so a `.mjs` file with real
|
|
325
|
+
* defects linted to zero findings and rendered a green all-clear (#566). A
|
|
326
|
+
* guess that cannot be distinguished from a clean result is a false green;
|
|
327
|
+
* `null` forces the caller to abstain instead.
|
|
328
|
+
*/
|
|
329
|
+
export function lintableFramework(path) {
|
|
305
330
|
const suffix = extname(path).toLowerCase();
|
|
306
331
|
const name = basename(path).toLowerCase();
|
|
307
332
|
if (suffix === '.py')
|
|
308
333
|
return 'pytest';
|
|
309
334
|
if (name.includes('playwright'))
|
|
310
335
|
return 'playwright';
|
|
311
|
-
if (
|
|
336
|
+
if (JS_EXT_SET.has(suffix))
|
|
312
337
|
return 'vitest';
|
|
313
|
-
return
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
/** Thrown when a scanner is asked for a file no ruleset can parse. */
|
|
341
|
+
export class UnsupportedTestFileError extends Error {
|
|
342
|
+
path;
|
|
343
|
+
constructor(path) {
|
|
344
|
+
super(`No linter ruleset can parse ${extname(path) || basename(path)}`);
|
|
345
|
+
this.path = path;
|
|
346
|
+
this.name = 'UnsupportedTestFileError';
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function requireFramework(path, framework) {
|
|
350
|
+
const fw = framework || lintableFramework(path);
|
|
351
|
+
if (fw === null)
|
|
352
|
+
throw new UnsupportedTestFileError(path);
|
|
353
|
+
return fw;
|
|
314
354
|
}
|
|
315
355
|
export class StaticLinter {
|
|
316
356
|
/** Full quality audit — all rules. */
|
|
@@ -322,7 +362,7 @@ export class StaticLinter {
|
|
|
322
362
|
// scanners go on mining `it(...)` declarations out of diff fixtures.
|
|
323
363
|
const lines = blankMultilineStrings(code.split('\n'));
|
|
324
364
|
const scanned = lines.join('\n');
|
|
325
|
-
const fw =
|
|
365
|
+
const fw = requireFramework(path, framework);
|
|
326
366
|
const findings = [
|
|
327
367
|
...scanFlakiness(lines, path),
|
|
328
368
|
...scanSelectors(lines, path),
|
|
Binary file
|
|
@@ -33,14 +33,21 @@ import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync,
|
|
|
33
33
|
import { randomBytes } from 'node:crypto';
|
|
34
34
|
import { dirname, join } from 'node:path';
|
|
35
35
|
import { STICKY_MARKER, findSticky } from './pr-comment.js';
|
|
36
|
+
import { readAllPages, restPageReader } from './github-paging.js';
|
|
36
37
|
/** Schema tag for adjudication records (independent of the findings schema). */
|
|
37
38
|
export const ADJUDICATION_SCHEMA_VERSION = '1.0';
|
|
38
39
|
/**
|
|
39
40
|
* Record `source` + filename prefix. Deliberately namespaced UNDER the
|
|
40
|
-
* `canary-pr-guardian-` prefix
|
|
41
|
-
* `*.json` in `.harness/analyses
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* `canary-pr-guardian-` prefix, because harness's `AnalysisArchive` reads every
|
|
42
|
+
* `*.json` in `.harness/analyses/`.
|
|
43
|
+
*
|
|
44
|
+
* The filenames are not provably distinct, though: a branch named
|
|
45
|
+
* `adjudication/pr-42` sanitizes through `analysisFilename` to exactly
|
|
46
|
+
* `canary-pr-guardian-adjudication-pr-42.json`, colliding with this prefix.
|
|
47
|
+
* What actually keeps the precision summary honest is the `source` field —
|
|
48
|
+
* `loadAdjudicationRecords` requires `source === ADJUDICATION_SOURCE` plus
|
|
49
|
+
* numeric `tp`/`fp`, so a findings record landing on that name is skipped, not
|
|
50
|
+
* mis-tallied. Read the field, never the filename.
|
|
44
51
|
*/
|
|
45
52
|
export const ADJUDICATION_SOURCE = 'canary-pr-guardian-adjudication';
|
|
46
53
|
/** GitHub reaction contents that carry an adjudication verdict. */
|
|
@@ -68,44 +75,35 @@ export class FakeReactionsClient {
|
|
|
68
75
|
}
|
|
69
76
|
/**
|
|
70
77
|
* Thin real {@link ReactionsClient} over the GitHub REST API (`fetch`).
|
|
71
|
-
* Network lives ONLY
|
|
72
|
-
*
|
|
78
|
+
* Network lives ONLY in the default {@link restPageReader}; both endpoints are
|
|
79
|
+
* reads, so a fork's read-only token is sufficient. The `read` seam exists so
|
|
80
|
+
* the #528 paging wiring is testable without a socket — production callers
|
|
81
|
+
* construct this with three arguments and get the real reader.
|
|
73
82
|
*/
|
|
74
83
|
export class RestReactionsClient {
|
|
75
84
|
repo;
|
|
76
85
|
prNumber;
|
|
77
|
-
token;
|
|
78
86
|
static API = 'https://api.github.com';
|
|
79
|
-
|
|
87
|
+
read;
|
|
88
|
+
constructor(repo, prNumber, token, read) {
|
|
80
89
|
this.repo = repo;
|
|
81
90
|
this.prNumber = prNumber;
|
|
82
|
-
this.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
'X-GitHub-Api-Version': '2022-11-28',
|
|
91
|
-
'User-Agent': 'canary-pr-guardian',
|
|
92
|
-
},
|
|
93
|
-
});
|
|
94
|
-
if (!resp.ok) {
|
|
95
|
-
throw new Error(`GitHub API ${resp.status}: ${url}`);
|
|
96
|
-
}
|
|
97
|
-
return resp.json();
|
|
91
|
+
this.read =
|
|
92
|
+
read ??
|
|
93
|
+
restPageReader({
|
|
94
|
+
Authorization: `Bearer ${token}`,
|
|
95
|
+
Accept: 'application/vnd.github+json',
|
|
96
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
97
|
+
'User-Agent': 'canary-pr-guardian',
|
|
98
|
+
}, (status, url) => new Error(`GitHub API ${status}: ${url}`));
|
|
98
99
|
}
|
|
99
100
|
async listComments() {
|
|
100
101
|
const url = `${RestReactionsClient.API}/repos/${this.repo}/issues/${this.prNumber}/comments`;
|
|
101
|
-
|
|
102
|
-
return Array.isArray(result) ? result : [];
|
|
102
|
+
return (await readAllPages(url, this.read));
|
|
103
103
|
}
|
|
104
104
|
async listReactions(commentId) {
|
|
105
105
|
const url = `${RestReactionsClient.API}/repos/${this.repo}/issues/comments/${commentId}/reactions`;
|
|
106
|
-
const result = await this.
|
|
107
|
-
if (!Array.isArray(result))
|
|
108
|
-
return [];
|
|
106
|
+
const result = await readAllPages(url, this.read);
|
|
109
107
|
const rows = [];
|
|
110
108
|
for (const raw of result) {
|
|
111
109
|
if (typeof raw !== 'object' || raw === null)
|
|
@@ -155,7 +153,13 @@ export function tallyAdjudications(reactions) {
|
|
|
155
153
|
// neither of which starts with a backtick, so anchoring on the second cell's
|
|
156
154
|
// leading backtick selects exactly the finding rows. Paths never contain `|`
|
|
157
155
|
// or backticks (see `fileLabel` in pr-check.ts), so the naive anchor is safe.
|
|
158
|
-
|
|
156
|
+
//
|
|
157
|
+
// The optional `[` accommodates the permalinked cell — `fileLabel` wraps the
|
|
158
|
+
// path as `[`path`](<blob url>)` whenever a blob base is resolvable, which is
|
|
159
|
+
// the normal case in CI. Without it this regex matched nothing on every posted
|
|
160
|
+
// comment and `activeFindingPaths` returned `[]`, zeroing the precision
|
|
161
|
+
// denominator silently instead of failing (#490, #508).
|
|
162
|
+
const FINDING_ROW_RE = /^\|[^|]*\|\s*\[?`([^`]+)`/;
|
|
159
163
|
/**
|
|
160
164
|
* Extract the file paths of the ACTIVE findings shown in a sticky-comment body
|
|
161
165
|
* (PURE). Reads the rendered table `render(fmt='comment')` emitted — this is
|
|
@@ -34,8 +34,10 @@
|
|
|
34
34
|
import { createHash, randomBytes } from 'node:crypto';
|
|
35
35
|
import { mkdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
36
36
|
import { dirname, join } from 'node:path';
|
|
37
|
-
import {
|
|
38
|
-
|
|
37
|
+
import { coverageDegradedNotice, coverageStatus, } from './coverage.js';
|
|
38
|
+
import { combineNotices, render } from './pr-check.js';
|
|
39
|
+
// 1.1 adds the additive `coverage` block (#554); readers of 1.0 are unaffected.
|
|
40
|
+
export const SCHEMA_VERSION = '1.1';
|
|
39
41
|
export const SOURCE = 'canary-pr-guardian';
|
|
40
42
|
const REF_SAFE = /[^A-Za-z0-9._-]/g;
|
|
41
43
|
const REF_MAX = 100; // cap the sanitized ref so a long branch never hits ENAMETOOLONG
|
|
@@ -94,7 +96,11 @@ function isoUtcNow() {
|
|
|
94
96
|
*/
|
|
95
97
|
export function buildAnalysisRecord(findings, args) {
|
|
96
98
|
const { ref, gate, effective_tier, degraded_notice, exit_code } = args;
|
|
97
|
-
|
|
99
|
+
// #554: the record states BOTH degradations — the tier's and the coverage
|
|
100
|
+
// input's — so "no findings" can never be read as "coverage said so".
|
|
101
|
+
const coverage = args.coverage ?? null;
|
|
102
|
+
const notice = combineNotices(degraded_notice, coverage ? coverageDegradedNotice(coverage) : null);
|
|
103
|
+
const inner = JSON.parse(render(findings, 'json', effective_tier, notice));
|
|
98
104
|
const active = findings.filter((f) => !f.suppressed);
|
|
99
105
|
const suppressed = findings.filter((f) => f.suppressed);
|
|
100
106
|
const byFidelity = {};
|
|
@@ -110,7 +116,10 @@ export function buildAnalysisRecord(findings, args) {
|
|
|
110
116
|
checked: args.checked ?? 0,
|
|
111
117
|
abstained: args.abstained ?? false,
|
|
112
118
|
tier: effective_tier,
|
|
113
|
-
degradedNotice:
|
|
119
|
+
degradedNotice: notice,
|
|
120
|
+
coverage: coverage === null
|
|
121
|
+
? null
|
|
122
|
+
: { status: coverageStatus(coverage), ...coverage },
|
|
114
123
|
summary: {
|
|
115
124
|
total: findings.length,
|
|
116
125
|
unaddressed: active.length,
|
|
@@ -53,12 +53,12 @@ import { AuthoringContext, InSessionAgentProbe, InSessionAgentTier, decideBlock,
|
|
|
53
53
|
import { RestReactionsClient, collectAdjudications, loadAdjudicationRecords, renderPrecision, summarizePrecision, } from './adjudication.js';
|
|
54
54
|
import { emitAnalysis } from './analysis-emit.js';
|
|
55
55
|
import { gateOutcome } from '../core/gate-result.js';
|
|
56
|
-
import { resolveCoverage, validateCoverageJson, } from './coverage.js';
|
|
56
|
+
import { coverageDegradedNotice, resolveCoverage, resolveCoverageWithInput, validateCoverageJson, } from './coverage.js';
|
|
57
57
|
import { buildApiDelta, writeApiDelta } from './delta-emitter.js';
|
|
58
58
|
import { extractApiDiff } from './diff-extractor.js';
|
|
59
59
|
import { HardGateAbstained, HardGateBlocked, RestBranchProtectionClient, applyHardGate, renderPlaybook, } from './hard-gate.js';
|
|
60
60
|
import { mapImpact } from './impact-mapper.js';
|
|
61
|
-
import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
|
|
61
|
+
import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestSupportUnits, filterTestUnits, filterTypeOnlyUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
|
|
62
62
|
import { RestGitHubClient, degradationAnnotation, upsertStickyComment, } from './pr-comment.js';
|
|
63
63
|
import { buildSummary } from './summary-emitter.js';
|
|
64
64
|
import { resolveTier } from './tier.js';
|
|
@@ -202,6 +202,53 @@ export function prContextFromEnv(env) {
|
|
|
202
202
|
}
|
|
203
203
|
return null;
|
|
204
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* The `https://github.com/<owner>/<repo>/blob/<sha>` prefix for comment
|
|
207
|
+
* permalinks, or `null` when it cannot be resolved.
|
|
208
|
+
*
|
|
209
|
+
* Prefers `pull_request.head.sha` from the event payload over `GITHUB_SHA`: on
|
|
210
|
+
* a `pull_request` event `GITHUB_SHA` is the ephemeral merge commit, and a blob
|
|
211
|
+
* URL against it can 404 once the ref is gone. The head SHA is a real commit on
|
|
212
|
+
* the contributor's branch and stays resolvable.
|
|
213
|
+
*
|
|
214
|
+
* Returns `null` rather than a partial URL whenever repo or SHA is missing, so
|
|
215
|
+
* {@link render} falls back to plain code text. That degradation is deliberate:
|
|
216
|
+
* an unresolvable link still *looks* clickable, which is worse than no link.
|
|
217
|
+
*/
|
|
218
|
+
export function blobBaseFromEnv(env) {
|
|
219
|
+
const repo = env['GITHUB_REPOSITORY'];
|
|
220
|
+
if (!repo || !repo.includes('/'))
|
|
221
|
+
return null;
|
|
222
|
+
const sha = headShaFromEvent(env['GITHUB_EVENT_PATH']) ?? env['GITHUB_SHA'];
|
|
223
|
+
if (!sha)
|
|
224
|
+
return null;
|
|
225
|
+
return `https://github.com/${repo}/blob/${sha}`;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* `pull_request.head.sha` from the event payload at `eventPath`, or `null`.
|
|
229
|
+
*
|
|
230
|
+
* Every failure — no path, unreadable file, non-JSON body, a payload without a
|
|
231
|
+
* `pull_request` — returns `null` so {@link blobBaseFromEnv} falls through to
|
|
232
|
+
* `GITHUB_SHA` rather than dropping links entirely: a merge-commit link still
|
|
233
|
+
* beats no link.
|
|
234
|
+
*/
|
|
235
|
+
function headShaFromEvent(eventPath) {
|
|
236
|
+
if (!eventPath)
|
|
237
|
+
return null;
|
|
238
|
+
try {
|
|
239
|
+
// Optional chaining carries the shape check: on a payload that is not an
|
|
240
|
+
// object (a bare number or string), `?.` short-circuits to `undefined`
|
|
241
|
+
// exactly as an explicit `typeof === 'object'` guard would.
|
|
242
|
+
const event = JSON.parse(readFileSync(eventPath, 'utf-8'));
|
|
243
|
+
const head = event?.pull_request?.head?.sha;
|
|
244
|
+
if (typeof head !== 'string')
|
|
245
|
+
return null;
|
|
246
|
+
return head || null;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
205
252
|
/**
|
|
206
253
|
* At-desk fork signal (guard b), FAIL-CLOSED on ambiguity.
|
|
207
254
|
*
|
|
@@ -787,8 +834,8 @@ function precisionCmd(opts, deps) {
|
|
|
787
834
|
* PR context is resolvable from env, prints the body instead of crashing; a
|
|
788
835
|
* read-only-token degradation is surfaced LOUDLY.
|
|
789
836
|
*/
|
|
790
|
-
async function postStickyComment(findings, resolution, deps) {
|
|
791
|
-
const body = render(findings, 'comment', resolution.effective, resolution.degraded_notice);
|
|
837
|
+
async function postStickyComment(findings, resolution, deps, gateMeta = null) {
|
|
838
|
+
const body = render(findings, 'comment', resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env));
|
|
792
839
|
const ctx = prContextFromEnv(deps.env);
|
|
793
840
|
if (ctx === null) {
|
|
794
841
|
deps.out(`guardian: no PR context in env ${EM_DASH} printing instead.`);
|
|
@@ -806,10 +853,17 @@ async function postStickyComment(findings, resolution, deps) {
|
|
|
806
853
|
// D7: every filtered path stays visible as a SkipEntry, never folded
|
|
807
854
|
// into "passed". One entry per path so the rendered count still equals
|
|
808
855
|
// the path count the old `N path(s) skipped` line reported.
|
|
809
|
-
function prCheckSkipEntries(skipped, testUnits, barrelUnits, noisePaths = []) {
|
|
856
|
+
function prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits = [], noisePaths = [], typeOnlyUnits = []) {
|
|
810
857
|
return [
|
|
811
858
|
...skipped.map((u) => ({ name: u.path, reason: 'skipGlobs' })),
|
|
812
859
|
...testUnits.map((u) => ({ name: u.path, reason: 'test path' })),
|
|
860
|
+
// #565: distinct from 'test path' on purpose -- these are test
|
|
861
|
+
// infrastructure recognised by filename idiom, and adjudication needs to
|
|
862
|
+
// tell the two suppression causes apart.
|
|
863
|
+
...supportUnits.map((u) => ({ name: u.path, reason: 'test support' })),
|
|
864
|
+
// #562: likewise distinct -- adjudication has to be able to measure this
|
|
865
|
+
// class separately, since it is the one that held precision at 13/20.
|
|
866
|
+
...typeOnlyUnits.map((u) => ({ name: u.path, reason: 'type-only module' })),
|
|
813
867
|
...barrelUnits.map((u) => ({
|
|
814
868
|
name: u.path,
|
|
815
869
|
reason: 're-export barrel',
|
|
@@ -839,7 +893,12 @@ function abstainPrCheck(skipped, format, deps) {
|
|
|
839
893
|
for (const line of PR_CHECK_ABSTAIN_REMEDIATION)
|
|
840
894
|
deps.out(line);
|
|
841
895
|
if (format === 'json') {
|
|
842
|
-
deps.out(ensureAscii(JSON.stringify(
|
|
896
|
+
deps.out(ensureAscii(JSON.stringify(
|
|
897
|
+
// #579: `skipped` carries the denominator the abstention collapsed
|
|
898
|
+
// to. Without it a consumer sees `abstained: true` and cannot tell
|
|
899
|
+
// WHAT was dropped or why -- the #508 class one layer down, on the
|
|
900
|
+
// only surface a machine can read.
|
|
901
|
+
{ findings: [], tier: 0, checked: 0, abstained: true, skipped }, null, 2)));
|
|
843
902
|
}
|
|
844
903
|
throw new CliExit(outcome.exitCode); // EXIT_ABSTAINED
|
|
845
904
|
}
|
|
@@ -923,19 +982,31 @@ async function prCheckCmd(opts, deps) {
|
|
|
923
982
|
const [keptSkip, skipped] = filterSkipped(units, config.skip_globs);
|
|
924
983
|
// FIX A: drop test-path units -- a test does not itself need a test.
|
|
925
984
|
const [keptTest, testUnits] = filterTestUnits(keptSkip);
|
|
985
|
+
// #565: drop test *support* units -- a conftest / fixture module is the
|
|
986
|
+
// harness the tests run inside, so no test can cover it at any tier.
|
|
987
|
+
const [keptSupport, supportUnits] = filterTestSupportUnits(keptTest);
|
|
988
|
+
// #562: drop modules with no runtime content -- an interface cannot be
|
|
989
|
+
// executed, so its uncovered lines are a finding no test could ever satisfy.
|
|
990
|
+
// `.` (cwd), matching the repoRoot convention `resolveCoverageWithInput`
|
|
991
|
+
// already uses for the heuristic tier's own file reads.
|
|
992
|
+
const [keptTyped, typeOnlyUnits] = filterTypeOnlyUnits(keptSupport, '.');
|
|
926
993
|
// FIX 2: drop pure re-export/barrel files.
|
|
927
994
|
const reexportPaths = findReexportOnly(diffText);
|
|
928
|
-
const barrelUnits =
|
|
929
|
-
const kept =
|
|
995
|
+
const barrelUnits = keptTyped.filter((u) => reexportPaths.has(u.path));
|
|
996
|
+
const kept = keptTyped.filter((u) => !reexportPaths.has(u.path));
|
|
930
997
|
// Advisory weak-test findings for added tests that assert nothing.
|
|
931
998
|
const weakFindings = config.weak_tests
|
|
932
999
|
? buildWeakTestFindings(testUnits, diffText)
|
|
933
1000
|
: [];
|
|
934
|
-
const preFilterSkipped = skipped.length +
|
|
1001
|
+
const preFilterSkipped = skipped.length +
|
|
1002
|
+
testUnits.length +
|
|
1003
|
+
barrelUnits.length +
|
|
1004
|
+
supportUnits.length +
|
|
1005
|
+
typeOnlyUnits.length;
|
|
935
1006
|
if (kept.length === 0 && weakFindings.length === 0) {
|
|
936
|
-
abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits), opts.format, deps);
|
|
1007
|
+
abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, [], typeOnlyUnits), opts.format, deps);
|
|
937
1008
|
}
|
|
938
|
-
const results =
|
|
1009
|
+
const { results, coverage } = resolveCoverageWithInput(kept, {
|
|
939
1010
|
coveragePath: opts.coverage ?? null,
|
|
940
1011
|
// #320: under a hard gate the graph tier requires a DIRECT test->source edge
|
|
941
1012
|
// (depth 1); soft stays unbounded. An explicit config value wins.
|
|
@@ -953,7 +1024,7 @@ async function prCheckCmd(opts, deps) {
|
|
|
953
1024
|
// SKIP rather than rendering an empty "0 unaddressed" report -- an adopter
|
|
954
1025
|
// must be able to tell "nothing was judgeable" from "everything passed".
|
|
955
1026
|
if (scoredResults.length === 0 && findings.length === 0) {
|
|
956
|
-
abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits, noiseResults.map((r) => r.unit.path)), opts.format, deps);
|
|
1027
|
+
abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, noiseResults.map((r) => r.unit.path), typeOnlyUnits), opts.format, deps);
|
|
957
1028
|
}
|
|
958
1029
|
// SC-5 (PR half): resolve the requested tier against actual capability. No
|
|
959
1030
|
// agent runtime exists (default NoAgentProbe), so any `pr.tier > 0` drops to
|
|
@@ -966,6 +1037,22 @@ async function prCheckCmd(opts, deps) {
|
|
|
966
1037
|
// Compute the gate result once, up front: the emitted record carries it and it
|
|
967
1038
|
// is the process exit at the end (SC-4 -- emit never changes the exit logic).
|
|
968
1039
|
const exitCode = computeExitCode(findings, effectiveGate);
|
|
1040
|
+
// #554: every surface below carries the coverage-input state, so a run that
|
|
1041
|
+
// never saw a coverage report cannot present as one that checked and passed.
|
|
1042
|
+
const gateMeta = {
|
|
1043
|
+
checked: scoredResults.length,
|
|
1044
|
+
abstained: false,
|
|
1045
|
+
coverage,
|
|
1046
|
+
};
|
|
1047
|
+
const coverageNotice = coverageDegradedNotice(coverage);
|
|
1048
|
+
if (coverageNotice) {
|
|
1049
|
+
// `--format json` owns stdout: a `::warning::` line there would make the
|
|
1050
|
+
// document unparseable, so the annotation goes to stderr on that path. Both
|
|
1051
|
+
// streams are scanned for workflow commands, so CI still sees it.
|
|
1052
|
+
const machineStdout = !opts.postComment && !opts.emitAnalysis && opts.format === 'json';
|
|
1053
|
+
(machineStdout ? deps.err : deps.out)(degradationAnnotation(coverageNotice));
|
|
1054
|
+
appendStepSummary(deps.env, coverageNotice);
|
|
1055
|
+
}
|
|
969
1056
|
let commentPosted = false;
|
|
970
1057
|
if (opts.emitAnalysis) {
|
|
971
1058
|
// SC-10 producer half: write ONE record to the analyses channel. On an
|
|
@@ -981,6 +1068,7 @@ async function prCheckCmd(opts, deps) {
|
|
|
981
1068
|
exit_code: exitCode,
|
|
982
1069
|
checked: scoredResults.length,
|
|
983
1070
|
abstained: false, // an abstained run exits before emit (see plan)
|
|
1071
|
+
coverage,
|
|
984
1072
|
});
|
|
985
1073
|
if (res.action === 'emitted') {
|
|
986
1074
|
deps.out(`guardian: wrote analysis record ${RIGHT_ARROW} ${res.path}`);
|
|
@@ -991,18 +1079,18 @@ async function prCheckCmd(opts, deps) {
|
|
|
991
1079
|
deps.out(degradationAnnotation(res.notice));
|
|
992
1080
|
appendStepSummary(deps.env, res.notice);
|
|
993
1081
|
deps.err(res.notice);
|
|
994
|
-
await postStickyComment(findings, resolution, deps);
|
|
1082
|
+
await postStickyComment(findings, resolution, deps, gateMeta);
|
|
995
1083
|
commentPosted = true;
|
|
996
1084
|
}
|
|
997
1085
|
}
|
|
998
1086
|
if (opts.postComment && !commentPosted) {
|
|
999
1087
|
// Explicit `--post-comment`: post/upsert unless the SC-10 fallback already
|
|
1000
1088
|
// posted this run.
|
|
1001
|
-
await postStickyComment(findings, resolution, deps);
|
|
1089
|
+
await postStickyComment(findings, resolution, deps, gateMeta);
|
|
1002
1090
|
}
|
|
1003
1091
|
else if (!opts.emitAnalysis && !opts.postComment) {
|
|
1004
1092
|
// Local, non-posting default: render to stdout in `--format`.
|
|
1005
|
-
deps.out(render(findings, opts.format, resolution.effective, resolution.degraded_notice,
|
|
1093
|
+
deps.out(render(findings, opts.format, resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env)));
|
|
1006
1094
|
}
|
|
1007
1095
|
throw new CliExit(exitCode);
|
|
1008
1096
|
}
|
|
@@ -1016,8 +1104,17 @@ function buildGaps(diffText, config, coveragePath, graphMaxDepth) {
|
|
|
1016
1104
|
const units = scopeDiff(diffText);
|
|
1017
1105
|
const [keptSkip] = filterSkipped(units, config.skip_globs);
|
|
1018
1106
|
const [keptTest] = filterTestUnits(keptSkip);
|
|
1107
|
+
// #565: never hand the authoring tier a conftest/fixture module either -- a
|
|
1108
|
+
// generated "test for the fixture" is exactly the inversion the gate exists
|
|
1109
|
+
// to prevent, except it also writes a file (measured: this surface proposed
|
|
1110
|
+
// `scripts/otel_bootstrap/test_conftest_otel.py`).
|
|
1111
|
+
const [keptSupport] = filterTestSupportUnits(keptTest);
|
|
1112
|
+
// #562: nor a type-only module -- measured, this surface proposed writing
|
|
1113
|
+
// `src/ConfirmModal/types.test.ts` to test an interface, a file whose only
|
|
1114
|
+
// possible content is an assertion about nothing.
|
|
1115
|
+
const [keptTyped] = filterTypeOnlyUnits(keptSupport, '.');
|
|
1019
1116
|
const reexportPaths = findReexportOnly(diffText);
|
|
1020
|
-
const kept =
|
|
1117
|
+
const kept = keptTyped.filter((u) => !reexportPaths.has(u.path));
|
|
1021
1118
|
if (kept.length === 0)
|
|
1022
1119
|
return [];
|
|
1023
1120
|
const results = resolveCoverage(kept, { coveragePath, graphMaxDepth });
|