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.
@@ -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, matching the shipping Typer CLI):
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 { resolveCoverage, validateCoverageJson } from './coverage.js';
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
- deps.out(pc.green(pc.bold(`${CHECK} ${path} is a valid coverage-json document.`)));
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
- function nothingToVerify(skippedCount) {
683
- return `guardian: nothing to verify (${skippedCount} path(s) skipped).`;
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
- deps.out(nothingToVerify(preFilterSkipped));
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
- deps.out(nothingToVerify(preFilterSkipped + noiseResults.length));
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 blocked(`could not confirm any check has reported on ${repo}@${branch}, ` +
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
  }