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
@@ -30,6 +30,7 @@ import { readJsonWithWarning } from '../core/config-validation.js';
30
30
  import { isAssertionFreeTest } from '../core/quality-scorer.js';
31
31
  import { Fidelity, coverageDegradedNotice, coverageStatus, isSourcePath, isTestPath, isTestSupportPath, isTypeOnlyModule, } from './coverage.js';
32
32
  import { Severity, severitySortKey } from './impact-mapper.js';
33
+ import { ensureAscii } from '../util/ensure-ascii.js';
33
34
  const HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
34
35
  // Suppression annotation: `// canary:allow-untested <reason>` or the `#`
35
36
  // variant. A comment leader (`//` or `#`) is REQUIRED immediately before the
@@ -699,19 +700,209 @@ function hasAddedTestBody(added) {
699
700
  }
700
701
  return false;
701
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
+ }
702
885
  /**
703
886
  * Advisory `weak-test` findings for ADDED tests that assert nothing.
704
887
  *
705
888
  * Consumes the test-path units {@link filterTestUnits} sets aside (a test file
706
889
  * needs no test of its own, but an added test that asserts nothing is itself a
707
- * gap). Scores only the diff's *added* lines per test file via
708
- * {@link isAssertionFreeTest} a high-precision signal (a real test function
709
- * with zero assertions), so snapshot/table-driven tests are not flagged. These
710
- * 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
711
901
  * {@link computeExitCode}): they surface, never block.
712
902
  */
