mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-26 06:34:20 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/5845-groupby-null-prototype.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user