Give Object.groupBy results a null prototype

Object.groupBy now builds its result record with a null prototype,
stored as a flag on the record's overflow map. Inspection prints
"[Object: null prototype]", Object.getPrototypeOf answers null, `in`
finds no inherited names, and String(), template literals, toString(),
hasOwnProperty() and propertyIsEnumerable() throw Node's TypeErrors.
Spread copies and structuredClone keep Object.prototype, as in Node.
Index-signature records converted from an Object.create(null) value
keep the null prototype too.
This commit is contained in:
Chris Tate
2026-10-17 03:35:02 -05:00
parent 0c4056fb0e
commit 844aa87ae0
23 changed files with 411 additions and 20 deletions
+38
View File
@@ -48,6 +48,7 @@ import {
arrNewCall,
elemAccess,
llFieldType,
mapFlagsOffset,
releaseSym,
traceAdapter,
traceArg,
@@ -1872,6 +1873,21 @@ export class LlDyn {
`${ovfp} = getelementptr inbounds %${struct}, ptr %r0, i64 0, i32 ${shape.fields.length + 1}`,
);
B.line(`${ovf} = load ptr, ptr ${ovfp} ; overflow map`);
if (shape.fields.length === 0) {
// Object.create(null)'s dictionary keeps its null prototype:
// ScrDyn.null_proto becomes SCR_MAP_NULL_PROTO.
const npp = B.tmp();
const np = B.tmp();
const flagsp = B.tmp();
B.line(
`${npp} = getelementptr inbounds i8, ptr %d, i64 ${this.abiOffset(13, 9)} ; ->null_proto`,
);
B.line(`${np} = load i8, ptr ${npp}`);
B.line(
`${flagsp} = getelementptr inbounds i8, ptr ${ovf}, i64 ${mapFlagsOffset(host.sizeType)}`,
);
B.line(`store i8 ${np}, ptr ${flagsp}`);
}
const n = this.lenOf(B, "%d");
const entries = this.itemsOf(B, "%d");
this.i64Loop(B, "dcv", n, (i, brNext) => {
@@ -2485,6 +2501,28 @@ export class LlDyn {
B.line(
`${d} = call ptr @scr_dyn_new_obj_with_identity(ptr %v, ptr ${rc.retain}, ptr @${sourceAccessor.name})`,
);
} else if (shape.indexValue && shape.fields.length === 0) {
// A pure index-signature record keeps a null prototype
// (SCR_MAP_NULL_PROTO on its overflow map; Object.groupBy).
host.declare(`declare ptr @scr_dyn_new_obj()`);
host.declare(`declare ptr @scr_dyn_new_obj_null_proto()`);
const ovfp = B.tmp();
const ovf = B.tmp();
const flagsp = B.tmp();
const flags = B.tmp();
const np = B.tmp();
const alloc = B.tmp();
B.line(`${ovfp} = getelementptr inbounds %${struct}, ptr %v, i64 0, i32 1`);
B.line(`${ovf} = load ptr, ptr ${ovfp} ; overflow map`);
B.line(
`${flagsp} = getelementptr inbounds i8, ptr ${ovf}, i64 ${mapFlagsOffset(host.sizeType)}`,
);
B.line(`${flags} = load i8, ptr ${flagsp}`);
B.line(`${np} = icmp ne i8 ${flags}, 0`);
B.line(
`${alloc} = select i1 ${np}, ptr @scr_dyn_new_obj_null_proto, ptr @scr_dyn_new_obj`,
);
B.line(`${d} = call ptr ${alloc}()`);
} else {
host.declare(`declare ptr @scr_dyn_new_obj()`);
B.line(`${d} = call ptr @scr_dyn_new_obj()`);
@@ -378,6 +378,7 @@ import {
type InlineRcMode,
limitInlineDynReleases,
llFieldType,
mapFlagsOffset,
type MapKeyAccess,
releaseSym,
retainSym,
@@ -5687,6 +5688,17 @@ export class LlEmitter {
return v;
}
/** The pointer to ScrMap.flags of an index-signature record's overflow
* map (byte offset sizeof(size_t) + 3; SCR_MAP_NULL_PROTO is bit 0). */
recordMapFlagsPtr(objName: string, shapeId: string): string {
const ovf = this.recordOvfPtr(objName, shapeId);
const p = this.B.tmp();
this.B.line(
`${p} = getelementptr inbounds i8, ptr ${ovf}, i64 ${mapFlagsOffset(this.sizeType)}`,
);
return p;
}
/** Loads a record field (i8-stored bools trunc to i1). An inline union
* field answers a private stack box with the slot's own bits, ABSENT
* included: it may only be borrowed. */
@@ -9350,6 +9362,7 @@ export class LlEmitter {
| "recordKeyGet"
| "recordOvfKeys"
| "recordOvfHas"
| "recordNullProto"
| "recordHas"
| "fieldAbsent"
>,
@@ -74,6 +74,7 @@ export function emitExpr(host: LlvmEmitterContext, e: IrExpr): LlValue {
case "recordKeyGet":
case "recordOvfKeys":
case "recordOvfHas":
case "recordNullProto":
case "recordHas":
case "fieldAbsent":
return host.emitRecordExpr(e);
@@ -939,6 +939,7 @@ export function emitRecordExpr(
| "recordKeyGet"
| "recordOvfKeys"
| "recordOvfHas"
| "recordNullProto"
| "recordHas"
| "fieldAbsent"
>,
@@ -982,6 +983,8 @@ export function emitRecordExpr(
const rec = B.tmp();
B.line(`${rec} = call ptr @${mangleRecordNew(shapeId)}()`);
const out = host.own({ name: rec, type: e.type });
// A null-prototype record: SCR_MAP_NULL_PROTO on the fresh map.
if (e.nullProto) B.line(`store i8 1, ptr ${host.recordMapFlagsPtr(rec, shapeId)}`);
emitRecordLitFields(host, e, rec);
return out;
}
@@ -1026,6 +1029,14 @@ export function emitRecordExpr(
B.line(`${t} = call zeroext i1 @scr_map_has_str(ptr ${ovf}, ptr ${key.name})`);
return { name: t, type: e.type };
}
case "recordNullProto": {
const obj = host.emitExpr(e.obj);
const flags = B.tmp();
const t = B.tmp();
B.line(`${flags} = load i8, ptr ${host.recordMapFlagsPtr(obj.name, e.shapeId)}`);
B.line(`${t} = icmp ne i8 ${flags}, 0`);
return { name: t, type: e.type };
}
case "recordOvfKeys": {
// The overflow map's live keys in JS own-key order — a fresh
// string[] snapshot (+1); the record is borrowed.
@@ -39,12 +39,12 @@ export function scalarRecordShape(
}
/** A literal that stores every field of its shape, none of them through
* the overflow map. */
* the overflow map, and that sets no overflow-map flag. */
export function completeRecordLiteral(
e: IrExpr & { kind: "recordLit" },
shape: IrRecordShape | undefined,
): boolean {
if (!shape || e.fields.some((field) => field.overflow)) return false;
if (!shape || e.nullProto || e.fields.some((field) => field.overflow)) return false;
const names = new Set(e.fields.map((field) => field.name));
return shape.fields.every((field) => names.has(field.name));
}
@@ -556,6 +556,7 @@ export class ReferenceWrites {
case "recordHas":
case "recordKeyGet":
case "recordOvfHas":
case "recordNullProto":
case "recordOvfKeys":
case "fieldAbsent":
case "unionDisc":
@@ -916,6 +916,12 @@ export function mapKeyParamType(access: MapKeyAccess): string {
return access === "bool" ? "i1 zeroext" : mapKeyLlType(access);
}
/** The byte offset of ScrMap.flags (scr_map.h), whose SCR_MAP_NULL_PROTO
* bit marks a null-prototype record's overflow map. */
export function mapFlagsOffset(sizeType: "i32" | "i64"): number {
return (sizeType === "i64" ? 8 : 4) + 3;
}
/** The ScrMapKeyKind / ScrMapValKind constants for scr_map_new. */
export function mapKeyKindNum(key: IrType, unionArms?: IrType[]): number {
if (key.kind === "dyn") return 4;
+1
View File
@@ -1023,6 +1023,7 @@ const OBSERVE = new Set<IrExpr["kind"]>([
"fieldAbsent",
"recordHas",
"recordOvfHas",
"recordNullProto",
"arrayHas",
"arrayState",
"jsonStringify",
@@ -242,9 +242,10 @@ function buildGroupByFn(
{
kind: "varDecl",
localId: "g.0",
// Object.groupBy's result object has a null prototype.
init: isMap
? { kind: "mapNew", type: resultT, loc }
: { kind: "recordLit", fields: [], type: resultT, loc },
: { kind: "recordLit", fields: [], nullProto: true, type: resultT, loc },
loc,
},
{
@@ -1,3 +1,4 @@
import { guardNullProto, nullProtoRecordShape } from "./null-prototype.js";
import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js";
import { lowerArrayCallbackCall } from "./containers/array-callback-call.js";
/** Dispatch source calls, builtin members and receiver-specific operations.
@@ -2411,6 +2412,7 @@ function lowerObjectOwnMethodCall(
call.arguments[0],
call.arguments.slice(1),
method,
access,
);
}
@@ -2448,6 +2450,9 @@ function lowerObjectOwnCall(
keyNode: ts.Expression | undefined,
extraNodes: readonly ts.Expression[],
method: string,
// The `receiver.method` access of a method call (null for the
// Object.prototype.method.call form).
methodAccess: ts.PropertyAccessExpression | null = null,
): IrExpr {
const enumerable = method === "propertyIsEnumerable";
const loc = locOf(call);
@@ -2517,6 +2522,8 @@ function lowerObjectOwnCall(
receiverNode ?? call,
);
}
// A null-prototype record (Object.groupBy) has no such method: Node
// throws after evaluating the arguments.
result = {
kind: "call",
callee: recordHasOwnHelper(lowerer, type.shapeId, loc),
@@ -2524,6 +2531,15 @@ function lowerObjectOwnCall(
type: BOOL,
loc,
};
if (methodAccess && nullProtoRecordShape(lowerer, type)) {
result = guardNullProto(
receiverRef,
type.shapeId,
`${methodAccess.getText()} is not a function`,
result,
loc,
);
}
} else if (type.kind === "dyn") {
result = {
kind: "libCall",
@@ -4126,19 +4142,33 @@ function lowerDefaultToStringCall(
// `(<A>{}).toString()` — assertion-wrapped literals and plain reads
// have nothing to evaluate; pureObjectToStringReceiver widens
// pureReceiverNode with the empty object literal.
if (pureObjectToStringReceiver(access.expression)) return constant;
// A null-prototype record (Object.groupBy) has no toString: Node
// throws "<callee> is not a function".
const nullProtoT = nullProtoRecordShape(lowerer, recvT);
const missing = nullProtoT ? `${access.getText()} is not a function` : null;
if (!missing && pureObjectToStringReceiver(access.expression)) return constant;
const recv = lowerer.lowerExpr(access.expression);
const key = `objToStr:${typeKey(recv.type)}`;
const key = `objToStr:${typeKey(recv.type)}${missing ? `:${missing}` : ""}`;
let helper = lowerer.valueHelpers.get(key);
if (!helper) {
helper = `%obj.tostr.${lowerer.valueHelpers.size}`;
lowerer.valueHelpers.set(key, helper);
const o = varRef("o.0", recv.type, loc);
lowerer.liftedFns.push({
name: helper,
params: [{ localId: "o.0", name: "o", type: recv.type }],
returnType: STRING,
locals: [{ id: "o.0", name: "o", type: recv.type, mutable: false }],
body: [{ kind: "return", value: { ...constant }, loc }],
body: [
{
kind: "return",
value:
nullProtoT && missing
? guardNullProto(o, nullProtoT.shapeId, missing, { ...constant }, loc)
: { ...constant },
loc,
},
],
loc,
});
}
@@ -1,3 +1,4 @@
import { guardNullProto, nullProtoRecordShape, recordNullProtoTest } from "./null-prototype.js";
import { lowerProtocolPropertyRead } from "./class-protocol-dispatch.js";
import { generatorDrain } from "./iterator-adapters.js";
import { lowerEventObjectProperty, lowerEventObjectPropertyWrite } from "./lower-event-target.js";
@@ -8470,6 +8471,10 @@ export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr
const shape = lowerer.shapes.get(e.type.shapeId);
if (isPerfEntryType(lowerer, e.type)) return perfEntryTag(lowerer, e, e.type, e.loc);
if (shape && !shape.tuple && !shape.fields.some((f) => f.name === "toString")) {
// A null-prototype record (Object.groupBy) has no toString or
// valueOf: ToPrimitive throws Node's TypeError.
const nullProtoT = nullProtoRecordShape(lowerer, e.type);
if (nullProtoT) return nullProtoToString(lowerer, e, nullProtoT);
return { kind: "toString", operand: e, type: STRING, loc: e.loc };
}
}
@@ -8477,6 +8482,41 @@ export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr
return { kind: "toString", operand: e, type: STRING, loc: e.loc };
}
/** String(r) over a record that may have a null prototype: an interned
* helper that throws Node's TypeError for a null-prototype record and
* answers "[object Object]" otherwise. */
function nullProtoToString(lowerer: Lowerer, e: IrExpr, t: IrType & { kind: "record" }): IrExpr {
const loc = e.loc;
const key = `nullProtoToString:${t.shapeId}`;
let helper = lowerer.valueHelpers.get(key);
if (!helper) {
helper = `%rec.tostr.${lowerer.valueHelpers.size}`;
lowerer.valueHelpers.set(key, helper);
const r = varRef("r.0", t, loc);
lowerer.liftedFns.push({
name: helper,
params: [{ localId: "r.0", name: "r", type: t }],
returnType: STRING,
locals: [{ id: "r.0", name: "r", type: t, mutable: false }],
body: [
{
kind: "return",
value: guardNullProto(
r,
t.shapeId,
"Cannot convert object to primitive value",
{ kind: "toString", operand: r, type: STRING, loc },
loc,
),
loc,
},
],
loc,
});
}
return { kind: "call", callee: helper, args: [e], type: STRING, loc };
}
/** `new String(x)` (stdlib provenance, ≤1 argument) in a ToString
* position: the wrapper's only distinguishers are typeof and identity —
* neither is observable where the value immediately stringifies — so the
@@ -12975,6 +13015,17 @@ function lowerInExpression(lowerer: Lowerer, expr: ts.BinaryExpression, loc: Src
recv.kind === "fieldGet" ||
pureRecvNode
) {
// A null-prototype record (Object.groupBy) has no inherited names.
const nullProtoT = inherited ? nullProtoRecordShape(lowerer, recv.type) : null;
if (nullProtoT) {
return {
kind: "unary",
op: "!",
operand: recordNullProtoTest(recv, nullProtoT.shapeId, loc),
type: BOOL,
loc,
};
}
return { kind: "boolLit", value: inherited, type: BOOL, loc };
}
lowerer.unsupported(
@@ -13042,19 +13093,33 @@ function lowerRuntimeKeyIn(
type: BOOL,
loc,
};
// A miss in the record's own keys still finds Object.prototype's.
let inherited: IrExpr = ovfHas;
// A miss in the record's own keys still finds Object.prototype's,
// unless the record has a null prototype (Object.groupBy).
let protoName: IrExpr | null = null;
for (const name of OBJECT_PROTOTYPE_KEYS) {
if (shape.fields.some((f) => f.name === name)) continue;
inherited = {
const eq: IrExpr = {
kind: "strEq",
negated: false,
left: k,
right: { kind: "strLit", value: name, type: STRING, loc },
type: BOOL,
loc,
};
protoName =
protoName === null
? eq
: { kind: "logical", op: "||", left: protoName, right: eq, type: BOOL, loc };
}
if (protoName !== null && nullProtoRecordShape(lowerer, recT)) {
protoName = {
kind: "logical",
op: "||",
left: inherited,
op: "&&",
left: protoName,
right: {
kind: "strEq",
negated: false,
left: k,
right: { kind: "strLit", value: name, type: STRING, loc },
kind: "unary",
op: "!",
operand: recordNullProtoTest(r, recvT.shapeId, loc),
type: BOOL,
loc,
},
@@ -13062,7 +13127,13 @@ function lowerRuntimeKeyIn(
loc,
};
}
body.push(ret(inherited));
body.push(
ret(
protoName === null
? ovfHas
: { kind: "logical", op: "||", left: ovfHas, right: protoName, type: BOOL, loc },
),
);
lowerer.liftedFns.push({
name: helper,
params: [
@@ -33,6 +33,7 @@ import { eventTargetRootOf } from "./lower-event-target.js";
import { lowerSystemErrorMap } from "./lower-system-errors.js";
import { isPerfEntryType, perfEntryJson } from "./builtins/performance.js";
import { recordMayKeepSource, recordSourceTest } from "./record-sources.js";
import { nullProtoRecordShape, recordNullProtoTest } from "./null-prototype.js";
/* ── IR construction shorthand ───────────────────────────────────────── */
function concatAll(parts: IrExpr[], loc: SrcLoc): IrExpr {
@@ -815,6 +816,20 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string {
const n = (): IrExpr => varRef("n.0", F64, loc);
const i = (): IrExpr => varRef("i.0", F64, loc);
const k = (): IrExpr => varRef("k.0", STRING, loc);
// A null-prototype record (Object.groupBy) prints Node's
// "[Object: null prototype]" base.
const nullProto = nullProtoRecordShape(lowerer, t) !== null;
const byProto = (plain: string, bare: string): IrExpr =>
nullProto
? {
kind: "ternary",
cond: recordNullProtoTest(v(), t.shapeId, loc),
then: strLit(bare, loc),
else_: strLit(plain, loc),
type: STRING,
loc,
}
: strLit(plain, loc);
const keyedRead = (): IrExpr => ({
kind: "recordKeyGet",
obj: v(),
@@ -847,11 +862,17 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string {
{
kind: "if",
cond: { kind: "bin", op: "===", left: n(), right: numLit(0, loc), type: BOOL, loc },
then: [ret(strLit("{}", loc))],
then: [ret(byProto("{}", "[Object: null prototype] {}"))],
else_: null,
loc,
},
{
kind: "if",
cond: { kind: "bin", op: ">", left: r(), right: d(), type: BOOL, loc },
then: [ret(byProto("[Object]", "[Object: null prototype]"))],
else_: null,
loc,
},
depthGate("[Object]"),
...begin(),
{
kind: "for",
@@ -884,7 +905,15 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string {
],
loc,
},
ret(end(strLit("", loc), strLit("{", loc), strLit("}", loc), false, boolLit(false, loc))),
ret(
end(
byProto("", "[Object: null prototype]"),
strLit("{", loc),
strLit("}", loc),
false,
boolLit(false, loc),
),
),
];
break;
}
@@ -823,6 +823,7 @@ export function moduleArtifacts(
case "recordKeyGet":
case "recordOvfKeys":
case "recordOvfHas":
case "recordNullProto":
case "recordHas":
visitShape(node.shapeId);
break;
@@ -0,0 +1,44 @@
import { nodeThrowExpr } from "../../ir/build.js";
import { BOOL, type IrExpr, type IrType, type SrcLoc } from "../../ir/ir.js";
import type { Lowerer } from "./lowerer.js";
/** Records that can have a null prototype: pure index-signature shapes,
* which Object.groupBy builds with a `nullProto` recordLit. The flag lives
* on the record's overflow map, so other shapes never carry it. */
export function nullProtoRecordShape(
lowerer: Lowerer,
t: IrType,
): (IrType & { kind: "record" }) | null {
if (t.kind !== "record") return null;
const shape = lowerer.shapes.get(t.shapeId);
return shape !== undefined &&
shape.indexValue !== undefined &&
!shape.tuple &&
shape.fields.length === 0
? t
: null;
}
/** `obj` has a null prototype (`obj` is a borrowed, pure read). */
export function recordNullProtoTest(obj: IrExpr, shapeId: string, loc: SrcLoc): IrExpr {
return { kind: "recordNullProto", obj, shapeId, type: BOOL, loc };
}
/** `value`, or Node's TypeError(message) when `obj` has a null prototype
* (`obj` is a borrowed, pure read evaluated before `value`). */
export function guardNullProto(
obj: IrExpr,
shapeId: string,
message: string,
value: IrExpr,
loc: SrcLoc,
): IrExpr {
return {
kind: "ternary",
cond: recordNullProtoTest(obj, shapeId, loc),
then: nodeThrowExpr(1, "", message, value.type, loc),
else_: value,
type: value.type,
loc,
};
}
@@ -86,6 +86,7 @@ const EXPR_FIELDS: Classified<IrExpr> = {
dynIterN: { notIterableMessage: "other" },
recordOvfKeys: { shapeId: "other" },
recordOvfHas: { shapeId: "other" },
recordNullProto: { shapeId: "other" },
recordHas: { shapeId: "other", field: "other" },
fieldAbsent: { unionId: "other" },
unionWrap: { unionId: "other" },
+7
View File
@@ -2964,9 +2964,12 @@ export type IrExpr =
* shape (their values have the shape's indexValue type — dyn included);
* they insert into the overflow map in list order, interleaved with the
* declared writes (one list keeps JS source-order evaluation). */
/** `nullProto` (index-signature shapes only) gives the record a null
* prototype, as Object.groupBy's result has (recordNullProto reads it). */
| {
kind: "recordLit";
fields: { name: string; value: IrExpr; overflow?: true }[];
nullProto?: true;
type: IrType;
loc: SrcLoc;
}
@@ -3268,6 +3271,10 @@ export type IrExpr =
* value. Declared fields are handled separately by the lowering. Both
* operands are borrowed, the key is string, the result is bool. */
| { kind: "recordOvfHas"; obj: IrExpr; shapeId: string; key: IrExpr; type: IrType; loc: SrcLoc }
/** Whether an index-signature record has a null prototype: the flag a
* `nullProto` recordLit sets on its overflow map. The receiver is
* borrowed, the result is bool. Never throws. */
| { kind: "recordNullProto"; obj: IrExpr; shapeId: string; type: IrType; loc: SrcLoc }
/** Own-property presence of a DECLARED record field (`"f" in r`,
* Object.keys/hasOwn and every other key surface). A field whose type
* has no undefined arm is always present; an undefined-armed union field
+4
View File
@@ -300,6 +300,8 @@ export function everyExprChild(
return expr(node.obj);
case "recordOvfHas":
return expr(node.obj) && expr(node.key);
case "recordNullProto":
return expr(node.obj);
case "recordHas":
return expr(node.obj);
case "fieldAbsent":
@@ -652,6 +654,8 @@ export function mapExprChildren(
return { ...node, obj: expr(node.obj) };
case "recordOvfHas":
return { ...node, obj: expr(node.obj), key: expr(node.key) };
case "recordNullProto":
return { ...node, obj: expr(node.obj) };
case "recordHas":
return { ...node, obj: expr(node.obj) };
case "fieldAbsent":
+40
View File
@@ -1467,6 +1467,46 @@ test.each(["receiver", "key", "result"] as const)(
},
);
function nullPrototypeModule(): IrModule {
const record: IrType = { kind: "record", shapeId: "dictionary" };
const check: IrExpr = {
kind: "recordNullProto",
shapeId: "dictionary",
obj: { kind: "recordLit", fields: [], nullProto: true, type: record, loc },
type: BOOL,
loc,
};
const mod = expressionModule(check, []);
mod.records = [{ id: "dictionary", fields: [], indexValue: F64 }];
return mod;
}
test("null-prototype records validate and serialize", () => {
const mod = nullPrototypeModule();
expect(validateModule(mod)).toEqual([]);
expect(deserializeModule(serializeModule(mod))).toEqual(mod);
});
test("null-prototype records require a map-bearing shape", () => {
const mod = nullPrototypeModule();
delete mod.records![0]!.indexValue;
const messages = validateModule(mod).map((error) => error.message);
expect(messages.some((m) => m.includes("recordNullProto on dictionary"))).toBe(true);
expect(messages.some((m) => m.includes("recordLit nullProto on dictionary"))).toBe(true);
});
test.each(["receiver", "result"] as const)("null-prototype tests check their %s type", (slot) => {
const mod = nullPrototypeModule();
const statement = mod.functions[0]!.body[0]!;
if (statement.kind !== "exprStmt" || statement.expr.kind !== "recordNullProto")
throw new Error("fixture");
const check = statement.expr;
if (slot === "receiver") check.obj = { kind: "numLit", value: 1, type: F64, loc };
else check.type = F64;
const message = slot === "result" ? "recordNullProto must be bool" : "recordNullProto receiver";
expect(validateModule(mod).some((error) => error.message.includes(message))).toBe(true);
});
function fieldPresenceModule(absentInLiteral: boolean): IrModule {
const optional: IrType = { kind: "union", unionId: "maybeCount" };
const record: IrType = { kind: "record", shapeId: "item" };
+14
View File
@@ -1364,6 +1364,7 @@ function validateFunction(
case "recordGet":
case "recordKeyGet":
case "recordOvfHas":
case "recordNullProto":
case "recordOvfKeys":
case "recordHas":
case "fieldAbsent":
@@ -3157,6 +3158,7 @@ function validateFunction(
| "recordGet"
| "recordKeyGet"
| "recordOvfHas"
| "recordNullProto"
| "recordOvfKeys"
| "recordHas"
| "fieldAbsent";
@@ -3173,6 +3175,8 @@ function validateFunction(
err(`recordLit of undeclared shape "${e.type.shapeId}"`, e.loc);
break;
}
if (e.nullProto && (!shape.indexValue || shape.tuple))
err(`recordLit nullProto on ${shape.id}: requires an index-signature record`, e.loc);
const want = recordValidation.get(shape.id)!.initializationFields;
const seen = new Set<string>();
for (const f of e.fields) {
@@ -3330,6 +3334,16 @@ function validateFunction(
if (e.type.kind !== "bool") err("recordOvfHas must be bool", e.loc);
break;
}
case "recordNullProto": {
checkExpr(e.obj);
const shape = records.get(e.shapeId);
if (!shape) err(`recordNullProto on undeclared shape "${e.shapeId}"`, e.loc);
else if (!shape.indexValue || shape.tuple)
err(`recordNullProto on ${e.shapeId}: requires an index-signature record`, e.loc);
expectType(e.obj, { kind: "record", shapeId: e.shapeId }, "recordNullProto receiver");
if (e.type.kind !== "bool") err("recordNullProto must be bool", e.loc);
break;
}
case "recordOvfKeys": {
checkExpr(e.obj);
const shape = records.get(e.shapeId);
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5845-groupby-null-prototype.ts"
],
"diags": []
}
+2
View File
@@ -374,6 +374,8 @@ struct ScrDyn {
};
/* Compiled class dispatch reads a capsule's instance and key in place. */
_Static_assert(offsetof(ScrDyn, v.typed_ref.ptr) == 16, "typed_ref.ptr offset");
_Static_assert(offsetof(ScrDyn, null_proto) == sizeof(size_t) + sizeof(ScrDynKind) + 1,
"dyn-to-record conversions read null_proto at byte 13 (9 on wasm32)");
_Static_assert(offsetof(ScrDyn, v.typed_ref.type_key) == 16 + 3 * sizeof(void *),
"typed_ref.type_key offset");
/* The region links ride in payload space the capsule variant already
+8 -1
View File
@@ -91,12 +91,17 @@ typedef struct ScrMapOps {
#define SCR_MAP_TRACED_KEY 1u
#define SCR_MAP_TRACED_VAL 2u
/* ScrMap.flags: SCR_MAP_NULL_PROTO marks the overflow map of a record
* whose object has a null prototype (Object.groupBy results). Compiled
* code sets and tests the byte; the runtime never copies it. */
#define SCR_MAP_NULL_PROTO 1u
typedef struct ScrMap {
size_t rc; /* SIZE_MAX = immortal (unused for maps; kept per convention) */
uint8_t key_kind; /* ScrMapKeyKind */
uint8_t val_kind; /* ScrMapValKind */
uint8_t traced; /* SCR_MAP_TRACED_KEY | SCR_MAP_TRACED_VAL, from ops */
uint8_t reserved;
uint8_t flags; /* SCR_MAP_NULL_PROTO */
uint32_t iter_depth; /* > 0: an iteration is active — no compaction */
const ScrMapOps *ops; /* never NULL */
const ScrMapDynOps *dyn_ops;
@@ -138,6 +143,8 @@ typedef struct ScrMap {
* entry's hash, the third field. */
_Static_assert(offsetof(ScrMap, traced) == sizeof(size_t) + 2,
"inline RC fast paths read ScrMap.traced as %ScrMapRc field 3");
_Static_assert(offsetof(ScrMap, flags) == sizeof(size_t) + 3,
"compiled records read ScrMap.flags at byte offset sizeof(size_t) + 3");
_Static_assert(offsetof(ScrMap, iter_depth) == sizeof(size_t) + 4 &&
offsetof(ScrMap, ops) == sizeof(size_t) + 8 &&
offsetof(ScrMap, dyn_ops) == offsetof(ScrMap, ops) + sizeof(void *),
@@ -0,0 +1,63 @@
// Object.groupBy returns a null-prototype object: inspection prints the
// "[Object: null prototype]" base, getPrototypeOf answers null, `in` finds
// no inherited names, and ToPrimitive and Object.prototype methods throw.
// Copies (spread, structuredClone) and other index-signature records keep
// Object.prototype, and a record converted from Object.create(null) keeps
// its null prototype.
import * as util from "node:util";
const nums = [1, 2, 3, 4, 5];
const g = Object.groupBy(nums, (n) => (n % 2 ? "odd" : "even") as string);
console.log(g);
console.log(util.inspect(g), util.inspect({ nested: { g } }, { depth: 1 }));
console.log(Object.getPrototypeOf(g) === null);
console.log("toString" in g, "constructor" in g, "hasOwnProperty" in g);
const names = ["odd", "even", "valueOf", "__proto__", "none"];
for (const name of names) console.log(name, name in g);
const empty = Object.groupBy([] as number[], (n) => String(n));
console.log(empty, Object.keys(empty).length, util.inspect([empty]));
const attempt = (label: string, f: () => unknown): void => {
try {
console.log(label, f());
} catch (e) {
console.log(label, (e as Error).name, (e as Error).message);
}
};
attempt("String", () => String(g));
attempt("template", () => `${g}`);
attempt("concat", () => "g=" + g);
attempt("toString", () => g.toString());
let evaluated = 0;
attempt("hasOwnProperty", () => g.hasOwnProperty((evaluated++, "odd")));
attempt("propertyIsEnumerable", () => g.propertyIsEnumerable("odd"));
console.log("argument evaluated", evaluated);
console.log(Object.prototype.toString.call(g), Object.hasOwn(g, "odd"));
console.log(Object.prototype.hasOwnProperty.call(g, "even"));
console.log(JSON.stringify(g), Object.keys(g), Object.entries(g).length);
const copy = { ...g };
console.log(copy, Object.getPrototypeOf(copy) === Object.prototype, "toString" in copy);
const cloned = structuredClone(g);
console.log(cloned, Object.getPrototypeOf(cloned) === Object.prototype);
const plain: Partial<Record<string, number[]>> = { odd: [1, 3, 5], even: [2, 4] };
console.log(plain, "toString" in plain, String(plain), plain.toString());
console.log(util.isDeepStrictEqual(g, plain), util.isDeepStrictEqual(copy, plain));
// The groups stay writable arrays and the record stays writable.
g.odd?.push(7);
g.extra = [0];
console.log(g, Object.keys(g));
const pass = (r: Partial<Record<string, number[]>>): string =>
`${Object.getPrototypeOf(r) === null} ${"toString" in r}`;
console.log(pass(g), pass(plain));
// An index-signature record converted from Object.create(null) keeps the
// null prototype; one converted from a plain object does not.
const bare: Record<string, number> = Object.create(null);
bare.a = 1;
const parsed = JSON.parse('{"a":1}') as Record<string, number>;
console.log(bare, "toString" in bare, Object.getPrototypeOf(bare) === null);
console.log(parsed, "toString" in parsed, Object.getPrototypeOf(parsed) === null);