diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index b4789e245a97..ba2be07da7e3 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -104,7 +104,9 @@ the ultimate source of truth. Upstream test262 files run verbatim from `test/tes synchronous iterators, and confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`. - [x] `for...in` over own keys of plain objects, arrays, strings, and tool references. `null`, `undefined`, and other non-objects iterate nothing. An un-awaited promise throws rather than iterating. -- [ ] `for...in` over inherited enumerable keys (`Object.create(proto)`), and skipping keys deleted during the loop. +- [x] `for...in` over inherited enumerable keys (`Object.create(proto)`): own keys first, then each prototype's, skipping + keys a nearer level already has, even as a non-enumerable property. +- [ ] `for...in` skipping keys deleted during the loop. - [x] Unlabeled `break` and `continue`. - [x] `try`, `catch`, optional catch bindings, and `finally`. - [x] `throw` with arbitrary values. @@ -338,8 +340,8 @@ reject }` object. (`Object.getPrototypeOf("a") === String.prototype`); `null`, `undefined`, and the two confined symbols throw a `TypeError`. - [x] `Object.create(proto)` with an object or `null` prototype; any other prototype is a `TypeError` - (`Object prototype may only be an Object or null`). Inherited reads, `in`, `hasOwnProperty`, and own-only - `Object.keys` follow the chain as in JS, but `for...in` still enumerates own keys only. A second `properties` + (`Object prototype may only be an Object or null`). Inherited reads, `in`, `hasOwnProperty`, own-only + `Object.keys`, and `for...in` follow the chain as in JS. A second `properties` argument other than `undefined` throws a `TypeError`: property descriptors are not supported (there is no `Object.defineProperty` either). - [x] `Object.freeze`, `Object.seal`, and `Object.preventExtensions`, with `isFrozen`, `isSealed`, and `isExtensible`. diff --git a/packages/codemode/src/interpreter/interpreter.ts b/packages/codemode/src/interpreter/interpreter.ts index 8cdab139da5e..555de4ad2408 100644 --- a/packages/codemode/src/interpreter/interpreter.ts +++ b/packages/codemode/src/interpreter/interpreter.ts @@ -80,6 +80,7 @@ import { IteratorObj, type Cursor, has, + hasOwn, hidden, hasPrototype, keys, @@ -1097,11 +1098,23 @@ class Frame { throw typeError(`${context} must be a function.`, node) } - // for...in over null/undefined iterates nothing, like JS. + // for...in over null/undefined iterates nothing, like JS. After the own keys come each prototype's enumerable + // string keys, skipping any key a nearer level has, even as a non-enumerable property. private enumerableKeys(value: Value, node: AstNode): Array { if (value instanceof ToolReference) return [...this.ctx.tools.keys(value.path)] if (value === null || value === undefined) return [] - return keys(enumerableSource(this.ctx, "for...in", value, node)) + const source = enumerableSource(this.ctx, "for...in", value, node) + const chain = [source] + // A string's characters stand in an array, but its prototype is String.prototype. + let proto = primitivePrototype(this.ctx.builtins, value) ?? source.proto + while (proto !== null) { + chain.push(proto) + proto = proto.proto + } + return chain.flatMap((target, depth) => { + const nearer = chain.slice(0, depth) + return keys(target).filter((key) => !nearer.some((level) => hasOwn(level, key))) + }) } private *evaluateForInStatement(node: ForInStatement, labels?: ReadonlySet): Eval { diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts index b9daf647c059..c46b6048d5be 100644 --- a/packages/codemode/test/enumeration.test.ts +++ b/packages/codemode/test/enumeration.test.ts @@ -176,4 +176,52 @@ describe("for...in", () => { "un-awaited Promise", ) }) + + const forIn = (target: string, setup = "") => + value(`${setup}; const out = []; for (const key in ${target}) out.push(key); return out`) + + test("visits inherited enumerable keys after own keys", async () => { + expect(await forIn("o", "const p = { inh: 1 }; const o = Object.create(p); o.own = 2")).toEqual(["own", "inh"]) + }) + + test("walks every level, skipping keys a nearer level already has", async () => { + expect( + await forIn( + "o", + `const top = { a: 1, top: 1 } + const mid = Object.create(top); mid.a = 2; mid.mid = 2 + const o = Object.create(mid); o.own = 3; o.mid = 3`, + ), + ).toEqual(["own", "mid", "a", "top"]) + }) + + test("orders integer-like keys first on each level", async () => { + expect( + await forIn("o", `const p = { b: 1, 10: 1, 2: 1 }; const o = Object.create(p); o.z = 1; o[5] = 1; o[1] = 1`), + ).toEqual(["1", "5", "z", "2", "10", "b"]) + }) + + test("a non-enumerable nearer property shadows an enumerable inherited one", async () => { + // An array's own length and Array.prototype.push are non-enumerable. + const setup = "Object.prototype.length = 1; Object.prototype.push = 2; Object.prototype.extra = 3" + expect(await forIn("[5]", setup)).toEqual(["0", "extra"]) + expect(await forIn("Object.create([5])", setup)).toEqual(["0", "extra"]) + }) + + test("a null prototype ends the walk", async () => { + expect(await forIn("o", "const o = Object.create(null); o.a = 1")).toEqual(["a"]) + expect(await forIn("Object.create(null)")).toEqual([]) + }) + + test("arrays and strings only visit their indexes, since built-in methods are non-enumerable", async () => { + expect(await forIn("[7, 8]")).toEqual(["0", "1"]) + expect(await forIn(`"ab"`)).toEqual(["0", "1"]) + }) + + test("visits enumerable properties a program adds to a built-in prototype", async () => { + expect(await forIn("[7, 8]", "Array.prototype.extra = 1")).toEqual(["0", "1", "extra"]) + expect(await forIn(`"ab"`, "Array.prototype.extra = 1")).toEqual(["0", "1"]) + expect(await forIn(`"ab"`, "String.prototype.extra = 1")).toEqual(["0", "1", "extra"]) + expect(await forIn("5", "Number.prototype.extra = 1")).toEqual(["extra"]) + }) }) diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 02f5d9eaa24e..72b00a447a53 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -1382,7 +1382,7 @@ describe("Object.getPrototypeOf and Object.create", () => { expect((await error(`Object.getPrototypeOf(Symbol.iterator)`)).message).toContain("cannot convert a symbol") }) - test("Object.create links the prototype: inherited reads, in, and own-only keys", async () => { + test("Object.create links the prototype: inherited reads, in, own-only keys, and inherited for...in", async () => { expect( await value(` const p = { greet(name) { return "hi " + name }, a: 1 } @@ -1392,7 +1392,7 @@ describe("Object.getPrototypeOf and Object.create", () => { for (const key in c) seen.push(key) return ["greet" in c, Object.keys(c), c.hasOwnProperty("a"), c.a, c.greet(c.name), Object.getPrototypeOf(c) === p, Object.getPrototypeOf(Object.create(null)), seen] `), - ).toEqual([true, ["name"], false, 1, "hi x", true, null, ["name"]]) + ).toEqual([true, ["name"], false, 1, "hi x", true, null, ["name", "greet", "a"]]) }) test("Object.create rejects non-object prototypes and property descriptors", async () => {