@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.
- package/README.md +83 -2
- package/dist/td.js +1490 -206
- package/dist/tested.js +1490 -206
- 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,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
|
|
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";
|
|
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
|
|
1401
|
+
async function fetchOriginRef(ctx, ref) {
|
|
1402
|
+
const spec = ref.startsWith("origin/") ? ref.slice("origin/".length) : ref;
|
|
673
1403
|
try {
|
|
674
|
-
|
|
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
|
-
|
|
696
|
-
return
|
|
1409
|
+
await ctx.git.raw(["fetch", "--depth=1", "origin", spec]);
|
|
1410
|
+
return true;
|
|
697
1411
|
} catch {
|
|
698
|
-
return
|
|
1412
|
+
return false;
|
|
699
1413
|
}
|
|
700
1414
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
|
732
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
|
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
|
|
969
|
-
|
|
970
|
-
|
|
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 =
|
|
1664
|
+
const baseCoveragePath = resolve4(cwd, opts.withBaseCoverage);
|
|
980
1665
|
assertWithinRoot(ctx.repoRoot, baseCoveragePath);
|
|
981
|
-
const baseFiles = await
|
|
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
|
-
|
|
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
|
|
1916
|
+
const stat3 = opts?.statSyncFn ?? statSync;
|
|
1036
1917
|
let mode;
|
|
1037
1918
|
try {
|
|
1038
|
-
const st =
|
|
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 ??
|
|
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 :
|
|
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 :
|
|
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 =
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
1462
|
-
const ctx = await openRepoFn(deps.cwd);
|
|
1463
|
-
onProgress("computing diff\u2026");
|
|
1464
|
-
let diff;
|
|
2488
|
+
let merge;
|
|
1465
2489
|
try {
|
|
1466
|
-
|
|
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
|
-
|
|
1477
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1557
|
-
...testReport ? { testReport } : {}
|
|
2652
|
+
...mergeFields
|
|
1558
2653
|
});
|
|
1559
|
-
onProgress(
|
|
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 ??
|
|
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 =
|
|
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
|
|
2843
|
+
let coverageRels = ["coverage/coverage-final.json"];
|
|
1735
2844
|
if (hasConfig) {
|
|
1736
2845
|
try {
|
|
1737
2846
|
const config = await loadConfigFn({ cwd });
|
|
1738
|
-
|
|
2847
|
+
const listed = coveragePathList(config.coverage.path);
|
|
2848
|
+
if (listed.length > 0) coverageRels = listed;
|
|
1739
2849
|
} catch {
|
|
1740
2850
|
}
|
|
1741
2851
|
}
|
|
1742
|
-
const
|
|
1743
|
-
|
|
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 ${
|
|
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 =
|
|
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.
|
|
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 ??
|
|
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 =
|
|
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
|
|
2206
|
-
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";
|
|
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 =
|
|
2277
|
-
const abs = isAbsolute3(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
|
|
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 =
|
|
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:
|
|
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 ${
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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).
|
|
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
|
|
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
|
|
2669
|
-
import { resolve as
|
|
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
|
|
2731
|
-
|
|
2732
|
-
|
|
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 =
|
|
4024
|
+
const resolvedSource = resolve7(ctx.repoRoot, relPath);
|
|
2741
4025
|
assertWithinRoot(ctx.repoRoot, resolvedSource);
|
|
2742
|
-
const source = await
|
|
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
|
|
4138
|
+
const resolve8 = opts.resolveTokenFn ?? resolveToken;
|
|
2855
4139
|
let tokenSet = false;
|
|
2856
4140
|
let source = null;
|
|
2857
4141
|
try {
|
|
2858
|
-
const token =
|
|
4142
|
+
const token = resolve8({ env, isTTY: false, warn: () => {
|
|
2859
4143
|
} });
|
|
2860
4144
|
tokenSet = Boolean(token);
|
|
2861
4145
|
source = tokenSet ? tokenSourceFromEnv(env) : null;
|