@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.
- package/README.md +83 -2
- package/dist/td.js +1298 -180
- package/dist/tested.js +1298 -180
- 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
|
|
353
|
-
import { join as
|
|
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
|
|
358
|
-
import { basename, isAbsolute as isAbsolute2, join as
|
|
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
|
|
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(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/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
|
|
961
|
+
function decodeXmlEntities2(s) {
|
|
408
962
|
return s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/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
|
|
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 =
|
|
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
|
-
|
|
548
|
-
|
|
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.
|
|
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 =
|
|
1205
|
+
const file = join3(opts.cwd, ".tested.yaml");
|
|
612
1206
|
let raw = {};
|
|
613
1207
|
try {
|
|
614
|
-
const text = await
|
|
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
|
|
626
|
-
import { join as
|
|
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
|
|
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
|
|
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
|
|
993
|
-
|
|
994
|
-
|
|
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 =
|
|
1664
|
+
const baseCoveragePath = resolve4(cwd, opts.withBaseCoverage);
|
|
1004
1665
|
assertWithinRoot(ctx.repoRoot, baseCoveragePath);
|
|
1005
|
-
const baseFiles = await
|
|
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
|
-
|
|
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
|
|
1916
|
+
const stat3 = opts?.statSyncFn ?? statSync;
|
|
1060
1917
|
let mode;
|
|
1061
1918
|
try {
|
|
1062
|
-
const st =
|
|
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 ??
|
|
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 :
|
|
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 :
|
|
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 =
|
|
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
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
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
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1720
|
-
...testReport ? { testReport } : {}
|
|
2652
|
+
...mergeFields
|
|
1721
2653
|
});
|
|
1722
|
-
onProgress(
|
|
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 ??
|
|
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 =
|
|
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
|
|
2843
|
+
let coverageRels = ["coverage/coverage-final.json"];
|
|
1901
2844
|
if (hasConfig) {
|
|
1902
2845
|
try {
|
|
1903
2846
|
const config = await loadConfigFn({ cwd });
|
|
1904
|
-
|
|
2847
|
+
const listed = coveragePathList(config.coverage.path);
|
|
2848
|
+
if (listed.length > 0) coverageRels = listed;
|
|
1905
2849
|
} catch {
|
|
1906
2850
|
}
|
|
1907
2851
|
}
|
|
1908
|
-
const
|
|
1909
|
-
|
|
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 ${
|
|
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 =
|
|
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.
|
|
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 ??
|
|
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 =
|
|
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
|
|
2372
|
-
import { isAbsolute as isAbsolute3, resolve as
|
|
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 =
|
|
2443
|
-
const abs = isAbsolute3(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
|
|
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 =
|
|
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:
|
|
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 ${
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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).
|
|
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
|
|
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
|
|
2835
|
-
import { resolve as
|
|
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
|
|
2897
|
-
|
|
2898
|
-
|
|
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 =
|
|
4024
|
+
const resolvedSource = resolve7(ctx.repoRoot, relPath);
|
|
2907
4025
|
assertWithinRoot(ctx.repoRoot, resolvedSource);
|
|
2908
|
-
const source = await
|
|
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
|
|
4138
|
+
const resolve8 = opts.resolveTokenFn ?? resolveToken;
|
|
3021
4139
|
let tokenSet = false;
|
|
3022
4140
|
let source = null;
|
|
3023
4141
|
try {
|
|
3024
|
-
const token =
|
|
4142
|
+
const token = resolve8({ env, isTTY: false, warn: () => {
|
|
3025
4143
|
} });
|
|
3026
4144
|
tokenSet = Boolean(token);
|
|
3027
4145
|
source = tokenSet ? tokenSourceFromEnv(env) : null;
|