canary-test-cli 7.1.0 → 7.2.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 +308 -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-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 +180 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -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 +196 -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 +129 -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-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 +214 -0
- package/dist/engine/cli-commands.js +3 -65
- package/dist/engine/core/quality-scorer.js +15 -2
- 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/test-files.js +77 -0
- package/dist/engine/core/vacuity-scanner.js +184 -14
- package/dist/engine/guardian/analysis-emit.js +7 -2
- package/dist/engine/guardian/cli.js +121 -9
- package/dist/engine/guardian/pr-check.js +317 -18
- package/dist/engine/skills-cli.js +123 -19
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/package.json +3 -2
|
@@ -52,14 +52,14 @@ import pc from 'picocolors';
|
|
|
52
52
|
import { AuthoringContext, InSessionAgentProbe, InSessionAgentTier, decideBlock, } from './agent-tier.js';
|
|
53
53
|
import { RestReactionsClient, collectAdjudications, loadAdjudicationRecords, renderPrecision, summarizePrecision, } from './adjudication.js';
|
|
54
54
|
import { emitAnalysis } from './analysis-emit.js';
|
|
55
|
-
import { gateOutcome } from '../core/gate-result.js';
|
|
55
|
+
import { EXIT_ABSTAINED, gateOutcome, } from '../core/gate-result.js';
|
|
56
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
61
|
import { ensureAscii } from '../util/ensure-ascii.js';
|
|
62
|
-
import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestSupportUnits, filterTestUnits, filterTypeOnlyUnits, findReexportOnly, loadGuardianConfig, renderFindings, scopeDiff, } from './pr-check.js';
|
|
62
|
+
import { MERGE_REF_WARNING, provenanceLine, applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestSupportUnits, filterTestUnits, filterTypeOnlyUnits, findReexportOnly, isCoverageAbstention, loadGuardianConfig, renderFindings, scopeDiff, } from './pr-check.js';
|
|
63
63
|
import { RestGitHubClient, degradationAnnotation, upsertStickyComment, } from './pr-comment.js';
|
|
64
64
|
import { buildSummary } from './summary-emitter.js';
|
|
65
65
|
import { resolveTier } from './tier.js';
|
|
@@ -374,6 +374,59 @@ function readWorktreeDiff(deps) {
|
|
|
374
374
|
return unstaged;
|
|
375
375
|
return deps.runGit(['diff', '--staged'])?.stdout ?? '';
|
|
376
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* The PR head sha the CI event declares, if this is a `pull_request` event.
|
|
379
|
+
*
|
|
380
|
+
* Distinct from {@link eventBaseSha}: that answers "what are we diffing
|
|
381
|
+
* against", this answers "what SHOULD the diffed HEAD be". They are compared in
|
|
382
|
+
* {@link detectMergeRef} (#761).
|
|
383
|
+
*/
|
|
384
|
+
function eventHeadSha(env) {
|
|
385
|
+
const eventPath = env['GITHUB_EVENT_PATH'];
|
|
386
|
+
if (!eventPath)
|
|
387
|
+
return null;
|
|
388
|
+
let sha;
|
|
389
|
+
try {
|
|
390
|
+
const event = JSON.parse(readFileSync(eventPath, 'utf-8'));
|
|
391
|
+
sha = event?.pull_request?.head?.sha;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
return typeof sha === 'string' && sha.trim() ? sha.trim() : null;
|
|
397
|
+
}
|
|
398
|
+
/** Resolve `HEAD` to a full sha, or null when git cannot answer. */
|
|
399
|
+
function resolveHeadSha(deps) {
|
|
400
|
+
const res = deps.runGit(['rev-parse', 'HEAD']);
|
|
401
|
+
if (res === null || res.code !== 0)
|
|
402
|
+
return null;
|
|
403
|
+
const sha = res.stdout.trim();
|
|
404
|
+
return sha || null;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* True when the checked-out HEAD is a `pull_request` MERGE REF, not the PR head.
|
|
408
|
+
*
|
|
409
|
+
* This is the merge-ref diff defect (#761). `actions/checkout` on a
|
|
410
|
+
* `pull_request` event checks out `refs/pull/<n>/merge` — the base branch
|
|
411
|
+
* merged with the PR head — unless the caller passes an explicit `ref`. Any
|
|
412
|
+
* diff taken to that HEAD includes every commit merged into the base branch
|
|
413
|
+
* since the base sha, because the triple-dot merge base degenerates to the base
|
|
414
|
+
* sha itself (it is an ancestor of the merge commit). A one-file docs PR was
|
|
415
|
+
* analyzed as 43 files that way.
|
|
416
|
+
*
|
|
417
|
+
* Detection is a comparison, not a heuristic: the event payload states the PR
|
|
418
|
+
* head sha outright, so a HEAD that differs from it is diffing something else.
|
|
419
|
+
* Returns false whenever either side is unknown — an undetectable case must not
|
|
420
|
+
* masquerade as a detected-clean one.
|
|
421
|
+
*/
|
|
422
|
+
export function detectMergeRef(headSha, deps) {
|
|
423
|
+
if (deps.env['GITHUB_EVENT_NAME'] !== 'pull_request')
|
|
424
|
+
return false;
|
|
425
|
+
const declared = eventHeadSha(deps.env);
|
|
426
|
+
if (!declared || !headSha)
|
|
427
|
+
return false;
|
|
428
|
+
return declared !== headSha;
|
|
429
|
+
}
|
|
377
430
|
/** True when the process looks like a CI runner rather than a dev worktree. */
|
|
378
431
|
function isCiContext(env) {
|
|
379
432
|
return Boolean(env['GITHUB_ACTIONS'] || env['CI']);
|
|
@@ -470,6 +523,11 @@ export function readPrDiff(source, deps) {
|
|
|
470
523
|
}
|
|
471
524
|
return { text: readWorktreeDiff(deps), origin: 'worktree', base: null };
|
|
472
525
|
}
|
|
526
|
+
// Built from the shared fragment so the annotation and the rendered provenance
|
|
527
|
+
// line cannot drift into describing the same defect two different ways (#761).
|
|
528
|
+
const MERGE_REF_NOTICE = `guardian: ${MERGE_REF_WARNING} — findings may name files this PR never ` +
|
|
529
|
+
'touched. Check out with `ref: ${{ github.event.pull_request.head.sha }}`, ' +
|
|
530
|
+
'or diff to that sha instead of HEAD.';
|
|
473
531
|
const EMPTY_CI_DIFF_NOTICE = 'guardian: 0 changed paths — fell back to a working-tree `git diff`, which ' +
|
|
474
532
|
'is empty on a clean CI checkout, so NOTHING was verified. Pass ' +
|
|
475
533
|
'`--diff <base>...<head>`, or checkout with `fetch-depth: 0` so the PR base ' +
|
|
@@ -877,11 +935,18 @@ const PR_CHECK_ABSTAIN_REMEDIATION = [
|
|
|
877
935
|
'every path.',
|
|
878
936
|
];
|
|
879
937
|
/** Exit 3 with the structural abstention line + remediation (#508). */
|
|
880
|
-
function abstainPrCheck(skipped, format, deps) {
|
|
938
|
+
function abstainPrCheck(skipped, format, deps, provenance = null) {
|
|
881
939
|
const outcome = gateOutcome({ checked: 0, findings: [], skipped }, 'gate', {
|
|
882
940
|
noun: 'unit(s)',
|
|
883
941
|
});
|
|
884
942
|
deps.out(outcome.summaryLine);
|
|
943
|
+
// #761: an abstention says "I verified zero items" — the immediate next
|
|
944
|
+
// question is "over WHAT?", and the run that motivated this feature is
|
|
945
|
+
// precisely one that should have abstained. Stating the range here is what
|
|
946
|
+
// separates "correctly abstained on a docs-only PR" from "abstained because
|
|
947
|
+
// the diff was wrong", which read identically without it.
|
|
948
|
+
if (provenance)
|
|
949
|
+
deps.out(provenanceLine(provenance));
|
|
885
950
|
for (const line of PR_CHECK_ABSTAIN_REMEDIATION)
|
|
886
951
|
deps.out(line);
|
|
887
952
|
if (format === 'json') {
|
|
@@ -890,7 +955,16 @@ function abstainPrCheck(skipped, format, deps) {
|
|
|
890
955
|
// to. Without it a consumer sees `abstained: true` and cannot tell
|
|
891
956
|
// WHAT was dropped or why -- the #508 class one layer down, on the
|
|
892
957
|
// only surface a machine can read.
|
|
893
|
-
{
|
|
958
|
+
{
|
|
959
|
+
findings: [],
|
|
960
|
+
tier: 0,
|
|
961
|
+
checked: 0,
|
|
962
|
+
abstained: true,
|
|
963
|
+
skipped,
|
|
964
|
+
// #761: `null` when no diff was resolved -- never absent, so a
|
|
965
|
+
// reader can tell "not applicable" from "this producer is old".
|
|
966
|
+
provenance,
|
|
967
|
+
}, null, 2)));
|
|
894
968
|
}
|
|
895
969
|
throw new CliExitError(outcome.exitCode); // EXIT_ABSTAINED
|
|
896
970
|
}
|
|
@@ -970,6 +1044,27 @@ async function prCheckCmd(opts, deps) {
|
|
|
970
1044
|
const diffText = resolvedDiff.text;
|
|
971
1045
|
const units = scopeDiff(diffText);
|
|
972
1046
|
warnIfEmptyCiDiff(resolvedDiff, units.length, deps);
|
|
1047
|
+
// #761: capture what the diff was taken between, BEFORE the skip/test/
|
|
1048
|
+
// type-only filters run — `fileCount` is the size of the surface guardian was
|
|
1049
|
+
// handed, which is the number a reviewer can check against their own PR.
|
|
1050
|
+
// Populated even for an explicit `--diff` (where `base` is unknowable): the
|
|
1051
|
+
// merge-ref warning and the file count are exactly what was missing on
|
|
1052
|
+
// the consumer run that surfaced #761, which passed `--diff` from a file.
|
|
1053
|
+
const headSha = resolveHeadSha(deps);
|
|
1054
|
+
const mergeRef = detectMergeRef(headSha, deps);
|
|
1055
|
+
const provenance = {
|
|
1056
|
+
base: resolvedDiff.base,
|
|
1057
|
+
head: headSha,
|
|
1058
|
+
origin: resolvedDiff.origin,
|
|
1059
|
+
fileCount: units.length,
|
|
1060
|
+
...(mergeRef ? { mergeRef: true } : {}),
|
|
1061
|
+
};
|
|
1062
|
+
if (mergeRef) {
|
|
1063
|
+
// Loud, because it invalidates every count downstream — but non-blocking:
|
|
1064
|
+
// the caller owns the checkout, so guardian reports and carries on.
|
|
1065
|
+
deps.err(degradationAnnotation(MERGE_REF_NOTICE));
|
|
1066
|
+
appendStepSummary(deps.env, MERGE_REF_NOTICE);
|
|
1067
|
+
}
|
|
973
1068
|
// SC-2: drop docs/config-only units matching skipGlobs.
|
|
974
1069
|
const [keptSkip, skipped] = filterSkipped(units, config.skip_globs);
|
|
975
1070
|
// FIX A: drop test-path units -- a test does not itself need a test.
|
|
@@ -1000,7 +1095,7 @@ async function prCheckCmd(opts, deps) {
|
|
|
1000
1095
|
// number on this path.
|
|
1001
1096
|
const preCoverageSkips = prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, typeOnlyUnits);
|
|
1002
1097
|
if (kept.length === 0 && weakFindings.length === 0) {
|
|
1003
|
-
abstainPrCheck(preCoverageSkips, opts.format, deps);
|
|
1098
|
+
abstainPrCheck(preCoverageSkips, opts.format, deps, provenance);
|
|
1004
1099
|
}
|
|
1005
1100
|
const { results, coverage } = resolveCoverageWithInput(kept, {
|
|
1006
1101
|
coveragePath: opts.coverage ?? null,
|
|
@@ -1026,7 +1121,7 @@ async function prCheckCmd(opts, deps) {
|
|
|
1026
1121
|
// SKIP rather than rendering an empty "0 unaddressed" report -- an adopter
|
|
1027
1122
|
// must be able to tell "nothing was judgeable" from "everything passed".
|
|
1028
1123
|
if (scoredResults.length === 0 && findings.length === 0) {
|
|
1029
|
-
abstainPrCheck(allSkips, opts.format, deps);
|
|
1124
|
+
abstainPrCheck(allSkips, opts.format, deps, provenance);
|
|
1030
1125
|
}
|
|
1031
1126
|
// SC-5 (PR half): resolve the requested tier against actual capability. No
|
|
1032
1127
|
// agent runtime exists (default NoAgentProbe), so any `pr.tier > 0` drops to
|
|
@@ -1036,15 +1131,26 @@ async function prCheckCmd(opts, deps) {
|
|
|
1036
1131
|
deps.out(degradationAnnotation(resolution.degraded_notice));
|
|
1037
1132
|
appendStepSummary(deps.env, resolution.degraded_notice);
|
|
1038
1133
|
}
|
|
1134
|
+
// #761: the run judged units but VERIFIED no coverage, and every finding is a
|
|
1135
|
+
// naming guess. That is an abstention on the coverage denominator — a
|
|
1136
|
+
// different test from the findings-eligible one the two `abstainPrCheck`
|
|
1137
|
+
// calls above make, and the one that consumer run needed. It does not exit
|
|
1138
|
+
// through `abstainPrCheck`: those findings are worth showing, so the run keeps
|
|
1139
|
+
// every surface and changes only its headline and its exit code.
|
|
1140
|
+
const coverageAbstained = isCoverageAbstention(coverage, findings);
|
|
1039
1141
|
// Compute the gate result once, up front: the emitted record carries it and it
|
|
1040
1142
|
// is the process exit at the end (SC-4 -- emit never changes the exit logic).
|
|
1041
|
-
const exitCode =
|
|
1143
|
+
const exitCode = coverageAbstained
|
|
1144
|
+
? EXIT_ABSTAINED
|
|
1145
|
+
: computeExitCode(findings, effectiveGate);
|
|
1042
1146
|
// #554: every surface below carries the coverage-input state, so a run that
|
|
1043
1147
|
// never saw a coverage report cannot present as one that checked and passed.
|
|
1044
1148
|
const gateMeta = {
|
|
1045
1149
|
checked: scoredResults.length,
|
|
1046
|
-
abstained:
|
|
1150
|
+
abstained: coverageAbstained,
|
|
1047
1151
|
coverage,
|
|
1152
|
+
// #761: the endpoints every count above is scoped by.
|
|
1153
|
+
provenance,
|
|
1048
1154
|
// #582: `checked` is the numerator of a fraction whose denominator was
|
|
1049
1155
|
// never printed. This is the rest of it.
|
|
1050
1156
|
skipped: allSkips,
|
|
@@ -1072,9 +1178,15 @@ async function prCheckCmd(opts, deps) {
|
|
|
1072
1178
|
degraded_notice: resolution.degraded_notice,
|
|
1073
1179
|
exit_code: exitCode,
|
|
1074
1180
|
checked: scoredResults.length,
|
|
1075
|
-
|
|
1181
|
+
// #761: a coverage abstention DOES reach emit — unlike the
|
|
1182
|
+
// findings-eligible abstention, it keeps its findings, so a machine
|
|
1183
|
+
// consumer must see the flag rather than infer a result from the array.
|
|
1184
|
+
abstained: coverageAbstained,
|
|
1076
1185
|
coverage,
|
|
1077
1186
|
skipped: allSkips,
|
|
1187
|
+
// #761: the archived artifact is where an inflated diff gets diagnosed
|
|
1188
|
+
// long after the run, so it carries the endpoints too.
|
|
1189
|
+
provenance,
|
|
1078
1190
|
});
|
|
1079
1191
|
if (res.action === 'emitted') {
|
|
1080
1192
|
deps.out(`guardian: wrote analysis record ${RIGHT_ARROW} ${res.path}`);
|
|
@@ -700,19 +700,209 @@ function hasAddedTestBody(added) {
|
|
|
700
700
|
}
|
|
701
701
|
return false;
|
|
702
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* The declaration line of a single test, per framework family (#747).
|
|
705
|
+
*
|
|
706
|
+
* Narrower than {@link TEST_SIGNATURE_RE} on purpose: `describe(` opens a
|
|
707
|
+
* *group*, and judging assertion presence over a whole describe block would
|
|
708
|
+
* suppress a genuinely empty test sitting beside an asserting sibling. A
|
|
709
|
+
* modifier chain (`it.only`, `test.each`) still opens one test, so it counts.
|
|
710
|
+
*/
|
|
711
|
+
const TEST_DECL_PY = /^\s*(?:async\s+)?def\s+test\w*\s*\(/;
|
|
712
|
+
const TEST_DECL_JS = /^\s*(?:async\s+)?(?:it|test)(?:\.\w+)*\s*\(/;
|
|
713
|
+
function testDeclRe(framework) {
|
|
714
|
+
return framework === 'pytest' ? TEST_DECL_PY : TEST_DECL_JS;
|
|
715
|
+
}
|
|
716
|
+
/** Indentation width of `line`, counting a tab as one column. */
|
|
717
|
+
function indentWidth(line) {
|
|
718
|
+
return line.length - line.trimStart().length;
|
|
719
|
+
}
|
|
720
|
+
// String literals and line comments are blanked before delimiter counting, so
|
|
721
|
+
// a brace inside `'a { b'` or a trailing `// }` cannot unbalance a block.
|
|
722
|
+
const JS_STRING_OR_COMMENT = /(['"`])(?:\\.|(?!\1).)*?\1|\/\/.*$|\/\*[\s\S]*?\*\//g;
|
|
723
|
+
/**
|
|
724
|
+
* Consume a `diff --git` / `+++` / `---` / `@@` line, returning whether the
|
|
725
|
+
* line was a header. Split out from the content handling so neither half has
|
|
726
|
+
* to carry the other's branches.
|
|
727
|
+
*/
|
|
728
|
+
function applyDiffHeader(line, cur, files) {
|
|
729
|
+
if (line.startsWith('diff --git')) {
|
|
730
|
+
cur.inHunk = false;
|
|
731
|
+
cur.current = null;
|
|
732
|
+
cur.skipCurrent = false;
|
|
733
|
+
return true;
|
|
734
|
+
}
|
|
735
|
+
if (!cur.inHunk && line.startsWith('+++ ')) {
|
|
736
|
+
const target = line.slice(4).trim();
|
|
737
|
+
if (target === '/dev/null') {
|
|
738
|
+
cur.skipCurrent = true;
|
|
739
|
+
cur.current = null;
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
cur.skipCurrent = false;
|
|
743
|
+
const path = target.startsWith('b/') ? target.slice(2) : target;
|
|
744
|
+
cur.current = files.get(path) ?? { text: new Map(), added: new Set() };
|
|
745
|
+
files.set(path, cur.current);
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
if (!cur.inHunk && line.startsWith('--- '))
|
|
749
|
+
return true;
|
|
750
|
+
const hunk = HUNK_RE.exec(line);
|
|
751
|
+
if (hunk) {
|
|
752
|
+
cur.newLineno = Number.parseInt(hunk[1], 10);
|
|
753
|
+
cur.inHunk = true;
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
/** Record one content line against the file the cursor is pointing at. */
|
|
759
|
+
function applyDiffContent(line, cur) {
|
|
760
|
+
const file = cur.current;
|
|
761
|
+
if (!file)
|
|
762
|
+
return;
|
|
763
|
+
// `-` is gone from the new file and `\` is the no-newline marker; neither
|
|
764
|
+
// occupies a line number on the `+` side.
|
|
765
|
+
if (line.startsWith('-') || line.startsWith('\\'))
|
|
766
|
+
return;
|
|
767
|
+
const added = line.startsWith('+');
|
|
768
|
+
file.text.set(cur.newLineno, line.slice(1));
|
|
769
|
+
if (added)
|
|
770
|
+
file.added.add(cur.newLineno);
|
|
771
|
+
cur.newLineno += 1;
|
|
772
|
+
}
|
|
773
|
+
function visibleLinesByPath(diffText) {
|
|
774
|
+
const files = new Map();
|
|
775
|
+
const cur = {
|
|
776
|
+
current: null,
|
|
777
|
+
newLineno: 0,
|
|
778
|
+
skipCurrent: false,
|
|
779
|
+
inHunk: false,
|
|
780
|
+
};
|
|
781
|
+
for (const line of splitLines(diffText)) {
|
|
782
|
+
if (applyDiffHeader(line, cur, files))
|
|
783
|
+
continue;
|
|
784
|
+
if (cur.skipCurrent || cur.current === null)
|
|
785
|
+
continue;
|
|
786
|
+
applyDiffContent(line, cur);
|
|
787
|
+
}
|
|
788
|
+
return files;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* The line the enclosing test declaration sits on, or `null` when none is
|
|
792
|
+
* visible (#747).
|
|
793
|
+
*
|
|
794
|
+
* Walks up through the CONTIGUOUS visible run only: a gap between hunks means
|
|
795
|
+
* the lines between are unknown, so a declaration on the far side of it is not
|
|
796
|
+
* evidence about this line. Returning `null` is the abstention — a changed line
|
|
797
|
+
* whose enclosing test cannot be resolved (a Playwright `setup(...)` fixture, a
|
|
798
|
+
* bare helper) is not judged at all rather than reported as assertion-free.
|
|
799
|
+
*/
|
|
800
|
+
function enclosingTestDecl(file, lineNo, declRe) {
|
|
801
|
+
for (let n = lineNo; file.text.has(n); n--) {
|
|
802
|
+
if (declRe.test(file.text.get(n)))
|
|
803
|
+
return n;
|
|
804
|
+
}
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* The last line of the test block opened at `start`, bounded by what the diff
|
|
809
|
+
* shows (#747).
|
|
810
|
+
*
|
|
811
|
+
* Python closes on the first non-blank line indented no deeper than the `def`;
|
|
812
|
+
* JS/TS closes when the delimiter depth opened by the declaration returns to
|
|
813
|
+
* zero. When neither lands inside the visible run the span is truncated at its
|
|
814
|
+
* end — the assertion search is then over less than the whole block, which can
|
|
815
|
+
* still miss an assertion further down. That residual is accepted: it is a
|
|
816
|
+
* strictly smaller window of error than scoring the added lines alone, which is
|
|
817
|
+
* what #747 measured, and widening the span past what the diff shows would mean
|
|
818
|
+
* reading the working tree, which this function deliberately does not do.
|
|
819
|
+
*/
|
|
820
|
+
function testBlockEnd(file, start, isPython) {
|
|
821
|
+
let last = start;
|
|
822
|
+
if (isPython) {
|
|
823
|
+
const declIndent = indentWidth(file.text.get(start));
|
|
824
|
+
for (let n = start + 1; file.text.has(n); n++) {
|
|
825
|
+
const text = file.text.get(n);
|
|
826
|
+
if (text.trim() && indentWidth(text) <= declIndent)
|
|
827
|
+
return n - 1;
|
|
828
|
+
last = n;
|
|
829
|
+
}
|
|
830
|
+
return last;
|
|
831
|
+
}
|
|
832
|
+
let depth = 0;
|
|
833
|
+
let opened = false;
|
|
834
|
+
for (let n = start; file.text.has(n); n++) {
|
|
835
|
+
const text = file.text.get(n).replace(JS_STRING_OR_COMMENT, '');
|
|
836
|
+
for (const ch of text) {
|
|
837
|
+
if (ch === '{' || ch === '(') {
|
|
838
|
+
depth += 1;
|
|
839
|
+
opened = true;
|
|
840
|
+
}
|
|
841
|
+
else if (ch === '}' || ch === ')')
|
|
842
|
+
depth -= 1;
|
|
843
|
+
}
|
|
844
|
+
last = n;
|
|
845
|
+
if (opened && depth <= 0)
|
|
846
|
+
return n;
|
|
847
|
+
}
|
|
848
|
+
return last;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* True iff some test block touched by `unit`'s added lines asserts nothing.
|
|
852
|
+
*
|
|
853
|
+
* A block qualifies for judgement only when it is resolvable AND at least one
|
|
854
|
+
* of its own added lines is a real body line — the FP-3 rename guard, applied
|
|
855
|
+
* per block rather than per file so a rename in one test cannot excuse an empty
|
|
856
|
+
* one elsewhere in the same diff. Blocks are visited once each.
|
|
857
|
+
*/
|
|
858
|
+
function weakBlockIn(file, unit, framework) {
|
|
859
|
+
const declRe = testDeclRe(framework);
|
|
860
|
+
const isPython = framework === 'pytest';
|
|
861
|
+
const seen = new Set();
|
|
862
|
+
for (const lineNo of linesInRanges(unit.added_ranges)) {
|
|
863
|
+
const start = enclosingTestDecl(file, lineNo, declRe);
|
|
864
|
+
if (start === null || seen.has(start))
|
|
865
|
+
continue;
|
|
866
|
+
seen.add(start);
|
|
867
|
+
const end = testBlockEnd(file, start, isPython);
|
|
868
|
+
const span = [];
|
|
869
|
+
const addedInBlock = [];
|
|
870
|
+
for (let n = start; n <= end; n++) {
|
|
871
|
+
const text = file.text.get(n);
|
|
872
|
+
if (text === undefined)
|
|
873
|
+
continue;
|
|
874
|
+
span.push(text);
|
|
875
|
+
if (file.added.has(n))
|
|
876
|
+
addedInBlock.push(text);
|
|
877
|
+
}
|
|
878
|
+
if (!hasAddedTestBody(addedInBlock))
|
|
879
|
+
continue;
|
|
880
|
+
if (isAssertionFreeTest(span.join('\n'), framework))
|
|
881
|
+
return true;
|
|
882
|
+
}
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
703
885
|
/**
|
|
704
886
|
* Advisory `weak-test` findings for ADDED tests that assert nothing.
|
|
705
887
|
*
|
|
706
888
|
* Consumes the test-path units {@link filterTestUnits} sets aside (a test file
|
|
707
889
|
* needs no test of its own, but an added test that asserts nothing is itself a
|
|
708
|
-
* gap).
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
890
|
+
* gap). A high-precision signal by construction: a snapshot or table-driven
|
|
891
|
+
* test still matches an assertion pattern, so it is not flagged.
|
|
892
|
+
*
|
|
893
|
+
* #747: the span scored is the ENCLOSING TEST BLOCK of each added line, not the
|
|
894
|
+
* added lines themselves. Scoring the added lines alone reported every
|
|
895
|
+
* arrange/act-only edit as assertion-free, because a test's setup is edited far
|
|
896
|
+
* more often than its `expect` — six such findings, all wrong, in the run that
|
|
897
|
+
* produced the report. A changed line whose enclosing test cannot be resolved
|
|
898
|
+
* from the diff is ABSTAINED on, never reported.
|
|
899
|
+
*
|
|
900
|
+
* These findings are `LOW`/`weak-test` and are **never** gated (see
|
|
712
901
|
* {@link computeExitCode}): they surface, never block.
|
|
713
902
|
*/
|
|
714
903
|
export function buildWeakTestFindings(testUnits, diffText) {
|
|
715
904
|
const addedByPath = addedContentByPath(diffText);
|
|
905
|
+
const visibleByPath = visibleLinesByPath(diffText);
|
|
716
906
|
const findings = [];
|
|
717
907
|
for (const unit of testUnits) {
|
|
718
908
|
const added = addedByPath.get(unit.path);
|
|
@@ -722,9 +912,11 @@ export function buildWeakTestFindings(testUnits, diffText) {
|
|
|
722
912
|
// nothing new to judge, so don't flag it (FP guard).
|
|
723
913
|
if (!hasAddedTestBody(added))
|
|
724
914
|
continue;
|
|
725
|
-
const code = added.join('\n');
|
|
726
915
|
const framework = frameworkForTestPath(unit.path);
|
|
727
|
-
|
|
916
|
+
const file = visibleByPath.get(unit.path);
|
|
917
|
+
if (!file)
|
|
918
|
+
continue;
|
|
919
|
+
if (weakBlockIn(file, unit, framework)) {
|
|
728
920
|
findings.push(new GuardianFinding({
|
|
729
921
|
path: unit.path,
|
|
730
922
|
unit: unit.path,
|
|
@@ -909,6 +1101,26 @@ function findingDict(finding) {
|
|
|
909
1101
|
uncovered_lines: finding.uncovered_lines,
|
|
910
1102
|
};
|
|
911
1103
|
}
|
|
1104
|
+
/** Short display form for a rev: 10 chars of a sha, a ref name verbatim. */
|
|
1105
|
+
function shortRev(rev) {
|
|
1106
|
+
if (!rev)
|
|
1107
|
+
return '?';
|
|
1108
|
+
return /^[0-9a-f]{40}$/i.test(rev) ? rev.slice(0, 10) : rev;
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* The one-line diff provenance shown on every surface (#761).
|
|
1112
|
+
*
|
|
1113
|
+
* Deliberately terse and always present — a line that appears only when
|
|
1114
|
+
* something is wrong teaches readers to ignore it when it does appear.
|
|
1115
|
+
*/
|
|
1116
|
+
export const MERGE_REF_WARNING = 'HEAD is a pull_request MERGE REF, not the PR head, so this diff spans ' +
|
|
1117
|
+
'commits merged into the base branch and is WIDER than the PR';
|
|
1118
|
+
export function provenanceLine(p) {
|
|
1119
|
+
const noun = p.fileCount === 1 ? 'file' : 'files';
|
|
1120
|
+
const range = `${shortRev(p.base)}...${shortRev(p.head)}`;
|
|
1121
|
+
const warn = p.mergeRef ? ` ${EM_DASH} ${MERGE_REF_WARNING}` : '';
|
|
1122
|
+
return `Diff: \`${range}\` (${p.fileCount} ${noun}, via ${p.origin})${warn}`;
|
|
1123
|
+
}
|
|
912
1124
|
/**
|
|
913
1125
|
* Join every degradation notice this run produced into one line, dropping the
|
|
914
1126
|
* empty ones. Notices are independent (the agent tier and the coverage input
|
|
@@ -922,6 +1134,54 @@ export function combineNotices(...notices) {
|
|
|
922
1134
|
function coverageBlock(state) {
|
|
923
1135
|
return { status: coverageStatus(state), ...state };
|
|
924
1136
|
}
|
|
1137
|
+
/**
|
|
1138
|
+
* True when this run VERIFIED NO COVERAGE and every finding it produced is a
|
|
1139
|
+
* naming-heuristic guess (#761) — an abstention, not a result.
|
|
1140
|
+
*
|
|
1141
|
+
* Guardian's existing abstention keys off the *findings-eligible* count, which
|
|
1142
|
+
* is the wrong denominator: a run can have plenty of eligible units and still
|
|
1143
|
+
* have verified nothing, because "findings-eligible" and "coverage-verifiable"
|
|
1144
|
+
* are different counts. The measured shape is a code PR whose lcov never
|
|
1145
|
+
* reached the runner: N eligible units, zero coverage denominator, and a
|
|
1146
|
+
* confident "6 files need test coverage" headline under a green check.
|
|
1147
|
+
*
|
|
1148
|
+
* Two narrowings keep this honest rather than merely loud:
|
|
1149
|
+
*
|
|
1150
|
+
* - `unitsTotal === 0` is NOT this case. A run that judged nothing makes no
|
|
1151
|
+
* coverage claim in either direction; the eligible-count abstention owns it,
|
|
1152
|
+
* the same boundary {@link coverageDegradedNotice} already draws.
|
|
1153
|
+
* - A single coverage- or graph-verified finding disproves it. Real evidence
|
|
1154
|
+
* means the run measured something, so it is a result and must not be
|
|
1155
|
+
* downgraded to an abstention.
|
|
1156
|
+
* - A run with NO findings is left alone. It states nothing a reader can
|
|
1157
|
+
* mistake for a measurement: #554 already replaced its all-clear headline
|
|
1158
|
+
* with "no gaps found, but coverage was unavailable" plus the body line
|
|
1159
|
+
* saying that is an abstention, not a pass. The defect #761 reports is
|
|
1160
|
+
* specifically a CONFIDENT COUNT over a zero coverage denominator, so that
|
|
1161
|
+
* is what changes here.
|
|
1162
|
+
*/
|
|
1163
|
+
export function isCoverageAbstention(coverage, findings) {
|
|
1164
|
+
if (!coverage || coverage.unitsTotal === 0)
|
|
1165
|
+
return false;
|
|
1166
|
+
if (coverageStatus(coverage) !== 'unavailable')
|
|
1167
|
+
return false;
|
|
1168
|
+
if (findings.length === 0)
|
|
1169
|
+
return false;
|
|
1170
|
+
return findings.every((f) => f.fidelity === Fidelity.Heuristic);
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* The abstention headline (#761) — states what was NOT verified, and never a
|
|
1174
|
+
* count of findings, which is what reads as a measured result.
|
|
1175
|
+
*/
|
|
1176
|
+
function abstentionHeadline(checked) {
|
|
1177
|
+
const noun = checked === 1 ? 'file' : 'files';
|
|
1178
|
+
return (`${WARNING} abstained: no coverage data ` +
|
|
1179
|
+
`(${checked} ${noun} judged heuristically)`);
|
|
1180
|
+
}
|
|
1181
|
+
/** The body paragraph that stops the heuristic findings reading as a verdict. */
|
|
1182
|
+
const ABSTENTION_BODY = 'No coverage report reached this run, so nothing below is a coverage ' +
|
|
1183
|
+
'verdict — every finding is a filename-level guess. A gate that verified ' +
|
|
1184
|
+
'zero items has abstained; this is not a pass.';
|
|
925
1185
|
/**
|
|
926
1186
|
* The comment body for a run with zero active findings.
|
|
927
1187
|
*
|
|
@@ -930,11 +1190,13 @@ function coverageBlock(state) {
|
|
|
930
1190
|
* footer under a green headline is read as boilerplate, and this is the exact
|
|
931
1191
|
* shape that let 43 coverage-blind PRs read as covered.
|
|
932
1192
|
*/
|
|
933
|
-
function noGapsLines(coverageState, suppressedCount) {
|
|
1193
|
+
function noGapsLines(coverageState, suppressedCount, abstained = false, checked = 0) {
|
|
934
1194
|
const notice = coverageState ? coverageDegradedNotice(coverageState) : null;
|
|
935
|
-
const headline =
|
|
936
|
-
?
|
|
937
|
-
:
|
|
1195
|
+
const headline = abstained
|
|
1196
|
+
? abstentionHeadline(checked)
|
|
1197
|
+
: notice
|
|
1198
|
+
? `${WARNING} no gaps found, but coverage was ${coverageStatus(coverageState)}`
|
|
1199
|
+
: `${WHITE_CHECK} no test-coverage gaps`;
|
|
938
1200
|
const lines = [`## ${BABY_CHICK} Canary PR Guardian ${EM_DASH} ${headline}`];
|
|
939
1201
|
if (notice) {
|
|
940
1202
|
lines.push(`> **${notice}**`, '', 'Zero files matched is an abstention, not a pass — nothing here is ' +
|
|
@@ -947,6 +1209,8 @@ function noGapsLines(coverageState, suppressedCount) {
|
|
|
947
1209
|
}
|
|
948
1210
|
export function renderFindings(findings, fmt, tier = 0, degradedNotice = null, gateMeta = null, blobBase = null) {
|
|
949
1211
|
const ordered = [...findings].sort((a, b) => severitySortKey(a.severity) - severitySortKey(b.severity));
|
|
1212
|
+
// #761: an abstained run never headlines a count, on any surface.
|
|
1213
|
+
const abstained = gateMeta?.abstained === true;
|
|
950
1214
|
// #554: the coverage ladder's own degradation, stated alongside the tier's.
|
|
951
1215
|
const coverageState = gateMeta?.coverage ?? null;
|
|
952
1216
|
const coverageNotice = coverageState
|
|
@@ -970,6 +1234,10 @@ export function renderFindings(findings, fmt, tier = 0, degradedNotice = null, g
|
|
|
970
1234
|
payload['skipped'] = gateMeta.skipped ?? [];
|
|
971
1235
|
if (coverageState)
|
|
972
1236
|
payload['coverage'] = coverageBlock(coverageState);
|
|
1237
|
+
// #761: machine consumers need the diff's endpoints for the same reason
|
|
1238
|
+
// humans do — every count in this payload is scoped by them.
|
|
1239
|
+
if (gateMeta.provenance)
|
|
1240
|
+
payload['provenance'] = { ...gateMeta.provenance };
|
|
973
1241
|
}
|
|
974
1242
|
return ensureAscii(JSON.stringify(payload, null, 2));
|
|
975
1243
|
}
|
|
@@ -1023,19 +1291,33 @@ export function renderFindings(findings, fmt, tier = 0, degradedNotice = null, g
|
|
|
1023
1291
|
// nothing. The notice goes in the BODY, not only the footer — a `<sub>` line
|
|
1024
1292
|
// under a green headline is read as boilerplate.
|
|
1025
1293
|
const coverageLine = coverageNotice ? `> **${coverageNotice}**` : null;
|
|
1294
|
+
// #761: shown on EVERY comment, clean or not. The run that motivated this was
|
|
1295
|
+
// a findings run whose findings were all phantom, so gating the line on a
|
|
1296
|
+
// problem guardian had not detected would have hidden it exactly when needed.
|
|
1297
|
+
const provLine = gateMeta?.provenance
|
|
1298
|
+
? `<sub>${provenanceLine(gateMeta.provenance)}</sub>`
|
|
1299
|
+
: null;
|
|
1026
1300
|
if (fmt === 'comment') {
|
|
1027
1301
|
const fileCount = new Set(active.map((f) => f.path)).size;
|
|
1028
1302
|
const lines = [STICKY_MARKER];
|
|
1029
1303
|
if (active.length === 0) {
|
|
1030
|
-
lines.push(...noGapsLines(coverageState, suppressed.length));
|
|
1304
|
+
lines.push(...noGapsLines(coverageState, suppressed.length, abstained, gateMeta?.checked ?? 0));
|
|
1031
1305
|
}
|
|
1032
1306
|
else {
|
|
1033
1307
|
const noun = fileCount === 1 ? 'file needs' : 'files need';
|
|
1308
|
+
// #761: on an abstained run the headline states the abstention instead of
|
|
1309
|
+
// a count. The findings stay in the table below — they are useful, they
|
|
1310
|
+
// are just not a coverage verdict, and a count headline is exactly what
|
|
1311
|
+
// makes a reader take them for one.
|
|
1034
1312
|
lines.push(`## ${BABY_CHICK} Canary PR Guardian ${EM_DASH} ` +
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1313
|
+
(abstained
|
|
1314
|
+
? abstentionHeadline(gateMeta?.checked ?? 0)
|
|
1315
|
+
: `${fileCount} ${noun} test coverage`));
|
|
1316
|
+
lines.push(abstained
|
|
1317
|
+
? ABSTENTION_BODY
|
|
1318
|
+
: 'These lines were changed by this PR but no test exercises them. Add or ' +
|
|
1319
|
+
'extend a test that covers them, or mark the line ' +
|
|
1320
|
+
'`// canary:allow-untested <reason>` if it is intentionally untested.');
|
|
1039
1321
|
if (coverageLine)
|
|
1040
1322
|
lines.push('', coverageLine);
|
|
1041
1323
|
lines.push('', '| Sev | File | What is uncovered, and what to do | Confidence |', '| --- | --- | --- | --- |');
|
|
@@ -1069,6 +1351,10 @@ export function renderFindings(findings, fmt, tier = 0, degradedNotice = null, g
|
|
|
1069
1351
|
lines.push('', `<sub>${suppressed.length} finding(s) suppressed as intentional and not counted above.</sub>`);
|
|
1070
1352
|
}
|
|
1071
1353
|
}
|
|
1354
|
+
// Directly above the confidence footer: provenance and confidence are the
|
|
1355
|
+
// two "how much should I trust this" facts, so they read as one block.
|
|
1356
|
+
if (provLine)
|
|
1357
|
+
lines.push('', provLine);
|
|
1072
1358
|
lines.push('', footerLine);
|
|
1073
1359
|
return lines.join('\n');
|
|
1074
1360
|
}
|
|
@@ -1077,16 +1363,29 @@ export function renderFindings(findings, fmt, tier = 0, degradedNotice = null, g
|
|
|
1077
1363
|
? // #554: same rule as the comment surface — a blind run never claims clean.
|
|
1078
1364
|
`Canary PR Guardian — no gaps found, but coverage was ${coverageStatus(coverageState)}`
|
|
1079
1365
|
: 'Canary PR Guardian — no test-coverage gaps';
|
|
1366
|
+
// #761: the same rule on the surface an engineer reads at their desk. The
|
|
1367
|
+
// headline is stripped of the comment surface's markdown-era glyph so the
|
|
1368
|
+
// terminal line stays plain text.
|
|
1369
|
+
const textAbstention = `Canary PR Guardian — ` +
|
|
1370
|
+
abstentionHeadline(gateMeta?.checked ?? 0).replace(`${WARNING} `, '');
|
|
1080
1371
|
const lines = [
|
|
1081
|
-
|
|
1082
|
-
?
|
|
1083
|
-
:
|
|
1372
|
+
abstained
|
|
1373
|
+
? textAbstention
|
|
1374
|
+
: active.length === 0
|
|
1375
|
+
? cleanHeadline
|
|
1376
|
+
: `Canary PR Guardian — ${new Set(active.map((f) => f.path)).size} file(s) need test coverage`,
|
|
1084
1377
|
];
|
|
1085
1378
|
for (const finding of ordered) {
|
|
1086
1379
|
const unit = finding.unit && finding.unit !== finding.path ? ` → ${finding.unit}` : '';
|
|
1087
1380
|
const mark = finding.suppressed ? ' (suppressed)' : '';
|
|
1088
1381
|
lines.push(`[${finding.severity}] ${finding.path}${unit} — ${finding.evidence} (${finding.fidelity})${mark}`);
|
|
1089
1382
|
}
|
|
1383
|
+
// #761: the terminal surface gets the same provenance the comment does —
|
|
1384
|
+
// this is the one an engineer reads at their desk, where a wrong `--diff` is
|
|
1385
|
+
// likeliest.
|
|
1386
|
+
if (gateMeta?.provenance) {
|
|
1387
|
+
lines.push(provenanceLine(gateMeta.provenance).replace(/`/g, ''));
|
|
1388
|
+
}
|
|
1090
1389
|
let footer = `tier ${tier}: deterministic check, no LLM`;
|
|
1091
1390
|
if (notice)
|
|
1092
1391
|
footer += ` - ${notice}`;
|