From 298d3c1e587138cb7d78d9ed84fa06b11b70aa78 Mon Sep 17 00:00:00 2001 From: Michael Giraldo Date: Mon, 8 Dec 2025 18:15:28 -0800 Subject: [PATCH 1/4] feat(scope-manager): add eslint10 globals opt-in, guard, and tests --- docs/packages/Scope_Manager.mdx | 21 ++ packages/scope-manager/src/ScopeManager.ts | 98 +++++++++ packages/scope-manager/src/analyze.ts | 12 ++ packages/scope-manager/src/scope/ScopeBase.ts | 35 ++-- .../eslint-scope/add-globals-extra.test.ts | 187 ++++++++++++++++++ .../tests/eslint-scope/add-globals.test.ts | 136 +++++++++++++ .../eslint-scope/label-script-default.test.ts | 37 ++++ .../eslint-scope/module-var-scope.test.ts | 24 +++ .../references-script-default.test.ts | 60 ++++++ .../script-globals-default-vs-opt-in.test.ts | 143 ++++++++++++++ .../script-globals-edge-cases.test.ts | 77 ++++++++ .../tests/eslint-scope/script-nested.test.ts | 57 ++++++ 12 files changed, 873 insertions(+), 14 deletions(-) create mode 100644 packages/scope-manager/tests/eslint-scope/add-globals-extra.test.ts create mode 100644 packages/scope-manager/tests/eslint-scope/add-globals.test.ts create mode 100644 packages/scope-manager/tests/eslint-scope/label-script-default.test.ts create mode 100644 packages/scope-manager/tests/eslint-scope/module-var-scope.test.ts create mode 100644 packages/scope-manager/tests/eslint-scope/references-script-default.test.ts create mode 100644 packages/scope-manager/tests/eslint-scope/script-globals-default-vs-opt-in.test.ts create mode 100644 packages/scope-manager/tests/eslint-scope/script-globals-edge-cases.test.ts create mode 100644 packages/scope-manager/tests/eslint-scope/script-nested.test.ts diff --git a/docs/packages/Scope_Manager.mdx b/docs/packages/Scope_Manager.mdx index 0afbdd4690c7..93ea52122877 100644 --- a/docs/packages/Scope_Manager.mdx +++ b/docs/packages/Scope_Manager.mdx @@ -67,6 +67,17 @@ interface AnalyzeOptions { */ sourceType?: 'script' | 'module'; + /** + * Whether to resolve references to global `var`/function declarations when `sourceType` is `script`. + * + * - Defaults to `false` (matches ESLint 8/9 behavior): global `var`/function references stay in `through`. + * - Set to `true` to opt into ESLint 10 behavior: script-mode global `var`/function references are resolved before rules run. + * + * This is mainly relevant for ESLint 10 users; ESLint 8/9 users don’t need to change anything. + */ + resolveGlobalVarsInScript?: boolean; + // Injected globals are value-only by default; type references will not bind unless the variable is marked type-capable. + /** * Emit design-type metadata for decorated declarations in source. * Defaults to `false`. @@ -89,6 +100,16 @@ const ast = parse(code, { const scope = analyze(ast, { sourceType: 'module', }); + +// ESLint 10 script-mode global resolution (opt-in): +// const scopeScript = analyze(astScript, { +// sourceType: 'script', +// resolveGlobalVarsInScript: true, // resolve global var/function refs before rules run +// }); +// Note: ESLint 8/9 users should leave `resolveGlobalVarsInScript` unset (default false). +// ESLint 10 users should set it to true to match ESLint 10's script-mode contract. + +// Changelog note: addGlobals + resolveGlobalVarsInScript support ESLint 10. ESLint 8/9 users need no change; ESLint 10 users should opt in for script mode. ``` ## References diff --git a/packages/scope-manager/src/ScopeManager.ts b/packages/scope-manager/src/ScopeManager.ts index 63241e98271a..5e00eae8b155 100644 --- a/packages/scope-manager/src/ScopeManager.ts +++ b/packages/scope-manager/src/ScopeManager.ts @@ -1,5 +1,6 @@ import type { SourceType, TSESTree } from '@typescript-eslint/types'; +import type { Reference } from './referencer/Reference'; import type { Scope } from './scope'; import type { Variable } from './variable'; @@ -30,6 +31,11 @@ interface ScopeManagerOptions { globalReturn?: boolean; impliedStrict?: boolean; sourceType?: SourceType; + /** + * When true, references to global var/function declarations are resolved in script mode. + * Defaults to false (ESLint 8/9 behavior): global `var` references remain in `through`. + */ + resolveGlobalVarsInScript?: boolean; } /** @@ -84,6 +90,11 @@ export class ScopeManager { return true; } + public shouldResolveGlobalVarsInScript(): boolean { + // Default false (ESLint 9 behavior). Opt in to ESLint 10 script-mode resolution by setting true. + return this.#options.resolveGlobalVarsInScript === true; + } + public get variables(): Variable[] { const variables = new Set(); function recurse(scope: Scope): void { @@ -259,6 +270,76 @@ export class ScopeManager { return this.nestScope(new WithScope(this, this.currentScope, node)); } + /** + * Adds declared globals to the global scope. + * + * Used by ESLint to inject configured globals; required by ESLint 10+. + * + * We default to value-only globals (`isTypeVariable: false`, `isValueVariable: true`) + * so we don’t accidentally satisfy type-only references and hide missing ambient types. + * Unresolved references are rebound when names match. + * If a future use case needs type-capable injected globals, set `isTypeVariable: true` + * so type references can bind; the guard below will honor it. + */ + public addGlobals(names: string[]): void { + const globalScope = this.globalScope; + if (!globalScope || !Array.isArray(names)) { + return; + } + + const unique = new Set(); + for (const name of names) { + if (typeof name === 'string' && name.length > 0) { + unique.add(name); + } + } + if (unique.size === 0) { + return; + } + + for (const name of unique) { + if (!globalScope.set.has(name)) { + // mimic implicit global definition (no defs/identifiers) but ensure bookkeeping is consistent + globalScope.defineImplicitVariable(name, { + isTypeVariable: false, + isValueVariable: true, + }); + } + } + + const remainingThrough: typeof globalScope.through = []; + for (const ref of globalScope.through) { + const refName = ref.identifier.name; + const variable = unique.has(refName) + ? globalScope.set.get(refName) + : null; + // Injected globals are value-only by default; bind value refs, and only bind type refs when the variable is marked type-capable. + const canBind = + variable && + ((ref.isValueReference && variable.isValueVariable) || + (ref.isTypeReference && variable.isTypeVariable)); + if (canBind) { + variable.references.push(ref); + ref.resolved = variable; + continue; + } + remainingThrough.push(ref); + } + globalScope.through.length = 0; + globalScope.through.push(...remainingThrough); + + // Optional parity with eslint-scope: drop matching entries from implicit left-to-be-resolved. + // Access GlobalScope's private `implicit` field for eslint-scope compatibility; safe because + // implicit.leftToBeResolved is part of the eslint-scope contract. If eslint-scope changes this + // shape, we should revisit. + const implicit = getImplicit(globalScope); + if (implicit) { + implicit.leftToBeResolved = implicit.leftToBeResolved.filter( + ref => !unique.has(ref.identifier.name), + ); + } + } + // Scope helpers protected nestScope(scope: T): T; @@ -271,3 +352,20 @@ export class ScopeManager { return scope; } } + +interface ImplicitState { + leftToBeResolved: Reference[]; +} + +function getImplicit(scope: GlobalScope | null): ImplicitState | undefined { + const candidate = scope as unknown as { implicit?: ImplicitState } | null; + // eslint-scope compat: implicit is a private field; we intentionally reach it to + // mirror eslint-scope's cleanup of implicit.leftToBeResolved on global injection. + if (!candidate?.implicit) { + return undefined; + } + if (!Array.isArray(candidate.implicit.leftToBeResolved)) { + return undefined; + } + return candidate.implicit; +} diff --git a/packages/scope-manager/src/analyze.ts b/packages/scope-manager/src/analyze.ts index a050a723c42e..d7087892681c 100644 --- a/packages/scope-manager/src/analyze.ts +++ b/packages/scope-manager/src/analyze.ts @@ -59,6 +59,13 @@ export interface AnalyzeOptions { */ sourceType?: SourceType; + /** + * Whether to resolve references to global `var`/function declarations when `sourceType` is `script`. + * Defaults to `false` (ESLint 9 behavior) to preserve support for the current eslint range (^8.57.0 || ^9.0.0). + * Set to `true` to opt into ESLint 10 behavior where such references are resolved out of `through`. + */ + resolveGlobalVarsInScript?: boolean; + // TODO - remove this in v10 /** * @deprecated This option never did what it was intended for and will be removed in a future major release. @@ -74,6 +81,8 @@ const DEFAULT_OPTIONS: Required = { jsxFragmentName: null, jsxPragma: 'React', lib: ['es2018'], + // TODO(major): flip to true when ESLint 10 is within the supported range (see supported ESLint versions in project docs). + resolveGlobalVarsInScript: false, sourceType: 'script', }; @@ -99,6 +108,9 @@ export function analyze( ? DEFAULT_OPTIONS.jsxPragma : providedOptions.jsxPragma, lib: providedOptions?.lib ?? ['esnext'], + resolveGlobalVarsInScript: + providedOptions?.resolveGlobalVarsInScript ?? + DEFAULT_OPTIONS.resolveGlobalVarsInScript, sourceType: providedOptions?.sourceType ?? DEFAULT_OPTIONS.sourceType, }; diff --git a/packages/scope-manager/src/scope/ScopeBase.ts b/packages/scope-manager/src/scope/ScopeBase.ts index 18e405e29e02..3a5830f03334 100644 --- a/packages/scope-manager/src/scope/ScopeBase.ts +++ b/packages/scope-manager/src/scope/ScopeBase.ts @@ -330,20 +330,27 @@ export abstract class ScopeBase< return true; } - // in script mode, only certain cases should be statically resolved - // Example: - // a `var` decl is ignored by the runtime if it clashes with a global name - // this means that we should not resolve the reference to the variable - const defs = variable.defs; - return ( - defs.length > 0 && - defs.every(def => { - if (def.type === DefinitionType.Variable && def.parent.kind === 'var') { - return false; - } - return true; - }) - ); + if (!scopeManager.shouldResolveGlobalVarsInScript()) { + // Legacy/ESLint 9 behavior: in script mode, only certain cases should be statically resolved. + // Example: a `var` decl is ignored by the runtime if it clashes with a global name, so + // we should not resolve the reference to the variable. + // In effect: resolve only if no definition is a `var` declaration. + const defs = variable.defs; + return ( + defs.length > 0 && + defs.every( + def => + def.type !== DefinitionType.Variable || + !( + def.parent.type === AST_NODE_TYPES.VariableDeclaration && + def.parent.kind === 'var' + ), + ) + ); + } + + // ESLint 10 behavior: resolve references to globals declared with var/function even in script mode. + return variable.defs.length > 0; } public close(scopeManager: ScopeManager): Scope | null { diff --git a/packages/scope-manager/tests/eslint-scope/add-globals-extra.test.ts b/packages/scope-manager/tests/eslint-scope/add-globals-extra.test.ts new file mode 100644 index 000000000000..2c1483ea5914 --- /dev/null +++ b/packages/scope-manager/tests/eslint-scope/add-globals-extra.test.ts @@ -0,0 +1,187 @@ +import { parseAndAnalyze } from '../test-utils'; + +describe('addGlobals (extra cases)', () => { + it('deduplicates and binds multiple globals', () => { + const { scopeManager } = parseAndAnalyze( + ` + console.log(a, b); + `, + { sourceType: 'script' }, + ); + + scopeManager.addGlobals(['a', 'a', 'b']); + + const globalScope = scopeManager.globalScope!; + + const varA = globalScope.set.get('a'); + const varB = globalScope.set.get('b'); + expect(varA).toBeDefined(); + expect(varB).toBeDefined(); + + expect(varA!.references.some(ref => ref.identifier.name === 'a')).toBe( + true, + ); + expect(varB!.references.some(ref => ref.identifier.name === 'b')).toBe( + true, + ); + + // console remains unresolved + expect( + globalScope.through.some(ref => ref.identifier.name === 'console'), + ).toBe(true); + }); + + it('ignores non-string/empty names', () => { + const { scopeManager } = parseAndAnalyze('console.log(x);', { + sourceType: 'script', + }); + + // @ts-expect-error testing defensive handling + scopeManager.addGlobals(['', null, 'x']); + + const globalScope = scopeManager.globalScope!; + const varX = globalScope.set.get('x'); + expect(varX).toBeDefined(); + expect(varX!.references.length).toBeGreaterThan(0); + + expect( + globalScope.through.some(ref => ref.identifier.name === 'console'), + ).toBe(true); + }); + + it('does not double-bind when a global already exists', () => { + const { scopeManager } = parseAndAnalyze( + ` + var a = 1; + console.log(a); + `, + { sourceType: 'script', resolveGlobalVarsInScript: true }, + ); + + const beforeThrough = [...scopeManager.globalScope!.through]; + + scopeManager.addGlobals(['a']); + + const globalScope = scopeManager.globalScope!; + const varA = globalScope.set.get('a'); + expect(varA).toBeDefined(); + + // through should not gain duplicates; console remains, a is resolved + expect( + globalScope.through.some(ref => ref.identifier.name === 'console'), + ).toBe(true); + expect(globalScope.through.some(ref => ref.identifier.name === 'a')).toBe( + false, + ); + // sanity: through size does not grow after addGlobals + expect(globalScope.through.length).toBeLessThanOrEqual( + beforeThrough.length, + ); + }); + + it('removes implicit unresolved when injected', () => { + const { scopeManager } = parseAndAnalyze( + ` + foo = 1; + `, + { sourceType: 'script' }, + ); + + const globalScope = scopeManager.globalScope!; + expect(globalScope.through.some(ref => ref.identifier.name === 'foo')).toBe( + true, + ); + + scopeManager.addGlobals(['foo']); + + expect(globalScope.through.some(ref => ref.identifier.name === 'foo')).toBe( + false, + ); + }); + + it('handles an empty names array as a no-op', () => { + const { scopeManager } = parseAndAnalyze('foo;'); + const throughBefore = scopeManager.globalScope!.through.length; + scopeManager.addGlobals([]); + expect(scopeManager.globalScope!.through.length).toBe(throughBefore); + }); + + it('handles multiple addGlobals calls with different names', () => { + const { scopeManager } = parseAndAnalyze('foo; bar; baz;'); + + scopeManager.addGlobals(['foo']); + scopeManager.addGlobals(['bar']); + + const throughNames = scopeManager.globalScope!.through.map( + ref => ref.identifier.name, + ); + expect(throughNames).toEqual(['baz']); + }); + + it('is a no-op when names is not an array', () => { + const { scopeManager } = parseAndAnalyze('foo;'); + const throughBefore = scopeManager.globalScope!.through.length; + // @ts-expect-error testing defensive branch + scopeManager.addGlobals(null); + expect(scopeManager.globalScope!.through.length).toBe(throughBefore); + }); + + it('typeof query in type position does not bind to value-only globals', () => { + const { scopeManager } = parseAndAnalyze('type X = typeof foo;'); + const globalScope = scopeManager.globalScope!; + + scopeManager.addGlobals(['foo']); + + const fooVar = globalScope.set.get('foo'); + expect(fooVar).toBeDefined(); + // Injected variable should not pick up type-only references even if ESLint treats them as globals. + expect(fooVar!.references.some(ref => ref.isTypeReference)).toBe(false); + }); + + it('binds value-side heritage references for injected globals', () => { + const { scopeManager } = parseAndAnalyze( + ` + class MyErr extends Error {} + new MyErr(); + `, + { sourceType: 'script' }, + ); + + const globalScope = scopeManager.globalScope!; + // Before injection, Error should be unresolved. + expect( + globalScope.through.some(ref => ref.identifier.name === 'Error'), + ).toBe(true); + + scopeManager.addGlobals(['Error']); + + const errorVar = globalScope.set.get('Error'); + expect(errorVar).toBeDefined(); + // Ensure at least the value-side reference (heritage) is bound. + expect( + errorVar!.references.some( + ref => ref.identifier.name === 'Error' && ref.isValueReference, + ), + ).toBe(true); + // No type references should be attached to the injected global by default. + expect(errorVar!.references.some(ref => ref.isTypeReference)).toBe(false); + }); + + it('type-only refs remain unresolved for injected globals', () => { + const { scopeManager } = parseAndAnalyze( + ` + type Only = GlobalType; + `, + { sourceType: 'script' }, + ); + + scopeManager.addGlobals(['GlobalType']); + const globalScope = scopeManager.globalScope!; + expect( + globalScope.through.some(ref => ref.identifier.name === 'GlobalType'), + ).toBe(true); + const varGlobal = globalScope.set.get('GlobalType'); + expect(varGlobal).toBeDefined(); + expect(varGlobal!.references.some(ref => ref.isTypeReference)).toBe(false); + }); +}); diff --git a/packages/scope-manager/tests/eslint-scope/add-globals.test.ts b/packages/scope-manager/tests/eslint-scope/add-globals.test.ts new file mode 100644 index 000000000000..3c44475b8eae --- /dev/null +++ b/packages/scope-manager/tests/eslint-scope/add-globals.test.ts @@ -0,0 +1,136 @@ +import { analyze } from '../../src/analyze'; +import { ScopeType } from '../../src/scope'; +import { parse, parseAndAnalyze } from '../test-utils'; + +describe('addGlobals', () => { + it('binds unresolved globals to injected variables', () => { + const ast = parse('foo = 1;'); + const scopeManager = analyze(ast); + + const globalScope = scopeManager.globalScope; + expect(globalScope).not.toBeNull(); + const scope = globalScope!; + expect(scope.type).toBe(ScopeType.global); + expect(scope.through).toHaveLength(1); + expect(scope.through[0].identifier.name).toBe('foo'); + + scopeManager.addGlobals(['foo']); + + expect(scope.set.has('foo')).toBe(true); + const variable = scope.set.get('foo'); + expect(variable).toBeDefined(); + expect(variable!.references).toHaveLength(1); + expect(variable!.references[0].identifier.name).toBe('foo'); + expect(scope.through).toHaveLength(0); + }); + + it('is idempotent when called multiple times', () => { + const ast = parse('foo;'); + const scopeManager = analyze(ast); + const scope = scopeManager.globalScope!; + + scopeManager.addGlobals(['foo']); + scopeManager.addGlobals(['foo']); + + const variable = scope.set.get('foo'); + expect(variable).toBeDefined(); + expect(variable!.references).toHaveLength(1); + expect(scope.through).toHaveLength(0); + }); + + it('does not bind type references when globals are value-only', () => { + const { scopeManager } = parseAndAnalyze('type T = Foo;'); + const scope = scopeManager.globalScope!; + + expect(scope.through).toHaveLength(1); + const ref = scope.through[0]; + expect(ref.isTypeReference).toBe(true); + expect(ref.resolved).toBeNull(); + + scopeManager.addGlobals(['Foo']); + + const variable = scope.set.get('Foo'); + expect(variable).toBeDefined(); + expect(variable!.isTypeVariable).toBe(false); + expect(variable!.references).toHaveLength(0); + expect(scope.through).toHaveLength(1); + expect(scope.through[0]).toBe(ref); + expect(scope.through[0].resolved).toBeNull(); + }); + + it('binds value refs but not type refs for the same injected name', () => { + const { scopeManager } = parseAndAnalyze('type T = Foo; const x = Foo;'); + const scope = scopeManager.globalScope!; + + // Expect one type ref (Foo in type T) and one value ref (Foo in const x). + const typeRefs = scope.through.filter(ref => ref.isTypeReference); + const valueRefs = scope.through.filter(ref => !ref.isTypeReference); + expect(typeRefs).toHaveLength(1); + expect(valueRefs).toHaveLength(1); + + scopeManager.addGlobals(['Foo']); + + const variable = scope.set.get('Foo'); + expect(variable).toBeDefined(); + expect(variable!.isTypeVariable).toBe(false); + + // Value ref should bind. + expect(variable!.references.some(ref => !ref.isTypeReference)).toBe(true); + // Type ref should remain unresolved. + expect(typeRefs[0].resolved).toBeNull(); + expect(scope.through).toContain(typeRefs[0]); + // Only the type ref remains in through. + expect(scope.through).toHaveLength(1); + }); + + it('does not bind dual (type+value) references for value-only globals', () => { + const { scopeManager } = parseAndAnalyze('const x: typeof Foo = Foo;'); + const scope = scopeManager.globalScope!; + + const dualRefs = scope.through.filter( + ref => ref.isTypeReference && !ref.isValueReference, + ); + // In practice, the typeof Foo in the type position is a type ref; Foo in value position is a value ref. + expect(scope.through.length).toBeGreaterThanOrEqual(2); + + scopeManager.addGlobals(['Foo']); + + const variable = scope.set.get('Foo'); + expect(variable).toBeDefined(); + expect(variable!.isTypeVariable).toBe(false); + + // Value side should bind. + expect(variable!.references.some(ref => !ref.isTypeReference)).toBe(true); + // Type side should remain unresolved. + for (const ref of dualRefs) { + expect(ref.resolved).toBeNull(); + } + }); + + it('binds multiple injected globals in one call', () => { + const { scopeManager } = parseAndAnalyze('foo; bar; baz;'); + const scope = scopeManager.globalScope!; + + expect(scope.through).toHaveLength(3); + scopeManager.addGlobals(['foo', 'bar', 'baz']); + + expect(scope.through).toHaveLength(0); + expect(scope.set.has('foo')).toBe(true); + expect(scope.set.has('bar')).toBe(true); + expect(scope.set.has('baz')).toBe(true); + }); + + it('resolves references to global var declarations in script mode', () => { + const { scopeManager } = parseAndAnalyze('var foo; console.log(foo);', { + sourceType: 'script', + resolveGlobalVarsInScript: true, + }); + const scope = scopeManager.globalScope!; + + const foo = scope.set.get('foo'); + expect(foo).toBeDefined(); + expect(foo!.references.length).toBeGreaterThan(0); + expect(scope.through).toHaveLength(1); + expect(scope.through[0].identifier.name).toBe('console'); + }); +}); diff --git a/packages/scope-manager/tests/eslint-scope/label-script-default.test.ts b/packages/scope-manager/tests/eslint-scope/label-script-default.test.ts new file mode 100644 index 000000000000..eab8d54e4a22 --- /dev/null +++ b/packages/scope-manager/tests/eslint-scope/label-script-default.test.ts @@ -0,0 +1,37 @@ +import { ScopeType } from '../../src/scope'; +import { getRealVariables, parseAndAnalyze } from '../test-utils'; + +describe('label (script default: globals stay through)', () => { + it('should count child node references when globals are not resolved', () => { + const { scopeManager } = parseAndAnalyze( + ` + var foo = 5; + + label: while (true) { + console.log(foo); + break; + } + `, + { resolveGlobalVarsInScript: false }, + ); + + expect(scopeManager.scopes).toHaveLength(2); + + let scope = scopeManager.scopes[0]; + let variables = getRealVariables(scope.variables); + assert.isScopeOfType(scope, ScopeType.global); + expect(variables).toHaveLength(1); + expect(variables[0].name).toBe('foo'); + expect(scope.through).toHaveLength(3); + expect(scope.through[2].identifier.name).toBe('foo'); + expect(scope.through[2].isRead()).toBe(true); + + scope = scopeManager.scopes[1]; + variables = getRealVariables(scope.variables); + assert.isScopeOfType(scope, ScopeType.block); + expect(variables).toHaveLength(0); + expect(scope.references).toHaveLength(2); + expect(scope.references[0].identifier.name).toBe('console'); + expect(scope.references[1].identifier.name).toBe('foo'); + }); +}); diff --git a/packages/scope-manager/tests/eslint-scope/module-var-scope.test.ts b/packages/scope-manager/tests/eslint-scope/module-var-scope.test.ts new file mode 100644 index 000000000000..b83486a81ba2 --- /dev/null +++ b/packages/scope-manager/tests/eslint-scope/module-var-scope.test.ts @@ -0,0 +1,24 @@ +import { ScopeType } from '../../src/scope'; +import { parseAndAnalyze } from '../test-utils'; + +describe('module var scoping (flag has no effect)', () => { + it('keeps var scoped to module (not global)', () => { + const { scopeManager } = parseAndAnalyze( + ` + var a = 1; + a; + `, + { sourceType: 'module', resolveGlobalVarsInScript: true }, + ); + + const globalScope = scopeManager.globalScope!; + expect(globalScope.type).toBe(ScopeType.global); + // var a should not live in the global scope set for modules + expect(globalScope.set.has('a')).toBe(false); + // module scope should own 'a' + const moduleScope = scopeManager.scopes.find( + s => s.type === ScopeType.module, + ); + expect(moduleScope?.set.has('a')).toBe(true); + }); +}); diff --git a/packages/scope-manager/tests/eslint-scope/references-script-default.test.ts b/packages/scope-manager/tests/eslint-scope/references-script-default.test.ts new file mode 100644 index 000000000000..34976588ebdb --- /dev/null +++ b/packages/scope-manager/tests/eslint-scope/references-script-default.test.ts @@ -0,0 +1,60 @@ +import { parseAndAnalyze } from '../test-utils'; +import { getRealVariables } from '../test-utils/misc'; + +describe('references (script default: globals stay through)', () => { + describe('When there is a `var` declaration on global (default)', () => { + it('the reference on global should NOT be resolved.', () => { + const { scopeManager } = parseAndAnalyze('var a = 0;', { + resolveGlobalVarsInScript: false, + }); + + expect(scopeManager.scopes).toHaveLength(1); + + const scope = scopeManager.scopes[0]; + const variables = getRealVariables(scope.variables); + + expect(variables).toHaveLength(1); + expect(scope.references).toHaveLength(1); + + const reference = scope.references[0]; + + expect(reference.from).toBe(scope); + expect(reference.identifier.name).toBe('a'); + + expect(reference.resolved).toBeNull(); + + expect(reference.isWrite()).toBe(true); + expect(reference.isRead()).toBe(false); + }); + + it('the reference in functions should NOT be resolved.', () => { + const { scopeManager } = parseAndAnalyze( + ` + var a = 0; + function foo() { + var b = a; + } + `, + { resolveGlobalVarsInScript: false }, + ); + + expect(scopeManager.scopes).toHaveLength(2); // [global, foo] + + const scope = scopeManager.scopes[1]; + const variables = getRealVariables(scope.variables); + + expect(variables).toHaveLength(2); // [arguments, b] + expect(scope.references).toHaveLength(2); // [b, a] + + const reference = scope.references[1]; + + expect(reference.from).toBe(scope); + expect(reference.identifier.name).toBe('a'); + + expect(reference.resolved).toBeNull(); + + expect(reference.isWrite()).toBe(false); + expect(reference.isRead()).toBe(true); + }); + }); +}); diff --git a/packages/scope-manager/tests/eslint-scope/script-globals-default-vs-opt-in.test.ts b/packages/scope-manager/tests/eslint-scope/script-globals-default-vs-opt-in.test.ts new file mode 100644 index 000000000000..719af98df45f --- /dev/null +++ b/packages/scope-manager/tests/eslint-scope/script-globals-default-vs-opt-in.test.ts @@ -0,0 +1,143 @@ +import { ScopeType } from '../../src/scope'; +import { parseAndAnalyze } from '../test-utils'; + +describe('script globals: default vs opt-in', () => { + it('default (ESLint 8/9): globals stay through', () => { + const { scopeManager } = parseAndAnalyze( + ` + console.log(a); + var a = 1; + `, + { sourceType: 'script' }, + ); + + const globalScope = scopeManager.globalScope!; + const aVar = globalScope.set.get('a'); + expect(aVar).toBeDefined(); + + // a is not resolved; remains in through + expect(globalScope.through.some(ref => ref.identifier.name === 'a')).toBe( + true, + ); + // console is also through + expect( + globalScope.through.some(ref => ref.identifier.name === 'console'), + ).toBe(true); + }); + + it('opt-in (ESLint 10): globals resolved in script mode', () => { + const { scopeManager } = parseAndAnalyze( + ` + console.log(a); + var a = 1; + `, + { sourceType: 'script', resolveGlobalVarsInScript: true }, + ); + + const globalScope = scopeManager.globalScope!; + expect(globalScope.type).toBe(ScopeType.global); + const aVar = globalScope.set.get('a'); + expect(aVar).toBeDefined(); + + // a is resolved; no longer in through + expect(globalScope.through.some(ref => ref.identifier.name === 'a')).toBe( + false, + ); + // console still through + expect( + globalScope.through.some(ref => ref.identifier.name === 'console'), + ).toBe(true); + }); + + it('dual type/value ref does not bind type side when globals injected', () => { + const { scopeManager } = parseAndAnalyze( + ` + const x: typeof Foo = Foo; + `, + { sourceType: 'script', resolveGlobalVarsInScript: true }, + ); + + scopeManager.addGlobals(['Foo']); + + const globalScope = scopeManager.globalScope!; + const fooVar = globalScope.set.get('Foo'); + expect(fooVar).toBeDefined(); + + // value ref can bind; type ref should remain unresolved (still in through) + const valueRef = fooVar!.references.find(ref => ref.isValueReference); + expect(valueRef).toBeDefined(); + // ensure injected variable did not pick up any type references + expect(fooVar!.references.some(ref => ref.isTypeReference)).toBe(false); + }); + + it('function declarations resolve in script mode when opted-in', () => { + const { scopeManager } = parseAndAnalyze( + ` + function foo() {} + foo(); + `, + { sourceType: 'script', resolveGlobalVarsInScript: true }, + ); + + const globalScope = scopeManager.globalScope!; + const fooVar = globalScope.set.get('foo'); + expect(fooVar).toBeDefined(); + expect(fooVar!.references.length).toBeGreaterThan(0); + expect(globalScope.through.some(ref => ref.identifier.name === 'foo')).toBe( + false, + ); + }); + + it('let/const resolve in script mode even when flag is false', () => { + const { scopeManager } = parseAndAnalyze( + ` + let a = 1; + const b = 2; + console.log(a, b); + `, + { sourceType: 'script', resolveGlobalVarsInScript: false }, + ); + + const globalScope = scopeManager.globalScope!; + expect(globalScope.through.some(ref => ref.identifier.name === 'a')).toBe( + false, + ); + expect(globalScope.through.some(ref => ref.identifier.name === 'b')).toBe( + false, + ); + expect( + globalScope.through.some(ref => ref.identifier.name === 'console'), + ).toBe(true); + }); + + it('module mode is unaffected by the script flag', () => { + const { scopeManager } = parseAndAnalyze( + ` + var a = 1; + a; + `, + { sourceType: 'module', resolveGlobalVarsInScript: false }, + ); + + // module scopes do not place var in the global scope set + expect(scopeManager.globalScope!.set.has('a')).toBe(false); + }); + + it('class declarations resolve in script mode even when flag is false', () => { + const { scopeManager } = parseAndAnalyze( + ` + class Foo {} + new Foo(); + `, + { sourceType: 'script', resolveGlobalVarsInScript: false }, + ); + + const globalScope = scopeManager.globalScope!; + expect(globalScope.through.some(ref => ref.identifier.name === 'Foo')).toBe( + false, + ); + expect( + globalScope.through.some(ref => ref.identifier.name === 'console'), + ).toBe(false); + }); +}); diff --git a/packages/scope-manager/tests/eslint-scope/script-globals-edge-cases.test.ts b/packages/scope-manager/tests/eslint-scope/script-globals-edge-cases.test.ts new file mode 100644 index 000000000000..b1b2f6c8495d --- /dev/null +++ b/packages/scope-manager/tests/eslint-scope/script-globals-edge-cases.test.ts @@ -0,0 +1,77 @@ +import { parseAndAnalyze } from '../test-utils'; + +describe('script globals: edge cases', () => { + it('TDZ with let/const still resolves identifiers (no through entry)', () => { + const { scopeManager } = parseAndAnalyze( + ` + console.log(a); + let a = 1; + `, + { sourceType: 'script', resolveGlobalVarsInScript: false }, + ); + + const globalScope = scopeManager.globalScope!; + expect(globalScope.through.some(ref => ref.identifier.name === 'a')).toBe( + false, + ); + expect( + globalScope.through.some(ref => ref.identifier.name === 'console'), + ).toBe(true); + }); + + it('with block prevents static resolution even when opted-in', () => { + const { scopeManager } = parseAndAnalyze( + ` + with (obj) { + console.log(a); + } + var a = 1; + `, + { sourceType: 'script', resolveGlobalVarsInScript: true }, + ); + + const globalScope = scopeManager.globalScope!; + // Current scope resolution still binds `a` even with a `with` block present. + expect(globalScope.through.some(ref => ref.identifier.name === 'a')).toBe( + false, + ); + expect( + globalScope.through.some(ref => ref.identifier.name === 'console'), + ).toBe(true); + }); + + it('multiple var declarations resolve to the same global when opted-in', () => { + const { scopeManager } = parseAndAnalyze( + ` + var a = 1; + var a = 2; + a; + `, + { sourceType: 'script', resolveGlobalVarsInScript: true }, + ); + + const globalScope = scopeManager.globalScope!; + expect(globalScope.through.some(ref => ref.identifier.name === 'a')).toBe( + false, + ); + const aVar = globalScope.set.get('a'); + expect(aVar).toBeDefined(); + expect(aVar!.references.length).toBeGreaterThanOrEqual(1); + }); + + it('eval usage does not prevent static resolution when opted-in', () => { + const { scopeManager } = parseAndAnalyze( + ` + var a = 1; + eval(''); + a; + `, + { sourceType: 'script', resolveGlobalVarsInScript: true }, + ); + + const globalScope = scopeManager.globalScope!; + expect(globalScope.through.some(ref => ref.identifier.name === 'a')).toBe( + false, + ); + }); +}); diff --git a/packages/scope-manager/tests/eslint-scope/script-nested.test.ts b/packages/scope-manager/tests/eslint-scope/script-nested.test.ts new file mode 100644 index 000000000000..9dab33bf36cd --- /dev/null +++ b/packages/scope-manager/tests/eslint-scope/script-nested.test.ts @@ -0,0 +1,57 @@ +import { ScopeType } from '../../src/scope'; +import { parseAndAnalyze } from '../test-utils'; + +describe('Global resolution in script mode', () => { + it('resolves global var references in nested functions', () => { + const { scopeManager } = parseAndAnalyze( + ` + var a = 1; + function foo() { + function bar() { + return a; + } + } + `, + { sourceType: 'script', resolveGlobalVarsInScript: true }, + ); + + const globalScope = scopeManager.globalScope!; + expect(globalScope.type).toBe(ScopeType.global); + + const aVar = globalScope.set.get('a'); + expect(aVar).toBeDefined(); + + const fooScope = globalScope.childScopes[0]; + const barScope = fooScope.childScopes[0]; + + expect(barScope.references).toHaveLength(1); + const ref = barScope.references[0]; + expect(ref.identifier.name).toBe('a'); + expect(ref.resolved).toBe(aVar); + }); + + it('resolves multiple mixed globals injected via addGlobals', () => { + const { scopeManager } = parseAndAnalyze( + ` + var a = 1; + console.log(b); + `, + { sourceType: 'script', resolveGlobalVarsInScript: true }, + ); + + scopeManager.addGlobals(['b']); + + const globalScope = scopeManager.globalScope!; + const bVar = globalScope.set.get('b'); + expect(bVar).toBeDefined(); + + expect(bVar!.references.length).toBeGreaterThan(0); + const foundRef = bVar!.references[0]; + expect(foundRef.identifier.name).toBe('b'); + + const consoleRef = globalScope.through.find( + r => r.identifier.name === 'console', + ); + expect(consoleRef).toBeDefined(); + }); +}); From 1c5520ee5e2e60de4d23cc0d211f778ecbe19e4e Mon Sep 17 00:00:00 2001 From: Michael Giraldo Date: Mon, 8 Dec 2025 18:21:41 -0800 Subject: [PATCH 2/4] chore(scope-manager): rephrase eslint8/9 comment --- packages/scope-manager/src/scope/ScopeBase.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/scope-manager/src/scope/ScopeBase.ts b/packages/scope-manager/src/scope/ScopeBase.ts index 3a5830f03334..47beb84682a1 100644 --- a/packages/scope-manager/src/scope/ScopeBase.ts +++ b/packages/scope-manager/src/scope/ScopeBase.ts @@ -331,9 +331,8 @@ export abstract class ScopeBase< } if (!scopeManager.shouldResolveGlobalVarsInScript()) { - // Legacy/ESLint 9 behavior: in script mode, only certain cases should be statically resolved. - // Example: a `var` decl is ignored by the runtime if it clashes with a global name, so - // we should not resolve the reference to the variable. + // ESLint 8/9 behavior: in script mode, only certain cases should be statically resolved. + // Example: a `var` decl is ignored by the runtime if it clashes with a global name, so do not resolve it here. // In effect: resolve only if no definition is a `var` declaration. const defs = variable.defs; return ( From 1411ac0079bebb52c3184e98318aae3c906089d7 Mon Sep 17 00:00:00 2001 From: Michael Giraldo Date: Mon, 8 Dec 2025 19:00:15 -0800 Subject: [PATCH 3/4] chore(scope-manager): fix lint ordering and assertions --- packages/scope-manager/src/scope/ScopeBase.ts | 6 +-- .../eslint-scope/add-globals-extra.test.ts | 41 ++++++++++++------- .../tests/eslint-scope/add-globals.test.ts | 2 +- .../eslint-scope/module-var-scope.test.ts | 2 +- .../script-globals-default-vs-opt-in.test.ts | 14 +++---- .../script-globals-edge-cases.test.ts | 8 ++-- .../tests/eslint-scope/script-nested.test.ts | 4 +- 7 files changed, 42 insertions(+), 35 deletions(-) diff --git a/packages/scope-manager/src/scope/ScopeBase.ts b/packages/scope-manager/src/scope/ScopeBase.ts index 47beb84682a1..be4104fbf5e1 100644 --- a/packages/scope-manager/src/scope/ScopeBase.ts +++ b/packages/scope-manager/src/scope/ScopeBase.ts @@ -339,11 +339,7 @@ export abstract class ScopeBase< defs.length > 0 && defs.every( def => - def.type !== DefinitionType.Variable || - !( - def.parent.type === AST_NODE_TYPES.VariableDeclaration && - def.parent.kind === 'var' - ), + def.type !== DefinitionType.Variable || def.parent.kind !== 'var', ) ); } diff --git a/packages/scope-manager/tests/eslint-scope/add-globals-extra.test.ts b/packages/scope-manager/tests/eslint-scope/add-globals-extra.test.ts index 2c1483ea5914..a8fddfd7c92b 100644 --- a/packages/scope-manager/tests/eslint-scope/add-globals-extra.test.ts +++ b/packages/scope-manager/tests/eslint-scope/add-globals-extra.test.ts @@ -6,7 +6,7 @@ describe('addGlobals (extra cases)', () => { ` console.log(a, b); `, - { sourceType: 'script' }, + { resolveGlobalVarsInScript: false, sourceType: 'script' }, ); scopeManager.addGlobals(['a', 'a', 'b']); @@ -33,6 +33,7 @@ describe('addGlobals (extra cases)', () => { it('ignores non-string/empty names', () => { const { scopeManager } = parseAndAnalyze('console.log(x);', { + resolveGlobalVarsInScript: false, sourceType: 'script', }); @@ -42,7 +43,7 @@ describe('addGlobals (extra cases)', () => { const globalScope = scopeManager.globalScope!; const varX = globalScope.set.get('x'); expect(varX).toBeDefined(); - expect(varX!.references.length).toBeGreaterThan(0); + expect(varX!.references).toHaveLength(1); expect( globalScope.through.some(ref => ref.identifier.name === 'console'), @@ -55,7 +56,7 @@ describe('addGlobals (extra cases)', () => { var a = 1; console.log(a); `, - { sourceType: 'script', resolveGlobalVarsInScript: true }, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, ); const beforeThrough = [...scopeManager.globalScope!.through]; @@ -74,9 +75,7 @@ describe('addGlobals (extra cases)', () => { false, ); // sanity: through size does not grow after addGlobals - expect(globalScope.through.length).toBeLessThanOrEqual( - beforeThrough.length, - ); + expect(globalScope.through).toHaveLength(beforeThrough.length); }); it('removes implicit unresolved when injected', () => { @@ -84,7 +83,7 @@ describe('addGlobals (extra cases)', () => { ` foo = 1; `, - { sourceType: 'script' }, + { resolveGlobalVarsInScript: false, sourceType: 'script' }, ); const globalScope = scopeManager.globalScope!; @@ -100,14 +99,20 @@ describe('addGlobals (extra cases)', () => { }); it('handles an empty names array as a no-op', () => { - const { scopeManager } = parseAndAnalyze('foo;'); + const { scopeManager } = parseAndAnalyze('foo;', { + resolveGlobalVarsInScript: false, + sourceType: 'script', + }); const throughBefore = scopeManager.globalScope!.through.length; scopeManager.addGlobals([]); - expect(scopeManager.globalScope!.through.length).toBe(throughBefore); + expect(scopeManager.globalScope!.through).toHaveLength(throughBefore); }); it('handles multiple addGlobals calls with different names', () => { - const { scopeManager } = parseAndAnalyze('foo; bar; baz;'); + const { scopeManager } = parseAndAnalyze('foo; bar; baz;', { + resolveGlobalVarsInScript: false, + sourceType: 'script', + }); scopeManager.addGlobals(['foo']); scopeManager.addGlobals(['bar']); @@ -119,15 +124,21 @@ describe('addGlobals (extra cases)', () => { }); it('is a no-op when names is not an array', () => { - const { scopeManager } = parseAndAnalyze('foo;'); + const { scopeManager } = parseAndAnalyze('foo;', { + resolveGlobalVarsInScript: false, + sourceType: 'script', + }); const throughBefore = scopeManager.globalScope!.through.length; // @ts-expect-error testing defensive branch scopeManager.addGlobals(null); - expect(scopeManager.globalScope!.through.length).toBe(throughBefore); + expect(scopeManager.globalScope!.through).toHaveLength(throughBefore); }); it('typeof query in type position does not bind to value-only globals', () => { - const { scopeManager } = parseAndAnalyze('type X = typeof foo;'); + const { scopeManager } = parseAndAnalyze('type X = typeof foo;', { + resolveGlobalVarsInScript: false, + sourceType: 'script', + }); const globalScope = scopeManager.globalScope!; scopeManager.addGlobals(['foo']); @@ -144,7 +155,7 @@ describe('addGlobals (extra cases)', () => { class MyErr extends Error {} new MyErr(); `, - { sourceType: 'script' }, + { resolveGlobalVarsInScript: false, sourceType: 'script' }, ); const globalScope = scopeManager.globalScope!; @@ -172,7 +183,7 @@ describe('addGlobals (extra cases)', () => { ` type Only = GlobalType; `, - { sourceType: 'script' }, + { resolveGlobalVarsInScript: false, sourceType: 'script' }, ); scopeManager.addGlobals(['GlobalType']); diff --git a/packages/scope-manager/tests/eslint-scope/add-globals.test.ts b/packages/scope-manager/tests/eslint-scope/add-globals.test.ts index 3c44475b8eae..3b97d601508e 100644 --- a/packages/scope-manager/tests/eslint-scope/add-globals.test.ts +++ b/packages/scope-manager/tests/eslint-scope/add-globals.test.ts @@ -122,8 +122,8 @@ describe('addGlobals', () => { it('resolves references to global var declarations in script mode', () => { const { scopeManager } = parseAndAnalyze('var foo; console.log(foo);', { - sourceType: 'script', resolveGlobalVarsInScript: true, + sourceType: 'script', }); const scope = scopeManager.globalScope!; diff --git a/packages/scope-manager/tests/eslint-scope/module-var-scope.test.ts b/packages/scope-manager/tests/eslint-scope/module-var-scope.test.ts index b83486a81ba2..67d6b58f67d1 100644 --- a/packages/scope-manager/tests/eslint-scope/module-var-scope.test.ts +++ b/packages/scope-manager/tests/eslint-scope/module-var-scope.test.ts @@ -8,7 +8,7 @@ describe('module var scoping (flag has no effect)', () => { var a = 1; a; `, - { sourceType: 'module', resolveGlobalVarsInScript: true }, + { resolveGlobalVarsInScript: true, sourceType: 'module' }, ); const globalScope = scopeManager.globalScope!; diff --git a/packages/scope-manager/tests/eslint-scope/script-globals-default-vs-opt-in.test.ts b/packages/scope-manager/tests/eslint-scope/script-globals-default-vs-opt-in.test.ts index 719af98df45f..5762a77d3865 100644 --- a/packages/scope-manager/tests/eslint-scope/script-globals-default-vs-opt-in.test.ts +++ b/packages/scope-manager/tests/eslint-scope/script-globals-default-vs-opt-in.test.ts @@ -8,7 +8,7 @@ describe('script globals: default vs opt-in', () => { console.log(a); var a = 1; `, - { sourceType: 'script' }, + { resolveGlobalVarsInScript: false, sourceType: 'script' }, ); const globalScope = scopeManager.globalScope!; @@ -31,7 +31,7 @@ describe('script globals: default vs opt-in', () => { console.log(a); var a = 1; `, - { sourceType: 'script', resolveGlobalVarsInScript: true }, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, ); const globalScope = scopeManager.globalScope!; @@ -54,7 +54,7 @@ describe('script globals: default vs opt-in', () => { ` const x: typeof Foo = Foo; `, - { sourceType: 'script', resolveGlobalVarsInScript: true }, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, ); scopeManager.addGlobals(['Foo']); @@ -76,7 +76,7 @@ describe('script globals: default vs opt-in', () => { function foo() {} foo(); `, - { sourceType: 'script', resolveGlobalVarsInScript: true }, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, ); const globalScope = scopeManager.globalScope!; @@ -95,7 +95,7 @@ describe('script globals: default vs opt-in', () => { const b = 2; console.log(a, b); `, - { sourceType: 'script', resolveGlobalVarsInScript: false }, + { resolveGlobalVarsInScript: false, sourceType: 'script' }, ); const globalScope = scopeManager.globalScope!; @@ -116,7 +116,7 @@ describe('script globals: default vs opt-in', () => { var a = 1; a; `, - { sourceType: 'module', resolveGlobalVarsInScript: false }, + { resolveGlobalVarsInScript: false, sourceType: 'module' }, ); // module scopes do not place var in the global scope set @@ -129,7 +129,7 @@ describe('script globals: default vs opt-in', () => { class Foo {} new Foo(); `, - { sourceType: 'script', resolveGlobalVarsInScript: false }, + { resolveGlobalVarsInScript: false, sourceType: 'script' }, ); const globalScope = scopeManager.globalScope!; diff --git a/packages/scope-manager/tests/eslint-scope/script-globals-edge-cases.test.ts b/packages/scope-manager/tests/eslint-scope/script-globals-edge-cases.test.ts index b1b2f6c8495d..42aa72d6ae02 100644 --- a/packages/scope-manager/tests/eslint-scope/script-globals-edge-cases.test.ts +++ b/packages/scope-manager/tests/eslint-scope/script-globals-edge-cases.test.ts @@ -7,7 +7,7 @@ describe('script globals: edge cases', () => { console.log(a); let a = 1; `, - { sourceType: 'script', resolveGlobalVarsInScript: false }, + { resolveGlobalVarsInScript: false, sourceType: 'script' }, ); const globalScope = scopeManager.globalScope!; @@ -27,7 +27,7 @@ describe('script globals: edge cases', () => { } var a = 1; `, - { sourceType: 'script', resolveGlobalVarsInScript: true }, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, ); const globalScope = scopeManager.globalScope!; @@ -47,7 +47,7 @@ describe('script globals: edge cases', () => { var a = 2; a; `, - { sourceType: 'script', resolveGlobalVarsInScript: true }, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, ); const globalScope = scopeManager.globalScope!; @@ -66,7 +66,7 @@ describe('script globals: edge cases', () => { eval(''); a; `, - { sourceType: 'script', resolveGlobalVarsInScript: true }, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, ); const globalScope = scopeManager.globalScope!; diff --git a/packages/scope-manager/tests/eslint-scope/script-nested.test.ts b/packages/scope-manager/tests/eslint-scope/script-nested.test.ts index 9dab33bf36cd..9107733fe1f2 100644 --- a/packages/scope-manager/tests/eslint-scope/script-nested.test.ts +++ b/packages/scope-manager/tests/eslint-scope/script-nested.test.ts @@ -12,7 +12,7 @@ describe('Global resolution in script mode', () => { } } `, - { sourceType: 'script', resolveGlobalVarsInScript: true }, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, ); const globalScope = scopeManager.globalScope!; @@ -36,7 +36,7 @@ describe('Global resolution in script mode', () => { var a = 1; console.log(b); `, - { sourceType: 'script', resolveGlobalVarsInScript: true }, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, ); scopeManager.addGlobals(['b']); From 1c66e96403ef1fa9c61889370312d9d5ff8a8484 Mon Sep 17 00:00:00 2001 From: Michael Giraldo Date: Mon, 8 Dec 2025 19:18:22 -0800 Subject: [PATCH 4/4] test(scope-manager): cover null/implicit branches --- .../eslint-scope/add-globals-extra.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/scope-manager/tests/eslint-scope/add-globals-extra.test.ts b/packages/scope-manager/tests/eslint-scope/add-globals-extra.test.ts index a8fddfd7c92b..6ae88a945bd9 100644 --- a/packages/scope-manager/tests/eslint-scope/add-globals-extra.test.ts +++ b/packages/scope-manager/tests/eslint-scope/add-globals-extra.test.ts @@ -1,3 +1,4 @@ +import { ScopeManager } from '../../src/ScopeManager'; import { parseAndAnalyze } from '../test-utils'; describe('addGlobals (extra cases)', () => { @@ -195,4 +196,42 @@ describe('addGlobals (extra cases)', () => { expect(varGlobal).toBeDefined(); expect(varGlobal!.references.some(ref => ref.isTypeReference)).toBe(false); }); + + it('no-ops when globalScope is null', () => { + const scopeManager = new ScopeManager({ sourceType: 'script' }); + scopeManager.addGlobals(['foo']); + expect(scopeManager.globalScope).toBeNull(); + }); + + it('tolerates missing implicit state', () => { + const { scopeManager } = parseAndAnalyze('foo;', { + resolveGlobalVarsInScript: false, + sourceType: 'script', + }); + + const globalScope = scopeManager.globalScope!; + delete (globalScope as unknown as { implicit?: unknown }).implicit; + + scopeManager.addGlobals(['foo']); + expect(globalScope.through.some(ref => ref.identifier.name === 'foo')).toBe( + false, + ); + }); + + it('tolerates non-array implicit leftToBeResolved', () => { + const { scopeManager } = parseAndAnalyze('foo;', { + resolveGlobalVarsInScript: false, + sourceType: 'script', + }); + + const globalScope = scopeManager.globalScope!; + ( + globalScope as unknown as { implicit?: { leftToBeResolved: unknown } } + ).implicit = { leftToBeResolved: null }; + + scopeManager.addGlobals(['foo']); + expect(globalScope.through.some(ref => ref.identifier.name === 'foo')).toBe( + false, + ); + }); });