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
|
@@ -26,1425 +26,20 @@
|
|
|
26
26
|
* `readFileSync(path, 'utf-8')` silently substitutes U+FFFD. The report
|
|
27
27
|
* reader decodes with a *fatal* `TextDecoder` to preserve the Python
|
|
28
28
|
* "non-UTF-8 report → fall through" behavior.
|
|
29
|
-
*/
|
|
30
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
31
|
-
import { basename, extname, join, posix } from 'node:path';
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Shapes
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
/** Confidence tier of a coverage signal (lower rank == higher fidelity). */
|
|
36
|
-
export var Fidelity;
|
|
37
|
-
(function (Fidelity) {
|
|
38
|
-
Fidelity["CoverageVerified"] = "coverage-verified";
|
|
39
|
-
Fidelity["GraphVerified"] = "graph-verified";
|
|
40
|
-
Fidelity["Heuristic"] = "heuristic";
|
|
41
|
-
})(Fidelity || (Fidelity = {}));
|
|
42
|
-
const FIDELITY_RANK = {
|
|
43
|
-
[Fidelity.CoverageVerified]: 0,
|
|
44
|
-
[Fidelity.GraphVerified]: 1,
|
|
45
|
-
[Fidelity.Heuristic]: 2,
|
|
46
|
-
};
|
|
47
|
-
/** 0=coverage, 1=graph, 2=heuristic. Lower means higher fidelity. */
|
|
48
|
-
export function fidelityRank(fidelity) {
|
|
49
|
-
return FIDELITY_RANK[fidelity];
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Build a {@link CoverageResult}, defaulting `uncovered_lines` to `[]`. Stands
|
|
53
|
-
* in for the Python dataclass's `field(default_factory=list)` — the graph and
|
|
54
|
-
* heuristic tiers never populate uncovered lines and rely on that default.
|
|
55
|
-
*/
|
|
56
|
-
function makeResult(fields) {
|
|
57
|
-
return { ...fields, uncovered_lines: fields.uncovered_lines ?? [] };
|
|
58
|
-
}
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// Small helpers
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
/** Split like Python's `str.splitlines()` for the common line endings. */
|
|
63
|
-
function splitLines(text) {
|
|
64
|
-
return text.split(/\r\n|\r|\n/);
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Parse an integer the way Python's `int(str)` does for our inputs: optional
|
|
68
|
-
* surrounding whitespace and sign, digits only. Returns `null` on failure
|
|
69
|
-
* (Python would raise `ValueError`, which the callers catch-and-skip).
|
|
70
|
-
*/
|
|
71
|
-
function pyInt(value) {
|
|
72
|
-
const trimmed = value.trim();
|
|
73
|
-
if (!/^[+-]?\d+$/.test(trimmed))
|
|
74
|
-
return null;
|
|
75
|
-
return Number.parseInt(trimmed, 10);
|
|
76
|
-
}
|
|
77
|
-
/** bool is excluded (a JSON true/false is not a valid line/hit count). */
|
|
78
|
-
function isInt(value) {
|
|
79
|
-
// typeof boolean !== 'number', so booleans are already excluded here — the
|
|
80
|
-
// JS analog of Python's explicit `not isinstance(value, bool)` guard.
|
|
81
|
-
return typeof value === 'number' && Number.isInteger(value);
|
|
82
|
-
}
|
|
83
|
-
function isRecord(value) {
|
|
84
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
85
|
-
}
|
|
86
|
-
/** Flatten inclusive `[start, end]` ranges into a sorted, de-duped line list. */
|
|
87
|
-
function expandRanges(ranges) {
|
|
88
|
-
const lines = new Set();
|
|
89
|
-
for (const [start, end] of ranges) {
|
|
90
|
-
for (let ln = start; ln <= end; ln++)
|
|
91
|
-
lines.add(ln);
|
|
92
|
-
}
|
|
93
|
-
return [...lines].sort((a, b) => a - b);
|
|
94
|
-
}
|
|
95
|
-
/** Python's `Path(p).stem`: basename minus its final extension. */
|
|
96
|
-
function stem(path) {
|
|
97
|
-
const base = basename(path);
|
|
98
|
-
const ext = extname(base);
|
|
99
|
-
return ext ? base.slice(0, -ext.length) : base;
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* True iff `candidate` and `target` name the same file path suffix.
|
|
103
|
-
*
|
|
104
|
-
* Exact match, or one is a suffix of the other on a **path-separator boundary**
|
|
105
|
-
* (`a/b/foo.py` vs `foo.py`). Rejects loose substring collisions such as
|
|
106
|
-
* `foobar.py` vs `bar.py` and `usermodels.py` vs `models.py` (FIX 6).
|
|
107
|
-
*/
|
|
108
|
-
function pathBoundaryMatch(candidate, target) {
|
|
109
|
-
return (candidate === target ||
|
|
110
|
-
candidate.endsWith('/' + target) ||
|
|
111
|
-
target.endsWith('/' + candidate));
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Look up per-line hit counts for `path` in a report index.
|
|
115
|
-
*
|
|
116
|
-
* Prefers an EXACT path match. Otherwise falls back to a **boundary** suffix
|
|
117
|
-
* match (report paths may be absolute, `./`-prefixed, or repo-relative). On
|
|
118
|
-
* multiple boundary matches (duplicate basenames) the lookup is ambiguous and
|
|
119
|
-
* returns `null` — the unit is then skipped and falls through rather than
|
|
120
|
-
* binding to an arbitrary first match (FIX 6).
|
|
121
|
-
*/
|
|
122
|
-
function matchHits(path, index) {
|
|
123
|
-
if (path in index)
|
|
124
|
-
return index[path];
|
|
125
|
-
const matches = [];
|
|
126
|
-
for (const [reportPath, hits] of Object.entries(index)) {
|
|
127
|
-
if (pathBoundaryMatch(reportPath, path))
|
|
128
|
-
matches.push(hits);
|
|
129
|
-
}
|
|
130
|
-
return matches.length === 1 ? matches[0] : null;
|
|
131
|
-
}
|
|
132
|
-
/** Render ranges compactly, e.g. `[[12, 28], [30, 30]]` → `"12-28, 30"`. */
|
|
133
|
-
function rangesStr(ranges) {
|
|
134
|
-
return ranges
|
|
135
|
-
.map(([start, end]) => (start === end ? `${start}` : `${start}-${end}`))
|
|
136
|
-
.join(', ');
|
|
137
|
-
}
|
|
138
|
-
// ---------------------------------------------------------------------------
|
|
139
|
-
// LCOV
|
|
140
|
-
// ---------------------------------------------------------------------------
|
|
141
|
-
/** Parse `lcov.info` into `{path: {line: hits}}`. */
|
|
142
|
-
function parseLcov(text) {
|
|
143
|
-
const index = {};
|
|
144
|
-
let current = null;
|
|
145
|
-
for (const line of splitLines(text)) {
|
|
146
|
-
if (line.startsWith('SF:')) {
|
|
147
|
-
current = line.slice(3).trim();
|
|
148
|
-
if (!(current in index))
|
|
149
|
-
index[current] = {};
|
|
150
|
-
}
|
|
151
|
-
else if (line.startsWith('DA:') && current !== null) {
|
|
152
|
-
const body = line.slice(3).trim();
|
|
153
|
-
const parts = body.split(',');
|
|
154
|
-
if (parts.length >= 2) {
|
|
155
|
-
const lineno = pyInt(parts[0]);
|
|
156
|
-
const hits = pyInt(parts[1]);
|
|
157
|
-
if (lineno === null || hits === null)
|
|
158
|
-
continue;
|
|
159
|
-
index[current][lineno] = hits;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
else if (line.trim() === 'end_of_record') {
|
|
163
|
-
current = null;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
return index;
|
|
167
|
-
}
|
|
168
|
-
// ---------------------------------------------------------------------------
|
|
169
|
-
// coverage-json
|
|
170
|
-
// ---------------------------------------------------------------------------
|
|
171
|
-
// The coverage-json contract version this build understands. Bumped only on a
|
|
172
|
-
// breaking change; the shape evolves additively (see
|
|
173
|
-
// docs/specs/coverage-json-contract.md).
|
|
174
|
-
const COVERAGE_JSON_SCHEMA_VERSION = 1;
|
|
175
|
-
/**
|
|
176
|
-
* Parse the canary coverage-json shape into `{path: {line: hits}}`.
|
|
177
|
-
*
|
|
178
|
-
* Supports `{"files": {"<path>": {"covered_lines": [...]}}}` and the same with
|
|
179
|
-
* an explicit `line_hits` mapping. Unrecognized structure, or a
|
|
180
|
-
* `schema_version` this build does not understand, → `null`. The v1 contract
|
|
181
|
-
* is enforced strictly (integers only, 1-based lines, non-negative hits) so
|
|
182
|
-
* this parser and {@link validateCoverageJson} stay in lockstep.
|
|
183
|
-
*/
|
|
184
|
-
export function parseCoverageJson(data) {
|
|
185
|
-
if (!isRecord(data))
|
|
186
|
-
return null;
|
|
187
|
-
// Refuse a version we don't understand rather than silently consuming its
|
|
188
|
-
// v1-compatible parts and mislabeling the result coverage-verified.
|
|
189
|
-
const version = data['schema_version'];
|
|
190
|
-
if (version !== undefined &&
|
|
191
|
-
version !== null &&
|
|
192
|
-
!(isInt(version) && version === COVERAGE_JSON_SCHEMA_VERSION)) {
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
const files = data['files'];
|
|
196
|
-
if (!isRecord(files))
|
|
197
|
-
return null;
|
|
198
|
-
const index = {};
|
|
199
|
-
for (const [path, entry] of Object.entries(files)) {
|
|
200
|
-
if (!isRecord(entry))
|
|
201
|
-
continue;
|
|
202
|
-
const hits = {};
|
|
203
|
-
const authoritative = new Set(); // lines line_hits recorded (may be 0)
|
|
204
|
-
const lineHits = entry['line_hits'];
|
|
205
|
-
if (isRecord(lineHits)) {
|
|
206
|
-
for (const [k, v] of Object.entries(lineHits)) {
|
|
207
|
-
// Integers only, 1-based line, non-negative hits (see docstring).
|
|
208
|
-
if (!(isInt(v) && v >= 0))
|
|
209
|
-
continue;
|
|
210
|
-
const lineno = pyInt(k);
|
|
211
|
-
if (lineno === null)
|
|
212
|
-
continue;
|
|
213
|
-
if (lineno < 1)
|
|
214
|
-
continue;
|
|
215
|
-
hits[lineno] = v;
|
|
216
|
-
authoritative.add(lineno);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
const covered = entry['covered_lines'];
|
|
220
|
-
if (Array.isArray(covered)) {
|
|
221
|
-
for (const lineno of covered) {
|
|
222
|
-
if (!(isInt(lineno) && lineno >= 1))
|
|
223
|
-
continue;
|
|
224
|
-
// line_hits is authoritative: covered_lines may add a line it didn't
|
|
225
|
-
// mention, but never override an explicit hit count (so a `{"14": 0}`
|
|
226
|
-
// unhit line stays uncovered).
|
|
227
|
-
if (!authoritative.has(lineno))
|
|
228
|
-
hits[lineno] = 1;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
index[String(path)] = hits;
|
|
232
|
-
}
|
|
233
|
-
return index;
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* Validate a coverage-json document against the v1 producer contract.
|
|
237
|
-
*
|
|
238
|
-
* Reports, loudly, exactly what {@link parseCoverageJson} would silently
|
|
239
|
-
* accept-and-drop, at two severities. Never raises and never mutates — it is a
|
|
240
|
-
* lint for producers, mirroring the parser it lives beside so the two cannot
|
|
241
|
-
* drift.
|
|
242
|
-
*/
|
|
243
|
-
export function validateCoverageJson(data) {
|
|
244
|
-
const problems = [];
|
|
245
|
-
const err = (location, message) => {
|
|
246
|
-
problems.push({ severity: 'error', location, message });
|
|
247
|
-
};
|
|
248
|
-
const warn = (location, message) => {
|
|
249
|
-
problems.push({ severity: 'warning', location, message });
|
|
250
|
-
};
|
|
251
|
-
if (!isRecord(data)) {
|
|
252
|
-
err('(root)', 'top-level value must be a JSON object');
|
|
253
|
-
return problems;
|
|
254
|
-
}
|
|
255
|
-
const version = data['schema_version'];
|
|
256
|
-
if (version !== undefined &&
|
|
257
|
-
version !== null &&
|
|
258
|
-
!(isInt(version) && version === COVERAGE_JSON_SCHEMA_VERSION)) {
|
|
259
|
-
err('schema_version', `unsupported schema_version ${repr(version)}; this build understands ` +
|
|
260
|
-
`v${COVERAGE_JSON_SCHEMA_VERSION} (omit the field to default to it)`);
|
|
261
|
-
}
|
|
262
|
-
const files = data['files'];
|
|
263
|
-
if (files === undefined || files === null) {
|
|
264
|
-
err('files', "missing required 'files' object");
|
|
265
|
-
return problems;
|
|
266
|
-
}
|
|
267
|
-
if (!isRecord(files)) {
|
|
268
|
-
err('files', "'files' must be an object mapping path -> coverage");
|
|
269
|
-
return problems;
|
|
270
|
-
}
|
|
271
|
-
for (const [path, entry] of Object.entries(files)) {
|
|
272
|
-
const loc = `files['${path}']`;
|
|
273
|
-
if (!isRecord(entry)) {
|
|
274
|
-
err(loc, "entry must be an object; this file's coverage is dropped");
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
const lineHits = entry['line_hits'];
|
|
278
|
-
const covered = entry['covered_lines'];
|
|
279
|
-
// Mirror the parser's surviving hit map so the verdict is bound to what
|
|
280
|
-
// the parser actually keeps.
|
|
281
|
-
const recorded = {};
|
|
282
|
-
if (lineHits !== undefined && lineHits !== null) {
|
|
283
|
-
if (!isRecord(lineHits)) {
|
|
284
|
-
warn(`${loc}.line_hits`, 'must be an object mapping line -> hits; ignored');
|
|
285
|
-
}
|
|
286
|
-
else {
|
|
287
|
-
for (const [k, v] of Object.entries(lineHits)) {
|
|
288
|
-
const kloc = `${loc}.line_hits['${k}']`;
|
|
289
|
-
if (!isInt(v)) {
|
|
290
|
-
warn(kloc, `hits ${repr(v)} is not an integer; dropped`);
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
if (v < 0) {
|
|
294
|
-
warn(kloc, `hits ${v} is negative; dropped`);
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
const lineno = pyInt(k);
|
|
298
|
-
if (lineno === null) {
|
|
299
|
-
warn(kloc, 'line key is not an integer; dropped');
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
if (lineno < 1) {
|
|
303
|
-
warn(kloc, 'line number must be >= 1; dropped');
|
|
304
|
-
continue;
|
|
305
|
-
}
|
|
306
|
-
recorded[lineno] = v;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
if (covered !== undefined && covered !== null) {
|
|
311
|
-
if (!Array.isArray(covered)) {
|
|
312
|
-
warn(`${loc}.covered_lines`, 'must be an array of line numbers; ignored');
|
|
313
|
-
}
|
|
314
|
-
else {
|
|
315
|
-
covered.forEach((lineno, i) => {
|
|
316
|
-
const cloc = `${loc}.covered_lines[${i}]`;
|
|
317
|
-
if (!isInt(lineno)) {
|
|
318
|
-
warn(cloc, `${repr(lineno)} is not an integer; dropped`);
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
if (lineno < 1) {
|
|
322
|
-
warn(cloc, 'line number must be >= 1; dropped');
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
if (lineno in recorded) {
|
|
326
|
-
if (recorded[lineno] === 0) {
|
|
327
|
-
warn(cloc, `line ${lineno} is also in line_hits as unhit (0); ` +
|
|
328
|
-
'line_hits wins, so it stays uncovered');
|
|
329
|
-
}
|
|
330
|
-
// a positive line_hits count makes this entry redundant
|
|
331
|
-
}
|
|
332
|
-
else {
|
|
333
|
-
recorded[lineno] = 1;
|
|
334
|
-
}
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
if (Object.keys(recorded).length === 0) {
|
|
339
|
-
warn(loc, 'no usable coverage lines; contributes nothing');
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
return problems;
|
|
343
|
-
}
|
|
344
|
-
/** Rough analog of Python's `repr()` for scalar diagnostic values. */
|
|
345
|
-
function repr(value) {
|
|
346
|
-
if (typeof value === 'string')
|
|
347
|
-
return `'${value}'`;
|
|
348
|
-
// Match Python's `{v!r}` spelling of the JSON scalars so warning messages
|
|
349
|
-
// read byte-for-byte like the oracle (true→True, false→False, null→None).
|
|
350
|
-
if (value === true)
|
|
351
|
-
return 'True';
|
|
352
|
-
if (value === false)
|
|
353
|
-
return 'False';
|
|
354
|
-
if (value === null)
|
|
355
|
-
return 'None';
|
|
356
|
-
return String(value);
|
|
357
|
-
}
|
|
358
|
-
// ---------------------------------------------------------------------------
|
|
359
|
-
// Cobertura XML
|
|
360
|
-
// ---------------------------------------------------------------------------
|
|
361
|
-
// Coverage reports are semi-trusted CI artifacts, but canary distrusts input by
|
|
362
|
-
// default: cap size so a pathological XML cannot exhaust memory during parse.
|
|
363
|
-
// Exposed as a mutable object so tests can shrink the cap (the analog of the
|
|
364
|
-
// Python test's `monkeypatch.setattr(cov, "_MAX_REPORT_BYTES", 32)`).
|
|
365
|
-
export const coverageLimits = { maxReportBytes: 25 * 1024 * 1024 }; // 25 MiB
|
|
366
|
-
/**
|
|
367
|
-
* Parse a Cobertura `coverage.xml` into `{path: {line: hits}}`.
|
|
368
|
-
*
|
|
369
|
-
* Line-level only — branch/`condition-coverage` data is intentionally dropped.
|
|
370
|
-
* Pins to the canonical Cobertura shape emitted by coverage.py, Istanbul,
|
|
371
|
-
* SimpleCov and Jacoco→Cobertura converters: a `<coverage>` root with
|
|
372
|
-
* `<class filename=...>` elements carrying nested `<line number= hits=>`.
|
|
373
|
-
* (Native Jacoco XML uses a `<report>` root and is *not* Cobertura — it
|
|
374
|
-
* correctly returns `null`.) Any other XML → `null` so the caller falls through
|
|
375
|
-
* to a lower fidelity tier (absence never blocks).
|
|
376
|
-
*
|
|
377
|
-
* Security: rejects oversize input and DOCTYPE entity definitions *before*
|
|
378
|
-
* parsing (guards against entity-expansion / "billion laughs"). This targeted
|
|
379
|
-
* scanner resolves no entities and reads no DTD, so XXE is not in scope.
|
|
380
|
-
* Malformed input never throws — it degrades to `null`.
|
|
381
|
-
*/
|
|
382
|
-
function parseCobertura(text) {
|
|
383
|
-
if (text.length > coverageLimits.maxReportBytes)
|
|
384
|
-
return null;
|
|
385
|
-
// Reject any internal-subset DOCTYPE that declares entities. Scan the FULL
|
|
386
|
-
// (already size-capped) text: a leading comment can push the DOCTYPE past
|
|
387
|
-
// any fixed window, so a windowed check is bypassable.
|
|
388
|
-
if (text.includes('<!DOCTYPE') && text.includes('<!ENTITY'))
|
|
389
|
-
return null;
|
|
390
|
-
// Reject malformed XML up front, matching Python's `ET.fromstring` raising
|
|
391
|
-
// `ParseError` → the caller falls through to a lower-fidelity tier. Without
|
|
392
|
-
// this, a lenient scanner would happily extract coverage from a broken
|
|
393
|
-
// document, flipping both the fidelity tier AND the covered/uncovered verdict
|
|
394
|
-
// relative to the oracle.
|
|
395
|
-
if (!isWellFormedXml(text))
|
|
396
|
-
return null;
|
|
397
|
-
// Pin to the canonical (namespace-free) Cobertura root; anything else is a
|
|
398
|
-
// different XML format and is rejected rather than guessed at. Strip comments
|
|
399
|
-
// first so a `<foo>` inside a comment can't masquerade as the root element.
|
|
400
|
-
const withoutComments = text.replace(/<!--[\s\S]*?-->/g, '');
|
|
401
|
-
const rootMatch = /<(?![?!])([A-Za-z_][\w.:-]*)/.exec(withoutComments);
|
|
402
|
-
if (rootMatch === null || rootMatch[1] !== 'coverage')
|
|
403
|
-
return null;
|
|
404
|
-
const index = {};
|
|
405
|
-
// Walk each <class> open tag. A self-closing `<class .../>` is an empty class
|
|
406
|
-
// (its filename recorded, no lines) — critically, it must NOT be paired with
|
|
407
|
-
// the NEXT class's `</class>`, or that class's lines bind to the wrong file.
|
|
408
|
-
// Cobertura classes never nest, so for a real open tag the first following
|
|
409
|
-
// `</class>` is the correct close.
|
|
410
|
-
const CLOSE = '</class>';
|
|
411
|
-
const classOpenRe = /<class\b([^>]*?)(\/?)>/g;
|
|
412
|
-
for (let cls = classOpenRe.exec(withoutComments); cls !== null; cls = classOpenRe.exec(withoutComments)) {
|
|
413
|
-
const attrs = cls[1];
|
|
414
|
-
const selfClosing = cls[2] === '/';
|
|
415
|
-
let filename = attrValue(attrs, 'filename');
|
|
416
|
-
if (selfClosing) {
|
|
417
|
-
// Record the filename (mirrors ET's `setdefault`), but consume no body so
|
|
418
|
-
// the next class keeps its own lines.
|
|
419
|
-
if (filename)
|
|
420
|
-
index[filename.replace(/\\/g, '/')] ??= {};
|
|
421
|
-
continue;
|
|
422
|
-
}
|
|
423
|
-
const openEnd = classOpenRe.lastIndex;
|
|
424
|
-
const closeIdx = withoutComments.indexOf(CLOSE, openEnd);
|
|
425
|
-
if (closeIdx === -1)
|
|
426
|
-
break; // no close (well-formed XML guarantees one)
|
|
427
|
-
const body = withoutComments.slice(openEnd, closeIdx);
|
|
428
|
-
classOpenRe.lastIndex = closeIdx + CLOSE.length;
|
|
429
|
-
if (!filename)
|
|
430
|
-
continue;
|
|
431
|
-
// Normalize Windows separators so .NET/coverlet reports resolve against
|
|
432
|
-
// POSIX-style diff paths (the path matcher only recognizes "/").
|
|
433
|
-
filename = filename.replace(/\\/g, '/');
|
|
434
|
-
const hitsByLine = (index[filename] ??= {});
|
|
435
|
-
const lineRe = /<line\b([^>]*)>/g;
|
|
436
|
-
for (let ln = lineRe.exec(body); ln !== null; ln = lineRe.exec(body)) {
|
|
437
|
-
const lineAttrs = ln[1];
|
|
438
|
-
const num = attrValue(lineAttrs, 'number');
|
|
439
|
-
if (num === null)
|
|
440
|
-
continue;
|
|
441
|
-
const lineno = pyInt(num);
|
|
442
|
-
const hits = pyInt(attrValue(lineAttrs, 'hits') ?? '0');
|
|
443
|
-
if (lineno === null || hits === null)
|
|
444
|
-
continue;
|
|
445
|
-
// A line can appear at both method and class scope; keep the max.
|
|
446
|
-
hitsByLine[lineno] = Math.max(hitsByLine[lineno] ?? 0, hits);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
// Drop classes that yielded no parseable lines; require at least one.
|
|
450
|
-
const pruned = {};
|
|
451
|
-
for (const [path, hits] of Object.entries(index)) {
|
|
452
|
-
if (Object.keys(hits).length > 0)
|
|
453
|
-
pruned[path] = hits;
|
|
454
|
-
}
|
|
455
|
-
return Object.keys(pruned).length > 0 ? pruned : null;
|
|
456
|
-
}
|
|
457
|
-
const XML_WS = new Set([' ', '\t', '\n', '\r']);
|
|
458
|
-
/**
|
|
459
|
-
* Minimal, non-throwing XML well-formedness check — the analog of what
|
|
460
|
-
* `ET.fromstring` enforces before it will yield an element tree. Returns
|
|
461
|
-
* `false` (so the caller degrades to `null`) on: an unbalanced or mismatched
|
|
462
|
-
* tag, an unquoted attribute value, or a raw `&` that is not a valid entity
|
|
463
|
-
* reference. Skips comments, CDATA, processing instructions, and DOCTYPE
|
|
464
|
-
* declarations (entity-bearing DOCTYPEs are already rejected upstream). O(n)
|
|
465
|
-
* over the size-capped input, allocation-free via sticky regexes.
|
|
466
|
-
*/
|
|
467
|
-
function isWellFormedXml(text) {
|
|
468
|
-
const n = text.length;
|
|
469
|
-
const stack = [];
|
|
470
|
-
const nameRe = /[A-Za-z_][\w.:-]*/y;
|
|
471
|
-
const endTagRe = /([A-Za-z_][\w.:-]*)[ \t\n\r]*>/y;
|
|
472
|
-
const attrNameEqRe = /[A-Za-z_][\w.:-]*[ \t\n\r]*=[ \t\n\r]*/y;
|
|
473
|
-
const entityRe = /(?:#[0-9]+|#x[0-9A-Fa-f]+|[A-Za-z_][\w.-]*);/y;
|
|
474
|
-
let i = 0;
|
|
475
|
-
while (i < n) {
|
|
476
|
-
const ch = text[i];
|
|
477
|
-
if (ch === '<') {
|
|
478
|
-
if (text.startsWith('<!--', i)) {
|
|
479
|
-
const end = text.indexOf('-->', i + 4);
|
|
480
|
-
if (end === -1)
|
|
481
|
-
return false;
|
|
482
|
-
i = end + 3;
|
|
483
|
-
}
|
|
484
|
-
else if (text.startsWith('<![CDATA[', i)) {
|
|
485
|
-
const end = text.indexOf(']]>', i + 9);
|
|
486
|
-
if (end === -1)
|
|
487
|
-
return false;
|
|
488
|
-
i = end + 3;
|
|
489
|
-
}
|
|
490
|
-
else if (text.startsWith('<?', i)) {
|
|
491
|
-
const end = text.indexOf('?>', i + 2);
|
|
492
|
-
if (end === -1)
|
|
493
|
-
return false;
|
|
494
|
-
i = end + 2;
|
|
495
|
-
}
|
|
496
|
-
else if (text.startsWith('<!', i)) {
|
|
497
|
-
// DOCTYPE / declaration; skip to the matching top-level '>', honoring an
|
|
498
|
-
// internal-subset '[ ... ]' whose contents may contain '>'.
|
|
499
|
-
i += 2;
|
|
500
|
-
let depth = 0;
|
|
501
|
-
let closed = false;
|
|
502
|
-
while (i < n) {
|
|
503
|
-
const c = text[i];
|
|
504
|
-
if (c === '[')
|
|
505
|
-
depth++;
|
|
506
|
-
else if (c === ']') {
|
|
507
|
-
if (depth > 0)
|
|
508
|
-
depth--;
|
|
509
|
-
}
|
|
510
|
-
else if (c === '>' && depth === 0) {
|
|
511
|
-
i++;
|
|
512
|
-
closed = true;
|
|
513
|
-
break;
|
|
514
|
-
}
|
|
515
|
-
i++;
|
|
516
|
-
}
|
|
517
|
-
if (!closed)
|
|
518
|
-
return false;
|
|
519
|
-
}
|
|
520
|
-
else if (text.startsWith('</', i)) {
|
|
521
|
-
endTagRe.lastIndex = i + 2;
|
|
522
|
-
const m = endTagRe.exec(text);
|
|
523
|
-
if (m === null)
|
|
524
|
-
return false;
|
|
525
|
-
if (stack.pop() !== m[1])
|
|
526
|
-
return false;
|
|
527
|
-
i = endTagRe.lastIndex;
|
|
528
|
-
}
|
|
529
|
-
else {
|
|
530
|
-
// Start tag or self-closing element.
|
|
531
|
-
nameRe.lastIndex = i + 1;
|
|
532
|
-
const nm = nameRe.exec(text);
|
|
533
|
-
if (nm === null)
|
|
534
|
-
return false;
|
|
535
|
-
const name = nm[0];
|
|
536
|
-
i = nameRe.lastIndex;
|
|
537
|
-
let closed = false;
|
|
538
|
-
while (i < n) {
|
|
539
|
-
while (i < n && XML_WS.has(text[i]))
|
|
540
|
-
i++;
|
|
541
|
-
if (i >= n)
|
|
542
|
-
return false;
|
|
543
|
-
if (text[i] === '>') {
|
|
544
|
-
stack.push(name);
|
|
545
|
-
i++;
|
|
546
|
-
closed = true;
|
|
547
|
-
break;
|
|
548
|
-
}
|
|
549
|
-
if (text[i] === '/' && text[i + 1] === '>') {
|
|
550
|
-
i += 2;
|
|
551
|
-
closed = true;
|
|
552
|
-
break;
|
|
553
|
-
}
|
|
554
|
-
attrNameEqRe.lastIndex = i;
|
|
555
|
-
const am = attrNameEqRe.exec(text);
|
|
556
|
-
if (am === null)
|
|
557
|
-
return false; // junk or a valueless attribute
|
|
558
|
-
i = attrNameEqRe.lastIndex;
|
|
559
|
-
const q = text[i];
|
|
560
|
-
if (q !== '"' && q !== "'")
|
|
561
|
-
return false; // unquoted attribute value
|
|
562
|
-
const close = text.indexOf(q, i + 1);
|
|
563
|
-
if (close === -1)
|
|
564
|
-
return false;
|
|
565
|
-
i = close + 1;
|
|
566
|
-
}
|
|
567
|
-
if (!closed)
|
|
568
|
-
return false;
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
else if (ch === '&') {
|
|
572
|
-
entityRe.lastIndex = i + 1;
|
|
573
|
-
if (entityRe.exec(text) === null)
|
|
574
|
-
return false; // raw '&' not an entity
|
|
575
|
-
i = entityRe.lastIndex;
|
|
576
|
-
}
|
|
577
|
-
else {
|
|
578
|
-
i++;
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
return stack.length === 0;
|
|
582
|
-
}
|
|
583
|
-
/** Extract an XML attribute value (double- or single-quoted) from a tag body. */
|
|
584
|
-
function attrValue(attrs, name) {
|
|
585
|
-
const re = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`);
|
|
586
|
-
const m = re.exec(attrs);
|
|
587
|
-
if (m === null)
|
|
588
|
-
return null;
|
|
589
|
-
return m[2] ?? m[3] ?? '';
|
|
590
|
-
}
|
|
591
|
-
// ---------------------------------------------------------------------------
|
|
592
|
-
// Tier 1 — explicit report
|
|
593
|
-
// ---------------------------------------------------------------------------
|
|
594
|
-
/** Read a report file as UTF-8, returning `null` on any read/decode failure. */
|
|
595
|
-
function readReportText(reportPath) {
|
|
596
|
-
try {
|
|
597
|
-
const buf = readFileSync(reportPath);
|
|
598
|
-
// Fatal decode: a non-UTF-8 report must fall through, never raise out of
|
|
599
|
-
// the guardian gate (mirrors Python's UnicodeDecodeError → None).
|
|
600
|
-
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
601
|
-
}
|
|
602
|
-
catch {
|
|
603
|
-
return null;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
/**
|
|
607
|
-
* Tier 1: resolve coverage from an explicit report (`COVERAGE_VERIFIED`).
|
|
608
|
-
*
|
|
609
|
-
* Supports `lcov.info` (`DA:<line>,<hits>`), the canary coverage-json shape,
|
|
610
|
-
* and Cobertura `coverage.xml` (line-level). Unrecognized/empty/unreadable →
|
|
611
|
-
* `null` (caller falls through to a lower fidelity tier — absence never
|
|
612
|
-
* blocks).
|
|
613
|
-
*/
|
|
614
|
-
export function resolveFromReport(units, reportPath) {
|
|
615
|
-
const { index, absence } = readReportIndex(reportPath);
|
|
616
|
-
if (index === null)
|
|
617
|
-
return null;
|
|
618
|
-
return matchUnitsToIndex(units, index, absence);
|
|
619
|
-
}
|
|
620
|
-
/** Read + parse a coverage report, reporting each step's outcome separately. */
|
|
621
|
-
function readReportIndex(reportPath) {
|
|
622
|
-
const unusable = (found) => ({
|
|
623
|
-
found,
|
|
624
|
-
index: null,
|
|
625
|
-
absence: 'not-coverable',
|
|
626
|
-
});
|
|
627
|
-
if (!existsSync(reportPath))
|
|
628
|
-
return unusable(false);
|
|
629
|
-
const text = readReportText(reportPath);
|
|
630
|
-
// Present but unreadable/non-UTF-8 counts as found-and-unusable, not absent.
|
|
631
|
-
if (text === null)
|
|
632
|
-
return unusable(true);
|
|
633
|
-
const name = basename(reportPath).toLowerCase();
|
|
634
|
-
let index;
|
|
635
|
-
let absence;
|
|
636
|
-
if (name.endsWith('.json')) {
|
|
637
|
-
let parsed;
|
|
638
|
-
try {
|
|
639
|
-
parsed = JSON.parse(text);
|
|
640
|
-
}
|
|
641
|
-
catch {
|
|
642
|
-
return unusable(true);
|
|
643
|
-
}
|
|
644
|
-
index = parseCoverageJson(parsed);
|
|
645
|
-
absence = 'uncovered';
|
|
646
|
-
}
|
|
647
|
-
else if (name.endsWith('.info') || name.includes('lcov')) {
|
|
648
|
-
index = parseLcov(text);
|
|
649
|
-
absence = 'not-coverable';
|
|
650
|
-
}
|
|
651
|
-
else if (name.endsWith('.xml')) {
|
|
652
|
-
index = parseCobertura(text);
|
|
653
|
-
absence = 'not-coverable';
|
|
654
|
-
}
|
|
655
|
-
else {
|
|
656
|
-
// Unrecognized format → fall through to a lower fidelity tier.
|
|
657
|
-
return unusable(true);
|
|
658
|
-
}
|
|
659
|
-
if (index === null || Object.keys(index).length === 0)
|
|
660
|
-
return unusable(true);
|
|
661
|
-
return { found: true, index, absence };
|
|
662
|
-
}
|
|
663
|
-
/** Resolve every unit the report index can speak to (COVERAGE_VERIFIED). */
|
|
664
|
-
function matchUnitsToIndex(units, index, absence) {
|
|
665
|
-
const results = [];
|
|
666
|
-
for (const unit of units) {
|
|
667
|
-
const hits = matchHits(unit.path, index);
|
|
668
|
-
if (hits === null) {
|
|
669
|
-
// Unit path is nowhere in the report index → "not instrumented", which is
|
|
670
|
-
// NOT the same as "instrumented and unhit". Emit no COVERAGE_VERIFIED
|
|
671
|
-
// result so the orchestrator falls through to a lower-fidelity tier for
|
|
672
|
-
// this unit (FIX 2).
|
|
673
|
-
continue;
|
|
674
|
-
}
|
|
675
|
-
const added = expandRanges(unit.added_ranges);
|
|
676
|
-
// The per-line form of the check above (#655): under a format that
|
|
677
|
-
// enumerates instrumented lines, a changed line with no record could not
|
|
678
|
-
// have been executed and is scored by neither side.
|
|
679
|
-
const coverable = absence === 'not-coverable' ? added.filter((ln) => ln in hits) : added;
|
|
680
|
-
if (coverable.length === 0) {
|
|
681
|
-
// Every changed line is non-coverable, so this report has nothing to say
|
|
682
|
-
// about the unit. An abstention — never a clean pass, never a finding.
|
|
683
|
-
// Falls through to the graph/heuristic tier exactly as an absent path does.
|
|
684
|
-
continue;
|
|
685
|
-
}
|
|
686
|
-
const uncovered = coverable.filter((ln) => (hits[ln] ?? 0) <= 0);
|
|
687
|
-
const covered = uncovered.length === 0;
|
|
688
|
-
// State the denominator: "all covered" over 20 changed lines and over the 3
|
|
689
|
-
// of them that were coverable are very different claims (#508).
|
|
690
|
-
const evidence = covered
|
|
691
|
-
? `lines ${rangesStr(unit.added_ranges)}: all ${coverable.length} coverable line(s) covered`
|
|
692
|
-
: `lines ${rangesStr(unit.added_ranges)}: ${uncovered.length} of ${coverable.length} coverable line(s) uncovered`;
|
|
693
|
-
results.push(makeResult({
|
|
694
|
-
unit,
|
|
695
|
-
covered,
|
|
696
|
-
fidelity: Fidelity.CoverageVerified,
|
|
697
|
-
evidence,
|
|
698
|
-
uncovered_lines: uncovered,
|
|
699
|
-
coverable_lines: coverable.length,
|
|
700
|
-
}));
|
|
701
|
-
}
|
|
702
|
-
return results;
|
|
703
|
-
}
|
|
704
|
-
// ---------------------------------------------------------------------------
|
|
705
|
-
// Tier 2 — knowledge graph
|
|
706
|
-
// ---------------------------------------------------------------------------
|
|
707
|
-
// Edge types that indicate a test exercises a source unit. The live graph
|
|
708
|
-
// carries no explicit `tests`/`covers` edge, so coverage is *derived* from
|
|
709
|
-
// calls/imports reach.
|
|
710
|
-
const REACH_EDGE_TYPES = new Set(['calls', 'imports']);
|
|
711
|
-
const TEST_PATH_RE = /(^|\/)tests?\/|(^|\/)test_[^/]*\.py$|\.test\.[^/]+$|\.spec\.[^/]+$/;
|
|
712
|
-
/**
|
|
713
|
-
* True if `path` looks like a test file (`tests/**`, `test_*.py`, `*.test.*`,
|
|
714
|
-
* `*.spec.*`).
|
|
715
|
-
*/
|
|
716
|
-
export function isTestPath(path) {
|
|
717
|
-
return TEST_PATH_RE.test(path);
|
|
718
|
-
}
|
|
719
|
-
/**
|
|
720
|
-
* Split a basename's stem into `-`/`_`/`.`-separated components (#565).
|
|
721
|
-
*
|
|
722
|
-
* `playwright-fixture.ts` → `['playwright', 'fixture']`;
|
|
723
|
-
* `user.fixtures.ts` → `['user', 'fixtures']`. The final extension is dropped
|
|
724
|
-
* first so it never appears as a component.
|
|
725
|
-
*/
|
|
726
|
-
function basenameComponents(path) {
|
|
727
|
-
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
728
|
-
const dot = base.lastIndexOf('.');
|
|
729
|
-
const stem = dot > 0 ? base.slice(0, dot) : base;
|
|
730
|
-
return stem.split(/[-_.]/).filter((part) => part.length > 0);
|
|
731
|
-
}
|
|
732
|
-
/** Basename components that mark a file as test *support* rather than source. */
|
|
733
|
-
const FIXTURE_COMPONENTS = new Set([
|
|
734
|
-
'fixture',
|
|
735
|
-
'fixtures',
|
|
736
|
-
]);
|
|
737
|
-
/**
|
|
738
|
-
* True if `path` is test *infrastructure* identified by filename idiom (#565).
|
|
739
|
-
*
|
|
740
|
-
* {@link isTestPath} recognises tests by directory (`tests/**`) or by the
|
|
741
|
-
* `*.test.*` / `*.spec.*` / `test_*.py` naming rules, and the skip layer
|
|
742
|
-
* recognises fixtures by the `fixtures/` *directory* convention. Neither
|
|
743
|
-
* catches a file that is test support by **name** while sitting in an ordinary
|
|
744
|
-
* source directory — the measured cases being a pytest `conftest_otel.py` and a
|
|
745
|
-
* Playwright `playwright-fixture.ts`, both under `scripts/otel_bootstrap/`.
|
|
746
|
-
*
|
|
747
|
-
* Such a file cannot host a test in the sense a coverage finding means: it *is*
|
|
748
|
-
* the harness the tests run inside. Asking it for a covering test inverts the
|
|
749
|
-
* relationship, so a reviewer's only correct response is 👎 — the precision
|
|
750
|
-
* cost #413 and #562 both describe.
|
|
751
|
-
*
|
|
752
|
-
* Two deliberate narrowings:
|
|
753
|
-
*
|
|
754
|
-
* - **Components, not substrings.** `conftestimonial.py` and
|
|
755
|
-
* `prefixtures.ts` are ordinary source and must survive. Matching on
|
|
756
|
-
* `-`/`_`/`.`-separated components is what pytest's own name-based
|
|
757
|
-
* resolution and the `*.fixtures.ts` idiom actually mean.
|
|
758
|
-
* - **`conftest` is Python-only.** It is pytest's resolution rule
|
|
759
|
-
* specifically; a `conftest.js` carries no framework meaning and is left as
|
|
760
|
-
* source.
|
|
761
|
-
*
|
|
762
|
-
* Known over-match, accepted: a production module genuinely named
|
|
763
|
-
* `fixture-generator.ts` is suppressed. That trade is deliberate — a missed
|
|
764
|
-
* finding on a file named after fixtures costs far less than a finding no
|
|
765
|
-
* reviewer can ever act on, which is what drags `precision = TP / (TP + FP)`
|
|
766
|
-
* below the promotion bar.
|
|
767
|
-
*
|
|
768
|
-
* Kept separate from {@link isTestPath} on purpose: that predicate also decides
|
|
769
|
-
* what *confers* graph coverage, so widening it would let a conftest mark every
|
|
770
|
-
* module it imports as tested — a false negative in place of a false positive.
|
|
771
|
-
*/
|
|
772
|
-
export function isTestSupportPath(path) {
|
|
773
|
-
const components = basenameComponents(path);
|
|
774
|
-
if (path.endsWith('.py') && components.includes('conftest'))
|
|
775
|
-
return true;
|
|
776
|
-
return components.some((part) => FIXTURE_COMPONENTS.has(part));
|
|
777
|
-
}
|
|
778
|
-
/**
|
|
779
|
-
* Filenames/paths that plausibly hold nothing but type declarations (#562).
|
|
780
|
-
*
|
|
781
|
-
* A NAME GATE ONLY -- it decides which files are worth reading, never which
|
|
782
|
-
* are suppressed. {@link isTypeOnlyModule} always confirms against content,
|
|
783
|
-
* because a `types.ts` that also exports an enum or a const map is ordinary
|
|
784
|
-
* TypeScript and its findings are real.
|
|
785
|
-
*/
|
|
786
|
-
function isTypeModuleCandidate(path) {
|
|
787
|
-
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
788
|
-
if (base.endsWith('.d.ts'))
|
|
789
|
-
return true;
|
|
790
|
-
if (!/\.(ts|tsx|mts|cts)$/.test(base))
|
|
791
|
-
return false;
|
|
792
|
-
if (base === 'types.ts' || base.endsWith('.types.ts'))
|
|
793
|
-
return true;
|
|
794
|
-
return path.split('/').slice(0, -1).includes('types');
|
|
795
|
-
}
|
|
796
|
-
/**
|
|
797
|
-
* True if `path` is a module with no runtime content at all (#562).
|
|
798
|
-
*
|
|
799
|
-
* The false-positive class this closes is the one the heuristic-tier fix
|
|
800
|
-
* (#413) structurally cannot reach. {@link filterHeuristicNoise} is gated on
|
|
801
|
-
* `fidelity === HEURISTIC` on purpose -- a coverage-verified verdict rests on
|
|
802
|
-
* a real lcov row, so suppressing by path at that tier would discard
|
|
803
|
-
* evidence. A type-only module is the case that breaks the symmetry: the lcov
|
|
804
|
-
* row is accurate (39 lines, genuinely never executed) and the finding is
|
|
805
|
-
* still unsatisfiable, because an interface has no runtime existence for a
|
|
806
|
-
* test to reach. The evidence needed is therefore about the FILE, not the
|
|
807
|
-
* tier: prove there is nothing executable in it.
|
|
808
|
-
*
|
|
809
|
-
* Conservative in one direction on purpose. Every uncertainty -- an
|
|
810
|
-
* unreadable file, an unrecognised construct -- resolves to `false`, keeping
|
|
811
|
-
* the finding. A missed suppression costs one noisy finding; a wrong
|
|
812
|
-
* suppression hides untested code, which is the thing the guardian exists to
|
|
813
|
-
* catch.
|
|
814
|
-
*/
|
|
815
|
-
export function isTypeOnlyModule(path, repoRoot) {
|
|
816
|
-
if (!isTypeModuleCandidate(path))
|
|
817
|
-
return false;
|
|
818
|
-
let source;
|
|
819
|
-
try {
|
|
820
|
-
source = readFileSync(join(repoRoot, path), 'utf-8');
|
|
821
|
-
}
|
|
822
|
-
catch {
|
|
823
|
-
return false; // unreadable -> unproven -> keep the finding
|
|
824
|
-
}
|
|
825
|
-
return isTypeOnlySource(source);
|
|
826
|
-
}
|
|
827
|
-
/** Drop `//` and `/* *\/` comments so keywords inside prose never count. */
|
|
828
|
-
function stripComments(source) {
|
|
829
|
-
const out = [];
|
|
830
|
-
let inBlock = false;
|
|
831
|
-
for (const line of splitLines(source)) {
|
|
832
|
-
let text = line;
|
|
833
|
-
if (inBlock) {
|
|
834
|
-
const end = text.indexOf('*/');
|
|
835
|
-
if (end === -1) {
|
|
836
|
-
out.push('');
|
|
837
|
-
continue;
|
|
838
|
-
}
|
|
839
|
-
text = text.slice(end + 2);
|
|
840
|
-
inBlock = false;
|
|
841
|
-
}
|
|
842
|
-
for (;;) {
|
|
843
|
-
const start = text.indexOf('/*');
|
|
844
|
-
if (start === -1)
|
|
845
|
-
break;
|
|
846
|
-
const end = text.indexOf('*/', start + 2);
|
|
847
|
-
if (end === -1) {
|
|
848
|
-
text = text.slice(0, start);
|
|
849
|
-
inBlock = true;
|
|
850
|
-
break;
|
|
851
|
-
}
|
|
852
|
-
text = text.slice(0, start) + text.slice(end + 2);
|
|
853
|
-
}
|
|
854
|
-
const line2 = text.indexOf('//');
|
|
855
|
-
out.push(line2 === -1 ? text : text.slice(0, line2));
|
|
856
|
-
}
|
|
857
|
-
return out.join('\n');
|
|
858
|
-
}
|
|
859
|
-
/**
|
|
860
|
-
* Top-level constructs TypeScript erases entirely at compile time (#562).
|
|
861
|
-
*
|
|
862
|
-
* An ALLOWLIST, not a denylist, and that is the load-bearing choice. A
|
|
863
|
-
* denylist of runtime keywords misses everything it did not enumerate -- a
|
|
864
|
-
* bare `register('widget')` declares nothing and still runs -- and every gap
|
|
865
|
-
* in it suppresses a real finding. An allowlist fails the other way: an
|
|
866
|
-
* unrecognised construct reads as runtime and the finding survives.
|
|
867
|
-
*
|
|
868
|
-
* Plain `import { X } from '...'` is allowed because TypeScript elides an
|
|
869
|
-
* import whose bindings are only used in type positions; if a binding were
|
|
870
|
-
* used as a value, the using statement itself would appear at top level and
|
|
871
|
-
* be rejected. A side-effect `import './x'` carries no binding, is never
|
|
872
|
-
* elided, and is therefore not matched.
|
|
873
|
-
*/
|
|
874
|
-
function isErasableTopLevelLine(line) {
|
|
875
|
-
if (line === '')
|
|
876
|
-
return true;
|
|
877
|
-
if (/^[})\];,]+$/.test(line))
|
|
878
|
-
return true;
|
|
879
|
-
if (/^import\s+type\b/.test(line))
|
|
880
|
-
return true;
|
|
881
|
-
if (/^import\b.*\sfrom\s/.test(line))
|
|
882
|
-
return true;
|
|
883
|
-
if (/^export\s+type\b/.test(line))
|
|
884
|
-
return true;
|
|
885
|
-
const bare = line.replace(/^(?:export\s+default\s+|export\s+|declare\s+)+/, '');
|
|
886
|
-
return /^(?:interface|type)\s/.test(bare);
|
|
887
|
-
}
|
|
888
|
-
/**
|
|
889
|
-
* True if every TOP-LEVEL statement in `source` is compile-time-only.
|
|
890
|
-
*
|
|
891
|
-
* Brace depth is tracked so an interface body is never mistaken for
|
|
892
|
-
* statements: only depth-0 lines are judged. An unbalanced file (depth does
|
|
893
|
-
* not return to zero) is treated as unproven rather than type-only -- brace
|
|
894
|
-
* counting is lexical, so a `{` inside a string literal could otherwise hide
|
|
895
|
-
* the rest of the file from inspection.
|
|
896
|
-
*/
|
|
897
|
-
function isTypeOnlySource(source) {
|
|
898
|
-
let depth = 0;
|
|
899
|
-
for (const raw of splitLines(stripComments(source))) {
|
|
900
|
-
const line = raw.trim();
|
|
901
|
-
if (depth === 0 && !isErasableTopLevelLine(line))
|
|
902
|
-
return false;
|
|
903
|
-
depth += bracketDelta(line);
|
|
904
|
-
if (depth < 0)
|
|
905
|
-
return false;
|
|
906
|
-
}
|
|
907
|
-
return depth === 0;
|
|
908
|
-
}
|
|
909
|
-
/** Net nesting change across one line: openers minus closers. */
|
|
910
|
-
function bracketDelta(line) {
|
|
911
|
-
let delta = 0;
|
|
912
|
-
for (const ch of line) {
|
|
913
|
-
if (ch === '{' || ch === '(' || ch === '[')
|
|
914
|
-
delta += 1;
|
|
915
|
-
else if (ch === '}' || ch === ')' || ch === ']')
|
|
916
|
-
delta -= 1;
|
|
917
|
-
}
|
|
918
|
-
return delta;
|
|
919
|
-
}
|
|
920
|
-
/**
|
|
921
|
-
* Extensions that denote hand-authored, executable program source (#413).
|
|
922
|
-
*
|
|
923
|
-
* The membership rule is deliberately simple and defensible: **a programming
|
|
924
|
-
* language belongs; data, config, markup, and style do not.** `.sh` is in (it is
|
|
925
|
-
* executable logic — bats/shunit2 exist); `.json`, `.yaml`, `.sql`, `.css`, and
|
|
926
|
-
* `.html` are out (nothing a naming heuristic could meaningfully judge).
|
|
927
|
-
*
|
|
928
|
-
* A repo that disagrees at the margins tunes the glob layer
|
|
929
|
-
* (`canary.guardian.pr.heuristicExclude`) rather than this list.
|
|
930
|
-
*/
|
|
931
|
-
const SOURCE_EXTENSIONS = new Set([
|
|
932
|
-
// TS/JS + component dialects.
|
|
933
|
-
'.ts',
|
|
934
|
-
'.tsx',
|
|
935
|
-
'.mts',
|
|
936
|
-
'.cts',
|
|
937
|
-
'.js',
|
|
938
|
-
'.jsx',
|
|
939
|
-
'.mjs',
|
|
940
|
-
'.cjs',
|
|
941
|
-
'.vue',
|
|
942
|
-
'.svelte',
|
|
943
|
-
'.astro',
|
|
944
|
-
// Python / Ruby / PHP / Perl / Lua.
|
|
945
|
-
'.py',
|
|
946
|
-
'.pyi',
|
|
947
|
-
'.rb',
|
|
948
|
-
'.php',
|
|
949
|
-
'.pl',
|
|
950
|
-
'.pm',
|
|
951
|
-
'.lua',
|
|
952
|
-
// JVM + .NET.
|
|
953
|
-
'.java',
|
|
954
|
-
'.kt',
|
|
955
|
-
'.kts',
|
|
956
|
-
'.scala',
|
|
957
|
-
'.groovy',
|
|
958
|
-
'.clj',
|
|
959
|
-
'.cljs',
|
|
960
|
-
'.cs',
|
|
961
|
-
'.fs',
|
|
962
|
-
'.vb',
|
|
963
|
-
// Systems.
|
|
964
|
-
'.go',
|
|
965
|
-
'.rs',
|
|
966
|
-
'.c',
|
|
967
|
-
'.h',
|
|
968
|
-
'.cc',
|
|
969
|
-
'.cpp',
|
|
970
|
-
'.cxx',
|
|
971
|
-
'.hpp',
|
|
972
|
-
'.hh',
|
|
973
|
-
'.m',
|
|
974
|
-
'.mm',
|
|
975
|
-
'.swift',
|
|
976
|
-
// Functional / scientific / other.
|
|
977
|
-
'.ex',
|
|
978
|
-
'.exs',
|
|
979
|
-
'.erl',
|
|
980
|
-
'.dart',
|
|
981
|
-
'.r',
|
|
982
|
-
'.jl',
|
|
983
|
-
// Shell.
|
|
984
|
-
'.sh',
|
|
985
|
-
'.bash',
|
|
986
|
-
'.zsh',
|
|
987
|
-
'.ps1',
|
|
988
|
-
'.psm1',
|
|
989
|
-
]);
|
|
990
|
-
/**
|
|
991
|
-
* True if `path` looks like hand-authored program source (#413).
|
|
992
|
-
*
|
|
993
|
-
* Used to gate the Tier-3 naming heuristic. That heuristic asks "does any test
|
|
994
|
-
* file reference this file's stem or a top-level symbol?" — for a config
|
|
995
|
-
* dotfile, a lockfile, or a data blob there are no symbols and no test will
|
|
996
|
-
* ever name it, so the verdict is structurally always "uncovered": a guaranteed
|
|
997
|
-
* false positive rather than a signal. An extension-less file (`Makefile`,
|
|
998
|
-
* `Dockerfile`) and a bare dotfile (`.eslintrc`) are both non-source.
|
|
999
|
-
*/
|
|
1000
|
-
export function isSourcePath(path) {
|
|
1001
|
-
const base = basename(path);
|
|
1002
|
-
// `.eslintrc` — `extname` calls this '' already, but a dotfile WITH a real
|
|
1003
|
-
// extension (`.eslintrc.json`) must be judged on that extension, which the
|
|
1004
|
-
// normal path handles.
|
|
1005
|
-
const ext = extname(base).toLowerCase();
|
|
1006
|
-
if (!ext)
|
|
1007
|
-
return false;
|
|
1008
|
-
return SOURCE_EXTENSIONS.has(ext);
|
|
1009
|
-
}
|
|
1010
|
-
/**
|
|
1011
|
-
* Tier 2: derive coverage from the harness knowledge graph (`GRAPH_VERIFIED`).
|
|
1012
|
-
*
|
|
1013
|
-
* The graph has no explicit `tests`/`covers` edge, so coverage is **derived**:
|
|
1014
|
-
* a changed file is graph-covered iff some **test-path node** reaches the
|
|
1015
|
-
* file's node (or a symbol node it `contains`) via a `calls`/`imports` edge.
|
|
1016
|
-
* Conservative by design (edge present → covered).
|
|
1017
29
|
*
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
export
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
// an UNCAUGHT `UnicodeDecodeError` on a non-UTF-8 graph (the Python only
|
|
1034
|
-
// catches `OSError`) — a latent crash that violates the guardian's "absence
|
|
1035
|
-
// never blocks" contract. Node's `readFileSync(path, 'utf-8')` substitutes
|
|
1036
|
-
// U+FFFD instead of throwing; a replacement char inside a line just fails
|
|
1037
|
-
// that line's `JSON.parse` and is skipped. We intentionally KEEP the safe
|
|
1038
|
-
// degrade rather than reproduce the crash.
|
|
1039
|
-
text = readFileSync(graphPath, 'utf-8');
|
|
1040
|
-
}
|
|
1041
|
-
catch {
|
|
1042
|
-
return null;
|
|
1043
|
-
}
|
|
1044
|
-
if (text.trim() === '')
|
|
1045
|
-
return null;
|
|
1046
|
-
const idToPath = new Map();
|
|
1047
|
-
const containsFwd = new Map();
|
|
1048
|
-
const reachRev = new Map(); // to -> [from] over calls/imports
|
|
1049
|
-
for (const raw of splitLines(text)) {
|
|
1050
|
-
const line = raw.trim();
|
|
1051
|
-
if (!line)
|
|
1052
|
-
continue;
|
|
1053
|
-
let record;
|
|
1054
|
-
try {
|
|
1055
|
-
record = JSON.parse(line);
|
|
1056
|
-
}
|
|
1057
|
-
catch {
|
|
1058
|
-
continue;
|
|
1059
|
-
}
|
|
1060
|
-
if (!isRecord(record)) {
|
|
1061
|
-
// Valid JSON but not an object (e.g. `null`, `5`, `[1,2]`, `"x"`) → not a
|
|
1062
|
-
// node/edge record; skip it rather than crash on `.get` (FIX 3).
|
|
1063
|
-
continue;
|
|
1064
|
-
}
|
|
1065
|
-
const kind = record['kind'];
|
|
1066
|
-
if (kind === 'node') {
|
|
1067
|
-
const nodeId = record['id'];
|
|
1068
|
-
if (nodeId !== undefined && nodeId !== null) {
|
|
1069
|
-
const p = record['path'];
|
|
1070
|
-
idToPath.set(String(nodeId), typeof p === 'string' ? p : '');
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
else if (kind === 'edge') {
|
|
1074
|
-
const etype = record['type'];
|
|
1075
|
-
const src = record['from'];
|
|
1076
|
-
const dst = record['to'];
|
|
1077
|
-
if (src === undefined ||
|
|
1078
|
-
src === null ||
|
|
1079
|
-
dst === undefined ||
|
|
1080
|
-
dst === null) {
|
|
1081
|
-
continue;
|
|
1082
|
-
}
|
|
1083
|
-
const from = String(src);
|
|
1084
|
-
const to = String(dst);
|
|
1085
|
-
if (etype === 'contains') {
|
|
1086
|
-
push(containsFwd, from, to);
|
|
1087
|
-
}
|
|
1088
|
-
else if (typeof etype === 'string' && REACH_EDGE_TYPES.has(etype)) {
|
|
1089
|
-
push(reachRev, to, from);
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
if (idToPath.size === 0)
|
|
1094
|
-
return null;
|
|
1095
|
-
// Index file/symbol node ids by path (exact + suffix match support).
|
|
1096
|
-
const pathToIds = new Map();
|
|
1097
|
-
for (const [nodeId, nodePath] of idToPath) {
|
|
1098
|
-
if (nodePath)
|
|
1099
|
-
push(pathToIds, nodePath, nodeId);
|
|
1100
|
-
}
|
|
1101
|
-
const idsForPath = (path) => {
|
|
1102
|
-
const exact = pathToIds.get(path);
|
|
1103
|
-
if (exact !== undefined)
|
|
1104
|
-
return exact;
|
|
1105
|
-
// Boundary suffix match only; on a unique matched path use its ids. On
|
|
1106
|
-
// multiple distinct matched paths (duplicate basenames) treat as ambiguous
|
|
1107
|
-
// and return no ids — do NOT union unrelated nodes (FIX 6).
|
|
1108
|
-
const matchedPaths = [];
|
|
1109
|
-
for (const nodePath of pathToIds.keys()) {
|
|
1110
|
-
if (pathBoundaryMatch(nodePath, path))
|
|
1111
|
-
matchedPaths.push(nodePath);
|
|
1112
|
-
}
|
|
1113
|
-
return matchedPaths.length === 1 ? pathToIds.get(matchedPaths[0]) : [];
|
|
1114
|
-
};
|
|
1115
|
-
const results = [];
|
|
1116
|
-
for (const unit of units) {
|
|
1117
|
-
// Target set: the file node(s) for this unit + all symbols they contain.
|
|
1118
|
-
const seedIds = idsForPath(unit.path);
|
|
1119
|
-
if (seedIds.length === 0) {
|
|
1120
|
-
// Unit has no node in the graph → no graph signal. Emit nothing so the
|
|
1121
|
-
// orchestrator falls through to the heuristic tier (FIX 2).
|
|
1122
|
-
continue;
|
|
1123
|
-
}
|
|
1124
|
-
const targets = new Set(seedIds);
|
|
1125
|
-
const frontier = [...targets];
|
|
1126
|
-
while (frontier.length > 0) {
|
|
1127
|
-
const node = frontier.pop();
|
|
1128
|
-
for (const child of containsFwd.get(node) ?? []) {
|
|
1129
|
-
if (!targets.has(child)) {
|
|
1130
|
-
targets.add(child);
|
|
1131
|
-
frontier.push(child);
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
// Reverse-BFS over calls/imports; a reached test-path node → covered.
|
|
1136
|
-
// FIX 1 (#320): a genuine FIFO BFS so first-discovery depth is the minimum;
|
|
1137
|
-
// a LIFO stack could stamp an intermediate node at a non-minimal depth and
|
|
1138
|
-
// prune it before a shorter path arrives, under-crediting coverage at
|
|
1139
|
-
// maxDepth >= 3.
|
|
1140
|
-
let coveringTest = null;
|
|
1141
|
-
const seen = new Set(targets);
|
|
1142
|
-
const queue = [...targets].map((t) => [t, 0]);
|
|
1143
|
-
let head = 0;
|
|
1144
|
-
while (head < queue.length && coveringTest === null) {
|
|
1145
|
-
const [node, depth] = queue[head++];
|
|
1146
|
-
if (maxDepth !== null && depth >= maxDepth) {
|
|
1147
|
-
continue; // cannot expand deeper — predecessors would exceed bound
|
|
1148
|
-
}
|
|
1149
|
-
for (const source of reachRev.get(node) ?? []) {
|
|
1150
|
-
if (seen.has(source))
|
|
1151
|
-
continue;
|
|
1152
|
-
seen.add(source);
|
|
1153
|
-
const sourcePath = idToPath.get(source) ?? '';
|
|
1154
|
-
if (sourcePath && isTestPath(sourcePath) && !targets.has(source)) {
|
|
1155
|
-
coveringTest = sourcePath; // test reached within maxDepth
|
|
1156
|
-
break;
|
|
1157
|
-
}
|
|
1158
|
-
queue.push([source, depth + 1]);
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
const covered = coveringTest !== null;
|
|
1162
|
-
const evidence = covered
|
|
1163
|
-
? `reached by test ${coveringTest}`
|
|
1164
|
-
: `no test node reaches ${unit.path} via calls/imports`;
|
|
1165
|
-
results.push(makeResult({
|
|
1166
|
-
unit,
|
|
1167
|
-
covered,
|
|
1168
|
-
fidelity: Fidelity.GraphVerified,
|
|
1169
|
-
evidence,
|
|
1170
|
-
}));
|
|
1171
|
-
}
|
|
1172
|
-
return results;
|
|
1173
|
-
}
|
|
1174
|
-
function push(map, key, value) {
|
|
1175
|
-
const existing = map.get(key);
|
|
1176
|
-
if (existing === undefined)
|
|
1177
|
-
map.set(key, [value]);
|
|
1178
|
-
else
|
|
1179
|
-
existing.push(value);
|
|
1180
|
-
}
|
|
1181
|
-
// ---------------------------------------------------------------------------
|
|
1182
|
-
// Tier 3 — naming/AST heuristic
|
|
1183
|
-
// ---------------------------------------------------------------------------
|
|
1184
|
-
/**
|
|
1185
|
-
* Derive candidate symbol names for a unit: the file stem plus top-level
|
|
1186
|
-
* `def`/`class` names. Python uses `ast` for `.py`; here a column-0 line scan
|
|
1187
|
-
* approximates top-level extraction (indented, nested defs are excluded, just
|
|
1188
|
-
* as `ast.parse(...).body` would exclude them). A cheap regex covers other
|
|
1189
|
-
* languages.
|
|
1190
|
-
*
|
|
1191
|
-
* KNOWN HEURISTIC-TIER LIMITATION: unlike a real AST, this lexical scan can pick
|
|
1192
|
-
* up a phantom `def ghost()`/`class Phantom` sitting at column 0 inside a
|
|
1193
|
-
* triple-quoted string or in a syntactically-broken file, yielding a false
|
|
1194
|
-
* heuristic-covered verdict if a test happens to mention that name. Full `ast`
|
|
1195
|
-
* parity is not portable to Node; this is accepted as a lowest-fidelity-tier
|
|
1196
|
-
* (HEURISTIC) imprecision — the report and graph tiers, which outrank it, are
|
|
1197
|
-
* exact. (A cheap mitigation would be to blank string literals before the scan,
|
|
1198
|
-
* omitted here to avoid any risk of dropping a real top-level symbol.)
|
|
1199
|
-
*/
|
|
1200
|
-
function extractSymbols(unitPath, repoRoot) {
|
|
1201
|
-
const symbols = new Set([stem(unitPath)]);
|
|
1202
|
-
let source;
|
|
1203
|
-
try {
|
|
1204
|
-
source = readFileSync(join(repoRoot, unitPath), 'utf-8');
|
|
1205
|
-
}
|
|
1206
|
-
catch {
|
|
1207
|
-
return symbols;
|
|
1208
|
-
}
|
|
1209
|
-
if (unitPath.endsWith('.py')) {
|
|
1210
|
-
const topLevel = /^(?:async\s+def|def|class)\s+([A-Za-z_]\w*)/gm;
|
|
1211
|
-
for (let m = topLevel.exec(source); m !== null; m = topLevel.exec(source)) {
|
|
1212
|
-
symbols.add(m[1]);
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
else {
|
|
1216
|
-
const decl = /\b(?:function|class|def|const|let|var)\s+([A-Za-z_]\w*)/g;
|
|
1217
|
-
for (let m = decl.exec(source); m !== null; m = decl.exec(source)) {
|
|
1218
|
-
symbols.add(m[1]);
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
return symbols;
|
|
1222
|
-
}
|
|
1223
|
-
/** Recursively list every file under `root` (sorted for determinism). */
|
|
1224
|
-
function walkFiles(root) {
|
|
1225
|
-
const out = [];
|
|
1226
|
-
const walk = (dir) => {
|
|
1227
|
-
let entries;
|
|
1228
|
-
try {
|
|
1229
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
1230
|
-
}
|
|
1231
|
-
catch {
|
|
1232
|
-
return;
|
|
1233
|
-
}
|
|
1234
|
-
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1235
|
-
const full = join(dir, entry.name);
|
|
1236
|
-
if (entry.isDirectory())
|
|
1237
|
-
walk(full);
|
|
1238
|
-
else if (entry.isFile())
|
|
1239
|
-
out.push(full);
|
|
1240
|
-
}
|
|
1241
|
-
};
|
|
1242
|
-
walk(root);
|
|
1243
|
-
return out;
|
|
1244
|
-
}
|
|
1245
|
-
/** Relative POSIX path of `full` under `root`. */
|
|
1246
|
-
function relPosix(root, full) {
|
|
1247
|
-
const rel = full.slice(root.length).replace(/^[/\\]/, '');
|
|
1248
|
-
return rel.split(/[/\\]/).join(posix.sep);
|
|
1249
|
-
}
|
|
1250
|
-
/** Yield `[relPath, text]` for every test-looking file under `repoRoot`. */
|
|
1251
|
-
function iterTestFiles(repoRoot) {
|
|
1252
|
-
const all = walkFiles(repoRoot);
|
|
1253
|
-
const seen = new Set();
|
|
1254
|
-
const out = [];
|
|
1255
|
-
const emit = (full) => {
|
|
1256
|
-
if (seen.has(full))
|
|
1257
|
-
return;
|
|
1258
|
-
seen.add(full);
|
|
1259
|
-
let text;
|
|
1260
|
-
try {
|
|
1261
|
-
text = readFileSync(full, 'utf-8');
|
|
1262
|
-
}
|
|
1263
|
-
catch {
|
|
1264
|
-
return;
|
|
1265
|
-
}
|
|
1266
|
-
out.push([relPosix(repoRoot, full), text]);
|
|
1267
|
-
};
|
|
1268
|
-
const matchers = [
|
|
1269
|
-
(b) => /^test_.*\.py$/.test(b),
|
|
1270
|
-
(b) => b.includes('.test.'),
|
|
1271
|
-
(b) => b.includes('.spec.'),
|
|
1272
|
-
];
|
|
1273
|
-
for (const matches of matchers) {
|
|
1274
|
-
for (const full of all) {
|
|
1275
|
-
if (matches(basename(full)))
|
|
1276
|
-
emit(full);
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
// Also any file living under a tests/ directory (broader net).
|
|
1280
|
-
for (const full of all) {
|
|
1281
|
-
if (!full.endsWith('.py'))
|
|
1282
|
-
continue;
|
|
1283
|
-
if (isTestPath(relPosix(repoRoot, full)))
|
|
1284
|
-
emit(full);
|
|
1285
|
-
}
|
|
1286
|
-
return out;
|
|
1287
|
-
}
|
|
1288
|
-
/**
|
|
1289
|
-
* Tier 3: last-resort naming/AST heuristic (`HEURISTIC`, never `null`).
|
|
1290
|
-
*
|
|
1291
|
-
* A unit is heuristic-covered iff some test file under `repoRoot` references
|
|
1292
|
-
* the unit's file stem or a top-level symbol name (word-boundary scan).
|
|
1293
|
-
*/
|
|
1294
|
-
export function resolveHeuristic(units, repoRoot = '.') {
|
|
1295
|
-
const testFiles = iterTestFiles(repoRoot);
|
|
1296
|
-
const results = [];
|
|
1297
|
-
for (const unit of units) {
|
|
1298
|
-
const symbols = extractSymbols(unit.path, repoRoot);
|
|
1299
|
-
// Avoid pathological single-letter stems matching everything.
|
|
1300
|
-
const patterns = [];
|
|
1301
|
-
for (const sym of symbols) {
|
|
1302
|
-
if (sym.length >= 2)
|
|
1303
|
-
patterns.push(new RegExp(`\\b${escapeRe(sym)}\\b`));
|
|
1304
|
-
}
|
|
1305
|
-
let covering = null;
|
|
1306
|
-
for (const [rel, text] of testFiles) {
|
|
1307
|
-
// A file never counts as covering itself.
|
|
1308
|
-
if (rel === unit.path)
|
|
1309
|
-
continue;
|
|
1310
|
-
if (patterns.some((pat) => pat.test(text))) {
|
|
1311
|
-
covering = rel;
|
|
1312
|
-
break;
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
const covered = covering !== null;
|
|
1316
|
-
const evidence = covered
|
|
1317
|
-
? `referenced by ${covering}`
|
|
1318
|
-
: `no test file references ${stem(unit.path)}`;
|
|
1319
|
-
results.push(makeResult({
|
|
1320
|
-
unit,
|
|
1321
|
-
covered,
|
|
1322
|
-
fidelity: Fidelity.Heuristic,
|
|
1323
|
-
evidence,
|
|
1324
|
-
}));
|
|
1325
|
-
}
|
|
1326
|
-
return results;
|
|
1327
|
-
}
|
|
1328
|
-
/** Escape a string for literal use inside a RegExp (Python's `re.escape`). */
|
|
1329
|
-
function escapeRe(s) {
|
|
1330
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1331
|
-
}
|
|
1332
|
-
/**
|
|
1333
|
-
* SC-3 orchestrator: resolve each unit at the highest available fidelity.
|
|
1334
|
-
*
|
|
1335
|
-
* The ladder is applied **per unit**, not per batch. For each unit the first
|
|
1336
|
-
* tier that has a signal for *that* unit wins:
|
|
1337
|
-
*
|
|
1338
|
-
* 1. `coveragePath` lists the unit's path → `COVERAGE_VERIFIED`
|
|
1339
|
-
* 2. else a graph node for the unit exists → `GRAPH_VERIFIED`
|
|
1340
|
-
* 3. else the naming heuristic → `HEURISTIC` (always returns)
|
|
1341
|
-
*
|
|
1342
|
-
* A unit absent from the report is NOT judged COVERAGE_VERIFIED-uncovered; it
|
|
1343
|
-
* falls through to the graph then heuristic tier (FIX 2). Returns exactly one
|
|
1344
|
-
* {@link CoverageResult} per input unit, in input order, fidelity-labeled.
|
|
1345
|
-
*
|
|
1346
|
-
* `graphMaxDepth` bounds the graph tier's reverse-BFS hop distance (#320) and
|
|
1347
|
-
* is forwarded verbatim to {@link resolveFromGraph}.
|
|
1348
|
-
*/
|
|
1349
|
-
export function resolveCoverage(units, options = {}) {
|
|
1350
|
-
return resolveCoverageWithInput(units, options).results;
|
|
1351
|
-
}
|
|
1352
|
-
/** Classify a {@link CoverageInputState}. Zero matched is never `verified`. */
|
|
1353
|
-
export function coverageStatus(state) {
|
|
1354
|
-
if (state.unitsTotal > 0 && state.unitsMatched === state.unitsTotal) {
|
|
1355
|
-
return 'verified';
|
|
1356
|
-
}
|
|
1357
|
-
return state.unitsMatched > 0 ? 'partial' : 'unavailable';
|
|
1358
|
-
}
|
|
1359
|
-
const COVERAGE_EM_DASH = '\u{2014}';
|
|
1360
|
-
const FALLBACK_TIER = 'judged at graph/heuristic tier only';
|
|
1361
|
-
/**
|
|
1362
|
-
* The human-readable degradation notice for a coverage run, or `null` when the
|
|
1363
|
-
* report covered every changed unit (nothing was degraded, so nothing is said).
|
|
1364
|
-
*
|
|
1365
|
-
* A run that judged nothing (`unitsTotal === 0`) also returns `null` — it makes
|
|
1366
|
-
* no coverage claim in either direction, and the abstention path reports it.
|
|
1367
|
-
*/
|
|
1368
|
-
export function coverageDegradedNotice(state) {
|
|
1369
|
-
const { requested, found, parsed, filesInReport } = state;
|
|
1370
|
-
const { unitsMatched: matched, unitsTotal: total } = state;
|
|
1371
|
-
if (total === 0)
|
|
1372
|
-
return null;
|
|
1373
|
-
const status = coverageStatus(state);
|
|
1374
|
-
if (status === 'verified')
|
|
1375
|
-
return null;
|
|
1376
|
-
const dash = ` ${COVERAGE_EM_DASH} `;
|
|
1377
|
-
if (status === 'partial') {
|
|
1378
|
-
return (`coverage partial${dash}report at '${requested}' matched ` +
|
|
1379
|
-
`${matched} of ${total} changed file(s); the other ${total - matched} ` +
|
|
1380
|
-
FALLBACK_TIER);
|
|
1381
|
-
}
|
|
1382
|
-
const head = `coverage unavailable${dash}`;
|
|
1383
|
-
if (requested === null) {
|
|
1384
|
-
return `${head}no coverage report was supplied; ${total} changed file(s) ${FALLBACK_TIER}`;
|
|
1385
|
-
}
|
|
1386
|
-
if (!found) {
|
|
1387
|
-
return `${head}report not found at '${requested}'; ${total} changed file(s) ${FALLBACK_TIER}`;
|
|
1388
|
-
}
|
|
1389
|
-
if (!parsed) {
|
|
1390
|
-
return (`${head}report at '${requested}' yielded no usable records; ` +
|
|
1391
|
-
`${total} changed file(s) ${FALLBACK_TIER}`);
|
|
1392
|
-
}
|
|
1393
|
-
return (`${head}report at '${requested}' covers ${filesInReport} file(s) but ` +
|
|
1394
|
-
`matched 0 of ${total} changed file(s); ${FALLBACK_TIER}`);
|
|
1395
|
-
}
|
|
1396
|
-
/**
|
|
1397
|
-
* {@link resolveCoverage}, additionally reporting which mode the run was in.
|
|
1398
|
-
*
|
|
1399
|
-
* Identical ladder, identical results — the only addition is the
|
|
1400
|
-
* {@link CoverageInputState} record, so a later reader can tell a clean result
|
|
1401
|
-
* from a blind one (#554).
|
|
1402
|
-
*/
|
|
1403
|
-
export function resolveCoverageWithInput(units, options = {}) {
|
|
1404
|
-
const { coveragePath = null, graphPath = '.harness/graph/graph.json', repoRoot = '.', graphMaxDepth = null, } = options;
|
|
1405
|
-
// Reference-keyed map mirrors the Python `id(unit)` bookkeeping, so distinct
|
|
1406
|
-
// units that happen to share a path are still tracked independently.
|
|
1407
|
-
const resolved = new Map();
|
|
1408
|
-
let remaining = [...units];
|
|
1409
|
-
const coverage = {
|
|
1410
|
-
requested: coveragePath,
|
|
1411
|
-
found: false,
|
|
1412
|
-
parsed: false,
|
|
1413
|
-
filesInReport: 0,
|
|
1414
|
-
unitsMatched: 0,
|
|
1415
|
-
unitsTotal: units.length,
|
|
1416
|
-
};
|
|
1417
|
-
if (coveragePath !== null) {
|
|
1418
|
-
const read = readReportIndex(coveragePath);
|
|
1419
|
-
coverage.found = read.found;
|
|
1420
|
-
coverage.parsed = read.index !== null;
|
|
1421
|
-
coverage.filesInReport =
|
|
1422
|
-
read.index === null ? 0 : Object.keys(read.index).length;
|
|
1423
|
-
const report = read.index === null
|
|
1424
|
-
? null
|
|
1425
|
-
: matchUnitsToIndex(remaining, read.index, read.absence);
|
|
1426
|
-
// An empty array (no unit matched the report) is falsy-equivalent in the
|
|
1427
|
-
// Python `if report:` guard — fall through rather than lock in nothing.
|
|
1428
|
-
if (report !== null && report.length > 0) {
|
|
1429
|
-
for (const r of report)
|
|
1430
|
-
resolved.set(r.unit, r);
|
|
1431
|
-
coverage.unitsMatched = report.length;
|
|
1432
|
-
remaining = remaining.filter((u) => !resolved.has(u));
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
if (remaining.length > 0) {
|
|
1436
|
-
const graph = resolveFromGraph(remaining, graphPath, graphMaxDepth);
|
|
1437
|
-
if (graph !== null && graph.length > 0) {
|
|
1438
|
-
for (const r of graph)
|
|
1439
|
-
resolved.set(r.unit, r);
|
|
1440
|
-
remaining = remaining.filter((u) => !resolved.has(u));
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
if (remaining.length > 0) {
|
|
1444
|
-
for (const r of resolveHeuristic(remaining, repoRoot)) {
|
|
1445
|
-
resolved.set(r.unit, r);
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
return { results: units.map((unit) => resolved.get(unit)), coverage };
|
|
1449
|
-
}
|
|
30
|
+
* The implementation lives in `./diff-coverage/`, one module per seam of the ladder
|
|
31
|
+
* (#668). This file is the seam-free public face of it: every name the rest of
|
|
32
|
+
* the codebase imports from `guardian/coverage.js` is re-exported here, so the
|
|
33
|
+
* split moved no import in any consumer.
|
|
34
|
+
*/
|
|
35
|
+
export { coverageLimits } from './diff-coverage/formats/cobertura.js';
|
|
36
|
+
export { parseCoverageJson } from './diff-coverage/formats/coverage-json.js';
|
|
37
|
+
export { validateCoverageJson, } from './diff-coverage/formats/coverage-json-lint.js';
|
|
38
|
+
export { resolveFromGraph } from './diff-coverage/graph-tier.js';
|
|
39
|
+
export { resolveHeuristic } from './diff-coverage/heuristic-tier.js';
|
|
40
|
+
export { coverageDegradedNotice, coverageStatus, resolveCoverage, resolveCoverageWithInput, } from './diff-coverage/orchestrator.js';
|
|
41
|
+
export { isSourcePath, isTestPath, isTestSupportPath, } from './diff-coverage/paths.js';
|
|
42
|
+
export { resolveFromReport } from './diff-coverage/report-tier.js';
|
|
43
|
+
export { isTypeOnlyModule } from './diff-coverage/type-only.js';
|
|
44
|
+
export { fidelityRank, Fidelity, } from './diff-coverage/types.js';
|
|
1450
45
|
//# sourceMappingURL=coverage.js.map
|