canary-test-cli 6.4.0 → 6.5.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/dist/doctor.d.ts +51 -3
- package/dist/doctor.js +76 -9
- package/dist/engine/analysis/cli.js +69 -6
- package/dist/engine/cli-commands.js +33 -0
- package/dist/engine/core/gate-result.js +9 -2
- package/dist/engine/guardian/adjudication.js +364 -0
- package/dist/engine/guardian/analysis-emit.js +2 -0
- package/dist/engine/guardian/cli.js +282 -15
- package/dist/engine/guardian/hard-gate.js +15 -2
- package/dist/engine/guardian/pr-check.js +5 -12
- package/dist/engine/history/cli.js +67 -0
- package/dist/engine/history/ndjson-store.js +4 -0
- package/dist/engine/history/store.js +3 -0
- package/dist/gate-result.d.ts +67 -0
- package/dist/gate-result.js +73 -0
- package/dist/overlay-commands.js +17 -1
- package/package.json +2 -1
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
* `.exitOverride()` turns commander's own usage errors into throws too, so a
|
|
19
19
|
* test never terminates the process.
|
|
20
20
|
*
|
|
21
|
-
* Command surface (kebab-case names
|
|
21
|
+
* Command surface (kebab-case names; the first seven match the shipping Typer
|
|
22
|
+
* CLI, the last two are TS-native additions for #490):
|
|
22
23
|
* analyze | validate-coverage | harden-gate | pr-check | author-plan |
|
|
23
|
-
* mark-authored | watch.
|
|
24
|
+
* mark-authored | watch | collect-adjudications | precision.
|
|
24
25
|
*
|
|
25
26
|
* Python->TS nuances honored:
|
|
26
27
|
* - `json.dumps(obj, indent=2)` -> `ensureAscii(JSON.stringify(obj, null, 2))`
|
|
@@ -49,11 +50,13 @@ import { Command, Option } from 'commander';
|
|
|
49
50
|
import { load as loadYaml } from 'js-yaml';
|
|
50
51
|
import pc from 'picocolors';
|
|
51
52
|
import { AuthoringContext, InSessionAgentProbe, InSessionAgentTier, decideBlock, } from './agent-tier.js';
|
|
53
|
+
import { RestReactionsClient, collectAdjudications, loadAdjudicationRecords, renderPrecision, summarizePrecision, } from './adjudication.js';
|
|
52
54
|
import { emitAnalysis } from './analysis-emit.js';
|
|
53
|
-
import {
|
|
55
|
+
import { gateOutcome } from '../core/gate-result.js';
|
|
56
|
+
import { resolveCoverage, validateCoverageJson, } from './coverage.js';
|
|
54
57
|
import { buildApiDelta, writeApiDelta } from './delta-emitter.js';
|
|
55
58
|
import { extractApiDiff } from './diff-extractor.js';
|
|
56
|
-
import { HardGateBlocked, RestBranchProtectionClient, applyHardGate, renderPlaybook, } from './hard-gate.js';
|
|
59
|
+
import { HardGateAbstained, HardGateBlocked, RestBranchProtectionClient, applyHardGate, renderPlaybook, } from './hard-gate.js';
|
|
57
60
|
import { mapImpact } from './impact-mapper.js';
|
|
58
61
|
import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
|
|
59
62
|
import { RestGitHubClient, degradationAnnotation, upsertStickyComment, } from './pr-comment.js';
|
|
@@ -139,6 +142,7 @@ export function defaultDeps() {
|
|
|
139
142
|
};
|
|
140
143
|
},
|
|
141
144
|
buildCommentClient: (repo, prNumber) => new RestGitHubClient(repo, prNumber, process.env['GITHUB_TOKEN'] ?? ''),
|
|
145
|
+
buildReactionsClient: (repo, prNumber) => new RestReactionsClient(repo, prNumber, process.env['GITHUB_TOKEN'] ?? ''),
|
|
142
146
|
buildBranchProtectionClient: (repo, token) => new RestBranchProtectionClient(repo, token),
|
|
143
147
|
makeAgentTier: () => new InSessionAgentTier(),
|
|
144
148
|
sleep: (secs) => new Promise((resolve) => setTimeout(resolve, secs * 1000)),
|
|
@@ -523,6 +527,16 @@ function analyzeCmd(commit, opts, deps) {
|
|
|
523
527
|
const coverageRows = opts.coverage ? loadCoverage(opts.coverage) : [];
|
|
524
528
|
const gaps = mapImpact(diff, coverageRows);
|
|
525
529
|
const summary = buildSummary(gaps, sha, opts.suite);
|
|
530
|
+
// #508 advisory abstention (D3): a diff with zero endpoints analyzed
|
|
531
|
+
// nothing. gateOutcome is the only decision point -- no local === 0.
|
|
532
|
+
const endpointCount = diff.added.length + diff.removed.length + diff.changed.length;
|
|
533
|
+
const outcome = gateOutcome({ checked: endpointCount, findings: gaps }, 'advisory', { noun: 'endpoint(s)' });
|
|
534
|
+
if (outcome.abstained) {
|
|
535
|
+
deps.out(outcome.summaryLine);
|
|
536
|
+
deps.out('guardian: the spec diff contains zero endpoints, so there was no ' +
|
|
537
|
+
'impact to analyze. Pass --spec-before/--spec-after pointing at ' +
|
|
538
|
+
'specs that actually differ.');
|
|
539
|
+
}
|
|
526
540
|
if (opts.json) {
|
|
527
541
|
deps.out(ensureAscii(JSON.stringify({
|
|
528
542
|
commit: sha,
|
|
@@ -530,6 +544,8 @@ function analyzeCmd(commit, opts, deps) {
|
|
|
530
544
|
added: diff.added.length,
|
|
531
545
|
removed: diff.removed.length,
|
|
532
546
|
changed: diff.changed.length,
|
|
547
|
+
checked: endpointCount,
|
|
548
|
+
abstained: outcome.abstained,
|
|
533
549
|
gaps: gaps.map((g) => ({
|
|
534
550
|
path: g.path,
|
|
535
551
|
method: g.method,
|
|
@@ -547,6 +563,17 @@ function analyzeCmd(commit, opts, deps) {
|
|
|
547
563
|
}
|
|
548
564
|
}
|
|
549
565
|
const MAX_COVERAGE_BYTES = 25 * 1024 * 1024;
|
|
566
|
+
/** The validator's denominator: entries in the `files` map (#508). */
|
|
567
|
+
function coverageEntryCount(data) {
|
|
568
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
569
|
+
return 0;
|
|
570
|
+
}
|
|
571
|
+
const files = data['files'];
|
|
572
|
+
if (typeof files !== 'object' || files === null || Array.isArray(files)) {
|
|
573
|
+
return 0;
|
|
574
|
+
}
|
|
575
|
+
return Object.keys(files).length;
|
|
576
|
+
}
|
|
550
577
|
function validateCoverageCmd(path, opts, deps) {
|
|
551
578
|
let text;
|
|
552
579
|
try {
|
|
@@ -577,6 +604,7 @@ function validateCoverageCmd(path, opts, deps) {
|
|
|
577
604
|
const errors = problems.filter((p) => p.severity === 'error');
|
|
578
605
|
const warnings = problems.filter((p) => p.severity === 'warning');
|
|
579
606
|
const valid = errors.length === 0;
|
|
607
|
+
const outcome = gateOutcome({ checked: coverageEntryCount(data), findings: problems }, 'advisory', { noun: 'file entrie(s)' });
|
|
580
608
|
if (opts.json) {
|
|
581
609
|
// Plain stdout, NOT colored -- producer-controlled keys must not be
|
|
582
610
|
// interpreted as markup, and the payload must stay valid JSON.
|
|
@@ -587,6 +615,8 @@ function validateCoverageCmd(path, opts, deps) {
|
|
|
587
615
|
location: pr.location,
|
|
588
616
|
message: pr.message,
|
|
589
617
|
})),
|
|
618
|
+
checked: coverageEntryCount(data),
|
|
619
|
+
abstained: outcome.abstained,
|
|
590
620
|
}, null, 2)));
|
|
591
621
|
}
|
|
592
622
|
else {
|
|
@@ -597,7 +627,15 @@ function validateCoverageCmd(path, opts, deps) {
|
|
|
597
627
|
deps.out(`${pc.yellow('warning')} ${pr.location}: ${pr.message}`);
|
|
598
628
|
}
|
|
599
629
|
if (valid && warnings.length === 0) {
|
|
600
|
-
|
|
630
|
+
if (outcome.abstained) {
|
|
631
|
+
deps.out(outcome.summaryLine);
|
|
632
|
+
deps.out(`guardian: ${path} carries zero file entries ${EM_DASH} nothing ` +
|
|
633
|
+
'was validated. Check that the producer wrote a non-empty ' +
|
|
634
|
+
"'files' map.");
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
deps.out(pc.green(pc.bold(`${CHECK} ${path} is a valid coverage-json document.`)));
|
|
638
|
+
}
|
|
601
639
|
}
|
|
602
640
|
else if (valid) {
|
|
603
641
|
deps.out(`${pc.green(`${CHECK} valid`)} with ${warnings.length} warning(s) ${EM_DASH} coverage is usable but degraded.`);
|
|
@@ -610,6 +648,20 @@ function validateCoverageCmd(path, opts, deps) {
|
|
|
610
648
|
throw new CliExit(1);
|
|
611
649
|
}
|
|
612
650
|
}
|
|
651
|
+
/**
|
|
652
|
+
* Print the precision evidence the promotion contract depends on (#490).
|
|
653
|
+
*
|
|
654
|
+
* The soft→hard promotion is earned by reviewer adjudication feeding
|
|
655
|
+
* `precision = TP / (TP + FP)`; before #490 that contract lived only in a
|
|
656
|
+
* comment with nothing feeding it. This surfaces the measured number — or an
|
|
657
|
+
* honest `unknown` over an empty sample — in the readiness output. Advisory:
|
|
658
|
+
* it informs the operator's decision, it does not block the registration.
|
|
659
|
+
*/
|
|
660
|
+
function reportPrecisionEvidence(analysesDir, deps) {
|
|
661
|
+
const summary = summarizePrecision(loadAdjudicationRecords(analysesDir));
|
|
662
|
+
const line = renderPrecision(summary);
|
|
663
|
+
deps.out(summary.precision === null ? pc.yellow(line) : line);
|
|
664
|
+
}
|
|
613
665
|
async function hardenGateCmd(opts, deps) {
|
|
614
666
|
const repo = opts.repo;
|
|
615
667
|
if (!repo) {
|
|
@@ -617,6 +669,8 @@ async function hardenGateCmd(opts, deps) {
|
|
|
617
669
|
throw new CliExit(2);
|
|
618
670
|
}
|
|
619
671
|
const playbook = renderPlaybook(repo, opts.branch, opts.check);
|
|
672
|
+
// #490: the readiness evidence the promotion is supposed to rest on.
|
|
673
|
+
reportPrecisionEvidence(resolveAnalysesDir(opts.analysesDir, deps), deps);
|
|
620
674
|
if (!opts.apply) {
|
|
621
675
|
deps.out(`${pc.bold('Dry run')} ${EM_DASH} would require the '${opts.check}' check on ${repo}@${opts.branch}.`);
|
|
622
676
|
deps.out('On --apply this merges into existing protection (or creates minimal ' +
|
|
@@ -637,6 +691,15 @@ async function hardenGateCmd(opts, deps) {
|
|
|
637
691
|
plan = await applyHardGate(client, repo, opts.branch, opts.check, opts.force ?? false);
|
|
638
692
|
}
|
|
639
693
|
catch (exc) {
|
|
694
|
+
if (exc instanceof HardGateAbstained) {
|
|
695
|
+
const outcome = gateOutcome({ checked: 0, findings: [] }, 'gate', {
|
|
696
|
+
noun: 'check context(s)',
|
|
697
|
+
});
|
|
698
|
+
deps.out(outcome.summaryLine);
|
|
699
|
+
deps.out(`${pc.red(pc.bold(`${CROSS} ${exc.reason}`))}\n`);
|
|
700
|
+
deps.out(exc.playbook);
|
|
701
|
+
throw new CliExit(outcome.exitCode); // 3, never 1
|
|
702
|
+
}
|
|
640
703
|
if (exc instanceof HardGateBlocked) {
|
|
641
704
|
deps.out(`${pc.red(pc.bold(`${CROSS} ${exc.reason}`))}\n`);
|
|
642
705
|
deps.out(exc.playbook);
|
|
@@ -657,6 +720,67 @@ async function hardenGateCmd(opts, deps) {
|
|
|
657
720
|
'canary.guardian.pr.gate = "hard" in harness.config.json ' +
|
|
658
721
|
'(or run pr-check --gate hard).'));
|
|
659
722
|
}
|
|
723
|
+
/**
|
|
724
|
+
* Explicit adjudication sweep for one PR (the scheduled-sweep / at-desk shape;
|
|
725
|
+
* `pr-check` runs the same collection inline on its CI surfaces). Unlike the
|
|
726
|
+
* inline best-effort path this one FAILS LOUDLY (exit 1 on an unavailable
|
|
727
|
+
* channel) — an operator who asked for a collection must know it did not land.
|
|
728
|
+
*/
|
|
729
|
+
async function collectAdjudicationsCmd(opts, deps) {
|
|
730
|
+
let repo = opts.repo;
|
|
731
|
+
let prNumber = opts.pr;
|
|
732
|
+
if (!repo || prNumber === undefined) {
|
|
733
|
+
const ctx = prContextFromEnv(deps.env);
|
|
734
|
+
if (ctx !== null) {
|
|
735
|
+
repo = repo ?? ctx[0];
|
|
736
|
+
prNumber = prNumber ?? ctx[1];
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
if (!repo || prNumber === undefined) {
|
|
740
|
+
deps.out(`${pc.red(pc.bold(`${CROSS} no PR context`))} ${EM_DASH} pass --repo and --pr, or run in Actions.`);
|
|
741
|
+
throw new CliExit(2);
|
|
742
|
+
}
|
|
743
|
+
const client = deps.buildReactionsClient(repo, prNumber);
|
|
744
|
+
const res = await collectAdjudications(client, {
|
|
745
|
+
repo,
|
|
746
|
+
prNumber,
|
|
747
|
+
analysesDir: resolveAnalysesDir(opts.analysesDir, deps),
|
|
748
|
+
});
|
|
749
|
+
if (opts.json) {
|
|
750
|
+
deps.out(ensureAscii(JSON.stringify({ action: res.action, path: res.path, record: res.record }, null, 2)));
|
|
751
|
+
}
|
|
752
|
+
else if (res.action === 'collected' && res.record) {
|
|
753
|
+
deps.out(pc.green(`${CHECK} adjudication recorded (${res.record.tp} up / ` +
|
|
754
|
+
`${res.record.fp} down, ${res.record.granularity}-level) ` +
|
|
755
|
+
`${RIGHT_ARROW} ${res.path}`));
|
|
756
|
+
}
|
|
757
|
+
else if (res.action === 'no-comment') {
|
|
758
|
+
deps.out(`guardian: no sticky comment on ${repo}#${prNumber} ${EM_DASH} nothing to adjudicate.`);
|
|
759
|
+
}
|
|
760
|
+
else if (res.action === 'no-reactions') {
|
|
761
|
+
deps.out(`guardian: sticky comment on ${repo}#${prNumber} has no reviewer ` +
|
|
762
|
+
`verdicts yet ${EM_DASH} nothing recorded (no reaction is neutral, ` +
|
|
763
|
+
`not a data point).`);
|
|
764
|
+
}
|
|
765
|
+
if (res.action === 'unavailable') {
|
|
766
|
+
deps.out(pc.red(pc.bold(`${CROSS} ${res.notice ?? 'not persisted'}`)));
|
|
767
|
+
throw new CliExit(1);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/** Aggregate the persisted adjudications into the promotion evidence (#490). */
|
|
771
|
+
function precisionCmd(opts, deps) {
|
|
772
|
+
const analysesDir = resolveAnalysesDir(opts.analysesDir, deps);
|
|
773
|
+
const records = loadAdjudicationRecords(analysesDir);
|
|
774
|
+
const summary = summarizePrecision(records);
|
|
775
|
+
if (opts.json) {
|
|
776
|
+
// `precision: null` is the honest zero-denominator value — consumers must
|
|
777
|
+
// treat it as unknown, never as 1.0 (#490).
|
|
778
|
+
deps.out(ensureAscii(JSON.stringify({ ...summary, records: records.length }, null, 2)));
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const line = renderPrecision(summary);
|
|
782
|
+
deps.out(summary.precision === null ? pc.yellow(line) : line);
|
|
783
|
+
}
|
|
660
784
|
// --- pr-check -----------------------------------------------------------------
|
|
661
785
|
/**
|
|
662
786
|
* Upsert the Phase-2 sticky PR comment (behavior-preserving extraction). When no
|
|
@@ -679,8 +803,96 @@ async function postStickyComment(findings, resolution, deps) {
|
|
|
679
803
|
}
|
|
680
804
|
}
|
|
681
805
|
/** The gate's no-op line, shared by the pre- and post-filter exits. */
|
|
682
|
-
|
|
683
|
-
|
|
806
|
+
// D7: every filtered path stays visible as a SkipEntry, never folded
|
|
807
|
+
// into "passed". One entry per path so the rendered count still equals
|
|
808
|
+
// the path count the old `N path(s) skipped` line reported.
|
|
809
|
+
function prCheckSkipEntries(skipped, testUnits, barrelUnits, noisePaths = []) {
|
|
810
|
+
return [
|
|
811
|
+
...skipped.map((u) => ({ name: u.path, reason: 'skipGlobs' })),
|
|
812
|
+
...testUnits.map((u) => ({ name: u.path, reason: 'test path' })),
|
|
813
|
+
...barrelUnits.map((u) => ({
|
|
814
|
+
name: u.path,
|
|
815
|
+
reason: 're-export barrel',
|
|
816
|
+
})),
|
|
817
|
+
...noisePaths.map((p) => ({
|
|
818
|
+
name: p,
|
|
819
|
+
reason: 'heuristic-ineligible',
|
|
820
|
+
})),
|
|
821
|
+
];
|
|
822
|
+
}
|
|
823
|
+
// Remediation is required copy (#508): say WHY the denominator
|
|
824
|
+
// collapsed and the first fix step. The #456 class, now loud.
|
|
825
|
+
const PR_CHECK_ABSTAIN_REMEDIATION = [
|
|
826
|
+
'guardian: the diff contained no findings-eligible units, so NO ' +
|
|
827
|
+
'coverage was verified. This is not a pass (exit 3, abstained).',
|
|
828
|
+
'If you expected verification: in CI, checkout with fetch-depth: 0 ' +
|
|
829
|
+
'or pass --diff <base>...HEAD; locally, confirm the diff is ' +
|
|
830
|
+
'non-empty and skipGlobs/heuristicExclude are not filtering ' +
|
|
831
|
+
'every path.',
|
|
832
|
+
];
|
|
833
|
+
/** Exit 3 with the structural abstention line + remediation (#508). */
|
|
834
|
+
function abstainPrCheck(skipped, format, deps) {
|
|
835
|
+
const outcome = gateOutcome({ checked: 0, findings: [], skipped }, 'gate', {
|
|
836
|
+
noun: 'unit(s)',
|
|
837
|
+
});
|
|
838
|
+
deps.out(outcome.summaryLine);
|
|
839
|
+
for (const line of PR_CHECK_ABSTAIN_REMEDIATION)
|
|
840
|
+
deps.out(line);
|
|
841
|
+
if (format === 'json') {
|
|
842
|
+
deps.out(ensureAscii(JSON.stringify({ findings: [], tier: 0, checked: 0, abstained: true }, null, 2)));
|
|
843
|
+
}
|
|
844
|
+
throw new CliExit(outcome.exitCode); // EXIT_ABSTAINED
|
|
845
|
+
}
|
|
846
|
+
/** Resolve the analyses-channel dir (test override, else repo-root default). */
|
|
847
|
+
function resolveAnalysesDir(override, deps) {
|
|
848
|
+
return override ?? join(gitToplevel(deps), '.harness', 'analyses');
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Collect 👍/👎 adjudications off the sticky comment, best-effort (#490).
|
|
852
|
+
*
|
|
853
|
+
* Runs on the NEXT `pr-check` for a PR (the collection loop the #490 sketch
|
|
854
|
+
* chose over a scheduled sweep — the guardian already authenticates against
|
|
855
|
+
* this API and already finds its own comment by marker). Read-only against
|
|
856
|
+
* GitHub, so it works where the fork-degraded poster could not write.
|
|
857
|
+
*
|
|
858
|
+
* NEVER affects the gate: any failure prints a `::warning::` and returns —
|
|
859
|
+
* a broken feedback loop must not turn a coverage gate red.
|
|
860
|
+
*/
|
|
861
|
+
async function collectAdjudicationsBestEffort(analysesDirOverride, deps) {
|
|
862
|
+
const ctx = prContextFromEnv(deps.env);
|
|
863
|
+
if (ctx === null)
|
|
864
|
+
return; // no PR context — nothing to collect against
|
|
865
|
+
if (!deps.env['GITHUB_TOKEN']) {
|
|
866
|
+
// Every read here needs a token; skipping LOUDLY beats a guaranteed 401.
|
|
867
|
+
deps.out(degradationAnnotation(`guardian: no GITHUB_TOKEN ${EM_DASH} reviewer adjudications not ` +
|
|
868
|
+
'collected this run'));
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
// Resolved lazily (shells out to git) only once a collection will happen —
|
|
872
|
+
// an explicit `--diff` run without PR context must stay subprocess-free.
|
|
873
|
+
const analysesDir = resolveAnalysesDir(analysesDirOverride, deps);
|
|
874
|
+
try {
|
|
875
|
+
const client = deps.buildReactionsClient(ctx[0], ctx[1]);
|
|
876
|
+
const res = await collectAdjudications(client, {
|
|
877
|
+
repo: ctx[0],
|
|
878
|
+
prNumber: ctx[1],
|
|
879
|
+
analysesDir,
|
|
880
|
+
});
|
|
881
|
+
if (res.action === 'collected' && res.record) {
|
|
882
|
+
deps.out(`guardian: adjudication recorded (${res.record.tp} up / ` +
|
|
883
|
+
`${res.record.fp} down) ${RIGHT_ARROW} ${res.path}`);
|
|
884
|
+
}
|
|
885
|
+
else if (res.action === 'unavailable' && res.notice) {
|
|
886
|
+
deps.out(degradationAnnotation(res.notice));
|
|
887
|
+
}
|
|
888
|
+
// no-comment / no-reactions: nothing to say — absence of a reaction is
|
|
889
|
+
// neutral, not a data point (#490).
|
|
890
|
+
}
|
|
891
|
+
catch (exc) {
|
|
892
|
+
const message = exc instanceof Error ? exc.message : String(exc);
|
|
893
|
+
deps.out(degradationAnnotation(`guardian: adjudication collection failed (${message}) ${EM_DASH} ` +
|
|
894
|
+
'findings and gate unaffected'));
|
|
895
|
+
}
|
|
684
896
|
}
|
|
685
897
|
async function prCheckCmd(opts, deps) {
|
|
686
898
|
const [config, warning] = loadGuardianConfig(opts.config);
|
|
@@ -695,6 +907,12 @@ async function prCheckCmd(opts, deps) {
|
|
|
695
907
|
throw new CliExit(0);
|
|
696
908
|
}
|
|
697
909
|
const effectiveGate = opts.gate ?? config.pr_gate;
|
|
910
|
+
// #490: read reviewer 👍/👎 off the PREVIOUS run's sticky comment before this
|
|
911
|
+
// run touches it. Runs on the posting/emitting (CI) surfaces only, before the
|
|
912
|
+
// early exits so a docs-only follow-up push still harvests the verdicts.
|
|
913
|
+
if (opts.postComment || opts.emitAnalysis) {
|
|
914
|
+
await collectAdjudicationsBestEffort(opts.analysesDir, deps);
|
|
915
|
+
}
|
|
698
916
|
// #369: in CI an omitted `--diff` resolves the PR diff from the base ref;
|
|
699
917
|
// the working-tree fallback is empty on a clean checkout.
|
|
700
918
|
const resolvedDiff = readPrDiff(opts.diff ?? null, deps);
|
|
@@ -715,8 +933,7 @@ async function prCheckCmd(opts, deps) {
|
|
|
715
933
|
: [];
|
|
716
934
|
const preFilterSkipped = skipped.length + testUnits.length + barrelUnits.length;
|
|
717
935
|
if (kept.length === 0 && weakFindings.length === 0) {
|
|
718
|
-
|
|
719
|
-
throw new CliExit(0);
|
|
936
|
+
abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits), opts.format, deps);
|
|
720
937
|
}
|
|
721
938
|
const results = resolveCoverage(kept, {
|
|
722
939
|
coveragePath: opts.coverage ?? null,
|
|
@@ -736,8 +953,7 @@ async function prCheckCmd(opts, deps) {
|
|
|
736
953
|
// SKIP rather than rendering an empty "0 unaddressed" report -- an adopter
|
|
737
954
|
// must be able to tell "nothing was judgeable" from "everything passed".
|
|
738
955
|
if (scoredResults.length === 0 && findings.length === 0) {
|
|
739
|
-
|
|
740
|
-
throw new CliExit(0);
|
|
956
|
+
abstainPrCheck(prCheckSkipEntries(skipped, testUnits, barrelUnits, noiseResults.map((r) => r.unit.path)), opts.format, deps);
|
|
741
957
|
}
|
|
742
958
|
// SC-5 (PR half): resolve the requested tier against actual capability. No
|
|
743
959
|
// agent runtime exists (default NoAgentProbe), so any `pr.tier > 0` drops to
|
|
@@ -755,9 +971,7 @@ async function prCheckCmd(opts, deps) {
|
|
|
755
971
|
// SC-10 producer half: write ONE record to the analyses channel. On an
|
|
756
972
|
// unavailable channel `emitAnalysis` returns a LOUD notice and we fall back
|
|
757
973
|
// to the sticky comment -- the record is never silently dropped.
|
|
758
|
-
const analysesDir = opts.analysesDir
|
|
759
|
-
? opts.analysesDir
|
|
760
|
-
: join(gitToplevel(deps), '.harness', 'analyses');
|
|
974
|
+
const analysesDir = resolveAnalysesDir(opts.analysesDir, deps);
|
|
761
975
|
const res = emitAnalysis(findings, {
|
|
762
976
|
analysesDir,
|
|
763
977
|
ref: resolveAnalysisRef(deps),
|
|
@@ -765,6 +979,8 @@ async function prCheckCmd(opts, deps) {
|
|
|
765
979
|
effective_tier: resolution.effective,
|
|
766
980
|
degraded_notice: resolution.degraded_notice,
|
|
767
981
|
exit_code: exitCode,
|
|
982
|
+
checked: scoredResults.length,
|
|
983
|
+
abstained: false, // an abstained run exits before emit (see plan)
|
|
768
984
|
});
|
|
769
985
|
if (res.action === 'emitted') {
|
|
770
986
|
deps.out(`guardian: wrote analysis record ${RIGHT_ARROW} ${res.path}`);
|
|
@@ -786,7 +1002,7 @@ async function prCheckCmd(opts, deps) {
|
|
|
786
1002
|
}
|
|
787
1003
|
else if (!opts.emitAnalysis && !opts.postComment) {
|
|
788
1004
|
// Local, non-posting default: render to stdout in `--format`.
|
|
789
|
-
deps.out(render(findings, opts.format, resolution.effective, resolution.degraded_notice));
|
|
1005
|
+
deps.out(render(findings, opts.format, resolution.effective, resolution.degraded_notice, { checked: scoredResults.length, abstained: false }));
|
|
790
1006
|
}
|
|
791
1007
|
throw new CliExit(exitCode);
|
|
792
1008
|
}
|
|
@@ -822,6 +1038,33 @@ function intentDict(intent) {
|
|
|
822
1038
|
skip_reason: intent.skip_reason,
|
|
823
1039
|
};
|
|
824
1040
|
}
|
|
1041
|
+
/**
|
|
1042
|
+
* author-plan's denominator decision (#508, review-round gap).
|
|
1043
|
+
*
|
|
1044
|
+
* The spec's audit list named `author-plan` next to `pr-check`, but #515
|
|
1045
|
+
* deferred it ("guardian internals being reworked in parallel") and Wave 2 only
|
|
1046
|
+
* took pr-check. On an EMPTY diff this surface emitted
|
|
1047
|
+
* `block: false, authored_count: 0` and exited 0 -- "we examined nothing,
|
|
1048
|
+
* therefore do not block", which is the #456 class verbatim.
|
|
1049
|
+
*
|
|
1050
|
+
* ADVISORY, not a gate: author-plan is an authoring aid whose JSON an agent
|
|
1051
|
+
* reads (see `canary-pr-guardian/SKILL.md`); the exit-code contract belongs to
|
|
1052
|
+
* `pr-check` and the pre-commit gate. So the exit stays 0 and stdout stays a
|
|
1053
|
+
* single parseable object -- `checked`/`abstained` ride the payload additively
|
|
1054
|
+
* and the loud line goes to stderr, keeping `--json` consumers byte-compatible.
|
|
1055
|
+
*/
|
|
1056
|
+
function authorPlanOutcome(checked, results, deps) {
|
|
1057
|
+
const outcome = gateOutcome({ checked, findings: [...results] }, 'advisory', {
|
|
1058
|
+
noun: 'gap(s)',
|
|
1059
|
+
});
|
|
1060
|
+
if (outcome.abstained) {
|
|
1061
|
+
deps.err(`${outcome.summaryLine} guardian author-plan scoped zero coverage ` +
|
|
1062
|
+
'gaps, so "nothing to author" here means nothing was EXAMINED, not ' +
|
|
1063
|
+
'that everything is covered. Confirm the diff is non-empty and that ' +
|
|
1064
|
+
'skipGlobs is not filtering every path.');
|
|
1065
|
+
}
|
|
1066
|
+
return outcome;
|
|
1067
|
+
}
|
|
825
1068
|
function authorPlanCmd(opts, deps) {
|
|
826
1069
|
const [config, warning] = loadGuardianConfig(opts.config);
|
|
827
1070
|
if (warning !== null) {
|
|
@@ -845,6 +1088,7 @@ function authorPlanCmd(opts, deps) {
|
|
|
845
1088
|
});
|
|
846
1089
|
const results = deps.makeAgentTier().author_tests(gaps, ctx);
|
|
847
1090
|
const decision = decideBlock(results);
|
|
1091
|
+
const outcome = authorPlanOutcome(gaps.length, results, deps);
|
|
848
1092
|
const payload = {
|
|
849
1093
|
intents: results.map(intentDict),
|
|
850
1094
|
block: {
|
|
@@ -852,6 +1096,8 @@ function authorPlanCmd(opts, deps) {
|
|
|
852
1096
|
message: decision.message,
|
|
853
1097
|
authored_count: decision.authored_count,
|
|
854
1098
|
},
|
|
1099
|
+
checked: gaps.length,
|
|
1100
|
+
abstained: outcome.abstained,
|
|
855
1101
|
};
|
|
856
1102
|
deps.out(ensureAscii(JSON.stringify(payload, null, 2)));
|
|
857
1103
|
}
|
|
@@ -942,9 +1188,30 @@ export function createGuardianCommand(depsInit = {}) {
|
|
|
942
1188
|
.addOption(new Option('--check <check>', 'Status-check context to require (the guardian workflow job).').default('guardian'))
|
|
943
1189
|
.addOption(new Option('--token <token>', 'Admin token for --apply.').env('GITHUB_TOKEN'))
|
|
944
1190
|
.option('--force', 'Skip the check-context-exists verification (risky).')
|
|
1191
|
+
.addOption(new Option('--analyses-dir <dir>', 'Override the analyses dir (tests).').hideHelp())
|
|
945
1192
|
.action(async (opts) => {
|
|
946
1193
|
await hardenGateCmd(opts, deps);
|
|
947
1194
|
});
|
|
1195
|
+
program
|
|
1196
|
+
.command('collect-adjudications')
|
|
1197
|
+
.description("Read reviewer thumbs-up/down reactions off the guardian's sticky PR " +
|
|
1198
|
+
'comment and persist the adjudication record (#490).')
|
|
1199
|
+
.addOption(new Option('--repo <repo>', 'owner/repo.').env('GITHUB_REPOSITORY'))
|
|
1200
|
+
.addOption(new Option('--pr <number>', 'Pull-request number.').argParser((v) => Number.parseInt(v, 10)))
|
|
1201
|
+
.option('--json', 'Emit the collection result as JSON.')
|
|
1202
|
+
.addOption(new Option('--analyses-dir <dir>', 'Override the analyses dir (tests).').hideHelp())
|
|
1203
|
+
.action(async (opts) => {
|
|
1204
|
+
await collectAdjudicationsCmd(opts, deps);
|
|
1205
|
+
});
|
|
1206
|
+
program
|
|
1207
|
+
.command('precision')
|
|
1208
|
+
.description('Report guardian finding precision (TP / (TP + FP)) from collected ' +
|
|
1209
|
+
'adjudications, with its sample size.')
|
|
1210
|
+
.option('--json', 'Emit the summary as JSON (precision null = unknown).')
|
|
1211
|
+
.addOption(new Option('--analyses-dir <dir>', 'Override the analyses dir (tests).').hideHelp())
|
|
1212
|
+
.action((opts) => {
|
|
1213
|
+
precisionCmd(opts, deps);
|
|
1214
|
+
});
|
|
948
1215
|
program
|
|
949
1216
|
.command('pr-check')
|
|
950
1217
|
.description('Tier 0 deterministic PR guardian: scope, resolve, gate.')
|
|
@@ -56,6 +56,19 @@ export class HardGateBlocked extends Error {
|
|
|
56
56
|
this.playbook = playbook;
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* The verification population was EMPTY (#508): no check has ever
|
|
61
|
+
* reported on the branch, so the context could not be verified against
|
|
62
|
+
* anything. A subclass of {@link HardGateBlocked} so pre-#508 catch
|
|
63
|
+
* sites keep working; the CLI maps it to EXIT_ABSTAINED (3), distinct
|
|
64
|
+
* from real blockers (1).
|
|
65
|
+
*/
|
|
66
|
+
export class HardGateAbstained extends HardGateBlocked {
|
|
67
|
+
constructor(reason, playbook) {
|
|
68
|
+
super(reason, playbook);
|
|
69
|
+
this.name = 'HardGateAbstained';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
59
72
|
/**
|
|
60
73
|
* Decide the change without touching the network (PURE).
|
|
61
74
|
*
|
|
@@ -190,11 +203,11 @@ export async function applyHardGate(client, repo, branch, checkContext, force =
|
|
|
190
203
|
observed = [];
|
|
191
204
|
}
|
|
192
205
|
if (observed.length === 0) {
|
|
193
|
-
throw
|
|
206
|
+
throw new HardGateAbstained(`could not confirm any check has reported on ${repo}@${branch}, ` +
|
|
194
207
|
`so cannot verify '${checkContext}' is a real context; ` +
|
|
195
208
|
'requiring an unreported check would block every merge. ' +
|
|
196
209
|
'Open a PR so the guardian check runs at least once, or pass ' +
|
|
197
|
-
'--force to register it anyway.');
|
|
210
|
+
'--force to register it anyway.', playbook);
|
|
198
211
|
}
|
|
199
212
|
if (!observed.includes(checkContext)) {
|
|
200
213
|
const sorted = [...observed].sort();
|
|
@@ -729,18 +729,7 @@ function findingDict(finding) {
|
|
|
729
729
|
suppression_reason: finding.suppression_reason,
|
|
730
730
|
};
|
|
731
731
|
}
|
|
732
|
-
|
|
733
|
-
* Render findings as a sticky PR `comment`, `json`, or plain `text`.
|
|
734
|
-
*
|
|
735
|
-
* - `comment`: leads with the sticky marker `<!-- canary-pr-guardian -->`, a
|
|
736
|
-
* fidelity-labeled summary line, then severity-ranked findings (each showing
|
|
737
|
-
* path/unit, severity, fidelity, evidence). Suppressed findings are rendered
|
|
738
|
-
* but visually marked `suppressed`. Footer states `tier 0` and appends
|
|
739
|
-
* `degradedNotice` when present.
|
|
740
|
-
* - `json`: `{"findings": [...], "tier": <n>}` — stable schema.
|
|
741
|
-
* - `text`: plain, markdown-free, for local/CLI output.
|
|
742
|
-
*/
|
|
743
|
-
export function render(findings, fmt, tier = 0, degradedNotice = null) {
|
|
732
|
+
export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta = null) {
|
|
744
733
|
const ordered = [...findings].sort((a, b) => severitySortKey(a.severity) - severitySortKey(b.severity));
|
|
745
734
|
if (fmt === 'json') {
|
|
746
735
|
const payload = {
|
|
@@ -749,6 +738,10 @@ export function render(findings, fmt, tier = 0, degradedNotice = null) {
|
|
|
749
738
|
};
|
|
750
739
|
if (degradedNotice)
|
|
751
740
|
payload['degraded_notice'] = degradedNotice;
|
|
741
|
+
if (gateMeta !== null) {
|
|
742
|
+
payload['checked'] = gateMeta.checked;
|
|
743
|
+
payload['abstained'] = gateMeta.abstained;
|
|
744
|
+
}
|
|
752
745
|
return ensureAscii(JSON.stringify(payload, null, 2));
|
|
753
746
|
}
|
|
754
747
|
const active = ordered.filter((f) => !f.suppressed);
|
|
@@ -25,6 +25,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
25
25
|
import { Command, Option } from 'commander';
|
|
26
26
|
import pc from 'picocolors';
|
|
27
27
|
import { CliExit, jsonIndent2, normalizeUsageExit } from '../cli-common.js';
|
|
28
|
+
import { gateOutcome } from '../core/gate-result.js';
|
|
28
29
|
import { makeRunId } from './schema.js';
|
|
29
30
|
import { makeStore as realMakeStore } from './store.js';
|
|
30
31
|
import { pyFloat } from '../util/round.js';
|
|
@@ -32,6 +33,34 @@ const EM_DASH = '\u{2014}';
|
|
|
32
33
|
const GEQ = '\u{2265}';
|
|
33
34
|
const MDASH_CELL = '\u{2014}'; // rich `r.get("area") or <em-dash>`
|
|
34
35
|
const DEFAULT_HISTORY_FILE = 'test-results/reports/history-v2.jsonl';
|
|
36
|
+
/**
|
|
37
|
+
* The denominator guard for the history reports (#508 Wave 4a).
|
|
38
|
+
*
|
|
39
|
+
* `countRuns` is OPTIONAL on `AsyncHistoryStore`: a backend that cannot report
|
|
40
|
+
* how many runs it holds (today, the remote Supabase store) keeps
|
|
41
|
+
* benefit-of-the-doubt and never abstains. An UNKNOWN denominator is not a zero
|
|
42
|
+
* one -- inventing an abstention would be its own dishonesty, the same reason
|
|
43
|
+
* #527 renders `precision: null` rather than 0.
|
|
44
|
+
*
|
|
45
|
+
* Advisory (D3): exit stays 0. Returns true when the caller should stop.
|
|
46
|
+
*/
|
|
47
|
+
async function abstainOnEmptyHistory(store, deps, json, what) {
|
|
48
|
+
if (store.countRuns === undefined)
|
|
49
|
+
return false; // unknown, not zero
|
|
50
|
+
if ((await store.countRuns()) > 0)
|
|
51
|
+
return false;
|
|
52
|
+
const outcome = gateOutcome({ checked: 0, findings: [] }, 'advisory');
|
|
53
|
+
const notice = `${outcome.summaryLine} No runs recorded, so ${what} is unknown rather ` +
|
|
54
|
+
`than clean. Record runs first (\`canary history push\`), then re-run.`;
|
|
55
|
+
if (json) {
|
|
56
|
+
deps.out(jsonIndent2([]));
|
|
57
|
+
deps.err(notice);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
deps.out(notice);
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
35
64
|
/** Every field of the Python `RunRecord` dataclass (the push/migrate filter). */
|
|
36
65
|
const RUN_FIELDS = [
|
|
37
66
|
'run_id',
|
|
@@ -124,6 +153,9 @@ async function pushCmd(historyFile, opts, deps) {
|
|
|
124
153
|
}
|
|
125
154
|
async function flakyCmd(opts, deps) {
|
|
126
155
|
const store = deps.makeStore(opts.dbUrl);
|
|
156
|
+
if (await abstainOnEmptyHistory(store, deps, opts.json === true, 'flake rate')) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
127
159
|
const results = await store.queryFlaky(opts.window, opts.suite ?? null, opts.minRate);
|
|
128
160
|
if (opts.json) {
|
|
129
161
|
deps.out(jsonIndent2(results));
|
|
@@ -148,6 +180,14 @@ async function flakyCmd(opts, deps) {
|
|
|
148
180
|
}
|
|
149
181
|
async function timelineCmd(testName, opts, deps) {
|
|
150
182
|
const store = deps.makeStore(opts.dbUrl);
|
|
183
|
+
// #508 (review-round gap): `No history found for: <test>` rendered
|
|
184
|
+
// IDENTICALLY whether the test genuinely has no runs in a populated store (a
|
|
185
|
+
// real answer) or the store is empty and nothing was examined at all (an
|
|
186
|
+
// absent one). Same runs-vs-rows distinction Wave 4a built `countRuns()` for;
|
|
187
|
+
// `timeline` was missed because #515's audit table never listed it.
|
|
188
|
+
if (await abstainOnEmptyHistory(store, deps, opts.json === true, `the timeline for ${testName}`)) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
151
191
|
const rows = await store.queryTimeline(testName);
|
|
152
192
|
if (opts.json) {
|
|
153
193
|
deps.out(jsonIndent2(rows));
|
|
@@ -171,6 +211,24 @@ async function timelineCmd(testName, opts, deps) {
|
|
|
171
211
|
async function summaryCmd(suite, opts, deps) {
|
|
172
212
|
const store = deps.makeStore(opts.dbUrl);
|
|
173
213
|
const result = await store.querySummary(suite, opts.runs);
|
|
214
|
+
// #508: a summary over zero runs used to print `avg pass rate: 0.0%` -- a
|
|
215
|
+
// FABRICATED number, not a measured one, and the most misleading shape in the
|
|
216
|
+
// whole audit (0% reads as catastrophe, not as absence). The store's own
|
|
217
|
+
// `total_runs` is the denominator here; no extra probe is needed.
|
|
218
|
+
if ((result.total_runs ?? 0) === 0) {
|
|
219
|
+
const outcome = gateOutcome({ checked: 0, findings: [] }, 'advisory');
|
|
220
|
+
const notice = `${outcome.summaryLine} Suite ${suite} has no recorded runs, so its ` +
|
|
221
|
+
`pass rate is unknown -- not 0%. Record runs first ` +
|
|
222
|
+
`(\`canary history push\`), then re-run.`;
|
|
223
|
+
if (opts.json) {
|
|
224
|
+
deps.out(jsonIndent2({ ...result, abstained: true }));
|
|
225
|
+
deps.err(notice);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
deps.out(notice);
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
174
232
|
if (opts.json) {
|
|
175
233
|
deps.out(jsonIndent2(result));
|
|
176
234
|
return;
|
|
@@ -227,6 +285,15 @@ async function migrateCmd(file, opts, deps) {
|
|
|
227
285
|
await store.pushRun(run, []);
|
|
228
286
|
migrated += 1;
|
|
229
287
|
}
|
|
288
|
+
// #508: `Migrated 0 runs` in green is a success line over an empty
|
|
289
|
+
// denominator -- the #504 shape. A file that yielded nothing was not migrated.
|
|
290
|
+
if (migrated === 0) {
|
|
291
|
+
const outcome = gateOutcome({ checked: 0, findings: [] }, 'advisory');
|
|
292
|
+
deps.out(`${outcome.summaryLine} No run in ${file} could be migrated ` +
|
|
293
|
+
`(skipped ${skipped}). Check that the file is v1 history NDJSON and ` +
|
|
294
|
+
`that --suite/--repo match it.`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
230
297
|
deps.out(`${pc.green('Migrated')} ${migrated} runs, skipped ${skipped}`);
|
|
231
298
|
}
|
|
232
299
|
// --- assembly ----------------------------------------------------------------
|
|
@@ -46,6 +46,10 @@ export class NdjsonHistoryStore {
|
|
|
46
46
|
}
|
|
47
47
|
return records;
|
|
48
48
|
}
|
|
49
|
+
/** The denominator probe: how many runs this store holds (#508). */
|
|
50
|
+
countRuns() {
|
|
51
|
+
return this.readAll().length;
|
|
52
|
+
}
|
|
49
53
|
/**
|
|
50
54
|
* Append a run + its results as one NDJSON line. Idempotent: a run whose
|
|
51
55
|
* `run_id` is already present is silently skipped (matches Python
|
|
@@ -30,6 +30,9 @@ export class LocalAsyncAdapter {
|
|
|
30
30
|
async queryTimeline(testName) {
|
|
31
31
|
return this.inner.queryTimeline(testName);
|
|
32
32
|
}
|
|
33
|
+
async countRuns() {
|
|
34
|
+
return this.inner.countRuns();
|
|
35
|
+
}
|
|
33
36
|
async querySummary(suite, runs) {
|
|
34
37
|
return this.inner.querySummary(suite, runs);
|
|
35
38
|
}
|