Skip to content

npm run lint needs a 12 GB heap, which puts local linting out of reach for many contributors #3387

Description

@acoliver

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Metadata

Metadata

Assignees

Labels

Code Quality / ModularizationIssues to do with the quality of llxprt code and its maintainability.developmenttools and features that make it easier to test/develop LLxprt Code itself

Type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions