From 0675e9988d434079177478be4d4807faaf390da8 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 4 Sep 2026 11:26:52 +0200 Subject: [PATCH 1/4] fix: harden native collection method dispatch --- docs/architecture.md | 2 +- docs/release-readiness.md | 2 +- docs/supported-types.md | 10 +- scripts/size-budget.json | 8 +- src/membrane.ts | 292 +++++++++++++----- src/native-members.ts | 135 ++++++++ src/public-types.ts | 33 +- test/regression/collection-future-api.test.ts | 47 +++ .../native-member-inventory.test.ts | 74 +++++ test/types/modern-collections.ts | 49 +++ test/unit/set.test.ts | 110 ++++++- 11 files changed, 675 insertions(+), 87 deletions(-) create mode 100644 src/native-members.ts create mode 100644 test/regression/collection-future-api.test.ts create mode 100644 test/regression/native-member-inventory.test.ts create mode 100644 test/types/modern-collections.ts diff --git a/docs/architecture.md b/docs/architecture.md index 9896414..8bcda1c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,7 @@ Shadows remain extensible because `preventExtensions` throws, permitting live ke ## Reads and methods -Keys, membership, prototypes, and descriptors delegate to current source state, then object results pass through the membrane. Descriptor values, accessors, iterators, callbacks, collections, and prototypes cannot expose raw objects. Map/Set translate same-membrane view keys privately. Only exact native methods receive valid internal-slot sources; user-defined methods receive readonly `this`. Private fields may fail brand checks. +Keys, membership, prototypes, and descriptors delegate to current source state, then object results pass through the membrane. Descriptor values, accessors, iterators, callbacks, collections, and prototypes cannot expose raw objects. Map/Set translate same-membrane view keys privately. Map, Set, and Date use explicit prototype-member inventories: only reviewed native methods receive valid internal-slot sources, known mutators reject before invocation, and unclassified native members fail closed. Available modern Set composition methods return readonly Set views. User-defined methods receive readonly `this`. Private fields may fail brand checks. ## Memory diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 37179d0..add4d23 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -8,7 +8,7 @@ merged to `main`, whose full CI matrix is green. ## 1. Architecture -Each `readonlyView` call creates an independent WeakMap-backed membrane. Controlled extensible shadow targets preserve proxy invariants without modifying sources. Values are wrapped lazily, identity is stable inside a membrane, and Map, Set, and Date use narrow native adapters. +Each `readonlyView` call creates an independent WeakMap-backed membrane. Controlled extensible shadow targets preserve proxy invariants without modifying sources. Values are wrapped lazily, identity is stable inside a membrane, and Map, Set, and Date use explicit, fail-closed native-member inventories. ## 2. Public API diff --git a/docs/supported-types.md b/docs/supported-types.md index 607bbd8..3df4c70 100644 --- a/docs/supported-types.md +++ b/docs/supported-types.md @@ -5,9 +5,9 @@ | Primitive | Full | Returned unchanged | | Plain/null-prototype object | Full | Live reflection and deep values | | Array/tuple | Full | `Array.isArray`; mutations reject | -| Map | Full | Wrapped keys, values, iteration | -| Set | Full | Wrapped values and iteration | -| Date | Full | Reads work; every `set*` rejects | +| Map | Full | Inventoried reads wrap keys/values; mutators reject | +| Set | Full | Inventoried reads; available composition/relation methods | +| Date | Full | Inventoried reads work; every standard setter rejects | | Function | Documented | Readonly `this`; external effects possible | | Custom class | Documented | Prototype viewed; `instanceof` false; private brands may reject | | Consumer Proxy | Trust limitation | Source traps bound guarantees | @@ -17,3 +17,7 @@ | WeakMap, WeakSet, Promise | Unsupported | Native slots/unsafe exposure | Unsupported roots and lazily reached values throw `UnsupportedTypeError`. +Unclassified native members on Map, Set, and Date also fail closed instead of +being invoked with the mutable source as their receiver. Where the runtime +provides modern Set composition methods, their fresh Set results and members +remain readonly through the originating membrane. diff --git a/scripts/size-budget.json b/scripts/size-budget.json index 0d61ee8..37fd5be 100644 --- a/scripts/size-budget.json +++ b/scripts/size-budget.json @@ -1,6 +1,6 @@ { - "esmMinified": 7000, - "esmGzip": 2500, - "declarations": 2500, - "packed": 10000 + "esmMinified": 10000, + "esmGzip": 3000, + "declarations": 3500, + "packed": 12000 } diff --git a/src/membrane.ts b/src/membrane.ts index 56de23f..7d7ae09 100644 --- a/src/membrane.ts +++ b/src/membrane.ts @@ -1,8 +1,14 @@ import { isObjectLike, unsupportedKind } from './classify'; import { DirectMutationError, UnsupportedTypeError } from './errors'; +import { + nativeMemberKind, + type NativeKind, + type NativeMemberKind, +} from './native-members'; import type { DeepReadonly } from './public-types'; -const knownViews = new WeakSet(); +const knownViewSources = new WeakMap(); +const knownViewWrappers = new WeakMap unknown>(); type RuntimeConstructor = new (...arguments_: never[]) => object; const arrayMutators = new Set([ 'copyWithin', @@ -15,6 +21,80 @@ const arrayMutators = new Set([ 'splice', 'unshift', ]); +const setCompositionMethods = new Set([ + 'difference', + 'intersection', + 'symmetricDifference', + 'union', +]); +const setRelationMethods = new Set([ + 'isDisjointFrom', + 'isSubsetOf', + 'isSupersetOf', +]); + +function unwrapKnownView(value: unknown): unknown { + return isObjectLike(value) ? (knownViewSources.get(value) ?? value) : value; +} + +function wrapForKnownView(view: object, value: unknown): unknown { + return knownViewWrappers.get(view)?.(value) ?? value; +} + +function nativeSetLikeIterator(value: unknown): unknown { + if (!isObjectLike(value)) return value; + + const iterator = value; + const nextMethod = Reflect.get(iterator, 'next', iterator) as unknown; + const next = unwrapKnownView(nextMethod); + if (typeof next !== 'function') return value; + + return { + next(): IteratorResult { + const result: unknown = Reflect.apply(next, iterator, []); + if (!isObjectLike(result)) return result as IteratorResult; + const done: unknown = Reflect.get(result, 'done', result); + if (done) return { done: true, value: undefined }; + return { + done: false, + value: unwrapKnownView(Reflect.get(result, 'value', result)), + }; + }, + [Symbol.iterator]() { + return this; + }, + }; +} + +function nativeSetOperand(value: unknown): unknown { + if (!isObjectLike(value)) return value; + + const receiver = value; + return { + get size(): unknown { + const size: unknown = Reflect.get(receiver, 'size', receiver); + return size; + }, + get has(): unknown { + const method = Reflect.get(receiver, 'has', receiver) as unknown; + const callable = unwrapKnownView(method); + if (typeof callable !== 'function') return method; + return (member: unknown) => { + const result: unknown = Reflect.apply(callable, receiver, [ + wrapForKnownView(receiver, member), + ]); + return result; + }; + }, + get keys(): unknown { + const method = Reflect.get(receiver, 'keys', receiver) as unknown; + const callable = unwrapKnownView(method); + if (typeof callable !== 'function') return method; + return () => + nativeSetLikeIterator(Reflect.apply(callable, receiver, [])); + }, + }; +} function objectKind(source: object): string { if (Array.isArray(source)) return 'Array'; @@ -48,8 +128,19 @@ function mutation( throw new DirectMutationError(details); } +function requiredNativeMemberKind( + kind: NativeKind, + property: PropertyKey, +): Exclude { + const memberKind = nativeMemberKind(kind, property); + if (memberKind === undefined || memberKind === 'unsupported') { + throw new UnsupportedTypeError(`${kind}.${String(property)}`); + } + return memberKind; +} + export function isKnownReadonlyView(value: unknown): boolean { - return isObjectLike(value) && knownViews.has(value); + return isObjectLike(value) && knownViewSources.has(value); } export interface Membrane { @@ -152,36 +243,48 @@ export function createMembrane(): Membrane { property: PropertyKey, receiver: object, ): unknown { - if ( - property === 'size' && - descriptorOwner(source, property) === Map.prototype - ) { - return source.size; + const owner = descriptorOwner(source, property); + const classifiedKind = nativeMemberKind('Map', property); + if (owner !== null && classifiedKind === 'mutator') { + return cachedMethod( + source, + property, + () => () => mutation(source, String(property)), + ); } - if ( - property !== 'set' && - property !== 'delete' && - property !== 'clear' && - descriptorOwner(source, property) !== Map.prototype - ) { + if (owner !== Map.prototype) { return wrap(Reflect.get(source, property, receiver)); } + + const memberKind = requiredNativeMemberKind('Map', property); + if (memberKind === 'special' && property === 'size') return source.size; + if (memberKind === 'read' && property === 'constructor') { + return wrap(Reflect.get(source, property, receiver)); + } + if (memberKind === 'special' && property === Symbol.toStringTag) { + return Reflect.get(source, property, source); + } + return cachedMethod(source, property, () => { - if ( - property === 'set' || - property === 'delete' || - property === 'clear' - ) { + if (memberKind === 'mutator') { return () => mutation(source, String(property)); } - if (property === 'get') { + const method = Reflect.get(source, property, source) as ( + ...arguments_: unknown[] + ) => unknown; + if (memberKind === 'special' && property === 'get') { return (key: unknown) => - wrap(source.get(unwrapSameMembrane(key))); + wrap( + Reflect.apply(method, source, [ + unwrapSameMembrane(key), + ]), + ); } - if (property === 'has') { - return (key: unknown) => source.has(unwrapSameMembrane(key)); + if (memberKind === 'special' && property === 'has') { + return (key: unknown) => + Reflect.apply(method, source, [unwrapSameMembrane(key)]); } - if (property === 'forEach') { + if (memberKind === 'special' && property === 'forEach') { return ( callback: ( value: unknown, @@ -189,26 +292,31 @@ export function createMembrane(): Membrane { map: object, ) => void, thisArgument?: unknown, - ) => - source.forEach((value, key) => - Reflect.apply(callback, thisArgument, [ - wrap(value), - wrap(key), - receiver, - ]), + ) => { + return Reflect.apply(method, source, [ + (value: unknown, key: unknown) => + Reflect.apply(callback, thisArgument, [ + wrap(value), + wrap(key), + receiver, + ]), + ]); + }; + } + if ( + memberKind === 'special' && + (property === Symbol.iterator || + property === 'entries' || + property === 'keys' || + property === 'values') + ) { + return () => + wrapIterator( + Reflect.apply(method, source, []) as Iterator, ); } - const method: unknown = Reflect.get(source, property, source); - return typeof method === 'function' - ? () => - wrapIterator( - Reflect.apply( - method, - source, - [], - ) as Iterator, - ) - : wrap(method); + + throw new UnsupportedTypeError(`Map.${String(property)}`); }); } @@ -217,33 +325,37 @@ export function createMembrane(): Membrane { property: PropertyKey, receiver: object, ): unknown { - if ( - property === 'size' && - descriptorOwner(source, property) === Set.prototype - ) { - return source.size; + const owner = descriptorOwner(source, property); + const classifiedKind = nativeMemberKind('Set', property); + if (owner !== null && classifiedKind === 'mutator') { + return cachedMethod( + source, + property, + () => () => mutation(source, String(property)), + ); + } + if (owner !== Set.prototype) { + return wrap(Reflect.get(source, property, receiver)); } - if ( - property !== 'add' && - property !== 'delete' && - property !== 'clear' && - descriptorOwner(source, property) !== Set.prototype - ) { + + const memberKind = requiredNativeMemberKind('Set', property); + if (memberKind === 'special' && property === 'size') return source.size; + if (memberKind === 'read' && property === 'constructor') { return wrap(Reflect.get(source, property, receiver)); } + if (memberKind === 'special' && property === Symbol.toStringTag) { + return Reflect.get(source, property, source); + } + return cachedMethod(source, property, () => { - if ( - property === 'add' || - property === 'delete' || - property === 'clear' - ) { + if (memberKind === 'mutator') { return () => mutation(source, String(property)); } - if (property === 'has') { + if (memberKind === 'special' && property === 'has') { return (value: unknown) => source.has(unwrapSameMembrane(value)); } - if (property === 'forEach') { + if (memberKind === 'special' && property === 'forEach') { return ( callback: ( value: unknown, @@ -251,8 +363,8 @@ export function createMembrane(): Membrane { set: object, ) => void, thisArgument?: unknown, - ) => - source.forEach((value) => { + ) => { + return source.forEach((value) => { const wrapped = wrap(value); Reflect.apply(callback, thisArgument, [ wrapped, @@ -260,18 +372,41 @@ export function createMembrane(): Membrane { receiver, ]); }); + }; } const method: unknown = Reflect.get(source, property, source); - return typeof method === 'function' - ? () => - wrapIterator( - Reflect.apply( - method, - source, - [], - ) as Iterator, - ) - : wrap(method); + if (typeof method !== 'function') return wrap(method); + + if (setCompositionMethods.has(property)) { + return (other: unknown) => { + const result: unknown = Reflect.apply(method, source, [ + nativeSetOperand(other), + ]); + return wrap(result); + }; + } + if (setRelationMethods.has(property)) { + return (other: unknown) => { + const result: unknown = Reflect.apply(method, source, [ + nativeSetOperand(other), + ]); + return result; + }; + } + if ( + memberKind === 'special' && + (property === Symbol.iterator || + property === 'entries' || + property === 'keys' || + property === 'values') + ) { + return () => + wrapIterator( + Reflect.apply(method, source, []) as Iterator, + ); + } + + throw new UnsupportedTypeError(`Set.${String(property)}`); }); } @@ -282,11 +417,15 @@ export function createMembrane(): Membrane { ): unknown { const native = descriptorOwner(source, property) === Date.prototype; if (!native) return wrap(Reflect.get(source, property, receiver)); + const memberKind = requiredNativeMemberKind('Date', property); + if (memberKind === 'read' && property === 'constructor') { + return wrap(Reflect.get(source, property, receiver)); + } const value: unknown = Reflect.get(source, property, source); if (typeof value !== 'function') return wrap(value); return cachedMethod(source, property, () => { - if (typeof property === 'string' && property.startsWith('set')) { - return () => mutation(source, property); + if (memberKind === 'mutator') { + return () => mutation(source, String(property)); } return (...arguments_: unknown[]) => { const result: unknown = Reflect.apply( @@ -301,7 +440,7 @@ export function createMembrane(): Membrane { function wrap(value: T): DeepReadonly { if (!isObjectLike(value)) return value as DeepReadonly; - if (knownViews.has(value)) return value as DeepReadonly; + if (knownViewSources.has(value)) return value as DeepReadonly; const rejectedKind = unsupportedKind(value); if (rejectedKind !== undefined) { @@ -443,7 +582,8 @@ export function createMembrane(): Membrane { viewReference.current = view; sourceToView.set(source, view); viewToSource.set(view, source); - knownViews.add(view); + knownViewSources.set(view, source); + knownViewWrappers.set(view, wrap); return view as DeepReadonly; } @@ -454,7 +594,7 @@ export function createMembrane(): Membrane { } export function readonlyView(source: T): DeepReadonly { - if (!isObjectLike(source) || knownViews.has(source)) { + if (!isObjectLike(source) || knownViewSources.has(source)) { return source as DeepReadonly; } diff --git a/src/native-members.ts b/src/native-members.ts new file mode 100644 index 0000000..b7441b5 --- /dev/null +++ b/src/native-members.ts @@ -0,0 +1,135 @@ +export type NativeKind = 'Map' | 'Set' | 'Date'; +export type NativeMemberKind = 'read' | 'mutator' | 'special' | 'unsupported'; + +interface NativeMemberPolicy { + readonly read: ReadonlySet; + readonly mutator: ReadonlySet; + readonly special: ReadonlySet; + readonly unsupported: ReadonlySet; +} + +const mapPolicy: NativeMemberPolicy = { + read: new Set(['constructor']), + mutator: new Set([ + 'clear', + 'delete', + 'getOrInsert', + 'getOrInsertComputed', + 'set', + ]), + special: new Set([ + 'entries', + 'forEach', + 'get', + 'has', + 'keys', + 'size', + 'values', + Symbol.iterator, + Symbol.toStringTag, + ]), + unsupported: new Set(), +}; + +const setPolicy: NativeMemberPolicy = { + read: new Set(['constructor']), + mutator: new Set(['add', 'clear', 'delete']), + special: new Set([ + 'difference', + 'entries', + 'forEach', + 'has', + 'intersection', + 'isDisjointFrom', + 'isSubsetOf', + 'isSupersetOf', + 'keys', + 'size', + 'symmetricDifference', + 'union', + 'values', + Symbol.iterator, + Symbol.toStringTag, + ]), + unsupported: new Set(), +}; + +const datePolicy: NativeMemberPolicy = { + read: new Set([ + 'constructor', + 'getDate', + 'getDay', + 'getFullYear', + 'getHours', + 'getMilliseconds', + 'getMinutes', + 'getMonth', + 'getSeconds', + 'getTime', + 'getTimezoneOffset', + 'getUTCDate', + 'getUTCDay', + 'getUTCFullYear', + 'getUTCHours', + 'getUTCMilliseconds', + 'getUTCMinutes', + 'getUTCMonth', + 'getUTCSeconds', + 'getYear', + 'toDateString', + 'toGMTString', + 'toISOString', + 'toJSON', + 'toLocaleDateString', + 'toLocaleString', + 'toLocaleTimeString', + 'toString', + 'toTimeString', + 'toUTCString', + 'valueOf', + ]), + mutator: new Set([ + 'setDate', + 'setFullYear', + 'setHours', + 'setMilliseconds', + 'setMinutes', + 'setMonth', + 'setSeconds', + 'setTime', + 'setUTCDate', + 'setUTCFullYear', + 'setUTCHours', + 'setUTCMilliseconds', + 'setUTCMinutes', + 'setUTCMonth', + 'setUTCSeconds', + 'setYear', + ]), + special: new Set([Symbol.toPrimitive]), + unsupported: new Set(), +}; + +const nativePolicies: ReadonlyMap = new Map([ + ['Map', mapPolicy], + ['Set', setPolicy], + ['Date', datePolicy], +]); + +const nativeMemberKinds = [ + 'read', + 'mutator', + 'special', + 'unsupported', +] as const; + +export function nativeMemberKind( + kind: NativeKind, + property: PropertyKey, +): NativeMemberKind | undefined { + const policy = nativePolicies.get(kind); + for (const memberKind of nativeMemberKinds) { + if (policy?.[memberKind].has(property) === true) return memberKind; + } + return undefined; +} diff --git a/src/public-types.ts b/src/public-types.ts index f5b8e81..9b34865 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -4,6 +4,37 @@ type DateMutator = Extract; type ReadonlyDate = Readonly>; +type ReadonlySetOperand = { + readonly size: number; + has(value: Value): boolean; + keys(): Iterator; +}; + +type DeepReadonlySetMethods = { + union( + other: ReadonlySetOperand, + ): DeepReadonlySetView>; + intersection( + other: ReadonlySetOperand, + ): DeepReadonlySetView>; + difference( + other: ReadonlySetOperand, + ): DeepReadonlySetView; + symmetricDifference( + other: ReadonlySetOperand, + ): DeepReadonlySetView>; + isSubsetOf(other: ReadonlySetOperand): boolean; + isSupersetOf(other: ReadonlySetOperand): boolean; + isDisjointFrom(other: ReadonlySetOperand): boolean; +}; + +type DeepReadonlySetView = DeepReadonlySetMethods & + ReadonlySet; + +type DeepReadonlySet = 'union' extends keyof ReadonlySet + ? DeepReadonlySetView> + : ReadonlySet>; + type DeepReadonlyFunction unknown> = (( ...arguments_: Parameters ) => DeepReadonly>) & { @@ -31,7 +62,7 @@ export type DeepReadonly = T extends Primitive : T extends ReadonlyMap ? ReadonlyMap, DeepReadonly> : T extends ReadonlySet - ? ReadonlySet> + ? DeepReadonlySet : T extends object ? { readonly [Key in keyof T]: DeepReadonly } : T; diff --git a/test/regression/collection-future-api.test.ts b/test/regression/collection-future-api.test.ts new file mode 100644 index 0000000..844eba1 --- /dev/null +++ b/test/regression/collection-future-api.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { DirectMutationError, readonlyView } from '../../src/index'; + +type FutureMap = Map & { + getOrInsert(key: object, defaultValue: object): object; + getOrInsertComputed(key: object, callback: (key: object) => object): object; +}; + +describe('future Map mutators', () => { + it.each(['getOrInsert', 'getOrInsertComputed'] as const)( + 'blocks %s before a source-prototype shim executes', + (methodName) => { + const retainedKey = { id: 'retained' }; + const retainedValue = { value: 1 }; + const source = new Map([ + [retainedKey, retainedValue], + ]); + const sourcePrototype = Object.create(Map.prototype) as Map< + object, + object + >; + let called = false; + + Object.defineProperty(sourcePrototype, methodName, { + configurable: true, + value(this: Map, key: object) { + called = true; + const inserted = { unsafe: true }; + this.set(key, inserted); + return inserted; + }, + }); + Object.setPrototypeOf(source, sourcePrototype); + + const view = readonlyView(source) as unknown as FutureMap; + const snapshot = [...source]; + const invoke = + methodName === 'getOrInsert' + ? () => view.getOrInsert({}, {}) + : () => view.getOrInsertComputed({}, () => ({})); + + expect(invoke).toThrow(DirectMutationError); + expect(called).toBe(false); + expect([...source]).toEqual(snapshot); + }, + ); +}); diff --git a/test/regression/native-member-inventory.test.ts b/test/regression/native-member-inventory.test.ts new file mode 100644 index 0000000..40b82e1 --- /dev/null +++ b/test/regression/native-member-inventory.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { UnsupportedTypeError, readonlyView } from '../../src/index'; +import { nativeMemberKind, type NativeKind } from '../../src/native-members'; + +function assertNativeInventory(kind: NativeKind, prototype: object): void { + for (const property of Reflect.ownKeys(prototype)) { + expect( + nativeMemberKind(kind, property), + `${kind}.${String(property)}`, + ).toBeDefined(); + } +} + +describe('native member inventory', () => { + it.each([ + ['Map', Map.prototype], + ['Set', Set.prototype], + ['Date', Date.prototype], + ] as const)( + 'classifies every runtime %s prototype member', + (kind, prototype) => { + assertNativeInventory(kind, prototype); + }, + ); + + it.each([ + ['Map', new Map([['retained', 1]])], + ['Set', new Set(['retained'])], + ['Date', new Date(0)], + ] as const)( + 'fails closed before an unknown %s prototype method can mutate its raw receiver', + (kind, source) => { + const property = Symbol(`unknown${kind}Method`); + const prototype = Object.getPrototypeOf(source) as object; + let called = false; + + Object.defineProperty(prototype, property, { + configurable: true, + value(this: Map | Set | Date) { + called = true; + if (this instanceof Date) { + this.setTime(1); + return this.getTime(); + } + this.clear(); + return this.values(); + }, + }); + + try { + const view = readonlyView(source); + expect(() => { + const method: unknown = Reflect.get(view, property); + if (typeof method !== 'function') { + throw new TypeError('Expected a callable method'); + } + Reflect.apply(method, view, []); + }).toThrow( + new UnsupportedTypeError(`${kind}.${String(property)}`), + ); + expect(called).toBe(false); + if (source instanceof Date) { + expect(source.getTime()).toBe(0); + } else if (source instanceof Map) { + expect([...source]).toEqual([['retained', 1]]); + } else { + expect([...source]).toEqual(['retained']); + } + } finally { + Reflect.deleteProperty(prototype, property); + } + }, + ); +}); diff --git a/test/types/modern-collections.ts b/test/types/modern-collections.ts new file mode 100644 index 0000000..618c724 --- /dev/null +++ b/test/types/modern-collections.ts @@ -0,0 +1,49 @@ +/// + +import type { DeepReadonly } from '../../src/index'; + +declare const setView: DeepReadonly>; +declare const otherSet: ReadonlySet<{ label: string }>; + +const unionResult = setView.union(otherSet); +const intersectionResult = setView.intersection( + new Set<{ id: number; label: string }>(), +); +const differenceResult = setView.difference(otherSet); +const symmetricDifferenceResult = setView.symmetricDifference(otherSet); + +const readonlyUnion: ReadonlySet< + Readonly<{ id: number }> | Readonly<{ label: string }> +> = unionResult; +const readonlyIntersection: ReadonlySet< + Readonly<{ id: number; label: string }> +> = intersectionResult; +const readonlyDifference: ReadonlySet> = + differenceResult; +const readonlySymmetricDifference: ReadonlySet< + Readonly<{ id: number }> | Readonly<{ label: string }> +> = symmetricDifferenceResult; + +const relationResult: boolean = setView.isSubsetOf(otherSet); +const supersetResult: boolean = setView.isSupersetOf(otherSet); +const disjointResult: boolean = setView.isDisjointFrom(otherSet); +const chainedResult = unionResult.difference(otherSet); + +// @ts-expect-error Set composition results are readonly views at runtime +unionResult.add({ id: 2 }); +// @ts-expect-error Set composition members are deeply readonly +for (const value of intersectionResult) value.label = 'changed'; +// @ts-expect-error difference results expose no delete operation +differenceResult.delete({ id: 1 }); +// @ts-expect-error symmetric difference members are deeply readonly +for (const value of symmetricDifferenceResult) value.label = 'changed'; +// @ts-expect-error chained composition results stay readonly +chainedResult.clear(); + +void readonlyUnion; +void readonlyIntersection; +void readonlyDifference; +void readonlySymmetricDifference; +void relationResult; +void supersetResult; +void disjointResult; diff --git a/test/unit/set.test.ts b/test/unit/set.test.ts index 496e3f1..53344a6 100644 --- a/test/unit/set.test.ts +++ b/test/unit/set.test.ts @@ -1,5 +1,46 @@ import { describe, expect, it } from 'vitest'; -import { DirectMutationError, readonlyView } from '../../src/index'; +import { + DirectMutationError, + isReadonlyView, + readonlyView, +} from '../../src/index'; + +interface SetLike { + readonly size: number; + has(value: Value): boolean; + keys(): Iterator; +} + +type CompositionMethod = + 'union' | 'intersection' | 'difference' | 'symmetricDifference'; + +type RelationMethod = 'isSubsetOf' | 'isSupersetOf' | 'isDisjointFrom'; + +type ModernSet = Set & { + [Method in CompositionMethod]: (other: SetLike) => Set; +} & { + [Method in RelationMethod]: (other: SetLike) => boolean; +}; + +const compositionCases = [ + ['union', ['source', 'shared', 'operand']], + ['intersection', ['shared']], + ['difference', ['source']], + ['symmetricDifference', ['source', 'operand']], +] as const satisfies readonly (readonly [ + CompositionMethod, + readonly string[], +])[]; + +const relationCases = [ + ['isSubsetOf', false], + ['isSupersetOf', false], + ['isDisjointFrom', false], +] as const satisfies readonly (readonly [RelationMethod, boolean])[]; + +function hasSetMethod(methodName: CompositionMethod | RelationMethod): boolean { + return typeof Reflect.get(Set.prototype, methodName) === 'function'; +} describe('Set', () => { it('reads live wrapped values and supports view lookup', () => { @@ -51,4 +92,71 @@ describe('Set', () => { expect(called).toBe(true); }); + + it.each(compositionCases)( + '%s accepts source and readonly-view operands and returns a readonly Set', + (methodName, expectedIds) => { + if (!hasSetMethod(methodName)) return; + + const sourceOnly = { id: 'source', nested: { value: 1 } }; + const shared = { id: 'shared', nested: { value: 2 } }; + const operandOnly = { id: 'operand', nested: { value: 3 } }; + const graph = { + source: new Set([sourceOnly, shared]), + operand: new Set([shared, operandOnly]), + }; + const graphView = readonlyView(graph); + const independentOperandView = readonlyView(graph.operand); + const modernView = graphView.source as unknown as ModernSet< + typeof sourceOnly + >; + const invoke = (operand: SetLike) => + modernView[methodName].call(graphView.source, operand); + + for (const operand of [ + graph.operand, + graphView.operand, + independentOperandView, + ]) { + const result = invoke(operand); + const members = [...result]; + + expect(members.map((member) => member.id)).toEqual(expectedIds); + expect(isReadonlyView(result)).toBe(true); + expect(() => + result.add({ id: 'new', nested: { value: 4 } }), + ).toThrow(DirectMutationError); + for (const member of members) { + expect(isReadonlyView(member)).toBe(true); + expect(() => + Reflect.set(member.nested, 'value', 99), + ).toThrow(DirectMutationError); + } + } + }, + ); + + it.each(relationCases)( + '%s accepts source and readonly-view operands', + (methodName, expected) => { + if (!hasSetMethod(methodName)) return; + + const shared = { id: 'shared' }; + const graph = { + source: new Set([{ id: 'source' }, shared]), + operand: new Set([shared, { id: 'operand' }]), + }; + const graphView = readonlyView(graph); + const independentOperandView = readonlyView(graph.operand); + const modernView = graphView.source as unknown as ModernSet< + typeof shared + >; + const invoke = (operand: SetLike) => + modernView[methodName].call(graphView.source, operand); + + expect(invoke(graph.operand)).toBe(expected); + expect(invoke(graphView.operand)).toBe(expected); + expect(invoke(independentOperandView)).toBe(expected); + }, + ); }); From b674d73808689d502e3f1217844726b91b353cfd Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 4 Sep 2026 12:03:43 +0200 Subject: [PATCH 2/4] fix: close Date and Set protocol escapes --- src/membrane.ts | 30 +++++++++++++ src/native-members.ts | 3 +- src/public-types.ts | 7 ++- test/types/modern-collections.ts | 35 +++++++++++++-- test/unit/date.test.ts | 44 ++++++++++++++++++ test/unit/set.test.ts | 76 ++++++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 8 deletions(-) diff --git a/src/membrane.ts b/src/membrane.ts index 7d7ae09..4d82998 100644 --- a/src/membrane.ts +++ b/src/membrane.ts @@ -60,6 +60,23 @@ function nativeSetLikeIterator(value: unknown): unknown { value: unwrapKnownView(Reflect.get(result, 'value', result)), }; }, + get return(): unknown { + const returnMethod = Reflect.get( + iterator, + 'return', + iterator, + ) as unknown; + const callable = unwrapKnownView(returnMethod); + if (typeof callable !== 'function') return returnMethod; + return (...arguments_: unknown[]): unknown => { + const result: unknown = Reflect.apply( + callable, + iterator, + arguments_, + ); + return result; + }; + }, [Symbol.iterator]() { return this; }, @@ -427,6 +444,19 @@ export function createMembrane(): Membrane { if (memberKind === 'mutator') { return () => mutation(source, String(property)); } + if ( + memberKind === 'special' && + (property === 'toJSON' || property === Symbol.toPrimitive) + ) { + return (...arguments_: unknown[]) => { + const result: unknown = Reflect.apply( + value, + receiver, + arguments_, + ); + return wrap(result); + }; + } return (...arguments_: unknown[]) => { const result: unknown = Reflect.apply( value, diff --git a/src/native-members.ts b/src/native-members.ts index b7441b5..cd25744 100644 --- a/src/native-members.ts +++ b/src/native-members.ts @@ -79,7 +79,6 @@ const datePolicy: NativeMemberPolicy = { 'toDateString', 'toGMTString', 'toISOString', - 'toJSON', 'toLocaleDateString', 'toLocaleString', 'toLocaleTimeString', @@ -106,7 +105,7 @@ const datePolicy: NativeMemberPolicy = { 'setUTCSeconds', 'setYear', ]), - special: new Set([Symbol.toPrimitive]), + special: new Set(['toJSON', Symbol.toPrimitive]), unsupported: new Set(), }; diff --git a/src/public-types.ts b/src/public-types.ts index 9b34865..55c51e4 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -28,8 +28,11 @@ type DeepReadonlySetMethods = { isDisjointFrom(other: ReadonlySetOperand): boolean; }; -type DeepReadonlySetView = DeepReadonlySetMethods & - ReadonlySet; +type DeepReadonlySetView = Omit< + ReadonlySet, + keyof DeepReadonlySetMethods +> & + DeepReadonlySetMethods; type DeepReadonlySet = 'union' extends keyof ReadonlySet ? DeepReadonlySetView> diff --git a/test/types/modern-collections.ts b/test/types/modern-collections.ts index 618c724..67a9d4e 100644 --- a/test/types/modern-collections.ts +++ b/test/types/modern-collections.ts @@ -2,6 +2,17 @@ import type { DeepReadonly } from '../../src/index'; +type ModernSetMethod = + | 'union' + | 'intersection' + | 'difference' + | 'symmetricDifference' + | 'isSubsetOf' + | 'isSupersetOf' + | 'isDisjointFrom'; + +type ReadonlySetBase = Omit, ModernSetMethod>; + declare const setView: DeepReadonly>; declare const otherSet: ReadonlySet<{ label: string }>; @@ -12,15 +23,15 @@ const intersectionResult = setView.intersection( const differenceResult = setView.difference(otherSet); const symmetricDifferenceResult = setView.symmetricDifference(otherSet); -const readonlyUnion: ReadonlySet< +const readonlyUnion: ReadonlySetBase< Readonly<{ id: number }> | Readonly<{ label: string }> > = unionResult; -const readonlyIntersection: ReadonlySet< +const readonlyIntersection: ReadonlySetBase< Readonly<{ id: number; label: string }> > = intersectionResult; -const readonlyDifference: ReadonlySet> = +const readonlyDifference: ReadonlySetBase> = differenceResult; -const readonlySymmetricDifference: ReadonlySet< +const readonlySymmetricDifference: ReadonlySetBase< Readonly<{ id: number }> | Readonly<{ label: string }> > = symmetricDifferenceResult; @@ -28,6 +39,14 @@ const relationResult: boolean = setView.isSubsetOf(otherSet); const supersetResult: boolean = setView.isSupersetOf(otherSet); const disjointResult: boolean = setView.isDisjointFrom(otherSet); const chainedResult = unionResult.difference(otherSet); +declare const unionReturnTypeResult: ReturnType; +declare const intersectionReturnTypeResult: ReturnType< + typeof setView.intersection +>; +declare const differenceReturnTypeResult: ReturnType; +declare const symmetricDifferenceReturnTypeResult: ReturnType< + typeof setView.symmetricDifference +>; // @ts-expect-error Set composition results are readonly views at runtime unionResult.add({ id: 2 }); @@ -39,6 +58,14 @@ differenceResult.delete({ id: 1 }); for (const value of symmetricDifferenceResult) value.label = 'changed'; // @ts-expect-error chained composition results stay readonly chainedResult.clear(); +// @ts-expect-error union utility types expose no mutable native overload +unionReturnTypeResult.add({ id: 2 }); +// @ts-expect-error intersection utility types expose no mutable native overload +intersectionReturnTypeResult.add({ id: 2 }); +// @ts-expect-error difference utility types expose no mutable native overload +differenceReturnTypeResult.add({ id: 2 }); +// @ts-expect-error symmetric difference utility types expose no mutable native overload +symmetricDifferenceReturnTypeResult.add({ id: 2 }); void readonlyUnion; void readonlyIntersection; diff --git a/test/unit/date.test.ts b/test/unit/date.test.ts index dda43ab..102af77 100644 --- a/test/unit/date.test.ts +++ b/test/unit/date.test.ts @@ -35,4 +35,48 @@ describe('Date', () => { } expect(source.getTime()).toBe(0); }); + + it.each([ + ['toJSON', 'toISOString', 'toJSON', [], 'mutated', 1], + [ + 'Symbol.toPrimitive(number)', + 'valueOf', + Symbol.toPrimitive, + ['number'], + 2, + 2, + ], + [ + 'Symbol.toPrimitive(string)', + 'toString', + Symbol.toPrimitive, + ['string'], + 'mutated', + 3, + ], + ] as const)( + '%s does not give an own %s override the mutable source', + (_, overrideName, methodName, arguments_, returnValue, mutatedTime) => { + const source = new Date(0); + Object.defineProperty(source, overrideName, { + configurable: true, + value(this: Date) { + this.setTime(mutatedTime); + return returnValue; + }, + }); + const view = readonlyView(source); + const method: unknown = Reflect.get(view, methodName); + + expect(typeof method).toBe('function'); + expect(() => { + Reflect.apply( + method as (...arguments_: unknown[]) => unknown, + view, + arguments_, + ); + }).toThrow(DirectMutationError); + expect(source.getTime()).toBe(0); + }, + ); }); diff --git a/test/unit/set.test.ts b/test/unit/set.test.ts index 53344a6..4d3669c 100644 --- a/test/unit/set.test.ts +++ b/test/unit/set.test.ts @@ -159,4 +159,80 @@ describe('Set', () => { expect(invoke(independentOperandView)).toBe(expected); }, ); + + it.each(['raw', 'readonly-view'] as const)( + 'isSupersetOf closes a %s set-like operand iterator on early exit', + (operandKind) => { + if (!hasSetMethod('isSupersetOf')) return; + + let closed = false; + const operand: SetLike = { + size: 1, + has() { + return false; + }, + keys() { + return (function* () { + try { + yield 2; + } finally { + closed = true; + } + })(); + }, + }; + const suppliedOperand = + operandKind === 'readonly-view' + ? readonlyView(operand) + : operand; + const view = readonlyView( + new Set([1]), + ) as unknown as ModernSet; + + expect(view.isSupersetOf(suppliedOperand as SetLike)).toBe( + false, + ); + expect(closed).toBe(true); + }, + ); + + it.each(['raw', 'readonly-view'] as const)( + 'isSupersetOf forwards a %s iterator return error with its original receiver', + (operandKind) => { + if (!hasSetMethod('isSupersetOf')) return; + + const returnError = new Error('iterator return failed'); + let usedOriginalReceiver = false; + const iterator = { + next() { + return { done: false as const, value: 2 }; + }, + return(this: unknown) { + usedOriginalReceiver = this === iterator; + throw returnError; + }, + }; + const operand: SetLike = { + size: 1, + has() { + return false; + }, + keys() { + return iterator; + }, + }; + const suppliedOperand = + operandKind === 'readonly-view' + ? readonlyView(operand) + : operand; + const view = readonlyView( + new Set([1]), + ) as unknown as ModernSet; + + expect(() => + view.isSupersetOf(suppliedOperand as SetLike), + ).toThrow(returnError); + expect(usedOriginalReceiver).toBe(true); + }, + ); }); From bb9a8227547aa86807b6695204cdbf7f17cdae39 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 4 Sep 2026 12:14:17 +0200 Subject: [PATCH 3/4] chore: prepare readonly-view 2.0.1 --- CHANGELOG.md | 14 ++++++++++++++ docs/release-readiness.md | 4 ++++ docs/supported-types.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9add8c..05eef1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +## 2.0.1 - 2026-09-04 + +### Fixed + +- Replaced open-ended Map, Set, and Date native-method dispatch with explicit, + fail-closed member inventories. +- Blocked `Map.prototype.getOrInsert` and `getOrInsertComputed` where available. +- Added readonly modern Set composition and relation operations, including + protected results, set-like operands, iterator cleanup, and aligned types. +- Prevented Date conversion methods from redispatching to source-owned method + overrides with a mutable receiver. +- Added native prototype inventory tests so new platform methods require an + explicit safety classification. + ## 2.0.0 - 2026-09-03 ### Breaking Changes diff --git a/docs/release-readiness.md b/docs/release-readiness.md index add4d23..351cbc8 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -1,5 +1,9 @@ # ReadonlyView 2.0 release readiness +Patch status: `2.0.1` is an unpublished hardening candidate. It closes native +method-dispatch escapes discovered during the post-release product-story audit. +It must pass the full local and hosted release gates before publication. + Status: **STABLE READY and released.** `2.0.0` passed its complete local gate on 2026-09-03 using macOS 14.6.1 arm64, Node 24.20.0, and npm 11.19.0. It was published publicly with provenance, released on GitHub, and deployed to the diff --git a/docs/supported-types.md b/docs/supported-types.md index 3df4c70..19646f9 100644 --- a/docs/supported-types.md +++ b/docs/supported-types.md @@ -21,3 +21,9 @@ Unclassified native members on Map, Set, and Date also fail closed instead of being invoked with the mutable source as their receiver. Where the runtime provides modern Set composition methods, their fresh Set results and members remain readonly through the originating membrane. + +On TypeScript libraries that include the ES2025 Set methods, a ReadonlyView Set +is intentionally not structurally assignable to the complete built-in +`ReadonlySet` interface. The built-in interface declares composition methods +that return mutable `Set` instances; ReadonlyView returns readonly Set views +instead. Ordinary readonly Set reads remain available. diff --git a/package-lock.json b/package-lock.json index 85db9e0..016b081 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nipe-solutions/readonly-view", - "version": "2.0.0", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nipe-solutions/readonly-view", - "version": "2.0.0", + "version": "2.0.1", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index d260243..346eaf0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nipe-solutions/readonly-view", - "version": "2.0.0", + "version": "2.0.1", "packageManager": "npm@11.19.0", "description": "A deeply readonly, lazy, live view over mutable JavaScript data.", "type": "module", From f1c8c2380f0aa6338a01f7f8c281701009b025c0 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 4 Sep 2026 12:42:32 +0200 Subject: [PATCH 4/4] fix: align Set callback types and release evidence --- docs/release-readiness.md | 303 +++++++++++++++++++------------ src/public-types.ts | 8 + test/types/modern-collections.ts | 13 +- 3 files changed, 206 insertions(+), 118 deletions(-) diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 351cbc8..1679885 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -1,138 +1,209 @@ -# ReadonlyView 2.0 release readiness - -Patch status: `2.0.1` is an unpublished hardening candidate. It closes native -method-dispatch escapes discovered during the post-release product-story audit. -It must pass the full local and hosted release gates before publication. - -Status: **STABLE READY and released.** `2.0.0` passed its complete local gate on -2026-09-03 using macOS 14.6.1 arm64, Node 24.20.0, and npm 11.19.0. It was -published publicly with provenance, released on GitHub, and deployed to the -production documentation origin. The subsequent trusted-publishing update is -merged to `main`, whose full CI matrix is green. - -## 1. Architecture - -Each `readonlyView` call creates an independent WeakMap-backed membrane. Controlled extensible shadow targets preserve proxy invariants without modifying sources. Values are wrapped lazily, identity is stable inside a membrane, and Map, Set, and Date use explicit, fail-closed native-member inventories. - -## 2. Public API - -The package exports `readonlyView`, `isReadonlyView`, `DirectMutationError`, `UnsupportedTypeError`, and the type-only `DeepReadonly`. The exports map blocks private subpaths. - -## 3. Breaking changes - -The package is now `@nipe-solutions/readonly-view`. Version 1's wrapper, `.value`, `internalSet`, validation, deep merge, lifecycle, default export, and state-manager positioning are removed. - -## 4. Supported types - -Primitives, plain and null-prototype objects, arrays and tuples, Map, Set, Date, functions with documented semantics, public-state custom classes, circular graphs, shared references, symbols, and accessors are supported. - -## 5. Explicitly unsupported types - -RegExp, Error, URL, URLSearchParams, ArrayBuffer, SharedArrayBuffer, DataView, typed arrays, WeakMap, WeakSet, and Promise throw `UnsupportedTypeError`. Consumer proxies are accepted under the documented trust limitation. - -## 6. Important semantic decisions - -Separate top-level calls create separate membranes; passing an existing view returns it unchanged. Collection keys from the same membrane are privately translated for lookup. Prototype reflection returns protected views, so custom-class `instanceof` is intentionally false. Private-field brand checks may fail. Independently captured mutable references and getter/function side effects are outside the boundary. +# ReadonlyView release readiness + +This record deliberately separates the released `2.0.0` baseline from the +unpublished `2.0.1` hardening candidate. Evidence or guarantees in one section +must not be attributed to the other version. + +## Released baseline: 2.0.0 + +### Public release evidence + +Version `2.0.0` is released, not a release candidate. Registry and GitHub checks +on 2026-09-04 established that: + +- npm reports package version `2.0.0` and the `latest` dist-tag points to + `2.0.0`; npm records publication at `2026-09-03T22:01:04.084Z`. +- The public tarball is + `https://registry.npmjs.org/@nipe-solutions/readonly-view/-/readonly-view-2.0.0.tgz`. + It is 7,018 B downloaded and 33,691 B unpacked, contains seven files, has + SHA-1 `9463b4d177b3dfbb088306b73afb92240d1eb2ac`, and npm integrity + `sha512-WqdJb4ZZayPgtgngLM1SpwUXar7T4CNyypjJikecqn7kT0govmugnKej0Fce2AnYFWuFaNsENASH6mwWJfWx4w==`. +- GitHub release `v2.0.0` is public and targets commit + `251b0c9e86740a681c50426569c50651290db140`. +- The production home page, four legal routes, `/robots.txt`, and + `/sitemap.xml` all returned HTTP 200 over HTTPS. + +The initial publication and bootstrap-token revocation are complete. The +current release workflow uses npm trusted publishing with short-lived OIDC +credentials and stores no npm token secret. + +### Historical 2.0.0 validation + +The 2.0.0 local release gate ran on 2026-09-03 using macOS 14.6.1 arm64, Node +24.20.0, and npm 11.19.0. It passed 77 runtime tests across 21 files and the +strict compile-time suite. The package boundary passed five additional tests: +one packed-consumer test and four registry-classification tests. A fresh +coverage run over the 77 runtime tests measured 91.34% statements, 89.11% +branches, 93.10% functions, and 96.41% lines. -## 7. Tests +The local browser run passed all 22 scheduled tests: 11 on Chromium and 11 on +Firefox. WebKit was excluded on the macOS 14 host under the documented upstream +compatibility rule; the Linux CI WebKit job remained the hosted requirement. + +The 2.0.0 benchmark on the same host measured 100,000 iterations: view creation +26.90 ms, first nested access 77.62 ms, repeated nested read 15.34 ms, deep read +23.88 ms, array iteration 1,446.00 ms, Map read 20.00 ms, Set iteration 40.35 +ms, and Date read 16.05 ms. These are diagnostic local results, not marketing +claims. + +### 2.0.0 contract boundary + +The fail-closed Map, Set, and Date inventories, protected ES2025 Set results, +IteratorClose hardening, Date secondary-dispatch protection, and the corrected +`Set#forEach` callback type described below are **not** claimed for 2.0.0. + +## Unpublished candidate: 2.0.1 + +Status on 2026-09-04: `2.0.1` is a local hardening candidate. It has not been +published, npm `latest` still resolves to `2.0.0`, and no `v2.0.1` release is +claimed here. + +### Hardening contract introduced by 2.0.1 + +The following guarantees apply only to the 2.0.1 candidate and to a future +published 2.0.1 containing these commits: + +- Map, Set, and Date use explicit native-member inventories and fail closed for + unknown members reached on their intrinsic prototypes. +- The proposed Map mutators `getOrInsert` and `getOrInsertComputed` are rejected + before invocation. +- Current Set composition and relation methods accept readonly set-like + operands. Composition returns remain protected readonly views, including when + composition is reached through the third `Set#forEach` callback argument. +- Set operand iteration forwards IteratorClose, including the original `return` + receiver and error. +- Date secondary dispatch through `toJSON` and `Symbol.toPrimitive` remains on + the protected path. + +These guarantees assume untampered intrinsics and do not turn ReadonlyView into +a sandbox. + +### Current local candidate evidence + +The following evidence was collected from this 2.0.1 worktree on 2026-09-04 +using Node 24.20.0 and npm 11.19.0: + +- `npm run test:unit`: 99 tests passed across 23 files. +- `npm run test:types`: the strict compile-time suite passed, including the + ES2025 `Set#forEach` callback composition regression. +- `npm run release:check`: the complete local release gate passed, including + formatting, lint, source typechecking, package and website checks, benchmark + smoke, release metadata verification, and artifact creation. +- `npm run test:size`: minified ESM 9,195/10,000 B, minified+gzip ESM + 2,725/3,000 B, declarations 3,188/3,500 B, and packed tarball 8,626/12,000 B. +- `npm run package:artifact`: produced + `.artifacts/nipe-solutions-readonly-view-2.0.1.tgz`, 8,626 B packed and 48,867 + B unpacked, with SHA-1 `550e7581c850b1b3afd28a7db4114668e37b3252`. +- The candidate tarball contains exactly `CHANGELOG.md`, `LICENSE`, `README.md`, + `package.json`, `dist/index.js`, `dist/index.cjs`, and `dist/index.d.ts`. + Runtime dependencies remain zero. +- A direct registry query for `@nipe-solutions/readonly-view@2.0.1` returned npm + `E404`; `latest` remained `2.0.0`. + +This is local candidate evidence, not publication evidence. Rebuilding after a +source or package-content change invalidates the recorded artifact hash and +requires fresh measurements. + +### Pre-publication local confirmation + +The candidate gate passed in this worktree. Repeat the complete gate on the +reviewed `main` commit before publication: -The release-candidate run passed 77 runtime tests across 21 files and the strict -compile-time suite. The package boundary run passed five more tests: one real -packed-consumer test and four unpublished-registry classification tests. It -covers mutation routes, descriptors, frozen/sealed/non-extensible values, -liveness, identity, cycles, native adapters, randomized graphs, documentation -examples, and regression cases from the final independent review. A fresh -coverage run passed the same 77 tests and measured 91.34% statements, 89.11% -branches, 93.10% functions, and 96.41% lines. +```bash +npm ci +npm run release:check +npm run test:browser +npm run coverage +npm run benchmark +npm pack --dry-run --json +tar -tzf .artifacts/nipe-solutions-readonly-view-2.0.1.tgz +``` -## 8. Package and build +`release:check` includes formatting, lint, source typechecking, the 99-test unit +suite, compile-time tests, size limits, packed-consumer and registry-classifier +tests, website verification, benchmark smoke, release metadata verification, +and creation of the candidate tarball. -Vite emits ES2022 ESM and CommonJS builds; TypeScript plus Rollup emits one -declaration entry. The exact seven-file npm tarball contains `CHANGELOG.md`, -`LICENSE`, `README.md`, `package.json`, `dist/index.js`, `dist/index.cjs`, and -`dist/index.d.ts`. It contains no tests, website source, benchmarks, source -maps, or private modules. The tarball is installed into an isolated consumer -and exercised through ESM, CommonJS, strict TypeScript, esbuild, tree shaking, -and blocked private imports. Runtime dependencies: zero. +### Hosted prerequisites -The release-candidate `.artifacts/nipe-solutions-readonly-view-2.0.0.tgz` was -6,971 B packed and 33,691 B unpacked. The public registry reports the same -seven-file manifest and 33,691 B unpacked, SHA-1 -`9463b4d177b3dfbb088306b73afb92240d1eb2ac`, and integrity -`sha512-WqdJb4ZZayPgtgngLM1SpwUXar7T4CNyypjJikecqn7kT0govmugnKej0Fce2AnYFWuFaNsENASH6mwWJfWx4w==`. +Do not publish 2.0.1 until all of these conditions hold for the reviewed commit: -## 9. CI and release +1. Merge the reviewed hardening change to `main` and require green Node 24 + quality, Node 22 compatibility, Chromium, Firefox, WebKit, and Vercel preview + checks. +2. Require green `main` CI. Verify the production Vercel deployment and every + HTTPS route: `/`, `/privacy/`, `/impressum/`, `/de/datenschutz/`, + `/de/impressum/`, `/robots.txt`, and `/sitemap.xml`. +3. Confirm the protected GitHub environment is `npm`, is limited to `main`, and + requires review. Confirm npm trusts repository + `NIPE-Solutions/readonly-view`, workflow `release.yml`, and environment `npm`. +4. Confirm `npm view @nipe-solutions/readonly-view@2.0.1 version` returns a real + npm `E404`. Authentication, network, and registry-server failures are not + evidence that the version is available. -CI runs the Node 24 quality gate, Node 22 compatibility, and -Chromium/Firefox/WebKit jobs. Release is manual, main-only, exact-confirmation -gated, environment protected, checks version/channel policy and registry -availability, carries the verified tarball through the browser gate, and waits -for propagation before creating a SHA-targeted GitHub release. The initial -publication used a short-lived granular token plus OIDC provenance. That -bootstrap token and its GitHub secret were revoked after publication. Future -releases use the configured npm trusted publisher and short-lived OIDC -credentials only. +### Patch-release procedure -`@nipe-solutions/readonly-view@2.0.0` is public under `latest`. Its npm -attestation reports SLSA provenance. GitHub release `v2.0.0` targets the -reviewed release commit `251b0c9e86740a681c50426569c50651290db140`. The first -release workflow published successfully but timed out while polling registry -propagation, so the SHA-targeted GitHub release was completed separately after -the registry became available. This did not change the published artifact. +From the reviewed `main` commit, dispatch the protected workflow with the exact +stable-release inputs: -The local browser command passed all 22 scheduled tests: 11 on Chromium and 11 -on Firefox. Playwright deliberately excluded WebKit on this macOS 14 host under -the repository's documented upstream-compatibility rule. Linux CI subsequently -passed Chromium, Firefox, and WebKit together with the Node 24 quality and Node -22 compatibility jobs. +```bash +version=2.0.1 +channel=latest +gh workflow run release.yml --ref main \ + -f version="$version" \ + -f channel="$channel" \ + -f "confirmation=publish $version with $channel" +``` -## 10. Documentation +Approve the `npm` environment deployment. The workflow must carry the single +artifact from verification through browser testing, reconfirm registry absence, +publish those exact bytes with OIDC provenance, wait for registry propagation, +and only then create GitHub release `v2.0.1` at the immutable workflow commit. +There is no bootstrap-token step for 2.0.1. -The repository contains a rewritten README, API, guarantees, compatibility, supported-types, architecture, security/trust, performance, migration, FAQ, contribution, release, security-policy, changelog, and five decision records. The framework-free documentation website mirrors these semantics and has interactive runtime examples and responsive browser tests. +After publication, verify npm metadata, `latest`, provenance attestation, an +exact-version install in a temporary consumer, every production HTTPS route, +and the GitHub release target. Only after those checks may this section be +changed from “unpublished candidate” to “released.” -## 11. Size +## Shared 2.0 product record -Release-candidate measurements: minified ESM 5,807 B (7,000 B budget), -minified+gzip ESM 1,900 B (2,500 B), declarations 2,026 B (2,500 B), packed -tarball 6,971 B (10,000 B), and unpacked tarball 33,691 B. +### Architecture and public API -## 12. Benchmark summary +Each `readonlyView` call creates an independent WeakMap-backed membrane. +Controlled extensible shadow targets preserve proxy invariants without modifying +sources. Values are wrapped lazily and identity is stable inside a membrane. -On Node 24.20.0, macOS arm64, 100,000 iterations measured: view creation 26.90 -ms, first nested access 77.62 ms, repeated nested read 15.34 ms, deep read 23.88 -ms, array iteration 1,446.00 ms, Map read 20.00 ms, Set iteration 40.35 ms, and -Date read 16.05 ms. The required 100-iteration benchmark smoke also passed as -part of `release:check`. These are diagnostic local results, not marketing -claims; see the methodology for caveats. +The package exports `readonlyView`, `isReadonlyView`, `DirectMutationError`, +`UnsupportedTypeError`, and the type-only `DeepReadonly`. The exports map blocks +private subpaths. Vite emits ES2022 ESM and CommonJS builds; TypeScript plus +Rollup emits one declaration entry. -## 13. Known limitations +### Breaking changes and supported values -ReadonlyView is not a sandbox. Generic/overloaded callable types cannot always -be transformed without losing signature information. Custom class views do -not preserve `instanceof`; private fields can reject proxy receivers. -Cross-realm and consumer-proxy behavior is limited by the supplied realm/proxy. -WebKit is excluded locally on macOS 14 and is therefore verified in Linux CI. +The package is now `@nipe-solutions/readonly-view`. Version 1's wrapper, +`.value`, `internalSet`, validation, deep merge, lifecycle, default export, and +state-manager positioning are removed. -## 14. Release evidence and remaining scope +Primitives, plain and null-prototype objects, arrays and tuples, Map, Set, Date, +functions with documented semantics, public-state custom classes, circular +graphs, shared references, symbols, and accessors are supported. -- Production documentation: -- npm: -- GitHub release: -- Green post-release `main` CI: -- Production Vercel deployment: commit - `962e6a00778ba83a24f303b6fd3fd3b0e90d8b6a` +RegExp, Error, URL, URLSearchParams, ArrayBuffer, SharedArrayBuffer, DataView, +typed arrays, WeakMap, WeakSet, and Promise throw `UnsupportedTypeError`. +Consumer proxies are accepted under the documented trust limitation. -No blocker remains for `2.0.0`. Bun, Deno, older TypeScript, and additional -native adapters remain deliberately unclaimed until they receive dedicated CI. +### Semantic decisions and limitations -## Local verification +Separate top-level calls create separate membranes; passing an existing view +returns it unchanged. Collection keys from the same membrane are privately +translated for lookup. Prototype reflection returns protected views, so +custom-class `instanceof` is intentionally false. Private-field brand checks may +fail. Independently captured mutable references and getter/function side effects +are outside the boundary. -```bash -npm ci -npm run release:check -npm run test:browser -npm run coverage -npm run benchmark -npm pack --dry-run --json -tar -tzf .artifacts/nipe-solutions-readonly-view-2.0.0.tgz -``` +ReadonlyView is not a sandbox. Generic or overloaded callable types cannot +always be transformed without losing signature information. Cross-realm and +consumer-proxy behavior is limited by the supplied realm or proxy. Bun, Deno, +older TypeScript, and additional native adapters must not be claimed without +dedicated CI. diff --git a/src/public-types.ts b/src/public-types.ts index 55c51e4..f41a02c 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -11,6 +11,14 @@ type ReadonlySetOperand = { }; type DeepReadonlySetMethods = { + forEach( + callbackfn: ( + value: Value, + value2: Value, + set: DeepReadonlySetView, + ) => void, + thisArg?: unknown, + ): void; union( other: ReadonlySetOperand, ): DeepReadonlySetView>; diff --git a/test/types/modern-collections.ts b/test/types/modern-collections.ts index 67a9d4e..0472649 100644 --- a/test/types/modern-collections.ts +++ b/test/types/modern-collections.ts @@ -2,7 +2,8 @@ import type { DeepReadonly } from '../../src/index'; -type ModernSetMethod = +type ReplacedSetMethod = + | 'forEach' | 'union' | 'intersection' | 'difference' @@ -11,7 +12,7 @@ type ModernSetMethod = | 'isSupersetOf' | 'isDisjointFrom'; -type ReadonlySetBase = Omit, ModernSetMethod>; +type ReadonlySetBase = Omit, ReplacedSetMethod>; declare const setView: DeepReadonly>; declare const otherSet: ReadonlySet<{ label: string }>; @@ -39,6 +40,14 @@ const relationResult: boolean = setView.isSubsetOf(otherSet); const supersetResult: boolean = setView.isSupersetOf(otherSet); const disjointResult: boolean = setView.isDisjointFrom(otherSet); const chainedResult = unionResult.difference(otherSet); +setView.forEach((value, duplicateValue, callbackSet) => { + const callbackUnionResult = callbackSet.union( + new Set([value, duplicateValue]), + ); + + // @ts-expect-error callback Set composition results stay readonly + callbackUnionResult.add(value); +}); declare const unionReturnTypeResult: ReturnType; declare const intersectionReturnTypeResult: ReturnType< typeof setView.intersection