supercov 0.0.4 → 0.0.6

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 (82) hide show
  1. package/README.md +107 -62
  2. package/dist/agentJson.d.ts +44 -0
  3. package/dist/agentJson.d.ts.map +1 -0
  4. package/dist/agentJson.js +84 -0
  5. package/dist/agentJson.js.map +1 -0
  6. package/dist/analyze.d.ts.map +1 -1
  7. package/dist/analyze.js +1 -0
  8. package/dist/analyze.js.map +1 -1
  9. package/dist/cli.js +107 -19
  10. package/dist/cli.js.map +1 -1
  11. package/dist/directInstrumenter.d.ts +2 -2
  12. package/dist/directInstrumenter.d.ts.map +1 -1
  13. package/dist/directInstrumenter.js +18 -20
  14. package/dist/directInstrumenter.js.map +1 -1
  15. package/dist/esmInterceptor.d.ts +6 -0
  16. package/dist/esmInterceptor.d.ts.map +1 -0
  17. package/dist/esmInterceptor.js +63 -0
  18. package/dist/esmInterceptor.js.map +1 -0
  19. package/dist/evidenceArchive.d.ts +3 -1
  20. package/dist/evidenceArchive.d.ts.map +1 -1
  21. package/dist/evidenceArchive.js +31 -22
  22. package/dist/evidenceArchive.js.map +1 -1
  23. package/dist/instrumenter.d.ts.map +1 -1
  24. package/dist/instrumenter.js +30 -8
  25. package/dist/instrumenter.js.map +1 -1
  26. package/dist/integrity.d.ts.map +1 -1
  27. package/dist/integrity.js +3 -3
  28. package/dist/integrity.js.map +1 -1
  29. package/dist/launchSupervisor.d.ts +6 -0
  30. package/dist/launchSupervisor.d.ts.map +1 -1
  31. package/dist/launchSupervisor.js +78 -4
  32. package/dist/launchSupervisor.js.map +1 -1
  33. package/dist/merge.d.ts +2 -0
  34. package/dist/merge.d.ts.map +1 -0
  35. package/dist/merge.js +116 -0
  36. package/dist/merge.js.map +1 -0
  37. package/dist/nodeTest.d.ts +15 -0
  38. package/dist/nodeTest.d.ts.map +1 -0
  39. package/dist/nodeTest.js +123 -0
  40. package/dist/nodeTest.js.map +1 -0
  41. package/dist/project.d.ts +6 -1
  42. package/dist/project.d.ts.map +1 -1
  43. package/dist/project.js +36 -21
  44. package/dist/project.js.map +1 -1
  45. package/dist/query.d.ts +60 -0
  46. package/dist/query.d.ts.map +1 -1
  47. package/dist/query.js +493 -90
  48. package/dist/query.js.map +1 -1
  49. package/dist/queryCache.d.ts +15 -0
  50. package/dist/queryCache.d.ts.map +1 -0
  51. package/dist/queryCache.js +95 -0
  52. package/dist/queryCache.js.map +1 -0
  53. package/dist/register.mjs +40 -0
  54. package/dist/register.mjs.map +1 -1
  55. package/dist/resolve-loader.d.mts +1 -0
  56. package/dist/resolve-loader.d.mts.map +1 -1
  57. package/dist/resolve-loader.mjs +43 -0
  58. package/dist/resolve-loader.mjs.map +1 -1
  59. package/dist/runAnalysis.d.ts.map +1 -1
  60. package/dist/runAnalysis.js +18 -0
  61. package/dist/runAnalysis.js.map +1 -1
  62. package/dist/runnerEvidence.d.ts +19 -0
  63. package/dist/runnerEvidence.d.ts.map +1 -0
  64. package/dist/runnerEvidence.js +93 -0
  65. package/dist/runnerEvidence.js.map +1 -0
  66. package/dist/runtime.d.ts +7 -1
  67. package/dist/runtime.d.ts.map +1 -1
  68. package/dist/runtime.js +94 -11
  69. package/dist/runtime.js.map +1 -1
  70. package/dist/sourceDiscovery.d.ts +9 -0
  71. package/dist/sourceDiscovery.d.ts.map +1 -0
  72. package/dist/sourceDiscovery.js +227 -0
  73. package/dist/sourceDiscovery.js.map +1 -0
  74. package/dist/types.d.ts +15 -1
  75. package/dist/types.d.ts.map +1 -1
  76. package/dist/vitePlugin.d.ts +4 -0
  77. package/dist/vitePlugin.d.ts.map +1 -1
  78. package/dist/vitePlugin.js +13 -3
  79. package/dist/vitePlugin.js.map +1 -1
  80. package/dist/vitest.js +5 -1
  81. package/dist/vitest.js.map +1 -1
  82. package/package.json +12 -4
