From 1adf79f3cf31c10e4fbf679721b9fd85e396cd48 Mon Sep 17 00:00:00 2001 From: tycrimm Date: Tue, 3 Nov 2026 15:10:22 -0800 Subject: [PATCH 1/2] fix: avoid quadratic analysis time on large files computePureStaticValue copied every global and known top-level binding into a new object on each call, so analysis cost calls x bindings. Look bindings up on demand through a Proxy instead, in the same order. The proxy also answers `in` checks and rejects writes. Co-Authored-By: Claude Opus 5.5 --- src/analyze.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/analyze.ts b/src/analyze.ts index a4ba9862..9001deb1 100644 --- a/src/analyze.ts +++ b/src/analyze.ts @@ -510,15 +510,29 @@ export default async function analyze( } } + // Same lookup order as the old per-call copy: import.meta, then known + // bindings (shadowed ones resolve to undefined), then globals. + function getVarDescriptor(name: string | symbol) { + if (typeof name !== 'string') return undefined; + let value; + if (name === 'import.meta') value = { url: importMetaUrl }; + else if (name in knownBindings) value = getKnownBinding(name); + else if (Object.prototype.hasOwnProperty.call(globalBindings, name)) + value = { value: globalBindings[name] }; + else return undefined; + return { value, writable: true, enumerable: true, configurable: true }; + } async function computePureStaticValue(expr: Node, computeBranches = true) { - const vars = Object.create(null); - Object.keys(globalBindings).forEach((name) => { - vars[name] = { value: globalBindings[name] }; - }); - Object.keys(knownBindings).forEach((name) => { - vars[name] = getKnownBinding(name); + // Look bindings up on demand. Copying all of them into a new object on + // every call made analysis quadratic in the file size. + const vars = new Proxy(Object.create(null), { + getOwnPropertyDescriptor: (_target, name) => getVarDescriptor(name), + get: (_target, name) => getVarDescriptor(name)?.value, + has: (_target, name) => getVarDescriptor(name) !== undefined, + set: () => { + throw new Error('static eval vars are read-only'); + }, }); - vars['import.meta'] = { url: importMetaUrl }; // evaluate returns undefined for non-statically-analyzable const result = await evaluate(expr, vars, computeBranches); return result; From e0d4f529daaef1690c3d76fe655c938bce22104c Mon Sep 17 00:00:00 2001 From: tycrimm Date: Thu, 5 Nov 2026 11:52:13 -0800 Subject: [PATCH 2/2] refactor(analyze): lift static eval vars proxy and test it Build the proxy once per analyze() through an exported factory instead of on every computePureStaticValue call, and test it directly so the has and set traps are covered. The tests pin the old eager copy's behavior: lookup order, shadowed bindings, undefined-valued globals, and no Object.prototype leaks. Co-Authored-By: Claude Opus 5.5 --- src/analyze.ts | 75 +++++++++++++---------- test/static-eval-vars.test.js | 108 ++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 30 deletions(-) create mode 100644 test/static-eval-vars.test.js diff --git a/src/analyze.ts b/src/analyze.ts index 9001deb1..1937b852 100644 --- a/src/analyze.ts +++ b/src/analyze.ts @@ -267,6 +267,44 @@ export interface AnalyzeResult { isESM: boolean; } +type KnownBindings = Record< + string, + { shadowDepth: number; value: StaticValue | ConditionalValue } +>; + +// The vars that static-eval reads, looked up on demand. Copying every binding +// into a new object on each evaluation made analysis quadratic in the file +// size. Same lookup order as that copy: import.meta, then known bindings +// (shadowed ones resolve to undefined), then globals. Lookups are live, so +// bindings added or removed later are seen. +export function createStaticEvalVars( + knownBindings: KnownBindings, + globals: Record, + importMetaUrl: string, +) { + const hasOwn = Object.prototype.hasOwnProperty; + function getDescriptor(name: string | symbol) { + if (typeof name !== 'string') return undefined; + let value; + if (name === 'import.meta') value = { url: importMetaUrl }; + else if (hasOwn.call(knownBindings, name)) { + const binding = knownBindings[name]; + value = binding.shadowDepth === 0 ? binding.value : undefined; + } else if (hasOwn.call(globals, name)) value = { value: globals[name] }; + else return undefined; + // A proxy can only report a property its target lacks as configurable. + return { value, writable: true, enumerable: true, configurable: true }; + } + return new Proxy(Object.create(null), { + getOwnPropertyDescriptor: (_target, name) => getDescriptor(name), + get: (_target, name) => getDescriptor(name)?.value, + has: (_target, name) => getDescriptor(name) !== undefined, + set: () => { + throw new Error('static eval vars are read-only'); + }, + }); +} + // Helper: process pino transport config object // Extracts target strings from { target: '...' }, { targets: [...] }, or { pipeline: [...] } async function processPinoTransportObject( @@ -400,13 +438,7 @@ export default async function analyze( const importMetaUrl = pathToFileURL(id).href; - const knownBindings: Record< - string, - { - shadowDepth: number; - value: StaticValue | ConditionalValue; - } - > = Object.assign(Object.create(null), { + const knownBindings: KnownBindings = Object.assign(Object.create(null), { __dirname: { shadowDepth: 0, value: { value: path.resolve(id, '..') }, @@ -510,31 +542,14 @@ export default async function analyze( } } - // Same lookup order as the old per-call copy: import.meta, then known - // bindings (shadowed ones resolve to undefined), then globals. - function getVarDescriptor(name: string | symbol) { - if (typeof name !== 'string') return undefined; - let value; - if (name === 'import.meta') value = { url: importMetaUrl }; - else if (name in knownBindings) value = getKnownBinding(name); - else if (Object.prototype.hasOwnProperty.call(globalBindings, name)) - value = { value: globalBindings[name] }; - else return undefined; - return { value, writable: true, enumerable: true, configurable: true }; - } + const staticEvalVars = createStaticEvalVars( + knownBindings, + globalBindings, + importMetaUrl, + ); async function computePureStaticValue(expr: Node, computeBranches = true) { - // Look bindings up on demand. Copying all of them into a new object on - // every call made analysis quadratic in the file size. - const vars = new Proxy(Object.create(null), { - getOwnPropertyDescriptor: (_target, name) => getVarDescriptor(name), - get: (_target, name) => getVarDescriptor(name)?.value, - has: (_target, name) => getVarDescriptor(name) !== undefined, - set: () => { - throw new Error('static eval vars are read-only'); - }, - }); // evaluate returns undefined for non-statically-analyzable - const result = await evaluate(expr, vars, computeBranches); + const result = await evaluate(expr, staticEvalVars, computeBranches); return result; } diff --git a/test/static-eval-vars.test.js b/test/static-eval-vars.test.js new file mode 100644 index 00000000..a4bf1a64 --- /dev/null +++ b/test/static-eval-vars.test.js @@ -0,0 +1,108 @@ +const { createStaticEvalVars } = require('../out/analyze.js'); + +const hasOwn = (obj, name) => Object.prototype.hasOwnProperty.call(obj, name); + +// The eager copy that computePureStaticValue used to build on every call. +function copyVars(knownBindings, globals, importMetaUrl) { + const vars = Object.create(null); + for (const name of Object.keys(globals)) + vars[name] = { value: globals[name] }; + for (const name of Object.keys(knownBindings)) { + const binding = knownBindings[name]; + vars[name] = binding.shadowDepth === 0 ? binding.value : undefined; + } + vars['import.meta'] = { url: importMetaUrl }; + return vars; +} + +function setup() { + const knownBindings = Object.assign(Object.create(null), { + __dirname: { shadowDepth: 0, value: { value: '/app' } }, + URL: { shadowDepth: 0, value: { value: 'known URL' } }, + process: { shadowDepth: 1, value: { value: 'shadowed process' } }, + }); + const globals = { + URL: 'global URL', + process: 'global process', + Buffer: 'global Buffer', + MONGOOSE_DRIVER_PATH: undefined, + }; + const importMetaUrl = 'file:///app/index.js'; + const vars = createStaticEvalVars(knownBindings, globals, importMetaUrl); + return { knownBindings, globals, importMetaUrl, vars }; +} + +const PROTOTYPE_NAMES = [ + 'toString', + 'constructor', + 'hasOwnProperty', + '__proto__', +]; + +test('matches the eager copy for every lookup', () => { + const { knownBindings, globals, importMetaUrl, vars } = setup(); + const copy = copyVars(knownBindings, globals, importMetaUrl); + const names = [ + 'import.meta', + ...Object.keys(knownBindings), + ...Object.keys(globals), + 'missing', + ...PROTOTYPE_NAMES, + ]; + for (const name of names) { + expect([name, hasOwn(vars, name), name in vars, vars[name]]).toEqual([ + name, + hasOwn(copy, name), + name in copy, + copy[name], + ]); + } +}); + +test('looks up import.meta, then known bindings, then globals', () => { + const { vars } = setup(); + expect(vars['import.meta']).toEqual({ url: 'file:///app/index.js' }); + expect(vars.URL).toEqual({ value: 'known URL' }); + expect(vars.Buffer).toEqual({ value: 'global Buffer' }); +}); + +test('a shadowed binding is present with an undefined value', () => { + const { vars } = setup(); + expect(hasOwn(vars, 'process')).toBe(true); + expect('process' in vars).toBe(true); + expect(vars.process).toBeUndefined(); +}); + +test('a global with an undefined value is present', () => { + const { vars } = setup(); + expect(hasOwn(vars, 'MONGOOSE_DRIVER_PATH')).toBe(true); + expect('MONGOOSE_DRIVER_PATH' in vars).toBe(true); + expect(vars.MONGOOSE_DRIVER_PATH).toEqual({ value: undefined }); +}); + +test('Object.prototype members and symbols do not leak in', () => { + const { vars } = setup(); + for (const name of [...PROTOTYPE_NAMES, Symbol.iterator]) { + expect(hasOwn(vars, name)).toBe(false); + expect(name in vars).toBe(false); + expect(vars[name]).toBeUndefined(); + } +}); + +test('sees bindings that change after creation', () => { + const { knownBindings, vars } = setup(); + knownBindings.later = { shadowDepth: 0, value: { value: 'later' } }; + knownBindings.process.shadowDepth = 0; + delete knownBindings.__dirname; + expect(vars.later).toEqual({ value: 'later' }); + expect(vars.process).toEqual({ value: 'shadowed process' }); + expect('__dirname' in vars).toBe(false); +}); + +test('rejects writes', () => { + const { vars } = setup(); + expect(() => { + vars.URL = { value: 'overwritten' }; + }).toThrow('read-only'); + expect(vars.URL).toEqual({ value: 'known URL' }); +});