From abdd6d9c00a3288a67b745971be95fd4f4f05aa2 Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." <4629398+fpigeonjr@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:43:51 -0500 Subject: [PATCH 1/2] Guard the ESLint warning baseline against regressions Adds a ratcheting warning-baseline gate for ESLint, wired into CI alongside the existing ng lint step for both the root workspace and test-app. - eslint-baseline.json records the current accepted warning ceiling per workspace (root: 1631, test-app: 4), measured from ng lint --format json. - scripts/check-lint-baseline.mjs sums errorCount/warningCount from an ESLint JSON report and fails if warnings exceed the baseline for that workspace, or if there are any errors at all (regardless of warning count). --bump lowers (never raises) the baseline to the measured count, so cleanup PRs can lock in a reduction without editing CI config. - package.json / test-app/package.json add lint:report, lint:baseline, and lint:baseline:bump scripts. - .github/workflows/lint.yml runs the new baseline gate after each existing ng lint step. - scripts/check-lint-baseline.test.mjs covers pass/fail/bump/error- precedence/malformed-baseline cases via node --test. Closes #581 --- .github/workflows/lint.yml | 4 + .gitignore | 6 + eslint-baseline.json | 4 + package.json | 3 + scripts/check-lint-baseline.mjs | 135 ++++++++++++++++ scripts/check-lint-baseline.test.mjs | 220 +++++++++++++++++++++++++++ test-app/package.json | 5 +- 7 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 eslint-baseline.json create mode 100644 scripts/check-lint-baseline.mjs create mode 100644 scripts/check-lint-baseline.test.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 17772bc46..6855c57fb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,5 +28,9 @@ jobs: run: npm ci --prefix test-app - name: Lint library and documentation app run: npm run lint + - name: Guard root ESLint warning baseline + run: npm run lint:baseline - name: Lint test app run: npm --prefix test-app run lint + - name: Guard test-app ESLint warning baseline + run: npm --prefix test-app run lint:baseline diff --git a/.gitignore b/.gitignore index 071759ad4..6c6e1e44f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,12 @@ npm-debug.log # Coverage # /coverage/ +# ESLint report artifacts generated by lint:report / lint:baseline +eslint-report.json +test-app/eslint-report.json +.eslintcache +test-app/.eslintcache + # Typing # /src/typings/tsd/ /typings/ diff --git a/eslint-baseline.json b/eslint-baseline.json new file mode 100644 index 000000000..7423041d6 --- /dev/null +++ b/eslint-baseline.json @@ -0,0 +1,4 @@ +{ + "root": 1631, + "test-app": 4 +} diff --git a/package.json b/package.json index 492d91e95..97cd4faed 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ "format": "prettier --write .", "format:check": "prettier --check .", "lint": "ng lint", + "lint:report": "ng lint --format json --output-file eslint-report.json", + "lint:baseline": "npm run lint:report && node scripts/check-lint-baseline.mjs root eslint-report.json", + "lint:baseline:bump": "npm run lint:report && node scripts/check-lint-baseline.mjs --bump root eslint-report.json", "validate:publish": "node scripts/validate-publish-package.mjs" }, "peerDependencies": { diff --git a/scripts/check-lint-baseline.mjs b/scripts/check-lint-baseline.mjs new file mode 100644 index 000000000..88826e48b --- /dev/null +++ b/scripts/check-lint-baseline.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/** + * Ratcheting warning-baseline gate for ESLint (run via the `ng lint` + * @angular-eslint/builder). + * + * The ESLint migration (#566) intentionally warn-first'd a large amount of + * pre-existing lint debt (see #580) rather than blocking on it immediately. + * `ng lint`'s own `maxWarnings` option only supports a single fixed number, + * which can't ratchet down as debt is paid off without editing CI config on + * every cleanup PR. This script enforces a *ceiling* per workspace, recorded + * in `eslint-baseline.json` at the repo root: + * + * - New warnings above the recorded baseline fail the gate. + * - Any ESLint error fails the gate, regardless of the warning count. + * - Reducing warnings does NOT fail the gate; run with `--bump` to lock the + * improvement in as the new (lower) baseline. + * + * The baseline is a ratchet — `--bump` only ever lowers it. It never raises it, + * so an accidental regression can't be "fixed" by re-bumping. + * + * Usage: + * node scripts/check-lint-baseline.mjs + * node scripts/check-lint-baseline.mjs --bump + * + * Where is a key in eslint-baseline.json (e.g. "root", "test-app") + * and the ESLint report is produced with `--format json --output-file `. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const baselinePath = resolve(scriptDir, "..", "eslint-baseline.json"); + +const args = process.argv.slice(2); +const bump = args.includes("--bump"); +const positional = args.filter((arg) => !arg.startsWith("--")); +const [workspace, reportArg] = positional; + +if (!workspace || !reportArg) { + console.error( + "Usage: node scripts/check-lint-baseline.mjs [--bump] " + ); + process.exit(1); +} + +const reportPath = resolve(reportArg); + +let report; +try { + report = JSON.parse(readFileSync(reportPath, "utf8")); +} catch (error) { + console.error(`✖ Could not read ESLint report at ${reportPath}`); + console.error(` ${error.message}`); + console.error( + " Run `ng lint --format json --output-file ` first to generate it." + ); + process.exit(1); +} + +let baselines; +try { + baselines = JSON.parse(readFileSync(baselinePath, "utf8")); +} catch (error) { + console.error(`✖ Could not read lint baselines at ${baselinePath}`); + console.error(` ${error.message}`); + process.exit(1); +} + +const totals = report.reduce( + (acc, result) => { + acc.errors += result.errorCount ?? 0; + acc.warnings += result.warningCount ?? 0; + return acc; + }, + { errors: 0, warnings: 0 } +); + +if (bump) { + const rawCurrent = baselines[workspace]; + const current = Number.isFinite(rawCurrent) ? rawCurrent : 0; + // Ratchet only ever moves down. + const next = Math.min(current, totals.warnings); + + if (totals.errors > 0) { + console.error( + `✖ ${workspace}: ${totals.errors} ESLint error(s) found; fix errors before bumping the baseline.` + ); + process.exit(1); + } + + if (next === current) { + console.log( + `= ${workspace}: baseline already at or below current warnings (${current}); nothing to bump.` + ); + process.exit(0); + } + + baselines[workspace] = next; + writeFileSync(baselinePath, `${JSON.stringify(baselines, null, 2)}\n`); + console.log( + `↓ ${workspace}: baseline lowered ${current} → ${next} (measured ${totals.warnings}). Commit this change on its own.` + ); + process.exit(0); +} + +const baseline = baselines[workspace]; +if (!Number.isFinite(baseline)) { + console.error(`✖ ${workspace}: missing or invalid entry in ${baselinePath}`); + process.exit(1); +} + +if (totals.errors > 0) { + console.error( + `✖ ${workspace}: ${totals.errors} ESLint error(s) found. Errors are never allowed, regardless of the warning baseline.` + ); + process.exit(1); +} + +if (totals.warnings > baseline) { + console.error(`✖ ${workspace}: ESLint warning baseline exceeded.`); + console.error(` expected: <= ${baseline} warnings`); + console.error(` actual: ${totals.warnings} warnings`); + console.error( + "\n This change introduced new ESLint warnings beyond the accepted baseline.\n" + + " Fix the new findings, or if you intentionally reduced warnings elsewhere,\n" + + ` run \`node scripts/check-lint-baseline.mjs --bump ${workspace} ${reportArg}\` and commit\n` + + " the lowered baseline as its own change." + ); + process.exit(1); +} + +console.log( + `✓ ${workspace}: ${totals.warnings} warnings (baseline ${baseline}), ${totals.errors} errors. Lint baseline gate passed.` +); diff --git a/scripts/check-lint-baseline.test.mjs b/scripts/check-lint-baseline.test.mjs new file mode 100644 index 000000000..17c5ddab0 --- /dev/null +++ b/scripts/check-lint-baseline.test.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** + * Tests for the ESLint warning-baseline gate script + * (scripts/check-lint-baseline.mjs). + * + * These run on the Node built-in test runner (no extra deps) by invoking the + * script as a child process against temp fixture files, so we exercise the + * real CLI surface (exit codes, --bump, error precedence, malformed + * baselines) rather than internals. + * + * Run: node --test scripts/check-lint-baseline.test.mjs + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const script = join(scriptDir, "check-lint-baseline.mjs"); +const baselinePath = resolve(scriptDir, "..", "eslint-baseline.json"); + +/** Run the gate, returning { status, stdout, stderr }. */ +function run(args) { + try { + const stdout = execFileSync("node", [script, ...args], { + encoding: "utf8", + }); + return { status: 0, stdout, stderr: "" }; + } catch (error) { + return { + status: error.status ?? 1, + stdout: error.stdout?.toString() ?? "", + stderr: error.stderr?.toString() ?? "", + }; + } +} + +/** Writes a minimal ESLint JSON-formatter report with the given totals. */ +function writeReport(dir, { errors = 0, warnings = 0 } = {}) { + const path = join(dir, "eslint-report.json"); + const results = []; + if (errors > 0 || warnings > 0) { + results.push({ + filePath: join(dir, "fixture.ts"), + messages: [], + errorCount: errors, + warningCount: warnings, + }); + } else { + results.push({ + filePath: join(dir, "fixture.ts"), + messages: [], + errorCount: 0, + warningCount: 0, + }); + } + writeFileSync(path, JSON.stringify(results)); + return path; +} + +function withTempDir(fn) { + const dir = mkdtempSync(join(tmpdir(), "lintgate-")); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * Runs `fn` with the real eslint-baseline.json swapped for `baselines`, + * restoring the original afterwards. The script always reads the repo-root + * baseline file, so we back it up rather than parameterise the path. + */ +function withBaselines(baselines, fn) { + const backup = readFileSync(baselinePath, "utf8"); + try { + writeFileSync(baselinePath, `${JSON.stringify(baselines, null, 2)}\n`); + return fn(); + } finally { + writeFileSync(baselinePath, backup); + } +} + +test("passes when warnings are at the baseline", () => { + withBaselines({ root: 10, "test-app": 4 }, () => { + withTempDir((dir) => { + const report = writeReport(dir, { warnings: 10 }); + const { status, stdout } = run(["root", report]); + assert.equal(status, 0); + assert.match(stdout, /Lint baseline gate passed/); + }); + }); +}); + +test("passes when warnings are below the baseline", () => { + withBaselines({ root: 10, "test-app": 4 }, () => { + withTempDir((dir) => { + const report = writeReport(dir, { warnings: 3 }); + const { status } = run(["root", report]); + assert.equal(status, 0); + }); + }); +}); + +test("fails and reports expected vs. actual when warnings exceed the baseline", () => { + withBaselines({ root: 10, "test-app": 4 }, () => { + withTempDir((dir) => { + const report = writeReport(dir, { warnings: 11 }); + const { status, stderr } = run(["root", report]); + assert.equal(status, 1); + assert.match(stderr, /baseline exceeded/); + assert.match(stderr, /expected:\s*<=\s*10/); + assert.match(stderr, /actual:\s*11/); + }); + }); +}); + +test("fails on any error regardless of the warning count", () => { + withBaselines({ root: 10, "test-app": 4 }, () => { + withTempDir((dir) => { + const report = writeReport(dir, { errors: 1, warnings: 0 }); + const { status, stderr } = run(["root", report]); + assert.equal(status, 1); + assert.match(stderr, /1 ESLint error/); + }); + }); +}); + +test("fails on errors even when warnings are within baseline", () => { + withBaselines({ root: 10, "test-app": 4 }, () => { + withTempDir((dir) => { + const report = writeReport(dir, { errors: 2, warnings: 5 }); + const { status, stderr } = run(["root", report]); + assert.equal(status, 1); + assert.match(stderr, /2 ESLint error/); + }); + }); +}); + +test("--bump lowers the baseline to the measured warning count", () => { + withBaselines({ root: 10, "test-app": 4 }, () => { + withTempDir((dir) => { + const report = writeReport(dir, { warnings: 6 }); + const { status } = run(["--bump", "root", report]); + assert.equal(status, 0); + const written = JSON.parse(readFileSync(baselinePath, "utf8")); + assert.equal(written.root, 6); + assert.equal(written["test-app"], 4); + }); + }); +}); + +test("--bump never raises a baseline (ratchet-only)", () => { + withBaselines({ root: 10, "test-app": 4 }, () => { + withTempDir((dir) => { + const report = writeReport(dir, { warnings: 15 }); + const { status, stdout } = run(["--bump", "root", report]); + assert.equal(status, 0); + assert.match(stdout, /nothing to bump/); + const written = JSON.parse(readFileSync(baselinePath, "utf8")); + assert.equal(written.root, 10); + }); + }); +}); + +test("--bump refuses to run when there are errors", () => { + withBaselines({ root: 10, "test-app": 4 }, () => { + withTempDir((dir) => { + const report = writeReport(dir, { errors: 1, warnings: 2 }); + const { status, stderr } = run(["--bump", "root", report]); + assert.equal(status, 1); + assert.match(stderr, /fix errors before bumping/); + const written = JSON.parse(readFileSync(baselinePath, "utf8")); + assert.equal(written.root, 10); + }); + }); +}); + +test("a missing workspace entry in the baseline file fails loudly", () => { + withBaselines({ "test-app": 4 }, () => { + withTempDir((dir) => { + const report = writeReport(dir, { warnings: 1 }); + const { status, stderr } = run(["root", report]); + assert.equal(status, 1); + assert.match(stderr, /missing or invalid entry/); + }); + }); +}); + +test("a malformed (non-numeric) baseline entry fails loudly", () => { + withBaselines({ root: "ten", "test-app": 4 }, () => { + withTempDir((dir) => { + const report = writeReport(dir, { warnings: 1 }); + const { status, stderr } = run(["root", report]); + assert.equal(status, 1); + assert.match(stderr, /missing or invalid entry/); + }); + }); +}); + +test("exits non-zero when the ESLint report is missing", () => { + withBaselines({ root: 10, "test-app": 4 }, () => { + const { status, stderr } = run([ + "root", + join(tmpdir(), "does-not-exist-lintgate.json"), + ]); + assert.equal(status, 1); + assert.match(stderr, /Could not read ESLint report/); + }); +}); + +test("exits non-zero with usage when arguments are missing", () => { + const { status, stderr } = run([]); + assert.equal(status, 1); + assert.match(stderr, /Usage:/); +}); diff --git a/test-app/package.json b/test-app/package.json index 99f93ef34..cf9cd1bd7 100644 --- a/test-app/package.json +++ b/test-app/package.json @@ -10,7 +10,10 @@ "test:watch": "npm run init && ng test --code-coverage && node test-postjob && rimraf ./src/components", "test:e2e": "playwright test", "init": "rimraf ./src/components && node test-setup", - "lint": "ng lint" + "lint": "ng lint", + "lint:report": "ng lint --format json --output-file eslint-report.json", + "lint:baseline": "npm run lint:report && node ../scripts/check-lint-baseline.mjs test-app eslint-report.json", + "lint:baseline:bump": "npm run lint:report && node ../scripts/check-lint-baseline.mjs --bump test-app eslint-report.json" }, "private": true, "dependencies": { From 963e093b6cabd234fe5a6a99461cce6662a3b556 Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." <4629398+fpigeonjr@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:05:33 -0500 Subject: [PATCH 2/2] Address review feedback: fix bump validation and add CI-level raise guard - check-lint-baseline.mjs (--bump): validate the workspace entry exists and is numeric before computing the ratchet, instead of silently treating a missing/typo'd workspace (e.g. --bump roots) as 0 and reporting "nothing to bump". - Add scripts/check-baseline-not-increased.mjs, a new CI-only guard that compares eslint-baseline.json on the PR branch against the base branch's committed version and fails if any shared workspace's ceiling increased, closing the gap where a contributor could raise a baseline directly in the same PR that introduces new warnings. - Wire the new guard into .github/workflows/lint.yml (fetch-depth: 0, runs only on pull_request, diffs against the PR's base commit). - scripts/check-baseline-not-increased.test.mjs: 8 node --test cases covering unchanged/lowered/raised baselines, multi-workspace raises, new workspace entries, missing base file, and invalid values. - scripts/check-lint-baseline.test.mjs: add a case for --bump on an unknown workspace failing loudly. --- .github/workflows/lint.yml | 7 + scripts/check-baseline-not-increased.mjs | 83 ++++++++++++ scripts/check-baseline-not-increased.test.mjs | 123 ++++++++++++++++++ scripts/check-lint-baseline.mjs | 10 +- scripts/check-lint-baseline.test.mjs | 13 ++ 5 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 scripts/check-baseline-not-increased.mjs create mode 100644 scripts/check-baseline-not-increased.test.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6855c57fb..4daf45730 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,6 +13,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc @@ -26,6 +28,11 @@ jobs: run: npm run format:check - name: Install test-app dependencies run: npm ci --prefix test-app + - name: Guard against a raised ESLint warning baseline + if: github.event_name == 'pull_request' + run: | + git show "${{ github.event.pull_request.base.sha }}:eslint-baseline.json" > /tmp/base-eslint-baseline.json 2>/dev/null || echo '{}' > /tmp/base-eslint-baseline.json + node scripts/check-baseline-not-increased.mjs /tmp/base-eslint-baseline.json eslint-baseline.json - name: Lint library and documentation app run: npm run lint - name: Guard root ESLint warning baseline diff --git a/scripts/check-baseline-not-increased.mjs b/scripts/check-baseline-not-increased.mjs new file mode 100644 index 000000000..ec88f64ab --- /dev/null +++ b/scripts/check-baseline-not-increased.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/** + * Guards against the ESLint warning baseline being raised directly in + * eslint-baseline.json rather than earned via --bump. + * + * The per-workspace gate (check-lint-baseline.mjs) only evaluates the + * baseline committed in the branch being checked, so a contributor could + * raise a ceiling (e.g. bumping "root" from 1631 to 5000) in the same PR + * that introduces new warnings, and both guard steps would pass. This script + * closes that hole by comparing the baseline on the PR branch against the + * baseline on the trusted base branch and failing if any shared workspace's + * ceiling increased. + * + * New workspace keys that don't exist on the base branch are allowed (they + * can't be "increased" if there's nothing to compare against). Any decrease + * or unchanged value is allowed, matching the ratchet-only semantics of + * --bump. + * + * Usage: + * node scripts/check-baseline-not-increased.mjs + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const [baseArg, headArg] = process.argv.slice(2); + +if (!baseArg || !headArg) { + console.error( + "Usage: node scripts/check-baseline-not-increased.mjs " + ); + process.exit(1); +} + +function loadBaselines(label, path) { + try { + return JSON.parse(readFileSync(resolve(path), "utf8")); + } catch (error) { + console.error(`✖ Could not read ${label} baselines at ${path}`); + console.error(` ${error.message}`); + process.exit(1); + } +} + +const base = loadBaselines("base-branch", baseArg); +const head = loadBaselines("pull-request", headArg); + +let hasIncrease = false; + +for (const workspace of Object.keys(head)) { + if (!(workspace in base)) { + // New workspace entry — nothing to compare against, so it can't be an + // increase over a previously-trusted value. + continue; + } + + const baseValue = base[workspace]; + const headValue = head[workspace]; + + if (!Number.isFinite(baseValue) || !Number.isFinite(headValue)) { + console.error( + `✖ ${workspace}: invalid baseline value (base: ${baseValue}, head: ${headValue})` + ); + hasIncrease = true; + continue; + } + + if (headValue > baseValue) { + console.error( + `✖ ${workspace}: baseline increased ${baseValue} → ${headValue}. ` + + "The accepted warning ceiling can only go down (via --bump), never up. " + + "Revert this change to eslint-baseline.json." + ); + hasIncrease = true; + } +} + +if (hasIncrease) { + process.exit(1); +} + +console.log( + "✓ eslint-baseline.json: no workspace's warning ceiling increased vs. the base branch." +); diff --git a/scripts/check-baseline-not-increased.test.mjs b/scripts/check-baseline-not-increased.test.mjs new file mode 100644 index 000000000..ef3f4856a --- /dev/null +++ b/scripts/check-baseline-not-increased.test.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Tests for scripts/check-baseline-not-increased.mjs — the CI-only guard + * that compares eslint-baseline.json on a PR branch against the base + * branch's version and fails if any shared workspace's ceiling increased. + * + * Run: node --test scripts/check-baseline-not-increased.test.mjs + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const script = join(scriptDir, "check-baseline-not-increased.mjs"); + +function run(args) { + try { + const stdout = execFileSync("node", [script, ...args], { + encoding: "utf8", + }); + return { status: 0, stdout, stderr: "" }; + } catch (error) { + return { + status: error.status ?? 1, + stdout: error.stdout?.toString() ?? "", + stderr: error.stderr?.toString() ?? "", + }; + } +} + +function withTempDir(fn) { + const dir = mkdtempSync(join(tmpdir(), "baseline-guard-")); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function writeBaseline(dir, name, data) { + const path = join(dir, name); + writeFileSync(path, JSON.stringify(data)); + return path; +} + +test("passes when the baseline is unchanged", () => { + withTempDir((dir) => { + const base = writeBaseline(dir, "base.json", { root: 10, "test-app": 4 }); + const head = writeBaseline(dir, "head.json", { root: 10, "test-app": 4 }); + const { status } = run([base, head]); + assert.equal(status, 0); + }); +}); + +test("passes when a baseline is lowered", () => { + withTempDir((dir) => { + const base = writeBaseline(dir, "base.json", { root: 10, "test-app": 4 }); + const head = writeBaseline(dir, "head.json", { root: 6, "test-app": 4 }); + const { status } = run([base, head]); + assert.equal(status, 0); + }); +}); + +test("fails when a baseline is raised", () => { + withTempDir((dir) => { + const base = writeBaseline(dir, "base.json", { root: 10, "test-app": 4 }); + const head = writeBaseline(dir, "head.json", { root: 20, "test-app": 4 }); + const { status, stderr } = run([base, head]); + assert.equal(status, 1); + assert.match(stderr, /baseline increased 10 → 20/); + }); +}); + +test("fails when any of multiple workspaces is raised", () => { + withTempDir((dir) => { + const base = writeBaseline(dir, "base.json", { root: 10, "test-app": 4 }); + const head = writeBaseline(dir, "head.json", { + root: 10, + "test-app": 9, + }); + const { status, stderr } = run([base, head]); + assert.equal(status, 1); + assert.match(stderr, /test-app: baseline increased 4 → 9/); + }); +}); + +test("allows a brand-new workspace entry not present on the base branch", () => { + withTempDir((dir) => { + const base = writeBaseline(dir, "base.json", { root: 10 }); + const head = writeBaseline(dir, "head.json", { root: 10, "new-pkg": 50 }); + const { status } = run([base, head]); + assert.equal(status, 0); + }); +}); + +test("treats a missing base-branch baseline file as an empty baseline", () => { + withTempDir((dir) => { + const base = writeBaseline(dir, "base.json", {}); + const head = writeBaseline(dir, "head.json", { root: 10 }); + const { status } = run([base, head]); + assert.equal(status, 0); + }); +}); + +test("fails on a non-numeric baseline value", () => { + withTempDir((dir) => { + const base = writeBaseline(dir, "base.json", { root: 10 }); + const head = writeBaseline(dir, "head.json", { root: "ten" }); + const { status, stderr } = run([base, head]); + assert.equal(status, 1); + assert.match(stderr, /invalid baseline value/); + }); +}); + +test("exits non-zero with usage when arguments are missing", () => { + const { status, stderr } = run([]); + assert.equal(status, 1); + assert.match(stderr, /Usage:/); +}); diff --git a/scripts/check-lint-baseline.mjs b/scripts/check-lint-baseline.mjs index 88826e48b..d385e99ad 100644 --- a/scripts/check-lint-baseline.mjs +++ b/scripts/check-lint-baseline.mjs @@ -77,8 +77,14 @@ const totals = report.reduce( ); if (bump) { - const rawCurrent = baselines[workspace]; - const current = Number.isFinite(rawCurrent) ? rawCurrent : 0; + const current = baselines[workspace]; + if (!Number.isFinite(current)) { + console.error( + `✖ ${workspace}: missing or invalid entry in ${baselinePath}` + ); + process.exit(1); + } + // Ratchet only ever moves down. const next = Math.min(current, totals.warnings); diff --git a/scripts/check-lint-baseline.test.mjs b/scripts/check-lint-baseline.test.mjs index 17c5ddab0..367ae8db2 100644 --- a/scripts/check-lint-baseline.test.mjs +++ b/scripts/check-lint-baseline.test.mjs @@ -180,6 +180,19 @@ test("--bump refuses to run when there are errors", () => { }); }); +test("--bump on an unknown workspace fails loudly instead of silently reporting nothing to bump", () => { + withBaselines({ root: 10, "test-app": 4 }, () => { + withTempDir((dir) => { + const report = writeReport(dir, { warnings: 3 }); + const { status, stderr } = run(["--bump", "roots", report]); + assert.equal(status, 1); + assert.match(stderr, /missing or invalid entry/); + const written = JSON.parse(readFileSync(baselinePath, "utf8")); + assert.ok(!("roots" in written)); + }); + }); +}); + test("a missing workspace entry in the baseline file fails loudly", () => { withBaselines({ "test-app": 4 }, () => { withTempDir((dir) => {