fix: le dossier dit la verite sur le code, et la garde peut echouer

Cinq constats, aucun sur la frontiere des droits : le dossier et la garde.

LE MOTIF ENREGISTRE SUR `copyWithin` ETAIT FAUX, et c-etait raisonne, pas joue -
dans le meme lot que `rule_justify-a-return-value-by-running-it`, dont la
premiere application est a son propre voisin. Mesure : le vrai `readOnlyArray`
ne leve PAS, il execute et DETRUIT le premier element (["A","B","C"] devient
["B","C","C"]), tandis que `push`/`splice`/`sort`/`reverse`/`fill`/`shift`
levent a la lecture. `copyWithin` echappe a cette garde pour la raison exacte
qui le fait nommer ici : il est dans la meme liste mal etiquetee dont la garde
est faite. Le refuser est donc PLUS protecteur que la cible, pas equivalent :
une divergence deliberee, consignee comme telle, avec la mesure a cote.

LE FICHIER DOCUMENTAIT ENCORE LA REGLE QUE CE ROUND A SUPPRIMEE, et se
contredisait : deux en-tetes decrivaient le defaut par TYPE ("un compte, un
drapeau passent") et disaient que `isLoading` n-est pas nomme, alors que la
ligne 759 le nomme. Le contrat gardait les deux politiques a la fois. Une
doctrine qui survit a une reecriture est precisement ce qui egare le suivant.

LA GARDE NE VERIFIAIT PAS CE QU-ELLE ANNONCAIT. Prouve : une copie verbatim avec
{copyWithin, slice} en exceptions et `slice` retire de la liste servie passait
1 pass / 0 fail. Elle compare maintenant une PARTITION - l-ensemble effectif
amont egale l-union, les trois parts sont disjointes, l-exception vaut exactement
un membre nomme - et lit aussi les entrees SYMBOLE, invisibles a l-ancienne
expression. Verifie rouge dans les trois directions.

LA DEROGATION ECMASCRIPT ETAIT INFONDEE TELLE QUE RAISONNEE : la specification
ferme les CLES, pas les VALEURS. Un `Symbol.toStringTag` implemente en accesseur
rendant `Set(${size})` publiait le compte non filtre. Les deux cles sont
calculees a partir de la forme enveloppee. Et ce que la vue divulgue par
ailleurs - l-existence d-une cle sur la cible - est dit exactement, avec la
mesure qui montre qu-aucune presence de cle ne depend des objets.

`reduce` branchait sur `init === undefined` au lieu de l-arite.
This commit is contained in:
Sylvain Duchesne
2026-09-24 20:43:04 +02:00
parent 5c50ca67c0
commit a88b4497d9
4 changed files with 207 additions and 84 deletions
@@ -84,18 +84,20 @@ function cannotReachAnItem(v: unknown): boolean {
}
/**
* The keys the LANGUAGE reads, whose values are primitives and describe the object rather
* than its contents.
* WHAT THIS VIEW DISCLOSES BESIDE THE ITEMS, said exactly rather than claimed away.
*
* This is not an SDK spelling list and cannot become one: it is closed by the ECMAScript
* specification, not by `@ng-org/orm`, so no version bump adds to it. Without it a view
* cannot be printed, tagged or concatenated, and refusing those refuses a question nobody
* asked about the items.
* A member the target does not carry answers `undefined` (see {@link answerUndecidedMember}),
* so a caller can tell a key the SDK has from one it does not: `view.by_a` refuses where
* `view.by_zzz` answers. That is deliberate, not an oversight - refusing a member nobody
* ships would refuse a question nobody asked - and it discloses the SDK's PUBLISHED SHAPE,
* which is in the package, not the holder's data.
*
* It is not a count channel either, and that is MEASURED rather than assumed: on every
* shape that ships, no key's PRESENCE depends on the items. `Object.keys(deepSignal(new
* Set(...)))` is empty whatever the set holds, and the array form's own keys are its
* indices, which this view projects onto the readable ones. If a future collection grew a
* key per item, that presence would become a count and this note is where to come back to.
*/
const LANGUAGE_PRIMITIVE_KEYS: ReadonlySet<string | symbol> = new Set([
Symbol.toStringTag,
Symbol.isConcatSpreadable,
]);
/** What a refused member says. One wording for both shapes of refusal, so that a report of
* either is greppable as the same event. */
@@ -145,10 +147,16 @@ function writeRefusalOf(item: unknown): Error {
* itself. The prohibition worth keeping is on a list that lets unknowns THROUGH - that one
* breaks silently on a bump, which is how this whole defect arrived.
*
* Two things are not refusals, and neither is an SDK spelling:
* - a member the target does NOT CARRY answers `undefined`, because the SDK offers no
* such member and there is nothing behind it to derive anything from;
* - {@link LANGUAGE_PRIMITIVE_KEYS}, closed by the language rather than by any package.
* One thing is not a refusal: a member the target does NOT CARRY answers `undefined`,
* because the SDK offers no such member and there is nothing behind it to derive anything
* from. What that discloses, and what it does not, is set out above.
*
* Note what is NOT here any more. A carve-out used to forward the target's own value for
* `Symbol.toStringTag` and `Symbol.isConcatSpreadable`, on the ground that the ECMAScript
* specification closes that key set. It closes the KEYS; it says nothing about the VALUES,
* so a tag implemented as a getter returning `Set(${size})` would have published the
* unfiltered count through a key this file had called harmless. Those two are DECIDED in
* the collection view now, with values the view computes from the shape it wraps.
*
* A function is refused AT THE CALL rather than at the read, which is not politeness: a
* function value is read by things that never call it - a logger, a devtools inspection, a
@@ -157,7 +165,6 @@ function writeRefusalOf(item: unknown): Error {
function answerUndecidedMember(target: object, prop: string | symbol, where: string): unknown {
if (!(prop in target)) return undefined;
const v = Reflect.get(target, prop, target);
if (LANGUAGE_PRIMITIVE_KEYS.has(prop) && cannotReachAnItem(v)) return v;
if (typeof v === "function") {
return () => {
throw refusalOf(prop, where);
@@ -239,16 +246,30 @@ export const NON_MUTATING_ARRAY_ONLY: ReadonlySet<string> = new Set([
/**
* The ONE member upstream lists as non-mutating that is not: `Array.prototype.copyWithin`
* writes. Upstream's own `readOnlyArray` agrees in practice - it THROWS "Cannot modify
* readonly array" when you so much as read `copyWithin` off it - so the list and the
* implementation next to it disagree, and the list is the one that is wrong.
* WRITES.
*
* Serving it over the snapshot made it succeed and discard silently, which teaches a caller
* that a write worked when the collection it ships against refuses the same call. So it is
* not served: reading it off a real ordered collection raises upstream's own error, and off
* anything else it is refused like any other undecided member. Named here rather than
* dropped quietly, because the parse gate in `test/read-filter.test.ts` checks for exactly
* this one exception and nothing more.
* ── What was recorded here, and what is actually true ─────────────────────
* This note used to say upstream's own `readOnlyArray` throws on reading `copyWithin`, so
* that refusing it merely matched the target. That was reasoned, not run, and it is FALSE.
* Measured against the installed package:
*
* ro.copyWithin -> function (no throw on the read)
* ro.copyWithin(0, 1) -> ["B","C","C"] (from ["A","B","C"] - the first
* element is DESTROYED)
* ro.push / splice / sort / reverse / fill / shift
* -> THROWS on the READ:
* "Readonly arrays do not expose non-mutating..."
*
* So `readOnlyArray` refuses every mutator it knows about - and `copyWithin` escapes that
* refusal for exactly the reason it is named here: it sits in the same mislabelled list the
* refusal is built from. Upstream's list and upstream's guard agree with each other and are
* both wrong.
*
* Refusing it is therefore MORE protective than the target, not equal to it: a deliberate
* divergence, recorded as one. The alternative is worse - serving it over the snapshot made
* it succeed and discard in silence, which teaches a caller that a write worked. Named here
* rather than dropped quietly, because the parse gate in `test/read-filter.test.ts` pins
* this exception by identity and cardinality.
*/
export const UPSTREAM_MISLABELLED_AS_NON_MUTATING: ReadonlySet<string> = new Set(["copyWithin"]);
/** Upstream's set-only additions - the ES2025 combinators, which need a real `Set` to run
@@ -338,20 +359,25 @@ function filteredDescriptor(
* They are wired to one `read` function below rather than to three copies of the rule,
* because a boundary made of parallel switch statements is a boundary that drifts.
*
* ── Why the default is decided by TYPE, and not by a list of names ────────
* Until 2026-09-24 that reasoning was applied to FUNCTION members alone: everything else
* was forwarded raw, on the recorded ground that "a plain property carries no items". That
* ground was never a property of the model, only of the object that happened to be wrapped
* and the moment the wrapped object changed, three plain properties of it each carried
* every identity's items ({@link makeReadFilteredShapeResult}). A boundary that is safe for
* the shape in front of it and unsafe for the next one is not a boundary either.
* ── The default: DECIDED, or refused ─────────────────────────────────────
* Every member this file serves is named below. Anything else refuses, whatever its type.
* That is the whole rule, and {@link answerUndecidedMember} is where it lives.
*
* So the default is {@link cannotReachAnItem}, which asks of the VALUE what no version
* number can change: a primitive cannot hold a reference to anything, so it cannot reach an
* item and passes untouched - `size`, `isLoading`, a count, a flag, a name. An object or a
* function can, so it refuses unless this file has a filtered form of it. That is why
* nothing below names `isLoading`, and why a member nobody has seen yet is already decided:
* if it carries items it is an object or a function, and both refuse.
* Two earlier defaults are gone, and both are worth knowing because each read as obviously
* sound when it was written:
* 1. "a plain property carries no items" - true of the object that happened to be wrapped
* then, and false the moment the wrapped object changed, when three plain properties
* each carried every identity's items ({@link makeReadFilteredShapeResult});
* 2. "a primitive cannot reach an item, so it passes" - false because a COUNT is a
* primitive that DESCRIBES the items. The file already contradicted itself on this:
* `size` and `length` were computed, so a third spelling of the same number went
* through raw.
*
* There is no type-level test that separates a primitive which is a value from one derived
* from the collection - a number is a number - so the default cannot be a type at all. What
* makes the current one safe for a shape nobody has seen is not that it classifies
* correctly, but that it refuses: a member the next version adds says so, loudly, naming
* itself.
*/
// @provenance makeReadFilteredView kind=aligned level=1 ref=engine/verifier/src/verifier.rs:load_repo_from_read_cap — the same filter as a live view over a reactive set; removed at migration
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S {
@@ -452,6 +478,13 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
* call this, so neither can answer what the other refuses. */
const read = (target: object, prop: string | symbol): unknown => {
if (prop === Symbol.iterator) return function* () { yield* kept(target); };
// HOW THE LANGUAGE NAMES THIS VIEW. Computed from the shape wrapped, never forwarded:
// the specification closes the KEY, not the value, so a tag implemented as a getter
// returning `Set(${size})` would have published the unfiltered count. Answered only
// when the target carries one, so the view grows no member the collection lacks -
// measured, a `DeepSignalSet` carries the constant "Set" and a `readOnlyArray` none.
if (prop === Symbol.toStringTag && prop in target) return arrayLike ? "Array" : "Set";
if (prop === Symbol.isConcatSpreadable && prop in target) return arrayLike;
// Upstream lists it beside `Symbol.iterator`. Answered only when the target carries
// one, so the view never grows an iteration the collection does not have.
if (prop === Symbol.asyncIterator && prop in target) {
@@ -515,10 +548,17 @@ export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry
if (prop === "drop") return (n: number) => kept(target).slice(n);
if (prop === "flatMap") return (fn: (v: unknown, i: number) => unknown) => kept(target).flatMap(fn as never);
if (prop === "reduce") {
return (fn: (acc: unknown, v: unknown, i: number) => unknown, init?: unknown) =>
init === undefined
? kept(target).reduce(fn as never)
: kept(target).reduce(fn as never, init);
// On ARITY, not on `init === undefined`. Branching on the value made an EXPLICIT
// `reduce(fn, undefined)` look like the one-argument form: an owner with no readable
// items got "reduce of empty array with no initial value" where a Set or an Array
// answers `undefined`, and with one item got that item back without `fn` ever being
// called. Measured on both counts.
return function (this: unknown, ...args: unknown[]): unknown {
const fn = args[0] as never;
return args.length > 1
? kept(target).reduce(fn, args[1])
: kept(target).reduce(fn);
};
}
if (prop === "getById" || prop === "getBy") {
const inner = Reflect.get(target, prop, target) as ((...a: unknown[]) => unknown) | undefined;
@@ -705,13 +745,14 @@ function filteredPromise(value: PromiseLike<unknown>, caps: CapRegistry): Promis
* object cannot be both, so there are two views and this is the one `surface/use-shape.ts`
* applies.
*
* ── What each member answers, and why nothing here names `isLoading` ──────
* ── What each member answers ──────────────────────────────────────────────
* The three that carry the items are named and filtered; the two that command a page are
* named and forwarded, because they return `void` and load rows into a collection that is
* only ever seen through the filter. `isLoading` is NOT named: it is a boolean, and the
* default already passes anything that cannot reach an item. That is the whole point of
* deciding by type - a member added by a later version is already decided, and a name list
* would have been another thing to get right on the next bump.
* only ever seen through the filter; `isLoading` is named and served, because it describes
* the REQUEST rather than the items. All six of `alpha.21`'s members are therefore decided
* here, and a seventh that a later version adds refuses until someone decides it - which is
* the point, and the reason this list is not the "name list" the earlier type-based default
* was written to avoid. That default passed unknowns; this one refuses them.
*
* `subscription` is REFUSED rather than wrapped, and the reason is this surface's own: it
* is the SDK's `RdfOrmSubscription`, which this package deliberately does not publish
+6 -3
View File
@@ -3,9 +3,12 @@
* arguments. Once the cap emulation is in force, what comes back is a read-filtered VIEW
* over the RESULT ENVELOPE the hook returns - `data` and `promise` carry only items in
* documents the current holder has the `ReadCap` of, `subscription` is refused, and the
* page commands and the load state pass through. A member's DESCRIPTOR answers what
* reading it answers, so the spread and the inspections built on that call read through
* the view rather than round it. Before the first cap is issued the real
* page commands and the load state pass through. A member's DESCRIPTOR answers what reading
* it answers, so what is built on that call reads through the view rather than round it -
* which for the envelope means a SPREAD of the result THROWS on `subscription` rather than
* copying it out. What no trap reaches is the HOST's own introspection: `console.log` and a
* devtools inspector read the object behind the proxy and print every identity's items
* (measured on Bun/JSC). The contract says so; there is nothing here to fix. Before the first cap is issued the real
* result passes through unchanged. At migration the filtering disappears: the broker only
* delivers documents whose cap the wallet holds.
*
+111 -32
View File
@@ -253,43 +253,71 @@ test("the array form yields indices where a Set yields items", () => {
// The three sets this view serves are RESTATED from `@ng-org/alien-deepsignals`, because
// the package exports no subpath for them and `src/` may not import an optional peer at
// runtime anyway. A restatement drifts unless something reads the original, so this does:
// it parses the shipped `dist/iteratorHelpers.js` and fails when the two have parted
// company. Red here means upstream moved, not that this test is wrong.
// runtime anyway. A restatement drifts unless something reads the original, so this does.
//
// It asserts a PARTITION and not three filtered comparisons, which is what the earlier
// version got wrong: it checked that each restated list matched upstream minus the
// exceptions, which is satisfied by any number of exceptions. PROVED - a copy of it with
// `{copyWithin, slice}` as the exception set and `slice` dropped from the served list ran
// 1 pass / 0 fail, so nothing stopped a second exception from silently removing
// `data.slice()` from the owner. Now upstream's list must equal the union exactly, the
// exception set must be exactly the one member named, and the three parts must be
// disjoint - so dropping a member goes red, and smuggling one into the exceptions goes red
// on a different assertion. The SYMBOL entries are read too, because the old regex saw
// only quoted strings and upstream declares four keys as symbols.
test("the declared non-mutating surface still matches what upstream ships", () => {
const shipped = readFileSync(
join(import.meta.dir, "..", "node_modules", "@ng-org", "alien-deepsignals", "dist", "iteratorHelpers.js"),
"utf8",
);
/** The quoted string members of one `new Set([...])` literal, by its declared name. */
const members = (name: string): string[] => {
/** One `new Set([...])` literal, by its declared name: its quoted members and its
* `Symbol.x` members, deduplicated the way `new Set` deduplicates them. */
const literal = (name: string): { strings: string[]; symbols: string[] } => {
const body = shipped.split(`export const ${name} = new Set([`)[1]?.split("])")[0] ?? "";
// Deduplicated, because `new Set([...])` is: upstream's array literal names
// `toLocaleString` twice, and a parse that did not dedupe would read that as a
// divergence from a list that is identical.
return [...new Set([...body.matchAll(/"([^"]+)"/g)].map((m) => m[1]!))].sort();
return {
strings: [...new Set([...body.matchAll(/"([^"]+)"/g)].map((m) => m[1]!))].sort(),
symbols: [...new Set([...body.matchAll(/Symbol\.(\w+)/g)].map((m) => m[1]!))].sort(),
};
};
expect(members("iteratorFnKeys")).toEqual([...ITERATOR_HELPERS].sort());
// Upstream's array and set lists are declared as their own entries `.union(iteratorFnKeys)`,
// and are restated here the same way - so the comparison is against the literal, not the union.
// ...minus the one member upstream mislabels, which is named in the source rather than
// dropped quietly, so this gate still goes red on any OTHER change to the list.
expect(
members("nonMutatingArrayFnKeys").filter(
(k) => !ITERATOR_HELPERS.has(k) && !UPSTREAM_MISLABELLED_AS_NON_MUTATING.has(k),
),
).toEqual([...NON_MUTATING_ARRAY_ONLY].sort());
expect([...UPSTREAM_MISLABELLED_AS_NON_MUTATING].every((k) =>
members("nonMutatingArrayFnKeys").includes(k),
)).toBe(true);
expect(members("nonMutatingSetFnKeys").filter((k) => !ITERATOR_HELPERS.has(k)))
.toEqual([...NON_MUTATING_SET_ONLY].sort());
const sorted = (...sets: ReadonlySet<string>[]): string[] =>
[...new Set(sets.flatMap((x) => [...x]))].sort();
// (a) the iterator helpers are restated exactly.
const iterator = literal("iteratorFnKeys");
expect(iterator.strings).toEqual([...ITERATOR_HELPERS].sort());
// (b) upstream's EFFECTIVE array set is exactly the union of what is served, the helpers
// and the exception - so a served member that goes missing has nowhere to hide.
// "Effective" because upstream declares these lists as a literal `.union(iteratorFnKeys)`,
// so three helpers (`drop`, `take`, `toArray`) are in the set without being in the
// literal; comparing against the literal alone would have failed for that reason and not
// for a real divergence.
const array = literal("nonMutatingArrayFnKeys");
const effectiveArray = [...new Set([...array.strings, ...iterator.strings])].sort();
expect(effectiveArray).toEqual(
sorted(NON_MUTATING_ARRAY_ONLY, ITERATOR_HELPERS, UPSTREAM_MISLABELLED_AS_NON_MUTATING),
);
// (c) and the three parts are disjoint, so a member cannot be counted in two of them.
expect(sorted(NON_MUTATING_ARRAY_ONLY, ITERATOR_HELPERS, UPSTREAM_MISLABELLED_AS_NON_MUTATING).length)
.toBe(NON_MUTATING_ARRAY_ONLY.size + ITERATOR_HELPERS.size + UPSTREAM_MISLABELLED_AS_NON_MUTATING.size);
// (d) the same partition for the set list, which has no exception of its own.
const set = literal("nonMutatingSetFnKeys");
const effectiveSet = [...new Set([...set.strings, ...iterator.strings])].sort();
expect(effectiveSet).toEqual(sorted(NON_MUTATING_SET_ONLY, ITERATOR_HELPERS));
// (e) THE EXCEPTION IS EXACTLY ONE MEMBER, NAMED. Without this a second one could be
// added to the set and every comparison above would still pass.
expect([...UPSTREAM_MISLABELLED_AS_NON_MUTATING]).toEqual(["copyWithin"]);
expect(array.strings).toContain("copyWithin");
// (f) the SYMBOL entries, invisible to the old string-only parse. A new one appearing is
// a member this view has not decided what to do with, so it must go red here.
expect(iterator.symbols).toEqual(["asyncIterator", "iterator"]);
expect(array.symbols).toEqual(["toString", "toStringTag"]);
expect(set.symbols).toEqual(["toString", "toStringTag"]);
});
// F3 of the third adversarial pass: the OWNER, every item theirs, was refused on 18
// declared members of an ordered result and on the seven Set combinators - a leak traded
// for an outage. Each is exercised with an argument it actually takes, and each answer is
// checked to be drawn from the readable items rather than merely not to throw.
test("every declared non-mutating member answers for the holder, on both forms", () => {
const { caps } = setup("alice"); // alice holds MINE and LINKED, never FOREIGN
// Typed explicitly because six of these members are ES2023 and this workspace compiles
@@ -324,10 +352,11 @@ test("every declared non-mutating member answers for the holder, on both forms",
expect(ordered.toSorted((x, y) => x.id.localeCompare(y.id))).toEqual([MINE, LINKED]);
expect(ordered.toSpliced(0, 1)).toEqual([LINKED]);
expect(ordered.with(0, NOGRAPH)).toEqual([NOGRAPH, LINKED]);
// `copyWithin` is the one member upstream MISLABELS as non-mutating: it writes, and
// upstream's own `readOnlyArray` throws on so much as reading it. Serving it over the
// snapshot made it succeed and discard in silence, so it is refused instead - against a
// real ordered collection that refusal is upstream's own.
// `copyWithin` is the one member upstream MISLABELS as non-mutating: it writes. Refusing
// it is MORE protective than the target rather than equal to it - measured, the real
// `readOnlyArray` runs it and destroys its first element, because its own guard is built
// from the same mislabelled list. Serving it over the snapshot made it succeed and
// discard in silence, which taught a caller that a write had worked.
expect(() => (ordered as unknown as { copyWithin(a: number, b: number): unknown })
.copyWithin(0, 1)).toThrow();
expect([...ordered]).toEqual([MINE, LINKED]);
@@ -415,3 +444,53 @@ test("a member this file has not decided is refused, primitive or not", () => {
// than by any package, so no version bump adds to them.
expect(Object.prototype.toString.call(view)).toBe("[object Set]");
});
// REGRESSION (fifth adversarial pass, 2026-09-24). Two carved keys used to forward the
// TARGET's value on the ground that the ECMAScript specification closes that key set. It
// closes the KEYS, not the values: a tag implemented as a getter is free to return
// `Set(${size})`, and the carve-out would have published the unfiltered count through a key
// this file had called harmless. They are computed from the shape wrapped now.
test("the language's own tag is computed, not forwarded from the collection", () => {
const hostile = new Set<Item>([MINE, FOREIGN, FOREIGN]) as Set<Item>;
Object.defineProperty(hostile, Symbol.toStringTag, {
get(this: Set<Item>) {
return `Set(${this.size})`;
},
configurable: true,
});
const { caps } = setup("alice"); // holds MINE only, so one of three is readable
const view = makeReadFilteredView(hostile, caps);
expect(view.size).toBe(1);
expect(Object.prototype.toString.call(view)).toBe("[object Set]");
expect(String(Object.prototype.toString.call(view))).not.toContain("3");
// and the array form is tagged as one only if the collection is tagged at all.
const ordered = makeReadFilteredView([MINE, FOREIGN], caps);
expect(Object.prototype.toString.call(ordered)).toBe("[object Array]");
});
// REGRESSION (fifth adversarial pass). `reduce` branched on `init === undefined` instead of
// on ARITY, so an EXPLICIT `reduce(fn, undefined)` was read as the one-argument form: an
// owner with no readable items got "reduce of empty array with no initial value" where a Set
// or an Array answers `undefined`, and with one item got that item back without `fn` ever
// running. Both measured against the native behaviour they must match.
test("reduce follows the native arity, not the value of the accumulator", () => {
const { caps, become } = setup("alice");
const view = makeReadFilteredView(new Set<Item>([MINE, FOREIGN]), caps) as Set<Item> & {
reduce(fn: (acc: unknown, v: Item) => unknown, init?: unknown): unknown;
};
// One readable item, an EXPLICIT undefined accumulator: `fn` runs once, as on an array.
let calls = 0;
expect(view.reduce((acc) => {
calls += 1;
return acc;
}, undefined)).toBeUndefined();
expect(calls).toBe(1);
expect([MINE].reduce((acc: unknown) => acc, undefined)).toBeUndefined(); // the native answer
// No readable items, an explicit undefined accumulator: `undefined`, not a TypeError.
become("bob");
expect(view.reduce((acc) => acc, undefined)).toBeUndefined();
// and the one-argument form on an empty view still throws, exactly as an array does.
expect(() => view.reduce((acc) => acc)).toThrow(/empty/i);
});