@c9up/helix 0.1.4 → 0.1.5
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/dist/runtime/suite.d.ts +26 -0
- package/dist/runtime/suite.d.ts.map +1 -1
- package/dist/runtime/suite.js +21 -18
- package/dist/runtime/suite.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +2 -2
- package/src/cli/coverage/aggregate.ts +0 -231
- package/src/cli/coverage/collect.ts +0 -63
- package/src/cli/coverage/diff/base.ts +0 -46
- package/src/cli/coverage/diff/index.ts +0 -160
- package/src/cli/coverage/diff/overlay.ts +0 -62
- package/src/cli/coverage/diff/parse.ts +0 -121
- package/src/cli/coverage/diff/reporters.ts +0 -82
- package/src/cli/coverage/diff/types.ts +0 -46
- package/src/cli/coverage/filter.ts +0 -71
- package/src/cli/coverage/glob.ts +0 -0
- package/src/cli/coverage/index.ts +0 -126
- package/src/cli/coverage/reporters/json.ts +0 -40
- package/src/cli/coverage/reporters/lcov.ts +0 -54
- package/src/cli/coverage/reporters/text.ts +0 -48
- package/src/cli/coverage/thresholds.ts +0 -73
- package/src/cli/coverage/types.ts +0 -93
- package/src/cli/discover.ts +0 -174
- package/src/cli/native.ts +0 -104
- package/src/cli/pool.ts +0 -486
- package/src/cli/reporter.ts +0 -155
- package/src/cli/run.ts +0 -440
- package/src/cli/summary.ts +0 -42
- package/src/cli/watch/loop.ts +0 -159
- package/src/cli/watch/types.ts +0 -22
- package/src/cli/watch/watcher.ts +0 -145
- package/src/container/index.ts +0 -16
- package/src/container/override.ts +0 -86
- package/src/container/spy.ts +0 -25
- package/src/index.ts +0 -42
- package/src/runtime/assertion-error.ts +0 -38
- package/src/runtime/cli-worker.ts +0 -140
- package/src/runtime/equals.ts +0 -400
- package/src/runtime/expect.ts +0 -173
- package/src/runtime/index.ts +0 -50
- package/src/runtime/lifecycle.ts +0 -17
- package/src/runtime/matchers.ts +0 -452
- package/src/runtime/run.ts +0 -573
- package/src/runtime/suite.ts +0 -310
- package/src/runtime/test-context.ts +0 -59
- package/src/runtime/vi/fake-timers.ts +0 -410
- package/src/runtime/vi/index.ts +0 -254
- package/src/runtime/vi/spy.ts +0 -224
- package/src/runtime/vi/spyOn.ts +0 -155
- package/src/runtime/vi/system-time.ts +0 -121
- package/src/runtime/worker.ts +0 -239
- package/src/time/freeze.ts +0 -229
- package/src/time/index.ts +0 -16
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Overlay the diff map onto a `CoverageSummary` to compute per-file
|
|
3
|
-
* "added vs covered" totals.
|
|
4
|
-
*
|
|
5
|
-
* For each file in the diff:
|
|
6
|
-
* - find the matching coverage entry by absolute path
|
|
7
|
-
* - intersect the added line numbers with `lineHits` (count > 0 means
|
|
8
|
-
* covered)
|
|
9
|
-
* - emit a `DiffFileSummary`
|
|
10
|
-
*
|
|
11
|
-
* Files in the diff with no coverage entry are still reported (added
|
|
12
|
-
* lines, 0 covered) so a freshly-added module without any tests shows
|
|
13
|
-
* up red. Files in coverage but not in the diff are skipped (the PR
|
|
14
|
-
* didn't touch them, so they don't move the diff-coverage needle).
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { CoverageSummary } from "../types.js";
|
|
18
|
-
import type { DiffFileSummary, DiffMap, DiffSummary } from "./types.js";
|
|
19
|
-
|
|
20
|
-
function pct(covered: number, added: number): number {
|
|
21
|
-
if (added === 0) return 100;
|
|
22
|
-
return Math.round((covered / added) * 10000) / 100;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function overlay(coverage: CoverageSummary, diff: DiffMap): DiffSummary {
|
|
26
|
-
const coverageByFile = new Map(coverage.files.map((f) => [f.file, f]));
|
|
27
|
-
const files: DiffFileSummary[] = [];
|
|
28
|
-
let totalAdded = 0;
|
|
29
|
-
let totalCovered = 0;
|
|
30
|
-
|
|
31
|
-
for (const [file, addedLines] of diff) {
|
|
32
|
-
if (addedLines.size === 0) continue;
|
|
33
|
-
const cov = coverageByFile.get(file);
|
|
34
|
-
const hitsByLine = cov
|
|
35
|
-
? new Map(cov.lineHits.map((h) => [h.line, h.count]))
|
|
36
|
-
: undefined;
|
|
37
|
-
const sortedAdded = [...addedLines].sort((a, b) => a - b);
|
|
38
|
-
const lineHits: DiffFileSummary["lineHits"] = sortedAdded.map((line) => {
|
|
39
|
-
const count = hitsByLine?.get(line);
|
|
40
|
-
return { line, covered: typeof count === "number" && count > 0 };
|
|
41
|
-
});
|
|
42
|
-
const covered = lineHits.filter((h) => h.covered).length;
|
|
43
|
-
files.push({
|
|
44
|
-
file,
|
|
45
|
-
added: addedLines.size,
|
|
46
|
-
covered,
|
|
47
|
-
lineHits,
|
|
48
|
-
});
|
|
49
|
-
totalAdded += addedLines.size;
|
|
50
|
-
totalCovered += covered;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
files.sort((a, b) => a.file.localeCompare(b.file));
|
|
54
|
-
return {
|
|
55
|
-
files,
|
|
56
|
-
total: {
|
|
57
|
-
added: totalAdded,
|
|
58
|
-
covered: totalCovered,
|
|
59
|
-
pct: pct(totalCovered, totalAdded),
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
}
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Parse `git diff --unified=0 --no-color <base>...HEAD` output into a
|
|
3
|
-
* `DiffMap`. Why three-dot: it computes the diff at the merge-base, so
|
|
4
|
-
* commits made on `base` AFTER our branch diverged are excluded. That's
|
|
5
|
-
* the "what this PR contributes" semantic users expect.
|
|
6
|
-
*
|
|
7
|
-
* We force `--src-prefix=a/ --dst-prefix=b/` and `core.quotepath=false`
|
|
8
|
-
* so the parser doesn't have to guess at user-side `diff.noprefix` or
|
|
9
|
-
* non-ASCII path quoting.
|
|
10
|
-
*
|
|
11
|
-
* Renames produce `+++ b/<new path>` so they're picked up at the new
|
|
12
|
-
* location. Deletes produce `+++ /dev/null` and are skipped. Binary
|
|
13
|
-
* files produce no `+` lines so they contribute nothing.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { spawnSync } from "node:child_process";
|
|
17
|
-
import { realpathSync } from "node:fs";
|
|
18
|
-
import path from "node:path";
|
|
19
|
-
import type { DiffMap } from "./types.js";
|
|
20
|
-
|
|
21
|
-
const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
22
|
-
const FILE_HEADER_PLUS = /^\+\+\+ (?:b\/(.*)|\/dev\/null)$/;
|
|
23
|
-
|
|
24
|
-
export interface ParseOptions {
|
|
25
|
-
cwd: string;
|
|
26
|
-
base: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function tryRealpath(p: string): string {
|
|
30
|
-
try {
|
|
31
|
-
return realpathSync(p);
|
|
32
|
-
} catch {
|
|
33
|
-
return p;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function parseDiff(opts: ParseOptions): DiffMap {
|
|
38
|
-
const result = spawnSync(
|
|
39
|
-
"git",
|
|
40
|
-
[
|
|
41
|
-
"-c",
|
|
42
|
-
"core.quotepath=false",
|
|
43
|
-
"diff",
|
|
44
|
-
"--unified=0",
|
|
45
|
-
"--no-color",
|
|
46
|
-
"--src-prefix=a/",
|
|
47
|
-
"--dst-prefix=b/",
|
|
48
|
-
`${opts.base}...HEAD`,
|
|
49
|
-
],
|
|
50
|
-
{
|
|
51
|
-
cwd: opts.cwd,
|
|
52
|
-
encoding: "utf8",
|
|
53
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
54
|
-
},
|
|
55
|
-
);
|
|
56
|
-
if (result.error) {
|
|
57
|
-
const err = result.error as NodeJS.ErrnoException;
|
|
58
|
-
if (err.code === "ENOENT") {
|
|
59
|
-
throw new Error("git binary not found on PATH");
|
|
60
|
-
}
|
|
61
|
-
if (err.code === "ENOBUFS") {
|
|
62
|
-
throw new Error(
|
|
63
|
-
"diff output exceeded 64 MB — branch is too divergent to overlay",
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
throw new Error(`git diff failed: ${err.message}`);
|
|
67
|
-
}
|
|
68
|
-
if (result.status !== 0) {
|
|
69
|
-
throw new Error(
|
|
70
|
-
`git diff exited ${result.status}: ${result.stderr?.trim() || "(no stderr — likely shallow clone or pruned ref)"}`,
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
return parseDiffString(result.stdout, opts.cwd);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Pure parser — exposed separately so tests can feed canned diff
|
|
78
|
-
* strings without spawning git. Keys are canonicalised via realpath so
|
|
79
|
-
* they line up with V8 coverage paths (which `filter.ts` realpaths).
|
|
80
|
-
*/
|
|
81
|
-
export function parseDiffString(diff: string, cwd: string): DiffMap {
|
|
82
|
-
const map: DiffMap = new Map();
|
|
83
|
-
let currentFile: string | undefined;
|
|
84
|
-
let nextAddedLine = 0;
|
|
85
|
-
const lines = diff.split(/\r?\n/);
|
|
86
|
-
for (const line of lines) {
|
|
87
|
-
const fileMatch = line.match(FILE_HEADER_PLUS);
|
|
88
|
-
if (fileMatch) {
|
|
89
|
-
const newPath = fileMatch[1];
|
|
90
|
-
if (!newPath) {
|
|
91
|
-
currentFile = undefined;
|
|
92
|
-
nextAddedLine = 0;
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
currentFile = tryRealpath(path.resolve(cwd, newPath));
|
|
96
|
-
nextAddedLine = 0;
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
if (!currentFile) continue;
|
|
100
|
-
const hunkMatch = line.match(HUNK_HEADER);
|
|
101
|
-
if (hunkMatch) {
|
|
102
|
-
nextAddedLine = Number.parseInt(hunkMatch[1], 10);
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
106
|
-
if (nextAddedLine < 1) {
|
|
107
|
-
// `+0,0` deletion-only hunks set nextAddedLine = 0; ignore stray
|
|
108
|
-
// `+` lines rather than emit invalid line numbers.
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
let set = map.get(currentFile);
|
|
112
|
-
if (!set) {
|
|
113
|
-
set = new Set();
|
|
114
|
-
map.set(currentFile, set);
|
|
115
|
-
}
|
|
116
|
-
set.add(nextAddedLine);
|
|
117
|
-
nextAddedLine += 1;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return map;
|
|
121
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Diff-coverage reporters: text summary + JSON.
|
|
3
|
-
*
|
|
4
|
-
* The text reporter mirrors the full-tree text format so users can read
|
|
5
|
-
* both side-by-side. JSON is namespaced under `files` to avoid
|
|
6
|
-
* collisions with the literal `total` key (in case a file is named
|
|
7
|
-
* `total`).
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import path from "node:path";
|
|
11
|
-
import type { DiffFileSummary, DiffSummary } from "./types.js";
|
|
12
|
-
|
|
13
|
-
function rel(file: string, root: string): string {
|
|
14
|
-
const r = path.relative(root, file).split(path.sep).join("/");
|
|
15
|
-
return r.length > 0 && !r.startsWith("..") ? r : file;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function padRight(s: string, w: number): string {
|
|
19
|
-
if (s.length === w) return s;
|
|
20
|
-
if (s.length > w) return `…${s.slice(s.length - w + 1)}`;
|
|
21
|
-
return s + " ".repeat(w - s.length);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function fmtPct(n: number): string {
|
|
25
|
-
return `${n.toFixed(2).padStart(6)}%`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function diffTextSummary(summary: DiffSummary, root: string): string {
|
|
29
|
-
const lines: string[] = [];
|
|
30
|
-
lines.push("");
|
|
31
|
-
lines.push("── Diff coverage ───────────────────────────────────────────");
|
|
32
|
-
if (summary.files.length === 0) {
|
|
33
|
-
lines.push(" No instrumented files were touched by the diff.");
|
|
34
|
-
lines.push(
|
|
35
|
-
` ${summary.total.covered}/${summary.total.added} lines covered`,
|
|
36
|
-
);
|
|
37
|
-
return `${lines.join("\n")}\n`;
|
|
38
|
-
}
|
|
39
|
-
lines.push(
|
|
40
|
-
`${padRight("File", 50)} ${"Covered".padStart(10)} ${"%".padStart(8)}`,
|
|
41
|
-
);
|
|
42
|
-
lines.push("─".repeat(72));
|
|
43
|
-
for (const f of summary.files) {
|
|
44
|
-
const ratio = `${f.covered}/${f.added}`;
|
|
45
|
-
const pct = f.added > 0 ? (f.covered / f.added) * 100 : 100;
|
|
46
|
-
lines.push(
|
|
47
|
-
`${padRight(rel(f.file, root), 50)} ${ratio.padStart(10)} ${fmtPct(pct)}`,
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
lines.push("─".repeat(72));
|
|
51
|
-
lines.push(
|
|
52
|
-
`${padRight("Total", 50)} ${`${summary.total.covered}/${summary.total.added}`.padStart(10)} ${fmtPct(summary.total.pct)}`,
|
|
53
|
-
);
|
|
54
|
-
return `${lines.join("\n")}\n`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
interface DiffJsonFileEntry {
|
|
58
|
-
added: number;
|
|
59
|
-
covered: number;
|
|
60
|
-
lineHits: DiffFileSummary["lineHits"];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
interface DiffJsonOutput {
|
|
64
|
-
total: DiffSummary["total"];
|
|
65
|
-
files: Record<string, DiffJsonFileEntry>;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function diffJson(summary: DiffSummary, root: string): string {
|
|
69
|
-
const filesOut: Record<string, DiffJsonFileEntry> = Object.create(null);
|
|
70
|
-
for (const f of summary.files) {
|
|
71
|
-
filesOut[rel(f.file, root)] = {
|
|
72
|
-
added: f.added,
|
|
73
|
-
covered: f.covered,
|
|
74
|
-
lineHits: f.lineHits,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
const out: DiffJsonOutput = {
|
|
78
|
-
total: summary.total,
|
|
79
|
-
files: filesOut,
|
|
80
|
-
};
|
|
81
|
-
return `${JSON.stringify(out, null, 2)}\n`;
|
|
82
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Types for the diff-coverage pipeline.
|
|
3
|
-
*
|
|
4
|
-
* `DiffMap` is the output of `parse.ts` — for every file changed in
|
|
5
|
-
* `<base>...HEAD`, the set of 1-based line numbers added or modified
|
|
6
|
-
* (i.e. lines starting with `+` in unified diff, excluding the `+++`
|
|
7
|
-
* header). Deleted lines aren't relevant: we only care whether NEW
|
|
8
|
-
* code is covered.
|
|
9
|
-
*
|
|
10
|
-
* `DiffFileSummary` and `DiffSummary` mirror the shape of the full-tree
|
|
11
|
-
* `FileSummary` / `CoverageSummary` so the existing threshold
|
|
12
|
-
* enforcement machinery applies unchanged.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export type DiffMap = Map<string, Set<number>>;
|
|
16
|
-
|
|
17
|
-
export interface DiffFileSummary {
|
|
18
|
-
file: string;
|
|
19
|
-
/** Lines added by the PR (1-based). */
|
|
20
|
-
added: number;
|
|
21
|
-
/** Subset of `added` that have a hit count > 0 in the coverage summary. */
|
|
22
|
-
covered: number;
|
|
23
|
-
/** Per-line breakdown for reporters. */
|
|
24
|
-
lineHits: Array<{ line: number; covered: boolean }>;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface DiffSummary {
|
|
28
|
-
files: DiffFileSummary[];
|
|
29
|
-
total: {
|
|
30
|
-
added: number;
|
|
31
|
-
covered: number;
|
|
32
|
-
pct: number;
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface DiffOptions {
|
|
37
|
-
enabled: boolean;
|
|
38
|
-
/** Git revision to diff against. Default: resolved by `base.ts`. */
|
|
39
|
-
base?: string;
|
|
40
|
-
/** Inline thresholds. Reuses the full-tree shape. */
|
|
41
|
-
thresholds?: import("../types.js").Thresholds;
|
|
42
|
-
/** Output dir for `coverage-diff.json`. Defaults to coverage outputDir. */
|
|
43
|
-
outputDir?: string;
|
|
44
|
-
/** Where to spawn `git diff`. Defaults to `process.cwd()`. */
|
|
45
|
-
cwd?: string;
|
|
46
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
// Include / exclude filter for coverage files. Glob subset is
|
|
2
|
-
// implemented in `./glob.ts` (shared with the watch-mode filter).
|
|
3
|
-
//
|
|
4
|
-
// Symlinks are NOT followed: `path.realpath` is used to verify the file
|
|
5
|
-
// actually lives under `root`. Anything pointing outside (off-tree
|
|
6
|
-
// symlinks) is dropped — defence in depth against a malicious source
|
|
7
|
-
// tree dragging unrelated files into the coverage report.
|
|
8
|
-
|
|
9
|
-
import { realpathSync } from "node:fs";
|
|
10
|
-
import path from "node:path";
|
|
11
|
-
import { compileGlobs, globToRegex, matchesAnyGlob } from "./glob.js";
|
|
12
|
-
import type { RawFileCoverage } from "./types.js";
|
|
13
|
-
|
|
14
|
-
const DEFAULT_INCLUDE = ["src/**/*.{ts,tsx,js,mjs,cjs}"];
|
|
15
|
-
const DEFAULT_EXCLUDE = [
|
|
16
|
-
"node_modules/**",
|
|
17
|
-
"dist/**",
|
|
18
|
-
"build/**",
|
|
19
|
-
"coverage/**",
|
|
20
|
-
".git/**",
|
|
21
|
-
".wolf/**",
|
|
22
|
-
"target/**",
|
|
23
|
-
".next/**",
|
|
24
|
-
"tests/**",
|
|
25
|
-
"test/**",
|
|
26
|
-
"**/*.test.*",
|
|
27
|
-
"**/*.spec.*",
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
export interface FilterConfig {
|
|
31
|
-
root: string;
|
|
32
|
-
include?: string[];
|
|
33
|
-
exclude?: string[];
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Resolve a path's real (canonical) absolute location. Falls back to
|
|
38
|
-
* the lexical path if the file no longer exists (already deleted by the
|
|
39
|
-
* worker exit). Symlinks point at the target's real path.
|
|
40
|
-
*/
|
|
41
|
-
function tryRealpath(p: string): string {
|
|
42
|
-
try {
|
|
43
|
-
return realpathSync(p);
|
|
44
|
-
} catch {
|
|
45
|
-
return p;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function filter(
|
|
50
|
-
raw: RawFileCoverage[],
|
|
51
|
-
config: FilterConfig,
|
|
52
|
-
): RawFileCoverage[] {
|
|
53
|
-
const include = compileGlobs(config.include ?? DEFAULT_INCLUDE);
|
|
54
|
-
const exclude = compileGlobs(config.exclude ?? DEFAULT_EXCLUDE);
|
|
55
|
-
const root = tryRealpath(path.resolve(config.root));
|
|
56
|
-
const out: RawFileCoverage[] = [];
|
|
57
|
-
for (const entry of raw) {
|
|
58
|
-
const real = tryRealpath(entry.file);
|
|
59
|
-
const rel = path.relative(root, real).split(path.sep).join("/");
|
|
60
|
-
if (rel.length === 0 || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
if (!matchesAnyGlob(include, rel)) continue;
|
|
64
|
-
if (matchesAnyGlob(exclude, rel)) continue;
|
|
65
|
-
out.push({ ...entry, file: real });
|
|
66
|
-
}
|
|
67
|
-
return out;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Exposed for tests. */
|
|
71
|
-
export const __test__ = { globToRegex };
|
package/src/cli/coverage/glob.ts
DELETED
|
Binary file
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Coverage facade — wires collect → filter → aggregate → reporters →
|
|
3
|
-
* thresholds. Called by `run.ts` after the worker pool finishes.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
import { aggregate } from "./aggregate.js";
|
|
9
|
-
import { collect } from "./collect.js";
|
|
10
|
-
import { filter } from "./filter.js";
|
|
11
|
-
import { jsonSummary } from "./reporters/json.js";
|
|
12
|
-
import { lcov } from "./reporters/lcov.js";
|
|
13
|
-
import { textSummary } from "./reporters/text.js";
|
|
14
|
-
import { enforce } from "./thresholds.js";
|
|
15
|
-
import type {
|
|
16
|
-
CoverageOptions,
|
|
17
|
-
CoverageSummary,
|
|
18
|
-
ThresholdViolation,
|
|
19
|
-
} from "./types.js";
|
|
20
|
-
|
|
21
|
-
export { textSummary } from "./reporters/text.js";
|
|
22
|
-
export { enforce, violationSummary } from "./thresholds.js";
|
|
23
|
-
export type {
|
|
24
|
-
CoverageOptions,
|
|
25
|
-
CoverageSummary,
|
|
26
|
-
ThresholdViolation,
|
|
27
|
-
} from "./types.js";
|
|
28
|
-
|
|
29
|
-
export interface CoverageSession {
|
|
30
|
-
/** Temp dir passed as `NODE_V8_COVERAGE` to every worker. */
|
|
31
|
-
envDir: string;
|
|
32
|
-
/** Inject into the child environment. */
|
|
33
|
-
env: NodeJS.ProcessEnv;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Create a unique temp directory for a run. The orchestrator passes its
|
|
38
|
-
* path via `NODE_V8_COVERAGE`; every spawned worker writes its own
|
|
39
|
-
* `coverage-*.json` on exit. Returns an env bag the pool forwards.
|
|
40
|
-
*/
|
|
41
|
-
export async function openSession(baseDir?: string): Promise<CoverageSession> {
|
|
42
|
-
const root = baseDir ?? path.join(process.cwd(), ".helix-coverage");
|
|
43
|
-
const unique = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
44
|
-
const envDir = path.join(root, unique);
|
|
45
|
-
await mkdir(envDir, { recursive: true });
|
|
46
|
-
return {
|
|
47
|
-
envDir,
|
|
48
|
-
env: {
|
|
49
|
-
NODE_V8_COVERAGE: envDir,
|
|
50
|
-
},
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface FinaliseOptions extends CoverageOptions {
|
|
55
|
-
root: string;
|
|
56
|
-
session: CoverageSession;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface FinaliseResult {
|
|
60
|
-
summary: CoverageSummary;
|
|
61
|
-
violations: ThresholdViolation[];
|
|
62
|
-
textReport: string;
|
|
63
|
-
/** Absolute paths of written report files (lcov + json-summary). */
|
|
64
|
-
reportFiles: string[];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const KNOWN_REPORTERS = new Set(["text-summary", "lcov", "json-summary"]);
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* After all workers have exited, read the raw v8 output, aggregate, emit
|
|
71
|
-
* reporters + enforce thresholds.
|
|
72
|
-
*
|
|
73
|
-
* The temp dir is removed in `finally` so a write/aggregate failure
|
|
74
|
-
* doesn't leak gigabytes of raw V8 JSON under `.helix-coverage/`.
|
|
75
|
-
*/
|
|
76
|
-
export async function finalise(opts: FinaliseOptions): Promise<FinaliseResult> {
|
|
77
|
-
try {
|
|
78
|
-
const raw = await collect(opts.session.envDir);
|
|
79
|
-
const filtered = filter(raw, {
|
|
80
|
-
root: opts.root,
|
|
81
|
-
include: opts.include,
|
|
82
|
-
exclude: opts.exclude,
|
|
83
|
-
});
|
|
84
|
-
const summary = aggregate(filtered);
|
|
85
|
-
|
|
86
|
-
// Treat empty array as "use defaults" — same as undefined.
|
|
87
|
-
const requested =
|
|
88
|
-
opts.reporters && opts.reporters.length > 0
|
|
89
|
-
? opts.reporters
|
|
90
|
-
: ["text-summary", "lcov"];
|
|
91
|
-
// Warn on unknown reporter names so typos surface (instead of the
|
|
92
|
-
// reporter silently being skipped).
|
|
93
|
-
for (const r of requested) {
|
|
94
|
-
if (!KNOWN_REPORTERS.has(r)) {
|
|
95
|
-
process.stderr.write(
|
|
96
|
-
`helix-coverage: unknown reporter "${r}" — known: ${[...KNOWN_REPORTERS].join(", ")}\n`,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const outputDir = opts.outputDir ?? path.join(opts.root, "coverage");
|
|
101
|
-
await mkdir(outputDir, { recursive: true });
|
|
102
|
-
|
|
103
|
-
const reportFiles: string[] = [];
|
|
104
|
-
if (requested.includes("lcov")) {
|
|
105
|
-
const file = path.join(outputDir, "lcov.info");
|
|
106
|
-
await writeFile(file, lcov(summary, opts.root), "utf8");
|
|
107
|
-
reportFiles.push(file);
|
|
108
|
-
}
|
|
109
|
-
if (requested.includes("json-summary")) {
|
|
110
|
-
const file = path.join(outputDir, "coverage-summary.json");
|
|
111
|
-
await writeFile(file, jsonSummary(summary, opts.root), "utf8");
|
|
112
|
-
reportFiles.push(file);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const textReport = requested.includes("text-summary")
|
|
116
|
-
? textSummary(summary, opts.root)
|
|
117
|
-
: "";
|
|
118
|
-
|
|
119
|
-
const violations = opts.thresholds ? enforce(summary, opts.thresholds) : [];
|
|
120
|
-
|
|
121
|
-
return { summary, violations, textReport, reportFiles };
|
|
122
|
-
} finally {
|
|
123
|
-
// Always tidy the temp dir — even on aggregate / writeFile failure.
|
|
124
|
-
await rm(opts.session.envDir, { recursive: true, force: true });
|
|
125
|
-
}
|
|
126
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `coverage-summary.json` — Istanbul-compatible shape consumed by CI
|
|
3
|
-
* tooling (lcov-reporter-action, Codecov uploaders that prefer JSON).
|
|
4
|
-
*
|
|
5
|
-
* File keys are emitted as paths RELATIVE to `root` so the artifact is
|
|
6
|
-
* portable across machines. The reserved `total` key holds the
|
|
7
|
-
* aggregate; if a real file path collides with it (extraordinarily
|
|
8
|
-
* unlikely after relativisation), we keep the totals row and skip the
|
|
9
|
-
* collision rather than overwrite — protecting the consumer's invariant.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import path from "node:path";
|
|
13
|
-
import type { CoverageSummary } from "../types.js";
|
|
14
|
-
|
|
15
|
-
function rel(file: string, root: string): string {
|
|
16
|
-
const r = path.relative(root, file).split(path.sep).join("/");
|
|
17
|
-
return r.length > 0 && !r.startsWith("..") ? r : file;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function jsonSummary(summary: CoverageSummary, root: string): string {
|
|
21
|
-
// Use a fresh prototype-less object so a pathological file path of
|
|
22
|
-
// "__proto__" can't pollute the global Object prototype.
|
|
23
|
-
const out: Record<string, unknown> = Object.create(null);
|
|
24
|
-
out.total = summary.total;
|
|
25
|
-
for (const f of summary.files) {
|
|
26
|
-
const key = rel(f.file, root);
|
|
27
|
-
if (key === "total") {
|
|
28
|
-
// Astronomical edge: a file literally named "total" at the root.
|
|
29
|
-
// Prefer correctness of the totals row over reporting that file.
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
out[key] = {
|
|
33
|
-
lines: f.lines,
|
|
34
|
-
statements: f.statements,
|
|
35
|
-
functions: f.functions,
|
|
36
|
-
branches: f.branches,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
return `${JSON.stringify(out, null, 2)}\n`;
|
|
40
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LCOV reporter — writes standard `lcov.info` that Codecov, Coveralls,
|
|
3
|
-
* and IDE coverage gutters consume directly.
|
|
4
|
-
*
|
|
5
|
-
* Format reference: `man geninfo` /
|
|
6
|
-
* http://ltp.sourceforge.net/coverage/lcov/geninfo.1.php
|
|
7
|
-
*
|
|
8
|
-
* Paths in `SF:` are emitted RELATIVE TO `root` so the same artifact
|
|
9
|
-
* lines up across machines (developer laptop, CI runner, Codecov server).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import path from "node:path";
|
|
13
|
-
import type { CoverageSummary } from "../types.js";
|
|
14
|
-
|
|
15
|
-
function rel(file: string, root: string): string {
|
|
16
|
-
const r = path.relative(root, file).split(path.sep).join("/");
|
|
17
|
-
return r.length > 0 && !r.startsWith("..") ? r : file;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function lcov(summary: CoverageSummary, root: string): string {
|
|
21
|
-
const out: string[] = [];
|
|
22
|
-
for (const f of summary.files) {
|
|
23
|
-
out.push(`TN:`);
|
|
24
|
-
out.push(`SF:${rel(f.file, root)}`);
|
|
25
|
-
|
|
26
|
-
let fnf = 0;
|
|
27
|
-
let fnh = 0;
|
|
28
|
-
for (const fn of f.functionHits) {
|
|
29
|
-
// Skip records lcov rejects (line < 1).
|
|
30
|
-
if (fn.line < 1) continue;
|
|
31
|
-
out.push(`FN:${fn.line},${fn.name}`);
|
|
32
|
-
out.push(`FNDA:${fn.count},${fn.name}`);
|
|
33
|
-
fnf += 1;
|
|
34
|
-
if (fn.count > 0) fnh += 1;
|
|
35
|
-
}
|
|
36
|
-
out.push(`FNF:${fnf}`);
|
|
37
|
-
out.push(`FNH:${fnh}`);
|
|
38
|
-
|
|
39
|
-
let lf = 0;
|
|
40
|
-
let lh = 0;
|
|
41
|
-
for (const l of f.lineHits) {
|
|
42
|
-
out.push(`DA:${l.line},${l.count}`);
|
|
43
|
-
lf += 1;
|
|
44
|
-
if (l.count > 0) lh += 1;
|
|
45
|
-
}
|
|
46
|
-
out.push(`LF:${lf}`);
|
|
47
|
-
out.push(`LH:${lh}`);
|
|
48
|
-
|
|
49
|
-
out.push(`BRF:0`);
|
|
50
|
-
out.push(`BRH:0`);
|
|
51
|
-
out.push(`end_of_record`);
|
|
52
|
-
}
|
|
53
|
-
return `${out.join("\n")}\n`;
|
|
54
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Text summary reporter — one line per file + totals, written to stdout.
|
|
3
|
-
* Deliberately terse (mirrors c8's default output) so it fits alongside
|
|
4
|
-
* the test reporter's summary without overwhelming.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { CoverageSummary } from "../types.js";
|
|
8
|
-
|
|
9
|
-
function fmtPct(n: number): string {
|
|
10
|
-
return `${n.toFixed(2).padStart(6)}%`;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function padRight(s: string, w: number): string {
|
|
14
|
-
if (s.length === w) return s;
|
|
15
|
-
if (s.length > w) {
|
|
16
|
-
// Ellipsize from the LEFT — preserves the filename tail which is
|
|
17
|
-
// usually more informative than the leading directory.
|
|
18
|
-
return `…${s.slice(s.length - w + 1)}`;
|
|
19
|
-
}
|
|
20
|
-
return s + " ".repeat(w - s.length);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function textSummary(summary: CoverageSummary, root: string): string {
|
|
24
|
-
const lines: string[] = [];
|
|
25
|
-
lines.push("");
|
|
26
|
-
lines.push("── Coverage ─────────────────────────────────────────────────");
|
|
27
|
-
lines.push(
|
|
28
|
-
`${padRight("File", 50)} ${"Lines".padStart(8)} ${"Funcs".padStart(8)}`,
|
|
29
|
-
);
|
|
30
|
-
lines.push("─".repeat(70));
|
|
31
|
-
for (const f of summary.files) {
|
|
32
|
-
const rel = f.file.startsWith(root)
|
|
33
|
-
? f.file.slice(root.length + 1)
|
|
34
|
-
: f.file;
|
|
35
|
-
const linesPct =
|
|
36
|
-
f.lines.total > 0 ? (f.lines.covered / f.lines.total) * 100 : 100;
|
|
37
|
-
const fnPct =
|
|
38
|
-
f.functions.total > 0
|
|
39
|
-
? (f.functions.covered / f.functions.total) * 100
|
|
40
|
-
: 100;
|
|
41
|
-
lines.push(`${padRight(rel, 50)} ${fmtPct(linesPct)} ${fmtPct(fnPct)}`);
|
|
42
|
-
}
|
|
43
|
-
lines.push("─".repeat(70));
|
|
44
|
-
lines.push(
|
|
45
|
-
`${padRight("All files", 50)} ${fmtPct(summary.total.lines.pct)} ${fmtPct(summary.total.functions.pct)}`,
|
|
46
|
-
);
|
|
47
|
-
return `${lines.join("\n")}\n`;
|
|
48
|
-
}
|