canary-test-cli 7.1.0 → 8.0.0

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