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.
- package/agents/skills/README.md +308 -0
- package/agents/skills/canary:generate.md +49 -0
- package/agents/skills/canary:init.md +37 -0
- package/agents/skills/canary:migrate.md +66 -0
- package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
- package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
- package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
- package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
- package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
- package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
- package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
- package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
- package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
- package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
- package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
- package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
- package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
- package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
- package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
- package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
- package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
- package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
- package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
- package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
- package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
- package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
- package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
- package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
- package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
- package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
- package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
- package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
- package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
- package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
- package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
- package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
- package/agents/skills/lib/parse-args.mjs +214 -0
- package/dist/engine/analysis/cli.js +116 -54
- package/dist/engine/analysis/engine.js +34 -16
- package/dist/engine/analysis/reports.js +5 -4
- package/dist/engine/cli-commands.js +252 -106
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +9 -17
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/quality-scorer.js +15 -2
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +392 -0
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +310 -38
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +726 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +7 -6
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +5 -5
- package/dist/engine/guardian/analysis-emit.js +19 -28
- package/dist/engine/guardian/cli.js +150 -51
- package/dist/engine/guardian/coverage.js +1 -1
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
- package/dist/engine/guardian/pr-check.js +322 -33
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +249 -23
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/dist/uninstall.js +12 -5
- 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).
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
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
|
-
|
|
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
|
|
863
|
-
* (degradation annotations, upsert wrappers) without inviting
|
|
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 =
|
|
946
|
-
?
|
|
947
|
-
:
|
|
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
|
|
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
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
1092
|
-
?
|
|
1093
|
-
:
|
|
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.
|
|
36
|
-
// emits the identical literal at the head of a
|
|
37
|
-
// `findSticky` can locate the guardian comment for
|
|
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).
|