Fix ScopeAnalyzer.ts

This commit is contained in:
sanex3339
2026-01-27 14:29:55 +04:00
parent ec1252c7c4
commit 060c8f5be9
4 changed files with 262 additions and 0 deletions
@@ -84,6 +84,11 @@ export class ScopeAnalyzer implements IScopeAnalyzer {
sourceType: ScopeAnalyzer.sourceTypes[i]
});
// Fix Annex B function hoisting references
// eslint-scope doesn't implement Annex B semantics where function declarations
// in blocks also create a var-hoisted binding in the enclosing function scope
this.fixAnnexBFunctionHoisting();
return;
} catch (error) {
if (i < sourceTypeLength - 1) {
@@ -117,6 +122,101 @@ export class ScopeAnalyzer implements IScopeAnalyzer {
return scope;
}
/**
* Fix Annex B function hoisting references.
*
* In non-strict mode, function declarations in blocks have dual binding:
* 1. A block-scoped binding (handled by eslint-scope)
* 2. A var-hoisted binding in the enclosing function scope (NOT handled by eslint-scope)
*
* This method merges block-scoped function declarations into the enclosing
* function scope and links unresolved references.
*/
private fixAnnexBFunctionHoisting(): void {
if (!this.scopeManager) {
return;
}
this.walkScopes(this.scopeManager.globalScope, (scope: eslintScope.Scope) => {
if (scope.type !== 'block' && scope.type !== 'switch') {
return;
}
// Skip strict mode scopes - Annex B doesn't apply
if (scope.isStrict) {
return;
}
const functionScope = scope.variableScope;
if (!functionScope) {
return;
}
for (let i = scope.variables.length - 1; i >= 0; i--) {
const variable = scope.variables[i];
const isFunctionDeclaration = variable.defs.some(
(def) => def.type === 'FunctionName' && def.node?.type === 'FunctionDeclaration'
);
if (!isFunctionDeclaration) {
continue;
}
// Find existing variable with the same name in function scope (shadowing case)
const outerVariable = functionScope.variables.find((v) => v.name === variable.name && v !== variable);
// Per Annex B.3.3, hoisting only applies if outer binding is var/function (not let/const)
const isOuterLetOrConst = outerVariable?.defs.some(
(def) => def.type === 'Variable' && (def.parent?.kind === 'let' || def.parent?.kind === 'const')
);
// Skip Annex B hoisting if there's a let/const with the same name
if (isOuterLetOrConst) {
continue;
}
const targetVariable = outerVariable ?? variable;
if (outerVariable) {
// Merge inner function's identifiers and references into outer
outerVariable.identifiers.push(...variable.identifiers);
outerVariable.references.push(...variable.references);
} else {
// Move variable to function scope so references can find it
functionScope.variables.push(variable);
}
// Remove from block scope
scope.variables.splice(i, 1);
// Link "through" references with matching name to the target variable
this.linkThroughReferences(variable.name, functionScope, targetVariable);
}
});
}
/**
* Link unresolved "through" references to a variable.
*
* @param {string} name - The variable name to match
* @param {Scope} scope - The scope to start searching from
* @param {Variable} targetVariable - The variable to link references to
*/
private linkThroughReferences(name: string, scope: eslintScope.Scope, targetVariable: eslintScope.Variable): void {
for (let i = scope.through.length - 1; i >= 0; i--) {
if (scope.through[i].identifier.name === name) {
targetVariable.references.push(scope.through[i]);
scope.through.splice(i, 1);
}
}
for (const childScope of scope.childScopes) {
this.linkThroughReferences(name, childScope, targetVariable);
}
}
/**
* @param {Scope} scope
*/
@@ -150,4 +250,18 @@ export class ScopeAnalyzer implements IScopeAnalyzer {
this.sanitizeScopes(childScope);
}
}
/**
* Walk through all scopes in the scope tree
*
* @param {Scope} scope - Starting scope
* @param {Function} callback - Function to call for each scope
*/
private walkScopes(scope: eslintScope.Scope, callback: (scope: eslintScope.Scope) => void): void {
callback(scope);
for (const childScope of scope.childScopes) {
this.walkScopes(childScope, callback);
}
}
}
@@ -2,6 +2,7 @@ import 'reflect-metadata';
import { assert } from 'chai';
import { evalLocal } from '../../../helpers/evalLocal';
import { readFileAsString } from '../../../helpers/readFileAsString';
import { JavaScriptObfuscator } from '../../../../src/JavaScriptObfuscatorFacade';
@@ -43,5 +44,104 @@ describe('ScopeAnalyzer', () => {
assert.equal(error, null);
});
});
describe('Variant #2: Annex B function hoisting', () => {
describe('Variant #1: basic block-scoped function hoisting', () => {
const samplesCount: number = 50;
let testFunc: () => void;
beforeEach(() => {
const code: string = readFileAsString(__dirname + '/fixtures/annex-b-function-hoisting.js');
testFunc = () => {
for (let i = 0; i < samplesCount; i++) {
const obfuscatedCode: string = JavaScriptObfuscator.obfuscate(code, {
seed: i
}).getObfuscatedCode();
const result = evalLocal(obfuscatedCode);
if (result.test1 !== 'foo') {
throw new Error('test1 failed: expected foo, got ' + result.test1);
}
if (result.test2 !== 'bar') {
throw new Error('test2 failed: expected bar, got ' + result.test2);
}
}
};
});
it('should correctly handle Annex B function hoisting references', () => {
assert.doesNotThrow(testFunc);
});
});
describe('Variant #2: strict mode should not apply Annex B hoisting', () => {
let testFunc: () => void;
beforeEach(() => {
const code: string = `
'use strict';
function test() {
let foo;
if (true) {
function foo() { return 'inner'; }
foo(); // This refers to block-scoped foo
}
// foo here is the outer let, which is undefined
return typeof foo;
}
test();
`;
testFunc = () => {
const obfuscatedCode: string = JavaScriptObfuscator.obfuscate(code, {
seed: 12345
}).getObfuscatedCode();
eval(obfuscatedCode);
};
});
it('should correctly handle strict mode block-scoped functions', () => {
assert.doesNotThrow(testFunc);
});
});
describe('Variant #3: let/const shadowing should prevent Annex B hoisting', () => {
let testFunc: () => void;
beforeEach(() => {
const code: string = `
function test() {
let foo = 'outer';
if (true) {
function foo() { return 'inner'; }
foo(); // block-scoped foo
}
return foo; // should be 'outer', not the function
}
test();
`;
testFunc = () => {
const obfuscatedCode: string = JavaScriptObfuscator.obfuscate(code, {
seed: 12345
}).getObfuscatedCode();
const result = eval(obfuscatedCode);
if (result !== 'outer') {
throw new Error('Expected outer, got: ' + result);
}
};
});
it('should not hoist when let/const shadows the function name', () => {
assert.doesNotThrow(testFunc);
});
});
});
});
});
@@ -0,0 +1,39 @@
// Basic Annex B case: function in block referenced after block
function test1() {
if (true) {
function foo() {
return 'foo';
}
}
return foo();
}
// Function in switch case
function test2(x) {
switch (x) {
case 1:
function bar() {
return 'bar';
}
break;
}
return bar();
}
// Multiple blocks with same function name
function test3() {
if (true) {
function baz() {
return 'first';
}
}
if (false) {
function baz() {
return 'second';
}
}
return baz();
}
// Return results for testing
({ test1: test1(), test2: test2(1) });
+9
View File
@@ -0,0 +1,9 @@
/**
* Evaluates code using indirect eval.
* Indirect eval runs in global scope without inheriting strict mode from the calling context.
* This is needed for testing features like Annex B function hoisting.
*
* @param {string} code
* @returns {any}
*/
export const evalLocal = (code: string): any => (0, eval)(code);