Support Object.freeze, Object.seal, Object.preventExtensions and Object.defineProperty on class instances

Object.freeze, Object.seal and Object.preventExtensions accept a class
instance whose static type names the class, and Object.isFrozen,
Object.isSealed and Object.isExtensible answer for it as Node does.
Object.defineProperty with a string-literal key changes the value and
the writable, enumerable and configurable attributes of a declared
field, with Node's validation and errors.

Only hierarchies passed to these calls pay for them: they get a
per-instance integrity word and an attribute word per redefined field,
and each write to a declared field checks them first. A write to a
read-only field throws Node's TypeError in strict mode code and leaves
the field unchanged in sloppy mode code; a class field initializer on a
non-extensible instance throws "Cannot define property x, object is not
extensible". The instance's checked view carries the attributes, so
Object.keys, JSON.stringify, util.inspect and getOwnPropertyDescriptor
follow them. Readonly<C> over a class maps to the class.

Through an untyped value, freezing, sealing or preventing extensions of
a class instance throws SC2020 instead of doing nothing. The runtime's
"Cannot redefine property" and "Cannot add property" errors name the
property, as Node does.
This commit is contained in:
Chris Tate
2026-10-19 04:13:47 -05:00
parent ee5008c1e5
commit 91c66e0eba
24 changed files with 1114 additions and 44 deletions
@@ -1 +1 @@
- **Object freezing:** `Object.freeze` and `Object.isFrozen` support plain checked dictionaries and primitives. Freezing is shallow and preserves accessors. Aliased typed records, arrays, proxies, and other native objects cannot be frozen.
- **Object freezing:** `Object.freeze` and `Object.isFrozen` support plain checked dictionaries and primitives. Freezing is shallow and preserves accessors. Aliased typed records, arrays, proxies, and other native objects cannot be frozen. Class instances support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible` when the static type of the argument names the class. 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 accessor descriptor for a declared field throws `SC2020`. Freezing, sealing, or preventing extensions of a class instance held in an untyped value throws `SC2020`. A JavaScript class field that is first created after its instance became non-extensible is added instead of throwing.
@@ -5,10 +5,12 @@ import { streamTypedRefEligible, undefinedArmTag } from "../../ir/analysis.js";
import {
type IrRecordShape,
type IrType,
CLASS_ATTRIBUTES,
DYN_CLASS_PROPERTIES,
RUNTIME_STREAM_CLASSES,
SYMBOL_T,
classDynViewSupported,
classFieldAttributesName,
isClassOwnEnumerableFieldName,
isDirentShape,
isDynTypedRefType,
@@ -766,6 +768,7 @@ export function streamTypedRefMaterializeAdapter(
host.declare(`declare ptr @scr_stream_dyn_view(ptr)`);
B.line(`${out} = call ptr @scr_stream_dyn_view(ptr %p)`);
} else B.line(`${out} = call ptr @scr_dyn_new_obj()`);
const withAttributes = meta.def.fields.some((field) => field.name === CLASS_ATTRIBUTES);
if (meta.def.tracksOwnFields) {
host.declare(`declare ptr @scr_dyn_copy_property_descriptors(ptr, ptr)`);
host.declare(`declare void @scr_dyn_release_v(ptr)`);
@@ -835,9 +838,27 @@ export function streamTypedRefMaterializeAdapter(
const nullable = host.nullableFields.get(t.className, field.name);
if (nullable) fieldValue = nullableFieldUnion(host, B, fieldValue, nullable);
const boxed = host.streamTypedRefBoxValue(B, field.type, fieldValue);
// Attributes copied from the bag do not stop the view taking the value.
const setter = withAttributes ? "scr_dyn_obj_set_field" : "scr_dyn_obj_set";
if (withAttributes)
host.declare(`declare void @scr_dyn_obj_set_field(ptr, ptr, ${host.sizeType}, ptr)`);
B.line(
`call void @scr_dyn_obj_set(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`,
`call void @${setter}(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`,
);
// A field redefined through Object.defineProperty keeps its attributes.
const attributesField = classFieldAttributesName(field.name);
if (meta.def.fields.some((f) => f.name === attributesField)) {
host.declare(`declare void @scr_dyn_class_field_attributes(ptr, ptr, double)`);
const slot = B.tmp(),
bits = B.tmp();
B.line(
`${slot} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${classFieldIndex(meta, attributesField).index}`,
);
B.line(`${bits} = load double, ptr ${slot}`);
B.line(
`call void @scr_dyn_class_field_attributes(ptr ${out}, ptr ${host.internLiteral(field.name)}, double ${bits})`,
);
}
if (nullable) {
host.declare(`declare void @scr_union_release(ptr)`);
B.line(`call void @scr_union_release(ptr ${fieldValue})`);
@@ -920,6 +941,17 @@ export function streamTypedRefMaterializeAdapter(
B.line(`${copied} = call ptr @scr_dyn_copy_property_descriptors(ptr ${out}, ptr ${bag})`);
B.line(`call void @scr_dyn_release_v(ptr ${copied})`);
}
// A frozen, sealed or non-extensible instance's view says so.
if (withAttributes) {
host.declare(`declare void @scr_dyn_class_view_attributes(ptr, double)`);
const slot = B.tmp(),
bits = B.tmp();
B.line(
`${slot} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${classFieldIndex(meta, CLASS_ATTRIBUTES).index}`,
);
B.line(`${bits} = load double, ptr ${slot}`);
B.line(`call void @scr_dyn_class_view_attributes(ptr ${out}, double ${bits})`);
}
B.terminate(`ret ptr ${out}`);
} else if (t.kind === "record") {
const shape = host.recordsById.get(t.shapeId);
@@ -907,6 +907,12 @@ export const LIB_FN_SYMS: Record<string, string> = {
"dyn.objectTag": "scr_dyn_object_tag",
"dyn.freeze": "scr_dyn_freeze",
"dyn.isFrozen": "scr_dyn_is_frozen",
"class.defineField": "scr_class_define_field",
"class.definedBits": "scr_class_defined_bits",
"class.definedValue": "scr_class_defined_value",
"class.descriptorHasValue": "scr_class_descriptor_has_value",
"class.throwReadOnly": "scr_class_throw_read_only",
"class.throwNotExtensible": "scr_class_throw_not_extensible",
"dyn.nativeSetNew": "scr_dyn_native_set_new",
"dyn.nativeSetIs": "scr_dyn_native_set_is",
"dyn.nativeMapIs": "scr_dyn_native_map_is",
@@ -0,0 +1,416 @@
import * as ts from "../ts7/adapter.js";
import {
BOOL,
CLASS_ATTRIBUTES,
CLASS_FROZEN,
CLASS_NOT_EXTENSIBLE,
CLASS_SEALED,
DYN,
F64,
FIELD_NOT_WRITABLE,
STRING,
VOID,
canConvertToDyn,
canDynCheckTo,
classFieldAttributesName,
isClassOwnEnumerableFieldName,
isDynTypedRefType,
type IrExpr,
type IrFunction,
type IrStmt,
type IrType,
type SrcLoc,
} from "../../ir/ir.js";
import { numLit, strLit, varRef } from "../../ir/build.js";
import { transformStmtList } from "../../ir/traverse.js";
import { locOf } from "../program.js";
import type { Lowerer } from "./lowerer.js";
import type { ClassInfo } from "./lower-classes.js";
import { classPropertiesHelper } from "./class-dynamic-dispatch.js";
import { runtimeRooted } from "./class-descriptors.js";
/* Object.freeze, Object.seal, Object.preventExtensions and
* Object.defineProperty on class instances. Only the hierarchies these calls
* name pay for them: such a hierarchy gets a CLASS_ATTRIBUTES integrity word
* per instance, an attribute word per redefined field, and a check before
* each write to a declared field (guardClassAttributeWrites). The instance's
* checked view carries the attributes, so reflection answers from it. */
type ClassObjectType = Extract<IrType, { kind: "object" }>;
function attributeClass(lowerer: Lowerer, target: IrExpr): ClassInfo | null {
if (!isDynTypedRefType(target.type)) return null;
const info = lowerer.classes.get(target.type.className);
if (!info || runtimeRooted(info) || info.decl == null) return null;
return info;
}
/** Adds a zero-initialized f64 to every class of the hierarchy, at the same
* offset, like the property bag. */
function addHiddenField(info: ClassInfo, name: string): void {
let root = info;
while (root.base && !root.base.def.runtime) root = root.base;
if (root.fields.has(name)) return;
const index = root.def.fields.length;
const add = (current: ClassInfo): void => {
if (!current.fields.has(name)) {
current.fields.set(name, F64);
current.def.fields.splice(index, 0, { name, type: F64 });
}
current.subclasses.forEach(add);
};
add(root);
}
function hiddenFieldGet(receiver: IrExpr, className: string, field: string, loc: SrcLoc): IrExpr {
return { kind: "fieldGet", obj: receiver, className, field, type: F64, loc };
}
/** Object.freeze, Object.seal and Object.preventExtensions of a class
* instance: sets the instance's integrity bits, then applies the operation to
* the properties added to the instance. Answers the instance. */
export function lowerClassIntegrity(
lowerer: Lowerer,
call: ts.CallExpression,
member: "freeze" | "seal" | "preventExtensions",
target: IrExpr,
): IrExpr | null {
const info = attributeClass(lowerer, target);
if (!info) return null;
addHiddenField(info, CLASS_ATTRIBUTES);
const loc = locOf(call);
const type = target.type as ClassObjectType;
const bits =
member === "freeze"
? CLASS_NOT_EXTENSIBLE | CLASS_SEALED | CLASS_FROZEN
: member === "seal"
? CLASS_NOT_EXTENSIBLE | CLASS_SEALED
: CLASS_NOT_EXTENSIBLE;
const slot = lowerer.declareHiddenLocal("%integrityReceiver", type);
const receiver = (): IrExpr => varRef(slot.id, type, loc);
const bag: IrExpr = {
kind: "call",
callee: classPropertiesHelper(lowerer, loc).name,
args: [lowerer.coerceToExpected(receiver(), DYN)],
type: DYN,
loc,
};
const operation = {
freeze: "dyn.freeze",
seal: "dyn.seal",
preventExtensions: "dyn.preventExtensions",
} as const;
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: slot.id, init: target, loc },
{
kind: "fieldSet",
obj: receiver(),
className: type.className,
field: CLASS_ATTRIBUTES,
value: {
kind: "bin",
op: "|",
left: hiddenFieldGet(receiver(), type.className, CLASS_ATTRIBUTES, loc),
right: numLit(bits, loc),
type: F64,
loc,
},
attributesChecked: true,
loc,
},
{
kind: "exprStmt",
expr: { kind: "libCall", fn: operation[member], args: [bag], type: DYN, loc },
loc,
},
],
result: receiver(),
type,
loc,
};
}
/** Object.defineProperty with a string-literal key naming a declared field
* of a class instance: a data descriptor changes the field's value and
* attributes, validated as Node validates ordinary objects. Answers the
* instance, or null when the call is not of that form. */
export function lowerClassFieldDefine(
lowerer: Lowerer,
call: ts.CallExpression,
target: IrExpr,
): IrExpr | null {
const info = attributeClass(lowerer, target);
const key = call.arguments[1]!;
if (!info || !ts.isStringLiteral(key)) return null;
const field = key.text;
const fieldType = info.fields.get(field);
if (
fieldType === undefined ||
!isClassOwnEnumerableFieldName(field) ||
[...(info.symbolFields?.values() ?? [])].includes(field)
)
return null;
const getRecord = (id: string) => lowerer.shapes.get(id);
const getUnion = (id: string) => lowerer.unions.get(id);
if (
!canConvertToDyn(fieldType, getRecord, getUnion) ||
!canDynCheckTo(fieldType, getRecord, getUnion)
)
return null;
const attributesField = classFieldAttributesName(field);
addHiddenField(info, CLASS_ATTRIBUTES);
addHiddenField(info, attributesField);
const loc = locOf(call);
const type = target.type as ClassObjectType;
const receiverSlot = lowerer.declareHiddenLocal("%defineReceiver", type);
const descriptorSlot = lowerer.declareHiddenLocal("%defineDescriptor", DYN);
const holderSlot = lowerer.declareHiddenLocal("%defineHolder", DYN);
const receiver = (): IrExpr => varRef(receiverSlot.id, type, loc);
const descriptor = (): IrExpr => varRef(descriptorSlot.id, DYN, loc);
const holder = (): IrExpr => varRef(holderSlot.id, DYN, loc);
const name = (): IrExpr => strLit(field, loc);
const current: IrExpr = {
kind: "fieldGet",
obj: receiver(),
className: type.className,
field,
type: fieldType,
loc,
};
const defined: IrExpr = {
kind: "libCall",
fn: "class.definedValue",
args: [holder(), name()],
type: DYN,
loc,
};
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: receiverSlot.id, init: target, loc },
{
kind: "varDecl",
localId: descriptorSlot.id,
init: lowerer.lowerExprExpecting(call.arguments[2]!, DYN),
loc,
},
{
kind: "varDecl",
localId: holderSlot.id,
init: {
kind: "libCall",
fn: "class.defineField",
args: [
lowerer.coerceToExpected(current, DYN),
hiddenFieldGet(receiver(), type.className, attributesField, loc),
hiddenFieldGet(receiver(), type.className, CLASS_ATTRIBUTES, loc),
name(),
descriptor(),
],
type: DYN,
loc,
},
loc,
},
{
kind: "if",
cond: {
kind: "libCall",
fn: "class.descriptorHasValue",
args: [descriptor()],
type: BOOL,
loc,
},
then: [
{
kind: "fieldSet",
obj: receiver(),
className: type.className,
field,
value: lowerer.coerceToExpected(defined, fieldType),
attributesChecked: true,
loc,
},
],
else_: null,
loc,
},
{
kind: "fieldSet",
obj: receiver(),
className: type.className,
field: attributesField,
value: {
kind: "libCall",
fn: "class.definedBits",
args: [holder(), name()],
type: F64,
loc,
},
attributesChecked: true,
loc,
},
],
result: receiver(),
type,
loc,
};
}
function classDisplayName(info: ClassInfo): string {
return info.def.jsName ?? info.decl?.name?.text ?? info.def.name.replace(/^%/, "");
}
/** The instance's own class name for an error message: a helper testing the
* most derived classes of the hierarchy first. */
function instanceClassName(
lowerer: Lowerer,
info: ClassInfo,
receiver: IrExpr,
helpers: Map<string, IrFunction>,
loc: SrcLoc,
): IrExpr {
if (info.subclasses.length === 0) return strLit(classDisplayName(info), loc);
let helper = helpers.get(info.def.name);
if (!helper) {
const type: IrType = { kind: "object", className: info.def.name };
const value = varRef("value", type, loc);
const descendants = (current: ClassInfo): ClassInfo[] =>
current.subclasses.flatMap((child) => [...descendants(child), child]);
const body: IrStmt[] = descendants(info).map((child): IrStmt => ({
kind: "if",
cond: { kind: "instanceOf", value, className: child.def.name, type: BOOL, loc },
then: [{ kind: "return", value: strLit(classDisplayName(child), loc), loc }],
else_: null,
loc,
}));
body.push({ kind: "return", value: strLit(classDisplayName(info), loc), loc });
helper = {
name: `%class.instanceName.${helpers.size}`,
params: [{ localId: "value", name: "value", type }],
returnType: STRING,
locals: [{ id: "value", name: "value", type, mutable: false }],
body,
loc,
};
helpers.set(info.def.name, helper);
}
return { kind: "call", callee: helper.name, args: [receiver], type: STRING, loc };
}
/** Checks the integrity word, and the field's attribute word when it has one,
* before every write to a declared field of a hierarchy that has them. A
* read-only field throws Node's TypeError in strict mode code; a sloppy-mode
* write leaves it unchanged. Other classes keep their plain stores. */
export function guardClassAttributeWrites(lowerer: Lowerer, functions: IrFunction[]): void {
const guarded = new Set(
[...lowerer.classes.values()]
.filter((info) => info.def.fields.some((field) => field.name === CLASS_ATTRIBUTES))
.map((info) => info.def.name),
);
if (guarded.size === 0) return;
const helpers = new Map<string, IrFunction>();
for (const fn of functions) {
const local = (type: IrType, loc: SrcLoc): Extract<IrExpr, { kind: "varRef" }> => {
const id = `%attributeWrite.${fn.locals.length}`;
fn.locals.push({ id, name: id, type, mutable: false });
return { kind: "varRef", localId: id, type, loc };
};
fn.body = transformStmtList(fn.body, {
stmt: (statement) => {
if (
statement.kind !== "fieldSet" ||
statement.attributesChecked ||
!guarded.has(statement.className) ||
!isClassOwnEnumerableFieldName(statement.field)
)
return statement;
const info = lowerer.classes.get(statement.className)!;
const loc = statement.loc;
const receiver = local(statement.obj.type, loc);
const value = local(statement.value.type, loc);
const masked = (field: string, bit: number): IrExpr => ({
kind: "bin",
op: "&",
left: hiddenFieldGet(receiver, statement.className, field, loc),
right: numLit(bit, loc),
type: F64,
loc,
});
const attributesField = classFieldAttributesName(statement.field);
let readOnly = masked(
CLASS_ATTRIBUTES,
statement.define ? CLASS_NOT_EXTENSIBLE : CLASS_FROZEN,
);
if (!statement.define && info.fields.has(attributesField)) {
readOnly = {
kind: "bin",
op: "|",
left: readOnly,
right: masked(attributesField, FIELD_NOT_WRITABLE),
type: F64,
loc,
};
}
const reject: IrStmt[] = statement.define
? [
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "class.throwNotExtensible",
args: [strLit(statement.field, loc)],
type: VOID,
loc,
},
loc,
},
]
: statement.sloppy
? []
: [
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "class.throwReadOnly",
args: [
strLit(statement.field, loc),
instanceClassName(lowerer, info, receiver, helpers, loc),
],
type: VOID,
loc,
},
loc,
},
];
return {
kind: "block",
body: [
{ kind: "varDecl", localId: receiver.localId, init: statement.obj, loc },
{ kind: "varDecl", localId: value.localId, init: statement.value, loc },
{
kind: "if",
cond: {
kind: "bin",
op: "!==",
left: readOnly,
right: numLit(0, loc),
type: BOOL,
loc,
},
then: reject,
else_: [{ ...statement, obj: receiver, value, attributesChecked: true }],
loc,
},
],
loc,
};
},
expr: (expression) => expression,
});
}
for (const helper of helpers.values()) functions.push(helper);
}
@@ -23,7 +23,7 @@ function bagOnlyProperty(owner: ClassInfo, name: string): boolean {
return !owner.fields.has(name) && owner.subclasses.every((child) => bagOnlyProperty(child, name));
}
function runtimeRooted(info: ClassInfo): boolean {
export function runtimeRooted(info: ClassInfo): boolean {
for (let current: ClassInfo | null = info; current; current = current.base) {
if (
current.def.runtime ||
@@ -215,6 +215,7 @@ import {
} from "./optional-arguments.js";
import { fenceSymbolFieldCopy } from "./symbol-fields.js";
import { lowerClassDataDescriptor, lowerClassDescriptorRead } from "./class-descriptors.js";
import { lowerClassFieldDefine, lowerClassIntegrity } from "./class-attributes.js";
import { classStaticDataFor } from "./class-static-data.js";
import { lowerUrlFactory } from "./lower-url.js";
import { nullishValueUnitOf } from "./binding-analysis.js";
@@ -7415,6 +7416,8 @@ function lowerObjectStaticCall(
if (target && declaredTarget && isDynTypedRefType(declaredTarget))
target = lowerer.coerceToExpected(target, declaredTarget);
if (target && member === "defineProperty") {
const field = lowerClassFieldDefine(lowerer, call, target);
if (field) return field;
const native = lowerClassDataDescriptor(lowerer, call, member, target);
if (native) return native;
}
@@ -7557,6 +7560,19 @@ function lowerObjectStaticCall(
loc,
};
}
if (member === "preventExtensions" || member === "seal") {
const integrity = lowerClassIntegrity(lowerer, call, member, value);
if (integrity) return integrity;
} else if (isDynTypedRefType(value.type)) {
// The instance's checked view carries its integrity state.
return {
kind: "libCall",
fn: member === "isSealed" ? "dyn.isSealed" : "dyn.isExtensible",
args: [lowerer.coerceToExpected(value, DYN)],
type: BOOL,
loc: locOf(call),
};
}
if (value.type.kind === "dyn") {
const operation = {
preventExtensions: "dyn.preventExtensions",
@@ -7627,6 +7643,8 @@ function lowerObjectStaticCall(
)
)
return value;
const integrity = lowerClassIntegrity(lowerer, call, "freeze", value);
if (integrity) return integrity;
lowerer.noLowering(
"Object.freeze of a possibly-aliased value",
call,
@@ -7758,6 +7758,7 @@ export function fieldInitStmts(lowerer: Lowerer, info: ClassInfo, thisLocal: IrL
className: info.def.name,
field: f.name,
value: dynUndefinedExpr(loc),
define: true,
loc,
});
}
@@ -7773,6 +7774,7 @@ export function fieldInitStmts(lowerer: Lowerer, info: ClassInfo, thisLocal: IrL
className: info.def.name,
field: f.name,
value,
define: true,
loc: locOf(f.initializer),
});
} catch (e) {
@@ -64,6 +64,7 @@ import {
maybePromiseInstanceOf,
maybePromiseUnionOf,
} from "./maybe-promise.js";
import { isStrictCode } from "./strict-mode.js";
import { checkedClassAssertion, checkedClassUnionAssertion } from "./class-assertions.js";
import { checkedArmExtraction } from "./coercions/unions.js";
import { narrowClassUnion, narrowStoredClassValue } from "./class-unions.js";
@@ -16149,6 +16150,7 @@ export function fieldSetStmt(
className: target.className,
field: target.field,
value,
...(isStrictCode(blame) ? {} : { sloppy: true as const }),
loc,
}
: {
@@ -20,6 +20,7 @@ import {
type SrcLoc,
canConvertToDyn,
canDynCheckTo,
CLASS_FIELD_ATTRIBUTES_PREFIX,
classDynViewSupported,
DYN_CLASS_PROPERTIES,
isClassOwnEnumerableFieldName,
@@ -499,12 +500,16 @@ function classInspectsThroughView(lowerer: Lowerer, info: ClassInfo | undefined)
);
}
/** Whether a class holds properties added outside its declared layout, which
* only its checked-dynamic view can list. Decided after lowering, when the
/** Whether a class holds properties added outside its declared layout, or
* fields Object.defineProperty can make non-enumerable, which only its
* checked-dynamic view lists as Node does. Decided after lowering, when the
* class has its final fields. */
function classInspectsAddedProperties(lowerer: Lowerer, info: ClassInfo): boolean {
return (
info.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES) &&
info.def.fields.some(
(field) =>
field.name === DYN_CLASS_PROPERTIES || field.name.startsWith(CLASS_FIELD_ATTRIBUTES_PREFIX),
) &&
classDynViewSupported(
info.def.fields,
(id) => lowerer.shapes.get(id),
@@ -85,10 +85,10 @@ import {
isCjsJsFile,
isEsModuleStamp,
isJsSourceFile,
isNodeEsmFile,
locOf,
requireSpecOf,
} from "../program.js";
import { isStrictCode } from "./strict-mode.js";
import {
COMPOUND_ASSIGN_OPS,
type CompoundOp,
@@ -5879,35 +5879,6 @@ export function lowerTry(lowerer: Lowerer, stmt: ts.TryStatement): IrStmt {
};
}
function hasUseStrictDirective(statements: readonly ts.Statement[]): boolean {
for (const statement of statements) {
if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression))
return false;
if (statement.expression.text === "use strict") return true;
}
return false;
}
function isStrictDelete(node: ts.DeleteExpression): boolean {
for (let parent: ts.Node | undefined = node.parent; parent; parent = parent.parent) {
if (ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) return true;
if (
ts.isFunctionLike(parent) &&
parent.body &&
ts.isBlock(parent.body) &&
hasUseStrictDirective(parent.body.statements)
)
return true;
if (ts.isSourceFile(parent)) {
return (
hasUseStrictDirective(parent.statements) ||
(!isCjsJsFile(parent) && (isNodeEsmFile(parent) || ts.isExternalModule(parent)))
);
}
}
return false;
}
/** Statement-position `delete`: process.env keys → process.envUnset
* (unsetenv), pure `Record<string, T>` keys → recordKeyDelete (the
* overflow Map delete), declared OPTIONAL fields → the field's ABSENT
@@ -6001,7 +5972,7 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt
obj = lowerer.coerceToExpected(obj, DYN);
}
if (obj.type.kind === "dyn") {
const strict = { kind: "boolLit", value: isStrictDelete(expr), type: BOOL, loc } as const;
const strict = { kind: "boolLit", value: isStrictCode(expr), type: BOOL, loc } as const;
if (ts.isElementAccessExpression(target)) {
const key = lowerer.lowerExprExpecting(target.argumentExpression, DYN);
return {
@@ -112,6 +112,7 @@ import { InternalCompilerError } from "../../errors.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "./optional-arguments.js";
import { type ProtocolDispatch } from "./class-protocol-dispatch.js";
import { ClassDynamicDispatch, trackClassFieldCreation } from "./class-dynamic-dispatch.js";
import { guardClassAttributeWrites } from "./class-attributes.js";
import { ClassProtocols } from "./class-protocols.js";
import { earlyReadVarSymbols } from "./var-hoisting.js";
import { recordTextCodecClass } from "../../ir/ir.js";
@@ -6772,6 +6773,7 @@ export class Lowerer {
if (this.diags.length === 0) fuseArrayPipelines(this, functions);
finalizeClassMethodValues(this, functions);
trackClassFieldCreation(this, functions);
guardClassAttributeWrites(this, functions);
// Globals typed by a class that never REGISTERED (a JS class whose
// collection fenced — Symbol-keyed fields, an unsupported base): the
@@ -0,0 +1,34 @@
import * as ts from "../ts7/adapter.js";
import { isCjsJsFile, isNodeEsmFile } from "../program.js";
function hasUseStrictDirective(statements: readonly ts.Statement[]): boolean {
for (const statement of statements) {
if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression))
return false;
if (statement.expression.text === "use strict") return true;
}
return false;
}
/** Whether `node` runs in strict mode code: inside a class body, a function
* with a "use strict" directive, a module, or a script that starts with
* "use strict". CommonJS JavaScript is sloppy otherwise. */
export function isStrictCode(node: ts.Node): boolean {
for (let parent: ts.Node | undefined = node.parent; parent; parent = parent.parent) {
if (ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) return true;
if (
ts.isFunctionLike(parent) &&
parent.body &&
ts.isBlock(parent.body) &&
hasUseStrictDirective(parent.body.statements)
)
return true;
if (ts.isSourceFile(parent)) {
return (
hasUseStrictDirective(parent.statements) ||
(!isCjsJsFile(parent) && (isNodeEsmFile(parent) || ts.isExternalModule(parent)))
);
}
}
return false;
}
@@ -6,6 +6,7 @@ import type { IrRecordShape, IrType, IrUnionDef, IrUnionDiscriminant } from "../
import {
BYTES_ELEMENT_NAME,
arrayOf,
isDynTypedRefType,
BOOL,
bytesOf,
canConvertToDyn,
@@ -1511,6 +1512,10 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
contextResolutions++;
return DYN;
}
// Readonly<C> over a program class (Object.freeze's result type) is the
// same instance.
const readonlyClass = readonlyClassType(type, ctx);
if (readonlyClass !== null) return readonlyClass;
if (resolveTypeParam && type.flags & ts.TypeFlags.TypeParameter) {
const bound = resolveTypeParam(type);
if (bound) {
@@ -5111,6 +5116,19 @@ function mentionsMappedParams(
return false;
}
function readonlyClassType(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
const alias = type.getAliasSymbol();
if (alias?.name !== "Readonly") return null;
const args = type.getAliasTypeArguments();
if (args.length !== 1 || !args[0]) return null;
const isLibAlias = ctx.checker
.declarationsOf(alias)
.some((d) => ts.isTypeAliasDeclaration(d) && ctx.isStdlibFile(d.getSourceFile()));
if (!isLibAlias) return null;
const mapped = mapType(args[0], ctx);
return mapped !== null && isDynTypedRefType(mapped) ? mapped : null;
}
function mapGenericUtilityAlias(widened: ts.Type, ctx: TypeMapperCtx): IrType | null {
const { resolveTypeParam, shapes, unions } = ctx;
if (!resolveTypeParam) return null;
@@ -577,6 +577,9 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"dyn.mapSeedEntry",
"dyn.freeze",
"dyn.isFrozen",
"class.defineField",
"class.throwReadOnly",
"class.throwNotExtensible",
"dyn.toString",
"dyn.valueOf",
"string.fromCodePoint",
@@ -202,6 +202,23 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"dyn.in": { argTypes: [DYN, STRING], result: BOOL },
"dyn.freeze": { argTypes: [DYN], result: DYN },
"dyn.isFrozen": { argTypes: [DYN], result: BOOL },
/** Object.defineProperty on a declared class field (scr_json.c): the
* field's current value (borrowed), its FIELD_* attribute word, the
* instance's CLASS_* integrity word, the field name and the descriptor.
* Validates as Node does (TypeError "Cannot redefine property: f") and
* answers a one-property holder of the defined value and attributes (+1),
* read back with class.definedBits and class.definedValue. */
"class.defineField": { argTypes: [DYN, F64, F64, STRING, DYN], result: DYN },
"class.definedBits": { argTypes: [DYN, STRING], result: F64 },
"class.definedValue": { argTypes: [DYN, STRING], result: DYN },
/** Whether a property descriptor has a `value` member. */
"class.descriptorHasValue": { argTypes: [DYN], result: BOOL },
/** Throws Node's TypeError for a write to a read-only class field: the
* field name and the instance's class name. */
"class.throwReadOnly": { argTypes: [STRING, STRING], result: VOID },
/** Throws Node's TypeError for a class field initializer on a
* non-extensible instance: the field name. */
"class.throwNotExtensible": { argTypes: [STRING], result: VOID },
"dyn.nativeSetNew": { argTypes: [DYN], result: DYN },
"dyn.nativeSetIs": { argTypes: [DYN], result: BOOL },
"dyn.nativeMapIs": { argTypes: [DYN], result: BOOL },
+38 -2
View File
@@ -1896,8 +1896,23 @@ export type IrStmt =
| { kind: "return"; value: IrExpr | null; awaited?: true; loc: SrcLoc }
/** Field write `obj.f = v` — statement-only, like `assign`/`arraySet`.
* Evaluation order: obj, then value. The old value is released; ownership
* of a refcounted new value MOVES into the object. */
| { kind: "fieldSet"; obj: IrExpr; className: string; field: string; value: IrExpr; loc: SrcLoc }
* of a refcounted new value MOVES into the object. `sloppy` marks a write
* in sloppy-mode code, which skips a read-only field instead of throwing.
* `define` marks a class field initializer, which defines the property
* (a non-extensible instance rejects it). `attributesChecked` marks a
* write that already honors the instance's property attributes
* (CLASS_ATTRIBUTES). */
| {
kind: "fieldSet";
obj: IrExpr;
className: string;
field: string;
value: IrExpr;
sloppy?: true;
define?: true;
attributesChecked?: true;
loc: SrcLoc;
}
/** Record field write `r.f = v` — mirrors `fieldSet` exactly (evaluation
* order obj then value; old value released; refcounted new value moved
* in), with a shape id in place of a class name. */
@@ -4048,6 +4063,27 @@ export function isClassOwnEnumerableFieldName(name: string): boolean {
/** Compiler-owned storage for properties added through untyped references. */
export const DYN_CLASS_PROPERTIES = "%dynProperties";
/** Compiler-owned integrity state of a class instance whose hierarchy is
* passed to Object.freeze, Object.seal, Object.preventExtensions or
* Object.defineProperty: an f64 holding CLASS_NOT_EXTENSIBLE, CLASS_SEALED
* and CLASS_FROZEN bits. Other hierarchies have no such field. */
export const CLASS_ATTRIBUTES = "%attributes";
export const CLASS_NOT_EXTENSIBLE = 1;
export const CLASS_SEALED = 2;
export const CLASS_FROZEN = 4;
/** Compiler-owned attributes of one declared field that Object.defineProperty
* changes: an f64 holding FIELD_NOT_WRITABLE, FIELD_NOT_ENUMERABLE and
* FIELD_NOT_CONFIGURABLE bits (zero is an ordinary data property). */
export const CLASS_FIELD_ATTRIBUTES_PREFIX = "%attr:";
export const FIELD_NOT_WRITABLE = 1;
export const FIELD_NOT_ENUMERABLE = 2;
export const FIELD_NOT_CONFIGURABLE = 4;
export function classFieldAttributesName(field: string): string {
return CLASS_FIELD_ATTRIBUTES_PREFIX + field;
}
/** A class capsule can always preserve its exact native identity. Its
* optional property view additionally needs converters in both directions;
* fields such as Maps may remain opaque without preventing the round trip. */
@@ -0,0 +1,84 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../src/index.js";
import type { IrModule } from "../src/ir/ir.js";
async function lowerModule(source: string, file = "main.ts"): Promise<IrModule> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-class-attributes-"));
try {
const entry = join(dir, file);
const outPath = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
return deserializeModule(await readFile(outPath, "utf8"));
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function fieldsOf(mod: IrModule, className: string): string[] {
const cls = (mod.classes ?? []).find(
(c) => c.name === className || c.name.endsWith(`.${className}`),
);
if (!cls) throw new Error(`no class ${className}`);
return cls.fields.map((f) => f.name);
}
function functionsCalling(mod: IrModule, fn: string): string[] {
return mod.functions
.filter((f) => JSON.stringify(f.body).includes(`"fn":"${fn}"`))
.map((f) => f.name);
}
test("only hierarchies passed to an integrity call get attribute state and write checks", async () => {
const mod = await lowerModule(`
export {};
class Point { x = 1; }
class Other { x = 1; }
function movePoint(p: Point): void { p.x = 2; }
function moveOther(o: Other): void { o.x = 2; }
const p = new Point();
Object.freeze(p);
movePoint(p);
moveOther(new Other());
`);
expect(fieldsOf(mod, "Point")).toContain("%attributes");
expect(fieldsOf(mod, "Other")).not.toContain("%attributes");
const checked = functionsCalling(mod, "class.throwReadOnly");
expect(checked).toContain("movePoint");
expect(checked).not.toContain("moveOther");
});
test("a redefined field gets its own attribute word", async () => {
const mod = await lowerModule(`
export {};
class Config { mode = "dev"; level = 1; }
const c = new Config();
Object.defineProperty(c, "mode", { writable: false });
`);
const fields = fieldsOf(mod, "Config");
expect(fields).toContain("%attributes");
expect(fields).toContain("%attr:mode");
expect(fields).not.toContain("%attr:level");
});
test("sloppy-mode writes skip a read-only field without throwing", async () => {
const mod = await lowerModule(
`
class Box { constructor() { this.x = 1; } }
const b = new Box();
Object.freeze(b);
b.x = 2;
`,
"main.cjs",
);
expect(fieldsOf(mod, "Box")).toContain("%attributes");
// Class bodies are strict mode code; the top-level write is not.
const throwing = functionsCalling(mod, "class.throwReadOnly");
expect(throwing.length).toBeGreaterThan(0);
expect(throwing.every((name) => name.includes("constructor"))).toBe(true);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6004-class-instance-integrity.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6005-class-instance-integrity-sloppy.cjs"
],
"diags": []
}
+10
View File
@@ -882,7 +882,17 @@ double scr_dyn_arr_len(const ScrDyn *d);
ScrDyn *scr_dyn_arr_at(const ScrDyn *d, double i);
ScrDyn *scr_dyn_arr_named_get(const ScrDyn *d, const ScrStr *key);
void scr_dyn_obj_set(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *value);
void scr_dyn_obj_set_field(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *value);
ScrDyn *scr_dyn_define_property(ScrDyn *target, ScrDyn *key, ScrDyn *descriptor);
void scr_dyn_class_field_attributes(ScrDyn *view, ScrStr *name, double bits);
void scr_dyn_class_view_attributes(ScrDyn *view, double bits);
ScrDyn *scr_class_define_field(ScrDyn *current, double field_bits, double object_bits, ScrStr *name,
ScrDyn *descriptor);
double scr_class_defined_bits(ScrDyn *holder, ScrStr *name);
ScrDyn *scr_class_defined_value(ScrDyn *holder, ScrStr *name);
bool scr_class_descriptor_has_value(ScrDyn *descriptor);
void scr_class_throw_read_only(ScrStr *field, ScrStr *owner);
void scr_class_throw_not_extensible(ScrStr *field);
ScrDyn *scr_dyn_get_own_property_descriptor(ScrDyn *target, ScrDyn *key);
ScrDyn *scr_dyn_array_proto_call(ScrDyn *target, ScrStr *method, ScrDyn *args);
ScrDyn *scr_dyn_array_prototype(void); /* +1, installs native method adapters */
+194 -5
View File
@@ -1585,8 +1585,12 @@ static void scr_dyn_obj_put(ScrDyn *obj, const char *key, size_t key_len, ScrDyn
}
if (obj->non_extensible) {
scr_dyn_release(value);
static const char message[] = "Cannot add property, object is not extensible";
scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1);
ScrJsonBuf buffer;
scr_jb_init(&buffer);
scr_jb_puts(&buffer, "Cannot add property ");
scr_jb_write(&buffer, key, key_len);
scr_jb_puts(&buffer, ", object is not extensible");
scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer));
return;
}
if (obj->v.obj.len == obj->v.obj.cap) {
@@ -1922,9 +1926,40 @@ ScrDyn *scr_dyn_set_prototype(ScrDyn *object, ScrDyn *prototype) {
return scr_dyn_retain(object);
}
/* A class instance's integrity state lives in its native layout, which only
* statically typed calls can reach (the compiler adds the state to the
* hierarchies they name). Through an untyped value the operation throws. */
static bool scr_dyn_class_integrity_fence(const ScrDyn *object, const char *operation) {
if (object->kind != SCR_DYN_TYPED_REF) return false;
ScrJsonBuf buffer;
scr_jb_init(&buffer);
scr_jb_puts(&buffer, operation);
scr_jb_puts(&buffer, " of a class instance held in an untyped value is not supported yet");
ScrStr *message = scr_jb_finish(&buffer);
scr_throw_error_msg_code(SCR_ERR_ERROR, message->data, message->len, "SC2020");
scr_str_release(message);
return true;
}
/* Integrity queries on a class instance answer from its checked view, which
* carries the instance's attributes (scr_dyn_class_view_attributes). */
static bool scr_dyn_typed_ref_view_answer(ScrDyn *object, bool (*query)(const ScrDyn *),
bool *answer) {
if (object->kind != SCR_DYN_TYPED_REF) return false;
ScrDyn *view = scr_dyn_typed_ref_materialize(object);
*answer = scr_exc_pending() ? false : query(view);
scr_dyn_release(view);
return true;
}
static bool scr_dyn_is_extensible_view(const ScrDyn *view) {
return (view->kind == SCR_DYN_OBJ || view->kind == SCR_DYN_ARR) && !view->non_extensible;
}
ScrDyn *scr_dyn_prevent_extensions(ScrDyn *object) {
if (scr_dyn_class_reflection_fence(object)) return NULL;
if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.preventExtensions"); return NULL; }
if (scr_dyn_class_integrity_fence(object, "Object.preventExtensions")) return NULL;
if (object->kind == SCR_DYN_OBJ || object->kind == SCR_DYN_ARR) {
object->non_extensible = true;
if (object->kind == SCR_DYN_ARR && object->v.arr.properties)
@@ -1936,12 +1971,15 @@ ScrDyn *scr_dyn_prevent_extensions(ScrDyn *object) {
bool scr_dyn_is_extensible(ScrDyn *object) {
if (scr_dyn_class_reflection_fence(object)) return false;
if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.isExtensible"); return false; }
return (object->kind == SCR_DYN_OBJ || object->kind == SCR_DYN_ARR) && !object->non_extensible;
bool answer;
if (scr_dyn_typed_ref_view_answer(object, scr_dyn_is_extensible_view, &answer)) return answer;
return scr_dyn_is_extensible_view(object);
}
ScrDyn *scr_dyn_seal(ScrDyn *object) {
if (scr_dyn_class_reflection_fence(object)) return NULL;
if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.seal"); return NULL; }
if (scr_dyn_class_integrity_fence(object, "Object.seal")) return NULL;
if (object->symbol_properties) scr_dyn_release(scr_dyn_seal(object->symbol_properties));
if (object->kind == SCR_DYN_OBJ) {
object->non_extensible = true;
@@ -1958,6 +1996,8 @@ ScrDyn *scr_dyn_seal(ScrDyn *object) {
bool scr_dyn_is_sealed(const ScrDyn *object) {
if (scr_dyn_class_reflection_fence(object)) return false;
if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.isSealed"); return false; }
bool answer;
if (scr_dyn_typed_ref_view_answer((ScrDyn *)object, scr_dyn_is_sealed, &answer)) return answer;
if (object->symbol_properties && !scr_dyn_is_sealed(object->symbol_properties)) return false;
if (object->kind == SCR_DYN_ARR)
return object->non_extensible && (object->v.arr.len == 0 || object->v.arr.sealed) &&
@@ -4243,6 +4283,25 @@ void scr_dyn_obj_set(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *value
scr_dyn_obj_put(obj, key, key_len, value);
}
/* A class instance's view takes its native field values whatever the
* attributes copied into it (a frozen instance's view is built the same way
* as any other). Consumes value. */
void scr_dyn_obj_set_field(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *value) {
ScrDynEntry *existing = obj->v.obj.index ? scr_dyn_find_entry(obj, key, key_len)
: scr_dyn_find_linear_entry(obj, key, key_len);
if (existing && !existing->accessor) {
scr_dyn_region_store(obj, value);
ScrDyn *old = existing->value;
existing->value = value;
scr_dyn_release(old);
return;
}
bool non_extensible = obj->non_extensible;
obj->non_extensible = false;
scr_dyn_obj_put(obj, key, key_len, value);
obj->non_extensible = non_extensible;
}
static ScrStr *scr_dyn_property_key(const ScrDyn *key) {
return scr_dyn_string_coerce_js(key);
}
@@ -6036,9 +6095,12 @@ ScrDyn *scr_dyn_define_property(ScrDyn *target, ScrDyn *key, ScrDyn *descriptor)
(!accessor && ((!prior->writable && writable) ||
(!prior->writable && value && !scr_dyn_property_same_value(prior->value, value)))))) {
scr_dyn_descriptor_fields_drop(fields);
ScrJsonBuf buffer;
scr_jb_init(&buffer);
scr_jb_puts(&buffer, "Cannot redefine property: ");
scr_jb_write(&buffer, name->data, name->len);
scr_str_release(name);
static const char msg[] = "Cannot redefine property";
scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1);
scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer));
return NULL;
}
if (prior) {
@@ -13864,6 +13926,7 @@ ScrStr *scr_dyn_to_string_method(const ScrDyn *d, const ScrStr *enc, const ScrSt
ScrDyn *scr_dyn_freeze(ScrDyn *value) {
if (scr_dyn_class_reflection_fence(value)) return NULL;
if (scr_dyn_class_integrity_fence(value, "Object.freeze")) return NULL;
if (value->symbol_properties) scr_dyn_release(scr_dyn_freeze(value->symbol_properties));
if (value->kind == SCR_DYN_ARR) {
value->non_extensible = true;
@@ -13891,6 +13954,8 @@ ScrDyn *scr_dyn_freeze(ScrDyn *value) {
bool scr_dyn_is_frozen(const ScrDyn *value) {
if (scr_dyn_class_reflection_fence(value)) return false;
bool answer;
if (scr_dyn_typed_ref_view_answer((ScrDyn *)value, scr_dyn_is_frozen, &answer)) return answer;
if (value->symbol_properties && !scr_dyn_is_frozen(value->symbol_properties)) return false;
if (value->kind == SCR_DYN_ARR)
return scr_dyn_is_sealed(value) && (value->v.arr.len == 0 || value->v.arr.frozen) &&
@@ -13944,3 +14009,127 @@ ScrDyn *scr_caught_to_dyn(const ScrCaught *c) {
return scr_dyn_new_obj();
}
}
/* ── class instance attributes ───────────────────────────────────────────
* A hierarchy passed to Object.freeze, Object.seal, Object.preventExtensions
* or Object.defineProperty keeps an integrity state per instance and an
* attribute word per redefined field (CLASS_* and FIELD_* bits in the
* compiler's ir.ts). The checked view of an instance carries them, so
* reflection over the view answers as Node does. */
enum {
SCR_CLASS_NOT_EXTENSIBLE = 1,
SCR_CLASS_SEALED = 2,
SCR_CLASS_FROZEN = 4,
SCR_FIELD_NOT_WRITABLE = 1,
SCR_FIELD_NOT_ENUMERABLE = 2,
SCR_FIELD_NOT_CONFIGURABLE = 4,
};
void scr_dyn_class_field_attributes(ScrDyn *view, ScrStr *name, double bits) {
unsigned b = (unsigned)bits;
if (b == 0 || view->kind != SCR_DYN_OBJ) return;
ScrDynEntry *entry = scr_dyn_entry(view, name);
if (!entry) return;
if (b & SCR_FIELD_NOT_WRITABLE) entry->writable = false;
if (b & SCR_FIELD_NOT_ENUMERABLE) entry->enumerable = false;
if (b & SCR_FIELD_NOT_CONFIGURABLE) entry->configurable = false;
}
void scr_dyn_class_view_attributes(ScrDyn *view, double bits) {
unsigned b = (unsigned)bits;
if (b == 0 || view->kind != SCR_DYN_OBJ) return;
view->non_extensible = true;
for (size_t i = 0; i < view->v.obj.len; i++) {
ScrDynEntry *entry = &view->v.obj.entries[i];
if (b & (SCR_CLASS_SEALED | SCR_CLASS_FROZEN)) entry->configurable = false;
if ((b & SCR_CLASS_FROZEN) && !entry->accessor) entry->writable = false;
}
}
/* Object.defineProperty on a declared class field. The descriptor applies to
* a one-property object holding the field's current value and attributes,
* so validation and errors match ordinary objects. Answers that object (+1),
* or NULL with a pending exception. */
ScrDyn *scr_class_define_field(ScrDyn *current, double field_bits, double object_bits, ScrStr *name,
ScrDyn *descriptor) {
unsigned f = (unsigned)field_bits, o = (unsigned)object_bits;
ScrDyn *holder = scr_dyn_new_obj();
scr_dyn_obj_set(holder, name->data, name->len, scr_dyn_retain(current));
ScrDynEntry *entry = scr_dyn_entry(holder, name);
entry->writable = !(f & SCR_FIELD_NOT_WRITABLE) && !(o & SCR_CLASS_FROZEN);
entry->enumerable = !(f & SCR_FIELD_NOT_ENUMERABLE);
entry->configurable =
!(f & SCR_FIELD_NOT_CONFIGURABLE) && !(o & (SCR_CLASS_SEALED | SCR_CLASS_FROZEN));
ScrDyn *key = scr_dyn_new_str(name);
ScrDyn *defined = scr_dyn_define_property(holder, key, descriptor);
scr_dyn_release(key);
if (!defined) {
scr_dyn_release(holder);
return NULL;
}
scr_dyn_release(defined);
entry = scr_dyn_entry(holder, name);
if (entry->accessor) {
scr_dyn_release(holder);
ScrJsonBuf buffer;
scr_jb_init(&buffer);
scr_jb_puts(&buffer, "Object.defineProperty with an accessor descriptor for the declared field '");
scr_jb_write(&buffer, name->data, name->len);
scr_jb_puts(&buffer, "' is not supported yet");
ScrStr *message = scr_jb_finish(&buffer);
scr_throw_error_msg_code(SCR_ERR_ERROR, message->data, message->len, "SC2020");
scr_str_release(message);
return NULL;
}
return holder;
}
/* The field attribute word of a scr_class_define_field result. */
double scr_class_defined_bits(ScrDyn *holder, ScrStr *name) {
const ScrDynEntry *entry = scr_dyn_entry(holder, name);
unsigned bits = 0;
if (!entry->writable) bits |= SCR_FIELD_NOT_WRITABLE;
if (!entry->enumerable) bits |= SCR_FIELD_NOT_ENUMERABLE;
if (!entry->configurable) bits |= SCR_FIELD_NOT_CONFIGURABLE;
return (double)bits;
}
/* The value a scr_class_define_field result holds (+1). */
ScrDyn *scr_class_defined_value(ScrDyn *holder, ScrStr *name) {
return scr_dyn_retain(scr_dyn_entry(holder, name)->value);
}
/* Whether a property descriptor names a value (Node's ToPropertyDescriptor
* reads `value` with [[HasProperty]]). */
bool scr_class_descriptor_has_value(ScrDyn *descriptor) {
if (descriptor->kind == SCR_DYN_TYPED_REF) {
ScrDyn *view = scr_dyn_typed_ref_materialize(descriptor);
bool has = !scr_exc_pending() && scr_class_descriptor_has_value(view);
scr_dyn_release(view);
return has;
}
return descriptor->kind == SCR_DYN_OBJ && scr_dyn_obj_get(descriptor, "value", 5) != NULL;
}
/* Node's TypeError for a write to a read-only field of a class instance. */
void scr_class_throw_read_only(ScrStr *field, ScrStr *owner) {
ScrJsonBuf buffer;
scr_jb_init(&buffer);
scr_jb_puts(&buffer, "Cannot assign to read only property '");
scr_jb_write(&buffer, field->data, field->len);
scr_jb_puts(&buffer, "' of object '#<");
scr_jb_write(&buffer, owner->data, owner->len);
scr_jb_puts(&buffer, ">'");
scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer));
}
/* Node's TypeError for a class field initializer on a non-extensible
* instance (a base constructor froze or sealed `this`). */
void scr_class_throw_not_extensible(ScrStr *field) {
ScrJsonBuf buffer;
scr_jb_init(&buffer);
scr_jb_puts(&buffer, "Cannot define property ");
scr_jb_write(&buffer, field->data, field->len);
scr_jb_puts(&buffer, ", object is not extensible");
scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer));
}
@@ -0,0 +1,169 @@
export {};
import { inspect } from "node:util";
const message = (run: () => void): string => {
try {
run();
return "no error";
} catch (e) {
return `${e instanceof TypeError ? "TypeError" : "Error"}: ${(e as Error).message}`;
}
};
class Point {
x = 1;
y = 2;
label = "p";
move(dx: number): void {
this.x += dx;
}
}
const p = new Point();
console.log(Object.isFrozen(p), Object.isSealed(p), Object.isExtensible(p));
const same = Object.freeze(p);
console.log(same === p, Object.isFrozen(p), Object.isSealed(p), Object.isExtensible(p));
console.log(message(() => {
p.x = 5;
}));
console.log(message(() => p.move(1)));
console.log(p.x, Object.keys(p));
const q = new Point();
q.x = 9;
console.log(q.x, Object.isFrozen(q));
class Point3 extends Point {
z = 3;
}
const p3 = new Point3();
Object.freeze(p3);
console.log(message(() => {
p3.z = 1;
}));
const asBase: Point = new Point3();
Object.freeze(asBase);
console.log(message(() => {
asBase.y = 0;
}));
console.log(asBase.y);
class Counter {
n = 0;
}
const c = Object.seal(new Counter());
c.n = 5;
console.log(c.n, Object.isSealed(c), Object.isFrozen(c), Object.isExtensible(c));
const d = new Counter();
Object.preventExtensions(d);
d.n = 2;
console.log(d.n, Object.isExtensible(d), Object.isSealed(d), Object.isFrozen(d));
const loose: any = d;
console.log(message(() => {
loose.extra = 1;
}));
class Config {
mode = "dev";
level = 1;
}
const cfg = new Config();
Object.defineProperty(cfg, "mode", { value: "prod", writable: false });
console.log(cfg.mode, Object.keys(cfg), Object.values(cfg));
console.log(message(() => {
cfg.mode = "x";
}));
Object.defineProperty(cfg, "level", { enumerable: false });
console.log(Object.keys(cfg), Object.entries(cfg), cfg.level);
Object.defineProperty(cfg, "level", { configurable: false });
console.log(message(() => Object.defineProperty(cfg, "level", { enumerable: true })));
console.log(JSON.stringify(Object.getOwnPropertyDescriptor(cfg, "mode")));
console.log(JSON.stringify(Object.getOwnPropertyDescriptor(cfg, "level")));
cfg.level = 7;
console.log(cfg.level);
const fz = Object.freeze(new Config());
console.log(message(() => Object.defineProperty(fz, "level", { value: 2 })));
console.log(message(() => Object.defineProperty(fz, "level", { value: 1 })));
console.log(fz.level, JSON.stringify(Object.getOwnPropertyDescriptor(fz, "level")));
class Frozen {
a = 1;
constructor() {
Object.freeze(this);
}
}
const frozenInCtor = new Frozen();
console.log(frozenInCtor.a, Object.isFrozen(frozenInCtor));
console.log(message(() => {
frozenInCtor.a = 2;
}));
class Extended extends Frozen {
b = 2;
}
console.log(message(() => new Extended()));
class Empty {}
const empty = new Empty();
console.log(Object.isExtensible(empty), Object.isSealed(empty), Object.isFrozen(empty));
Object.preventExtensions(empty);
console.log(Object.isExtensible(empty), Object.isSealed(empty), Object.isFrozen(empty));
class Totals {
count = 1;
items: string[] = [];
}
const t = new Totals();
Object.freeze(t);
console.log(message(() => {
t.count += 1;
}));
console.log(message(() => {
t.count++;
}));
console.log(message(() => {
({ value: t.count } = { value: 5 });
}));
t.items.push("still mutable");
console.log(t.count, t.items, Object.isFrozen(t.items));
console.log(JSON.stringify(Object.getOwnPropertyDescriptor(t, "count")));
class Holder<T> {
value: T;
constructor(value: T) {
this.value = value;
}
}
const h = Object.freeze(new Holder("text"));
console.log(h.value, Object.isFrozen(h));
const h2 = new Holder(4);
Object.seal(h2);
h2.value = 5;
console.log(h2.value, Object.isSealed(h2));
class Settings {
mode = "dev";
level = 1;
}
const settings = new Settings();
console.log(settings);
Object.defineProperty(settings, "level", { enumerable: false });
console.log(settings, inspect(settings));
console.log([settings], { settings });
class Pair {
x = 1;
y = 2;
}
const pair = new Pair();
Object.freeze(pair);
console.log(pair, Object.isFrozen(pair));
const sealed = new Pair();
(sealed as any).extra = "added";
Object.seal(sealed);
console.log(sealed, Object.isSealed(sealed));
// JSON.stringify reads the same view: non-enumerable fields are skipped.
console.log(JSON.stringify(cfg), JSON.stringify(settings));
console.log(JSON.stringify({ settings, pair, sealed }));
console.log(JSON.stringify([fz, t]));
@@ -0,0 +1,40 @@
class Box {
constructor() {
this.x = 1;
}
bump() {
this.x = 2;
}
}
const b = new Box();
Object.freeze(b);
b.x = 5;
console.log(b.x, Object.isFrozen(b));
function strictWrite() {
"use strict";
try {
b.x = 6;
} catch (e) {
console.log(e instanceof TypeError, e.message);
}
}
strictWrite();
try {
b.bump();
} catch (e) {
console.log("class body:", e.message);
}
console.log(b.x);
const s = new Box();
Object.seal(s);
s.x = 3;
console.log(s.x, Object.isSealed(s), Object.isFrozen(s));
const plain = new Box();
Object.defineProperty(plain, "x", { writable: false });
plain.x = 9;
console.log(plain.x, JSON.stringify(Object.getOwnPropertyDescriptor(plain, "x")));
+4
View File
@@ -2226,6 +2226,8 @@
"6001-json-stringify-js-class-instances.js": 4668,
"6002-inspect-class-added-properties.ts": 6044,
"6003-inspect-js-class-properties.js": 2752,
"6004-class-instance-integrity.ts": 10548,
"6005-class-instance-integrity-sloppy.cjs": 7466,
"601-closures-loops.ts": 1788,
"6010-match-metadata.ts": 3990,
"6011-match-metadata-js.js": 3660,
@@ -5311,6 +5313,8 @@
"6001-json-stringify-js-class-instances.js": 7469,
"6002-inspect-class-added-properties.ts": 9670,
"6003-inspect-js-class-properties.js": 4403,
"6004-class-instance-integrity.ts": 16877,
"6005-class-instance-integrity-sloppy.cjs": 11946,
"601-closures-loops.ts": 2297,
"6010-match-metadata.ts": 5450,
"6011-match-metadata-js.js": 4890,