Summary
npm run lint asks Node for a 12 GB heap and can exhaust it. A contributor on a 16 GB laptop cannot reliably run the repo's own lint gate before pushing, so they either push and wait for CI to tell them about a formatting error, or they skip linting.
Target: get a full local lint under 8 GB. Under 4 GB would be better.
Evidence
package.json:
"lint": "cross-env NODE_OPTIONS=--max-old-space-size=12288 bun scripts/run-lint.ts",
"lint:fix": "cross-env NODE_OPTIONS=--max-old-space-size=12288 bun scripts/run-lint.ts --fix",
"lint:ci": "cross-env NODE_OPTIONS=--max-old-space-size=12288 eslint . --max-warnings 0",
scripts/run-lint.ts sets DEFAULT_HEAP_MB = 12288 and, for a full run, emits exactly one ESLint invocation over ..
Observed on a 128 GB machine on 2026-08-27, during work on #3379, three times:
[18783:0xc0fc00000] 285191 ms: Mark-Compact 12235.8 (12302.3) -> 12231.3 (12305.0) MB, ...
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
Lint runner: ESLint was terminated by SIGABRT (signal 6).
That is the ceiling being reached, not merely approached: roughly 12.2 GB live after a mark-compact, with GC no longer able to recover space.
The runner already anticipates this. buildSignalDiagnostic in scripts/run-lint.ts special-cases SIGKILL with "An out-of-memory kill is a likely cause given the full-tree run's memory profile." The memory profile is a known property of this setup, currently handled by printing a nicer message about it.
Why it is this large
Two things compound.
Scale. 5386 lintable files, unevenly distributed:
| path |
.ts/.tsx files |
| packages/cli |
1500 |
| packages/providers |
954 |
| packages/core |
803 |
| packages/agents |
625 |
| scripts/tests |
352 |
| packages/tools |
309 |
| everything else |
~440 |
Type-aware linting across the whole monorepo in one process. eslint.config.js uses projectService with tsconfigRootDir: projectRoot, so a full-tree run holds type information for every package simultaneously. TypeScript programs are the dominant cost here, not ESLint's own AST work, and they are retained for the duration.
scripts/run-lint.ts deliberately collapsed the previous multi-pass structure into a single root invocation (issue #2710), which removed duplicate traversal of integration-tests. That was the right call for wall-clock time; the side effect is that peak memory is now the sum of every package's type program rather than the maximum of any one.
Why CI does not surface this
CI usually takes a different path. .github/workflows/ci.yml (~line 494) runs npm run lint:runner with LLXPRT_LINT_TARGETS set to just the packages a PR touched, plus LLXPRT_LINT_CACHE=true. It only falls back to the full npm run lint:ci when the shard selector fails or reports a full run.
So the routine CI path is scoped and cached, while the routine contributor path (npm run lint, no arguments) is the full uncached tree. The expensive configuration is the one aimed at the people with the least hardware.
Options
Not a decision, just the shape of the space. Whoever picks this up should measure before choosing.
-
Per-package invocations in sequence. Run ESLint once per workspace, each with its own type program, so peak is the largest single package rather than the sum. packages/cli at 1500 files is the worst case and would set the new floor. Costs wall-clock time; a bounded worker pool trades some of that back for memory. This is the most direct route to the target and reuses the scoping machinery run-lint.ts already has for CI.
-
Split the type-aware rules from the rest. Run the syntactic rules over the whole tree cheaply, and the type-aware rules per package. More moving parts, but the cheap pass gives fast feedback on the majority of findings.
-
Enable the cache locally. LLXPRT_LINT_CACHE=true already exists and CI uses it. It does not reduce peak memory on a cold run, so it helps the second run and not the first. Worth doing anyway, but not a fix on its own.
-
Audit which type-aware rules are actually earning their cost. Some may be replaceable with syntactic equivalents. Worth checking before adding machinery, though unlikely to be sufficient alone.
Whatever is chosen, npm run lint with no arguments has to keep linting everything. Making the default cheap by making it partial would be worse than the current state.
Acceptance
npm run lint completes on a machine with 8 GB of RAM available to it, from cold, with no cache.
- The default heap in
package.json and scripts/run-lint.ts drops to match, so the number documents the real requirement.
- Full coverage is preserved: the set of files linted by a bare
npm run lint does not shrink.
- A recorded measurement of peak RSS before and after, so the next person does not have to rediscover it.
- Wall-clock regression, if any, is stated rather than discovered.
Summary
npm run lintasks Node for a 12 GB heap and can exhaust it. A contributor on a 16 GB laptop cannot reliably run the repo's own lint gate before pushing, so they either push and wait for CI to tell them about a formatting error, or they skip linting.Target: get a full local lint under 8 GB. Under 4 GB would be better.
Evidence
package.json:scripts/run-lint.tssetsDEFAULT_HEAP_MB = 12288and, for a full run, emits exactly one ESLint invocation over..Observed on a 128 GB machine on 2026-08-27, during work on #3379, three times:
That is the ceiling being reached, not merely approached: roughly 12.2 GB live after a mark-compact, with GC no longer able to recover space.
The runner already anticipates this.
buildSignalDiagnosticinscripts/run-lint.tsspecial-cases SIGKILL with "An out-of-memory kill is a likely cause given the full-tree run's memory profile." The memory profile is a known property of this setup, currently handled by printing a nicer message about it.Why it is this large
Two things compound.
Scale. 5386 lintable files, unevenly distributed:
Type-aware linting across the whole monorepo in one process.
eslint.config.jsusesprojectServicewithtsconfigRootDir: projectRoot, so a full-tree run holds type information for every package simultaneously. TypeScript programs are the dominant cost here, not ESLint's own AST work, and they are retained for the duration.scripts/run-lint.tsdeliberately collapsed the previous multi-pass structure into a single root invocation (issue #2710), which removed duplicate traversal ofintegration-tests. That was the right call for wall-clock time; the side effect is that peak memory is now the sum of every package's type program rather than the maximum of any one.Why CI does not surface this
CI usually takes a different path.
.github/workflows/ci.yml(~line 494) runsnpm run lint:runnerwithLLXPRT_LINT_TARGETSset to just the packages a PR touched, plusLLXPRT_LINT_CACHE=true. It only falls back to the fullnpm run lint:ciwhen the shard selector fails or reports a full run.So the routine CI path is scoped and cached, while the routine contributor path (
npm run lint, no arguments) is the full uncached tree. The expensive configuration is the one aimed at the people with the least hardware.Options
Not a decision, just the shape of the space. Whoever picks this up should measure before choosing.
Per-package invocations in sequence. Run ESLint once per workspace, each with its own type program, so peak is the largest single package rather than the sum.
packages/cliat 1500 files is the worst case and would set the new floor. Costs wall-clock time; a bounded worker pool trades some of that back for memory. This is the most direct route to the target and reuses the scoping machineryrun-lint.tsalready has for CI.Split the type-aware rules from the rest. Run the syntactic rules over the whole tree cheaply, and the type-aware rules per package. More moving parts, but the cheap pass gives fast feedback on the majority of findings.
Enable the cache locally.
LLXPRT_LINT_CACHE=truealready exists and CI uses it. It does not reduce peak memory on a cold run, so it helps the second run and not the first. Worth doing anyway, but not a fix on its own.Audit which type-aware rules are actually earning their cost. Some may be replaceable with syntactic equivalents. Worth checking before adding machinery, though unlikely to be sufficient alone.
Whatever is chosen,
npm run lintwith no arguments has to keep linting everything. Making the default cheap by making it partial would be worse than the current state.Acceptance
npm run lintcompletes on a machine with 8 GB of RAM available to it, from cold, with no cache.package.jsonandscripts/run-lint.tsdrops to match, so the number documents the real requirement.npm run lintdoes not shrink.