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:
@@ -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