diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index f6d5836a3..2e6cfff39 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1,6 +1,30 @@
name: Lint
+# Until #2923 this workflow was `workflow_dispatch`-only, so ESLint had never
+# gated a PR. That mattered more than it looked: `eslint.config.js` sets three
+# `object-ui/*` rules to `error` *specifically* so a new violation fails CI
+# (ADR-0054 Phase 5, #2879, and the objectql.ts ratchet), and each author
+# pre-cleaned the existing sites so the rule would lint clean. All three
+# ratchets were inert because nothing ran them.
+#
+# `--max-warnings` is deliberately not set: the 7,724 warnings repo-wide are 81%
+# `no-explicit-any`, plus React Compiler rules the config downgrades on purpose.
+# This gate is about errors.
on:
+ push:
+ branches: [main, develop]
+ paths-ignore:
+ - '**/*.md'
+ - 'content/**'
+ - 'docs/**'
+ - '.changeset/**'
+ pull_request:
+ branches: [main, develop]
+ paths-ignore:
+ - '**/*.md'
+ - 'content/**'
+ - 'docs/**'
+ - '.changeset/**'
workflow_dispatch:
concurrency:
@@ -33,6 +57,12 @@ jobs:
node-version: '20.x'
cache: 'pnpm'
+ # Every package must run ESLint or be declared a known gap. turbo skips
+ # scriptless packages silently, so without this a package reads as clean
+ # because nothing linted it. Runs before install: only reads package.json.
+ - name: Verify lint coverage
+ run: node scripts/check-lint-coverage.mjs
+
- name: Turbo Cache
uses: actions/cache@v6
with:
diff --git a/apps/site/app/(home)/page.tsx b/apps/site/app/(home)/page.tsx
index 34ce10b65..882d47d87 100644
--- a/apps/site/app/(home)/page.tsx
+++ b/apps/site/app/(home)/page.tsx
@@ -88,7 +88,8 @@ export default function HomePage() {
rel="noopener noreferrer"
className="inline-block transition hover:opacity-80"
>
- {/* eslint-disable-next-line @next/next/no-img-element */}
+ {/* Plain
, not next/image: these are third-party badge
+ endpoints that render their own SVG and need no optimisation. */}
[p.name, p]));
+
+const errors = [];
+
+// 1. Undeclared gap — a package that never runs ESLint.
+for (const pkg of packages) {
+ if (pkg.hasScript) continue;
+ if (DEBT[pkg.name]) continue;
+ errors.push(
+ `${pkg.name} (${pkg.dir}) has no "lint" script, so \`pnpm lint\` skips it entirely.\n` +
+ ` Add "lint": "eslint ." to its package.json. If it still has ESLint errors,\n` +
+ ` add it to DEBT in scripts/check-lint-coverage.mjs with an error count.`
+ );
+}
+
+// 2. Ratchet — a declared gap that has been closed must leave the list.
+for (const name of Object.keys(DEBT)) {
+ const pkg = byName.get(name);
+ if (!pkg) {
+ errors.push(`${name} is listed in DEBT but is not a workspace package any more — delete the entry.`);
+ } else if (pkg.hasScript) {
+ errors.push(
+ `${name} now has a "lint" script — delete its DEBT entry so the gap cannot reopen` +
+ `${DEBT[name].issue ? ` (and close #${DEBT[name].issue} if it is done)` : ""}.`
+ );
+ }
+}
+
+// ── Report ───────────────────────────────────────────────────────────────────
+const linted = packages.filter((p) => p.hasScript).length;
+const debtCount = Object.keys(DEBT).length;
+const debtErrors = Object.values(DEBT).reduce((sum, d) => sum + d.errors, 0);
+
+if (errors.length === 0) {
+ console.log(
+ `✅ lint coverage: ${linted}/${packages.length} packages linted, ` +
+ `${debtCount} with outstanding errors (${debtErrors} total).`
+ );
+ process.exit(0);
+}
+
+console.error("❌ lint coverage regressed:\n");
+for (const message of errors) {
+ console.error(` • ${message}`);
+}
+console.error(
+ "\nA package with no `lint` script is not clean — turbo skips it and CI sees nothing.\n" +
+ "See https://github.com/objectstack-ai/objectui/issues/2923 for why this guard exists."
+);
+process.exit(1);