From 91c66e0ebabefd13fda03cb12fd1b6a7f40fd337 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 04:13:47 -0500 Subject: [PATCH] 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 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. --- .../builtin-apis/object-freezing.mdx | 2 +- .../src/backend/llvm/expr-stream-bridges.ts | 34 +- .../compiler/src/backend/llvm/lib-shared.ts | 6 + .../src/frontend/lowering/class-attributes.ts | 416 ++++++++++++++++++ .../frontend/lowering/class-descriptors.ts | 2 +- .../src/frontend/lowering/lower-calls.ts | 18 + .../src/frontend/lowering/lower-classes.ts | 2 + .../src/frontend/lowering/lower-exprs.ts | 2 + .../src/frontend/lowering/lower-inspect.ts | 11 +- .../src/frontend/lowering/lower-stmts.ts | 33 +- .../compiler/src/frontend/lowering/lowerer.ts | 2 + .../src/frontend/lowering/strict-mode.ts | 34 ++ packages/compiler/src/frontend/type-mapper.ts | 18 + packages/compiler/src/ir/builtin-effects.ts | 3 + .../compiler/src/ir/builtin-signatures.ts | 17 + packages/compiler/src/ir/ir.ts | 40 +- .../compiler/test/class-attributes.test.ts | 84 ++++ .../6004-class-instance-integrity.ts.json | 6 + ...5-class-instance-integrity-sloppy.cjs.json | 6 + packages/runtime/src/scr_dyn.h | 10 + packages/runtime/src/scr_json.c | 199 ++++++++- tests/corpus/6004-class-instance-integrity.ts | 169 +++++++ .../6005-class-instance-integrity-sloppy.cjs | 40 ++ tests/harness/corpus-costs.json | 4 + 24 files changed, 1114 insertions(+), 44 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/class-attributes.ts create mode 100644 packages/compiler/src/frontend/lowering/strict-mode.ts create mode 100644 packages/compiler/test/class-attributes.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6004-class-instance-integrity.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6005-class-instance-integrity-sloppy.cjs.json create mode 100644 tests/corpus/6004-class-instance-integrity.ts create mode 100644 tests/corpus/6005-class-instance-integrity-sloppy.cjs diff --git a/docs/content/limitations/builtin-apis/object-freezing.mdx b/docs/content/limitations/builtin-apis/object-freezing.mdx index d4939c788..473f21b0e 100644 --- a/docs/content/limitations/builtin-apis/object-freezing.mdx +++ b/docs/content/limitations/builtin-apis/object-freezing.mdx @@ -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. diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 8a53b5fa8..134c74b92 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -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); diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 7fef9ca7c..484ef944d 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -907,6 +907,12 @@ export const LIB_FN_SYMS: Record = { "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", diff --git a/packages/compiler/src/frontend/lowering/class-attributes.ts b/packages/compiler/src/frontend/lowering/class-attributes.ts new file mode 100644 index 000000000..e3f4250a0 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/class-attributes.ts @@ -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; + +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, + 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(); + for (const fn of functions) { + const local = (type: IrType, loc: SrcLoc): Extract => { + 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); +} diff --git a/packages/compiler/src/frontend/lowering/class-descriptors.ts b/packages/compiler/src/frontend/lowering/class-descriptors.ts index 36e280707..bbfbc2c51 100644 --- a/packages/compiler/src/frontend/lowering/class-descriptors.ts +++ b/packages/compiler/src/frontend/lowering/class-descriptors.ts @@ -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 || diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index b3cd7e39b..c91ce3fcd 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -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, diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index d44748084..b3d38c1ad 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -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) { diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 42744212d..c1ad8c429 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -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, } : { diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 37233bcdc..5aeeef9b3 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -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), diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 700f68a2e..252b673da 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -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` 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 { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 2fb266be0..1b1b46174 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -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 diff --git a/packages/compiler/src/frontend/lowering/strict-mode.ts b/packages/compiler/src/frontend/lowering/strict-mode.ts new file mode 100644 index 000000000..a97e32f9e --- /dev/null +++ b/packages/compiler/src/frontend/lowering/strict-mode.ts @@ -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; +} diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 41d18a6a2..617c0efee 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -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 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; diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index fc4c383ff..c4fa209c8 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -577,6 +577,9 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dyn.mapSeedEntry", "dyn.freeze", "dyn.isFrozen", + "class.defineField", + "class.throwReadOnly", + "class.throwNotExtensible", "dyn.toString", "dyn.valueOf", "string.fromCodePoint", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 15b8d1627..cb41dec6a 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -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 }, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 87a4de606..aee26d5ba 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -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. */ diff --git a/packages/compiler/test/class-attributes.test.ts b/packages/compiler/test/class-attributes.test.ts new file mode 100644 index 000000000..1a6fea654 --- /dev/null +++ b/packages/compiler/test/class-attributes.test.ts @@ -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 { + 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); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6004-class-instance-integrity.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6004-class-instance-integrity.ts.json new file mode 100644 index 000000000..4d21236c0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6004-class-instance-integrity.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6004-class-instance-integrity.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6005-class-instance-integrity-sloppy.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6005-class-instance-integrity-sloppy.cjs.json new file mode 100644 index 000000000..1101d2180 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6005-class-instance-integrity-sloppy.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6005-class-instance-integrity-sloppy.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 1ab857e57..92a5b648a 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -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 */ diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 7accb818b..5c8d8858c 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -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)); +} diff --git a/tests/corpus/6004-class-instance-integrity.ts b/tests/corpus/6004-class-instance-integrity.ts new file mode 100644 index 000000000..7df516802 --- /dev/null +++ b/tests/corpus/6004-class-instance-integrity.ts @@ -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 { + 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])); diff --git a/tests/corpus/6005-class-instance-integrity-sloppy.cjs b/tests/corpus/6005-class-instance-integrity-sloppy.cjs new file mode 100644 index 000000000..92c959d34 --- /dev/null +++ b/tests/corpus/6005-class-instance-integrity-sloppy.cjs @@ -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"))); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 733f4b967..1d4f04d42 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -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,