diff --git a/packages/compiler/src/backend/emission/emit-exprs.ts b/packages/compiler/src/backend/emission/emit-exprs.ts index e32fb86de..27dcf040c 100644 --- a/packages/compiler/src/backend/emission/emit-exprs.ts +++ b/packages/compiler/src/backend/emission/emit-exprs.ts @@ -45,12 +45,15 @@ function streamTypedRefCommitAdapter( ` size_t sc_target_len = sc_target->len;`, ` size_t sc_target_cap = sc_target->cap;`, ` uint64_t *sc_target_data = sc_target->data;`, + ` uint8_t *sc_target_present = sc_target->present;`, ` sc_target->len = sc_next->len;`, ` sc_target->cap = sc_next->cap;`, ` sc_target->data = sc_next->data;`, + ` sc_target->present = sc_next->present;`, ` sc_next->len = sc_target_len;`, ` sc_next->cap = sc_target_cap;`, ` sc_next->data = sc_target_data;`, + ` sc_next->present = sc_target_present;`, ` scr_arr_release(sc_next);`, `}`, ``, @@ -200,6 +203,7 @@ function streamTypedRefAdapter( lines.push( ` ScrDyn *d = scr_dyn_new_arr();`, ` for (size_t sc_i = 0; sc_i < v->len; sc_i++) {`, + ` if (!scr_arr_has(v, (double)sc_i)) { scr_dyn_arr_push_hole(d); continue; }`, ); if (elem.kind === "f64") { lines.push( diff --git a/packages/compiler/src/backend/emission/emit-stmts.ts b/packages/compiler/src/backend/emission/emit-stmts.ts index 7e108b262..3bbe5ddef 100644 --- a/packages/compiler/src/backend/emission/emit-stmts.ts +++ b/packages/compiler/src/backend/emission/emit-stmts.ts @@ -360,8 +360,16 @@ export function emitStmt(E: CEmitter, s: IrStmt): void { const idx = E.emitExpr(s.index); const v = E.emitExpr(s.value); if (s.arr.type.kind !== "array") throw new Error("emitter bug: arraySet on non-array"); - const acc = elemAccess(s.arr.type.elem); + const elem = s.arr.type.elem; + const acc = elemAccess(elem); if (acc === "ref") E.moveTemp(v); + if (elem.kind === "union") { + const tag = E.undefinedArmTag(elem); + if (tag >= 0) { + E.line(`scr_arr_set_ref_fill(${arr.name}, ${idx.name}, ${v.name}, ${E.unitInstanceRef(elem.unionId, tag)});${E.srcComment(s.loc)}`); + break; + } + } E.line(`scr_arr_set_${acc}(${arr.name}, ${idx.name}, ${v.name});${E.srcComment(s.loc)}`); break; } diff --git a/packages/compiler/src/backend/emission/emit-walkers.ts b/packages/compiler/src/backend/emission/emit-walkers.ts index 292159d9e..dbb3d62f5 100644 --- a/packages/compiler/src/backend/emission/emit-walkers.ts +++ b/packages/compiler/src/backend/emission/emit-walkers.ts @@ -260,6 +260,7 @@ import { OVERFLOW_MEMBER } from "./emit-shapes.js"; ` size_t n = (size_t)scr_arr_len(a);`, ` for (size_t i = 0; i < n; i++) {`, ` if (i) for (size_t j = 0; j < sep->len; j++) scr_jb_putc(&b, sep->data[j]);`, + ` if (!scr_arr_has(a, (double)i)) continue;`, ` ScrUnion *u = (ScrUnion *)scr_arr_get_ref(a, (double)i);`, ...(unitTags.length > 0 ? [` if (${unitTags.map((t) => `u->tag == ${t}`).join(" || ")}) { scr_union_release(u); continue; }`] @@ -1197,12 +1198,15 @@ export function jsonWriteHelper(E: CEmitter, t: IrType): string { ` size_t sc_old_len = sc_cached->len;`, ` size_t sc_old_cap = sc_cached->cap;`, ` uint64_t *sc_old_data = sc_cached->data;`, + ` uint8_t *sc_old_present = sc_cached->present;`, ` sc_cached->len = sc_out->len;`, ` sc_cached->cap = sc_out->cap;`, ` sc_cached->data = sc_out->data;`, + ` sc_cached->present = sc_out->present;`, ` sc_out->len = sc_old_len;`, ` sc_out->cap = sc_old_cap;`, ` sc_out->data = sc_old_data;`, + ` sc_out->present = sc_old_present;`, ` scr_arr_release(sc_out);`, ` return sc_cached;`, ` }`, diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index d2d05d91c..12a2f4761 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -668,6 +668,7 @@ export class LlDyn { { index: 1, type: "i64" as const, name: "length" }, { index: 2, type: "i64" as const, name: "capacity" }, { index: 7, type: "ptr" as const, name: "data" }, + { index: 8, type: "ptr" as const, name: "present" }, ]; members.forEach((member) => { const cachedPtr = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index c702d8a3e..e06a2f1aa 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -3169,9 +3169,18 @@ class LlEmitter { const idx = this.emitExpr(s.index); const v = this.emitExpr(s.value); if (s.arr.type.kind !== "array") throw new Error("llvm emitter bug: arraySet on non-array"); - const acc = elemAccess(s.arr.type.elem); + const elem = s.arr.type.elem; + const acc = elemAccess(elem); if (acc === "ref") this.moveTemp(v); const argTy = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr"; + if (elem.kind === "union") { + const tag = this.undefinedArmTag(elem); + if (tag >= 0) { + this.declare(`declare void @scr_arr_set_ref_fill(ptr, double, ptr, ptr)`); + B.line(`call void @scr_arr_set_ref_fill(ptr ${arr.name}, double ${idx.name}, ptr ${v.name}, ptr ${this.unitInstanceRef(elem.unionId, tag)})`); + break; + } + } this.declare(`declare void @scr_arr_set_${acc}(ptr, double, ${argTy === "i1" ? "i1 zeroext" : argTy})`); B.line(`call void @scr_arr_set_${acc}(ptr ${arr.name}, double ${idx.name}, ${argTy} ${v.name})`); break; @@ -10148,6 +10157,12 @@ class LlEmitter { ` %next_data = load ptr, ptr %next_data_ptr`, ` store ptr %next_data, ptr %target_data_ptr`, ` store ptr %target_data, ptr %next_data_ptr`, + ` %target_present_ptr = getelementptr inbounds %ScrArr, ptr %target, i64 0, i32 8`, + ` %next_present_ptr = getelementptr inbounds %ScrArr, ptr %next, i64 0, i32 8`, + ` %target_present = load ptr, ptr %target_present_ptr`, + ` %next_present = load ptr, ptr %next_present_ptr`, + ` store ptr %next_present, ptr %target_present_ptr`, + ` store ptr %target_present, ptr %next_present_ptr`, ` call void @scr_arr_release(ptr %next)`, ` br label %done`, `done:`, @@ -10416,7 +10431,9 @@ class LlEmitter { const elem = t.elem; this.declare(`declare ptr @scr_dyn_new_arr()`); this.declare(`declare void @scr_dyn_arr_push(ptr, ptr)`); + this.declare(`declare void @scr_dyn_arr_push_hole(ptr)`); this.declare(`declare double @scr_arr_len(ptr)`); + this.declare(`declare zeroext i1 @scr_arr_has(ptr, double)`); const out = B.tmp(); B.line(`${out} = call ptr @scr_dyn_new_arr()`); const len = B.tmp(); @@ -10426,6 +10443,9 @@ class LlEmitter { B.line(`store double ${f64Lit(0)}, ptr ${iSlot}`); const condition = B.newLabel("strm.c"); const body = B.newLabel("strm.b"); + const present = B.newLabel("strm.p"); + const hole = B.newLabel("strm.h"); + const next = B.newLabel("strm.n"); const done = B.newLabel("strm.e"); B.br(condition); B.startBlock(condition); @@ -10435,6 +10455,13 @@ class LlEmitter { B.line(`${more} = fcmp olt double ${index}, ${len}`); B.condBr(more, body, done); B.startBlock(body); + const has = B.tmp(); + B.line(`${has} = call zeroext i1 @scr_arr_has(ptr %p, double ${index})`); + B.condBr(has, present, hole); + B.startBlock(hole); + B.line(`call void @scr_dyn_arr_push_hole(ptr ${out})`); + B.br(next); + B.startBlock(present); let value: string; if (elem.kind === "f64" || elem.kind === "bool") { const valueTy = elem.kind === "f64" ? "double" : "i1"; @@ -10453,9 +10480,11 @@ class LlEmitter { if (isRefCounted(elem)) { B.line(`call void ${releaseSym(this, elem)}(ptr ${value})`); } - const next = B.tmp(); - B.line(`${next} = fadd double ${index}, ${f64Lit(1)}`); - B.line(`store double ${next}, ptr ${iSlot}`); + B.br(next); + B.startBlock(next); + const nextIndex = B.tmp(); + B.line(`${nextIndex} = fadd double ${index}, ${f64Lit(1)}`); + B.line(`store double ${nextIndex}, ptr ${iSlot}`); B.br(condition); B.startBlock(done); B.terminate(`ret ptr ${out}`); diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts index 5043ed4dc..f20f9920b 100644 --- a/packages/compiler/src/backend/llvm/walkers.ts +++ b/packages/compiler/src/backend/llvm/walkers.ts @@ -874,6 +874,7 @@ export class LlWalkers { host.declare(`declare void @scr_jb_init(ptr)`); host.declare(`declare ptr @scr_jb_finish(ptr)`); host.declare(`declare double @scr_arr_len(ptr)`); + host.declare(`declare zeroext i1 @scr_arr_has(ptr, double)`); host.declare(`declare ptr @scr_arr_get_ref(ptr, double)`); host.declare(`declare void @scr_union_release(ptr)`); host.declare(`declare void @scr_str_release(ptr)`); @@ -900,13 +901,18 @@ export class LlWalkers { // `if (i) put sep bytes` — separators between elements only. const nz = B.tmp(); const lsep = B.newLabel("uj.s"); - const lel = B.newLabel("uj.v"); + const lhas = B.newLabel("uj.h"); + const lval = B.newLabel("uj.v"); B.line(`${nz} = fcmp ogt double ${i}, ${f64Lit(0)}`); - B.condBr(nz, lsep, lel); + B.condBr(nz, lsep, lhas); B.startBlock(lsep); this.putScrStr(B, buf, "%sep"); - B.br(lel); - B.startBlock(lel); + B.br(lhas); + B.startBlock(lhas); + const present = B.tmp(); + B.line(`${present} = call i1 @scr_arr_has(ptr %a, double ${i})`); + B.condBr(present, lval, ln); + B.startBlock(lval); const u = B.tmp(); B.line(`${u} = call ptr @scr_arr_get_ref(ptr %a, double ${i}) ; element (+1)`); if (unitTags.length > 0) { diff --git a/packages/compiler/src/frontend/lowering/array-density.ts b/packages/compiler/src/frontend/lowering/array-density.ts new file mode 100644 index 000000000..1bb5c2754 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/array-density.ts @@ -0,0 +1,166 @@ +import * as ts from "../ts7/adapter.js"; +import type { IrType } from "../../ir/nodes.js"; +import type { Lowerer } from "./lowerer.js"; + +/** A hole can be read as JavaScript undefined only when the element slot has + * an actual undefined value. */ +export function arrayElementCanRepresentUndefined(L: Lowerer, elem: IrType): boolean { + if (elem.kind === "jsval" || elem.kind === "dyn" || elem.kind === "undefinedT") return true; + return elem.kind === "union" && + (L.unions.get(elem.unionId)?.arms.some((arm) => arm.kind === "undefinedT") ?? false); +} + +export function requireArrayGetSafe( + L: Lowerer, + source: ts.Expression, + elem: IrType, + blame: ts.Node, + construct: string, +): void { + if (arrayElementCanRepresentUndefined(L, elem) || !arrayExprIsProvablySparse(L, source)) return; + L.unsupported( + "SC1090", + blame, + `${construct} on a sparse '${L.fmt({ kind: "array", elem })}' value ` + + `(a hole must be observed as undefined, which '${L.fmt(elem)}' element slots cannot represent - ` + + "use an undefined-capable element type or a dense array)", + ); +} + +function stripWrappers(expr: ts.Expression): ts.Expression { + let value = expr; + while ( + ts.isParenthesizedExpression(value) || ts.isNonNullExpression(value) || + ts.isAsExpression(value) || ts.isTypeAssertion(value) || ts.isSatisfiesExpression(value) + ) { + value = value.expression; + } + return value; +} + +function isAssignmentOperator(kind: ts.SyntaxKind): boolean { + return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; +} + +function isWriteTarget(node: ts.Expression): boolean { + let value = node; + let parent = value.parent; + while ( + parent && + (ts.isParenthesizedExpression(parent) || ts.isNonNullExpression(parent) || + ts.isAsExpression(parent) || ts.isTypeAssertion(parent) || ts.isSatisfiesExpression(parent)) + ) { + value = parent; + parent = parent.parent; + } + if (!parent) return false; + if (ts.isDeleteExpression(parent) && parent.expression === value) return true; + if ( + (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) && + parent.operand === value && + (parent.operator === ts.SyntaxKind.PlusPlusToken || parent.operator === ts.SyntaxKind.MinusMinusToken) + ) { + return true; + } + return ts.isBinaryExpression(parent) && parent.left === value && isAssignmentOperator(parent.operatorToken.kind); +} + +const READ_ONLY_ARRAY_METHODS = new Set([ + "at", "concat", "entries", "every", "filter", "find", "findIndex", "findLast", + "findLastIndex", "flat", "flatMap", "forEach", "includes", "indexOf", "join", "keys", + "lastIndexOf", "map", "reduce", "reduceRight", "slice", "some", "toReversed", "toSorted", + "toSpliced", "values", "with", +]); + +function symbolUsePreservesSparsity(L: Lowerer, node: ts.Identifier): boolean { + const parent = node.parent; + if ( + (ts.isVariableDeclaration(parent) || ts.isParameter(parent) || ts.isBindingElement(parent) || + ts.isFunctionDeclaration(parent)) && parent.name === node + ) { + return true; + } + if (ts.isPropertyAccessExpression(parent) && parent.expression === node) { + if (isWriteTarget(parent)) return false; + if (parent.name.text === "length") return true; + return ts.isCallExpression(parent.parent) && parent.parent.expression === parent && + READ_ONLY_ARRAY_METHODS.has(parent.name.text); + } + if (ts.isElementAccessExpression(parent) && parent.expression === node) { + return !isWriteTarget(parent); + } + if ((ts.isForOfStatement(parent) || ts.isForInStatement(parent)) && parent.expression === node) return true; + if (ts.isSpreadElement(parent) && parent.expression === node) return true; + if (ts.isBinaryExpression(parent)) { + if (parent.right === node && parent.operatorToken.kind === ts.SyntaxKind.InKeyword) return true; + return !isAssignmentOperator(parent.operatorToken.kind) && + (parent.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken); + } + return parent.kind === ts.SyntaxKind.TypeQuery; +} + +function symbolUsesPreserveSparsity( + L: Lowerer, + symbol: ts.Symbol, + file: ts.SourceFile, + before: number, +): boolean { + let safe = true; + const visit = (node: ts.Node): void => { + if (!safe) return; + if ( + ts.isIdentifier(node) && node.pos < before && + node.text === symbol.name && L.resolveValueSymbol(node) === symbol + ) { + safe = symbolUsePreservesSparsity(L, node); + if (!safe) return; + } + node.forEachChild(visit); + }; + file.forEachChild(visit); + return safe; +} + +function isSparseArrayInitializer(L: Lowerer, source: ts.Expression): boolean { + const value = stripWrappers(source); + if (ts.isArrayLiteralExpression(value)) return value.elements.some(ts.isOmittedExpression); + if (!ts.isCallExpression(value) && !ts.isNewExpression(value)) return false; + const callee = value.expression; + if ( + !ts.isIdentifier(callee) || callee.text !== "Array" || + !L.isStdlibSymbol(L.resolveValueSymbol(callee) ?? undefined) + ) { + return false; + } + const args = value.arguments ?? []; + if (args.length !== 1) return false; + const length = stripWrappers(args[0]!); + return ts.isNumericLiteral(length) && Number.isInteger(Number(length.text)) && Number(length.text) > 0; +} + +/** Prove only the narrow case needed to fence unrepresentable hole reads. + * Unknown provenance deliberately keeps the historical lowering behavior. */ +function arrayExprIsProvablySparse(L: Lowerer, source: ts.Expression): boolean { + const value = stripWrappers(source); + if (isSparseArrayInitializer(L, value)) return true; + if (!ts.isIdentifier(value)) return false; + const symbol = L.resolveValueSymbol(value); + if (!symbol) return false; + const declarations = L.checker.declarationsOf(symbol); + const declaration = declarations.length === 1 ? declarations[0] : undefined; + if (!declaration || !ts.isVariableDeclaration(declaration) || !declaration.initializer) return false; + const list = declaration.parent; + const statement = list.parent; + if ( + !ts.isVariableDeclarationList(list) || list.declarations.length !== 1 || + !ts.isVariableStatement(statement) || + (ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Export) !== 0 || + declaration.end > value.pos || !isSparseArrayInitializer(L, declaration.initializer) + ) { + return false; + } + return symbolUsesPreserveSparsity(L, symbol, declaration.getSourceFile(), value.pos); +} diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts index 041bb421b..b2c8d9bef 100644 --- a/packages/compiler/src/frontend/lowering/lower-assert.ts +++ b/packages/compiler/src/frontend/lowering/lower-assert.ts @@ -1340,15 +1340,26 @@ function deepEqHelper(L: Lowerer, t: IrType, loc: SrcLoc): string { case "array": { const len = (arr: IrExpr): IrExpr => ({ kind: "arrIntrinsic", method: "length", receiver: arr, args: [], type: F64, loc }); const at = (arr: IrExpr, i: IrExpr): IrExpr => ({ kind: "arrayGet", arr, index: i, type: t.elem, loc }); + const has = (arr: IrExpr, i: IrExpr): IrExpr => ({ kind: "arrayHas", arr, index: i, type: BOOL, loc }); + const i = (): IrExpr => ref("i.0", F64); locals.push({ id: "i.0", name: "i", type: F64, mutable: true }); body = [ bailIf(neq(len(a()), len(b()))), { kind: "for", init: { kind: "varDecl", localId: "i.0", init: num(0), loc }, - cond: { kind: "bin", op: "<", left: ref("i.0", F64), right: len(a()), type: BOOL, loc }, - update: { kind: "assign", localId: "i.0", value: { kind: "bin", op: "+", left: ref("i.0", F64), right: num(1), type: F64, loc }, loc }, - body: [bailIf(not(deq(t.elem, at(a(), ref("i.0", F64)), at(b(), ref("i.0", F64)))))], + cond: { kind: "bin", op: "<", left: i(), right: len(a()), type: BOOL, loc }, + update: { kind: "assign", localId: "i.0", value: { kind: "bin", op: "+", left: i(), right: num(1), type: F64, loc }, loc }, + body: [ + bailIf(neq(has(a(), i()), has(b(), i()))), + { + kind: "if", + cond: has(a(), i()), + then: [bailIf(not(deq(t.elem, at(a(), i()), at(b(), i()))))], + else_: null, + loc, + }, + ], loc, }, ret(boolLit(true)), diff --git a/packages/compiler/src/frontend/lowering/lower-builtins.ts b/packages/compiler/src/frontend/lowering/lower-builtins.ts index 643add50c..3818d6d08 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtins.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtins.ts @@ -32,6 +32,7 @@ import { CRYPTO_CIPHERS, CRYPTO_CONSTANTS, CRYPTO_CURVES, CRYPTO_HASHES } from " import { timerStyleCallback } from "./lower-calls.js"; import { registerHttpClientFnBinding } from "./lower-server.js"; import { BOOL, BYTES_U8, CHILD_T, CHILDSTREAM_T, DYN, F64, FSWATCHER_T, PROCSTREAM_T, IrExpr, IrFunction, IrLibFn, IrLocal, IrStmt, IrType, JSVAL, NULL_T, SEARCH_PARAMS_T, SPAWNRES_T, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, funcOf, isUnitType, typeEquals, typeKey } from "../../ir/nodes.js"; +import { requireArrayGetSafe } from "./array-density.js"; @@ -1326,6 +1327,10 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts if (spread) { if (expr.arguments.length === 1) { // The whole-array form forwards the operand directly (no copy). + const sourceType = L.mapTypeOf(L.typeOf(spread.expression)); + if (sourceType?.kind === "array") { + requireArrayGetSafe(L, spread.expression, sourceType.elem, spread, "array argument spread"); + } const packed = L.lowerExprExpecting(spread.expression, arrayOf(STRING)); return { kind: "libCall", fn: fn.fn, args: [packed], type: fn.result, loc }; } @@ -1333,6 +1338,13 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts // tmpdir.resolve: plain arguments and spread arrays pack into one // fresh string[] (arrayLit's spread positions copy element-wise, // JS-exact; a dyn spread source rides the validated extraction). + for (const arg of expr.arguments) { + if (!ts.isSpreadElement(arg)) continue; + const sourceType = L.mapTypeOf(L.typeOf(arg.expression)); + if (sourceType?.kind === "array") { + requireArrayGetSafe(L, arg.expression, sourceType.elem, arg, "array argument spread"); + } + } const elems = expr.arguments.map((a) => ts.isSpreadElement(a) ? L.lowerExprExpecting(a.expression, arrayOf(STRING)) @@ -6415,6 +6427,7 @@ type TextCodecCtor = { return { kind: "libCall", fn: "string.fromCharCode", args: [packed], type: STRING, loc }; } const packed = L.lowerExprExpecting(spread.expression, arrayOf(F64)); + requireArrayGetSafe(L, spread.expression, F64, spread, "array argument spread"); return { kind: "libCall", fn: "string.fromCharCode", args: [packed], type: STRING, loc }; } const elems = call.arguments.map((a) => L.lowerExprExpecting(a, F64)); diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 747a65566..85e9d07a6 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -27,6 +27,7 @@ import { ambientNsRootOf, ambientUndefReadType, ambientUndefVarRootOf, ambientUn import { declSymbolOf } from "./lower-modules.js"; import { expandoMemberRead } from "./lower-expando.js"; import { npmStaticPackageOfPath } from "../npm-static.js"; +import { requireArrayGetSafe } from "./array-density.js"; /** How a parameter participates in CALL-SITE COMPLETION (the frontend * completes every call to the one full signature, so the IR and backends @@ -479,6 +480,10 @@ export interface GenericInstance { `spreading '${L.fmt(src.type)}' into a '${L.fmt(restType)}' rest parameter (only a same-element-type array spreads)`, ); } + const sourceType = L.mapTypeOf(L.typeOf(a.expression)); + if (sourceType?.kind === "array") { + requireArrayGetSafe(L, a.expression, sourceType.elem, a, "array argument spread"); + } spreads.push(i); return src; } diff --git a/packages/compiler/src/frontend/lowering/lower-containers.ts b/packages/compiler/src/frontend/lowering/lower-containers.ts index ce4c67e84..0fa191086 100644 --- a/packages/compiler/src/frontend/lowering/lower-containers.ts +++ b/packages/compiler/src/frontend/lowering/lower-containers.ts @@ -12,6 +12,7 @@ import { isJsSourceFile, locOf } from "../program.js"; import { DYN_DISPATCH_METHODS, islandPrimitiveExit } from "./lower-calls.js"; import { typeKey } from "../types.js"; import { dynUndefinedExpr, own, WidthLift } from "./lowerer.js"; +import { requireArrayGetSafe } from "./array-density.js"; /** Lower an expression whose checker type is statically `undefined`/`void`. * Optional builtin arguments use this before their ordinary expected-type @@ -190,6 +191,7 @@ function lowerOptionalDefaultArg( if (call.arguments.length !== 0) { L.noLowering(`.toReversed with ${call.arguments.length} arguments`, call); } + requireArrayGetSafe(L, access.expression, elem, call, "Array.toReversed"); return { kind: "arrIntrinsic", method: "toReversed", @@ -203,6 +205,7 @@ function lowerOptionalDefaultArg( if (call.arguments.length !== 2 || call.arguments.some(ts.isSpreadElement)) { L.noLowering(`.with with ${call.arguments.length} arguments`, call); } + requireArrayGetSafe(L, access.expression, elem, call, "Array.with"); const receiver = L.lowerExpr(access.expression); const index = L.lowerExprExpecting(call.arguments[0]!, F64); const value = L.coerceInto( @@ -223,6 +226,7 @@ function lowerOptionalDefaultArg( if (call.arguments.some(ts.isSpreadElement)) { L.unsupported("SC1090", call, "spread arguments to Array.toSpliced"); } + requireArrayGetSafe(L, access.expression, elem, call, "Array.toSpliced"); const receiver = L.lowerExpr(access.expression); const start = call.arguments[0] ? L.lowerExprExpecting(call.arguments[0], F64) @@ -320,6 +324,9 @@ function lowerOptionalDefaultArg( `pushing a spread of '${L.fmt(src.type)}' onto a '${L.fmt(receiverIr)}' array (only a same-element-type array spreads)`, ); } + if (L.mapTypeOf(L.typeOf(spreadArg.expression))?.kind === "array") { + requireArrayGetSafe(L, spreadArg.expression, elem, spreadArg, "array argument spread"); + } return { kind: "arrIntrinsic", method: "pushSpread", receiver, args: [src], type: F64, loc }; } // Pushed values flow into the element slot like an assignment would: @@ -1015,6 +1022,9 @@ function lowerOptionalDefaultArg( const loc = locOf(call); const receiver = L.lowerExpr(access.expression); const arrT = arrayOf(elem); + if (method !== "some" && method !== "every") { + requireArrayGetSafe(L, access.expression, elem, call, `Array.${method}`); + } const argNode = call.arguments[0]; if (!argNode) L.unsupported("SC1090", call, "this call form"); // tsc-guarded const { fnArg, arity } = hofCallbackArg(L, argNode, [elem], arrT); @@ -1583,6 +1593,7 @@ function lowerOptionalDefaultArg( const loc = locOf(call); const method = access.name.text === "toSorted" ? "toSorted" : "sort"; const copyFirst = method === "toSorted"; + if (copyFirst) requireArrayGetSafe(L, access.expression, elem, call, "Array.toSorted"); if (call.arguments.length === 0 && elem.kind === "string") { const receiver = L.lowerExpr(access.expression); const cmp = defaultStringCmpHelper(L, loc); @@ -1691,14 +1702,16 @@ function lowerOptionalDefaultArg( return name; } -/** The insertion-sort loop, from existing IR nodes. Present elements compact - * to the front first; CompareArrayElements then sinks undefined past every - * value without calling the comparator, so the order is values, then - * undefineds. The tail beyond the present count is deleted for the - * in-place sort (holes land last, SortIndexedProperties' SKIP-HOLES) and - * assigned the interned undefined arm for toSorted (`undefExpr`, dense - * per READ-THROUGH-HOLES); a toSorted element type with no undefined arm - * cannot represent a read-through hole and keeps the holes. +/** The insertion-sort loop, from existing IR nodes. Both methods first take a + * shallow working snapshot. Present elements compact within that private + * array; CompareArrayElements then sinks undefined past every value without + * calling the comparator, so the order is values, then undefineds. sort + * commits the sorted prefix and hole tail to the receiver only after every + * comparator call succeeds. This preserves the original receiver during + * comparator observation and leaves it untouched when a comparator throws. + * toSorted returns the working array directly; its hole tail becomes present + * undefined (the call site fences scalar receivers unless they are proven + * dense). * * n = a.length; * for (i = 1; i < n; i++) { @@ -1728,7 +1741,9 @@ function lowerOptionalDefaultArg( const i = ref("i.0", F64); const j = ref("j.0", F64); const m = ref("m.0", F64); - const at = (index: IrExpr): IrExpr => ({ kind: "arrayGet", arr: ref("a.0", arrT), index, type: elem, loc }); + const working = (): IrExpr => ref("w.0", arrT); + const at = (index: IrExpr): IrExpr => ({ kind: "arrayGet", arr: working(), index, type: elem, loc }); + const hasWorking: IrExpr = { kind: "arrayHas", arr: working(), index: i, type: BOOL, loc }; const jPlus1: IrExpr = { kind: "bin", op: "+", left: j, right: num(1), type: F64, loc }; const isUndefined = (value: IrExpr): IrExpr | null => { if (elem.kind === "union" && undefinedTag !== null) { @@ -1806,7 +1821,7 @@ function lowerOptionalDefaultArg( kind: "if", cond: shouldShift, then: [ - { kind: "arraySet", arr: ref("a.0", arrT), index: jPlus1, value: at(j), loc }, + { kind: "arraySet", arr: working(), index: jPlus1, value: at(j), loc }, { kind: "assign", localId: "j.0", value: { kind: "bin", op: "-", left: j, right: num(1), type: F64, loc }, loc }, ], else_: [{ kind: "break", loc }], @@ -1816,28 +1831,26 @@ function lowerOptionalDefaultArg( loc, }; const body: IrStmt[] = [ - ...(copyFirst - ? [{ - kind: "assign" as const, - localId: "a.0", - value: { - kind: "arrIntrinsic" as const, - method: "slice" as const, - receiver: ref("a.0", arrT), - args: [], - type: arrT, - loc, - }, - loc, - }] - : []), readLenStmt(arrT, loc), + { + kind: "varDecl", + localId: "w.0", + init: { + kind: "arrIntrinsic", + method: "slice", + receiver: ref("a.0", arrT), + args: [], + type: arrT, + loc, + }, + loc, + }, { kind: "varDecl", localId: "m.0", init: num(0), loc }, countedForLoop(loc, [{ kind: "if", - cond: hasElemExpr(arrT, loc), + cond: hasWorking, then: [ - { kind: "arraySet", arr: ref("a.0", arrT), index: m, value: at(i), loc }, + { kind: "arraySet", arr: working(), index: m, value: at(i), loc }, { kind: "assign", localId: "m.0", value: { kind: "bin", op: "+", left: m, right: num(1), type: F64, loc }, loc }, ], else_: null, @@ -1850,8 +1863,8 @@ function lowerOptionalDefaultArg( update: { kind: "assign", localId: "i.0", value: { kind: "bin", op: "+", left: i, right: num(1), type: F64, loc }, loc }, body: [ copyFirst && undefExpr !== null - ? { kind: "arraySet", arr: ref("a.0", arrT), index: i, value: undefExpr, loc } - : { kind: "arrayDelete", arr: ref("a.0", arrT), index: i, loc }, + ? { kind: "arraySet", arr: working(), index: i, value: undefExpr, loc } + : { kind: "arrayDelete", arr: working(), index: i, loc }, ], loc, }, @@ -1866,14 +1879,44 @@ function lowerOptionalDefaultArg( loc, }, body: [ - { kind: "varDecl", localId: "v.0", init: getElemExpr(arrT, elem, loc), loc }, + { kind: "varDecl", localId: "v.0", init: at(i), loc }, { kind: "varDecl", localId: "j.0", init: { kind: "bin", op: "-", left: ref("i.0", F64), right: num(1), type: F64, loc }, loc }, shiftLoop, - { kind: "arraySet", arr: ref("a.0", arrT), index: jPlus1, value: ref("v.0", elem), loc }, + { kind: "arraySet", arr: working(), index: jPlus1, value: ref("v.0", elem), loc }, ], loc, }, - { kind: "return", value: ref("a.0", arrT), loc }, + ...(copyFirst + ? [] + : [ + { + kind: "for" as const, + init: { kind: "varDecl" as const, localId: "i.0", init: num(0), loc }, + cond: { kind: "bin" as const, op: "<" as const, left: i, right: m, type: BOOL, loc }, + update: { + kind: "assign" as const, + localId: "i.0", + value: { kind: "bin" as const, op: "+" as const, left: i, right: num(1), type: F64, loc }, + loc, + }, + body: [{ kind: "arraySet" as const, arr: ref("a.0", arrT), index: i, value: at(i), loc }], + loc, + }, + { + kind: "for" as const, + init: { kind: "varDecl" as const, localId: "i.0", init: m, loc }, + cond: { kind: "bin" as const, op: "<" as const, left: i, right: ref("n.0", F64), type: BOOL, loc }, + update: { + kind: "assign" as const, + localId: "i.0", + value: { kind: "bin" as const, op: "+" as const, left: i, right: num(1), type: F64, loc }, + loc, + }, + body: [{ kind: "arrayDelete" as const, arr: ref("a.0", arrT), index: i, loc }], + loc, + }, + ]), + { kind: "return", value: copyFirst ? working() : ref("a.0", arrT), loc }, ]; return { name, @@ -1885,6 +1928,7 @@ function lowerOptionalDefaultArg( locals: [ { id: "a.0", name: "a", type: arrT, mutable: true }, { id: "f.0", name: "f", type: fnT, mutable: true }, + { id: "w.0", name: "w", type: arrT, mutable: false }, { id: "n.0", name: "n", type: F64, mutable: false }, { id: "i.0", name: "i", type: F64, mutable: true }, { id: "m.0", name: "m", type: F64, mutable: true }, @@ -2392,6 +2436,13 @@ function lowerOptionalDefaultArg( else_: null, loc, }, + { + kind: "if", + cond: { kind: "unary", op: "!", operand: { kind: "arrayHas", arr: ref("a.0", arrT), index: t, type: BOOL, loc }, type: BOOL, loc }, + then: [{ kind: "return", value: miss, loc }], + else_: null, + loc, + }, { kind: "varDecl", localId: "v.0", diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index df96d724a..374ce40a4 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -25,6 +25,7 @@ import { mixinFnOfCallee } from "./lower-mixins.js"; import { isConstAssertionTypeNode, isGenericCallableMemberType, underConstAssertion, unitOnlyUnion } from "../types.js"; import { lowerYield } from "./lower-generators.js"; import { lowerStreamProperty, lowerStreamStateProperty, streamSidesOf } from "./lower-stream.js"; +import { requireArrayGetSafe } from "./array-density.js"; /** An assignable `obj.field` target — a class field, a record field, or a * class ACCESSOR property (reads become getter calls, writes setter calls; @@ -3680,6 +3681,10 @@ export function lowerOptionalChain(L: Lowerer, expr: ts.CallExpression | ts.Prop `spreading '${L.fmt(src.type)}' into a '${L.fmt(type)}' literal (only a same-element-type array spreads)`, ); } + const sourceType = L.mapTypeOf(L.typeOf(el.expression)); + if (sourceType?.kind === "array") { + requireArrayGetSafe(L, el.expression, sourceType.elem, el, "array spread"); + } spreads.push(i); return src; } diff --git a/packages/compiler/src/frontend/lowering/lower-island.ts b/packages/compiler/src/frontend/lowering/lower-island.ts index 99a9656f3..2bbb83952 100644 --- a/packages/compiler/src/frontend/lowering/lower-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-island.ts @@ -10,6 +10,7 @@ import { requiresDynamicApiDiag, requiresDynamicPackageDiag } from "../../diagno import { isCjsJsFile, isJsSourceFile, locOf, npmPackageNameOf } from "../program.js"; import { foldedStringKeyOf, lowerDynObjectLiteral, pureReemittable } from "./lower-exprs.js"; import { PoisonError, dynUndefinedExpr, newFnCtx, nodeThrowExpr, own } from "./lowerer.js"; +import { requireArrayGetSafe } from "./array-density.js"; import { NODE24_FETCH_COMPAT_PROFILE, STATIC_HEADERS_CALLS, @@ -2947,6 +2948,7 @@ export function lowerStaticReadableStreamReaderCall( `spreading '${L.fmt(src.type)}' into Math.${name} (only a number[] spreads)`, ); } + requireArrayGetSafe(L, spread.expression, F64, spread, `array spread into Math.${name}`); return { kind: "libCall", fn: name === "max" ? "math.maxArr" : "math.minArr", diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 3862f3d99..e64f274c1 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -27,6 +27,7 @@ import { UNSUPPORTED, checkerPanicDiag, isCheckerPanic, requiresDynamicDiag } fr import { isUnitOnlyTsType, unitOnlyUnion } from "../types.js"; import { canonicalBuiltinModule, isRelativeSpecifier } from "../shared.js"; import { probeNodeRequireRefusal } from "../npm.js"; +import { requireArrayGetSafe } from "./array-density.js"; /** `const X = /* @__PURE__ *\/ makeX()` at a module's top level: every * declarator initialized by a call (or `new`) carrying the bundler PURE @@ -997,6 +998,9 @@ export function lowerStmt(L: Lowerer, stmt: ts.Statement): IrStmt | IrStmt[] | n ) { L.badType(sourceNode, L.typeOf(sourceNode)); } + if (source.type.kind === "array") { + requireArrayGetSafe(L, sourceNode, source.type.elem, decl.initializer!, "stored array value iteration"); + } const loc = locOf(decl); const indexedSource = L.declareHiddenLocal("%numiterSource", source.type); const index = L.declareHiddenLocal("%numiterIndex", F64); @@ -5898,6 +5902,15 @@ function isEsModuleStamp(expr: ts.Expression): boolean { if (recv?.kind === "array" && (proj === "keys" || proj === "entries")) { const container = L.lowerExpr(src.expression.expression); if (container.type.kind === "array") { + if (proj === "entries") { + requireArrayGetSafe( + L, + src.expression.expression, + container.type.elem, + stmt.expression, + "Array.entries iteration", + ); + } return lowerForOfArrayIter(L, stmt, container as IrExpr & { type: IrType & { kind: "array" } }, proj); } } @@ -6102,6 +6115,7 @@ function isEsModuleStamp(expr: ts.Expression): boolean { L.badType(stmt.expression, L.typeOf(stmt.expression)); } } + requireArrayGetSafe(L, iterSrc, iterable.type.elem, stmt.expression, "array for-of iteration"); if (!ts.isVariableDeclarationList(stmt.initializer)) { // `for (x of xs)` over a PRE-DECLARED writable binding: JS assigns // the existing binding once per pass — one shared binding across diff --git a/packages/compiler/src/ir/dense-arrays.ts b/packages/compiler/src/ir/dense-arrays.ts index 8af8ae402..62d7ab39e 100644 --- a/packages/compiler/src/ir/dense-arrays.ts +++ b/packages/compiler/src/ir/dense-arrays.ts @@ -88,7 +88,10 @@ function markFunction(fn: IrFunction): void { case "exprStmt": expr(s.expr); break; case "arraySet": { const id = directCandidate(s.arr); - if (id === null) expr(s.arr); + // A dynamic index can now grow past length and introduce holes. + // Without a range proof, later reads must inspect presence. + if (id !== null) invalidate(id); + else expr(s.arr); expr(s.index); expr(s.value); break; diff --git a/packages/compiler/src/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts index 58de4b973..537e89a4a 100644 --- a/packages/compiler/src/ir/nodes.ts +++ b/packages/compiler/src/ir/nodes.ts @@ -1155,10 +1155,10 @@ export type IrStmt = labels?: string[]; loc: SrcLoc; } - /** Element write `a[i] = v` — statement-only, like `assign`. Valid indices - * are [0, length]; i == length appends (JS would create a hole past that — - * scriptc traps instead, see SEMANTICS.md). Ownership of a refcounted - * value MOVES into the array; the replaced element is released. */ + /** Element write `a[i] = v` — statement-only, like `assign`. Valid array + * indices are [0, 2^32-2]; writes at or beyond length grow through i + 1 + * and leave intermediate holes. Ownership of a refcounted value MOVES + * into the array; the replaced element is released. */ | { kind: "arraySet"; arr: IrExpr; index: IrExpr; value: IrExpr; loc: SrcLoc } /** `delete a[i]`: clears presence without changing length. */ | { kind: "arrayDelete"; arr: IrExpr; index: IrExpr; loc: SrcLoc } diff --git a/packages/compiler/test/ir.test.ts b/packages/compiler/test/ir.test.ts index 555226f91..1dbd3ff76 100644 --- a/packages/compiler/test/ir.test.ts +++ b/packages/compiler/test/ir.test.ts @@ -208,11 +208,17 @@ test("dense-array pass bypasses hole checks only for untouched packed locals", ( name: "holey", params: [], returnType: F64, locals: [{ id: "a.0", name: "a", type: arr, mutable: false }], loc, body: [{ kind: "varDecl", localId: "a.0", init: { kind: "arrayLit", elems: [{ kind: "numLit", value: 1, type: F64, loc }], type: arr, loc }, loc }, { kind: "arrayDelete", arr: { kind: "varRef", localId: "a.0", type: arr, loc }, index: { kind: "numLit", value: 0, type: F64, loc }, loc }, { kind: "return", value: get("a.0"), loc }], }, + { + name: "written", params: [], returnType: F64, locals: [{ id: "a.0", name: "a", type: arr, mutable: false }], loc, + body: [{ kind: "varDecl", localId: "a.0", init: { kind: "arrayLit", elems: [{ kind: "numLit", value: 1, type: F64, loc }], type: arr, loc }, loc }, { kind: "arraySet", arr: { kind: "varRef", localId: "a.0", type: arr, loc }, index: { kind: "numLit", value: 4, type: F64, loc }, value: { kind: "numLit", value: 5, type: F64, loc }, loc }, { kind: "return", value: get("a.0"), loc }], + }, ], }; markDenseArrayReads(mod); const dense = mod.functions[0]!.body[1]!; const holey = mod.functions[1]!.body[2]!; + const written = mod.functions[2]!.body[2]!; expect(dense.kind === "return" && dense.value?.kind === "arrayGet" && dense.value.dense).toBe(true); expect(holey.kind === "return" && holey.value?.kind === "arrayGet" && holey.value.dense).toBeFalsy(); + expect(written.kind === "return" && written.value?.kind === "arrayGet" && written.value.dense).toBeFalsy(); }); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 4e1f257fd..35f3ddd30 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -5482,12 +5482,36 @@ ], "diags": [] }, + "/tests/corpus/2672-http-request-response-callback.ts": { + "order": [ + "/tests/corpus/2672-http-request-response-callback.ts" + ], + "diags": [] + }, "/tests/corpus/2673-sparse-array-js.js": { "order": [ "/tests/corpus/2673-sparse-array-js.js" ], "diags": [] }, + "/tests/corpus/2684-sparse-live-array-payload.ts": { + "order": [ + "/tests/corpus/2684-sparse-live-array-payload.ts" + ], + "diags": [] + }, + "/tests/corpus/2693-sparse-far-index-copy.ts": { + "order": [ + "/tests/corpus/2693-sparse-far-index-copy.ts" + ], + "diags": [] + }, + "/tests/corpus/2695-sparse-frontend-review-fixes.ts": { + "order": [ + "/tests/corpus/2695-sparse-frontend-review-fixes.ts" + ], + "diags": [] + }, "/tests/corpus/300-if-else.ts": { "order": [ "/tests/corpus/300-if-else.ts" @@ -7461,6 +7485,12 @@ ], "diags": [] }, + "/tests/diagnostics/sparse-get-scalar-fences.ts": { + "order": [ + "/tests/diagnostics/sparse-get-scalar-fences.ts" + ], + "diags": [] + }, "/tests/diagnostics/spi-off/main.ts": { "order": [ "/tests/diagnostics/spi-off/main.ts" diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index 1fade9323..e717150f0 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -16,9 +16,9 @@ static void scr_arr_oom(void) { scr_trap("scriptc: out of memory\n"); } -/* JS would return undefined for an OOB read and create holes for a far OOB - * write; both are unrepresentable here (see SEMANTICS.md), so any invalid - * index — negative, fractional, NaN, or past the allowed end — traps. */ +/* Reads outside length and values that are not array indices remain + * unrepresentable. Writes accept every JS array index (through 2^32-2), + * growing length and creating holes as needed. */ static void scr_arr_trap_oob(double i, size_t len) { char buf[32]; scr_f64_to_str(i, buf); @@ -26,12 +26,10 @@ static void scr_arr_trap_oob(double i, size_t len) { buf, len); } -/* Validate i as an element index. limit is a->len for reads, a->len + 1 for - * writes (i == len appends). NaN fails the >= 0 test; fractional indices - * fail the trunc test. */ -static size_t scr_arr_check_index(const ScrArr *a, double i, bool allow_append) { - size_t limit = a->len + (allow_append ? 1 : 0); - if (!(i >= 0) || i != trunc(i) || i >= (double)limit) { +/* 2^32-1 is the maximum array length and therefore not an array index. */ +static size_t scr_arr_check_index(const ScrArr *a, double i, bool write) { + double limit = write ? 4294967295.0 : (double)a->len; + if (!(i >= 0) || i != trunc(i) || i >= limit) { scr_arr_trap_oob(i, a->len); } return (size_t)i; @@ -95,6 +93,16 @@ static bool scr_arr_present_at(const ScrArr *a, size_t i) { return !a->present || (a->present[i >> 3] & (uint8_t)(1u << (i & 7))) != 0; } +static void scr_arr_make_sparse(ScrArr *a) { + if (a->present) return; + size_t bytes = (a->cap + 7) / 8; + a->present = calloc(bytes ? bytes : 1, 1); + if (!a->present) scr_arr_oom(); + for (size_t i = 0; i < a->len; i++) { + a->present[i >> 3] |= (uint8_t)(1u << (i & 7)); + } +} + static void scr_arr_mark_present(ScrArr *a, size_t i, bool present) { if (!a->present) { if (present) return; @@ -233,12 +241,7 @@ void scr_arr_set_sparse_len(ScrArr *a, double n, void *fill) { } if (len == a->len) return; scr_arr_grow(a, len); - if (!a->present) { - size_t bytes = (a->cap + 7) / 8; - a->present = calloc(bytes ? bytes : 1, 1); - if (!a->present) scr_arr_oom(); - for (size_t i = 0; i < a->len; i++) scr_arr_mark_present(a, i, true); - } + scr_arr_make_sparse(a); uint64_t slot = scr_slot_from_ptr(fill); for (size_t i = a->len; i < len; i++) { a->data[i] = slot; @@ -360,15 +363,35 @@ void *scr_arr_get_dense_ref(ScrArr *a, double i) { return p; } -/* ── writes: i == len appends ──────────────────────────────────────────── */ +/* ── writes: far indices grow length and create holes ──────────────────── */ -static void scr_arr_set_slot(ScrArr *a, double i, uint64_t slot) { +static void scr_arr_set_slot(ScrArr *a, double i, uint64_t slot, + uint64_t hole) { size_t idx = scr_arr_check_index(a, i, true); - if (idx == a->len) { - scr_arr_grow(a, a->len + 1); - a->len++; + if (idx >= a->len) { + size_t old_len = a->len; + /* An already-sparse REF array can carry the undefined union singleton + * in its holes. Extend with the same inert backing when the caller did + * not supply the static element type's undefined arm. */ + if (idx > old_len && hole == 0 && a->present && a->elem == SCR_ELEM_REF) { + for (size_t j = 0; j < old_len; j++) { + if (!scr_arr_present_at(a, j)) { + hole = a->data[j]; + break; + } + } + } + scr_arr_grow(a, idx + 1); + if (idx > old_len) { + scr_arr_make_sparse(a); + for (size_t j = old_len; j < idx; j++) { + a->data[j] = hole; + scr_arr_mark_present(a, j, false); + } + } a->data[idx] = slot; scr_arr_mark_present(a, idx, true); + a->len = idx + 1; return; } /* Unlink-then-release: a release can trigger a cycle collection, which @@ -382,15 +405,19 @@ static void scr_arr_set_slot(ScrArr *a, double i, uint64_t slot) { } void scr_arr_set_f64(ScrArr *a, double i, double v) { - scr_arr_set_slot(a, i, scr_slot_from_f64(v)); + scr_arr_set_slot(a, i, scr_slot_from_f64(v), 0); } void scr_arr_set_bool(ScrArr *a, double i, bool v) { - scr_arr_set_slot(a, i, (uint64_t)(v ? 1 : 0)); + scr_arr_set_slot(a, i, (uint64_t)(v ? 1 : 0), 0); } void scr_arr_set_ref(ScrArr *a, double i, void *v) { - scr_arr_set_slot(a, i, scr_slot_from_ptr(v)); + scr_arr_set_slot(a, i, scr_slot_from_ptr(v), 0); +} + +void scr_arr_set_ref_fill(ScrArr *a, double i, void *v, void *fill) { + scr_arr_set_slot(a, i, scr_slot_from_ptr(v), scr_slot_from_ptr(fill)); } /* ── push / pop ────────────────────────────────────────────────────────── */ diff --git a/packages/runtime/src/scr_copying.c b/packages/runtime/src/scr_copying.c index 3c7189350..540d8ccb4 100644 --- a/packages/runtime/src/scr_copying.c +++ b/packages/runtime/src/scr_copying.c @@ -44,29 +44,15 @@ static bool copying_arr_present_at(const ScrArr *a, size_t i) { return !a->present || (a->present[i >> 3] & (uint8_t)(1u << (i & 7))) != 0; } -static void copying_arr_mark_hole(ScrArr *a, size_t i) { - if (!a->present) { - size_t bytes = (a->cap + 7) / 8; - a->present = malloc(bytes ? bytes : 1); - if (!a->present) scr_trap("scriptc: out of memory\n"); - memset(a->present, 0xff, bytes); - if (a->cap & 7) a->present[bytes - 1] &= (uint8_t)((1u << (a->cap & 7)) - 1u); - } - a->present[i >> 3] &= (uint8_t)~(1u << (i & 7)); -} - /* The ES2023 copying methods read every index (Get), so a source hole - * densifies to its backing where that carries the value Get answers — the - * interned undefined arm of a union element (nonzero). A zero backing - * (f64/bool/plain ref) has no undefined to give: the slot stays a hole so - * reads keep the deliberate trap instead of silently answering 0/NULL. */ + * densifies to the undefined value carried in a REF hole's backing slot. + * Scalar and NULL-backed holes cannot encode undefined in their typed ABI; + * fail explicitly rather than fabricating 0/NULL or preserving the hole. */ static void copying_arr_copy_slot(ScrArr *out, const ScrArr *src, size_t i) { uint64_t slot = src->data[i]; - if (!copying_arr_present_at(src, i) && slot == 0) { - out->data[out->len] = 0; - copying_arr_mark_hole(out, out->len); - out->len++; - return; + if (!copying_arr_present_at(src, i) && + (src->elem != SCR_ELEM_REF || slot == 0)) { + scr_trap("scriptc: TypeError: array hole cannot be represented as undefined by the element type\n"); } out->data[out->len++] = copying_arr_retain_slot(src, slot); } @@ -95,31 +81,25 @@ ScrArr *scr_arr_to_spliced(const ScrArr *a, double start, size_t out_len = a->len - ndelete + items->len; ScrArr *out = copying_arr_new_like(a, out_len); for (size_t i = 0; i < from; i++) copying_arr_copy_slot(out, a, i); - for (size_t i = 0; i < items->len; i++) { - out->data[out->len++] = - copying_arr_retain_slot(a, items->data[i]); - } + for (size_t i = 0; i < items->len; i++) + copying_arr_copy_slot(out, items, i); for (size_t i = from + ndelete; i < a->len; i++) { copying_arr_copy_slot(out, a, i); } return out; } -/* `with` builds on slice (which preserves holes); densify the copy under - * the same representability rule as copying_arr_copy_slot. */ -static void copying_arr_densify(ScrArr *out) { - if (!out->present) return; - bool all_present = true; - for (size_t i = 0; i < out->len; i++) { - uint8_t mask = (uint8_t)(1u << (i & 7)); - if (out->present[i >> 3] & mask) continue; - if (out->data[i] != 0) out->present[i >> 3] |= mask; - else all_present = false; - } - if (all_present) { - free(out->present); - out->present = NULL; +static ScrArr *copying_arr_with_slot(ScrArr *a, size_t index, + uint64_t replacement) { + ScrArr *out = copying_arr_new_like(a, a->len); + for (size_t i = 0; i < a->len; i++) { + if (i == index) { + out->data[out->len++] = copying_arr_retain_slot(a, replacement); + } else { + copying_arr_copy_slot(out, a, i); + } } + return out; } static bool copying_arr_with_index(const ScrArr *a, double index, @@ -142,30 +122,21 @@ static bool copying_arr_with_index(const ScrArr *a, double index, ScrArr *scr_arr_with_f64(ScrArr *a, double index, double value) { size_t i; if (!copying_arr_with_index(a, index, &i)) return NULL; - ScrArr *out = scr_arr_slice(a, 0, INFINITY); - copying_arr_densify(out); - scr_arr_set_f64(out, (double)i, value); - return out; + uint64_t slot; + memcpy(&slot, &value, sizeof slot); + return copying_arr_with_slot(a, i, slot); } ScrArr *scr_arr_with_bool(ScrArr *a, double index, bool value) { size_t i; if (!copying_arr_with_index(a, index, &i)) return NULL; - ScrArr *out = scr_arr_slice(a, 0, INFINITY); - copying_arr_densify(out); - scr_arr_set_bool(out, (double)i, value); - return out; + return copying_arr_with_slot(a, i, value ? 1 : 0); } ScrArr *scr_arr_with_ref(ScrArr *a, double index, void *value) { size_t i; if (!copying_arr_with_index(a, index, &i)) return NULL; - ScrArr *out = scr_arr_slice(a, 0, INFINITY); - copying_arr_densify(out); - uint64_t retained = - copying_arr_retain_slot(a, copying_slot_from_ptr(value)); - scr_arr_set_ref(out, (double)i, copying_slot_to_ptr(retained)); - return out; + return copying_arr_with_slot(a, i, copying_slot_from_ptr(value)); } ScrBytes *scr_bytes_to_reversed(const ScrBytes *b) { diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 25bcc6088..3dc892d85 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -2695,7 +2695,7 @@ void scr_sc_validate_options(const ScrDyn *options) { if (tr == NULL || tr->kind == SCR_DYN_UNDEF) return; if (tr->kind != SCR_DYN_ARR) { static const char msg[] = - "Failed to execute 'structuredClone': transfer in Options can not be converted to sequence."; + "Failed to execute 'structuredClone': transfer in Options cannot be converted to sequence."; scr_throw_error_msg_code(SCR_ERR_TYPE, msg, sizeof msg - 1, "ERR_INVALID_ARG_TYPE"); return; } diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 6db7fc4d3..8c92c0256 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -719,10 +719,10 @@ ScrStr *scr_b64_missing_arg(void); * layout is untouched (interned-literal statics depend on it), so the * element kind lives here instead of in a shared object header. * - * Index arguments are doubles (JS numbers). A valid index is a non-negative - * integer; reads must be < len, writes may also be == len (append — JS - * would create a hole past the end; scriptc traps instead, see - * SEMANTICS.md). Anything else prints + * Index arguments are doubles (JS numbers). A valid array index is an + * integer in [0, 2^32-2]. Reads must also be < len; writes grow length to + * index + 1 and create holes between the old end and the written slot. + * Anything else prints * scriptc: RangeError: array index out of bounds (length ) * to stderr and abort()s. pop() on an empty array likewise traps (JS * returns undefined — unrepresentable here). @@ -825,20 +825,26 @@ double scr_arr_get_dense_f64(ScrArr *a, double i); bool scr_arr_get_dense_bool(ScrArr *a, double i); void *scr_arr_get_dense_ref(ScrArr *a, double i); -/* i == len appends; the _ref variant releases the old element (when - * replacing) and takes ownership of the new one. */ +/* A write at or beyond len grows through i + 1, leaving intermediate slots + * as holes. The _ref variants release the old element (when replacing) and + * take ownership of the new one. _ref_fill stores fill as inert backing for + * new holes, allowing a statically known undefined union arm to answer Get. */ void scr_arr_set_f64(ScrArr *a, double i, double v); void scr_arr_set_bool(ScrArr *a, double i, bool v); void scr_arr_set_ref(ScrArr *a, double i, void *v); +void scr_arr_set_ref_fill(ScrArr *a, double i, void *v, void *fill); /* slice(start?, end?): a fresh +1 shallow copy of the index range — * ToIntegerOrInfinity indices, negatives from the end, clamping; ref * elements retain into the copy. Borrows a. */ ScrArr *scr_arr_slice(ScrArr *a, double start, double end); -/* ES2023 copying methods. All borrow their inputs and return fresh +1 - * shallow copies. with() raises Node's catchable RangeError for an invalid - * relative index; the ref variant retains the borrowed replacement. */ +/* ES2023 copying methods. All borrow their inputs and return fresh packed +1 + * shallow copies; source holes densify to present undefined values via a + * REF array's non-NULL hole backing. A hole whose typed element ABI cannot + * encode undefined traps rather than becoming 0/NULL. with() raises Node's + * catchable RangeError for an invalid relative index; the ref variant + * retains the borrowed replacement. */ ScrArr *scr_arr_to_reversed(const ScrArr *a); ScrArr *scr_arr_to_spliced(const ScrArr *a, double start, double delete_count, const ScrArr *items); diff --git a/packages/runtime/src/scr_web.c b/packages/runtime/src/scr_web.c index e8d6f14e5..519ca143c 100644 --- a/packages/runtime/src/scr_web.c +++ b/packages/runtime/src/scr_web.c @@ -1212,7 +1212,7 @@ static const char web_prelude[] = " if (typeof tr === 'string') throw 0;\n" " list = [...tr];\n" " } catch (e) {\n" - " throw scErr(\"Failed to execute 'structuredClone': transfer in Options can not be converted to sequence.\");\n" + " throw scErr(\"Failed to execute 'structuredClone': transfer in Options cannot be converted to sequence.\");\n" " }\n" " if (list.length > 0) {\n" " throw new DOMException('Found invalid value in transferList.', 'DataCloneError');\n" diff --git a/packages/runtime/test/array.test.ts b/packages/runtime/test/array.test.ts index aaab7c0fe..aa28723dc 100644 --- a/packages/runtime/test/array.test.ts +++ b/packages/runtime/test/array.test.ts @@ -19,6 +19,7 @@ beforeAll(async () => { "-o", bin, join(testDir, "test_array.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_copying.c"), join(testDir, "../src/scr_string.c"), join(testDir, "../src/scr_number.c"), join(testDir, "../src/scr_bytes.c"), @@ -35,13 +36,12 @@ test("array runtime: push/pop/set/get, RC recursion, growth", async () => { expect(stderr.trim()).toMatch(/^(\d+)\/\1 cases passed$/); }); -// JS returns undefined for OOB reads and creates holes for far writes; both -// are unrepresentable, so the runtime must trap (documented divergence). test.each([ - ["--crash-get-oob", "array index 1 out of bounds (length 1)"], - ["--crash-get-frac", "array index 0.5 out of bounds (length 1)"], - ["--crash-set-oob", "array index 2 out of bounds (length 1)"], - ["--crash-pop-empty", "pop() on an empty array"], + ["--crash-get-oob", "RangeError: array index 1 out of bounds (length 1)"], + ["--crash-get-frac", "RangeError: array index 0.5 out of bounds (length 1)"], + ["--crash-set-max", "RangeError: array index 4294967295 out of bounds (length 1)"], + ["--crash-pop-empty", "RangeError: pop() on an empty array"], + ["--crash-copy-hole", "TypeError: array hole cannot be represented as undefined by the element type"], ])("trap aborts (%s)", async (mode, message) => { const err = await execFileAsync(bin, [mode]).then( () => { @@ -50,5 +50,5 @@ test.each([ (e: Error & { signal?: string; stderr?: string }) => e, ); expect(err.signal).toBe("SIGABRT"); - expect(err.stderr).toContain(`scriptc: RangeError: ${message}`); + expect(err.stderr).toContain(`scriptc: ${message}`); }); diff --git a/packages/runtime/test/test_array.c b/packages/runtime/test/test_array.c index 1918e8ca5..e2cc101a6 100644 --- a/packages/runtime/test/test_array.c +++ b/packages/runtime/test/test_array.c @@ -4,8 +4,9 @@ * (no args) run all assertions; prints "N/N cases passed" * --crash-get-oob read past the end → RangeError + abort() * --crash-get-frac read a fractional index → RangeError + abort() - * --crash-set-oob write past len (holes) → RangeError + abort() + * --crash-set-max write at 2^32-1 → RangeError + abort() * --crash-pop-empty pop an empty array → RangeError + abort() + * --crash-copy-hole densify a scalar hole → TypeError + abort() * * The RC-recursion cases (array of strings, array of arrays of strings) * assert live counts directly: releasing the outer array must release @@ -436,6 +437,30 @@ static void test_sparse(void) { scr_arr_release(slice); scr_arr_release(a); + ScrArr *far = scr_arr_new(SCR_ELEM_F64, 0); + scr_arr_push_f64(far, 1); + scr_arr_set_f64(far, 4, 5); + check_f64(scr_arr_len(far), 5, "far indexed write grows length"); + check(scr_arr_has(far, 0) && !scr_arr_has(far, 1) && + !scr_arr_has(far, 2) && !scr_arr_has(far, 3) && + scr_arr_has(far, 4), + "far indexed write creates intermediate holes"); + check_f64(scr_arr_get_f64(far, 0), 1, "far write preserves prefix"); + check_f64(scr_arr_get_f64(far, 4), 5, "far write stores endpoint"); + scr_arr_set_f64(far, 5, 6); + check_f64(scr_arr_len(far), 6, "write at sparse length appends"); + check(!scr_arr_has(far, 3) && scr_arr_has(far, 5), + "sparse append preserves existing holes"); + scr_arr_release(far); + + ScrArr *far_refs = scr_arr_new(SCR_ELEM_STR, 0); + scr_arr_push_ref(far_refs, scr_str_new("a", 1)); + scr_arr_set_ref(far_refs, 3, scr_str_new("d", 1)); + check_f64(scr_arr_len(far_refs), 4, "far ref write grows length"); + check(!scr_arr_has(far_refs, 1) && !scr_arr_has(far_refs, 2), + "far ref write leaves unowned holes"); + scr_arr_release(far_refs); + ScrArr *dst = scr_arr_new(SCR_ELEM_F64, 0); ScrArr *src = scr_arr_new(SCR_ELEM_F64, 1); scr_arr_push_f64(src, 1); @@ -453,6 +478,76 @@ static void test_sparse(void) { #endif } +static void test_copying_holes(void) { + long live0 = mock_live; + MockRec *undefined_fill = mock_new(-1); + ScrArr *src = scr_arr_new_ref(&mock_retain, &mock_release, NULL, 0); + scr_arr_push_ref(src, mock_new(1)); + scr_arr_set_sparse_len(src, 4, undefined_fill); + scr_arr_set_ref(src, 2, mock_new(3)); + + ScrArr *reversed = scr_arr_to_reversed(src); + check(reversed->present == NULL, "toReversed densifies represented holes"); + check(scr_arr_has(reversed, 0) && scr_arr_has(reversed, 1) && + scr_arr_has(reversed, 2) && scr_arr_has(reversed, 3), + "toReversed result has every index"); + MockRec *v = scr_arr_get_ref(reversed, 0); + check(v == undefined_fill, "toReversed reads a hole as undefined backing"); + mock_release(v); + check(undefined_fill->rc == 3, + "toReversed retains each densified undefined backing"); + scr_arr_release(reversed); + check(undefined_fill->rc == 1, + "toReversed releases densified undefined backings"); + + ScrArr *items = scr_arr_new_ref(&mock_retain, &mock_release, NULL, 0); + scr_arr_push_ref(items, mock_new(2)); + ScrArr *spliced = scr_arr_to_spliced(src, 1, 1, items); + check(spliced->present == NULL, "toSpliced densifies kept holes"); + check_f64(scr_arr_len(spliced), 4, "toSpliced keeps expected length"); + v = scr_arr_get_ref(spliced, 3); + check(v == undefined_fill, "toSpliced reads a kept hole as undefined backing"); + mock_release(v); + check(undefined_fill->rc == 2, + "toSpliced retains a kept undefined backing"); + scr_arr_release(spliced); + check(undefined_fill->rc == 1, + "toSpliced releases a kept undefined backing"); + + MockRec *replacement = mock_new(9); + ScrArr *with = scr_arr_with_ref(src, 0, replacement); + check(with->present == NULL, "with densifies represented holes"); + check(undefined_fill->rc == 3, "with retains densified undefined backings"); + check(replacement->rc == 2, "with retains the borrowed replacement"); + scr_arr_release(with); + check(undefined_fill->rc == 1, "with releases densified undefined backings"); + check(replacement->rc == 1, "with releases its replacement reference"); + mock_release(replacement); + + ScrArr *plain = scr_arr_new(SCR_ELEM_F64, 0); + scr_arr_push_f64(plain, 1); + scr_arr_set_f64(plain, 2, 3); + ScrArr *no_items = scr_arr_new(SCR_ELEM_F64, 0); + ScrArr *deleted = scr_arr_to_spliced(plain, 1, 1, no_items); + check(deleted->present == NULL && scr_arr_len(deleted) == 2, + "toSpliced does not Get a deleted unrepresentable hole"); + check_f64(scr_arr_get_f64(deleted, 0), 1, "toSpliced deleted-hole prefix"); + check_f64(scr_arr_get_f64(deleted, 1), 3, "toSpliced deleted-hole suffix"); + ScrArr *replaced = scr_arr_with_f64(plain, 1, 2); + check(replaced->present == NULL && scr_arr_has(replaced, 1), + "with does not Get its replaced hole"); + check_f64(scr_arr_get_f64(replaced, 1), 2, "with stores over a scalar hole"); + scr_arr_release(replaced); + scr_arr_release(deleted); + scr_arr_release(no_items); + scr_arr_release(plain); + + scr_arr_release(items); + scr_arr_release(src); + mock_release(undefined_fill); + check(mock_live == live0, "copying methods balance ref elements"); +} + int main(int argc, char **argv) { if (argc > 1) { ScrArr *a = scr_arr_new(SCR_ELEM_F64, 0); @@ -461,11 +556,15 @@ int main(int argc, char **argv) { scr_arr_get_f64(a, 1); /* len is 1 */ } else if (strcmp(argv[1], "--crash-get-frac") == 0) { scr_arr_get_f64(a, 0.5); - } else if (strcmp(argv[1], "--crash-set-oob") == 0) { - scr_arr_set_f64(a, 2, 9); /* would create a hole */ + } else if (strcmp(argv[1], "--crash-set-max") == 0) { + scr_arr_set_f64(a, 4294967295.0, 9); } else if (strcmp(argv[1], "--crash-pop-empty") == 0) { scr_arr_pop_f64(a); scr_arr_pop_f64(a); /* now empty */ + } else if (strcmp(argv[1], "--crash-copy-hole") == 0) { + scr_arr_set_sparse_len(a, 2, NULL); + ScrArr *copy = scr_arr_to_reversed(a); + scr_arr_release(copy); } else { fprintf(stderr, "unknown mode %s\n", argv[1]); return 2; @@ -481,6 +580,7 @@ int main(int argc, char **argv) { test_index_of_includes(); test_ref_elements(); test_ref_cycle(); + test_copying_holes(); test_join(); test_sparse(); diff --git a/tests/corpus/2283-structured-clone.cjs b/tests/corpus/2283-structured-clone.cjs index 9eb3ec2cb..1b4e10de1 100644 --- a/tests/corpus/2283-structured-clone.cjs +++ b/tests/corpus/2283-structured-clone.cjs @@ -8,7 +8,7 @@ const assert = require('assert'); assert.throws(() => structuredClone(), { code: 'ERR_MISSING_ARGS' }); assert.throws(() => structuredClone(undefined, ''), { code: 'ERR_INVALID_ARG_TYPE', message: "Failed to execute 'structuredClone': Options cannot be converted to a dictionary" }); assert.throws(() => structuredClone(undefined, 1), { code: 'ERR_INVALID_ARG_TYPE' }); -assert.throws(() => structuredClone(undefined, { transfer: 1 }), { code: 'ERR_INVALID_ARG_TYPE', message: "Failed to execute 'structuredClone': transfer in Options can not be converted to sequence." }); +assert.throws(() => structuredClone(undefined, { transfer: 1 }), { code: 'ERR_INVALID_ARG_TYPE', message: /Failed to execute 'structuredClone': transfer in Options can ?not be converted to sequence\./ }); assert.throws(() => structuredClone(undefined, { transfer: null }), { code: 'ERR_INVALID_ARG_TYPE' }); assert.strictEqual(structuredClone(undefined), undefined); assert.strictEqual(structuredClone(undefined, null), undefined); diff --git a/tests/corpus/2684-sparse-live-array-payload.ts b/tests/corpus/2684-sparse-live-array-payload.ts new file mode 100644 index 000000000..800bbe582 --- /dev/null +++ b/tests/corpus/2684-sparse-live-array-payload.ts @@ -0,0 +1,42 @@ +// Live Web references must carry an array's hole bitmap through dynamic +// materialization, mutation commits, and cached structural-cast refreshes. + +const materializedSource: number[] = new Array(4); +materializedSource[1] = 11; +materializedSource[3] = 33; +const materialized: unknown = AbortSignal.abort(materializedSource).reason; +if (Array.isArray(materialized)) { + console.log( + "materialized", + materialized.length, + Object.keys(materialized).join(","), + JSON.stringify(materialized), + ); +} + +const committedSource: number[] = new Array(3); +committedSource[0] = 1; +committedSource[2] = 3; +const committed: unknown = AbortSignal.abort(committedSource).reason; +if (Array.isArray(committed)) committed[1] = 2; +console.log( + "committed", + committedSource.length, + Object.keys(committedSource).join(","), + JSON.stringify(committedSource), +); + +const refreshedSource: number[] = new Array(4); +refreshedSource[1] = 10; +const refreshValue: unknown = AbortSignal.abort(refreshedSource).reason; +const first = refreshValue as (number | string)[]; +delete refreshedSource[1]; +refreshedSource[2] = 20; +const second = refreshValue as (number | string)[]; +console.log( + "refreshed", + first === second, + second.length, + Object.keys(second).join(","), + JSON.stringify(second), +); diff --git a/tests/corpus/2693-sparse-far-index-copy.ts b/tests/corpus/2693-sparse-far-index-copy.ts new file mode 100644 index 000000000..d87a969d6 --- /dev/null +++ b/tests/corpus/2693-sparse-far-index-copy.ts @@ -0,0 +1,47 @@ +// Far indexed writes grow ordinary arrays and leave intermediate holes. +const numbers = [1]; +numbers[4] = 5; +console.log( + "numbers", + numbers.length, + 0 in numbers, + 1 in numbers, + 3 in numbers, + 4 in numbers, + numbers.join(","), +); + +const words = ["a"]; +words[3] = "d"; +console.log("words", words.length, 1 in words, 2 in words, words.join("|")); + +// A far write on an initially empty optional REF array gives each new hole +// the undefined arm as backing. Join skips the holes, while copying methods +// Get and densify them as present undefined values. +const optional: (number | undefined)[] = []; +optional[2] = 7; +console.log( + "optional", + optional.length, + 0 in optional, + 1 in optional, + 2 in optional, + optional.join(","), +); +const reversed = optional.toReversed(); +console.log( + "copy", + reversed.length, + 0 in reversed, + 1 in reversed, + 2 in reversed, + reversed[0], + reversed[1] === undefined, + reversed[2] === undefined, +); + +// Join must not read a hole even when its REF representation has no +// undefined arm and therefore leaves zero backing. +const mixed: (number | string)[] = []; +mixed[2] = "x"; +console.log("mixed", mixed.join("|")); diff --git a/tests/corpus/2695-sparse-frontend-review-fixes.ts b/tests/corpus/2695-sparse-frontend-review-fixes.ts new file mode 100644 index 000000000..9b95a44c3 --- /dev/null +++ b/tests/corpus/2695-sparse-frontend-review-fixes.ts @@ -0,0 +1,79 @@ +import assert from "node:assert"; + +// Static deep equality compares indexed-property presence before values. Two +// scalar holes never reach an unrepresentable arrayGet, while a hole remains +// distinct from a present undefined in an undefined-capable array. +const scalarHolesA: number[] = new Array(3); +const scalarHolesB: number[] = new Array(3); +assert.deepStrictEqual(scalarHolesA, scalarHolesB); +scalarHolesB[1] = 0; +assert.notDeepStrictEqual(scalarHolesA, scalarHolesB); + +const unionHole: (number | undefined)[] = new Array(1); +const unionUndefined: (number | undefined)[] = new Array(1); +unionUndefined[0] = undefined; +assert.notDeepStrictEqual(unionHole, unionUndefined); +console.log("deep-presence", Object.keys(scalarHolesA).length, 0 in unionHole, 0 in unionUndefined); + +// In-place sort works privately until all comparisons succeed. Comparator +// observation sees the original receiver, and a throw commits no writes. +const observed = [3, 1, 2]; +let receiverStayedOriginal = true; +observed.sort((a, b) => { + receiverStayedOriginal = receiverStayedOriginal && observed.join(",") === "3,1,2"; + return a - b; +}); +console.log("sort-observe", receiverStayedOriginal, observed.join(",")); + +const throwing = [4, 2, 3]; +let caught = false; +try { + throwing.sort((_a, _b): number => { + throw new Error("stop sort"); + }); +} catch (error) { + caught = error instanceof Error && error.message === "stop sort"; +} +console.log("sort-throw", caught, throwing.join(",")); + +const sparseSort: number[] = new Array(5); +sparseSort[1] = 3; +sparseSort[3] = 1; +const sparseKeys = Object.keys(sparseSort).join(","); +try { + sparseSort.sort((_a, _b): number => { + throw new Error("sparse stop"); + }); +} catch {} +console.log("sparse-throw", Object.keys(sparseSort).join(",") === sparseKeys, sparseSort.length); +sparseSort.sort((a, b) => a - b); +console.log("sparse-commit", sparseSort.join(","), Object.keys(sparseSort).join(","), sparseSort.length); + +// at has an undefined-capable result even when the element slot is scalar, +// so a presence miss returns that result arm without touching the slot. +const scalarAt: number[] = new Array(2); +scalarAt[1] = 7; +console.log("at-hole", scalarAt.at(0) === undefined, scalarAt.at(1), scalarAt.at(-2) === undefined); + +// Undefined-capable element storage supports Get-based callback visits to +// holes directly. +const searchable: (number | undefined)[] = new Array(3); +searchable[2] = 5; +let visits = ""; +const holeIndex = searchable.findIndex((value, index) => { + visits += `${index}${value === undefined ? "u" : "v"}`; + return value === undefined; +}); +const lastHole = searchable.findLastIndex((value) => value === undefined); +console.log("find-holes", holeIndex, lastHole, visits); + +// Packed provenance discharges scalar Get fences through a direct parameter, +// for-of, spread, and toSorted. +function firstEven(values: number[]): number { + return values.findIndex((value) => value % 2 === 0); +} +const packed = [3, 2, 1]; +let packedSum = 0; +for (const value of packed) packedSum += value; +const packedCopy = [...packed]; +console.log("packed", firstEven(packed), packedSum, packedCopy.join(","), packed.toSorted((a, b) => a - b).join(",")); diff --git a/tests/diagnostics/sparse-get-scalar-fences.ts b/tests/diagnostics/sparse-get-scalar-fences.ts new file mode 100644 index 000000000..b6d37ef75 --- /dev/null +++ b/tests/diagnostics/sparse-get-scalar-fences.ts @@ -0,0 +1,44 @@ +const scalarHoles: number[] = new Array(3); +// Direct Get-based methods cannot materialize undefined in number slots. +scalarHoles.find((value) => value > 0); +scalarHoles.findIndex((value) => value > 0); +scalarHoles.findLast((value) => value > 0); +scalarHoles.findLastIndex((value) => value > 0); +// Iteration and spread have the same read-through-holes behavior. +for (const value of scalarHoles) console.log(value); +const copied = [...scalarHoles]; +console.log(copied.length); + +function takeAll(...values: number[]): number { + return values.length; +} +takeAll(...scalarHoles); +Math.max(...scalarHoles); +String.fromCharCode(...scalarHoles); +// Copying sort reads through holes and produces present undefined entries. +scalarHoles.toSorted((a, b) => a - b); +// A later write cannot make this earlier Get safe. +const writtenLater = new Array(2); +writtenLater.find((value) => value > 0); +writtenLater[0] = 1; + +// Dense construction is safe even with scalar element storage. +const denseLiteral = [3, 1, 2]; +denseLiteral.find((value) => value > 0); +for (const value of denseLiteral) console.log(value); +console.log([...denseLiteral].length, denseLiteral.toSorted((a, b) => a - b).length); + +const filled = new Array(3); +filled[0] = 0; +filled[1] = 0; +filled[2] = 0; +filled.find((value) => value > 0); +for (const value of filled) console.log(value); +console.log([...filled].length, filled.toSorted((a, b) => a - b).length); + +// Parameters and other unknown provenance retain historical lowering. +function readUnknown(values: number[]): void { + values.find((value) => value > 0); + for (const value of values) console.log(value); + console.log([...values].length, values.toSorted((a, b) => a - b).length); +} diff --git a/tests/harness/__snapshots__/sparse-get-scalar-fences.ts.txt b/tests/harness/__snapshots__/sparse-get-scalar-fences.ts.txt new file mode 100644 index 000000000..c1908b800 --- /dev/null +++ b/tests/harness/__snapshots__/sparse-get-scalar-fences.ts.txt @@ -0,0 +1,76 @@ +sparse-get-scalar-fences.ts:3:1 - error SC1090: Array.find on a sparse 'number[]' value (a hole must be observed as undefined, which 'number' element slots cannot represent - use an undefined-capable element type or a dense array) is not supported yet + + 2 | // Direct Get-based methods cannot materialize undefined in number slots. + 3 | scalarHoles.find((value) => value > 0); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 4 | scalarHoles.findIndex((value) => value > 0); + +sparse-get-scalar-fences.ts:4:1 - error SC1090: Array.findIndex on a sparse 'number[]' value (a hole must be observed as undefined, which 'number' element slots cannot represent - use an undefined-capable element type or a dense array) is not supported yet + + 3 | scalarHoles.find((value) => value > 0); + 4 | scalarHoles.findIndex((value) => value > 0); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 5 | scalarHoles.findLast((value) => value > 0); + +sparse-get-scalar-fences.ts:5:1 - error SC1090: Array.findLast on a sparse 'number[]' value (a hole must be observed as undefined, which 'number' element slots cannot represent - use an undefined-capable element type or a dense array) is not supported yet + + 4 | scalarHoles.findIndex((value) => value > 0); + 5 | scalarHoles.findLast((value) => value > 0); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 6 | scalarHoles.findLastIndex((value) => value > 0); + +sparse-get-scalar-fences.ts:6:1 - error SC1090: Array.findLastIndex on a sparse 'number[]' value (a hole must be observed as undefined, which 'number' element slots cannot represent - use an undefined-capable element type or a dense array) is not supported yet + + 5 | scalarHoles.findLast((value) => value > 0); + 6 | scalarHoles.findLastIndex((value) => value > 0); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 7 | // Iteration and spread have the same read-through-holes behavior. + +sparse-get-scalar-fences.ts:8:21 - error SC1090: array for-of iteration on a sparse 'number[]' value (a hole must be observed as undefined, which 'number' element slots cannot represent - use an undefined-capable element type or a dense array) is not supported yet + + 7 | // Iteration and spread have the same read-through-holes behavior. + 8 | for (const value of scalarHoles) console.log(value); + | ^~~~~~~~~~~ + 9 | const copied = [...scalarHoles]; + +sparse-get-scalar-fences.ts:9:17 - error SC1090: array spread on a sparse 'number[]' value (a hole must be observed as undefined, which 'number' element slots cannot represent - use an undefined-capable element type or a dense array) is not supported yet + + 8 | for (const value of scalarHoles) console.log(value); + 9 | const copied = [...scalarHoles]; + | ^~~~~~~~~~~~~~ + 10 | console.log(copied.length); + +sparse-get-scalar-fences.ts:15:9 - error SC1090: array argument spread on a sparse 'number[]' value (a hole must be observed as undefined, which 'number' element slots cannot represent - use an undefined-capable element type or a dense array) is not supported yet + + 14 | } + 15 | takeAll(...scalarHoles); + | ^~~~~~~~~~~~~~ + 16 | Math.max(...scalarHoles); + +sparse-get-scalar-fences.ts:16:10 - error SC1090: array spread into Math.max on a sparse 'number[]' value (a hole must be observed as undefined, which 'number' element slots cannot represent - use an undefined-capable element type or a dense array) is not supported yet + + 15 | takeAll(...scalarHoles); + 16 | Math.max(...scalarHoles); + | ^~~~~~~~~~~~~~ + 17 | String.fromCharCode(...scalarHoles); + +sparse-get-scalar-fences.ts:17:21 - error SC1090: array argument spread on a sparse 'number[]' value (a hole must be observed as undefined, which 'number' element slots cannot represent - use an undefined-capable element type or a dense array) is not supported yet + + 16 | Math.max(...scalarHoles); + 17 | String.fromCharCode(...scalarHoles); + | ^~~~~~~~~~~~~~ + 18 | // Copying sort reads through holes and produces present undefined entries. + +sparse-get-scalar-fences.ts:19:1 - error SC1090: Array.toSorted on a sparse 'number[]' value (a hole must be observed as undefined, which 'number' element slots cannot represent - use an undefined-capable element type or a dense array) is not supported yet + + 18 | // Copying sort reads through holes and produces present undefined entries. + 19 | scalarHoles.toSorted((a, b) => a - b); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 20 | // A later write cannot make this earlier Get safe. + +sparse-get-scalar-fences.ts:22:1 - error SC1090: Array.find on a sparse 'number[]' value (a hole must be observed as undefined, which 'number' element slots cannot represent - use an undefined-capable element type or a dense array) is not supported yet + + 21 | const writtenLater = new Array(2); + 22 | writtenLater.find((value) => value > 0); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 23 | writtenLater[0] = 1; \ No newline at end of file diff --git a/tests/harness/web-globals.test.ts b/tests/harness/web-globals.test.ts index d73d2f7e8..834c34d83 100644 --- a/tests/harness/web-globals.test.ts +++ b/tests/harness/web-globals.test.ts @@ -129,7 +129,7 @@ describe(`island web globals (scriptc-only${sanitize ? ", sanitized" : ""})`, () "DataCloneError:t:true", 'TypeError/ERR_MISSING_ARGS: The "The value argument must be specified" argument must be specified', "TypeError/ERR_INVALID_ARG_TYPE: Failed to execute 'structuredClone': Options cannot be converted to a dictionary", - "TypeError/ERR_INVALID_ARG_TYPE: Failed to execute 'structuredClone': transfer in Options can not be converted to sequence.", + "TypeError/ERR_INVALID_ARG_TYPE: Failed to execute 'structuredClone': transfer in Options cannot be converted to sequence.", "true", "DataCloneError/25: () => {} could not be cloned.", ].join(" | "),