From 4eb0142fc875b59321f3a5ce6c215a86519253fb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:52:06 +0000 Subject: [PATCH 1/7] types/rtti: read a tuple schema by length, not by enumerable entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parse` and `validate` walked a container schema with `Object.entries`, which skips a sparse array's holes, while the data form's `containerUnion` walks it with `for…of`, which yields `undefined` for a hole and preserves length. The three readers therefore disagreed about what a sparse tuple schema declares. Length wins, per the todo's decision: a hole is a declared position whose schema is `undefined`. `tupleSchemaEntries`/`structSchemaEntries` in `rtti/common` are now the per-kind entry readers, passed to all four container factories beside the `getItem` knob they already take. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FfPCE6b3fK6cDMtYxhnLGL --- fjs/types/rtti/README.md | 24 +++++ fjs/types/rtti/common/module.f.mjs | 33 ++++++- fjs/types/rtti/common/proof.f.mjs | 30 ++++++- fjs/types/rtti/common/types.ts | 9 ++ fjs/types/rtti/parse/module.f.mjs | 42 ++++++--- .../rtti/todo/sparse-tuple-schema-entries.md | 89 ------------------- fjs/types/rtti/validate/module.f.mjs | 48 ++++++---- fjs/types/rtti/validate/proof.f.mjs | 11 +++ 8 files changed, 164 insertions(+), 122 deletions(-) delete mode 100644 fjs/types/rtti/todo/sparse-tuple-schema-entries.md diff --git a/fjs/types/rtti/README.md b/fjs/types/rtti/README.md index 3b01ec882..3dd090137 100644 --- a/fjs/types/rtti/README.md +++ b/fjs/types/rtti/README.md @@ -102,6 +102,30 @@ undeclared keys are unconstrained, a tuple's `rest` is `unknown` — so [Closed containers](#closed-containers) below. Closedness is stated, never inferred. +#### A hole is a declared position + +A `Tuple` schema is read by **length**, so a sparse one declares as many +positions as it is long and a hole is a position whose schema is `undefined`: + +| schema | value | all three readers | +| --- | --- | --- | +| `new Array(1)` | `[undefined]` | ok | +| `new Array(1)` | `[1, 2, 3]` | error | +| `[, number]` | `[undefined, 5]` | ok | +| `[, number]` | `[9, 5]` | error | + +Reading index `0` of `new Array(1)` yields `undefined`, and `undefined` is a +`Const` schema in its own right, so this is what follows from `Tuple` being +`readonly Type[]`. `Object.entries` — which skips holes — was the schema-form +readers' entry list until it disagreed with the data form's `for…of` on exactly +these rows; `tupleSchemaEntries` in `common/module.f.mjs` is now the one place +that says how a tuple schema is read, and `structSchemaEntries` is its struct +counterpart. The alternative reading would make `new Array(1)` and `[]` the +same schema while `[undefined]` stayed different from both. + +Nothing about a dense schema changes: on an array without holes the two entry +lists are identical. + #### This is deliberate; please do not "fix" it The tempting mistake is to read `Ts` and conclude tuples must be exact: diff --git a/fjs/types/rtti/common/module.f.mjs b/fjs/types/rtti/common/module.f.mjs index 3ffb5716e..b29efeb22 100644 --- a/fjs/types/rtti/common/module.f.mjs +++ b/fjs/types/rtti/common/module.f.mjs @@ -18,6 +18,8 @@ * - `eachEntry`: the container entry loop (array/record/tuple/struct). Callers * choose what (if anything) to accumulate, so a caller that only needs * pass/fail pays no allocation per entry. + * - `tupleSchemaEntries`/`structSchemaEntries`: what a container schema + * declares, per kind — the entry list its readers walk. * - `undeclaredEntries`: the other half of a closed container's loop — the * entries a `Tuple`/`Struct` schema does not name. * - `orVisit`: the shared `or` handler — try each variant's recursive walker, @@ -29,10 +31,10 @@ * @module * * @import { Primitive, Unknown } from '../ts/types.ts' - * @import { Const, Info0, Primitive0, Tag1, Tuple, Type } from '../types.ts' + * @import { Const, Info0, Primitive0, Struct, Tag1, Tuple, Type } from '../types.ts' * @import { Error, Result as CommonResult } from '../../result/types.ts' * @import { StringMap } from '../../object/types.ts' - * @import { Validate, Visitor, IsContainer, Container, ResultE, ValidateE, ValidationError } from './types.ts' + * @import { Validate, Visitor, IsContainer, Container, ResultE, SchemaEntries, ValidateE, ValidationError } from './types.ts' */ import { assert } from '../../../asserts/module.f.mjs' @@ -130,6 +132,33 @@ export const eachEntry = return ok(acc) } +/** + * What a `Tuple` schema declares, read by **length**. + * + * `Array.from` yields `undefined` for a hole and preserves the schema's + * length, so a hole is a declared position whose schema is `undefined` — which + * is a `Const` schema in its own right, and exactly what reading index `0` of + * `new Array(1)` gives. That is the reading `../data/module.f.mjs`'s + * `containerUnion` has always had, so the canonical data form stays fixed. + * + * `Object.entries` skips holes, which is why it is not used here: it would + * make `new Array(1)` and `[]` the same schema while `[undefined]` stayed + * different from both. On a dense array the two agree exactly. + * + * @type {SchemaEntries} + */ +export const tupleSchemaEntries = rtti => + Array.from(rtti, (t, i) => [String(i), t]) + +/** + * What a `Struct` schema declares: its enumerable own keys. A struct has no + * holes, so there is nothing for this to disagree with. + * + * @type {SchemaEntries} + */ +export const structSchemaEntries = rtti => + Object.entries(rtti) + /** * The entries of `value` that `declared` does not name. * diff --git a/fjs/types/rtti/common/proof.f.mjs b/fjs/types/rtti/common/proof.f.mjs index e2af98918..98d9915dd 100644 --- a/fjs/types/rtti/common/proof.f.mjs +++ b/fjs/types/rtti/common/proof.f.mjs @@ -3,7 +3,7 @@ * @import { ValidationError } from './types.ts' */ -import { eachEntry, undeclaredEntries } from './module.f.mjs' +import { eachEntry, structSchemaEntries, tupleSchemaEntries, undeclaredEntries } from './module.f.mjs' import { error, ok } from '../../result/module.f.mjs' import { assert, assertEq, assertStructurallySame } from '../../../asserts/module.f.mjs' @@ -72,6 +72,34 @@ export const proof = { // length — see `fits` in `../parse/module.f.mjs`. holeIsNotAnEntry: () => assertEq(undeclaredEntries(['0'], [1, , 3]).length, 1), }, + // What a container schema declares, per kind. A tuple is read by length, + // so a hole is a declared position whose schema is `undefined` — the same + // reading `../data/module.f.mjs` has, and the reason the two kinds need + // different entry readers at all. + schemaEntries: { + tuple: () => assertStructurallySame( + tupleSchemaEntries([1, 'a']), + [['0', 1], ['1', 'a']], + ), + // `Object.entries` would answer `[]` here, and `[['0', undefined]]` for + // `[undefined]` — two schemas that denote the same set, read as two. + tupleHole: () => assertStructurallySame( + tupleSchemaEntries(new Array(1)), + [['0', undefined]], + ), + tupleHoleIsTheDenseReading: () => assertStructurallySame( + tupleSchemaEntries(new Array(1)), + tupleSchemaEntries([undefined]), + ), + struct: () => assertStructurallySame( + structSchemaEntries({ a: 1, b: 'x' }), + [['a', 1], ['b', 'x']], + ), + empty: () => { + assertEq(tupleSchemaEntries([]).length, 0) + assertEq(structSchemaEntries({}).length, 0) + }, + }, pathPrefixed: () => { /** @type {(k: string, v: number) => Result} */ const nested = (k, v) => diff --git a/fjs/types/rtti/common/types.ts b/fjs/types/rtti/common/types.ts index 5a31de5d1..24bb3b7c2 100644 --- a/fjs/types/rtti/common/types.ts +++ b/fjs/types/rtti/common/types.ts @@ -45,6 +45,15 @@ export type Visitor = { readonly unknown: () => R } +/** + * Reads what a container schema declares, as `[key, Type]` pairs — one per + * container kind, since a `Tuple` is read by length and a `Struct` by + * enumerable key. See `tupleSchemaEntries` in `./module.f.mjs` for why the two + * readings are not interchangeable on a sparse array. + */ +export type SchemaEntries = + (rtti: S) => ReadonlyArray + /** Type guard narrowing `Unknown` to a specific container type `C`. */ export type IsContainer = (value: Unknown) => value is C diff --git a/fjs/types/rtti/parse/module.f.mjs b/fjs/types/rtti/parse/module.f.mjs index 385d6cfef..1969e7f02 100644 --- a/fjs/types/rtti/parse/module.f.mjs +++ b/fjs/types/rtti/parse/module.f.mjs @@ -20,6 +20,10 @@ * member reads as `undefined`, on both kinds — so a shorter array whose * trailing position admits `undefined` is accepted and the gap is filled. * + * A tuple schema declares by length, so a hole in one is a declared position + * whose schema is `undefined` — see "A hole is a declared position" in + * `../README.md`. + * * Openness is what makes this forward-compatible with extended serialization * formats: a schema-based parser keeps working when newer versions of the * format add extra fields or tuple elements. @@ -45,11 +49,11 @@ * * @module * - * @import { ConstObject, Info1, Struct, Tag1, Tuple, Type } from '../types.ts' + * @import { ConstObject, Info1, Tag1, Type } from '../types.ts' * @import { Result as CommonResult } from '../../result/types.ts' * @import { StringMap } from '../../object/types.ts' * @import { List } from '../../list/types.ts' - * @import { Container, IsContainer, ValidateE, ValidationError, Visitor } from '../common/types.ts' + * @import { Container, IsContainer, SchemaEntries, ValidateE, ValidationError, Visitor } from '../common/types.ts' * @import { Unknown } from '../ts/types.ts' * @import { Parse } from './types.ts' */ @@ -63,6 +67,8 @@ import { isObject, orVisit, primitive0Validate, + structSchemaEntries, + tupleSchemaEntries, undeclaredEntries, verror, visit, @@ -127,26 +133,28 @@ const arrayParse = containerParse(isArray, arrayRebuild) const recordParse = containerParse(isObject, recordRebuild) /** - * Builds a parser for `Tuple` or `Struct` const schemas. It iterates the - * *schema's* entries, which is what makes both kinds open: a longer array or - * an undeclared key is never visited, so it is accepted and left out of the - * rebuilt result. + * Builds a parser for `Tuple` or `Struct` const schemas. It iterates what the + * *schema* declares — `schemaEntries`, per kind — which is what makes both + * kinds open: a longer array or an undeclared key is never visited, so it is + * accepted and left out of the rebuilt result. */ const constContainerParse = /** * @template {Unknown} C + * @template {ConstObject} S * @param {IsContainer} isContainer + * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem * @param {_Rebuild} rebuild - * @returns {(rtti: T) => Parse} + * @returns {(rtti: T) => Parse} */ - (isContainer, getItem, rebuild) => + (isContainer, schemaEntries, getItem, rebuild) => rtti => value => { if (!isContainer(value)) { return verror('unexpected value') } const r = eachEntry( - entries(rtti), + schemaEntries(rtti), (k, t) => (/** @type {any} */ (parse(t))(getItem(value, k))), emptyEntries, consEntry, @@ -156,12 +164,14 @@ const constContainerParse = const tupleParse = constContainerParse( isArray, + tupleSchemaEntries, (value, k) => value[Number(k)], arrayRebuild, ) const structParse = constContainerParse( isObject, + structSchemaEntries, (value, k) => value[k], recordRebuild, ) @@ -182,16 +192,18 @@ const noAccumulate = () => undefined const closeContainerParse = /** * @template {ReadonlyArray | StringMap} C + * @template {ConstObject} S * @param {IsContainer} isContainer + * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem * @param {_Rebuild} rebuild * @param {(value: C, declared: number) => boolean} fits - * @returns {(rtti: ConstObject, rest: Type | undefined) => ValidateE} + * @returns {(rtti: S, rest: Type | undefined) => ValidateE} */ - (isContainer, getItem, rebuild, fits) => + (isContainer, schemaEntries, getItem, rebuild, fits) => (rtti, rest) => { // Depend on the schema alone, so they are computed once per schema. - const rttiEntries = entries(rtti) + const rttiEntries = schemaEntries(rtti) const declared = rttiEntries.map(([k]) => k) return value => { if (!isContainer(value)) { @@ -218,6 +230,7 @@ const closeContainerParse = const closeTupleParse = closeContainerParse( isArray, + tupleSchemaEntries, (value, k) => value[Number(k)], arrayRebuild, (value, declared) => value.length <= declared, @@ -225,6 +238,7 @@ const closeTupleParse = closeContainerParse( const closeStructParse = closeContainerParse( isObject, + structSchemaEntries, (value, k) => value[k], recordRebuild, () => true, @@ -232,7 +246,9 @@ const closeStructParse = closeContainerParse( /** @type {(rtti: ConstObject, rest: Type | undefined) => ValidateE} */ const closeParse = (rtti, rest) => - (rtti instanceof Array ? closeTupleParse : closeStructParse)(rtti, rest) + rtti instanceof Array + ? closeTupleParse(rtti, rest) + : closeStructParse(rtti, rest) const orParse = /** diff --git a/fjs/types/rtti/todo/sparse-tuple-schema-entries.md b/fjs/types/rtti/todo/sparse-tuple-schema-entries.md deleted file mode 100644 index 44d678a1d..000000000 --- a/fjs/types/rtti/todo/sparse-tuple-schema-entries.md +++ /dev/null @@ -1,89 +0,0 @@ -# Read a tuple schema by length, not by enumerable entries - -**Priority:** P3 -**Status:** open - -## Problem - -A `Tuple` schema is `readonly Type[]`, and the three readers disagree about -what a **sparse** one declares. `toData` reads it by length and the schema-form -readers read it by enumerable entries, so a hole is a declared `undefined` -position to one and no position at all to the other. - -- `../data/module.f.mjs`'s `containerUnion` walks the schema with - `for (const item of c)`, which yields `undefined` for a hole and visits every - index, so `new Array(1)` becomes the one-position prefix `[{ unit: undefined }]`. -- `../parse/module.f.mjs`'s `constContainerParse` and - `../validate/module.f.mjs`'s `constContainerValidate` walk it with - `Object.entries(rtti)`, which skips holes entirely, so the same schema - declares nothing. - -That breaks the agreement `../validate/proof.f.mjs` pins as a table — every -reader of a schema answers the same way. Verified against `6c609e6`, before -`close` existed, so this is not about closed containers: - -| schema | value | `validate` | `parse` | data form | -| --- | --- | --- | --- | --- | -| `new Array(1)` | `[1, 2, 3]` | ok | ok | **error** | -| `[, number]` | `[9, 5]` | ok | ok | **error** | - -The schema-form readers declare nothing and accept anything array-shaped; the -data form holds position 0 to `undefined` and rejects a value that has -something there. `close` reaches the same disagreement from the other side — -`close(new Array(1))` rejects `new Array(1)` and `[undefined]` in the -schema-form readers because `declared.length` is `0` while the value's length -is `1`, and the data form accepts both — but it is the same one bug, and -predates it. - -Neither reader is right by construction, so the fix is a decision about what a -hole *means*, not a patch to one side. - -## Proposal - -**Length wins: a hole is a declared position whose schema is `undefined`.** -Reading index `0` of `new Array(1)` yields `undefined`, and `undefined` is a -`Const` schema in its own right, so the length-based reading is the one that -follows from `Tuple` being `readonly Type[]`. It is also what `toData` already -does, which keeps the canonical data form — the content-addressed one — fixed. - -Give the container factories a per-kind schema-entry function, beside the -`getItem` knob they already take: - -```js -const tupleSchemaEntries = rtti => Array.from(rtti, (t, i) => [String(i), t]) -const structSchemaEntries = Object.entries -``` - -`Array.from` treats a hole as `undefined` and preserves length, so it agrees -with `containerUnion`'s `for…of` exactly, and it is identical to -`Object.entries` on a dense array — the only shape any schema in this -repository actually has. Apply it in all four factories: `constContainerParse`, -`constContainerValidate`, and the two `close` ones. - -This changes open-tuple acceptance for sparse schemas, so it wants its own -changelog entry rather than riding along with an unrelated change. - -The alternative — entries win, and `containerUnion` switches to -`Object.entries` — is worth stating only to reject it: it would make -`new Array(1)` and `[]` the same schema while `[undefined]` stays different -from both, which is a distinction no reader of the source could predict. - -## Tasks - -- [ ] Confirm the length reading is the intended meaning of a hole. -- [ ] Add the per-kind schema-entry function and use it in the four container - factories. -- [ ] Add sparse-schema rows to `../validate/proof.f.mjs`'s acceptance table, - which is where the disagreement should have shown up. -- [ ] Changelog entry: open tuple schemas with holes change acceptance. - -## Related - -- `../validate/proof.f.mjs` — the acceptance table that runs one set of rows - through all three readers; it carries no sparse-schema row today, which is - why this survived. -- `../data/module.f.mjs`, `containerUnion` — the length-based reading. -- `../parse/module.f.mjs`, `../validate/module.f.mjs` — the entry-based one. -- Reported by an automated reviewer on - [PR #1687](https://github.com/functionalscript/functionalscript/pull/1687), - which added `close`; confirmed there to predate it. diff --git a/fjs/types/rtti/validate/module.f.mjs b/fjs/types/rtti/validate/module.f.mjs index 757ae78e2..d3878071b 100644 --- a/fjs/types/rtti/validate/module.f.mjs +++ b/fjs/types/rtti/validate/module.f.mjs @@ -29,11 +29,13 @@ * ## Structs and tuples are open * * Openness is the shared rule, not a `parse` detail — see "Structs and tuples - * are open" in `../README.md`. `validate` iterates the *schema's* entries, so - * an undeclared key or a longer array is never visited: it is accepted, and it - * is still there afterwards because the value is returned as-is. An absent + * are open" in `../README.md`. `validate` iterates what the *schema* declares, + * so an undeclared key or a longer array is never visited: it is accepted, and + * it is still there afterwards because the value is returned as-is. An absent * member reads as `undefined`, so a member is required exactly when its set - * excludes `undefined`. + * excludes `undefined`. A tuple schema declares by length, so a hole is a + * position whose schema is `undefined` — see "A hole is a declared position" in + * `../README.md`. * * **Do not add a length check for tuples here.** `Ts` is the * exact tuple only because TypeScript cannot express the open one (see @@ -72,8 +74,8 @@ * @module * * @import { Unknown } from '../ts/types.ts' - * @import { ConstObject, Info1, Struct, Tag1, Tuple, Type } from '../types.ts' - * @import { Container, IsContainer, Validate, ValidateE, Visitor } from '../common/types.ts' + * @import { ConstObject, Info1, Tag1, Type } from '../types.ts' + * @import { Container, IsContainer, SchemaEntries, Validate, ValidateE, Visitor } from '../common/types.ts' * @import { StringMap } from '../../object/types.ts' */ @@ -85,6 +87,8 @@ import { isObject, orVisit, primitive0Validate, + structSchemaEntries, + tupleSchemaEntries, undeclaredEntries, verror, visit, @@ -129,23 +133,25 @@ const arrayValidate = containerValidate(isArray) const recordValidate = containerValidate(isObject) /** - * Builds a validator for `Tuple` or `Struct` const schemas. It iterates the - * *schema's* entries, which is what makes both kinds open: a longer array or - * an undeclared key is never visited, so it is accepted — and, the value being - * returned as it came, it survives. + * Builds a validator for `Tuple` or `Struct` const schemas. It iterates what + * the *schema* declares — `schemaEntries`, per kind — which is what makes both + * kinds open: a longer array or an undeclared key is never visited, so it is + * accepted — and, the value being returned as it came, it survives. */ const constContainerValidate = /** * @template {Unknown} C + * @template {ConstObject} S * @param {IsContainer} isContainer + * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem - * @returns {(rtti: T) => Validate} + * @returns {(rtti: T) => Validate} */ - (isContainer, getItem) => + (isContainer, schemaEntries, getItem) => rtti => { // Depends on `rtti` alone, so it is computed once per schema rather // than once per validated value. - const rttiEntries = entries(rtti) + const rttiEntries = schemaEntries(rtti) return value => { if (!isContainer(value)) { return verror('unexpected value') @@ -164,11 +170,13 @@ const constContainerValidate = const tupleValidate = constContainerValidate( isArray, + tupleSchemaEntries, (value, k) => value[Number(k)], ) const structValidate = constContainerValidate( isObject, + structSchemaEntries, (value, k) => value[k], ) @@ -186,15 +194,17 @@ const structValidate = constContainerValidate( const closeContainerValidate = /** * @template {ReadonlyArray | StringMap} C + * @template {ConstObject} S * @param {IsContainer} isContainer + * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem * @param {(value: C, declared: number) => boolean} fits - * @returns {(rtti: ConstObject, rest: Type | undefined) => ValidateE} + * @returns {(rtti: S, rest: Type | undefined) => ValidateE} */ - (isContainer, getItem, fits) => + (isContainer, schemaEntries, getItem, fits) => (rtti, rest) => { // Depend on the schema alone, so they are computed once per schema. - const rttiEntries = entries(rtti) + const rttiEntries = schemaEntries(rtti) const declared = rttiEntries.map(([k]) => k) return value => { if (!isContainer(value)) { @@ -221,19 +231,23 @@ const closeContainerValidate = const closeTupleValidate = closeContainerValidate( isArray, + tupleSchemaEntries, (value, k) => value[Number(k)], (value, declared) => value.length <= declared, ) const closeStructValidate = closeContainerValidate( isObject, + structSchemaEntries, (value, k) => value[k], () => true, ) /** @type {(rtti: ConstObject, rest: Type | undefined) => ValidateE} */ const closeValidate = (rtti, rest) => - (rtti instanceof Array ? closeTupleValidate : closeStructValidate)(rtti, rest) + rtti instanceof Array + ? closeTupleValidate(rtti, rest) + : closeStructValidate(rtti, rest) const orValidate = /** diff --git a/fjs/types/rtti/validate/proof.f.mjs b/fjs/types/rtti/validate/proof.f.mjs index 9ba7772c3..2eb811e72 100644 --- a/fjs/types/rtti/validate/proof.f.mjs +++ b/fjs/types/rtti/validate/proof.f.mjs @@ -76,12 +76,23 @@ const rows = [ [[/** @type {const} */ (42)], []], [{ a: number, b: option(string) }, { a: 1 }], [{ a: number }, { a: 'one' }], + // a hole in a tuple schema is a declared position whose schema is + // `undefined`, so the schema's length is what it declares — the reading + // the data form has always had, and the one `Object.entries` lost + [new Array(1), [1, 2, 3]], + [new Array(1), [undefined]], + [new Array(1), []], + [[, number], [9, 5]], + [[, number], [undefined, 5]], // the closed counterparts of the four openness rows, and the rest [close([number]), [42]], [close([number]), [42, 'extra']], [close([number]), [42, ,]], [close([number]), Object.assign([42], { foo: 1 })], [close([number]), []], + [close(new Array(1)), new Array(1)], + [close(new Array(1)), [undefined]], + [close(new Array(1)), [1]], [close([number, option(string)]), [42]], [close({ a: number }), { a: 1 }], [close({ a: number }), { a: 1, b: 'x' }], From 9cf2987ecc9eb17a7cb9a5fc96b947ec34a1cebe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:52:43 +0000 Subject: [PATCH 2/7] changelog: add 1712 entry Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FfPCE6b3fK6cDMtYxhnLGL --- changelog/unreleased/1712.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/unreleased/1712.md diff --git a/changelog/unreleased/1712.md b/changelog/unreleased/1712.md new file mode 100644 index 000000000..00970ec11 --- /dev/null +++ b/changelog/unreleased/1712.md @@ -0,0 +1,3 @@ +- `types/rtti`: `parse` and `validate` read a tuple schema by length, so a hole + in one is a declared position whose schema is `undefined` — agreeing with the + data form. Sparse schemas change acceptance; dense ones are unaffected. From ab2cab7ea62f555b9d7999a78ad60139b1c6afff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:53:04 +0000 Subject: [PATCH 3/7] changelog: mark 1712 as a breaking change Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FfPCE6b3fK6cDMtYxhnLGL --- changelog/unreleased/1712.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/changelog/unreleased/1712.md b/changelog/unreleased/1712.md index 00970ec11..21dfe8d4a 100644 --- a/changelog/unreleased/1712.md +++ b/changelog/unreleased/1712.md @@ -1,3 +1,4 @@ -- `types/rtti`: `parse` and `validate` read a tuple schema by length, so a hole - in one is a declared position whose schema is `undefined` — agreeing with the - data form. Sparse schemas change acceptance; dense ones are unaffected. +- **BREAKING CHANGES:** `types/rtti`: `parse` and `validate` read a tuple schema + by length, so a hole in one is a declared position whose schema is + `undefined` — `validate([, number])([9, 5])` is now an error, agreeing with + the data form. Dense schemas are unaffected From f3dfaef9d424bb91de21e6c863fd45cf0d48e090 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 07:40:59 +0000 Subject: [PATCH 4/7] types/rtti: name the non-index-property acceptance change, and pin the verdicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. Two gaps, both in what the change says rather than what it does. The length reading also stops a tuple schema's non-index enumerable own properties from being declared, which the todo never mentioned. It is the same rule and a strict improvement — `Object.assign([number], { foo: string })` against `[1]` was an error from `parse`/`validate` and ok from the data form — and sharper than a missing declaration: a tuple is read by index, so `foo` was declared and then matched against `value[NaN]`. It now has a changelog line, a README paragraph, two acceptance-table rows and a proof. The sparse rows assert only that the three readers agree, never what they agree on. `sparseTuple` states the verdicts directly — the hole reading, the closed arity, and the non-index property — and all three die when `tupleSchemaEntries` is mutated back to `Object.entries`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FfPCE6b3fK6cDMtYxhnLGL --- changelog/unreleased/1712.md | 3 +++ fjs/types/rtti/README.md | 11 +++++++++-- fjs/types/rtti/common/module.f.mjs | 5 ++++- fjs/types/rtti/validate/proof.f.mjs | 27 +++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/changelog/unreleased/1712.md b/changelog/unreleased/1712.md index 21dfe8d4a..7ec599197 100644 --- a/changelog/unreleased/1712.md +++ b/changelog/unreleased/1712.md @@ -2,3 +2,6 @@ by length, so a hole in one is a declared position whose schema is `undefined` — `validate([, number])([9, 5])` is now an error, agreeing with the data form. Dense schemas are unaffected +- **BREAKING CHANGES:** `types/rtti`: a tuple schema's non-index enumerable own + property is no longer a declared member — a tuple is read by index, so such a + key was matched against `value[NaN]`. The data form always ignored it diff --git a/fjs/types/rtti/README.md b/fjs/types/rtti/README.md index 3dd090137..cfc14e525 100644 --- a/fjs/types/rtti/README.md +++ b/fjs/types/rtti/README.md @@ -123,8 +123,15 @@ that says how a tuple schema is read, and `structSchemaEntries` is its struct counterpart. The alternative reading would make `new Array(1)` and `[]` the same schema while `[undefined]` stayed different from both. -Nothing about a dense schema changes: on an array without holes the two entry -lists are identical. +The same rule settles a tuple schema's **non-index** enumerable own properties, +which are no positions either. `Object.assign([number], { foo: string })` +declares one position and nothing named `foo`: a tuple is read by index, so the +entry reading declared `foo` and then matched it against `value[NaN]` — the +property literally named `NaN`, which no ordinary value carries. The data form +ignored it all along; now so do the schema-form readers. + +Nothing about a dense schema changes: on an array with neither holes nor extra +own properties the two entry lists are identical. #### This is deliberate; please do not "fix" it diff --git a/fjs/types/rtti/common/module.f.mjs b/fjs/types/rtti/common/module.f.mjs index b29efeb22..f155120a6 100644 --- a/fjs/types/rtti/common/module.f.mjs +++ b/fjs/types/rtti/common/module.f.mjs @@ -143,7 +143,10 @@ export const eachEntry = * * `Object.entries` skips holes, which is why it is not used here: it would * make `new Array(1)` and `[]` the same schema while `[undefined]` stayed - * different from both. On a dense array the two agree exactly. + * different from both. It also yields a non-index own property, which is no + * position either — `getItem` reads a tuple by index, so such a key was + * declared and then matched against `value[NaN]`. `Array.from` answers + * positions only. On a plain dense array the two agree exactly. * * @type {SchemaEntries} */ diff --git a/fjs/types/rtti/validate/proof.f.mjs b/fjs/types/rtti/validate/proof.f.mjs index 2eb811e72..957e56d9d 100644 --- a/fjs/types/rtti/validate/proof.f.mjs +++ b/fjs/types/rtti/validate/proof.f.mjs @@ -84,6 +84,11 @@ const rows = [ [new Array(1), []], [[, number], [9, 5]], [[, number], [undefined, 5]], + // and a non-index enumerable own property is no position at all: a tuple + // schema is read by index, so `foo` was declared and then matched against + // `value[NaN]`, which no ordinary value carries + [Object.assign([number], { foo: string }), [1]], + [Object.assign([number], { foo: string }), Object.assign([1], { foo: 'x' })], // the closed counterparts of the four openness rows, and the rest [close([number]), [42]], [close([number]), [42, 'extra']], @@ -200,6 +205,28 @@ export const proof = { assertEq(d(t)(value)[0], p(t)(value)[0], 'the data form must accept what `parse` accepts') } }, + // The two tables above pin that the three readers *agree*; these pin what + // they agree on, which is what the changelog entry claims. + sparseTuple: { + holeIsDeclaredUndefined: () => { + assertError(validate([, number])([9, 5])) + assertOk(validate([, number])([undefined, 5])) + assertError(validate(new Array(1))([1, 2, 3])) + assertOk(validate(new Array(1))([undefined])) + }, + // A hole is a position, so a closed sparse schema is as long as it + // looks: `declared.length` is the schema's length, not its key count. + closedArityIsTheSchemaLength: () => { + assertOk(validate(close(new Array(1)))([undefined])) + assertError(validate(close(new Array(1)))([1])) + }, + nonIndexPropertyIsNotDeclared: () => { + const schema = Object.assign([number], { foo: string }) + assertOk(validate(schema)([1])) + assertOk(validate(schema)(Object.assign([1], { foo: 'x' }))) + assertError(validate(schema)(['x'])) + }, + }, boolean: { ok: () => { /** @typedef {Assert, boolean>>} _RoundTrip */ From a6117eaf7f9e5223a561d1f0be9634b30684cdba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 07:48:31 +0000 Subject: [PATCH 5/7] types/rtti: say why the schema entry reading goes through the iterator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up, documentation only. `Array.from` walks the iterator, so "read by length" is imprecise for a schema carrying an overridden `Symbol.iterator`. Choosing it is deliberate: it is the same walk `containerUnion` makes, so the two agree by construction rather than by two rules that coincide on ordinary arrays. Reading indices here instead would put the schema-form readers back at odds with the data form on exactly that schema — measured: read as `number` by an entry/index reading and as `string` by `containerUnion`. Reading both by index is defensible but changes the canonical, content-addressed data form, so it belongs with that decision. FunctionalScript has neither symbols nor mutation, so such a schema is reachable only from plain JavaScript, which is why no proof can pin the case. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FfPCE6b3fK6cDMtYxhnLGL --- fjs/types/rtti/README.md | 7 +++++++ fjs/types/rtti/common/module.f.mjs | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/fjs/types/rtti/README.md b/fjs/types/rtti/README.md index cfc14e525..99918029d 100644 --- a/fjs/types/rtti/README.md +++ b/fjs/types/rtti/README.md @@ -130,6 +130,13 @@ entry reading declared `foo` and then matched it against `value[NaN]` — the property literally named `NaN`, which no ordinary value carries. The data form ignored it all along; now so do the schema-form readers. +"By length" is how every schema anyone can write is read; the mechanism is the +iterator, the same one `containerUnion` walks, so the two agree by construction +rather than by two rules that happen to coincide. That matters only for a schema +carrying an overridden `Symbol.iterator` — which FunctionalScript cannot build, +having neither symbols nor mutation — where reading indices here would put the +schema-form readers back at odds with the data form. + Nothing about a dense schema changes: on an array with neither holes nor extra own properties the two entry lists are identical. diff --git a/fjs/types/rtti/common/module.f.mjs b/fjs/types/rtti/common/module.f.mjs index f155120a6..cca017db5 100644 --- a/fjs/types/rtti/common/module.f.mjs +++ b/fjs/types/rtti/common/module.f.mjs @@ -141,6 +141,17 @@ export const eachEntry = * `new Array(1)` gives. That is the reading `../data/module.f.mjs`'s * `containerUnion` has always had, so the canonical data form stays fixed. * + * `Array.from` walks the iterator, which is the *same* walk `containerUnion` + * makes, and that is the point rather than an accident: the two agree by + * construction. It holds even for a schema carrying an overridden + * `Symbol.iterator`, where reading indices here would disagree with the data + * form all over again — verified: such a schema is read as `number` by the + * entry reading and as `string` by `containerUnion`. Reading *both* by index + * is defensible, but it changes the canonical, content-addressed data form and + * belongs with that decision, not here. FunctionalScript cannot build such a + * schema in the first place: it has no symbols and no mutation, so the case is + * reachable only from plain JavaScript, which is also why no proof can pin it. + * * `Object.entries` skips holes, which is why it is not used here: it would * make `new Array(1)` and `[]` the same schema while `[undefined]` stayed * different from both. It also yields a non-index own property, which is no From 4330c75b8003811e57d926d87953a2bcf6f053d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 07:54:01 +0000 Subject: [PATCH 6/7] types/rtti: file the schema-walk question the review kept finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review bot raised the same thing twice on this PR from two directions — an overridden `Symbol.iterator`, then an inherited numeric index — and both reduce to one question: should a container schema be walked by iteration or by own indices? Neither is a disagreement between the readers, because they make the same walk; both were disagreements before this PR, and closing that split is what it does. Answering it means changing `containerUnion` as well, which moves the canonical content-addressed data form, so it is its own pull request. Filed with both probes and the constraint that the two have to move together. Changelog: none Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FfPCE6b3fK6cDMtYxhnLGL --- .../rtti/todo/schema-walk-own-indices.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 fjs/types/rtti/todo/schema-walk-own-indices.md diff --git a/fjs/types/rtti/todo/schema-walk-own-indices.md b/fjs/types/rtti/todo/schema-walk-own-indices.md new file mode 100644 index 000000000..9dd51800f --- /dev/null +++ b/fjs/types/rtti/todo/schema-walk-own-indices.md @@ -0,0 +1,85 @@ +# Walk a container schema by own indices, or keep walking it by iteration + +**Priority:** P4 +**Status:** open — a decision about the canonical data form, not a patch to one reader + +## Problem + +Every reader of a container schema walks it by **iteration**: +[`../common/module.f.mjs`](../common/module.f.mjs)'s `tupleSchemaEntries` with +`Array.from`, and [`../data/module.f.mjs`](../data/module.f.mjs)'s +`containerUnion` with `for…of`. Iteration is an ordinary property lookup driven +by `length`, so two things about a schema object that is not a plain array can +change what it declares: + +- an overridden `Symbol.iterator` yields whatever it likes, whatever the indices + hold; +- an inherited numeric index — a custom prototype, or a polluted + `Array.prototype[0]` — is read in place of a hole, so a hole stops declaring + `undefined`, which is the contract [`../README.md`](../README.md) states under + "A hole is a declared position". + +Verified against `a6117ea`, one probe each: + +```js +const s = [number]; s[Symbol.iterator] = function* () { yield string } +validate(s)([1]) // error — the iterator wins over index 0 + +const proto = Object.create(Array.prototype); proto[0] = number +const h = new Array(1); Object.setPrototypeOf(h, proto) +validate(h)([undefined]) // error — the inherited schema wins over the hole +``` + +**Neither is a disagreement, which is why this is P4 rather than a bug.** All +three readers answer the same on both, because they make the same walk — and +before `a6117ea`'s parent they did *not*: `Object.entries` reads own enumerable +keys only, so the schema-form readers and the data form split on both probes. +Closing that split is what +[#1712](https://github.com/functionalscript/functionalscript/pull/1712) did. What +is left is a question about which walk the agreed-on one should be. + +FunctionalScript can express neither schema — it has no symbols and no mutation +— so both are reachable only from a caller already writing plain JavaScript, and +neither can be pinned by a `.f.mjs` proof. That is also the reason the current +behaviour is documented rather than guarded. + +## Proposal + +Undecided; the two options are not a ladder. + +1. **Keep iteration.** It is what `containerUnion` has always done, and the + canonical data form is content-addressed, so leaving it alone leaves every + hash alone. The cost is that "a hole declares `undefined`" holds for ordinary + arrays rather than universally. +2. **Read own indices, in both.** `Array.from({ length: rtti.length }, …)` with + an own-property check in the schema-form readers, and the matching change to + `containerUnion`. The contract then holds unconditionally. The cost is that it + moves the canonical data form, so it wants its own pull request and its own + look at whether any stored hash is affected. + +Option 2 is only coherent if **both** move. Changing `tupleSchemaEntries` alone +re-opens the split that #1712 closed — measured on the probes above. + +The value side is a separate question and looks settled: `getItem` reads +`value[k]`, which follows a value's prototype chain too, and the readers agree +with each other there today. + +## Tasks + +- [ ] Decide between iteration and own indices for a container schema walk. +- [ ] If own indices win, change `tupleSchemaEntries` and `containerUnion` + together, and say whether any stored data-form hash moves. +- [ ] Re-word "A hole is a declared position" in `../README.md` to match + whichever is chosen. + +## Related + +- [`../common/module.f.mjs`](../common/module.f.mjs) — `tupleSchemaEntries`, + whose doc comment records why iteration is the current choice. +- [`../data/module.f.mjs`](../data/module.f.mjs) — `containerUnion`, the walk it + has to agree with. +- [`../README.md`](../README.md) — "A hole is a declared position", the contract + the inherited-index case qualifies. +- Reported by the Codex review bot on + [#1712](https://github.com/functionalscript/functionalscript/pull/1712), twice: + once for the iterator and once for the inherited index. From 07eccbbc2f0d601139d1cb427b9664aa60ec20ad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 08:07:09 +0000 Subject: [PATCH 7/7] types/rtti: date the schema-walk split to the commit that closed it Review nit. The todo said the readers disagreed until `a6117ea`'s parent; the split actually closed at `4eb0142`, this pull request's code commit, and the two after it are documentation and a changelog entry. Verified by re-running both probes there. Changelog: none Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FfPCE6b3fK6cDMtYxhnLGL --- fjs/types/rtti/todo/schema-walk-own-indices.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/fjs/types/rtti/todo/schema-walk-own-indices.md b/fjs/types/rtti/todo/schema-walk-own-indices.md index 9dd51800f..49c63fee0 100644 --- a/fjs/types/rtti/todo/schema-walk-own-indices.md +++ b/fjs/types/rtti/todo/schema-walk-own-indices.md @@ -32,11 +32,12 @@ validate(h)([undefined]) // error — the inherited schema wins ov **Neither is a disagreement, which is why this is P4 rather than a bug.** All three readers answer the same on both, because they make the same walk — and -before `a6117ea`'s parent they did *not*: `Object.entries` reads own enumerable -keys only, so the schema-form readers and the data form split on both probes. -Closing that split is what -[#1712](https://github.com/functionalscript/functionalscript/pull/1712) did. What -is left is a question about which walk the agreed-on one should be. +before `4eb0142`, the code commit of +[#1712](https://github.com/functionalscript/functionalscript/pull/1712), they did +*not*: `Object.entries` reads own enumerable keys only, so the schema-form +readers and the data form split on both probes. Closing that split is what that +commit did, and every commit from it onward agrees. What is left is a question +about which walk the agreed-on one should be. FunctionalScript can express neither schema — it has no symbols and no mutation — so both are reachable only from a caller already writing plain JavaScript, and