Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion apps/site/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img>, not next/image: these are third-party badge
endpoints that render their own SVG and need no optimisation. */}
<img
src={b.src}
alt={b.alt}
Expand Down
1 change: 1 addition & 0 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"scripts": {
"prebuild": "pnpm --filter @object-ui/example-schema-catalog build",
"build": "next build",
"lint": "eslint .",
"dev": "next dev",
"start": "next start",
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
Expand Down
16 changes: 15 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,21 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import objectUi from './eslint-rules/index.js'

export default tseslint.config({ ignores: ['**/dist', '**/.next', '**/node_modules', '**/public'] }, {
export default tseslint.config({
ignores: [
'**/dist',
'**/.next',
'**/node_modules',
'**/public',
// fumadocs-mdx codegen for apps/site (gitignored — see apps/site/.gitignore).
// Linting generated output only reports on the generator's choices.
'**/.source',
// Tailwind configs are authored in TypeScript despite the `.js` extension,
// and the TS parser below is scoped to `.ts`/`.tsx`, so the base JS parser
// would fail on `import type`. Build config, not app code.
'**/tailwind.config.js',
],
}, {
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
Expand Down
1 change: 1 addition & 0 deletions examples/byo-backend-console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"type-check": "tsc --noEmit",
"preview": "vite preview"
},
Expand Down
1 change: 1 addition & 0 deletions examples/console-starter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"type-check": "tsc --noEmit",
"preview": "vite preview"
},
Expand Down
3 changes: 3 additions & 0 deletions examples/hello-world/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@
"@object-ui/react": "workspace:*",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"scripts": {
"lint": "eslint ."
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"lint": "turbo run lint",
"lint:coverage": "node scripts/check-lint-coverage.mjs",
"type-check": "turbo run type-check",
"type-check:coverage": "node scripts/check-type-check-coverage.mjs",
"cli": "node packages/cli/dist/cli.js",
Expand Down
1 change: 1 addition & 0 deletions packages/vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@
"scripts": {
"vscode:prepublish": "pnpm run build",
"build": "tsup",
"lint": "eslint .",
"dev": "tsup --watch",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
Expand Down
129 changes: 129 additions & 0 deletions scripts/check-lint-coverage.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env node
/**
* Validates that every workspace package is actually linted by CI.
*
* The sibling of scripts/check-type-check-coverage.mjs, for the same reason:
* `pnpm lint` runs `turbo run lint`, and turbo silently skips any package with
* no `lint` script — so a package without one is not "clean", it is unlinted.
* That is how `apps/console`, the largest surface in the repo, went unlinted
* while carrying 14 ESLint errors that nobody had ever seen (#2923).
*
* The stakes are concrete: `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 type-discipline ratchet). Those ratchets are worthless in
* a package that never runs ESLint.
*
* Run: node scripts/check-lint-coverage.mjs
* Exit: 0 = OK, 1 = coverage regressed or the list is stale
*/

import { readFileSync, readdirSync, statSync } from "fs";
import { resolve, dirname, join } from "path";
import { fileURLToPath } from "url";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");

// ── Known gaps ───────────────────────────────────────────────────────────────
// Packages with outstanding ESLint *errors*, so they cannot carry a `lint`
// script without turning CI red. Warnings do not matter here — ESLint only
// fails on errors unless `--max-warnings` is set, and it deliberately is not
// (7,724 warnings repo-wide are dominated by `no-explicit-any`; see #2923).
//
// Every entry is real debt: fix the errors, add `"lint": "eslint ."`, then
// delete the entry.
const DEBT = {
"@object-ui/console": {
errors: 14,
issue: 2927,
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",
},
"@object-ui/runner": {
errors: 3,
issue: 2927,
note: "3x react-hooks/static-components in LayoutRenderer",
},
};

// ── Collect workspace packages ───────────────────────────────────────────────
const GROUPS = ["packages", "apps", "examples"];

function collect() {
const out = [];
for (const group of GROUPS) {
let entries;
try {
entries = readdirSync(resolve(root, group));
} catch {
continue;
}
for (const entry of entries) {
const dir = join(group, entry);
const manifest = resolve(root, dir, "package.json");
try {
statSync(manifest);
} catch {
continue;
}
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
if (!pkg.name) continue;
out.push({
name: pkg.name,
dir,
hasScript: Boolean(pkg.scripts?.lint),
});
}
}
return out;
}

const packages = collect();
const byName = new Map(packages.map((p) => [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);
Loading