713
903
  export function buildWeakTestFindings(testUnits, diffText) {
714
904
  const addedByPath = addedContentByPath(diffText);
905
+ const visibleByPath = visibleLinesByPath(diffText);
715
906
  const findings = [];
716
907
  for (const unit of testUnits) {
717
908
  const added = addedByPath.get(unit.path);
@@ -721,9 +912,11 @@ export function buildWeakTestFindings(testUnits, diffText) {
721
912
  // nothing new to judge, so don't flag it (FP guard).
722
913
  if (!hasAddedTestBody(added))
723
914
  continue;
724
- const code = added.join('\n');
725
915
  const framework = frameworkForTestPath(unit.path);
726
- if (isAssertionFreeTest(code, framework)) {
916
+ const file = visibleByPath.get(unit.path);
917
+ if (!file)
918
+ continue;
919
+ if (weakBlockIn(file, unit, framework)) {
727
920
  findings.push(new GuardianFinding({
728
921
  path: unit.path,
729
922
  unit: unit.path,
@@ -859,9 +1052,9 @@ const STICKY_MARKER = '<!-- canary-pr-guardian -->';
859
1052
  * silently produces nothing on exactly the large PRs that need it most -- the
860
1053
  * same silent-green failure #369 was filed for.
861
1054
  *
862
- * 60,000 leaves ~5.5k of headroom for anything appended outside `render`
863
- * (degradation annotations, upsert wrappers) without inviting a body that only
864
- * *just* fits and then breaks when a filename grows.
1055
+ * 60,000 leaves ~5.5k of headroom for anything appended outside
1056
+ * `renderFindings` (degradation annotations, upsert wrappers) without inviting
1057
+ * a body that only *just* fits and then breaks when a filename grows.
865
1058
  *
866
1059
  * The cap applies ONLY to the comment. The `--emit-analysis` JSON record is the
867
1060
  * authoritative complete set and is never truncated.
@@ -893,17 +1086,6 @@ const SEVERITY_ICON = {
893
1086
  [Severity.MEDIUM]: YELLOW_CIRCLE,
894
1087
  [Severity.LOW]: WHITE_CIRCLE,
895
1088
  };
896
- /**
897
- * Escape every non-ASCII (>= U+0080) code unit to a `\uXXXX` sequence, matching
898
- * Python's `json.dumps(..., ensure_ascii=True)` (the library default). `JSON`
899
- * `.stringify` emits raw UTF-8 for these, so a finding whose evidence carries an
900
- * em-dash (`—`, U+2014) would otherwise diverge byte-for-byte from the Python
901
- * oracle. Only touches the >= 0x80 range, so the ASCII escapes JSON.stringify
902
- * already produced (`\"`, `\\`, control chars) are left intact.
903
- */
904
- function ensureAscii(json) {
905
- return json.replace(/[€-￿]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
906
- }
907
1089
  /** Serialize a {@link GuardianFinding} to a stable JSON-friendly object. */
908
1090
  function findingDict(finding) {
909
1091
  return {
@@ -919,6 +1101,26 @@ function findingDict(finding) {
919
1101
  uncovered_lines: finding.uncovered_lines,
920
1102
  };
921
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
+ }
922
1124
  /**
923
1125
  * Join every degradation notice this run produced into one line, dropping the
924
1126
  * empty ones. Notices are independent (the agent tier and the coverage input
@@ -932,6 +1134,54 @@ export function combineNotices(...notices) {
932
1134
  function coverageBlock(state) {
933
1135
  return { status: coverageStatus(state), ...state };
934
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.';
935
1185
  /**
936
1186
  * The comment body for a run with zero active findings.
937
1187
  *
@@ -940,11 +1190,13 @@ function coverageBlock(state) {
940
1190
  * footer under a green headline is read as boilerplate, and this is the exact
941
1191
  * shape that let 43 coverage-blind PRs read as covered.
942
1192
  */
943
- function noGapsLines(coverageState, suppressedCount) {
1193
+ function noGapsLines(coverageState, suppressedCount, abstained = false, checked = 0) {
944
1194
  const notice = coverageState ? coverageDegradedNotice(coverageState) : null;
945
- const headline = notice
946
- ? `${WARNING} no gaps found, but coverage was ${coverageStatus(coverageState)}`
947
- : `${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`;
948
1200
  const lines = [`## ${BABY_CHICK} Canary PR Guardian ${EM_DASH} ${headline}`];
949
1201
  if (notice) {
950
1202
  lines.push(`> **${notice}**`, '', 'Zero files matched is an abstention, not a pass — nothing here is ' +
@@ -955,8 +1207,10 @@ function noGapsLines(coverageState, suppressedCount) {
955
1207
  }
956
1208
  return lines;
957
1209
  }
958
- export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta = null, blobBase = null) {
1210
+ export function renderFindings(findings, fmt, tier = 0, degradedNotice = null, gateMeta = null, blobBase = null) {
959
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;
960
1214
  // #554: the coverage ladder's own degradation, stated alongside the tier's.
961
1215
  const coverageState = gateMeta?.coverage ?? null;
962
1216
  const coverageNotice = coverageState
@@ -980,6 +1234,10 @@ export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta
980
1234
  payload['skipped'] = gateMeta.skipped ?? [];
981
1235
  if (coverageState)
982
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 };
983
1241
  }
984
1242
  return ensureAscii(JSON.stringify(payload, null, 2));
985
1243
  }
@@ -1033,19 +1291,33 @@ export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta
1033
1291
  // nothing. The notice goes in the BODY, not only the footer — a `<sub>` line
1034
1292
  // under a green headline is read as boilerplate.
1035
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;
1036
1300
  if (fmt === 'comment') {
1037
1301
  const fileCount = new Set(active.map((f) => f.path)).size;
1038
1302
  const lines = [STICKY_MARKER];
1039
1303
  if (active.length === 0) {
1040
- lines.push(...noGapsLines(coverageState, suppressed.length));
1304
+ lines.push(...noGapsLines(coverageState, suppressed.length, abstained, gateMeta?.checked ?? 0));
1041
1305
  }
1042
1306
  else {
1043
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.
1044
1312
  lines.push(`## ${BABY_CHICK} Canary PR Guardian ${EM_DASH} ` +
1045
- `${fileCount} ${noun} test coverage`);
1046
- lines.push('These lines were changed by this PR but no test exercises them. Add or ' +
1047
- 'extend a test that covers them, or mark the line ' +
1048
- '`// 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.');
1049
1321
  if (coverageLine)
1050
1322
  lines.push('', coverageLine);
1051
1323
  lines.push('', '| Sev | File | What is uncovered, and what to do | Confidence |', '| --- | --- | --- | --- |');
@@ -1079,6 +1351,10 @@ export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta
1079
1351
  lines.push('', `<sub>${suppressed.length} finding(s) suppressed as intentional and not counted above.</sub>`);
1080
1352
  }
1081
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);
1082
1358
  lines.push('', footerLine);
1083
1359
  return lines.join('\n');
1084
1360
  }
@@ -1087,16 +1363,29 @@ export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta
1087
1363
  ? // #554: same rule as the comment surface — a blind run never claims clean.
1088
1364
  `Canary PR Guardian — no gaps found, but coverage was ${coverageStatus(coverageState)}`
1089
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} `, '');
1090
1371
  const lines = [
1091
- active.length === 0
1092
- ? cleanHeadline
1093
- : `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`,
1094
1377
  ];
1095
1378
  for (const finding of ordered) {
1096
1379
  const unit = finding.unit && finding.unit !== finding.path ? ` → ${finding.unit}` : '';
1097
1380
  const mark = finding.suppressed ? ' (suppressed)' : '';
1098
1381
  lines.push(`[${finding.severity}] ${finding.path}${unit} — ${finding.evidence} (${finding.fidelity})${mark}`);
1099
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
+ }
1100
1389
  let footer = `tier ${tier}: deterministic check, no LLM`;
1101
1390
  if (notice)
1102
1391
  footer += ` - ${notice}`;
@@ -32,9 +32,10 @@
32
32
  * permission error here; any other non-2xx propagates as a generic error.
33
33
  */
34
34
  import { readAllPages, restPageReader } from './github-paging.js';
35
- // Single source of truth for the sticky-comment marker. `pr_check.render`
36
- // emits the identical literal at the head of a `comment`-format body so
37
- // `findSticky` can locate the guardian comment for in-place upsert.
35
+ // Single source of truth for the sticky-comment marker.
36
+ // `pr_check.renderFindings` emits the identical literal at the head of a
37
+ // `comment`-format body so `findSticky` can locate the guardian comment for
38
+ // in-place upsert.
38
39
  export const STICKY_MARKER = '<!-- canary-pr-guardian -->';
39
40
  /**
40
41
  * A client cannot write (fork read-only token → HTTP 403).