package/dist/query.js CHANGED
@@ -3,39 +3,79 @@ import { resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { coverageSummaryForTests, isIndependencePair } from "./analyze.js";
5
5
  import { EVIDENCE_ARCHIVE_SCHEMA_VERSION } from "./evidenceArchive.js";
6
- import { analyzeCoverageArchive } from "./runAnalysis.js";
6
+ import { analyzeCoverageArchiveCached, readCoverageQueryIndex, } from "./queryCache.js";
7
7
  import { compareRunIntegrity, createRunIntegrity } from "./integrity.js";
8
8
  import { discoverCoverageProject } from "./project.js";
9
- function parseOptions(args) {
9
+ import { agentPagination, agentSuccessJson, SupercovError, } from "./agentJson.js";
10
+ function parseOptions(command, args) {
10
11
  const options = {
12
+ command: command === "runs" || command === "diff" || command === "help"
13
+ ? command
14
+ : `coverage.${command}`,
11
15
  limit: 20,
12
16
  offset: 0,
13
17
  json: false,
14
18
  filter: "all",
19
+ target: 100,
20
+ metric: "all",
15
21
  positional: [],
16
22
  };
17
23
  for (let index = 0; index < args.length; index += 1) {
18
24
  const value = args[index];
19
25
  if (value === "--json")
20
26
  options.json = true;
21
- else if (value === "--run")
22
- options.run = args[++index];
23
- else if (value === "--kind")
24
- options.kind = args[++index]?.toLowerCase();
25
- else if (value === "--runner")
26
- options.runner = args[++index]?.toLowerCase();
27
+ else if (value === "--run") {
28
+ const run = args[++index];
29
+ if (!run)
30
+ throw new SupercovError("INVALID_ARGUMENT", "--run requires a run ID");
31
+ options.run = run;
32
+ }
33
+ else if (value === "--kind") {
34
+ const kind = args[++index]?.toLowerCase();
35
+ if (!kind)
36
+ throw new SupercovError("INVALID_ARGUMENT", "--kind requires a test kind");
37
+ options.kind = kind;
38
+ }
39
+ else if (value === "--runner") {
40
+ const runner = args[++index]?.toLowerCase();
41
+ if (!runner)
42
+ throw new SupercovError("INVALID_ARGUMENT", "--runner requires a runner name");
43
+ options.runner = runner;
44
+ }
45
+ else if (value === "--target") {
46
+ const target = Number(args[++index]);
47
+ if (!Number.isFinite(target) || target < 0 || target > 100)
48
+ throw new SupercovError("INVALID_ARGUMENT", "--target must be between 0 and 100");
49
+ options.target = target;
50
+ }
51
+ else if (value === "--metric") {
52
+ const metric = args[++index]?.toLowerCase();
53
+ if (!metric || !["all", "lines", "statements", "functions", "branches", "mcdc"].includes(metric))
54
+ throw new SupercovError("INVALID_ARGUMENT", "--metric must be all, lines, statements, functions, branches, or mcdc");
55
+ options.metric = metric;
56
+ }
27
57
  else if (value === "--filter") {
28
58
  const filter = args[++index]?.toLowerCase();
29
59
  if (filter !== "all" && filter !== "passed" && filter !== "failed")
30
- throw new Error("--filter must be all, passed, or failed");
60
+ throw new SupercovError("INVALID_ARGUMENT", "--filter must be all, passed, or failed");
31
61
  options.filter = filter;
32
62
  }
33
- else if (value === "--limit")
34
- options.limit = Math.max(1, Number(args[++index]) || 20);
35
- else if (value === "--offset")
36
- options.offset = Math.max(0, Number(args[++index]) || 0);
63
+ else if (value === "--limit") {
64
+ const limit = Number(args[++index]);
65
+ if (!Number.isSafeInteger(limit) || limit < 1)
66
+ throw new SupercovError("INVALID_ARGUMENT", "--limit must be a positive integer");
67
+ options.limit = limit;
68
+ }
69
+ else if (value === "--offset") {
70
+ const offset = Number(args[++index]);
71
+ if (!Number.isSafeInteger(offset) || offset < 0)
72
+ throw new SupercovError("INVALID_ARGUMENT", "--offset must be a non-negative integer");
73
+ options.offset = offset;
74
+ }
37
75
  else if (value.startsWith("--"))
38
- throw new Error(`Unknown option: ${value}`);
76
+ throw new SupercovError("INVALID_ARGUMENT", `Unknown option: ${value}`, {
77
+ details: { option: value },
78
+ });
39
79
  else
40
80
  options.positional.push(value);
41
81
  }
@@ -46,7 +86,7 @@ function filteredCoverage(report, options) {
46
86
  return report;
47
87
  const filtered = report.filters?.[options.filter];
48
88
  if (!filtered) {
49
- throw new Error("This run does not contain outcome-filtered coverage. Create a new coverage run.");
89
+ throw new SupercovError("FILTER_UNAVAILABLE", "This run does not contain outcome-filtered coverage. Create a new coverage run.");
50
90
  }
51
91
  return filtered;
52
92
  }
@@ -80,24 +120,32 @@ function discoverRuns(root) {
80
120
  }
81
121
  return [...runs.values()].sort((left, right) => right.id.localeCompare(left.id));
82
122
  }
83
- function analyzeStoredRun(run) {
84
- return analyzeCoverageArchive(run.evidencePath, {
123
+ function storedRunAnalysisOptions(run) {
124
+ return {
85
125
  runId: run.id,
86
126
  testExitCode: run.metadata?.testExitCode,
87
127
  integrity: run.metadata?.integrity,
88
128
  generatedAt: run.metadata?.startedAt,
89
- });
129
+ };
130
+ }
131
+ function analyzeStoredRun(run) {
132
+ return analyzeCoverageArchiveCached(run.evidencePath, storedRunAnalysisOptions(run));
90
133
  }
91
- function selectRun(root, selector, currentIntegrity) {
134
+ function readStoredRunIndex(run) {
135
+ return readCoverageQueryIndex(run.evidencePath, storedRunAnalysisOptions(run));
136
+ }
137
+ function selectRun(root, selector, currentIntegrity, quiet = false) {
92
138
  const runs = discoverRuns(root);
93
139
  if (runs.length === 0)
94
- throw new Error("No local coverage runs. Run supercov first.");
140
+ throw new SupercovError("NO_RUNS", "No local coverage runs. Run supercov first.");
95
141
  const selected = !selector || selector === "latest"
96
142
  ? runs[0]
97
143
  : (runs.find((run) => run.id === selector) ??
98
144
  runs.find((run) => run.id.startsWith(selector)));
99
145
  if (!selected)
100
- throw new Error(`Coverage run not found: ${selector}`);
146
+ throw new SupercovError("RUN_NOT_FOUND", `Coverage run not found: ${selector}`, {
147
+ details: { selector },
148
+ });
101
149
  const report = analyzeStoredRun(selected);
102
150
  if (currentIntegrity) {
103
151
  const comparison = compareRunIntegrity(selected.metadata?.integrity ?? report.integrity, currentIntegrity);
@@ -106,7 +154,7 @@ function selectRun(root, selector, currentIntegrity) {
106
154
  stale: comparison.stale,
107
155
  staleReasons: comparison.reasons,
108
156
  };
109
- if (comparison.stale) {
157
+ if (comparison.stale && !quiet) {
110
158
  console.error(`[supercov] stale run ${selected.id}: ${comparison.reasons.join(", ")}`);
111
159
  }
112
160
  }
@@ -123,8 +171,11 @@ function currentProjectIntegrity(root) {
123
171
  function page(values, options) {
124
172
  return values.slice(options.offset, options.offset + options.limit);
125
173
  }
126
- function output(value, options, text) {
127
- console.log(options.json ? JSON.stringify(value, null, 2) : text);
174
+ function output(value, options, text, pagination) {
175
+ process.stdout.write(options.json ? agentSuccessJson(options.command, value, pagination) : `${text}\n`);
176
+ }
177
+ function queryPagination(total, returned, options) {
178
+ return agentPagination(options.offset, options.limit, returned, total);
128
179
  }
129
180
  function pct(value) {
130
181
  return `${value.toFixed(2)}%`;
@@ -132,6 +183,175 @@ function pct(value) {
132
183
  function shellQuote(value) {
133
184
  return `'${value.replaceAll("'", `'\\''`)}'`;
134
185
  }
186
+ function optionKey(option) {
187
+ return [...new Set(option)].sort().join("\0");
188
+ }
189
+ function minimumTestObligations(report, candidates) {
190
+ const tests = new Map(report.tests.map((test) => [test.id, test]));
191
+ const testsByFile = new Map();
192
+ for (const test of report.tests) {
193
+ if (test.role !== "test" || !test.file || !candidates.has(test.id))
194
+ continue;
195
+ const ids = testsByFile.get(test.file) ?? [];
196
+ ids.push(test.id);
197
+ testsByFile.set(test.file, ids);
198
+ }
199
+ const evidenceChoices = (ids) => {
200
+ const options = [];
201
+ for (const id of ids) {
202
+ const test = tests.get(id);
203
+ if (!test)
204
+ continue;
205
+ if (test.role === "background")
206
+ options.push([]);
207
+ else if (test.role === "setup" && test.file)
208
+ options.push(...(testsByFile.get(test.file) ?? []).map((candidate) => [candidate]));
209
+ else if (candidates.has(id))
210
+ options.push([id]);
211
+ }
212
+ return [...new Map(options.map((option) => [optionKey(option), option])).values()];
213
+ };
214
+ const obligations = [];
215
+ const uniqueLines = new Map();
216
+ for (const line of report.lines)
217
+ uniqueLines.set(`${line.file}:${line.line}`, line);
218
+ for (const [id, line] of uniqueLines)
219
+ obligations.push({ id: `line:${id}`, metric: "lines", options: evidenceChoices(line.tests) });
220
+ for (const point of report.points)
221
+ obligations.push({
222
+ id: `${point.meta.kind}:${point.meta.id}`,
223
+ metric: point.meta.kind === "statement" ? "statements" : "functions",
224
+ options: evidenceChoices(point.tests),
225
+ });
226
+ for (const branch of report.branches)
227
+ for (const alternative of branch.alternatives)
228
+ obligations.push({
229
+ id: `branch:${branch.meta.id}:${alternative.id}`,
230
+ metric: "branches",
231
+ options: evidenceChoices(alternative.tests),
232
+ });
233
+ for (const decision of report.decisions) {
234
+ for (let condition = 0; condition < decision.meta.conditions.length; condition += 1) {
235
+ const options = [];
236
+ for (let left = 0; left < decision.vectorObservations.length; left += 1) {
237
+ for (let right = left + 1; right < decision.vectorObservations.length; right += 1) {
238
+ const first = decision.vectorObservations[left];
239
+ const second = decision.vectorObservations[right];
240
+ if (!isIndependencePair(first.vector, second.vector, condition))
241
+ continue;
242
+ for (const firstChoice of evidenceChoices(first.tests))
243
+ for (const secondChoice of evidenceChoices(second.tests))
244
+ options.push([...new Set([...firstChoice, ...secondChoice])].sort());
245
+ }
246
+ }
247
+ obligations.push({
248
+ id: `mcdc:${decision.meta.id}:${condition}`,
249
+ metric: "mcdc",
250
+ options: [...new Map(options.map((option) => [optionKey(option), option])).values()],
251
+ });
252
+ }
253
+ }
254
+ return {
255
+ obligations,
256
+ expand(selected) {
257
+ const expanded = new Set(selected);
258
+ for (const test of report.tests) {
259
+ if (test.role === "background")
260
+ expanded.add(test.id);
261
+ else if (test.role === "setup" &&
262
+ test.file &&
263
+ (testsByFile.get(test.file) ?? []).some((id) => selected.has(id)))
264
+ expanded.add(test.id);
265
+ }
266
+ return expanded;
267
+ },
268
+ };
269
+ }
270
+ function obligationSatisfied(obligation, selected) {
271
+ return obligation.options.some((option) => option.every((test) => selected.has(test)));
272
+ }
273
+ /** Exact branch-and-bound solver; MC/DC obligations retain their witness-pair structure. */
274
+ export function minimumTestSet(report, target = 100, metric = "all") {
275
+ const unattributed = report.tests.filter((test) => test.role === "background" &&
276
+ (test.hits.length > 0 || test.decisions.some((decision) => decision.vectors.length > 0)));
277
+ if (unattributed.length > 0) {
278
+ throw new SupercovError("UNATTRIBUTED_EVIDENCE", "Cannot minimize exactly: this coverage view contains background/unattributed evidence. Use a runner with exact test attribution or select a fully attributed coverage view.");
279
+ }
280
+ const candidateTests = report.tests
281
+ .filter((test) => test.role === "test")
282
+ .map((test) => test.id)
283
+ .sort();
284
+ const candidates = new Set(candidateTests);
285
+ const model = minimumTestObligations(report, candidates);
286
+ const metrics = metric === "all"
287
+ ? ["lines", "statements", "functions", "branches", "mcdc"]
288
+ : [metric];
289
+ const obligations = model.obligations.filter((obligation) => metrics.includes(obligation.metric));
290
+ const totals = new Map();
291
+ for (const obligation of obligations)
292
+ totals.set(obligation.metric, (totals.get(obligation.metric) ?? 0) + 1);
293
+ const skipLimits = new Map();
294
+ for (const selectedMetric of metrics) {
295
+ const total = totals.get(selectedMetric) ?? 0;
296
+ const required = Math.ceil((total * target) / 100);
297
+ skipLimits.set(selectedMetric, total - required);
298
+ }
299
+ let best = new Set(candidateTests);
300
+ const fullExpanded = model.expand(best);
301
+ const fullSummary = coverageSummaryForTests(report, fullExpanded);
302
+ const percentage = (selectedMetric, summary) => selectedMetric === "mcdc" ? summary.conditionCoveragePct : summary[selectedMetric].percentage;
303
+ const impossible = metrics.find((selectedMetric) => percentage(selectedMetric, fullSummary) + 1e-9 < target);
304
+ if (impossible)
305
+ throw new SupercovError("TARGET_UNREACHABLE", `The full selected test view reaches only ${percentage(impossible, fullSummary).toFixed(2)}% ${impossible}; target ${target}% is impossible`, { details: { metric: impossible, target, reachable: percentage(impossible, fullSummary) } });
306
+ let exploredStates = 0;
307
+ const seen = new Set();
308
+ const search = (selected, skipped, skippedByMetric) => {
309
+ exploredStates += 1;
310
+ if (selected.size >= best.size)
311
+ return;
312
+ const stateKey = `${[...selected].sort().join(",")}|${[...skipped].sort().join(",")}`;
313
+ if (seen.has(stateKey))
314
+ return;
315
+ seen.add(stateKey);
316
+ const unmet = obligations.filter((obligation) => !skipped.has(obligation.id) && !obligationSatisfied(obligation, selected));
317
+ if (unmet.length === 0) {
318
+ best = new Set(selected);
319
+ return;
320
+ }
321
+ const obligation = unmet.sort((left, right) => {
322
+ const feasible = (value) => value.options.filter((option) => option.some((test) => !selected.has(test))).length;
323
+ return feasible(left) - feasible(right) || left.id.localeCompare(right.id);
324
+ })[0];
325
+ const additions = [...new Map(obligation.options
326
+ .map((option) => option.filter((test) => !selected.has(test)))
327
+ .filter((option) => option.length > 0)
328
+ .map((option) => [optionKey(option), option])).values()].sort((left, right) => left.length - right.length || optionKey(left).localeCompare(optionKey(right)));
329
+ for (const addition of additions) {
330
+ if (selected.size + addition.length >= best.size)
331
+ continue;
332
+ search(new Set([...selected, ...addition]), skipped, skippedByMetric);
333
+ }
334
+ const skippedCount = skippedByMetric.get(obligation.metric) ?? 0;
335
+ if (skippedCount < (skipLimits.get(obligation.metric) ?? 0)) {
336
+ const nextSkipped = new Set(skipped);
337
+ nextSkipped.add(obligation.id);
338
+ const nextCounts = new Map(skippedByMetric);
339
+ nextCounts.set(obligation.metric, skippedCount + 1);
340
+ search(selected, nextSkipped, nextCounts);
341
+ }
342
+ };
343
+ search(new Set(), new Set(), new Map());
344
+ const expanded = model.expand(best);
345
+ return {
346
+ optimal: true,
347
+ target,
348
+ metric,
349
+ selected: [...best].sort(),
350
+ expanded: [...expanded].sort(),
351
+ summary: coverageSummaryForTests(report, expanded),
352
+ exploredStates,
353
+ };
354
+ }
135
355
  function coverageCommand(runId, options, child) {
136
356
  return [
137
357
  "npx supercov runs",
@@ -168,7 +388,9 @@ function selectedTestIds(report, options) {
168
388
  ]
169
389
  .filter(Boolean)
170
390
  .join(", ");
171
- throw new Error(`No tests match ${filter}`);
391
+ throw new SupercovError("TEST_FILTER_EMPTY", `No tests match ${filter}`, {
392
+ details: { kind: options.kind, runner: options.runner },
393
+ });
172
394
  }
173
395
  return new Set(selected.map((test) => test.id));
174
396
  }
@@ -240,6 +462,13 @@ function filterLabel(options) {
240
462
  .filter(Boolean)
241
463
  .join(", ");
242
464
  }
465
+ function queryFilters(options) {
466
+ return {
467
+ outcome: options.filter,
468
+ kind: options.kind ?? null,
469
+ runner: options.runner ?? null,
470
+ };
471
+ }
243
472
  function attribution(report, selected) {
244
473
  const phases = selected
245
474
  ? report.phases.filter((phase) => selected.has(phase.test))
@@ -251,7 +480,7 @@ function attribution(report, selected) {
251
480
  serverFallback: phases.reduce((sum, phase) => sum + phase.inferredServerEvents, 0),
252
481
  };
253
482
  }
254
- function fileGaps(report, selected) {
483
+ export function fileGaps(report, selected) {
255
484
  const files = new Map();
256
485
  const get = (file) => {
257
486
  const existing = files.get(file);
@@ -264,6 +493,8 @@ function fileGaps(report, selected) {
264
493
  uncoveredFunctions: 0,
265
494
  missingBranches: 0,
266
495
  missingMcdcConditions: 0,
496
+ measurementLimitations: 0,
497
+ limitationKinds: [],
267
498
  coveredByOtherTests: {
268
499
  lines: 0,
269
500
  statements: 0,
@@ -325,55 +556,117 @@ function fileGaps(report, selected) {
325
556
  }
326
557
  }
327
558
  }
559
+ for (const limitation of report.limitations ?? []) {
560
+ const gap = get(limitation.file);
561
+ gap.measurementLimitations += 1;
562
+ if (!gap.limitationKinds.includes(limitation.kind))
563
+ gap.limitationKinds.push(limitation.kind);
564
+ }
328
565
  for (const gap of files.values()) {
566
+ gap.limitationKinds.sort();
329
567
  gap.score =
330
568
  gap.uncoveredLines +
331
569
  gap.uncoveredFunctions * 2 +
332
570
  gap.missingBranches * 2 +
333
- gap.missingMcdcConditions * 3;
571
+ gap.missingMcdcConditions * 3 +
572
+ gap.measurementLimitations * 3;
334
573
  }
335
574
  return [...files.values()].sort((left, right) => right.score - left.score || left.file.localeCompare(right.file));
336
575
  }
337
576
  function findFile(report, selector) {
338
- const files = [...new Set(report.lines.map((line) => line.file))];
577
+ const files = [
578
+ ...new Set([
579
+ ...report.lines.map((line) => line.file),
580
+ ...(report.limitations ?? []).map((limitation) => limitation.file),
581
+ ]),
582
+ ];
339
583
  if (files.includes(selector))
340
584
  return selector;
341
585
  const matches = files.filter((file) => file.includes(selector));
342
586
  if (matches.length === 1)
343
587
  return matches[0];
344
588
  if (matches.length === 0)
345
- throw new Error(`Source file not found: ${selector}`);
346
- throw new Error(`Ambiguous file selector: ${matches.join(", ")}`);
589
+ throw new SupercovError("SOURCE_NOT_FOUND", `Source file not found: ${selector}`, {
590
+ details: { selector },
591
+ });
592
+ throw new SupercovError("AMBIGUOUS_SELECTOR", `Ambiguous file selector: ${matches.join(", ")}`, {
593
+ details: { selector, matches },
594
+ });
595
+ }
596
+ export function coverageMeasurement(report) {
597
+ const limitations = report.limitations ?? [];
598
+ const byKind = {
599
+ "dynamic-code": 0,
600
+ "semantic-safety": 0,
601
+ "source-scope": 0,
602
+ };
603
+ for (const limitation of limitations)
604
+ byKind[limitation.kind] += 1;
605
+ return {
606
+ complete: limitations.length === 0,
607
+ limitations: limitations.length,
608
+ // Every current limitation removes source from the measured denominator.
609
+ blocking: limitations.length,
610
+ files: new Set(limitations.map((limitation) => limitation.file)).size,
611
+ byKind,
612
+ };
347
613
  }
348
614
  function locationSelector(selector) {
349
615
  const match = /^(.*):(\d+)(?::\d+)?$/.exec(selector);
350
616
  if (!match)
351
- throw new Error("Expected <source-file>:<line>");
617
+ throw new SupercovError("INVALID_ARGUMENT", "Expected <source-file>:<line>", {
618
+ details: { selector },
619
+ });
352
620
  return { file: match[1], line: Number(match[2]) };
353
621
  }
354
622
  function vectorText(values, outcome) {
355
623
  return `${values.map((value) => (value === null ? "-" : value ? "T" : "F")).join("")} -> ${outcome ? "T" : "F"}`;
356
624
  }
357
- function help() {
358
- console.log(`Agent-oriented local coverage queries:
625
+ const helpText = `Agent-oriented local coverage queries:
359
626
  supercov runs [--limit N] [--json]
360
627
  supercov runs <run-id> coverage [--filter all|passed|failed] [--kind e2e] [--runner playwright] [--json]
361
628
  supercov runs <run-id> coverage kinds [--json]
362
629
  supercov runs <run-id> coverage runners [--json]
630
+ supercov runs <run-id> coverage scope [--limit N] [--offset N] [--json]
363
631
  supercov runs <run-id> coverage files [--filter all|passed|failed] [--limit N] [--offset N] [--json]
364
632
  supercov runs <run-id> coverage gaps [--filter all|passed|failed] [--kind e2e] [--limit N] [--offset N] [--json]
365
633
  supercov runs <run-id> coverage file <source-file> [--kind e2e] [--limit N] [--offset N] [--json]
366
634
  supercov runs <run-id> coverage decision <id|source-file:line> [--kind e2e] [--json]
367
635
  supercov runs <run-id> coverage covers <source-file:line> [--kind e2e] [--json]
368
636
  supercov runs <run-id> coverage test <id|name-fragment> [--kind e2e] [--limit N] [--json]
637
+ supercov runs <run-id> coverage minimize [--target 0..100] [--metric all|lines|statements|functions|branches|mcdc] [--filter all|passed|failed] [--limit N] [--offset N] [--json]
369
638
  supercov diff <older-run> <newer-run> [--limit N] [--json]
639
+ supercov merge <run-id> <run-id> [...]
370
640
  supercov prune [--keep N] [--dry-run]
371
641
  supercov clean [--keep N] [--dry-run]
372
642
 
373
643
  Use "latest" as <run-id> to query the newest local run.
374
644
 
375
645
  Create a run with:
376
- supercov -- <test command>`);
646
+ supercov -- <test command>`;
647
+ function help(options) {
648
+ if (options.json) {
649
+ return output({
650
+ usage: "supercov -- <test command>",
651
+ runSelector: "Use latest as <run-id> to query the newest local run.",
652
+ queryCommands: [
653
+ "runs",
654
+ "runs <run-id> coverage",
655
+ "runs <run-id> coverage kinds",
656
+ "runs <run-id> coverage runners",
657
+ "runs <run-id> coverage scope",
658
+ "runs <run-id> coverage files",
659
+ "runs <run-id> coverage gaps",
660
+ "runs <run-id> coverage file <source-file>",
661
+ "runs <run-id> coverage decision <id|source-file:line>",
662
+ "runs <run-id> coverage covers <source-file:line>",
663
+ "runs <run-id> coverage test <id|name-fragment>",
664
+ "runs <run-id> coverage minimize",
665
+ "diff <older-run> <newer-run>",
666
+ ],
667
+ }, options, helpText);
668
+ }
669
+ console.log(helpText);
377
670
  }
378
671
  /** Resolve the instance-first coverage resource syntax. */
379
672
  export function resolveCoverageQueryInvocation(command, args) {
@@ -391,15 +684,17 @@ export function resolveCoverageQueryInvocation(command, args) {
391
684
  "summary",
392
685
  "kinds",
393
686
  "runners",
687
+ "scope",
394
688
  "files",
395
689
  "gaps",
396
690
  "file",
397
691
  "decision",
398
692
  "covers",
399
693
  "test",
694
+ "minimize",
400
695
  ]);
401
696
  if (!coverageCommands.has(child)) {
402
- throw new Error(`Unknown coverage query: ${child}. Try supercov help.`);
697
+ throw new SupercovError("UNKNOWN_COMMAND", `Unknown coverage query: ${child}. Try supercov help.`, { details: { command: child } });
403
698
  }
404
699
  return {
405
700
  command: child,
@@ -409,20 +704,22 @@ export function resolveCoverageQueryInvocation(command, args) {
409
704
  export async function runQueryCommand(command, args, root = process.cwd()) {
410
705
  const resolved = resolveCoverageQueryInvocation(command, args);
411
706
  command = resolved.command;
412
- const options = parseOptions(resolved.args);
707
+ const options = parseOptions(command, resolved.args);
413
708
  if (command === "help")
414
- return help();
709
+ return help(options);
415
710
  const currentIntegrity = currentProjectIntegrity(root);
416
711
  if (command === "runs") {
417
712
  const availableRuns = discoverRuns(root);
418
713
  const runs = page(availableRuns, options).map((run) => {
419
- const report = filteredCoverage(analyzeStoredRun(run), options);
714
+ const cached = readStoredRunIndex(run);
715
+ const report = cached ? filteredCoverage(cached, options) : undefined;
420
716
  return {
421
717
  id: run.id,
422
- generatedAt: report.generatedAt,
423
- lines: report.summary.lines.percentage,
424
- branches: report.summary.branches.percentage,
425
- mcdc: report.summary.conditionCoveragePct,
718
+ generatedAt: report?.generatedAt ?? run.metadata?.startedAt,
719
+ coverageIndexed: Boolean(report),
720
+ lines: report?.summary.lines.percentage ?? null,
721
+ branches: report?.summary.branches.percentage ?? null,
722
+ mcdc: report?.summary.conditionCoveragePct ?? null,
426
723
  command: run.metadata?.command,
427
724
  durationMs: run.metadata?.durationMs,
428
725
  timings: run.metadata?.timings,
@@ -436,18 +733,18 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
436
733
  });
437
734
  const runsBase = `npx supercov runs${options.filter !== "all" ? ` --filter ${options.filter}` : ""}`;
438
735
  const runsNext = nextPageCommand(runsBase, availableRuns.length, runs.length, options);
439
- return output(runs, options, runs
440
- .map((run) => `${run.id} lines ${pct(run.lines ?? 0)} branches ${pct(run.branches ?? 0)} MC/DC ${pct(run.mcdc ?? 0)}${run.stale ? ` STALE (${run.reasons.join(", ")})` : ""}`)
736
+ return output({ filters: queryFilters(options), runs }, options, runs
737
+ .map((run) => `${run.id} ${run.coverageIndexed ? `lines ${pct(run.lines)} branches ${pct(run.branches)} MC/DC ${pct(run.mcdc)}` : "coverage not indexed"}${run.stale ? ` STALE (${run.reasons.join(", ")})` : ""}`)
441
738
  .join("\n") +
442
739
  `\n${pageLabel(availableRuns.length, runs.length, options)}` +
443
- (runsNext ? `\nnext page: ${runsNext}` : ""));
740
+ (runsNext ? `\nnext page: ${runsNext}` : ""), queryPagination(availableRuns.length, runs.length, options));
444
741
  }
445
742
  if (command === "diff") {
446
743
  const [olderSelector, newerSelector] = options.positional;
447
744
  if (!olderSelector || !newerSelector)
448
- throw new Error("Usage: supercov diff <older-run> <newer-run>");
449
- const olderSelected = selectRun(root, olderSelector, currentIntegrity);
450
- const newerSelected = selectRun(root, newerSelector, currentIntegrity);
745
+ throw new SupercovError("INVALID_ARGUMENT", "Usage: supercov diff <older-run> <newer-run>");
746
+ const olderSelected = selectRun(root, olderSelector, currentIntegrity, options.json);
747
+ const newerSelected = selectRun(root, newerSelector, currentIntegrity, options.json);
451
748
  const older = {
452
749
  ...olderSelected,
453
750
  report: filteredCoverage(olderSelected.report, options),
@@ -502,6 +799,7 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
502
799
  .map(([, label]) => label)
503
800
  .sort();
504
801
  const result = {
802
+ filters: queryFilters(options),
505
803
  older: older.run.id,
506
804
  newer: newer.run.id,
507
805
  delta: {
@@ -540,9 +838,9 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
540
838
  .filter(Boolean)
541
839
  .join(" ");
542
840
  const diffNext = nextPageCommand(diffBase, diffTotal, diffReturned, options);
543
- return output(result, options, `${older.run.id} -> ${newer.run.id}\nlines ${result.delta.lines >= 0 ? "+" : ""}${result.delta.lines}pp, branches ${result.delta.branches >= 0 ? "+" : ""}${result.delta.branches}pp, MC/DC ${result.delta.mcdc >= 0 ? "+" : ""}${result.delta.mcdc}pp\ngained: ${gainedLines.length} lines, ${gainedBranches.length} branches, ${gainedMcdc.length} MC/DC conditions\nlost: ${lostLines.length} lines, ${lostBranches.length} branches, ${lostMcdc.length} MC/DC conditions\n${result.gained.lines.map((line) => `+ line ${line}`).join("\n")}${result.gained.branches.length ? `\n${result.gained.branches.map((item) => `+ branch ${item}`).join("\n")}` : ""}${result.gained.mcdc.length ? `\n${result.gained.mcdc.map((item) => `+ MC/DC ${item}`).join("\n")}` : ""}\n${pageLabel(diffTotal, diffReturned, options)} per category${diffNext ? `\nnext page: ${diffNext}` : ""}`);
841
+ return output(result, options, `${older.run.id} -> ${newer.run.id}\nlines ${result.delta.lines >= 0 ? "+" : ""}${result.delta.lines}pp, branches ${result.delta.branches >= 0 ? "+" : ""}${result.delta.branches}pp, MC/DC ${result.delta.mcdc >= 0 ? "+" : ""}${result.delta.mcdc}pp\ngained: ${gainedLines.length} lines, ${gainedBranches.length} branches, ${gainedMcdc.length} MC/DC conditions\nlost: ${lostLines.length} lines, ${lostBranches.length} branches, ${lostMcdc.length} MC/DC conditions\n${result.gained.lines.map((line) => `+ line ${line}`).join("\n")}${result.gained.branches.length ? `\n${result.gained.branches.map((item) => `+ branch ${item}`).join("\n")}` : ""}${result.gained.mcdc.length ? `\n${result.gained.mcdc.map((item) => `+ MC/DC ${item}`).join("\n")}` : ""}\n${pageLabel(diffTotal, diffReturned, options)} per category${diffNext ? `\nnext page: ${diffNext}` : ""}`, queryPagination(diffTotal, diffReturned, options));
544
842
  }
545
- const selectedRun = selectRun(root, options.run, currentIntegrity);
843
+ const selectedRun = selectRun(root, options.run, currentIntegrity, options.json);
546
844
  const run = selectedRun.run;
547
845
  const report = filteredCoverage(selectedRun.report, options);
548
846
  const selectedTestSet = selectedTestIds(report, options);
@@ -551,6 +849,7 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
551
849
  ? coverageSummaryForTests(report, selectedTestSet)
552
850
  : report.summary;
553
851
  const gaps = fileGaps(report, selectedTestSet).filter((gap) => gap.score > 0);
852
+ const measurement = coverageMeasurement(report);
554
853
  const selectedTests = report.tests.filter((test) => !selectedTestSet || selectedTestSet.has(test.id));
555
854
  const testCount = selectedTests.filter((test) => (test.role ?? "test") === "test").length;
556
855
  const setupCount = selectedTests.filter((test) => test.role === "setup").length;
@@ -560,18 +859,19 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
560
859
  ]));
561
860
  const result = {
562
861
  run: run.id,
563
- filter: options.filter,
564
- ...(filterLabel(options) ? { filter: filterLabel(options) } : {}),
862
+ filters: queryFilters(options),
565
863
  generatedAt: report.generatedAt,
566
864
  valid: run.metadata?.testExitCode === 0,
567
865
  stale: report.integrity?.stale ?? false,
568
866
  staleReasons: report.integrity?.staleReasons ?? [],
569
- structurallyComplete: summary.coverageComplete,
867
+ structurallyComplete: summary.coverageComplete && measurement.complete,
570
868
  complete: options.filter === "passed" &&
571
869
  run.metadata?.testExitCode === 0 &&
572
870
  !report.integrity?.stale &&
573
- summary.coverageComplete,
871
+ summary.coverageComplete &&
872
+ measurement.complete,
574
873
  coverage: summary,
874
+ measurement,
575
875
  coverageByKind: report.coverageByKind,
576
876
  coverageByRunner: report.coverageByRunner,
577
877
  attribution: attribution(report, selectedTestSet),
@@ -588,11 +888,94 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
588
888
  }
589
889
  : {}),
590
890
  filesWithGaps: gaps.length,
891
+ filesWithCoverageGaps: gaps.filter((gap) => gap.uncoveredLines > 0 ||
892
+ gap.uncoveredStatements > 0 ||
893
+ gap.uncoveredFunctions > 0 ||
894
+ gap.missingBranches > 0 ||
895
+ gap.missingMcdcConditions > 0).length,
896
+ filesWithMeasurementLimitations: measurement.files,
591
897
  tests: testCount,
592
898
  setups: setupCount,
593
899
  testOutcomes,
900
+ sourceScope: report.scope
901
+ ? {
902
+ mode: report.scope.mode,
903
+ roots: report.scope.roots,
904
+ included: report.scope.entries.filter((entry) => entry.status === "included").length,
905
+ excluded: report.scope.entries.filter((entry) => entry.status === "excluded").length,
906
+ ambiguous: report.scope.entries.filter((entry) => entry.status === "ambiguous").length,
907
+ }
908
+ : undefined,
594
909
  };
595
- return output(result, options, `run ${run.id}${filterLabel(options) ? ` (${filterLabel(options)})` : ""}${run.metadata?.testExitCode !== 0 ? ` [INVALID: test exit ${run.metadata?.testExitCode ?? "unknown"}]` : ""}${report.integrity?.stale ? ` [STALE: ${(report.integrity.staleReasons ?? []).join(", ")}]` : ""}\nlines ${pct(summary.lines.percentage)} (${summary.lines.covered}/${summary.lines.total})\nbranches ${pct(summary.branches.percentage)} (${summary.branches.covered}/${summary.branches.total})\nMC/DC ${pct(summary.conditionCoveragePct)} (${summary.coveredConditions}/${summary.conditions})${!selectedTestSet ? `\nconfidence: ${report.lines.filter((line) => line.confidence?.level === "asserted").length} asserted lines, ${report.lines.filter((line) => line.confidence?.level === "action").length} action-linked, ${report.lines.filter((line) => line.confidence?.level === "executed").length} execution-only; ${report.decisions.reduce((total, decision) => total + decision.conditions.filter((condition) => condition.assertionCovered).length, 0)} assertion-linked MC/DC conditions` : ""}\n${testCount} test(s)${setupCount ? ` + ${setupCount} setup scope(s)` : ""}; outcomes ${Object.entries(testOutcomes).filter(([, count]) => count > 0).map(([outcome, count]) => `${outcome}=${count}`).join(", ") || "none"}; ${gaps.length} file(s) have remaining obligations${(report.limitations?.length ?? 0) ? `; ${report.limitations.length} completeness blocker(s)` : ""}`);
910
+ return output(result, options, `run ${run.id}${filterLabel(options) ? ` (${filterLabel(options)})` : ""}${run.metadata?.testExitCode !== 0 ? ` [INVALID: test exit ${run.metadata?.testExitCode ?? "unknown"}]` : ""}${report.integrity?.stale ? ` [STALE: ${(report.integrity.staleReasons ?? []).join(", ")}]` : ""}\nlines ${pct(summary.lines.percentage)} (${summary.lines.covered}/${summary.lines.total})\nbranches ${pct(summary.branches.percentage)} (${summary.branches.covered}/${summary.branches.total})\nMC/DC ${pct(summary.conditionCoveragePct)} (${summary.coveredConditions}/${summary.conditions})\nmeasurement: ${measurement.complete ? "complete" : `incomplete — ${measurement.blocking} blocking limitation(s) in ${measurement.files} file(s)`}${!selectedTestSet ? `\nconfidence: ${report.lines.filter((line) => line.confidence?.level === "asserted").length} asserted lines, ${report.lines.filter((line) => line.confidence?.level === "action").length} action-linked, ${report.lines.filter((line) => line.confidence?.level === "executed").length} execution-only; ${report.decisions.reduce((total, decision) => total + decision.conditions.filter((condition) => condition.assertionCovered).length, 0)} assertion-linked MC/DC conditions` : ""}\n${testCount} test(s)${setupCount ? ` + ${setupCount} setup scope(s)` : ""}; outcomes ${Object.entries(testOutcomes).filter(([, count]) => count > 0).map(([outcome, count]) => `${outcome}=${count}`).join(", ") || "none"}; ${gaps.length} file(s) have unresolved coverage or measurement gaps`);
911
+ }
912
+ if (command === "minimize") {
913
+ const solverReport = selectedTestSet
914
+ ? {
915
+ ...report,
916
+ tests: report.tests.filter((test) => selectedTestSet.has(test.id)),
917
+ }
918
+ : report;
919
+ const minimized = minimumTestSet(solverReport, options.target, options.metric);
920
+ const selectedDetails = minimized.selected.map((id) => {
921
+ const test = report.tests.find((candidate) => candidate.id === id);
922
+ return {
923
+ id,
924
+ name: test.name,
925
+ file: test.file,
926
+ runner: test.provenance.runner,
927
+ kind: test.provenance.kind,
928
+ };
929
+ });
930
+ const selectedPage = page(selectedDetails, options);
931
+ const base = `${coverageCommand(run.id, options, "minimize")} --target ${options.target}${options.metric !== "all" ? ` --metric ${options.metric}` : ""}`;
932
+ const next = nextPageCommand(base, selectedDetails.length, selectedPage.length, options);
933
+ return output({
934
+ run: run.id,
935
+ filters: queryFilters(options),
936
+ ...minimized,
937
+ selectedCount: selectedDetails.length,
938
+ totalCandidateTests: solverReport.tests.filter((test) => test.role === "test").length,
939
+ tests: selectedPage,
940
+ }, options, `exact minimum ${selectedDetails.length}/${solverReport.tests.filter((test) => test.role === "test").length} test(s) for ${options.target}% ${options.metric === "all" ? "coverage across all measured metrics" : options.metric}; explored ${minimized.exploredStates} state(s)\nlines ${pct(minimized.summary.lines.percentage)}, statements ${pct(minimized.summary.statements.percentage)}, functions ${pct(minimized.summary.functions.percentage)}, branches ${pct(minimized.summary.branches.percentage)}, MC/DC ${pct(minimized.summary.conditionCoveragePct)}\n${selectedPage.map((test) => `${test.id} ${test.kind}/${test.runner} ${test.file ?? "unknown"} ${test.name}`).join("\n")}\n${pageLabel(selectedDetails.length, selectedPage.length, options)}${next ? `\nnext page: ${next}` : ""}`, queryPagination(selectedDetails.length, selectedPage.length, options));
941
+ }
942
+ if (command === "scope") {
943
+ if (!report.scope)
944
+ throw new SupercovError("SCOPE_UNAVAILABLE", "This run does not contain a source-scope inventory.");
945
+ const limitationsByFile = new Map();
946
+ for (const limitation of report.limitations ?? []) {
947
+ const existing = limitationsByFile.get(limitation.file) ?? [];
948
+ existing.push(limitation);
949
+ limitationsByFile.set(limitation.file, existing);
950
+ }
951
+ const ordered = report.scope.entries.map((entry) => {
952
+ const limitations = limitationsByFile.get(entry.file) ?? [];
953
+ return {
954
+ ...entry,
955
+ measurementLimitations: limitations.length,
956
+ limitationKinds: [...new Set(limitations.map((item) => item.kind))].sort(),
957
+ };
958
+ }).sort((left, right) => {
959
+ const rank = { ambiguous: 0, included: 1, excluded: 2 };
960
+ return rank[left.status] - rank[right.status] || left.file.localeCompare(right.file);
961
+ });
962
+ const selectedEntries = page(ordered, options);
963
+ const base = coverageCommand(run.id, options, "scope");
964
+ const next = nextPageCommand(base, ordered.length, selectedEntries.length, options);
965
+ const counts = {
966
+ included: ordered.filter((entry) => entry.status === "included").length,
967
+ excluded: ordered.filter((entry) => entry.status === "excluded").length,
968
+ ambiguous: ordered.filter((entry) => entry.status === "ambiguous").length,
969
+ };
970
+ return output({
971
+ run: run.id,
972
+ filters: queryFilters(options),
973
+ mode: report.scope.mode,
974
+ roots: report.scope.roots,
975
+ counts,
976
+ measurement: coverageMeasurement(report),
977
+ entries: selectedEntries,
978
+ }, options, `mode ${report.scope.mode}; roots ${report.scope.roots.join(", ") || "none"}; included ${counts.included}, excluded ${counts.excluded}, ambiguous ${counts.ambiguous}; measurement ${coverageMeasurement(report).complete ? "complete" : `${coverageMeasurement(report).blocking} blocking limitation(s)`}\n${selectedEntries.map((entry) => `${entry.status.toUpperCase()} ${entry.file} ${entry.reason}${entry.measurementLimitations ? ` [measurement limitations: ${entry.measurementLimitations} ${entry.limitationKinds.join(", ")}]` : ""}${entry.packageRoot ? ` [package ${entry.packageRoot}]` : ""}`).join("\n")}\n${pageLabel(ordered.length, selectedEntries.length, options)}${next ? `\nnext page: ${next}` : ""}`, queryPagination(ordered.length, selectedEntries.length, options));
596
979
  }
597
980
  if (command === "kinds" || command === "runners") {
598
981
  const dimension = command === "kinds" ? report.coverageByKind : report.coverageByRunner;
@@ -600,8 +983,7 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
600
983
  const dimensionNext = nextPageCommand(coverageCommand(run.id, options, command), dimension.length, selectedDimension.length, options);
601
984
  return output({
602
985
  run: run.id,
603
- total: dimension.length,
604
- offset: options.offset,
986
+ filters: queryFilters(options),
605
987
  [command]: selectedDimension,
606
988
  }, options, selectedDimension
607
989
  .map((entry) => {
@@ -610,7 +992,7 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
610
992
  })
611
993
  .join("\n") +
612
994
  `\n${pageLabel(dimension.length, selectedDimension.length, options)}` +
613
- (dimensionNext ? `\nnext page: ${dimensionNext}` : ""));
995
+ (dimensionNext ? `\nnext page: ${dimensionNext}` : ""), queryPagination(dimension.length, selectedDimension.length, options));
614
996
  }
615
997
  if (command === "files" || command === "gaps") {
616
998
  const files = fileGaps(report, selectedTestSet);
@@ -624,25 +1006,31 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
624
1006
  : undefined;
625
1007
  return output({
626
1008
  run: run.id,
627
- ...(filterLabel(options) ? { filter: filterLabel(options) } : {}),
628
- total: all.length,
629
- offset: options.offset,
1009
+ filters: queryFilters(options),
630
1010
  [command]: selectedFiles,
631
1011
  }, options, selectedFiles
632
1012
  .map((gap) => {
633
- const status = gap.score === 0
634
- ? "complete"
1013
+ const missing = gap.uncoveredLines +
1014
+ gap.uncoveredStatements +
1015
+ gap.uncoveredFunctions +
1016
+ gap.missingBranches +
1017
+ gap.missingMcdcConditions;
1018
+ const status = missing === 0
1019
+ ? "coverage complete"
635
1020
  : `missing: lines ${gap.uncoveredLines} stmts ${gap.uncoveredStatements} funcs ${gap.uncoveredFunctions} branches ${gap.missingBranches} MC/DC ${gap.missingMcdcConditions}`;
636
- return `${gap.file} ${status}${selectedTestSet ? ` [covered elsewhere: ${Object.values(gap.coveredByOtherTests).reduce((sum, value) => sum + value, 0)}; nowhere: ${Object.values(gap.uncoveredEverywhere).reduce((sum, value) => sum + value, 0)}]` : ""}`;
1021
+ const limitations = gap.measurementLimitations
1022
+ ? ` measurement limitations ${gap.measurementLimitations} (${gap.limitationKinds.join(", ")})`
1023
+ : "";
1024
+ return `${gap.file} ${status}${limitations}${selectedTestSet ? ` [covered elsewhere: ${Object.values(gap.coveredByOtherTests).reduce((sum, value) => sum + value, 0)}; nowhere: ${Object.values(gap.uncoveredEverywhere).reduce((sum, value) => sum + value, 0)}]` : ""}`;
637
1025
  })
638
1026
  .join("\n") +
639
1027
  `\nshowing ${pageStart}-${pageEnd} of ${all.length}` +
640
- (nextCommand ? `\nnext page: ${nextCommand}` : ""));
1028
+ (nextCommand ? `\nnext page: ${nextCommand}` : ""), queryPagination(all.length, selectedFiles.length, options));
641
1029
  }
642
1030
  if (command === "file") {
643
1031
  const selector = options.positional.join(" ");
644
1032
  if (!selector)
645
- throw new Error("Usage: supercov runs <run-id> coverage file <source-file>");
1033
+ throw new SupercovError("INVALID_ARGUMENT", "Usage: supercov runs <run-id> coverage file <source-file>");
646
1034
  const file = findFile(report, selector);
647
1035
  const uncoveredLines = report.lines
648
1036
  .filter((line) => line.file === file &&
@@ -707,6 +1095,16 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
707
1095
  ...branches,
708
1096
  ...mcdc,
709
1097
  ].sort((left, right) => left.line - right.line || left.kind.localeCompare(right.kind));
1098
+ const allFileLimitations = (report.limitations ?? [])
1099
+ .filter((limitation) => limitation.file === file)
1100
+ .map((limitation) => ({
1101
+ ...limitation,
1102
+ blocking: true,
1103
+ effect: "outside-measured-denominator",
1104
+ }))
1105
+ .sort((left, right) => left.line - right.line ||
1106
+ left.column - right.column ||
1107
+ left.id.localeCompare(right.id));
710
1108
  const allFileTests = report.tests
711
1109
  .filter((test) => (!selectedTestSet || selectedTestSet.has(test.id)) &&
712
1110
  test.lines.some((line) => line.file === file))
@@ -717,15 +1115,16 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
717
1115
  }));
718
1116
  const tests = page(allFileTests, options);
719
1117
  const selected = page(obligations, options);
720
- const filePageTotal = Math.max(obligations.length, allFileTests.length);
721
- const filePageReturned = Math.max(selected.length, tests.length);
1118
+ const limitations = page(allFileLimitations, options);
1119
+ const filePageTotal = Math.max(obligations.length, allFileTests.length, allFileLimitations.length);
1120
+ const filePageReturned = Math.max(selected.length, tests.length, limitations.length);
722
1121
  const nextFileOffset = options.offset + filePageReturned;
723
1122
  const nextFileCommand = filePageReturned > 0 && nextFileOffset < filePageTotal
724
1123
  ? `${coverageCommand(run.id, options, "file")} ${shellQuote(file)} --offset ${nextFileOffset}${options.limit !== 20 ? ` --limit ${options.limit}` : ""}`
725
1124
  : undefined;
726
1125
  const result = {
727
1126
  run: run.id,
728
- ...(filterLabel(options) ? { filter: filterLabel(options) } : {}),
1127
+ filters: queryFilters(options),
729
1128
  file,
730
1129
  counts: {
731
1130
  uncoveredLines: uncoveredLines.length,
@@ -733,14 +1132,16 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
733
1132
  uncoveredFunctions: functions.length,
734
1133
  missingBranches: branches.length,
735
1134
  missingMcdcConditions: mcdc.length,
1135
+ measurementLimitations: allFileLimitations.length,
736
1136
  },
737
1137
  tests,
738
1138
  totalTests: allFileTests.length,
739
1139
  totalObligations: obligations.length,
740
- offset: options.offset,
741
1140
  obligations: selected,
1141
+ totalLimitations: allFileLimitations.length,
1142
+ limitations,
742
1143
  };
743
- return output(result, options, `${file}\nlines ${uncoveredLines.length}, statements ${statements.length}, functions ${functions.length}, branches ${branches.length}, MC/DC ${mcdc.length}\ncovered by ${allFileTests.length} test(s)\n${selected
1144
+ return output(result, options, `${file}\nlines ${uncoveredLines.length}, statements ${statements.length}, functions ${functions.length}, branches ${branches.length}, MC/DC ${mcdc.length}, measurement limitations ${allFileLimitations.length}\ncovered by ${allFileTests.length} test(s)\n${selected
744
1145
  .map((item) => item.kind === "line"
745
1146
  ? `line ${item.line}: ${item.otherCoverage.coveredElsewhere ? `covered only by ${item.otherCoverage.kinds.join(", ")}/${item.otherCoverage.runners.join(", ")}` : "uncovered everywhere"}`
746
1147
  : item.kind === "statement"
@@ -750,12 +1151,12 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
750
1151
  : item.kind === "branch"
751
1152
  ? `branch ${item.line}:${item.column}: missing ${item.missing}${item.otherCoverage.coveredElsewhere ? ` [covered only by ${item.otherCoverage.kinds.join(", ")}/${item.otherCoverage.runners.join(", ")}]` : ""}`
752
1153
  : `MC/DC ${item.line}:${item.column} [${item.id}]: ${item.missingCondition}${item.otherCoverage.coveredElsewhere ? ` [covered only by ${item.otherCoverage.kinds.join(", ")}/${item.otherCoverage.runners.join(", ")}]` : ""}`)
753
- .join("\n")}\n${pageLabel(filePageTotal, filePageReturned, options)} obligations/tests${nextFileCommand ? `\nnext page: ${nextFileCommand}` : ""}`);
1154
+ .join("\n")}${selected.length && limitations.length ? "\n" : ""}${limitations.map((limitation) => `LIMITATION ${limitation.kind} ${limitation.line}:${limitation.column} [${limitation.id}]\n ${limitation.reason}\n source: ${limitation.source}\n effect: outside measured denominator`).join("\n")}\n${pageLabel(filePageTotal, filePageReturned, options)} obligations/tests/limitations per category${nextFileCommand ? `\nnext page: ${nextFileCommand}` : ""}`, queryPagination(filePageTotal, filePageReturned, options));
754
1155
  }
755
1156
  if (command === "decision") {
756
1157
  const selector = options.positional[0];
757
1158
  if (!selector)
758
- throw new Error("Usage: supercov runs <run-id> coverage decision <id|source-file:line>");
1159
+ throw new SupercovError("INVALID_ARGUMENT", "Usage: supercov runs <run-id> coverage decision <id|source-file:line>");
759
1160
  let matches = report.decisions.filter((decision) => decision.meta.id === selector);
760
1161
  if (matches.length === 0 && /:\d+(?::\d+)?$/.test(selector)) {
761
1162
  const location = locationSelector(selector);
@@ -763,7 +1164,9 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
763
1164
  decision.meta.line === location.line);
764
1165
  }
765
1166
  if (matches.length === 0)
766
- throw new Error(`Decision not found: ${selector}`);
1167
+ throw new SupercovError("DECISION_NOT_FOUND", `Decision not found: ${selector}`, {
1168
+ details: { selector },
1169
+ });
767
1170
  if (matches.length > 1) {
768
1171
  const matchingDecisions = page(matches, options).map((decision) => ({
769
1172
  id: decision.meta.id,
@@ -775,10 +1178,9 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
775
1178
  const matchesNext = nextPageCommand(`${coverageCommand(run.id, options, "decision")} ${shellQuote(selector)}`, matches.length, matchingDecisions.length, options);
776
1179
  return output({
777
1180
  run: run.id,
778
- total: matches.length,
779
- offset: options.offset,
1181
+ filters: queryFilters(options),
780
1182
  decisions: matchingDecisions,
781
- }, options, `${matchingDecisions.map((decision) => `${decision.id} ${decision.file}:${decision.line}:${decision.column} ${decision.source}`).join("\n")}\n${pageLabel(matches.length, matchingDecisions.length, options)} matching decisions${matchesNext ? `\nnext page: ${matchesNext}` : ""}`);
1183
+ }, options, `${matchingDecisions.map((decision) => `${decision.id} ${decision.file}:${decision.line}:${decision.column} ${decision.source}`).join("\n")}\n${pageLabel(matches.length, matchingDecisions.length, options)} matching decisions${matchesNext ? `\nnext page: ${matchesNext}` : ""}`, queryPagination(matches.length, matchingDecisions.length, options));
782
1184
  }
783
1185
  matches = matches.map((decision) => filterDecision(decision, selectedTestSet));
784
1186
  const totalDecisionEvidence = Math.max(0, ...matches.map((decision) => Math.max(decision.vectorObservations.length, decision.conditions.length, decision.tests.length)));
@@ -796,7 +1198,7 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
796
1198
  const decisionNext = nextPageCommand(`${coverageCommand(run.id, options, "decision")} ${shellQuote(selector)}`, totalDecisionEvidence, returnedDecisionEvidence, options);
797
1199
  const result = {
798
1200
  run: run.id,
799
- ...(filterLabel(options) ? { filter: filterLabel(options) } : {}),
1201
+ filters: queryFilters(options),
800
1202
  decisions: matches,
801
1203
  };
802
1204
  return output(result, options, matches
@@ -805,12 +1207,12 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
805
1207
  .join("\n")}\nconfidence ${decision.confidence?.level ?? "unknown"}; asserted MC/DC ${decision.conditions.filter((condition) => condition.assertionCovered).length}/${decision.conditions.length}\nvectors:\n${decision.vectorObservations.map((observation) => ` ${vectorText(observation.vector.values, observation.vector.outcome)} tests=${observation.tests.length} confidence=${observation.confidence?.level ?? "unknown"}`).join("\n") || " none"}`)
806
1208
  .join("\n\n") +
807
1209
  `\n${pageLabel(totalDecisionEvidence, returnedDecisionEvidence, options)} conditions/vectors/tests per decision` +
808
- (decisionNext ? `\nnext page: ${decisionNext}` : ""));
1210
+ (decisionNext ? `\nnext page: ${decisionNext}` : ""), queryPagination(totalDecisionEvidence, returnedDecisionEvidence, options));
809
1211
  }
810
1212
  if (command === "covers") {
811
1213
  const selector = options.positional[0];
812
1214
  if (!selector)
813
- throw new Error("Usage: supercov runs <run-id> coverage covers <source-file:line>");
1215
+ throw new SupercovError("INVALID_ARGUMENT", "Usage: supercov runs <run-id> coverage covers <source-file:line>");
814
1216
  const location = locationSelector(selector);
815
1217
  const line = report.lines.find((candidate) => candidate.file === location.file && candidate.line === location.line);
816
1218
  const allTests = (line?.tests ?? [])
@@ -843,7 +1245,7 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
843
1245
  const coversNext = nextPageCommand(`${coverageCommand(run.id, options, "covers")} ${shellQuote(selector)}`, coversTotal, coversReturned, options);
844
1246
  const result = {
845
1247
  run: run.id,
846
- ...(filterLabel(options) ? { filter: filterLabel(options) } : {}),
1248
+ filters: queryFilters(options),
847
1249
  location,
848
1250
  covered: includesSelectedTest(line?.tests ?? [], selectedTestSet),
849
1251
  confidence: line?.confidence,
@@ -852,16 +1254,18 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
852
1254
  tests,
853
1255
  phases,
854
1256
  };
855
- return output(result, options, `${location.file}:${location.line} ${result.covered ? "covered" : "uncovered"}; confidence ${result.confidence?.level ?? "unknown"}${result.confidence?.e2e ? "; E2E-covered" : ""}\n${tests.map((test) => `test: ${test.name} [${test.id}] (${test.provenance?.kind ?? "unknown"}/${test.provenance?.runner ?? "unknown"})`).join("\n") || "no covering tests"}\n${phases.map((phase) => `phase: ${phase.operation}${phase.status ? ` (${phase.status})` : ""}${phase.source ? ` at ${phase.source}` : ""}`).join("\n")}\n${pageLabel(coversTotal, coversReturned, options)} tests/phases${coversNext ? `\nnext page: ${coversNext}` : ""}`);
1257
+ return output(result, options, `${location.file}:${location.line} ${result.covered ? "covered" : "uncovered"}; confidence ${result.confidence?.level ?? "unknown"}${result.confidence?.e2e ? "; E2E-covered" : ""}\n${tests.map((test) => `test: ${test.name} [${test.id}] (${test.provenance?.kind ?? "unknown"}/${test.provenance?.runner ?? "unknown"})`).join("\n") || "no covering tests"}\n${phases.map((phase) => `phase: ${phase.operation}${phase.status ? ` (${phase.status})` : ""}${phase.source ? ` at ${phase.source}` : ""}`).join("\n")}\n${pageLabel(coversTotal, coversReturned, options)} tests/phases${coversNext ? `\nnext page: ${coversNext}` : ""}`, queryPagination(coversTotal, coversReturned, options));
856
1258
  }
857
1259
  if (command === "test") {
858
1260
  const selector = options.positional.join(" ").toLowerCase();
859
1261
  if (!selector)
860
- throw new Error("Usage: supercov runs <run-id> coverage test <id|name-fragment>");
1262
+ throw new SupercovError("INVALID_ARGUMENT", "Usage: supercov runs <run-id> coverage test <id|name-fragment>");
861
1263
  const matches = report.tests.filter((test) => (!selectedTestSet || selectedTestSet.has(test.id)) &&
862
1264
  (test.id === selector || test.name.toLowerCase().includes(selector)));
863
1265
  if (matches.length === 0)
864
- throw new Error(`Test not found: ${selector}`);
1266
+ throw new SupercovError("TEST_NOT_FOUND", `Test not found: ${selector}`, {
1267
+ details: { selector },
1268
+ });
865
1269
  const testBase = `${coverageCommand(run.id, options, "test")} ${shellQuote(options.positional.join(" "))}`;
866
1270
  if (matches.length > 1) {
867
1271
  const matchingTests = page(matches, options).map((test) => ({
@@ -873,10 +1277,9 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
873
1277
  const matchesNext = nextPageCommand(testBase, matches.length, matchingTests.length, options);
874
1278
  return output({
875
1279
  run: run.id,
876
- total: matches.length,
877
- offset: options.offset,
1280
+ filters: queryFilters(options),
878
1281
  tests: matchingTests,
879
- }, options, `${matchingTests.map((test) => `${test.name} [${test.id}] — ${test.outcome}`).join("\n")}\n${pageLabel(matches.length, matchingTests.length, options)} matching tests${matchesNext ? `\nnext page: ${matchesNext}` : ""}`);
1282
+ }, options, `${matchingTests.map((test) => `${test.name} [${test.id}] — ${test.outcome}`).join("\n")}\n${pageLabel(matches.length, matchingTests.length, options)} matching tests${matchesNext ? `\nnext page: ${matchesNext}` : ""}`, queryPagination(matches.length, matchingTests.length, options));
880
1283
  }
881
1284
  const test = matches[0];
882
1285
  const allPhases = report.phases
@@ -909,11 +1312,11 @@ export async function runQueryCommand(command, args, root = process.cwd()) {
909
1312
  const testNext = nextPageCommand(testBase, testTotal, testReturned, options);
910
1313
  return output({
911
1314
  run: run.id,
912
- ...(filterLabel(options) ? { filter: filterLabel(options) } : {}),
1315
+ filters: queryFilters(options),
913
1316
  tests: [selected],
914
- }, options, `${selected.name}\noutcome ${selected.outcome}${selected.attempts.length ? `; ${selected.attempts.map((attempt) => `retry ${attempt.retry}=${attempt.status}`).join(", ")}` : ""}\n${selected.totals.lines} lines, ${selected.totals.hits} hits, ${selected.totals.decisions} decisions, ${selected.totals.phases} phases\n${selected.lines.map((line) => `line: ${line.file}:${line.line}`).join("\n")}${selected.lines.length && selected.phases.length ? "\n" : ""}${selected.phases.map((phase) => `${phase.kind}: ${phase.operation}${phase.source ? ` at ${phase.source}` : ""}`).join("\n")}\n${pageLabel(testTotal, testReturned, options)} per evidence category${testNext ? `\nnext page: ${testNext}` : ""}`);
1317
+ }, options, `${selected.name}\noutcome ${selected.outcome}${selected.attempts.length ? `; ${selected.attempts.map((attempt) => `retry ${attempt.retry}=${attempt.status}`).join(", ")}` : ""}\n${selected.totals.lines} lines, ${selected.totals.hits} hits, ${selected.totals.decisions} decisions, ${selected.totals.phases} phases\n${selected.lines.map((line) => `line: ${line.file}:${line.line}`).join("\n")}${selected.lines.length && selected.phases.length ? "\n" : ""}${selected.phases.map((phase) => `${phase.kind}: ${phase.operation}${phase.source ? ` at ${phase.source}` : ""}`).join("\n")}\n${pageLabel(testTotal, testReturned, options)} per evidence category${testNext ? `\nnext page: ${testNext}` : ""}`, queryPagination(testTotal, testReturned, options));
915
1318
  }
916
- throw new Error(`Unknown coverage query: ${command}. Try supercov help.`);
1319
+ throw new SupercovError("UNKNOWN_COMMAND", `Unknown coverage query: ${command}. Try supercov help.`, { details: { command } });
917
1320
  }
918
1321
  export const coverageQueryCommands = new Set(["help", "runs", "diff"]);
919
1322
  //# sourceMappingURL=query.js.map