canary-test-cli 5.15.0 → 6.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 (79) hide show
  1. package/agent/frameworks/registry.json +655 -0
  2. package/bin/canary.js +20 -15
  3. package/dist/doctor-manifest.d.ts +94 -0
  4. package/dist/doctor.d.ts +67 -0
  5. package/dist/engine/analysis/cli.js +270 -0
  6. package/dist/engine/analysis/engine.js +146 -0
  7. package/dist/engine/analysis/reports.js +0 -0
  8. package/dist/engine/analysis/rows.js +9 -0
  9. package/dist/engine/cli-commands.js +618 -0
  10. package/dist/engine/cli-common.js +60 -0
  11. package/dist/engine/cli.core.js +208 -0
  12. package/dist/engine/cli.js +31 -0
  13. package/dist/engine/company-knowledge-cli.js +201 -0
  14. package/dist/engine/core/ci-env.js +33 -0
  15. package/dist/engine/core/classifier.js +192 -0
  16. package/dist/engine/core/company-knowledge.js +765 -0
  17. package/dist/engine/core/config-validation.js +74 -0
  18. package/dist/engine/core/detection.js +48 -0
  19. package/dist/engine/core/domain-scanner.js +212 -0
  20. package/dist/engine/core/environment-detect.js +410 -0
  21. package/dist/engine/core/executor.js +181 -0
  22. package/dist/engine/core/feedback.js +93 -0
  23. package/dist/engine/core/fixture-scanner.js +173 -0
  24. package/dist/engine/core/framework-registry.js +123 -0
  25. package/dist/engine/core/mcp-validator.js +218 -0
  26. package/dist/engine/core/metadata-scanner.js +147 -0
  27. package/dist/engine/core/migrator.js +1112 -0
  28. package/dist/engine/core/overlays.js +176 -0
  29. package/dist/engine/core/pattern-healer.js +147 -0
  30. package/dist/engine/core/pattern-matcher.js +255 -0
  31. package/dist/engine/core/quality-scorer.js +213 -0
  32. package/dist/engine/core/recommender.js +152 -0
  33. package/dist/engine/core/reporter.js +211 -0
  34. package/dist/engine/core/scaffolder.js +236 -0
  35. package/dist/engine/core/skill-registry.js +522 -0
  36. package/dist/engine/core/static-linter.js +237 -0
  37. package/dist/engine/core/ticket-updater.js +639 -0
  38. package/dist/engine/core/workflow-discovery.js +693 -0
  39. package/dist/engine/guardian/agent-tier.js +338 -0
  40. package/dist/engine/guardian/analysis-emit.js +201 -0
  41. package/dist/engine/guardian/cli.js +787 -0
  42. package/dist/engine/guardian/coverage.js +1055 -0
  43. package/dist/engine/guardian/delta-emitter.js +46 -0
  44. package/dist/engine/guardian/diff-extractor.js +257 -0
  45. package/dist/engine/guardian/hard-gate.js +373 -0
  46. package/dist/engine/guardian/impact-mapper.js +121 -0
  47. package/dist/engine/guardian/pr-check.js +975 -0
  48. package/dist/engine/guardian/pr-comment.js +200 -0
  49. package/dist/engine/guardian/summary-emitter.js +94 -0
  50. package/dist/engine/guardian/tier.js +58 -0
  51. package/dist/engine/history/cli.js +303 -0
  52. package/dist/engine/history/detector.js +68 -0
  53. package/dist/engine/history/ndjson-store.js +177 -0
  54. package/dist/engine/history/record.js +14 -0
  55. package/dist/engine/history/schema.js +59 -0
  56. package/dist/engine/history/store.js +47 -0
  57. package/dist/engine/history/supabase-store.js +113 -0
  58. package/dist/engine/main-deps.js +105 -0
  59. package/dist/engine/mcp-server.js +647 -0
  60. package/dist/engine/package.json +4 -0
  61. package/dist/engine/skills-cli.js +181 -0
  62. package/dist/engine/ui/banner.js +50 -0
  63. package/dist/engine/util/coalesce.js +12 -0
  64. package/dist/engine/util/round.js +43 -0
  65. package/dist/engine/workflow-cli.js +242 -0
  66. package/dist/engine-checks.d.ts +49 -0
  67. package/dist/overlay-commands.d.ts +81 -0
  68. package/dist/overlay-conflicts.d.ts +33 -0
  69. package/dist/overlay-lint.d.ts +19 -0
  70. package/dist/overlays-registry.d.ts +74 -0
  71. package/dist/reporters/testtracker.d.ts +89 -0
  72. package/dist/reporters/testtracker.js +195 -0
  73. package/dist/router.d.ts +12 -0
  74. package/dist/router.js +4 -4
  75. package/dist/skill-requirements.d.ts +57 -0
  76. package/dist/source-spec.d.ts +20 -0
  77. package/package.json +30 -6
  78. package/bin/canary +0 -0
  79. package/scripts/install.js +0 -104
