@tested/cli 0.1.7 → 0.1.8

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 (4) hide show
  1. package/README.md +83 -2
  2. package/dist/td.js +1298 -180
  3. package/dist/tested.js +1298 -180
  4. package/package.json +10 -13
package/dist/tested.js CHANGED
@@ -46,6 +46,8 @@ function badge(kind) {
46
46
  return pc.cyan("[INFO]");
47
47
  case "skip":
48
48
  return pc.cyan("[SKIP]");
49
+ case "missing":
50
+ return pc.yellow("[MISSING]");
49
51
  }
50
52
  }
51
53
  function metricBar(pct3, width = 10) {
@@ -349,24 +351,576 @@ function registerInitCommand(program2) {
349
351
  }
350
352
 
351
353
  // src/commands/setup.ts
352
- import { existsSync as existsSync4 } from "fs";
353
- import { join as join5 } from "path";
354
+ import { existsSync as existsSync5 } from "fs";
355
+ import { join as join6 } from "path";
354
356
  import "commander";
355
357
 
356
358
  // src/commands/doctor.ts
357
- import { existsSync as existsSync3, accessSync, constants as fsConstants } from "fs";
358
- import { basename, isAbsolute as isAbsolute2, join as join4, resolve as resolve4 } from "path";
359
+ import { existsSync as existsSync4, accessSync, constants as fsConstants } from "fs";
360
+ import { basename as basename2, isAbsolute as isAbsolute2, join as join5, resolve as resolve5 } from "path";
359
361
  import "commander";
360
362
  import { simpleGit as simpleGit3 } from "simple-git";
361
363
 
362
364
  // src/config.ts
363
- import { readFile } from "fs/promises";
364
- import { join as join2 } from "path";
365
+ import { readFile as readFile4 } from "fs/promises";
366
+ import { join as join3 } from "path";
365
367
  import { parse as parseYaml } from "yaml";
366
368
 
367
369
  // src/schemas.ts
368
370
  import { z as z2 } from "zod";
369
371
 
372
+ // src/core/coverage.ts
373
+ import { readFile as readFile3, stat as stat2 } from "fs/promises";
374
+ import { basename } from "path";
375
+
376
+ // src/core/istanbul.ts
377
+ import { readFile } from "fs/promises";
378
+
379
+ // src/core/coverage-model.ts
380
+ import { isAbsolute, relative, resolve } from "path";
381
+ function resolveCoverageEntryPath(repoRoot, entryPath) {
382
+ const cleaned = entryPath.trim().replace(/\\/g, "/");
383
+ if (cleaned.startsWith("file://")) {
384
+ try {
385
+ return decodeURIComponent(new URL(cleaned).pathname);
386
+ } catch {
387
+ return resolve(repoRoot, cleaned.replace(/^file:\/\//, ""));
388
+ }
389
+ }
390
+ return isAbsolute(cleaned) ? resolve(cleaned) : resolve(repoRoot, cleaned);
391
+ }
392
+ function isCoveragePathInsideRoot(repoRoot, entryPath) {
393
+ const root = resolve(repoRoot);
394
+ let absPath;
395
+ try {
396
+ absPath = resolveCoverageEntryPath(root, entryPath);
397
+ } catch {
398
+ return false;
399
+ }
400
+ const relPath = relative(root, absPath).split("\\").join("/");
401
+ if (!relPath || relPath === "") return true;
402
+ if (isAbsolute(relPath)) return false;
403
+ if (relPath === ".." || relPath.startsWith("../")) return false;
404
+ return true;
405
+ }
406
+ function toFileCoverage(repoRoot, entryPath, statements) {
407
+ if (!isCoveragePathInsideRoot(repoRoot, entryPath)) return null;
408
+ const absPath = resolveCoverageEntryPath(repoRoot, entryPath);
409
+ const relPath = relative(resolve(repoRoot), absPath).split("\\").join("/");
410
+ return { path: relPath, absPath, statements };
411
+ }
412
+ function statementsFromLineHits(lineHits) {
413
+ return [...lineHits].filter(([line]) => Number.isInteger(line) && line > 0).sort((a, b) => a[0] - b[0]).map(([line, hits]) => ({
414
+ id: String(line),
415
+ startLine: line,
416
+ endLine: line,
417
+ hits: Number.isFinite(hits) && hits > 0 ? hits : 0
418
+ }));
419
+ }
420
+ function mergeLineHits(into, line, hits) {
421
+ if (!Number.isInteger(line) || line <= 0) return;
422
+ const n = Number.isFinite(hits) && hits > 0 ? hits : 0;
423
+ into.set(line, (into.get(line) ?? 0) + n);
424
+ }
425
+ function fileCoverageFromLineHits(repoRoot, entryPath, lineHits) {
426
+ return toFileCoverage(repoRoot, entryPath, statementsFromLineHits(lineHits));
427
+ }
428
+
429
+ // src/core/istanbul.ts
430
+ function parseIstanbulString(raw, repoRoot, pathForError) {
431
+ let data;
432
+ try {
433
+ data = JSON.parse(raw);
434
+ } catch {
435
+ throw new Error(
436
+ `Istanbul/V8 JSON is not valid JSON${pathForError ? ` (${pathForError})` : ""}`
437
+ );
438
+ }
439
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
440
+ throw new Error("Istanbul/V8 JSON must be an object of file entries");
441
+ }
442
+ const out = [];
443
+ for (const entry of Object.values(data)) {
444
+ if (!entry || typeof entry !== "object" || typeof entry.path !== "string") {
445
+ continue;
446
+ }
447
+ if (!entry.statementMap || typeof entry.statementMap !== "object") {
448
+ continue;
449
+ }
450
+ const statements = Object.entries(
451
+ entry.statementMap
452
+ ).map(([id, loc]) => ({
453
+ id,
454
+ startLine: loc.start.line,
455
+ endLine: loc.end.line,
456
+ hits: entry.s?.[id] ?? 0
457
+ }));
458
+ const file = toFileCoverage(repoRoot, entry.path, statements);
459
+ if (file) out.push(file);
460
+ }
461
+ return out;
462
+ }
463
+
464
+ // src/core/formats/lcov.ts
465
+ function parseLcov(raw, repoRoot) {
466
+ const byFile = /* @__PURE__ */ new Map();
467
+ let current = null;
468
+ for (const rawLine of raw.split(/\r?\n/)) {
469
+ const line = rawLine.trim();
470
+ if (!line) continue;
471
+ if (line.startsWith("SF:")) {
472
+ current = line.slice(3).trim();
473
+ if (current && !byFile.has(current)) byFile.set(current, /* @__PURE__ */ new Map());
474
+ continue;
475
+ }
476
+ if (line === "end_of_record") {
477
+ current = null;
478
+ continue;
479
+ }
480
+ if (!current || !line.startsWith("DA:")) continue;
481
+ const payload = line.slice(3);
482
+ const comma = payload.indexOf(",");
483
+ if (comma < 0) continue;
484
+ const lineNo = Number(payload.slice(0, comma));
485
+ const hitsRaw = payload.slice(comma + 1).split(",")[0] ?? "0";
486
+ const hits = Number(hitsRaw);
487
+ mergeLineHits(byFile.get(current), lineNo, hits);
488
+ }
489
+ const out = [];
490
+ for (const [path, hits] of byFile) {
491
+ const file = fileCoverageFromLineHits(repoRoot, path, hits);
492
+ if (file) out.push(file);
493
+ }
494
+ return out;
495
+ }
496
+
497
+ // src/core/formats/xml.ts
498
+ function decodeXmlEntities(s) {
499
+ return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
500
+ }
501
+ function xmlAttr(tag, name) {
502
+ const re = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i");
503
+ const m = tag.match(re);
504
+ if (!m) return void 0;
505
+ return decodeXmlEntities(m[2] ?? m[3] ?? "");
506
+ }
507
+
508
+ // src/core/formats/cobertura.ts
509
+ function parseCobertura(raw, repoRoot) {
510
+ const byFile = /* @__PURE__ */ new Map();
511
+ const classRe = /<class\b([^>]*)>([\s\S]*?)<\/class>|<class\b([^>]*)\s*\/>/gi;
512
+ let classMatch;
513
+ while ((classMatch = classRe.exec(raw)) !== null) {
514
+ const attrs = (classMatch[1] ?? classMatch[3] ?? "").trim();
515
+ const body = classMatch[2] ?? "";
516
+ const filename = xmlAttr(attrs, "filename")?.trim();
517
+ const name = xmlAttr(attrs, "name")?.trim();
518
+ const path = classPath(filename, name);
519
+ if (!path) continue;
520
+ let hits = byFile.get(path);
521
+ if (!hits) {
522
+ hits = /* @__PURE__ */ new Map();
523
+ byFile.set(path, hits);
524
+ }
525
+ const lineRe = /<line\b([^>]*)\/?>/gi;
526
+ let lineMatch;
527
+ while ((lineMatch = lineRe.exec(body)) !== null) {
528
+ const lineAttrs = lineMatch[1] ?? "";
529
+ const number = Number(xmlAttr(lineAttrs, "number"));
530
+ const hitCount = Number(xmlAttr(lineAttrs, "hits") ?? "0");
531
+ mergeLineHits(hits, number, hitCount);
532
+ }
533
+ }
534
+ const out = [];
535
+ for (const [path, hits] of byFile) {
536
+ const file = fileCoverageFromLineHits(repoRoot, path, hits);
537
+ if (file) out.push(file);
538
+ }
539
+ return out;
540
+ }
541
+ function classPath(filename, name) {
542
+ if (filename) {
543
+ if (filename.includes("/") || filename.includes("\\")) return filename;
544
+ if (name && name.includes(".")) {
545
+ const dir = name.split(".").slice(0, -1).join("/");
546
+ return dir ? `${dir}/${filename}` : filename;
547
+ }
548
+ return filename;
549
+ }
550
+ if (name) return name.replace(/\./g, "/");
551
+ return null;
552
+ }
553
+
554
+ // src/core/formats/gcov.ts
555
+ import { readdir, readFile as readFile2, stat } from "fs/promises";
556
+ import { join as join2 } from "path";
557
+ function parseGcov(raw, repoRoot) {
558
+ const byFile = /* @__PURE__ */ new Map();
559
+ let current = null;
560
+ for (const rawLine of raw.split(/\r?\n/)) {
561
+ const parsed = parseGcovLine(rawLine);
562
+ if (!parsed) continue;
563
+ if (parsed.lineNo === 0) {
564
+ const source = sourceFromMeta(parsed.source);
565
+ if (source) {
566
+ current = source;
567
+ if (!byFile.has(current)) byFile.set(current, /* @__PURE__ */ new Map());
568
+ }
569
+ continue;
570
+ }
571
+ if (parsed.hits === null) continue;
572
+ if (!current) continue;
573
+ mergeLineHits(byFile.get(current), parsed.lineNo, parsed.hits);
574
+ }
575
+ const out = [];
576
+ for (const [path, hits] of byFile) {
577
+ const file = fileCoverageFromLineHits(repoRoot, path, hits);
578
+ if (file) out.push(file);
579
+ }
580
+ return out;
581
+ }
582
+ async function parseGcovPath(opts) {
583
+ const info = await stat(opts.path);
584
+ if (info.isDirectory()) {
585
+ const names = (await readdir(opts.path)).filter((n) => n.endsWith(".gcov")).sort();
586
+ if (names.length === 0) {
587
+ throw new Error(
588
+ `no .gcov files in ${opts.path}. Run \`gcov\` on your .gcda files (binary .gcno/.gcda notes are not parsed).`
589
+ );
590
+ }
591
+ const byPath = /* @__PURE__ */ new Map();
592
+ const absByPath = /* @__PURE__ */ new Map();
593
+ for (const name of names) {
594
+ const filePath = join2(opts.path, name);
595
+ const raw2 = await readFile2(filePath, "utf8");
596
+ for (const file of parseGcov(raw2, opts.repoRoot)) {
597
+ let hits = byPath.get(file.path);
598
+ if (!hits) {
599
+ hits = /* @__PURE__ */ new Map();
600
+ byPath.set(file.path, hits);
601
+ absByPath.set(file.path, file.absPath);
602
+ }
603
+ for (const s of file.statements) {
604
+ mergeLineHits(hits, s.startLine, s.hits);
605
+ }
606
+ }
607
+ }
608
+ return [...byPath.entries()].map(([path, hits]) => ({
609
+ path,
610
+ absPath: absByPath.get(path),
611
+ statements: statementsFromLineHits(hits)
612
+ }));
613
+ }
614
+ const raw = await readFile2(opts.path, "utf8");
615
+ return parseGcov(raw, opts.repoRoot);
616
+ }
617
+ function parseGcovLine(rawLine) {
618
+ const first = rawLine.indexOf(":");
619
+ if (first < 0) return null;
620
+ const second = rawLine.indexOf(":", first + 1);
621
+ if (second < 0) return null;
622
+ const countField = rawLine.slice(0, first).trim();
623
+ const lineNo = Number(rawLine.slice(first + 1, second).trim());
624
+ if (!Number.isInteger(lineNo)) return null;
625
+ const source = rawLine.slice(second + 1);
626
+ if (countField === "-" || countField === "") {
627
+ return { hits: null, lineNo, source };
628
+ }
629
+ if (countField.startsWith("#") || countField.startsWith("=")) {
630
+ return { hits: 0, lineNo, source };
631
+ }
632
+ const hits = Number.parseInt(countField.replace(/\*+$/, ""), 10);
633
+ if (!Number.isFinite(hits)) return { hits: 0, lineNo, source };
634
+ return { hits, lineNo, source };
635
+ }
636
+ function sourceFromMeta(source) {
637
+ const trimmed = source.trim();
638
+ const m = trimmed.match(/^Source:(.*)$/);
639
+ if (!m) return null;
640
+ const path = (m[1] ?? "").trim();
641
+ return path || null;
642
+ }
643
+
644
+ // src/core/formats/jacoco.ts
645
+ function parseJacoco(raw, repoRoot) {
646
+ const byFile = /* @__PURE__ */ new Map();
647
+ const pkgRe = /<package\b([^>]*)>([\s\S]*?)<\/package>/gi;
648
+ let pkgMatch;
649
+ while ((pkgMatch = pkgRe.exec(raw)) !== null) {
650
+ const pkgName = xmlAttr(pkgMatch[1] ?? "", "name")?.trim() ?? "";
651
+ const pkgPath = normalizePackagePath(pkgName);
652
+ const body = pkgMatch[2] ?? "";
653
+ const sfRe = /<sourcefile\b([^>]*)>([\s\S]*?)<\/sourcefile>/gi;
654
+ let sfMatch;
655
+ while ((sfMatch = sfRe.exec(body)) !== null) {
656
+ const sourceName = xmlAttr(sfMatch[1] ?? "", "name")?.trim();
657
+ if (!sourceName) continue;
658
+ const path = pkgPath ? `${pkgPath}/${sourceName}` : sourceName;
659
+ let hits = byFile.get(path);
660
+ if (!hits) {
661
+ hits = /* @__PURE__ */ new Map();
662
+ byFile.set(path, hits);
663
+ }
664
+ const lineRe = /<line\b([^>]*)\/?>/gi;
665
+ let lineMatch;
666
+ while ((lineMatch = lineRe.exec(sfMatch[2] ?? "")) !== null) {
667
+ const lineAttrs = lineMatch[1] ?? "";
668
+ const nr = Number(xmlAttr(lineAttrs, "nr"));
669
+ const ci = Number(xmlAttr(lineAttrs, "ci") ?? "0");
670
+ mergeLineHits(hits, nr, Number.isFinite(ci) ? ci : 0);
671
+ }
672
+ }
673
+ }
674
+ const out = [];
675
+ for (const [path, hits] of byFile) {
676
+ const file = fileCoverageFromLineHits(repoRoot, path, hits);
677
+ if (file) out.push(file);
678
+ }
679
+ return out;
680
+ }
681
+ function normalizePackagePath(name) {
682
+ if (!name) return "";
683
+ if (name.includes("/")) return name.replace(/\\/g, "/");
684
+ return name.replace(/\./g, "/");
685
+ }
686
+
687
+ // src/core/formats/simplecov.ts
688
+ function parseSimpleCov(raw, repoRoot) {
689
+ let data;
690
+ try {
691
+ data = JSON.parse(raw);
692
+ } catch {
693
+ throw new Error("SimpleCov coverage file is not valid JSON");
694
+ }
695
+ const byFile = /* @__PURE__ */ new Map();
696
+ if (isSimpleCovJsonGem(data)) {
697
+ for (const file of data.files) {
698
+ const filename = typeof file.filename === "string" ? file.filename : "";
699
+ if (!filename) continue;
700
+ addCoverageArray(byFile, filename, file.coverage);
701
+ }
702
+ } else if (isSimpleCovResultset(data)) {
703
+ for (const suite of Object.values(data)) {
704
+ if (!suite || typeof suite !== "object") continue;
705
+ const coverage = suite.coverage;
706
+ if (!coverage || typeof coverage !== "object") continue;
707
+ for (const [filename, entry] of Object.entries(
708
+ coverage
709
+ )) {
710
+ addCoverageArray(byFile, filename, linesFromResultsetEntry(entry));
711
+ }
712
+ }
713
+ } else {
714
+ throw new Error(
715
+ "Not a SimpleCov resultset or simplecov-json report. Expected coverage/.resultset.json"
716
+ );
717
+ }
718
+ const out = [];
719
+ for (const [path, hits] of byFile) {
720
+ const file = fileCoverageFromLineHits(repoRoot, path, hits);
721
+ if (file) out.push(file);
722
+ }
723
+ return out;
724
+ }
725
+ function isSimpleCovJsonGem(data) {
726
+ return typeof data === "object" && data !== null && Array.isArray(data.files) && data.files.some(
727
+ (f) => typeof f === "object" && f !== null && typeof f.filename === "string"
728
+ );
729
+ }
730
+ function isSimpleCovResultset(data) {
731
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
732
+ return false;
733
+ }
734
+ if (isSimpleCovJsonGem(data)) return false;
735
+ return Object.values(data).some((suite) => {
736
+ if (typeof suite !== "object" || suite === null) return false;
737
+ const coverage = suite.coverage;
738
+ return typeof coverage === "object" && coverage !== null;
739
+ });
740
+ }
741
+ function linesFromResultsetEntry(entry) {
742
+ if (Array.isArray(entry)) return entry;
743
+ if (entry && typeof entry === "object" && "lines" in entry) {
744
+ return entry.lines;
745
+ }
746
+ return void 0;
747
+ }
748
+ function addCoverageArray(byFile, filename, coverage) {
749
+ if (!Array.isArray(coverage)) return;
750
+ let hits = byFile.get(filename);
751
+ if (!hits) {
752
+ hits = /* @__PURE__ */ new Map();
753
+ byFile.set(filename, hits);
754
+ }
755
+ coverage.forEach((cell, idx) => {
756
+ if (cell === null || cell === void 0) return;
757
+ const n = typeof cell === "number" ? cell : Number(cell);
758
+ if (!Number.isFinite(n)) return;
759
+ mergeLineHits(hits, idx + 1, n);
760
+ });
761
+ }
762
+
763
+ // src/core/coverage.ts
764
+ var COVERAGE_FORMATS = [
765
+ "istanbul-json",
766
+ "v8-json",
767
+ "lcov",
768
+ "cobertura",
769
+ "jacoco",
770
+ "gcov",
771
+ "simplecov"
772
+ ];
773
+ function resolveCoverageFormat(format) {
774
+ return format === "v8-json" ? "istanbul-json" : format;
775
+ }
776
+ var MISSING_COVERAGE = "coverage file missing. Run `tested run` first, or set coverage.path in .tested.yaml.";
777
+ async function parseCoverage(opts) {
778
+ const explicit = opts.format ? resolveCoverageFormat(opts.format) : void 0;
779
+ let info;
780
+ try {
781
+ info = await stat2(opts.path);
782
+ } catch (err) {
783
+ if (err.code === "ENOENT") {
784
+ throw missingCoverageError(opts.path);
785
+ }
786
+ throw err;
787
+ }
788
+ if (info.isDirectory()) {
789
+ const format2 = explicit ?? detectFormatFromPath(opts.path) ?? "gcov";
790
+ if (format2 !== "gcov") {
791
+ throw new Error(
792
+ `coverage path ${opts.path} is a directory; only gcov accepts a directory of .gcov files`
793
+ );
794
+ }
795
+ return parseGcovPath({ path: opts.path, repoRoot: opts.repoRoot });
796
+ }
797
+ let raw;
798
+ try {
799
+ raw = await readFile3(opts.path, "utf8");
800
+ } catch (err) {
801
+ if (err.code === "ENOENT") {
802
+ throw missingCoverageError(opts.path);
803
+ }
804
+ throw err;
805
+ }
806
+ const format = explicit ?? detectFormatFromContents(raw) ?? detectFormatFromPath(opts.path) ?? defaultIstanbulIfCoverageFinal(opts.path);
807
+ if (!format) {
808
+ throw new Error(
809
+ `Unable to detect coverage format for ${opts.path}. Set coverage.format in .tested.yaml (${COVERAGE_FORMATS.filter((f) => f !== "v8-json").join(", ")}).`
810
+ );
811
+ }
812
+ return parseCoverageText({
813
+ raw,
814
+ path: opts.path,
815
+ repoRoot: opts.repoRoot,
816
+ format
817
+ });
818
+ }
819
+ function parseCoverageText(opts) {
820
+ switch (opts.format) {
821
+ case "istanbul-json":
822
+ return parseIstanbulString(opts.raw, opts.repoRoot, opts.path);
823
+ case "lcov":
824
+ return parseLcov(opts.raw, opts.repoRoot);
825
+ case "cobertura":
826
+ return parseCobertura(opts.raw, opts.repoRoot);
827
+ case "jacoco":
828
+ return parseJacoco(opts.raw, opts.repoRoot);
829
+ case "gcov":
830
+ return parseGcov(opts.raw, opts.repoRoot);
831
+ case "simplecov":
832
+ return parseSimpleCov(opts.raw, opts.repoRoot);
833
+ default: {
834
+ const _exhaustive = opts.format;
835
+ throw new Error(`Unsupported coverage format: ${String(_exhaustive)}`);
836
+ }
837
+ }
838
+ }
839
+ function detectFormatFromPath(filePath) {
840
+ const base = basename(filePath).toLowerCase();
841
+ if (base === "coverage-final.json") return "istanbul-json";
842
+ if (base === "lcov.info" || base.endsWith(".lcov")) return "lcov";
843
+ if (base.includes("cobertura")) return "cobertura";
844
+ if (base.includes("jacoco")) return "jacoco";
845
+ if (base.endsWith(".gcov")) return "gcov";
846
+ if (base === ".resultset.json" || base === "resultset.json") return "simplecov";
847
+ return void 0;
848
+ }
849
+ function detectFormatFromContents(raw) {
850
+ const text = raw.replace(/^\uFEFF/, "");
851
+ const trimmed = text.trimStart();
852
+ if (!trimmed) return void 0;
853
+ if (/^\s*-:\s*0:Source:/m.test(trimmed.slice(0, 4e3))) return "gcov";
854
+ const headLines = trimmed.slice(0, 2e3);
855
+ if (/^(TN:|SF:|DA:\d)/m.test(headLines)) return "lcov";
856
+ if (trimmed.startsWith("<?xml") || trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<coverage") || trimmed.startsWith("<report")) {
857
+ return detectXmlFormat(trimmed.slice(0, 8e3));
858
+ }
859
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
860
+ try {
861
+ const data = JSON.parse(text);
862
+ if (isCoveragePyJson(data)) {
863
+ throw new Error(
864
+ "coverage.py JSON is not supported. Emit lcov or Cobertura XML (pytest-cov `--cov-report=lcov` or `--cov-report=xml`) instead."
865
+ );
866
+ }
867
+ if (isIstanbulJson(data)) return "istanbul-json";
868
+ if (isSimpleCovJsonGem(data) || isSimpleCovResultset(data)) {
869
+ return "simplecov";
870
+ }
871
+ } catch (err) {
872
+ if (err instanceof Error && /coverage\.py JSON/.test(err.message)) {
873
+ throw err;
874
+ }
875
+ return void 0;
876
+ }
877
+ }
878
+ const firstData = trimmed.split(/\r?\n/).find((l) => l.includes(":"));
879
+ if (firstData && /^\s*(?:#####|=======|\d+)\s*:\s*\d+:/.test(firstData)) {
880
+ return "gcov";
881
+ }
882
+ return void 0;
883
+ }
884
+ function detectXmlFormat(head) {
885
+ const lower = head.toLowerCase();
886
+ if (lower.includes("cobertura")) return "cobertura";
887
+ if (lower.includes("jacoco")) return "jacoco";
888
+ if (lower.includes("<report") && /<line\b[^>]*\bci\s*=/.test(lower)) {
889
+ return "jacoco";
890
+ }
891
+ if (lower.includes("<coverage") && /<line\b[^>]*\bhits\s*=/.test(lower)) {
892
+ return "cobertura";
893
+ }
894
+ if (lower.includes("<report")) return "jacoco";
895
+ if (lower.includes("<coverage")) return "cobertura";
896
+ return void 0;
897
+ }
898
+ function defaultIstanbulIfCoverageFinal(filePath) {
899
+ const base = basename(filePath).toLowerCase();
900
+ if (base === "coverage-final.json") return "istanbul-json";
901
+ return void 0;
902
+ }
903
+ function isIstanbulJson(data) {
904
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
905
+ return false;
906
+ }
907
+ const values = Object.values(data);
908
+ if (values.length === 0) return true;
909
+ return values.some(
910
+ (v) => typeof v === "object" && v !== null && "statementMap" in v && "s" in v
911
+ );
912
+ }
913
+ function isCoveragePyJson(data) {
914
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
915
+ return false;
916
+ }
917
+ const rec = data;
918
+ return typeof rec.meta === "object" && rec.meta !== null && typeof rec.files === "object" && rec.files !== null && !Array.isArray(rec.files);
919
+ }
920
+ function missingCoverageError(path) {
921
+ return new Error(`${MISSING_COVERAGE} (${path})`);
922
+ }
923
+
370
924
  // src/core/junit.ts
