diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 45ee627ff..b85ca0062 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -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()`); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 1aa1e4197..09bf4979c 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -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" >, diff --git a/packages/compiler/src/backend/llvm/expr-dispatch.ts b/packages/compiler/src/backend/llvm/expr-dispatch.ts index 9c21cb652..e53f47d47 100644 --- a/packages/compiler/src/backend/llvm/expr-dispatch.ts +++ b/packages/compiler/src/backend/llvm/expr-dispatch.ts @@ -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); diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 7a2d03cdc..016ae0e7c 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -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. diff --git a/packages/compiler/src/backend/llvm/record-results.ts b/packages/compiler/src/backend/llvm/record-results.ts index 9cd11623d..41d76564e 100644 --- a/packages/compiler/src/backend/llvm/record-results.ts +++ b/packages/compiler/src/backend/llvm/record-results.ts @@ -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)); } diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index f0ddd8d3e..dd151de01 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -556,6 +556,7 @@ export class ReferenceWrites { case "recordHas": case "recordKeyGet": case "recordOvfHas": + case "recordNullProto": case "recordOvfKeys": case "fieldAbsent": case "unionDisc": diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index c3d4aff8d..23565eff4 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -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; diff --git a/packages/compiler/src/contracts/check.ts b/packages/compiler/src/contracts/check.ts index a2e6287cf..21fb9f097 100644 --- a/packages/compiler/src/contracts/check.ts +++ b/packages/compiler/src/contracts/check.ts @@ -1023,6 +1023,7 @@ const OBSERVE = new Set([ "fieldAbsent", "recordHas", "recordOvfHas", + "recordNullProto", "arrayHas", "arrayState", "jsonStringify", diff --git a/packages/compiler/src/frontend/lowering/containers/collection-grouping.ts b/packages/compiler/src/frontend/lowering/containers/collection-grouping.ts index a2a8e2bf2..0dd0a59f8 100644 --- a/packages/compiler/src/frontend/lowering/containers/collection-grouping.ts +++ b/packages/compiler/src/frontend/lowering/containers/collection-grouping.ts @@ -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, }, { diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index e09bcd8b1..edf740ac4 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -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( // `({}).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 " 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, }); } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 9a764a61f..4a08c6338 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -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: [ diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 3be3671a6..7c08a34f9 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -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; } diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 55f21b162..9300d19d4 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -823,6 +823,7 @@ export function moduleArtifacts( case "recordKeyGet": case "recordOvfKeys": case "recordOvfHas": + case "recordNullProto": case "recordHas": visitShape(node.shapeId); break; diff --git a/packages/compiler/src/frontend/lowering/null-prototype.ts b/packages/compiler/src/frontend/lowering/null-prototype.ts new file mode 100644 index 000000000..0a6a96a72 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/null-prototype.ts @@ -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, + }; +} diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts index 7987c5859..c3a03f046 100644 --- a/packages/compiler/src/ir/function-references.test.ts +++ b/packages/compiler/src/ir/function-references.test.ts @@ -86,6 +86,7 @@ const EXPR_FIELDS: Classified = { dynIterN: { notIterableMessage: "other" }, recordOvfKeys: { shapeId: "other" }, recordOvfHas: { shapeId: "other" }, + recordNullProto: { shapeId: "other" }, recordHas: { shapeId: "other", field: "other" }, fieldAbsent: { unionId: "other" }, unionWrap: { unionId: "other" }, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 227852e71..82bd924a9 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -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 diff --git a/packages/compiler/src/ir/traverse.ts b/packages/compiler/src/ir/traverse.ts index 7532139f8..1ef70bdb2 100644 --- a/packages/compiler/src/ir/traverse.ts +++ b/packages/compiler/src/ir/traverse.ts @@ -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": diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 463b9bc62..d44a187f4 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -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" }; diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index e0f7a304a..b80127591 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -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(); 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); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5845-groupby-null-prototype.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5845-groupby-null-prototype.ts.json new file mode 100644 index 000000000..2e58d1f7d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5845-groupby-null-prototype.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5845-groupby-null-prototype.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 2f2435013..2895487d9 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -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 diff --git a/packages/runtime/src/scr_map.h b/packages/runtime/src/scr_map.h index b3acff74a..9ac2b0977 100644 --- a/packages/runtime/src/scr_map.h +++ b/packages/runtime/src/scr_map.h @@ -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 *), diff --git a/tests/corpus/5845-groupby-null-prototype.ts b/tests/corpus/5845-groupby-null-prototype.ts new file mode 100644 index 000000000..0f74ea61a --- /dev/null +++ b/tests/corpus/5845-groupby-null-prototype.ts @@ -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> = { 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>): 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 = Object.create(null); +bare.a = 1; +const parsed = JSON.parse('{"a":1}') as Record; +console.log(bare, "toString" in bare, Object.getPrototypeOf(bare) === null); +console.log(parsed, "toString" in parsed, Object.getPrototypeOf(parsed) === null);