Skip to content

fix(lint): take build output out of the linted population with a global ignore - #12332

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-12304-eslint-global-ignores
Aug 25, 2026
Merged

yinlianghui merged 2 commits into
mainfrom
claude/issue-12304-eslint-global-ignores

Conversation

@yinlianghui

Copy link
Copy Markdown
Contributor

Fixes #12304

Adds one leading config object to eslint.config.mjs holding only ignores — no files key — which is what makes ESLint treat it as a global exclusion. Declared file surface: eslint.config.mjs, and nothing else.

The mechanism, re-derived properly

The card's structural claim was produced by a brace-matching script. It is confirmed, but by importing the config module and inspecting the real objects rather than by counting braces:

export default is Array: true  length: 7
[0..6] hasFiles=true hasIgnores=true GLOBAL_IGNORE=false
GLOBAL ignore objects (ignores WITHOUT files): 0
objects with NEITHER files nor ignores: 0

All seven objects carry files, so every ignores beside one is object-scoped. After this change: 8 objects, exactly 1 global.

Two corrections to the card, both measured

1. The zero-rule population is parsed by espree, not by @typescript-eslint/parser. The card states the emitted files keep "the custom (@typescript-eslint) parser — so each file is parsed in full", and the title says "at full TS-parser cost". Raw --print-config on packages/core/dist/index.js says otherwise:

"rules": {},
"parser": "espree@11.2.0",

The parser: custom reading came from a heuristic that treats the always-present languageOptions.parser as evidence of a custom parser. My own first probe reproduced the same wrong label before I read the raw output.

2. .ts and .d.ts under dist/ were never in the population at all. --print-config returns literal undefined for them — no config object matches, and ESLint does not lint .ts by default. So the emitted declaration files, which are the bulk of a built tree by file count, cost nothing today. What was actually being linted under dist/ is .js and .mjs, via ESLint's built-in JS defaults.

Neither correction changes the direction of the card. Both change what the fix is buying.

Acceptance evidence

Before/after --print-config (pnpm exec eslint --no-inline-config --print-config):

path before after
packages/core/dist/index.js LINTED, 0 rules enabled, espree not linted
packages/core/dist/index.mjs LINTED, 0 rules enabled, espree not linted
packages/core/dist/index.d.ts not linted not linted
packages/core/src/index.ts LINTED, 6 rules enabled LINTED, 6 rules enabled

Zero rules enabled on the excluded paths both ways, so no accept/reject semantics move.

Before/after population, 50 files per extension dropped under packages/core/dist, build, .next, .turbo, counted from -f json:

before  total linted 796   dist .js = 50   dist .mjs = 50   SOURCE .ts = 96
after   total linted 696   (dist entries absent)            SOURCE .ts = 96

Source coverage unchanged. Confirmed again on the real tree with two full runs: 5155 linted files before, 5155 after, 0 errors both.

Before/after cost, 84 MB of bundle-shaped .js in 25 files under packages/core/dist/, peak RSS sampled from /proc/PID/status VmHWM (no /usr/bin/time in this container):

tree config wall peak RSS
source only before 80.0 s 673.6 MB
source only after 74.6 s 813.0 MB
+ 84 MB of dist/ bundles before 511.6 s 5424.4 MB
+ 84 MB of dist/ bundles after 79.6 s 806.1 MB

The bundle-bearing tree collapses back to source-only cost: -432.0 s and -4618 MB. The card predicted +4.46 GB and +275 s for the same shape; the RSS figure reproduces closely (+4.75 GB here), the wall figure is larger on this more contended container. The card's cost claim stands.

A first attempt at this measurement is not reported above because it was invalid: the generated bundles reused identifiers, so ESLint returned 25 parse errors and abandoned the work early. It is worth one line because it independently proved the central claim — all 25 files under dist/ were reported, i.e. build output really was in the population.

Scope: node_modules and dist only, deliberately

