canary-test-cli 6.8.1 → 7.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/dist/engine/analysis/cli.js +155 -45
- package/dist/engine/analysis/engine.js +9 -9
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/cli-commands.js +2 -2
- package/dist/engine/core/migrator.js +142 -31
- package/dist/engine/core/static-linter.js +2 -2
- package/dist/engine/core/workspace-detect.js +0 -0
- package/dist/engine/guardian/adjudication.js +1 -1
- package/dist/engine/guardian/agent-tier.js +3 -3
- package/dist/engine/guardian/coverage.js +15 -1420
- package/dist/engine/guardian/diff-coverage/formats/cobertura.js +130 -0
- package/dist/engine/guardian/diff-coverage/formats/coverage-json-lint.js +197 -0
- package/dist/engine/guardian/diff-coverage/formats/coverage-json.js +107 -0
- package/dist/engine/guardian/diff-coverage/formats/xml.js +151 -0
- package/dist/engine/guardian/diff-coverage/graph-tier.js +223 -0
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +150 -0
- package/dist/engine/guardian/diff-coverage/orchestrator.js +125 -0
- package/dist/engine/guardian/diff-coverage/paths.js +164 -0
- package/dist/engine/guardian/diff-coverage/report-tier.js +153 -0
- package/dist/engine/guardian/diff-coverage/type-only.js +150 -0
- package/dist/engine/guardian/diff-coverage/types.js +115 -0
- package/dist/engine/guardian/pr-check.js +5 -5
- package/dist/engine-checks.d.ts +15 -0
- package/dist/engine-checks.js +92 -1
- package/dist/overlay-commands.d.ts +12 -1
- package/dist/overlay-commands.js +28 -2
- package/dist/router.js +17 -5
- package/dist/uninstall-render.d.ts +11 -0
- package/dist/uninstall-render.js +60 -0
- package/dist/uninstall-scan.d.ts +14 -0
- package/dist/uninstall-scan.js +273 -0
- package/dist/uninstall-types.d.ts +46 -0
- package/dist/uninstall-types.js +91 -0
- package/dist/uninstall.d.ts +13 -0
- package/dist/uninstall.js +174 -0
- package/package.json +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cobertura `coverage.xml` reader — a minimal, targeted scanner pinned to the
|
|
3
|
+
* canonical `<coverage>...<class filename="..."><line number hits/>` shape
|
|
4
|
+
* rather than a general XML parser (see `./xml.ts` for why).
|
|
5
|
+
*/
|
|
6
|
+
import { attrValue, isWellFormedXml } from './xml.js';
|
|
7
|
+
import { pyInt, selfDescribing, } from '../types.js';
|
|
8
|
+
// Coverage reports are semi-trusted CI artifacts, but canary distrusts input by
|
|
9
|
+
// default: cap size so a pathological XML cannot exhaust memory during parse.
|
|
10
|
+
// Exposed as a mutable object so tests can shrink the cap (the analog of the
|
|
11
|
+
// Python test's `monkeypatch.setattr(cov, "_MAX_REPORT_BYTES", 32)`).
|
|
12
|
+
export const coverageLimits = { maxReportBytes: 25 * 1024 * 1024 }; // 25 MiB
|
|
13
|
+
/**
|
|
14
|
+
* Parse a Cobertura `coverage.xml` into `{path: {line: hits}}`.
|
|
15
|
+
*
|
|
16
|
+
* Line-level only — branch/`condition-coverage` data is intentionally dropped.
|
|
17
|
+
* Pins to the canonical Cobertura shape emitted by coverage.py, Istanbul,
|
|
18
|
+
* SimpleCov and Jacoco→Cobertura converters: a `<coverage>` root with
|
|
19
|
+
* `<class filename=...>` elements carrying nested `<line number= hits=>`.
|
|
20
|
+
* (Native Jacoco XML uses a `<report>` root and is *not* Cobertura — it
|
|
21
|
+
* correctly returns `null`.) Any other XML → `null` so the caller falls through
|
|
22
|
+
* to a lower fidelity tier (absence never blocks).
|
|
23
|
+
*
|
|
24
|
+
* Security: rejects oversize input and DOCTYPE entity definitions *before*
|
|
25
|
+
* parsing (guards against entity-expansion / "billion laughs"). This targeted
|
|
26
|
+
* scanner resolves no entities and reads no DTD, so XXE is not in scope.
|
|
27
|
+
* Malformed input never throws — it degrades to `null`.
|
|
28
|
+
*/
|
|
29
|
+
export function parseCobertura(text) {
|
|
30
|
+
const body = coberturaBody(text);
|
|
31
|
+
if (body === null)
|
|
32
|
+
return null;
|
|
33
|
+
// Built as raw hit maps; every `<line>` record is a line the instrumenter
|
|
34
|
+
// measured, so the recorded lines become the coverable set on the way out.
|
|
35
|
+
const index = {};
|
|
36
|
+
collectClasses(body, index);
|
|
37
|
+
return pruneEmptyClasses(index);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The comment-stripped document, or `null` if it is not a Cobertura report we
|
|
41
|
+
* are willing to read: oversize, entity-bearing, malformed, or a different
|
|
42
|
+
* format entirely.
|
|
43
|
+
*/
|
|
44
|
+
function coberturaBody(text) {
|
|
45
|
+
if (text.length > coverageLimits.maxReportBytes)
|
|
46
|
+
return null;
|
|
47
|
+
// Reject any internal-subset DOCTYPE that declares entities. Scan the FULL
|
|
48
|
+
// (already size-capped) text: a leading comment can push the DOCTYPE past
|
|
49
|
+
// any fixed window, so a windowed check is bypassable.
|
|
50
|
+
if (text.includes('<!DOCTYPE') && text.includes('<!ENTITY'))
|
|
51
|
+
return null;
|
|
52
|
+
// Reject malformed XML up front, matching Python's `ET.fromstring` raising
|
|
53
|
+
// `ParseError` → the caller falls through to a lower-fidelity tier. Without
|
|
54
|
+
// this, a lenient scanner would happily extract coverage from a broken
|
|
55
|
+
// document, flipping both the fidelity tier AND the covered/uncovered verdict
|
|
56
|
+
// relative to the oracle.
|
|
57
|
+
if (!isWellFormedXml(text))
|
|
58
|
+
return null;
|
|
59
|
+
// Pin to the canonical (namespace-free) Cobertura root; anything else is a
|
|
60
|
+
// different XML format and is rejected rather than guessed at. Strip comments
|
|
61
|
+
// first so a `<foo>` inside a comment can't masquerade as the root element.
|
|
62
|
+
const withoutComments = text.replace(/<!--[\s\S]*?-->/g, '');
|
|
63
|
+
const rootMatch = /<(?![?!])([A-Za-z_][\w.:-]*)/.exec(withoutComments);
|
|
64
|
+
if (rootMatch === null || rootMatch[1] !== 'coverage')
|
|
65
|
+
return null;
|
|
66
|
+
return withoutComments;
|
|
67
|
+
}
|
|
68
|
+
const CLOSE_CLASS = '</class>';
|
|
69
|
+
/**
|
|
70
|
+
* Walk each `<class>` open tag, folding its lines into `index` by filename.
|
|
71
|
+
*
|
|
72
|
+
* A self-closing `<class .../>` is an empty class (its filename recorded, no
|
|
73
|
+
* lines) — critically, it must NOT be paired with the NEXT class's
|
|
74
|
+
* `</class>`, or that class's lines bind to the wrong file. Cobertura classes
|
|
75
|
+
* never nest, so for a real open tag the first following `</class>` is the
|
|
76
|
+
* correct close.
|
|
77
|
+
*/
|
|
78
|
+
function collectClasses(body, index) {
|
|
79
|
+
const classOpenRe = /<class\b([^>]*?)(\/?)>/g;
|
|
80
|
+
for (let cls = classOpenRe.exec(body); cls !== null; cls = classOpenRe.exec(body)) {
|
|
81
|
+
// Normalize Windows separators so .NET/coverlet reports resolve against
|
|
82
|
+
// POSIX-style diff paths (the path matcher only recognizes "/").
|
|
83
|
+
const raw = attrValue(cls[1], 'filename');
|
|
84
|
+
const filename = raw === null ? null : raw.replace(/\\/g, '/');
|
|
85
|
+
if (cls[2] === '/') {
|
|
86
|
+
// Record the filename (mirrors ET's `setdefault`), but consume no body so
|
|
87
|
+
// the next class keeps its own lines.
|
|
88
|
+
if (filename)
|
|
89
|
+
bucketFor(index, filename);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const openEnd = classOpenRe.lastIndex;
|
|
93
|
+
const closeIdx = body.indexOf(CLOSE_CLASS, openEnd);
|
|
94
|
+
if (closeIdx === -1)
|
|
95
|
+
return; // no close (well-formed XML guarantees one)
|
|
96
|
+
classOpenRe.lastIndex = closeIdx + CLOSE_CLASS.length;
|
|
97
|
+
if (!filename)
|
|
98
|
+
continue;
|
|
99
|
+
readClassLines(body.slice(openEnd, closeIdx), bucketFor(index, filename));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** One filename's hit map, created on first mention (ET's `setdefault`). */
|
|
103
|
+
function bucketFor(index, filename) {
|
|
104
|
+
return (index[filename] ??= {});
|
|
105
|
+
}
|
|
106
|
+
/** Fold every `<line number= hits=>` in one class body into `hitsByLine`. */
|
|
107
|
+
function readClassLines(body, hitsByLine) {
|
|
108
|
+
const lineRe = /<line\b([^>]*)>/g;
|
|
109
|
+
for (let ln = lineRe.exec(body); ln !== null; ln = lineRe.exec(body)) {
|
|
110
|
+
const num = attrValue(ln[1], 'number');
|
|
111
|
+
if (num === null)
|
|
112
|
+
continue;
|
|
113
|
+
const lineno = pyInt(num);
|
|
114
|
+
const hits = pyInt(attrValue(ln[1], 'hits') ?? '0');
|
|
115
|
+
if (lineno === null || hits === null)
|
|
116
|
+
continue;
|
|
117
|
+
// A line can appear at both method and class scope; keep the max.
|
|
118
|
+
hitsByLine[lineno] = Math.max(hitsByLine[lineno] ?? 0, hits);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Drop classes that yielded no parseable lines; require at least one. */
|
|
122
|
+
function pruneEmptyClasses(index) {
|
|
123
|
+
const pruned = {};
|
|
124
|
+
for (const [path, hits] of Object.entries(index)) {
|
|
125
|
+
if (Object.keys(hits).length > 0)
|
|
126
|
+
pruned[path] = selfDescribing(hits);
|
|
127
|
+
}
|
|
128
|
+
return Object.keys(pruned).length > 0 ? pruned : null;
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=cobertura.js.map
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The producer-facing lint for coverage-json: it reports, loudly, exactly what
|
|
3
|
+
* `./coverage-json.ts` would silently accept-and-drop. Split one validator per
|
|
4
|
+
* field so each stays readable and the parser it mirrors stays adjacent.
|
|
5
|
+
*/
|
|
6
|
+
import { COVERAGE_JSON_SCHEMA_VERSION, isSupportedSchemaVersion, } from './coverage-json.js';
|
|
7
|
+
import { isInt, isRecord, pyInt } from '../types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Validate a coverage-json document against the v1 producer contract.
|
|
10
|
+
*
|
|
11
|
+
* Reports, loudly, exactly what `parseCoverageJson` would silently
|
|
12
|
+
* accept-and-drop, at two severities. Never raises and never mutates — it is a
|
|
13
|
+
* lint for producers, mirroring the parser it lives beside so the two cannot
|
|
14
|
+
* drift.
|
|
15
|
+
*/
|
|
16
|
+
export function validateCoverageJson(data) {
|
|
17
|
+
const problems = [];
|
|
18
|
+
const report = {
|
|
19
|
+
err: (location, message) => void problems.push({ severity: 'error', location, message }),
|
|
20
|
+
warn: (location, message) => void problems.push({ severity: 'warning', location, message }),
|
|
21
|
+
};
|
|
22
|
+
if (!isRecord(data)) {
|
|
23
|
+
report.err('(root)', 'top-level value must be a JSON object');
|
|
24
|
+
return problems;
|
|
25
|
+
}
|
|
26
|
+
const version = data['schema_version'];
|
|
27
|
+
if (!isSupportedSchemaVersion(version)) {
|
|
28
|
+
report.err('schema_version', `unsupported schema_version ${repr(version)}; this build understands ` +
|
|
29
|
+
`v${COVERAGE_JSON_SCHEMA_VERSION} (omit the field to default to it)`);
|
|
30
|
+
}
|
|
31
|
+
const files = data['files'];
|
|
32
|
+
if (files === undefined || files === null) {
|
|
33
|
+
report.err('files', "missing required 'files' object");
|
|
34
|
+
return problems;
|
|
35
|
+
}
|
|
36
|
+
if (!isRecord(files)) {
|
|
37
|
+
report.err('files', "'files' must be an object mapping path -> coverage");
|
|
38
|
+
return problems;
|
|
39
|
+
}
|
|
40
|
+
for (const [path, entry] of Object.entries(files)) {
|
|
41
|
+
validateFileEntry(`files['${path}']`, entry, report);
|
|
42
|
+
}
|
|
43
|
+
return problems;
|
|
44
|
+
}
|
|
45
|
+
/** Validate one `files[path]` entry, mirroring what the parser keeps. */
|
|
46
|
+
function validateFileEntry(loc, entry, report) {
|
|
47
|
+
if (!isRecord(entry)) {
|
|
48
|
+
report.err(loc, "entry must be an object; this file's coverage is dropped");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Mirror the parser's surviving hit map so the verdict is bound to what
|
|
52
|
+
// the parser actually keeps.
|
|
53
|
+
const recorded = {};
|
|
54
|
+
validateLineHits(entry['line_hits'], recorded, loc, report.warn);
|
|
55
|
+
validateCoveredLines(entry['covered_lines'], recorded, loc, report.warn);
|
|
56
|
+
if (Object.keys(recorded).length === 0) {
|
|
57
|
+
report.warn(loc, 'no usable coverage lines; contributes nothing');
|
|
58
|
+
}
|
|
59
|
+
validateInstrumentedLines(entry, recorded, loc, report.warn);
|
|
60
|
+
}
|
|
61
|
+
/** Check `line_hits`, recording into `recorded` every entry the parser keeps. */
|
|
62
|
+
function validateLineHits(lineHits, recorded, loc, warn) {
|
|
63
|
+
if (lineHits === undefined || lineHits === null)
|
|
64
|
+
return;
|
|
65
|
+
if (!isRecord(lineHits)) {
|
|
66
|
+
warn(`${loc}.line_hits`, 'must be an object mapping line -> hits; ignored');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
for (const [k, v] of Object.entries(lineHits)) {
|
|
70
|
+
const kloc = `${loc}.line_hits['${k}']`;
|
|
71
|
+
if (!isInt(v)) {
|
|
72
|
+
warn(kloc, `hits ${repr(v)} is not an integer; dropped`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (v < 0) {
|
|
76
|
+
warn(kloc, `hits ${v} is negative; dropped`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const lineno = pyInt(k);
|
|
80
|
+
if (lineno === null) {
|
|
81
|
+
warn(kloc, 'line key is not an integer; dropped');
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (lineno < 1) {
|
|
85
|
+
warn(kloc, 'line number must be >= 1; dropped');
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
recorded[lineno] = v;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Check `covered_lines`, folding the survivors into `recorded` as the parser does. */
|
|
92
|
+
function validateCoveredLines(covered, recorded, loc, warn) {
|
|
93
|
+
if (covered === undefined || covered === null)
|
|
94
|
+
return;
|
|
95
|
+
if (!Array.isArray(covered)) {
|
|
96
|
+
warn(`${loc}.covered_lines`, 'must be an array of line numbers; ignored');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
covered.forEach((lineno, i) => {
|
|
100
|
+
const cloc = `${loc}.covered_lines[${i}]`;
|
|
101
|
+
if (!isInt(lineno)) {
|
|
102
|
+
warn(cloc, `${repr(lineno)} is not an integer; dropped`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (lineno < 1) {
|
|
106
|
+
warn(cloc, 'line number must be >= 1; dropped');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!(lineno in recorded)) {
|
|
110
|
+
recorded[lineno] = 1;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (recorded[lineno] === 0) {
|
|
114
|
+
warn(cloc, `line ${lineno} is also in line_hits as unhit (0); ` +
|
|
115
|
+
'line_hits wins, so it stays uncovered');
|
|
116
|
+
}
|
|
117
|
+
// a positive line_hits count makes this entry redundant
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Check a file entry's `instrumented_lines`, and nudge producers that need it.
|
|
122
|
+
*
|
|
123
|
+
* Two jobs. The shape checks mirror the parser, as everywhere else in this
|
|
124
|
+
* validator. The interesting one is the **ambiguity** warning: a document
|
|
125
|
+
* leaning on `covered_lines` cannot express an unhit line *or* a
|
|
126
|
+
* non-instrumented one, so every line it omits lands downstream as a coverage
|
|
127
|
+
* gap. That is exactly what transcoding lcov into this format produces, and it
|
|
128
|
+
* is the failure #657 exists to close — the producer sees success while the
|
|
129
|
+
* consumer invents findings. Silence there would be the same shape this repo
|
|
130
|
+
* keeps closing, so the validator says it out loud.
|
|
131
|
+
*
|
|
132
|
+
* It is deliberately scoped to `covered_lines`, the field that *structurally*
|
|
133
|
+
* cannot report a miss. A `line_hits` document with no zeros is not evidence of
|
|
134
|
+
* the same mistake: that producer can express an unhit line and simply had none
|
|
135
|
+
* to report, which is what a fully-covered file looks like. Warning there would
|
|
136
|
+
* fire on correct documents, and a warning that cries wolf is one producers
|
|
137
|
+
* learn to skip — the precision lesson from #553.
|
|
138
|
+
*
|
|
139
|
+
* The warning clears as soon as the document is unambiguous by either route: a
|
|
140
|
+
* declared `instrumented_lines`, or an explicit `0` in `line_hits`. A producer
|
|
141
|
+
* already doing the right thing must not be nagged toward a second mechanism.
|
|
142
|
+
*/
|
|
143
|
+
function validateInstrumentedLines(entry, recorded, loc, warn) {
|
|
144
|
+
const declaration = entry['instrumented_lines'];
|
|
145
|
+
const dloc = `${loc}.instrumented_lines`;
|
|
146
|
+
if (declaration === undefined || declaration === null) {
|
|
147
|
+
const usesShorthand = Array.isArray(entry['covered_lines']);
|
|
148
|
+
const declaresAMiss = Object.values(recorded).includes(0);
|
|
149
|
+
if (usesShorthand && !declaresAMiss) {
|
|
150
|
+
warn(loc, "reports coverage via 'covered_lines', which cannot express an unhit " +
|
|
151
|
+
'line, so a changed line this document omits is read as uncovered — ' +
|
|
152
|
+
'a line that was never instrumented (a comment, an import, a type ' +
|
|
153
|
+
"declaration) becomes a coverage gap. Declare 'instrumented_lines', " +
|
|
154
|
+
'or record unhit lines as line_hits 0');
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!Array.isArray(declaration)) {
|
|
159
|
+
warn(dloc, 'must be an array of line numbers; ignored');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
declaration.forEach((lineno, i) => {
|
|
163
|
+
if (!isInt(lineno)) {
|
|
164
|
+
warn(`${dloc}[${i}]`, `${repr(lineno)} is not an integer; dropped`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (lineno < 1) {
|
|
168
|
+
warn(`${dloc}[${i}]`, 'line number must be >= 1; dropped');
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
// A recorded line the declaration omits is a producer contradicting itself.
|
|
172
|
+
// The parser keeps the measurement, so this costs no coverage — but a
|
|
173
|
+
// declaration that disagrees with the data is worth hearing about.
|
|
174
|
+
const declared = new Set(declaration.filter(isInt));
|
|
175
|
+
const undeclared = Object.keys(recorded)
|
|
176
|
+
.map(Number)
|
|
177
|
+
.filter((ln) => !declared.has(ln));
|
|
178
|
+
if (undeclared.length > 0) {
|
|
179
|
+
warn(dloc, `line(s) ${undeclared.join(', ')} have coverage data but are not ` +
|
|
180
|
+
'declared instrumented; the measurement wins and they stay coverable');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Rough analog of Python's `repr()` for scalar diagnostic values. */
|
|
184
|
+
function repr(value) {
|
|
185
|
+
if (typeof value === 'string')
|
|
186
|
+
return `'${value}'`;
|
|
187
|
+
// Match Python's `{v!r}` spelling of the JSON scalars so warning messages
|
|
188
|
+
// read byte-for-byte like the oracle (true→True, false→False, null→None).
|
|
189
|
+
if (value === true)
|
|
190
|
+
return 'True';
|
|
191
|
+
if (value === false)
|
|
192
|
+
return 'False';
|
|
193
|
+
if (value === null)
|
|
194
|
+
return 'None';
|
|
195
|
+
return String(value);
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=coverage-json-lint.js.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader for the canary coverage-json shape. The producer contract it enforces
|
|
3
|
+
* is linted, field for field, by `./coverage-json-lint.ts` — the two are
|
|
4
|
+
* deliberately kept side by side so they cannot drift.
|
|
5
|
+
*/
|
|
6
|
+
import { isInt, isRecord, pyInt, recordedLines, } from '../types.js';
|
|
7
|
+
// The coverage-json contract version this build understands. Bumped only on a
|
|
8
|
+
// breaking change; the shape evolves additively (see
|
|
9
|
+
// docs/specs/coverage-json-contract.md).
|
|
10
|
+
export const COVERAGE_JSON_SCHEMA_VERSION = 1;
|
|
11
|
+
/** True when `schema_version` is absent or names a version this build reads. */
|
|
12
|
+
export function isSupportedSchemaVersion(version) {
|
|
13
|
+
if (version === undefined || version === null)
|
|
14
|
+
return true;
|
|
15
|
+
return isInt(version) && version === COVERAGE_JSON_SCHEMA_VERSION;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parse the canary coverage-json shape into `{path: {line: hits}}`.
|
|
19
|
+
*
|
|
20
|
+
* Supports `{"files": {"<path>": {"covered_lines": [...]}}}` and the same with
|
|
21
|
+
* an explicit `line_hits` mapping. Unrecognized structure, or a
|
|
22
|
+
* `schema_version` this build does not understand, → `null`. The v1 contract
|
|
23
|
+
* is enforced strictly (integers only, 1-based lines, non-negative hits) so
|
|
24
|
+
* this parser and `validateCoverageJson` stay in lockstep.
|
|
25
|
+
*/
|
|
26
|
+
export function parseCoverageJson(data) {
|
|
27
|
+
if (!isRecord(data))
|
|
28
|
+
return null;
|
|
29
|
+
// Refuse a version we don't understand rather than silently consuming its
|
|
30
|
+
// v1-compatible parts and mislabeling the result coverage-verified.
|
|
31
|
+
if (!isSupportedSchemaVersion(data['schema_version']))
|
|
32
|
+
return null;
|
|
33
|
+
const files = data['files'];
|
|
34
|
+
if (!isRecord(files))
|
|
35
|
+
return null;
|
|
36
|
+
const index = {};
|
|
37
|
+
for (const [path, entry] of Object.entries(files)) {
|
|
38
|
+
if (isRecord(entry))
|
|
39
|
+
index[String(path)] = parseFileEntry(entry);
|
|
40
|
+
}
|
|
41
|
+
return index;
|
|
42
|
+
}
|
|
43
|
+
/** Resolve one `files[path]` entry into its hit map and coverable set. */
|
|
44
|
+
function parseFileEntry(entry) {
|
|
45
|
+
// line_hits is authoritative: covered_lines may add a line it didn't mention,
|
|
46
|
+
// but never overrides an explicit hit count (so a `{"14": 0}` unhit line
|
|
47
|
+
// stays uncovered).
|
|
48
|
+
const recorded = readLineHits(entry['line_hits']);
|
|
49
|
+
const hits = { ...recorded };
|
|
50
|
+
for (const lineno of coveredLineNumbers(entry['covered_lines'])) {
|
|
51
|
+
if (!(lineno in recorded))
|
|
52
|
+
hits[lineno] = 1;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
hits,
|
|
56
|
+
coverable: declaredCoverable(entry['instrumented_lines'], hits),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** `line_hits` → hit map, dropping anything the v1 contract rejects. */
|
|
60
|
+
function readLineHits(value) {
|
|
61
|
+
const hits = {};
|
|
62
|
+
if (!isRecord(value))
|
|
63
|
+
return hits;
|
|
64
|
+
for (const [key, count] of Object.entries(value)) {
|
|
65
|
+
// Integers only, 1-based line, non-negative hits (see docstring).
|
|
66
|
+
if (!(isInt(count) && count >= 0))
|
|
67
|
+
continue;
|
|
68
|
+
const lineno = pyInt(key);
|
|
69
|
+
if (lineno === null || lineno < 1)
|
|
70
|
+
continue;
|
|
71
|
+
hits[lineno] = count;
|
|
72
|
+
}
|
|
73
|
+
return hits;
|
|
74
|
+
}
|
|
75
|
+
/** `covered_lines` → the line numbers that survive the v1 contract. */
|
|
76
|
+
function coveredLineNumbers(value) {
|
|
77
|
+
if (!Array.isArray(value))
|
|
78
|
+
return [];
|
|
79
|
+
return value.filter((n) => isInt(n) && n >= 1);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a file entry's `instrumented_lines` declaration (#657).
|
|
83
|
+
*
|
|
84
|
+
* Returns the set of lines the report can speak to, or `null` when the producer
|
|
85
|
+
* did not declare one — which is every v1 document written before this field
|
|
86
|
+
* existed, and is why adding it changes no existing behaviour.
|
|
87
|
+
*
|
|
88
|
+
* A malformed declaration degrades to `null` rather than dropping the entry,
|
|
89
|
+
* matching the parser's leniency everywhere else: one bad field costs that
|
|
90
|
+
* field, never the file's coverage. `validateCoverageJson` is what makes the
|
|
91
|
+
* degradation audible.
|
|
92
|
+
*
|
|
93
|
+
* Recorded lines are unioned in. A `line_hits` key outside the declared set is
|
|
94
|
+
* a producer contradicting itself, and real measurement outranks a declaration
|
|
95
|
+
* — dropping it would discard a hit count the producer actually took.
|
|
96
|
+
*/
|
|
97
|
+
function declaredCoverable(declaration, hits) {
|
|
98
|
+
if (!Array.isArray(declaration))
|
|
99
|
+
return null;
|
|
100
|
+
const coverable = recordedLines(hits);
|
|
101
|
+
for (const lineno of declaration) {
|
|
102
|
+
if (isInt(lineno) && lineno >= 1)
|
|
103
|
+
coverable.add(lineno);
|
|
104
|
+
}
|
|
105
|
+
return coverable;
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=coverage-json.js.map
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The XML primitives the Cobertura reader stands on: a non-throwing
|
|
3
|
+
* well-formedness check and an attribute extractor.
|
|
4
|
+
*
|
|
5
|
+
* Node has no built-in XML parser, and pulling one in would put a third-party
|
|
6
|
+
* parser inside the guardian's security boundary. Instead the check below is a
|
|
7
|
+
* hand-rolled scanner that resolves no entities and reads no DTD.
|
|
8
|
+
*
|
|
9
|
+
* Structured as one dispatch per construct rather than a single loop: each
|
|
10
|
+
* reader takes the current offset and returns the offset just past what it
|
|
11
|
+
* consumed, or `-1` for "not well formed". That keeps the branch count of every
|
|
12
|
+
* individual function low enough to reason about — the shape the 38-branch
|
|
13
|
+
* original could not offer (#668).
|
|
14
|
+
*/
|
|
15
|
+
const XML_WS = new Set([' ', '\t', '\n', '\r']);
|
|
16
|
+
// Sticky (`y`) so each reader matches exactly at the offset it was handed, with
|
|
17
|
+
// no substring allocation. Every use sets `lastIndex` immediately before `exec`.
|
|
18
|
+
const NAME_RE = /[A-Za-z_][\w.:-]*/y;
|
|
19
|
+
const END_TAG_RE = /([A-Za-z_][\w.:-]*)[ \t\n\r]*>/y;
|
|
20
|
+
const ATTR_NAME_EQ_RE = /[A-Za-z_][\w.:-]*[ \t\n\r]*=[ \t\n\r]*/y;
|
|
21
|
+
const ENTITY_RE = /(?:#[0-9]+|#x[0-9A-Fa-f]+|[A-Za-z_][\w.-]*);/y;
|
|
22
|
+
/** "Not well formed" — the sentinel every reader below returns on failure. */
|
|
23
|
+
const BAD = -1;
|
|
24
|
+
/**
|
|
25
|
+
* Minimal, non-throwing XML well-formedness check — the analog of what
|
|
26
|
+
* `ET.fromstring` enforces before it will yield an element tree. Returns
|
|
27
|
+
* `false` (so the caller degrades to `null`) on: an unbalanced or mismatched
|
|
28
|
+
* tag, an unquoted attribute value, or a raw `&` that is not a valid entity
|
|
29
|
+
* reference. Skips comments, CDATA, processing instructions, and DOCTYPE
|
|
30
|
+
* declarations (entity-bearing DOCTYPEs are already rejected upstream). O(n)
|
|
31
|
+
* over the size-capped input, allocation-free via sticky regexes.
|
|
32
|
+
*/
|
|
33
|
+
export function isWellFormedXml(text) {
|
|
34
|
+
const stack = [];
|
|
35
|
+
let i = 0;
|
|
36
|
+
while (i < text.length) {
|
|
37
|
+
const ch = text[i];
|
|
38
|
+
let next;
|
|
39
|
+
if (ch === '<')
|
|
40
|
+
next = readMarkup(text, i, stack);
|
|
41
|
+
else if (ch === '&')
|
|
42
|
+
next = readEntity(text, i);
|
|
43
|
+
else
|
|
44
|
+
next = i + 1;
|
|
45
|
+
if (next === BAD)
|
|
46
|
+
return false;
|
|
47
|
+
i = next;
|
|
48
|
+
}
|
|
49
|
+
return stack.length === 0;
|
|
50
|
+
}
|
|
51
|
+
/** Dispatch on which `<`-introduced construct starts at `i`. */
|
|
52
|
+
function readMarkup(text, i, stack) {
|
|
53
|
+
if (text.startsWith('<!--', i))
|
|
54
|
+
return skipPast(text, '-->', i + 4);
|
|
55
|
+
if (text.startsWith('<![CDATA[', i))
|
|
56
|
+
return skipPast(text, ']]>', i + 9);
|
|
57
|
+
if (text.startsWith('<?', i))
|
|
58
|
+
return skipPast(text, '?>', i + 2);
|
|
59
|
+
if (text.startsWith('<!', i))
|
|
60
|
+
return skipDeclaration(text, i);
|
|
61
|
+
if (text.startsWith('</', i))
|
|
62
|
+
return readEndTag(text, i, stack);
|
|
63
|
+
return readStartTag(text, i, stack);
|
|
64
|
+
}
|
|
65
|
+
/** Offset just past the next `marker` at or after `from`. */
|
|
66
|
+
function skipPast(text, marker, from) {
|
|
67
|
+
const end = text.indexOf(marker, from);
|
|
68
|
+
return end === BAD ? BAD : end + marker.length;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Skip a DOCTYPE or other `<!` declaration to its matching top-level `>`,
|
|
72
|
+
* honoring an internal-subset `[ ... ]` whose contents may contain `>`.
|
|
73
|
+
*/
|
|
74
|
+
function skipDeclaration(text, i) {
|
|
75
|
+
let depth = 0;
|
|
76
|
+
for (let j = i + 2; j < text.length; j++) {
|
|
77
|
+
const c = text[j];
|
|
78
|
+
if (c === '[')
|
|
79
|
+
depth++;
|
|
80
|
+
else if (c === ']') {
|
|
81
|
+
if (depth > 0)
|
|
82
|
+
depth--;
|
|
83
|
+
}
|
|
84
|
+
else if (c === '>' && depth === 0)
|
|
85
|
+
return j + 1;
|
|
86
|
+
}
|
|
87
|
+
return BAD;
|
|
88
|
+
}
|
|
89
|
+
/** Read `</name>`, requiring it to close the element on top of the stack. */
|
|
90
|
+
function readEndTag(text, i, stack) {
|
|
91
|
+
END_TAG_RE.lastIndex = i + 2;
|
|
92
|
+
const m = END_TAG_RE.exec(text);
|
|
93
|
+
if (m === null)
|
|
94
|
+
return BAD;
|
|
95
|
+
if (stack.pop() !== m[1])
|
|
96
|
+
return BAD;
|
|
97
|
+
return END_TAG_RE.lastIndex;
|
|
98
|
+
}
|
|
99
|
+
/** Read a start tag or a self-closing element, pushing the former. */
|
|
100
|
+
function readStartTag(text, i, stack) {
|
|
101
|
+
NAME_RE.lastIndex = i + 1;
|
|
102
|
+
const nm = NAME_RE.exec(text);
|
|
103
|
+
if (nm === null)
|
|
104
|
+
return BAD;
|
|
105
|
+
const name = nm[0];
|
|
106
|
+
let j = NAME_RE.lastIndex;
|
|
107
|
+
while (j < text.length) {
|
|
108
|
+
while (j < text.length && XML_WS.has(text[j]))
|
|
109
|
+
j++;
|
|
110
|
+
if (j >= text.length)
|
|
111
|
+
return BAD;
|
|
112
|
+
if (text[j] === '>') {
|
|
113
|
+
stack.push(name);
|
|
114
|
+
return j + 1;
|
|
115
|
+
}
|
|
116
|
+
if (text[j] === '/' && text[j + 1] === '>')
|
|
117
|
+
return j + 2;
|
|
118
|
+
j = readAttribute(text, j);
|
|
119
|
+
if (j === BAD)
|
|
120
|
+
return BAD;
|
|
121
|
+
}
|
|
122
|
+
return BAD; // ran out of input before the tag closed
|
|
123
|
+
}
|
|
124
|
+
/** Read one `name="value"` pair; rejects junk and unquoted values. */
|
|
125
|
+
function readAttribute(text, i) {
|
|
126
|
+
ATTR_NAME_EQ_RE.lastIndex = i;
|
|
127
|
+
if (ATTR_NAME_EQ_RE.exec(text) === null)
|
|
128
|
+
return BAD; // junk or valueless attr
|
|
129
|
+
const j = ATTR_NAME_EQ_RE.lastIndex;
|
|
130
|
+
const quote = text[j];
|
|
131
|
+
if (quote !== '"' && quote !== "'")
|
|
132
|
+
return BAD;
|
|
133
|
+
const close = text.indexOf(quote, j + 1);
|
|
134
|
+
return close === BAD ? BAD : close + 1;
|
|
135
|
+
}
|
|
136
|
+
/** Read an `&entity;` reference; a raw `&` is not well formed. */
|
|
137
|
+
function readEntity(text, i) {
|
|
138
|
+
ENTITY_RE.lastIndex = i + 1;
|
|
139
|
+
if (ENTITY_RE.exec(text) === null)
|
|
140
|
+
return BAD;
|
|
141
|
+
return ENTITY_RE.lastIndex;
|
|
142
|
+
}
|
|
143
|
+
/** Extract an XML attribute value (double- or single-quoted) from a tag body. */
|
|
144
|
+
export function attrValue(attrs, name) {
|
|
145
|
+
const re = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`);
|
|
146
|
+
const m = re.exec(attrs);
|
|
147
|
+
if (m === null)
|
|
148
|
+
return null;
|
|
149
|
+
return m[2] ?? m[3] ?? '';
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=xml.js.map
|