Skip to content

build: eslint.config.shared.js mutates module-level shared objects (cross-call clobbering, fragile index lookup) #895

Description

@bpowers

Problem

eslint.config.shared.js defines baseConfig, reactConfig, and jestConfig as module-level object literals. createConfig() pushes those same objects (by reference) into the array it returns, then mutates one of them in place:

const baseConfig = { /* module-level, shared */ };

const createConfig = (options = {}) => {
  const configs = [];
  if (options.ignorePatterns) {
    options.ignorePatterns.push('eslint.*.js');   // (b) mutates the CALLER's array
    configs.push({ ignores: options.ignorePatterns });
  }
  configs.push(baseConfig);                        // by reference
  if (options.react) configs.push(reactConfig);    // by reference
  configs.push(jestConfig);                        // by reference

  if (options.project) {
    // (a) mutates the module-level baseConfig, and (c) finds it by index arithmetic
    configs[configs.length - (options.react ? 3 : 2)]
      .languageOptions.parserOptions.project = options.project;
  }
  return configs;
};

Three distinct defects follow.

(a) Two createConfig() calls in one process clobber each other

Because every call returns the same baseConfig object, the last call's project wins for all of them. Verified by loading the real module with its dependencies stubbed:

call A requested ./core.json    -> A reports: "./server.json"
call B requested ./server.json  -> B reports: "./server.json"
A.base === B.base (same object): true
=> LAST CALL WINS FOR BOTH: true

This is latent, not currently firing: ESLint loads one flat config per package in its own process, so createConfig() is called once per process today. It becomes a live bug the moment anything calls it twice -- a root config that composes packages, a programmatic ESLint API run over the monorepo, a lint-staged setup, or a test that imports two package configs. The failure mode is silent and awful: one package type-checks against another package's tsconfig.

(b) It mutates the caller's ignorePatterns array

options.ignorePatterns.push('eslint.*.js') writes into the array the caller passed in. Repeated calls accumulate duplicates:

caller's array after one call:  ["lib/","eslint.*.js"]
caller's array after two calls: ["lib/","eslint.*.js","eslint.*.js"]

Harmless today only because each consumer passes a fresh array literal.

(c) It locates baseConfig by fragile index arithmetic

configs[configs.length - (options.react ? 3 : 2)] identifies the base config positionally.

Correction to the original audit note: I checked all four {ignorePatterns?} x {react?} combinations and the arithmetic is currently correct in every one -- it counts backwards from the end, so the leading ignores entry doesn't shift it:

no ignores, no react   len=2 landedIdx=0 onBaseConfig=true
no ignores, react      len=3 landedIdx=0 onBaseConfig=true
ignores,    no react   len=3 landedIdx=1 onBaseConfig=true
ignores,    react      len=4 landedIdx=1 onBaseConfig=true

So this is not a live bug either. It is still unacceptably fragile: it silently depends on jestConfig being pushed last and on reactConfig being the only conditional push after baseConfig. Appending any config inside createConfig() silently retargets the mutation onto the wrong object -- and if it lands on jestConfig, project would apply only to test files, with no error. Note that src/server/eslint.config.js already appends to the returned array; it happens to do so after createConfig() returns, so it's safe, but it shows the push-order assumption is one refactor away from being violated.

Why it matters

Shared mutable module state in a config factory is a correctness hazard with no upside. Defects (a) and (c) both fail silently and wrongly -- a package linted against the wrong Program produces plausible-looking output, not an error. This is exactly the kind of thing that surfaces as an inexplicable CI result months later.

Fix

  1. Construct fresh config objects per call (build them inside createConfig(), or deep-clone the templates). Do not hand out references to module-level state.
  2. Copy options.ignorePatterns instead of pushing into it: [...options.ignorePatterns, 'eslint.*.js'].
  3. Select the base config by identity, not index -- keep a local const base = makeBaseConfig() and mutate that directly before pushing, so there is no lookup at all.

(3) makes (1) natural: if you build the object locally you already hold the reference.

Components affected

  • eslint.config.shared.js
  • All consumers: src/{core,diagram,app,engine,server}/eslint.config.js, website/eslint.config.js

Verification

Reproduced by loading eslint.config.shared.js under plain node with its five imports stubbed (they are otherwise unresolvable from the repo root -- see the companion phantom-dependency issue).

Discovery context

Identified during an audit of eslint.config.shared.js while migrating the repo to TypeScript 7.

Activity

  1. added
    bugSomething isn't working
    hygieneToil, but its useful to get get too behind on it
    javascriptPull requests that update Javascript code
    on Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghygieneToil, but its useful to get get too behind on itjavascriptPull requests that update Javascript code

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions