From 8e75df1cdf3d1bd7ac6cb8c32567ee9457b5c775 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 01:09:36 +0000 Subject: [PATCH] tooling(devx): report source files ESLint reaches for NEITHER reason `check:lint-rule-coverage` gains a second predicate. The first reports files ESLint WALKS that resolve zero rules; this one reports source files ESLint never walks at all, because their extension is in neither a rule-bearing `files` glob nor ESLint's default lint set. The unreachable extension set is DERIVED from the live config on every run rather than listed, so it is not a `.mts` special case: on this base it is `.jsx` `.cts` `.mts`. Per file, the discrimination is a substitution on the candidate's own path -- a build output or a name-excluded file is excluded for a reason that is not this gate's business. This widens the gate's subject on purpose, and its header now says so. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr --- .../check-lint-rule-coverage.test.ts | 190 +++++++- scripts/check-lint-rule-coverage.mjs | 433 ++++++++++++++++-- 2 files changed, 590 insertions(+), 33 deletions(-) diff --git a/scripts/__tests__/check-lint-rule-coverage.test.ts b/scripts/__tests__/check-lint-rule-coverage.test.ts index a42b44aa71..c5d57ece9a 100644 --- a/scripts/__tests__/check-lint-rule-coverage.test.ts +++ b/scripts/__tests__/check-lint-rule-coverage.test.ts @@ -9,9 +9,13 @@ import { ESLint } from 'eslint'; import { CENSUS_FLOORS, PROBE_BASENAME, + SOURCE_EXTENSIONS, + UNREACHED_GROUPS, VACUOUS_GROUPS, analyze, censusCollapse, + extensionProbeCollapse, + extensionReach, ruleCountFor, walkedFiles, } from '../check-lint-rule-coverage.mjs'; @@ -39,6 +43,18 @@ import { * 4. **A green is never "the walk found nothing."** The fixture greens assert * their own counters, and the repository run asserts the census floors. * 5. **This repository is green today**, with every ledger row still live. + * 7. **objectui#8337 -- the second predicate.** A source file ESLint does not + * walk AT ALL is a different defect from a walked file with no rules, and + * predicate 1 cannot see it by construction. The unreachable EXTENSION set + * is derived from the live config rather than listed, so a predicate that + * knew only about `.mts` would fail these cases. + * 8. **The discrimination is per file, not per ignore.** A `.mts` inside an + * ignored directory, and one excluded by a name pattern, are both excluded + * for reasons that are not this gate's business; the substitution test + * separates them from the extension gap. + * 9. **Predicate 2 cannot pass vacuously even once it is fixed.** Its control + * is on the probe -- both answers in the same run -- so an empty population + * stays a reading rather than becoming a blind spot. * 6. **The gate is wired** in `package.json`, and its only enforcement path is * the `this repository is green` case above, running inside `pnpm test`. * The absence of a `ci.yml` step is asserted rather than assumed, so the @@ -81,6 +97,12 @@ const FILES = { 'tools/vacuous.mjs': 'export const b = 2;\n', }; +/** Predicate 1's ledger for {@link FILES}, so a predicate-2 fixture reds for one reason only. */ +const LEDGER = [ + { glob: 'tools/**/*.mjs', reason: 'fixture', card: 'objectui#7908' }, + { glob: 'eslint.config.js', reason: 'the fixture config', card: 'objectui#7908' }, +]; + describe('the walk is the walk ESLint actually performs', () => { it('enumerates exactly what ESLint#lintFiles reaches', async () => { const root = tree('walk', { @@ -198,7 +220,9 @@ describe('the ledger, and the three directions it goes red', () => { it('is GREEN when every vacuous file is declared, with counters that are not zero', async () => { const root = tree('green', { 'eslint.config.js': TS_ONLY_CONFIG, ...FILES }); - const result = await analyze({ root, groups }); + // Predicate 2 has its own ledger and its own fixtures below; an empty one + // keeps this case a statement about vacuity alone. + const result = await analyze({ root, groups, unreachedGroups: [] }); expect(result.findings).toEqual([]); expect(result.vacuous).toEqual(['eslint.config.js', 'tools/vacuous.mjs']); @@ -242,7 +266,7 @@ describe('the ledger, and the three directions it goes red', () => { 'eslint.config.js': TS_ONLY_CONFIG.replace("files: ['**/*.ts']", "files: ['**/*.{ts,mjs}']"), ...FILES, }); - const result = await analyze({ root, groups }); + const result = await analyze({ root, groups, unreachedGroups: [] }); // Only the config file is left unjudged; the row that declared `tools/` // now over-claims a covered file AND declares nothing, which is both reds. @@ -263,6 +287,119 @@ describe('the ledger, and the three directions it goes red', () => { }); }); + +describe('objectui#8337 -- the source files ESLint does not walk at all', () => { + // The predicate-2 ledger every fixture below judges against, and the shape + // the real one has: an exact path, never a population glob. + const unreachedGroups = [ + { glob: 'src/tool.mts', reason: 'the fixture config never names .mts', card: 'objectui#8337' }, + ]; + + it('derives the unreachable extension set from the config, and it is not just `.mts`', async () => { + const root = tree('reach', { 'eslint.config.js': TS_ONLY_CONFIG, ...FILES }); + const reach = await extensionReach(new ESLint({ cwd: root }), root); + + // `.js`/`.cjs`/`.mjs` are ESLint's default set; `.ts` is reachable only + // because the config names it. Everything else falls through BOTH. + expect(reach.reachable).toEqual(['js', 'cjs', 'mjs', 'ts']); + expect(reach.unreachable).toEqual(['jsx', 'cts', 'mts', 'tsx']); + // A predicate that knew only about `.mts` would report one of these four. + expect(reach.unreachable.length).toBeGreaterThan(1); + }); + + it('follows the config rather than a list -- widening it moves an extension out of the set', async () => { + const root = tree('reach-widened', { + 'eslint.config.js': TS_ONLY_CONFIG.replace("files: ['**/*.ts']", "files: ['**/*.{ts,mts}']"), + ...FILES, + }); + const reach = await extensionReach(new ESLint({ cwd: root }), root); + + expect(reach.reachable).toContain('mts'); + expect(reach.unreachable).not.toContain('mts'); + // `.cts` did not move: the config named one extension, not a family. + expect(reach.unreachable).toContain('cts'); + }); + + it('reds on an unwalked source file, and the same tree greens once a row declares it', async () => { + const files = { 'eslint.config.js': TS_ONLY_CONFIG, ...FILES, 'src/tool.mts': 'export const t = 7;\n' }; + const root = tree('unreached', files); + + const bare = await analyze({ root, groups: LEDGER, unreachedGroups: [] }); + const red = bare.findings.filter((f) => f.kind === 'unreached-unledgered'); + expect(red).toHaveLength(1); + expect(red[0].files).toEqual(['src/tool.mts']); + // It is NOT the other predicate's class: ESLint never opened it, so it is + // in neither `walked` nor `vacuous`. + expect(bare.walked).not.toContain('src/tool.mts'); + expect(bare.vacuous).not.toContain('src/tool.mts'); + expect(bare.unwalkedSource).toContain('src/tool.mts'); + + const declared = await analyze({ root, groups: LEDGER, unreachedGroups }); + expect(declared.findings).toEqual([]); + expect(declared.unreachedRows[0].unreachedMatches).toEqual(['src/tool.mts']); + }); + + it('does not claim a file excluded by its LOCATION or by a NAME pattern', async () => { + // Two legitimate exclusions that are none of this gate's business, and the + // extension gap sitting between them in the same tree. + const root = tree('not-extension', { + 'eslint.config.js': TS_ONLY_CONFIG.replace( + "{ ignores: ['**/generated/**'] },", + "{ ignores: ['**/generated/**', '**/*.gen.*'] },", + ), + ...FILES, + 'generated/build.mts': 'export const g = 8;\n', + 'src/schema.gen.mts': 'export const h = 9;\n', + 'src/tool.mts': 'export const t = 7;\n', + }); + const result = await analyze({ root, groups: LEDGER, unreachedGroups: [] }); + + const red = result.findings.filter((f) => f.kind === 'unreached-unledgered'); + expect(red).toHaveLength(1); + // Only the one whose path WOULD be walked under a reachable extension. + expect(red[0].files).toEqual(['src/tool.mts']); + }); + + it('reds as OVER-BROAD when a row also claims a file ESLint walks', async () => { + const root = tree('unreached-overbroad', { + 'eslint.config.js': TS_ONLY_CONFIG, + ...FILES, + 'src/tool.mts': 'export const t = 7;\n', + }); + const result = await analyze({ + root, + groups: LEDGER, + unreachedGroups: [{ glob: 'src/*', reason: 'claims the covered .ts too', card: 'objectui#8337' }], + }); + + const overBroad = result.findings.filter((f) => f.kind === 'unreached-over-broad'); + expect(overBroad).toHaveLength(1); + expect(overBroad[0].files).toEqual(['src/covered.ts']); + }); + + it('reds as STALE when the config grows to reach the declared extension', async () => { + // The remedy direction, driven through ESLint rather than by editing the + // row: reach `.mts` and the waiver has nothing left to waive. + const root = tree('unreached-stale', { + 'eslint.config.js': TS_ONLY_CONFIG.replace("files: ['**/*.ts']", "files: ['**/*.{ts,mts}']"), + ...FILES, + 'src/tool.mts': 'export const t = 7;\n', + }); + const result = await analyze({ root, groups: LEDGER, unreachedGroups }); + + expect(result.unreached).toEqual([]); + expect(result.findings.filter((f) => f.kind === 'unreached-stale')).toHaveLength(1); + }); + + it('cannot report a clean sheet on a broken probe -- the control is on the instrument', async () => { + // An empty population is what FIXING this looks like, so the control has to + // survive the fix: it asks the probe to answer both ways in one run. + expect(extensionProbeCollapse({ reachable: [], controlUnreachable: true })).toMatch(/probe collapsed/); + expect(extensionProbeCollapse({ reachable: ['ts'], controlUnreachable: false })).toMatch(/probe collapsed/); + expect(extensionProbeCollapse({ reachable: ['ts'], controlUnreachable: true })).toBeNull(); + }); +}); + describe('the census cannot pass by collapsing', () => { it('rejects a walk that reached nothing', () => { expect(censusCollapse({ walked: [], ruleBearing: [], vacuous: [] })).toMatch(/census collapsed/); @@ -287,6 +424,14 @@ describe('this repository', () => { expect(row.vacuousMatches.length, `ledger row '${row.glob}' declares nothing any more`).toBeGreaterThan(0); expect(row.ruleBearingMatches, `ledger row '${row.glob}' over-claims`).toEqual([]); } + // Predicate 2, same two properties. Its population is one file today, and a + // green here means that file is DECLARED rather than invisible. + expect(extensionProbeCollapse(result.reach)).toBeNull(); + expect(result.unreached).toEqual(['vitest.config.mts']); + for (const row of result.unreachedRows) { + expect(row.unreachedMatches.length, `unreached row '${row.glob}' declares nothing any more`).toBeGreaterThan(0); + expect(row.walkedMatches, `unreached row '${row.glob}' over-claims`).toEqual([]); + } }, 60_000); it('still has the defect the card measured -- the JS family resolves zero rules', async () => { @@ -298,6 +443,35 @@ describe('this repository', () => { expect(await ruleCountFor(eslint, path.join(repoRoot, 'playwright.config.ts'))).toBeGreaterThan(100); }, 30_000); + it('still has objectui#8337 -- three controls, three DISTINCT states, one run', async () => { + const eslint = new ESLint({ cwd: repoRoot }); + const mts = path.join(repoRoot, 'vitest.config.mts'); + + // NOT WALKED. `undefined` is not "zero rules"; it is ESLint declining to + // look, and collapsing the two would delete the finding. + expect(await eslint.isPathIgnored(mts)).toBe(true); + expect(await eslint.calculateConfigForFile(mts)).toBeUndefined(); + // Walked and ruled. + expect(await ruleCountFor(eslint, path.join(repoRoot, 'playwright.config.ts'))).toBeGreaterThan(100); + // Walked, zero rules -- predicate 1's class, which is a DIFFERENT state. + expect(await eslint.calculateConfigForFile(path.join(repoRoot, 'scripts/github-slug.mjs'))).toBeDefined(); + expect(await ruleCountFor(eslint, path.join(repoRoot, 'scripts/github-slug.mjs'))).toBe(0); + }, 30_000); + + it('has an unreachable extension set that is derived, and a probe that discriminates', async () => { + const reach = await extensionReach(new ESLint({ cwd: repoRoot }), repoRoot); + + expect(extensionProbeCollapse(reach)).toBeNull(); + // Measured on 868e825012: the rule-bearing globs are TS-and-TSX and the + // default set is the JS family, so three extensions fall through both -- + // `.jsx` among them, which neither the card nor its triage names. + expect(reach.unreachable).toEqual(['jsx', 'cts', 'mts']); + expect(reach.reachable).toEqual(['js', 'cjs', 'mjs', 'ts', 'tsx']); + // Every candidate got an answer: the probe partitions the set, it does not + // quietly drop an extension it could not decide. + expect([...reach.reachable, ...reach.unreachable].sort()).toEqual([...SOURCE_EXTENSIONS].sort()); + }, 30_000); + it('declares a reason and an owning card on every ledger row', () => { expect(VACUOUS_GROUPS.length).toBeGreaterThan(0); for (const row of VACUOUS_GROUPS) { @@ -310,6 +484,18 @@ describe('this repository', () => { expect(VACUOUS_GROUPS.length).toBeLessThan(20); }); + it("declares predicate 2's rows as exact PATHS, not populations", () => { + expect(UNREACHED_GROUPS.length).toBeGreaterThan(0); + for (const row of UNREACHED_GROUPS) { + expect(row.reason.length, `row '${row.glob}' needs a reason, not a bare path`).toBeGreaterThan(40); + expect(row.card, `row '${row.glob}' must name the card that owns it`).toMatch(/objectui#\d+/); + // The asymmetry with VACUOUS_GROUPS, pinned because it is deliberate: a + // `**/*.mts` row would waive the next `.mts` file, which is the only + // thing this predicate exists to catch. + expect(row.glob, `row '${row.glob}' must not be a population glob`).not.toMatch(/[*?[\]{]/); + } + }); + it('is wired in package.json, and deliberately not in a workflow yet', () => { const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); expect(manifest.scripts['check:lint-rule-coverage']).toBe('node scripts/check-lint-rule-coverage.mjs'); diff --git a/scripts/check-lint-rule-coverage.mjs b/scripts/check-lint-rule-coverage.mjs index b2b3e4bd5f..cfe59d902a 100644 --- a/scripts/check-lint-rule-coverage.mjs +++ b/scripts/check-lint-rule-coverage.mjs @@ -1,12 +1,25 @@ #!/usr/bin/env node /** - * Every file ESLint WALKS must resolve at least one rule, or be a declared row - * in the ledger below. + * TWO predicates over one walk, because there are two ways a source file can go + * unjudged and only one of them was visible here before: + * + * 1. Every file ESLint WALKS must resolve at least one rule (objectui#7908). + * 2. Every source file ESLint does NOT walk must be walked for a reason other + * than its EXTENSION (objectui#8337). + * + * Each has its own ledger, and both ledgers are shrink-only. + * + * ⚠️ Predicate 2 is a deliberate WIDENING of this gate's subject, added by the + * PR that fixes objectui#8337. Until then this file's subject was the walked + * population alone, and it said so in this line. The subject moved on purpose: + * predicate 1 cannot see predicate 2's population BY CONSTRUCTION -- it reports + * files ESLint walks, and those files are not walked at all. * * Run: node scripts/check-lint-rule-coverage.mjs (also `pnpm check:lint-rule-coverage`) - * Exit: 0 = every walked file resolves rules or is ledgered, 1 = a walked file - * resolves zero rules outside the ledger, a ledger row has gone stale, or - * the census collapsed + * Exit: 0 = every walked file resolves rules or is ledgered AND every unwalked + * source file is unwalked for some reason other than its extension or is + * ledgered, 1 = any of those is violated, a ledger row has gone stale, or + * a census/probe control collapsed * * ## The two truths this gate exists to separate (objectui#7908) * @@ -154,6 +167,89 @@ * real-world way this defect gets ADDED to a config, and it is the fourth * ablation leg on this gate's PR. * + * ## Predicate 2: the extensions ESLint reaches for NEITHER reason (objectui#8337) + * + * The section above ends on `{ files: ['**\/*.mts'] }` ENTERING the walk. The + * neighbouring state is the one where nothing puts `.mts` in the walk at all, + * and predicate 1 is blind to it by construction: it reports files ESLint + * walks. + * + * Re-measured on `868e825012` (this branch's base) with a real install, ESLint + * v10.8.1, via the Node API -- three controls in one run, three DISTINCT + * states, which is what makes any of them a reading: + * + * vitest.config.mts -> isPathIgnored: true, config undefined NOT WALKED + * playwright.config.ts -> isPathIgnored: false, 116 rules walked + ruled + * scripts/github-slug.mjs -> isPathIgnored: false, 0 rules predicate 1's class + * + * ⭐ `undefined` is not "zero rules" -- it is ESLint declining to look. Collapse + * the three states into one and the finding disappears. + * + * In flat config a file is linted only if some config object's `files` matches + * it, PLUS the default set ESLint always lints (`.js` / `.cjs` / `.mjs`). Every + * rule-bearing object here is scoped TS-and-TSX, so an extension in neither + * place falls through both. + * + * ## Why this predicate is over EXTENSIONS and not over `.mts` + * + * A predicate that knows only about `.mts` reproduces the blind spot one size + * smaller. So the unreachable set is DERIVED from the live config on every run + * -- {@link extensionReach} probes a synthetic path per source extension -- and + * the measurement immediately paid for itself. On `868e825012` it is: + * + * reachable js cjs mjs (default set) ts tsx (the rule-bearing globs) + * unreachable jsx cts mts + * + * ⭐ THREE, not one. `.jsx` is unreachable here for exactly the same reason as + * `.mts` and neither the card nor its triage names it; there are no `.jsx` + * files today, so nothing reported it and nothing would have. Deriving the set + * rather than listing it is also what makes the ledger shrink-only in the right + * direction: widen a rule-bearing glob to cover `.mts` and the extension leaves + * the unreachable set, its ledger row stops matching, and the row goes STALE. + * + * ## Why the per-file test substitutes an extension instead of trusting the walk + * + * `isPathIgnored` is true for `packages/core/dist/x.mts` as well, and that file + * is ignored for a completely legitimate reason -- its LOCATION. Measured, same + * run: inside `**\/dist` and `**\/.source` every one of the eight source + * extensions comes back ignored, while at the repository root, under + * `scripts/`, and under `packages/core/src/` the answer is identical and + * extension-shaped. So the discrimination is per file and mechanical: take the + * candidate's own path, substitute a REACHABLE extension onto it, and ask + * again. If some substitution is walked, then the extension -- and nothing else + * about that path -- is why ESLint declines to look. If every substitution is + * still ignored, the path is excluded by location or by name and this gate has + * nothing to say about it. + * + * ## Why predicate 2 has a ledger at all, and why its rows are PATHS + * + * There is one file in the population today, repo-wide, and it is + * `vitest.config.mts` (`.cts` and `.jsx` have no files). Reaching it means + * widening a rule-bearing `files` glob, whose red set is UNMEASURED -- a rule + * STRENGTH decision that objectui#8337's triage deliberately kept OUT of the + * gate that reports it. So the file is declared, exactly as predicate 1 + * declares `eslint.config.js`, and the first run is green with it counted. + * + * ⚠️ Its rows are exact PATHS where {@link VACUOUS_GROUPS}'s rows are + * population globs, and the difference is not an inconsistency. There, a new + * `scripts/check-foo.mjs` adds no new information -- the row already declares + * that whole class, and 38 such files landed in 14 days. Here, the entire point + * is that the NEXT `.mts` or `.cts` gets reported: a `**\/*.mts` row would + * waive precisely the thing this predicate exists to catch, turning a gate into + * a permanent exemption. + * + * ## What keeps predicate 2 from passing vacuously + * + * A gate that would be green on an empty population is not a gate, and this one + * CAN reach an empty population legitimately -- that is what fixing it looks + * like. So the non-vacuity control is on the INSTRUMENT rather than on the + * population, and it survives the fix: {@link extensionProbeCollapse} requires + * the probe to answer BOTH ways in the same run -- at least one source + * extension reachable (so the config loaded and the rule-bearing globs are + * live), and a synthetic extension no config object can name coming back + * unreachable (so the probe is able to say "no" at all). If those two hold and + * the unreachable set is empty, the emptiness is a reading. + * * ## Cost * * Measured on `fedfa3e4a`, this branch's base: 4438 walked files out of 6699 on disk. Directory @@ -183,7 +279,7 @@ */ import { readdirSync } from 'node:fs'; -import { join, matchesGlob, relative, sep } from 'node:path'; +import { extname, join, matchesGlob, relative, sep } from 'node:path'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -211,6 +307,33 @@ export const STRUCTURAL_SKIP_DIRS = new Set(['.git', 'node_modules']); */ export const PROBE_BASENAME = 'eslint-rule-coverage-probe.js'; +/** + * The JS/TS module extensions ESLint could lint here, as the CLOSED set Node + * and TypeScript between them define: the three Node module extensions and + * their JSX form, and the same four for TypeScript. Anything else is not source + * this toolchain can parse, so it is not a coverage gap when ESLint skips it. + * + * This is the candidate set, never the answer. Which of these ESLint actually + * reaches is derived from the live config on every run -- see + * {@link extensionReach}. + */ +export const SOURCE_EXTENSIONS = ['js', 'cjs', 'mjs', 'jsx', 'ts', 'cts', 'mts', 'tsx']; + +/** + * Basename stem for the synthetic per-extension probe. Never written to disk: + * `isPathIgnored` answers about a path, and the path need not exist, which is + * what lets the question be asked about an extension that has no files yet. + */ +export const EXTENSION_PROBE_STEM = 'eslint-extension-reach-probe'; + +/** + * The negative half of the probe's own control: an extension no config object + * in any repository would name and that is not in ESLint's default set, so a + * probe that can say "no" at all must say it here. See + * {@link extensionProbeCollapse}. + */ +export const UNREACHABLE_CONTROL_EXTENSION = 'eslint-reach-control'; + /** * The declared vacuous population: every group of files ESLint walks today with * an empty rule set, as a glob and the reason it is here. @@ -280,6 +403,30 @@ export const VACUOUS_GROUPS = [ }, ]; +/** + * The declared population of predicate 2: source files ESLint declines to look + * at BECAUSE OF THEIR EXTENSION, as a path and the reason it is here. + * + * Like {@link VACUOUS_GROUPS} this is an EXEMPTION list that can only be + * narrowed or deleted -- but its rows are exact PATHS rather than population + * globs, and the header says why: a `**\/*.mts` row would waive the next + * `.mts` file, which is the only thing this predicate exists to catch. + * + * @type {{ glob: string, reason: string, card: string }[]} + */ +export const UNREACHED_GROUPS = [ + { + glob: 'vitest.config.mts', + reason: + 'The entire population of this predicate today, and the file objectui#8337 was filed about. It ' + + 'defines every project, include glob and setup file the whole test run uses, and it is the one ' + + 'config in this repository no lint run has ever read. Declared rather than repaired because ' + + 'reaching it means widening a rule-bearing `files` glob, whose red set is UNMEASURED -- a rule ' + + 'STRENGTH decision that objectui#8337 triage deliberately kept out of the gate that reports it.', + card: 'objectui#8337', + }, +]; + /** * Floors below which a green would be asserting nothing. An empty walk, or a * walk that reached no rule-bearing file, passes every check above it -- so the @@ -291,6 +438,63 @@ export const VACUOUS_GROUPS = [ */ export const CENSUS_FLOORS = { walked: 1000, ruleBearing: 1000 }; +/** + * Which source extensions the live config lets ESLint reach, derived rather + * than listed. + * + * The probe is a path that need not exist, taken at the repository root -- a + * location with no directory ignore over it, so the only thing the answer can + * be about is the extension. Measured on `868e825012`: the answer is identical + * at the root, under `scripts/`, and under `packages/core/src/`. + * + * @param {ESLint} eslint + * @param {string} root absolute + * @returns {Promise<{ reachable: string[], unreachable: string[], controlUnreachable: boolean }>} + */ +export async function extensionReach(eslint, root) { + /** @type {string[]} */ + const reachable = []; + /** @type {string[]} */ + const unreachable = []; + for (const ext of SOURCE_EXTENSIONS) { + const probe = join(root, `${EXTENSION_PROBE_STEM}.${ext}`); + if (await eslint.isPathIgnored(probe)) unreachable.push(ext); + else reachable.push(ext); + } + const controlUnreachable = await eslint.isPathIgnored( + join(root, `${EXTENSION_PROBE_STEM}.${UNREACHABLE_CONTROL_EXTENSION}`), + ); + return { reachable, unreachable, controlUnreachable }; +} + +/** + * The non-vacuity control for predicate 2, on the INSTRUMENT rather than on the + * population -- because an empty population is what fixing this defect looks + * like, and a control that dies at the fix would be a control that only ever + * guarded the broken state. + * + * @param {{ reachable: string[], controlUnreachable: boolean }} reach + * @returns {string | null} the collapse message, or null when the probe discriminates + */ +export function extensionProbeCollapse(reach) { + if (!reach.reachable.length) { + return ( + 'The extension probe collapsed: ESLint reaches NONE of ' + + `${SOURCE_EXTENSIONS.join(', ')} at the repository root. The default lint set alone should make ` + + 'js/cjs/mjs reachable, so the config did not load or the probe path is wrong. Every source file ' + + 'would be reported, which is loud -- but the reading is still not one.' + ); + } + if (!reach.controlUnreachable) { + return ( + `The extension probe collapsed: a synthetic '.${UNREACHABLE_CONTROL_EXTENSION}' path came back ` + + 'REACHABLE. The probe cannot answer "no", so an empty unreachable set would mean nothing rather ' + + 'than meaning every source extension is covered.' + ); + } + return null; +} + /** * Is `dir` a directory ESLint ignores wholesale? * @@ -303,16 +507,25 @@ export async function isIgnoredDirectory(eslint, dir) { } /** - * Every file on disk under `root` that ESLint would walk, as paths relative to - * `root` with forward slashes. + * One walk, two populations: the files ESLint walks, and the source files it + * does NOT walk. Both are what the two predicates need, and they are the same + * traversal -- `isPathIgnored` is 1.9s of this gate's ~2s, so walking twice + * would double the whole cost to re-answer a question already asked. + * + * Directories ESLint ignores wholesale are pruned for BOTH populations, which + * is why `packages/core/dist/x.mts` never reaches predicate 2: a build output + * is not an extension gap. * * @param {string} root absolute * @param {ESLint} eslint an instance whose `cwd` is `root` - * @returns {Promise} + * @returns {Promise<{ walked: string[], unwalkedSource: string[] }>} */ -export async function walkedFiles(root, eslint) { +export async function walkTree(root, eslint) { /** @type {string[]} */ - const out = []; + const walked = []; + /** @type {string[]} */ + const unwalkedSource = []; + const sourceExtensions = new Set(SOURCE_EXTENSIONS); /** @param {string} dir */ async function descend(dir) { @@ -330,17 +543,87 @@ export async function walkedFiles(root, eslint) { if (await isIgnoredDirectory(eslint, full)) continue; await descend(full); } else if (entry.isFile()) { - if (await eslint.isPathIgnored(full)) continue; - out.push(relative(root, full).split(sep).join('/')); + const rel = relative(root, full).split(sep).join('/'); + if (await eslint.isPathIgnored(full)) { + if (sourceExtensions.has(extname(entry.name).slice(1))) unwalkedSource.push(rel); + continue; + } + walked.push(rel); } } } await descend(root); - out.sort(); + walked.sort(); + unwalkedSource.sort(); + return { walked, unwalkedSource }; +} + +/** + * Every file on disk under `root` that ESLint would walk, as paths relative to + * `root` with forward slashes. + * + * @param {string} root absolute + * @param {ESLint} eslint an instance whose `cwd` is `root` + * @returns {Promise} + */ +export async function walkedFiles(root, eslint) { + return (await walkTree(root, eslint)).walked; +} + +/** + * Of `candidates` -- source files ESLint does not walk -- the ones it declines + * to look at BECAUSE OF THEIR EXTENSION and nothing else. + * + * The test is a substitution on the candidate's OWN path: swap in a reachable + * extension and ask again. Walked under some substitution means the path is + * fine and only the extension is not; still ignored under every substitution + * means the exclusion is by location or by name, which is somebody's deliberate + * choice and none of this gate's business. + * + * @param {ESLint} eslint + * @param {string} root absolute + * @param {string[]} candidates paths relative to `root` + * @param {string[]} reachable extensions, without the dot + * @returns {Promise} + */ +export async function unreachedByExtension(eslint, root, candidates, reachable) { + /** @type {string[]} */ + const out = []; + for (const file of candidates) { + const stem = file.slice(0, file.length - extname(file).length); + for (const ext of reachable) { + if (!(await eslint.isPathIgnored(join(root, `${stem}.${ext}`)))) { + out.push(file); + break; + } + } + } return out; } +/** + * Judge one ledger against one measured population. Shared by both predicates: + * the three directions a row can be wrong are identical, only the populations + * differ -- `matches` is what the row claims and found, `counterMatches` is + * what it claims and should not have. + * + * @template {{ glob: string }} Row + * @param {Row[]} groups + * @param {string[]} positives the population the ledger is allowed to declare + * @param {string[]} negatives the population a row must never match + * @returns {{ rows: (Row & { matches: string[], counterMatches: string[] })[], unledgered: string[] }} + */ +export function judgeLedger(groups, positives, negatives) { + const rows = groups.map((group) => ({ + ...group, + matches: positives.filter((f) => matchesGlob(f, group.glob)), + counterMatches: negatives.filter((f) => matchesGlob(f, group.glob)), + })); + const unledgered = positives.filter((f) => !groups.some((g) => matchesGlob(f, g.glob))); + return { rows, unledgered }; +} + /** * The number of rules `file` resolves. Zero means ESLint walks it and has * nothing to say about it, whatever its contents. @@ -355,20 +638,30 @@ export async function ruleCountFor(eslint, file) { } /** - * Walk `root`, resolve every walked file's rule count, and judge the ledger. + * Walk `root` once and judge both predicates against their ledgers. * * @param {object} options * @param {string} options.root absolute repository root - * @param {typeof VACUOUS_GROUPS} [options.groups] the ledger to judge against + * @param {typeof VACUOUS_GROUPS} [options.groups] predicate 1's ledger + * @param {typeof UNREACHED_GROUPS} [options.unreachedGroups] predicate 2's ledger * @returns {Promise<{ * walked: string[], * vacuous: string[], * ruleBearing: string[], + * unwalkedSource: string[], + * reach: { reachable: string[], unreachable: string[], controlUnreachable: boolean }, + * unreached: string[], * rows: { glob: string, reason: string, card: string, vacuousMatches: string[], ruleBearingMatches: string[] }[], - * findings: { kind: 'unledgered' | 'over-broad' | 'stale', glob?: string, files: string[] }[], + * unreachedRows: { glob: string, reason: string, card: string, unreachedMatches: string[], walkedMatches: string[] }[], + * findings: { + * kind: 'unledgered' | 'over-broad' | 'stale' + * | 'unreached-unledgered' | 'unreached-over-broad' | 'unreached-stale', + * glob?: string, + * files: string[], + * }[], * }>} */ -export async function analyze({ root, groups = VACUOUS_GROUPS }) { +export async function analyze({ root, groups = VACUOUS_GROUPS, unreachedGroups = UNREACHED_GROUPS }) { if (typeof matchesGlob !== 'function') { throw new Error( "node:path does not export matchesGlob on this runtime. It arrived in Node 22.5 and package.json " + @@ -378,7 +671,9 @@ export async function analyze({ root, groups = VACUOUS_GROUPS }) { } const eslint = new ESLint({ cwd: root }); - const walked = await walkedFiles(root, eslint); + const { walked, unwalkedSource } = await walkTree(root, eslint); + const reach = await extensionReach(eslint, root); + const unreached = await unreachedByExtension(eslint, root, unwalkedSource, reach.reachable); /** @type {string[]} */ const vacuous = []; @@ -389,17 +684,33 @@ export async function analyze({ root, groups = VACUOUS_GROUPS }) { else ruleBearing.push(file); } - const rows = groups.map((group) => ({ + const vacuity = judgeLedger(groups, vacuous, ruleBearing); + const rows = vacuity.rows.map(({ matches, counterMatches, ...group }) => ({ ...group, - vacuousMatches: vacuous.filter((f) => matchesGlob(f, group.glob)), - ruleBearingMatches: ruleBearing.filter((f) => matchesGlob(f, group.glob)), + vacuousMatches: matches, + ruleBearingMatches: counterMatches, })); - /** @type {{ kind: 'unledgered' | 'over-broad' | 'stale', glob?: string, files: string[] }[]} */ + // Predicate 2's counter-population is the WALKED set: a row here over-claims + // when the file it declares unreachable has since been reached. + const reachJudgement = judgeLedger(unreachedGroups, unreached, walked); + const unreachedRows = reachJudgement.rows.map(({ matches, counterMatches, ...group }) => ({ + ...group, + unreachedMatches: matches, + walkedMatches: counterMatches, + })); + + /** + * @type {{ + * kind: 'unledgered' | 'over-broad' | 'stale' + * | 'unreached-unledgered' | 'unreached-over-broad' | 'unreached-stale', + * glob?: string, + * files: string[], + * }[]} + */ const findings = []; - const unledgered = vacuous.filter((f) => !groups.some((g) => matchesGlob(f, g.glob))); - if (unledgered.length) findings.push({ kind: 'unledgered', files: unledgered }); + if (vacuity.unledgered.length) findings.push({ kind: 'unledgered', files: vacuity.unledgered }); for (const row of rows) { if (row.ruleBearingMatches.length) { @@ -410,7 +721,20 @@ export async function analyze({ root, groups = VACUOUS_GROUPS }) { } } - return { walked, vacuous, ruleBearing, rows, findings }; + if (reachJudgement.unledgered.length) { + findings.push({ kind: 'unreached-unledgered', files: reachJudgement.unledgered }); + } + + for (const row of unreachedRows) { + if (row.walkedMatches.length) { + findings.push({ kind: 'unreached-over-broad', glob: row.glob, files: row.walkedMatches }); + } + if (row.unreachedMatches.length === 0) { + findings.push({ kind: 'unreached-stale', glob: row.glob, files: [] }); + } + } + + return { walked, vacuous, ruleBearing, unwalkedSource, reach, unreached, rows, unreachedRows, findings }; } /** @@ -432,7 +756,7 @@ async function main() { const root = resolve(scriptDir, '..'); const result = await analyze({ root }); - const collapse = censusCollapse(result); + const collapse = censusCollapse(result) ?? extensionProbeCollapse(result.reach); if (collapse) { console.error(collapse); process.exit(1); @@ -447,9 +771,20 @@ async function main() { for (const row of result.rows) { console.log(` ${String(row.vacuousMatches.length).padStart(3)} ${row.glob} (${row.card})`); } + console.log( + `\n ESLint reaches ${result.reach.reachable.map((e) => `.${e}`).join(' ')} and NOT ` + + `${result.reach.unreachable.map((e) => `.${e}`).join(' ') || '(nothing)'} -- ` + + `${result.unreached.length} source file(s) on disk carry an unreached extension, every one ` + + `declared by ${result.unreachedRows.length} ledger row(s):`, + ); + for (const row of result.unreachedRows) { + console.log(` ${String(row.unreachedMatches.length).padStart(3)} ${row.glob} (${row.card})`); + } console.log( '\n A zero-rule file is one ESLint parses and has nothing to say about. It is not clean; it is\n' + - ' unjudged, and every exit code downstream reads it as clean.', + ' unjudged, and every exit code downstream reads it as clean. An unreached file is one step\n' + + ' further out: ESLint never opened it, and `undefined` is not zero rules -- it is ESLint\n' + + ' declining to look.', ); process.exit(0); } @@ -476,18 +811,54 @@ async function main() { '\n The row over-claims: it declares files vacuous that are now covered. Narrow the glob so it\n' + ' keeps claiming only what is actually unjudged. The ledger only ever shrinks.\n', ); - } else { + } else if (finding.kind === 'stale') { console.error(` the ledger row '${finding.glob}' matches no file ESLint walks with zero rules.`); console.error( '\n The vacuity this row declares is gone -- the files gained rules, moved, or were deleted.\n' + ' A row that waives nothing is a live waiver for nothing. Delete it.\n', ); + } else if (finding.kind === 'unreached-unledgered') { + console.error( + ` ${finding.files.length} source file(s) ESLint does NOT walk, purely because of their extension,\n` + + ' and no ledger row declares them:', + ); + for (const file of finding.files) console.error(` ${file}`); + console.error( + '\n These are not linted vacuously -- they are not linted at all. `calculateConfigForFile`\n' + + ' returns `undefined` for them, which is ESLint declining to look, and no lint run in this\n' + + ' repository reports on them in either direction. The same path with a reachable extension\n' + + ' WOULD be walked, so the extension is the whole reason. Either add the extension to a\n' + + ' rule-bearing `files` glob in eslint.config.js -- a rule-STRENGTH change, measure its red\n' + + ' set first -- or add a row to UNREACHED_GROUPS in scripts/check-lint-rule-coverage.mjs with\n' + + ' the card that owns the decision.\n', + ); + } else if (finding.kind === 'unreached-over-broad') { + console.error( + ` the unreached-ledger row '${finding.glob}' also matches ${finding.files.length} file(s) ESLint DOES walk:`, + ); + for (const file of finding.files.slice(0, 20)) console.error(` ${file}`); + if (finding.files.length > 20) console.error(` ... and ${finding.files.length - 20} more`); + console.error( + '\n The row over-claims: it declares files unreachable that ESLint now reaches. Narrow it to\n' + + ' what is still outside every run. This ledger only ever shrinks.\n', + ); + } else { + console.error( + ` the unreached-ledger row '${finding.glob}' matches no source file ESLint declines to look at.`, + ); + console.error( + '\n Either the file was reached -- a rule-bearing `files` glob grew to cover its extension --\n' + + ' or it moved or was deleted. Either way the waiver waives nothing. Delete the row.\n', + ); } } console.error( ` census: ${result.walked.length} walked, ${result.ruleBearing.length} rule-bearing, ` + - `${result.vacuous.length} zero-rule.\n` + - ' See https://github.com/objectstack-ai/objectui/issues/7908 for why this gate exists.', + `${result.vacuous.length} zero-rule; ${result.unwalkedSource.length} source file(s) not walked, ` + + `${result.unreached.length} of them only because of their extension ` + + `(unreached: ${result.reach.unreachable.map((e) => `.${e}`).join(' ') || 'none'}).\n` + + ' See https://github.com/objectstack-ai/objectui/issues/7908 (predicate 1) and\n' + + ' https://github.com/objectstack-ai/objectui/issues/8337 (predicate 2) for why this gate exists.', ); process.exit(1); }