diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index a72c0b246..f3044f8bc 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -1244,7 +1244,8 @@ function liveRecordOps( `}`, ``, `@${names} = internal constant [${fields.length} x ptr] [${literals.join(", ")}]`, - `@${ops} = internal constant { ${host.sizeType}, ptr, ptr, ptr } { ${host.sizeType} ${fields.length}, ptr @${names}, ptr @${has}, ptr @${get} }`, + // Mutable: the runtime fills the trailing key-array slot on first use. + `@${ops} = internal global { ${host.sizeType}, ptr, ptr, ptr, i8, ptr } { ${host.sizeType} ${fields.length}, ptr @${names}, ptr @${has}, ptr @${get}, i8 ${optional.length === 0 ? 1 : 0}, ptr null }`, ``, ); } diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index df8206511..89cfbfcdc 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1027,6 +1027,9 @@ export const LIB_FN_SYMS: Record = { // (catchable SyntaxError) — all may-throw generics over the checked-dynamic tree. "dyn.objKeys": "scr_dyn_obj_keys", "dyn.forInKeys": "scr_dyn_for_in_keys", + "dyn.forInKeyStrings": "scr_dyn_for_in_key_strs", + "dyn.forInStable": "scr_dyn_for_in_stable", + "dyn.objKeyStrings": "scr_dyn_obj_key_strs", "dyn.hasOwn": "scr_dyn_has_own", "dyn.propertyIsEnumerable": "scr_dyn_property_is_enumerable", "dyn.assign": "scr_dyn_assign", diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index b60c79e8a..ca8888320 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -593,7 +593,8 @@ export class ClassDynamicDispatch { private readonly prototypeIdentityClasses = new Set(); private readonly instancePrototypes = new Map }>(); private readonly checkedCasts = new Map }>(); - private enumeration: IrFunction | null = null; + /** The for-in key helpers, by libCall (dyn and native string[] keys). */ + private readonly enumerations = new Map<"dyn.forInKeys" | "dyn.forInKeyStrings", IrFunction>(); private readonly enumerationClasses = new Set(); process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean { @@ -690,6 +691,7 @@ export class ClassDynamicDispatch { expr.fn === "dyn.toString" || expr.fn === "dyn.stringConstructor" || expr.fn === "dyn.forInKeys" || + expr.fn === "dyn.forInKeyStrings" || expr.fn === "dyn.hasKeyComputed" || expr.fn === "dyn.hasOwnComputed" || expr.fn === "dyn.propertyIsEnumerableComputed" || @@ -1353,27 +1355,35 @@ export class ClassDynamicDispatch { loc: expr.loc, }; } - if (expr.kind === "libCall" && expr.fn === "dyn.forInKeys") { - if (!this.enumeration) { + if ( + expr.kind === "libCall" && + (expr.fn === "dyn.forInKeys" || expr.fn === "dyn.forInKeyStrings") + ) { + let enumeration = this.enumerations.get(expr.fn); + if (!enumeration) { const loc = expr.loc; const value = varRef("p.0", DYN, loc); - this.enumeration = { - name: "%dyn.class.forInKeys", + enumeration = { + name: + expr.fn === "dyn.forInKeys" + ? "%dyn.class.forInKeys" + : "%dyn.class.forInKeyStrings", params: [{ localId: "p.0", name: "value", type: DYN }], - returnType: DYN, + returnType: expr.type, locals: [{ id: "p.0", name: "value", type: DYN, mutable: false }], body: [{ kind: "return", value: { ...expr, args: [value] }, loc }], loc, }; - this.generated.add(this.enumeration); - lowerer.liftedFns.push(this.enumeration); + this.enumerations.set(expr.fn, enumeration); + this.generated.add(enumeration); + lowerer.liftedFns.push(enumeration); changed = true; } return { kind: "call", - callee: this.enumeration.name, + callee: enumeration.name, args: expr.args, - type: DYN, + type: expr.type, loc: expr.loc, }; } @@ -2701,20 +2711,21 @@ export class ClassDynamicDispatch { changed = true; } } - if (this.enumeration) + for (const [fn, enumeration] of this.enumerations) for (const plan of this.propertyReceivers(lowerer, false, false)) { - if (!plan.info.def.tracksOwnFields || this.enumerationClasses.has(plan.key)) continue; - this.enumerationClasses.add(plan.key); - const loc = this.enumeration.loc; + const classKey = `${fn}:${plan.key}`; + if (!plan.info.def.tracksOwnFields || this.enumerationClasses.has(classKey)) continue; + this.enumerationClasses.add(classKey); + const loc = enumeration.loc; const { condition } = this.propertyReceiver(plan, loc); const bag = this.readPropertyBagOrEmpty(lowerer, varRef("p.0", DYN, loc), loc); - this.enumeration.body.unshift({ + enumeration.body.unshift({ kind: "if", cond: condition, then: [ { kind: "return", - value: { kind: "libCall", fn: "dyn.forInKeys", args: [bag], type: DYN, loc }, + value: { kind: "libCall", fn, args: [bag], type: enumeration.returnType, loc }, loc, }, ], diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index f064368ca..b85eecb29 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -7388,6 +7388,21 @@ function lowerObjectStaticCall( : "dyn.objEntries"; let v = lowerer.lowerExpr(argNode); if (v.type.kind !== "dyn") v = { kind: "dynFrom", value: v, type: DYN, loc: locOf(call) }; + // In TypeScript the keys come back as a native string[]: the checker + // keeps every later write to the array a string. JavaScript may store + // anything into it, so it keeps the checked-dynamic array. + const keysT = + member === "keys" && !isJsSourceFile(call.getSourceFile()) + ? lowerer.mapTypeOf(lowerer.typeOf(call)) + : null; + if (keysT?.kind === "array" && keysT.elem.kind === "string") + return { + kind: "libCall", + fn: "dyn.objKeyStrings", + args: [v], + type: keysT, + loc: locOf(call), + }; return { kind: "libCall", fn, args: [v], type: DYN, loc: locOf(call) }; } } diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 5993c47fc..bf8419b37 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -9558,17 +9558,35 @@ function lowerForIn(lowerer: Lowerer, stmt: ts.ForInStatement): IrStmt { const recv = lowerer.declareHiddenLocal("%indyn", DYN); const ref: IrExpr = { kind: "varRef", localId: recv.id, type: DYN, loc }; const keys: IrExpr = { - kind: "dynCheck", - value: { kind: "libCall", fn: "dyn.forInKeys", args: [ref], type: DYN, loc }, + kind: "libCall", + fn: "dyn.forInKeyStrings", + args: [ref], type: arrayOf(STRING), loc, }; - const loop = lowerForInOverKeys(lowerer, stmt, keys, labels, (key) => - classForInHasKey(lowerer, ref, key, loc), - ); + // A stable snapshot (a typed record's fields) needs no live presence + // test per visit. + const stable = lowerer.declareHiddenLocal("%instable", BOOL); + const loop = lowerForInOverKeys(lowerer, stmt, keys, labels, (key) => ({ + kind: "ternary", + cond: { kind: "varRef", localId: stable.id, type: BOOL, loc }, + then: { kind: "boolLit", value: true, type: BOOL, loc }, + else_: classForInHasKey(lowerer, ref, key, loc), + type: BOOL, + loc, + })); return { kind: "block", - body: [{ kind: "varDecl", localId: recv.id, init: receiver, loc }, loop], + body: [ + { kind: "varDecl", localId: recv.id, init: receiver, loc }, + { + kind: "varDecl", + localId: stable.id, + init: { kind: "libCall", fn: "dyn.forInStable", args: [ref], type: BOOL, loc }, + loc, + }, + loop, + ], loc, }; } diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 5284bd4f1..94c73de20 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -397,6 +397,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ // the dyn Object walks throw on null/undefined receivers "dyn.objKeys", "dyn.forInKeys", + "dyn.forInKeyStrings", + "dyn.objKeyStrings", "dyn.hasOwn", "dyn.propertyIsEnumerable", "dyn.assign", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index dd9cb27be..cb96de425 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2668,6 +2668,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * ("Cannot convert undefined or null to object"). */ "dyn.objKeys": { argTypes: [DYN], result: DYN }, "dyn.forInKeys": { argTypes: [DYN], result: DYN }, + "dyn.forInKeyStrings": { argTypes: [DYN], result: arrayOf(STRING) }, + "dyn.forInStable": { argTypes: [DYN], result: BOOL }, + "dyn.objKeyStrings": { argTypes: [DYN], result: arrayOf(STRING) }, "dyn.hasOwn": { argTypes: [DYN, STRING], result: BOOL }, "dyn.propertyIsEnumerable": { argTypes: [DYN, STRING], result: BOOL }, "dyn.assign": { argTypes: [DYN, DYN], result: DYN }, diff --git a/packages/compiler/test/forin-keys.test.ts b/packages/compiler/test/forin-keys.test.ts new file mode 100644 index 000000000..e6d9fb1bf --- /dev/null +++ b/packages/compiler/test/forin-keys.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function emit(source: string, file = "main.ts"): Promise { + const outDir = await mkdtemp(join(tmpdir(), "scriptc-forin-")); + directories.push(outDir); + const entry = join(outDir, file); + await writeFile(entry, source); + const outPath = join(outDir, "program.ll"); + const result = await compile(entry, { entry, outDir, outPath, outputKind: "llvm" }); + expect(result.ok, JSON.stringify(result)).toBe(true); + return readFile(outPath, "utf8"); +} + +test("for-in over a checked-dynamic value walks native string keys", async () => { + const llvm = await emit(` +type Row = { id: number; name: string }; +function count(o: unknown): number { + let n = 0; + for (const k in o as Record) n += k.length; + return n; +} +const row: Row = { id: 1, name: "a" }; +console.log(count(row), count(JSON.parse('{"b":1}'))); +`); + expect(llvm).toContain("call ptr @scr_dyn_for_in_key_strs("); + expect(llvm).toContain("call zeroext i1 @scr_dyn_for_in_stable("); + expect(llvm).not.toContain("@scr_dyn_for_in_keys("); + // Fully required record shapes are marked fixed for the stable walk. + expect(llvm).toMatch( + /_rops = internal global \{ i64, ptr, ptr, ptr, i8, ptr \} \{[^}]*i8 1, ptr null \}/, + ); +}); + +test("Object.keys of an unknown value is a native string[] in TypeScript only", async () => { + const source = ` +function keysOf(o) { + return Object.keys(o); +} +console.log(keysOf(JSON.parse('{"a":1}')).length); +`; + const ts = await emit( + source.replace("(o)", "(o: unknown)").replace("keys(o)", "keys(o as object)"), + ); + expect(ts).toContain("call ptr @scr_dyn_obj_key_strs("); + const js = await emit(source, "main.js"); + expect(js).not.toContain("@scr_dyn_obj_key_strs("); + expect(js).toContain("call ptr @scr_dyn_obj_keys("); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5175-forin-typed-as-unknown.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5175-forin-typed-as-unknown.ts.json new file mode 100644 index 000000000..c2ccd5eb4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5175-forin-typed-as-unknown.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5175-forin-typed-as-unknown.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5176-object-keys-typed-as-unknown.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5176-object-keys-typed-as-unknown.ts.json new file mode 100644 index 000000000..89d37a914 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5176-object-keys-typed-as-unknown.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5176-object-keys-typed-as-unknown.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5177-forin-dynamic-objects.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5177-forin-dynamic-objects.ts.json new file mode 100644 index 000000000..81a19a9ce --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5177-forin-dynamic-objects.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5177-forin-dynamic-objects.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5178-forin-keys-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5178-forin-keys-js.js.json new file mode 100644 index 000000000..d70075fab --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5178-forin-keys-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5178-forin-keys-js.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5179-forin-typed-shapes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5179-forin-typed-shapes.ts.json new file mode 100644 index 000000000..731b96694 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5179-forin-typed-shapes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5179-forin-typed-shapes.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 3d81e84f9..89cc33eb2 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -8577,6 +8577,120 @@ ScrDyn *scr_dyn_for_in_keys(const ScrDyn *v) { } } +/* Whether for-in's key snapshot of `v` stays exact through the walk, so + * visits need no live presence test: a typed record whose fields are all + * required cannot lose a key. */ +bool scr_dyn_for_in_stable(const ScrDyn *v) { + if (v->kind != SCR_DYN_TYPED_REF) return false; + const ScrDynTypedRecordOps *record = scr_typed_ref_record_ops(v); + return record != NULL && record->fixed; +} + +/* The record shape's key array for walks that only read it: built once, + * immortal, and shared by every thread through the ops' atomic slot. */ +static ScrArr *scr_record_shared_keys(const ScrDynTypedRecordOps *record) { + ScrArr **slot = &((ScrDynTypedRecordOps *)record)->keys; + ScrArr *keys = __atomic_load_n(slot, __ATOMIC_ACQUIRE); + if (keys) return keys; + ScrArr *built = scr_arr_new(SCR_ELEM_STR, record->count); + for (size_t i = 0; i < record->count; i++) + scr_arr_push_ref(built, scr_str_retain(record->names[i])); + built->rc = SIZE_MAX; + ScrArr *expected = NULL; + if (__atomic_compare_exchange_n(slot, &expected, built, false, __ATOMIC_ACQ_REL, + __ATOMIC_ACQUIRE)) { +#ifdef SCR_RC_AUDIT + scr_arr_live_forget(); /* immortal: outside the exit-time RC audit */ +#endif + return built; + } + built->rc = 1; + scr_arr_release(built); + return expected; +} + +/* A typed record's present field names, or a plain object's enumerable + * own keys (integer-like keys ascending, then insertion order), as a + * native string[] (+1); NULL for every other value. `shared`: the caller + * only reads the array, so a record shape's cached array may serve. */ +static ScrArr *scr_dyn_own_key_strs(const ScrDyn *v, bool shared) { + if (v->kind == SCR_DYN_TYPED_REF) { + const ScrDynTypedRecordOps *record = scr_typed_ref_record_ops(v); + if (!record) return NULL; + void *rec = v->v.typed_ref.ptr; + bool all = true; + for (size_t i = 0; i < record->count && all; i++) all = record->has(rec, i); + if (shared && all) return scr_record_shared_keys(record); + ScrArr *out = scr_arr_new(SCR_ELEM_STR, record->count); + for (size_t i = 0; i < record->count; i++) + if (record->has(rec, i)) scr_arr_push_ref(out, scr_str_retain(record->names[i])); + return out; + } + if (v->kind != SCR_DYN_OBJ) return NULL; + size_t n = v->v.obj.len; + ScrArr *out = scr_arr_new(SCR_ELEM_STR, n); + ScrKeyIndex *indices = NULL; + size_t count = 0; + for (size_t i = 0; i < n; i++) { + const ScrDynEntry *entry = &v->v.obj.entries[i]; + uint32_t index; + if (!entry->enumerable || !scr_key_array_index(entry->key, entry->key_len, &index)) continue; + if (!indices) { + indices = malloc(n * sizeof *indices); + if (!indices) scr_json_oom(); + } + indices[count++] = (ScrKeyIndex){index, i}; + } + if (count) { + scr_key_index_sort(indices, count); + for (size_t i = 0; i < count; i++) { + const ScrDynEntry *entry = &v->v.obj.entries[indices[i].entry]; + scr_arr_push_ref(out, scr_str_new(entry->key, entry->key_len)); + } + } + free(indices); + for (size_t i = 0; i < n; i++) { + const ScrDynEntry *entry = &v->v.obj.entries[i]; + uint32_t index; + if (entry->enumerable && !scr_key_array_index(entry->key, entry->key_len, &index)) + scr_arr_push_ref(out, scr_str_new(entry->key, entry->key_len)); + } + return out; +} + +/* A dyn string array's strings as a native string[] (+1), releasing it. */ +static ScrArr *scr_dyn_key_strs_from(ScrDyn *keys) { + if (!keys) return NULL; + ScrArr *out = scr_arr_new(SCR_ELEM_STR, keys->v.arr.len); + for (size_t i = 0; i < keys->v.arr.len; i++) + scr_arr_push_ref(out, scr_str_retain(keys->v.arr.items[i]->v.str)); + scr_dyn_release(keys); + return out; +} + +/* for-in's key snapshot as a native string[] (+1): the typed record's + * field names, or a plain object's enumerable own keys (integer-like keys + * ascending, then insertion order) when its prototype contributes none. + * Every other value takes the dyn walk and converts its result. */ +ScrArr *scr_dyn_for_in_key_strs(const ScrDyn *v) { + if (scr_dyn_class_reflection_fence(v)) return NULL; + /* A plain object's prototype adds keys unless it is Object.prototype. */ + if (v->kind != SCR_DYN_OBJ || v->prototype == NULL || + v->prototype == scr_builtin_object_prototype) { + ScrArr *own = scr_dyn_own_key_strs(v, true); + if (own) return own; + } + return scr_dyn_key_strs_from(scr_dyn_for_in_keys(v)); +} + +/* Object.keys as a native string[] (+1): a fresh array the caller owns. */ +ScrArr *scr_dyn_obj_key_strs(const ScrDyn *v) { + if (scr_dyn_class_reflection_fence(v)) return NULL; + ScrArr *own = scr_dyn_own_key_strs(v, false); + if (own) return own; + return scr_dyn_key_strs_from(scr_dyn_obj_keys(v)); +} + /* One source's own enumerable members onto an OBJ target. OBJ sources * snapshot their keys, then check attributes and read values in order so * getters can affect later entries. Arrays, strings, and bytes use their diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 17fd53dd1..6973eadc4 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -4706,6 +4706,12 @@ typedef struct ScrDynTypedRecordOps { ScrStr *const *names; /* immortal field-name strings */ bool (*has)(void *rec, size_t index); ScrDyn *(*get)(void *rec, size_t index); + /* Every field is required: `has` always answers true and no key of the + * record can be deleted. */ + bool fixed; + /* The field names as an immortal string[], built on first use by + * for-in over a record with every field present (atomic slot). */ + ScrArr *keys; } ScrDynTypedRecordOps; /* Prototype members of a compiled class seen as `unknown`, emitted per @@ -4903,6 +4909,11 @@ bool scr_dyn_field_eq_bool(const ScrDyn *d, const ScrStr *key, bool value); * TypeError. */ ScrDyn *scr_dyn_obj_keys(const ScrDyn *v); ScrDyn *scr_dyn_for_in_keys(const ScrDyn *v); +/* for-in keys as a native string[] (+1); NULL with an exception pending. */ +ScrArr *scr_dyn_for_in_key_strs(const ScrDyn *v); +bool scr_dyn_for_in_stable(const ScrDyn *v); +/* Object.keys as a native string[] (+1); NULL with an exception pending. */ +ScrArr *scr_dyn_obj_key_strs(const ScrDyn *v); /* Snapshot all own string keys of a SCR_DYN_OBJ in JS order. Returns +1. */ ScrDyn *scr_dyn_obj_own_keys(const ScrDyn *v); ScrDyn *scr_dyn_own_keys(const ScrDyn *value); diff --git a/tests/corpus/5175-forin-typed-as-unknown.ts b/tests/corpus/5175-forin-typed-as-unknown.ts new file mode 100644 index 000000000..7f4d40956 --- /dev/null +++ b/tests/corpus/5175-forin-typed-as-unknown.ts @@ -0,0 +1,61 @@ +// for-in over typed records seen as unknown or any: key order, optional +// fields, loop control and every binding form. +const keysIn = (o: unknown): string => { + const out: string[] = []; + for (const k in o as Record) out.push(k); + return out.join(","); +}; +type Row = { id: number; name: string; tags: string[]; nested: { a: number; b: number } }; +const row: Row = { id: 1, name: "r", tags: ["x"], nested: { a: 1, b: 2 } }; +console.log("row", keysIn(row), keysIn(row.nested), keysIn(row.tags)); +type Opt = { zeta: number; alpha?: string; mid: boolean; extra?: number[] }; +const opts: Opt[] = [ + { zeta: 1, mid: true }, + { zeta: 2, alpha: "a", mid: false }, + { zeta: 3, mid: true, extra: [1] }, + { zeta: 4, alpha: undefined, mid: false }, +]; +for (const o of opts) console.log("opt", keysIn(o)); +const numeric = { b: 1, "2": 2, a: 3, "1": 4, "10": 5, "-1": 6, "01": 7 }; +console.log("numeric", keysIn(numeric)); + +// The same shape walked many times, and two shapes interleaved. +let total = 0; +const other = { p: 1, q: 2, r: 3 }; +for (let i = 0; i < 1000; i++) total += keysIn(i % 2 ? row : other).length; +console.log("repeat", total, keysIn(row), keysIn(other)); + +// break, continue, labels, nested loops. +function walk(o: unknown): string { + const out: string[] = []; + outer: for (const k in o as Record) { + if (k === "name") continue; + for (const j in o as Record) { + if (j === k) continue outer; + if (j === "tags") break; + out.push(k + ":" + j); + } + if (k === "nested") break; + } + return out.join(" "); +} +console.log("control", walk(row)); + +// Binding forms: var, a pre-declared let, and reading values by key. +function forms(o: unknown): string { + const rec = o as Record; + let last = ""; + for (last in rec) { + // assigns the outer binding + } + const out: string[] = []; + for (var k in rec) out.push(k + "=" + JSON.stringify(rec[k])); + return last + "|" + out.join(";"); +} +console.log("forms", forms(row)); + +// any receivers. +const loose: any = row; +const anyKeys: string[] = []; +for (const k in loose) anyKeys.push(k); +console.log("any", anyKeys.join(",")); diff --git a/tests/corpus/5176-object-keys-typed-as-unknown.ts b/tests/corpus/5176-object-keys-typed-as-unknown.ts new file mode 100644 index 000000000..8c9cc88ae --- /dev/null +++ b/tests/corpus/5176-object-keys-typed-as-unknown.ts @@ -0,0 +1,38 @@ +// Object.keys over values seen as unknown: typed records, optional +// fields, arrays, strings, nullish receivers, and independent results. +const keysOf = (o: unknown): string[] => Object.keys(o as object); +type Point = { x: number; y: number; label?: string }; +const p: Point = { x: 1, y: 2 }; +const q: Point = { x: 3, y: 4, label: "q" }; +console.log(keysOf(p).join(","), keysOf(q).join(","), keysOf(p).length + keysOf(q).length); +console.log(keysOf([5, 6, 7]).join(","), keysOf("hey").join(","), keysOf(42).length, keysOf(true).length); +console.log(keysOf({ b: 1, "3": 2, a: 3, "1": 4 }).join(",")); + +// Each call returns a new array the caller may change. +const first = keysOf(p); +first.push("added"); +first[0] = "changed"; +console.log(first.join(","), keysOf(p).join(","), keysOf({ ...p }).join(",")); +const sorted = keysOf(q).sort(); +console.log(sorted.join(","), keysOf(q).join(",")); + +for (const bad of [null, undefined]) { + try { + keysOf(bad); + } catch (e) { + console.log((e as Error).name, (e as Error).message); + } +} + +// Keys feed ordinary string operations. +const joined = keysOf(q) + .map((k) => k.toUpperCase()) + .filter((k) => k !== "Y") + .join("-"); +console.log(joined, keysOf(q).includes("label"), keysOf(q).indexOf("y")); + +class Box { + width = 2; + height = 3; +} +console.log(keysOf(new Box()).join(",")); diff --git a/tests/corpus/5177-forin-dynamic-objects.ts b/tests/corpus/5177-forin-dynamic-objects.ts new file mode 100644 index 000000000..de1b0d061 --- /dev/null +++ b/tests/corpus/5177-forin-dynamic-objects.ts @@ -0,0 +1,33 @@ +// for-in and Object.keys over checked-dynamic objects: JSON values, +// prototypes, null-prototype objects, and keys added or deleted mid-walk. +const parsed: any = JSON.parse('{"b":1,"2":2,"a":3,"1":4,"nested":{"z":1,"y":2}}'); +const keysIn = (o: unknown): string => { + const out: string[] = []; + for (const k in o as Record) out.push(k); + return out.join(","); +}; +console.log(keysIn(parsed), keysIn(parsed.nested), Object.keys(parsed).join(",")); + +const visited: string[] = []; +for (const k in parsed) { + visited.push(k); + if (k === "1") { + delete parsed.a; + parsed.added = true; + } +} +console.log("mutating", visited.join(","), Object.keys(parsed).join(",")); + +const bare = Object.create(null); +bare.z = 1; +bare["5"] = 2; +bare.y = 3; +console.log("null proto", keysIn(bare), Object.keys(bare).join(",")); + +const list: any = JSON.parse("[10, 20, 30]"); +console.log("array", keysIn(list), Object.keys(list).join(",")); + +let count = 0; +for (let i = 0; i < 2000; i++) for (const k in parsed) count += k.length; +console.log("repeat", count); +console.log("empty", keysIn(JSON.parse("{}")), "[" + keysIn(null) + "]", "[" + keysIn(undefined) + "]"); diff --git a/tests/corpus/5178-forin-keys-js.js b/tests/corpus/5178-forin-keys-js.js new file mode 100644 index 000000000..681bc327e --- /dev/null +++ b/tests/corpus/5178-forin-keys-js.js @@ -0,0 +1,23 @@ +// JavaScript for-in and Object.keys: results stay ordinary arrays that +// accept any value, and dynamic objects keep JS key order. +function keysIn(o) { + const out = []; + for (const k in o) out.push(k); + return out; +} +const rec = { zeta: 1, alpha: 2, 3: "three", 1: "one" }; +console.log(keysIn(rec).join(","), Object.keys(rec).join(",")); +const ks = Object.keys(rec); +ks.push(42, null); +ks.unshift({ k: 1 }); +console.log(JSON.stringify(ks), Object.keys(rec).length); +const parsed = JSON.parse('{"b":1,"a":2}'); +const pk = Object.keys(parsed); +pk[5] = "gap"; +console.log(pk.length, JSON.stringify(pk)); +console.log(keysIn([1, 2]).join(","), keysIn("ab").join(",")); +function Legacy() { + this.own = 1; +} +Legacy.prototype.shared = 2; +console.log(keysIn(new Legacy()).join(","), Object.keys(new Legacy()).join(",")); diff --git a/tests/corpus/5179-forin-typed-shapes.ts b/tests/corpus/5179-forin-typed-shapes.ts new file mode 100644 index 000000000..7d8add030 --- /dev/null +++ b/tests/corpus/5179-forin-typed-shapes.ts @@ -0,0 +1,33 @@ +// for-in over typed values crossing an unknown boundary: records in +// arrays and maps, generic helpers, and records with every field optional. +type Partial3 = { a?: number; b?: string; c?: boolean }; +const partials: Partial3[] = [{}, { a: 1 }, { b: "x", c: true }, { a: 1, b: "y", c: false }]; +function keyList(o: unknown): string { + const out: string[] = []; + for (const k in o as Record) out.push(k); + return "[" + out.join(",") + "]"; +} +console.log(partials.map(keyList).join(" ")); + +function describe(value: T): string { + return keyList(value) + Object.keys(value as unknown as object).length; +} +console.log(describe({ one: 1 }), describe({ two: 2, three: [3] })); + +const byName = new Map([ + ["a", { id: 1, tag: "t" }], + ["b", { id: 2, tag: "u" }], +]); +for (const [name, v] of byName) console.log(name, keyList(v)); + +// Writes through the unknown view are visible to later walks. +const live: { x: number; y?: number } = { x: 1 }; +const view = live as unknown as Record; +console.log(keyList(view)); +live.y = 2; +console.log(keyList(view), Object.keys(view).join(",")); + +let sum = 0; +const shapes = [{ k1: 1 }, { k1: 1, k2: 2 }, { k3: 3 }]; +for (let i = 0; i < 3000; i++) for (const k in shapes[i % 3] as Record) sum += k.length; +console.log("sum", sum);