|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Validates that every workspace package is actually linted by CI. |
| 4 | + * |
| 5 | + * The sibling of scripts/check-type-check-coverage.mjs, for the same reason: |
| 6 | + * `pnpm lint` runs `turbo run lint`, and turbo silently skips any package with |
| 7 | + * no `lint` script — so a package without one is not "clean", it is unlinted. |
| 8 | + * That is how `apps/console`, the largest surface in the repo, went unlinted |
| 9 | + * while carrying 14 ESLint errors that nobody had ever seen (#2923). |
| 10 | + * |
| 11 | + * The stakes are concrete: `eslint.config.js` sets three `object-ui/*` rules to |
| 12 | + * `error` specifically so a new violation fails CI (ADR-0054 Phase 5, #2879, |
| 13 | + * and the objectql.ts type-discipline ratchet). Those ratchets are worthless in |
| 14 | + * a package that never runs ESLint. |
| 15 | + * |
| 16 | + * Run: node scripts/check-lint-coverage.mjs |
| 17 | + * Exit: 0 = OK, 1 = coverage regressed or the list is stale |
| 18 | + */ |
| 19 | + |
| 20 | +import { readFileSync, readdirSync, statSync } from "fs"; |
| 21 | +import { resolve, dirname, join } from "path"; |
| 22 | +import { fileURLToPath } from "url"; |
| 23 | + |
| 24 | +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); |
| 25 | + |
| 26 | +// ── Known gaps ─────────────────────────────────────────────────────────────── |
| 27 | +// Packages with outstanding ESLint *errors*, so they cannot carry a `lint` |
| 28 | +// script without turning CI red. Warnings do not matter here — ESLint only |
| 29 | +// fails on errors unless `--max-warnings` is set, and it deliberately is not |
| 30 | +// (7,724 warnings repo-wide are dominated by `no-explicit-any`; see #2923). |
| 31 | +// |
| 32 | +// Every entry is real debt: fix the errors, add `"lint": "eslint ."`, then |
| 33 | +// delete the entry. |
| 34 | +const DEBT = { |
| 35 | + "@object-ui/console": { |
| 36 | + errors: 14, |
| 37 | + issue: 2927, |
| 38 | + note: "8x react-hooks/purity (Date.now during render) + 4x react-hooks/static-components (components created during render reset state) + no-useless-assignment + prefer-const", |
| 39 | + }, |
| 40 | + "@object-ui/runner": { |
| 41 | + errors: 3, |
| 42 | + issue: 2927, |
| 43 | + note: "3x react-hooks/static-components in LayoutRenderer", |
| 44 | + }, |
| 45 | +}; |
| 46 | + |
| 47 | +// ── Collect workspace packages ─────────────────────────────────────────────── |
| 48 | +const GROUPS = ["packages", "apps", "examples"]; |
| 49 | + |
| 50 | +function collect() { |
| 51 | + const out = []; |
| 52 | + for (const group of GROUPS) { |
| 53 | + let entries; |
| 54 | + try { |
| 55 | + entries = readdirSync(resolve(root, group)); |
| 56 | + } catch { |
| 57 | + continue; |
| 58 | + } |
| 59 | + for (const entry of entries) { |
| 60 | + const dir = join(group, entry); |
| 61 | + const manifest = resolve(root, dir, "package.json"); |
| 62 | + try { |
| 63 | + statSync(manifest); |
| 64 | + } catch { |
| 65 | + continue; |
| 66 | + } |
| 67 | + const pkg = JSON.parse(readFileSync(manifest, "utf8")); |
| 68 | + if (!pkg.name) continue; |
| 69 | + out.push({ |
| 70 | + name: pkg.name, |
| 71 | + dir, |
| 72 | + hasScript: Boolean(pkg.scripts?.lint), |
| 73 | + }); |
| 74 | + } |
| 75 | + } |
| 76 | + return out; |
| 77 | +} |
| 78 | + |
| 79 | +const packages = collect(); |
| 80 | +const byName = new Map(packages.map((p) => [p.name, p])); |
| 81 | + |
| 82 | +const errors = []; |
| 83 | + |
| 84 | +// 1. Undeclared gap — a package that never runs ESLint. |
| 85 | +for (const pkg of packages) { |
| 86 | + if (pkg.hasScript) continue; |
| 87 | + if (DEBT[pkg.name]) continue; |
| 88 | + errors.push( |
| 89 | + `${pkg.name} (${pkg.dir}) has no "lint" script, so \`pnpm lint\` skips it entirely.\n` + |
| 90 | + ` Add "lint": "eslint ." to its package.json. If it still has ESLint errors,\n` + |
| 91 | + ` add it to DEBT in scripts/check-lint-coverage.mjs with an error count.` |
| 92 | + ); |
| 93 | +} |
| 94 | + |
| 95 | +// 2. Ratchet — a declared gap that has been closed must leave the list. |
| 96 | +for (const name of Object.keys(DEBT)) { |
| 97 | + const pkg = byName.get(name); |
| 98 | + if (!pkg) { |
| 99 | + errors.push(`${name} is listed in DEBT but is not a workspace package any more — delete the entry.`); |
| 100 | + } else if (pkg.hasScript) { |
| 101 | + errors.push( |
| 102 | + `${name} now has a "lint" script — delete its DEBT entry so the gap cannot reopen` + |
| 103 | + `${DEBT[name].issue ? ` (and close #${DEBT[name].issue} if it is done)` : ""}.` |
| 104 | + ); |
| 105 | + } |
| 106 | +} |
| 107 | + |
| 108 | +// ── Report ─────────────────────────────────────────────────────────────────── |
| 109 | +const linted = packages.filter((p) => p.hasScript).length; |
| 110 | +const debtCount = Object.keys(DEBT).length; |
| 111 | +const debtErrors = Object.values(DEBT).reduce((sum, d) => sum + d.errors, 0); |
| 112 | + |
| 113 | +if (errors.length === 0) { |
| 114 | + console.log( |
| 115 | + `✅ lint coverage: ${linted}/${packages.length} packages linted, ` + |
| 116 | + `${debtCount} with outstanding errors (${debtErrors} total).` |
| 117 | + ); |
| 118 | + process.exit(0); |
| 119 | +} |
| 120 | + |
| 121 | +console.error("❌ lint coverage regressed:\n"); |
| 122 | +for (const message of errors) { |
| 123 | + console.error(` • ${message}`); |
| 124 | +} |
| 125 | +console.error( |
| 126 | + "\nA package with no `lint` script is not clean — turbo skips it and CI sees nothing.\n" + |
| 127 | + "See https://github.com/objectstack-ai/objectui/issues/2923 for why this guard exists." |
| 128 | +); |
| 129 | +process.exit(1); |
0 commit comments