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. A method call whose NAME matches a Date.prototype, Number.prototype or
some Array.prototype methods is lowered to that built-in by name, whatever the receiver is. The user's method is never
called: you get NaN, [object Object], Invalid Date or RangeError: Invalid time value instead.
Reproduction
package.json
{"name":"repro","private":true,"type":"module"}
dayjs_like.js (dayjs 1.11.x toJSON shape)
function Dayjs(ms) { this.$ms = ms; }
Dayjs.prototype.toISOString = function () { return 'Dayjs.toISOString(' + this.$ms + ')'; };
Dayjs.prototype.toJSON = function () { return this.toISOString(); };
try { console.log('js this.toISOString() in toJSON', new Dayjs(0).toJSON()); } catch (e) { console.log('js this.toISOString() in toJSON THREW', String(e)); }
main.ts
import './dayjs_like.js';
const t = (label: string, f: () => unknown) => { try { console.log(label, String(f())); } catch (e) { console.log(label, 'THREW', String(e)); } };
function Money(this: any, v: number) { this.v = v; }
Money.prototype.toFixed = function (d: number) { return 'Money.toFixed'; };
Money.prototype.getTime = function () { return 'Money.getTime'; };
Money.prototype.toISOString = function () { return 'Money.toISOString'; };
const m: any = new (Money as any)(1);
t('function-ctor m.toFixed(2) ', () => m.toFixed(2));
t('function-ctor m.getTime() ', () => m.getTime());
t('function-ctor m.toISOString()', () => m.toISOString());
class Clock {
getTime() { return 'Clock.getTime'; }
toFixed(d: number) { return 'Clock.toFixed'; }
setHours(h: number) { return 'Clock.setHours'; }
}
const c = new Clock();
t('class c.getTime() ', () => c.getTime());
t('class c.toFixed(2) ', () => c.toFixed(2));
t('class c.setHours(1) ', () => c.setHours(1));
const lit = { getFullYear() { return 'lit.getFullYear'; }, toPrecision(p: number) { return 'lit.toPrecision'; } };
t('object literal getFullYear() ', () => lit.getFullYear());
t('object literal toPrecision(3)', () => lit.toPrecision(3));
node main.ts
perry compile main.ts -o out && ./out
Expected (Node 26.5.1)
js this.toISOString() in toJSON Dayjs.toISOString(0)
function-ctor m.toFixed(2) Money.toFixed
function-ctor m.getTime() Money.getTime
function-ctor m.toISOString() Money.toISOString
class c.getTime() Clock.getTime
class c.toFixed(2) Clock.toFixed
class c.setHours(1) Clock.setHours
object literal getFullYear() lit.getFullYear
object literal toPrecision(3) lit.toPrecision
Actual (Perry)
Same output with and without PERRY_NO_AUTO_OPTIMIZE=1:
js this.toISOString() in toJSON THREW RangeError: Invalid time value
function-ctor m.toFixed(2) NaN
function-ctor m.getTime() [object Object]
function-ctor m.toISOString() THREW RangeError: Invalid time value
class c.getTime() [object Object]
class c.toFixed(2) NaN
class c.setHours(1) NaN
object literal getFullYear() NaN
object literal toPrecision(3) NaN
A zero-argument call of a user method named endsWith, includes or startsWith does not even compile. endswith.js
imported from main.ts:
function Path() {}
Path.prototype.endsWith = function () { return 'Path.endsWith'; };
console.log(new Path().endsWith());
Error compiling module 'endswith.js' (...) with --backend llvm: lowering entry of module 'endswith.js': lowering init statements of non-entry module 'endswith.js': perry-codegen: String.endsWith expects 1 or 2 args, got 0
(Node prints Path.endsWith.)
Impact
- dayjs 1.11.21:
d.toISOString(), d.toJSON() (esm/index.js:496, return this.isValid() ? this.toISOString() : null) throw RangeError: Invalid time value, so JSON.stringify(dayjs()) fails.
- moment 2.30.1: same path (
moment.js:4329 toJSON), plus the toISOString call on a moment instance.
- decimal.js 10.6.0 (
P.toFixed = function (dp, rm), decimal.js:2025) and bignumber.js 11.1.5 (P.toFixed, dist/bignumber.js:2673): toFixed/toExponential/toPrecision return "NaN". That also breaks every money-formatting chain.
- Any library class exposing
getTime(), setTime(), toFixed() or toSorted(), e.g. timers, clocks, fixed-point and vector types. The class form is affected too, not only ES5 prototypes.
Notes
I swept all 185 identifier-named methods of the Date/Number/String/Array/RegExp/Map/Set/Promise/Object/Function/
Boolean/Symbol/Error/ArrayBuffer/TypedArray/WeakMap/BigInt/DataView prototypes. Each was defined as a user method
returning a sentinel and called in these forms: new F().m(), new F().m(2), this.m() inside another prototype
method, var P = F.prototype; P.m = …, F.prototype = { m }, Object.create({ m }), and class K { m() {} } (JS
module). Methods that never run:
| names |
receivers affected |
getDate getDay getFullYear getHours getMilliseconds getMinutes getMonth getSeconds getTime getTimezoneOffset getUTCDate getUTCDay getUTCFullYear getUTCHours getUTCMilliseconds getUTCMinutes getUTCMonth getUTCSeconds and every set* counterpart (setDate … setUTCSeconds, setTime) |
all forms, including class instances and object literals |
toFixed toPrecision toExponential |
all forms |
toSorted toReversed |
all forms |
reduceRight hasOwnProperty propertyIsEnumerable (called with an argument) |
all forms, including class instances |
flatMap toSpliced (called with an argument) |
Object.create({ m }) receivers |
toISOString toDateString toTimeString toUTCString toGMTString, and zero-arg toLocaleString toLocaleDateString toLocaleTimeString |
this.m() inside a prototype method, and any-typed receivers in TS. A plain local in a JS module and class instances escape. |
endsWith includes startsWith with zero args |
compile error (above), on non-class receivers |
All String-named methods other than those three compile and run correctly. So do Map/Set/Promise/RegExp-named methods.
Suspected locations (inferred from reading the code, not bisected):
crates/perry-hir/src/lower/expr_call/url_date_instance.rs:149: receiver_may_be_date is true unless the static
receiver class is one of URL/Object/Buffer/BlockList/SocketAddress/Uint8Array/Uint8ClampedArray/Array. Unknown
receivers and user classes fall into the Date arms ("getTime" => Expr::DateGetTime, line 231; setters, line 370).
Only toJSON is gated on a proven Date receiver.
crates/perry-codegen/src/lower_call/property_get/number_string.rs:30/46/66: toFixed/toPrecision/toExponential
lower to js_number_to_* for any receiver that is not provably a string or array ("tests often call it on Any locals").
crates/perry-codegen/src/lower_string_method.rs:716: the arity bail! fires before the receiver is known to be a
string.
Related closed issues for the same name-collision class: #7673 (zod .trim() → String.prototype.trim), #5271 (joi
trim), #6065 (charAt/charCodeAt/codePointAt). Sibling audit bug: #10477 (decimal.js/bignumber.js instanceof).
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. A method call whose NAME matches a
Date.prototype,Number.prototypeorsome
Array.prototypemethods is lowered to that built-in by name, whatever the receiver is. The user's method is nevercalled: you get
NaN,[object Object],Invalid DateorRangeError: Invalid time valueinstead.Reproduction
package.json{"name":"repro","private":true,"type":"module"}dayjs_like.js(dayjs 1.11.xtoJSONshape)main.tsnode main.ts perry compile main.ts -o out && ./outExpected (Node 26.5.1)
Actual (Perry)
Same output with and without
PERRY_NO_AUTO_OPTIMIZE=1:A zero-argument call of a user method named
endsWith,includesorstartsWithdoes not even compile.endswith.jsimported from
main.ts:(Node prints
Path.endsWith.)Impact
d.toISOString(),d.toJSON()(esm/index.js:496,return this.isValid() ? this.toISOString() : null) throwRangeError: Invalid time value, soJSON.stringify(dayjs())fails.moment.js:4329toJSON), plus thetoISOStringcall on a moment instance.P.toFixed = function (dp, rm),decimal.js:2025) and bignumber.js 11.1.5 (P.toFixed,dist/bignumber.js:2673):toFixed/toExponential/toPrecisionreturn"NaN". That also breaks every money-formatting chain.getTime(),setTime(),toFixed()ortoSorted(), e.g. timers, clocks, fixed-point and vector types. The class form is affected too, not only ES5 prototypes.Notes
I swept all 185 identifier-named methods of the Date/Number/String/Array/RegExp/Map/Set/Promise/Object/Function/
Boolean/Symbol/Error/ArrayBuffer/TypedArray/WeakMap/BigInt/DataView prototypes. Each was defined as a user method
returning a sentinel and called in these forms:
new F().m(),new F().m(2),this.m()inside another prototypemethod,
var P = F.prototype; P.m = …,F.prototype = { m },Object.create({ m }), andclass K { m() {} }(JSmodule). Methods that never run:
getDate getDay getFullYear getHours getMilliseconds getMinutes getMonth getSeconds getTime getTimezoneOffset getUTCDate getUTCDay getUTCFullYear getUTCHours getUTCMilliseconds getUTCMinutes getUTCMonth getUTCSecondsand everyset*counterpart (setDate…setUTCSeconds,setTime)toFixed toPrecision toExponentialtoSorted toReversedreduceRight hasOwnProperty propertyIsEnumerable(called with an argument)flatMap toSpliced(called with an argument)Object.create({ m })receiverstoISOString toDateString toTimeString toUTCString toGMTString, and zero-argtoLocaleString toLocaleDateString toLocaleTimeStringthis.m()inside a prototype method, andany-typed receivers in TS. A plain local in a JS module and class instances escape.endsWith includes startsWithwith zero argsAll String-named methods other than those three compile and run correctly. So do Map/Set/Promise/RegExp-named methods.
Suspected locations (inferred from reading the code, not bisected):
crates/perry-hir/src/lower/expr_call/url_date_instance.rs:149:receiver_may_be_dateis true unless the staticreceiver class is one of URL/Object/Buffer/BlockList/SocketAddress/Uint8Array/Uint8ClampedArray/Array. Unknown
receivers and user classes fall into the Date arms (
"getTime" => Expr::DateGetTime, line 231; setters, line 370).Only
toJSONis gated on a proven Date receiver.crates/perry-codegen/src/lower_call/property_get/number_string.rs:30/46/66:toFixed/toPrecision/toExponentiallower to
js_number_to_*for any receiver that is not provably a string or array ("tests often call it on Any locals").crates/perry-codegen/src/lower_string_method.rs:716: the aritybail!fires before the receiver is known to be astring.
Related closed issues for the same name-collision class: #7673 (zod
.trim()→String.prototype.trim), #5271 (joitrim), #6065 (charAt/charCodeAt/codePointAt). Sibling audit bug: #10477 (decimal.js/bignumber.jsinstanceof).