@tested/cli 0.1.6 → 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 +1490 -206
  3. package/dist/tested.js +1490 -206
  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,16 +1215,151 @@ 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";
1344
+
1345
+ // src/git-ref.ts
1346
+ var SAFE_GIT_REF_RE = /^[A-Za-z0-9_./@~^-]{1,256}$/;
1347
+ function assertSafeGitRef(ref) {
1348
+ if (!ref) {
1349
+ throw new Error("git ref must not be empty");
1350
+ }
1351
+ if (ref.startsWith("-")) {
1352
+ throw new Error(`git ref must not start with '-': ${ref}`);
1353
+ }
1354
+ if (!SAFE_GIT_REF_RE.test(ref)) {
1355
+ throw new Error(
1356
+ `git ref contains invalid characters or is too long (max 256): ${ref}`
1357
+ );
1358
+ }
1359
+ return ref;
1360
+ }
1361
+
1362
+ // src/git.ts
634
1363
  async function openRepo(cwd) {
635
1364
  const git = simpleGit2({ baseDir: cwd });
636
1365
  const repoRoot = (await git.revparse(["--show-toplevel"])).trim();
@@ -669,95 +1398,56 @@ async function resolveEffectiveBase(ctx, requested) {
669
1398
  async function headSha(ctx) {
670
1399
  return (await ctx.git.revparse(["HEAD"])).trim();
671
1400
  }
672
- async function unifiedDiff(ctx, base) {
1401
+ async function fetchOriginRef(ctx, ref) {
1402
+ const spec = ref.startsWith("origin/") ? ref.slice("origin/".length) : ref;
673
1403
  try {
674
- const mergeBase = (await ctx.git.raw(["merge-base", base, "HEAD"])).trim();
675
- if (mergeBase) {
676
- return ctx.git.diff([`${base}...HEAD`]);
677
- }
678
- } catch {
679
- }
680
- return ctx.git.diff([base, "HEAD"]);
681
- }
682
- async function remoteUrl(ctx, remote = "origin") {
683
- return (await ctx.git.raw(["remote", "get-url", remote])).trim();
684
- }
685
- async function currentBranch(ctx) {
686
- try {
687
- const name = (await ctx.git.revparse(["--abbrev-ref", "HEAD"])).trim();
688
- return name === "HEAD" ? "" : name;
1404
+ assertSafeGitRef(spec);
689
1405
  } catch {
690
- return "";
1406
+ return false;
691
1407
  }
692
- }
693
- async function gitUserName(ctx) {
694
1408
  try {
695
- const name = (await ctx.git.raw(["config", "user.name"])).trim();
696
- return name || null;
1409
+ await ctx.git.raw(["fetch", "--depth=1", "origin", spec]);
1410
+ return true;
697
1411
  } catch {
698
- return null;
1412
+ return false;
699
1413
  }
700
1414
  }
701
-
702
- // src/git-ref.ts
703
- var SAFE_GIT_REF_RE = /^[A-Za-z0-9_./@~^-]{1,256}$/;
704
- function assertSafeGitRef(ref) {
705
- if (!ref) {
706
- throw new Error("git ref must not be empty");
1415
+ async function resolveAfterFetch(ctx, ref) {
1416
+ if (await tryRevparse(ctx, ref)) return ref;
1417
+ if (!ref.startsWith("origin/")) {
1418
+ const originRef = `origin/${ref}`;
1419
+ if (await tryRevparse(ctx, originRef)) return originRef;
707
1420
  }
708
- if (ref.startsWith("-")) {
709
- throw new Error(`git ref must not start with '-': ${ref}`);
710
- }
711
- if (!SAFE_GIT_REF_RE.test(ref)) {
712
- throw new Error(
713
- `git ref contains invalid characters or is too long (max 256): ${ref}`
714
- );
715
- }
716
- return ref;
1421
+ return tryRevparse(ctx, "FETCH_HEAD");
717
1422
  }
718
-
719
- // src/core/istanbul.ts
720
- import { readFile as readFile2 } from "fs/promises";
721
- import { isAbsolute, relative, resolve } from "path";
722
- function isCoveragePathInsideRoot(repoRoot, entryPath) {
723
- const root = resolve(repoRoot);
724
- const absPath = resolve(entryPath);
725
- const relPath = relative(root, absPath).split("\\").join("/");
726
- if (!relPath || relPath === "") return true;
727
- if (isAbsolute(relPath)) return false;
728
- if (relPath === ".." || relPath.startsWith("../")) return false;
729
- return true;
1423
+ async function unifiedDiff(ctx, base) {
1424
+ try {
1425
+ const mergeBase = (await ctx.git.raw(["merge-base", base, "HEAD"])).trim();
1426
+ if (mergeBase) {
1427
+ return ctx.git.diff([`${base}...HEAD`]);
1428
+ }
1429
+ } catch {
1430
+ }
1431
+ return ctx.git.diff([base, "HEAD"]);
730
1432
  }
731
- async function parseIstanbul(opts) {
732
- let raw;
1433
+ async function remoteUrl(ctx, remote = "origin") {
1434
+ return (await ctx.git.raw(["remote", "get-url", remote])).trim();
1435
+ }
1436
+ async function currentBranch(ctx) {
733
1437
  try {
734
- raw = await readFile2(opts.path, "utf8");
735
- } catch (err) {
736
- if (err.code === "ENOENT") {
737
- throw new Error(
738
- `coverage-final.json not found at ${opts.path}. Run \`tested run\` first.`
739
- );
740
- }
741
- throw err;
1438
+ const name = (await ctx.git.revparse(["--abbrev-ref", "HEAD"])).trim();
1439
+ return name === "HEAD" ? "" : name;
1440
+ } catch {
1441
+ return "";
742
1442
  }
743
- const data = JSON.parse(raw);
744
- const root = resolve(opts.repoRoot);
745
- const out = [];
746
- for (const entry of Object.values(data)) {
747
- if (!isCoveragePathInsideRoot(root, entry.path)) {
748
- continue;
749
- }
750
- const absPath = resolve(entry.path);
751
- const relPath = relative(root, absPath).split("\\").join("/");
752
- const statements = Object.entries(entry.statementMap).map(([id, loc]) => ({
753
- id,
754
- startLine: loc.start.line,
755
- endLine: loc.end.line,
756
- hits: entry.s[id] ?? 0
757
- }));
758
- out.push({ path: relPath, absPath, statements });
1443
+ }
1444
+ async function gitUserName(ctx) {
1445
+ try {
1446
+ const name = (await ctx.git.raw(["config", "user.name"])).trim();
1447
+ return name || null;
1448
+ } catch {
1449
+ return null;
759
1450
  }
760
- return out;
761
1451
  }
762
1452
 
763
1453
  // src/core/diff.ts
@@ -813,18 +1503,6 @@ function splitByIgnore(paths, patterns) {
813
1503
  return { kept, ignored };
814
1504
  }
815
1505
 
816
- // src/core/assert-within-root.ts
817
- import { resolve as resolve2, sep } from "path";
818
- function assertWithinRoot(root, resolvedPath) {
819
- const safeRoot = resolve2(root) + sep;
820
- const safePath = resolve2(resolvedPath);
821
- if (!safePath.startsWith(safeRoot)) {
822
- throw new Error(
823
- `Path traversal rejected: ${safePath} is outside repository root ${safeRoot}`
824
- );
825
- }
826
- }
827
-
828
1506
  // src/core/patch.ts
829
1507
  var EMPTY_PATCH_REASON = "no executable lines in the patch";
830
1508
  function isEmptyPatch(totals) {
@@ -957,7 +1635,7 @@ function buildDiffOutput(args) {
957
1635
  }
958
1636
 
959
1637
  // src/core/computeDiff.ts
960
- async function computeDiff(opts) {
1638
+ async function computeDiffContext(opts) {
961
1639
  const { cwd, config } = opts;
962
1640
  const ctx = opts.ctx ?? await openRepo(cwd);
963
1641
  const requested = assertSafeGitRef(opts.baseRef ?? config.base);
@@ -965,9 +1643,16 @@ async function computeDiff(opts) {
965
1643
  const head = await headSha(ctx);
966
1644
  const diffText = await unifiedDiff(ctx, base);
967
1645
  const addedByFile = parseUnifiedDiff(diffText);
968
- const coveragePath = resolve3(cwd, config.coverage.path);
969
- assertWithinRoot(ctx.repoRoot, coveragePath);
970
- 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
+ });
971
1656
  const { kept, ignored } = splitByIgnore(
972
1657
  allFiles.map((f) => f.path),
973
1658
  config.ignores
@@ -976,11 +1661,12 @@ async function computeDiff(opts) {
976
1661
  const files = allFiles.filter((f) => keptSet.has(f.path));
977
1662
  let projectDelta = null;
978
1663
  if (opts.withBaseCoverage) {
979
- const baseCoveragePath = resolve3(cwd, opts.withBaseCoverage);
1664
+ const baseCoveragePath = resolve4(cwd, opts.withBaseCoverage);
980
1665
  assertWithinRoot(ctx.repoRoot, baseCoveragePath);
981
- const baseFiles = await parseIstanbul({
1666
+ const baseFiles = await parseCoverage({
982
1667
  path: baseCoveragePath,
983
- repoRoot: ctx.repoRoot
1668
+ repoRoot: ctx.repoRoot,
1669
+ ...config.coverage.format ? { format: config.coverage.format } : {}
984
1670
  });
985
1671
  const baseKept = baseFiles.filter((f) => !ignored.includes(f.path));
986
1672
  const baseExec = baseKept.reduce((n, f) => n + f.statements.length, 0);
@@ -999,7 +1685,7 @@ async function computeDiff(opts) {
999
1685
  })();
1000
1686
  projectDelta = Math.round((headPct - basePct) * 10) / 10;
1001
1687
  }
1002
- return buildDiffOutput({
1688
+ const diff = buildDiffOutput({
1003
1689
  base: baseRef,
1004
1690
  head,
1005
1691
  files,
@@ -1007,6 +1693,201 @@ async function computeDiff(opts) {
1007
1693
  ignored,
1008
1694
  projectDelta
1009
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.`;
1010
1891
  }
1011
1892
 
1012
1893
  // src/commands/push.ts
@@ -1032,10 +1913,10 @@ function parseGitHubRepository(repo) {
1032
1913
  var tokenArgvWarned = false;
1033
1914
  function readTokenFile(filePath, opts) {
1034
1915
  const read = opts?.readFileSyncFn ?? readFileSync2;
1035
- const stat = opts?.statSyncFn ?? statSync;
1916
+ const stat3 = opts?.statSyncFn ?? statSync;
1036
1917
  let mode;
1037
1918
  try {
1038
- const st = stat(filePath);
1919
+ const st = stat3(filePath);
1039
1920
  mode = st.mode;
1040
1921
  } catch (err) {
1041
1922
  const message = err instanceof Error ? err.message : String(err);
@@ -1170,6 +2051,125 @@ function sanitizeAuthor(name) {
1170
2051
  function toBranchName(ref) {
1171
2052
  return ref.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\//, "").replace(/^origin\//, "");
1172
2053
  }
2054
+ var GITHUB_COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/i;
2055
+ function githubApiToken(env) {
2056
+ const raw = env.GITHUB_TOKEN ?? env.GH_TOKEN;
2057
+ if (raw === void 0 || raw === "") return null;
2058
+ return raw;
2059
+ }
2060
+ function parseGitHubPullBase(parsed) {
2061
+ if (!parsed || typeof parsed !== "object" || !("base" in parsed)) return null;
2062
+ const base = parsed.base;
2063
+ if (!base || typeof base !== "object") return null;
2064
+ const ref = "ref" in base ? base.ref : void 0;
2065
+ const sha = "sha" in base ? base.sha : void 0;
2066
+ if (typeof ref !== "string" || typeof sha !== "string") return null;
2067
+ if (!GITHUB_COMMIT_SHA_RE.test(sha)) return null;
2068
+ try {
2069
+ return { ref: assertSafeGitRef(ref), sha: assertSafeGitRef(sha) };
2070
+ } catch {
2071
+ return null;
2072
+ }
2073
+ }
2074
+ async function fetchGitHubPullBase(opts) {
2075
+ if (!parseGitHubRepository(`${opts.owner}/${opts.name}`)) return null;
2076
+ if (!Number.isInteger(opts.prNumber) || opts.prNumber <= 0) return null;
2077
+ const fetchFn = opts.fetchFn ?? globalThis.fetch;
2078
+ const url = `https://api.github.com/repos/${opts.owner}/${opts.name}/pulls/${opts.prNumber}`;
2079
+ const headers = {
2080
+ Accept: "application/vnd.github+json",
2081
+ "X-GitHub-Api-Version": "2022-11-28",
2082
+ "User-Agent": "tested-cli"
2083
+ };
2084
+ const token = githubApiToken(opts.env ?? process.env);
2085
+ if (token) headers.Authorization = `Bearer ${token}`;
2086
+ let res;
2087
+ try {
2088
+ res = await fetchFn(url, { method: "GET", redirect: "manual", headers });
2089
+ } catch {
2090
+ return null;
2091
+ }
2092
+ if (res.status !== 200) return null;
2093
+ try {
2094
+ return parseGitHubPullBase(JSON.parse(await res.text()));
2095
+ } catch {
2096
+ return null;
2097
+ }
2098
+ }
2099
+ async function resolvePrPushBase(opts) {
2100
+ let requested;
2101
+ try {
2102
+ requested = assertSafeGitRef(opts.requested);
2103
+ } catch {
2104
+ return void 0;
2105
+ }
2106
+ if (await tryRevparse(opts.ctx, requested)) return requested;
2107
+ const branch = toBranchName(requested) || "main";
2108
+ const originRef = `origin/${branch}`;
2109
+ if (originRef !== requested && await tryRevparse(opts.ctx, originRef)) {
2110
+ return originRef;
2111
+ }
2112
+ let prBase = null;
2113
+ if (opts.owner && opts.name) {
2114
+ prBase = await fetchGitHubPullBase({
2115
+ owner: opts.owner,
2116
+ name: opts.name,
2117
+ prNumber: opts.prNumber,
2118
+ ...opts.fetchFn ? { fetchFn: opts.fetchFn } : {},
2119
+ ...opts.env ? { env: opts.env } : {}
2120
+ });
2121
+ }
2122
+ if (prBase && await tryRevparse(opts.ctx, prBase.sha)) return prBase.sha;
2123
+ const fetchTargets = [];
2124
+ if (prBase) {
2125
+ fetchTargets.push(prBase.sha, prBase.ref);
2126
+ }
2127
+ fetchTargets.push(branch);
2128
+ let announced = false;
2129
+ const seen = /* @__PURE__ */ new Set();
2130
+ for (const target of fetchTargets) {
2131
+ if (seen.has(target)) continue;
2132
+ seen.add(target);
2133
+ let spec;
2134
+ try {
2135
+ spec = assertSafeGitRef(target);
2136
+ } catch {
2137
+ continue;
2138
+ }
2139
+ if (!announced) {
2140
+ announced = true;
2141
+ opts.onProgress?.("fetching base\u2026");
2142
+ }
2143
+ if (!await fetchOriginRef(opts.ctx, spec)) continue;
2144
+ const resolved = await resolveAfterFetch(opts.ctx, spec);
2145
+ if (resolved) return resolved;
2146
+ if (prBase && await tryRevparse(opts.ctx, prBase.sha)) return prBase.sha;
2147
+ }
2148
+ return void 0;
2149
+ }
2150
+ async function peekRepoIdentity(opts) {
2151
+ let owner = opts.owner;
2152
+ let name = opts.name;
2153
+ if (!owner || !name) {
2154
+ const fromActions = parseGitHubRepository(opts.env.GITHUB_REPOSITORY);
2155
+ if (fromActions) {
2156
+ owner = owner ?? fromActions.owner;
2157
+ name = name ?? fromActions.name;
2158
+ }
2159
+ }
2160
+ if (!owner || !name) {
2161
+ try {
2162
+ const origin = await remoteUrl(opts.ctx, "origin");
2163
+ const parsed = parseGitHubRemote(origin);
2164
+ if (parsed) {
2165
+ owner = owner ?? parsed.owner;
2166
+ name = name ?? parsed.name;
2167
+ }
2168
+ } catch {
2169
+ }
2170
+ }
2171
+ return { owner: owner ?? null, name: name ?? null };
2172
+ }
1173
2173
  function buildIngestBody(input) {
1174
2174
  const baseRefName = toBranchName(input.baseRef);
1175
2175
  return {
@@ -1188,8 +2188,10 @@ function buildIngestBody(input) {
1188
2188
  state: "open"
1189
2189
  },
1190
2190
  runUrl: input.runUrl,
1191
- diff: input.diff,
1192
- ...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 } : {}
1193
2195
  };
1194
2196
  }
1195
2197
  function buildMainlineIngestBody(input) {
@@ -1200,11 +2202,13 @@ function buildMainlineIngestBody(input) {
1200
2202
  defaultBranch: input.defaultBranch
1201
2203
  },
1202
2204
  runUrl: input.runUrl,
1203
- diff: input.diff,
2205
+ ...input.diff ? { diff: input.diff } : {},
1204
2206
  ref: input.ref,
1205
2207
  isDefaultBranch: true,
1206
2208
  headSha: input.headSha,
1207
- ...input.testReport ? { testReport: input.testReport } : {}
2209
+ ...input.testReport ? { testReport: input.testReport } : {},
2210
+ ...input.coverageMerge ? { coverageMerge: input.coverageMerge } : {},
2211
+ ...input.flags ? { flags: input.flags } : {}
1208
2212
  };
1209
2213
  }
1210
2214
  var DEFAULT_JUNIT_CANDIDATES = [
@@ -1215,10 +2219,10 @@ var DEFAULT_JUNIT_CANDIDATES = [
1215
2219
  ];
1216
2220
  function resolveJunitPath(opts) {
1217
2221
  const env = opts.env ?? process.env;
1218
- const exists = opts.existsSyncFn ?? existsSync2;
2222
+ const exists = opts.existsSyncFn ?? existsSync3;
1219
2223
  if (opts.flag && opts.flag.trim()) {
1220
2224
  const p = opts.flag.trim();
1221
- const abs = p.startsWith("/") ? p : join3(opts.cwd, p);
2225
+ const abs = p.startsWith("/") ? p : join4(opts.cwd, p);
1222
2226
  if (!exists(abs)) {
1223
2227
  throw new Error(`JUnit file not found: ${p}`);
1224
2228
  }
@@ -1226,14 +2230,14 @@ function resolveJunitPath(opts) {
1226
2230
  }
1227
2231
  const fromEnv = env.TESTED_JUNIT?.trim();
1228
2232
  if (fromEnv) {
1229
- const abs = fromEnv.startsWith("/") ? fromEnv : join3(opts.cwd, fromEnv);
2233
+ const abs = fromEnv.startsWith("/") ? fromEnv : join4(opts.cwd, fromEnv);
1230
2234
  if (!exists(abs)) {
1231
2235
  throw new Error(`TESTED_JUNIT file not found: ${fromEnv}`);
1232
2236
  }
1233
2237
  return abs;
1234
2238
  }
1235
2239
  for (const rel of DEFAULT_JUNIT_CANDIDATES) {
1236
- const abs = join3(opts.cwd, rel);
2240
+ const abs = join4(opts.cwd, rel);
1237
2241
  if (exists(abs)) return abs;
1238
2242
  }
1239
2243
  return null;
@@ -1279,15 +2283,12 @@ async function postIngest(opts) {
1279
2283
  parsed = null;
1280
2284
  }
1281
2285
  }
1282
- if (res.status === 200) {
1283
- const data = parsed;
1284
- if (!data) {
1285
- return {
1286
- ok: false,
1287
- status: res.status,
1288
- message: "ingest succeeded but response was empty"
1289
- };
1290
- }
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 ?? {};
1291
2292
  if (data.mainline === true) {
1292
2293
  return {
1293
2294
  ok: true,
@@ -1295,11 +2296,20 @@ async function postIngest(opts) {
1295
2296
  data: {
1296
2297
  mainline: true,
1297
2298
  ...typeof data.date === "string" ? { date: data.date } : {},
1298
- ...typeof data.projectPct === "number" ? { projectPct: data.projectPct } : {}
2299
+ ...typeof data.projectPct === "number" ? { projectPct: data.projectPct } : {},
2300
+ ...acceptIncomplete ? { complete: false } : {}
1299
2301
  }
1300
2302
  };
1301
2303
  }
1302
- 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
+ }
1303
2313
  return {
1304
2314
  ok: false,
1305
2315
  status: res.status,
@@ -1310,8 +2320,9 @@ async function postIngest(opts) {
1310
2320
  ok: true,
1311
2321
  status: res.status,
1312
2322
  data: {
1313
- shareUrl: data.shareUrl,
1314
- ...typeof data.expiresAt === "string" ? { expiresAt: data.expiresAt } : {}
2323
+ ...shareUrl2 ? { shareUrl: shareUrl2 } : {},
2324
+ ...typeof data.expiresAt === "string" ? { expiresAt: data.expiresAt } : {},
2325
+ ...acceptIncomplete || handshakeOnly ? { complete: concludesGate } : {}
1315
2326
  }
1316
2327
  };
1317
2328
  }
@@ -1328,7 +2339,8 @@ async function postIngest(opts) {
1328
2339
  }
1329
2340
  return { ok: false, status: res.status, message, ...code ? { code } : {} };
1330
2341
  }
1331
- function formatPushSuccess(data, json) {
2342
+ function formatPushSuccess(data, json, merge) {
2343
+ const incomplete = merge ? !merge.complete : data.complete === false;
1332
2344
  if (json) {
1333
2345
  const payload = {};
1334
2346
  if (data.shareUrl) payload.shareUrl = data.shareUrl;
@@ -1336,9 +2348,23 @@ function formatPushSuccess(data, json) {
1336
2348
  if (data.mainline) payload.mainline = true;
1337
2349
  if (data.date) payload.date = data.date;
1338
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
+ }
1339
2358
  return { stdout: JSON.stringify(payload) + "\n", stderr: "" };
1340
2359
  }
1341
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
+ }
1342
2368
  if (data.mainline) {
1343
2369
  lines.push(
1344
2370
  successLine(
@@ -1362,16 +2388,16 @@ function formatMissingTokenError(opts) {
1362
2388
  "or pass --token <token> (avoid on shared hosts: visible in ps)"
1363
2389
  ]);
1364
2390
  }
1365
- function formatPushError(status, message, code) {
2391
+ function formatPushError(status, message, code, identity) {
1366
2392
  if (status === 0) {
1367
2393
  return errorBlock(message);
1368
2394
  }
1369
2395
  const normalized = (code ?? message).toLowerCase();
1370
- if (normalized.includes("token_required") || normalized.includes("invalid token") || normalized.includes("unauthorized") || status === 401) {
2396
+ if (normalized.includes("token_required") || normalized.includes("invalid token") || normalized.includes("unauthorized") || normalized.includes("invalid credentials") || status === 401) {
1371
2397
  return errorBlock("ingest auth failed", [
1372
2398
  message,
1373
2399
  "",
1374
- ...tokenMintGuidance(),
2400
+ ...tokenMintGuidance(identity),
1375
2401
  "or --token"
1376
2402
  ]);
1377
2403
  }
@@ -1394,7 +2420,8 @@ function formatPushError(status, message, code) {
1394
2420
  }
1395
2421
  async function executePush(cli, deps) {
1396
2422
  const env = deps.env ?? process.env;
1397
- const computeDiffFn = deps.computeDiffFn ?? computeDiff;
2423
+ const computeDiffFn = deps.computeDiffFn;
2424
+ const computeDiffContextFn = deps.computeDiffContextFn ?? computeDiffContext;
1398
2425
  const fetchFn = deps.fetchFn ?? globalThis.fetch;
1399
2426
  const openRepoFn = deps.openRepoFn ?? openRepo;
1400
2427
  const loadConfigFn = deps.loadConfigFn ?? loadConfig;
@@ -1458,23 +2485,87 @@ async function executePush(cli, deps) {
1458
2485
  const message = err instanceof Error ? err.message : String(err);
1459
2486
  return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
1460
2487
  }
1461
- const config = await loadConfigFn({ cwd: deps.cwd });
1462
- const ctx = await openRepoFn(deps.cwd);
1463
- onProgress("computing diff\u2026");
1464
- let diff;
2488
+ let merge;
1465
2489
  try {
1466
- diff = await computeDiffFn({
1467
- cwd: deps.cwd,
1468
- config,
1469
- ...cli.base !== void 0 ? { baseRef: cli.base } : {},
1470
- ctx
1471
- });
2490
+ merge = resolveCoverageMerge(cli, env);
1472
2491
  } catch (err) {
1473
2492
  const message = err instanceof Error ? err.message : String(err);
1474
2493
  return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
1475
2494
  }
1476
- let owner = cli.owner;
1477
- let name = cli.name;
2495
+ const coverageMerge = toCoverageMergePayload(merge);
2496
+ const config = await loadConfigFn({ cwd: deps.cwd });
2497
+ const ctx = await openRepoFn(deps.cwd);
2498
+ const peeked = await peekRepoIdentity({
2499
+ ...cli.owner !== void 0 ? { owner: cli.owner } : {},
2500
+ ...cli.name !== void 0 ? { name: cli.name } : {},
2501
+ env,
2502
+ ctx
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);
2511
+ let diff;
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 } : {},
2535
+ ctx,
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() } : {}
2561
+ });
2562
+ } catch (err) {
2563
+ const message = err instanceof Error ? err.message : String(err);
2564
+ return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
2565
+ }
2566
+ }
2567
+ let owner = cli.owner ?? peeked.owner ?? void 0;
2568
+ let name = cli.name ?? peeked.name ?? void 0;
1478
2569
  if (!owner || !name) {
1479
2570
  const fromActions = parseGitHubRepository(env.GITHUB_REPOSITORY);
1480
2571
  if (fromActions) {
@@ -1527,13 +2618,19 @@ async function executePush(cli, deps) {
1527
2618
  env
1528
2619
  });
1529
2620
  if (junitPath) {
1530
- onProgress("parsing JUnit\u2026");
2621
+ onProgress(`parsing JUnit (${junitPath})\u2026`);
1531
2622
  testReport = loadTestReportFromJunit(junitPath);
1532
2623
  }
1533
2624
  } catch (err) {
1534
2625
  const message = err instanceof Error ? err.message : String(err);
1535
2626
  return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
1536
2627
  }
2628
+ const mergeFields = {
2629
+ coverageMerge,
2630
+ ...diff ? { diff } : {},
2631
+ ...testReport ? { testReport } : {},
2632
+ ...flags ? { flags } : {}
2633
+ };
1537
2634
  const body = mainline ? buildMainlineIngestBody({
1538
2635
  owner,
1539
2636
  name,
@@ -1541,8 +2638,7 @@ async function executePush(cli, deps) {
1541
2638
  headSha: sha,
1542
2639
  ref: `refs/heads/${baseRef}`,
1543
2640
  runUrl: cli.runUrl ?? null,
1544
- diff,
1545
- ...testReport ? { testReport } : {}
2641
+ ...mergeFields
1546
2642
  }) : buildIngestBody({
1547
2643
  owner,
1548
2644
  name,
@@ -1553,23 +2649,28 @@ async function executePush(cli, deps) {
1553
2649
  headRef,
1554
2650
  headSha: sha,
1555
2651
  runUrl: cli.runUrl ?? null,
1556
- diff,
1557
- ...testReport ? { testReport } : {}
2652
+ ...mergeFields
1558
2653
  });
1559
- 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
+ );
1560
2657
  const result = await postIngest({ apiBase, token, body, fetchFn });
1561
2658
  if (!result.ok) {
1562
2659
  return {
1563
2660
  exitCode: 1,
1564
2661
  stdout: "",
1565
- stderr: formatPushError(result.status, result.message, result.code)
2662
+ stderr: formatPushError(result.status, result.message, result.code, {
2663
+ owner,
2664
+ name
2665
+ })
1566
2666
  };
1567
2667
  }
1568
- const formatted = formatPushSuccess(result.data, cli.json);
2668
+ const formatted = formatPushSuccess(result.data, cli.json, merge);
1569
2669
  return {
1570
2670
  exitCode: 0,
1571
2671
  stdout: formatted.stdout,
1572
2672
  stderr: formatted.stderr,
2673
+ complete: merge.complete,
1573
2674
  ...result.data.shareUrl !== void 0 ? { shareUrl: result.data.shareUrl } : {},
1574
2675
  ...result.data.expiresAt !== void 0 ? { expiresAt: result.data.expiresAt } : {}
1575
2676
  };
@@ -1593,7 +2694,15 @@ function registerPushCommand(program2) {
1593
2694
  "Base branch name sent to the API (default: .tested.yaml base or main)"
1594
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(
1595
2696
  "--junit <path>",
1596
- "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)"
1597
2706
  ).option("--json", "Emit machine-readable JSON instead of the share URL only", false).action(async (opts) => {
1598
2707
  try {
1599
2708
  const result = await executePush(opts, { cwd: process.cwd() });
@@ -1667,7 +2776,7 @@ function isReadableFile(path, exists) {
1667
2776
  async function runDoctor(deps) {
1668
2777
  const cwd = deps.cwd;
1669
2778
  const env = deps.env ?? process.env;
1670
- const exists = deps.existsSyncFn ?? existsSync3;
2779
+ const exists = deps.existsSyncFn ?? existsSync4;
1671
2780
  const gitFactory = deps.gitFactory ?? simpleGit3;
1672
2781
  const loadConfigFn = deps.loadConfigFn ?? loadConfig;
1673
2782
  const resolveTokenFn = deps.resolveTokenFn ?? resolveToken;
@@ -1714,7 +2823,7 @@ async function runDoctor(deps) {
1714
2823
  detail: "not a git repository \u2014 run from a repo root"
1715
2824
  });
1716
2825
  }
1717
- const configPath = join4(cwd, ".tested.yaml");
2826
+ const configPath = join5(cwd, ".tested.yaml");
1718
2827
  const hasConfig = exists(configPath);
1719
2828
  if (hasConfig) {
1720
2829
  checks.push({
@@ -1731,19 +2840,21 @@ async function runDoctor(deps) {
1731
2840
  detail: "missing \u2014 run: tested setup (or tested init)"
1732
2841
  });
1733
2842
  }
1734
- let coverageRel = "coverage/coverage-final.json";
2843
+ let coverageRels = ["coverage/coverage-final.json"];
1735
2844
  if (hasConfig) {
1736
2845
  try {
1737
2846
  const config = await loadConfigFn({ cwd });
1738
- coverageRel = config.coverage.path;
2847
+ const listed = coveragePathList(config.coverage.path);
2848
+ if (listed.length > 0) coverageRels = listed;
1739
2849
  } catch {
1740
2850
  }
1741
2851
  }
1742
- const coverageAbs = resolve4(cwd, coverageRel);
1743
- 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) {
1744
2855
  checks.push({
1745
2856
  id: "coverage",
1746
- label: "Coverage file",
2857
+ label: coverageRels.length > 1 ? "Coverage files" : "Coverage file",
1747
2858
  status: "pass",
1748
2859
  detail: coverageRel,
1749
2860
  optional: true
@@ -1751,9 +2862,9 @@ async function runDoctor(deps) {
1751
2862
  } else {
1752
2863
  checks.push({
1753
2864
  id: "coverage",
1754
- label: "Coverage file",
2865
+ label: coverageRels.length > 1 ? "Coverage files" : "Coverage file",
1755
2866
  status: "warn",
1756
- detail: `missing ${coverageRel} \u2014 run: tested run`,
2867
+ detail: `missing ${missing.join(", ")} \u2014 run: tested run`,
1757
2868
  optional: true
1758
2869
  });
1759
2870
  }
@@ -1876,7 +2987,7 @@ async function runDoctor(deps) {
1876
2987
  }
1877
2988
  const testedBin = env.TESTED_BIN;
1878
2989
  if (testedBin !== void 0 && testedBin !== "") {
1879
- const base = basename(testedBin);
2990
+ const base = basename2(testedBin);
1880
2991
  const okName = TESTED_BIN_BASENAME_RE.test(base);
1881
2992
  if (!okName) {
1882
2993
  checks.push({
@@ -1961,7 +3072,7 @@ import pc3 from "picocolors";
1961
3072
  // package.json
1962
3073
  var package_default = {
1963
3074
  name: "@tested/cli",
1964
- version: "0.1.6",
3075
+ version: "0.1.8",
1965
3076
  description: "Coverage your agent can use. CLI for patch + project coverage with agent-readable JSON output.",
1966
3077
  license: "MIT",
1967
3078
  homepage: "https://tested.dev",
@@ -2112,13 +3223,13 @@ function formatSetupHuman(opts) {
2112
3223
  async function runSetup(deps) {
2113
3224
  const cwd = deps.cwd;
2114
3225
  const env = deps.env ?? process.env;
2115
- const exists = deps.existsSyncFn ?? existsSync4;
3226
+ const exists = deps.existsSyncFn ?? existsSync5;
2116
3227
  const runInitFn = deps.runInitFn ?? runInit;
2117
3228
  const runDoctorFn = deps.runDoctorFn ?? runDoctor;
2118
3229
  const force = deps.force ?? false;
2119
3230
  const hooks = deps.hooks ?? false;
2120
3231
  const json = deps.json ?? false;
2121
- const configPath = join5(cwd, ".tested.yaml");
3232
+ const configPath = join6(cwd, ".tested.yaml");
2122
3233
  let initRan = false;
2123
3234
  let initResult = null;
2124
3235
  if (!exists(configPath) || force) {
@@ -2202,8 +3313,8 @@ function registerSetupCommand(program2) {
2202
3313
  }
2203
3314
 
2204
3315
  // src/commands/run.ts
2205
- import { existsSync as existsSync5 } from "fs";
2206
- 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";
2207
3318
  import { spawn } from "child_process";
2208
3319
  import "commander";
2209
3320
  function splitRunArgs(extraArgs) {
@@ -2273,8 +3384,8 @@ function shouldEnforceSafeRun(opts) {
2273
3384
  return false;
2274
3385
  }
2275
3386
  function configPathEscapesRoot(configPath, repoRoot) {
2276
- const root = resolve5(repoRoot);
2277
- const abs = isAbsolute3(configPath) ? resolve5(configPath) : resolve5(repoRoot, configPath);
3387
+ const root = resolve6(repoRoot);
3388
+ const abs = isAbsolute3(configPath) ? resolve6(configPath) : resolve6(repoRoot, configPath);
2278
3389
  const safeRoot = root.endsWith(sep2) ? root : root + sep2;
2279
3390
  return !(abs === root || abs.startsWith(safeRoot));
2280
3391
  }
@@ -2325,7 +3436,8 @@ function registerRunCommand(program2) {
2325
3436
  return;
2326
3437
  }
2327
3438
  const config = await loadConfig({ cwd });
2328
- 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);
2329
3441
  const { command, args } = resolveRunCommand({
2330
3442
  runner: config.testRunner,
2331
3443
  extraArgs: forwarded
@@ -2337,14 +3449,14 @@ function registerRunCommand(program2) {
2337
3449
  const child = spawn(command, args, { stdio: "inherit" });
2338
3450
  child.on("exit", (code) => {
2339
3451
  const exit = code ?? 1;
2340
- const coverageWritten = existsSync5(coveragePath);
3452
+ const coverageWritten = existsSync6(coveragePath);
2341
3453
  if (json) {
2342
3454
  const payload = buildRunJsonOutput({
2343
3455
  command,
2344
3456
  args,
2345
3457
  exitCode: exit,
2346
3458
  coverageWritten,
2347
- coveragePath: config.coverage.path
3459
+ coveragePath: coverageRel
2348
3460
  });
2349
3461
  process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
2350
3462
  process.exit(exit);
@@ -2358,7 +3470,7 @@ function registerRunCommand(program2) {
2358
3470
  process.stderr.write("\n");
2359
3471
  process.stderr.write(
2360
3472
  dim(
2361
- 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}`
2362
3474
  ) + "\n"
2363
3475
  );
2364
3476
  }
@@ -2499,21 +3611,29 @@ function formatHuman(out, opts = {}) {
2499
3611
 
2500
3612
  // src/commands/diff.ts
2501
3613
  function registerDiffCommand(program2) {
2502
- 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) => {
2503
3620
  try {
2504
3621
  const cwd = process.cwd();
2505
3622
  const config = await loadConfig({ cwd });
2506
- const output = await computeDiff({
3623
+ const { diff, files, addedByFile } = await computeDiffContext({
2507
3624
  cwd,
2508
3625
  config,
2509
3626
  ...opts.base !== void 0 ? { baseRef: opts.base } : {},
2510
- ...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 } : {}
2511
3629
  });
2512
3630
  if (opts.json) {
2513
- 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");
2514
3634
  } else {
2515
3635
  process.stdout.write(
2516
- formatHuman(output, {
3636
+ formatHuman(diff, {
2517
3637
  ...config.thresholds ? { thresholds: config.thresholds } : {},
2518
3638
  tips: true
2519
3639
  }) + "\n"
@@ -2528,11 +3648,107 @@ function registerDiffCommand(program2) {
2528
3648
  }
2529
3649
 
2530
3650
  // src/commands/check.ts
3651
+ import { existsSync as existsSync7 } from "fs";
2531
3652
  import "commander";
2532
- 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 = " ") {
2533
3717
  const pctStr = pct3.toFixed(1);
2534
3718
  const status = pass ? badge("pass") : badge("fail");
2535
- 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;
2536
3752
  }
2537
3753
  function runCheck(input) {
2538
3754
  const { config, diff, json } = input;
@@ -2542,6 +3758,7 @@ function runCheck(input) {
2542
3758
  patchPass: true,
2543
3759
  projectPass: true,
2544
3760
  overall: "pass",
3761
+ flagResults: [],
2545
3762
  stdout: "",
2546
3763
  stderr: `${dim("tested.dev \u2014 coverage gate")} ${badge("info")}
2547
3764
 
@@ -2558,7 +3775,14 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2558
3775
  const patchSkipped = isEmptyPatch(diff.patch);
2559
3776
  const patchPass = patchSkipped ? true : patchPct >= patchThreshold;
2560
3777
  const projectPass = projectPct >= projectThreshold;
2561
- 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";
2562
3786
  const exitCode = overall === "pass" ? 0 : 1;
2563
3787
  if (json) {
2564
3788
  const payload = {
@@ -2569,6 +3793,7 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2569
3793
  ...patchSkipped ? { skipped: true, reason: EMPTY_PATCH_REASON } : {}
2570
3794
  },
2571
3795
  project: { pct: projectPct, threshold: projectThreshold, pass: projectPass },
3796
+ ...flagResults.length > 0 ? { flags: flagsToJson(flagResults) } : {},
2572
3797
  overall,
2573
3798
  ...patchSkipped ? { note: EMPTY_PATCH_REASON } : {}
2574
3799
  };
@@ -2577,6 +3802,7 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2577
3802
  patchPass,
2578
3803
  projectPass,
2579
3804
  overall,
3805
+ flagResults,
2580
3806
  stdout: JSON.stringify(payload) + "\n",
2581
3807
  stderr: "",
2582
3808
  exitCode
@@ -2595,11 +3821,20 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2595
3821
  lines.push(formatMetricLine("Patch", patchPct, patchThreshold, patchPass));
2596
3822
  }
2597
3823
  lines.push(formatMetricLine("Project", projectPct, projectThreshold, projectPass));
3824
+ lines.push(...formatFlagLines(flagResults));
2598
3825
  if (overall === "fail") {
2599
3826
  lines.push("");
2600
3827
  if (patchSkipped) {
2601
3828
  lines.push(dim("No executable lines in the patch \u2014 patch gate skipped."));
2602
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
+ }
2603
3838
  lines.push(tip("add tests for uncovered ranges: tested diff"));
2604
3839
  } else {
2605
3840
  lines.push("");
@@ -2615,6 +3850,7 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2615
3850
  patchPass,
2616
3851
  projectPass,
2617
3852
  overall,
3853
+ flagResults,
2618
3854
  stdout: lines.join("\n"),
2619
3855
  stderr: "",
2620
3856
  exitCode
@@ -2623,10 +3859,47 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2623
3859
  function registerCheckCommand(program2) {
2624
3860
  program2.command("check").description(
2625
3861
  "Exit non-zero if patch or project coverage falls below configured thresholds."
2626
- ).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) => {
2627
3871
  try {
2628
3872
  const cwd = process.cwd();
2629
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
+ }
2630
3903
  if (!config.thresholds) {
2631
3904
  const result2 = runCheck({
2632
3905
  config,
@@ -2647,12 +3920,20 @@ function registerCheckCommand(program2) {
2647
3920
  process.exitCode = result2.exitCode;
2648
3921
  return;
2649
3922
  }
2650
- const diff = await computeDiff({
3923
+ const { diff, files, addedByFile } = await computeDiffContext({
2651
3924
  cwd,
2652
3925
  config,
2653
- ...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() } : {}
2654
3936
  });
2655
- const result = runCheck({ config, diff, json: opts.json });
2656
3937
  if (result.stderr) process.stderr.write(result.stderr);
2657
3938
  if (result.stdout) process.stdout.write(result.stdout);
2658
3939
  process.exitCode = result.exitCode;
@@ -2665,8 +3946,8 @@ function registerCheckCommand(program2) {
2665
3946
  }
2666
3947
 
2667
3948
  // src/commands/explain.ts
2668
- import { readFile as readFile3 } from "fs/promises";
2669
- import { resolve as resolve6 } from "path";
3949
+ import { readFile as readFile5 } from "fs/promises";
3950
+ import { resolve as resolve7 } from "path";
2670
3951
  import "commander";
2671
3952
  function parseLocation(input) {
2672
3953
  const idx = input.lastIndexOf(":");
@@ -2727,9 +4008,12 @@ function registerExplainCommand(program2) {
2727
4008
  const { path: relPath, line } = parseLocation(location);
2728
4009
  const config = await loadConfig({ cwd });
2729
4010
  const ctx = await openRepo(cwd);
2730
- const coveragePath = resolve6(cwd, config.coverage.path);
2731
- assertWithinRoot(ctx.repoRoot, coveragePath);
2732
- 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
+ });
2733
4017
  const file = files.find((f) => f.path === relPath);
2734
4018
  if (!file) {
2735
4019
  process.stderr.write(`error: no coverage data for ${relPath}
@@ -2737,9 +4021,9 @@ function registerExplainCommand(program2) {
2737
4021
  process.exitCode = 2;
2738
4022
  return;
2739
4023
  }
2740
- const resolvedSource = resolve6(ctx.repoRoot, relPath);
4024
+ const resolvedSource = resolve7(ctx.repoRoot, relPath);
2741
4025
  assertWithinRoot(ctx.repoRoot, resolvedSource);
2742
- const source = await readFile3(resolvedSource, "utf8");
4026
+ const source = await readFile5(resolvedSource, "utf8");
2743
4027
  const sourceLines = source.split("\n");
2744
4028
  const result = explainAt(file, line, sourceLines);
2745
4029
  if (opts.json) {
@@ -2851,11 +4135,11 @@ async function runToken(opts) {
2851
4135
  async function runWhoami(opts) {
2852
4136
  const env = opts.env ?? process.env;
2853
4137
  const identity = await resolveRepoIdentity(opts);
2854
- const resolve7 = opts.resolveTokenFn ?? resolveToken;
4138
+ const resolve8 = opts.resolveTokenFn ?? resolveToken;
2855
4139
  let tokenSet = false;
2856
4140
  let source = null;
2857
4141
  try {
2858
- const token = resolve7({ env, isTTY: false, warn: () => {
4142
+ const token = resolve8({ env, isTTY: false, warn: () => {
2859
4143
  } });
2860
4144
  tokenSet = Boolean(token);
2861
4145
  source = tokenSet ? tokenSourceFromEnv(env) : null;