diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 1243b7d715..61b46978bd 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -217,17 +217,25 @@ export function scopeTarget(base: T, key: ScopeKey | undefined // and sets land on `base` directly. return new Proxy(base, { get(target, prop) { + // Proxy get invariants pin what this trap may report for a + // non-configurable OWN property of the base: a non-writable data prop + // must be reported AS-IS (neither overlaid nor bound), a getterless + // accessor as undefined — checked FIRST so even an overlay key + // colliding with a frozen own prop of a (pathological) base yields the + // base's value instead of an engine TypeError. Such a base forgoes + // scope filtering; no production base freezes these keys. + const own = Reflect.getOwnPropertyDescriptor(target, prop) + const pinned = own !== undefined && own.configurable === false + && own.get === undefined && own.writable !== true // hasOwn, not `in`: the overlay literal inherits Object.prototype, so // `in` would claim `toString`/`constructor` and shadow the subject's. - if (Object.hasOwn(overlay, prop)) return overlay[prop] + if (!pinned && Object.hasOwn(overlay, prop)) return overlay[prop] const value: unknown = Reflect.get(target, prop, target) - if (typeof value !== 'function') return value - // Proxy invariant guard: a non-configurable, non-writable OWN data - // property must be reported unchanged, so it cannot be bound. Class - // methods live on the prototype (no own descriptor) and bind freely; - // only a frozen own-function prop keeps the raw (unbound) function. - const own = Reflect.getOwnPropertyDescriptor(target, prop) - if (own !== undefined && own.configurable === false && own.writable === false) return value + if (typeof value !== 'function' || pinned) return value + // `constructor` is looked up, never invoked as a subject method — keep + // the real one (withProps special-cases it the same way), so + // `carrier.constructor` still identifies the subject's class. + if (prop === 'constructor') return value // `Function.prototype.bind` types as `any`; the value is structurally // T[prop] and the trap's contract is untyped (`any`), so unknown is the // honest safe return. diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 53e5bc931b..664ae00d7e 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -222,6 +222,28 @@ describe('scopeTarget dispatch filtering', () => { // it from shadowing the subject's own prototype-surface members. expect(String(carrier)).toBe('base-str') }) + + it('honors the get invariant even when an overlay key collides with a frozen own prop of the base', () => { + // Pathological but engine-enforced: a base whose own [Context.filter] is + // a non-configurable, non-writable data prop pins what any proxy over it + // may report for that key. The carrier must yield the base's value (an + // overlay there would be a runtime TypeError from the engine, not a + // filtering choice). Such a base forgoes scope filtering by construction. + const pinnedFilter = (): boolean => true + const base = {} + Object.defineProperty(base, Context.filter, { value: pinnedFilter, writable: false, configurable: false }) + const carrier = scopeTarget(base, { name: 'key' }) + expect((carrier as Record)[Context.filter]).toBe(pinnedFilter) + }) + + it('keeps the real constructor: class identity survives the carrier', () => { + class Subject { work(): string { return 'w' } } + const subject = new Subject() + const carrier = scopeTarget(subject, subject) + // `constructor` is looked up, never invoked as a subject method — binding + // it would break `carrier.constructor === Subject` for no benefit. + expect(carrier.constructor).toBe(Subject) + }) }) describe('carrier marks', () => {