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.
Files changed (36) hide show
  1. package/dist/engine/analysis/cli.js +155 -45
  2. package/dist/engine/analysis/engine.js +9 -9
  3. package/dist/engine/analysis/reports.js +0 -0
  4. package/dist/engine/cli-commands.js +2 -2
  5. package/dist/engine/core/migrator.js +142 -31
  6. package/dist/engine/core/static-linter.js +2 -2
  7. package/dist/engine/core/workspace-detect.js +0 -0
  8. package/dist/engine/guardian/adjudication.js +1 -1
  9. package/dist/engine/guardian/agent-tier.js +3 -3
  10. package/dist/engine/guardian/coverage.js +15 -1420
  11. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +130 -0
  12. package/dist/engine/guardian/diff-coverage/formats/coverage-json-lint.js +197 -0
  13. package/dist/engine/guardian/diff-coverage/formats/coverage-json.js +107 -0
  14. package/dist/engine/guardian/diff-coverage/formats/xml.js +151 -0
  15. package/dist/engine/guardian/diff-coverage/graph-tier.js +223 -0
  16. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +150 -0
  17. package/dist/engine/guardian/diff-coverage/orchestrator.js +125 -0
  18. package/dist/engine/guardian/diff-coverage/paths.js +164 -0
  19. package/dist/engine/guardian/diff-coverage/report-tier.js +153 -0
  20. package/dist/engine/guardian/diff-coverage/type-only.js +150 -0
  21. package/dist/engine/guardian/diff-coverage/types.js +115 -0
  22. package/dist/engine/guardian/pr-check.js +5 -5
  23. package/dist/engine-checks.d.ts +15 -0
  24. package/dist/engine-checks.js +92 -1
  25. package/dist/overlay-commands.d.ts +12 -1
  26. package/dist/overlay-commands.js +28 -2
  27. package/dist/router.js +17 -5
  28. package/dist/uninstall-render.d.ts +11 -0
  29. package/dist/uninstall-render.js +60 -0
  30. package/dist/uninstall-scan.d.ts +14 -0
  31. package/dist/uninstall-scan.js +273 -0
  32. package/dist/uninstall-types.d.ts +46 -0
  33. package/dist/uninstall-types.js +91 -0
  34. package/dist/uninstall.d.ts +13 -0
  35. package/dist/uninstall.js +174 -0
  36. 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
- * `maxDepth` bounds the reverse-BFS hop distance from the changed unit's
1019
- * node(s) to the covering test node (#320). The changed unit's nodes are depth
1020
- * 0; their direct predecessors are depth 1; one hop of indirection is depth 2;
1021
- * and so on. `maxDepth=1` requires a DIRECT test→source edge; `maxDepth=null`
1022
- * is unbounded (today's behavior, byte-for-byte unchanged).
1023
- *
1024
- * Reads the NDJSON `graph.json` directly. Missing/empty graph → `null` (never
1025
- * blocks).
1026
- */
1027
- export function resolveFromGraph(units, graphPath = '.harness/graph/graph.json', maxDepth = null) {
1028
- let text;
1029
- try {
1030
- if (!existsSync(graphPath))
1031
- return null;
1032
- // ACCEPTED DIVERGENCE: Python's `read_text(encoding="utf-8")` here can raise
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