fix(scope): generalize the carrier's proxy-invariant guard; keep the real constructor
Codex follow-up findings on the carrier commit, both verified: B: the invariant guard only protected the bind path — an overlay key colliding with a non-configurable, non-writable OWN prop of a (pathological) base would have the get trap report the overlay value, which the engine rejects as a proxy invariant violation (TypeError at read time). The pin check now runs FIRST and covers both invariant-pinned shapes (non-writable own data prop reported as-is; getterless non-configurable accessor reported undefined via the delegated read) before overlay and bind alike. Such a base forgoes scope filtering by construction — correctness of the read beats filtering for a base no production code ships. C: `constructor` is looked up for identity, never invoked as a subject method — binding it broke `carrier.constructor === Subject` for no benefit; it now returns raw (the same special-case withProps had). Both pinned in scope.spec: the frozen-own-filter collision yields the base's value without throwing, and class identity survives the carrier.
This commit is contained in:
@@ -217,17 +217,25 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
|
|||||||
// and sets land on `base` directly.
|
// and sets land on `base` directly.
|
||||||
return new Proxy(base, {
|
return new Proxy(base, {
|
||||||
get(target, prop) {
|
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
|
// hasOwn, not `in`: the overlay literal inherits Object.prototype, so
|
||||||
// `in` would claim `toString`/`constructor` and shadow the subject's.
|
// `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)
|
const value: unknown = Reflect.get(target, prop, target)
|
||||||
if (typeof value !== 'function') return value
|
if (typeof value !== 'function' || pinned) return value
|
||||||
// Proxy invariant guard: a non-configurable, non-writable OWN data
|
// `constructor` is looked up, never invoked as a subject method — keep
|
||||||
// property must be reported unchanged, so it cannot be bound. Class
|
// the real one (withProps special-cases it the same way), so
|
||||||
// methods live on the prototype (no own descriptor) and bind freely;
|
// `carrier.constructor` still identifies the subject's class.
|
||||||
// only a frozen own-function prop keeps the raw (unbound) function.
|
if (prop === 'constructor') return value
|
||||||
const own = Reflect.getOwnPropertyDescriptor(target, prop)
|
|
||||||
if (own !== undefined && own.configurable === false && own.writable === false) return value
|
|
||||||
// `Function.prototype.bind` types as `any`; the value is structurally
|
// `Function.prototype.bind` types as `any`; the value is structurally
|
||||||
// T[prop] and the trap's contract is untyped (`any`), so unknown is the
|
// T[prop] and the trap's contract is untyped (`any`), so unknown is the
|
||||||
// honest safe return.
|
// honest safe return.
|
||||||
|
|||||||
@@ -222,6 +222,28 @@ describe('scopeTarget dispatch filtering', () => {
|
|||||||
// it from shadowing the subject's own prototype-surface members.
|
// it from shadowing the subject's own prototype-surface members.
|
||||||
expect(String(carrier)).toBe('base-str')
|
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<symbol, unknown>)[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', () => {
|
describe('carrier marks', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user