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/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..1679885 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -1,134 +1,209 @@ -# ReadonlyView 2.0 release readiness - -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 narrow native adapters. - -## 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/docs/supported-types.md b/docs/supported-types.md index 607bbd8..19646f9 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,13 @@ | 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. + +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", 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..4d82998 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,97 @@ 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)), + }; + }, + 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; + }, + }; +} + +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 +145,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 +260,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 +309,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 +342,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 +380,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 +389,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 +434,28 @@ 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)); + } + 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( @@ -301,7 +470,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 +612,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 +624,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..cd25744 --- /dev/null +++ b/src/native-members.ts @@ -0,0 +1,134 @@ +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', + '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(['toJSON', 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..f41a02c 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -4,6 +4,48 @@ type DateMutator = Extract; type ReadonlyDate = Readonly>; +type ReadonlySetOperand = { + readonly size: number; + has(value: Value): boolean; + keys(): Iterator; +}; + +type DeepReadonlySetMethods = { + forEach( + callbackfn: ( + value: Value, + value2: Value, + set: DeepReadonlySetView, + ) => void, + thisArg?: unknown, + ): void; + 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 = Omit< + ReadonlySet, + keyof DeepReadonlySetMethods +> & + DeepReadonlySetMethods; + +type DeepReadonlySet = 'union' extends keyof ReadonlySet + ? DeepReadonlySetView> + : ReadonlySet>; + type DeepReadonlyFunction unknown> = (( ...arguments_: Parameters ) => DeepReadonly>) & { @@ -31,7 +73,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..0472649 --- /dev/null +++ b/test/types/modern-collections.ts @@ -0,0 +1,85 @@ +/// + +import type { DeepReadonly } from '../../src/index'; + +type ReplacedSetMethod = + | 'forEach' + | 'union' + | 'intersection' + | 'difference' + | 'symmetricDifference' + | 'isSubsetOf' + | 'isSupersetOf' + | 'isDisjointFrom'; + +type ReadonlySetBase = Omit, ReplacedSetMethod>; + +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: ReadonlySetBase< + Readonly<{ id: number }> | Readonly<{ label: string }> +> = unionResult; +const readonlyIntersection: ReadonlySetBase< + Readonly<{ id: number; label: string }> +> = intersectionResult; +const readonlyDifference: ReadonlySetBase> = + differenceResult; +const readonlySymmetricDifference: ReadonlySetBase< + 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); +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 +>; +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 }); +// @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(); +// @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; +void readonlyDifference; +void readonlySymmetricDifference; +void relationResult; +void supersetResult; +void disjointResult; 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 496e3f1..4d3669c 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,147 @@ 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); + }, + ); + + 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); + }, + ); });