bin/no-bloat.mjs
220 lines and 15 functions in DrDustinEdwards/devkit, at commit 505cda4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| knipCounts | 41 to 46 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| measure | 56 to 86 | 9 | 94% | WatchCRAP 9 | No data: mutation testing has not run on this repo yet |
| compare | 95 to 131 | 29 | 100% | WatchCRAP 29 | No data: mutation testing has not run on this repo yet |
| before | 100 to 100 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:113> | 113 to 113 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| render | 140 to 151 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| show | 141 to 141 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:147> | 147 to 147 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| <anonymous:149> | 149 to 149 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| npxArgs | 158 to 160 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| runnerIn | 163 to 175 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:165> | 165 to 174 | 7 | 100% | WatchCRAP 7 | No data: mutation testing has not run on this repo yet |
| messageOf | 177 to 179 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| shortHead | 181 to 187 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| main | 189 to 239 | 17 | 100% | WatchCRAP 17 | No data: mutation testing has not run on this repo yet |
Imports
It imports no other module of this repo. No module of this repo imports it.
Source
#!/usr/bin/env node
// The no-bloat ratchet. Knip for unused files, exports and dependencies, jscpd for duplicated blocks, each count compared
// with the repository's own baseline. A count above its baseline fails the run (exit 1, an ::error:: line per count); a count
// at or below it passes, and one that went down is named so the baseline can be lowered to lock the gain in. A tool that
// cannot run fails the run too (an ::error:: naming its error): a count nobody measured is not a pass. The table goes to the
// job summary.
//
// npx devkit-no-bloat report against .no-bloat.json (no file: one knip run, jscpd on its own config)
// npx devkit-no-bloat --config <file> another config
// npx devkit-no-bloat --write-baseline measure and record the counts as the config's baseline
// npx devkit-no-bloat --warn-only the same report, every error a warning, exit 0: a report, never a gate
//
// The config, every field optional:
//
// {
// "knip": [{ "label": "knip (Worker)" }, { "label": "knip (dashboard)", "args": ["--directory", "dashboard"] }],
// "jscpd": { "args": ["app", "workers", "--min-lines", "8"] },
// "tools": { "knip": "knip@6", "jscpd": "jscpd@4" },
// "baseline": { "measured_at": "036d615", "knip (Worker)": { "Unused exports": 12 }, "jscpd": { "clones": 23, "percentage": 0.59 } }
// }
//
// A tool the repository installs is run from node_modules/.bin; otherwise through npx at the spec in `tools`.
import { execFileSync, spawnSync } from "node:child_process";
import { appendFileSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
export const DEFAULT_TOOLS = { knip: "knip@6", jscpd: "jscpd@4" };
/**
* @typedef {{ label: string, args?: string[] }} KnipRun
* @typedef {{ knip?: KnipRun[], jscpd?: { args?: string[] } | false, tools?: { knip?: string, jscpd?: string },
* baseline?: Record<string, unknown> & { measured_at?: string } }} Config
* @typedef {(name: "knip" | "jscpd", args: string[]) => string} Runner
* @typedef {Record<string, Record<string, number>>} Counts
*/
/** Knip's compact report as counts per heading, e.g. { "Unused exports": 12 }. */
export function knipCounts(/** @type {string} */ text) {
/** @type {Record<string, number>} */
const counts = {};
for (const m of text.matchAll(/^([A-Z][A-Za-z ]+) \((\d+)\)$/gm)) counts[m[1]] = Number(m[2]);
return counts;
}
/**
* Runs every tool the config names and returns the counts, keyed as the baseline is, and a warning for each tool that could
* not run.
* @param {Config} config
* @param {Runner} run
* @param {boolean} [jscpdConfigured] whether the repository has its own .jscpd.json, which then names the paths
* @returns {{ counts: Counts, warnings: string[] }}
*/
export function measure(config, run, jscpdConfigured = true) {
/** @type {Counts} */
const counts = {};
const warnings = [];
for (const { label, args = [] } of config.knip ?? [{ label: "knip" }]) {
try {
counts[label] = knipCounts(run("knip", [...args, "--no-exit-code", "--no-progress", "--reporter", "compact"]));
} catch (error) {
warnings.push(`${label} could not run: ${messageOf(error)}`);
}
}
if (config.jscpd !== false) {
const own = config.jscpd?.args ?? [];
// jscpd with no path and no config of its own scans nothing; the repository root is the honest default.
const paths = own.length === 0 && !jscpdConfigured ? ["."] : [];
const out = mkdtempSync(join(tmpdir(), "jscpd-"));
try {
run("jscpd", [...own, ...paths, "--reporters", "json", "--output", out, "--silent"]);
const total = JSON.parse(readFileSync(join(out, "jscpd-report.json"), "utf8")).statistics.total;
counts.jscpd = { clones: total.clones, percentage: Number(Number(total.percentage).toFixed(2)) };
} catch (error) {
warnings.push(`jscpd could not run: ${messageOf(error)}`);
} finally {
rmSync(out, { recursive: true, force: true });
}
}
return { counts, warnings };
}
/**
* One row per finding the baseline or the measurement names, an error for each that grew, and a note for each that went
* down. A finding with no baseline is a row and never an error: there is nothing to have grown from.
* @param {Record<string, unknown>} baseline
* @param {Counts} counts
* @param {KnipRun[]} [knipRuns]
*/
export function compare(baseline, counts, knipRuns = [{ label: "knip" }]) {
/** @type {Array<[string, string, number | null, number]>} */
const rows = [];
const errors = [];
const lowered = [];
const before = (/** @type {string} */ tool) => /** @type {Record<string, number> | null} */ (tool in baseline ? baseline[tool] : null);
for (const [tool, now] of Object.entries(counts)) {
if (tool === "jscpd") continue;
const was = before(tool);
const keys = [...new Set([...Object.keys(was ?? {}), ...Object.keys(now)])];
// A clean run is a row too, so the table shows the tool ran.
if (keys.length === 0) rows.push([tool, "findings", was ? 0 : null, 0]);
for (const key of keys) {
const a = was ? (was[key] ?? 0) : null;
const b = now[key] ?? 0;
rows.push([tool, key, a, b]);
if (a !== null && b > a) {
const args = knipRuns.find((r) => r.label === tool)?.args ?? [];
errors.push(`${tool}: ${key} went from ${a} to ${b}. Run \`npx knip${args.length ? ` ${args.join(" ")}` : ""}\` to see which.`);
}
if (a !== null && b < a) lowered.push(`${tool}: ${key} ${a} to ${b}`);
}
}
if (counts.jscpd) {
const was = before("jscpd");
const now = counts.jscpd;
rows.push(["jscpd", "clones", was ? (was.clones ?? 0) : null, now.clones]);
rows.push(["jscpd", "duplicated lines %", was ? (was.percentage ?? 0) : null, now.percentage]);
if (was && now.clones < (was.clones ?? 0)) lowered.push(`jscpd: clones ${was.clones} to ${now.clones}`);
if (was && now.clones > (was.clones ?? 0)) {
errors.push(`jscpd: clones went from ${was.clones ?? 0} to ${now.clones} (${now.percentage}% of lines, baseline ${was.percentage ?? 0}%). Run \`npx jscpd\` to see them.`);
}
}
return { rows, errors, lowered };
}
/**
* The job summary table.
* @param {Array<[string, string, number | null, number]>} rows
* @param {string[]} problems the errors, then the warnings
* @param {string | undefined} measuredAt
* @param {boolean} [warnOnly]
*/
export function render(rows, problems, measuredAt, warnOnly = false) {
const show = (/** @type {number | null} */ n) => (n === null ? "none" : String(n));
return [
`### No-bloat ${warnOnly ? "report (warn-only, never blocks a merge)" : "ratchet"} (${measuredAt ? `baseline ${measuredAt}` : "no baseline yet: run `npx devkit-no-bloat --write-baseline`"})`,
"",
"| Tool | Finding | Baseline | Now |",
"|---|---|---|---|",
...rows.map(([tool, key, a, b]) => `| ${tool} | ${key} | ${show(a)} | ${b}${a !== null && b > a ? " (up)" : ""} |`),
"",
problems.length ? problems.map((w) => `- ${w}`).join("\n") : "Nothing grew past the baseline.",
].join("\n");
}
/**
* The npx arguments for a tool that is not installed. The package is named with --package, never as the command: run
* under `npx --package <devkit>`, this bin inherits npm_config_package, and a bare `npx knip@6` would then look for a
* command called "knip@6" in devkit.
*/
export function npxArgs(/** @type {string} */ name, /** @type {string} */ spec, /** @type {string[]} */ args) {
return ["--yes", "--package", spec, "--", name, ...args];
}
/** Runs a tool from the repository's node_modules/.bin when it is installed there, through npx at `spec` otherwise. */
export function runnerIn(/** @type {string} */ root, /** @type {{ knip: string, jscpd: string }} */ tools) {
/** @type {Runner} */
return (name, args) => {
const local = join(root, "node_modules", ".bin", process.platform === "win32" ? `${name}.cmd` : name);
const [file, argv] = existsSync(local) ? [local, args] : ["npx", npxArgs(name, tools[name], args)];
// A .cmd shim (and npx itself) on Windows needs cmd.exe; elsewhere the bin runs directly.
const [cmd, all] = process.platform === "win32" ? ["cmd.exe", ["/c", file, ...argv]] : [file, argv];
const r = spawnSync(cmd, all, { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
if (r.error) throw r.error;
if (r.status !== 0) throw new Error(`${name} exited ${r.status}: ${(r.stderr || r.stdout).trim().slice(0, 400)}`);
return r.stdout;
};
}
function messageOf(/** @type {unknown} */ error) {
return error instanceof Error ? error.message : String(error);
}
function shortHead(/** @type {string} */ root) {
try {
return execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
} catch {
return new Date().toISOString().slice(0, 10);
}
}
function main() {
const argv = process.argv.slice(2);
const at = argv.indexOf("--config");
const root = process.cwd();
const file = resolve(root, at >= 0 && argv[at + 1] ? argv[at + 1] : ".no-bloat.json");
const write = argv.includes("--write-baseline");
const warnOnly = argv.includes("--warn-only");
/** @type {Config} */
let config = {};
if (existsSync(file)) {
try {
config = JSON.parse(readFileSync(file, "utf8"));
} catch (error) {
// A broken config would otherwise be no baseline, and so a ratchet that passes everything.
console.error(`devkit-no-bloat: ${file} is not JSON: ${messageOf(error)}`);
process.exit(1);
}
}
const measured = measure(config, runnerIn(root, { ...DEFAULT_TOOLS, ...config.tools }), existsSync(join(root, ".jscpd.json")));
if (write) {
if (measured.warnings.length) {
for (const w of measured.warnings) console.error(`devkit-no-bloat: ${w}`);
console.error("devkit-no-bloat: the baseline was not written, because a tool could not run.");
process.exit(1);
}
config.baseline = { measured_at: shortHead(root), ...measured.counts };
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
console.log(`devkit-no-bloat: wrote the baseline to ${file}`);
return;
}
const { rows, errors, lowered } = compare(config.baseline ?? {}, measured.counts, config.knip);
/** @type {string[]} */
const warnings = [];
if (errors.length) {
errors.push("Remove what was added. If it is deliberate, run `npx devkit-no-bloat --write-baseline` and commit the config, so the raise is reviewed.");
}
errors.unshift(...measured.warnings);
if (lowered.length) {
warnings.push(`Lower the baseline to lock this in (${lowered.join(", ")}): run \`npx devkit-no-bloat --write-baseline\` and commit the config.`);
}
const table = render(rows, [...errors, ...warnings], config.baseline?.measured_at, warnOnly);
console.log(table);
for (const e of errors) console.log(`::${warnOnly ? "warning" : "error"}::${e}`);
for (const w of warnings) console.log(`::warning::${w}`);
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${table}\n`);
if (errors.length && !warnOnly) process.exit(1);
}
// Run through node_modules/.bin, argv[1] is the link and import.meta.url the file it points to.
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) main();