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..be4104fbf5e1 100644 --- a/packages/scope-manager/src/scope/ScopeBase.ts +++ b/packages/scope-manager/src/scope/ScopeBase.ts @@ -330,20 +330,22 @@ 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()) { + // 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 ( + defs.length > 0 && + defs.every( + def => + def.type !== DefinitionType.Variable || 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..6ae88a945bd9 --- /dev/null +++ b/packages/scope-manager/tests/eslint-scope/add-globals-extra.test.ts @@ -0,0 +1,237 @@ +import { ScopeManager } from '../../src/ScopeManager'; +import { parseAndAnalyze } from '../test-utils'; + +describe('addGlobals (extra cases)', () => { + it('deduplicates and binds multiple globals', () => { + const { scopeManager } = parseAndAnalyze( + ` + console.log(a, b); + `, + { resolveGlobalVarsInScript: false, 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);', { + resolveGlobalVarsInScript: false, + 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).toHaveLength(1); + + 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); + `, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, + ); + + 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).toHaveLength(beforeThrough.length); + }); + + it('removes implicit unresolved when injected', () => { + const { scopeManager } = parseAndAnalyze( + ` + foo = 1; + `, + { resolveGlobalVarsInScript: false, 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;', { + resolveGlobalVarsInScript: false, + sourceType: 'script', + }); + const throughBefore = scopeManager.globalScope!.through.length; + scopeManager.addGlobals([]); + expect(scopeManager.globalScope!.through).toHaveLength(throughBefore); + }); + + it('handles multiple addGlobals calls with different names', () => { + const { scopeManager } = parseAndAnalyze('foo; bar; baz;', { + resolveGlobalVarsInScript: false, + sourceType: 'script', + }); + + 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;', { + resolveGlobalVarsInScript: false, + sourceType: 'script', + }); + const throughBefore = scopeManager.globalScope!.through.length; + // @ts-expect-error testing defensive branch + scopeManager.addGlobals(null); + 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;', { + resolveGlobalVarsInScript: false, + sourceType: 'script', + }); + 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(); + `, + { resolveGlobalVarsInScript: false, 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; + `, + { resolveGlobalVarsInScript: false, 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); + }); + + 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, + ); + }); +}); 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..3b97d601508e --- /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);', { + resolveGlobalVarsInScript: true, + sourceType: 'script', + }); + 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..67d6b58f67d1 --- /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; + `, + { resolveGlobalVarsInScript: true, sourceType: 'module' }, + ); + + 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..5762a77d3865 --- /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; + `, + { resolveGlobalVarsInScript: false, 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; + `, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, + ); + + 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; + `, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, + ); + + 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(); + `, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, + ); + + 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); + `, + { resolveGlobalVarsInScript: false, sourceType: 'script' }, + ); + + 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; + `, + { resolveGlobalVarsInScript: false, sourceType: 'module' }, + ); + + // 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(); + `, + { resolveGlobalVarsInScript: false, sourceType: 'script' }, + ); + + 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..42aa72d6ae02 --- /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; + `, + { resolveGlobalVarsInScript: false, sourceType: 'script' }, + ); + + 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; + `, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, + ); + + 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; + `, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, + ); + + 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; + `, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, + ); + + 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..9107733fe1f2 --- /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; + } + } + `, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, + ); + + 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); + `, + { resolveGlobalVarsInScript: true, sourceType: 'script' }, + ); + + 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(); + }); +});