canary-test-cli 7.2.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/skills/README.md +23 -4
- package/agents/skills/claude-code/canary-batwoman/SKILL.md +119 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +23 -16
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +3 -1
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +20 -3
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +1 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +15 -0
- package/agents/skills/claude-code/canary-screech/SKILL.md +109 -0
- package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
- package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
- package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
- package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
- package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -0
- package/agents/skills/lib/parse-args.mjs +200 -139
- package/dist/engine/analysis/batwoman/audit.js +39 -0
- package/dist/engine/analysis/batwoman/closure.js +159 -0
- package/dist/engine/analysis/batwoman/gh-history.js +119 -0
- package/dist/engine/analysis/batwoman/probes.js +195 -0
- package/dist/engine/analysis/batwoman/registry.js +142 -0
- package/dist/engine/analysis/batwoman/render.js +194 -0
- package/dist/engine/analysis/batwoman/run-window.js +122 -0
- package/dist/engine/analysis/batwoman/text.js +84 -0
- package/dist/engine/analysis/batwoman/triggers.js +122 -0
- package/dist/engine/analysis/batwoman/verdict.js +64 -0
- package/dist/engine/analysis/cli.js +47 -14
- package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
- package/dist/engine/batwoman-cli.js +119 -0
- package/dist/engine/ci-ready-cli.js +71 -0
- package/dist/engine/cli-commands.js +46 -7
- package/dist/engine/cli.core.js +16 -0
- package/dist/engine/company-knowledge-cli.js +10 -2
- package/dist/engine/core/ci-ready.js +112 -0
- package/dist/engine/core/company-knowledge.js +8 -0
- package/dist/engine/core/migrator.js +147 -20
- package/dist/engine/core/permission-matrix.js +219 -0
- package/dist/engine/core/quality-scorer.js +13 -18
- package/dist/engine/core/scaling-curve.js +143 -0
- package/dist/engine/core/string-literals.js +3 -1
- package/dist/engine/core/vacuity-scanner.js +151 -6
- package/dist/engine/core/workflow-discovery.js +41 -23
- package/dist/engine/guardian/adjudication-github.js +136 -0
- package/dist/engine/guardian/adjudication.js +119 -340
- package/dist/engine/guardian/cli.js +180 -264
- package/dist/engine/guardian/coverage.js +2 -1
- package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
- package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
- package/dist/engine/guardian/diff-coverage/paths.js +5 -9
- package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
- package/dist/engine/guardian/diff-extractor.js +31 -32
- package/dist/engine/guardian/pr-check.js +262 -430
- package/dist/engine/guardian/pr-comment.js +35 -58
- package/dist/engine/guardian/weak-test.js +236 -0
- package/dist/engine/mcp-server.js +67 -4
- package/dist/engine/permission-matrix-cli.js +51 -0
- package/dist/engine/scaling-curve-cli.js +147 -0
- package/dist/engine/skills-cli.js +48 -32
- package/dist/engine/workflow-cli.js +85 -65
- package/package.json +1 -1
|
@@ -35,9 +35,10 @@
|
|
|
35
35
|
export { coverageLimits } from './diff-coverage/formats/cobertura.js';
|
|
36
36
|
export { parseCoverageJson } from './diff-coverage/formats/coverage-json.js';
|
|
37
37
|
export { validateCoverageJson, } from './diff-coverage/formats/coverage-json-lint.js';
|
|
38
|
+
export { coverageDeltaNotice, coverageDeltaStatus, resolveCoverageDelta, } from './diff-coverage/coverage-delta.js';
|
|
38
39
|
export { resolveFromGraph } from './diff-coverage/graph-tier.js';
|
|
39
40
|
export { resolveFromHeuristic } from './diff-coverage/heuristic-tier.js';
|
|
40
|
-
export { coverageDegradedNotice, coverageStatus, resolveCoverage, resolveCoverageWithInput, } from './diff-coverage/orchestrator.js';
|
|
41
|
+
export { coverageCauses, coverageDegradedNotice, coverageStatus, resolveCoverage, resolveCoverageWithInput, } from './diff-coverage/orchestrator.js';
|
|
41
42
|
export { isSourcePath, isTestPath, isTestSupportPath, } from './diff-coverage/paths.js';
|
|
42
43
|
export { resolveFromReport } from './diff-coverage/report-tier.js';
|
|
43
44
|
export { isTypeOnlyModule } from './diff-coverage/type-only.js';
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage **regression** on units a PR touches, versus base (#606).
|
|
3
|
+
*
|
|
4
|
+
* The fidelity ladder in `orchestrator.ts` answers one question — *is this
|
|
5
|
+
* changed unit covered at all?* — and is blind to the case that actually ships
|
|
6
|
+
* regressions: a file that was 95% covered on `main` and is 60% covered on the
|
|
7
|
+
* PR head. Every line is still "covered", so the ladder reports nothing. This
|
|
8
|
+
* module asks the second question by comparing two coverage reports over the
|
|
9
|
+
* same touched files.
|
|
10
|
+
*
|
|
11
|
+
* It is a leaf of the diff-coverage package: it reads report indexes through
|
|
12
|
+
* `report-tier.js` and speaks only in the shapes `types.js` defines. Finding
|
|
13
|
+
* construction and severity live in `pr-check.ts`, which owns
|
|
14
|
+
* `GuardianFinding` — nothing here imports upward.
|
|
15
|
+
*
|
|
16
|
+
* **Why a ratio and not line identity.** A diff shifts line numbers, so base
|
|
17
|
+
* line N and head line N are not the same line. A per-line "was hit, now
|
|
18
|
+
* unhit" rule would manufacture findings out of an insertion. The trigger is
|
|
19
|
+
* therefore the whole-file covered ratio; the raw counts travel alongside it so
|
|
20
|
+
* a reviewer can see the shape of the drop rather than a bare percentage.
|
|
21
|
+
*
|
|
22
|
+
* **Why the state object.** Most CI never uploads a base-branch coverage
|
|
23
|
+
* artifact, which is the accepted risk #606 was filed with. A delta that was
|
|
24
|
+
* never computed must not read as a delta that came back clean, so — exactly
|
|
25
|
+
* as `CoverageInputState` does for the ladder (#554) — every field here is a
|
|
26
|
+
* count or a fact about what the run *observed*, never a verdict, and a zero
|
|
27
|
+
* denominator classifies as `unavailable` rather than as agreement (ADR 0010).
|
|
28
|
+
*/
|
|
29
|
+
import { countEligible, readReportIndex, zeroMatchClause, } from './report-tier.js';
|
|
30
|
+
import { matchFile } from './types.js';
|
|
31
|
+
/** Classify a {@link CoverageDeltaState}. Zero compared is never `compared`. */
|
|
32
|
+
export function coverageDeltaStatus(state) {
|
|
33
|
+
if (state.unitsTotal > 0 && state.unitsCompared === state.unitsTotal) {
|
|
34
|
+
return 'compared';
|
|
35
|
+
}
|
|
36
|
+
return state.unitsCompared > 0 ? 'partial' : 'unavailable';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Float-noise guard, in percentage points. Two reports of the same coverage can
|
|
40
|
+
* differ in the last bits of a division; a drop under a tenth of a point is
|
|
41
|
+
* arithmetic, not a regression.
|
|
42
|
+
*/
|
|
43
|
+
const DROP_EPSILON_POINTS = 0.1;
|
|
44
|
+
const EM_DASH = '\u{2014}';
|
|
45
|
+
const HEAD_ONLY = 'were judged against the head report only, so a coverage REGRESSION on ' +
|
|
46
|
+
'them cannot be detected by this run';
|
|
47
|
+
/**
|
|
48
|
+
* The LOUD degradation line for a delta run, or `null` when every touched unit
|
|
49
|
+
* was compared (nothing degraded, so nothing is said).
|
|
50
|
+
*
|
|
51
|
+
* A run that judged nothing (`unitsTotal === 0`) also returns `null`: it makes
|
|
52
|
+
* no claim in either direction, and the caller's abstention path reports that.
|
|
53
|
+
*/
|
|
54
|
+
export function coverageDeltaNotice(state) {
|
|
55
|
+
const { baseRequested, baseFound, baseParsed, filesInBaseReport } = state;
|
|
56
|
+
const { unitsCompared: compared, unitsTotal: total } = state;
|
|
57
|
+
if (total === 0)
|
|
58
|
+
return null;
|
|
59
|
+
const status = coverageDeltaStatus(state);
|
|
60
|
+
if (status === 'compared')
|
|
61
|
+
return null;
|
|
62
|
+
const dash = ` ${EM_DASH} `;
|
|
63
|
+
if (status === 'partial') {
|
|
64
|
+
return (`coverage delta partial${dash}head-only for ${total - compared} of ` +
|
|
65
|
+
`${total} changed file(s); base report '${baseRequested}' matched ` +
|
|
66
|
+
`${compared}. The unmatched file(s) ${HEAD_ONLY}`);
|
|
67
|
+
}
|
|
68
|
+
const head = `coverage delta unavailable${dash}head-only: `;
|
|
69
|
+
const tail = `; ${total} changed file(s) ${HEAD_ONLY}`;
|
|
70
|
+
if (baseRequested === null) {
|
|
71
|
+
return `${head}no base coverage report was supplied${tail}`;
|
|
72
|
+
}
|
|
73
|
+
if (!baseFound) {
|
|
74
|
+
return `${head}base report not found at '${baseRequested}'${tail}`;
|
|
75
|
+
}
|
|
76
|
+
if (!baseParsed) {
|
|
77
|
+
return `${head}base report at '${baseRequested}' yielded no usable records${tail}`;
|
|
78
|
+
}
|
|
79
|
+
return (`${head}base report at '${baseRequested}' covers ${filesInBaseReport} ` +
|
|
80
|
+
`file(s) but ${zeroMatchClause(state.unitsEligible, total)}${tail}`);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Count a file's covered/coverable lines in one report index, or `null` when
|
|
84
|
+
* the report cannot speak to the path with a usable denominator.
|
|
85
|
+
*
|
|
86
|
+
* `coverable` is the report's own instrumented set where the format declares
|
|
87
|
+
* one (lcov, Cobertura), else the lines it recorded. A file present with an
|
|
88
|
+
* empty record has a denominator of zero: a ratio there would be invented, so
|
|
89
|
+
* the unit is skipped rather than scored — the same reason the ladder never
|
|
90
|
+
* grades a unit it could not measure.
|
|
91
|
+
*/
|
|
92
|
+
function ratioFor(path, index) {
|
|
93
|
+
const file = matchFile(path, index);
|
|
94
|
+
if (file === null)
|
|
95
|
+
return null;
|
|
96
|
+
const coverableLines = file.coverable !== null
|
|
97
|
+
? [...file.coverable]
|
|
98
|
+
: Object.keys(file.hits).map(Number);
|
|
99
|
+
if (coverableLines.length === 0)
|
|
100
|
+
return null;
|
|
101
|
+
let covered = 0;
|
|
102
|
+
for (const line of coverableLines) {
|
|
103
|
+
if ((file.hits[line] ?? 0) > 0)
|
|
104
|
+
covered++;
|
|
105
|
+
}
|
|
106
|
+
return { covered, coverable: coverableLines.length };
|
|
107
|
+
}
|
|
108
|
+
const percent = (r) => (r.covered / r.coverable) * 100;
|
|
109
|
+
/**
|
|
110
|
+
* Compare base-branch and head coverage over the touched units (#606).
|
|
111
|
+
*
|
|
112
|
+
* Returns one {@link UnitCoverageDelta} per unit **both** reports could score,
|
|
113
|
+
* plus the {@link CoverageDeltaState} denominator. A unit either side cannot
|
|
114
|
+
* score is silently absent from `deltas` and loudly absent from
|
|
115
|
+
* `unitsCompared` — the notice, not the empty array, is what reports it.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveCoverageDelta(units, options = {}) {
|
|
118
|
+
const { baseCoveragePath = null, headCoveragePath = null } = options;
|
|
119
|
+
const repoRoot = options.repoRoot ?? '.';
|
|
120
|
+
const state = {
|
|
121
|
+
baseRequested: baseCoveragePath,
|
|
122
|
+
baseFound: false,
|
|
123
|
+
baseParsed: false,
|
|
124
|
+
filesInBaseReport: 0,
|
|
125
|
+
unitsCompared: 0,
|
|
126
|
+
unitsTotal: units.length,
|
|
127
|
+
};
|
|
128
|
+
if (baseCoveragePath === null)
|
|
129
|
+
return { deltas: [], state };
|
|
130
|
+
const baseRead = readReportIndex(baseCoveragePath);
|
|
131
|
+
state.baseFound = baseRead.found;
|
|
132
|
+
state.baseParsed = baseRead.index !== null;
|
|
133
|
+
state.filesInBaseReport =
|
|
134
|
+
baseRead.index === null ? 0 : Object.keys(baseRead.index).length;
|
|
135
|
+
if (baseRead.index === null)
|
|
136
|
+
return { deltas: [], state };
|
|
137
|
+
const paths = units.map((u) => u.path);
|
|
138
|
+
state.unitsEligible = countEligible(paths, baseRead.index, baseCoveragePath, repoRoot);
|
|
139
|
+
if (headCoveragePath === null)
|
|
140
|
+
return { deltas: [], state };
|
|
141
|
+
const headRead = readReportIndex(headCoveragePath);
|
|
142
|
+
if (headRead.index === null)
|
|
143
|
+
return { deltas: [], state };
|
|
144
|
+
const deltas = [];
|
|
145
|
+
for (const unit of units) {
|
|
146
|
+
const base = ratioFor(unit.path, baseRead.index);
|
|
147
|
+
const head = ratioFor(unit.path, headRead.index);
|
|
148
|
+
if (base === null || head === null)
|
|
149
|
+
continue;
|
|
150
|
+
state.unitsCompared++;
|
|
151
|
+
const dropPoints = percent(base) - percent(head);
|
|
152
|
+
deltas.push({
|
|
153
|
+
path: unit.path,
|
|
154
|
+
base,
|
|
155
|
+
head,
|
|
156
|
+
dropPoints,
|
|
157
|
+
regressed: dropPoints > DROP_EPSILON_POINTS,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return { deltas, state };
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=coverage-delta.js.map
|
|
@@ -59,12 +59,56 @@ function coberturaBody(text) {
|
|
|
59
59
|
// Pin to the canonical (namespace-free) Cobertura root; anything else is a
|
|
60
60
|
// different XML format and is rejected rather than guessed at. Strip comments
|
|
61
61
|
// first so a `<foo>` inside a comment can't masquerade as the root element.
|
|
62
|
-
const withoutComments = text
|
|
62
|
+
const withoutComments = stripComments(text);
|
|
63
63
|
const rootMatch = /<(?![?!])([A-Za-z_][\w.:-]*)/.exec(withoutComments);
|
|
64
64
|
if (rootMatch === null || rootMatch[1] !== 'coverage')
|
|
65
65
|
return null;
|
|
66
66
|
return withoutComments;
|
|
67
67
|
}
|
|
68
|
+
const CDATA_OPEN = '<![CDATA[';
|
|
69
|
+
const CDATA_CLOSE = ']]>';
|
|
70
|
+
const COMMENT_OPEN = '<!--';
|
|
71
|
+
const COMMENT_CLOSE = '-->';
|
|
72
|
+
/**
|
|
73
|
+
* Drop every XML comment, leaving CDATA sections intact.
|
|
74
|
+
*
|
|
75
|
+
* Must not be a plain `/<!--[\s\S]*?-->/g` replace: inside CDATA those markers
|
|
76
|
+
* are ordinary character data, so a document carrying `<!--` in one CDATA
|
|
77
|
+
* section and `-->` in a later one would have every real element between them
|
|
78
|
+
* deleted. The damage is worse than data loss — the surviving `</class>` then
|
|
79
|
+
* rebinds the next class's `<line>` records to the previous class's filename,
|
|
80
|
+
* so a crafted report can attribute one file's hit counts to another.
|
|
81
|
+
*
|
|
82
|
+
* Chunked with `indexOf` rather than scanned per character so the cost stays
|
|
83
|
+
* proportional to the number of markers, not the size of the capped document.
|
|
84
|
+
*/
|
|
85
|
+
function stripComments(text) {
|
|
86
|
+
const out = [];
|
|
87
|
+
let i = 0;
|
|
88
|
+
for (;;) {
|
|
89
|
+
const cdata = text.indexOf(CDATA_OPEN, i);
|
|
90
|
+
const comment = text.indexOf(COMMENT_OPEN, i);
|
|
91
|
+
if (comment === -1)
|
|
92
|
+
break;
|
|
93
|
+
// A CDATA section that opens first swallows this comment marker: copy the
|
|
94
|
+
// whole section verbatim and resume looking after it.
|
|
95
|
+
if (cdata !== -1 && cdata < comment) {
|
|
96
|
+
const end = text.indexOf(CDATA_CLOSE, cdata + CDATA_OPEN.length);
|
|
97
|
+
if (end === -1)
|
|
98
|
+
break; // unterminated CDATA — nothing further is markup
|
|
99
|
+
out.push(text.slice(i, end + CDATA_CLOSE.length));
|
|
100
|
+
i = end + CDATA_CLOSE.length;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const end = text.indexOf(COMMENT_CLOSE, comment + COMMENT_OPEN.length);
|
|
104
|
+
if (end === -1)
|
|
105
|
+
break; // unterminated comment — leave the tail as-is
|
|
106
|
+
out.push(text.slice(i, comment));
|
|
107
|
+
i = end + COMMENT_CLOSE.length;
|
|
108
|
+
}
|
|
109
|
+
out.push(text.slice(i));
|
|
110
|
+
return out.join('');
|
|
111
|
+
}
|
|
68
112
|
const CLOSE_CLASS = '</class>';
|
|
69
113
|
/**
|
|
70
114
|
* Walk each `<class>` open tag, folding its lines into `index` by filename.
|
|
@@ -5,27 +5,23 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { resolveFromGraph } from './graph-tier.js';
|
|
7
7
|
import { resolveFromHeuristic } from './heuristic-tier.js';
|
|
8
|
-
import { matchUnitsToIndex, readReportIndex } from './report-tier.js';
|
|
8
|
+
import { countEligible, instrumentedTrees, matchUnitsToIndex, readReportIndex, zeroMatchClause, } from './report-tier.js';
|
|
9
9
|
/**
|
|
10
|
-
* SC-3 orchestrator
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* 1. `coveragePath` lists the unit's path → `COVERAGE_VERIFIED`
|
|
16
|
-
* 2. else a graph node for the unit exists → `GRAPH_VERIFIED`
|
|
17
|
-
* 3. else the naming heuristic → `HEURISTIC` (always returns)
|
|
18
|
-
*
|
|
19
|
-
* A unit absent from the report is NOT judged COVERAGE_VERIFIED-uncovered; it
|
|
20
|
-
* falls through to the graph then heuristic tier (FIX 2). Returns exactly one
|
|
21
|
-
* {@link CoverageResult} per input unit, in input order, fidelity-labeled.
|
|
22
|
-
*
|
|
23
|
-
* `graphMaxDepth` bounds the graph tier's reverse-BFS hop distance (#320) and
|
|
24
|
-
* is forwarded verbatim to `resolveFromGraph`.
|
|
10
|
+
* SC-3 orchestrator, applied per unit: the report (`COVERAGE_VERIFIED`), else
|
|
11
|
+
* the graph (`GRAPH_VERIFIED`, bounded by `graphMaxDepth`, #320), else the
|
|
12
|
+
* naming heuristic. An absent unit falls through, never uncovered (FIX 2).
|
|
13
|
+
* Returns one {@link CoverageResult} per unit, in input order.
|
|
25
14
|
*/
|
|
26
15
|
export function resolveCoverage(units, options = {}) {
|
|
27
16
|
return resolveCoverageWithInput(units, options).results;
|
|
28
17
|
}
|
|
18
|
+
/** The units the report did not verify, split by cause (#928). */
|
|
19
|
+
export function coverageCauses(state) {
|
|
20
|
+
const nonCoverable = state.unitsNonCoverable ?? 0;
|
|
21
|
+
const stale = state.unitsEligible ?? 0;
|
|
22
|
+
const absent = state.unitsTotal - state.unitsMatched - nonCoverable;
|
|
23
|
+
return { stale, scopeGap: absent - stale, nonCoverable };
|
|
24
|
+
}
|
|
29
25
|
/** Classify a {@link CoverageInputState}. Zero matched is never `verified`. */
|
|
30
26
|
export function coverageStatus(state) {
|
|
31
27
|
if (state.unitsTotal > 0 && state.unitsMatched === state.unitsTotal) {
|
|
@@ -47,6 +43,10 @@ export function coverageDegradedNotice(state) {
|
|
|
47
43
|
const { unitsMatched: matched, unitsTotal: total } = state;
|
|
48
44
|
if (total === 0)
|
|
49
45
|
return null;
|
|
46
|
+
const { stale, scopeGap } = coverageCauses(state);
|
|
47
|
+
// The report spoke to every unit, even if only to say "nothing coverable".
|
|
48
|
+
if (parsed && stale + scopeGap === 0)
|
|
49
|
+
return null;
|
|
50
50
|
const status = coverageStatus(state);
|
|
51
51
|
if (status === 'verified')
|
|
52
52
|
return null;
|
|
@@ -68,7 +68,7 @@ export function coverageDegradedNotice(state) {
|
|
|
68
68
|
`${total} changed file(s) ${FALLBACK_TIER}`);
|
|
69
69
|
}
|
|
70
70
|
return (`${head}report at '${requested}' covers ${filesInReport} file(s) but ` +
|
|
71
|
-
|
|
71
|
+
`${zeroMatchClause(state.unitsEligible, stale + scopeGap)}; ${FALLBACK_TIER}`);
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
74
74
|
* {@link resolveCoverage}, additionally reporting which mode the run was in.
|
|
@@ -97,14 +97,18 @@ export function resolveCoverageWithInput(units, options = {}) {
|
|
|
97
97
|
coverage.parsed = read.index !== null;
|
|
98
98
|
coverage.filesInReport =
|
|
99
99
|
read.index === null ? 0 : Object.keys(read.index).length;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (report !== null && report.length > 0) {
|
|
100
|
+
if (read.index !== null) {
|
|
101
|
+
const nonCoverable = [];
|
|
102
|
+
const report = matchUnitsToIndex(remaining, read.index, nonCoverable);
|
|
104
103
|
for (const r of report)
|
|
105
104
|
resolved.set(r.unit, r);
|
|
106
105
|
coverage.unitsMatched = report.length;
|
|
106
|
+
coverage.unitsNonCoverable = nonCoverable.length;
|
|
107
107
|
remaining = remaining.filter((u) => !resolved.has(u));
|
|
108
|
+
// #928: only ABSENT units can make a report stale.
|
|
109
|
+
const absent = remaining.filter((u) => !nonCoverable.includes(u));
|
|
110
|
+
coverage.unitsEligible = countEligible(absent.map((u) => u.path), read.index, coveragePath, repoRoot);
|
|
111
|
+
coverage.instrumentedTrees = instrumentedTrees(read.index, coveragePath, repoRoot);
|
|
108
112
|
}
|
|
109
113
|
}
|
|
110
114
|
if (remaining.length > 0) {
|
|
@@ -75,9 +75,11 @@ export function isTestSupportPath(path) {
|
|
|
75
75
|
* Extensions that denote hand-authored, executable program source (#413).
|
|
76
76
|
*
|
|
77
77
|
* The membership rule is deliberately simple and defensible: **a programming
|
|
78
|
-
* language belongs; data, config, markup, and style do not.** `.sh
|
|
79
|
-
*
|
|
80
|
-
*
|
|
78
|
+
* language belongs; data, config, markup, and style do not.** Shell (`.sh`,
|
|
79
|
+
* `.bash`, `.zsh`, `.ps1`, `.psm1`) is out since #933: ops and seed scripts drew
|
|
80
|
+
* "no test file references" findings no repo will ever satisfy. `.json`,
|
|
81
|
+
* `.yaml`, `.sql`, `.css`, and `.html` are out (nothing to meaningfully judge).
|
|
82
|
+
* This one set feeds both the heuristic noise filter and the coverage-unit floor.
|
|
81
83
|
*
|
|
82
84
|
* A repo that disagrees at the margins tunes the glob layer
|
|
83
85
|
* (`canary.guardian.pr.heuristicExclude`) rather than this list.
|
|
@@ -134,12 +136,6 @@ const SOURCE_EXTENSIONS = new Set([
|
|
|
134
136
|
'.dart',
|
|
135
137
|
'.r',
|
|
136
138
|
'.jl',
|
|
137
|
-
// Shell.
|
|
138
|
-
'.sh',
|
|
139
|
-
'.bash',
|
|
140
|
-
'.zsh',
|
|
141
|
-
'.ps1',
|
|
142
|
-
'.psm1',
|
|
143
139
|
]);
|
|
144
140
|
/**
|
|
145
141
|
* True if `path` looks like hand-authored program source (#413).
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* and the per-unit matching that turns a report index into verdicts.
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
-
import { basename } from 'node:path';
|
|
8
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from 'node:path';
|
|
9
9
|
import { parseCobertura } from './formats/cobertura.js';
|
|
10
10
|
import { parseCoverageJson } from './formats/coverage-json.js';
|
|
11
11
|
import { expandRanges, makeResult, matchFile, rangesStr, selfDescribing, splitLines, pyInt, Fidelity, } from './types.js';
|
|
@@ -88,6 +88,81 @@ export function readReportIndex(reportPath) {
|
|
|
88
88
|
return unusable(true);
|
|
89
89
|
return { found: true, index };
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* The repo-relative prefix a report's relative paths are rooted at (#883): the
|
|
93
|
+
* nearest ancestor of the report's directory under which any of `samples`
|
|
94
|
+
* exists (vitest under `ts/` writes `src/cli.ts`). Every sample is tried, since
|
|
95
|
+
* a stale report names deleted files. No hit, or outside the repo: the root.
|
|
96
|
+
*/
|
|
97
|
+
function anchorPrefix(samples, reportPath, root) {
|
|
98
|
+
const dir = relative(root, resolve(dirname(reportPath))).split(sep);
|
|
99
|
+
if (dir[0] === '..' || isAbsolute(dir.join('/')))
|
|
100
|
+
return '';
|
|
101
|
+
for (let i = dir.length; i > 0 && dir[0] !== ''; i--) {
|
|
102
|
+
const prefix = dir.slice(0, i).join('/');
|
|
103
|
+
if (samples.some((s) => existsSync(join(root, prefix, s)))) {
|
|
104
|
+
return `${prefix}/`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
/** The deepest directory shared by every path in `paths` ('' when none). */
|
|
110
|
+
function commonDir(paths) {
|
|
111
|
+
const dirs = paths.map((p) => p.split('/').slice(0, -1));
|
|
112
|
+
const first = dirs[0] ?? [];
|
|
113
|
+
let n = first.length;
|
|
114
|
+
for (const d of dirs) {
|
|
115
|
+
while (n > 0 && d.slice(0, n).join('/') !== first.slice(0, n).join('/'))
|
|
116
|
+
n--;
|
|
117
|
+
}
|
|
118
|
+
return first.slice(0, n).join('/');
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The repo-relative source trees a report instruments (#883): one per top-level
|
|
122
|
+
* segment of its (anchored) paths, narrowed to the deepest shared directory.
|
|
123
|
+
* `''` means the report is rooted at the repo itself and covers everything.
|
|
124
|
+
*/
|
|
125
|
+
export function instrumentedTrees(index, reportPath, repoRoot) {
|
|
126
|
+
const root = resolve(repoRoot);
|
|
127
|
+
const groups = new Map();
|
|
128
|
+
for (const raw of Object.keys(index)) {
|
|
129
|
+
const p = isAbsolute(raw) ? relative(root, raw).split(sep).join('/') : raw;
|
|
130
|
+
const clean = p.replace(/^\.\//, '');
|
|
131
|
+
const top = clean.split('/')[0];
|
|
132
|
+
groups.set(top, [...(groups.get(top) ?? []), clean]);
|
|
133
|
+
}
|
|
134
|
+
return [...groups.values()].map((paths) => {
|
|
135
|
+
const tree = anchorPrefix(paths, reportPath, root) + commonDir(paths);
|
|
136
|
+
return tree.replace(/\/$/, '');
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* How many changed `paths` lie inside a tree the report instruments (#883) —
|
|
141
|
+
* the denominator a zero-match abstention needs. Zero eligible means the files
|
|
142
|
+
* are outside the instrumentation scope, so no fresh report could match them;
|
|
143
|
+
* eligible-but-unmatched means the report itself is stale.
|
|
144
|
+
*/
|
|
145
|
+
export function countEligible(paths, index, reportPath, repoRoot) {
|
|
146
|
+
const trees = instrumentedTrees(index, reportPath, repoRoot);
|
|
147
|
+
const inside = (p) => trees.some((t) => t === '' || p.startsWith(`${t}/`));
|
|
148
|
+
return paths.filter(inside).length;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The "matched 0" clause for a zero-match notice, naming its cause (#883).
|
|
152
|
+
* `eligible` is undefined for a producer that never computed it.
|
|
153
|
+
*/
|
|
154
|
+
export function zeroMatchClause(eligible, total) {
|
|
155
|
+
const matched = `matched 0 of ${total} changed file(s)`;
|
|
156
|
+
if (eligible === undefined)
|
|
157
|
+
return matched;
|
|
158
|
+
if (eligible === 0) {
|
|
159
|
+
return (`${matched}: 0 of ${total} changed file(s) lie inside a tree it ` +
|
|
160
|
+
'instruments, so they are outside every tree the report covers ' +
|
|
161
|
+
'(an instrumentation-scope gap; regenerating the report cannot fix it)');
|
|
162
|
+
}
|
|
163
|
+
return (`${matched}, though ${eligible} of ${total} changed file(s) lie inside a ` +
|
|
164
|
+
'tree it instruments (the report is likely stale or from another checkout)');
|
|
165
|
+
}
|
|
91
166
|
/** Pick the reader by report filename; `null` for a format we don't know. */
|
|
92
167
|
function parseByFormat(name, text) {
|
|
93
168
|
if (name.endsWith('.json')) {
|
|
@@ -107,18 +182,19 @@ function parseByFormat(name, text) {
|
|
|
107
182
|
// Unrecognized format → fall through to a lower fidelity tier.
|
|
108
183
|
return null;
|
|
109
184
|
}
|
|
110
|
-
/**
|
|
111
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Resolve every unit the report index can speak to (COVERAGE_VERIFIED). Units
|
|
187
|
+
* the report lists but whose changed lines are all non-coverable are pushed to
|
|
188
|
+
* `nonCoverable`, so a caller can tell them from absent units (#928).
|
|
189
|
+
*/
|
|
190
|
+
export function matchUnitsToIndex(units, index, nonCoverable = []) {
|
|
112
191
|
const results = [];
|
|
113
192
|
for (const unit of units) {
|
|
114
193
|
const file = matchFile(unit.path, index);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// result so the orchestrator falls through to a lower-fidelity tier for
|
|
119
|
-
// this unit (FIX 2).
|
|
194
|
+
// Absent → "not instrumented", NOT "instrumented and unhit": no result, so
|
|
195
|
+
// the unit falls through to a lower-fidelity tier (FIX 2).
|
|
196
|
+
if (file === null)
|
|
120
197
|
continue;
|
|
121
|
-
}
|
|
122
198
|
const { hits, coverable: measured } = file;
|
|
123
199
|
const added = expandRanges(unit.added_ranges);
|
|
124
200
|
// The per-line form of the check above (#655/#657): where the report says
|
|
@@ -127,9 +203,9 @@ export function matchUnitsToIndex(units, index) {
|
|
|
127
203
|
// every changed line counts and absence means uncovered.
|
|
128
204
|
const coverable = measured === null ? added : added.filter((ln) => measured.has(ln));
|
|
129
205
|
if (coverable.length === 0) {
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
206
|
+
// Nothing coverable changed: never a pass or a finding here, and it falls
|
|
207
|
+
// through like an absent path — but it is recorded as its own cause.
|
|
208
|
+
nonCoverable.push(unit);
|
|
133
209
|
continue;
|
|
134
210
|
}
|
|
135
211
|
const uncovered = coverable.filter((ln) => (hits[ln] ?? 0) <= 0);
|
|
@@ -103,44 +103,43 @@ function pyGet(obj, key, fallback) {
|
|
|
103
103
|
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
|
|
104
104
|
}
|
|
105
105
|
/**
|
|
106
|
-
* Deep equality
|
|
107
|
-
*
|
|
108
|
-
*
|
|
106
|
+
* Deep equality for spec values: arrays by order, objects by key set, null and
|
|
107
|
+
* undefined interchangeable. A boolean never equals a number (#922): a spec's
|
|
108
|
+
* true -> 1 rewrite changes the type consumers see, so it is a change.
|
|
109
109
|
*/
|
|
110
110
|
function pyEqual(a, b) {
|
|
111
111
|
if (a === b)
|
|
112
112
|
return true;
|
|
113
|
-
if (a
|
|
114
|
-
return
|
|
115
|
-
if (
|
|
113
|
+
if (isNone(a) || isNone(b))
|
|
114
|
+
return isNone(a) && isNone(b);
|
|
115
|
+
if (Array.isArray(a) || Array.isArray(b))
|
|
116
|
+
return pyListEqual(a, b);
|
|
117
|
+
return pyDictEqual(a, b);
|
|
118
|
+
}
|
|
119
|
+
/** Python `None`: JSON `null` or a missing key read as `undefined`. */
|
|
120
|
+
function isNone(value) {
|
|
121
|
+
return value === null || value === undefined;
|
|
122
|
+
}
|
|
123
|
+
/** Python `list == list`: same length, element-wise equal in order. */
|
|
124
|
+
function pyListEqual(a, b) {
|
|
125
|
+
if (!Array.isArray(a) || !Array.isArray(b))
|
|
116
126
|
return false;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (!
|
|
121
|
-
return false;
|
|
122
|
-
for (let i = 0; i < a.length; i++) {
|
|
123
|
-
if (!pyEqual(a[i], b[i]))
|
|
124
|
-
return false;
|
|
125
|
-
}
|
|
126
|
-
return true;
|
|
127
|
-
}
|
|
128
|
-
if (typeof a === 'object' && typeof b === 'object') {
|
|
129
|
-
const ao = a;
|
|
130
|
-
const bo = b;
|
|
131
|
-
const aKeys = Object.keys(ao);
|
|
132
|
-
const bKeys = Object.keys(bo);
|
|
133
|
-
if (aKeys.length !== bKeys.length)
|
|
127
|
+
if (a.length !== b.length)
|
|
128
|
+
return false;
|
|
129
|
+
for (let i = 0; i < a.length; i++)
|
|
130
|
+
if (!pyEqual(a[i], b[i]))
|
|
134
131
|
return false;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
/** Python `dict == dict`: same key set in any order, values equal. */
|
|
135
|
+
function pyDictEqual(a, b) {
|
|
136
|
+
if (typeof a !== 'object' || typeof b !== 'object')
|
|
137
|
+
return false;
|
|
138
|
+
const [ao, bo] = [a, b];
|
|
139
|
+
const keys = Object.keys(ao);
|
|
140
|
+
if (keys.length !== Object.keys(bo).length)
|
|
141
|
+
return false;
|
|
142
|
+
return keys.every((k) => Object.hasOwn(bo, k) && pyEqual(ao[k], bo[k]));
|
|
144
143
|
}
|
|
145
144
|
function setEqual(a, b) {
|
|
146
145
|
if (a.size !== b.size)
|