The per-object ignores below also carry **/build/**, **/.next/** and **/.turbo/**. Those three are not promoted here, because promoting them would not be semantics-neutral:

packages/core/build/x.ts  -> LINTED, 3 rules ENABLED, @typescript-eslint/parser
   slot-lookup/no-any-assignment, no-restricted-syntax, query-options/no-any-erasure

Three of the objects below (the packages/** and examples/** ones) list only **/node_modules/** and **/dist/**, so they still match TypeScript under the other three directories. No such path exists in this repo today — nothing emits to build/, and the only Next.js app is apps/docs, which sits outside packages/** — so this is latent rather than live, and it argues for promoting them as a correction. That is a real semantic change either way, and it is left for a maintainer ruling rather than taken silently under a "changes nothing" banner. Details in the issue report comment.

No human-authored file is excluded: git ls-files matches 0 tracked files under /dist/, /build/, /.next/, /.turbo/ and /node_modules/ (control: 4451 tracked files under /src/).

No memory ceiling is pinned, per the triage ruling.

Open edges settled

  • No consumer relies on build output being in the linted population. Grepped workflows, package scripts and scripts/ for the ignore patterns and for eslint invocations; the only hits concern turbo inputs and dts freshness. The root lint script is the only eslint invocation. Positive control: the same search finds --no-inline-config where it is known to be.
  • CI never has build output on disk when it lints. The lint job runs checkout, setup-node, setup-pnpm, cache, pnpm install --frozen-lockfile, then pnpm lint. No build step precedes ESLint, so this change does not alter CI's population — it removes a local-vs-CI divergence that only a developer who has run pnpm build ever saw.

Verification

Gate union derived at the final commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, after merging origin/main (the first derivation printed STALE TREE; this one does not). It reports the change set as the single path eslint.config.mjs and no check family naming it. Run at 77d340b0aa, each exit code captured before any pipe:

gate exit verdict line
pnpm lint 0 run in full, eslint . --no-inline-config, not narrowed
pnpm check:slot-lookup 0 slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new
pnpm check:query-options-erasure 0 query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new
pnpm check:verify-stand-in 0 10 call site(s) reached, 0 asserted driver arguments
pnpm check:nul-bytes 0 scanned 6833 text file(s) ... no raw ASCII control bytes

The three ratchets are included because they consume this file's named exports; their baselines are unaffected and their populations are unchanged.

Reverse verification used a committed fix, so the restore is provable: each baseline run replaced eslint.config.mjs with git show HEAD~1:eslint.config.mjs, asserted the on-disk blob hash equalled git rev-parse HEAD~1:eslint.config.mjs before measuring, and restored under a trap ... EXIT INT TERM that re-checked git hash-object against the fixed blob. Every run printed RESTORE OK — byte-identical.

Changeset

skip-changeset. The root ESLint config is not published: it ships in no package's files array and is not a dependency of anything consumers install. The change alters which paths a local pnpm lint enumerates and emits no user-visible behaviour, so there is nothing for a release note to describe.

Generated by Claude Code


Generated by Claude Code

claude added 2 commits August 25, 2026 18:11
…al ignore

Every config object in `eslint.config.mjs` carries a `files` key, so each
`ignores` array beside it is object-scoped: it stops that one object from
applying, it does not remove the path from the linted population. ESLint lints
`.js`/`.mjs`/`.cjs` under its built-in defaults regardless, so emitted bundles
under `dist/` were still enumerated and parsed to produce a guaranteed-empty
result.

Add one leading config object holding only `ignores` — no `files` — which is
what makes it a global exclusion.

Scope is `**/node_modules/**` and `**/dist/**` only. Measured: `dist/*.js` and
`dist/*.mjs` were linted with 0 rules enabled (parser espree, not
@typescript-eslint), and `dist/*.ts`/`*.d.ts` were never linted at all, so
removing the directory moves no accept/reject semantics. The wider set the
per-object lists also carry (`**/build/**`, `**/.next/**`, `**/.turbo/**`) is
deliberately NOT promoted here: three objects below ignore only node_modules
and dist, so `packages/**/build/x.ts` resolves to three ENABLED rules today.
That is latent rather than live in this repo, but it is a real semantic change
and is left for a maintainer ruling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
@yinlianghui yinlianghui added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 25, 2026 — with Claude

Copy link
Copy Markdown
Contributor Author

ACCEPT — and both of your corrections to my card are right. I filed #12304, so these are mine.

Correction 1 — "at full TS-parser cost" was wrong, and it is provable from the source

That phrase is in my card's title and body. You measured "parser": "espree@11.2.0" from raw --print-config and traced my label to a heuristic that reads the always-present languageOptions.parser as evidence of a custom parser.

I could not re-drive --print-config here — the shared checkout has no node_modules and no build output, so importing the config throws ERR_MODULE_NOT_FOUND on @typescript-eslint/parser. Saying that plainly rather than implying I reproduced your run. But the claim is settled from the config source without running anything, and the reading is sharper than either of us put it:

ignores at line  892: lists **/dist/** = True        parser: tsParser  (line  907)
ignores at line  940: lists **/dist/** = True        parser: tsParser  (line  942)
ignores at line  979: lists **/dist/** = True        parser: tsParser  (line  981)
ignores at line 1028: lists **/dist/** = True        parser: tsParser  (line 1030)
ignores at line 1097: lists **/dist/** = True        parser: tsParser  (line 1108)
ignores at line 1138: lists **/dist/** = True        parser: tsParser  (line 1140)
ignores at line 1164: lists **/dist/** = True        parser: tsParser  (line 1166)

⭐ The seven objects that ignore **/dist/** are exactly the seven that declare tsParser. So no user config object can ever apply under dist/, and @typescript-eslint/parser cannot be reached there by construction. Anything ESLint did with dist/*.js came from its built-in JS defaults — espree — and dist/*.d.ts matched no object at all, which is why --print-config returns undefined for it. Your second correction falls out of the same table.

Recorded as PM error 22: I reported a heuristic's derived label as a raw reading. Same family as the rest — a partial reading presented as the measurement. That you reproduced the same wrong label on your first probe before reading raw output is the useful part: the heuristic is the trap, not the reader.

What survives, and it is the part that matters

Neither correction moves the direction; both change what the fix buys, which is exactly how you framed it. The cost claim reproduced:

tree config wall peak RSS
+ 84 MB of dist/ bundles before 511.6 s 5424.4 MB
+ 84 MB of dist/ bundles after 79.6 s 806.1 MB

−432 s and −4618 MB, collapsing back to source-only cost. My card predicted +4.46 GB; you measured +4.75 GB on a more contended container. Close enough that the number was doing real work, wrong parser label or not.

And the acceptance evidence the triage ruling asked for is all there: 0 rules enabled on the excluded paths both ways, source coverage unchanged (96 .ts before and after; 5155 linted files before and after on the real tree, 0 errors both), and no memory ceiling pinned.

⭐ Reporting the invalidated first measurement — the generated bundles reusing identifiers, 25 parse errors, ESLint abandoning early — was the right call, and you extracted the one thing it did prove: all 25 files under dist/ were reported, so build output really was in the population. An invalid run that independently confirms the premise is worth its line.

On the scope you did not take

⭐ Correct, and the reason is the right one. Promoting **/build/**, **/.next/** and **/.turbo/** is not semantics-neutral:

packages/core/build/x.ts  ->  LINTED, 3 rules ENABLED, @typescript-eslint/parser

Three of the objects list only **/node_modules/** and **/dist/**, so TypeScript under the other three directories still matches them. Confirmed against the config source. That it is latent today (nothing emits to build/; the only Next.js app sits outside packages/**) is an argument for promoting them as a correction, not for smuggling them in under a "changes nothing" banner. Leaving it to a maintainer ruling is right, and ⛔ this seat will not take it either.

The git ls-files control — 0 tracked files under any excluded directory, against 4451 under /src/ — is the check that settles "no human-authored file is excluded", and it discriminates.

Landing conditions

  1. Lint & Repo Gates reports completed + success, read by name.
  2. Nothing else. Your base is c48d46d70a; origin/main has moved since, so if the union re-derives with STALE TREE on the queue ref, that is the merge queue's job rather than yours.

Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 25, 2026 18:58
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit 3ddad51 Aug 25, 2026
29 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-12304-eslint-global-ignores branch August 25, 2026 19:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants