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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user