371
925
  import { z } from "zod";
372
926
  function testCaseKey(classname, name) {
@@ -404,14 +958,14 @@ var TestReportSchema = z.object({
404
958
  ).max(50),
405
959
  slowest: z.array(TestCaseRefSchema).max(15)
406
960
  });
407
- function decodeXmlEntities(s) {
961
+ function decodeXmlEntities2(s) {
408
962
  return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
409
963
  }
410
964
  function attr(tag, name) {
411
965
  const re = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i");
412
966
  const m = tag.match(re);
413
967
  if (!m) return void 0;
414
- return decodeXmlEntities(m[2] ?? m[3] ?? "");
968
+ return decodeXmlEntities2(m[2] ?? m[3] ?? "");
415
969
  }
416
970
  function parseJunitXml(xml) {
417
971
  const cases = [];
@@ -438,7 +992,7 @@ function parseJunitXml(xml) {
438
992
  message = fm ? attr(fm[1] ?? "", "message") : void 0;
439
993
  if (!message) {
440
994
  const inner = body.match(/<failure\b[^>]*>([\s\S]*?)<\/failure>/i);
441
- if (inner?.[1]?.trim()) message = decodeXmlEntities(inner[1].trim()).slice(0, 500);
995
+ if (inner?.[1]?.trim()) message = decodeXmlEntities2(inner[1].trim()).slice(0, 500);
442
996
  }
443
997
  } else if (/<error\b/i.test(body)) {
444
998
  status = "error";
@@ -541,22 +1095,43 @@ function parseJunitToTestReport(xml) {
541
1095
  }
542
1096
 
543
1097
  // src/schemas.ts
1098
+ var CoverageFormatSchema = z2.enum(COVERAGE_FORMATS);
1099
+ var FLAG_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
1100
+ var FlagNameSchema = z2.string().regex(FLAG_NAME_PATTERN, "flag names must be alphanumeric plus _ . -");
1101
+ var FlagThresholdsSchema = z2.object({
1102
+ patch: z2.number().min(0).max(100).optional(),
1103
+ project: z2.number().min(0).max(100).optional()
1104
+ });
1105
+ var FlagConfigSchema = z2.object({
1106
+ paths: z2.array(z2.string().min(1)).min(1),
1107
+ thresholds: FlagThresholdsSchema.optional()
1108
+ });
544
1109
  var TestedConfigSchema = z2.object({
545
1110
  ignores: z2.array(z2.string()).default([]),
546
1111
  coverage: z2.object({
547
- format: z2.literal("istanbul-json").default("istanbul-json"),
548
- path: z2.string().default("coverage/coverage-final.json")
1112
+ /** Omit to auto-detect from path and file contents. */
1113
+ format: CoverageFormatSchema.optional(),
1114
+ /**
1115
+ * One file or a list of files to merge (union of paths, max hits).
1116
+ * A CI matrix that uploads many files in one job should list them here
1117
+ * or pass `--file` / Action `files`.
1118
+ */
1119
+ path: z2.union([z2.string().min(1), z2.array(z2.string().min(1)).min(1)]).default("coverage/coverage-final.json")
549
1120
  }).prefault({}),
550
1121
  base: z2.string().default("origin/main"),
551
1122
  testRunner: z2.enum(["vitest", "jest", "pytest"]).nullable().default(null),
552
1123
  // Patch / project coverage gates. `tested init` writes these so users can
553
1124
  // tune what counts as "passing" — schema MUST accept them so loadConfig
554
- // doesn't silently drop the field. Enforcement in `diff` lands in a
555
- // follow-up; today we just round-trip the values cleanly.
1125
+ // doesn't silently drop the field.
556
1126
  thresholds: z2.object({
557
1127
  patch: z2.number().min(0).max(100),
558
1128
  project: z2.number().min(0).max(100)
559
- }).optional()
1129
+ }).optional(),
1130
+ /**
1131
+ * Per-package gates. Each flag is graded from this run's coverage files
1132
+ * only — a missing path is missing (pending/fail), never last week's totals.
1133
+ */
1134
+ flags: z2.record(FlagNameSchema, FlagConfigSchema).optional()
560
1135
  });
561
1136
  var UncoveredRangeSchema = z2.object({
562
1137
  start: z2.number().int().positive(),
@@ -588,6 +1163,25 @@ var DiffOutputSchema = z2.object({
588
1163
  files: z2.array(FileCoverageSchema),
589
1164
  ignored: z2.array(z2.string())
590
1165
  });
1166
+ var FlagMetricJsonSchema = z2.object({
1167
+ pct: z2.number().min(0).max(100),
1168
+ threshold: z2.number().min(0).max(100),
1169
+ pass: z2.boolean(),
1170
+ executable: z2.number().int().nonnegative(),
1171
+ covered: z2.number().int().nonnegative(),
1172
+ skipped: z2.literal(true).optional(),
1173
+ reason: z2.string().optional()
1174
+ });
1175
+ var FlagResultJsonSchema = z2.object({
1176
+ status: z2.enum(["pass", "fail", "missing"]),
1177
+ present: z2.boolean(),
1178
+ reason: z2.string().optional(),
1179
+ patchCheck: z2.string(),
1180
+ projectCheck: z2.string(),
1181
+ patch: FlagMetricJsonSchema,
1182
+ project: FlagMetricJsonSchema
1183
+ });
1184
+ var FlagsJsonMapSchema = z2.record(FlagNameSchema, FlagResultJsonSchema);
591
1185
 
592
1186
  // src/config.ts
593
1187
  var DEFAULT_IGNORES = [
@@ -608,10 +1202,10 @@ var DEFAULT_IGNORES = [
608
1202
  "stubs/**"
609
1203
  ];
610
1204
  async function loadConfig(opts) {
611
- const file = join2(opts.cwd, ".tested.yaml");
1205
+ const file = join3(opts.cwd, ".tested.yaml");
612
1206
  let raw = {};
613
1207
  try {
614
- const text = await readFile(file, "utf8");
1208
+ const text = await readFile4(file, "utf8");
615
1209
  raw = parseYaml(text) ?? {};
616
1210
  } catch (err) {
617
1211
  if (err.code !== "ENOENT") throw err;
@@ -621,13 +1215,129 @@ async function loadConfig(opts) {
621
1215
  return { ...parsed, ignores: [...merged] };
622
1216
  }
623
1217
 
1218
+ // src/core/coverage-paths.ts
1219
+ import { existsSync as existsSync2 } from "fs";
1220
+ import { resolve as resolve3 } from "path";
1221
+
1222
+ // src/core/assert-within-root.ts
1223
+ import { resolve as resolve2, sep } from "path";
1224
+ function assertWithinRoot(root, resolvedPath) {
1225
+ const safeRoot = resolve2(root) + sep;
1226
+ const safePath = resolve2(resolvedPath);
1227
+ if (!safePath.startsWith(safeRoot)) {
1228
+ throw new Error(
1229
+ `Path traversal rejected: ${safePath} is outside repository root ${safeRoot}`
1230
+ );
1231
+ }
1232
+ }
1233
+
1234
+ // src/core/merge-coverage.ts
1235
+ function statementKey(stmt) {
1236
+ return `${stmt.startLine}:${stmt.endLine}:${stmt.id}`;
1237
+ }
1238
+ function sameRange(a, b) {
1239
+ return a.startLine === b.startLine && a.endLine === b.endLine;
1240
+ }
1241
+ function mergeStatements(left, right) {
1242
+ const out = /* @__PURE__ */ new Map();
1243
+ for (const stmt of left) {
1244
+ out.set(statementKey(stmt), { ...stmt });
1245
+ }
1246
+ for (const stmt of right) {
1247
+ const exact = statementKey(stmt);
1248
+ const existing = out.get(exact);
1249
+ if (existing) {
1250
+ existing.hits = Math.max(existing.hits, stmt.hits);
1251
+ continue;
1252
+ }
1253
+ const rangeMatch = [...out.values()].find((prev) => sameRange(prev, stmt));
1254
+ if (rangeMatch) {
1255
+ rangeMatch.hits = Math.max(rangeMatch.hits, stmt.hits);
1256
+ continue;
1257
+ }
1258
+ out.set(exact, { ...stmt });
1259
+ }
1260
+ return [...out.values()].sort(
1261
+ (a, b) => a.startLine - b.startLine || a.endLine - b.endLine || a.id.localeCompare(b.id)
1262
+ );
1263
+ }
1264
+ function mergeFileCoverage(shards) {
1265
+ const byPath = /* @__PURE__ */ new Map();
1266
+ for (const shard of shards) {
1267
+ for (const file of shard) {
1268
+ const existing = byPath.get(file.path);
1269
+ if (!existing) {
1270
+ byPath.set(file.path, {
1271
+ path: file.path,
1272
+ absPath: file.absPath,
1273
+ statements: file.statements.map((s) => ({ ...s }))
1274
+ });
1275
+ continue;
1276
+ }
1277
+ byPath.set(file.path, {
1278
+ path: existing.path,
1279
+ absPath: existing.absPath,
1280
+ statements: mergeStatements(existing.statements, file.statements)
1281
+ });
1282
+ }
1283
+ }
1284
+ return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
1285
+ }
1286
+
1287
+ // src/core/coverage-paths.ts
1288
+ function coveragePathList(path) {
1289
+ const list = Array.isArray(path) ? [...path] : [path];
1290
+ return list.map((p) => p.trim()).filter((p) => p.length > 0);
1291
+ }
1292
+ function parseCoverageFileList(raw) {
1293
+ if (raw === void 0 || raw === null) return [];
1294
+ return raw.split(/[\n,]+/).map((p) => p.trim()).filter((p) => p.length > 0);
1295
+ }
1296
+ function collectCoverageFile(value, prev) {
1297
+ const next = value.trim();
1298
+ return next ? [...prev, next] : prev;
1299
+ }
1300
+ function resolveCoveragePaths(opts) {
1301
+ const fromFlag = (opts.files ?? []).map((p) => p.trim()).filter((p) => p.length > 0);
1302
+ if (fromFlag.length > 0) return fromFlag;
1303
+ const fromEnv = parseCoverageFileList(opts.env?.TESTED_COVERAGE_FILES);
1304
+ if (fromEnv.length > 0) return fromEnv;
1305
+ return coveragePathList(opts.configPath);
1306
+ }
1307
+ function existingCoveragePaths(paths, cwd, existsFn = existsSync2) {
1308
+ return paths.filter((rel) => {
1309
+ const abs = resolve3(cwd, rel);
1310
+ return existsFn(abs);
1311
+ });
1312
+ }
1313
+ async function parseAndMergeCoverage(opts) {
1314
+ if (opts.paths.length === 0) {
1315
+ throw new Error(
1316
+ "coverage file missing. Run `tested run` first, or set coverage.path in .tested.yaml."
1317
+ );
1318
+ }
1319
+ const shards = [];
1320
+ for (const rel of opts.paths) {
1321
+ const coveragePath = resolve3(opts.cwd, rel);
1322
+ assertWithinRoot(opts.repoRoot, coveragePath);
1323
+ shards.push(
1324
+ await parseCoverage({
1325
+ path: coveragePath,
1326
+ repoRoot: opts.repoRoot,
1327
+ ...opts.format ? { format: opts.format } : {}
1328
+ })
1329
+ );
1330
+ }
1331
+ return mergeFileCoverage(shards);
1332
+ }
1333
+
624
1334
  // src/commands/push.ts
625
- import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
626
- import { join as join3 } from "path";
1335
+ import { existsSync as existsSync3, readFileSync as readFileSync2, statSync } from "fs";
1336
+ import { join as join4 } from "path";
627
1337
  import "commander";
628
1338
 
629
1339
  // src/core/computeDiff.ts
630
- import { resolve as resolve3 } from "path";
1340
+ import { resolve as resolve4 } from "path";
631
1341
 
632
1342
  // src/git.ts
633
1343
  import { simpleGit as simpleGit2 } from "simple-git";
@@ -740,50 +1450,6 @@ async function gitUserName(ctx) {
740
1450
  }
741
1451
  }
742
1452
 
743
- // src/core/istanbul.ts
744
- import { readFile as readFile2 } from "fs/promises";
745
- import { isAbsolute, relative, resolve } from "path";
746
- function isCoveragePathInsideRoot(repoRoot, entryPath) {
747
- const root = resolve(repoRoot);
748
- const absPath = resolve(entryPath);
749
- const relPath = relative(root, absPath).split("\\").join("/");
750
- if (!relPath || relPath === "") return true;
751
- if (isAbsolute(relPath)) return false;
752
- if (relPath === ".." || relPath.startsWith("../")) return false;
753
- return true;
754
- }
755
- async function parseIstanbul(opts) {
756
- let raw;
757
- try {
758
- raw = await readFile2(opts.path, "utf8");
759
- } catch (err) {
760
- if (err.code === "ENOENT") {
761
- throw new Error(
762
- `coverage-final.json not found at ${opts.path}. Run \`tested run\` first.`
763
- );
764
- }
765
- throw err;
766
- }
767
- const data = JSON.parse(raw);
768
- const root = resolve(opts.repoRoot);
769
- const out = [];
770
- for (const entry of Object.values(data)) {
771
- if (!isCoveragePathInsideRoot(root, entry.path)) {
772
- continue;
773
- }
774
- const absPath = resolve(entry.path);
775
- const relPath = relative(root, absPath).split("\\").join("/");
776
- const statements = Object.entries(entry.statementMap).map(([id, loc]) => ({
777
- id,
778
- startLine: loc.start.line,
779
- endLine: loc.end.line,
780
- hits: entry.s[id] ?? 0
781
- }));
782
- out.push({ path: relPath, absPath, statements });
783
- }
784
- return out;
785
- }
786
-
787
1453
  // src/core/diff.ts
788
1454
  var FILE_HEADER = /^diff --git a\/(.+?) b\/(.+?)$/;
789
1455
  var HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
@@ -837,18 +1503,6 @@ function splitByIgnore(paths, patterns) {
837
1503
  return { kept, ignored };
838
1504
  }
839
1505
 
840
- // src/core/assert-within-root.ts
841
- import { resolve as resolve2, sep } from "path";
842
- function assertWithinRoot(root, resolvedPath) {
843
- const safeRoot = resolve2(root) + sep;
844
- const safePath = resolve2(resolvedPath);
845
- if (!safePath.startsWith(safeRoot)) {
846
- throw new Error(
847
- `Path traversal rejected: ${safePath} is outside repository root ${safeRoot}`
848
- );
849
- }
850
- }
851
-
852
1506
  // src/core/patch.ts
853
1507
  var EMPTY_PATCH_REASON = "no executable lines in the patch";
854
1508
  function isEmptyPatch(totals) {
@@ -981,7 +1635,7 @@ function buildDiffOutput(args) {
981
1635
  }
982
1636
 
983
1637
  // src/core/computeDiff.ts
984
- async function computeDiff(opts) {
1638
+ async function computeDiffContext(opts) {
985
1639
  const { cwd, config } = opts;
986
1640
  const ctx = opts.ctx ?? await openRepo(cwd);
987
1641
  const requested = assertSafeGitRef(opts.baseRef ?? config.base);
@@ -989,9 +1643,16 @@ async function computeDiff(opts) {
989
1643
  const head = await headSha(ctx);
990
1644
  const diffText = await unifiedDiff(ctx, base);
991
1645
  const addedByFile = parseUnifiedDiff(diffText);
992
- const coveragePath = resolve3(cwd, config.coverage.path);
993
- assertWithinRoot(ctx.repoRoot, coveragePath);
994
- const allFiles = await parseIstanbul({ path: coveragePath, repoRoot: ctx.repoRoot });
1646
+ const coveragePaths = resolveCoveragePaths({
1647
+ ...opts.coveragePaths ? { files: opts.coveragePaths } : {},
1648
+ configPath: config.coverage.path
1649
+ });
1650
+ const allFiles = await parseAndMergeCoverage({
1651
+ paths: coveragePaths,
1652
+ cwd,
1653
+ repoRoot: ctx.repoRoot,
1654
+ ...config.coverage.format ? { format: config.coverage.format } : {}
1655
+ });
995
1656
  const { kept, ignored } = splitByIgnore(
996
1657
  allFiles.map((f) => f.path),
997
1658
  config.ignores
@@ -1000,11 +1661,12 @@ async function computeDiff(opts) {
1000
1661
  const files = allFiles.filter((f) => keptSet.has(f.path));
1001
1662
  let projectDelta = null;
1002
1663
  if (opts.withBaseCoverage) {
1003
- const baseCoveragePath = resolve3(cwd, opts.withBaseCoverage);
1664
+ const baseCoveragePath = resolve4(cwd, opts.withBaseCoverage);
1004
1665
  assertWithinRoot(ctx.repoRoot, baseCoveragePath);
1005
- const baseFiles = await parseIstanbul({
1666
+ const baseFiles = await parseCoverage({
1006
1667
  path: baseCoveragePath,
1007
- repoRoot: ctx.repoRoot
1668
+ repoRoot: ctx.repoRoot,
1669
+ ...config.coverage.format ? { format: config.coverage.format } : {}
1008
1670
  });
1009
1671
  const baseKept = baseFiles.filter((f) => !ignored.includes(f.path));
1010
1672
  const baseExec = baseKept.reduce((n, f) => n + f.statements.length, 0);
@@ -1023,7 +1685,7 @@ async function computeDiff(opts) {
1023
1685
  })();
1024
1686
  projectDelta = Math.round((headPct - basePct) * 10) / 10;
1025
1687
  }
1026
- return buildDiffOutput({
1688
+ const diff = buildDiffOutput({
1027
1689
  base: baseRef,
1028
1690
  head,
1029
1691
  files,
@@ -1031,6 +1693,201 @@ async function computeDiff(opts) {
1031
1693
  ignored,
1032
1694
  projectDelta
1033
1695
  });
1696
+ return { diff, files, addedByFile };
1697
+ }
1698
+
1699
+ // src/core/flags.ts
1700
+ import { minimatch as minimatch2 } from "minimatch";
1701
+ var MISSING_FLAG_REASON = "no coverage files matched this flag in this run";
1702
+ var SCOPED_MISSING_FLAG_REASON = "no coverage files in this run for this flag";
1703
+ var MATCH_OPTS = { dot: true, matchBase: true };
1704
+ function pathMatchesFlag(filePath, patterns) {
1705
+ const normalized = filePath.replace(/\\/g, "/");
1706
+ return patterns.some(
1707
+ (p) => minimatch2(normalized, p, MATCH_OPTS) || minimatch2(normalized, `**/${p}`, MATCH_OPTS)
1708
+ );
1709
+ }
1710
+ function filterFilesByFlag(files, patterns) {
1711
+ return files.filter((f) => pathMatchesFlag(f.path, patterns));
1712
+ }
1713
+ function resolveFlagThresholds(flag, global) {
1714
+ return {
1715
+ patch: flag.thresholds?.patch ?? global.patch,
1716
+ project: flag.thresholds?.project ?? global.project
1717
+ };
1718
+ }
1719
+ function unknownFlagError(name, configured) {
1720
+ const list = configured.length > 0 ? configured.join(", ") : "(none)";
1721
+ return new Error(`unknown flag "${name}" \u2014 configured flags: ${list}`);
1722
+ }
1723
+ function totalsToMetric(totals, threshold, kind) {
1724
+ const empty = isEmptyPatch(totals);
1725
+ if (kind === "patch" && empty) {
1726
+ return {
1727
+ pct: totals.pct,
1728
+ threshold,
1729
+ pass: true,
1730
+ executable: totals.executable,
1731
+ covered: totals.covered,
1732
+ skipped: true,
1733
+ reason: EMPTY_PATCH_REASON
1734
+ };
1735
+ }
1736
+ const pass = totals.pct >= threshold;
1737
+ return {
1738
+ pct: totals.pct,
1739
+ threshold,
1740
+ pass,
1741
+ executable: totals.executable,
1742
+ covered: totals.covered
1743
+ };
1744
+ }
1745
+ function missingMetric(threshold) {
1746
+ return {
1747
+ pct: 0,
1748
+ threshold,
1749
+ pass: false,
1750
+ executable: 0,
1751
+ covered: 0
1752
+ };
1753
+ }
1754
+ function checkSlug(kind, name) {
1755
+ return `tested.dev / ${kind} / ${name}`;
1756
+ }
1757
+ function evaluatePresentFlag(name, files, addedByFile, thresholds) {
1758
+ const patch = computePatchCoverage(files, addedByFile);
1759
+ const project = computeProjectCoverage(files);
1760
+ const patchMetric = totalsToMetric(patch.totals, thresholds.patch, "patch");
1761
+ const projectMetric = totalsToMetric(project.totals, thresholds.project, "project");
1762
+ const status = patchMetric.pass && projectMetric.pass ? "pass" : "fail";
1763
+ return {
1764
+ name,
1765
+ present: true,
1766
+ status,
1767
+ patchCheck: checkSlug("patch", name),
1768
+ projectCheck: checkSlug("project", name),
1769
+ patch: patchMetric,
1770
+ project: projectMetric
1771
+ };
1772
+ }
1773
+ function missingFlag(name, thresholds, reason) {
1774
+ return {
1775
+ name,
1776
+ present: false,
1777
+ status: "missing",
1778
+ reason,
1779
+ patchCheck: checkSlug("patch", name),
1780
+ projectCheck: checkSlug("project", name),
1781
+ patch: missingMetric(thresholds.patch),
1782
+ project: missingMetric(thresholds.project)
1783
+ };
1784
+ }
1785
+ function evaluateFlags(input) {
1786
+ const flags = input.config.flags;
1787
+ const global = input.config.thresholds;
1788
+ if (!flags || !global) return [];
1789
+ const names = Object.keys(flags);
1790
+ if (input.onlyFlag !== void 0 && input.onlyFlag !== "") {
1791
+ const def = flags[input.onlyFlag];
1792
+ if (!def) throw unknownFlagError(input.onlyFlag, names);
1793
+ const thresholds = resolveFlagThresholds(def, global);
1794
+ if (input.files.length === 0) {
1795
+ return [missingFlag(input.onlyFlag, thresholds, SCOPED_MISSING_FLAG_REASON)];
1796
+ }
1797
+ return [evaluatePresentFlag(input.onlyFlag, input.files, input.addedByFile, thresholds)];
1798
+ }
1799
+ const results = [];
1800
+ for (const name of names) {
1801
+ const def = flags[name];
1802
+ const thresholds = resolveFlagThresholds(def, global);
1803
+ const matched = filterFilesByFlag(input.files, def.paths);
1804
+ if (matched.length === 0) {
1805
+ results.push(missingFlag(name, thresholds, MISSING_FLAG_REASON));
1806
+ continue;
1807
+ }
1808
+ results.push(evaluatePresentFlag(name, matched, input.addedByFile, thresholds));
1809
+ }
1810
+ return results;
1811
+ }
1812
+ function flagsPass(results) {
1813
+ return results.every((f) => f.status === "pass");
1814
+ }
1815
+ function flagsToJson(results) {
1816
+ const out = {};
1817
+ for (const flag of results) {
1818
+ out[flag.name] = {
1819
+ status: flag.status,
1820
+ present: flag.present,
1821
+ ...flag.reason ? { reason: flag.reason } : {},
1822
+ patchCheck: flag.patchCheck,
1823
+ projectCheck: flag.projectCheck,
1824
+ patch: flag.patch,
1825
+ project: flag.project
1826
+ };
1827
+ }
1828
+ return out;
1829
+ }
1830
+ function resolveFlagsJson(input) {
1831
+ const results = evaluateFlags(input);
1832
+ return results.length > 0 ? flagsToJson(results) : void 0;
1833
+ }
1834
+
1835
+ // src/core/coverage-merge.ts
1836
+ function emptyToUndef(value) {
1837
+ const trimmed = value?.trim();
1838
+ return trimmed ? trimmed : void 0;
1839
+ }
1840
+ function parsePositiveInt(raw, label) {
1841
+ const trimmed = emptyToUndef(raw);
1842
+ if (trimmed === void 0) return void 0;
1843
+ const n = Number(trimmed);
1844
+ if (!Number.isInteger(n) || n <= 0) {
1845
+ throw new Error(`invalid ${label} "${raw}" \u2014 expected a positive integer`);
1846
+ }
1847
+ return n;
1848
+ }
1849
+ function resolveCoverageMerge(cli, env = process.env) {
1850
+ if (cli.complete && cli.incomplete) {
1851
+ throw new Error("cannot pass both --complete and --incomplete");
1852
+ }
1853
+ const totalParts = parsePositiveInt(cli.parts ?? env.TESTED_PARTS, "--parts");
1854
+ const part = parsePositiveInt(cli.part ?? env.TESTED_PART, "--part");
1855
+ const runId = emptyToUndef(cli.runId ?? env.TESTED_RUN_ID);
1856
+ const shard = emptyToUndef(cli.shard ?? env.TESTED_SHARD);
1857
+ if (part !== void 0 && totalParts !== void 0 && part > totalParts) {
1858
+ throw new Error(`--part ${part} is greater than --parts ${totalParts}`);
1859
+ }
1860
+ let complete;
1861
+ if (cli.complete) {
1862
+ complete = true;
1863
+ } else if (cli.incomplete) {
1864
+ complete = false;
1865
+ } else if (totalParts !== void 0) {
1866
+ complete = part !== void 0 && part === totalParts;
1867
+ } else {
1868
+ complete = true;
1869
+ }
1870
+ const state = { complete };
1871
+ if (totalParts !== void 0) state.totalParts = totalParts;
1872
+ if (part !== void 0) state.part = part;
1873
+ if (runId !== void 0) state.runId = runId;
1874
+ if (shard !== void 0) state.shard = shard;
1875
+ return state;
1876
+ }
1877
+ function toCoverageMergePayload(state) {
1878
+ const payload = { complete: state.complete };
1879
+ if (state.totalParts !== void 0) payload.totalParts = state.totalParts;
1880
+ if (state.part !== void 0) payload.part = state.part;
1881
+ if (state.runId !== void 0) payload.runId = state.runId;
1882
+ if (state.shard !== void 0) payload.shard = state.shard;
1883
+ return payload;
1884
+ }
1885
+ function hasShardMetadata(state) {
1886
+ return !state.complete || state.totalParts !== void 0 || state.part !== void 0 || state.runId !== void 0 || state.shard !== void 0;
1887
+ }
1888
+ function formatIncompleteGateMessage(state) {
1889
+ const part = state.part !== void 0 && state.totalParts !== void 0 ? `part ${state.part} of ${state.totalParts}` : state.totalParts !== void 0 ? `waiting for ${state.totalParts} parts` : "incomplete shard";
1890
+ return `coverage shard is incomplete (${part}). The patch/project gate runs only on --complete or the last part.`;
1034
1891
  }
1035
1892
 
1036
1893
  // src/commands/push.ts
@@ -1056,10 +1913,10 @@ function parseGitHubRepository(repo) {
1056
1913
  var tokenArgvWarned = false;
1057
1914
  function readTokenFile(filePath, opts) {
1058
1915
  const read = opts?.readFileSyncFn ?? readFileSync2;
1059
- const stat = opts?.statSyncFn ?? statSync;
1916
+ const stat3 = opts?.statSyncFn ?? statSync;
1060
1917
  let mode;
1061
1918
  try {
1062
- const st = stat(filePath);
1919
+ const st = stat3(filePath);
1063
1920
  mode = st.mode;
1064
1921
  } catch (err) {
1065
1922
  const message = err instanceof Error ? err.message : String(err);
@@ -1331,8 +2188,10 @@ function buildIngestBody(input) {
1331
2188
  state: "open"
1332
2189
  },
1333
2190
  runUrl: input.runUrl,
1334
- diff: input.diff,
1335
- ...input.testReport ? { testReport: input.testReport } : {}
2191
+ ...input.diff ? { diff: input.diff } : {},
2192
+ ...input.testReport ? { testReport: input.testReport } : {},
2193
+ ...input.coverageMerge ? { coverageMerge: input.coverageMerge } : {},
2194
+ ...input.flags ? { flags: input.flags } : {}
1336
2195
  };
1337
2196
  }
1338
2197
  function buildMainlineIngestBody(input) {
@@ -1343,11 +2202,13 @@ function buildMainlineIngestBody(input) {
1343
2202
  defaultBranch: input.defaultBranch
1344
2203
  },
1345
2204
  runUrl: input.runUrl,
1346
- diff: input.diff,
2205
+ ...input.diff ? { diff: input.diff } : {},
1347
2206
  ref: input.ref,
1348
2207
  isDefaultBranch: true,
1349
2208
  headSha: input.headSha,
1350
- ...input.testReport ? { testReport: input.testReport } : {}
2209
+ ...input.testReport ? { testReport: input.testReport } : {},
2210
+ ...input.coverageMerge ? { coverageMerge: input.coverageMerge } : {},
2211
+ ...input.flags ? { flags: input.flags } : {}
1351
2212
  };
1352
2213
  }
1353
2214
  var DEFAULT_JUNIT_CANDIDATES = [
@@ -1358,10 +2219,10 @@ var DEFAULT_JUNIT_CANDIDATES = [
1358
2219
  ];
1359
2220
  function resolveJunitPath(opts) {
1360
2221
  const env = opts.env ?? process.env;
1361
- const exists = opts.existsSyncFn ?? existsSync2;
2222
+ const exists = opts.existsSyncFn ?? existsSync3;
1362
2223
  if (opts.flag && opts.flag.trim()) {
1363
2224
  const p = opts.flag.trim();
1364
- const abs = p.startsWith("/") ? p : join3(opts.cwd, p);
2225
+ const abs = p.startsWith("/") ? p : join4(opts.cwd, p);
1365
2226
  if (!exists(abs)) {
1366
2227
  throw new Error(`JUnit file not found: ${p}`);
1367
2228
  }
@@ -1369,14 +2230,14 @@ function resolveJunitPath(opts) {
1369
2230
  }
1370
2231
  const fromEnv = env.TESTED_JUNIT?.trim();
1371
2232
  if (fromEnv) {
1372
- const abs = fromEnv.startsWith("/") ? fromEnv : join3(opts.cwd, fromEnv);
2233
+ const abs = fromEnv.startsWith("/") ? fromEnv : join4(opts.cwd, fromEnv);
1373
2234
  if (!exists(abs)) {
1374
2235
  throw new Error(`TESTED_JUNIT file not found: ${fromEnv}`);
1375
2236
  }
1376
2237
  return abs;
1377
2238
  }
1378
2239
  for (const rel of DEFAULT_JUNIT_CANDIDATES) {
1379
- const abs = join3(opts.cwd, rel);
2240
+ const abs = join4(opts.cwd, rel);
1380
2241
  if (exists(abs)) return abs;
1381
2242
  }
1382
2243
  return null;
@@ -1422,15 +2283,12 @@ async function postIngest(opts) {
1422
2283
  parsed = null;
1423
2284
  }
1424
2285
  }
1425
- if (res.status === 200) {
1426
- const data = parsed;
1427
- if (!data) {
1428
- return {
1429
- ok: false,
1430
- status: res.status,
1431
- message: "ingest succeeded but response was empty"
1432
- };
1433
- }
2286
+ const concludesGate = opts.body.coverageMerge?.complete !== false;
2287
+ const handshakeOnly = concludesGate && !opts.body.diff;
2288
+ const acceptIncomplete = opts.body.coverageMerge?.complete === false;
2289
+ const successStatus = res.status === 200 || acceptIncomplete && res.status === 202;
2290
+ if (successStatus) {
2291
+ const data = parsed ?? {};
1434
2292
  if (data.mainline === true) {
1435
2293
  return {
1436
2294
  ok: true,
@@ -1438,11 +2296,20 @@ async function postIngest(opts) {
1438
2296
  data: {
1439
2297
  mainline: true,
1440
2298
  ...typeof data.date === "string" ? { date: data.date } : {},
1441
- ...typeof data.projectPct === "number" ? { projectPct: data.projectPct } : {}
2299
+ ...typeof data.projectPct === "number" ? { projectPct: data.projectPct } : {},
2300
+ ...acceptIncomplete ? { complete: false } : {}
1442
2301
  }
1443
2302
  };
1444
2303
  }
1445
- if (typeof data.shareUrl !== "string" || !data.shareUrl) {
2304
+ const shareUrl2 = typeof data.shareUrl === "string" && data.shareUrl ? data.shareUrl : void 0;
2305
+ if (concludesGate && !handshakeOnly && !acceptIncomplete && !shareUrl2) {
2306
+ if (parsed === null) {
2307
+ return {
2308
+ ok: false,
2309
+ status: res.status,
2310
+ message: "ingest succeeded but response was empty"
2311
+ };
2312
+ }
1446
2313
  return {
1447
2314
  ok: false,
1448
2315
  status: res.status,
@@ -1453,8 +2320,9 @@ async function postIngest(opts) {
1453
2320
  ok: true,
1454
2321
  status: res.status,
1455
2322
  data: {
1456
- shareUrl: data.shareUrl,
1457
- ...typeof data.expiresAt === "string" ? { expiresAt: data.expiresAt } : {}
2323
+ ...shareUrl2 ? { shareUrl: shareUrl2 } : {},
2324
+ ...typeof data.expiresAt === "string" ? { expiresAt: data.expiresAt } : {},
2325
+ ...acceptIncomplete || handshakeOnly ? { complete: concludesGate } : {}
1458
2326
  }
1459
2327
  };
1460
2328
  }
@@ -1471,7 +2339,8 @@ async function postIngest(opts) {
1471
2339
  }
1472
2340
  return { ok: false, status: res.status, message, ...code ? { code } : {} };
1473
2341
  }
1474
- function formatPushSuccess(data, json) {
2342
+ function formatPushSuccess(data, json, merge) {
2343
+ const incomplete = merge ? !merge.complete : data.complete === false;
1475
2344
  if (json) {
1476
2345
  const payload = {};
1477
2346
  if (data.shareUrl) payload.shareUrl = data.shareUrl;
@@ -1479,9 +2348,23 @@ function formatPushSuccess(data, json) {
1479
2348
  if (data.mainline) payload.mainline = true;
1480
2349
  if (data.date) payload.date = data.date;
1481
2350
  if (typeof data.projectPct === "number") payload.projectPct = data.projectPct;
2351
+ if (merge && hasShardMetadata(merge)) {
2352
+ payload.complete = merge.complete;
2353
+ if (merge.part !== void 0) payload.part = merge.part;
2354
+ if (merge.totalParts !== void 0) payload.totalParts = merge.totalParts;
2355
+ } else if (incomplete) {
2356
+ payload.complete = false;
2357
+ }
1482
2358
  return { stdout: JSON.stringify(payload) + "\n", stderr: "" };
1483
2359
  }
1484
2360
  const lines = [];
2361
+ if (incomplete && merge) {
2362
+ lines.push(successLine(`uploaded shard (${formatIncompleteGateMessage(merge)})`));
2363
+ if (data.shareUrl) {
2364
+ lines.push(dim(` ${shareUrl(data.shareUrl)} (pending \u2014 not a gate result)`));
2365
+ }
2366
+ return { stdout: lines.join("\n") + "\n", stderr: "" };
2367
+ }
1485
2368
  if (data.mainline) {
1486
2369
  lines.push(
1487
2370
  successLine(
@@ -1537,7 +2420,8 @@ function formatPushError(status, message, code, identity) {
1537
2420
  }
1538
2421
  async function executePush(cli, deps) {
1539
2422
  const env = deps.env ?? process.env;
1540
- const computeDiffFn = deps.computeDiffFn ?? computeDiff;
2423
+ const computeDiffFn = deps.computeDiffFn;
2424
+ const computeDiffContextFn = deps.computeDiffContextFn ?? computeDiffContext;
1541
2425
  const fetchFn = deps.fetchFn ?? globalThis.fetch;
1542
2426
  const openRepoFn = deps.openRepoFn ?? openRepo;
1543
2427
  const loadConfigFn = deps.loadConfigFn ?? loadConfig;
@@ -1601,6 +2485,14 @@ async function executePush(cli, deps) {
1601
2485
  const message = err instanceof Error ? err.message : String(err);
1602
2486
  return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
1603
2487
  }
2488
+ let merge;
2489
+ try {
2490
+ merge = resolveCoverageMerge(cli, env);
2491
+ } catch (err) {
2492
+ const message = err instanceof Error ? err.message : String(err);
2493
+ return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
2494
+ }
2495
+ const coverageMerge = toCoverageMergePayload(merge);
1604
2496
  const config = await loadConfigFn({ cwd: deps.cwd });
1605
2497
  const ctx = await openRepoFn(deps.cwd);
1606
2498
  const peeked = await peekRepoIdentity({
@@ -1609,32 +2501,68 @@ async function executePush(cli, deps) {
1609
2501
  env,
1610
2502
  ctx
1611
2503
  });
2504
+ const coveragePaths = resolveCoveragePaths({
2505
+ ...cli.file && cli.file.length > 0 ? { files: cli.file } : {},
2506
+ env,
2507
+ configPath: config.coverage.path
2508
+ });
2509
+ const existingCoverage = existingCoveragePaths(coveragePaths, deps.cwd);
2510
+ const handshakeOnly = merge.complete && existingCoverage.length === 0 && (cli.complete === true || merge.totalParts !== void 0);
1612
2511
  let diff;
1613
- try {
1614
- let baseOverride = cli.base;
1615
- if (baseOverride === void 0 && prNumber !== null) {
1616
- const resolved = await resolvePrPushBase({
2512
+ let files = [];
2513
+ let addedByFile = /* @__PURE__ */ new Map();
2514
+ if (!handshakeOnly) {
2515
+ try {
2516
+ let baseOverride = cli.base;
2517
+ if (baseOverride === void 0 && prNumber !== null) {
2518
+ const resolved = await resolvePrPushBase({
2519
+ ctx,
2520
+ requested: config.base,
2521
+ prNumber,
2522
+ ...peeked.owner != null ? { owner: peeked.owner } : {},
2523
+ ...peeked.name != null ? { name: peeked.name } : {},
2524
+ fetchFn,
2525
+ env,
2526
+ onProgress
2527
+ });
2528
+ if (resolved !== void 0) baseOverride = resolved;
2529
+ }
2530
+ onProgress("computing diff\u2026");
2531
+ const diffOpts = {
2532
+ cwd: deps.cwd,
2533
+ config,
2534
+ ...baseOverride !== void 0 ? { baseRef: baseOverride } : {},
1617
2535
  ctx,
1618
- requested: config.base,
1619
- prNumber,
1620
- ...peeked.owner != null ? { owner: peeked.owner } : {},
1621
- ...peeked.name != null ? { name: peeked.name } : {},
1622
- fetchFn,
1623
- env,
1624
- onProgress
2536
+ ...coveragePaths.length > 0 ? { coveragePaths } : {}
2537
+ };
2538
+ if (computeDiffFn) {
2539
+ diff = await computeDiffFn(diffOpts);
2540
+ } else {
2541
+ const computed = await computeDiffContextFn(diffOpts);
2542
+ diff = computed.diff;
2543
+ files = computed.files;
2544
+ addedByFile = computed.addedByFile;
2545
+ }
2546
+ } catch (err) {
2547
+ const message = err instanceof Error ? err.message : String(err);
2548
+ return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
2549
+ }
2550
+ } else {
2551
+ onProgress("complete handshake (no local coverage)\u2026");
2552
+ }
2553
+ let flags;
2554
+ if (!handshakeOnly && !computeDiffFn) {
2555
+ try {
2556
+ flags = resolveFlagsJson({
2557
+ config,
2558
+ files,
2559
+ addedByFile,
2560
+ ...cli.flag && cli.flag.trim() ? { onlyFlag: cli.flag.trim() } : {}
1625
2561
  });
1626
- if (resolved !== void 0) baseOverride = resolved;
2562
+ } catch (err) {
2563
+ const message = err instanceof Error ? err.message : String(err);
2564
+ return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
1627
2565
  }
1628
- onProgress("computing diff\u2026");
1629
- diff = await computeDiffFn({
1630
- cwd: deps.cwd,
1631
- config,
1632
- ...baseOverride !== void 0 ? { baseRef: baseOverride } : {},
1633
- ctx
1634
- });
1635
- } catch (err) {
1636
- const message = err instanceof Error ? err.message : String(err);
1637
- return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
1638
2566
  }
1639
2567
  let owner = cli.owner ?? peeked.owner ?? void 0;
1640
2568
  let name = cli.name ?? peeked.name ?? void 0;
@@ -1690,13 +2618,19 @@ async function executePush(cli, deps) {
1690
2618
  env
1691
2619
  });
1692
2620
  if (junitPath) {
1693
- onProgress("parsing JUnit\u2026");
2621
+ onProgress(`parsing JUnit (${junitPath})\u2026`);
1694
2622
  testReport = loadTestReportFromJunit(junitPath);
1695
2623
  }
1696
2624
  } catch (err) {
1697
2625
  const message = err instanceof Error ? err.message : String(err);
1698
2626
  return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
1699
2627
  }
2628
+ const mergeFields = {
2629
+ coverageMerge,
2630
+ ...diff ? { diff } : {},
2631
+ ...testReport ? { testReport } : {},
2632
+ ...flags ? { flags } : {}
2633
+ };
1700
2634
  const body = mainline ? buildMainlineIngestBody({
1701
2635
  owner,
1702
2636
  name,
@@ -1704,8 +2638,7 @@ async function executePush(cli, deps) {
1704
2638
  headSha: sha,
1705
2639
  ref: `refs/heads/${baseRef}`,
1706
2640
  runUrl: cli.runUrl ?? null,
1707
- diff,
1708
- ...testReport ? { testReport } : {}
2641
+ ...mergeFields
1709
2642
  }) : buildIngestBody({
1710
2643
  owner,
1711
2644
  name,
@@ -1716,10 +2649,11 @@ async function executePush(cli, deps) {
1716
2649
  headRef,
1717
2650
  headSha: sha,
1718
2651
  runUrl: cli.runUrl ?? null,
1719
- diff,
1720
- ...testReport ? { testReport } : {}
2652
+ ...mergeFields
1721
2653
  });
1722
- onProgress(mainline ? "uploading mainline coverage\u2026" : "uploading\u2026");
2654
+ onProgress(
2655
+ !merge.complete ? "uploading shard (incomplete)\u2026" : mainline ? "uploading mainline coverage\u2026" : handshakeOnly ? "sending complete handshake\u2026" : "uploading\u2026"
2656
+ );
1723
2657
  const result = await postIngest({ apiBase, token, body, fetchFn });
1724
2658
  if (!result.ok) {
1725
2659
  return {
@@ -1731,11 +2665,12 @@ async function executePush(cli, deps) {
1731
2665
  })
1732
2666
  };
1733
2667
  }
1734
- const formatted = formatPushSuccess(result.data, cli.json);
2668
+ const formatted = formatPushSuccess(result.data, cli.json, merge);
1735
2669
  return {
1736
2670
  exitCode: 0,
1737
2671
  stdout: formatted.stdout,
1738
2672
  stderr: formatted.stderr,
2673
+ complete: merge.complete,
1739
2674
  ...result.data.shareUrl !== void 0 ? { shareUrl: result.data.shareUrl } : {},
1740
2675
  ...result.data.expiresAt !== void 0 ? { expiresAt: result.data.expiresAt } : {}
1741
2676
  };
@@ -1759,7 +2694,15 @@ function registerPushCommand(program2) {
1759
2694
  "Base branch name sent to the API (default: .tested.yaml base or main)"
1760
2695
  ).option("--head-ref <ref>", "Head branch name (default: current branch)").option("--run-url <url>", "Optional CI run URL attached to the ingest").option("--base <ref>", "Git base ref to diff against (same as `tested diff --base`)").option(
1761
2696
  "--junit <path>",
1762
- "JUnit XML for test analytics (flakes / slowest). Also TESTED_JUNIT or junit.xml"
2697
+ "JUnit XML for test analytics (flakes / slowest). Also TESTED_JUNIT or auto-detect junit.xml / test-results/junit.xml / coverage/junit.xml"
2698
+ ).option(
2699
+ "--file <path>",
2700
+ "Coverage file to merge (repeatable). Overrides coverage.path.",
2701
+ collectCoverageFile,
2702
+ []
2703
+ ).option("--complete", "Conclude the gate (last shard / finish job)", false).option("--incomplete", "Upload a shard without concluding GitHub checks", false).option("--parts <n>", "Total shard count (Codecov after_n_builds / Qlty total-parts-count)").option("--part <n>", "1-based shard index").option("--run-id <id>", "CI run id grouping shards for one SHA (or TESTED_RUN_ID)").option("--shard <id>", "Optional shard label (or TESTED_SHARD)").option(
2704
+ "--flag <name>",
2705
+ "This coverage file is the named flag (job already scoped \u2014 omit other packages)"
1763
2706
  ).option("--json", "Emit machine-readable JSON instead of the share URL only", false).action(async (opts) => {
1764
2707
  try {
1765
2708
  const result = await executePush(opts, { cwd: process.cwd() });
@@ -1833,7 +2776,7 @@ function isReadableFile(path, exists) {
1833
2776
  async function runDoctor(deps) {
1834
2777
  const cwd = deps.cwd;
1835
2778
  const env = deps.env ?? process.env;
1836
- const exists = deps.existsSyncFn ?? existsSync3;
2779
+ const exists = deps.existsSyncFn ?? existsSync4;
1837
2780
  const gitFactory = deps.gitFactory ?? simpleGit3;
1838
2781
  const loadConfigFn = deps.loadConfigFn ?? loadConfig;
1839
2782
  const resolveTokenFn = deps.resolveTokenFn ?? resolveToken;
@@ -1880,7 +2823,7 @@ async function runDoctor(deps) {
1880
2823
  detail: "not a git repository \u2014 run from a repo root"
1881
2824
  });
1882
2825
  }
1883
- const configPath = join4(cwd, ".tested.yaml");
2826
+ const configPath = join5(cwd, ".tested.yaml");
1884
2827
  const hasConfig = exists(configPath);
1885
2828
  if (hasConfig) {
1886
2829
  checks.push({
@@ -1897,19 +2840,21 @@ async function runDoctor(deps) {
1897
2840
  detail: "missing \u2014 run: tested setup (or tested init)"
1898
2841
  });
1899
2842
  }
1900
- let coverageRel = "coverage/coverage-final.json";
2843
+ let coverageRels = ["coverage/coverage-final.json"];
1901
2844
  if (hasConfig) {
1902
2845
  try {
1903
2846
  const config = await loadConfigFn({ cwd });
1904
- coverageRel = config.coverage.path;
2847
+ const listed = coveragePathList(config.coverage.path);
2848
+ if (listed.length > 0) coverageRels = listed;
1905
2849
  } catch {
1906
2850
  }
1907
2851
  }
1908
- const coverageAbs = resolve4(cwd, coverageRel);
1909
- if (isReadableFile(coverageAbs, exists)) {
2852
+ const coverageRel = coverageRels.join(", ");
2853
+ const missing = coverageRels.filter((rel) => !isReadableFile(resolve5(cwd, rel), exists));
2854
+ if (missing.length === 0) {
1910
2855
  checks.push({
1911
2856
  id: "coverage",
1912
- label: "Coverage file",
2857
+ label: coverageRels.length > 1 ? "Coverage files" : "Coverage file",
1913
2858
  status: "pass",
1914
2859
  detail: coverageRel,
1915
2860
  optional: true
@@ -1917,9 +2862,9 @@ async function runDoctor(deps) {
1917
2862
  } else {
1918
2863
  checks.push({
1919
2864
  id: "coverage",
1920
- label: "Coverage file",
2865
+ label: coverageRels.length > 1 ? "Coverage files" : "Coverage file",
1921
2866
  status: "warn",
1922
- detail: `missing ${coverageRel} \u2014 run: tested run`,
2867
+ detail: `missing ${missing.join(", ")} \u2014 run: tested run`,
1923
2868
  optional: true
1924
2869
  });
1925
2870
  }
@@ -2042,7 +2987,7 @@ async function runDoctor(deps) {
2042
2987
  }
2043
2988
  const testedBin = env.TESTED_BIN;
2044
2989
  if (testedBin !== void 0 && testedBin !== "") {
2045
- const base = basename(testedBin);
2990
+ const base = basename2(testedBin);
2046
2991
  const okName = TESTED_BIN_BASENAME_RE.test(base);
2047
2992
  if (!okName) {
2048
2993
  checks.push({
@@ -2127,7 +3072,7 @@ import pc3 from "picocolors";
2127
3072
  // package.json
2128
3073
  var package_default = {
2129
3074
  name: "@tested/cli",
2130
- version: "0.1.7",
3075
+ version: "0.1.8",
2131
3076
  description: "Coverage your agent can use. CLI for patch + project coverage with agent-readable JSON output.",
2132
3077
  license: "MIT",
2133
3078
  homepage: "https://tested.dev",
@@ -2278,13 +3223,13 @@ function formatSetupHuman(opts) {
2278
3223
  async function runSetup(deps) {
2279
3224
  const cwd = deps.cwd;
2280
3225
  const env = deps.env ?? process.env;
2281
- const exists = deps.existsSyncFn ?? existsSync4;
3226
+ const exists = deps.existsSyncFn ?? existsSync5;
2282
3227
  const runInitFn = deps.runInitFn ?? runInit;
2283
3228
  const runDoctorFn = deps.runDoctorFn ?? runDoctor;
2284
3229
  const force = deps.force ?? false;
2285
3230
  const hooks = deps.hooks ?? false;
2286
3231
  const json = deps.json ?? false;
2287
- const configPath = join5(cwd, ".tested.yaml");
3232
+ const configPath = join6(cwd, ".tested.yaml");
2288
3233
  let initRan = false;
2289
3234
  let initResult = null;
2290
3235
  if (!exists(configPath) || force) {
@@ -2368,8 +3313,8 @@ function registerSetupCommand(program2) {
2368
3313
  }
2369
3314
 
2370
3315
  // src/commands/run.ts
2371
- import { existsSync as existsSync5 } from "fs";
2372
- import { isAbsolute as isAbsolute3, resolve as resolve5, sep as sep2 } from "path";
3316
+ import { existsSync as existsSync6 } from "fs";
3317
+ import { isAbsolute as isAbsolute3, resolve as resolve6, sep as sep2 } from "path";
2373
3318
  import { spawn } from "child_process";
2374
3319
  import "commander";
2375
3320
  function splitRunArgs(extraArgs) {
@@ -2439,8 +3384,8 @@ function shouldEnforceSafeRun(opts) {
2439
3384
  return false;
2440
3385
  }
2441
3386
  function configPathEscapesRoot(configPath, repoRoot) {
2442
- const root = resolve5(repoRoot);
2443
- const abs = isAbsolute3(configPath) ? resolve5(configPath) : resolve5(repoRoot, configPath);
3387
+ const root = resolve6(repoRoot);
3388
+ const abs = isAbsolute3(configPath) ? resolve6(configPath) : resolve6(repoRoot, configPath);
2444
3389
  const safeRoot = root.endsWith(sep2) ? root : root + sep2;
2445
3390
  return !(abs === root || abs.startsWith(safeRoot));
2446
3391
  }
@@ -2491,7 +3436,8 @@ function registerRunCommand(program2) {
2491
3436
  return;
2492
3437
  }
2493
3438
  const config = await loadConfig({ cwd });
2494
- const coveragePath = resolve5(cwd, config.coverage.path);
3439
+ const coverageRel = coveragePathList(config.coverage.path)[0] ?? "coverage/coverage-final.json";
3440
+ const coveragePath = resolve6(cwd, coverageRel);
2495
3441
  const { command, args } = resolveRunCommand({
2496
3442
  runner: config.testRunner,
2497
3443
  extraArgs: forwarded
@@ -2503,14 +3449,14 @@ function registerRunCommand(program2) {
2503
3449
  const child = spawn(command, args, { stdio: "inherit" });
2504
3450
  child.on("exit", (code) => {
2505
3451
  const exit = code ?? 1;
2506
- const coverageWritten = existsSync5(coveragePath);
3452
+ const coverageWritten = existsSync6(coveragePath);
2507
3453
  if (json) {
2508
3454
  const payload = buildRunJsonOutput({
2509
3455
  command,
2510
3456
  args,
2511
3457
  exitCode: exit,
2512
3458
  coverageWritten,
2513
- coveragePath: config.coverage.path
3459
+ coveragePath: coverageRel
2514
3460
  });
2515
3461
  process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
2516
3462
  process.exit(exit);
@@ -2524,7 +3470,7 @@ function registerRunCommand(program2) {
2524
3470
  process.stderr.write("\n");
2525
3471
  process.stderr.write(
2526
3472
  dim(
2527
- coverageWritten ? `tests failed (exit ${exit}); coverage still written to ${config.coverage.path}` : `tests failed (exit ${exit}); no coverage file at ${config.coverage.path}`
3473
+ coverageWritten ? `tests failed (exit ${exit}); coverage still written to ${coverageRel}` : `tests failed (exit ${exit}); no coverage file at ${coverageRel}`
2528
3474
  ) + "\n"
2529
3475
  );
2530
3476
  }
@@ -2665,21 +3611,29 @@ function formatHuman(out, opts = {}) {
2665
3611
 
2666
3612
  // src/commands/diff.ts
2667
3613
  function registerDiffCommand(program2) {
2668
- program2.command("diff").description("Compute patch + project coverage against a base ref").option("--base <ref>", "Git base ref to diff against", void 0).option("--with-base-coverage <path>", "Compare project coverage against a baseline JSON", void 0).option("--json", "Emit schema-v1 JSON instead of human text", false).action(async (opts) => {
3614
+ program2.command("diff").description("Compute patch + project coverage against a base ref").option("--base <ref>", "Git base ref to diff against", void 0).option("--with-base-coverage <path>", "Compare project coverage against a baseline JSON", void 0).option(
3615
+ "--file <path>",
3616
+ "Coverage file to merge (repeatable). Overrides coverage.path.",
3617
+ collectCoverageFile,
3618
+ []
3619
+ ).option("--json", "Emit schema-v1 JSON instead of human text", false).action(async (opts) => {
2669
3620
  try {
2670
3621
  const cwd = process.cwd();
2671
3622
  const config = await loadConfig({ cwd });
2672
- const output = await computeDiff({
3623
+ const { diff, files, addedByFile } = await computeDiffContext({
2673
3624
  cwd,
2674
3625
  config,
2675
3626
  ...opts.base !== void 0 ? { baseRef: opts.base } : {},
2676
- ...opts.withBaseCoverage !== void 0 ? { withBaseCoverage: opts.withBaseCoverage } : {}
3627
+ ...opts.withBaseCoverage !== void 0 ? { withBaseCoverage: opts.withBaseCoverage } : {},
3628
+ ...opts.file && opts.file.length > 0 ? { coveragePaths: opts.file } : {}
2677
3629
  });
2678
3630
  if (opts.json) {
2679
- process.stdout.write(JSON.stringify(output, null, 2) + "\n");
3631
+ const flags = resolveFlagsJson({ config, files, addedByFile });
3632
+ const payload = flags ? { ...diff, flags } : diff;
3633
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
2680
3634
  } else {
2681
3635
  process.stdout.write(
2682
- formatHuman(output, {
3636
+ formatHuman(diff, {
2683
3637
  ...config.thresholds ? { thresholds: config.thresholds } : {},
2684
3638
  tips: true
2685
3639
  }) + "\n"
@@ -2694,11 +3648,107 @@ function registerDiffCommand(program2) {
2694
3648
  }
2695
3649
 
2696
3650
  // src/commands/check.ts
3651
+ import { existsSync as existsSync7 } from "fs";
2697
3652
  import "commander";
2698
- function formatMetricLine(label, pct3, threshold, pass) {
3653
+ function formatIncompleteCheck(state, json) {
3654
+ const message = formatIncompleteGateMessage(state);
3655
+ if (json) {
3656
+ return {
3657
+ skipped: true,
3658
+ patchPass: true,
3659
+ projectPass: true,
3660
+ overall: "pass",
3661
+ flagResults: [],
3662
+ stdout: JSON.stringify({
3663
+ overall: "pending",
3664
+ complete: false,
3665
+ ...state.part !== void 0 ? { part: state.part } : {},
3666
+ ...state.totalParts !== void 0 ? { totalParts: state.totalParts } : {},
3667
+ note: message
3668
+ }) + "\n",
3669
+ stderr: "",
3670
+ exitCode: 0
3671
+ };
3672
+ }
3673
+ return {
3674
+ skipped: true,
3675
+ patchPass: true,
3676
+ projectPass: true,
3677
+ overall: "pass",
3678
+ flagResults: [],
3679
+ stdout: "",
3680
+ stderr: `${dim("tested.dev \u2014 coverage gate")} ${badge("info")}
3681
+
3682
+ ${dim(` ${message}`)}
3683
+ ${tip("tested push --complete (or --parts N --part N)")}
3684
+ `,
3685
+ exitCode: 0
3686
+ };
3687
+ }
3688
+ function formatCompleteHandshakeCheck(json) {
3689
+ const note = "no local coverage files \u2014 complete handshake only. The app merges stored shards; this job does not evaluate the gate.";
3690
+ if (json) {
3691
+ return {
3692
+ skipped: true,
3693
+ patchPass: true,
3694
+ projectPass: true,
3695
+ overall: "pass",
3696
+ flagResults: [],
3697
+ stdout: JSON.stringify({ overall: "pending", complete: true, note }) + "\n",
3698
+ stderr: "",
3699
+ exitCode: 0
3700
+ };
3701
+ }
3702
+ return {
3703
+ skipped: true,
3704
+ patchPass: true,
3705
+ projectPass: true,
3706
+ overall: "pass",
3707
+ flagResults: [],
3708
+ stdout: "",
3709
+ stderr: `${dim("tested.dev \u2014 coverage gate")} ${badge("info")}
3710
+
3711
+ ${dim(` ${note}`)}
3712
+ `,
3713
+ exitCode: 0
3714
+ };
3715
+ }
3716
+ function formatMetricLine(label, pct3, threshold, pass, indent = " ") {
2699
3717
  const pctStr = pct3.toFixed(1);
2700
3718
  const status = pass ? badge("pass") : badge("fail");
2701
- return ` ${label.padEnd(8)} ${pctStr}% (threshold ${threshold}) ${status}`;
3719
+ return `${indent}${label.padEnd(8)} ${pctStr}% (threshold ${threshold}) ${status}`;
3720
+ }
3721
+ function formatFlagLines(results) {
3722
+ if (results.length === 0) return [];
3723
+ const lines = [""];
3724
+ for (const flag of results) {
3725
+ if (flag.status === "missing") {
3726
+ lines.push(
3727
+ ` ${flag.name} ${dim(flag.reason ?? "missing this run")} ${badge("missing")}`
3728
+ );
3729
+ continue;
3730
+ }
3731
+ lines.push(` ${flag.name} ${flag.status === "pass" ? badge("pass") : badge("fail")}`);
3732
+ if (flag.patch.skipped) {
3733
+ lines.push(
3734
+ ` ${"Patch".padEnd(8)} ${dim("-")} ${EMPTY_PATCH_REASON} ${badge("skip")}`
3735
+ );
3736
+ } else {
3737
+ lines.push(
3738
+ formatMetricLine("Patch", flag.patch.pct, flag.patch.threshold, flag.patch.pass, " ")
3739
+ );
3740
+ }
3741
+ lines.push(
3742
+ formatMetricLine(
3743
+ "Project",
3744
+ flag.project.pct,
3745
+ flag.project.threshold,
3746
+ flag.project.pass,
3747
+ " "
3748
+ )
3749
+ );
3750
+ }
3751
+ return lines;
2702
3752
  }
2703
3753
  function runCheck(input) {
2704
3754
  const { config, diff, json } = input;
@@ -2708,6 +3758,7 @@ function runCheck(input) {
2708
3758
  patchPass: true,
2709
3759
  projectPass: true,
2710
3760
  overall: "pass",
3761
+ flagResults: [],
2711
3762
  stdout: "",
2712
3763
  stderr: `${dim("tested.dev \u2014 coverage gate")} ${badge("info")}
2713
3764
 
@@ -2724,7 +3775,14 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2724
3775
  const patchSkipped = isEmptyPatch(diff.patch);
2725
3776
  const patchPass = patchSkipped ? true : patchPct >= patchThreshold;
2726
3777
  const projectPass = projectPct >= projectThreshold;
2727
- const overall = patchPass && projectPass ? "pass" : "fail";
3778
+ const flagResults = evaluateFlags({
3779
+ config,
3780
+ files: input.files ?? [],
3781
+ addedByFile: input.addedByFile ?? /* @__PURE__ */ new Map(),
3782
+ ...input.onlyFlag !== void 0 ? { onlyFlag: input.onlyFlag } : {}
3783
+ });
3784
+ const flagsOk = flagsPass(flagResults);
3785
+ const overall = patchPass && projectPass && flagsOk ? "pass" : "fail";
2728
3786
  const exitCode = overall === "pass" ? 0 : 1;
2729
3787
  if (json) {
2730
3788
  const payload = {
@@ -2735,6 +3793,7 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2735
3793
  ...patchSkipped ? { skipped: true, reason: EMPTY_PATCH_REASON } : {}
2736
3794
  },
2737
3795
  project: { pct: projectPct, threshold: projectThreshold, pass: projectPass },
3796
+ ...flagResults.length > 0 ? { flags: flagsToJson(flagResults) } : {},
2738
3797
  overall,
2739
3798
  ...patchSkipped ? { note: EMPTY_PATCH_REASON } : {}
2740
3799
  };
@@ -2743,6 +3802,7 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2743
3802
  patchPass,
2744
3803
  projectPass,
2745
3804
  overall,
3805
+ flagResults,
2746
3806
  stdout: JSON.stringify(payload) + "\n",
2747
3807
  stderr: "",
2748
3808
  exitCode
@@ -2761,11 +3821,20 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2761
3821
  lines.push(formatMetricLine("Patch", patchPct, patchThreshold, patchPass));
2762
3822
  }
2763
3823
  lines.push(formatMetricLine("Project", projectPct, projectThreshold, projectPass));
3824
+ lines.push(...formatFlagLines(flagResults));
2764
3825
  if (overall === "fail") {
2765
3826
  lines.push("");
2766
3827
  if (patchSkipped) {
2767
3828
  lines.push(dim("No executable lines in the patch \u2014 patch gate skipped."));
2768
3829
  }
3830
+ const missing = flagResults.filter((f) => f.status === "missing");
3831
+ if (missing.length > 0) {
3832
+ lines.push(
3833
+ dim(
3834
+ "Missing flags fail this run (no carryforward). Collect that package's coverage or scope the job with --flag."
3835
+ )
3836
+ );
3837
+ }
2769
3838
  lines.push(tip("add tests for uncovered ranges: tested diff"));
2770
3839
  } else {
2771
3840
  lines.push("");
@@ -2781,6 +3850,7 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2781
3850
  patchPass,
2782
3851
  projectPass,
2783
3852
  overall,
3853
+ flagResults,
2784
3854
  stdout: lines.join("\n"),
2785
3855
  stderr: "",
2786
3856
  exitCode
@@ -2789,10 +3859,47 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2789
3859
  function registerCheckCommand(program2) {
2790
3860
  program2.command("check").description(
2791
3861
  "Exit non-zero if patch or project coverage falls below configured thresholds."
2792
- ).option("--json", "Emit machine-readable JSON to stdout (exit code unchanged).", false).option("--base <ref>", "Git base ref to diff against", void 0).action(async (opts) => {
3862
+ ).option("--json", "Emit machine-readable JSON to stdout (exit code unchanged).", false).option("--base <ref>", "Git base ref to diff against", void 0).option(
3863
+ "--file <path>",
3864
+ "Coverage file to merge (repeatable). Overrides coverage.path.",
3865
+ collectCoverageFile,
3866
+ []
3867
+ ).option("--complete", "Conclude the gate (last shard / finish job)", false).option("--incomplete", "Do not evaluate the gate (shard 1 of N)", false).option("--parts <n>", "Total shard count (gate waits until last part / --complete)").option("--part <n>", "1-based shard index").option(
3868
+ "--flag <name>",
3869
+ "Evaluate only this flag (job already scoped \u2014 the coverage file is the flag)"
3870
+ ).action(async (opts) => {
2793
3871
  try {
2794
3872
  const cwd = process.cwd();
2795
3873
  const config = await loadConfig({ cwd });
3874
+ let merge;
3875
+ try {
3876
+ merge = resolveCoverageMerge(opts);
3877
+ } catch (err) {
3878
+ const message = err instanceof Error ? err.message : String(err);
3879
+ process.stderr.write(formatCliError(message));
3880
+ process.exitCode = 1;
3881
+ return;
3882
+ }
3883
+ if (!merge.complete) {
3884
+ const result2 = formatIncompleteCheck(merge, opts.json);
3885
+ if (result2.stderr) process.stderr.write(result2.stderr);
3886
+ if (result2.stdout) process.stdout.write(result2.stdout);
3887
+ process.exitCode = result2.exitCode;
3888
+ return;
3889
+ }
3890
+ const coveragePaths = resolveCoveragePaths({
3891
+ ...opts.file && opts.file.length > 0 ? { files: opts.file } : {},
3892
+ configPath: config.coverage.path
3893
+ });
3894
+ const existing = existingCoveragePaths(coveragePaths, cwd, existsSync7);
3895
+ const handshakeOnly = merge.complete && existing.length === 0 && (opts.complete || merge.totalParts !== void 0);
3896
+ if (handshakeOnly) {
3897
+ const result2 = formatCompleteHandshakeCheck(opts.json);
3898
+ if (result2.stderr) process.stderr.write(result2.stderr);
3899
+ if (result2.stdout) process.stdout.write(result2.stdout);
3900
+ process.exitCode = result2.exitCode;
3901
+ return;
3902
+ }
2796
3903
  if (!config.thresholds) {
2797
3904
  const result2 = runCheck({
2798
3905
  config,
@@ -2813,12 +3920,20 @@ function registerCheckCommand(program2) {
2813
3920
  process.exitCode = result2.exitCode;
2814
3921
  return;
2815
3922
  }
2816
- const diff = await computeDiff({
3923
+ const { diff, files, addedByFile } = await computeDiffContext({
2817
3924
  cwd,
2818
3925
  config,
2819
- ...opts.base !== void 0 ? { baseRef: opts.base } : {}
3926
+ ...opts.base !== void 0 ? { baseRef: opts.base } : {},
3927
+ ...coveragePaths.length > 0 ? { coveragePaths } : {}
3928
+ });
3929
+ const result = runCheck({
3930
+ config,
3931
+ diff,
3932
+ json: opts.json,
3933
+ files,
3934
+ addedByFile,
3935
+ ...opts.flag && opts.flag.trim() ? { onlyFlag: opts.flag.trim() } : {}
2820
3936
  });
2821
- const result = runCheck({ config, diff, json: opts.json });
2822
3937
  if (result.stderr) process.stderr.write(result.stderr);
2823
3938
  if (result.stdout) process.stdout.write(result.stdout);
2824
3939
  process.exitCode = result.exitCode;
@@ -2831,8 +3946,8 @@ function registerCheckCommand(program2) {
2831
3946
  }
2832
3947
 
2833
3948
  // src/commands/explain.ts
2834
- import { readFile as readFile3 } from "fs/promises";
2835
- import { resolve as resolve6 } from "path";
3949
+ import { readFile as readFile5 } from "fs/promises";
3950
+ import { resolve as resolve7 } from "path";
2836
3951
  import "commander";
2837
3952
  function parseLocation(input) {
2838
3953
  const idx = input.lastIndexOf(":");
@@ -2893,9 +4008,12 @@ function registerExplainCommand(program2) {
2893
4008
  const { path: relPath, line } = parseLocation(location);
2894
4009
  const config = await loadConfig({ cwd });
2895
4010
  const ctx = await openRepo(cwd);
2896
- const coveragePath = resolve6(cwd, config.coverage.path);
2897
- assertWithinRoot(ctx.repoRoot, coveragePath);
2898
- const files = await parseIstanbul({ path: coveragePath, repoRoot: ctx.repoRoot });
4011
+ const files = await parseAndMergeCoverage({
4012
+ paths: resolveCoveragePaths({ configPath: config.coverage.path }),
4013
+ cwd,
4014
+ repoRoot: ctx.repoRoot,
4015
+ ...config.coverage.format ? { format: config.coverage.format } : {}
4016
+ });
2899
4017
  const file = files.find((f) => f.path === relPath);
2900
4018
  if (!file) {
2901
4019
  process.stderr.write(`error: no coverage data for ${relPath}
@@ -2903,9 +4021,9 @@ function registerExplainCommand(program2) {
2903
4021
  process.exitCode = 2;
2904
4022
  return;
2905
4023
  }
2906
- const resolvedSource = resolve6(ctx.repoRoot, relPath);
4024
+ const resolvedSource = resolve7(ctx.repoRoot, relPath);
2907
4025
  assertWithinRoot(ctx.repoRoot, resolvedSource);
2908
- const source = await readFile3(resolvedSource, "utf8");
4026
+ const source = await readFile5(resolvedSource, "utf8");
2909
4027
  const sourceLines = source.split("\n");
2910
4028
  const result = explainAt(file, line, sourceLines);
2911
4029
  if (opts.json) {
@@ -3017,11 +4135,11 @@ async function runToken(opts) {
3017
4135
  async function runWhoami(opts) {
3018
4136
  const env = opts.env ?? process.env;
3019
4137
  const identity = await resolveRepoIdentity(opts);
3020
- const resolve7 = opts.resolveTokenFn ?? resolveToken;
4138
+ const resolve8 = opts.resolveTokenFn ?? resolveToken;
3021
4139
  let tokenSet = false;
3022
4140
  let source = null;
3023
4141
  try {
3024
- const token = resolve7({ env, isTTY: false, warn: () => {
4142
+ const token = resolve8({ env, isTTY: false, warn: () => {
3025
4143
  } });
3026
4144
  tokenSet = Boolean(token);
3027
4145
  source = tokenSet ? tokenSourceFromEnv(env) : null;