Repository navigation
Closed
Labels
package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
Description
Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. When a class is declared in a function body, including the body of a
compiled CommonJS module, its methods see the value an enclosing var had when the class was defined. Later
assignments are not seen. let bindings and plain closures are live. The most common victim is TypeScript's emit for
a class whose static members refer to the class itself (var _a; class X {...} _a = X;): _a stays undefined
inside the static methods.
Reproduction
package.json
{"name":"repro","private":true,"type":"module"}lib.cjs (the shape TypeScript emits when static members of a class refer to the class itself)
"use strict";
var _a;
class Client {
static create(x) { return _a.factory(x); }
static factory(x) { return 'factory(' + x + ')'; }
}
_a = Client;
exports.Client = Client;main.ts
import lib from './lib.cjs';
try { console.log('CJS static via _a:', (lib as any).Client.create(1)); } catch (e: any) { console.log('CJS static via _a: THREW', e.message); }
function scope() {
var v = 1;
let l = 1;
var u: any;
class K {
static s() { return JSON.stringify([v, l, u]); }
i() { return JSON.stringify([v, l, u]); }
}
v = 2; l = 2; u = 'set';
const fn = () => JSON.stringify([v, l, u]);
console.log('after assignment: static', K.s(), '| instance', new K().i(), '| arrow', fn());
v = 3; l = 3; u = 'again';
console.log('after 2nd assign: static', K.s(), '| instance', new K().i(), '| arrow', fn());
}
scope();node main.ts
perry compile main.ts -o out && ./outExpected (Node 26.5.1)
CJS static via _a: factory(1)
after assignment: static [2,2,"set"] | instance [2,2,"set"] | arrow [2,2,"set"]
after 2nd assign: static [3,3,"again"] | instance [3,3,"again"] | arrow [3,3,"again"]
Actual (Perry)
Same with and without PERRY_NO_AUTO_OPTIMIZE=1:
CJS static via _a: THREW Cannot read properties of undefined (reading 'factory')
after assignment: static [1,2,null] | instance [1,2,"set"] | arrow [2,2,"set"]
after 2nd assign: static [1,3,null] | instance [1,3,"again"] | arrow [3,3,"again"]
Impact
@redis/client6.1.0:dist/lib/client/index.js:116(static create(options) { return _a.factory(options)(options); },
with_a = RedisClient;at line 1194) throwsCannot read properties of undefined (reading 'factory'). The same
var _a … _a = Xemit is in 5 files of@redis/client(lib/client/index.js,lib/client/enterprise-maintenance-manager.js,
lib/cluster/cluster-slots.js,lib/cluster/cluster-reconnection-tracker.js,lib/RESP/decoder.js).- Any TypeScript-compiled CommonJS package using this emit, and any CJS/bundled code that declares a class before
assigning avarit reads.
Notes
- The same
var _a; class X {…} _a = X;at the top level of an ESM module (.tsor.js) works; only classes nested
in a function scope (a CJS module body counts) are affected. - The results differ by member kind and binding. For a
varwith an initializer, both static and instance methods keep
the initial value. For avarwithout an initializer, static methods stayundefinedwhile instance methods see
later assignments.letis always live, and so are arrow functions/function declarations in the same scope. - A later write from another function (
exports.bump = () => { v = 3 }) is also invisible to class methods in the
CJS module. - Related closed issues on the class-capture snapshot machinery: hir: class-expression capture snapshot stale for vars assigned after the class — semver Comparator TypeError kills pi-native at init #6604 (class expression capture snapshot stale for
vars assigned after the class; the refreshinsert_class_capture_refresh_after_assignments,
crates/perry-hir/src/lower/expr_function.rs:1738, was described there as covering class declarations only), destructuring-var forward capture into a class method reads undefined (semver Comparator shape) — regressed on main, CI change-scoping missed it #6037,
Next.js standalone: deferred-require binding captured by-value (as unresolved thunk) by a class constructor → 'undefined is not a constructor' #5437. This case is a class declaration and still stale, so either the refresh does not cover static methods /
varbindings or it does not fire for these assignments (inferred, not traced). - redis 6.1.0 detail: in
@redis/client/dist/lib/client/index.jsthe TS emit also passes the alias as a value —
attachConfig({ BaseClass: _a, … })(:99), which doesclass extends BaseClass(dist/lib/commander.js:8). With
_astale-undefined this surfaces as "Class extends value is not a constructor" when constructing a client, which is
why patching only the_a.call sites is not enough. The next redis blocker after this one is Class expression returned from a function in a non-entry module has no per-evaluation identity: every call returns the same class, and the lastextendswins #10455.
Metadata
Metadata
Assignees
Labels
package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
Activity
fix(hir): capture class member enclosing vars by reference, not snapshot