Skip to the content

Capsid menu

bin/no-bloat.mjs

220 lines and 15 functions in DrDustinEdwards/devkit, at commit 505cda4 on 2026-10-24. This file on GitHub.

Functions

The functions of no-bloat.mjs
FunctionLinesComplexityCoveredCRAPMutation
knipCounts41 to 462100%OkCRAP 2No data: mutation testing has not run on this repo yet
measure56 to 86994%WatchCRAP 9No data: mutation testing has not run on this repo yet
compare95 to 13129100%WatchCRAP 29No data: mutation testing has not run on this repo yet
before100 to 1002100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:113>113 to 1131100%OkCRAP 1No data: mutation testing has not run on this repo yet
render140 to 1514100%OkCRAP 4No data: mutation testing has not run on this repo yet
show141 to 1412100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:147>147 to 1473100%OkCRAP 3No data: mutation testing has not run on this repo yet
<anonymous:149>149 to 1491100%OkCRAP 1No data: mutation testing has not run on this repo yet
npxArgs158 to 1601100%OkCRAP 1No data: mutation testing has not run on this repo yet
runnerIn163 to 1751100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:165>165 to 1747100%WatchCRAP 7No data: mutation testing has not run on this repo yet
messageOf177 to 1792100%OkCRAP 2No data: mutation testing has not run on this repo yet
shortHead181 to 1872100%OkCRAP 2No data: mutation testing has not run on this repo yet
main189 to 23917100%WatchCRAP 17No 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();