canary-test-cli 7.1.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/skills/README.md +327 -0
- package/agents/skills/canary:generate.md +49 -0
- package/agents/skills/canary:init.md +37 -0
- package/agents/skills/canary:migrate.md +66 -0
- package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
- package/agents/skills/claude-code/canary-batwoman/SKILL.md +119 -0
- package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +187 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +270 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +178 -0
- package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
- package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
- package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
- package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
- package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +197 -0
- package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
- package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
- package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
- package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
- package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
- package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
- package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
- package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
- package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
- package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +144 -0
- package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
- package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
- package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
- package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
- package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
- package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
- package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-screech/SKILL.md +109 -0
- package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
- package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
- package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
- package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
- package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -0
- package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
- package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
- package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
- package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
- package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
- package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
- package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
- package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
- package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
- package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
- package/agents/skills/lib/parse-args.mjs +275 -0
- package/dist/engine/analysis/batwoman/audit.js +39 -0
- package/dist/engine/analysis/batwoman/closure.js +159 -0
- package/dist/engine/analysis/batwoman/gh-history.js +119 -0
- package/dist/engine/analysis/batwoman/probes.js +195 -0
- package/dist/engine/analysis/batwoman/registry.js +142 -0
- package/dist/engine/analysis/batwoman/render.js +194 -0
- package/dist/engine/analysis/batwoman/run-window.js +122 -0
- package/dist/engine/analysis/batwoman/text.js +84 -0
- package/dist/engine/analysis/batwoman/triggers.js +122 -0
- package/dist/engine/analysis/batwoman/verdict.js +64 -0
- package/dist/engine/analysis/cli.js +47 -14
- package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
- package/dist/engine/batwoman-cli.js +119 -0
- package/dist/engine/ci-ready-cli.js +71 -0
- package/dist/engine/cli-commands.js +49 -72
- package/dist/engine/cli.core.js +16 -0
- package/dist/engine/company-knowledge-cli.js +10 -2
- package/dist/engine/core/ci-ready.js +112 -0
- package/dist/engine/core/company-knowledge.js +8 -0
- package/dist/engine/core/migrator.js +147 -20
- package/dist/engine/core/permission-matrix.js +219 -0
- package/dist/engine/core/quality-scorer.js +27 -19
- package/dist/engine/core/scaling-curve.js +143 -0
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +103 -3
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/string-literals.js +3 -1
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/vacuity-scanner.js +330 -15
- package/dist/engine/core/workflow-discovery.js +41 -23
- package/dist/engine/guardian/adjudication-github.js +136 -0
- package/dist/engine/guardian/adjudication.js +119 -340
- package/dist/engine/guardian/analysis-emit.js +7 -2
- package/dist/engine/guardian/cli.js +277 -249
- package/dist/engine/guardian/coverage.js +2 -1
- package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
- package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
- package/dist/engine/guardian/diff-coverage/paths.js +5 -9
- package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
- package/dist/engine/guardian/diff-extractor.js +31 -32
- package/dist/engine/guardian/pr-check.js +354 -223
- package/dist/engine/guardian/pr-comment.js +35 -58
- package/dist/engine/guardian/weak-test.js +236 -0
- package/dist/engine/mcp-server.js +67 -4
- package/dist/engine/permission-matrix-cli.js +51 -0
- package/dist/engine/scaling-curve-cli.js +147 -0
- package/dist/engine/skills-cli.js +171 -51
- package/dist/engine/workflow-cli.js +85 -65
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/package.json +3 -2
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
* test never terminates the process.
|
|
20
20
|
*
|
|
21
21
|
* Command surface (kebab-case names; the first seven match the shipping Typer
|
|
22
|
-
* CLI, the last
|
|
22
|
+
* CLI, the last is a TS-native addition, derived per ADR 0025):
|
|
23
23
|
* analyze | validate-coverage | harden-gate | pr-check | author-plan |
|
|
24
|
-
* mark-authored | watch |
|
|
24
|
+
* mark-authored | watch | precision.
|
|
25
25
|
*
|
|
26
26
|
* Python->TS nuances honored:
|
|
27
27
|
* - `json.dumps(obj, indent=2)` -> `ensureAscii(JSON.stringify(obj, null, 2))`
|
|
@@ -50,16 +50,18 @@ import { Command, Option } from 'commander';
|
|
|
50
50
|
import { load as loadYaml } from 'js-yaml';
|
|
51
51
|
import pc from 'picocolors';
|
|
52
52
|
import { AuthoringContext, InSessionAgentProbe, InSessionAgentTier, decideBlock, } from './agent-tier.js';
|
|
53
|
-
import {
|
|
53
|
+
import { deriveReport, renderReport } from './adjudication.js';
|
|
54
|
+
import { GitHubAdjudicationSource, collectEvidence, } from './adjudication-github.js';
|
|
54
55
|
import { emitAnalysis } from './analysis-emit.js';
|
|
55
|
-
import { gateOutcome } from '../core/gate-result.js';
|
|
56
|
-
import { coverageDegradedNotice, resolveCoverage, resolveCoverageWithInput, validateCoverageJson, } from './coverage.js';
|
|
56
|
+
import { EXIT_ABSTAINED, gateOutcome, } from '../core/gate-result.js';
|
|
57
|
+
import { coverageDegradedNotice, coverageDeltaNotice, isSourcePath, resolveCoverage, resolveCoverageDelta, resolveCoverageWithInput, validateCoverageJson, } from './coverage.js';
|
|
57
58
|
import { buildApiDelta, writeApiDelta } from './delta-emitter.js';
|
|
58
59
|
import { extractApiDiff } from './diff-extractor.js';
|
|
59
60
|
import { HardGateAbstained, HardGateBlocked, RestBranchProtectionClient, applyHardGate, renderPlaybook, } from './hard-gate.js';
|
|
60
61
|
import { mapImpact } from './impact-mapper.js';
|
|
61
62
|
import { ensureAscii } from '../util/ensure-ascii.js';
|
|
62
|
-
import { applySuppressions, buildFindings,
|
|
63
|
+
import { MERGE_REF_WARNING, provenanceLine, applySuppressions, buildFindings, buildRegressionFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestSupportUnits, filterTestUnits, filterTypeOnlyUnits, findReexportOnly, isCoverageAbstention, loadGuardianConfig, renderFindings, scopeDiff, } from './pr-check.js';
|
|
64
|
+
import { buildWeakTestFindings } from './weak-test.js';
|
|
63
65
|
import { RestGitHubClient, degradationAnnotation, upsertStickyComment, } from './pr-comment.js';
|
|
64
66
|
import { buildSummary } from './summary-emitter.js';
|
|
65
67
|
import { resolveTier } from './tier.js';
|
|
@@ -101,6 +103,33 @@ export class WatchInterruptError extends Error {
|
|
|
101
103
|
this.name = 'WatchInterruptError';
|
|
102
104
|
}
|
|
103
105
|
}
|
|
106
|
+
/** Run `git`; `null` when the binary is missing (Python OSError fail-safe). */
|
|
107
|
+
function spawnGit(args, cwd) {
|
|
108
|
+
const res = spawnSync('git', args, {
|
|
109
|
+
encoding: 'utf-8',
|
|
110
|
+
maxBuffer: Infinity,
|
|
111
|
+
...(cwd ? { cwd } : {}),
|
|
112
|
+
});
|
|
113
|
+
if (res.error)
|
|
114
|
+
return null;
|
|
115
|
+
return { code: res.status ?? 1, stdout: res.stdout ?? '' };
|
|
116
|
+
}
|
|
117
|
+
/** Run `gh` with a 30s timeout; `failed` when it could not be spawned. */
|
|
118
|
+
function spawnGh(args) {
|
|
119
|
+
const res = spawnSync('gh', args, {
|
|
120
|
+
encoding: 'utf-8',
|
|
121
|
+
timeout: 30_000,
|
|
122
|
+
maxBuffer: Infinity,
|
|
123
|
+
});
|
|
124
|
+
if (res.error)
|
|
125
|
+
return { status: null, stdout: '', stderr: '', failed: true };
|
|
126
|
+
return {
|
|
127
|
+
status: res.status,
|
|
128
|
+
stdout: res.stdout ?? '',
|
|
129
|
+
stderr: res.stderr ?? '',
|
|
130
|
+
failed: false,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
104
133
|
/** Process-backed defaults for production (the `guardianCommand` export). */
|
|
105
134
|
export function defaultDeps() {
|
|
106
135
|
return {
|
|
@@ -116,34 +145,10 @@ export function defaultDeps() {
|
|
|
116
145
|
},
|
|
117
146
|
env: process.env,
|
|
118
147
|
cwd: () => process.cwd(),
|
|
119
|
-
runGit:
|
|
120
|
-
|
|
121
|
-
encoding: 'utf-8',
|
|
122
|
-
maxBuffer: Infinity,
|
|
123
|
-
...(cwd ? { cwd } : {}),
|
|
124
|
-
});
|
|
125
|
-
if (res.error)
|
|
126
|
-
return null; // missing binary -> Python OSError fail-safe
|
|
127
|
-
return { code: res.status ?? 1, stdout: res.stdout ?? '' };
|
|
128
|
-
},
|
|
129
|
-
runGh: (args) => {
|
|
130
|
-
const res = spawnSync('gh', args, {
|
|
131
|
-
encoding: 'utf-8',
|
|
132
|
-
timeout: 30_000,
|
|
133
|
-
maxBuffer: Infinity,
|
|
134
|
-
});
|
|
135
|
-
if (res.error) {
|
|
136
|
-
return { status: null, stdout: '', stderr: '', failed: true };
|
|
137
|
-
}
|
|
138
|
-
return {
|
|
139
|
-
status: res.status,
|
|
140
|
-
stdout: res.stdout ?? '',
|
|
141
|
-
stderr: res.stderr ?? '',
|
|
142
|
-
failed: false,
|
|
143
|
-
};
|
|
144
|
-
},
|
|
148
|
+
runGit: spawnGit,
|
|
149
|
+
runGh: spawnGh,
|
|
145
150
|
buildCommentClient: (repo, prNumber) => new RestGitHubClient(repo, prNumber, process.env['GITHUB_TOKEN'] ?? ''),
|
|
146
|
-
|
|
151
|
+
buildAdjudicationSource: (repo, token) => new GitHubAdjudicationSource(repo, token),
|
|
147
152
|
buildBranchProtectionClient: (repo, token) => new RestBranchProtectionClient(repo, token),
|
|
148
153
|
makeAgentTier: () => new InSessionAgentTier(),
|
|
149
154
|
sleep: (secs) => new Promise((resolve) => setTimeout(resolve, secs * 1000)),
|
|
@@ -320,20 +325,9 @@ function headSha(deps, root) {
|
|
|
320
325
|
return res.stdout.trim().toLowerCase() || null;
|
|
321
326
|
}
|
|
322
327
|
/**
|
|
323
|
-
* Is the loop guard live
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
* This is the surviving half of the stage-and-block-once contract (#456). The
|
|
327
|
-
* component that CLEARED the sentinel on the next commit
|
|
328
|
-
* (`hooks/guardian_precommit.py`) was deleted as dead code in #449, which left
|
|
329
|
-
* `author-plan` fail-closed forever: author once in a clone and Tier-2 authoring
|
|
330
|
-
* never ran again. Stamping HEAD makes the guard self-expiring -- once the human
|
|
331
|
-
* reviews and commits the staged tests, `HEAD` moves, the stamp stops matching,
|
|
332
|
-
* and authoring re-enables itself with no manual step and no hook.
|
|
333
|
-
*
|
|
334
|
-
* Every unverifiable state FAILS OPEN (returns `false`, authoring allowed):
|
|
335
|
-
* missing or unreadable sentinel, a malformed/absent `HEAD` header, or a `HEAD`
|
|
336
|
-
* we cannot resolve. Fail-closed here is exactly the bug being fixed.
|
|
328
|
+
* Is the loop guard live, i.e. is the sentinel stamped at the CURRENT `HEAD`
|
|
329
|
+
* (#456)? Stamping HEAD makes the guard self-expiring once the staged tests are
|
|
330
|
+
* committed. Every unverifiable state FAILS OPEN (returns `false`).
|
|
337
331
|
*/
|
|
338
332
|
function authoredSentinelActive(deps, root) {
|
|
339
333
|
let body;
|
|
@@ -374,6 +368,71 @@ function readWorktreeDiff(deps) {
|
|
|
374
368
|
return unstaged;
|
|
375
369
|
return deps.runGit(['diff', '--staged'])?.stdout ?? '';
|
|
376
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* The PR head sha the CI event declares, if this is a `pull_request` event.
|
|
373
|
+
*
|
|
374
|
+
* Distinct from {@link eventBaseSha}: that answers "what are we diffing
|
|
375
|
+
* against", this answers "what SHOULD the diffed HEAD be". They are compared in
|
|
376
|
+
* {@link detectMergeRef} (#761).
|
|
377
|
+
*/
|
|
378
|
+
function eventHeadSha(env) {
|
|
379
|
+
const eventPath = env['GITHUB_EVENT_PATH'];
|
|
380
|
+
if (!eventPath)
|
|
381
|
+
return null;
|
|
382
|
+
let sha;
|
|
383
|
+
try {
|
|
384
|
+
const event = JSON.parse(readFileSync(eventPath, 'utf-8'));
|
|
385
|
+
sha = event?.pull_request?.head?.sha;
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
return typeof sha === 'string' && sha.trim() ? sha.trim() : null;
|
|
391
|
+
}
|
|
392
|
+
/** Resolve `HEAD` to a full sha, or null when git cannot answer. */
|
|
393
|
+
function resolveHeadSha(deps) {
|
|
394
|
+
const res = deps.runGit(['rev-parse', 'HEAD']);
|
|
395
|
+
if (res === null || res.code !== 0)
|
|
396
|
+
return null;
|
|
397
|
+
const sha = res.stdout.trim();
|
|
398
|
+
return sha || null;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* True when HEAD is a `pull_request` MERGE REF, not the PR head (#761): a diff
|
|
402
|
+
* to `refs/pull/<n>/merge` sweeps in every commit merged into base since the
|
|
403
|
+
* base sha (a one-file PR read as 43 files). A comparison against the event's
|
|
404
|
+
* PR head sha, false whenever either side is unknown.
|
|
405
|
+
*/
|
|
406
|
+
export function detectMergeRef(headSha, deps) {
|
|
407
|
+
if (deps.env['GITHUB_EVENT_NAME'] !== 'pull_request')
|
|
408
|
+
return false;
|
|
409
|
+
const declared = eventHeadSha(deps.env);
|
|
410
|
+
if (!declared || !headSha)
|
|
411
|
+
return false;
|
|
412
|
+
return declared !== headSha;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* The event-declared PR head, when it resolves to a local commit (#883).
|
|
416
|
+
*
|
|
417
|
+
* Diffing to it instead of `HEAD` judges the PR's own changes rather than the
|
|
418
|
+
* merge ref's widened set. Null outside a `pull_request` event or when the sha
|
|
419
|
+
* was not fetched (a shallow checkout) — the caller then diffs to `HEAD` and
|
|
420
|
+
* {@link detectMergeRef} still discloses the widening.
|
|
421
|
+
*/
|
|
422
|
+
function resolvePrHead(deps) {
|
|
423
|
+
if (deps.env['GITHUB_EVENT_NAME'] !== 'pull_request')
|
|
424
|
+
return null;
|
|
425
|
+
const sha = eventHeadSha(deps.env);
|
|
426
|
+
if (!sha)
|
|
427
|
+
return null;
|
|
428
|
+
const res = deps.runGit([
|
|
429
|
+
'rev-parse',
|
|
430
|
+
'--verify',
|
|
431
|
+
'--quiet',
|
|
432
|
+
`${sha}^{commit}`,
|
|
433
|
+
]);
|
|
434
|
+
return res !== null && res.code === 0 ? sha : null;
|
|
435
|
+
}
|
|
377
436
|
/** True when the process looks like a CI runner rather than a dev worktree. */
|
|
378
437
|
function isCiContext(env) {
|
|
379
438
|
return Boolean(env['GITHUB_ACTIONS'] || env['CI']);
|
|
@@ -438,19 +497,9 @@ function resolveBaseRev(deps) {
|
|
|
438
497
|
return null;
|
|
439
498
|
}
|
|
440
499
|
/**
|
|
441
|
-
* Resolve the diff `pr-check` should scope
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
* `--diff` omitted:
|
|
445
|
-
*
|
|
446
|
-
* - **In CI** with a resolvable base rev → `git diff <base>...HEAD`. The
|
|
447
|
-
* TRIPLE-dot form diffs against the merge base, so commits that land on the
|
|
448
|
-
* base branch mid-PR never appear as part of this PR's changed surface.
|
|
449
|
-
* - **Otherwise** → the at-desk working-tree diff ({@link readWorktreeDiff}).
|
|
450
|
-
*
|
|
451
|
-
* The legacy behavior was the working-tree diff unconditionally, which is empty
|
|
452
|
-
* on a clean CI checkout — the gate then scoped zero paths and exited 0, so an
|
|
453
|
-
* adopting repo could not tell a working gate from a broken one.
|
|
500
|
+
* Resolve the diff `pr-check` should scope (#369). An explicit `--diff` wins;
|
|
501
|
+
* in CI with a base rev it is `git diff <base>...HEAD` (merge base, so commits
|
|
502
|
+
* landing on base mid-PR stay out); otherwise the working-tree diff.
|
|
454
503
|
*/
|
|
455
504
|
export function readPrDiff(source, deps) {
|
|
456
505
|
if (source === '-') {
|
|
@@ -462,14 +511,20 @@ export function readPrDiff(source, deps) {
|
|
|
462
511
|
if (isCiContext(deps.env)) {
|
|
463
512
|
const base = resolveBaseRev(deps);
|
|
464
513
|
if (base !== null) {
|
|
465
|
-
const
|
|
514
|
+
const head = resolvePrHead(deps);
|
|
515
|
+
const res = deps.runGit(['diff', `${base}...${head ?? 'HEAD'}`]);
|
|
466
516
|
if (res !== null && res.code === 0) {
|
|
467
|
-
return { text: res.stdout, origin: 'ci-base', base };
|
|
517
|
+
return { text: res.stdout, origin: 'ci-base', base, head };
|
|
468
518
|
}
|
|
469
519
|
}
|
|
470
520
|
}
|
|
471
521
|
return { text: readWorktreeDiff(deps), origin: 'worktree', base: null };
|
|
472
522
|
}
|
|
523
|
+
// Built from the shared fragment so the annotation and the rendered provenance
|
|
524
|
+
// line cannot drift into describing the same defect two different ways (#761).
|
|
525
|
+
const MERGE_REF_NOTICE = `guardian: ${MERGE_REF_WARNING} — findings may name files this PR never ` +
|
|
526
|
+
'touched. Check out with `ref: ${{ github.event.pull_request.head.sha }}`, ' +
|
|
527
|
+
'or diff to that sha instead of HEAD.';
|
|
473
528
|
const EMPTY_CI_DIFF_NOTICE = 'guardian: 0 changed paths — fell back to a working-tree `git diff`, which ' +
|
|
474
529
|
'is empty on a clean CI checkout, so NOTHING was verified. Pass ' +
|
|
475
530
|
'`--diff <base>...<head>`, or checkout with `fetch-depth: 0` so the PR base ' +
|
|
@@ -682,20 +737,6 @@ function validateCoverageCmd(path, opts, deps) {
|
|
|
682
737
|
throw new CliExitError(1);
|
|
683
738
|
}
|
|
684
739
|
}
|
|
685
|
-
/**
|
|
686
|
-
* Print the precision evidence the promotion contract depends on (#490).
|
|
687
|
-
*
|
|
688
|
-
* The soft→hard promotion is earned by reviewer adjudication feeding
|
|
689
|
-
* `precision = TP / (TP + FP)`; before #490 that contract lived only in a
|
|
690
|
-
* comment with nothing feeding it. This surfaces the measured number — or an
|
|
691
|
-
* honest `unknown` over an empty sample — in the readiness output. Advisory:
|
|
692
|
-
* it informs the operator's decision, it does not block the registration.
|
|
693
|
-
*/
|
|
694
|
-
function reportPrecisionEvidence(analysesDir, deps) {
|
|
695
|
-
const summary = summarizePrecision(loadAdjudicationRecords(analysesDir));
|
|
696
|
-
const line = renderPrecision(summary);
|
|
697
|
-
deps.out(summary.precision === null ? pc.yellow(line) : line);
|
|
698
|
-
}
|
|
699
740
|
async function hardenGateCmd(opts, deps) {
|
|
700
741
|
const repo = opts.repo;
|
|
701
742
|
if (!repo) {
|
|
@@ -703,8 +744,9 @@ async function hardenGateCmd(opts, deps) {
|
|
|
703
744
|
throw new CliExitError(2);
|
|
704
745
|
}
|
|
705
746
|
const playbook = renderPlaybook(repo, opts.branch, opts.check);
|
|
706
|
-
//
|
|
707
|
-
|
|
747
|
+
// ADR 0025: precision is derived from merged PRs, not stored locally.
|
|
748
|
+
deps.out(pc.yellow(`guardian precision: not measured here ${EM_DASH} run ` +
|
|
749
|
+
'`canary guardian precision` (or the weekly Guardian precision workflow).'));
|
|
708
750
|
if (!opts.apply) {
|
|
709
751
|
deps.out(`${pc.bold('Dry run')} ${EM_DASH} would require the '${opts.check}' check on ${repo}@${opts.branch}.`);
|
|
710
752
|
deps.out('On --apply this merges into existing protection (or creates minimal ' +
|
|
@@ -755,65 +797,26 @@ async function hardenGateCmd(opts, deps) {
|
|
|
755
797
|
'(or run pr-check --gate hard).'));
|
|
756
798
|
}
|
|
757
799
|
/**
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
* channel) — an operator who asked for a collection must know it did not land.
|
|
800
|
+
* Derive guardian precision from merged PRs on demand (ADR 0025): the sticky's
|
|
801
|
+
* first vs last revision, the merged diff's suppressions, and nothing stored.
|
|
802
|
+
* Exits 2 without a repo or token rather than reporting an empty sample.
|
|
762
803
|
*/
|
|
763
|
-
async function
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
if (ctx !== null) {
|
|
769
|
-
repo = repo ?? ctx[0];
|
|
770
|
-
prNumber = prNumber ?? ctx[1];
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
if (!repo || prNumber === undefined) {
|
|
774
|
-
deps.out(`${pc.red(pc.bold(`${CROSS} no PR context`))} ${EM_DASH} pass --repo and --pr, or run in Actions.`);
|
|
804
|
+
async function precisionCmd(opts, deps) {
|
|
805
|
+
const token = deps.env['GITHUB_TOKEN'];
|
|
806
|
+
if (!opts.repo || !token) {
|
|
807
|
+
deps.out(`${pc.red(pc.bold(`${CROSS} precision needs --repo and GITHUB_TOKEN`))} ` +
|
|
808
|
+
`${EM_DASH} nothing was measured.`);
|
|
775
809
|
throw new CliExitError(2);
|
|
776
810
|
}
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
else if (res.action === 'collected' && res.record) {
|
|
787
|
-
deps.out(pc.green(`${CHECK} adjudication recorded (${res.record.tp} up / ` +
|
|
788
|
-
`${res.record.fp} down, ${res.record.granularity}-level) ` +
|
|
789
|
-
`${RIGHT_ARROW} ${res.path}`));
|
|
790
|
-
}
|
|
791
|
-
else if (res.action === 'no-comment') {
|
|
792
|
-
deps.out(`guardian: no sticky comment on ${repo}#${prNumber} ${EM_DASH} nothing to adjudicate.`);
|
|
793
|
-
}
|
|
794
|
-
else if (res.action === 'no-reactions') {
|
|
795
|
-
deps.out(`guardian: sticky comment on ${repo}#${prNumber} has no reviewer ` +
|
|
796
|
-
`verdicts yet ${EM_DASH} nothing recorded (no reaction is neutral, ` +
|
|
797
|
-
`not a data point).`);
|
|
798
|
-
}
|
|
799
|
-
if (res.action === 'unavailable') {
|
|
800
|
-
deps.out(pc.red(pc.bold(`${CROSS} ${res.notice ?? 'not persisted'}`)));
|
|
801
|
-
throw new CliExitError(1);
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
/** Aggregate the persisted adjudications into the promotion evidence (#490). */
|
|
805
|
-
function precisionCmd(opts, deps) {
|
|
806
|
-
const analysesDir = resolveAnalysesDir(opts.analysesDir, deps);
|
|
807
|
-
const records = loadAdjudicationRecords(analysesDir);
|
|
808
|
-
const summary = summarizePrecision(records);
|
|
809
|
-
if (opts.json) {
|
|
810
|
-
// `precision: null` is the honest zero-denominator value — consumers must
|
|
811
|
-
// treat it as unknown, never as 1.0 (#490).
|
|
812
|
-
deps.out(ensureAscii(JSON.stringify({ ...summary, records: records.length }, null, 2)));
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
const line = renderPrecision(summary);
|
|
816
|
-
deps.out(summary.precision === null ? pc.yellow(line) : line);
|
|
811
|
+
const since = new Date(Date.now() - opts.days * 86_400_000)
|
|
812
|
+
.toISOString()
|
|
813
|
+
.slice(0, 10);
|
|
814
|
+
const source = deps.buildAdjudicationSource(opts.repo, token);
|
|
815
|
+
const { evidence, scanned } = await collectEvidence(source, since);
|
|
816
|
+
const report = deriveReport(evidence, scanned);
|
|
817
|
+
deps.out(opts.json
|
|
818
|
+
? JSON.stringify({ since, ...report }, null, 2)
|
|
819
|
+
: `${renderReport(report)}\nwindow: merged since ${since}`);
|
|
817
820
|
}
|
|
818
821
|
// --- pr-check -----------------------------------------------------------------
|
|
819
822
|
/**
|
|
@@ -836,11 +839,17 @@ async function postStickyComment(findings, resolution, deps, gateMeta = null) {
|
|
|
836
839
|
appendStepSummary(deps.env, res.notice);
|
|
837
840
|
}
|
|
838
841
|
}
|
|
842
|
+
/** An abstained run judged nothing, so no agent tier was ever in play. */
|
|
843
|
+
const NO_TIER = {
|
|
844
|
+
requested: 0,
|
|
845
|
+
effective: 0,
|
|
846
|
+
degraded_notice: null,
|
|
847
|
+
};
|
|
839
848
|
/** The gate's no-op line, shared by the pre- and post-filter exits. */
|
|
840
849
|
// D7: every filtered path stays visible as a SkipEntry, never folded
|
|
841
850
|
// into "passed". One entry per path so the rendered count still equals
|
|
842
851
|
// the path count the old `N path(s) skipped` line reported.
|
|
843
|
-
function prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits = [], typeOnlyUnits = []) {
|
|
852
|
+
function prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits = [], typeOnlyUnits = [], nonSourceUnits = []) {
|
|
844
853
|
return [
|
|
845
854
|
...skipped.map((u) => ({ name: u.path, reason: 'skipGlobs' })),
|
|
846
855
|
...testUnits.map((u) => ({ name: u.path, reason: 'test path' })),
|
|
@@ -851,6 +860,8 @@ function prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits = [],
|
|
|
851
860
|
// #562: likewise distinct -- adjudication has to be able to measure this
|
|
852
861
|
// class separately, since it is the one that held precision at 13/20.
|
|
853
862
|
...typeOnlyUnits.map((u) => ({ name: u.path, reason: 'type-only module' })),
|
|
863
|
+
// #928: config/data files, below the source floor.
|
|
864
|
+
...nonSourceUnits.map((u) => ({ name: u.path, reason: 'non-source' })),
|
|
854
865
|
...barrelUnits.map((u) => ({
|
|
855
866
|
name: u.path,
|
|
856
867
|
reason: 're-export barrel',
|
|
@@ -876,21 +887,49 @@ const PR_CHECK_ABSTAIN_REMEDIATION = [
|
|
|
876
887
|
'non-empty and skipGlobs/heuristicExclude are not filtering ' +
|
|
877
888
|
'every path.',
|
|
878
889
|
];
|
|
879
|
-
/**
|
|
880
|
-
|
|
890
|
+
/**
|
|
891
|
+
* Exit 3 with the structural abstention line + remediation (#508). Nothing was
|
|
892
|
+
* eligible (docs, tests, config), so under `--post-comment` it also upserts the
|
|
893
|
+
* ✅ "nothing to test" sticky and an earlier ⚠️ one cannot linger (#928).
|
|
894
|
+
*/
|
|
895
|
+
async function abstainPrCheck(skipped, opts, deps, provenance = null) {
|
|
881
896
|
const outcome = gateOutcome({ checked: 0, findings: [], skipped }, 'gate', {
|
|
882
897
|
noun: 'unit(s)',
|
|
883
898
|
});
|
|
884
899
|
deps.out(outcome.summaryLine);
|
|
900
|
+
// #761: state the range, so "correctly abstained" and "wrong diff" differ.
|
|
901
|
+
if (provenance)
|
|
902
|
+
deps.out(provenanceLine(provenance));
|
|
885
903
|
for (const line of PR_CHECK_ABSTAIN_REMEDIATION)
|
|
886
904
|
deps.out(line);
|
|
887
|
-
if (
|
|
905
|
+
if (opts.postComment) {
|
|
906
|
+
const coverage = { requested: null, found: false, parsed: false };
|
|
907
|
+
await postStickyComment([], NO_TIER, deps, {
|
|
908
|
+
checked: 0,
|
|
909
|
+
abstained: false,
|
|
910
|
+
coverage: {
|
|
911
|
+
...coverage,
|
|
912
|
+
filesInReport: 0,
|
|
913
|
+
unitsMatched: 0,
|
|
914
|
+
unitsTotal: 0,
|
|
915
|
+
},
|
|
916
|
+
skipped,
|
|
917
|
+
provenance,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
if (opts.format === 'json') {
|
|
888
921
|
deps.out(ensureAscii(JSON.stringify(
|
|
889
|
-
// #579: `skipped`
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
922
|
+
// #579: `skipped` is the denominator the abstention collapsed to.
|
|
923
|
+
{
|
|
924
|
+
findings: [],
|
|
925
|
+
tier: 0,
|
|
926
|
+
checked: 0,
|
|
927
|
+
abstained: true,
|
|
928
|
+
skipped,
|
|
929
|
+
// #761: `null` when no diff was resolved -- never absent, so a
|
|
930
|
+
// reader can tell "not applicable" from "this producer is old".
|
|
931
|
+
provenance,
|
|
932
|
+
}, null, 2)));
|
|
894
933
|
}
|
|
895
934
|
throw new CliExitError(outcome.exitCode); // EXIT_ABSTAINED
|
|
896
935
|
}
|
|
@@ -898,53 +937,6 @@ function abstainPrCheck(skipped, format, deps) {
|
|
|
898
937
|
function resolveAnalysesDir(override, deps) {
|
|
899
938
|
return override ?? join(gitToplevel(deps), '.harness', 'analyses');
|
|
900
939
|
}
|
|
901
|
-
/**
|
|
902
|
-
* Collect 👍/👎 adjudications off the sticky comment, best-effort (#490).
|
|
903
|
-
*
|
|
904
|
-
* Runs on the NEXT `pr-check` for a PR (the collection loop the #490 sketch
|
|
905
|
-
* chose over a scheduled sweep — the guardian already authenticates against
|
|
906
|
-
* this API and already finds its own comment by marker). Read-only against
|
|
907
|
-
* GitHub, so it works where the fork-degraded poster could not write.
|
|
908
|
-
*
|
|
909
|
-
* NEVER affects the gate: any failure prints a `::warning::` and returns —
|
|
910
|
-
* a broken feedback loop must not turn a coverage gate red.
|
|
911
|
-
*/
|
|
912
|
-
async function collectAdjudicationsBestEffort(analysesDirOverride, deps) {
|
|
913
|
-
const ctx = prContextFromEnv(deps.env);
|
|
914
|
-
if (ctx === null)
|
|
915
|
-
return; // no PR context — nothing to collect against
|
|
916
|
-
if (!deps.env['GITHUB_TOKEN']) {
|
|
917
|
-
// Every read here needs a token; skipping LOUDLY beats a guaranteed 401.
|
|
918
|
-
deps.out(degradationAnnotation(`guardian: no GITHUB_TOKEN ${EM_DASH} reviewer adjudications not ` +
|
|
919
|
-
'collected this run'));
|
|
920
|
-
return;
|
|
921
|
-
}
|
|
922
|
-
// Resolved lazily (shells out to git) only once a collection will happen —
|
|
923
|
-
// an explicit `--diff` run without PR context must stay subprocess-free.
|
|
924
|
-
const analysesDir = resolveAnalysesDir(analysesDirOverride, deps);
|
|
925
|
-
try {
|
|
926
|
-
const client = deps.buildReactionsClient(ctx[0], ctx[1]);
|
|
927
|
-
const res = await collectAdjudications(client, {
|
|
928
|
-
repo: ctx[0],
|
|
929
|
-
prNumber: ctx[1],
|
|
930
|
-
analysesDir,
|
|
931
|
-
});
|
|
932
|
-
if (res.action === 'collected' && res.record) {
|
|
933
|
-
deps.out(`guardian: adjudication recorded (${res.record.tp} up / ` +
|
|
934
|
-
`${res.record.fp} down) ${RIGHT_ARROW} ${res.path}`);
|
|
935
|
-
}
|
|
936
|
-
else if (res.action === 'unavailable' && res.notice) {
|
|
937
|
-
deps.out(degradationAnnotation(res.notice));
|
|
938
|
-
}
|
|
939
|
-
// no-comment / no-reactions: nothing to say — absence of a reaction is
|
|
940
|
-
// neutral, not a data point (#490).
|
|
941
|
-
}
|
|
942
|
-
catch (exc) {
|
|
943
|
-
const message = exc instanceof Error ? exc.message : String(exc);
|
|
944
|
-
deps.out(degradationAnnotation(`guardian: adjudication collection failed (${message}) ${EM_DASH} ` +
|
|
945
|
-
'findings and gate unaffected'));
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
940
|
async function prCheckCmd(opts, deps) {
|
|
949
941
|
const [config, warning] = loadGuardianConfig(opts.config);
|
|
950
942
|
if (warning !== null) {
|
|
@@ -958,18 +950,34 @@ async function prCheckCmd(opts, deps) {
|
|
|
958
950
|
throw new CliExitError(0);
|
|
959
951
|
}
|
|
960
952
|
const effectiveGate = opts.gate ?? config.pr_gate;
|
|
961
|
-
// #490: read reviewer 👍/👎 off the PREVIOUS run's sticky comment before this
|
|
962
|
-
// run touches it. Runs on the posting/emitting (CI) surfaces only, before the
|
|
963
|
-
// early exits so a docs-only follow-up push still harvests the verdicts.
|
|
964
|
-
if (opts.postComment || opts.emitAnalysis) {
|
|
965
|
-
await collectAdjudicationsBestEffort(opts.analysesDir, deps);
|
|
966
|
-
}
|
|
967
953
|
// #369: in CI an omitted `--diff` resolves the PR diff from the base ref;
|
|
968
954
|
// the working-tree fallback is empty on a clean checkout.
|
|
969
955
|
const resolvedDiff = readPrDiff(opts.diff ?? null, deps);
|
|
970
956
|
const diffText = resolvedDiff.text;
|
|
971
957
|
const units = scopeDiff(diffText);
|
|
972
958
|
warnIfEmptyCiDiff(resolvedDiff, units.length, deps);
|
|
959
|
+
// #761: capture what the diff was taken between, BEFORE the skip/test/
|
|
960
|
+
// type-only filters run — `fileCount` is the size of the surface guardian was
|
|
961
|
+
// handed, which is the number a reviewer can check against their own PR.
|
|
962
|
+
// Populated even for an explicit `--diff` (where `base` is unknowable): the
|
|
963
|
+
// merge-ref warning and the file count are exactly what was missing on
|
|
964
|
+
// the consumer run that surfaced #761, which passed `--diff` from a file.
|
|
965
|
+
// #883: a diff taken to the PR head is the PR's own, so it is not widened.
|
|
966
|
+
const headSha = resolvedDiff.head ?? resolveHeadSha(deps);
|
|
967
|
+
const mergeRef = detectMergeRef(headSha, deps);
|
|
968
|
+
const provenance = {
|
|
969
|
+
base: resolvedDiff.base,
|
|
970
|
+
head: headSha,
|
|
971
|
+
origin: resolvedDiff.origin,
|
|
972
|
+
fileCount: units.length,
|
|
973
|
+
...(mergeRef ? { mergeRef: true } : {}),
|
|
974
|
+
};
|
|
975
|
+
if (mergeRef) {
|
|
976
|
+
// Loud, because it invalidates every count downstream — but non-blocking:
|
|
977
|
+
// the caller owns the checkout, so guardian reports and carries on.
|
|
978
|
+
deps.err(degradationAnnotation(MERGE_REF_NOTICE));
|
|
979
|
+
appendStepSummary(deps.env, MERGE_REF_NOTICE);
|
|
980
|
+
}
|
|
973
981
|
// SC-2: drop docs/config-only units matching skipGlobs.
|
|
974
982
|
const [keptSkip, skipped] = filterSkipped(units, config.skip_globs);
|
|
975
983
|
// FIX A: drop test-path units -- a test does not itself need a test.
|
|
@@ -985,22 +993,20 @@ async function prCheckCmd(opts, deps) {
|
|
|
985
993
|
// FIX 2: drop pure re-export/barrel files.
|
|
986
994
|
const reexportPaths = findReexportOnly(diffText);
|
|
987
995
|
const barrelUnits = keptTyped.filter((u) => reexportPaths.has(u.path));
|
|
988
|
-
const
|
|
996
|
+
const keptBarrel = keptTyped.filter((u) => !reexportPaths.has(u.path));
|
|
997
|
+
// #928: the heuristic tier's source floor (#413) applies to coverage units
|
|
998
|
+
// too; a config/data file can never match a coverage report.
|
|
999
|
+
const nonSourceUnits = keptBarrel.filter((u) => !isSourcePath(u.path));
|
|
1000
|
+
const kept = keptBarrel.filter((u) => isSourcePath(u.path));
|
|
989
1001
|
// Advisory weak-test findings for added tests that assert nothing.
|
|
990
1002
|
const weakFindings = config.weak_tests
|
|
991
1003
|
? buildWeakTestFindings(testUnits, diffText)
|
|
992
1004
|
: [];
|
|
993
|
-
// #582: build the skip list ONCE, above the abstain exit, so
|
|
994
|
-
//
|
|
995
|
-
|
|
996
|
-
// coverage ladder has run, so it is appended below rather than passed here.
|
|
997
|
-
//
|
|
998
|
-
// This supersedes a `preFilterSkipped` count that was computed at this point
|
|
999
|
-
// and read by nothing — the fossil of an earlier attempt to surface the same
|
|
1000
|
-
// number on this path.
|
|
1001
|
-
const preCoverageSkips = prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, typeOnlyUnits);
|
|
1005
|
+
// #582: build the skip list ONCE, above the abstain exit, so both paths carry
|
|
1006
|
+
// the same denominator; the heuristic-noise class is appended below.
|
|
1007
|
+
const preCoverageSkips = prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, typeOnlyUnits, nonSourceUnits);
|
|
1002
1008
|
if (kept.length === 0 && weakFindings.length === 0) {
|
|
1003
|
-
abstainPrCheck(preCoverageSkips, opts
|
|
1009
|
+
await abstainPrCheck(preCoverageSkips, opts, deps, provenance);
|
|
1004
1010
|
}
|
|
1005
1011
|
const { results, coverage } = resolveCoverageWithInput(kept, {
|
|
1006
1012
|
coveragePath: opts.coverage ?? null,
|
|
@@ -1012,8 +1018,17 @@ async function prCheckCmd(opts, deps) {
|
|
|
1012
1018
|
// never judge (non-source, or an excluded glob). Coverage/graph-verified
|
|
1013
1019
|
// verdicts on the same paths are real evidence and survive.
|
|
1014
1020
|
const [scoredResults, noiseResults] = filterHeuristicNoise(results, opts.heuristicExclude ?? config.heuristic_exclude);
|
|
1021
|
+
// #606: the second coverage question — did coverage go DOWN on a unit this
|
|
1022
|
+
// PR touches? Run over the same `kept` units the ladder scored, so the two
|
|
1023
|
+
// denominators are the same surface. With no base artifact this returns no
|
|
1024
|
+
// deltas and a state that says so, which the notice below reports loudly.
|
|
1025
|
+
const { deltas, state: coverageDelta } = resolveCoverageDelta(kept, {
|
|
1026
|
+
baseCoveragePath: opts.baseCoverage ?? null,
|
|
1027
|
+
headCoveragePath: opts.coverage ?? null,
|
|
1028
|
+
});
|
|
1015
1029
|
const findings = [
|
|
1016
1030
|
...applySuppressions(buildFindings(scoredResults)),
|
|
1031
|
+
...buildRegressionFindings(deltas),
|
|
1017
1032
|
...weakFindings,
|
|
1018
1033
|
];
|
|
1019
1034
|
// The complete skip list for this run: the pre-coverage filters plus the
|
|
@@ -1026,7 +1041,7 @@ async function prCheckCmd(opts, deps) {
|
|
|
1026
1041
|
// SKIP rather than rendering an empty "0 unaddressed" report -- an adopter
|
|
1027
1042
|
// must be able to tell "nothing was judgeable" from "everything passed".
|
|
1028
1043
|
if (scoredResults.length === 0 && findings.length === 0) {
|
|
1029
|
-
abstainPrCheck(allSkips, opts
|
|
1044
|
+
await abstainPrCheck(allSkips, opts, deps, provenance);
|
|
1030
1045
|
}
|
|
1031
1046
|
// SC-5 (PR half): resolve the requested tier against actual capability. No
|
|
1032
1047
|
// agent runtime exists (default NoAgentProbe), so any `pr.tier > 0` drops to
|
|
@@ -1036,27 +1051,49 @@ async function prCheckCmd(opts, deps) {
|
|
|
1036
1051
|
deps.out(degradationAnnotation(resolution.degraded_notice));
|
|
1037
1052
|
appendStepSummary(deps.env, resolution.degraded_notice);
|
|
1038
1053
|
}
|
|
1054
|
+
// #761: the run judged units but VERIFIED no coverage, and every finding is a
|
|
1055
|
+
// naming guess. That is an abstention on the coverage denominator — a
|
|
1056
|
+
// different test from the findings-eligible one the two `abstainPrCheck`
|
|
1057
|
+
// calls above make, and the one that consumer run needed. It does not exit
|
|
1058
|
+
// through `abstainPrCheck`: those findings are worth showing, so the run keeps
|
|
1059
|
+
// every surface and changes only its headline and its exit code.
|
|
1060
|
+
const coverageAbstained = isCoverageAbstention(coverage, findings);
|
|
1039
1061
|
// Compute the gate result once, up front: the emitted record carries it and it
|
|
1040
1062
|
// is the process exit at the end (SC-4 -- emit never changes the exit logic).
|
|
1041
|
-
const exitCode =
|
|
1063
|
+
const exitCode = coverageAbstained
|
|
1064
|
+
? EXIT_ABSTAINED
|
|
1065
|
+
: computeExitCode(findings, effectiveGate);
|
|
1042
1066
|
// #554: every surface below carries the coverage-input state, so a run that
|
|
1043
1067
|
// never saw a coverage report cannot present as one that checked and passed.
|
|
1044
1068
|
const gateMeta = {
|
|
1045
1069
|
checked: scoredResults.length,
|
|
1046
|
-
abstained:
|
|
1070
|
+
abstained: coverageAbstained,
|
|
1047
1071
|
coverage,
|
|
1072
|
+
// #606: the delta's own denominator, so "no regressions" can never be read
|
|
1073
|
+
// as "compared and clean" on a run that compared nothing.
|
|
1074
|
+
coverageDelta,
|
|
1075
|
+
// #761: the endpoints every count above is scoped by.
|
|
1076
|
+
provenance,
|
|
1048
1077
|
// #582: `checked` is the numerator of a fraction whose denominator was
|
|
1049
1078
|
// never printed. This is the rest of it.
|
|
1050
1079
|
skipped: allSkips,
|
|
1051
1080
|
};
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1081
|
+
// #606: the delta degradation rides the exact same surfaces as the coverage
|
|
1082
|
+
// one — a head-only run is as blind about regressions as a report-less run is
|
|
1083
|
+
// about coverage, and must be as loud.
|
|
1084
|
+
const notices = [
|
|
1085
|
+
coverageDegradedNotice(coverage),
|
|
1086
|
+
coverageDeltaNotice(coverageDelta),
|
|
1087
|
+
];
|
|
1088
|
+
// `--format json` owns stdout: a `::warning::` line there would make the
|
|
1089
|
+
// document unparseable, so the annotation goes to stderr on that path. Both
|
|
1090
|
+
// streams are scanned for workflow commands, so CI still sees it.
|
|
1091
|
+
const machineStdout = !opts.postComment && !opts.emitAnalysis && opts.format === 'json';
|
|
1092
|
+
for (const notice of notices) {
|
|
1093
|
+
if (!notice)
|
|
1094
|
+
continue;
|
|
1095
|
+
(machineStdout ? deps.err : deps.out)(degradationAnnotation(notice));
|
|
1096
|
+
appendStepSummary(deps.env, notice);
|
|
1060
1097
|
}
|
|
1061
1098
|
let commentPosted = false;
|
|
1062
1099
|
if (opts.emitAnalysis) {
|
|
@@ -1072,9 +1109,15 @@ async function prCheckCmd(opts, deps) {
|
|
|
1072
1109
|
degraded_notice: resolution.degraded_notice,
|
|
1073
1110
|
exit_code: exitCode,
|
|
1074
1111
|
checked: scoredResults.length,
|
|
1075
|
-
|
|
1112
|
+
// #761: a coverage abstention DOES reach emit — unlike the
|
|
1113
|
+
// findings-eligible abstention, it keeps its findings, so a machine
|
|
1114
|
+
// consumer must see the flag rather than infer a result from the array.
|
|
1115
|
+
abstained: coverageAbstained,
|
|
1076
1116
|
coverage,
|
|
1077
1117
|
skipped: allSkips,
|
|
1118
|
+
// #761: the archived artifact is where an inflated diff gets diagnosed
|
|
1119
|
+
// long after the run, so it carries the endpoints too.
|
|
1120
|
+
provenance,
|
|
1078
1121
|
});
|
|
1079
1122
|
if (res.action === 'emitted') {
|
|
1080
1123
|
deps.out(`guardian: wrote analysis record ${RIGHT_ARROW} ${res.path}`);
|
|
@@ -1142,19 +1185,10 @@ function intentDict(intent) {
|
|
|
1142
1185
|
};
|
|
1143
1186
|
}
|
|
1144
1187
|
/**
|
|
1145
|
-
* author-plan's denominator decision (#508
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
* took pr-check. On an EMPTY diff this surface emitted
|
|
1150
|
-
* `block: false, authored_count: 0` and exited 0 -- "we examined nothing,
|
|
1151
|
-
* therefore do not block", which is the #456 class verbatim.
|
|
1152
|
-
*
|
|
1153
|
-
* ADVISORY, not a gate: author-plan is an authoring aid whose JSON an agent
|
|
1154
|
-
* reads (see `canary-pr-guardian/SKILL.md`); the exit-code contract belongs to
|
|
1155
|
-
* `pr-check` and the pre-commit gate. So the exit stays 0 and stdout stays a
|
|
1156
|
-
* single parseable object -- `checked`/`abstained` ride the payload additively
|
|
1157
|
-
* and the loud line goes to stderr, keeping `--json` consumers byte-compatible.
|
|
1188
|
+
* author-plan's denominator decision (#508): an empty diff must not read as
|
|
1189
|
+
* "examined nothing, so do not block" (#456). ADVISORY, not a gate: exit stays
|
|
1190
|
+
* 0 and stdout one parseable object; `checked`/`abstained` ride the payload and
|
|
1191
|
+
* the loud line goes to stderr.
|
|
1158
1192
|
*/
|
|
1159
1193
|
function authorPlanOutcome(checked, results, deps) {
|
|
1160
1194
|
const outcome = gateOutcome({ checked, findings: [...results] }, 'advisory', {
|
|
@@ -1291,29 +1325,20 @@ export function createGuardianCommand(depsInit = {}) {
|
|
|
1291
1325
|
.addOption(new Option('--check <check>', 'Status-check context to require (the guardian workflow job).').default('guardian'))
|
|
1292
1326
|
.addOption(new Option('--token <token>', 'Admin token for --apply.').env('GITHUB_TOKEN'))
|
|
1293
1327
|
.option('--force', 'Skip the check-context-exists verification (risky).')
|
|
1294
|
-
.addOption(new Option('--analyses-dir <dir>', 'Override the analyses dir (tests).').hideHelp())
|
|
1295
1328
|
.action(async (opts) => {
|
|
1296
1329
|
await hardenGateCmd(opts, deps);
|
|
1297
1330
|
});
|
|
1298
1331
|
program
|
|
1299
|
-
.command('
|
|
1300
|
-
.description(
|
|
1301
|
-
'
|
|
1332
|
+
.command('precision')
|
|
1333
|
+
.description('Derive guardian finding precision (TP / (TP + FP)) from merged PRs, ' +
|
|
1334
|
+
'with its sample size (ADR 0025).')
|
|
1302
1335
|
.addOption(new Option('--repo <repo>', 'owner/repo.').env('GITHUB_REPOSITORY'))
|
|
1303
|
-
.addOption(new Option('--
|
|
1304
|
-
.
|
|
1305
|
-
.
|
|
1336
|
+
.addOption(new Option('--days <n>', 'Merged-PR window in days.')
|
|
1337
|
+
.default(14)
|
|
1338
|
+
.argParser((v) => Number.parseInt(v, 10)))
|
|
1339
|
+
.option('--json', 'Emit the report as JSON (precision null = unknown).')
|
|
1306
1340
|
.action(async (opts) => {
|
|
1307
|
-
await
|
|
1308
|
-
});
|
|
1309
|
-
program
|
|
1310
|
-
.command('precision')
|
|
1311
|
-
.description('Report guardian finding precision (TP / (TP + FP)) from collected ' +
|
|
1312
|
-
'adjudications, with its sample size.')
|
|
1313
|
-
.option('--json', 'Emit the summary as JSON (precision null = unknown).')
|
|
1314
|
-
.addOption(new Option('--analyses-dir <dir>', 'Override the analyses dir (tests).').hideHelp())
|
|
1315
|
-
.action((opts) => {
|
|
1316
|
-
precisionCmd(opts, deps);
|
|
1341
|
+
await precisionCmd(opts, deps);
|
|
1317
1342
|
});
|
|
1318
1343
|
program
|
|
1319
1344
|
.command('pr-check')
|
|
@@ -1321,6 +1346,9 @@ export function createGuardianCommand(depsInit = {}) {
|
|
|
1321
1346
|
.option('--diff <diff>', "Diff file, '-' for stdin, or omit to auto-resolve: the PR diff " +
|
|
1322
1347
|
'(`<base>...HEAD`) in CI, else the local working-tree `git diff`.')
|
|
1323
1348
|
.option('--coverage <path>', 'Coverage report path (lcov/json).')
|
|
1349
|
+
.option('--base-coverage <path>', "Coverage report for the PR's BASE ref (#606). Enables coverage-" +
|
|
1350
|
+
'regression findings on touched units. Without it the run degrades ' +
|
|
1351
|
+
'LOUDLY to head-only — it never silently reports no regressions.')
|
|
1324
1352
|
.addOption(new Option('--format <fmt>', 'comment|json|text').default('comment'))
|
|
1325
1353
|
.addOption(new Option('--config <path>').default('harness.config.json'))
|
|
1326
1354
|
.option('--gate <gate>', 'Override config gate: soft|hard')
|