diff --git a/packages/domain/src/__tests__/lint-gate.test.ts b/packages/domain/src/__tests__/lint-gate.test.ts new file mode 100644 index 0000000..17d8297 --- /dev/null +++ b/packages/domain/src/__tests__/lint-gate.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { checkLintGate } from "../../../../scripts/check-lint-gate.mjs"; + +describe("lint gate", () => { + it("fails on warnings instead of silently passing", () => { + const violations = checkLintGate(); + if (violations.length > 0) { + throw new Error( + `Lint gate integrity violations:\n ${violations.join("\n ")}`, + ); + } + expect(violations).toHaveLength(0); + }); +}); diff --git a/scripts/check-lint-gate.mjs b/scripts/check-lint-gate.mjs new file mode 100644 index 0000000..8182e84 --- /dev/null +++ b/scripts/check-lint-gate.mjs @@ -0,0 +1,71 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); +const RUN_LINE_RE = /^\s*run:\s*(.+?)\s*$/; + +/** @param {string} path */ +const read = (path) => readFileSync(join(ROOT, path), "utf-8"); + +/** + * Gates must invoke `pnpm oxlint` with no extra arguments: pnpm forwards them + * after `--`, where oxlint reads them as file paths and lints nothing. + * @param {Record} scripts + */ +function findGatesWithOwnFlags(scripts) { + const commands = Object.entries(scripts) + .filter(([name]) => name !== "oxlint") + .flatMap(([name, body]) => + body.split("&&").map((c) => [`scripts.${name}`, c.trim()]), + ); + for (const line of read("lefthook.yml").split("\n")) { + const command = RUN_LINE_RE.exec(line)?.[1]; + if (command) commands.push(["lefthook.yml", command]); + } + return commands + .filter(([, c]) => c.includes("oxlint") && c !== "pnpm oxlint") + .map(([source, c]) => `${source} runs "${c}" instead of "pnpm oxlint"`); +} + +/** + * Runs the configured command with one rule forced to warn. A working gate + * exits non-zero; exiting 0 means it lints nothing, or does not fail on + * warnings. + * @param {string} oxlintScript + */ +function failsOnWarnings(oxlintScript) { + const args = [...oxlintScript.split(" ").slice(1), "-W", "no-console"]; + try { + execFileSync(join(ROOT, "node_modules/.bin/oxlint"), args, { + cwd: ROOT, + stdio: "ignore", + }); + return false; + } catch { + return true; + } +} + +/** + * Reports ways the oxlint gate could pass without checking anything, which is + * indistinguishable from a clean run. + * @returns {string[]} + */ +export function checkLintGate() { + /** @type {Record} */ + const scripts = JSON.parse(read("package.json")).scripts; + const violations = findGatesWithOwnFlags(scripts); + + // The canary rule below is not type-aware, so the probe alone cannot + // detect type-aware rules being switched off. + if (!scripts.oxlint.includes("--type-aware")) { + violations.push("scripts.oxlint is missing --type-aware"); + } + if (!failsOnWarnings(scripts.oxlint)) { + violations.push( + `scripts.oxlint exits 0 despite no-console violations: "${scripts.oxlint}"`, + ); + } + return violations; +}