Keep frozen checked records frozen, and give sloppy-mode this the global object

Typed records checked from frozen untyped values stay frozen, as the
objects they copy are. A sloppy-mode function called with undefined or null
as this receives the global object that globalThis reads.

The record check reads the source's integrity with a query that answers for
every value: a proxy answers as its target, and values without a modeled
integrity level, which Object.freeze refuses, are not frozen. Object.isFrozen
keeps its refusal of those values.
This commit is contained in:
Chris Tate
2026-10-27 23:07:13 -05:00
parent 18a47fedd5
commit 41e7950ea9
19 changed files with 256 additions and 27 deletions
@@ -5,6 +5,7 @@
A write to a read-only field throws Node's `TypeError` in strict mode code and leaves the field unchanged in sloppy mode code.
`Object.defineProperty` with a string-literal key changes the value, `writable`, `enumerable`, and `configurable` attributes of a declared field; an object-literal accessor descriptor (`get` or `set`) for a declared field is refused at compile time with `SC2020`, and another accessor descriptor for a declared field throws `SC2020` when the call runs.
`Object.freeze`, `Object.seal`, and `Object.preventExtensions` of a class instance held in an untyped value change the instance itself, so writes through its typed references are checked the same way.
A typed record checked from a frozen untyped object (an object literal frozen in a JavaScript file, or a frozen `JSON.parse` result) is frozen too, and writes through its typed references are checked the same way.
The `TypeError` for a write to a read-only property that was added to a class instance through an untyped value names `#<Object>` where Node names the class.
Functions support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible`; the operations apply to the function's own properties (`name`, `length`, `prototype`, and the ones a program assigns).
Class constructors are not supported.
@@ -5,3 +5,5 @@
- **JavaScript parameters:** In a JavaScript file TypeScript does not check, a prototype method (`C.prototype.m = function (...) {}`) takes whatever values its callers pass; its JSDoc types document the usual values without constraining them.
A sloppy-mode function with named parameters may read `arguments` only where the aliasing of its parameters cannot show: it reads `arguments.length`, or reads elements and never writes a parameter or an element.
Other uses of `arguments` in such a function are refused with `SC1090`; ES modules, `'use strict'` code, classes, and functions with default, rest, or destructured parameters have no such restriction.
A sloppy-mode function called with `undefined` or `null` as its receiver gets the global object as `this`, as in Node.
Called with a primitive receiver, its `this` is the primitive itself, where Node passes the primitive's wrapper object (`typeof this` is `"object"` there).
+15
View File
@@ -2889,6 +2889,21 @@ export class LlDyn {
B.line(`call void @scr_str_release(ptr ${ek})`);
});
}
// A record built from a frozen object is frozen too, as the object it
// copies (frozen-records.ts makes such shapes freezable).
if (shape.freezable) {
host.declare(`declare zeroext i1 @scr_dyn_checked_record_frozen(ptr)`);
host.declare(`declare void @scr_record_freeze(ptr)`);
const frozen = B.tmp();
const lFreeze = B.newLabel("dcr.freeze");
const lDone = B.newLabel("dcr.frozen");
B.line(`${frozen} = call i1 @scr_dyn_checked_record_frozen(ptr %d)`);
B.condBr(frozen, lFreeze, lDone);
B.startBlock(lFreeze);
B.line(`call void @scr_record_freeze(ptr %r0)`);
B.br(lDone);
B.startBlock(lDone);
}
B.terminate(`ret ptr %r0`);
break;
}
@@ -94,6 +94,7 @@ const INERT_LIB_CALLS = new Set([
"dyn.compare",
// Reads the ambient receiver (a fresh reference to the bound value).
"dyn.this",
// A sloppy-mode function's receiver from it (a fresh reference).
"error.new",
"error.nodeThrow",
"dyn.typedRefIs",
@@ -121,6 +121,8 @@ function freezableTypes(
const shapes = new Set<string>();
const arrays = new Set<string>();
const live: IrType[] = [];
const checked: IrType[] = [];
let freezesUntyped = false;
for (const fn of functions)
everyStmtList(fn.body, {
expr: (e) => {
@@ -130,19 +132,28 @@ function freezableTypes(
} else if (e.kind === "libCall" && e.fn === "array.freeze") {
const target = e.args[0];
if (target && target.type.kind === "array") arrays.add(typeKey(target.type));
} else if (e.kind === "dynFrom") live.push(e.value.type);
} else if (e.kind === "libCall" && e.fn === "dyn.freeze") freezesUntyped = true;
else if (e.kind === "dynFrom") live.push(e.value.type);
else if (e.kind === "dynCheck") checked.push(e.type);
return true;
},
stmt: () => true,
});
if (!lowerer.untypedIntegrity) return { shapes, arrays };
// An untyped reference to a class instance, or to a record or array
// boxed live, reaches the values its fields and elements hold as further
// live references. Class instances also cross into untyped code through
// compiled callbacks, so every class is a root.
const stack = [...live];
for (const info of lowerer.classes.values())
for (const field of info.def.fields) stack.push(field.type);
if (!lowerer.untypedIntegrity && !freezesUntyped) return { shapes, arrays };
const stack: IrType[] = [];
// A record checked from an untyped value copies it, frozen when the value
// is (a fresh literal frozen in a JavaScript file included), so the shapes
// a check builds are freezable in a program that freezes untyped values.
if (freezesUntyped) stack.push(...checked);
if (lowerer.untypedIntegrity) {
// An untyped reference to a class instance, or to a record or array
// boxed live, reaches the values its fields and elements hold as further
// live references. Class instances also cross into untyped code through
// compiled callbacks, so every class is a root.
stack.push(...live);
for (const info of lowerer.classes.values())
for (const field of info.def.fields) stack.push(field.type);
}
const seen = new Set<string>();
while (stack.length > 0) {
const type = stack.pop()!;
@@ -38,6 +38,8 @@ import {
blockBodyOf,
} from "./function-returns.js";
import { generatorMeta, isThisParameter, type ParamShape } from "./call-signatures.js";
import { isStrictCode } from "./strict-mode.js";
import { lowerSloppyGlobalReceiver } from "./lower-global-value.js";
/** Save the call-site receiver before defaults or body effects. Escaping
* arrows capture this binding instead of reading a later call's receiver. */
@@ -48,16 +50,28 @@ function declareCallReceiver(
): IrStmt {
const receiver = lowerer.declareThis(DYN);
const loc = locOf(node);
const bound: IrExpr = {
kind: "libCall",
fn: generator ? "dyn.generatorThis" : "dyn.this",
args: [],
type: DYN,
loc,
};
// A sloppy-mode function's `this` is the global object when it is called
// with undefined or null. The function's own body decides its mode.
const sloppy = !ts.isArrowFunction(node) && node.body !== undefined && !isStrictCode(node.body);
return {
kind: "varDecl",
localId: receiver.id,
init: {
kind: "libCall",
fn: generator ? "dyn.generatorThis" : "dyn.this",
args: [],
type: DYN,
loc,
},
init: sloppy
? {
kind: "nullish",
left: bound,
right: lowerSloppyGlobalReceiver(lowerer, node, loc),
type: DYN,
loc,
}
: bound,
loc,
};
}
@@ -1,5 +1,5 @@
import * as ts from "../ts7/adapter.js";
import { DYN, STRING, arrayOf, type IrExpr } from "../../ir/ir.js";
import { DYN, STRING, arrayOf, type IrExpr, type SrcLoc } from "../../ir/ir.js";
import { locOf } from "../program.js";
import type { Lowerer } from "./lowerer.js";
import { TEMPORAL_GLOBAL } from "../builtin-modules.js";
@@ -8,11 +8,29 @@ import { TEMPORAL_GLOBAL } from "../builtin-modules.js";
* Known globals without a value implementation refuse at runtime; absent host
* capabilities answer undefined. The checker supplies names, never values. */
export function lowerGlobalValue(lowerer: Lowerer, expr: ts.Expression): IrExpr {
const loc = locOf(expr);
return lowerGlobalValueOfType(lowerer, lowerer.typeOf(expr), locOf(expr));
}
/** The global object a sloppy-mode function receives as `this` when it is
* called with undefined or null: the value `globalThis` reads. */
export function lowerSloppyGlobalReceiver(lowerer: Lowerer, node: ts.Node, loc: SrcLoc): IrExpr {
const symbol = lowerer.checker.resolveName(
"globalThis",
ts.SymbolFlags.Value,
ts.getSourceFileOfNode(node),
false,
);
return lowerGlobalValueOfType(
lowerer,
symbol === undefined ? undefined : lowerer.checker.getTypeOfSymbol(symbol),
loc,
);
}
function lowerGlobalValueOfType(lowerer: Lowerer, type: ts.Type | undefined, loc: SrcLoc): IrExpr {
// The temporal global is typed for the programs that name it (TS 7's
// lib slice) but absent in Node 24: reads answer undefined.
const names = lowerer.checker
.getPropertiesOfType(lowerer.typeOf(expr))
const names = (type === undefined ? [] : lowerer.checker.getPropertiesOfType(type))
.map((property) => ts.symbolName(property))
.filter((name) => name !== TEMPORAL_GLOBAL);
return {
@@ -3556,6 +3556,8 @@ function buildLibFnSignatures() {
"process.pid": { argTypes: [], result: F64 },
"dyn.this": { argTypes: [], result: DYN },
"dyn.generatorThis": { argTypes: [], result: DYN },
/** A sloppy-mode function's `this` from its receiver: the global object
* for undefined and null, else the receiver. Never throws. */
"process.getuid": { argTypes: [], result: F64 },
"process.getgid": { argTypes: [], result: F64 },
/** geteuid(2) / getegid(2) / getgroups(2) (the effective gid appended
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/frozen-checked-records-sloppy.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/frozen-checked-records.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/sloppy-this-global.cjs"
],
"diags": []
}
+5
View File
@@ -1095,6 +1095,11 @@ ScrDyn *scr_dyn_freeze(ScrDyn *value);
* the programs that link it. */
ScrDyn *scr_dyn_freeze_data(ScrDyn *value);
bool scr_dyn_is_frozen(const ScrDyn *value);
/* Whether a record checked from `value` is frozen: `value` is. It answers
* for every value, where scr_dyn_is_frozen refuses values without a modeled
* integrity level: those are not frozen, as Object.freeze refuses them. A
* proxy is frozen exactly when its target is (the proxy invariants). */
bool scr_dyn_checked_record_frozen(const ScrDyn *value);
ScrDyn *scr_dyn_new_bigint(ScrBigInt *value); /* borrows and retains value */
ScrDyn *scr_dyn_new_symbol(ScrSym *value); /* borrows and retains value */
ScrSym *scr_sym_well_known(ScrStr *name);
+28 -7
View File
@@ -19210,26 +19210,29 @@ ScrDyn *scr_dyn_freeze_data(ScrDyn *value) {
return NULL;
}
bool scr_dyn_is_frozen(const ScrDyn *value) {
if (scr_dyn_class_reflection_fence(value)) return false;
/* Object.isFrozen of `value` with `self` for the values it contains. A value
* without an integrity level the runtime models refuses with SC2020 when
* `refuse` is set, and otherwise answers false: Object.freeze refuses such
* a value too, so it is never frozen. */
static bool scr_dyn_frozen_answer(const ScrDyn *value, bool (*self)(const ScrDyn *), bool refuse) {
if (scr_dyn_published_object(value)) return true;
bool answer;
if (scr_dyn_typed_ref_view_answer((ScrDyn *)value, scr_dyn_is_frozen, &answer)) return answer;
if (scr_dyn_typed_ref_view_answer((ScrDyn *)value, self, &answer)) return answer;
ScrMap *collection = scr_dyn_native_collection(value);
if (collection) return !scr_map_is_extensible(collection);
if (value->symbol_properties && !scr_dyn_is_frozen(value->symbol_properties)) return false;
if (value->symbol_properties && !self(value->symbol_properties)) return false;
if (scr_dyn_plain_fn(value)) {
ScrDyn *table = scr_dyn_fn_existing_table(value);
answer = table && scr_dyn_is_frozen(table);
answer = table && self(table);
scr_dyn_release(table);
return answer;
}
if (value->kind == SCR_DYN_ARR)
return scr_dyn_is_sealed(value) && (value->v.arr.len == 0 || value->v.arr.frozen) &&
(!value->v.arr.properties || scr_dyn_is_frozen(value->v.arr.properties));
(!value->v.arr.properties || self(value->v.arr.properties));
if (value->kind == SCR_DYN_BYTES)
return (value->v.bytes->is_data_view || value->v.bytes->len == 0) && value->v.bytes->properties &&
scr_dyn_is_frozen(value->v.bytes->properties);
self(value->v.bytes->properties);
if (value->kind == SCR_DYN_OBJ && !value->copied_from_native) {
if (!value->non_extensible) return false;
for (size_t i = 0; i < value->v.obj.len; i++) {
@@ -19241,11 +19244,29 @@ bool scr_dyn_is_frozen(const ScrDyn *value) {
}
if (value->kind == SCR_DYN_NULL || value->kind == SCR_DYN_UNDEF || value->kind == SCR_DYN_NUM ||
value->kind == SCR_DYN_BOOL || value->kind == SCR_DYN_STR || value->kind == SCR_DYN_BIGINT || value->kind == SCR_DYN_SYMBOL) return true;
if (!refuse) return false;
static const char message[] = "Object.isFrozen of this native value has no lowering";
scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020");
return false;
}
bool scr_dyn_is_frozen(const ScrDyn *value) {
if (scr_dyn_class_reflection_fence(value)) return false;
return scr_dyn_frozen_answer(value, scr_dyn_is_frozen, true);
}
/* A record checked from `value` copies its integrity: whether `value` is
* frozen, answered for every value the check can meet. A proxy is frozen
* exactly when its target is (the proxy invariants). Object.prototype and
* class constructors, which Object.isFrozen fences, and native values
* without a modeled integrity level are not frozen. */
bool scr_dyn_checked_record_frozen(const ScrDyn *value) {
while (value && value->kind == SCR_DYN_PROXY) value = value->v.proxy.target;
if (!value || value == scr_builtin_object_prototype) return false;
if (value->kind == SCR_DYN_FUNC && value->v.fn.class_obj && !value->v.fn.class_obj->static_data) return false;
return scr_dyn_frozen_answer(value, scr_dyn_checked_record_frozen, false);
}
double scr_buffer_byte_length_dyn(ScrDyn *value, ScrStr *encoding) {
if (value->kind == SCR_DYN_STR) return scr_bytes_byte_length_str(value->v.str, encoding);
if (value->kind == SCR_DYN_BYTES) return scr_bytes_byte_len(value->v.bytes);
@@ -0,0 +1,37 @@
// Untyped values frozen in sloppy-mode code and then held as typed records
// stay frozen: writes through typed parameters and a typed `this` leave them
// unchanged, as Node does, and Object.isFrozen answers true. An unfrozen value
// checked into the same record type stays writable.
/** @typedef {{ count: number, label: string }} Counter */
/** @param {Counter} c @param {number} by */
function bump(c, by) {
c.count += by;
c.label = c.label + "!";
return c.count;
}
/** @this {Counter} @param {number} by */
function bumpThis(by) {
this.count += by;
this.label = "this";
return this.count;
}
/** @type {Counter} */
const literal = Object.freeze({ count: 1, label: "a" });
console.log(bump(literal, 2), bumpThis.call(literal, 3), JSON.stringify(literal), Object.isFrozen(literal));
/** @type {Counter} */
const parsed = Object.freeze(JSON.parse('{"count":10,"label":"b"}'));
console.log(bump(parsed, 2), bumpThis.call(parsed, 3), JSON.stringify(parsed), Object.isFrozen(parsed));
/** @type {Counter} */
const open = JSON.parse('{"count":100,"label":"c"}');
console.log(bump(open, 2), bumpThis.call(open, 3), JSON.stringify(open), Object.isFrozen(open));
/** @type {Counter[]} */
const list = [Object.freeze({ count: 5, label: "l" }), { count: 6, label: "m" }];
for (const item of list) bump(item, 1);
console.log(JSON.stringify(list), Object.isFrozen(list[0]), Object.isFrozen(list[1]));
+29
View File
@@ -0,0 +1,29 @@
// An untyped value frozen and then held as a typed record stays frozen:
// writes to it in strict mode code throw Node's TypeError and leave it
// unchanged. An unfrozen value checked into the same record type stays
// writable.
type Counter = { count: number; label: string };
function bump(c: Counter, by: number): string {
try {
c.count += by;
return "wrote " + c.count;
} catch (e) {
return e instanceof TypeError ? "TypeError" : "other";
}
}
const frozen = Object.freeze(JSON.parse('{"count":1,"label":"a"}')) as Counter;
console.log(bump(frozen, 2), frozen.count, Object.isFrozen(frozen));
const open = JSON.parse('{"count":10,"label":"b"}') as Counter;
console.log(bump(open, 2), open.count, Object.isFrozen(open));
const raw: unknown = Object.freeze(JSON.parse('{"count":20,"label":"c"}'));
const viaUnknown = raw as Counter;
console.log(bump(viaUnknown, 2), viaUnknown.count, Object.isFrozen(viaUnknown));
// A proxy checked into the record type stays writable.
const proxied = new Proxy({ count: 40, label: "q" }, {}) as Counter;
console.log(bump(proxied, 2), proxied.count);
+52
View File
@@ -0,0 +1,52 @@
// In sloppy-mode functions, `this` is the global object when the function is
// called with undefined or null as its receiver: by a plain call, by a
// detached method, and through call and apply. Strict functions and arrows
// keep the receiver they get.
// Before anything else reads the global object.
function readConsole() {
return typeof this.console.log === "function" && this.console === console;
}
console.log(readConsole(), readConsole.call(null), readConsole.call({ console: 1 }));
function receiver() {
return this;
}
function kind() {
return typeof this;
}
function strictReceiver() {
"use strict";
return this;
}
console.log(receiver() === globalThis, kind());
console.log(receiver.call(undefined) === globalThis, receiver.call(null) === globalThis);
console.log(receiver.apply(undefined, []) === globalThis, receiver.apply(null) === globalThis);
console.log(strictReceiver() === undefined, strictReceiver.call(null) === null);
const holder = {
name: "holder",
self() {
return this;
},
};
const detached = holder.self;
console.log(holder.self() === holder, detached() === globalThis);
function outer() {
function inner() {
return this;
}
return inner() === globalThis;
}
console.log(outer(), outer.call({ name: "o" }));
globalThis.sloppyMarker = 42;
function readMarker() {
return this.sloppyMarker;
}
console.log(readMarker(), readMarker.call(undefined), readMarker.call({ sloppyMarker: 7 }));
const arrow = () => this;
console.log(arrow() === module.exports, arrow.call(null) === module.exports);
@@ -0,0 +1 @@
{ "plain": 3590, "san": 12400 }
@@ -0,0 +1 @@
{ "plain": 2610, "san": 12260 }
@@ -0,0 +1 @@
{ "plain": 3030, "san": 3970 }