Answer untyped property reads of plain values without the class tables (#2013)

This commit is contained in:
Chris Tate
2026-10-23 12:38:38 -05:00
committed by GitHub
8 changed files with 331 additions and 8 deletions
@@ -282,6 +282,11 @@ interface PropertyDispatch {
fn: IrFunction;
classes: Set<string>;
reflect?: boolean;
/** The helper's fallthrough statements as created, and the same access
* applied to the receiver itself: what the fallthrough reduces to for a
* value that is neither a class capsule nor a function, whose bag is the
* value. */
plain?: { tail: readonly IrStmt[]; access: IrStmt[] };
}
export function classInstanceOf(
@@ -2724,7 +2729,28 @@ export class ClassDynamicDispatch {
loc,
});
}
dispatch = { name, write: !!write, fn: helper, classes: new Set() };
const receiverAccess: IrStmt[] = read
? [{ kind: "return", value: { ...read, value: varRef("p.0", DYN, loc) }, loc }]
: [
{
kind: "exprStmt",
expr: {
...write!,
args: [varRef("p.0", DYN, loc), write!.args[1]!, varRef("p.1", DYN, loc)],
},
loc,
},
{ kind: "return", value: null, loc },
];
dispatch = {
name,
write: !!write,
fn: helper,
classes: new Set(),
...(name !== "constructor" && name !== "toString"
? { plain: { tail: [...helper.body], access: receiverAccess } }
: {}),
};
this.properties.set(key, dispatch);
this.generated.add(helper);
lowerer.liftedFns.push(helper);
@@ -4896,6 +4922,7 @@ export class ClassDynamicDispatch {
);
}
if (lowerer) this.guardClassTables(lowerer);
if (lowerer) this.guardPropertyHelpers(lowerer);
}
/** Each tracked class's bag initializer over a typed receiver (`this`),
@@ -4985,6 +5012,87 @@ export class ClassDynamicDispatch {
}
}
/** Fast exits for the generated property helpers: when every class
* branch tests a capsule key or a class key and the fallthrough is still
* the bag access the helper was created with, a value of another kind
* (a plain object, array or primitive) normalizes to itself, has itself
* as its bag, and so gets the plain access directly, without the
* normalization, bag and static-bag calls. */
private guardPropertyHelpers(lowerer: Lowerer): void {
// Every class table the fallthrough or normalization calls must answer
// a plain value with the value itself: its kind exit is in place.
const exits = (name: string): boolean => {
const helper = lowerer.liftedFnNamed(name);
const first = helper?.body[0];
const param = helper?.params[0]?.localId;
return (
param !== undefined &&
first?.kind === "if" &&
first.cond.kind === "dynTest" &&
first.cond.negated === true &&
first.then.length === 1 &&
first.then[0]!.kind === "return" &&
first.then[0]!.value !== null &&
returnsParam(first.then[0]!.value, param)
);
};
const tables = new Set([
"%dyn.class.properties",
READ_PROPERTIES,
READ_PROPERTIES_OR_EMPTY,
"%dyn.class.normalize",
"%dyn.class.staticProperties",
]);
const callsOnlyGuardedTables = (statements: IrStmt[]): boolean =>
everyStmtList(statements, {
stmt: () => true,
expr: (expression) =>
expression.kind !== "call" || (tables.has(expression.callee) && exits(expression.callee)),
});
for (const dispatch of this.properties.values()) {
const plain = dispatch.plain;
if (!plain) continue;
const fn = dispatch.fn;
const param = fn.params[0]?.localId;
if (param === undefined) continue;
const head = fn.body.length - plain.tail.length;
if (head < 0 || plain.tail.some((stmt, i) => fn.body[head + i] !== stmt)) continue;
let ok = true;
for (const stmt of fn.body.slice(0, head)) {
if (
stmt.kind === "assign" &&
stmt.localId === param &&
stmt.value.kind === "call" &&
stmt.value.callee === "%dyn.class.normalize"
)
continue;
if (stmt.kind === "if" && stmt.else_ === null && branchKind(stmt.cond, param) !== null)
continue;
ok = false;
break;
}
// Class branches never run for a plain value; the rest always does.
if (!ok || !callsOnlyGuardedTables(fn.body.filter((stmt) => stmt.kind !== "if"))) continue;
const loc = fn.loc;
const not = (test: "classCapsule" | "funcKind"): IrExpr => ({
kind: "dynTest",
test,
negated: true,
value: varRef(param, DYN, loc),
type: BOOL,
loc,
});
fn.body.unshift({
kind: "if",
cond: not("classCapsule"),
then: [{ kind: "if", cond: not("funcKind"), then: plain.access, else_: null, loc }],
else_: null,
loc,
});
delete dispatch.plain;
}
}
private normalizedDispatches(): IrFunction[] {
return [
...(this.propertyBag ? [this.propertyBag] : []),
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7792-untyped-property-reads-mixed-receivers.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7793-untyped-key-writes.js"
],
"diags": []
}
+14 -7
View File
@@ -9369,10 +9369,12 @@ bool scr_dyn_in(const ScrDyn *v, const ScrStr *key) {
return false;
}
/* One lookup of the own property decides the assignment: an own property
* is written (or runs its setter, or refuses the write); an absent one
* consults the prototype chain and is then added. */
static void scr_dyn_object_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value, const ScrDyn *receiver) {
for (size_t i = 0; i < recv->v.obj.len; i++) {
ScrDynEntry *entry = &recv->v.obj.entries[i];
if (entry->key_len != key->len || memcmp(entry->key, key->data, key->len) != 0) continue;
ScrDynEntry *entry = scr_dyn_entry(recv, key);
if (entry) {
if (entry->accessor && entry->setter) {
ScrDyn *args[] = {value};
ScrDyn *setter = scr_dyn_retain(entry->setter);
@@ -9388,8 +9390,14 @@ static void scr_dyn_object_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value, con
scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1);
return;
}
scr_dyn_region_store(recv, value);
ScrDyn *old = entry->value;
entry->value = scr_dyn_retain(value);
scr_dyn_release(old);
scr_error_sync_cause(recv, key);
return;
}
if (!scr_dyn_entry(recv, key)) {
{
for (const ScrDyn *current = scr_dyn_proto_next(recv); current;) {
ScrDyn *table = current->kind == SCR_DYN_FUNC ? scr_dyn_fn_properties(current) : NULL;
const ScrDyn *object = table ? table : current;
@@ -9421,7 +9429,7 @@ static void scr_dyn_object_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value, con
break;
}
}
if (recv->non_extensible && !scr_dyn_entry(recv, key)) {
if (recv->non_extensible) {
ScrJsonBuf buffer;
scr_jb_init(&buffer);
scr_jb_puts(&buffer, "Cannot add property ");
@@ -9430,9 +9438,8 @@ static void scr_dyn_object_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value, con
scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&buffer));
return;
}
bool created = scr_dyn_entry(recv, key) == NULL;
scr_dyn_obj_set(recv, key->data, key->len, scr_dyn_retain(value));
if (created && key->len == 5 && memcmp(key->data, "stack", 5) == 0 &&
if (key->len == 5 && memcmp(key->data, "stack", 5) == 0 &&
scr_errdyn_find(recv, false) != SIZE_MAX) {
/* An error's own `stack` exists from construction as a non-enumerable
* data property, so assigning it adds no enumerable key. */
@@ -0,0 +1,111 @@
"use strict";
// One untyped property read site meets class instances (fixed and tracked
// layouts, getters, own properties added later), plain objects, arrays,
// strings, functions, class constructors with statics, null prototypes and
// optional chains. Each receiver kind answers as in Node.
class Status {
constructor() {
this.value = "valid";
}
}
class Lazy {
constructor(key) {
this.key = key;
}
get value() {
return `lazy:${this.key}`;
}
get length() {
return 7;
}
}
class Tracked {
constructor(list) {
list.push(this);
this.value = list.length;
}
static value = "static";
}
function plainFunction() {}
plainFunction.value = "fn";
/** @param {any} target */
function readValue(target) {
return target.value;
}
/** @param {any} target */
function readLength(target) {
return target.length;
}
/** @param {any} target */
function readOptional(target) {
return target?.value;
}
/** @param {any} target @param {unknown} value */
function writeValue(target, value) {
target.value = value;
}
const list = [];
const added = new Status();
writeValue(added, "dirty");
/** @type {any} */
const extra = new Lazy("x");
extra.other = 1;
const nullProto = Object.create(null);
nullProto.value = "bare";
const inherited = Object.create({ value: "from prototype" });
const receivers = [
new Status(),
added,
new Lazy("k"),
extra,
new Tracked(list),
{ value: 1, length: 2 },
{ other: true },
[1, 2, 3],
"text",
42,
true,
plainFunction,
Tracked,
nullProto,
inherited,
];
for (const receiver of receivers) {
console.log(typeof receiver, JSON.stringify(readValue(receiver)), JSON.stringify(readLength(receiver)), JSON.stringify(readOptional(receiver)));
}
console.log(readOptional(null), readOptional(undefined));
for (const bad of [null, undefined]) {
try {
readValue(bad);
} catch (e) {
console.log(e instanceof TypeError, e.message);
}
}
const plain = { value: 0 };
const arr = [];
writeValue(plain, 5);
writeValue(arr, "on array");
writeValue(inherited, "own now");
writeValue(plainFunction, "fn2");
const status = new Status();
writeValue(status, "aborted");
console.log(plain.value, arr.value, Object.keys(inherited), plainFunction.value, status.value, JSON.stringify(status));
try {
writeValue("text", 1);
} catch (e) {
console.log(e instanceof TypeError, e.message);
}
const frozen = Object.freeze({ value: "frozen" });
try {
writeValue(frozen, 2);
} catch (e) {
console.log(e instanceof TypeError);
}
console.log(frozen.value);
+83
View File
@@ -0,0 +1,83 @@
"use strict";
// Untyped property writes through one site: new keys append in order,
// existing keys keep their position, own and inherited setters run,
// getter-only and read-only properties refuse the write, and
// non-extensible objects refuse new keys, as in Node.
/** @param {any} target @param {string} key @param {unknown} value */
function put(target, key, value) {
target[key] = value;
}
/** @param {() => void} fn */
function attempt(label, fn) {
try {
fn();
console.log(label, "ok");
} catch (e) {
console.log(label, e instanceof TypeError ? "TypeError" : String(e));
}
}
const order = {};
for (const key of ["b", "a", "c", "a", "b", "d"]) put(order, key, key.charCodeAt(0));
console.log(JSON.stringify(order), Object.keys(order).join(""));
const log = [];
const withSetter = {
_v: 0,
get v() {
return this._v;
},
set v(value) {
log.push(`own ${value}`);
this._v = value * 2;
},
get onlyGetter() {
return "g";
},
};
put(withSetter, "v", 4);
console.log(withSetter.v, JSON.stringify(log));
attempt("getter-only", () => put(withSetter, "onlyGetter", 1));
const proto = {
/** @param {number} value */
set inherited(value) {
log.push(`inherited ${value} on ${this === child}`);
},
};
Object.defineProperty(proto, "fixed", { value: 1, writable: false });
const child = Object.create(proto);
put(child, "inherited", 7);
attempt("inherited read-only", () => put(child, "fixed", 2));
put(child, "own", 3);
console.log(JSON.stringify(log), Object.keys(child).join(","), child.fixed);
/** @type {any} */
const sealed = { x: 1 };
Object.seal(sealed);
put(sealed, "x", 2);
attempt("sealed add", () => put(sealed, "y", 3));
/** @type {any} */
const closed = { x: 1 };
Object.preventExtensions(closed);
put(closed, "x", 5);
attempt("closed add", () => put(closed, "z", 1));
const frozen = Object.freeze({ x: 1 });
attempt("frozen write", () => put(frozen, "x", 9));
console.log(JSON.stringify(sealed), JSON.stringify(closed), JSON.stringify(frozen));
const readOnly = {};
Object.defineProperty(readOnly, "k", { value: "kept", writable: false, enumerable: true });
attempt("own read-only", () => put(readOnly, "k", "changed"));
console.log(readOnly.k);
const error = new Error("boom");
put(error, "cause", "why");
put(error, "code", "E_X");
console.log(error.cause, error.code);
const wide = {};
for (let i = 0; i < 40; i++) put(wide, `k${i % 25}`, i);
console.log(Object.keys(wide).length, wide.k0, wide.k24, Object.keys(wide).slice(0, 3).join(","));
@@ -0,0 +1 @@
{ "plain": 4500, "san": 5000 }
@@ -0,0 +1 @@
{ "plain": 4300, "san": 4800 }