tryscript 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # tryscript
2
2
 
3
- [![CI](https://github.com/jlevy/tryscript/actions/workflows/ci.yml/badge.svg)](https://github.com/jlevy/tryscript/actions/runs/20774584634)
4
- [![Coverage](https://raw.githubusercontent.com/jlevy/tryscript/main/badges/packages/tryscript/coverage-total.svg)](https://github.com/jlevy/tryscript/actions/runs/20774584634)
3
+ [![CI](https://github.com/jlevy/tryscript/actions/workflows/ci.yml/badge.svg)](https://github.com/jlevy/tryscript/actions/runs/20828303484)
4
+ [![Coverage](https://raw.githubusercontent.com/jlevy/tryscript/main/badges/packages/tryscript/coverage-total.svg)](https://github.com/jlevy/tryscript/actions/runs/20828303484)
5
5
  [![npm version](https://img.shields.io/npm/v/tryscript)](https://www.npmjs.com/package/tryscript)
6
6
  [![X Follow](https://img.shields.io/twitter/follow/ojoshe)](https://x.com/ojoshe)
7
7
 
package/dist/bin.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
 
4
- const require_src = require('./src-rTwoOhL4.cjs');
4
+ const require_src = require('./src-BGWMAShO.cjs');
5
5
  let node_url = require("node:url");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
@@ -170,6 +170,237 @@ function buildUpdatedBlock(block, result) {
170
170
  return lines.join("\n");
171
171
  }
172
172
 
173
+ //#endregion
174
+ //#region src/lib/lcov.ts
175
+ /**
176
+ * LCOV parsing, merging, and writing utilities.
177
+ *
178
+ * LCOV format reference:
179
+ * - SF: Source file path
180
+ * - DA:linenum,hitcount - Line data
181
+ * - FN:linenum,funcname - Function definition
182
+ * - FNDA:hitcount,funcname - Function hit data
183
+ * - FNF: Functions found count
184
+ * - FNH: Functions hit count
185
+ * - BRF: Branches found count
186
+ * - BRH: Branches hit count
187
+ * - BRDA:line,block,branch,taken - Branch data
188
+ * - LF: Lines found count
189
+ * - LH: Lines hit count
190
+ * - end_of_record - End of file record
191
+ */
192
+ /**
193
+ * Parse LCOV content into structured data.
194
+ */
195
+ function parseLcov(content) {
196
+ const files = /* @__PURE__ */ new Map();
197
+ let currentFile = null;
198
+ for (const line of content.split("\n")) {
199
+ const trimmed = line.trim();
200
+ if (trimmed.startsWith("SF:")) {
201
+ const path = trimmed.slice(3);
202
+ currentFile = {
203
+ path,
204
+ lines: /* @__PURE__ */ new Map(),
205
+ functions: /* @__PURE__ */ new Map(),
206
+ branches: []
207
+ };
208
+ files.set(path, currentFile);
209
+ } else if (trimmed.startsWith("DA:") && currentFile) {
210
+ const parts = trimmed.slice(3).split(",");
211
+ const lineNumber = parseInt(parts[0], 10);
212
+ const hitCount = parseInt(parts[1], 10);
213
+ currentFile.lines.set(lineNumber, {
214
+ lineNumber,
215
+ hitCount
216
+ });
217
+ } else if (trimmed.startsWith("FN:") && currentFile) {
218
+ const parts = trimmed.slice(3).split(",");
219
+ const lineNumber = parseInt(parts[0], 10);
220
+ const name = parts.slice(1).join(",");
221
+ if (!currentFile.functions.has(name)) currentFile.functions.set(name, {
222
+ name,
223
+ lineNumber,
224
+ hitCount: 0
225
+ });
226
+ else currentFile.functions.get(name).lineNumber = lineNumber;
227
+ } else if (trimmed.startsWith("FNDA:") && currentFile) {
228
+ const parts = trimmed.slice(5).split(",");
229
+ const hitCount = parseInt(parts[0], 10);
230
+ const name = parts.slice(1).join(",");
231
+ if (currentFile.functions.has(name)) currentFile.functions.get(name).hitCount = hitCount;
232
+ else currentFile.functions.set(name, {
233
+ name,
234
+ lineNumber: 0,
235
+ hitCount
236
+ });
237
+ } else if (trimmed.startsWith("BRDA:") && currentFile) {
238
+ const parts = trimmed.slice(5).split(",");
239
+ currentFile.branches.push({
240
+ line: parseInt(parts[0], 10),
241
+ block: parseInt(parts[1], 10),
242
+ branch: parseInt(parts[2], 10),
243
+ taken: parts[3] === "-" ? -1 : parseInt(parts[3], 10)
244
+ });
245
+ } else if (trimmed === "end_of_record") currentFile = null;
246
+ }
247
+ return { files };
248
+ }
249
+ /**
250
+ * Merge multiple LCOV data structures, taking max hit counts.
251
+ */
252
+ function mergeLcov(...lcovs) {
253
+ const merged = /* @__PURE__ */ new Map();
254
+ for (const lcov of lcovs) for (const [path, file] of lcov.files) if (!merged.has(path)) merged.set(path, {
255
+ path,
256
+ lines: new Map(file.lines),
257
+ functions: new Map(file.functions),
258
+ branches: [...file.branches]
259
+ });
260
+ else {
261
+ const existing = merged.get(path);
262
+ for (const [lineNum, lineData] of file.lines) {
263
+ const existingLine = existing.lines.get(lineNum);
264
+ if (existingLine) existingLine.hitCount = Math.max(existingLine.hitCount, lineData.hitCount);
265
+ else existing.lines.set(lineNum, { ...lineData });
266
+ }
267
+ for (const [name, funcData] of file.functions) {
268
+ const existingFunc = existing.functions.get(name);
269
+ if (existingFunc) existingFunc.hitCount = Math.max(existingFunc.hitCount, funcData.hitCount);
270
+ else existing.functions.set(name, { ...funcData });
271
+ }
272
+ for (const branch of file.branches) {
273
+ const existingBranch = existing.branches.find((b) => b.line === branch.line && b.block === branch.block && b.branch === branch.branch);
274
+ if (existingBranch) {
275
+ if (branch.taken >= 0) existingBranch.taken = existingBranch.taken >= 0 ? Math.max(existingBranch.taken, branch.taken) : branch.taken;
276
+ } else existing.branches.push({ ...branch });
277
+ }
278
+ }
279
+ return { files: merged };
280
+ }
281
+ /**
282
+ * Convert LCOV data back to LCOV format string.
283
+ */
284
+ function formatLcov(lcov) {
285
+ const lines = [];
286
+ for (const file of lcov.files.values()) {
287
+ lines.push(`SF:${file.path}`);
288
+ const sortedFunctions = [...file.functions.values()].sort((a, b) => a.lineNumber - b.lineNumber);
289
+ for (const func of sortedFunctions) lines.push(`FN:${func.lineNumber},${func.name}`);
290
+ for (const func of sortedFunctions) lines.push(`FNDA:${func.hitCount},${func.name}`);
291
+ const fnf = file.functions.size;
292
+ const fnh = [...file.functions.values()].filter((f) => f.hitCount > 0).length;
293
+ lines.push(`FNF:${fnf}`);
294
+ lines.push(`FNH:${fnh}`);
295
+ for (const branch of file.branches) {
296
+ const taken = branch.taken < 0 ? "-" : branch.taken.toString();
297
+ lines.push(`BRDA:${branch.line},${branch.block},${branch.branch},${taken}`);
298
+ }
299
+ const brf = file.branches.length;
300
+ const brh = file.branches.filter((b) => b.taken > 0).length;
301
+ lines.push(`BRF:${brf}`);
302
+ lines.push(`BRH:${brh}`);
303
+ const sortedLines = [...file.lines.values()].sort((a, b) => a.lineNumber - b.lineNumber);
304
+ for (const line of sortedLines) lines.push(`DA:${line.lineNumber},${line.hitCount}`);
305
+ const lf = file.lines.size;
306
+ const lh = [...file.lines.values()].filter((l) => l.hitCount > 0).length;
307
+ lines.push(`LF:${lf}`);
308
+ lines.push(`LH:${lh}`);
309
+ lines.push("end_of_record");
310
+ }
311
+ return lines.join("\n") + "\n";
312
+ }
313
+ /**
314
+ * Convert LCOV data to JSON summary format (compatible with istanbul/vitest).
315
+ */
316
+ function lcovToJsonSummary(lcov) {
317
+ const withPct = (total, covered) => ({
318
+ total,
319
+ covered,
320
+ skipped: 0,
321
+ pct: total > 0 ? parseFloat((covered / total * 100).toFixed(2)) : 100
322
+ });
323
+ const totals = {
324
+ lines: {
325
+ total: 0,
326
+ covered: 0
327
+ },
328
+ functions: {
329
+ total: 0,
330
+ covered: 0
331
+ },
332
+ branches: {
333
+ total: 0,
334
+ covered: 0
335
+ }
336
+ };
337
+ const summary = { total: {
338
+ lines: withPct(0, 0),
339
+ statements: withPct(0, 0),
340
+ functions: withPct(0, 0),
341
+ branches: withPct(0, 0),
342
+ branchesTrue: {
343
+ total: 0,
344
+ covered: 0,
345
+ skipped: 0,
346
+ pct: 100
347
+ }
348
+ } };
349
+ for (const file of lcov.files.values()) {
350
+ const linesTotal = file.lines.size;
351
+ const linesCovered = [...file.lines.values()].filter((l) => l.hitCount > 0).length;
352
+ const funcsTotal = file.functions.size;
353
+ const funcsCovered = [...file.functions.values()].filter((f) => f.hitCount > 0).length;
354
+ const branchesTotal = file.branches.length;
355
+ const branchesCovered = file.branches.filter((b) => b.taken > 0).length;
356
+ summary[file.path] = {
357
+ lines: withPct(linesTotal, linesCovered),
358
+ statements: withPct(linesTotal, linesCovered),
359
+ functions: withPct(funcsTotal, funcsCovered),
360
+ branches: withPct(branchesTotal, branchesCovered)
361
+ };
362
+ totals.lines.total += linesTotal;
363
+ totals.lines.covered += linesCovered;
364
+ totals.functions.total += funcsTotal;
365
+ totals.functions.covered += funcsCovered;
366
+ totals.branches.total += branchesTotal;
367
+ totals.branches.covered += branchesCovered;
368
+ }
369
+ summary.total = {
370
+ lines: withPct(totals.lines.total, totals.lines.covered),
371
+ statements: withPct(totals.lines.total, totals.lines.covered),
372
+ functions: withPct(totals.functions.total, totals.functions.covered),
373
+ branches: withPct(totals.branches.total, totals.branches.covered),
374
+ branchesTrue: {
375
+ total: 0,
376
+ covered: 0,
377
+ skipped: 0,
378
+ pct: 100
379
+ }
380
+ };
381
+ return summary;
382
+ }
383
+ /**
384
+ * Read and parse an LCOV file.
385
+ */
386
+ function readLcovFile(path) {
387
+ return parseLcov((0, node_fs.readFileSync)(path, "utf8"));
388
+ }
389
+ /**
390
+ * Write LCOV data to a file.
391
+ */
392
+ function writeLcovFile(path, lcov) {
393
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(path), { recursive: true });
394
+ (0, node_fs.writeFileSync)(path, formatLcov(lcov));
395
+ }
396
+ /**
397
+ * Write JSON summary to a file.
398
+ */
399
+ function writeJsonSummary(path, summary) {
400
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(path), { recursive: true });
401
+ (0, node_fs.writeFileSync)(path, JSON.stringify(summary, null, 2));
402
+ }
403
+
173
404
  //#endregion
174
405
  //#region src/lib/coverage.ts
175
406
  /**
@@ -275,6 +506,33 @@ async function cleanupCoverageContext(ctx) {
275
506
  });
276
507
  } catch {}
277
508
  }
509
+ /**
510
+ * Merge external LCOV file with generated coverage.
511
+ * Reads the generated lcov.info, merges with external LCOV, and writes back.
512
+ * Also generates coverage-summary.json for badge generation.
513
+ *
514
+ * @returns Object with merged coverage percentages, or null if merge failed
515
+ */
516
+ function mergeExternalCoverage(reportsDir, externalLcovPath) {
517
+ const generatedLcovPath = (0, node_path.join)(reportsDir, "lcov.info");
518
+ if (!(0, node_fs.existsSync)(externalLcovPath)) {
519
+ console.error(`External LCOV file not found: ${externalLcovPath}`);
520
+ return null;
521
+ }
522
+ if (!(0, node_fs.existsSync)(generatedLcovPath)) {
523
+ console.error(`Generated LCOV file not found: ${generatedLcovPath}`);
524
+ console.error("Make sure \"lcov\" is included in reporters");
525
+ return null;
526
+ }
527
+ const mergedLcov = mergeLcov(readLcovFile(externalLcovPath), readLcovFile(generatedLcovPath));
528
+ writeLcovFile(generatedLcovPath, mergedLcov);
529
+ const summary = lcovToJsonSummary(mergedLcov);
530
+ writeJsonSummary((0, node_path.join)(reportsDir, "coverage-summary.json"), summary);
531
+ return {
532
+ lines: summary.total.lines.pct,
533
+ functions: summary.total.functions.pct
534
+ };
535
+ }
278
536
 
279
537
  //#endregion
280
538
  //#region src/cli/commands/run.ts
@@ -282,7 +540,7 @@ async function cleanupCoverageContext(ctx) {
282
540
  * Register the run command.
283
541
  */
284
542
  function registerRunCommand(program) {
285
- 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);
543
+ 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)").option("--merge-lcov <path>", "Merge coverage from an existing LCOV file (e.g., from vitest --coverage)").action(runCommand$1);
286
544
  }
287
545
  async function runCommand$1(files, options) {
288
546
  const startTime = Date.now();
@@ -311,16 +569,26 @@ async function runCommand$1(files, options) {
311
569
  logError("Coverage requires c8. Install with: npm install -D c8");
312
570
  process.exit(1);
313
571
  }
572
+ let reporters = options.coverageReporter ?? globalConfig.coverage?.reporters;
573
+ if (options.mergeLcov) {
574
+ if (!reporters) reporters = [
575
+ "text",
576
+ "html",
577
+ "lcov"
578
+ ];
579
+ else if (!reporters.includes("lcov")) reporters = [...reporters, "lcov"];
580
+ }
314
581
  coverageCtx = await createCoverageContext({
315
582
  ...globalConfig.coverage,
316
583
  reportsDir: options.coverageDir ?? globalConfig.coverage?.reportsDir,
317
- reporters: options.coverageReporter ?? globalConfig.coverage?.reporters,
584
+ reporters,
318
585
  exclude: options.coverageExclude ?? globalConfig.coverage?.exclude,
319
586
  excludeNodeModules: options.coverageExcludeNodeModules ?? globalConfig.coverage?.excludeNodeModules,
320
587
  excludeAfterRemap: options.coverageExcludeAfterRemap ?? globalConfig.coverage?.excludeAfterRemap,
321
588
  skipFull: options.coverageSkipFull ?? globalConfig.coverage?.skipFull,
322
589
  allowExternal: options.coverageAllowExternal ?? globalConfig.coverage?.allowExternal,
323
- monocart: options.coverageMonocart ?? globalConfig.coverage?.monocart
590
+ monocart: options.coverageMonocart ?? globalConfig.coverage?.monocart,
591
+ mergeLcov: options.mergeLcov ?? globalConfig.coverage?.mergeLcov
324
592
  });
325
593
  coverageEnv = getCoverageEnv(coverageCtx);
326
594
  }
@@ -394,6 +662,11 @@ async function runCommand$1(files, options) {
394
662
  console.error("\nGenerating coverage report...");
395
663
  try {
396
664
  await generateCoverageReport(coverageCtx);
665
+ if (coverageCtx.options.mergeLcov) {
666
+ console.error(`Merging with external coverage: ${coverageCtx.options.mergeLcov}`);
667
+ const merged = mergeExternalCoverage(coverageCtx.options.reportsDir, coverageCtx.options.mergeLcov);
668
+ if (merged) console.error(colors.success(`Merged coverage: ${merged.lines}% lines, ${merged.functions}% functions`));
669
+ }
397
670
  console.error(colors.success(`Coverage report written to ${coverageCtx.options.reportsDir}/`));
398
671
  } catch (error) {
399
672
  logError(`Failed to generate coverage report: ${error instanceof Error ? error.message : String(error)}`);
@@ -410,7 +683,7 @@ async function runCommand$1(files, options) {
410
683
  * Register the coverage command.
411
684
  */
412
685
  function registerCoverageCommand(program) {
413
- 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);
686
+ 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);
414
687
  }
415
688
  /**
416
689
  * Run a command with inherited coverage environment.
@@ -606,12 +879,32 @@ async function coverageCommand(commands, options) {
606
879
  if (parsedOptions.verbose && stats.fileCount > 0) await generateTextReport(coverageTemp, parsedOptions, command);
607
880
  previousFileCount = stats.fileCount;
608
881
  }
609
- console.error(colors.info("\n=== Generating merged coverage report ==="));
882
+ console.error(colors.info("\n=== Generating coverage report ==="));
610
883
  if (!await generateReport(coverageTemp, parsedOptions)) {
611
884
  logError("Failed to generate coverage report");
612
885
  process.exit(1);
613
886
  }
614
- console.error(colors.success(`\nCoverage report written to ${parsedOptions.reportsDir ?? "coverage"}/`));
887
+ const reportsDir = parsedOptions.reportsDir ?? "coverage";
888
+ if (parsedOptions.mergeLcov) {
889
+ const externalLcovPath = parsedOptions.mergeLcov;
890
+ const generatedLcovPath = (0, node_path.join)(reportsDir, "lcov.info");
891
+ if (!(0, node_fs.existsSync)(externalLcovPath)) {
892
+ logError(`External LCOV file not found: ${externalLcovPath}`);
893
+ process.exit(1);
894
+ }
895
+ if (!(0, node_fs.existsSync)(generatedLcovPath)) {
896
+ logError(`Generated LCOV file not found: ${generatedLcovPath}`);
897
+ logError("Make sure \"lcov\" is included in reporters");
898
+ process.exit(1);
899
+ }
900
+ console.error(colors.info(`\nMerging with external coverage: ${externalLcovPath}`));
901
+ const mergedLcov = mergeLcov(readLcovFile(externalLcovPath), readLcovFile(generatedLcovPath));
902
+ writeLcovFile(generatedLcovPath, mergedLcov);
903
+ const summary = lcovToJsonSummary(mergedLcov);
904
+ writeJsonSummary((0, node_path.join)(reportsDir, "coverage-summary.json"), summary);
905
+ console.error(colors.success(`\nMerged coverage: ${summary.total.lines.pct}% lines, ${summary.total.functions.pct}% functions`));
906
+ }
907
+ console.error(colors.success(`\nCoverage report written to ${reportsDir}/`));
615
908
  } finally {
616
909
  await (0, node_fs_promises.rm)(coverageTemp, {
617
910
  recursive: true,