tryscript 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.mjs CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
 
4
- import { a as createExecutionContext, c as parseTestFile, d as mergeConfig, f as resolveCoverageConfig, i as cleanupExecutionContext, n as matchOutput, o as runAfterHook, s as runBlock, t as VERSION, u as loadConfig } from "./src-CC3xA1cp.mjs";
4
+ import { b as resolveCoverageConfig, f as cleanupExecutionContext, g as parseTestFile, h as runBlock, m as runAfterHook, p as createExecutionContext, r as writeCaptureLog, s as expandTestFile, t as VERSION, u as matchOutput, v as loadConfig, y as mergeConfig } from "./src-BQxIhzgF.mjs";
5
5
  import { fileURLToPath } from "node:url";
6
- import { existsSync, readFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { dirname, join, resolve } from "node:path";
8
8
  import { spawn } from "node:child_process";
9
9
  import { access, mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises";
10
10
  import { tmpdir } from "node:os";
11
+ import { writeFile } from "atomically";
11
12
  import { Command } from "commander";
12
13
  import fg from "fast-glob";
13
14
  import pc from "picocolors";
14
15
  import { createPatch } from "diff";
15
- import { writeFile } from "atomically";
16
16
 
17
17
  //#region src/cli/lib/shared.ts
18
18
  /**
@@ -134,9 +134,10 @@ function reportSummary(summary, _options) {
134
134
  async function updateTestFile(file, results) {
135
135
  let content = file.rawContent;
136
136
  const changes = [];
137
- const blocksWithResults = file.blocks.map((block, i) => ({
137
+ const resultByBlock = new Map(results.map((result) => [result.block, result]));
138
+ const blocksWithResults = [...file.blocks].map((block) => ({
138
139
  block,
139
- result: results[i]
140
+ result: resultByBlock.get(block)
140
141
  })).reverse();
141
142
  for (const { block, result } of blocksWithResults) {
142
143
  if (!result) continue;
@@ -159,15 +160,248 @@ async function updateTestFile(file, results) {
159
160
  * Build an updated console block with new expected output.
160
161
  */
161
162
  function buildUpdatedBlock(block, result) {
162
- const lines = ["```console", ...block.command.split("\n").map((line, i) => {
163
+ const fence = "`".repeat(/^(`+)/.exec(block.rawContent)?.[1]?.length ?? 3);
164
+ const commandLines = block.command.split("\n").map((line, i) => {
163
165
  return i === 0 ? `$ ${line}` : `> ${line}`;
164
- })];
166
+ });
167
+ const lines = [`${fence}console`, ...commandLines];
165
168
  const trimmedOutput = result.actualOutput.trimEnd();
166
169
  if (trimmedOutput) lines.push(trimmedOutput);
167
- lines.push(`? ${result.actualExitCode}`, "```");
170
+ lines.push(`? ${result.actualExitCode}`, fence);
168
171
  return lines.join("\n");
169
172
  }
170
173
 
174
+ //#endregion
175
+ //#region src/lib/lcov.ts
176
+ /**
177
+ * LCOV parsing, merging, and writing utilities.
178
+ *
179
+ * LCOV format reference:
180
+ * - SF: Source file path
181
+ * - DA:linenum,hitcount - Line data
182
+ * - FN:linenum,funcname - Function definition
183
+ * - FNDA:hitcount,funcname - Function hit data
184
+ * - FNF: Functions found count
185
+ * - FNH: Functions hit count
186
+ * - BRF: Branches found count
187
+ * - BRH: Branches hit count
188
+ * - BRDA:line,block,branch,taken - Branch data
189
+ * - LF: Lines found count
190
+ * - LH: Lines hit count
191
+ * - end_of_record - End of file record
192
+ */
193
+ /**
194
+ * Parse LCOV content into structured data.
195
+ */
196
+ function parseLcov(content) {
197
+ const files = /* @__PURE__ */ new Map();
198
+ let currentFile = null;
199
+ for (const line of content.split("\n")) {
200
+ const trimmed = line.trim();
201
+ if (trimmed.startsWith("SF:")) {
202
+ const path = trimmed.slice(3);
203
+ currentFile = {
204
+ path,
205
+ lines: /* @__PURE__ */ new Map(),
206
+ functions: /* @__PURE__ */ new Map(),
207
+ branches: []
208
+ };
209
+ files.set(path, currentFile);
210
+ } else if (trimmed.startsWith("DA:") && currentFile) {
211
+ const parts = trimmed.slice(3).split(",");
212
+ const lineNumber = parseInt(parts[0], 10);
213
+ const hitCount = parseInt(parts[1], 10);
214
+ currentFile.lines.set(lineNumber, {
215
+ lineNumber,
216
+ hitCount
217
+ });
218
+ } else if (trimmed.startsWith("FN:") && currentFile) {
219
+ const parts = trimmed.slice(3).split(",");
220
+ const lineNumber = parseInt(parts[0], 10);
221
+ const name = parts.slice(1).join(",");
222
+ if (!currentFile.functions.has(name)) currentFile.functions.set(name, {
223
+ name,
224
+ lineNumber,
225
+ hitCount: 0
226
+ });
227
+ else currentFile.functions.get(name).lineNumber = lineNumber;
228
+ } else if (trimmed.startsWith("FNDA:") && currentFile) {
229
+ const parts = trimmed.slice(5).split(",");
230
+ const hitCount = parseInt(parts[0], 10);
231
+ const name = parts.slice(1).join(",");
232
+ if (currentFile.functions.has(name)) currentFile.functions.get(name).hitCount = hitCount;
233
+ else currentFile.functions.set(name, {
234
+ name,
235
+ lineNumber: 0,
236
+ hitCount
237
+ });
238
+ } else if (trimmed.startsWith("BRDA:") && currentFile) {
239
+ const parts = trimmed.slice(5).split(",");
240
+ currentFile.branches.push({
241
+ line: parseInt(parts[0], 10),
242
+ block: parseInt(parts[1], 10),
243
+ branch: parseInt(parts[2], 10),
244
+ taken: parts[3] === "-" ? -1 : parseInt(parts[3], 10)
245
+ });
246
+ } else if (trimmed === "end_of_record") currentFile = null;
247
+ }
248
+ return { files };
249
+ }
250
+ /**
251
+ * Merge multiple LCOV data structures, taking max hit counts.
252
+ */
253
+ function mergeLcov(...lcovs) {
254
+ const merged = /* @__PURE__ */ new Map();
255
+ for (const lcov of lcovs) for (const [path, file] of lcov.files) if (!merged.has(path)) merged.set(path, {
256
+ path,
257
+ lines: new Map(file.lines),
258
+ functions: new Map(file.functions),
259
+ branches: [...file.branches]
260
+ });
261
+ else {
262
+ const existing = merged.get(path);
263
+ for (const [lineNum, lineData] of file.lines) {
264
+ const existingLine = existing.lines.get(lineNum);
265
+ if (existingLine) existingLine.hitCount = Math.max(existingLine.hitCount, lineData.hitCount);
266
+ else existing.lines.set(lineNum, { ...lineData });
267
+ }
268
+ for (const [name, funcData] of file.functions) {
269
+ const existingFunc = existing.functions.get(name);
270
+ if (existingFunc) existingFunc.hitCount = Math.max(existingFunc.hitCount, funcData.hitCount);
271
+ else existing.functions.set(name, { ...funcData });
272
+ }
273
+ for (const branch of file.branches) {
274
+ const existingBranch = existing.branches.find((b) => b.line === branch.line && b.block === branch.block && b.branch === branch.branch);
275
+ if (existingBranch) {
276
+ if (branch.taken >= 0) existingBranch.taken = existingBranch.taken >= 0 ? Math.max(existingBranch.taken, branch.taken) : branch.taken;
277
+ } else existing.branches.push({ ...branch });
278
+ }
279
+ }
280
+ return { files: merged };
281
+ }
282
+ /**
283
+ * Convert LCOV data back to LCOV format string.
284
+ */
285
+ function formatLcov(lcov) {
286
+ const lines = [];
287
+ for (const file of lcov.files.values()) {
288
+ lines.push(`SF:${file.path}`);
289
+ const sortedFunctions = [...file.functions.values()].sort((a, b) => a.lineNumber - b.lineNumber);
290
+ for (const func of sortedFunctions) lines.push(`FN:${func.lineNumber},${func.name}`);
291
+ for (const func of sortedFunctions) lines.push(`FNDA:${func.hitCount},${func.name}`);
292
+ const fnf = file.functions.size;
293
+ const fnh = [...file.functions.values()].filter((f) => f.hitCount > 0).length;
294
+ lines.push(`FNF:${fnf}`);
295
+ lines.push(`FNH:${fnh}`);
296
+ for (const branch of file.branches) {
297
+ const taken = branch.taken < 0 ? "-" : branch.taken.toString();
298
+ lines.push(`BRDA:${branch.line},${branch.block},${branch.branch},${taken}`);
299
+ }
300
+ const brf = file.branches.length;
301
+ const brh = file.branches.filter((b) => b.taken > 0).length;
302
+ lines.push(`BRF:${brf}`);
303
+ lines.push(`BRH:${brh}`);
304
+ const sortedLines = [...file.lines.values()].sort((a, b) => a.lineNumber - b.lineNumber);
305
+ for (const line of sortedLines) lines.push(`DA:${line.lineNumber},${line.hitCount}`);
306
+ const lf = file.lines.size;
307
+ const lh = [...file.lines.values()].filter((l) => l.hitCount > 0).length;
308
+ lines.push(`LF:${lf}`);
309
+ lines.push(`LH:${lh}`);
310
+ lines.push("end_of_record");
311
+ }
312
+ return lines.join("\n") + "\n";
313
+ }
314
+ /**
315
+ * Convert LCOV data to JSON summary format (compatible with istanbul/vitest).
316
+ */
317
+ function lcovToJsonSummary(lcov) {
318
+ const withPct = (total, covered) => ({
319
+ total,
320
+ covered,
321
+ skipped: 0,
322
+ pct: total > 0 ? parseFloat((covered / total * 100).toFixed(2)) : 100
323
+ });
324
+ const totals = {
325
+ lines: {
326
+ total: 0,
327
+ covered: 0
328
+ },
329
+ functions: {
330
+ total: 0,
331
+ covered: 0
332
+ },
333
+ branches: {
334
+ total: 0,
335
+ covered: 0
336
+ }
337
+ };
338
+ const summary = { total: {
339
+ lines: withPct(0, 0),
340
+ statements: withPct(0, 0),
341
+ functions: withPct(0, 0),
342
+ branches: withPct(0, 0),
343
+ branchesTrue: {
344
+ total: 0,
345
+ covered: 0,
346
+ skipped: 0,
347
+ pct: 100
348
+ }
349
+ } };
350
+ for (const file of lcov.files.values()) {
351
+ const linesTotal = file.lines.size;
352
+ const linesCovered = [...file.lines.values()].filter((l) => l.hitCount > 0).length;
353
+ const funcsTotal = file.functions.size;
354
+ const funcsCovered = [...file.functions.values()].filter((f) => f.hitCount > 0).length;
355
+ const branchesTotal = file.branches.length;
356
+ const branchesCovered = file.branches.filter((b) => b.taken > 0).length;
357
+ summary[file.path] = {
358
+ lines: withPct(linesTotal, linesCovered),
359
+ statements: withPct(linesTotal, linesCovered),
360
+ functions: withPct(funcsTotal, funcsCovered),
361
+ branches: withPct(branchesTotal, branchesCovered)
362
+ };
363
+ totals.lines.total += linesTotal;
364
+ totals.lines.covered += linesCovered;
365
+ totals.functions.total += funcsTotal;
366
+ totals.functions.covered += funcsCovered;
367
+ totals.branches.total += branchesTotal;
368
+ totals.branches.covered += branchesCovered;
369
+ }
370
+ summary.total = {
371
+ lines: withPct(totals.lines.total, totals.lines.covered),
372
+ statements: withPct(totals.lines.total, totals.lines.covered),
373
+ functions: withPct(totals.functions.total, totals.functions.covered),
374
+ branches: withPct(totals.branches.total, totals.branches.covered),
375
+ branchesTrue: {
376
+ total: 0,
377
+ covered: 0,
378
+ skipped: 0,
379
+ pct: 100
380
+ }
381
+ };
382
+ return summary;
383
+ }
384
+ /**
385
+ * Read and parse an LCOV file.
386
+ */
387
+ function readLcovFile(path) {
388
+ return parseLcov(readFileSync(path, "utf8"));
389
+ }
390
+ /**
391
+ * Write LCOV data to a file.
392
+ */
393
+ function writeLcovFile(path, lcov) {
394
+ mkdirSync(dirname(path), { recursive: true });
395
+ writeFileSync(path, formatLcov(lcov));
396
+ }
397
+ /**
398
+ * Write JSON summary to a file.
399
+ */
400
+ function writeJsonSummary(path, summary) {
401
+ mkdirSync(dirname(path), { recursive: true });
402
+ writeFileSync(path, JSON.stringify(summary, null, 2));
403
+ }
404
+
171
405
  //#endregion
172
406
  //#region src/lib/coverage.ts
173
407
  /**
@@ -273,6 +507,33 @@ async function cleanupCoverageContext(ctx) {
273
507
  });
274
508
  } catch {}
275
509
  }
510
+ /**
511
+ * Merge external LCOV file with generated coverage.
512
+ * Reads the generated lcov.info, merges with external LCOV, and writes back.
513
+ * Also generates coverage-summary.json for badge generation.
514
+ *
515
+ * @returns Object with merged coverage percentages, or null if merge failed
516
+ */
517
+ function mergeExternalCoverage(reportsDir, externalLcovPath) {
518
+ const generatedLcovPath = join(reportsDir, "lcov.info");
519
+ if (!existsSync(externalLcovPath)) {
520
+ console.error(`External LCOV file not found: ${externalLcovPath}`);
521
+ return null;
522
+ }
523
+ if (!existsSync(generatedLcovPath)) {
524
+ console.error(`Generated LCOV file not found: ${generatedLcovPath}`);
525
+ console.error("Make sure \"lcov\" is included in reporters");
526
+ return null;
527
+ }
528
+ const mergedLcov = mergeLcov(readLcovFile(externalLcovPath), readLcovFile(generatedLcovPath));
529
+ writeLcovFile(generatedLcovPath, mergedLcov);
530
+ const summary = lcovToJsonSummary(mergedLcov);
531
+ writeJsonSummary(join(reportsDir, "coverage-summary.json"), summary);
532
+ return {
533
+ lines: summary.total.lines.pct,
534
+ functions: summary.total.functions.pct
535
+ };
536
+ }
276
537
 
277
538
  //#endregion
278
539
  //#region src/cli/commands/run.ts
@@ -280,10 +541,32 @@ async function cleanupCoverageContext(ctx) {
280
541
  * Register the run command.
281
542
  */
282
543
  function registerRunCommand(program) {
283
- program.command("run").description("Run golden tests").argument("[files...]", "Test files to run (default: **/*.tryscript.md)").option("--update", "Update golden files with actual output").option("--diff", "Show diff on failure (default: true)").option("--no-diff", "Hide diff on failure").option("--fail-fast", "Stop on first failure").option("--filter <pattern>", "Filter tests by name pattern").option("--verbose", "Show detailed output including passing test output").option("--quiet", "Suppress non-essential output (only show failures)").option("--coverage", "Enable code coverage collection (requires c8)").option("--coverage-dir <dir>", "Coverage output directory (default: coverage-tryscript)").option("--coverage-reporter <reporter...>", "Coverage reporters (default: text, html). Can be specified multiple times.").option("--coverage-exclude <pattern...>", "Patterns to exclude from coverage (c8 --exclude). Can be specified multiple times.").option("--coverage-exclude-node-modules", "Exclude node_modules from coverage (c8 --exclude-node-modules, default: true)").option("--no-coverage-exclude-node-modules", "Include node_modules in coverage (c8 --no-exclude-node-modules)").option("--coverage-exclude-after-remap", "Apply exclude logic after sourcemap remapping (c8 --exclude-after-remap)").option("--coverage-skip-full", "Hide files with 100% coverage (c8 --skip-full)").option("--coverage-allow-external", "Allow files from outside cwd (c8 --allowExternal)").option("--coverage-monocart", "Use monocart for accurate line counts, better for merging with vitest (c8 --experimental-monocart)").action(runCommand$1);
544
+ program.command("run").description("Run golden tests").argument("[files...]", "Test files to run (default: **/*.tryscript.md)").option("--update", "Update golden files with actual output").option("--diff", "Show diff on failure (default: true)").option("--no-diff", "Hide diff on failure").option("--fail-fast", "Stop on first failure").option("--filter <pattern>", "Filter tests by name pattern").option("--verbose", "Show detailed output including passing test output").option("--quiet", "Suppress non-essential output (only show failures)").option("--expand", "Expand unknown wildcards (??? and [??]) with actual output").option("--expand-generic", "Expand unknown and generic wildcards with actual output").option("--expand-all", "Expand all wildcards (including named patterns) with actual output").option("--capture-log <path>", "Write wildcard capture log to YAML file").option("--coverage", "Enable code coverage collection (requires c8)").option("--coverage-dir <dir>", "Coverage output directory (default: coverage-tryscript)").option("--coverage-reporter <reporter...>", "Coverage reporters (default: text, html). Can be specified multiple times.").option("--coverage-exclude <pattern...>", "Patterns to exclude from coverage (c8 --exclude). Can be specified multiple times.").option("--coverage-exclude-node-modules", "Exclude node_modules from coverage (c8 --exclude-node-modules, default: true)").option("--no-coverage-exclude-node-modules", "Include node_modules in coverage (c8 --no-exclude-node-modules)").option("--coverage-exclude-after-remap", "Apply exclude logic after sourcemap remapping (c8 --exclude-after-remap)").option("--coverage-skip-full", "Hide files with 100% coverage (c8 --skip-full)").option("--coverage-allow-external", "Allow files from outside cwd (c8 --allowExternal)").option("--coverage-monocart", "Use monocart for accurate line counts, better for merging with vitest (c8 --experimental-monocart)").option("--merge-lcov <path>", "Merge coverage from an existing LCOV file (e.g., from vitest --coverage)").action(runCommand$1);
545
+ }
546
+ /**
547
+ * Count unknown wildcard tokens (`???` and `[??]`) in expected output.
548
+ */
549
+ function countUnknownWildcards(expectedOutput) {
550
+ return (expectedOutput.match(/\[\?\?]/g) ?? []).length + (expectedOutput.match(/\?\?\?\n/g) ?? []).length;
284
551
  }
285
552
  async function runCommand$1(files, options) {
286
553
  const startTime = Date.now();
554
+ if ([
555
+ options.expand,
556
+ options.expandGeneric,
557
+ options.expandAll
558
+ ].filter(Boolean).length > 1) {
559
+ logError("--expand, --expand-generic, and --expand-all are mutually exclusive");
560
+ process.exit(1);
561
+ }
562
+ let expandLevel;
563
+ if (options.expand) expandLevel = "unknown";
564
+ else if (options.expandGeneric) expandLevel = "generic";
565
+ else if (options.expandAll) expandLevel = "all";
566
+ if (expandLevel && options.update) {
567
+ logError("--expand* flags and --update are mutually exclusive");
568
+ process.exit(1);
569
+ }
287
570
  const opts = {
288
571
  diff: options.diff !== false,
289
572
  verbose: options.verbose ?? false,
@@ -309,20 +592,32 @@ async function runCommand$1(files, options) {
309
592
  logError("Coverage requires c8. Install with: npm install -D c8");
310
593
  process.exit(1);
311
594
  }
595
+ let reporters = options.coverageReporter ?? globalConfig.coverage?.reporters;
596
+ if (options.mergeLcov) {
597
+ if (!reporters) reporters = [
598
+ "text",
599
+ "html",
600
+ "lcov"
601
+ ];
602
+ else if (!reporters.includes("lcov")) reporters = [...reporters, "lcov"];
603
+ }
312
604
  coverageCtx = await createCoverageContext({
313
605
  ...globalConfig.coverage,
314
606
  reportsDir: options.coverageDir ?? globalConfig.coverage?.reportsDir,
315
- reporters: options.coverageReporter ?? globalConfig.coverage?.reporters,
607
+ reporters,
316
608
  exclude: options.coverageExclude ?? globalConfig.coverage?.exclude,
317
609
  excludeNodeModules: options.coverageExcludeNodeModules ?? globalConfig.coverage?.excludeNodeModules,
318
610
  excludeAfterRemap: options.coverageExcludeAfterRemap ?? globalConfig.coverage?.excludeAfterRemap,
319
611
  skipFull: options.coverageSkipFull ?? globalConfig.coverage?.skipFull,
320
612
  allowExternal: options.coverageAllowExternal ?? globalConfig.coverage?.allowExternal,
321
- monocart: options.coverageMonocart ?? globalConfig.coverage?.monocart
613
+ monocart: options.coverageMonocart ?? globalConfig.coverage?.monocart,
614
+ mergeLcov: options.mergeLcov ?? globalConfig.coverage?.mergeLcov
322
615
  });
323
616
  coverageEnv = getCoverageEnv(coverageCtx);
324
617
  }
325
618
  const fileResults = [];
619
+ const fileContexts = /* @__PURE__ */ new Map();
620
+ const filePatterns = /* @__PURE__ */ new Map();
326
621
  let shouldStop = false;
327
622
  for (const filePath of testFiles) {
328
623
  if (shouldStop) break;
@@ -338,6 +633,7 @@ async function runCommand$1(files, options) {
338
633
  if (blocksToRun.length === 0) continue;
339
634
  const ctx = await createExecutionContext(config, filePath, coverageEnv);
340
635
  const results = [];
636
+ let fileContext;
341
637
  try {
342
638
  for (const block of blocksToRun) {
343
639
  const result = await runBlock(block, ctx);
@@ -364,6 +660,12 @@ async function runCommand$1(files, options) {
364
660
  }
365
661
  }
366
662
  await runAfterHook(ctx);
663
+ fileContext = {
664
+ root: ctx.testDir,
665
+ cwd: ctx.cwd
666
+ };
667
+ fileContexts.set(filePath, fileContext);
668
+ filePatterns.set(filePath, config.patterns ?? {});
367
669
  } finally {
368
670
  await cleanupExecutionContext(ctx);
369
671
  }
@@ -379,7 +681,14 @@ async function runCommand$1(files, options) {
379
681
  const { updated, changes } = await updateTestFile(testFile, results);
380
682
  if (updated) console.error(colors.warn(` ${status.update} Updated: ${changes.join(", ")}`));
381
683
  }
684
+ if (expandLevel && fileContext) {
685
+ const { expanded, expandedCount, changes } = await expandTestFile(testFile, results, expandLevel, fileContext, config.patterns ?? {});
686
+ if (expanded) console.error(colors.warn(` ${status.update} Expanded ${expandedCount} wildcard(s): ${changes.join(", ")}`));
687
+ }
382
688
  }
689
+ let totalUnknownWildcards = 0;
690
+ for (const fr of fileResults) for (const block of fr.file.blocks) totalUnknownWildcards += countUnknownWildcards(block.expectedOutput);
691
+ if (totalUnknownWildcards > 0) logWarn(`${totalUnknownWildcards} unknown wildcard(s) found (??? or [??]). These are temporary and should be expanded. Use --expand to fill them in.`);
383
692
  const summary = {
384
693
  files: fileResults,
385
694
  totalPassed: fileResults.reduce((sum, f) => sum + f.results.filter((r) => r.passed).length, 0),
@@ -388,11 +697,25 @@ async function runCommand$1(files, options) {
388
697
  duration: Date.now() - startTime
389
698
  };
390
699
  reportSummary(summary, opts);
700
+ if (options.captureLog) try {
701
+ await writeCaptureLog(options.captureLog, fileResults, (file) => fileContexts.get(file.path) ?? {
702
+ root: process.cwd(),
703
+ cwd: process.cwd()
704
+ }, (file) => filePatterns.get(file.path) ?? {});
705
+ console.error(colors.info(`Capture log written to ${options.captureLog}`));
706
+ } catch (error) {
707
+ logError(`Failed to write capture log: ${error instanceof Error ? error.message : String(error)}`);
708
+ }
391
709
  if (coverageCtx) {
392
710
  console.error("\nGenerating coverage report...");
393
711
  try {
394
712
  await generateCoverageReport(coverageCtx);
395
713
  console.error(colors.success(`Coverage report written to ${coverageCtx.options.reportsDir}/`));
714
+ if (coverageCtx.options.mergeLcov) {
715
+ console.error(`Merging with external coverage: ${coverageCtx.options.mergeLcov}`);
716
+ const merged = mergeExternalCoverage(coverageCtx.options.reportsDir, coverageCtx.options.mergeLcov);
717
+ if (merged) console.error(colors.success(`Merged coverage: ${merged.lines}% lines, ${merged.functions}% functions`));
718
+ }
396
719
  } catch (error) {
397
720
  logError(`Failed to generate coverage report: ${error instanceof Error ? error.message : String(error)}`);
398
721
  } finally {
@@ -408,7 +731,7 @@ async function runCommand$1(files, options) {
408
731
  * Register the coverage command.
409
732
  */
410
733
  function registerCoverageCommand(program) {
411
- program.command("coverage").description("Run commands with merged V8 coverage").argument("<commands...>", "Commands to run (each will inherit coverage environment)").option("--reports-dir <dir>", "Coverage output directory (default: coverage)").option("--reporters <reporters>", "Comma-separated coverage reporters (default: text,json,json-summary,lcov,html)").option("--include <patterns>", "Comma-separated patterns to include in coverage").option("--exclude <patterns>", "Comma-separated patterns to exclude from coverage").option("--exclude-node-modules", "Exclude node_modules from coverage (default: true)", true).option("--no-exclude-node-modules", "Include node_modules in coverage").option("--exclude-after-remap", "Apply exclude logic after sourcemap remapping").option("--skip-full", "Hide files with 100% coverage").option("--allow-external", "Allow files from outside cwd").option("--monocart", "Use monocart for accurate line counts (recommended for merging)").option("--src <dir>", "Source directory for sourcemap remapping (default: src)").option("--verbose", "Show coverage summary after each command for debugging").action(coverageCommand);
734
+ program.command("coverage").description("Run commands with merged V8 coverage").argument("<commands...>", "Commands to run (each will inherit coverage environment)").option("--reports-dir <dir>", "Coverage output directory (default: coverage)").option("--reporters <reporters>", "Comma-separated coverage reporters (default: text,json,json-summary,lcov,html)").option("--include <patterns>", "Comma-separated patterns to include in coverage").option("--exclude <patterns>", "Comma-separated patterns to exclude from coverage").option("--exclude-node-modules", "Exclude node_modules from coverage (default: true)", true).option("--no-exclude-node-modules", "Include node_modules in coverage").option("--exclude-after-remap", "Apply exclude logic after sourcemap remapping").option("--skip-full", "Hide files with 100% coverage").option("--allow-external", "Allow files from outside cwd").option("--monocart", "Use monocart for accurate line counts (recommended for merging)").option("--src <dir>", "Source directory for sourcemap remapping (default: src)").option("--verbose", "Show coverage summary after each command for debugging").option("--merge-lcov <path>", "Merge coverage from an existing LCOV file (e.g., from vitest --coverage)").action(coverageCommand);
412
735
  }
413
736
  /**
414
737
  * Run a command with inherited coverage environment.
@@ -604,12 +927,32 @@ async function coverageCommand(commands, options) {
604
927
  if (parsedOptions.verbose && stats.fileCount > 0) await generateTextReport(coverageTemp, parsedOptions, command);
605
928
  previousFileCount = stats.fileCount;
606
929
  }
607
- console.error(colors.info("\n=== Generating merged coverage report ==="));
930
+ console.error(colors.info("\n=== Generating coverage report ==="));
608
931
  if (!await generateReport(coverageTemp, parsedOptions)) {
609
932
  logError("Failed to generate coverage report");
610
933
  process.exit(1);
611
934
  }
612
- console.error(colors.success(`\nCoverage report written to ${parsedOptions.reportsDir ?? "coverage"}/`));
935
+ const reportsDir = parsedOptions.reportsDir ?? "coverage";
936
+ if (parsedOptions.mergeLcov) {
937
+ const externalLcovPath = parsedOptions.mergeLcov;
938
+ const generatedLcovPath = join(reportsDir, "lcov.info");
939
+ if (!existsSync(externalLcovPath)) {
940
+ logError(`External LCOV file not found: ${externalLcovPath}`);
941
+ process.exit(1);
942
+ }
943
+ if (!existsSync(generatedLcovPath)) {
944
+ logError(`Generated LCOV file not found: ${generatedLcovPath}`);
945
+ logError("Make sure \"lcov\" is included in reporters");
946
+ process.exit(1);
947
+ }
948
+ console.error(colors.info(`\nMerging with external coverage: ${externalLcovPath}`));
949
+ const mergedLcov = mergeLcov(readLcovFile(externalLcovPath), readLcovFile(generatedLcovPath));
950
+ writeLcovFile(generatedLcovPath, mergedLcov);
951
+ const summary = lcovToJsonSummary(mergedLcov);
952
+ writeJsonSummary(join(reportsDir, "coverage-summary.json"), summary);
953
+ console.error(colors.success(`\nMerged coverage: ${summary.total.lines.pct}% lines, ${summary.total.functions.pct}% functions`));
954
+ }
955
+ console.error(colors.success(`\nCoverage report written to ${reportsDir}/`));
613
956
  } finally {
614
957
  await rm(coverageTemp, {
615
958
  recursive: true,