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.
@@ -301,16 +301,56 @@ function scanAssertionFreeJs(code, file) {
301
301
  }
302
302
  return out;
303
303
  }
304
- function detectFramework(path) {
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 (suffix === '.ts' || suffix === '.js')
336
+ if (JS_EXT_SET.has(suffix))
312
337
  return 'vitest';
313
- return 'pytest';
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 = framework || detectFramework(path);
365
+ const fw = requireFramework(path, framework);
326
366
  const findings = [
327
367
  ...scanFlakiness(lines, path),
328
368
  ...scanSelectors(lines, path),
@@ -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 (harness's `AnalysisArchive` reads every
41
- * `*.json` in `.harness/analyses/`) while never colliding with a pr-check
42
- * findings record: those are `canary-pr-guardian-<sanitized-ref>.json` and a
43
- * ref is sanitized from a git ref / `pr-<n>`, never `adjudication-pr-<n>`.
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 here; no unit test exercises this class. Both endpoints
72
- * are reads, so a fork's read-only token is sufficient.
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
- constructor(repo, prNumber, token) {
87
+ read;
88
+ constructor(repo, prNumber, token, read) {
80
89
  this.repo = repo;
81
90
  this.prNumber = prNumber;
82
- this.token = token;
83
- }
84
- async get(url) {
85
- const resp = await fetch(url, {
86
- method: 'GET',
87
- headers: {
88
- Authorization: `Bearer ${this.token}`,
89
- Accept: 'application/vnd.github+json',
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
- const result = await this.get(url);
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.get(url);
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
- const FINDING_ROW_RE = /^\|[^|]*\|\s*`([^`]+)`/;
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 { render } from './pr-check.js';
38
- export const SCHEMA_VERSION = '1.0';
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
- const inner = JSON.parse(render(findings, 'json', effective_tier, degraded_notice));
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: degraded_notice,
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({ findings: [], tier: 0, checked: 0, abstained: true }, null, 2)));
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 = keptTest.filter((u) => reexportPaths.has(u.path));
929
- const kept = keptTest.filter((u) => !reexportPaths.has(u.path));
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 + testUnits.length + barrelUnits.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 = resolveCoverage(kept, {
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, { checked: scoredResults.length, abstained: false }));
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 = keptTest.filter((u) => !reexportPaths.has(u.path));
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 });