canary-test-cli 7.0.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/agents/skills/README.md +308 -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-blackhawk/SKILL.md +170 -0
  7. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  11. package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
  12. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  14. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
  15. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  16. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  17. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  18. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  19. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  21. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  22. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  27. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  28. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  29. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
  30. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  31. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  32. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  37. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  38. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  42. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  44. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  45. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  46. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  52. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  53. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  54. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  55. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  56. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  57. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  58. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  59. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  60. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  61. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  62. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  63. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  64. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  65. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  66. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  67. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  68. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  69. package/agents/skills/lib/parse-args.mjs +214 -0
  70. package/dist/engine/analysis/cli.js +116 -54
  71. package/dist/engine/analysis/engine.js +34 -16
  72. package/dist/engine/analysis/reports.js +5 -4
  73. package/dist/engine/cli-commands.js +252 -106
  74. package/dist/engine/cli-common.js +15 -24
  75. package/dist/engine/cli.core.js +37 -11
  76. package/dist/engine/cli.js +2 -2
  77. package/dist/engine/company-knowledge-cli.js +2 -2
  78. package/dist/engine/core/adoption.js +408 -0
  79. package/dist/engine/core/framework-probes.js +7 -7
  80. package/dist/engine/core/fs-glob.js +2 -2
  81. package/dist/engine/core/gate-result.js +17 -0
  82. package/dist/engine/core/migrator.js +9 -17
  83. package/dist/engine/core/pattern-matcher.js +23 -5
  84. package/dist/engine/core/persona.js +421 -0
  85. package/dist/engine/core/promotion-verdict.js +261 -0
  86. package/dist/engine/core/quality-scorer.js +15 -2
  87. package/dist/engine/core/reporter.js +1 -9
  88. package/dist/engine/core/skill-dispatch.js +115 -0
  89. package/dist/engine/core/skill-examples.js +392 -0
  90. package/dist/engine/core/skill-registry.js +59 -4
  91. package/dist/engine/core/skill-surfaces.js +307 -0
  92. package/dist/engine/core/static-linter.js +310 -38
  93. package/dist/engine/core/test-files.js +77 -0
  94. package/dist/engine/core/ticket-updater.js +1 -7
  95. package/dist/engine/core/vacuity-scanner.js +726 -0
  96. package/dist/engine/core/workflow-discovery.js +2 -8
  97. package/dist/engine/core/workspace-detect.js +7 -6
  98. package/dist/engine/data/personas/registry.json +36 -0
  99. package/dist/engine/guardian/adjudication.js +5 -5
  100. package/dist/engine/guardian/analysis-emit.js +19 -28
  101. package/dist/engine/guardian/cli.js +150 -51
  102. package/dist/engine/guardian/coverage.js +1 -1
  103. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  104. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  105. package/dist/engine/guardian/pr-check.js +322 -33
  106. package/dist/engine/guardian/pr-comment.js +4 -3
  107. package/dist/engine/history/cli.js +210 -6
  108. package/dist/engine/history/ndjson-store.js +9 -5
  109. package/dist/engine/history/record.js +34 -5
  110. package/dist/engine/history/run-recorder.js +165 -0
  111. package/dist/engine/history/schema.js +25 -7
  112. package/dist/engine/history/store.js +9 -0
  113. package/dist/engine/mcp-server.js +35 -13
  114. package/dist/engine/skills-cli.js +249 -23
  115. package/dist/engine/util/ensure-ascii.js +37 -0
  116. package/dist/engine/workflow-cli.js +6 -6
  117. package/dist/gate-result.d.ts +11 -0
  118. package/dist/gate-result.js +18 -0
  119. package/dist/reporters/testtracker.d.ts +1 -1
  120. package/dist/reporters/testtracker.js +1 -1
  121. package/dist/uninstall.js +12 -5
  122. package/package.json +3 -2
@@ -13,8 +13,8 @@
13
13
  * `_branch_protection_client` seams were replaced.
14
14
  * - Commands are THIN: parse -> call the already-ported guardian library ->
15
15
  * emit. No business logic lives in a handler.
