@decohere/ci-tools 0.0.1-dev.10

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.
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ const WORKSPACE_ROOT = process.cwd();
8
+ const VITEST_BIN = join(WORKSPACE_ROOT, "node_modules/.bin/vitest");
9
+ const CONFIG = join(WORKSPACE_ROOT, "vitest.e2e.config.ts");
10
+ const TRACE_SPEC = "projects/terminal/e2e/build.trace.e2e.spec.ts";
11
+ const TRACE_LOG_DIR = join(WORKSPACE_ROOT, ".decohere", "logs");
12
+
13
+ function truthy(value, defaultValue = false) {
14
+ if (value == null) return defaultValue;
15
+ return !["0", "false", "no", "off"].includes(String(value).toLowerCase());
16
+ }
17
+
18
+ function toFileTimestamp(date = new Date()) {
19
+ const pad = (n) => String(n).padStart(2, "0");
20
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
21
+ }
22
+
23
+ function toTraceSample(line, parsed, durationMs) {
24
+ return {
25
+ time: parsed.time,
26
+ name: parsed.name,
27
+ message: parsed.msg,
28
+ durationMs,
29
+ line,
30
+ };
31
+ }
32
+
33
+ function parseTraceSamples(rawOutput) {
34
+ const samples = [];
35
+ for (const rawLine of rawOutput.split(/\r?\n/)) {
36
+ const line = rawLine.trim();
37
+ if (!line.startsWith("{")) continue;
38
+
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(line);
42
+ } catch {
43
+ continue;
44
+ }
45
+
46
+ if (typeof parsed?.name !== "string" || typeof parsed?.msg !== "string") continue;
47
+ const match = parsed.msg.match(/^(.*):\s+([0-9]+(?:\.[0-9]+)?)ms$/);
48
+ if (match == null) continue;
49
+
50
+ const durationMs = Number(match[2]);
51
+ if (!Number.isFinite(durationMs)) continue;
52
+
53
+ samples.push({
54
+ className: parsed.name,
55
+ methodLabel: match[1],
56
+ methodName: match[1].split("(")[0],
57
+ durationMs,
58
+ sample: toTraceSample(rawLine, parsed, durationMs),
59
+ });
60
+ }
61
+ return samples;
62
+ }
63
+
64
+ function aggregateSamples(samples, keyBuilder) {
65
+ const aggregates = new Map();
66
+ for (const sample of samples) {
67
+ const key = keyBuilder(sample);
68
+ const current = aggregates.get(key) ?? {
69
+ key,
70
+ hitCount: 0,
71
+ totalMs: 0,
72
+ maxMs: 0,
73
+ };
74
+ current.hitCount += 1;
75
+ current.totalMs += sample.durationMs;
76
+ if (sample.durationMs > current.maxMs) current.maxMs = sample.durationMs;
77
+ aggregates.set(key, current);
78
+ }
79
+ return [...aggregates.values()];
80
+ }
81
+
82
+ function analyzeTraceOutput(rawOutput) {
83
+ const samples = parseTraceSamples(rawOutput);
84
+ if (samples.length === 0) {
85
+ process.stderr.write("\n⚠ No structured trace samples found to analyze.\n");
86
+ return;
87
+ }
88
+
89
+ const byMethod = aggregateSamples(
90
+ samples,
91
+ (sample) => `${sample.className}.${sample.methodName}`,
92
+ );
93
+ const byClass = aggregateSamples(samples, (sample) => sample.className);
94
+ const byHits = [...byMethod].sort((a, b) => b.hitCount - a.hitCount).slice(0, 10);
95
+ const byTotal = [...byMethod].sort((a, b) => b.totalMs - a.totalMs).slice(0, 10);
96
+ const byMax = [...byMethod].sort((a, b) => b.maxMs - a.maxMs).slice(0, 10);
97
+ const byClassTotal = [...byClass].sort((a, b) => b.totalMs - a.totalMs).slice(0, 5);
98
+ const totalTraceMs = samples.reduce((sum, sample) => sum + sample.durationMs, 0);
99
+ const hottest = [...samples].sort((a, b) => b.durationMs - a.durationMs).slice(0, 5);
100
+
101
+ process.stderr.write(
102
+ "\n┌─ Trace Analysis ─────────────────────────────────────────────────────────\n",
103
+ );
104
+ process.stderr.write(`│ Samples : ${samples.length}\n`);
105
+ process.stderr.write(`│ Unique funcs : ${byMethod.length}\n`);
106
+ process.stderr.write(`│ Total traced : ${totalTraceMs.toFixed(2)}ms\n`);
107
+ process.stderr.write(`│ Avg sample : ${(totalTraceMs / samples.length).toFixed(3)}ms\n`);
108
+ process.stderr.write(
109
+ "└──────────────────────────────────────────────────────────────────────────\n",
110
+ );
111
+
112
+ const printTable = (title, rows, valueFormatter) => {
113
+ process.stderr.write(`\n${title}\n`);
114
+ for (const row of rows) {
115
+ process.stderr.write(
116
+ ` ${valueFormatter(row).padStart(10)} ${row.key} (hits=${row.hitCount}, max=${row.maxMs.toFixed(2)}ms)\n`,
117
+ );
118
+ }
119
+ };
120
+
121
+ printTable("Top Functions By Hit Count", byHits, (row) => String(row.hitCount));
122
+ printTable("Top Functions By Total Time", byTotal, (row) => `${row.totalMs.toFixed(2)}ms`);
123
+ printTable(
124
+ "Top Functions By Worst Single Call",
125
+ byMax,
126
+ (row) => `${row.maxMs.toFixed(2)}ms`,
127
+ );
128
+ printTable(
129
+ "Top Classes By Total Trace Time",
130
+ byClassTotal,
131
+ (row) => `${row.totalMs.toFixed(2)}ms`,
132
+ );
133
+
134
+ process.stderr.write("\nSlowest Individual Samples\n");
135
+ for (const hot of hottest) {
136
+ process.stderr.write(
137
+ ` ${hot.durationMs.toFixed(2).padStart(10)}ms ${hot.className}.${hot.methodLabel}\n`,
138
+ );
139
+ }
140
+ }
141
+
142
+ const shouldAnalyzeTrace = truthy(process.env.BENCH_ANALYZE_TRACE, true);
143
+ const shouldCollectTraceLog = truthy(process.env.BENCH_TRACE_LOG, true);
144
+ const traceLogPath = shouldCollectTraceLog
145
+ ? join(TRACE_LOG_DIR, `perf-trace-${toFileTimestamp()}.log`)
146
+ : null;
147
+
148
+ if (traceLogPath != null) {
149
+ mkdirSync(TRACE_LOG_DIR, { recursive: true });
150
+ }
151
+
152
+ const result = spawnSync(VITEST_BIN, ["run", "--config", CONFIG, TRACE_SPEC], {
153
+ cwd: WORKSPACE_ROOT,
154
+ stdio: "pipe",
155
+ encoding: "utf8",
156
+ maxBuffer: 1024 * 1024 * 200,
157
+ env: {
158
+ ...process.env,
159
+ NODE_OPTIONS: "",
160
+ FORGE_TRACE: "1",
161
+ FORGE_TRACE_STRUCTURED: "1",
162
+ LOG_LEVEL: process.env.LOG_LEVEL ?? "trace",
163
+ },
164
+ });
165
+
166
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
167
+ const stderr = typeof result.stderr === "string" ? result.stderr : "";
168
+ if (stdout.length > 0) process.stdout.write(stdout);
169
+ if (stderr.length > 0) process.stderr.write(stderr);
170
+
171
+ const combined = `${stdout}${stderr.length > 0 ? `\n${stderr}` : ""}`;
172
+ if (traceLogPath != null) {
173
+ writeFileSync(traceLogPath, combined);
174
+ process.stderr.write(`\n✓ Trace log written: ${traceLogPath}\n`);
175
+ }
176
+
177
+ if (shouldAnalyzeTrace) {
178
+ analyzeTraceOutput(combined);
179
+ }
180
+
181
+ if (result.status !== 0) {
182
+ process.exit(result.status ?? 1);
183
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Aggregates pending .changeset/*.md entries into the root CHANGELOG.md,
3
+ * then deletes the consumed entry files.
4
+ *
5
+ * Changeset entry format:
6
+ * ---
7
+ * "@pkg/name": major|minor|patch
8
+ * ---
9
+ * Human-readable description of the change.
10
+ *
11
+ * Usage: node aggregate-changelog.mjs [--dry-run]
12
+ * --dry-run Print what would be written without modifying any files.
13
+ */
14
+
15
+ import { readdir, readFile, rm, writeFile } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+
18
+ const dryRun = process.argv.includes("--dry-run");
19
+ const root = process.cwd();
20
+ const changesetDir = join(root, ".changeset");
21
+
22
+ /** Bump type precedence (higher = more significant) */
23
+ const PRECEDENCE = { major: 3, minor: 2, patch: 1, prerelease: 0 };
24
+
25
+ /** Section headers for each bump type */
26
+ const SECTION_HEADINGS = {
27
+ major: "Breaking Changes",
28
+ minor: "Minor Changes",
29
+ patch: "Patch Changes",
30
+ };
31
+
32
+ let entries;
33
+ try {
34
+ const allFiles = await readdir(changesetDir);
35
+ entries = allFiles.filter((f) => f.endsWith(".md") && f !== "README.md");
36
+ } catch {
37
+ console.log("No .changeset directory found. Nothing to aggregate.");
38
+ process.exit(0);
39
+ }
40
+
41
+ if (entries.length === 0) {
42
+ console.log("No pending changeset entries found. Nothing to aggregate.");
43
+ process.exit(0);
44
+ }
45
+
46
+ /** Parse a single changeset file → { packages: Record<string, string>, body: string } */
47
+ function parseEntry(content) {
48
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
49
+ if (!match) return null;
50
+ const packages = {};
51
+ for (const line of match[1].split("\n")) {
52
+ const m = line.match(/^"([^"]+)"\s*:\s*(\w+)/);
53
+ if (m) packages[m[1]] = m[2];
54
+ }
55
+ return { packages, body: match[2].trim() };
56
+ }
57
+
58
+ /** Group entries into sections by the highest bump type they declare */
59
+ const sections = { major: [], minor: [], patch: [] };
60
+
61
+ for (const file of entries) {
62
+ const content = await readFile(join(changesetDir, file), "utf8");
63
+ const parsed = parseEntry(content);
64
+ if (!parsed) {
65
+ console.warn(`Skipping malformed changeset: ${file}`);
66
+ continue;
67
+ }
68
+
69
+ const bumpTypes = Object.values(parsed.packages);
70
+ const highestBump = bumpTypes.reduce((max, t) => {
71
+ return (PRECEDENCE[t] ?? -1) > (PRECEDENCE[max] ?? -1) ? t : max;
72
+ }, "patch");
73
+
74
+ const section = sections[highestBump] ?? sections.patch;
75
+ const pkgList = Object.keys(parsed.packages)
76
+ .map((p) => `\`${p}\``)
77
+ .join(", ");
78
+ section.push(`- **${pkgList}**: ${parsed.body}`);
79
+ }
80
+
81
+ const date = new Date().toISOString().slice(0, 10);
82
+ const sectionBlocks = [];
83
+
84
+ for (const [type, heading] of Object.entries(SECTION_HEADINGS)) {
85
+ if (sections[type].length > 0) {
86
+ sectionBlocks.push(`### ${heading}\n\n${sections[type].join("\n")}`);
87
+ }
88
+ }
89
+
90
+ if (sectionBlocks.length === 0) {
91
+ console.log("No parseable changeset entries. Nothing written.");
92
+ process.exit(0);
93
+ }
94
+
95
+ const newEntry = `## ${date}\n\n${sectionBlocks.join("\n\n")}\n\n`;
96
+
97
+ let existing = "";
98
+ try {
99
+ existing = await readFile(join(root, "CHANGELOG.md"), "utf8");
100
+ } catch {
101
+ /* new file */
102
+ }
103
+
104
+ if (dryRun) {
105
+ console.log("[dry-run] Would prepend to CHANGELOG.md:");
106
+ console.log(newEntry);
107
+ console.log(`[dry-run] Would delete ${entries.length} changeset file(s):`, entries);
108
+ process.exit(0);
109
+ }
110
+
111
+ await writeFile(join(root, "CHANGELOG.md"), newEntry + existing);
112
+ console.log(`Wrote aggregated changelog with ${entries.length} changeset(s).`);
113
+
114
+ for (const file of entries) {
115
+ await rm(join(changesetDir, file));
116
+ }
117
+ console.log(`Deleted ${entries.length} consumed changeset file(s).`);
118
+
119
+ console.log(`Wrote aggregated changelog with ${sections.length} package(s).`);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Checks whether there is at least one pending changeset entry describing the
3
+ * nature of the changes (breaking, minor, or patch). Works in workspace/lockstep
4
+ * mode: one entry covering any package is sufficient — you do NOT need an entry
5
+ * per package.
6
+ *
7
+ * Usage: node check-status.mjs [--allow-empty]
8
+ * --allow-empty Exit 0 even when no changeset entries are found (e.g. docs-only PRs)
9
+ */
10
+
11
+ import { readFile } from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { glob } from "glob";
14
+
15
+ const allowEmpty = process.argv.includes("--allow-empty");
16
+ const root = process.cwd();
17
+
18
+ // Find all changeset files
19
+ const changesetFiles = await glob(".changeset/*.md", { cwd: root, absolute: false });
20
+ const pendingChangesets = changesetFiles.filter((f) => !f.endsWith("README.md"));
21
+
22
+ if (pendingChangesets.length === 0 && allowEmpty) {
23
+ console.log("No pending changesets found (--allow-empty: OK).");
24
+ process.exit(0);
25
+ }
26
+
27
+ if (pendingChangesets.length === 0) {
28
+ console.error("No pending changeset files found in .changeset/");
29
+ console.error("Run: pnpm changeset to create one.");
30
+ process.exit(1);
31
+ }
32
+
33
+ // Parse which packages are covered and what bump types are declared
34
+ const coveredPackages = new Set();
35
+ const bumpTypes = new Set();
36
+ for (const filePath of pendingChangesets) {
37
+ const content = await readFile(path.join(root, filePath), "utf8");
38
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
39
+ if (!match) continue;
40
+ for (const line of match[1].split("\n")) {
41
+ const m = line.match(/^"([^"]+)"\s*:\s*(\w+)/);
42
+ if (m) {
43
+ coveredPackages.add(m[1]);
44
+ bumpTypes.add(m[2]);
45
+ }
46
+ }
47
+ }
48
+
49
+ console.log(
50
+ `Found ${pendingChangesets.length} changeset file(s): bump=${[...bumpTypes].join("/")} packages=${[...coveredPackages].join(", ") || "(none)"}`,
51
+ );
52
+ console.log("OK — changeset entries present.");
53
+
54
+ console.log("All changed packages have pending changesets.");
@@ -0,0 +1,57 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import path from "node:path";
10
+ import { parseLcovByProject } from "../lib/lcov.mjs";
11
+ import { createBadgeSvg, getColor } from "../lib/svg.mjs";
12
+
13
+ const summaryPath = path.resolve("dist/coverage/coverage-summary.json");
14
+ const lcovPath = path.resolve("dist/coverage/lcov.info");
15
+ const badgesDirectory = path.resolve("projects/ci-tools/dist/ci/badges");
16
+ const globalBadgePath = path.join(badgesDirectory, "coverage.svg");
17
+ const projectBadgesDirectory = path.join(badgesDirectory, "projects");
18
+
19
+ if (!existsSync(summaryPath)) {
20
+ throw new Error(`Coverage summary not found: ${summaryPath}`);
21
+ }
22
+
23
+ if (!existsSync(lcovPath)) {
24
+ throw new Error(`LCOV file not found: ${lcovPath}`);
25
+ }
26
+
27
+ const summary = JSON.parse(readFileSync(summaryPath, "utf8"));
28
+ const linesCoverage = summary?.total?.lines?.pct;
29
+
30
+ if (typeof linesCoverage !== "number" || Number.isNaN(linesCoverage)) {
31
+ throw new Error("Invalid coverage summary: missing total.lines.pct");
32
+ }
33
+
34
+ const percentage = Number(linesCoverage.toFixed(1));
35
+ const color = getColor(percentage);
36
+
37
+ mkdirSync(badgesDirectory, { recursive: true });
38
+ writeFileSync(globalBadgePath, createBadgeSvg("Coverage", `${percentage}%`, color));
39
+
40
+ mkdirSync(projectBadgesDirectory, { recursive: true });
41
+ for (const entry of existsSync(projectBadgesDirectory)
42
+ ? readdirSync(projectBadgesDirectory)
43
+ : []) {
44
+ if (entry.endsWith(".svg")) {
45
+ rmSync(path.join(projectBadgesDirectory, entry));
46
+ }
47
+ }
48
+
49
+ const projectCoverage = parseLcovByProject(readFileSync(lcovPath, "utf8"));
50
+ for (const [project, metrics] of [...projectCoverage.entries()].sort(([a], [b]) =>
51
+ a.localeCompare(b),
52
+ )) {
53
+ if (metrics.totalLines === 0) continue;
54
+ const pct = Number(((metrics.coveredLines / metrics.totalLines) * 100).toFixed(1));
55
+ const projectBadgePath = path.join(projectBadgesDirectory, `${project}.svg`);
56
+ writeFileSync(projectBadgePath, createBadgeSvg(project, `${pct}%`, getColor(pct)));
57
+ }
@@ -0,0 +1,144 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { parseLcovByProject } from "../lib/lcov.mjs";
5
+
6
+ function parseArgs(argv) {
7
+ const options = {
8
+ lcov: ".forge/coverage/lcov.info",
9
+ thresholds: null,
10
+ thresholdsModule: null,
11
+ projectsDir: "projects",
12
+ };
13
+
14
+ for (let index = 0; index < argv.length; index += 1) {
15
+ const arg = argv[index];
16
+ if (arg === "--lcov") {
17
+ options.lcov = argv[index + 1] ?? options.lcov;
18
+ index += 1;
19
+ continue;
20
+ }
21
+ if (arg === "--thresholds") {
22
+ options.thresholds = argv[index + 1] ?? options.thresholds;
23
+ index += 1;
24
+ continue;
25
+ }
26
+ if (arg === "--thresholds-module") {
27
+ options.thresholdsModule = argv[index + 1] ?? options.thresholdsModule;
28
+ index += 1;
29
+ continue;
30
+ }
31
+ if (arg === "--projectsDir") {
32
+ options.projectsDir = argv[index + 1] ?? options.projectsDir;
33
+ index += 1;
34
+ }
35
+ }
36
+
37
+ return options;
38
+ }
39
+
40
+ async function resolveThresholds(options) {
41
+ if (options.thresholdsModule) {
42
+ const moduleUrl = pathToFileURL(resolve(options.thresholdsModule)).href;
43
+ const mod = await import(moduleUrl);
44
+ const thresholds = mod.projectCoverageThresholds;
45
+ if (typeof thresholds !== "object" || thresholds == null) {
46
+ console.error(
47
+ `Module ${options.thresholdsModule} must export a 'projectCoverageThresholds' object.`,
48
+ );
49
+ process.exit(1);
50
+ }
51
+ return thresholds;
52
+ }
53
+
54
+ const path = options.thresholds ?? "coverage.config.json";
55
+ if (!existsSync(path)) {
56
+ console.error(`Thresholds config not found: ${path}`);
57
+ process.exit(1);
58
+ }
59
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
60
+ if (typeof parsed !== "object" || parsed == null) {
61
+ console.error(
62
+ "Thresholds config must be a JSON object mapping project => minimum coverage percentage.",
63
+ );
64
+ process.exit(1);
65
+ }
66
+ return parsed;
67
+ }
68
+
69
+ function formatPct(value) {
70
+ return `${value.toFixed(2)}%`;
71
+ }
72
+
73
+ async function main() {
74
+ const options = parseArgs(process.argv.slice(2));
75
+ const lcovPath = resolve(options.lcov);
76
+ const projectsDirPath = resolve(options.projectsDir);
77
+
78
+ if (!existsSync(lcovPath)) {
79
+ console.error(`LCOV file not found: ${lcovPath}`);
80
+ console.error("Run coverage first (example: npm run test:coverage)");
81
+ process.exit(1);
82
+ }
83
+
84
+ if (!existsSync(projectsDirPath)) {
85
+ console.error(`Projects directory not found: ${projectsDirPath}`);
86
+ process.exit(1);
87
+ }
88
+
89
+ const thresholds = await resolveThresholds(options);
90
+ const projectDirs = readdirSync(projectsDirPath, { withFileTypes: true })
91
+ .filter((entry) => entry.isDirectory())
92
+ .map((entry) => entry.name)
93
+ .sort((left, right) => left.localeCompare(right));
94
+
95
+ const coverageByProject = parseLcovByProject(readFileSync(lcovPath, "utf8"));
96
+
97
+ const missingThresholds = projectDirs.filter(
98
+ (projectName) => thresholds[projectName] == null,
99
+ );
100
+ if (missingThresholds.length > 0) {
101
+ console.error("Missing coverage thresholds for projects:");
102
+ for (const projectName of missingThresholds) console.error(`- ${projectName}`);
103
+ console.error(
104
+ "Update projectCoverageThresholds in vitest.config.ts to include all project directories.",
105
+ );
106
+ process.exit(1);
107
+ }
108
+
109
+ const failures = [];
110
+ console.log("Project line coverage thresholds");
111
+
112
+ for (const projectName of projectDirs) {
113
+ const projectCoverage = coverageByProject.get(projectName) ?? {
114
+ totalLines: 0,
115
+ coveredLines: 0,
116
+ };
117
+ const currentPct =
118
+ projectCoverage.totalLines === 0
119
+ ? 0
120
+ : (projectCoverage.coveredLines / projectCoverage.totalLines) * 100;
121
+ const minimumPct = Number(thresholds[projectName]);
122
+ const passed = Number.isFinite(minimumPct) && currentPct >= minimumPct;
123
+
124
+ console.log(
125
+ `${passed ? "✅" : "❌"} ${projectName}: ${formatPct(currentPct)} (min ${formatPct(minimumPct)})`,
126
+ );
127
+
128
+ if (!passed) failures.push({ projectName, currentPct, minimumPct });
129
+ }
130
+
131
+ if (failures.length > 0) {
132
+ console.error("\nCoverage threshold check failed:");
133
+ for (const failure of failures) {
134
+ console.error(
135
+ `- ${failure.projectName}: ${formatPct(failure.currentPct)} is below ${formatPct(failure.minimumPct)}`,
136
+ );
137
+ }
138
+ process.exit(1);
139
+ }
140
+
141
+ console.log("\nAll project coverage thresholds passed.");
142
+ }
143
+
144
+ main();
@@ -0,0 +1,138 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { parseLcovFiles } from "../lib/lcov.mjs";
4
+
5
+ function parseArgs(argv) {
6
+ const options = {
7
+ lcov: ".forge/coverage/lcov.info",
8
+ project: undefined,
9
+ pathPrefix: undefined,
10
+ top: undefined,
11
+ json: false,
12
+ };
13
+
14
+ for (let index = 0; index < argv.length; index += 1) {
15
+ const arg = argv[index];
16
+ if (arg === "--lcov") {
17
+ options.lcov = argv[index + 1] ?? options.lcov;
18
+ index += 1;
19
+ continue;
20
+ }
21
+ if (arg === "--project") {
22
+ options.project = argv[index + 1] ?? options.project;
23
+ index += 1;
24
+ continue;
25
+ }
26
+ if (arg === "--pathPrefix") {
27
+ options.pathPrefix = argv[index + 1] ?? options.pathPrefix;
28
+ index += 1;
29
+ continue;
30
+ }
31
+ if (arg === "--top") {
32
+ const parsed = Number(argv[index + 1]);
33
+ if (!Number.isNaN(parsed) && parsed > 0) options.top = parsed;
34
+ index += 1;
35
+ continue;
36
+ }
37
+ if (arg === "--json") options.json = true;
38
+ }
39
+
40
+ return options;
41
+ }
42
+
43
+ function formatLineRanges(lines) {
44
+ if (lines.length === 0) return "-";
45
+ const ranges = [];
46
+ let rangeStart = lines[0];
47
+ let previous = lines[0];
48
+ for (const current of lines.slice(1)) {
49
+ if (current === previous + 1) {
50
+ previous = current;
51
+ continue;
52
+ }
53
+ ranges.push(rangeStart === previous ? `${rangeStart}` : `${rangeStart}-${previous}`);
54
+ rangeStart = current;
55
+ previous = current;
56
+ }
57
+ ranges.push(rangeStart === previous ? `${rangeStart}` : `${rangeStart}-${previous}`);
58
+ return ranges.join(",");
59
+ }
60
+
61
+ function padRight(value, width) {
62
+ const text = String(value);
63
+ return text.length >= width ? text : text + " ".repeat(width - text.length);
64
+ }
65
+
66
+ function printHumanReport(files, options) {
67
+ const totalLines = files.reduce((sum, file) => sum + file.totalLines, 0);
68
+ const coveredLines = files.reduce((sum, file) => sum + file.coveredLines, 0);
69
+ const uncoveredLines = totalLines - coveredLines;
70
+ const linePct = totalLines === 0 ? 100 : (coveredLines / totalLines) * 100;
71
+
72
+ const headerProject = options.project == null ? "all projects" : options.project;
73
+ console.log(`Uncovered lines report (${headerProject})`);
74
+ console.log(
75
+ `Scope lines: ${coveredLines}/${totalLines} covered (${linePct.toFixed(2)}%), ${uncoveredLines} uncovered`,
76
+ );
77
+ console.log("");
78
+
79
+ if (files.length === 0) {
80
+ console.log("No uncovered files found for this scope.");
81
+ return;
82
+ }
83
+
84
+ const pathWidth = Math.min(
85
+ 100,
86
+ Math.max("File".length, ...files.map((file) => file.sourcePath.length)),
87
+ );
88
+ console.log(
89
+ `${padRight("Uncovered", 10)} ${padRight("File", pathWidth)} ${padRight("Line %", 7)} Uncovered line ranges`,
90
+ );
91
+ console.log(`${"-".repeat(10)} ${"-".repeat(pathWidth)} ${"-".repeat(7)} ${"-".repeat(24)}`);
92
+
93
+ for (const file of files) {
94
+ const pct = file.totalLines === 0 ? 100 : (file.coveredLines / file.totalLines) * 100;
95
+ console.log(
96
+ `${padRight(file.uncoveredLines, 10)} ${padRight(file.sourcePath, pathWidth)} ${padRight(pct.toFixed(2), 7)} ${formatLineRanges(file.uncoveredLineNumbers)}`,
97
+ );
98
+ }
99
+ }
100
+
101
+ const options = parseArgs(process.argv.slice(2));
102
+ const lcovPath = resolve(options.lcov);
103
+
104
+ if (!existsSync(lcovPath)) {
105
+ console.error(`LCOV file not found: ${lcovPath}`);
106
+ console.error("Run coverage first (example: npm run test:coverage)");
107
+ process.exit(1);
108
+ }
109
+
110
+ const files = parseLcovFiles(readFileSync(lcovPath, "utf8"));
111
+ const filtered = files.filter((file) => {
112
+ if (options.project == null) {
113
+ return options.pathPrefix == null
114
+ ? file.project != null
115
+ : file.sourcePath.startsWith(options.pathPrefix);
116
+ }
117
+ if (file.project !== options.project) return false;
118
+ return options.pathPrefix == null || file.sourcePath.startsWith(options.pathPrefix);
119
+ });
120
+
121
+ const uncoveredOnly = filtered
122
+ .filter((file) => file.uncoveredLines > 0)
123
+ .sort(
124
+ (left, right) =>
125
+ right.uncoveredLines - left.uncoveredLines ||
126
+ left.sourcePath.localeCompare(right.sourcePath),
127
+ );
128
+
129
+ const ranked = options.top == null ? uncoveredOnly : uncoveredOnly.slice(0, options.top);
130
+
131
+ if (options.json) {
132
+ console.log(
133
+ JSON.stringify({ scope: options.project ?? "all", lcovPath, files: ranked }, null, 2),
134
+ );
135
+ process.exit(0);
136
+ }
137
+
138
+ printHumanReport(ranked, options);