tooling(devx): report the files ESLint walks with ZERO rules resolved, behind a shrink-only ledger - #8339
Conversation
`pnpm lint:coverage` says `46/46 packages linted, 0 with outstanding
errors` and cannot see this: its predicate is whether a package RUNS
ESLint, not whether ESLint HAS rules for what that package walks. Both
statements are true at once, which is why the gap survived.
Every rule-bearing config object in `eslint.config.js` is scoped
`files: ['**/*.{ts,tsx}']` or narrower, including the `no-console`
exemption whose `scripts/**` carve-out is spelled
`scripts/**/*.{ts,tsx}`. So the entire `.js`/`.mjs`/`.cjs` family
resolves an EMPTY rule set: ESLint parses those files, reports nothing,
and exits 0. Measured on fedfa3e with ESLint v10.8.1: `lint:root`
walks 272 files and 95 of them (35%) resolve zero rules, and that 95 is
where this repository keeps its gate scripts.
The new gate resolves the rule COUNT per walked file, repo-wide, and
reports every file that resolves zero. The 123 such files on the base
commit are declared as eight glob rows with reasons, so the first run is
green; the ledger goes red on an unledgered zero-rule file, on a row that
also claims files which now resolve rules, and on a row that declares
nothing any more. It can therefore only be narrowed or deleted.
This turns no rule on. Whether the JS family should get
`js.configs.recommended` is a gate-strength decision with an unmeasured
red set and its own card.
Refs: objectui#7908
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
The fourth ablation leg was supposed to narrow a `files:` glob so a
covered file dropped to zero rules. Measured, it does not: narrowing
every `**/*.{ts,tsx}` to `**/*.tsx` moved 776 files OUT OF THE WALK
(`packages/core/src/index.ts` resolves `undefined`, `isPathIgnored`
true) and left the zero-rule count unchanged at 124.
The mechanism is that `.js`/`.cjs`/`.mjs` are linted BY DEFAULT in flat
config while `.ts` is walked only because some object's `files` names
it. So vacuity can only be produced by the default-lint set, or by a
`files:` entry that carries no rules -- which is the real-world way this
defect gets added to a config, and reds the gate naming the file.
Both directions are now in the gate's header and pinned by two cases.
Refs: objectui#7908
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
…lanned The dispatch for this gate cited objectui#8301 as the open question owning whether this shift's gates get a workflow step. Re-derived: it is CLOSED as `not planned` (2026-09-07T13:15Z) and its subject is `check:unused-deps` specifically, not a policy for new gates. What that card does establish, and what this gate inherits, is the enforcement route: a `check:*` script with no `ci.yml` step is enforced through its pin test's repository-green case inside `pnpm test`, at the two costs #8301 named — attribution buried in a 120-file suite, and cost placed in the heavy half of CI. Both apply here and are now written down instead of being described as an open question. Refs: objectui#7908 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
|
ACCEPT (#7908 — the rules-resolved predicate) — reviewed on the tree at head Verified from the diff and the tree, not the report:
Three results I want on the record because they contradict what was expected:
And a correction this seat owes: the dev re-derived the dispatch's premise about #8301 and found it closed No changeset owed; all three paths NOT GOVERNED. Next: CI convergence → flip ready → post-flip guard → auto-merge SQUASH → queue confirmation → landing probe (the Generated by Claude Code |
|
Armed: PR #8339 flipped ready at 2026-09-07T13:47:53Z on head Generated by Claude Code |
|
LANDED: merged at 2026-09-07T14:04:28Z as Generated by Claude Code |
Fixes #7908
Two truths, and why the gap survived
Both of these are true on
mainat the same time, and that is the whole finding:scripts/check-lint-coverage.mjsasks whether a package RUNS ESLint. It readspackage.jsonfor alintscript and nothing else, so a package that runs ESLint over files ESLint has no rules for is, to that gate, fully covered. It is not wrong; it answers a different question, and its confident answer is the reason nobody looked further.Every rule-bearing config object in
eslint.config.jsis scoped to TS-and-TSX (a brace expansion over those two extensions) or narrower — including theno-consoleexemption, whosescripts/carve-out is spelled with the same TS-and-TSX suffix. So the entire.js/.mjs/.cjsfamily resolves an EMPTY rule set. ESLint parses those files, reports nothing, and exits 0; every downstream reading calls that clean.This PR turns no rule on. Whether the JS family should get
js.configs.recommendedis a gate-strength decision with an UNMEASURED red set and its own owner.The funnel, re-derived (not inherited)
Measured on
fedfa3e4awith a real install, ESLint v10.8.1,eslint --print-config FILEcross-checked against the ESLint Node API:scripts/github-slug.mjsscripts/check-lint-coverage.mjseslint.config.jseslint-rules/index.jsplaywright.config.tsapps/console/src/__tests__/bootSplash.test.tsBoth controls were taken in the same run on the same install; without them the zeros are not a reading.
lint:root's own JSON population, this install: 272 files,.ts174,.tsx3 (116–117 rules each),.mjs72,.js22,.cjs1 → 95 of 272 (35%) resolve zero rules.Two differences from the card, which measured at
fa7d66c4. The card read 252 files / 89 zero-rule; this branch reads 272 / 95. The percentage is the same (35%) and the mechanism is identical — the tree simply grew. The card's numbers are stale and these are the live ones.The card also names a stale-looking
eslint-disableatscripts/github-slug.mjs:44. Re-derived: there is none. Line 44 is the vendoredNON_SLUG_CHARSregex, and a scan of all 124 zero-rule files finds zero real directives — the four textual hits are prose inside comments. That is consistent rather than surprising:reportUnusedDisableDirectives: 'error'is live over this population (it is the one config block with nofileskey), so a stale directive there would already be alint:rooterror, andlint:rootis at 0 errors. No card was filed for a defect that does not exist.Repo-wide, which is the population this gate actually judges: 4440 files walked, 4316 resolve rules, 124 resolve zero.
The gate
New sibling
scripts/check-lint-rule-coverage.mjs, wired aspnpm check:lint-rule-coverage. It enumerates the files ESLint walks (ESLint#isPathIgnored, not a re-implementation of the walk), resolves the rule count per file (ESLint#calculateConfigForFile, the API half of--print-config), and reports every file that resolves zero.Cost, measured: 4440 walked out of 6700 on disk — directory walk 23ms,
isPathIgnored1.9s,calculateConfigForFile29ms. About two seconds, one process, no lint pass. Per-file resolution is 3.65ms amortised, so there is no need to resolve per file-GROUP and the gate does not.Enumeration validated against ground truth, not assumed: compared with the
filePathset of a realeslint . --format jsonrun at thelint:rootscope — 272 files, 272 matches, zero difference either way. The pin test re-proves the equivalence againstESLint#lintFileson a fixture tree.Three return shapes from
calculateConfigForFile, all load-bearing:undefined= not walked; an object with norules= WALKED with zero rules (the finding); an object withrules= covered.Home decision: a sibling, not an extension
package.jsonreads), different cost class. Keeping them apart is what keeps BOTH numbers printed —46/46 packages lintedand124 files resolve zero rulesare the pair that makes the gap legible, and a merged gate prints one summary line again, which is how this survived.check-lint-coverage.mjs's own header, which states its subject in its first line, and would give one gate a failure message that has to answer two unrelated questions.check:*line in the rootpackage.json. No workflow (see below), no new dependency, no rewrite of a working gate.eslint.config.jsandeslint-rules/are COUNTED, not exemptedThe objection triage raised is that "a predicate that reddens on its own config file is a bad first day." It does not redden, and the reason is the ledger: every vacuous file that exists today is a declared row, so the first run is GREEN with the config file counted. Once that is true, a blanket exemption is a SECOND mechanism doing what the ledger already does — and strictly worse, because an exemption is invisible while a row carries a reason and goes red when it stops applying.
Measured both ways before choosing:
The 20 are not incidental.
eslint-rules/is 19 hand-written files — ten rule implementations and their.test.jsneighbours — and they implement the ratchetseslint.config.jsitself sets toerror. "The code that enforces our lint rules is unlinted" is a finding someone may want to act on; an exemption would delete it from the output permanently and unmeasured.On the four axes: real need — the exemption serves no measured need, only an aesthetic one about day one, and the ledger already answers day one. Long-term — a second waiver mechanism is the kind of workaround this repo's contract-first rule bans. AI-error — a declared row is a strict contract that must be maintained; a blanket exemption is consumer-side tolerance, the shape that hides bulk mistakes. Startup focus — one concept instead of two.
The ledger, and the three directions it goes red
VACUOUS_GROUPSis an exemption list, never the population. Rows are globs, not paths: a row is a statement about a POPULATION, which is why a newscripts/check-foo.mjsdoes not redden the gate — the row already declares it, and nothing new is hidden.That granularity is measured, not convenient: 38 new JS-family files landed under
scripts/andeslint-rules/in the 14 days before this branch (git window2026-07-30to2026-09-07, so the count is not truncated). An exact per-row COUNT would therefore have reddened on essentially every tooling PR, with "increment a number" as its remedy. A ratchet whose remedy is not the fix is a ratchet that gets switched off.scripts/JS familyeslint-rules/JSeslint.config.jslazy-test.mjse2e/mjspackages/*/scripts/mjspostcss.configjs/mjsapps/site/next.config.mjsThree reds, so a row can only ever be narrowed or deleted:
Ablation, on the committed tree, with trap/restore
Every leg proved its mutation on disk (
git hash-objectagainst the HEAD blob, plus agrep -con the injected text) before any result was read, and proved restore by blob hash and an emptygit diff HEAD.zz-ablation-7908.mjsat the repo root1 file(s) ESLint walks resolve ZERO rules and no ledger row declares them: zz-ablation-7908.mjsfilesto reacheslint-rules/the ledger row 'eslint-rules/...' also matches 19 file(s) that DO resolve rulesfilesentry to TSX onlyfilesentry for.mtscarrying no rules1 file(s) ... no ledger row declares them: vitest.config.mtsgit diff HEADempty,eslint.config.jsbyte-identical to its HEAD blobLeg 3 falsified its own premise, and that is a result worth having. The dispatch predicted that narrowing a
filesglob would drop a covered file to zero rules. Measured, it does not:packages/core/src/index.tscame backisPathIgnored: true, configundefined, and 776 files left the WALK while the zero-rule count stayed at 124. The mechanism is that.js/.cjs/.mjsare linted BY DEFAULT in flat config, while.tsis walked only because some object'sfilesnames it — so narrowing removes files from the walk, it cannot manufacture vacuity. Leg 4 is the corrected form and exercises the real-world shape: afilesentry that carries no rules. Both directions are now in the gate's header and pinned by two cases.One leg-4 honesty note: its first attempt used a regex that matched nothing (
NO-MUTATION-DID-NOT-LAND, injected-text count 0). That reading was void and is not reported as a leg; the anchor was fixed and the leg re-run, which is the table row above.Wiring, and what actually enforces this
package.jsononly. There is noci.ymlstep, and.github/workflows/was outside this session's declared file surface.The dispatch's premise about #8301 did not hold and was re-derived. That card is no longer open: GitHub reports its
state_reasonasnot_planned, at 2026-09-07T13:15Z, and its subject ischeck:unused-depsspecifically, not a policy for this shift's gates. What it does establish is the enforcement route this gate inherits: thethis repository is greencase in the pin test runs the whole analysis insidepnpm test, so a new unledgered zero-rule file fails CI in the PR that adds it. The two costs #8301 named apply here too — the failure surfaces as one assertion inside a 120-file suite rather than a named step, and it lands in the heavy half of CI for a two-second install-only check. Recorded in the header rather than worked around. 15 of this repo'scheck:*scripts already run in no workflow.Gates, each with its exit code
Exit codes captured by redirecting first and reading
$?— never through a pipe.pnpm exec vitest run scripts/__tests__/check-lint-rule-coverage.test.tspnpm exec vitest run scripts/__tests__/node scripts/check-lint-rule-coverage.mjsOK 4440 file(s) walked ... 124 resolve ZERO rules -- every one declared by 8 ledger row(s)pnpm lint:rootpnpm lint:coverage46/46 packages linted, 0 with outstanding errorspnpm check:entry-guardpnpm check:phantom-depspnpm check:unused-depspnpm check:doc-examplespnpm check:comment-mask-corpuspnpm type-check:scriptspnpm check:control-bytesnode scripts/check-changeset-presence.mjsnode scripts/check-governed-queue-guard.mjs --test(all 3 paths)Also scanned by hand for control bytes with a
grep -naPover the changed files: no hits.Filed out of scope
vitest.config.mtsis walked by NO ESLint run in this repository. A neighbouring class this gate cannot see by construction: not vacuously linted, entirely unwalked, because.mts/.ctsare in neither the rule-bearing globs nor ESLint's default lint set. One file today, zero.cts. Deduped against all 448 open issues.Not addressed here, deliberately
eslint-rules/should be linted at all.linterOptionsis untouched.Files changed
scripts/check-lint-rule-coverage.mjs(new)scripts/__tests__/check-lint-rule-coverage.test.ts(new)package.json(one script line)🤖 Generated with Claude Code
https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
Generated by Claude Code