canary-test-cli 7.1.0 → 7.2.0

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