16
- * - Business exit codes are carried by throwing {@link CliExit} (Python's
17
- * `typer.Exit(n)`); `parseAsync` from a test catches it to read the code.
16
+ * - Business exit codes are carried by throwing {@link CliExitError}
17
+ * (Python's `typer.Exit(n)`); a test's `parseAsync` catches it to read it.
18
18
  * `.exitOverride()` turns commander's own usage errors into throws too, so a
19
19
  * test never terminates the process.
20
20
  *
@@ -52,13 +52,14 @@ import pc from 'picocolors';
52
52
  import { AuthoringContext, InSessionAgentProbe, InSessionAgentTier, decideBlock, } from './agent-tier.js';
53
53
  import { RestReactionsClient, collectAdjudications, loadAdjudicationRecords, renderPrecision, summarizePrecision, } from './adjudication.js';
54
54
  import { emitAnalysis } from './analysis-emit.js';
55
- import { gateOutcome } from '../core/gate-result.js';
55
+ import { EXIT_ABSTAINED, gateOutcome, } from '../core/gate-result.js';
56
56
  import { coverageDegradedNotice, resolveCoverage, resolveCoverageWithInput, validateCoverageJson, } from './coverage.js';
57
57
  import { buildApiDelta, writeApiDelta } from './delta-emitter.js';
58
58
  import { extractApiDiff } from './diff-extractor.js';
59
59
  import { HardGateAbstained, HardGateBlocked, RestBranchProtectionClient, applyHardGate, renderPlaybook, } from './hard-gate.js';
60
60
  import { mapImpact } from './impact-mapper.js';
61
- import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestSupportUnits, filterTestUnits, filterTypeOnlyUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
61
+ import { ensureAscii } from '../util/ensure-ascii.js';
62
+ import { MERGE_REF_WARNING, provenanceLine, applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestSupportUnits, filterTestUnits, filterTypeOnlyUnits, findReexportOnly, isCoverageAbstention, loadGuardianConfig, renderFindings, scopeDiff, } from './pr-check.js';
62
63
  import { RestGitHubClient, degradationAnnotation, upsertStickyComment, } from './pr-comment.js';
63
64
  import { buildSummary } from './summary-emitter.js';
64
65
  import { resolveTier } from './tier.js';
@@ -71,12 +72,12 @@ const CROSS = '\u{2717}';
71
72
  * Business exit signal. Thrown from a handler to carry an exit code the way
72
73
  * Python's `typer.Exit(code)` did; the runner catches it to read the code.
73
74
  */
74
- export class CliExit extends Error {
75
+ export class CliExitError extends Error {
75
76
  code;
76
77
  constructor(code) {
77
78
  super(`exit ${code}`);
78
79
  this.code = code;
79
- this.name = 'CliExit';
80
+ this.name = 'CliExitError';
80
81
  }
81
82
  }
82
83
  /**
@@ -94,10 +95,10 @@ function normalizeUsageExit(err) {
94
95
  throw err;
95
96
  }
96
97
  /** Raised by `deps.sleep` to break the `watch` poll loop (Ctrl+C analog). */
97
- export class WatchInterrupt extends Error {
98
+ export class WatchInterruptError extends Error {
98
99
  constructor() {
99
100
  super('watch interrupted');
100
- this.name = 'WatchInterrupt';
101
+ this.name = 'WatchInterruptError';
101
102
  }
102
103
  }
103
104
  /** Process-backed defaults for production (the `guardianCommand` export). */
@@ -148,21 +149,6 @@ export function defaultDeps() {
148
149
  sleep: (secs) => new Promise((resolve) => setTimeout(resolve, secs * 1000)),
149
150
  };
150
151
  }
151
- /**
152
- * Escape non-ASCII to `\uXXXX`, matching Python `json.dumps(ensure_ascii=True)`.
153
- */
154
- function ensureAscii(json) {
155
- // Escape every UTF-16 code UNIT >= 0x80 to \uXXXX, matching Python
156
- // json.dumps(ensure_ascii=True). Iterating by unit (not code point) means an
157
- // astral char's surrogate pair emits \udXXX\udXXX, like Python; a code-point
158
- // regex would stop at U+FFFF and leave astral chars raw.
159
- let out = '';
160
- for (let i = 0; i < json.length; i++) {
161
- const c = json.charCodeAt(i);
162
- out += c >= 0x80 ? '\\u' + c.toString(16).padStart(4, '0') : json[i];
163
- }
164
- return out;
165
- }
166
152
  /** ISO-8601 UTC timestamp with a `+00:00` offset (Python `isoformat`-shaped). */
167
153
  function isoUtcNow() {
168
154
  return new Date().toISOString().replace('Z', '+00:00');
@@ -212,8 +198,9 @@ export function prContextFromEnv(env) {
212
198
  * the contributor's branch and stays resolvable.
213
199
  *
214
200
  * Returns `null` rather than a partial URL whenever repo or SHA is missing, so
215
- * {@link render} falls back to plain code text. That degradation is deliberate:
216
- * an unresolvable link still *looks* clickable, which is worse than no link.
201
+ * {@link renderFindings} falls back to plain code text. That degradation is
202
+ * deliberate: an unresolvable link still *looks* clickable, which is worse than
203
+ * no link.
217
204
  */
218
205
  export function blobBaseFromEnv(env) {
219
206
  const repo = env['GITHUB_REPOSITORY'];
@@ -387,6 +374,59 @@ function readWorktreeDiff(deps) {
387
374
  return unstaged;
388
375
  return deps.runGit(['diff', '--staged'])?.stdout ?? '';
389
376
  }
377
+ /**
378
+ * The PR head sha the CI event declares, if this is a `pull_request` event.
379
+ *
380
+ * Distinct from {@link eventBaseSha}: that answers "what are we diffing
381
+ * against", this answers "what SHOULD the diffed HEAD be". They are compared in
382
+ * {@link detectMergeRef} (#761).
383
+ */
384
+ function eventHeadSha(env) {
385
+ const eventPath = env['GITHUB_EVENT_PATH'];
386
+ if (!eventPath)
387
+ return null;
388
+ let sha;
389
+ try {
390
+ const event = JSON.parse(readFileSync(eventPath, 'utf-8'));
391
+ sha = event?.pull_request?.head?.sha;
392
+ }
393
+ catch {
394
+ return null;
395
+ }
396
+ return typeof sha === 'string' && sha.trim() ? sha.trim() : null;
397
+ }
398
+ /** Resolve `HEAD` to a full sha, or null when git cannot answer. */
399
+ function resolveHeadSha(deps) {
400
+ const res = deps.runGit(['rev-parse', 'HEAD']);
401
+ if (res === null || res.code !== 0)
402
+ return null;
403
+ const sha = res.stdout.trim();
404
+ return sha || null;
405
+ }
406
+ /**
407
+ * True when the checked-out HEAD is a `pull_request` MERGE REF, not the PR head.
408
+ *
409
+ * This is the merge-ref diff defect (#761). `actions/checkout` on a
410
+ * `pull_request` event checks out `refs/pull/<n>/merge` — the base branch
411
+ * merged with the PR head — unless the caller passes an explicit `ref`. Any
412
+ * diff taken to that HEAD includes every commit merged into the base branch
413
+ * since the base sha, because the triple-dot merge base degenerates to the base
414
+ * sha itself (it is an ancestor of the merge commit). A one-file docs PR was
415
+ * analyzed as 43 files that way.
416
+ *
417
+ * Detection is a comparison, not a heuristic: the event payload states the PR
418
+ * head sha outright, so a HEAD that differs from it is diffing something else.
419
+ * Returns false whenever either side is unknown — an undetectable case must not
420
+ * masquerade as a detected-clean one.
421
+ */
422
+ export function detectMergeRef(headSha, deps) {
423
+ if (deps.env['GITHUB_EVENT_NAME'] !== 'pull_request')
424
+ return false;
425
+ const declared = eventHeadSha(deps.env);
426
+ if (!declared || !headSha)
427
+ return false;
428
+ return declared !== headSha;
429
+ }
390
430
  /** True when the process looks like a CI runner rather than a dev worktree. */
391
431
  function isCiContext(env) {
392
432
  return Boolean(env['GITHUB_ACTIONS'] || env['CI']);
@@ -483,6 +523,11 @@ export function readPrDiff(source, deps) {
483
523
  }
484
524
  return { text: readWorktreeDiff(deps), origin: 'worktree', base: null };
485
525
  }
526
+ // Built from the shared fragment so the annotation and the rendered provenance
527
+ // line cannot drift into describing the same defect two different ways (#761).
528
+ const MERGE_REF_NOTICE = `guardian: ${MERGE_REF_WARNING} — findings may name files this PR never ` +
529
+ 'touched. Check out with `ref: ${{ github.event.pull_request.head.sha }}`, ' +
530
+ 'or diff to that sha instead of HEAD.';
486
531
  const EMPTY_CI_DIFF_NOTICE = 'guardian: 0 changed paths — fell back to a working-tree `git diff`, which ' +
487
532
  'is empty on a clean CI checkout, so NOTHING was verified. Pass ' +
488
533
  '`--diff <base>...<head>`, or checkout with `fetch-depth: 0` so the PR base ' +
@@ -513,7 +558,7 @@ function warnIfEmptyCiDiff(resolved, unitCount, deps) {
513
558
  function loadSpec(path, deps) {
514
559
  if (!existsSync(path)) {
515
560
  deps.err(`Spec file not found: ${path}`);
516
- throw new CliExit(2);
561
+ throw new CliExitError(2);
517
562
  }
518
563
  const text = readFileSync(path, 'utf-8');
519
564
  // Python `_load_spec`: `.json` -> json.loads; otherwise yaml.safe_load (with a
@@ -627,16 +672,16 @@ function validateCoverageCmd(path, opts, deps) {
627
672
  const st = statSync(path);
628
673
  if (st.isDirectory() || st.size > MAX_COVERAGE_BYTES) {
629
674
  deps.out(`${pc.red(pc.bold(`${CROSS} cannot read ${path}:`))} not a readable file within the size limit`);
630
- throw new CliExit(2);
675
+ throw new CliExitError(2);
631
676
  }
632
677
  text = readFileSync(path, 'utf-8');
633
678
  }
634
679
  catch (exc) {
635
- if (exc instanceof CliExit)
680
+ if (exc instanceof CliExitError)
636
681
  throw exc;
637
682
  const msg = exc instanceof Error ? exc.message : String(exc);
638
683
  deps.out(`${pc.red(pc.bold(`${CROSS} cannot read ${path}:`))} ${msg}`);
639
- throw new CliExit(2);
684
+ throw new CliExitError(2);
640
685
  }
641
686
  let data;
642
687
  try {
@@ -645,7 +690,7 @@ function validateCoverageCmd(path, opts, deps) {
645
690
  catch (exc) {
646
691
  const msg = exc instanceof Error ? exc.message : String(exc);
647
692
  deps.out(`${pc.red(pc.bold(`${CROSS} ${path} is not valid JSON:`))} ${msg}`);
648
- throw new CliExit(2);
693
+ throw new CliExitError(2);
649
694
  }
650
695
  const problems = validateCoverageJson(data);
651
696
  const errors = problems.filter((p) => p.severity === 'error');
@@ -692,7 +737,7 @@ function validateCoverageCmd(path, opts, deps) {
692
737
  }
693
738
  }
694
739
  if (errors.length > 0 || (opts.strict && warnings.length > 0)) {
695
- throw new CliExit(1);
740
+ throw new CliExitError(1);
696
741
  }
697
742
  }
698
743
  /**
@@ -713,7 +758,7 @@ async function hardenGateCmd(opts, deps) {
713
758
  const repo = opts.repo;
714
759
  if (!repo) {
715
760
  deps.out(`${pc.red(pc.bold(`${CROSS} no repo`))} ${EM_DASH} pass --repo owner/repo or set GITHUB_REPOSITORY.`);
716
- throw new CliExit(2);
761
+ throw new CliExitError(2);
717
762
  }
718
763
  const playbook = renderPlaybook(repo, opts.branch, opts.check);
719
764
  // #490: the readiness evidence the promotion is supposed to rest on.
@@ -730,7 +775,7 @@ async function hardenGateCmd(opts, deps) {
730
775
  if (!opts.token) {
731
776
  deps.out(`${pc.red(pc.bold(`${CROSS} --apply needs an admin token`))} (pass --token or set GITHUB_TOKEN).\n`);
732
777
  deps.out(playbook);
733
- throw new CliExit(2);
778
+ throw new CliExitError(2);
734
779
  }
735
780
  const client = deps.buildBranchProtectionClient(repo, opts.token);
736
781
  let plan;
@@ -745,12 +790,12 @@ async function hardenGateCmd(opts, deps) {
745
790
  deps.out(outcome.summaryLine);
746
791
  deps.out(`${pc.red(pc.bold(`${CROSS} ${exc.reason}`))}\n`);
747
792
  deps.out(exc.playbook);
748
- throw new CliExit(outcome.exitCode); // 3, never 1
793
+ throw new CliExitError(outcome.exitCode); // 3, never 1
749
794
  }
750
795
  if (exc instanceof HardGateBlocked) {
751
796
  deps.out(`${pc.red(pc.bold(`${CROSS} ${exc.reason}`))}\n`);
752
797
  deps.out(exc.playbook);
753
- throw new CliExit(1);
798
+ throw new CliExitError(1);
754
799
  }
755
800
  throw exc;
756
801
  }
@@ -785,7 +830,7 @@ async function collectAdjudicationsCmd(opts, deps) {
785
830
  }
786
831
  if (!repo || prNumber === undefined) {
787
832
  deps.out(`${pc.red(pc.bold(`${CROSS} no PR context`))} ${EM_DASH} pass --repo and --pr, or run in Actions.`);
788
- throw new CliExit(2);
833
+ throw new CliExitError(2);
789
834
  }
790
835
  const client = deps.buildReactionsClient(repo, prNumber);
791
836
  const res = await collectAdjudications(client, {
@@ -811,7 +856,7 @@ async function collectAdjudicationsCmd(opts, deps) {
811
856
  }
812
857
  if (res.action === 'unavailable') {
813
858
  deps.out(pc.red(pc.bold(`${CROSS} ${res.notice ?? 'not persisted'}`)));
814
- throw new CliExit(1);
859
+ throw new CliExitError(1);
815
860
  }
816
861
  }
817
862
  /** Aggregate the persisted adjudications into the promotion evidence (#490). */
@@ -835,7 +880,7 @@ function precisionCmd(opts, deps) {
835
880
  * read-only-token degradation is surfaced LOUDLY.
836
881
  */
837
882
  async function postStickyComment(findings, resolution, deps, gateMeta = null) {
838
- const body = render(findings, 'comment', resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env));
883
+ const body = renderFindings(findings, 'comment', resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env));
839
884
  const ctx = prContextFromEnv(deps.env);
840
885
  if (ctx === null) {
841
886
  deps.out(`guardian: no PR context in env ${EM_DASH} printing instead.`);
@@ -890,11 +935,18 @@ const PR_CHECK_ABSTAIN_REMEDIATION = [
890
935
  'every path.',
891
936
  ];
892
937
  /** Exit 3 with the structural abstention line + remediation (#508). */
893
- function abstainPrCheck(skipped, format, deps) {
938
+ function abstainPrCheck(skipped, format, deps, provenance = null) {
894
939
  const outcome = gateOutcome({ checked: 0, findings: [], skipped }, 'gate', {
895
940
  noun: 'unit(s)',
896
941
  });
897
942
  deps.out(outcome.summaryLine);
943
+ // #761: an abstention says "I verified zero items" — the immediate next
944
+ // question is "over WHAT?", and the run that motivated this feature is
945
+ // precisely one that should have abstained. Stating the range here is what
946
+ // separates "correctly abstained on a docs-only PR" from "abstained because
947
+ // the diff was wrong", which read identically without it.
948
+ if (provenance)
949
+ deps.out(provenanceLine(provenance));
898
950
  for (const line of PR_CHECK_ABSTAIN_REMEDIATION)
899
951
  deps.out(line);
900
952
  if (format === 'json') {
@@ -903,9 +955,18 @@ function abstainPrCheck(skipped, format, deps) {
903
955
  // to. Without it a consumer sees `abstained: true` and cannot tell
904
956
  // WHAT was dropped or why -- the #508 class one layer down, on the
905
957
  // only surface a machine can read.
906
- { findings: [], tier: 0, checked: 0, abstained: true, skipped }, null, 2)));
958
+ {
959
+ findings: [],
960
+ tier: 0,
961
+ checked: 0,
962
+ abstained: true,
963
+ skipped,
964
+ // #761: `null` when no diff was resolved -- never absent, so a
965
+ // reader can tell "not applicable" from "this producer is old".
966
+ provenance,
967
+ }, null, 2)));
907
968
  }
908
- throw new CliExit(outcome.exitCode); // EXIT_ABSTAINED
969
+ throw new CliExitError(outcome.exitCode); // EXIT_ABSTAINED
909
970
  }
910
971
  /** Resolve the analyses-channel dir (test override, else repo-root default). */
911
972
  function resolveAnalysesDir(override, deps) {
@@ -968,7 +1029,7 @@ async function prCheckCmd(opts, deps) {
968
1029
  // entirely (no diff scoped, no comment posted, exit 0).
969
1030
  if (opts.postComment && !config.pr_enabled) {
970
1031
  deps.out(`guardian: pr.enabled is false ${EM_DASH} skipping PR surface.`);
971
- throw new CliExit(0);
1032
+ throw new CliExitError(0);
972
1033
  }
973
1034
  const effectiveGate = opts.gate ?? config.pr_gate;
974
1035
  // #490: read reviewer 👍/👎 off the PREVIOUS run's sticky comment before this
@@ -983,6 +1044,27 @@ async function prCheckCmd(opts, deps) {
983
1044
  const diffText = resolvedDiff.text;
984
1045
  const units = scopeDiff(diffText);
985
1046
  warnIfEmptyCiDiff(resolvedDiff, units.length, deps);
1047
+ // #761: capture what the diff was taken between, BEFORE the skip/test/
1048
+ // type-only filters run — `fileCount` is the size of the surface guardian was
1049
+ // handed, which is the number a reviewer can check against their own PR.
1050
+ // Populated even for an explicit `--diff` (where `base` is unknowable): the
1051
+ // merge-ref warning and the file count are exactly what was missing on
1052
+ // the consumer run that surfaced #761, which passed `--diff` from a file.
1053
+ const headSha = resolveHeadSha(deps);
1054
+ const mergeRef = detectMergeRef(headSha, deps);
1055
+ const provenance = {
1056
+ base: resolvedDiff.base,
1057
+ head: headSha,
1058
+ origin: resolvedDiff.origin,
1059
+ fileCount: units.length,
1060
+ ...(mergeRef ? { mergeRef: true } : {}),
1061
+ };
1062
+ if (mergeRef) {
1063
+ // Loud, because it invalidates every count downstream — but non-blocking:
1064
+ // the caller owns the checkout, so guardian reports and carries on.
1065
+ deps.err(degradationAnnotation(MERGE_REF_NOTICE));
1066
+ appendStepSummary(deps.env, MERGE_REF_NOTICE);
1067
+ }
986
1068
  // SC-2: drop docs/config-only units matching skipGlobs.
987
1069
  const [keptSkip, skipped] = filterSkipped(units, config.skip_globs);
988
1070
  // FIX A: drop test-path units -- a test does not itself need a test.
@@ -1013,7 +1095,7 @@ async function prCheckCmd(opts, deps) {
1013
1095
  // number on this path.
1014
1096
  const preCoverageSkips = prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, typeOnlyUnits);
1015
1097
  if (kept.length === 0 && weakFindings.length === 0) {
1016
- abstainPrCheck(preCoverageSkips, opts.format, deps);
1098
+ abstainPrCheck(preCoverageSkips, opts.format, deps, provenance);
1017
1099
  }
1018
1100
  const { results, coverage } = resolveCoverageWithInput(kept, {
1019
1101
  coveragePath: opts.coverage ?? null,
@@ -1039,7 +1121,7 @@ async function prCheckCmd(opts, deps) {
1039
1121
  // SKIP rather than rendering an empty "0 unaddressed" report -- an adopter
1040
1122
  // must be able to tell "nothing was judgeable" from "everything passed".
1041
1123
  if (scoredResults.length === 0 && findings.length === 0) {
1042
- abstainPrCheck(allSkips, opts.format, deps);
1124
+ abstainPrCheck(allSkips, opts.format, deps, provenance);
1043
1125
  }
1044
1126
  // SC-5 (PR half): resolve the requested tier against actual capability. No
1045
1127
  // agent runtime exists (default NoAgentProbe), so any `pr.tier > 0` drops to
@@ -1049,15 +1131,26 @@ async function prCheckCmd(opts, deps) {
1049
1131
  deps.out(degradationAnnotation(resolution.degraded_notice));
1050
1132
  appendStepSummary(deps.env, resolution.degraded_notice);
1051
1133
  }
1134
+ // #761: the run judged units but VERIFIED no coverage, and every finding is a
1135
+ // naming guess. That is an abstention on the coverage denominator — a
1136
+ // different test from the findings-eligible one the two `abstainPrCheck`
1137
+ // calls above make, and the one that consumer run needed. It does not exit
1138
+ // through `abstainPrCheck`: those findings are worth showing, so the run keeps
1139
+ // every surface and changes only its headline and its exit code.
1140
+ const coverageAbstained = isCoverageAbstention(coverage, findings);
1052
1141
  // Compute the gate result once, up front: the emitted record carries it and it
1053
1142
  // is the process exit at the end (SC-4 -- emit never changes the exit logic).
1054
- const exitCode = computeExitCode(findings, effectiveGate);
1143
+ const exitCode = coverageAbstained
1144
+ ? EXIT_ABSTAINED
1145
+ : computeExitCode(findings, effectiveGate);
1055
1146
  // #554: every surface below carries the coverage-input state, so a run that
1056
1147
  // never saw a coverage report cannot present as one that checked and passed.
1057
1148
  const gateMeta = {
1058
1149
  checked: scoredResults.length,
1059
- abstained: false,
1150
+ abstained: coverageAbstained,
1060
1151
  coverage,
1152
+ // #761: the endpoints every count above is scoped by.
1153
+ provenance,
1061
1154
  // #582: `checked` is the numerator of a fraction whose denominator was
1062
1155
  // never printed. This is the rest of it.
1063
1156
  skipped: allSkips,
@@ -1085,9 +1178,15 @@ async function prCheckCmd(opts, deps) {
1085
1178
  degraded_notice: resolution.degraded_notice,
1086
1179
  exit_code: exitCode,
1087
1180
  checked: scoredResults.length,
1088
- abstained: false, // an abstained run exits before emit (see plan)
1181
+ // #761: a coverage abstention DOES reach emit unlike the
1182
+ // findings-eligible abstention, it keeps its findings, so a machine
1183
+ // consumer must see the flag rather than infer a result from the array.
1184
+ abstained: coverageAbstained,
1089
1185
  coverage,
1090
1186
  skipped: allSkips,
1187
+ // #761: the archived artifact is where an inflated diff gets diagnosed
1188
+ // long after the run, so it carries the endpoints too.
1189
+ provenance,
1091
1190
  });
1092
1191
  if (res.action === 'emitted') {
1093
1192
  deps.out(`guardian: wrote analysis record ${RIGHT_ARROW} ${res.path}`);
@@ -1109,9 +1208,9 @@ async function prCheckCmd(opts, deps) {
1109
1208
  }
1110
1209
  else if (!opts.emitAnalysis && !opts.postComment) {
1111
1210
  // Local, non-posting default: render to stdout in `--format`.
1112
- deps.out(render(findings, opts.format, resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env)));
1211
+ deps.out(renderFindings(findings, opts.format, resolution.effective, resolution.degraded_notice, gateMeta, blobBaseFromEnv(deps.env)));
1113
1212
  }
1114
- throw new CliExit(exitCode);
1213
+ throw new CliExitError(exitCode);
1115
1214
  }
1116
1215
  // --- author-plan --------------------------------------------------------------
1117
1216
  /**
@@ -1246,7 +1345,7 @@ async function watchCmd(opts, deps) {
1246
1345
  }
1247
1346
  }
1248
1347
  catch (exc) {
1249
- if (exc instanceof WatchInterrupt) {
1348
+ if (exc instanceof WatchInterruptError) {
1250
1349
  deps.out(`\n${pc.yellow('Watch stopped.')}`);
1251
1350
  return;
1252
1351
  }
@@ -1262,7 +1361,7 @@ function collect(value, previous) {
1262
1361
  * Build a fresh `guardian` command wired to `depsInit` (process-backed defaults
1263
1362
  * fill any gap). Every subcommand uses `.exitOverride()` so a usage error throws
1264
1363
  * a `CommanderError` rather than terminating the process -- tests read the exit
1265
- * code from the thrown error (or from {@link CliExit} for business exits).
1364
+ * code from the thrown error (or from {@link CliExitError} for business exits).
1266
1365
  */
1267
1366
  export function createGuardianCommand(depsInit = {}) {
1268
1367
  const deps = { ...defaultDeps(), ...depsInit };
@@ -36,7 +36,7 @@ export { coverageLimits } from './diff-coverage/formats/cobertura.js';
36
36
  export { parseCoverageJson } from './diff-coverage/formats/coverage-json.js';
37
37
  export { validateCoverageJson, } from './diff-coverage/formats/coverage-json-lint.js';
38
38
  export { resolveFromGraph } from './diff-coverage/graph-tier.js';
39
- export { resolveHeuristic } from './diff-coverage/heuristic-tier.js';
39
+ export { resolveFromHeuristic } from './diff-coverage/heuristic-tier.js';
40
40
  export { coverageDegradedNotice, coverageStatus, resolveCoverage, resolveCoverageWithInput, } from './diff-coverage/orchestrator.js';
41
41
  export { isSourcePath, isTestPath, isTestSupportPath, } from './diff-coverage/paths.js';
42
42
  export { resolveFromReport } from './diff-coverage/report-tier.js';
@@ -109,7 +109,7 @@ function iterTestFiles(repoRoot) {
109
109
  * A unit is heuristic-covered iff some test file under `repoRoot` references
110
110
  * the unit's file stem or a top-level symbol name (word-boundary scan).
111
111
  */
112
- export function resolveHeuristic(units, repoRoot = '.') {
112
+ export function resolveFromHeuristic(units, repoRoot = '.') {
113
113
  const testFiles = iterTestFiles(repoRoot);
114
114
  const results = [];
115
115
  for (const unit of units) {
@@ -4,7 +4,7 @@
4
4
  * clean" from "never checked".
5
5
  */
6
6
  import { resolveFromGraph } from './graph-tier.js';
7
- import { resolveHeuristic } from './heuristic-tier.js';
7
+ import { resolveFromHeuristic } from './heuristic-tier.js';
8
8
  import { matchUnitsToIndex, readReportIndex } from './report-tier.js';
9
9
  /**
10
10
  * SC-3 orchestrator: resolve each unit at the highest available fidelity.
@@ -116,7 +116,7 @@ export function resolveCoverageWithInput(units, options = {}) {
116
116
  }
117
117
  }
118
118
  if (remaining.length > 0) {
119
- for (const r of resolveHeuristic(remaining, repoRoot)) {
119
+ for (const r of resolveFromHeuristic(remaining, repoRoot)) {
120
120
  resolved.set(r.unit, r);
121
121
  }
122
122
  }