From e7863700bbeccc01c0b581ed27ae9f690c1693a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 15:06:31 +0000 Subject: [PATCH 1/6] fix(runtime): resolve an ancestor's private brand through the instance's evaluation heritage chain (#11127, #11131) --- .../src/object/class_constructors.rs | 2 +- .../src/object/field_get_set/ic_miss.rs | 8 ++++++- .../ic_miss/private_member_access.rs | 24 +++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 3bedba1be3..247324d7f3 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -1219,7 +1219,7 @@ static KEEP_JS_ERROR_SUBCLASS_DEFAULT_INIT: unsafe extern "C" fn(f64, f64) = /// fresh derived class's pinned parent chain. The template class-id registry /// identifies which constructor to replay, but it cannot identify which /// evaluation's captured environment belongs to that constructor. -fn pinned_class_object_for_ancestor(start: f64, target_cid: u32) -> Option { +pub(crate) fn pinned_class_object_for_ancestor(start: f64, target_cid: u32) -> Option { let mut current = start; let mut depth = 0usize; while depth < 32 && super::class_registry::is_class_object_value(current) { diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index 5749b19c03..61f4f1a87f 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -1688,7 +1688,8 @@ fn private_evaluation_brand(value: f64, declaring_class_id: u32) -> Option return None; } let value = crate::proxy::private_element_receiver(value); - if super::super::class_registry::is_class_object_value(value) { + let value_is_class_object = super::super::class_registry::is_class_object_value(value); + if value_is_class_object { let object = JSValue::from_bits(value.to_bits()).as_pointer::(); if !object.is_null() && js_object_get_class_id(object) == declaring_class_id { return Some(value.to_bits()); @@ -1709,6 +1710,11 @@ fn private_evaluation_brand(value: f64, declaring_class_id: u32) -> Option if !super::super::class_registry::is_class_object_value(brand) { return None; } + if !value_is_class_object { + // #11127/#11131: an instance carries its MOST-DERIVED evaluation; an + // ancestor's brand is that evaluation's pinned heritage chain. + return instance_ancestor_evaluation_brand(brand, declaring_class_id); + } let object = JSValue::from_bits(brand.to_bits()).as_pointer::(); (!object.is_null() && js_object_get_class_id(object) == declaring_class_id) .then_some(brand.to_bits()) diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs index 06343ba7fc..c6c9b4c92b 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs @@ -265,6 +265,30 @@ pub(crate) fn private_member_set_by_name( true } +/// Resolve an INSTANCE's private brand for `declaring_class_id` from the +/// evaluation stamped on it (`stamp_private_evaluation_brand`). +/// +/// The stamp is the most-derived class evaluation that constructed the +/// instance: `new E()` stamps E's class object even when the private member +/// being accessed was declared by an ancestor. Every ancestor evaluation whose +/// constructor ran on the instance through `super()` is reachable from that +/// stamp by the per-evaluation parent edge each fresh class object pins +/// (`js_class_object_pin_parent`), so walk it and answer with the ancestor +/// evaluation belonging to `declaring_class_id`'s template. Comparing only the +/// stamp rejected every legal `this.#x` in an inherited method when both +/// classes are per-evaluation — function-local classes (#11127), and +/// top-level classes that capture a CommonJS-wrapper local such as +/// `const EventEmitter = require("events")` (#11131). +/// +/// The walk stops at the first non-class-object heritage (a static ClassRef, +/// a closure, a builtin), which answers `None` exactly as before, and the +/// caller still requires the per-field marker, so a brand found here never +/// admits an uninitialized element. +fn instance_ancestor_evaluation_brand(brand: f64, declaring_class_id: u32) -> Option { + super::super::class_constructors::pinned_class_object_for_ancestor(brand, declaring_class_id) + .map(f64::to_bits) +} + /// If the lexical class evaluation can be recovered from `brand_owner`, /// compare `obj` against that exact evaluation. `None` asks callers to retain /// the existing template-class check for ordinary (single-evaluation) classes. From f24065ddff16837e6d3127e929f27d354e23334d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 15:46:48 +0000 Subject: [PATCH 2/6] test: private brand of an ancestor evaluation on subclass instances (#11127, #11131) --- .../private_brand_ancestor_evaluation.rs | 107 ++++++++++++++++++ ...7_private_field_function_local_subclass.ts | 61 ++++++++++ ...1131_private_field_cjs_require_subclass.ts | 84 ++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 crates/perry/tests/private_brand_ancestor_evaluation.rs create mode 100644 test-files/test_gap_11127_private_field_function_local_subclass.ts create mode 100644 test-files/test_gap_11131_private_field_cjs_require_subclass.ts diff --git a/crates/perry/tests/private_brand_ancestor_evaluation.rs b/crates/perry/tests/private_brand_ancestor_evaluation.rs new file mode 100644 index 0000000000..3e55623432 --- /dev/null +++ b/crates/perry/tests/private_brand_ancestor_evaluation.rs @@ -0,0 +1,107 @@ +//! #11131: a class whose constructor stores `new EventEmitter()` (from a bare +//! CommonJS `require("events")`) in a `#private` field could not read that +//! field back through an inherited method once the class was constructed as +//! the parent of a subclass: +//! `TypeError: Cannot access private member from an object whose class did +//! not declare it`. +//! +//! EventEmitter is incidental. Capturing the CommonJS-wrapper local +//! `EventEmitter` makes both top-level classes per-evaluation classes +//! (`ClassExprFresh`). An instance is stamped with its MOST-DERIVED class +//! evaluation, and the private-brand check compared that stamp against the +//! declaring class exactly, so an ancestor's private member was rejected on +//! every subclass instance. The same defect, without any CommonJS, is #11127 +//! (function-local classes) — covered by +//! `test-files/test_gap_11127_private_field_function_local_subclass.ts`. +//! +//! This file pins the literal bare-`require` form, which the gap suite cannot +//! run: Node executes `test-files/*.ts` as ESM (the repo's package.json has +//! `"type": "module"`), where `require` is undefined. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &std::path::Path, file_name: &str, source: &str) -> String { + let entry = dir.join(file_name); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// The issue's repro plus a grandchild, a dynamic `new`, a write through an +/// inherited method, and an ergonomic brand check (`#x in o`). Expected output +/// is Node 26.5.1's for the same source saved as `.cjs`. +const SOURCE: &str = r#" +const EventEmitter = require("events"); +class Client { + #socket; + #count = 0; + constructor() { this.#socket = new EventEmitter(); } + kind() { return typeof this.#socket; } + bump() { return ++this.#count; } + static has(o) { return #socket in o; } +} +class Sub extends Client {} +class SubSub extends Sub {} +try { console.log("private", new Sub().kind()); } catch (e) { console.log("private threw", e.message); } +console.log("direct", new Client().kind()); +console.log("dynamic", new Sub().kind()); +const g = new SubSub(); +g.bump(); +console.log("grandchild", g.kind(), g.bump(), Client.has(g), Client.has({})); +"#; + +const EXPECTED: &str = "private object\n\ +direct object\n\ +dynamic object\n\ +grandchild object 2 true false\n"; + +#[test] +fn cjs_entry_subclass_reads_parent_private_field() { + let dir = tempfile::tempdir().expect("tempdir"); + assert_eq!(compile_and_run(dir.path(), "main.cjs", SOURCE), EXPECTED); +} + +/// The issue's own spelling: a `.ts` entry whose bare `require` makes Perry +/// wrap it as CommonJS. +#[test] +fn ts_entry_with_bare_require_subclass_reads_parent_private_field() { + let dir = tempfile::tempdir().expect("tempdir"); + let ts = SOURCE + .replace(" #socket;", " #socket: any;") + .replace("static has(o)", "static has(o: any)") + .replace("e.message", "(e as Error).message"); + assert_eq!(compile_and_run(dir.path(), "main.ts", &ts), EXPECTED); +} diff --git a/test-files/test_gap_11127_private_field_function_local_subclass.ts b/test-files/test_gap_11127_private_field_function_local_subclass.ts new file mode 100644 index 0000000000..51e7fea443 --- /dev/null +++ b/test-files/test_gap_11127_private_field_function_local_subclass.ts @@ -0,0 +1,61 @@ +// #11127: a base class with a #private field and a subclass, both declared +// inside a function, are per-evaluation classes. An inherited base method +// (not only a getter) must read and write the base's private elements on a +// subclass instance. + +function local() { + class S { + #l = 0; + get l() { return this.#l; } + read() { return this.#l; } + inc() { ++this.#l; } + addTo(n: number) { this.#l += n; return this.#l; } + has(o: any) { return #l in o; } + #hidden() { return "hidden:" + this.#l; } + callHidden() { return this.#hidden(); } + } + class E extends S { x = 1; } + class F extends E { y = 2; } + const a = new E(); + console.log("getter", a.l); + console.log("read", a.read()); + a.inc(); + console.log("after inc", a.l, a.read()); + console.log("addTo", a.addTo(5)); + console.log("brand check", a.has(a), a.has({})); + console.log("private method", a.callHidden()); + const b = new F(); + b.inc(); + b.inc(); + console.log("grandchild", b.read(), b.x, b.y, b.has(b)); + const s = new S(); + s.inc(); + console.log("base", s.read(), s.has(a), a.has(s)); + return { S, E, a }; +} +const first = local(); +const second = local(); +// A second evaluation's methods must NOT accept the first evaluation's +// instances: each evaluation has its own private names. +console.log("same eval", first.a.read(), second.a.read()); +console.log("cross-eval brand", second.a.has(first.a), first.a.has(second.a)); +try { + console.log("cross-eval read", second.S.prototype.read.call(first.a)); +} catch (e) { + console.log("cross-eval read threw", (e as Error).constructor.name); +} + +// A factory whose class expression extends a function-local base. +function makePair(start: number) { + class Counter { + #n: number; + constructor() { this.#n = start; } + next() { return this.#n++; } + } + return class extends Counter { + twice() { return [this.next(), this.next()]; } + }; +} +const P = makePair(10); +const p = new P(); +console.log("factory", p.twice().join(","), p.next()); diff --git a/test-files/test_gap_11131_private_field_cjs_require_subclass.ts b/test-files/test_gap_11131_private_field_cjs_require_subclass.ts new file mode 100644 index 0000000000..c011447a0a --- /dev/null +++ b/test-files/test_gap_11131_private_field_cjs_require_subclass.ts @@ -0,0 +1,84 @@ +// #11131: a top-level class that captures a CommonJS local +// (`const EventEmitter = require("events")`) is lowered inside the CJS +// wrapper, so it becomes a per-evaluation class. When it is constructed as +// the parent of a subclass, its inherited methods must still read its +// #private fields. Mirrors @redis/client's RedisClient/RedisSocket shape. +// (@redis/client then subclasses RedisClient through `attachConfig`'s +// `class extends BaseClass {}`, whose `extends` operand is a parameter; that +// dynamic-heritage class expression additionally needs #11042's +// per-evaluation class expressions and is not exercised here.) +// +// Node runs test-files/*.ts as ESM (the repo's package.json has +// "type": "module"), where a bare `require` does not exist. So this file +// spells the CJS wrapper out: the module body is a function and the +// `require`d EventEmitter is a local of it -- the exact scope shape Perry's +// wrapper gives a CommonJS module. The literal bare-`require` form is covered +// by crates/perry/tests/private_brand_ancestor_evaluation.rs. +import events from "node:events"; + +function cjsModuleBody(require: (id: string) => any) { + const EventEmitter = require("events"); + + class Client { + #socket: any; + #queue: string[] = []; + constructor() { + this.#socket = new EventEmitter(); + } + kind() { + return typeof this.#socket; + } + send(cmd: string) { + this.#queue.push(cmd); + this.#socket.emit("data", cmd); + return this.#queue.length; + } + onData(fn: (s: string) => void) { + this.#socket.on("data", fn); + return this; + } + static hasSocket(o: any) { + return #socket in o; + } + } + + class Sub extends Client {} + + try { + console.log("private", new Sub().kind()); + } catch (e) { + console.log("private threw", (e as Error).message); + } + console.log("direct", new Client().kind()); + console.log("dynamic", new (Sub as any)().kind()); + + const seen: string[] = []; + const s = new Sub().onData((d) => seen.push(d)); + console.log("send", s.send("PING"), s.send("SET k v"), seen.join("|")); + console.log("brand", Client.hasSocket(s), Client.hasSocket({})); + + class Socket extends EventEmitter { + #connected = false; + connect() { + this.#connected = true; + this.emit("connect"); + return this.#connected; + } + } + class RedisLike { + #socket: Socket; + constructor() { + this.#socket = new Socket(); + } + connect() { + let events = 0; + this.#socket.on("connect", () => events++); + const ok = this.#socket.connect(); + return ok + ":" + events; + } + } + class Redis extends RedisLike {} + console.log("redis-like", new Redis().connect()); +} + +cjsModuleBody((id: string) => (id === "events" ? events : undefined)); From 030b7c272c2ca9ab9d5d0da2941b794c811aa9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 16:48:00 +0000 Subject: [PATCH 3/6] test(runtime): instance private brand resolves through pinned ancestor evaluations --- .../ic_miss/private_member_access.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs index c6c9b4c92b..b3dc7da434 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs @@ -413,3 +413,52 @@ pub(crate) fn test_push_catch_private_hint(marker: u32) { }); }); } + +#[cfg(test)] +mod instance_ancestor_evaluation_brand_tests { + use super::*; + + unsafe fn class_object(cid: u32) -> f64 { + let class = crate::object::js_object_alloc(cid, 0); + crate::object::class_registry::js_object_mark_class(class as i64); + crate::value::js_nanbox_pointer(class as i64) + } + + unsafe fn pin_parent(class: f64, parent: f64) { + let key = super::super::super::class_registry::parent_static::CLASS_OBJECT_PARENT_KEY; + let key = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + let class = JSValue::from_bits(class.to_bits()).as_pointer::(); + js_object_set_field_by_name(class as *mut ObjectHeader, key, parent); + } + + /// #11127/#11131: `new E()` stamps E's evaluation, yet a method of the + /// ancestor S must find S's evaluation on the instance through E's pinned + /// parent. A class object keeps the exact comparison: static private + /// elements are not inherited. + #[test] + fn instance_brand_resolves_through_pinned_ancestor_evaluations() { + unsafe { + const CID_S: u32 = 62_511; + const CID_E: u32 = 62_512; + const CID_F: u32 = 62_513; + const CID_OTHER: u32 = 62_514; + let s = class_object(CID_S); + let e = class_object(CID_E); + let f = class_object(CID_F); + pin_parent(e, s); + pin_parent(f, e); + + let instance = crate::object::js_object_alloc(CID_F, 0); + stamp_private_evaluation_brand(instance, f); + let instance = crate::value::js_nanbox_pointer(instance as i64); + + assert_eq!(private_evaluation_brand(instance, CID_F), Some(f.to_bits())); + assert_eq!(private_evaluation_brand(instance, CID_E), Some(e.to_bits())); + assert_eq!(private_evaluation_brand(instance, CID_S), Some(s.to_bits())); + assert_eq!(private_evaluation_brand(instance, CID_OTHER), None); + + assert_eq!(private_evaluation_brand(f, CID_F), Some(f.to_bits())); + assert_eq!(private_evaluation_brand(f, CID_S), None); + } + } +} From 59e46b17e81aa3019013391ca8bbe3e803d39196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 17:40:02 +0000 Subject: [PATCH 4/6] changelog: #11141 private brand of ancestor evaluations --- changelog.d/11141-private-brand-ancestor-evaluation.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/11141-private-brand-ancestor-evaluation.md diff --git a/changelog.d/11141-private-brand-ancestor-evaluation.md b/changelog.d/11141-private-brand-ancestor-evaluation.md new file mode 100644 index 0000000000..c03ff1709e --- /dev/null +++ b/changelog.d/11141-private-brand-ancestor-evaluation.md @@ -0,0 +1,7 @@ +- **Inherited methods can now read an ancestor's `#private` members on a subclass instance when the classes are per-evaluation** (#11127, #11131). Both issues had the same runtime root cause. A class declared inside a function is lowered as a per-evaluation class (`ClassExprFresh`). So is a top-level class that captures a CommonJS-wrapper local, for example `const EventEmitter = require("events")`. `new E()` stamps the instance with E's evaluation (`meta.private_evaluation_brand`). Vtable dispatch into an inherited method `S.read` then pushes S's evaluation as the lexical private brand. `private_evaluation_brand(instance, S)` compared the stamp to S's template id exactly and got `None`, so `this.#l` on every subclass instance threw `Cannot access private member from an object whose class did not declare it`. Getters did not throw because they take a different path that falls back to the per-field marker. In #11131, `EventEmitter` does not matter by itself. It only matters because capturing the CJS local turns `Client` into a per-evaluation class, which is why the `import { EventEmitter }` form works. + + Fix (`perry-runtime`): for an instance, `private_evaluation_brand` now walks from the stamped evaluation up each fresh class object's pinned per-evaluation parent (`__perry_parent_class`, set by `js_class_object_pin_parent`) and answers with the ancestor evaluation for the declaring template. It reuses the existing `pinned_class_object_for_ancestor` walk from `class_constructors.rs`, which constructor replay already uses. The walk stops at the first heritage that is not a class object and returns `None`, the same result as before. Class-object receivers (static private members) keep the exact comparison, because static private elements are not inherited. A private access still requires the per-field marker afterwards, so the walk cannot admit an uninitialized element. The brand remains exact per evaluation: a second evaluation's method rejects the first evaluation's instance, and the gap test asserts this. + + Not covered: a subclass that is a class *expression* with a dynamic `extends` operand, such as @redis/client's `attachConfig` `class extends BaseClass {}`. On `main` that expression is a shared template, so its instances carry no evaluation stamp to walk from. #11122 (#11042) gives it its own evaluation, and with #11122 applied this fix covers that shape too (verified on a local stack). + + Tests: `test-files/test_gap_11127_private_field_function_local_subclass.ts`, `test-files/test_gap_11131_private_field_cjs_require_subclass.ts` (ESM spelling of the CJS wrapper scope, because Node runs `test-files/*.ts` as ESM), `crates/perry/tests/private_brand_ancestor_evaluation.rs` (the literal bare-`require` form as `.cjs` and `.ts` entries), and a `perry-runtime` unit test for the walk. From 551b8f0410743d7a1a8f9d4baefdfaa77a56e6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 19:41:15 +0000 Subject: [PATCH 5/6] test(gap): cover redis attachConfig's per-call class extends BaseClass (#11131) --- ...1131_private_field_cjs_require_subclass.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/test-files/test_gap_11131_private_field_cjs_require_subclass.ts b/test-files/test_gap_11131_private_field_cjs_require_subclass.ts index c011447a0a..04c23b4569 100644 --- a/test-files/test_gap_11131_private_field_cjs_require_subclass.ts +++ b/test-files/test_gap_11131_private_field_cjs_require_subclass.ts @@ -2,11 +2,10 @@ // (`const EventEmitter = require("events")`) is lowered inside the CJS // wrapper, so it becomes a per-evaluation class. When it is constructed as // the parent of a subclass, its inherited methods must still read its -// #private fields. Mirrors @redis/client's RedisClient/RedisSocket shape. -// (@redis/client then subclasses RedisClient through `attachConfig`'s -// `class extends BaseClass {}`, whose `extends` operand is a parameter; that -// dynamic-heritage class expression additionally needs #11042's -// per-evaluation class expressions and is not exercised here.) +// #private fields. Mirrors @redis/client's RedisClient/RedisSocket shape, +// including `attachConfig`'s `class extends BaseClass {}` built inside a +// helper whose `extends` operand is a parameter (a per-call class expression +// since #11042). // // Node runs test-files/*.ts as ESM (the repo's package.json has // "type": "module"), where a bare `require` does not exist. So this file @@ -57,6 +56,19 @@ function cjsModuleBody(require: (id: string) => any) { console.log("send", s.send("PING"), s.send("SET k v"), seen.join("|")); console.log("brand", Client.hasSocket(s), Client.hasSocket({})); + function attachConfig({ BaseClass, commands }: { BaseClass: any; commands: Record }) { + const Class = class extends BaseClass {}; + for (const [name, reply] of Object.entries(commands)) { + Class.prototype[name] = function (this: any) { + return this.send(reply); + }; + } + return Class; + } + const Attached = attachConfig({ BaseClass: Client, commands: { PING: "PONG" } }); + const c: any = new Attached(); + console.log("attached", c.kind(), c.PING(), Client.hasSocket(c), c instanceof Client); + class Socket extends EventEmitter { #connected = false; connect() { @@ -77,7 +89,7 @@ function cjsModuleBody(require: (id: string) => any) { return ok + ":" + events; } } - class Redis extends RedisLike {} + const Redis = attachConfig({ BaseClass: RedisLike, commands: {} }); console.log("redis-like", new Redis().connect()); } From 1423921b7f23a9c22dac4d0b8706252d8761b3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 19:41:22 +0000 Subject: [PATCH 6/6] changelog: #11141 attachConfig coverage, #11142 remainder --- changelog.d/11141-private-brand-ancestor-evaluation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/11141-private-brand-ancestor-evaluation.md b/changelog.d/11141-private-brand-ancestor-evaluation.md index c03ff1709e..4b9b07aa47 100644 --- a/changelog.d/11141-private-brand-ancestor-evaluation.md +++ b/changelog.d/11141-private-brand-ancestor-evaluation.md @@ -2,6 +2,6 @@ Fix (`perry-runtime`): for an instance, `private_evaluation_brand` now walks from the stamped evaluation up each fresh class object's pinned per-evaluation parent (`__perry_parent_class`, set by `js_class_object_pin_parent`) and answers with the ancestor evaluation for the declaring template. It reuses the existing `pinned_class_object_for_ancestor` walk from `class_constructors.rs`, which constructor replay already uses. The walk stops at the first heritage that is not a class object and returns `None`, the same result as before. Class-object receivers (static private members) keep the exact comparison, because static private elements are not inherited. A private access still requires the per-field marker afterwards, so the walk cannot admit an uninitialized element. The brand remains exact per evaluation: a second evaluation's method rejects the first evaluation's instance, and the gap test asserts this. - Not covered: a subclass that is a class *expression* with a dynamic `extends` operand, such as @redis/client's `attachConfig` `class extends BaseClass {}`. On `main` that expression is a shared template, so its instances carry no evaluation stamp to walk from. #11122 (#11042) gives it its own evaluation, and with #11122 applied this fix covers that shape too (verified on a local stack). + This also covers @redis/client's `attachConfig` shape (`class extends BaseClass {}` built in a helper whose `extends` operand is a parameter), which is a per-call evaluation since 86cb666973. Still open: a subclass built inside the base class's *own* static method (redis's `RedisClient.factory`) passes the field read but fails `#x in` and `instanceof`. Filed as #11142. Tests: `test-files/test_gap_11127_private_field_function_local_subclass.ts`, `test-files/test_gap_11131_private_field_cjs_require_subclass.ts` (ESM spelling of the CJS wrapper scope, because Node runs `test-files/*.ts` as ESM), `crates/perry/tests/private_brand_ancestor_evaluation.rs` (the literal bare-`require` form as `.cjs` and `.ts` entries), and a `perry-runtime` unit test for the walk.