@tested/cli 0.1.7 → 0.1.9
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 +1333 -180
- package/dist/tested.js +1333 -180
- package/package.json +10 -13
package/dist/tested.js
CHANGED
|
@@ -46,6 +46,8 @@ function badge(kind) {
|
|
|
46
46
|
return pc.cyan("[INFO]");
|
|
47
47
|
case "skip":
|
|
48
48
|
return pc.cyan("[SKIP]");
|
|
49
|
+
case "missing":
|
|
50
|
+
return pc.yellow("[MISSING]");
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
function metricBar(pct3, width = 10) {
|
|
@@ -349,24 +351,576 @@ function registerInitCommand(program2) {
|
|
|
349
351
|
}
|
|
350
352
|
|
|
351
353
|
// src/commands/setup.ts
|
|
352
|
-
import { existsSync as
|
|
353
|
-
import { join as
|
|
354
|
+
import { existsSync as existsSync5 } from "fs";
|
|
355
|
+
import { join as join6 } from "path";
|
|
354
356
|
import "commander";
|
|
355
357
|
|
|
356
358
|
// src/commands/doctor.ts
|
|
357
|
-
import { existsSync as
|
|
358
|
-
import { basename, isAbsolute as isAbsolute2, join as
|
|
359
|
+
import { existsSync as existsSync4, accessSync, constants as fsConstants } from "fs";
|
|
360
|
+
import { basename as basename2, isAbsolute as isAbsolute2, join as join5, resolve as resolve5 } from "path";
|
|
359
361
|
import "commander";
|
|
360
362
|
import { simpleGit as simpleGit3 } from "simple-git";
|
|
361
363
|
|
|
362
364
|
// src/config.ts
|
|
363
|
-
import { readFile } from "fs/promises";
|
|
364
|
-
import { join as
|
|
365
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
366
|
+
import { join as join3 } from "path";
|
|
365
367
|
import { parse as parseYaml } from "yaml";
|
|
366
368
|
|
|
367
369
|
// src/schemas.ts
|
|
368
370
|
import { z as z2 } from "zod";
|
|
369
371
|
|
|
372
|
+
// src/core/coverage.ts
|
|
373
|
+
import { readFile as readFile3, stat as stat2 } from "fs/promises";
|
|
374
|
+
import { basename } from "path";
|
|
375
|
+
|
|
376
|
+
// src/core/istanbul.ts
|
|
377
|
+
import { readFile } from "fs/promises";
|
|
378
|
+
|
|
379
|
+
// src/core/coverage-model.ts
|
|
380
|
+
import { isAbsolute, relative, resolve } from "path";
|
|
381
|
+
function resolveCoverageEntryPath(repoRoot, entryPath) {
|
|
382
|
+
const cleaned = entryPath.trim().replace(/\\/g, "/");
|
|
383
|
+
if (cleaned.startsWith("file://")) {
|
|
384
|
+
try {
|
|
385
|
+
return decodeURIComponent(new URL(cleaned).pathname);
|
|
386
|
+
} catch {
|
|
387
|
+
return resolve(repoRoot, cleaned.replace(/^file:\/\//, ""));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return isAbsolute(cleaned) ? resolve(cleaned) : resolve(repoRoot, cleaned);
|
|
391
|
+
}
|
|
392
|
+
function isCoveragePathInsideRoot(repoRoot, entryPath) {
|
|
393
|
+
const root = resolve(repoRoot);
|
|
394
|
+
let absPath;
|
|
395
|
+
try {
|
|
396
|
+
absPath = resolveCoverageEntryPath(root, entryPath);
|
|
397
|
+
} catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
const relPath = relative(root, absPath).split("\\").join("/");
|
|
401
|
+
if (!relPath || relPath === "") return true;
|
|
402
|
+
if (isAbsolute(relPath)) return false;
|
|
403
|
+
if (relPath === ".." || relPath.startsWith("../")) return false;
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
function toFileCoverage(repoRoot, entryPath, statements) {
|
|
407
|
+
if (!isCoveragePathInsideRoot(repoRoot, entryPath)) return null;
|
|
408
|
+
const absPath = resolveCoverageEntryPath(repoRoot, entryPath);
|
|
409
|
+
const relPath = relative(resolve(repoRoot), absPath).split("\\").join("/");
|
|
410
|
+
return { path: relPath, absPath, statements };
|
|
411
|
+
}
|
|
412
|
+
function statementsFromLineHits(lineHits) {
|
|
413
|
+
return [...lineHits].filter(([line]) => Number.isInteger(line) && line > 0).sort((a, b) => a[0] - b[0]).map(([line, hits]) => ({
|
|
414
|
+
id: String(line),
|
|
415
|
+
startLine: line,
|
|
416
|
+
endLine: line,
|
|
417
|
+
hits: Number.isFinite(hits) && hits > 0 ? hits : 0
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
function mergeLineHits(into, line, hits) {
|
|
421
|
+
if (!Number.isInteger(line) || line <= 0) return;
|
|
422
|
+
const n = Number.isFinite(hits) && hits > 0 ? hits : 0;
|
|
423
|
+
into.set(line, (into.get(line) ?? 0) + n);
|
|
424
|
+
}
|
|
425
|
+
function fileCoverageFromLineHits(repoRoot, entryPath, lineHits) {
|
|
426
|
+
return toFileCoverage(repoRoot, entryPath, statementsFromLineHits(lineHits));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/core/istanbul.ts
|
|
430
|
+
function parseIstanbulString(raw, repoRoot, pathForError) {
|
|
431
|
+
let data;
|
|
432
|
+
try {
|
|
433
|
+
data = JSON.parse(raw);
|
|
434
|
+
} catch {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`Istanbul/V8 JSON is not valid JSON${pathForError ? ` (${pathForError})` : ""}`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
440
|
+
throw new Error("Istanbul/V8 JSON must be an object of file entries");
|
|
441
|
+
}
|
|
442
|
+
const out = [];
|
|
443
|
+
for (const entry of Object.values(data)) {
|
|
444
|
+
if (!entry || typeof entry !== "object" || typeof entry.path !== "string") {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (!entry.statementMap || typeof entry.statementMap !== "object") {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const statements = Object.entries(
|
|
451
|
+
entry.statementMap
|
|
452
|
+
).map(([id, loc]) => ({
|
|
453
|
+
id,
|
|
454
|
+
startLine: loc.start.line,
|
|
455
|
+
endLine: loc.end.line,
|
|
456
|
+
hits: entry.s?.[id] ?? 0
|
|
457
|
+
}));
|
|
458
|
+
const file = toFileCoverage(repoRoot, entry.path, statements);
|
|
459
|
+
if (file) out.push(file);
|
|
460
|
+
}
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// src/core/formats/lcov.ts
|
|
465
|
+
function parseLcov(raw, repoRoot) {
|
|
466
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
467
|
+
let current = null;
|
|
468
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
469
|
+
const line = rawLine.trim();
|
|
470
|
+
if (!line) continue;
|
|
471
|
+
if (line.startsWith("SF:")) {
|
|
472
|
+
current = line.slice(3).trim();
|
|
473
|
+
if (current && !byFile.has(current)) byFile.set(current, /* @__PURE__ */ new Map());
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (line === "end_of_record") {
|
|
477
|
+
current = null;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (!current || !line.startsWith("DA:")) continue;
|
|
481
|
+
const payload = line.slice(3);
|
|
482
|
+
const comma = payload.indexOf(",");
|
|
483
|
+
if (comma < 0) continue;
|
|
484
|
+
const lineNo = Number(payload.slice(0, comma));
|
|
485
|
+
const hitsRaw = payload.slice(comma + 1).split(",")[0] ?? "0";
|
|
486
|
+
const hits = Number(hitsRaw);
|
|
487
|
+
mergeLineHits(byFile.get(current), lineNo, hits);
|
|
488
|
+
}
|
|
489
|
+
const out = [];
|
|
490
|
+
for (const [path, hits] of byFile) {
|
|
491
|
+
const file = fileCoverageFromLineHits(repoRoot, path, hits);
|
|
492
|
+
if (file) out.push(file);
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// src/core/formats/xml.ts
|
|
498
|
+
function decodeXmlEntities(s) {
|
|
499
|
+
return s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
500
|
+
}
|
|
501
|
+
function xmlAttr(tag, name) {
|
|
502
|
+
const re = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i");
|
|
503
|
+
const m = tag.match(re);
|
|
504
|
+
if (!m) return void 0;
|
|
505
|
+
return decodeXmlEntities(m[2] ?? m[3] ?? "");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/core/formats/cobertura.ts
|
|
509
|
+
function parseCobertura(raw, repoRoot) {
|
|
510
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
511
|
+
const classRe = /<class\b([^>]*)>([\s\S]*?)<\/class>|<class\b([^>]*)\s*\/>/gi;
|
|
512
|
+
let classMatch;
|
|
513
|
+
while ((classMatch = classRe.exec(raw)) !== null) {
|
|
514
|
+
const attrs = (classMatch[1] ?? classMatch[3] ?? "").trim();
|
|
515
|
+
const body = classMatch[2] ?? "";
|
|
516
|
+
const filename = xmlAttr(attrs, "filename")?.trim();
|
|
517
|
+
const name = xmlAttr(attrs, "name")?.trim();
|
|
518
|
+
const path = classPath(filename, name);
|
|
519
|
+
if (!path) continue;
|
|
520
|
+
let hits = byFile.get(path);
|
|
521
|
+
if (!hits) {
|
|
522
|
+
hits = /* @__PURE__ */ new Map();
|
|
523
|
+
byFile.set(path, hits);
|
|
524
|
+
}
|
|
525
|
+
const lineRe = /<line\b([^>]*)\/?>/gi;
|
|
526
|
+
let lineMatch;
|
|
527
|
+
while ((lineMatch = lineRe.exec(body)) !== null) {
|
|
528
|
+
const lineAttrs = lineMatch[1] ?? "";
|
|
529
|
+
const number = Number(xmlAttr(lineAttrs, "number"));
|
|
530
|
+
const hitCount = Number(xmlAttr(lineAttrs, "hits") ?? "0");
|
|
531
|
+
mergeLineHits(hits, number, hitCount);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const out = [];
|
|
535
|
+
for (const [path, hits] of byFile) {
|
|
536
|
+
const file = fileCoverageFromLineHits(repoRoot, path, hits);
|
|
537
|
+
if (file) out.push(file);
|
|
538
|
+
}
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
function classPath(filename, name) {
|
|
542
|
+
if (filename) {
|
|
543
|
+
if (filename.includes("/") || filename.includes("\\")) return filename;
|
|
544
|
+
if (name && name.includes(".")) {
|
|
545
|
+
const dir = name.split(".").slice(0, -1).join("/");
|
|
546
|
+
return dir ? `${dir}/${filename}` : filename;
|
|
547
|
+
}
|
|
548
|
+
return filename;
|
|
549
|
+
}
|
|
550
|
+
if (name) return name.replace(/\./g, "/");
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// src/core/formats/gcov.ts
|
|
555
|
+
import { readdir, readFile as readFile2, stat } from "fs/promises";
|
|
556
|
+
import { join as join2 } from "path";
|
|
557
|
+
function parseGcov(raw, repoRoot) {
|
|
558
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
559
|
+
let current = null;
|
|
560
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
561
|
+
const parsed = parseGcovLine(rawLine);
|
|
562
|
+
if (!parsed) continue;
|
|
563
|
+
if (parsed.lineNo === 0) {
|
|
564
|
+
const source = sourceFromMeta(parsed.source);
|
|
565
|
+
if (source) {
|
|
566
|
+
current = source;
|
|
567
|
+
if (!byFile.has(current)) byFile.set(current, /* @__PURE__ */ new Map());
|
|
568
|
+
}
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (parsed.hits === null) continue;
|
|
572
|
+
if (!current) continue;
|
|
573
|
+
mergeLineHits(byFile.get(current), parsed.lineNo, parsed.hits);
|
|
574
|
+
}
|
|
575
|
+
const out = [];
|
|
576
|
+
for (const [path, hits] of byFile) {
|
|
577
|
+
const file = fileCoverageFromLineHits(repoRoot, path, hits);
|
|
578
|
+
if (file) out.push(file);
|
|
579
|
+
}
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
async function parseGcovPath(opts) {
|
|
583
|
+
const info = await stat(opts.path);
|
|
584
|
+
if (info.isDirectory()) {
|
|
585
|
+
const names = (await readdir(opts.path)).filter((n) => n.endsWith(".gcov")).sort();
|
|
586
|
+
if (names.length === 0) {
|
|
587
|
+
throw new Error(
|
|
588
|
+
`no .gcov files in ${opts.path}. Run \`gcov\` on your .gcda files (binary .gcno/.gcda notes are not parsed).`
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
592
|
+
const absByPath = /* @__PURE__ */ new Map();
|
|
593
|
+
for (const name of names) {
|
|
594
|
+
const filePath = join2(opts.path, name);
|
|
595
|
+
const raw2 = await readFile2(filePath, "utf8");
|
|
596
|
+
for (const file of parseGcov(raw2, opts.repoRoot)) {
|
|
597
|
+
let hits = byPath.get(file.path);
|
|
598
|
+
if (!hits) {
|
|
599
|
+
hits = /* @__PURE__ */ new Map();
|
|
600
|
+
byPath.set(file.path, hits);
|
|
601
|
+
absByPath.set(file.path, file.absPath);
|
|
602
|
+
}
|
|
603
|
+
for (const s of file.statements) {
|
|
604
|
+
mergeLineHits(hits, s.startLine, s.hits);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return [...byPath.entries()].map(([path, hits]) => ({
|
|
609
|
+
path,
|
|
610
|
+
absPath: absByPath.get(path),
|
|
611
|
+
statements: statementsFromLineHits(hits)
|
|
612
|
+
}));
|
|
613
|
+
}
|
|
614
|
+
const raw = await readFile2(opts.path, "utf8");
|
|
615
|
+
return parseGcov(raw, opts.repoRoot);
|
|
616
|
+
}
|
|
617
|
+
function parseGcovLine(rawLine) {
|
|
618
|
+
const first = rawLine.indexOf(":");
|
|
619
|
+
if (first < 0) return null;
|
|
620
|
+
const second = rawLine.indexOf(":", first + 1);
|
|
621
|
+
if (second < 0) return null;
|
|
622
|
+
const countField = rawLine.slice(0, first).trim();
|
|
623
|
+
const lineNo = Number(rawLine.slice(first + 1, second).trim());
|
|
624
|
+
if (!Number.isInteger(lineNo)) return null;
|
|
625
|
+
const source = rawLine.slice(second + 1);
|
|
626
|
+
if (countField === "-" || countField === "") {
|
|
627
|
+
return { hits: null, lineNo, source };
|
|
628
|
+
}
|
|
629
|
+
if (countField.startsWith("#") || countField.startsWith("=")) {
|
|
630
|
+
return { hits: 0, lineNo, source };
|
|
631
|
+
}
|
|
632
|
+
const hits = Number.parseInt(countField.replace(/\*+$/, ""), 10);
|
|
633
|
+
if (!Number.isFinite(hits)) return { hits: 0, lineNo, source };
|
|
634
|
+
return { hits, lineNo, source };
|
|
635
|
+
}
|
|
636
|
+
function sourceFromMeta(source) {
|
|
637
|
+
const trimmed = source.trim();
|
|
638
|
+
const m = trimmed.match(/^Source:(.*)$/);
|
|
639
|
+
if (!m) return null;
|
|
640
|
+
const path = (m[1] ?? "").trim();
|
|
641
|
+
return path || null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// src/core/formats/jacoco.ts
|
|
645
|
+
function parseJacoco(raw, repoRoot) {
|
|
646
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
647
|
+
const pkgRe = /<package\b([^>]*)>([\s\S]*?)<\/package>/gi;
|
|
648
|
+
let pkgMatch;
|
|
649
|
+
while ((pkgMatch = pkgRe.exec(raw)) !== null) {
|
|
650
|
+
const pkgName = xmlAttr(pkgMatch[1] ?? "", "name")?.trim() ?? "";
|
|
651
|
+
const pkgPath = normalizePackagePath(pkgName);
|
|
652
|
+
const body = pkgMatch[2] ?? "";
|
|
653
|
+
const sfRe = /<sourcefile\b([^>]*)>([\s\S]*?)<\/sourcefile>/gi;
|
|
654
|
+
let sfMatch;
|
|
655
|
+
while ((sfMatch = sfRe.exec(body)) !== null) {
|
|
656
|
+
const sourceName = xmlAttr(sfMatch[1] ?? "", "name")?.trim();
|
|
657
|
+
if (!sourceName) continue;
|
|
658
|
+
const path = pkgPath ? `${pkgPath}/${sourceName}` : sourceName;
|
|
659
|
+
let hits = byFile.get(path);
|
|
660
|
+
if (!hits) {
|
|
661
|
+
hits = /* @__PURE__ */ new Map();
|
|
662
|
+
byFile.set(path, hits);
|
|
663
|
+
}
|
|
664
|
+
const lineRe = /<line\b([^>]*)\/?>/gi;
|
|
665
|
+
let lineMatch;
|
|
666
|
+
while ((lineMatch = lineRe.exec(sfMatch[2] ?? "")) !== null) {
|
|
667
|
+
const lineAttrs = lineMatch[1] ?? "";
|
|
668
|
+
const nr = Number(xmlAttr(lineAttrs, "nr"));
|
|
669
|
+
const ci = Number(xmlAttr(lineAttrs, "ci") ?? "0");
|
|
670
|
+
mergeLineHits(hits, nr, Number.isFinite(ci) ? ci : 0);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const out = [];
|
|
675
|
+
for (const [path, hits] of byFile) {
|
|
676
|
+
const file = fileCoverageFromLineHits(repoRoot, path, hits);
|
|
677
|
+
if (file) out.push(file);
|
|
678
|
+
}
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
681
|
+
function normalizePackagePath(name) {
|
|
682
|
+
if (!name) return "";
|
|
683
|
+
if (name.includes("/")) return name.replace(/\\/g, "/");
|
|
684
|
+
return name.replace(/\./g, "/");
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/core/formats/simplecov.ts
|
|
688
|
+
function parseSimpleCov(raw, repoRoot) {
|
|
689
|
+
let data;
|
|
690
|
+
try {
|
|
691
|
+
data = JSON.parse(raw);
|
|
692
|
+
} catch {
|
|
693
|
+
throw new Error("SimpleCov coverage file is not valid JSON");
|
|
694
|
+
}
|
|
695
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
696
|
+
if (isSimpleCovJsonGem(data)) {
|
|
697
|
+
for (const file of data.files) {
|
|
698
|
+
const filename = typeof file.filename === "string" ? file.filename : "";
|
|
699
|
+
if (!filename) continue;
|
|
700
|
+
addCoverageArray(byFile, filename, file.coverage);
|
|
701
|
+
}
|
|
702
|
+
} else if (isSimpleCovResultset(data)) {
|
|
703
|
+
for (const suite of Object.values(data)) {
|
|
704
|
+
if (!suite || typeof suite !== "object") continue;
|
|
705
|
+
const coverage = suite.coverage;
|
|
706
|
+
if (!coverage || typeof coverage !== "object") continue;
|
|
707
|
+
for (const [filename, entry] of Object.entries(
|
|
708
|
+
coverage
|
|
709
|
+
)) {
|
|
710
|
+
addCoverageArray(byFile, filename, linesFromResultsetEntry(entry));
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
} else {
|
|
714
|
+
throw new Error(
|
|
715
|
+
"Not a SimpleCov resultset or simplecov-json report. Expected coverage/.resultset.json"
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
const out = [];
|
|
719
|
+
for (const [path, hits] of byFile) {
|
|
720
|
+
const file = fileCoverageFromLineHits(repoRoot, path, hits);
|
|
721
|
+
if (file) out.push(file);
|
|
722
|
+
}
|
|
723
|
+
return out;
|
|
724
|
+
}
|
|
725
|
+
function isSimpleCovJsonGem(data) {
|
|
726
|
+
return typeof data === "object" && data !== null && Array.isArray(data.files) && data.files.some(
|
|
727
|
+
(f) => typeof f === "object" && f !== null && typeof f.filename === "string"
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
function isSimpleCovResultset(data) {
|
|
731
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
if (isSimpleCovJsonGem(data)) return false;
|
|
735
|
+
return Object.values(data).some((suite) => {
|
|
736
|
+
if (typeof suite !== "object" || suite === null) return false;
|
|
737
|
+
const coverage = suite.coverage;
|
|
738
|
+
return typeof coverage === "object" && coverage !== null;
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
function linesFromResultsetEntry(entry) {
|
|
742
|
+
if (Array.isArray(entry)) return entry;
|
|
743
|
+
if (entry && typeof entry === "object" && "lines" in entry) {
|
|
744
|
+
return entry.lines;
|
|
745
|
+
}
|
|
746
|
+
return void 0;
|
|
747
|
+
}
|
|
748
|
+
function addCoverageArray(byFile, filename, coverage) {
|
|
749
|
+
if (!Array.isArray(coverage)) return;
|
|
750
|
+
let hits = byFile.get(filename);
|
|
751
|
+
if (!hits) {
|
|
752
|
+
hits = /* @__PURE__ */ new Map();
|
|
753
|
+
byFile.set(filename, hits);
|
|
754
|
+
}
|
|
755
|
+
coverage.forEach((cell, idx) => {
|
|
756
|
+
if (cell === null || cell === void 0) return;
|
|
757
|
+
const n = typeof cell === "number" ? cell : Number(cell);
|
|
758
|
+
if (!Number.isFinite(n)) return;
|
|
759
|
+
mergeLineHits(hits, idx + 1, n);
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/core/coverage.ts
|
|
764
|
+
var COVERAGE_FORMATS = [
|
|
765
|
+
"istanbul-json",
|
|
766
|
+
"v8-json",
|
|
767
|
+
"lcov",
|
|
768
|
+
"cobertura",
|
|
769
|
+
"jacoco",
|
|
770
|
+
"gcov",
|
|
771
|
+
"simplecov"
|
|
772
|
+
];
|
|
773
|
+
function resolveCoverageFormat(format) {
|
|
774
|
+
return format === "v8-json" ? "istanbul-json" : format;
|
|
775
|
+
}
|
|
776
|
+
var MISSING_COVERAGE = "coverage file missing. Run `tested run` first, or set coverage.path in .tested.yaml.";
|
|
777
|
+
async function parseCoverage(opts) {
|
|
778
|
+
const explicit = opts.format ? resolveCoverageFormat(opts.format) : void 0;
|
|
779
|
+
let info;
|
|
780
|
+
try {
|
|
781
|
+
info = await stat2(opts.path);
|
|
782
|
+
} catch (err) {
|
|
783
|
+
if (err.code === "ENOENT") {
|
|
784
|
+
throw missingCoverageError(opts.path);
|
|
785
|
+
}
|
|
786
|
+
throw err;
|
|
787
|
+
}
|
|
788
|
+
if (info.isDirectory()) {
|
|
789
|
+
const format2 = explicit ?? detectFormatFromPath(opts.path) ?? "gcov";
|
|
790
|
+
if (format2 !== "gcov") {
|
|
791
|
+
throw new Error(
|
|
792
|
+
`coverage path ${opts.path} is a directory; only gcov accepts a directory of .gcov files`
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
return parseGcovPath({ path: opts.path, repoRoot: opts.repoRoot });
|
|
796
|
+
}
|
|
797
|
+
let raw;
|
|
798
|
+
try {
|
|
799
|
+
raw = await readFile3(opts.path, "utf8");
|
|
800
|
+
} catch (err) {
|
|
801
|
+
if (err.code === "ENOENT") {
|
|
802
|
+
throw missingCoverageError(opts.path);
|
|
803
|
+
}
|
|
804
|
+
throw err;
|
|
805
|
+
}
|
|
806
|
+
const format = explicit ?? detectFormatFromContents(raw) ?? detectFormatFromPath(opts.path) ?? defaultIstanbulIfCoverageFinal(opts.path);
|
|
807
|
+
if (!format) {
|
|
808
|
+
throw new Error(
|
|
809
|
+
`Unable to detect coverage format for ${opts.path}. Set coverage.format in .tested.yaml (${COVERAGE_FORMATS.filter((f) => f !== "v8-json").join(", ")}).`
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
return parseCoverageText({
|
|
813
|
+
raw,
|
|
814
|
+
path: opts.path,
|
|
815
|
+
repoRoot: opts.repoRoot,
|
|
816
|
+
format
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
function parseCoverageText(opts) {
|
|
820
|
+
switch (opts.format) {
|
|
821
|
+
case "istanbul-json":
|
|
822
|
+
return parseIstanbulString(opts.raw, opts.repoRoot, opts.path);
|
|
823
|
+
case "lcov":
|
|
824
|
+
return parseLcov(opts.raw, opts.repoRoot);
|
|
825
|
+
case "cobertura":
|
|
826
|
+
return parseCobertura(opts.raw, opts.repoRoot);
|
|
827
|
+
case "jacoco":
|
|
828
|
+
return parseJacoco(opts.raw, opts.repoRoot);
|
|
829
|
+
case "gcov":
|
|
830
|
+
return parseGcov(opts.raw, opts.repoRoot);
|
|
831
|
+
case "simplecov":
|
|
832
|
+
return parseSimpleCov(opts.raw, opts.repoRoot);
|
|
833
|
+
default: {
|
|
834
|
+
const _exhaustive = opts.format;
|
|
835
|
+
throw new Error(`Unsupported coverage format: ${String(_exhaustive)}`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function detectFormatFromPath(filePath) {
|
|
840
|
+
const base = basename(filePath).toLowerCase();
|
|
841
|
+
if (base === "coverage-final.json") return "istanbul-json";
|
|
842
|
+
if (base === "lcov.info" || base.endsWith(".lcov")) return "lcov";
|
|
843
|
+
if (base.includes("cobertura")) return "cobertura";
|
|
844
|
+
if (base.includes("jacoco")) return "jacoco";
|
|
845
|
+
if (base.endsWith(".gcov")) return "gcov";
|
|
846
|
+
if (base === ".resultset.json" || base === "resultset.json") return "simplecov";
|
|
847
|
+
return void 0;
|
|
848
|
+
}
|
|
849
|
+
function detectFormatFromContents(raw) {
|
|
850
|
+
const text = raw.replace(/^\uFEFF/, "");
|
|
851
|
+
const trimmed = text.trimStart();
|
|
852
|
+
if (!trimmed) return void 0;
|
|
853
|
+
if (/^\s*-:\s*0:Source:/m.test(trimmed.slice(0, 4e3))) return "gcov";
|
|
854
|
+
const headLines = trimmed.slice(0, 2e3);
|
|
855
|
+
if (/^(TN:|SF:|DA:\d)/m.test(headLines)) return "lcov";
|
|
856
|
+
if (trimmed.startsWith("<?xml") || trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<coverage") || trimmed.startsWith("<report")) {
|
|
857
|
+
return detectXmlFormat(trimmed.slice(0, 8e3));
|
|
858
|
+
}
|
|
859
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
860
|
+
try {
|
|
861
|
+
const data = JSON.parse(text);
|
|
862
|
+
if (isCoveragePyJson(data)) {
|
|
863
|
+
throw new Error(
|
|
864
|
+
"coverage.py JSON is not supported. Emit lcov or Cobertura XML (pytest-cov `--cov-report=lcov` or `--cov-report=xml`) instead."
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
if (isIstanbulJson(data)) return "istanbul-json";
|
|
868
|
+
if (isSimpleCovJsonGem(data) || isSimpleCovResultset(data)) {
|
|
869
|
+
return "simplecov";
|
|
870
|
+
}
|
|
871
|
+
} catch (err) {
|
|
872
|
+
if (err instanceof Error && /coverage\.py JSON/.test(err.message)) {
|
|
873
|
+
throw err;
|
|
874
|
+
}
|
|
875
|
+
return void 0;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
const firstData = trimmed.split(/\r?\n/).find((l) => l.includes(":"));
|
|
879
|
+
if (firstData && /^\s*(?:#####|=======|\d+)\s*:\s*\d+:/.test(firstData)) {
|
|
880
|
+
return "gcov";
|
|
881
|
+
}
|
|
882
|
+
return void 0;
|
|
883
|
+
}
|
|
884
|
+
function detectXmlFormat(head) {
|
|
885
|
+
const lower = head.toLowerCase();
|
|
886
|
+
if (lower.includes("cobertura")) return "cobertura";
|
|
887
|
+
if (lower.includes("jacoco")) return "jacoco";
|
|
888
|
+
if (lower.includes("<report") && /<line\b[^>]*\bci\s*=/.test(lower)) {
|
|
889
|
+
return "jacoco";
|
|
890
|
+
}
|
|
891
|
+
if (lower.includes("<coverage") && /<line\b[^>]*\bhits\s*=/.test(lower)) {
|
|
892
|
+
return "cobertura";
|
|
893
|
+
}
|
|
894
|
+
if (lower.includes("<report")) return "jacoco";
|
|
895
|
+
if (lower.includes("<coverage")) return "cobertura";
|
|
896
|
+
return void 0;
|
|
897
|
+
}
|
|
898
|
+
function defaultIstanbulIfCoverageFinal(filePath) {
|
|
899
|
+
const base = basename(filePath).toLowerCase();
|
|
900
|
+
if (base === "coverage-final.json") return "istanbul-json";
|
|
901
|
+
return void 0;
|
|
902
|
+
}
|
|
903
|
+
function isIstanbulJson(data) {
|
|
904
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
const values = Object.values(data);
|
|
908
|
+
if (values.length === 0) return true;
|
|
909
|
+
return values.some(
|
|
910
|
+
(v) => typeof v === "object" && v !== null && "statementMap" in v && "s" in v
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
function isCoveragePyJson(data) {
|
|
914
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
915
|
+
return false;
|
|
916
|
+
}
|
|
917
|
+
const rec = data;
|
|
918
|
+
return typeof rec.meta === "object" && rec.meta !== null && typeof rec.files === "object" && rec.files !== null && !Array.isArray(rec.files);
|
|
919
|
+
}
|
|
920
|
+
function missingCoverageError(path) {
|
|
921
|
+
return new Error(`${MISSING_COVERAGE} (${path})`);
|
|
922
|
+
}
|
|
923
|
+
|
|
370
924
|
// src/core/junit.ts
|
|
371
925
|
import { z } from "zod";
|
|
372
926
|
function testCaseKey(classname, name) {
|
|
@@ -404,14 +958,14 @@ var TestReportSchema = z.object({
|
|
|
404
958
|
).max(50),
|
|
405
959
|
slowest: z.array(TestCaseRefSchema).max(15)
|
|
406
960
|
});
|
|
407
|
-
function
|
|
961
|
+
function decodeXmlEntities2(s) {
|
|
408
962
|
return s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
409
963
|
}
|
|
410
964
|
function attr(tag, name) {
|
|
411
965
|
const re = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i");
|
|
412
966
|
const m = tag.match(re);
|
|
413
967
|
if (!m) return void 0;
|
|
414
|
-
return
|
|
968
|
+
return decodeXmlEntities2(m[2] ?? m[3] ?? "");
|
|
415
969
|
}
|
|
416
970
|
function parseJunitXml(xml) {
|
|
417
971
|
const cases = [];
|
|
@@ -438,7 +992,7 @@ function parseJunitXml(xml) {
|
|
|
438
992
|
message = fm ? attr(fm[1] ?? "", "message") : void 0;
|
|
439
993
|
if (!message) {
|
|
440
994
|
const inner = body.match(/<failure\b[^>]*>([\s\S]*?)<\/failure>/i);
|
|
441
|
-
if (inner?.[1]?.trim()) message =
|
|
995
|
+
if (inner?.[1]?.trim()) message = decodeXmlEntities2(inner[1].trim()).slice(0, 500);
|
|
442
996
|
}
|
|
443
997
|
} else if (/<error\b/i.test(body)) {
|
|
444
998
|
status = "error";
|
|
@@ -541,22 +1095,43 @@ function parseJunitToTestReport(xml) {
|
|
|
541
1095
|
}
|
|
542
1096
|
|
|
543
1097
|
// src/schemas.ts
|
|
1098
|
+
var CoverageFormatSchema = z2.enum(COVERAGE_FORMATS);
|
|
1099
|
+
var FLAG_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
1100
|
+
var FlagNameSchema = z2.string().regex(FLAG_NAME_PATTERN, "flag names must be alphanumeric plus _ . -");
|
|
1101
|
+
var FlagThresholdsSchema = z2.object({
|
|
1102
|
+
patch: z2.number().min(0).max(100).optional(),
|
|
1103
|
+
project: z2.number().min(0).max(100).optional()
|
|
1104
|
+
});
|
|
1105
|
+
var FlagConfigSchema = z2.object({
|
|
1106
|
+
paths: z2.array(z2.string().min(1)).min(1),
|
|
1107
|
+
thresholds: FlagThresholdsSchema.optional()
|
|
1108
|
+
});
|
|
544
1109
|
var TestedConfigSchema = z2.object({
|
|
545
1110
|
ignores: z2.array(z2.string()).default([]),
|
|
546
1111
|
coverage: z2.object({
|
|
547
|
-
|
|
548
|
-
|
|
1112
|
+
/** Omit to auto-detect from path and file contents. */
|
|
1113
|
+
format: CoverageFormatSchema.optional(),
|
|
1114
|
+
/**
|
|
1115
|
+
* One file or a list of files to merge (union of paths, max hits).
|
|
1116
|
+
* A CI matrix that uploads many files in one job should list them here
|
|
1117
|
+
* or pass `--file` / Action `files`.
|
|
1118
|
+
*/
|
|
1119
|
+
path: z2.union([z2.string().min(1), z2.array(z2.string().min(1)).min(1)]).default("coverage/coverage-final.json")
|
|
549
1120
|
}).prefault({}),
|
|
550
1121
|
base: z2.string().default("origin/main"),
|
|
551
1122
|
testRunner: z2.enum(["vitest", "jest", "pytest"]).nullable().default(null),
|
|
552
1123
|
// Patch / project coverage gates. `tested init` writes these so users can
|
|
553
1124
|
// tune what counts as "passing" — schema MUST accept them so loadConfig
|
|
554
|
-
// doesn't silently drop the field.
|
|
555
|
-
// follow-up; today we just round-trip the values cleanly.
|
|
1125
|
+
// doesn't silently drop the field.
|
|
556
1126
|
thresholds: z2.object({
|
|
557
1127
|
patch: z2.number().min(0).max(100),
|
|
558
1128
|
project: z2.number().min(0).max(100)
|
|
559
|
-
}).optional()
|
|
1129
|
+
}).optional(),
|
|
1130
|
+
/**
|
|
1131
|
+
* Per-package gates. Each flag is graded from this run's coverage files
|
|
1132
|
+
* only — a missing path is skipped (not 0%), never another flag'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,29 @@ 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
|
+
/** Omitted when this flag had no files this run (`skipped: true`). */
|
|
1168
|
+
pct: z2.number().min(0).max(100).optional(),
|
|
1169
|
+
threshold: z2.number().min(0).max(100),
|
|
1170
|
+
/** Omitted when skipped — a missing flag is not a 0% fail. */
|
|
1171
|
+
pass: z2.boolean().optional(),
|
|
1172
|
+
executable: z2.number().int().nonnegative().optional(),
|
|
1173
|
+
covered: z2.number().int().nonnegative().optional(),
|
|
1174
|
+
skipped: z2.literal(true).optional(),
|
|
1175
|
+
reason: z2.string().optional()
|
|
1176
|
+
});
|
|
1177
|
+
var FlagResultJsonSchema = z2.object({
|
|
1178
|
+
status: z2.enum(["pass", "fail", "missing"]),
|
|
1179
|
+
present: z2.boolean(),
|
|
1180
|
+
/** True when this flag had no coverage files this run (not a 0% result). */
|
|
1181
|
+
skipped: z2.literal(true).optional(),
|
|
1182
|
+
reason: z2.string().optional(),
|
|
1183
|
+
patchCheck: z2.string(),
|
|
1184
|
+
projectCheck: z2.string(),
|
|
1185
|
+
patch: FlagMetricJsonSchema,
|
|
1186
|
+
project: FlagMetricJsonSchema
|
|
1187
|
+
});
|
|
1188
|
+
var FlagsJsonMapSchema = z2.record(FlagNameSchema, FlagResultJsonSchema);
|
|
591
1189
|
|
|
592
1190
|
// src/config.ts
|
|
593
1191
|
var DEFAULT_IGNORES = [
|
|
@@ -608,10 +1206,10 @@ var DEFAULT_IGNORES = [
|
|
|
608
1206
|
"stubs/**"
|
|
609
1207
|
];
|
|
610
1208
|
async function loadConfig(opts) {
|
|
611
|
-
const file =
|
|
1209
|
+
const file = join3(opts.cwd, ".tested.yaml");
|
|
612
1210
|
let raw = {};
|
|
613
1211
|
try {
|
|
614
|
-
const text = await
|
|
1212
|
+
const text = await readFile4(file, "utf8");
|
|
615
1213
|
raw = parseYaml(text) ?? {};
|
|
616
1214
|
} catch (err) {
|
|
617
1215
|
if (err.code !== "ENOENT") throw err;
|
|
@@ -621,13 +1219,129 @@ async function loadConfig(opts) {
|
|
|
621
1219
|
return { ...parsed, ignores: [...merged] };
|
|
622
1220
|
}
|
|
623
1221
|
|
|
1222
|
+
// src/core/coverage-paths.ts
|
|
1223
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1224
|
+
import { resolve as resolve3 } from "path";
|
|
1225
|
+
|
|
1226
|
+
// src/core/assert-within-root.ts
|
|
1227
|
+
import { resolve as resolve2, sep } from "path";
|
|
1228
|
+
function assertWithinRoot(root, resolvedPath) {
|
|
1229
|
+
const safeRoot = resolve2(root) + sep;
|
|
1230
|
+
const safePath = resolve2(resolvedPath);
|
|
1231
|
+
if (!safePath.startsWith(safeRoot)) {
|
|
1232
|
+
throw new Error(
|
|
1233
|
+
`Path traversal rejected: ${safePath} is outside repository root ${safeRoot}`
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// src/core/merge-coverage.ts
|
|
1239
|
+
function statementKey(stmt) {
|
|
1240
|
+
return `${stmt.startLine}:${stmt.endLine}:${stmt.id}`;
|
|
1241
|
+
}
|
|
1242
|
+
function sameRange(a, b) {
|
|
1243
|
+
return a.startLine === b.startLine && a.endLine === b.endLine;
|
|
1244
|
+
}
|
|
1245
|
+
function mergeStatements(left, right) {
|
|
1246
|
+
const out = /* @__PURE__ */ new Map();
|
|
1247
|
+
for (const stmt of left) {
|
|
1248
|
+
out.set(statementKey(stmt), { ...stmt });
|
|
1249
|
+
}
|
|
1250
|
+
for (const stmt of right) {
|
|
1251
|
+
const exact = statementKey(stmt);
|
|
1252
|
+
const existing = out.get(exact);
|
|
1253
|
+
if (existing) {
|
|
1254
|
+
existing.hits = Math.max(existing.hits, stmt.hits);
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
const rangeMatch = [...out.values()].find((prev) => sameRange(prev, stmt));
|
|
1258
|
+
if (rangeMatch) {
|
|
1259
|
+
rangeMatch.hits = Math.max(rangeMatch.hits, stmt.hits);
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
out.set(exact, { ...stmt });
|
|
1263
|
+
}
|
|
1264
|
+
return [...out.values()].sort(
|
|
1265
|
+
(a, b) => a.startLine - b.startLine || a.endLine - b.endLine || a.id.localeCompare(b.id)
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
function mergeFileCoverage(shards) {
|
|
1269
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
1270
|
+
for (const shard of shards) {
|
|
1271
|
+
for (const file of shard) {
|
|
1272
|
+
const existing = byPath.get(file.path);
|
|
1273
|
+
if (!existing) {
|
|
1274
|
+
byPath.set(file.path, {
|
|
1275
|
+
path: file.path,
|
|
1276
|
+
absPath: file.absPath,
|
|
1277
|
+
statements: file.statements.map((s) => ({ ...s }))
|
|
1278
|
+
});
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
byPath.set(file.path, {
|
|
1282
|
+
path: existing.path,
|
|
1283
|
+
absPath: existing.absPath,
|
|
1284
|
+
statements: mergeStatements(existing.statements, file.statements)
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// src/core/coverage-paths.ts
|
|
1292
|
+
function coveragePathList(path) {
|
|
1293
|
+
const list = Array.isArray(path) ? [...path] : [path];
|
|
1294
|
+
return list.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
1295
|
+
}
|
|
1296
|
+
function parseCoverageFileList(raw) {
|
|
1297
|
+
if (raw === void 0 || raw === null) return [];
|
|
1298
|
+
return raw.split(/[\n,]+/).map((p) => p.trim()).filter((p) => p.length > 0);
|
|
1299
|
+
}
|
|
1300
|
+
function collectCoverageFile(value, prev) {
|
|
1301
|
+
const next = value.trim();
|
|
1302
|
+
return next ? [...prev, next] : prev;
|
|
1303
|
+
}
|
|
1304
|
+
function resolveCoveragePaths(opts) {
|
|
1305
|
+
const fromFlag = (opts.files ?? []).map((p) => p.trim()).filter((p) => p.length > 0);
|
|
1306
|
+
if (fromFlag.length > 0) return fromFlag;
|
|
1307
|
+
const fromEnv = parseCoverageFileList(opts.env?.TESTED_COVERAGE_FILES);
|
|
1308
|
+
if (fromEnv.length > 0) return fromEnv;
|
|
1309
|
+
return coveragePathList(opts.configPath);
|
|
1310
|
+
}
|
|
1311
|
+
function existingCoveragePaths(paths, cwd, existsFn = existsSync2) {
|
|
1312
|
+
return paths.filter((rel) => {
|
|
1313
|
+
const abs = resolve3(cwd, rel);
|
|
1314
|
+
return existsFn(abs);
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
async function parseAndMergeCoverage(opts) {
|
|
1318
|
+
if (opts.paths.length === 0) {
|
|
1319
|
+
throw new Error(
|
|
1320
|
+
"coverage file missing. Run `tested run` first, or set coverage.path in .tested.yaml."
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
const shards = [];
|
|
1324
|
+
for (const rel of opts.paths) {
|
|
1325
|
+
const coveragePath = resolve3(opts.cwd, rel);
|
|
1326
|
+
assertWithinRoot(opts.repoRoot, coveragePath);
|
|
1327
|
+
shards.push(
|
|
1328
|
+
await parseCoverage({
|
|
1329
|
+
path: coveragePath,
|
|
1330
|
+
repoRoot: opts.repoRoot,
|
|
1331
|
+
...opts.format ? { format: opts.format } : {}
|
|
1332
|
+
})
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
return mergeFileCoverage(shards);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
624
1338
|
// src/commands/push.ts
|
|
625
|
-
import { existsSync as
|
|
626
|
-
import { join as
|
|
1339
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2, statSync } from "fs";
|
|
1340
|
+
import { join as join4 } from "path";
|
|
627
1341
|
import "commander";
|
|
628
1342
|
|
|
629
1343
|
// src/core/computeDiff.ts
|
|
630
|
-
import { resolve as
|
|
1344
|
+
import { resolve as resolve4 } from "path";
|
|
631
1345
|
|
|
632
1346
|
// src/git.ts
|
|
633
1347
|
import { simpleGit as simpleGit2 } from "simple-git";
|
|
@@ -740,50 +1454,6 @@ async function gitUserName(ctx) {
|
|
|
740
1454
|
}
|
|
741
1455
|
}
|
|
742
1456
|
|
|
743
|
-
// src/core/istanbul.ts
|
|
744
|
-
import { readFile as readFile2 } from "fs/promises";
|
|
745
|
-
import { isAbsolute, relative, resolve } from "path";
|
|
746
|
-
function isCoveragePathInsideRoot(repoRoot, entryPath) {
|
|
747
|
-
const root = resolve(repoRoot);
|
|
748
|
-
const absPath = resolve(entryPath);
|
|
749
|
-
const relPath = relative(root, absPath).split("\\").join("/");
|
|
750
|
-
if (!relPath || relPath === "") return true;
|
|
751
|
-
if (isAbsolute(relPath)) return false;
|
|
752
|
-
if (relPath === ".." || relPath.startsWith("../")) return false;
|
|
753
|
-
return true;
|
|
754
|
-
}
|
|
755
|
-
async function parseIstanbul(opts) {
|
|
756
|
-
let raw;
|
|
757
|
-
try {
|
|
758
|
-
raw = await readFile2(opts.path, "utf8");
|
|
759
|
-
} catch (err) {
|
|
760
|
-
if (err.code === "ENOENT") {
|
|
761
|
-
throw new Error(
|
|
762
|
-
`coverage-final.json not found at ${opts.path}. Run \`tested run\` first.`
|
|
763
|
-
);
|
|
764
|
-
}
|
|
765
|
-
throw err;
|
|
766
|
-
}
|
|
767
|
-
const data = JSON.parse(raw);
|
|
768
|
-
const root = resolve(opts.repoRoot);
|
|
769
|
-
const out = [];
|
|
770
|
-
for (const entry of Object.values(data)) {
|
|
771
|
-
if (!isCoveragePathInsideRoot(root, entry.path)) {
|
|
772
|
-
continue;
|
|
773
|
-
}
|
|
774
|
-
const absPath = resolve(entry.path);
|
|
775
|
-
const relPath = relative(root, absPath).split("\\").join("/");
|
|
776
|
-
const statements = Object.entries(entry.statementMap).map(([id, loc]) => ({
|
|
777
|
-
id,
|
|
778
|
-
startLine: loc.start.line,
|
|
779
|
-
endLine: loc.end.line,
|
|
780
|
-
hits: entry.s[id] ?? 0
|
|
781
|
-
}));
|
|
782
|
-
out.push({ path: relPath, absPath, statements });
|
|
783
|
-
}
|
|
784
|
-
return out;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
1457
|
// src/core/diff.ts
|
|
788
1458
|
var FILE_HEADER = /^diff --git a\/(.+?) b\/(.+?)$/;
|
|
789
1459
|
var HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
@@ -837,18 +1507,6 @@ function splitByIgnore(paths, patterns) {
|
|
|
837
1507
|
return { kept, ignored };
|
|
838
1508
|
}
|
|
839
1509
|
|
|
840
|
-
// src/core/assert-within-root.ts
|
|
841
|
-
import { resolve as resolve2, sep } from "path";
|
|
842
|
-
function assertWithinRoot(root, resolvedPath) {
|
|
843
|
-
const safeRoot = resolve2(root) + sep;
|
|
844
|
-
const safePath = resolve2(resolvedPath);
|
|
845
|
-
if (!safePath.startsWith(safeRoot)) {
|
|
846
|
-
throw new Error(
|
|
847
|
-
`Path traversal rejected: ${safePath} is outside repository root ${safeRoot}`
|
|
848
|
-
);
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
|
|
852
1510
|
// src/core/patch.ts
|
|
853
1511
|
var EMPTY_PATCH_REASON = "no executable lines in the patch";
|
|
854
1512
|
function isEmptyPatch(totals) {
|
|
@@ -981,7 +1639,7 @@ function buildDiffOutput(args) {
|
|
|
981
1639
|
}
|
|
982
1640
|
|
|
983
1641
|
// src/core/computeDiff.ts
|
|
984
|
-
async function
|
|
1642
|
+
async function computeDiffContext(opts) {
|
|
985
1643
|
const { cwd, config } = opts;
|
|
986
1644
|
const ctx = opts.ctx ?? await openRepo(cwd);
|
|
987
1645
|
const requested = assertSafeGitRef(opts.baseRef ?? config.base);
|
|
@@ -989,9 +1647,16 @@ async function computeDiff(opts) {
|
|
|
989
1647
|
const head = await headSha(ctx);
|
|
990
1648
|
const diffText = await unifiedDiff(ctx, base);
|
|
991
1649
|
const addedByFile = parseUnifiedDiff(diffText);
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
1650
|
+
const coveragePaths = resolveCoveragePaths({
|
|
1651
|
+
...opts.coveragePaths ? { files: opts.coveragePaths } : {},
|
|
1652
|
+
configPath: config.coverage.path
|
|
1653
|
+
});
|
|
1654
|
+
const allFiles = await parseAndMergeCoverage({
|
|
1655
|
+
paths: coveragePaths,
|
|
1656
|
+
cwd,
|
|
1657
|
+
repoRoot: ctx.repoRoot,
|
|
1658
|
+
...config.coverage.format ? { format: config.coverage.format } : {}
|
|
1659
|
+
});
|
|
995
1660
|
const { kept, ignored } = splitByIgnore(
|
|
996
1661
|
allFiles.map((f) => f.path),
|
|
997
1662
|
config.ignores
|
|
@@ -1000,11 +1665,12 @@ async function computeDiff(opts) {
|
|
|
1000
1665
|
const files = allFiles.filter((f) => keptSet.has(f.path));
|
|
1001
1666
|
let projectDelta = null;
|
|
1002
1667
|
if (opts.withBaseCoverage) {
|
|
1003
|
-
const baseCoveragePath =
|
|
1668
|
+
const baseCoveragePath = resolve4(cwd, opts.withBaseCoverage);
|
|
1004
1669
|
assertWithinRoot(ctx.repoRoot, baseCoveragePath);
|
|
1005
|
-
const baseFiles = await
|
|
1670
|
+
const baseFiles = await parseCoverage({
|
|
1006
1671
|
path: baseCoveragePath,
|
|
1007
|
-
repoRoot: ctx.repoRoot
|
|
1672
|
+
repoRoot: ctx.repoRoot,
|
|
1673
|
+
...config.coverage.format ? { format: config.coverage.format } : {}
|
|
1008
1674
|
});
|
|
1009
1675
|
const baseKept = baseFiles.filter((f) => !ignored.includes(f.path));
|
|
1010
1676
|
const baseExec = baseKept.reduce((n, f) => n + f.statements.length, 0);
|
|
@@ -1023,7 +1689,7 @@ async function computeDiff(opts) {
|
|
|
1023
1689
|
})();
|
|
1024
1690
|
projectDelta = Math.round((headPct - basePct) * 10) / 10;
|
|
1025
1691
|
}
|
|
1026
|
-
|
|
1692
|
+
const diff = buildDiffOutput({
|
|
1027
1693
|
base: baseRef,
|
|
1028
1694
|
head,
|
|
1029
1695
|
files,
|
|
@@ -1031,6 +1697,224 @@ async function computeDiff(opts) {
|
|
|
1031
1697
|
ignored,
|
|
1032
1698
|
projectDelta
|
|
1033
1699
|
});
|
|
1700
|
+
return { diff, files, addedByFile };
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// src/core/flags.ts
|
|
1704
|
+
import { minimatch as minimatch2 } from "minimatch";
|
|
1705
|
+
var MISSING_FLAG_REASON = "no coverage files matched this flag in this run";
|
|
1706
|
+
var SCOPED_MISSING_FLAG_REASON = "no coverage files in this run for this flag";
|
|
1707
|
+
var MATCH_OPTS = { dot: true, matchBase: true };
|
|
1708
|
+
function pathMatchesFlag(filePath, patterns) {
|
|
1709
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
1710
|
+
return patterns.some(
|
|
1711
|
+
(p) => minimatch2(normalized, p, MATCH_OPTS) || minimatch2(normalized, `**/${p}`, MATCH_OPTS)
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
function filterFilesByFlag(files, patterns) {
|
|
1715
|
+
return files.filter((f) => pathMatchesFlag(f.path, patterns));
|
|
1716
|
+
}
|
|
1717
|
+
function resolveFlagThresholds(flag, global) {
|
|
1718
|
+
return {
|
|
1719
|
+
patch: flag.thresholds?.patch ?? global.patch,
|
|
1720
|
+
project: flag.thresholds?.project ?? global.project
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
function unknownFlagError(name, configured) {
|
|
1724
|
+
const list = configured.length > 0 ? configured.join(", ") : "(none)";
|
|
1725
|
+
return new Error(`unknown flag "${name}" \u2014 configured flags: ${list}`);
|
|
1726
|
+
}
|
|
1727
|
+
function totalsToMetric(totals, threshold, kind) {
|
|
1728
|
+
const empty = isEmptyPatch(totals);
|
|
1729
|
+
if (kind === "patch" && empty) {
|
|
1730
|
+
return {
|
|
1731
|
+
pct: totals.pct,
|
|
1732
|
+
threshold,
|
|
1733
|
+
pass: true,
|
|
1734
|
+
executable: totals.executable,
|
|
1735
|
+
covered: totals.covered,
|
|
1736
|
+
skipped: true,
|
|
1737
|
+
reason: EMPTY_PATCH_REASON
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
const pass = totals.pct >= threshold;
|
|
1741
|
+
return {
|
|
1742
|
+
pct: totals.pct,
|
|
1743
|
+
threshold,
|
|
1744
|
+
pass,
|
|
1745
|
+
executable: totals.executable,
|
|
1746
|
+
covered: totals.covered
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
function missingMetric(threshold, reason) {
|
|
1750
|
+
return {
|
|
1751
|
+
threshold,
|
|
1752
|
+
skipped: true,
|
|
1753
|
+
reason
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
function checkSlug(kind, name) {
|
|
1757
|
+
return `tested.dev / ${kind} / ${name}`;
|
|
1758
|
+
}
|
|
1759
|
+
function evaluatePresentFlag(name, files, addedByFile, thresholds) {
|
|
1760
|
+
const patch = computePatchCoverage(files, addedByFile);
|
|
1761
|
+
const project = computeProjectCoverage(files);
|
|
1762
|
+
const patchMetric = totalsToMetric(patch.totals, thresholds.patch, "patch");
|
|
1763
|
+
const projectMetric = totalsToMetric(project.totals, thresholds.project, "project");
|
|
1764
|
+
const status = patchMetric.pass && projectMetric.pass ? "pass" : "fail";
|
|
1765
|
+
return {
|
|
1766
|
+
name,
|
|
1767
|
+
present: true,
|
|
1768
|
+
status,
|
|
1769
|
+
patchCheck: checkSlug("patch", name),
|
|
1770
|
+
projectCheck: checkSlug("project", name),
|
|
1771
|
+
patch: patchMetric,
|
|
1772
|
+
project: projectMetric
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
function missingFlag(name, thresholds, reason) {
|
|
1776
|
+
return {
|
|
1777
|
+
name,
|
|
1778
|
+
present: false,
|
|
1779
|
+
status: "missing",
|
|
1780
|
+
reason,
|
|
1781
|
+
patchCheck: checkSlug("patch", name),
|
|
1782
|
+
projectCheck: checkSlug("project", name),
|
|
1783
|
+
patch: missingMetric(thresholds.patch, reason),
|
|
1784
|
+
project: missingMetric(thresholds.project, reason)
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
function evaluateFlags(input) {
|
|
1788
|
+
const flags = input.config.flags;
|
|
1789
|
+
const global = input.config.thresholds;
|
|
1790
|
+
if (!flags || !global) return [];
|
|
1791
|
+
const names = Object.keys(flags);
|
|
1792
|
+
if (input.onlyFlag !== void 0 && input.onlyFlag !== "") {
|
|
1793
|
+
const def = flags[input.onlyFlag];
|
|
1794
|
+
if (!def) throw unknownFlagError(input.onlyFlag, names);
|
|
1795
|
+
const thresholds = resolveFlagThresholds(def, global);
|
|
1796
|
+
if (input.files.length === 0) {
|
|
1797
|
+
return [missingFlag(input.onlyFlag, thresholds, SCOPED_MISSING_FLAG_REASON)];
|
|
1798
|
+
}
|
|
1799
|
+
return [evaluatePresentFlag(input.onlyFlag, input.files, input.addedByFile, thresholds)];
|
|
1800
|
+
}
|
|
1801
|
+
const results = [];
|
|
1802
|
+
for (const name of names) {
|
|
1803
|
+
const def = flags[name];
|
|
1804
|
+
const thresholds = resolveFlagThresholds(def, global);
|
|
1805
|
+
const matched = filterFilesByFlag(input.files, def.paths);
|
|
1806
|
+
if (matched.length === 0) {
|
|
1807
|
+
results.push(missingFlag(name, thresholds, MISSING_FLAG_REASON));
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
results.push(evaluatePresentFlag(name, matched, input.addedByFile, thresholds));
|
|
1811
|
+
}
|
|
1812
|
+
return results;
|
|
1813
|
+
}
|
|
1814
|
+
function flagsPass(results) {
|
|
1815
|
+
return results.every((f) => f.status !== "fail");
|
|
1816
|
+
}
|
|
1817
|
+
function flagsToJson(results) {
|
|
1818
|
+
const out = {};
|
|
1819
|
+
for (const flag of results) {
|
|
1820
|
+
out[flag.name] = {
|
|
1821
|
+
status: flag.status,
|
|
1822
|
+
present: flag.present,
|
|
1823
|
+
...flag.status === "missing" ? { skipped: true } : {},
|
|
1824
|
+
...flag.reason ? { reason: flag.reason } : {},
|
|
1825
|
+
patchCheck: flag.patchCheck,
|
|
1826
|
+
projectCheck: flag.projectCheck,
|
|
1827
|
+
patch: metricToJson(flag.patch),
|
|
1828
|
+
project: metricToJson(flag.project)
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
return out;
|
|
1832
|
+
}
|
|
1833
|
+
function metricToJson(metric) {
|
|
1834
|
+
if (metric.skipped) {
|
|
1835
|
+
return {
|
|
1836
|
+
threshold: metric.threshold,
|
|
1837
|
+
skipped: true,
|
|
1838
|
+
...metric.reason ? { reason: metric.reason } : {}
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
return {
|
|
1842
|
+
pct: metric.pct ?? 0,
|
|
1843
|
+
threshold: metric.threshold,
|
|
1844
|
+
pass: metric.pass ?? false,
|
|
1845
|
+
executable: metric.executable ?? 0,
|
|
1846
|
+
covered: metric.covered ?? 0,
|
|
1847
|
+
...metric.reason ? { reason: metric.reason } : {}
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
function flagsToIngestJson(results) {
|
|
1851
|
+
const present = results.filter((f) => f.present);
|
|
1852
|
+
return present.length > 0 ? flagsToJson(present) : void 0;
|
|
1853
|
+
}
|
|
1854
|
+
function resolveFlagsJson(input) {
|
|
1855
|
+
const results = evaluateFlags(input);
|
|
1856
|
+
return results.length > 0 ? flagsToJson(results) : void 0;
|
|
1857
|
+
}
|
|
1858
|
+
function resolveIngestFlagsJson(input) {
|
|
1859
|
+
return flagsToIngestJson(evaluateFlags(input));
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// src/core/coverage-merge.ts
|
|
1863
|
+
function emptyToUndef(value) {
|
|
1864
|
+
const trimmed = value?.trim();
|
|
1865
|
+
return trimmed ? trimmed : void 0;
|
|
1866
|
+
}
|
|
1867
|
+
function parsePositiveInt(raw, label) {
|
|
1868
|
+
const trimmed = emptyToUndef(raw);
|
|
1869
|
+
if (trimmed === void 0) return void 0;
|
|
1870
|
+
const n = Number(trimmed);
|
|
1871
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
1872
|
+
throw new Error(`invalid ${label} "${raw}" \u2014 expected a positive integer`);
|
|
1873
|
+
}
|
|
1874
|
+
return n;
|
|
1875
|
+
}
|
|
1876
|
+
function resolveCoverageMerge(cli, env = process.env) {
|
|
1877
|
+
if (cli.complete && cli.incomplete) {
|
|
1878
|
+
throw new Error("cannot pass both --complete and --incomplete");
|
|
1879
|
+
}
|
|
1880
|
+
const totalParts = parsePositiveInt(cli.parts ?? env.TESTED_PARTS, "--parts");
|
|
1881
|
+
const part = parsePositiveInt(cli.part ?? env.TESTED_PART, "--part");
|
|
1882
|
+
const runId = emptyToUndef(cli.runId ?? env.TESTED_RUN_ID);
|
|
1883
|
+
const shard = emptyToUndef(cli.shard ?? env.TESTED_SHARD);
|
|
1884
|
+
if (part !== void 0 && totalParts !== void 0 && part > totalParts) {
|
|
1885
|
+
throw new Error(`--part ${part} is greater than --parts ${totalParts}`);
|
|
1886
|
+
}
|
|
1887
|
+
let complete;
|
|
1888
|
+
if (cli.complete) {
|
|
1889
|
+
complete = true;
|
|
1890
|
+
} else if (cli.incomplete) {
|
|
1891
|
+
complete = false;
|
|
1892
|
+
} else if (totalParts !== void 0) {
|
|
1893
|
+
complete = part !== void 0 && part === totalParts;
|
|
1894
|
+
} else {
|
|
1895
|
+
complete = true;
|
|
1896
|
+
}
|
|
1897
|
+
const state = { complete };
|
|
1898
|
+
if (totalParts !== void 0) state.totalParts = totalParts;
|
|
1899
|
+
if (part !== void 0) state.part = part;
|
|
1900
|
+
if (runId !== void 0) state.runId = runId;
|
|
1901
|
+
if (shard !== void 0) state.shard = shard;
|
|
1902
|
+
return state;
|
|
1903
|
+
}
|
|
1904
|
+
function toCoverageMergePayload(state) {
|
|
1905
|
+
const payload = { complete: state.complete };
|
|
1906
|
+
if (state.totalParts !== void 0) payload.totalParts = state.totalParts;
|
|
1907
|
+
if (state.part !== void 0) payload.part = state.part;
|
|
1908
|
+
if (state.runId !== void 0) payload.runId = state.runId;
|
|
1909
|
+
if (state.shard !== void 0) payload.shard = state.shard;
|
|
1910
|
+
return payload;
|
|
1911
|
+
}
|
|
1912
|
+
function hasShardMetadata(state) {
|
|
1913
|
+
return !state.complete || state.totalParts !== void 0 || state.part !== void 0 || state.runId !== void 0 || state.shard !== void 0;
|
|
1914
|
+
}
|
|
1915
|
+
function formatIncompleteGateMessage(state) {
|
|
1916
|
+
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";
|
|
1917
|
+
return `coverage shard is incomplete (${part}). The patch/project gate runs only on --complete or the last part.`;
|
|
1034
1918
|
}
|
|
1035
1919
|
|
|
1036
1920
|
// src/commands/push.ts
|
|
@@ -1056,10 +1940,10 @@ function parseGitHubRepository(repo) {
|
|
|
1056
1940
|
var tokenArgvWarned = false;
|
|
1057
1941
|
function readTokenFile(filePath, opts) {
|
|
1058
1942
|
const read = opts?.readFileSyncFn ?? readFileSync2;
|
|
1059
|
-
const
|
|
1943
|
+
const stat3 = opts?.statSyncFn ?? statSync;
|
|
1060
1944
|
let mode;
|
|
1061
1945
|
try {
|
|
1062
|
-
const st =
|
|
1946
|
+
const st = stat3(filePath);
|
|
1063
1947
|
mode = st.mode;
|
|
1064
1948
|
} catch (err) {
|
|
1065
1949
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1331,8 +2215,10 @@ function buildIngestBody(input) {
|
|
|
1331
2215
|
state: "open"
|
|
1332
2216
|
},
|
|
1333
2217
|
runUrl: input.runUrl,
|
|
1334
|
-
diff: input.diff,
|
|
1335
|
-
...input.testReport ? { testReport: input.testReport } : {}
|
|
2218
|
+
...input.diff ? { diff: input.diff } : {},
|
|
2219
|
+
...input.testReport ? { testReport: input.testReport } : {},
|
|
2220
|
+
...input.coverageMerge ? { coverageMerge: input.coverageMerge } : {},
|
|
2221
|
+
...input.flags ? { flags: input.flags } : {}
|
|
1336
2222
|
};
|
|
1337
2223
|
}
|
|
1338
2224
|
function buildMainlineIngestBody(input) {
|
|
@@ -1343,11 +2229,13 @@ function buildMainlineIngestBody(input) {
|
|
|
1343
2229
|
defaultBranch: input.defaultBranch
|
|
1344
2230
|
},
|
|
1345
2231
|
runUrl: input.runUrl,
|
|
1346
|
-
diff: input.diff,
|
|
2232
|
+
...input.diff ? { diff: input.diff } : {},
|
|
1347
2233
|
ref: input.ref,
|
|
1348
2234
|
isDefaultBranch: true,
|
|
1349
2235
|
headSha: input.headSha,
|
|
1350
|
-
...input.testReport ? { testReport: input.testReport } : {}
|
|
2236
|
+
...input.testReport ? { testReport: input.testReport } : {},
|
|
2237
|
+
...input.coverageMerge ? { coverageMerge: input.coverageMerge } : {},
|
|
2238
|
+
...input.flags ? { flags: input.flags } : {}
|
|
1351
2239
|
};
|
|
1352
2240
|
}
|
|
1353
2241
|
var DEFAULT_JUNIT_CANDIDATES = [
|
|
@@ -1358,10 +2246,10 @@ var DEFAULT_JUNIT_CANDIDATES = [
|
|
|
1358
2246
|
];
|
|
1359
2247
|
function resolveJunitPath(opts) {
|
|
1360
2248
|
const env = opts.env ?? process.env;
|
|
1361
|
-
const exists = opts.existsSyncFn ??
|
|
2249
|
+
const exists = opts.existsSyncFn ?? existsSync3;
|
|
1362
2250
|
if (opts.flag && opts.flag.trim()) {
|
|
1363
2251
|
const p = opts.flag.trim();
|
|
1364
|
-
const abs = p.startsWith("/") ? p :
|
|
2252
|
+
const abs = p.startsWith("/") ? p : join4(opts.cwd, p);
|
|
1365
2253
|
if (!exists(abs)) {
|
|
1366
2254
|
throw new Error(`JUnit file not found: ${p}`);
|
|
1367
2255
|
}
|
|
@@ -1369,14 +2257,14 @@ function resolveJunitPath(opts) {
|
|
|
1369
2257
|
}
|
|
1370
2258
|
const fromEnv = env.TESTED_JUNIT?.trim();
|
|
1371
2259
|
if (fromEnv) {
|
|
1372
|
-
const abs = fromEnv.startsWith("/") ? fromEnv :
|
|
2260
|
+
const abs = fromEnv.startsWith("/") ? fromEnv : join4(opts.cwd, fromEnv);
|
|
1373
2261
|
if (!exists(abs)) {
|
|
1374
2262
|
throw new Error(`TESTED_JUNIT file not found: ${fromEnv}`);
|
|
1375
2263
|
}
|
|
1376
2264
|
return abs;
|
|
1377
2265
|
}
|
|
1378
2266
|
for (const rel of DEFAULT_JUNIT_CANDIDATES) {
|
|
1379
|
-
const abs =
|
|
2267
|
+
const abs = join4(opts.cwd, rel);
|
|
1380
2268
|
if (exists(abs)) return abs;
|
|
1381
2269
|
}
|
|
1382
2270
|
return null;
|
|
@@ -1422,15 +2310,12 @@ async function postIngest(opts) {
|
|
|
1422
2310
|
parsed = null;
|
|
1423
2311
|
}
|
|
1424
2312
|
}
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
message: "ingest succeeded but response was empty"
|
|
1432
|
-
};
|
|
1433
|
-
}
|
|
2313
|
+
const concludesGate = opts.body.coverageMerge?.complete !== false;
|
|
2314
|
+
const handshakeOnly = concludesGate && !opts.body.diff;
|
|
2315
|
+
const acceptIncomplete = opts.body.coverageMerge?.complete === false;
|
|
2316
|
+
const successStatus = res.status === 200 || acceptIncomplete && res.status === 202;
|
|
2317
|
+
if (successStatus) {
|
|
2318
|
+
const data = parsed ?? {};
|
|
1434
2319
|
if (data.mainline === true) {
|
|
1435
2320
|
return {
|
|
1436
2321
|
ok: true,
|
|
@@ -1438,11 +2323,20 @@ async function postIngest(opts) {
|
|
|
1438
2323
|
data: {
|
|
1439
2324
|
mainline: true,
|
|
1440
2325
|
...typeof data.date === "string" ? { date: data.date } : {},
|
|
1441
|
-
...typeof data.projectPct === "number" ? { projectPct: data.projectPct } : {}
|
|
2326
|
+
...typeof data.projectPct === "number" ? { projectPct: data.projectPct } : {},
|
|
2327
|
+
...acceptIncomplete ? { complete: false } : {}
|
|
1442
2328
|
}
|
|
1443
2329
|
};
|
|
1444
2330
|
}
|
|
1445
|
-
|
|
2331
|
+
const shareUrl2 = typeof data.shareUrl === "string" && data.shareUrl ? data.shareUrl : void 0;
|
|
2332
|
+
if (concludesGate && !handshakeOnly && !acceptIncomplete && !shareUrl2) {
|
|
2333
|
+
if (parsed === null) {
|
|
2334
|
+
return {
|
|
2335
|
+
ok: false,
|
|
2336
|
+
status: res.status,
|
|
2337
|
+
message: "ingest succeeded but response was empty"
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
1446
2340
|
return {
|
|
1447
2341
|
ok: false,
|
|
1448
2342
|
status: res.status,
|
|
@@ -1453,8 +2347,9 @@ async function postIngest(opts) {
|
|
|
1453
2347
|
ok: true,
|
|
1454
2348
|
status: res.status,
|
|
1455
2349
|
data: {
|
|
1456
|
-
shareUrl:
|
|
1457
|
-
...typeof data.expiresAt === "string" ? { expiresAt: data.expiresAt } : {}
|
|
2350
|
+
...shareUrl2 ? { shareUrl: shareUrl2 } : {},
|
|
2351
|
+
...typeof data.expiresAt === "string" ? { expiresAt: data.expiresAt } : {},
|
|
2352
|
+
...acceptIncomplete || handshakeOnly ? { complete: concludesGate } : {}
|
|
1458
2353
|
}
|
|
1459
2354
|
};
|
|
1460
2355
|
}
|
|
@@ -1471,7 +2366,8 @@ async function postIngest(opts) {
|
|
|
1471
2366
|
}
|
|
1472
2367
|
return { ok: false, status: res.status, message, ...code ? { code } : {} };
|
|
1473
2368
|
}
|
|
1474
|
-
function formatPushSuccess(data, json) {
|
|
2369
|
+
function formatPushSuccess(data, json, merge) {
|
|
2370
|
+
const incomplete = merge ? !merge.complete : data.complete === false;
|
|
1475
2371
|
if (json) {
|
|
1476
2372
|
const payload = {};
|
|
1477
2373
|
if (data.shareUrl) payload.shareUrl = data.shareUrl;
|
|
@@ -1479,9 +2375,23 @@ function formatPushSuccess(data, json) {
|
|
|
1479
2375
|
if (data.mainline) payload.mainline = true;
|
|
1480
2376
|
if (data.date) payload.date = data.date;
|
|
1481
2377
|
if (typeof data.projectPct === "number") payload.projectPct = data.projectPct;
|
|
2378
|
+
if (merge && hasShardMetadata(merge)) {
|
|
2379
|
+
payload.complete = merge.complete;
|
|
2380
|
+
if (merge.part !== void 0) payload.part = merge.part;
|
|
2381
|
+
if (merge.totalParts !== void 0) payload.totalParts = merge.totalParts;
|
|
2382
|
+
} else if (incomplete) {
|
|
2383
|
+
payload.complete = false;
|
|
2384
|
+
}
|
|
1482
2385
|
return { stdout: JSON.stringify(payload) + "\n", stderr: "" };
|
|
1483
2386
|
}
|
|
1484
2387
|
const lines = [];
|
|
2388
|
+
if (incomplete && merge) {
|
|
2389
|
+
lines.push(successLine(`uploaded shard (${formatIncompleteGateMessage(merge)})`));
|
|
2390
|
+
if (data.shareUrl) {
|
|
2391
|
+
lines.push(dim(` ${shareUrl(data.shareUrl)} (pending \u2014 not a gate result)`));
|
|
2392
|
+
}
|
|
2393
|
+
return { stdout: lines.join("\n") + "\n", stderr: "" };
|
|
2394
|
+
}
|
|
1485
2395
|
if (data.mainline) {
|
|
1486
2396
|
lines.push(
|
|
1487
2397
|
successLine(
|
|
@@ -1537,7 +2447,8 @@ function formatPushError(status, message, code, identity) {
|
|
|
1537
2447
|
}
|
|
1538
2448
|
async function executePush(cli, deps) {
|
|
1539
2449
|
const env = deps.env ?? process.env;
|
|
1540
|
-
const computeDiffFn = deps.computeDiffFn
|
|
2450
|
+
const computeDiffFn = deps.computeDiffFn;
|
|
2451
|
+
const computeDiffContextFn = deps.computeDiffContextFn ?? computeDiffContext;
|
|
1541
2452
|
const fetchFn = deps.fetchFn ?? globalThis.fetch;
|
|
1542
2453
|
const openRepoFn = deps.openRepoFn ?? openRepo;
|
|
1543
2454
|
const loadConfigFn = deps.loadConfigFn ?? loadConfig;
|
|
@@ -1601,6 +2512,14 @@ async function executePush(cli, deps) {
|
|
|
1601
2512
|
const message = err instanceof Error ? err.message : String(err);
|
|
1602
2513
|
return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
|
|
1603
2514
|
}
|
|
2515
|
+
let merge;
|
|
2516
|
+
try {
|
|
2517
|
+
merge = resolveCoverageMerge(cli, env);
|
|
2518
|
+
} catch (err) {
|
|
2519
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2520
|
+
return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
|
|
2521
|
+
}
|
|
2522
|
+
const coverageMerge = toCoverageMergePayload(merge);
|
|
1604
2523
|
const config = await loadConfigFn({ cwd: deps.cwd });
|
|
1605
2524
|
const ctx = await openRepoFn(deps.cwd);
|
|
1606
2525
|
const peeked = await peekRepoIdentity({
|
|
@@ -1609,32 +2528,68 @@ async function executePush(cli, deps) {
|
|
|
1609
2528
|
env,
|
|
1610
2529
|
ctx
|
|
1611
2530
|
});
|
|
2531
|
+
const coveragePaths = resolveCoveragePaths({
|
|
2532
|
+
...cli.file && cli.file.length > 0 ? { files: cli.file } : {},
|
|
2533
|
+
env,
|
|
2534
|
+
configPath: config.coverage.path
|
|
2535
|
+
});
|
|
2536
|
+
const existingCoverage = existingCoveragePaths(coveragePaths, deps.cwd);
|
|
2537
|
+
const handshakeOnly = merge.complete && existingCoverage.length === 0 && (cli.complete === true || merge.totalParts !== void 0);
|
|
1612
2538
|
let diff;
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
2539
|
+
let files = [];
|
|
2540
|
+
let addedByFile = /* @__PURE__ */ new Map();
|
|
2541
|
+
if (!handshakeOnly) {
|
|
2542
|
+
try {
|
|
2543
|
+
let baseOverride = cli.base;
|
|
2544
|
+
if (baseOverride === void 0 && prNumber !== null) {
|
|
2545
|
+
const resolved = await resolvePrPushBase({
|
|
2546
|
+
ctx,
|
|
2547
|
+
requested: config.base,
|
|
2548
|
+
prNumber,
|
|
2549
|
+
...peeked.owner != null ? { owner: peeked.owner } : {},
|
|
2550
|
+
...peeked.name != null ? { name: peeked.name } : {},
|
|
2551
|
+
fetchFn,
|
|
2552
|
+
env,
|
|
2553
|
+
onProgress
|
|
2554
|
+
});
|
|
2555
|
+
if (resolved !== void 0) baseOverride = resolved;
|
|
2556
|
+
}
|
|
2557
|
+
onProgress("computing diff\u2026");
|
|
2558
|
+
const diffOpts = {
|
|
2559
|
+
cwd: deps.cwd,
|
|
2560
|
+
config,
|
|
2561
|
+
...baseOverride !== void 0 ? { baseRef: baseOverride } : {},
|
|
1617
2562
|
ctx,
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
2563
|
+
...coveragePaths.length > 0 ? { coveragePaths } : {}
|
|
2564
|
+
};
|
|
2565
|
+
if (computeDiffFn) {
|
|
2566
|
+
diff = await computeDiffFn(diffOpts);
|
|
2567
|
+
} else {
|
|
2568
|
+
const computed = await computeDiffContextFn(diffOpts);
|
|
2569
|
+
diff = computed.diff;
|
|
2570
|
+
files = computed.files;
|
|
2571
|
+
addedByFile = computed.addedByFile;
|
|
2572
|
+
}
|
|
2573
|
+
} catch (err) {
|
|
2574
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2575
|
+
return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
|
|
2576
|
+
}
|
|
2577
|
+
} else {
|
|
2578
|
+
onProgress("complete handshake (no local coverage)\u2026");
|
|
2579
|
+
}
|
|
2580
|
+
let flags;
|
|
2581
|
+
if (!handshakeOnly && !computeDiffFn) {
|
|
2582
|
+
try {
|
|
2583
|
+
flags = resolveIngestFlagsJson({
|
|
2584
|
+
config,
|
|
2585
|
+
files,
|
|
2586
|
+
addedByFile,
|
|
2587
|
+
...cli.flag && cli.flag.trim() ? { onlyFlag: cli.flag.trim() } : {}
|
|
1625
2588
|
});
|
|
1626
|
-
|
|
2589
|
+
} catch (err) {
|
|
2590
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2591
|
+
return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
|
|
1627
2592
|
}
|
|
1628
|
-
onProgress("computing diff\u2026");
|
|
1629
|
-
diff = await computeDiffFn({
|
|
1630
|
-
cwd: deps.cwd,
|
|
1631
|
-
config,
|
|
1632
|
-
...baseOverride !== void 0 ? { baseRef: baseOverride } : {},
|
|
1633
|
-
ctx
|
|
1634
|
-
});
|
|
1635
|
-
} catch (err) {
|
|
1636
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1637
|
-
return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
|
|
1638
2593
|
}
|
|
1639
2594
|
let owner = cli.owner ?? peeked.owner ?? void 0;
|
|
1640
2595
|
let name = cli.name ?? peeked.name ?? void 0;
|
|
@@ -1690,13 +2645,19 @@ async function executePush(cli, deps) {
|
|
|
1690
2645
|
env
|
|
1691
2646
|
});
|
|
1692
2647
|
if (junitPath) {
|
|
1693
|
-
onProgress(
|
|
2648
|
+
onProgress(`parsing JUnit (${junitPath})\u2026`);
|
|
1694
2649
|
testReport = loadTestReportFromJunit(junitPath);
|
|
1695
2650
|
}
|
|
1696
2651
|
} catch (err) {
|
|
1697
2652
|
const message = err instanceof Error ? err.message : String(err);
|
|
1698
2653
|
return { exitCode: 1, stdout: "", stderr: errorBlock(message) };
|
|
1699
2654
|
}
|
|
2655
|
+
const mergeFields = {
|
|
2656
|
+
coverageMerge,
|
|
2657
|
+
...diff ? { diff } : {},
|
|
2658
|
+
...testReport ? { testReport } : {},
|
|
2659
|
+
...flags ? { flags } : {}
|
|
2660
|
+
};
|
|
1700
2661
|
const body = mainline ? buildMainlineIngestBody({
|
|
1701
2662
|
owner,
|
|
1702
2663
|
name,
|
|
@@ -1704,8 +2665,7 @@ async function executePush(cli, deps) {
|
|
|
1704
2665
|
headSha: sha,
|
|
1705
2666
|
ref: `refs/heads/${baseRef}`,
|
|
1706
2667
|
runUrl: cli.runUrl ?? null,
|
|
1707
|
-
|
|
1708
|
-
...testReport ? { testReport } : {}
|
|
2668
|
+
...mergeFields
|
|
1709
2669
|
}) : buildIngestBody({
|
|
1710
2670
|
owner,
|
|
1711
2671
|
name,
|
|
@@ -1716,10 +2676,11 @@ async function executePush(cli, deps) {
|
|
|
1716
2676
|
headRef,
|
|
1717
2677
|
headSha: sha,
|
|
1718
2678
|
runUrl: cli.runUrl ?? null,
|
|
1719
|
-
|
|
1720
|
-
...testReport ? { testReport } : {}
|
|
2679
|
+
...mergeFields
|
|
1721
2680
|
});
|
|
1722
|
-
onProgress(
|
|
2681
|
+
onProgress(
|
|
2682
|
+
!merge.complete ? "uploading shard (incomplete)\u2026" : mainline ? "uploading mainline coverage\u2026" : handshakeOnly ? "sending complete handshake\u2026" : "uploading\u2026"
|
|
2683
|
+
);
|
|
1723
2684
|
const result = await postIngest({ apiBase, token, body, fetchFn });
|
|
1724
2685
|
if (!result.ok) {
|
|
1725
2686
|
return {
|
|
@@ -1731,11 +2692,12 @@ async function executePush(cli, deps) {
|
|
|
1731
2692
|
})
|
|
1732
2693
|
};
|
|
1733
2694
|
}
|
|
1734
|
-
const formatted = formatPushSuccess(result.data, cli.json);
|
|
2695
|
+
const formatted = formatPushSuccess(result.data, cli.json, merge);
|
|
1735
2696
|
return {
|
|
1736
2697
|
exitCode: 0,
|
|
1737
2698
|
stdout: formatted.stdout,
|
|
1738
2699
|
stderr: formatted.stderr,
|
|
2700
|
+
complete: merge.complete,
|
|
1739
2701
|
...result.data.shareUrl !== void 0 ? { shareUrl: result.data.shareUrl } : {},
|
|
1740
2702
|
...result.data.expiresAt !== void 0 ? { expiresAt: result.data.expiresAt } : {}
|
|
1741
2703
|
};
|
|
@@ -1759,7 +2721,15 @@ function registerPushCommand(program2) {
|
|
|
1759
2721
|
"Base branch name sent to the API (default: .tested.yaml base or main)"
|
|
1760
2722
|
).option("--head-ref <ref>", "Head branch name (default: current branch)").option("--run-url <url>", "Optional CI run URL attached to the ingest").option("--base <ref>", "Git base ref to diff against (same as `tested diff --base`)").option(
|
|
1761
2723
|
"--junit <path>",
|
|
1762
|
-
"JUnit XML for test analytics (flakes / slowest). Also TESTED_JUNIT or junit.xml"
|
|
2724
|
+
"JUnit XML for test analytics (flakes / slowest). Also TESTED_JUNIT or auto-detect junit.xml / test-results/junit.xml / coverage/junit.xml"
|
|
2725
|
+
).option(
|
|
2726
|
+
"--file <path>",
|
|
2727
|
+
"Coverage file to merge (repeatable). Overrides coverage.path.",
|
|
2728
|
+
collectCoverageFile,
|
|
2729
|
+
[]
|
|
2730
|
+
).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(
|
|
2731
|
+
"--flag <name>",
|
|
2732
|
+
"This coverage file is the named flag (job already scoped \u2014 omit other packages)"
|
|
1763
2733
|
).option("--json", "Emit machine-readable JSON instead of the share URL only", false).action(async (opts) => {
|
|
1764
2734
|
try {
|
|
1765
2735
|
const result = await executePush(opts, { cwd: process.cwd() });
|
|
@@ -1833,7 +2803,7 @@ function isReadableFile(path, exists) {
|
|
|
1833
2803
|
async function runDoctor(deps) {
|
|
1834
2804
|
const cwd = deps.cwd;
|
|
1835
2805
|
const env = deps.env ?? process.env;
|
|
1836
|
-
const exists = deps.existsSyncFn ??
|
|
2806
|
+
const exists = deps.existsSyncFn ?? existsSync4;
|
|
1837
2807
|
const gitFactory = deps.gitFactory ?? simpleGit3;
|
|
1838
2808
|
const loadConfigFn = deps.loadConfigFn ?? loadConfig;
|
|
1839
2809
|
const resolveTokenFn = deps.resolveTokenFn ?? resolveToken;
|
|
@@ -1880,7 +2850,7 @@ async function runDoctor(deps) {
|
|
|
1880
2850
|
detail: "not a git repository \u2014 run from a repo root"
|
|
1881
2851
|
});
|
|
1882
2852
|
}
|
|
1883
|
-
const configPath =
|
|
2853
|
+
const configPath = join5(cwd, ".tested.yaml");
|
|
1884
2854
|
const hasConfig = exists(configPath);
|
|
1885
2855
|
if (hasConfig) {
|
|
1886
2856
|
checks.push({
|
|
@@ -1897,19 +2867,21 @@ async function runDoctor(deps) {
|
|
|
1897
2867
|
detail: "missing \u2014 run: tested setup (or tested init)"
|
|
1898
2868
|
});
|
|
1899
2869
|
}
|
|
1900
|
-
let
|
|
2870
|
+
let coverageRels = ["coverage/coverage-final.json"];
|
|
1901
2871
|
if (hasConfig) {
|
|
1902
2872
|
try {
|
|
1903
2873
|
const config = await loadConfigFn({ cwd });
|
|
1904
|
-
|
|
2874
|
+
const listed = coveragePathList(config.coverage.path);
|
|
2875
|
+
if (listed.length > 0) coverageRels = listed;
|
|
1905
2876
|
} catch {
|
|
1906
2877
|
}
|
|
1907
2878
|
}
|
|
1908
|
-
const
|
|
1909
|
-
|
|
2879
|
+
const coverageRel = coverageRels.join(", ");
|
|
2880
|
+
const missing = coverageRels.filter((rel) => !isReadableFile(resolve5(cwd, rel), exists));
|
|
2881
|
+
if (missing.length === 0) {
|
|
1910
2882
|
checks.push({
|
|
1911
2883
|
id: "coverage",
|
|
1912
|
-
label: "Coverage file",
|
|
2884
|
+
label: coverageRels.length > 1 ? "Coverage files" : "Coverage file",
|
|
1913
2885
|
status: "pass",
|
|
1914
2886
|
detail: coverageRel,
|
|
1915
2887
|
optional: true
|
|
@@ -1917,9 +2889,9 @@ async function runDoctor(deps) {
|
|
|
1917
2889
|
} else {
|
|
1918
2890
|
checks.push({
|
|
1919
2891
|
id: "coverage",
|
|
1920
|
-
label: "Coverage file",
|
|
2892
|
+
label: coverageRels.length > 1 ? "Coverage files" : "Coverage file",
|
|
1921
2893
|
status: "warn",
|
|
1922
|
-
detail: `missing ${
|
|
2894
|
+
detail: `missing ${missing.join(", ")} \u2014 run: tested run`,
|
|
1923
2895
|
optional: true
|
|
1924
2896
|
});
|
|
1925
2897
|
}
|
|
@@ -2042,7 +3014,7 @@ async function runDoctor(deps) {
|
|
|
2042
3014
|
}
|
|
2043
3015
|
const testedBin = env.TESTED_BIN;
|
|
2044
3016
|
if (testedBin !== void 0 && testedBin !== "") {
|
|
2045
|
-
const base =
|
|
3017
|
+
const base = basename2(testedBin);
|
|
2046
3018
|
const okName = TESTED_BIN_BASENAME_RE.test(base);
|
|
2047
3019
|
if (!okName) {
|
|
2048
3020
|
checks.push({
|
|
@@ -2127,7 +3099,7 @@ import pc3 from "picocolors";
|
|
|
2127
3099
|
// package.json
|
|
2128
3100
|
var package_default = {
|
|
2129
3101
|
name: "@tested/cli",
|
|
2130
|
-
version: "0.1.
|
|
3102
|
+
version: "0.1.9",
|
|
2131
3103
|
description: "Coverage your agent can use. CLI for patch + project coverage with agent-readable JSON output.",
|
|
2132
3104
|
license: "MIT",
|
|
2133
3105
|
homepage: "https://tested.dev",
|
|
@@ -2278,13 +3250,13 @@ function formatSetupHuman(opts) {
|
|
|
2278
3250
|
async function runSetup(deps) {
|
|
2279
3251
|
const cwd = deps.cwd;
|
|
2280
3252
|
const env = deps.env ?? process.env;
|
|
2281
|
-
const exists = deps.existsSyncFn ??
|
|
3253
|
+
const exists = deps.existsSyncFn ?? existsSync5;
|
|
2282
3254
|
const runInitFn = deps.runInitFn ?? runInit;
|
|
2283
3255
|
const runDoctorFn = deps.runDoctorFn ?? runDoctor;
|
|
2284
3256
|
const force = deps.force ?? false;
|
|
2285
3257
|
const hooks = deps.hooks ?? false;
|
|
2286
3258
|
const json = deps.json ?? false;
|
|
2287
|
-
const configPath =
|
|
3259
|
+
const configPath = join6(cwd, ".tested.yaml");
|
|
2288
3260
|
let initRan = false;
|
|
2289
3261
|
let initResult = null;
|
|
2290
3262
|
if (!exists(configPath) || force) {
|
|
@@ -2368,8 +3340,8 @@ function registerSetupCommand(program2) {
|
|
|
2368
3340
|
}
|
|
2369
3341
|
|
|
2370
3342
|
// src/commands/run.ts
|
|
2371
|
-
import { existsSync as
|
|
2372
|
-
import { isAbsolute as isAbsolute3, resolve as
|
|
3343
|
+
import { existsSync as existsSync6 } from "fs";
|
|
3344
|
+
import { isAbsolute as isAbsolute3, resolve as resolve6, sep as sep2 } from "path";
|
|
2373
3345
|
import { spawn } from "child_process";
|
|
2374
3346
|
import "commander";
|
|
2375
3347
|
function splitRunArgs(extraArgs) {
|
|
@@ -2439,8 +3411,8 @@ function shouldEnforceSafeRun(opts) {
|
|
|
2439
3411
|
return false;
|
|
2440
3412
|
}
|
|
2441
3413
|
function configPathEscapesRoot(configPath, repoRoot) {
|
|
2442
|
-
const root =
|
|
2443
|
-
const abs = isAbsolute3(configPath) ?
|
|
3414
|
+
const root = resolve6(repoRoot);
|
|
3415
|
+
const abs = isAbsolute3(configPath) ? resolve6(configPath) : resolve6(repoRoot, configPath);
|
|
2444
3416
|
const safeRoot = root.endsWith(sep2) ? root : root + sep2;
|
|
2445
3417
|
return !(abs === root || abs.startsWith(safeRoot));
|
|
2446
3418
|
}
|
|
@@ -2491,7 +3463,8 @@ function registerRunCommand(program2) {
|
|
|
2491
3463
|
return;
|
|
2492
3464
|
}
|
|
2493
3465
|
const config = await loadConfig({ cwd });
|
|
2494
|
-
const
|
|
3466
|
+
const coverageRel = coveragePathList(config.coverage.path)[0] ?? "coverage/coverage-final.json";
|
|
3467
|
+
const coveragePath = resolve6(cwd, coverageRel);
|
|
2495
3468
|
const { command, args } = resolveRunCommand({
|
|
2496
3469
|
runner: config.testRunner,
|
|
2497
3470
|
extraArgs: forwarded
|
|
@@ -2503,14 +3476,14 @@ function registerRunCommand(program2) {
|
|
|
2503
3476
|
const child = spawn(command, args, { stdio: "inherit" });
|
|
2504
3477
|
child.on("exit", (code) => {
|
|
2505
3478
|
const exit = code ?? 1;
|
|
2506
|
-
const coverageWritten =
|
|
3479
|
+
const coverageWritten = existsSync6(coveragePath);
|
|
2507
3480
|
if (json) {
|
|
2508
3481
|
const payload = buildRunJsonOutput({
|
|
2509
3482
|
command,
|
|
2510
3483
|
args,
|
|
2511
3484
|
exitCode: exit,
|
|
2512
3485
|
coverageWritten,
|
|
2513
|
-
coveragePath:
|
|
3486
|
+
coveragePath: coverageRel
|
|
2514
3487
|
});
|
|
2515
3488
|
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
2516
3489
|
process.exit(exit);
|
|
@@ -2524,7 +3497,7 @@ function registerRunCommand(program2) {
|
|
|
2524
3497
|
process.stderr.write("\n");
|
|
2525
3498
|
process.stderr.write(
|
|
2526
3499
|
dim(
|
|
2527
|
-
coverageWritten ? `tests failed (exit ${exit}); coverage still written to ${
|
|
3500
|
+
coverageWritten ? `tests failed (exit ${exit}); coverage still written to ${coverageRel}` : `tests failed (exit ${exit}); no coverage file at ${coverageRel}`
|
|
2528
3501
|
) + "\n"
|
|
2529
3502
|
);
|
|
2530
3503
|
}
|
|
@@ -2665,21 +3638,29 @@ function formatHuman(out, opts = {}) {
|
|
|
2665
3638
|
|
|
2666
3639
|
// src/commands/diff.ts
|
|
2667
3640
|
function registerDiffCommand(program2) {
|
|
2668
|
-
program2.command("diff").description("Compute patch + project coverage against a base ref").option("--base <ref>", "Git base ref to diff against", void 0).option("--with-base-coverage <path>", "Compare project coverage against a baseline JSON", void 0).option(
|
|
3641
|
+
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(
|
|
3642
|
+
"--file <path>",
|
|
3643
|
+
"Coverage file to merge (repeatable). Overrides coverage.path.",
|
|
3644
|
+
collectCoverageFile,
|
|
3645
|
+
[]
|
|
3646
|
+
).option("--json", "Emit schema-v1 JSON instead of human text", false).action(async (opts) => {
|
|
2669
3647
|
try {
|
|
2670
3648
|
const cwd = process.cwd();
|
|
2671
3649
|
const config = await loadConfig({ cwd });
|
|
2672
|
-
const
|
|
3650
|
+
const { diff, files, addedByFile } = await computeDiffContext({
|
|
2673
3651
|
cwd,
|
|
2674
3652
|
config,
|
|
2675
3653
|
...opts.base !== void 0 ? { baseRef: opts.base } : {},
|
|
2676
|
-
...opts.withBaseCoverage !== void 0 ? { withBaseCoverage: opts.withBaseCoverage } : {}
|
|
3654
|
+
...opts.withBaseCoverage !== void 0 ? { withBaseCoverage: opts.withBaseCoverage } : {},
|
|
3655
|
+
...opts.file && opts.file.length > 0 ? { coveragePaths: opts.file } : {}
|
|
2677
3656
|
});
|
|
2678
3657
|
if (opts.json) {
|
|
2679
|
-
|
|
3658
|
+
const flags = resolveFlagsJson({ config, files, addedByFile });
|
|
3659
|
+
const payload = flags ? { ...diff, flags } : diff;
|
|
3660
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
2680
3661
|
} else {
|
|
2681
3662
|
process.stdout.write(
|
|
2682
|
-
formatHuman(
|
|
3663
|
+
formatHuman(diff, {
|
|
2683
3664
|
...config.thresholds ? { thresholds: config.thresholds } : {},
|
|
2684
3665
|
tips: true
|
|
2685
3666
|
}) + "\n"
|
|
@@ -2694,11 +3675,115 @@ function registerDiffCommand(program2) {
|
|
|
2694
3675
|
}
|
|
2695
3676
|
|
|
2696
3677
|
// src/commands/check.ts
|
|
3678
|
+
import { existsSync as existsSync7 } from "fs";
|
|
2697
3679
|
import "commander";
|
|
2698
|
-
function
|
|
3680
|
+
function formatIncompleteCheck(state, json) {
|
|
3681
|
+
const message = formatIncompleteGateMessage(state);
|
|
3682
|
+
if (json) {
|
|
3683
|
+
return {
|
|
3684
|
+
skipped: true,
|
|
3685
|
+
patchPass: true,
|
|
3686
|
+
projectPass: true,
|
|
3687
|
+
overall: "pass",
|
|
3688
|
+
flagResults: [],
|
|
3689
|
+
stdout: JSON.stringify({
|
|
3690
|
+
overall: "pending",
|
|
3691
|
+
complete: false,
|
|
3692
|
+
...state.part !== void 0 ? { part: state.part } : {},
|
|
3693
|
+
...state.totalParts !== void 0 ? { totalParts: state.totalParts } : {},
|
|
3694
|
+
note: message
|
|
3695
|
+
}) + "\n",
|
|
3696
|
+
stderr: "",
|
|
3697
|
+
exitCode: 0
|
|
3698
|
+
};
|
|
3699
|
+
}
|
|
3700
|
+
return {
|
|
3701
|
+
skipped: true,
|
|
3702
|
+
patchPass: true,
|
|
3703
|
+
projectPass: true,
|
|
3704
|
+
overall: "pass",
|
|
3705
|
+
flagResults: [],
|
|
3706
|
+
stdout: "",
|
|
3707
|
+
stderr: `${dim("tested.dev \u2014 coverage gate")} ${badge("info")}
|
|
3708
|
+
|
|
3709
|
+
${dim(` ${message}`)}
|
|
3710
|
+
${tip("tested push --complete (or --parts N --part N)")}
|
|
3711
|
+
`,
|
|
3712
|
+
exitCode: 0
|
|
3713
|
+
};
|
|
3714
|
+
}
|
|
3715
|
+
function formatCompleteHandshakeCheck(json) {
|
|
3716
|
+
const note = "no local coverage files \u2014 complete handshake only. The app merges stored shards; this job does not evaluate the gate.";
|
|
3717
|
+
if (json) {
|
|
3718
|
+
return {
|
|
3719
|
+
skipped: true,
|
|
3720
|
+
patchPass: true,
|
|
3721
|
+
projectPass: true,
|
|
3722
|
+
overall: "pass",
|
|
3723
|
+
flagResults: [],
|
|
3724
|
+
stdout: JSON.stringify({ overall: "pending", complete: true, note }) + "\n",
|
|
3725
|
+
stderr: "",
|
|
3726
|
+
exitCode: 0
|
|
3727
|
+
};
|
|
3728
|
+
}
|
|
3729
|
+
return {
|
|
3730
|
+
skipped: true,
|
|
3731
|
+
patchPass: true,
|
|
3732
|
+
projectPass: true,
|
|
3733
|
+
overall: "pass",
|
|
3734
|
+
flagResults: [],
|
|
3735
|
+
stdout: "",
|
|
3736
|
+
stderr: `${dim("tested.dev \u2014 coverage gate")} ${badge("info")}
|
|
3737
|
+
|
|
3738
|
+
${dim(` ${note}`)}
|
|
3739
|
+
`,
|
|
3740
|
+
exitCode: 0
|
|
3741
|
+
};
|
|
3742
|
+
}
|
|
3743
|
+
function formatMetricLine(label, pct3, threshold, pass, indent = " ") {
|
|
2699
3744
|
const pctStr = pct3.toFixed(1);
|
|
2700
3745
|
const status = pass ? badge("pass") : badge("fail");
|
|
2701
|
-
return
|
|
3746
|
+
return `${indent}${label.padEnd(8)} ${pctStr}% (threshold ${threshold}) ${status}`;
|
|
3747
|
+
}
|
|
3748
|
+
function presentPct(metric) {
|
|
3749
|
+
if (metric.pct === void 0 || metric.pass === void 0) {
|
|
3750
|
+
throw new Error("present flag metric missing pct/pass");
|
|
3751
|
+
}
|
|
3752
|
+
return { pct: metric.pct, pass: metric.pass };
|
|
3753
|
+
}
|
|
3754
|
+
function formatFlagLines(results) {
|
|
3755
|
+
if (results.length === 0) return [];
|
|
3756
|
+
const lines = [""];
|
|
3757
|
+
for (const flag of results) {
|
|
3758
|
+
if (flag.status === "missing") {
|
|
3759
|
+
lines.push(
|
|
3760
|
+
` ${flag.name} ${dim(flag.reason ?? "missing this run")} ${badge("missing")}`
|
|
3761
|
+
);
|
|
3762
|
+
continue;
|
|
3763
|
+
}
|
|
3764
|
+
lines.push(` ${flag.name} ${flag.status === "pass" ? badge("pass") : badge("fail")}`);
|
|
3765
|
+
if (flag.patch.skipped) {
|
|
3766
|
+
lines.push(
|
|
3767
|
+
` ${"Patch".padEnd(8)} ${dim("-")} ${EMPTY_PATCH_REASON} ${badge("skip")}`
|
|
3768
|
+
);
|
|
3769
|
+
} else {
|
|
3770
|
+
const patch = presentPct(flag.patch);
|
|
3771
|
+
lines.push(
|
|
3772
|
+
formatMetricLine("Patch", patch.pct, flag.patch.threshold, patch.pass, " ")
|
|
3773
|
+
);
|
|
3774
|
+
}
|
|
3775
|
+
const project = presentPct(flag.project);
|
|
3776
|
+
lines.push(
|
|
3777
|
+
formatMetricLine(
|
|
3778
|
+
"Project",
|
|
3779
|
+
project.pct,
|
|
3780
|
+
flag.project.threshold,
|
|
3781
|
+
project.pass,
|
|
3782
|
+
" "
|
|
3783
|
+
)
|
|
3784
|
+
);
|
|
3785
|
+
}
|
|
3786
|
+
return lines;
|
|
2702
3787
|
}
|
|
2703
3788
|
function runCheck(input) {
|
|
2704
3789
|
const { config, diff, json } = input;
|
|
@@ -2708,6 +3793,7 @@ function runCheck(input) {
|
|
|
2708
3793
|
patchPass: true,
|
|
2709
3794
|
projectPass: true,
|
|
2710
3795
|
overall: "pass",
|
|
3796
|
+
flagResults: [],
|
|
2711
3797
|
stdout: "",
|
|
2712
3798
|
stderr: `${dim("tested.dev \u2014 coverage gate")} ${badge("info")}
|
|
2713
3799
|
|
|
@@ -2724,7 +3810,14 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
|
|
|
2724
3810
|
const patchSkipped = isEmptyPatch(diff.patch);
|
|
2725
3811
|
const patchPass = patchSkipped ? true : patchPct >= patchThreshold;
|
|
2726
3812
|
const projectPass = projectPct >= projectThreshold;
|
|
2727
|
-
const
|
|
3813
|
+
const flagResults = evaluateFlags({
|
|
3814
|
+
config,
|
|
3815
|
+
files: input.files ?? [],
|
|
3816
|
+
addedByFile: input.addedByFile ?? /* @__PURE__ */ new Map(),
|
|
3817
|
+
...input.onlyFlag !== void 0 ? { onlyFlag: input.onlyFlag } : {}
|
|
3818
|
+
});
|
|
3819
|
+
const flagsOk = flagsPass(flagResults);
|
|
3820
|
+
const overall = patchPass && projectPass && flagsOk ? "pass" : "fail";
|
|
2728
3821
|
const exitCode = overall === "pass" ? 0 : 1;
|
|
2729
3822
|
if (json) {
|
|
2730
3823
|
const payload = {
|
|
@@ -2735,6 +3828,7 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
|
|
|
2735
3828
|
...patchSkipped ? { skipped: true, reason: EMPTY_PATCH_REASON } : {}
|
|
2736
3829
|
},
|
|
2737
3830
|
project: { pct: projectPct, threshold: projectThreshold, pass: projectPass },
|
|
3831
|
+
...flagResults.length > 0 ? { flags: flagsToJson(flagResults) } : {},
|
|
2738
3832
|
overall,
|
|
2739
3833
|
...patchSkipped ? { note: EMPTY_PATCH_REASON } : {}
|
|
2740
3834
|
};
|
|
@@ -2743,6 +3837,7 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
|
|
|
2743
3837
|
patchPass,
|
|
2744
3838
|
projectPass,
|
|
2745
3839
|
overall,
|
|
3840
|
+
flagResults,
|
|
2746
3841
|
stdout: JSON.stringify(payload) + "\n",
|
|
2747
3842
|
stderr: "",
|
|
2748
3843
|
exitCode
|
|
@@ -2761,11 +3856,20 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
|
|
|
2761
3856
|
lines.push(formatMetricLine("Patch", patchPct, patchThreshold, patchPass));
|
|
2762
3857
|
}
|
|
2763
3858
|
lines.push(formatMetricLine("Project", projectPct, projectThreshold, projectPass));
|
|
3859
|
+
lines.push(...formatFlagLines(flagResults));
|
|
2764
3860
|
if (overall === "fail") {
|
|
2765
3861
|
lines.push("");
|
|
2766
3862
|
if (patchSkipped) {
|
|
2767
3863
|
lines.push(dim("No executable lines in the patch \u2014 patch gate skipped."));
|
|
2768
3864
|
}
|
|
3865
|
+
const missing = flagResults.filter((f) => f.status === "missing");
|
|
3866
|
+
if (missing.length > 0) {
|
|
3867
|
+
lines.push(
|
|
3868
|
+
dim(
|
|
3869
|
+
"Flags with no files this run are skipped (not 0%). Scope the job with --flag when this coverage file is one package."
|
|
3870
|
+
)
|
|
3871
|
+
);
|
|
3872
|
+
}
|
|
2769
3873
|
lines.push(tip("add tests for uncovered ranges: tested diff"));
|
|
2770
3874
|
} else {
|
|
2771
3875
|
lines.push("");
|
|
@@ -2781,6 +3885,7 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
|
|
|
2781
3885
|
patchPass,
|
|
2782
3886
|
projectPass,
|
|
2783
3887
|
overall,
|
|
3888
|
+
flagResults,
|
|
2784
3889
|
stdout: lines.join("\n"),
|
|
2785
3890
|
stderr: "",
|
|
2786
3891
|
exitCode
|
|
@@ -2789,10 +3894,47 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
|
|
|
2789
3894
|
function registerCheckCommand(program2) {
|
|
2790
3895
|
program2.command("check").description(
|
|
2791
3896
|
"Exit non-zero if patch or project coverage falls below configured thresholds."
|
|
2792
|
-
).option("--json", "Emit machine-readable JSON to stdout (exit code unchanged).", false).option("--base <ref>", "Git base ref to diff against", void 0).
|
|
3897
|
+
).option("--json", "Emit machine-readable JSON to stdout (exit code unchanged).", false).option("--base <ref>", "Git base ref to diff against", void 0).option(
|
|
3898
|
+
"--file <path>",
|
|
3899
|
+
"Coverage file to merge (repeatable). Overrides coverage.path.",
|
|
3900
|
+
collectCoverageFile,
|
|
3901
|
+
[]
|
|
3902
|
+
).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(
|
|
3903
|
+
"--flag <name>",
|
|
3904
|
+
"Evaluate only this flag (job already scoped \u2014 the coverage file is the flag)"
|
|
3905
|
+
).action(async (opts) => {
|
|
2793
3906
|
try {
|
|
2794
3907
|
const cwd = process.cwd();
|
|
2795
3908
|
const config = await loadConfig({ cwd });
|
|
3909
|
+
let merge;
|
|
3910
|
+
try {
|
|
3911
|
+
merge = resolveCoverageMerge(opts);
|
|
3912
|
+
} catch (err) {
|
|
3913
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3914
|
+
process.stderr.write(formatCliError(message));
|
|
3915
|
+
process.exitCode = 1;
|
|
3916
|
+
return;
|
|
3917
|
+
}
|
|
3918
|
+
if (!merge.complete) {
|
|
3919
|
+
const result2 = formatIncompleteCheck(merge, opts.json);
|
|
3920
|
+
if (result2.stderr) process.stderr.write(result2.stderr);
|
|
3921
|
+
if (result2.stdout) process.stdout.write(result2.stdout);
|
|
3922
|
+
process.exitCode = result2.exitCode;
|
|
3923
|
+
return;
|
|
3924
|
+
}
|
|
3925
|
+
const coveragePaths = resolveCoveragePaths({
|
|
3926
|
+
...opts.file && opts.file.length > 0 ? { files: opts.file } : {},
|
|
3927
|
+
configPath: config.coverage.path
|
|
3928
|
+
});
|
|
3929
|
+
const existing = existingCoveragePaths(coveragePaths, cwd, existsSync7);
|
|
3930
|
+
const handshakeOnly = merge.complete && existing.length === 0 && (opts.complete || merge.totalParts !== void 0);
|
|
3931
|
+
if (handshakeOnly) {
|
|
3932
|
+
const result2 = formatCompleteHandshakeCheck(opts.json);
|
|
3933
|
+
if (result2.stderr) process.stderr.write(result2.stderr);
|
|
3934
|
+
if (result2.stdout) process.stdout.write(result2.stdout);
|
|
3935
|
+
process.exitCode = result2.exitCode;
|
|
3936
|
+
return;
|
|
3937
|
+
}
|
|
2796
3938
|
if (!config.thresholds) {
|
|
2797
3939
|
const result2 = runCheck({
|
|
2798
3940
|
config,
|
|
@@ -2813,12 +3955,20 @@ function registerCheckCommand(program2) {
|
|
|
2813
3955
|
process.exitCode = result2.exitCode;
|
|
2814
3956
|
return;
|
|
2815
3957
|
}
|
|
2816
|
-
const diff = await
|
|
3958
|
+
const { diff, files, addedByFile } = await computeDiffContext({
|
|
2817
3959
|
cwd,
|
|
2818
3960
|
config,
|
|
2819
|
-
...opts.base !== void 0 ? { baseRef: opts.base } : {}
|
|
3961
|
+
...opts.base !== void 0 ? { baseRef: opts.base } : {},
|
|
3962
|
+
...coveragePaths.length > 0 ? { coveragePaths } : {}
|
|
3963
|
+
});
|
|
3964
|
+
const result = runCheck({
|
|
3965
|
+
config,
|
|
3966
|
+
diff,
|
|
3967
|
+
json: opts.json,
|
|
3968
|
+
files,
|
|
3969
|
+
addedByFile,
|
|
3970
|
+
...opts.flag && opts.flag.trim() ? { onlyFlag: opts.flag.trim() } : {}
|
|
2820
3971
|
});
|
|
2821
|
-
const result = runCheck({ config, diff, json: opts.json });
|
|
2822
3972
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
2823
3973
|
if (result.stdout) process.stdout.write(result.stdout);
|
|
2824
3974
|
process.exitCode = result.exitCode;
|
|
@@ -2831,8 +3981,8 @@ function registerCheckCommand(program2) {
|
|
|
2831
3981
|
}
|
|
2832
3982
|
|
|
2833
3983
|
// src/commands/explain.ts
|
|
2834
|
-
import { readFile as
|
|
2835
|
-
import { resolve as
|
|
3984
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
3985
|
+
import { resolve as resolve7 } from "path";
|
|
2836
3986
|
import "commander";
|
|
2837
3987
|
function parseLocation(input) {
|
|
2838
3988
|
const idx = input.lastIndexOf(":");
|
|
@@ -2893,9 +4043,12 @@ function registerExplainCommand(program2) {
|
|
|
2893
4043
|
const { path: relPath, line } = parseLocation(location);
|
|
2894
4044
|
const config = await loadConfig({ cwd });
|
|
2895
4045
|
const ctx = await openRepo(cwd);
|
|
2896
|
-
const
|
|
2897
|
-
|
|
2898
|
-
|
|
4046
|
+
const files = await parseAndMergeCoverage({
|
|
4047
|
+
paths: resolveCoveragePaths({ configPath: config.coverage.path }),
|
|
4048
|
+
cwd,
|
|
4049
|
+
repoRoot: ctx.repoRoot,
|
|
4050
|
+
...config.coverage.format ? { format: config.coverage.format } : {}
|
|
4051
|
+
});
|
|
2899
4052
|
const file = files.find((f) => f.path === relPath);
|
|
2900
4053
|
if (!file) {
|
|
2901
4054
|
process.stderr.write(`error: no coverage data for ${relPath}
|
|
@@ -2903,9 +4056,9 @@ function registerExplainCommand(program2) {
|
|
|
2903
4056
|
process.exitCode = 2;
|
|
2904
4057
|
return;
|
|
2905
4058
|
}
|
|
2906
|
-
const resolvedSource =
|
|
4059
|
+
const resolvedSource = resolve7(ctx.repoRoot, relPath);
|
|
2907
4060
|
assertWithinRoot(ctx.repoRoot, resolvedSource);
|
|
2908
|
-
const source = await
|
|
4061
|
+
const source = await readFile5(resolvedSource, "utf8");
|
|
2909
4062
|
const sourceLines = source.split("\n");
|
|
2910
4063
|
const result = explainAt(file, line, sourceLines);
|
|
2911
4064
|
if (opts.json) {
|
|
@@ -3017,11 +4170,11 @@ async function runToken(opts) {
|
|
|
3017
4170
|
async function runWhoami(opts) {
|
|
3018
4171
|
const env = opts.env ?? process.env;
|
|
3019
4172
|
const identity = await resolveRepoIdentity(opts);
|
|
3020
|
-
const
|
|
4173
|
+
const resolve8 = opts.resolveTokenFn ?? resolveToken;
|
|
3021
4174
|
let tokenSet = false;
|
|
3022
4175
|
let source = null;
|
|
3023
4176
|
try {
|
|
3024
|
-
const token =
|
|
4177
|
+
const token = resolve8({ env, isTTY: false, warn: () => {
|
|
3025
4178
|
} });
|
|
3026
4179
|
tokenSet = Boolean(token);
|
|
3027
4180
|
source = tokenSet ? tokenSourceFromEnv(env) : null;
|