@@ -0,0 +1,1055 @@
1
+ /**
2
+ * Tiered, agent-free coverage-fidelity resolution for the PR guardian.
3
+ *
4
+ * Faithful TypeScript port of `agent/guardian/coverage.py`.
5
+ *
6
+ * Phase 1 (Tier 0) — resolves diff-coverage for a changed unit at the highest
7
+ * available fidelity: an explicit coverage **report** beats a **graph**-derived
8
+ * signal beats a naming **heuristic**. Each result is labeled with its
9
+ * {@link Fidelity} so downstream findings can communicate confidence.
10
+ *
11
+ * SC-11 boundary: this module imports **no** agent/LLM module and never
12
+ * references the `analyze_diff`/`get_impact` MCP tools. Graph coverage reads
13
+ * the NDJSON `.harness/graph/graph.json` directly.
14
+ *
15
+ * Python→TS nuances (see the module-porting notes for the migration):
16
+ * - **XML**: Node has no built-in XML parser. The Cobertura reader is a
17
+ * minimal, targeted scanner pinned to the canonical
18
+ * `<coverage>...<class filename="..."><line number hits/>` shape. The
19
+ * entity-expansion / oversize / DOCTYPE guards are pre-parse *string*
20
+ * checks, so no XML library is involved in the security boundary.
21
+ * - **int vs. number**: `JSON.parse` collapses `3.0` to the integer `3`
22
+ * (JS has no int/float distinction), so — unlike Python's `json` — a
23
+ * literal `3.0` in a coverage-json cannot be rejected as "not an integer".
24
+ * Genuine non-integers (`3.7`) and booleans/strings are still rejected.
25
+ * - **UTF-8**: `read_text(encoding="utf-8")` raises on invalid bytes; Node's
26
+ * `readFileSync(path, 'utf-8')` silently substitutes U+FFFD. The report
27
+ * reader decodes with a *fatal* `TextDecoder` to preserve the Python
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
+ export 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
+ if (!existsSync(reportPath))
598
+ return null;
599
+ const buf = readFileSync(reportPath);
600
+ // Fatal decode: a non-UTF-8 report must fall through, never raise out of
601
+ // the guardian gate (mirrors Python's UnicodeDecodeError → None).
602
+ return new TextDecoder('utf-8', { fatal: true }).decode(buf);
603
+ }
604
+ catch {
605
+ return null;
606
+ }
607
+ }
608
+ /**
609
+ * Tier 1: resolve coverage from an explicit report (`COVERAGE_VERIFIED`).
610
+ *
611
+ * Supports `lcov.info` (`DA:<line>,<hits>`), the canary coverage-json shape,
612
+ * and Cobertura `coverage.xml` (line-level). Unrecognized/empty/unreadable →
613
+ * `null` (caller falls through to a lower fidelity tier — absence never
614
+ * blocks).
615
+ */
616
+ export function resolveFromReport(units, reportPath) {
617
+ const text = readReportText(reportPath);
618
+ if (text === null)
619
+ return null;
620
+ const name = basename(reportPath).toLowerCase();
621
+ let index;
622
+ if (name.endsWith('.json')) {
623
+ let parsed;
624
+ try {
625
+ parsed = JSON.parse(text);
626
+ }
627
+ catch {
628
+ return null;
629
+ }
630
+ index = parseCoverageJson(parsed);
631
+ }
632
+ else if (name.endsWith('.info') || name.includes('lcov')) {
633
+ index = parseLcov(text);
634
+ }
635
+ else if (name.endsWith('.xml')) {
636
+ index = parseCobertura(text);
637
+ }
638
+ else {
639
+ // Unrecognized format → fall through to a lower fidelity tier.
640
+ return null;
641
+ }
642
+ if (index === null || Object.keys(index).length === 0)
643
+ return null;
644
+ const results = [];
645
+ for (const unit of units) {
646
+ const hits = matchHits(unit.path, index);
647
+ if (hits === null) {
648
+ // Unit path is nowhere in the report index → "not instrumented", which is
649
+ // NOT the same as "instrumented and unhit". Emit no COVERAGE_VERIFIED
650
+ // result so the orchestrator falls through to a lower-fidelity tier for
651
+ // this unit (FIX 2).
652
+ continue;
653
+ }
654
+ const added = expandRanges(unit.added_ranges);
655
+ const uncovered = added.filter((ln) => (hits[ln] ?? 0) <= 0);
656
+ const covered = uncovered.length === 0;
657
+ const evidence = covered
658
+ ? `lines ${rangesStr(unit.added_ranges)}: all covered`
659
+ : `lines ${rangesStr(unit.added_ranges)}: ${uncovered.length} uncovered`;
660
+ results.push(makeResult({
661
+ unit,
662
+ covered,
663
+ fidelity: Fidelity.CoverageVerified,
664
+ evidence,
665
+ uncovered_lines: uncovered,
666
+ }));
667
+ }
668
+ return results;
669
+ }
670
+ // ---------------------------------------------------------------------------
671
+ // Tier 2 — knowledge graph
672
+ // ---------------------------------------------------------------------------
673
+ // Edge types that indicate a test exercises a source unit. The live graph
674
+ // carries no explicit `tests`/`covers` edge, so coverage is *derived* from
675
+ // calls/imports reach.
676
+ const REACH_EDGE_TYPES = new Set(['calls', 'imports']);
677
+ const TEST_PATH_RE = /(^|\/)tests?\/|(^|\/)test_[^/]*\.py$|\.test\.[^/]+$|\.spec\.[^/]+$/;
678
+ /**
679
+ * True if `path` looks like a test file (`tests/**`, `test_*.py`, `*.test.*`,
680
+ * `*.spec.*`).
681
+ */
682
+ export function isTestPath(path) {
683
+ return TEST_PATH_RE.test(path);
684
+ }
685
+ /**
686
+ * Tier 2: derive coverage from the harness knowledge graph (`GRAPH_VERIFIED`).
687
+ *
688
+ * The graph has no explicit `tests`/`covers` edge, so coverage is **derived**:
689
+ * a changed file is graph-covered iff some **test-path node** reaches the
690
+ * file's node (or a symbol node it `contains`) via a `calls`/`imports` edge.
691
+ * Conservative by design (edge present → covered).
692
+ *
693
+ * `maxDepth` bounds the reverse-BFS hop distance from the changed unit's
694
+ * node(s) to the covering test node (#320). The changed unit's nodes are depth
695
+ * 0; their direct predecessors are depth 1; one hop of indirection is depth 2;
696
+ * and so on. `maxDepth=1` requires a DIRECT test→source edge; `maxDepth=null`
697
+ * is unbounded (today's behavior, byte-for-byte unchanged).
698
+ *
699
+ * Reads the NDJSON `graph.json` directly. Missing/empty graph → `null` (never
700
+ * blocks).
701
+ */
702
+ export function resolveFromGraph(units, graphPath = '.harness/graph/graph.json', maxDepth = null) {
703
+ let text;
704
+ try {
705
+ if (!existsSync(graphPath))
706
+ return null;
707
+ // ACCEPTED DIVERGENCE: Python's `read_text(encoding="utf-8")` here can raise
708
+ // an UNCAUGHT `UnicodeDecodeError` on a non-UTF-8 graph (the Python only
709
+ // catches `OSError`) — a latent crash that violates the guardian's "absence
710
+ // never blocks" contract. Node's `readFileSync(path, 'utf-8')` substitutes
711
+ // U+FFFD instead of throwing; a replacement char inside a line just fails
712
+ // that line's `JSON.parse` and is skipped. We intentionally KEEP the safe
713
+ // degrade rather than reproduce the crash.
714
+ text = readFileSync(graphPath, 'utf-8');
715
+ }
716
+ catch {
717
+ return null;
718
+ }
719
+ if (text.trim() === '')
720
+ return null;
721
+ const idToPath = new Map();
722
+ const containsFwd = new Map();
723
+ const reachRev = new Map(); // to -> [from] over calls/imports
724
+ for (const raw of splitLines(text)) {
725
+ const line = raw.trim();
726
+ if (!line)
727
+ continue;
728
+ let record;
729
+ try {
730
+ record = JSON.parse(line);
731
+ }
732
+ catch {
733
+ continue;
734
+ }
735
+ if (!isRecord(record)) {
736
+ // Valid JSON but not an object (e.g. `null`, `5`, `[1,2]`, `"x"`) → not a
737
+ // node/edge record; skip it rather than crash on `.get` (FIX 3).
738
+ continue;
739
+ }
740
+ const kind = record['kind'];
741
+ if (kind === 'node') {
742
+ const nodeId = record['id'];
743
+ if (nodeId !== undefined && nodeId !== null) {
744
+ const p = record['path'];
745
+ idToPath.set(String(nodeId), typeof p === 'string' ? p : '');
746
+ }
747
+ }
748
+ else if (kind === 'edge') {
749
+ const etype = record['type'];
750
+ const src = record['from'];
751
+ const dst = record['to'];
752
+ if (src === undefined ||
753
+ src === null ||
754
+ dst === undefined ||
755
+ dst === null) {
756
+ continue;
757
+ }
758
+ const from = String(src);
759
+ const to = String(dst);
760
+ if (etype === 'contains') {
761
+ push(containsFwd, from, to);
762
+ }
763
+ else if (typeof etype === 'string' && REACH_EDGE_TYPES.has(etype)) {
764
+ push(reachRev, to, from);
765
+ }
766
+ }
767
+ }
768
+ if (idToPath.size === 0)
769
+ return null;
770
+ // Index file/symbol node ids by path (exact + suffix match support).
771
+ const pathToIds = new Map();
772
+ for (const [nodeId, nodePath] of idToPath) {
773
+ if (nodePath)
774
+ push(pathToIds, nodePath, nodeId);
775
+ }
776
+ const idsForPath = (path) => {
777
+ const exact = pathToIds.get(path);
778
+ if (exact !== undefined)
779
+ return exact;
780
+ // Boundary suffix match only; on a unique matched path use its ids. On
781
+ // multiple distinct matched paths (duplicate basenames) treat as ambiguous
782
+ // and return no ids — do NOT union unrelated nodes (FIX 6).
783
+ const matchedPaths = [];
784
+ for (const nodePath of pathToIds.keys()) {
785
+ if (pathBoundaryMatch(nodePath, path))
786
+ matchedPaths.push(nodePath);
787
+ }
788
+ return matchedPaths.length === 1 ? pathToIds.get(matchedPaths[0]) : [];
789
+ };
790
+ const results = [];
791
+ for (const unit of units) {
792
+ // Target set: the file node(s) for this unit + all symbols they contain.
793
+ const seedIds = idsForPath(unit.path);
794
+ if (seedIds.length === 0) {
795
+ // Unit has no node in the graph → no graph signal. Emit nothing so the
796
+ // orchestrator falls through to the heuristic tier (FIX 2).
797
+ continue;
798
+ }
799
+ const targets = new Set(seedIds);
800
+ const frontier = [...targets];
801
+ while (frontier.length > 0) {
802
+ const node = frontier.pop();
803
+ for (const child of containsFwd.get(node) ?? []) {
804
+ if (!targets.has(child)) {
805
+ targets.add(child);
806
+ frontier.push(child);
807
+ }
808
+ }
809
+ }
810
+ // Reverse-BFS over calls/imports; a reached test-path node → covered.
811
+ // FIX 1 (#320): a genuine FIFO BFS so first-discovery depth is the minimum;
812
+ // a LIFO stack could stamp an intermediate node at a non-minimal depth and
813
+ // prune it before a shorter path arrives, under-crediting coverage at
814
+ // maxDepth >= 3.
815
+ let coveringTest = null;
816
+ const seen = new Set(targets);
817
+ const queue = [...targets].map((t) => [t, 0]);
818
+ let head = 0;
819
+ while (head < queue.length && coveringTest === null) {
820
+ const [node, depth] = queue[head++];
821
+ if (maxDepth !== null && depth >= maxDepth) {
822
+ continue; // cannot expand deeper — predecessors would exceed bound
823
+ }
824
+ for (const source of reachRev.get(node) ?? []) {
825
+ if (seen.has(source))
826
+ continue;
827
+ seen.add(source);
828
+ const sourcePath = idToPath.get(source) ?? '';
829
+ if (sourcePath && isTestPath(sourcePath) && !targets.has(source)) {
830
+ coveringTest = sourcePath; // test reached within maxDepth
831
+ break;
832
+ }
833
+ queue.push([source, depth + 1]);
834
+ }
835
+ }
836
+ const covered = coveringTest !== null;
837
+ const evidence = covered
838
+ ? `reached by test ${coveringTest}`
839
+ : `no test node reaches ${unit.path} via calls/imports`;
840
+ results.push(makeResult({
841
+ unit,
842
+ covered,
843
+ fidelity: Fidelity.GraphVerified,
844
+ evidence,
845
+ }));
846
+ }
847
+ return results;
848
+ }
849
+ function push(map, key, value) {
850
+ const existing = map.get(key);
851
+ if (existing === undefined)
852
+ map.set(key, [value]);
853
+ else
854
+ existing.push(value);
855
+ }
856
+ // ---------------------------------------------------------------------------
857
+ // Tier 3 — naming/AST heuristic
858
+ // ---------------------------------------------------------------------------
859
+ /**
860
+ * Derive candidate symbol names for a unit: the file stem plus top-level
861
+ * `def`/`class` names. Python uses `ast` for `.py`; here a column-0 line scan
862
+ * approximates top-level extraction (indented, nested defs are excluded, just
863
+ * as `ast.parse(...).body` would exclude them). A cheap regex covers other
864
+ * languages.
865
+ *
866
+ * KNOWN HEURISTIC-TIER LIMITATION: unlike a real AST, this lexical scan can pick
867
+ * up a phantom `def ghost()`/`class Phantom` sitting at column 0 inside a
868
+ * triple-quoted string or in a syntactically-broken file, yielding a false
869
+ * heuristic-covered verdict if a test happens to mention that name. Full `ast`
870
+ * parity is not portable to Node; this is accepted as a lowest-fidelity-tier
871
+ * (HEURISTIC) imprecision — the report and graph tiers, which outrank it, are
872
+ * exact. (A cheap mitigation would be to blank string literals before the scan,
873
+ * omitted here to avoid any risk of dropping a real top-level symbol.)
874
+ */
875
+ function extractSymbols(unitPath, repoRoot) {
876
+ const symbols = new Set([stem(unitPath)]);
877
+ let source;
878
+ try {
879
+ source = readFileSync(join(repoRoot, unitPath), 'utf-8');
880
+ }
881
+ catch {
882
+ return symbols;
883
+ }
884
+ if (unitPath.endsWith('.py')) {
885
+ const topLevel = /^(?:async\s+def|def|class)\s+([A-Za-z_]\w*)/gm;
886
+ for (let m = topLevel.exec(source); m !== null; m = topLevel.exec(source)) {
887
+ symbols.add(m[1]);
888
+ }
889
+ }
890
+ else {
891
+ const decl = /\b(?:function|class|def|const|let|var)\s+([A-Za-z_]\w*)/g;
892
+ for (let m = decl.exec(source); m !== null; m = decl.exec(source)) {
893
+ symbols.add(m[1]);
894
+ }
895
+ }
896
+ return symbols;
897
+ }
898
+ /** Recursively list every file under `root` (sorted for determinism). */
899
+ function walkFiles(root) {
900
+ const out = [];
901
+ const walk = (dir) => {
902
+ let entries;
903
+ try {
904
+ entries = readdirSync(dir, { withFileTypes: true });
905
+ }
906
+ catch {
907
+ return;
908
+ }
909
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
910
+ const full = join(dir, entry.name);
911
+ if (entry.isDirectory())
912
+ walk(full);
913
+ else if (entry.isFile())
914
+ out.push(full);
915
+ }
916
+ };
917
+ walk(root);
918
+ return out;
919
+ }
920
+ /** Relative POSIX path of `full` under `root`. */
921
+ function relPosix(root, full) {
922
+ const rel = full.slice(root.length).replace(/^[/\\]/, '');
923
+ return rel.split(/[/\\]/).join(posix.sep);
924
+ }
925
+ /** Yield `[relPath, text]` for every test-looking file under `repoRoot`. */
926
+ function iterTestFiles(repoRoot) {
927
+ const all = walkFiles(repoRoot);
928
+ const seen = new Set();
929
+ const out = [];
930
+ const emit = (full) => {
931
+ if (seen.has(full))
932
+ return;
933
+ seen.add(full);
934
+ let text;
935
+ try {
936
+ text = readFileSync(full, 'utf-8');
937
+ }
938
+ catch {
939
+ return;
940
+ }
941
+ out.push([relPosix(repoRoot, full), text]);
942
+ };
943
+ const matchers = [
944
+ (b) => /^test_.*\.py$/.test(b),
945
+ (b) => b.includes('.test.'),
946
+ (b) => b.includes('.spec.'),
947
+ ];
948
+ for (const matches of matchers) {
949
+ for (const full of all) {
950
+ if (matches(basename(full)))
951
+ emit(full);
952
+ }
953
+ }
954
+ // Also any file living under a tests/ directory (broader net).
955
+ for (const full of all) {
956
+ if (!full.endsWith('.py'))
957
+ continue;
958
+ if (isTestPath(relPosix(repoRoot, full)))
959
+ emit(full);
960
+ }
961
+ return out;
962
+ }
963
+ /**
964
+ * Tier 3: last-resort naming/AST heuristic (`HEURISTIC`, never `null`).
965
+ *
966
+ * A unit is heuristic-covered iff some test file under `repoRoot` references
967
+ * the unit's file stem or a top-level symbol name (word-boundary scan).
968
+ */
969
+ export function resolveHeuristic(units, repoRoot = '.') {
970
+ const testFiles = iterTestFiles(repoRoot);
971
+ const results = [];
972
+ for (const unit of units) {
973
+ const symbols = extractSymbols(unit.path, repoRoot);
974
+ // Avoid pathological single-letter stems matching everything.
975
+ const patterns = [];
976
+ for (const sym of symbols) {
977
+ if (sym.length >= 2)
978
+ patterns.push(new RegExp(`\\b${escapeRe(sym)}\\b`));
979
+ }
980
+ let covering = null;
981
+ for (const [rel, text] of testFiles) {
982
+ // A file never counts as covering itself.
983
+ if (rel === unit.path)
984
+ continue;
985
+ if (patterns.some((pat) => pat.test(text))) {
986
+ covering = rel;
987
+ break;
988
+ }
989
+ }
990
+ const covered = covering !== null;
991
+ const evidence = covered
992
+ ? `referenced by ${covering}`
993
+ : `no test file references ${stem(unit.path)}`;
994
+ results.push(makeResult({
995
+ unit,
996
+ covered,
997
+ fidelity: Fidelity.Heuristic,
998
+ evidence,
999
+ }));
1000
+ }
1001
+ return results;
1002
+ }
1003
+ /** Escape a string for literal use inside a RegExp (Python's `re.escape`). */
1004
+ function escapeRe(s) {
1005
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1006
+ }
1007
+ /**
1008
+ * SC-3 orchestrator: resolve each unit at the highest available fidelity.
1009
+ *
1010
+ * The ladder is applied **per unit**, not per batch. For each unit the first
1011
+ * tier that has a signal for *that* unit wins:
1012
+ *
1013
+ * 1. `coveragePath` lists the unit's path → `COVERAGE_VERIFIED`
1014
+ * 2. else a graph node for the unit exists → `GRAPH_VERIFIED`
1015
+ * 3. else the naming heuristic → `HEURISTIC` (always returns)
1016
+ *
1017
+ * A unit absent from the report is NOT judged COVERAGE_VERIFIED-uncovered; it
1018
+ * falls through to the graph then heuristic tier (FIX 2). Returns exactly one
1019
+ * {@link CoverageResult} per input unit, in input order, fidelity-labeled.
1020
+ *
1021
+ * `graphMaxDepth` bounds the graph tier's reverse-BFS hop distance (#320) and
1022
+ * is forwarded verbatim to {@link resolveFromGraph}.
1023
+ */
1024
+ export function resolveCoverage(units, options = {}) {
1025
+ const { coveragePath = null, graphPath = '.harness/graph/graph.json', repoRoot = '.', graphMaxDepth = null, } = options;
1026
+ // Reference-keyed map mirrors the Python `id(unit)` bookkeeping, so distinct
1027
+ // units that happen to share a path are still tracked independently.
1028
+ const resolved = new Map();
1029
+ let remaining = [...units];
1030
+ if (coveragePath !== null) {
1031
+ const report = resolveFromReport(remaining, coveragePath);
1032
+ // An empty array (no unit matched the report) is falsy-equivalent in the
1033
+ // Python `if report:` guard — fall through rather than lock in nothing.
1034
+ if (report !== null && report.length > 0) {
1035
+ for (const r of report)
1036
+ resolved.set(r.unit, r);
1037
+ remaining = remaining.filter((u) => !resolved.has(u));
1038
+ }
1039
+ }
1040
+ if (remaining.length > 0) {
1041
+ const graph = resolveFromGraph(remaining, graphPath, graphMaxDepth);
1042
+ if (graph !== null && graph.length > 0) {
1043
+ for (const r of graph)
1044
+ resolved.set(r.unit, r);
1045
+ remaining = remaining.filter((u) => !resolved.has(u));
1046
+ }
1047
+ }
1048
+ if (remaining.length > 0) {
1049
+ for (const r of resolveHeuristic(remaining, repoRoot)) {
1050
+ resolved.set(r.unit, r);
1051
+ }
1052
+ }
1053
+ return units.map((unit) => resolved.get(unit));
1054
+ }
1055
+ //# sourceMappingURL=coverage.js.map