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
- Construct fresh config objects per call (build them inside
createConfig(), or deep-clone the templates). Do not hand out references to module-level state.
- Copy
options.ignorePatterns instead of pushing into it: [...options.ignorePatterns, 'eslint.*.js'].
- 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.
Problem
eslint.config.shared.jsdefinesbaseConfig,reactConfig, andjestConfigas module-level object literals.createConfig()pushes those same objects (by reference) into the array it returns, then mutates one of them in place:Three distinct defects follow.
(a) Two
createConfig()calls in one process clobber each otherBecause every call returns the same
baseConfigobject, the last call'sprojectwins for all of them. Verified by loading the real module with its dependencies stubbed: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 programmaticESLintAPI 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
ignorePatternsarrayoptions.ignorePatterns.push('eslint.*.js')writes into the array the caller passed in. Repeated calls accumulate duplicates:Harmless today only because each consumer passes a fresh array literal.
(c) It locates
baseConfigby fragile index arithmeticconfigs[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 leadingignoresentry doesn't shift it:So this is not a live bug either. It is still unacceptably fragile: it silently depends on
jestConfigbeing pushed last and onreactConfigbeing the only conditional push afterbaseConfig. Appending any config insidecreateConfig()silently retargets the mutation onto the wrong object -- and if it lands onjestConfig,projectwould apply only to test files, with no error. Note thatsrc/server/eslint.config.jsalready appends to the returned array; it happens to do so aftercreateConfig()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
Programproduces plausible-looking output, not an error. This is exactly the kind of thing that surfaces as an inexplicable CI result months later.Fix
createConfig(), or deep-clone the templates). Do not hand out references to module-level state.options.ignorePatternsinstead of pushing into it:[...options.ignorePatterns, 'eslint.*.js'].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.jssrc/{core,diagram,app,engine,server}/eslint.config.js,website/eslint.config.jsVerification
Reproduced by loading
eslint.config.shared.jsunder 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.jswhile migrating the repo to TypeScript 7.