From 18a235581bae454c1d3dd41f25c44d42b20bb948 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 03:18:06 +0000 Subject: [PATCH 01/11] =?UTF-8?q?rtti:=20`option`=20is=20a=20nullary=20sch?= =?UTF-8?q?ema=20denoting=20absence=20=E2=80=94=20stage=202=20of=20option-?= =?UTF-8?q?as-omission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Absence stops being a spelling of `undefined` and becomes a member of the set: a member that may be omitted is `or(option, t)`, `{}` and `{ a: undefined }` are distinct sets, and every reader, `subset`, both renderers and the JSON Schema derivation agree on which is which. - `option` is a new nullary tag; `visit` gains the case and both schema-form readers an `option` handler that rejects normally, so `orVisit` tries the other members of `or(option, t)` for a present value. - Absence is decided by the container loops before dispatch: a declared member with no own or inherited key succeeds iff its schema admits absence (`admitsAbsence`, traversing nested `or`s with a visited set); a prototype-supplied member is still held to the present part. - `parse` omits an absent member: the struct kind drops the key, the array kind rebuilds by slice-then-map, so a trailing absent run shortens the result and an interior hole survives — the old JSON round-trip defect of materialized `undefined` is gone. - Data form: `absentBit` as the fifth unit bit, excluded from `unknown`; `toData(option)` via an explicit `thunkUnion` case; inline rests are stripped of the bit (referenced ones exempt — `subset` resolves them rather than masking, which the absence-only cycle shows would be unsound); `trimPrefix` drops a trailing declared position restating its rest; the declared-member top is `or(option, unknown)`; `objectMayOmit`, `objectPresentSet` and a split `arraySetSubset` move to the bit; the data reader gets the same before-dispatch absence test. - Renderers: `Ts<>` renders an omittable member optional with absence stripped (`readonly a?: number`, `readonly [1, number?]` — exact under `exactOptionalPropertyTypes`), an interior tuple position as `T | undefined`, `array(option)` as `readonly []`; the runtime printer matches; `toJsonSchema` derives `required`/`minItems` from the bit while `stripUndefined` stays keyed on `undefined`. `Absent`, `_TsRaw` and `CheckRaw` are the raw-shape surface `Phantom` annotations need. - Every `option(t)` call site migrated to `or(option, t)` — deliberately narrowing: a present `undefined` is no longer accepted at those members — and the direct `or(…, undefined)` optionality spellings audited (`mcp/cas`, `media/json/schema`); `protocol/mcp`'s `_noParams` checks a read top-level value, so it carries `undefined` the value. - Docs and JSDoc swept; `parse-omits-undefined-members` dissolved and the `option-as-omission` todo completed, both deleted. Changelog: - **BREAKING CHANGES:** rtti's `option` is a nullary schema denoting absence; `option(t)` becomes `or(option, t)`, which also narrows — the faithful translation of the old set is `or(option, t, undefined)`. `parse` no longer materializes an absent member. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/option-as-omission.md | 20 + fjs/AGENTS.md | 5 +- fjs/cas/evo/module.f.mjs | 9 +- fjs/ci/common/module.f.mjs | 12 +- fjs/mcp/cas/module.f.mjs | 4 +- fjs/mcp/evo/module.f.mjs | 12 +- fjs/mcp/proof.f.mjs | 6 +- fjs/media/json/schema/module.f.mjs | 110 +-- fjs/media/json/schema/proof.f.mjs | 47 +- fjs/media/note/README.md | 4 +- fjs/media/note/module.f.mjs | 4 +- fjs/media/note/todo/extend-note-format.md | 4 +- fjs/media/revision/README.md | 10 +- fjs/media/revision/module.f.mjs | 6 +- fjs/media/revision/proof.f.mjs | 2 +- fjs/protocol/json_rpc/module.f.mjs | 6 +- fjs/protocol/mcp/README.md | 4 +- fjs/protocol/mcp/module.f.mjs | 25 +- fjs/rtti/README.md | 21 +- fjs/rtti/common/module.f.mjs | 46 ++ fjs/rtti/common/types.ts | 9 + fjs/rtti/data/README.md | 56 +- fjs/rtti/data/module.f.mjs | 252 +++++-- fjs/rtti/data/proof.f.mjs | 264 ++++++- fjs/rtti/data/types.ts | 31 +- fjs/rtti/host.proof.mjs | 38 +- fjs/rtti/module.f.mjs | 29 +- fjs/rtti/parse/module.f.mjs | 118 ++- fjs/rtti/parse/proof.f.mjs | 65 +- fjs/rtti/proof.f.mjs | 16 +- fjs/rtti/todo/checked-const-pin.md | 4 +- .../data-validate-admits-non-djs-values.md | 4 +- fjs/rtti/todo/option-as-omission.md | 713 ------------------ .../todo/parse-omits-undefined-members.md | 168 ----- fjs/rtti/ts/README.md | 4 +- fjs/rtti/ts/module.f.mjs | 87 ++- fjs/rtti/ts/proof.f.mjs | 109 ++- fjs/rtti/ts/types.ts | 417 +++++++--- fjs/rtti/types.ts | 17 +- fjs/rtti/validate/module.f.mjs | 36 +- fjs/rtti/validate/proof.f.mjs | 135 +++- fjs/types/phantom/types.ts | 15 + todo/rtti-type-system.md | 9 +- 43 files changed, 1578 insertions(+), 1375 deletions(-) create mode 100644 changelog/unreleased/option-as-omission.md delete mode 100644 fjs/rtti/todo/option-as-omission.md delete mode 100644 fjs/rtti/todo/parse-omits-undefined-members.md diff --git a/changelog/unreleased/option-as-omission.md b/changelog/unreleased/option-as-omission.md new file mode 100644 index 000000000..fc8b0dc8e --- /dev/null +++ b/changelog/unreleased/option-as-omission.md @@ -0,0 +1,20 @@ +- **BREAKING CHANGES:** rtti's `option` is a nullary schema denoting + **absence** — the member that is not there — so a member that may be + omitted is `or(option, t)` and absence stops being a spelling of + `undefined`: `{}` and `{ a: undefined }` are now distinct sets, told apart + by every reader and by `subset`. `option(t)` becomes `or(option, t)`, which + also **narrows**: a schema that accepted a present `undefined` at that + member no longer does — the faithful translation of the old set is + `or(option, t, undefined)`, and every migrated schema in this repository + took the narrowing deliberately. `parse` no longer materializes an absent + member: the struct kind drops the key, the array kind keeps a hole a hole + and shortens a trailing absent run, so an optional member survives a JSON + round-trip. In the data form absence is a fifth `unit` bit (`absentBit`), + excluded from `unknown`; a declared `unknown` member is therefore required + now, and "anything, or nothing" is `or(option, unknown)`. `Ts<>` and the + runtime printer render an omittable member optional with absence stripped + (`readonly a?: number`, `readonly [1, number?]`) — exact under + `exactOptionalPropertyTypes` — and `toJsonSchema` derives `required` and + `minItems` from the absent bit. A `Phantom` annotation on a schema whose + root admits absence must carry the new `Absent` marker, pinned with the + new `CheckRaw`. diff --git a/fjs/AGENTS.md b/fjs/AGENTS.md index eb11b882b..e979e8fdc 100644 --- a/fjs/AGENTS.md +++ b/fjs/AGENTS.md @@ -380,7 +380,7 @@ FunctionalScript data is immutable, but stock `tsc` widens literals by default and tuple-dependent typing (`Ts<>` over an rtti schema, tagged-tuple discriminants in the effect system). The rule scopes to literals because a const assertion is only legal on a literal or enum member (TS1355) — calls, -conditionals, and references (`or(...)`, `option(...)`, a bare `string`) already +conditionals, and references (`or(...)`, a bare `string` or `option`) already carry precise, non-widening types and are exempt. The mistake is invisible at runtime (the value is correct; only the type widens), which is exactly why it must be a style rule. @@ -402,7 +402,8 @@ validate({ a: 42 }) // the same, with `` A cast there is the absence of a modifier on the callee, not a fact about the value — and it has to be repeated at every call, where the modifier is written -once. `rtti` (`or`, `option`, `array`, `record`), `rtti/validate`, +once. `rtti` (`or`, `array`, `record` — `option` is nullary and takes +nothing), `rtti/validate`, `rtti/parse`, `types/result` (`ok`, `error`), `protocol/mcp`'s `toolEntry`, and `bnf`'s `option` already carry it; a new schema- or literal-taking export should too. diff --git a/fjs/cas/evo/module.f.mjs b/fjs/cas/evo/module.f.mjs index 87514d2db..2a9dc76c8 100644 --- a/fjs/cas/evo/module.f.mjs +++ b/fjs/cas/evo/module.f.mjs @@ -458,6 +458,9 @@ const buildRevision = input => parents => { if (parentSubjectsResult[0] === 'error') { return parentSubjectsResult } const snapshotResult = resolveSnapshot(input)(subject)(parents) if (snapshotResult[0] === 'error') { return snapshotResult } + // `archived` and `lock` are omittable members of the revision schema, and + // an absent member is *absent* — spelling either as a present `undefined` + // would build a value the schema rejects. /** @type {Revision} */ const revision = { dialect, @@ -465,8 +468,8 @@ const buildRevision = input => parents => { parents: input.parents, snapshot: snapshotResult[1], generation: computeGeneration(parents), - archived: input.archived, - lock: input.lock, + ...(input.archived === undefined ? {} : { archived: input.archived }), + ...(input.lock === undefined ? {} : { lock: input.lock }), } const referencesResult = checkReferences(revision) if (referencesResult[0] === 'error') { return referencesResult } @@ -474,7 +477,7 @@ const buildRevision = input => parents => { ...revision, parents: revision.parents.map(canonicalHash), snapshot: canonicalHash(revision.snapshot), - lock: revision.lock === undefined ? undefined : canonicalLockField(revision.lock), + ...(revision.lock === undefined ? {} : { lock: canonicalLockField(revision.lock) }), }) } diff --git a/fjs/ci/common/module.f.mjs b/fjs/ci/common/module.f.mjs index 4bbf5901e..1c55977de 100644 --- a/fjs/ci/common/module.f.mjs +++ b/fjs/ci/common/module.f.mjs @@ -11,7 +11,7 @@ */ import { actions, images } from '../config/module.f.mjs' -import { option, array, record, string } from '../../rtti/module.f.mjs' +import { array, option, or, record, string } from '../../rtti/module.f.mjs' import { parse as rttiParse } from '../../rtti/parse/module.f.mjs' export const os = /** @type {const} */ (['ubuntu', 'macos', 'windows']) @@ -25,9 +25,9 @@ export const architecture = /** @type {const} */ (['intel', 'arm']) // `if`, `env` and much else — would need `open`. export const stepSchema = /** @type {const} */ ({ - run: option(string), - uses: option(string), - with: option(record(string)) + run: or(option, string), + uses: or(option, string), + with: or(option, record(string)) }) export const jobSchema = /** @type {const} */ ({ @@ -40,8 +40,8 @@ export const jobsSchema = record(jobSchema) export const gitHubActionSchema = /** @type {const} */ ({ name: string, on: { - pull_request: option({}), - merge_group: option({}) + pull_request: or(option, {}), + merge_group: or(option, {}) }, permissions: record(string), jobs: jobsSchema diff --git a/fjs/mcp/cas/module.f.mjs b/fjs/mcp/cas/module.f.mjs index e1a692774..288c7251a 100644 --- a/fjs/mcp/cas/module.f.mjs +++ b/fjs/mcp/cas/module.f.mjs @@ -143,13 +143,13 @@ import { assertNotNullish } from '../../asserts/module.f.mjs' /** Arguments for `cas_add`: content to store, with optional encoding type. */ export const casAddArgs = /** @type {const} */ ({ content: string, - type: or('text', 'base64', undefined) + type: or(option, 'text', 'base64') }) /** Arguments for `cas_get`: the cBase32 hash to look up; optionally request inline content. */ export const casGetArgs = /** @type {const} */ ({ hash: string, - content: option(boolean) + content: or(option, boolean) }) /** Arguments for `cas_list`: none. */ diff --git a/fjs/mcp/evo/module.f.mjs b/fjs/mcp/evo/module.f.mjs index 9b536a682..7e2280cad 100644 --- a/fjs/mcp/evo/module.f.mjs +++ b/fjs/mcp/evo/module.f.mjs @@ -49,7 +49,7 @@ * @import { Evo } from '../../cas/evo/types.ts' */ -import { string, option, array } from '../../rtti/module.f.mjs' +import { array, option, or, string } from '../../rtti/module.f.mjs' import { lockField } from '../../media/revision/module.f.mjs' import { evoSummary } from '../../cas/evo/module.f.mjs' import { toolEntry, toolResultStep } from '../../protocol/mcp/module.f.mjs' @@ -67,7 +67,7 @@ import { identity } from '../../types/function/module.f.mjs' * `Evo.list` — omitted lists the active subjects, `true` the archived ones. */ export const evoListArgs = /** @type {const} */ ({ - archived: option(true), + archived: or(option, true), }) /** Arguments for `evo_head`: the subject whose current heads are requested. */ @@ -96,10 +96,10 @@ export const evoRevisionArgs = /** @type {const} */ ({ */ export const evoAddArgs = /** @type {const} */ ({ parents: array(string), - snapshot: option(string), - subject: option(string), - archived: option(true), - lock: option(lockField), + snapshot: or(option, string), + subject: or(option, string), + archived: or(option, true), + lock: or(option, lockField), }) // ── Tool registry ──────────────────────────────────────────────────────────────── diff --git a/fjs/mcp/proof.f.mjs b/fjs/mcp/proof.f.mjs index ab2bd5da4..d0e7b3ced 100644 --- a/fjs/mcp/proof.f.mjs +++ b/fjs/mcp/proof.f.mjs @@ -16,7 +16,7 @@ import { assert, assertEq } from '../asserts/module.f.mjs' import { pureOk, step } from '../effects/module.f.mjs' import { create } from '../effects/memory/module.f.mjs' import { parse as parseJson } from '../media/json/module.f.mjs' -import { number as rttiNumber, option, string as rttiString } from '../rtti/module.f.mjs' +import { number as rttiNumber, option, or, string as rttiString } from '../rtti/module.f.mjs' import { parse as rttiParse } from '../rtti/parse/module.f.mjs' import { msb, u8ListToVec, vec8, repeat, length, maxLengthBytes } from '../types/bit_vec/module.f.mjs' import { vecToCBase32 } from '../basen/cbase32/module.f.mjs' @@ -46,8 +46,8 @@ const casGetResult = /** @type {const} */ ({ mimeType: rttiString, type: rttiString, uri: rttiString, - text: option(rttiString), - blob: option(rttiString), + text: or(option, rttiString), + blob: or(option, rttiString), }) const parseCasGetResult = rttiParse(casGetResult) diff --git a/fjs/media/json/schema/module.f.mjs b/fjs/media/json/schema/module.f.mjs index e7707b1ec..7a08c770c 100644 --- a/fjs/media/json/schema/module.f.mjs +++ b/fjs/media/json/schema/module.f.mjs @@ -27,7 +27,7 @@ import { assert, assertNotNullish } from '../../../asserts/module.f.mjs' import { at, definedEntries } from '../../../types/object/module.f.mjs' import { array, number, option, or, record, string } from '../../../rtti/module.f.mjs' -import { cmp, toData, unitBit, unknown as top, withoutUnits } from '../../../rtti/data/module.f.mjs' +import { absentBit, cmp, toData, unitBit, unknown as top, withoutUnits } from '../../../rtti/data/module.f.mjs' import { unknown as jsonUnknown } from '../rtti/module.f.mjs' /** @type {() => readonly ['const', typeof unknownConst]} */ @@ -49,33 +49,35 @@ export const unknown = unknownThunk /** A JSON Schema (draft 2020-12) document — the subset of keywords that `toJsonSchema` emits. */ /** @typedef {Ts} Unknown */ +// Every field may be **omitted** — a JSON Schema document carries only the +// keywords it needs, and JSON has no `undefined` to hold in a present field — +// so the two enumerated keywords spell their optionality as `or(option, …)` +// like the rest, not as a member `undefined`. const unknownConst = /** @type {const} */ ({ - $schema: option(string), - $ref: option(string), - $defs: option(record(unknown)), - type: or('boolean', 'number', 'string', 'integer', 'array', 'object', undefined), - const: option(jsonUnknown), - not: option(unknown), - anyOf: option(array(unknown)), - items: or(unknown, false, undefined), - prefixItems: option(array(unknown)), - minItems: option(number), - properties: option(record(unknown)), - required: option(array(string)), - additionalProperties: option(unknown), + $schema: or(option, string), + $ref: or(option, string), + $defs: or(option, record(unknown)), + type: or(option, 'boolean', 'number', 'string', 'integer', 'array', 'object'), + const: or(option, jsonUnknown), + not: or(option, unknown), + anyOf: or(option, array(unknown)), + items: or(option, unknown, false), + prefixItems: or(option, array(unknown)), + minItems: or(option, number), + properties: or(option, record(unknown)), + required: or(option, array(string)), + additionalProperties: or(option, unknown), }) /** * Hand-written base type used as the `$out` annotation on `unknown`. * - * The `?` markers are required even though `Ts<>` already includes `undefined` - * in each field type. Without `?`, `Unknown = _UnknownConst` would require all - * 12 fields to be present in every object literal returned by `toJsonSchema`, - * because TypeScript distinguishes "field absent" (`?`) from "field present but - * undefined" (`T | undefined`). JSON Schema objects only include the fields - * they need, so all fields must be optional. `$defs` is an *open* map — an - * absent entry types as `undefined`, so missing-reference handling cannot be - * skipped. + * The `?` markers spell what `or(option, …)` says in the schema: every field + * may be absent, and `Ts<>` renders such a member optional with absence + * stripped from its type, so each field here is `?:` over the member's + * present part. JSON Schema objects only include the keywords they need. + * `$defs` is an *open* map — an absent entry types as `undefined`, so + * missing-reference handling cannot be skipped. * @typedef {{ * readonly $schema?: Ts * readonly $ref?: Ts @@ -174,24 +176,25 @@ const unitSchemas = bits => [ /** * The length below which the array would leave a declared position that - * excludes `undefined` unfilled: one past the last such position, and zero - * when every position admits absence. The array counterpart of the `required` - * key list — an absent element reads as `undefined` just as an absent key - * does — and, arrays being contiguous, one number says it for every position. + * excludes **absence** unfilled: one past the last such position, and zero + * when every position admits absence. The array counterpart of the + * `required` key list — and, arrays being contiguous, one number says it for + * every position. * * @type {(rules: RuleSet) => (prefix: readonly Node[]) => number} */ const minLength = rules => prefix => - prefix.findLastIndex(n => !admitsUndefined(rules)(n)) + 1 + prefix.findLastIndex(n => !admitsAbsence(rules)(n)) + 1 /** * A set of arrays: `prefixItems` for the declared positions, `items` for what * may follow — `false` when nothing may, which is what makes the exact-length * pattern exact. `prefixItems` alone constrains only elements that exist * (draft 2020-12 implies no minimum length), so the required length is - * `minItems`, and a position past it — one the array may simply end before — - * has `undefined` stripped from its schema, absence being expressed by - * `minItems` already. Both are the object side's `required` / + * `minItems` — one past the last position excluding absence — and a position + * past it has `undefined` stripped from its schema, JSON spelling an + * unfilled position as `null`-less truncation rather than a written + * `undefined`. Both are the object side's `required` / * {@link stripUndefined} pair, one kind over. * * @type {(rules: RuleSet) => (p: ArraySet) => Unknown} @@ -209,20 +212,28 @@ const arraySetSchema = rules => p => { } } -/** Whether the node's value set admits `undefined` — its unit bit, read - * through a reference if needed. +/** + * Whether the node's set admits **absence** — its absent bit, read through a + * reference if needed. What drives `required` and `minItems`: absence is + * what lets a key or position be left out, so this is a different question + * from {@link stripUndefined}'s. + * * @type {(rules: RuleSet) => (n: Node) => boolean} */ -const admitsUndefined = rules => n => { +const admitsAbsence = rules => n => { const u = typeof n === 'string' ? assertNotNullish(at(n)(rules)) : n - return ((u.unit ?? 0) & undefinedBit) !== 0 + return ((u.unit ?? 0) & absentBit) !== 0 } /** - * The node with `undefined` removed — for an optional property's schema, - * where absence is already expressed by the key not being `required`. A - * reference is kept as-is: its definition is shared, and the extra - * `{ "not": {} }` member it may carry matches no JSON value anyway. + * The node with `undefined` removed — asking what JSON can **carry**, so it + * stays keyed on the `undefined` bit while `required`/`minItems` moved to + * the absent one. A key of `or(number, undefined)` is required and renders + * as `number`: JSON has no way to write the `undefined` case, so the + * rendering under-approximates — the same corner this module already + * documents for `NaN` and `-0`. A reference is kept as-is: its definition is + * shared, and the extra `{ "not": {} }` member it may carry matches no JSON + * value anyway. * * @type {(n: Node) => Node} */ @@ -231,16 +242,17 @@ const stripUndefined = n => /** * A set of objects: `properties` for the declared keys — a key admitting - * `undefined` is optional and has `undefined` stripped from its schema, - * every other key is `required` — and `additionalProperties` for the rest. - * No `rest` leaves the other keys unconstrained (lenient), matching rtti's - * open-struct validation semantics. + * **absence** is left out of `required`, every key has `undefined` stripped + * from its printed schema ({@link stripUndefined}, JSON carrying no + * `undefined`) — and `additionalProperties` for the rest. No `rest` leaves + * the other keys unconstrained (lenient), matching rtti's open-struct + * validation semantics. * * @type {(rules: RuleSet) => (p: ObjectSet) => Unknown} */ const objectSetSchema = rules => p => { const ents = definedEntries(p.props) - const required = ents.filter(([, n]) => !admitsUndefined(rules)(n)).map(([k]) => k) + const required = ents.filter(([, n]) => !admitsAbsence(rules)(n)).map(([k]) => k) return { type: 'object', ...(ents.length === 0 ? {} : { @@ -255,8 +267,16 @@ const objectSetSchema = rules => p => { /** @type {(u: UnionSet) => boolean} */ const isTop = u => cmp([{}, u])([{}, top]) === 0 -/** @type {(rules: RuleSet) => (u: UnionSet) => Unknown} */ -const unionSchema = rules => u => { +/** + * The absent bit is masked before rendering: absence is not a JSON value — + * it is spelled by a key's omission from `required`, or by `minItems` — so + * it contributes no schema member, and `or(option, unknown)` is the + * always-true `{}` like plain `unknown`. + * + * @type {(rules: RuleSet) => (u: UnionSet) => Unknown} + */ +const unionSchema = rules => u0 => { + const u = withoutUnits(absentBit)(u0) if (isTop(u)) { return {} } const members = [ ...unitSchemas(u.unit ?? 0), diff --git a/fjs/media/json/schema/proof.f.mjs b/fjs/media/json/schema/proof.f.mjs index 041bbf393..9e5639902 100644 --- a/fjs/media/json/schema/proof.f.mjs +++ b/fjs/media/json/schema/proof.f.mjs @@ -6,7 +6,7 @@ import { boolean, number, string, bigint, never, unknown, array, open, record, or, option } from '../../../rtti/module.f.mjs' import { stringify } from '../module.f.mjs' import { dataToJsonSchema, toJsonSchema, unknown as schemaUnknown } from './module.f.mjs' -import { unitBit } from '../../../rtti/data/module.f.mjs' +import { absentBit, unitBit } from '../../../rtti/data/module.f.mjs' import { assert, assertEq } from '../../../asserts/module.f.mjs' /** @type {(v: Unknown) => string} */ @@ -91,13 +91,13 @@ export const proof = { minItems: 2, items: false, }), - withOptional: eq(/** @type {const} */ ([number, option(string)]), { + withOptional: eq(/** @type {const} */ ([number, or(option, string)]), { type: 'array', prefixItems: [{ type: 'number' }, { type: 'string' }], minItems: 1, items: false, }), - allOptional: eq(/** @type {const} */ ([option(number)]), { + allOptional: eq(/** @type {const} */ ([or(option, number)]), { type: 'array', prefixItems: [{ type: 'number' }], items: false, @@ -118,13 +118,13 @@ export const proof = { required: ['x', 'y'], additionalProperties: { not: {} }, }), - withOptional: eq(/** @type {const} */ ({ x: number, y: option(string) }), { + withOptional: eq(/** @type {const} */ ({ x: number, y: or(option, string) }), { type: 'object', properties: { x: { type: 'number' }, y: { type: 'string' } }, required: ['x'], additionalProperties: { not: {} }, }), - allOptional: eq(/** @type {const} */ ({ x: option(number) }), { + allOptional: eq(/** @type {const} */ ({ x: or(option, number) }), { type: 'object', properties: { x: { type: 'number' } }, additionalProperties: { not: {} }, @@ -141,11 +141,20 @@ export const proof = { properties: { x: { type: 'number' }, y: { type: 'string' } }, required: ['x', 'y'], }), - orOptional: eq(/** @type {const} */ ({ x: or(string, number, undefined) }), { + orOptional: eq(/** @type {const} */ ({ x: or(option, string, number) }), { type: 'object', properties: { x: { anyOf: [{ type: 'number' }, { type: 'string' }] } }, additionalProperties: { not: {} }, }), + // a present `undefined` no longer spells optionality: the key is + // required, and `stripUndefined` under-approximates its schema — + // JSON has no way to write the `undefined` case + orPresentUndefined: eq(/** @type {const} */ ({ x: or(string, undefined) }), { + type: 'object', + properties: { x: { type: 'string' } }, + required: ['x'], + additionalProperties: { not: {} }, + }), withConst: eq(/** @type {const} */ ({ x: null, y: string }), { type: 'object', properties: { x: { const: null }, y: { type: 'string' } }, @@ -153,12 +162,12 @@ export const proof = { additionalProperties: { not: {} }, }), optionalOfEveryKind: eq(/** @type {const} */ ({ - a: option(number), - b: option(string), - c: option(bigint), - d: option(array(number)), - e: option(record(string)), - f: or(null, undefined), + a: or(option, number), + b: or(option, string), + c: or(option, bigint), + d: or(option, array(number)), + e: or(option, record(string)), + f: or(option, null), }), { type: 'object', properties: { @@ -184,7 +193,7 @@ export const proof = { orWithConst: eq(or(null, string, 42), { anyOf: [{ const: null }, { const: 42 }, { type: 'string' }], }), - structWithOr: eq(/** @type {const} */ ({ id: or(string, number), name: option(string) }), { + structWithOr: eq(/** @type {const} */ ({ id: or(string, number), name: or(option, string) }), { type: 'object', properties: { id: { anyOf: [{ type: 'number' }, { type: 'string' }] }, @@ -260,7 +269,7 @@ export const proof = { $ref: '#/$defs/rec', $defs: { rec: { type: 'object', additionalProperties: { $ref: '#/$defs/rec' } } }, }), - optionalRecursiveProperty: eq(/** @type {const} */ ({ p: option(list) }), { + optionalRecursiveProperty: eq(/** @type {const} */ ({ p: or(option, list) }), { type: 'object', properties: { p: { type: 'array', items: listRef } }, additionalProperties: { not: {} }, @@ -305,7 +314,7 @@ export const proof = { }] /** @type {Data} */ const optionalByReference = [ - { r: { unit: unitBit(null) | unitBit(undefined), number: true } }, + { r: { unit: unitBit(null) | absentBit, number: true } }, { object: [{ props: { p: 'r' } }] }, ] return { @@ -322,12 +331,14 @@ export const proof = { required: ['a'], additionalProperties: { type: 'string' }, }), - // a referenced definition admitting `undefined` makes the key - // optional; the reference itself is kept as the property schema + // a referenced definition admitting absence makes the key + // optional; the reference itself is kept as the property schema, + // and the absent bit is masked from the definition — absence is + // spelled by the key's omission from `required`, not by a member optionalByReference: eqData(optionalByReference, { type: 'object', properties: { p: { $ref: '#/$defs/r' } }, - $defs: { r: { anyOf: [{ const: null }, { not: {} }, { type: 'number' }] } }, + $defs: { r: { anyOf: [{ const: null }, { type: 'number' }] } }, }), } })(), diff --git a/fjs/media/note/README.md b/fjs/media/note/README.md index 7729956fd..707f65709 100644 --- a/fjs/media/note/README.md +++ b/fjs/media/note/README.md @@ -23,8 +23,8 @@ export const priorities = ['P1', 'P2', 'P3', 'P4', 'P5'] as const export const noteSchema = { dialect: 'vnd.fjs.note', text: string, - dependencies: option(array(string)), - priority: option(or(...priorities)), + dependencies: or(option, array(string)), + priority: or(option, ...priorities), } as const ``` diff --git a/fjs/media/note/module.f.mjs b/fjs/media/note/module.f.mjs index 05d9d615e..7638cb723 100644 --- a/fjs/media/note/module.f.mjs +++ b/fjs/media/note/module.f.mjs @@ -101,8 +101,8 @@ export const priorities = /** @type {const} */ (['P1', 'P2', 'P3', 'P4', 'P5']) export const noteSchema = open(/** @type {const} */ ({ dialect, text: string, - dependencies: option(array(string)), - priority: option(or(...priorities)), + dependencies: or(option, array(string)), + priority: or(option, ...priorities), })) /** Serializes a note canonically, sorting every object's property names. diff --git a/fjs/media/note/todo/extend-note-format.md b/fjs/media/note/todo/extend-note-format.md index 4488b721b..c64c06293 100644 --- a/fjs/media/note/todo/extend-note-format.md +++ b/fjs/media/note/todo/extend-note-format.md @@ -21,10 +21,10 @@ the field forces a new dialect) and the Candidates, roughly in order of usefulness: -- `title: option(string)` — a short summary line (issues, events). Decide +- `title: or(option, string)` — a short summary line (issues, events). Decide whether an absent title and `''` collapse into one meaning, or whether the field should reject `''` the way `lock` blobs treat emptiness. -- `tags: option(array(string))` — free-form labels. Absent and `[]` are two +- `tags: or(option, array(string))` — free-form labels. Absent and `[]` are two spellings of "no tags"; decide which one canonical writers emit, or make the field's presence require a non-empty array. - Event fields — `start` / `end` times. Needs a time representation decision diff --git a/fjs/media/revision/README.md b/fjs/media/revision/README.md index 1dc03a5ac..73eb909ce 100644 --- a/fjs/media/revision/README.md +++ b/fjs/media/revision/README.md @@ -21,8 +21,8 @@ export const revisionSchema = { parents: array(hash), snapshot: hash, generation: number, - archived: option(true), - lock: option(lockField), + archived: or(option, true), + lock: or(option, lockField), } as const export const lock = () => ['record', lockValue] as const @@ -174,7 +174,7 @@ absent value is a constant default.** `snapshot` and `generation` are required because their absence would force inference (a resolution algorithm and an ancestry walk, respectively). `archived` is the documented boundary of the rule and stays **optional**: its absence is the constant `false`, derivable -from nothing, so the `option(true)` presence-flag idiom is exactly right — +from nothing, so the `or(option, true)` presence-flag idiom is exactly right — forcing `archived: false` onto every blob would be pure noise. Inference has not disappeared; it moved to the write boundary. The `evo_add` @@ -354,8 +354,8 @@ section of [fjs/cas/evo/README.md](../../cas/evo/README.md). `archived` marks a mutable object as no longer worked on (e.g. a finished task); its blobs can be deleted from a local CAS after a backup. It follows -the existing `option(true)` idiom (a presence-only flag) rather than -`option(boolean)`. +the existing `or(option, true)` idiom (a presence-only flag) rather than +`or(option, boolean)`. ## Out of scope (this module) diff --git a/fjs/media/revision/module.f.mjs b/fjs/media/revision/module.f.mjs index 5190721f6..b03e24267 100644 --- a/fjs/media/revision/module.f.mjs +++ b/fjs/media/revision/module.f.mjs @@ -21,7 +21,7 @@ * @import { LockField, LockFieldSchema, LockMap, LockSchema, Revision, RevisionError } from './types.ts' */ -import { array, number, open, option, string } from '../../rtti/module.f.mjs' +import { array, number, open, option, or, string } from '../../rtti/module.f.mjs' import { parse as rttiParse } from '../../rtti/parse/module.f.mjs' import { parse as parseJson } from '../json/module.f.mjs' import { cBase32ToVec } from '../../basen/cbase32/module.f.mjs' @@ -126,8 +126,8 @@ export const revisionSchema = open(/** @type {const} */ ({ parents: array(hash), snapshot: hash, generation: number, - archived: option(true), - lock: option(lockField), + archived: or(option, true), + lock: or(option, lockField), })) /** Serializes a revision canonically, recursively sorting every object's property names. diff --git a/fjs/media/revision/proof.f.mjs b/fjs/media/revision/proof.f.mjs index 5ed61e3da..a0c4bad44 100644 --- a/fjs/media/revision/proof.f.mjs +++ b/fjs/media/revision/proof.f.mjs @@ -122,7 +122,7 @@ export const proof = { assertEq(t, 'error') }, - // `archived` follows the presence-only `option(true)` idiom. + // `archived` follows the presence-only `or(option, true)` idiom. archivedAccepted: () => { const [t] = validate(revisionOf({ archived: true })) assertEq(t, 'ok') diff --git a/fjs/protocol/json_rpc/module.f.mjs b/fjs/protocol/json_rpc/module.f.mjs index e1cc85d16..275b3dabe 100644 --- a/fjs/protocol/json_rpc/module.f.mjs +++ b/fjs/protocol/json_rpc/module.f.mjs @@ -42,15 +42,15 @@ export const _id = or(string, number, null) export const request = open(/** @type {const} */ ({ jsonrpc, method: string, - params: option(unknown), - id: option(_id), + params: or(option, unknown), + id: or(option, _id), })) /** The JSON-RPC error object — `open`, for the reason {@link request} gives. */ export const error = open(/** @type {const} */ ({ code: number, message: string, - data: option(unknown), + data: or(option, unknown), })) export const successResponse = open(/** @type {const} */ ({ jsonrpc, result: unknown, id: _id })) diff --git a/fjs/protocol/mcp/README.md b/fjs/protocol/mcp/README.md index c9ad7026b..fac0eb0ea 100644 --- a/fjs/protocol/mcp/README.md +++ b/fjs/protocol/mcp/README.md @@ -65,10 +65,10 @@ export const fromRegistry = ( Define argument schemas as RTTI: ```ts -import { string, number, option } from '../../rtti/module.f.mjs' +import { string, number, option, or } from '../../rtti/module.f.mjs' const addArgs = { a: number, b: number } as const -const greetArgs = { name: string, greeting: option(string) } as const +const greetArgs = { name: string, greeting: or(option, string) } as const ``` Create tool entries with type-safe handlers: diff --git a/fjs/protocol/mcp/module.f.mjs b/fjs/protocol/mcp/module.f.mjs index ebcc028fd..6ad6da56b 100644 --- a/fjs/protocol/mcp/module.f.mjs +++ b/fjs/protocol/mcp/module.f.mjs @@ -51,11 +51,11 @@ export const implementation = open(/** @type {const} */ ({ // ── Capabilities ─────────────────────────────────────────────────────────────── -const toolsCapability = open(/** @type {const} */ ({ listChanged: option(boolean) })) +const toolsCapability = open(/** @type {const} */ ({ listChanged: or(option, boolean) })) /** Server capabilities advertised in the `initialize` response. */ export const serverCapabilities = open(/** @type {const} */ ({ - tools: option(toolsCapability), + tools: or(option, toolsCapability), })) // ── Lifecycle ────────────────────────────────────────────────────────────────── @@ -72,7 +72,7 @@ export const initializeResult = open(/** @type {const} */ ({ protocolVersion: string, capabilities: serverCapabilities, serverInfo: implementation, - instructions: option(string), + instructions: or(option, string), })) // ── Content ──────────────────────────────────────────────────────────────────── @@ -88,7 +88,7 @@ export const textContent = open(/** @type {const} */ ({ type: 'text', text: stri */ export const blobResource = open(/** @type {const} */ ({ uri: string, - mimeType: option(string), + mimeType: or(option, string), blob: string, })) @@ -114,7 +114,7 @@ export const contentItem = or(textContent, embeddedResource) */ export const tool = open(/** @type {const} */ ({ name: string, - description: option(string), + description: or(option, string), inputSchema: unknown, })) @@ -123,22 +123,22 @@ export const tool = open(/** @type {const} */ ({ * from a previous `ToolsListResult.nextCursor`. */ export const toolsListParams = open(/** @type {const} */ ({ - cursor: option(string), + cursor: or(option, string), })) export const toolsListResult = open(/** @type {const} */ ({ tools: array(tool), - nextCursor: option(string), + nextCursor: or(option, string), })) export const toolsCallParams = open(/** @type {const} */ ({ name: string, - arguments: option(record(unknown)), + arguments: or(option, record(unknown)), })) export const toolsCallResult = open(/** @type {const} */ ({ content: array(contentItem), - isError: option(boolean), + isError: or(option, boolean), })) // ── Dispatch ─────────────────────────────────────────────────────────────────── @@ -260,8 +260,11 @@ export const fromRegistry = registry => ({ export const notInitialized = rpcError(-32002)('Server not initialized') // Params for methods that take no arguments (`ping`, `notifications/initialized`): -// absent, or an object (which may carry `_meta`). -const _noParams = option(record(unknown)) +// absent, or an object (which may carry `_meta`). Checked against the *read* +// `message.params`, a top-level value — absence has already become the read +// `undefined` by then, so the union carries `undefined` the value, not +// `option`: at the entry position nothing can be absent. +const _noParams = or(record(unknown), undefined) /** Initial session state — always start here. */ /** @type {McpSessionState} */ diff --git a/fjs/rtti/README.md b/fjs/rtti/README.md index fce9dd4bb..e88dd0ea6 100644 --- a/fjs/rtti/README.md +++ b/fjs/rtti/README.md @@ -97,7 +97,7 @@ value carrying more is not one of its values, on either reader: | `{ a: 42 }` | `{ a: 42, b: 'x' }` | error | error | | `{ a: 42 }` | `{ a: 42 }` | `{ a: 42 }` | `{ a: 42 }` | | `[42]` | `[42, 'extra']` | error | error | -| `[number, option(string)]` | `[42]` | `[42, undefined]` | `[42]` | +| `[number, or(option, string)]` | `[42]` | `[42]` | `[42]` | | `[42]` | `[]` | error | error | A tuple answers by **length** as well as by member: a hole past the prefix is @@ -105,13 +105,17 @@ no member, so `[42, , ]` would slip through a member check alone while the array is still that long. The last two rows are one rule, and closedness leaves it alone — it is about -*undeclared* members, and a declared position admitting `undefined` stays -omittable. An absent member reads as `undefined`, so a member is **required -exactly when its set excludes `undefined`**. Position 1 of -`[number, option(string)]` admits `undefined`, so a shorter array is fine — -`parse` fills the gap in what it builds, `validate` has nothing to fill — -while `42` excludes it, so position 0 of `[42]` is required and `[]` fails for -both. This is the same rule the data form states for object keys. +*undeclared* members, and a declared position admitting **absence** stays +omittable. A member is absent when its key or index is neither an own +property nor an inherited one, and it is **required exactly when its set +excludes absence** — the `option` member of its union. Position 1 of +`[number, or(option, string)]` admits absence, so a shorter array is fine — +and neither reader materializes anything: `parse` builds `[42]`, omitting the +absent member — while `42` excludes it, so position 0 of `[42]` is required +and `[]` fails for both. Absence is not a spelling of `undefined`: `{}` and +`{ a: undefined }` are two distinct values, `or(option, t)` admits the first +and `or(t, undefined)` the second. This is the same rule the data form +states for object keys, as the `absentBit` of a member's unit bitset. The data form says the same thing in its own vocabulary — a bare container's `rest` is `never` on both kinds — so `validate(toData(s))` accepts exactly what @@ -274,6 +278,7 @@ unary schemas (`array`, `record`) return `Info1` (a tag + inner type tuple). | `string` | `['string']` | any `string` | | `bigint` | `['bigint']` | any `bigint` | | `unknown` | `['unknown']` | any DJS value | +| `option` | `['option']` | nothing — **absence**: `or(option, t)` is a member that may be left out | | `array(t)` | `['array', t]` | `readonly Ts[]` | | `record(t)` | `['record', t]` | `{ readonly[K: string]: Ts }` | | `rest(c, r)` | `['rest', c, r]` | `c`'s members, and only members of `r` besides | diff --git a/fjs/rtti/common/module.f.mjs b/fjs/rtti/common/module.f.mjs index f6467e383..e0bdd519f 100644 --- a/fjs/rtti/common/module.f.mjs +++ b/fjs/rtti/common/module.f.mjs @@ -285,6 +285,51 @@ export const undeclaredMembers = (declared, value) => { ] } +/** + * Whether `rtti` admits **absence** with `visited` already ruled out — the + * recursive half of {@link admitsAbsence}, carrying the thunks on the current + * path so a recursive union such as `X = or(X, option)` terminates. + * + * @type {(visited: readonly Type[], rtti: Type) => boolean} + */ +const absenceIn = (visited, rtti) => { + if (typeof rtti !== 'function') { return false } + if (visited.some(v => v === rtti)) { return false } + const [tag, ...operands] = rtti() + if (tag === 'option') { return true } + if (tag !== 'or') { return false } + return operands.some(op => absenceIn([...visited, rtti], op)) +} + +/** + * Whether the schema admits **absence** — whether `option` is reachable + * through its unions, so a container may leave the member out entirely. + * + * This is the container loop's question, asked *before* dispatch: a + * recursive reader is handed only the value read, and an absent key reads + * `undefined`, so absence cannot be decided downstream of the read. The + * predicate traverses nested `or` nodes — the schema-form `or` does no + * flattening, so `or(or(option, number), string)` has no `option` among its + * direct members while admitting absence — descends the thunks they hold, + * stops at any other tag, and carries the visited thunks to terminate on a + * recursive `X = or(X, option)`. The data form needs no such traversal: + * `toData` has already flattened, so its readers test one unit bit. + * + * @type {(rtti: Type) => boolean} + */ +export const admitsAbsence = rtti => absenceIn([], rtti) + +/** + * The shared answer for a declared member that is not there — no own or + * inherited key at its position: the member is legal exactly when its schema + * admits absence. The `ok` payload is unused by pass/fail callers and is not + * a value read from the container, absence being the whole point. + * + * @type {(rtti: Type) => ResultE} + */ +export const absentMember = rtti => + admitsAbsence(rtti) ? ok(undefined) : verror('unexpected value') + /** * First variant in `variants` that `recurse` accepts, else `verror('no match')`. * @@ -338,6 +383,7 @@ export const visit = case 'array': return v.array(value[0]) case 'record': return v.record(value[0]) case 'unknown': return v.unknown() + case 'option': return v.option() case 'or': return v.or(value) case 'rest': { const [c, r] = value diff --git a/fjs/rtti/common/types.ts b/fjs/rtti/common/types.ts index 50805cb3f..8c6055200 100644 --- a/fjs/rtti/common/types.ts +++ b/fjs/rtti/common/types.ts @@ -45,6 +45,15 @@ export type Visitor = { readonly constPrimitive: (p: Primitive) => R readonly primitive0: (tag: Primitive0) => R readonly unknown: () => R + /** + * The nullary `option` schema — absence. A reader's handler *rejects* + * normally: absence is decided by the container loop before dispatch + * (see `admitsAbsence` in `./module.f.mjs`), so a value that reaches a + * recursive reader is present by construction, and under `or(option, t)` + * the `option` branch has to return an ordinary error for `t` to be + * tried. + */ + readonly option: () => R } /** diff --git a/fjs/rtti/data/README.md b/fjs/rtti/data/README.md index 77918e265..4681f1731 100644 --- a/fjs/rtti/data/README.md +++ b/fjs/rtti/data/README.md @@ -36,7 +36,7 @@ are kind-wise: | kind | representation | notes | | -------- | --------------------------------------- | -------------------------------------------- | -| `unit` | bitset over `null, undefined, false, true` | `or(true, false)` is the two boolean bits — "boolean" needs no special rule | +| `unit` | bitset over `null, undefined, false, true`, plus the `absentBit` | `or(true, false)` is the two boolean bits — "boolean" needs no special rule; bit `16` is **absence**, rtti's `option`, which is no DJS value and so no `unitList` member | | `number` | `true` (all) or sorted literals | SameValue semantics: `-0 ≠ 0`, `NaN` allowed | | `string` | `true` or sorted literals | | | `bigint` | `true` or sorted literals | | @@ -46,8 +46,9 @@ are kind-wise: **Arrays and tuples share one kind** because their value sets overlap: a tuple is an array whose leading positions carry distinct element types. The shared pattern is a tuple-with-rest: a `prefix` entry constrains the value *read* at -that position — reading past the array's end yields `undefined`, so a position -is required exactly when its set excludes `undefined` — and `rest` constrains +that position and whether one must be there — a position past the array's end, +or a hole, is **absent**, so a position is required exactly when its set +excludes the `absentBit` — and `rest` constrains every position after the prefix, admitting nothing there when it is absent. A bare tuple schema is `{ prefix }` alone — the exact-length set — an `open` one is `{ prefix, rest: unknown }`, and a uniform array is `{ prefix: [], rest }`. @@ -57,10 +58,10 @@ which the coverage collapse uses to drop `open([number, number])` from `{ a, b }` from `or(open({ a, b }), open({ a }))`. **Records and structs share one kind** for the same reason, and by the same -rule one kind over: a `props` entry constrains the value *read* at that key — -reading an absent key yields `undefined`, so a key is required exactly when -its set excludes `undefined`, and `option(t)` props are optional with no extra -mechanism. `rest` constrains the values at the remaining *present* keys; an +rule one kind over: a `props` entry constrains the value *read* at that key +and whether one must be there — a key is required exactly when its set +excludes the `absentBit`, so `or(option, t)` props are optional with no extra +mechanism and `{}` is told apart from `{ a: undefined }`. `rest` constrains the values at the remaining *present* keys; an `open` struct leaves them unconstrained (no `rest`), matching TypeScript's structural typing, and a bare, closed one says `rest: never`. @@ -94,9 +95,11 @@ disambiguated with a counter on collision. - array/object patterns are sorted, deduplicated, and *coverage-collapsed*: a pattern included in a sibling pattern is dropped; - degenerate patterns are simplified: an empty position empties the pattern, - an identity `rest`/prop disappears, a trailing position restating a `rest` - that admits absence is dropped, and a pattern constraining nothing is its - whole kind — `array(unknown)`, `[]` and `[unknown]` are one `Node`; + an identity `rest`/prop disappears, an inline `rest` is stripped of the + `absentBit` (a rest never sees an absent member), a trailing position that + admits absence and restates the `rest` is dropped, and a pattern + constraining nothing is its whole kind — `array(unknown)`, `open([])` and + `open([or(option, unknown)])` are one `Node`; - pure `or` cycles dissolve (`X = number | X` is `number` — the least fixpoint), rules are pruned to the reachable set and sorted, and an entry rule nothing else references is inlined; @@ -162,7 +165,7 @@ now. A bare `Tuple` schema is **closed** on all three readers, and says so here as `{ prefix }` with no `rest`: nothing past the prefix, so the array is at most `prefix.length` long — and at least as long as its last position excluding -`undefined` (see +absence (see [Structs and tuples are closed](../README.md#structs-and-tuples-are-closed)). `open(c)` is the thunk-form schema that widens it, converting to a `rest` of `unknown`, and `rest(c, R)` to that `R`; a `rest` of `never` normalizes back to @@ -173,8 +176,8 @@ no `rest` at all on this kind, so `rest(c, never)` and the bare `c` are one parse([42])([42, 'extra']) // ['error', …] parse(open([42]))([42, 'extra']) // ['ok', [42]] validate(toData(open([42])))([42, 'extra']) // ['ok', [42, 'extra']] -parse([number, option(string)])([42]) // ['ok', [42, undefined]] -validate(toData([number, option(string)]))([42]) // ['ok', [42]] +parse([number, or(option, string)])([42]) // ['ok', [42]] +validate(toData([number, or(option, string)]))([42]) // ['ok', [42]] ``` `../validate/proof.f.mjs` runs one acceptance table through all three readers, @@ -191,9 +194,24 @@ the `rest` is gone, since an undeclared key may be absent or else must belong to present, `{ props: { a: unknown }, rest: never }` (objects with at most the key `a`) and `{ props: {}, rest: never }` (the empty object) are two different sets. -Note the asymmetry that phrasing preserves: a *declared* key constrains the -value **read** at it, so an absent one reads `undefined` and is admitted when -the declared set holds `undefined`. An *undeclared* key is checked as an -**entry**, so a present `b: undefined` must satisfy `rest` itself rather than -being excused by its absence — `{ props: { a: number }, rest: string }` rejects -`{ a: 1, b: undefined }` and accepts `{ a: 1 }`. +Note the symmetry stage 2 of `option`-as-omission completed: a *declared* +key is admitted absent exactly when its set carries the `absentBit`, and a +present `undefined` there must be a member of the set as a value. An +*undeclared* key is checked as an **entry**, so a present `b: undefined` must +satisfy `rest` itself — `{ props: { a: number }, rest: string }` rejects +`{ a: 1, b: undefined }` and accepts `{ a: 1 }` — and a missing one is no +entry at all. Absence is describable on both sides. + +Two more structural incompletenesses join the rule-name one above, both from +the referenced-`rest` exemption: an inline `rest` is stripped of the +`absentBit` while a **referenced** one is left alone (the same rule may be +used at a declared position, where the bit is live, and for a recursive rule +the stripped form is a different fixpoint, not a bit-mask — materializing it +would be the bisimulation-grade work this form avoids), and the same +exemption covers a referenced **trailing position** that restates its rest. +Such a pair denotes one set spelled two ways where the reference's present +part is non-empty — mutual `subset`s, structurally distinct — and two +genuinely different sets where it is empty: an absence-only referenced rest +admits any hole-only array, while its stripped form bounds the length. That +last case is why `subset` **resolves** a referenced rest rather than masking +its bit: a mask would answer `true` for that non-inclusion. diff --git a/fjs/rtti/data/module.f.mjs b/fjs/rtti/data/module.f.mjs index 25f00c18e..14a137eb6 100644 --- a/fjs/rtti/data/module.f.mjs +++ b/fjs/rtti/data/module.f.mjs @@ -30,12 +30,19 @@ import { eachEntry, isArray, undeclaredMembers, verror } from '../common/module. /** * The unit kind's enumeration: bit `1 << i` of a {@link UnionSet}'s `unit` * bitset stands for `unitList[i]`. + * + * {@link absentBit} is the one `unit` bit with no `unitList` entry: absence + * is not a DJS value, so it has nothing to enumerate here — see the bit's + * own doc, and `UnionSet` in `./types.ts` for the serialized contract. */ export const unitList = /** @type {const} */ (['null', 'undefined', 'false', 'true']) /** * The `unit` bit of one unit value. * + * Value-keyed, so it cannot answer for {@link absentBit}: the absent bit has + * no JS value to key on — absence is the member that is not there. + * * @type {(v: null | undefined | boolean) => number} */ export const unitBit = v => @@ -43,6 +50,18 @@ export const unitBit = v => v === undefined ? 2 : v ? 8 : 4 +/** + * The fifth `unit` bit: **absence**, rtti's nullary `option`. Not a member + * of {@link unitList}, because it is not a DJS value — no value reads as + * absent; a *container position* is absent by having no own or inherited + * key. The set algebra does not care: union, `subset`, `cmp`, `equal` and + * the coverage collapse are bitwise over the unit kind, so the bit rides + * along. What does care is normalization — a `rest` never sees absence, so + * an inline rest is stripped of the bit ({@link arraySet}/{@link objectSet}) + * — and the readers, which test it where a declared member is missing. + */ +export const absentBit = 16 + const allUnits = unitBit(null) | unitBit(undefined) | unitBit(false) | unitBit(true) const booleanUnits = unitBit(false) | unitBit(true) @@ -287,49 +306,92 @@ const isNever = n => typeof n !== 'string' && cmpUnion(n, never) === 0 const isTop = n => typeof n !== 'string' && cmpUnion(n, unknown) === 0 /** - * The prefix with its redundant tail removed: a last position stating exactly - * the `rest` says nothing the `rest` does not already say, *provided* the - * `rest` admits `undefined`. - * - * Every array carrying a value at that position is read against the same set - * either way, so the two spellings can only differ on the arrays with nothing - * there — one that ends before it, and one holding a hole at it. Both read - * `undefined`, which the `rest` alone imposes nothing on, so dropping the - * position widens the set unless the `rest` admits `undefined` too. That is - * why `{ prefix: [number], rest: number }` keeps its position and stays "one - * or more numbers": `[]` and `[ , 1]` belong to `{ prefix: [], rest: number }` - * and not to it. + * The **declared-member** top: any value, or nothing — `or(option, unknown)`. + * A declared position is where absence is observable, so its top carries the + * absent bit; a `rest`'s top is plain {@link unknown}, a rest never seeing + * an absent member. * - * This is what keeps one set to one spelling: the open tuples `[]` and - * `[unknown]` are both every array and have to produce one `Node`. + * @type {UnionSet} + */ +const declaredTop = { ...unknown, unit: allUnits | absentBit } + +/** @type {(n: Node) => boolean} */ +const isDeclaredTop = n => typeof n !== 'string' && cmpUnion(n, declaredTop) === 0 + +/** + * The node with the absent bit stripped — what a `rest` position normalizes + * an **inline** union to, absence being unobservable there: a declared + * member is checked as the value read at its position, but a `rest` is + * checked against each *present* member, so the bit in a rest constrains + * nothing. A **referenced** rest is left alone: the same rule may be used at + * a declared position, where the bit is live, so clearing it globally would + * delete optionality elsewhere — and the stripped form of a recursive rule + * is a different fixpoint, not a bit-mask (see `./README.md`). + * + * @type {(n: Node) => Node} + */ +const stripAbsent = n => + typeof n === 'string' ? n : withoutUnits(absentBit)(n) + +/** + * The prefix with its redundant tail removed: a trailing declared position + * that **admits absence** and whose absence-stripped set states exactly the + * `rest` says nothing the `rest` does not already say. * - * A referenced `rest` is left alone — reading its unit bits would need the - * rule set, and the form already declines to see through a reference (see - * `./README.md`). + * Every array carrying a value at that position is read against the same set + * either way, so the two spellings can only differ on the arrays with + * nothing there — one that ends before it, and one holding a hole at it. + * Both are the position *absent*, which the position must admit for either + * to belong; past the prefix a hole is no member, so the `rest` admits both + * for free. That is why `{ prefix: [number], rest: number }` keeps its + * position and stays "one or more numbers": `[]` and `[ , 1]` belong to + * `{ prefix: [], rest: number }` and not to it. + * + * This is what keeps one set to one spelling: `rest([or(option, number)], + * number)` and `array(number)` are both "arrays of numbers, any of which may + * be a hole" and have to produce one `Node`. + * + * Two exemptions. A **referenced** trailing position is left alone — reading + * its unit bits would need the rule set, and the form already declines to + * see through a reference (see `./README.md`). And the trim never sees an + * **empty** `rest` — {@link arraySet} returns the exact-length pattern + * before trimming — which is what keeps `[option]` (its sole position + * stripping to `never`, like the `rest`) distinct from `[]`: the two differ + * on `new Array(1)`, a length the first admits and the second bounds out. * * @type {(prefix: readonly Node[], rest: Node) => readonly Node[]} */ -const trimPrefix = (prefix, rest) => - typeof rest === 'string' || ((rest.unit ?? 0) & unitBit(undefined)) === 0 - ? prefix - : prefix.slice(0, prefix.findLastIndex(n => cmpNode(n, rest) !== 0) + 1) +const trimPrefix = (prefix, rest) => { + if (typeof rest === 'string') { return prefix } + /** @type {(n: Node) => boolean} */ + const redundant = n => + typeof n !== 'string' + && ((n.unit ?? 0) & absentBit) !== 0 + && cmpUnion(withoutUnits(absentBit)(n), rest) === 0 + return prefix.slice(0, prefix.findLastIndex(n => !redundant(n)) + 1) +} /** * Canonical array-kind singleton. A syntactically empty position makes the - * whole pattern empty (a position past the array's end reads as `undefined`, - * which the empty set excludes, so no length escapes it); an empty `rest` - * admits nothing past the prefix, which is what no `rest` already says; a - * prefix restating its `rest` is {@link trimPrefix}'d away; an unconstrained - * `rest` with nothing left before it is every array. + * whole pattern empty (nothing may be there and it may not be absent, so no + * array has such a position — and none is short enough to escape it, a + * missing index being absence); an inline `rest` is stripped of the absent + * bit ({@link stripAbsent} — a rest never sees an absent member); an empty + * `rest` admits nothing past the prefix, which is what no `rest` already + * says; a prefix restating its `rest` is {@link trimPrefix}'d away; an + * unconstrained `rest` with nothing left before it is every array. * * Every array set is stated with a `rest` — `never` for a bare tuple, * `unknown` for an `open` one, the element set for a uniform array — so this * takes one rather than an optional one; the absent `rest` is what it - * normalizes an empty one *to*. + * normalizes an empty one *to*. `array(option)` is therefore the empty + * array: its element set strips to `never`, and a `never` rest is the + * exact-length pattern of its (empty) prefix. * * @type {(prefix: readonly Node[], rest: Node) => UnionSet} */ -const arraySet = (prefix, rest) => { +const arraySet = (prefix, rest0) => { + const rest = stripAbsent(rest0) if (prefix.some(isNever)) { return never } if (isNever(rest)) { return { array: [{ prefix }] } } const p = trimPrefix(prefix, rest) @@ -337,24 +399,36 @@ const arraySet = (prefix, rest) => { } /** - * Canonical object-kind singleton. An unconstrained `rest` is the same set as - * no `rest`; an unconstrained key is then dropped too; a syntactically empty - * key set makes the whole pattern empty; with nothing left, the pattern is - * every object. + * Canonical object-kind singleton. An inline `rest` is stripped of the + * absent bit ({@link stripAbsent}); an unconstrained `rest` is the same set + * as no `rest`; an unconstrained key is then dropped too; a syntactically + * empty key set makes the whole pattern empty; with nothing left, the + * pattern is every object. * * A key is dropped only once the `rest` is gone, and that order is the whole * rule: an undeclared key may be absent, or must belong to `rest`, which * leaves it unconstrained exactly when there is no `rest` — so with one * present a key saying "anything" says strictly more than leaving it out. * A bare struct's empty `rest` is where the two part company — - * `{ props: { a: unknown }, rest: never }` admits `{ a: 1 }` and + * `{ props: { a: or(option, unknown) }, rest: never }` admits `{ a: 1 }` and * `{ props: {}, rest: never }` admits only `{}`. * - * @type {(props: readonly (readonly [string, Node])[], rest: Node | undefined) => UnionSet} + * "Unconstrained", for a declared key, is {@link isDeclaredTop} — anything + * *or nothing*, `or(option, unknown)` — not the plain top: a key declared + * `unknown` must be present, which an undeclared key need not be, so + * dropping it would widen the set. + * + * Like {@link arraySet}, every object set is stated with a `rest` — `never` + * for a bare struct, `unknown` for an `open` one, the value set for a + * uniform record — and the absent `rest` is what an unconstrained one + * normalizes *to*. + * + * @type {(props: readonly (readonly [string, Node])[], rest: Node) => UnionSet} */ -const objectSet = (props, rest) => { - const r = rest !== undefined && isTop(rest) ? undefined : rest - const constrained = r === undefined ? props.filter(([, v]) => !isTop(v)) : props +const objectSet = (props, rest0) => { + const rest = stripAbsent(rest0) + const r = isTop(rest) ? undefined : rest + const constrained = r === undefined ? props.filter(([, v]) => !isDeclaredTop(v)) : props if (constrained.some(([, v]) => isNever(v))) { return never } if (constrained.length === 0 && r === undefined) { return { object: true } } /** @type {StringMap} */ @@ -410,15 +484,35 @@ const kindSubset = le => (a, b) => { return a.every(x => b.some(y => le(x, y))) } +/** + * Whether the node's set carries the absent bit, read through a reference + * (own-property only). + * + * @type {(rules: RuleSet) => (n: Node) => boolean} + */ +const nodeAdmitsAbsence = rules => n => + ((resolve(rules)(n).unit ?? 0) & absentBit) !== 0 + /** * Only the *longest* array each side admits is tested here — `pn` without a * `rest`, unbounded with one. The shortest needs no test of its own: a - * position `q` insists on (one whose set excludes `undefined`) is a position - * `p` insists on too as soon as the pointwise check below passes, since - * otherwise `undefined` would be a member of `p.prefix[i]` and not of - * `q.prefix[i]`. Sound, and incomplete in the way `subset` is elsewhere: a - * `p` shorter than `q` is answered `false` even when every position past its - * end is one `q` admits as absent. + * position `q` insists on (one whose set excludes absence) is a position `p` + * insists on too as soon as the per-position check below passes, which is + * exactly what its absence-implication half states. Sound, and incomplete in + * the way `subset` is elsewhere: a `p` shorter than `q` is answered `false` + * even when every position past its end is one `q` admits as absent. + * + * A declared position asks the two questions the object kind asks of a key + * ({@link objectSetSubset}): what `p` may hold there must be something `q` + * holds there — the **absence-stripped** sets compared, absence not being a + * value — and `p` may leave the position out only where `q` lets it, which + * `q` does past its prefix (a hole there is no entry, so any `rest` admits + * it) or where its own position carries the bit. A left position that is a + * *reference* is compared unstripped — masking a reference is unsound, see + * `./README.md` — so such a pair is answered `false` unless the right + * carries the bit at that position: the accepted structural incompleteness. + * `p`'s own `rest` needs neither question, a rest carrying no absent bit + * after normalization. * * @type {(ctx: _Ctx) => (assumed: _Assumed) => (p: ArraySet, q: ArraySet) => boolean} */ @@ -432,7 +526,13 @@ const arraySetSubset = ctx => assumed => (p, q) => { if (!lengthOk) { return false } /** @type {(i: number) => Node} */ const qAt = i => i < qn ? q.prefix[i] : assertNotNullish(q.rest) - return p.prefix.every((el, i) => le(el, qAt(i))) + /** @type {(i: number) => boolean} */ + const qAdmitsAbsenceAt = i => i >= qn || nodeAdmitsAbsence(ctx[1])(q.prefix[i]) + return p.prefix.every((el, i) => + le(stripAbsent(el), qAt(i)) + && (typeof el === 'string' + || ((el.unit ?? 0) & absentBit) === 0 + || qAdmitsAbsenceAt(i))) && (p.rest === undefined || le(p.rest, assertNotNullish(q.rest))) } @@ -441,40 +541,41 @@ const keyed = n => [n, typeof n === 'string' ? `r:${n}` : undefined] /** * The set of values the pattern admits at key `k` when the key is **present**: - * the declared set, else the `rest`, else anything. - * - * Presence is the whole point of splitting this from {@link objectMayOmit}. An - * absent key and a key present holding `undefined` are not the same object, and - * the two sides of a pattern read them differently: a *declared* key constrains - * the value read at it, so absence reads `undefined` and passes when the set - * holds it, whereas an *undeclared* key is checked as an entry, so a present - * `undefined` must belong to `rest` itself (see {@link objectSetValidate}). - * Folding the two into one "read set" of `rest ∪ undefined` made - * `{ a: option(number) }` a subset of `record(number)`, which admits - * `{ a: undefined }` on the left and rejects it on the right. + * the declared set with its absent bit stripped, else the `rest`, else + * anything. + * + * Presence is the whole point of splitting this from {@link objectMayOmit}: + * this answers "what may be *present* at this key", that one answers whether + * the key may be missing, and {@link objectSetSubset} asks both. Absence is + * not a value, so a declared set's absent bit does not belong here — left + * unstripped, the closed `{ a: or(option, number) }` tested + * `(Absent | number) ⊆ number` against `record(number)` and answered `false` + * though its only values are `{}` and `{ a: number }`, both of which + * `record(number)` admits. A declared *reference* is kept unstripped — + * masking a reference is unsound (see `./README.md`) — the same structural + * incompleteness a referenced rest accepts. * * @type {(pattern: ObjectSet) => (k: string) => _Keyed} */ const objectPresentSet = pattern => k => { const n = at(k)(pattern.props) - if (n !== null) { return keyed(n) } + if (n !== null) { return keyed(stripAbsent(n)) } const { rest } = pattern return rest === undefined ? [unknown, 't'] : keyed(rest) } /** * Whether the pattern admits an object carrying no `k` at all: an undeclared - * key may always be missing, and a declared one exactly when its set holds - * `undefined`, since an absent property reads as `undefined`. + * key may always be missing, and a declared one exactly when its set carries + * the **absent bit**. * - * This is the half of the old read-set that the `∪ undefined` stood for, now - * asked as its own question — a local unit-bit test, so it needs no memo. + * The other half of the split — a local unit-bit test, so it needs no memo. * * @type {(rules: RuleSet) => (pattern: ObjectSet) => (k: string) => boolean} */ const objectMayOmit = rules => pattern => k => { const n = at(k)(pattern.props) - return n === null || ((resolve(rules)(n).unit ?? 0) & unitBit(undefined)) !== 0 + return n === null || nodeAdmitsAbsence(rules)(n) } /** @type {(list: readonly string[]) => readonly string[]} */ @@ -900,6 +1001,11 @@ const thunkUnion = (state, t) => { case 'string': { return [state, { string: true }] } case 'bigint': { return [state, { bigint: true }] } case 'unknown': { return [state, unknown] } + // An explicit case: the `default` arm below is `orUnion`, and a + // nullary tag has an empty operand list, so without it + // `toData(option)` would be the empty union — `never` — and + // `toData(or(option, t))` would silently lose the bit. + case 'option': { return [state, { unit: absentBit }] } case 'array': { const [state1, item] = nodeOf(state)(rest[0]) return [state1, arraySet([], item)] @@ -1117,12 +1223,16 @@ const patternsValidate = (k, item, value) => { } /** - * The declared positions are checked by reading the value at each — a - * position past the end reads as `undefined`, so a position is required - * exactly when its set excludes `undefined`, and no minimum length is tested - * for. What is left over is tested against `rest`, or, with no `rest`, must - * not be there at all. Same shape as {@link objectSetValidate}, one kind - * over. + * The declared positions are checked with absence decided **before** + * dispatch — an index that is neither an own property nor an inherited one + * is a missing member, legal exactly when its set carries the absent bit; + * a present one is checked as the value read. No minimum length is tested + * for: a too-short array is caught by the absence test at the first + * position that excludes it. What is left over is tested against `rest`, + * or, with no `rest`, must not be there at all. Same shape as + * {@link objectSetValidate}, one kind over — and the same before-dispatch + * test the schema-form readers make, so the three readers agree on `{}` + * versus `{ a: undefined }` and on sparse tuples. * * `undeclaredMembers` is what the schema-form readers walk too, so "what is * left over" is one rule rather than two that happen to coincide — including @@ -1136,7 +1246,9 @@ const arraySetValidate = rules => p => value => { const { rest } = p const declared = eachEntry( Object.entries(p.prefix), - (k, n) => nodeValidate(rules)(n)(value[Number(k)]), + (k, n) => k in value + ? nodeValidate(rules)(n)(value[Number(k)]) + : nodeAdmitsAbsence(rules)(n) ? ok(undefined) : verror('unexpected value'), undefined, noAccumulate, ) @@ -1161,7 +1273,9 @@ const arraySetValidate = rules => p => value => { const objectSetValidate = rules => p => value => { const declared = eachEntry( definedEntries(p.props), - (k, n) => nodeValidate(rules)(n)(value[k]), + (k, n) => k in value + ? nodeValidate(rules)(n)(value[k]) + : nodeAdmitsAbsence(rules)(n) ? ok(undefined) : verror('unexpected value'), undefined, noAccumulate, ) diff --git a/fjs/rtti/data/proof.f.mjs b/fjs/rtti/data/proof.f.mjs index aa0c1bb9f..56a589252 100644 --- a/fjs/rtti/data/proof.f.mjs +++ b/fjs/rtti/data/proof.f.mjs @@ -1,5 +1,5 @@ /** - * @import { Or } from '../types.ts' + * @import { Option, Or } from '../types.ts' * @import { Data } from './types.ts' */ @@ -18,7 +18,7 @@ import { string, unknown as unknownRtti, } from '../module.f.mjs' -import { cmp, equal, never, subset, toData, unitBit, unitList, unknown, validate, withoutUnits } from './module.f.mjs' +import { absentBit, cmp, equal, never, subset, toData, unitBit, unitList, unknown, validate, withoutUnits } from './module.f.mjs' /** @type {(actual: Data) => (expected: Data) => void} */ const assertData = actual => expected => @@ -94,12 +94,12 @@ const b2 = () => ['array', b2] const recordSelf = () => ['record', recordSelf] /** Mutual recursion through object *properties* rather than containers. */ -/** @typedef {() => readonly ['const', { readonly value: typeof number, readonly next: Or }]} _Even */ -/** @typedef {() => readonly ['const', { readonly value: typeof number, readonly next: Or }]} _Odd */ +/** @typedef {() => readonly ['const', { readonly value: typeof number, readonly next: Or }]} _Even */ +/** @typedef {() => readonly ['const', { readonly value: typeof number, readonly next: Or }]} _Odd */ /** @type {_Even} */ -const even = () => ['const', { value: number, next: option(odd) }] +const even = () => ['const', { value: number, next: or(option, odd) }] /** @type {_Odd} */ -const odd = () => ['const', { value: number, next: option(even) }] +const odd = () => ['const', { value: number, next: or(option, even) }] /** @typedef {() => readonly ['array', _Rec]} _Rec */ /** Every call returns a fresh recursive thunk whose function name is `f`. */ @@ -129,6 +129,45 @@ const closedChildren = () => ['array', closedNode] /** @type {_NestedRest} */ const nestedRest = () => ['rest', { a: number }, nestedRest] +/** + * A recursive rule that admits absence, with a non-empty present part — + * the referenced-rest exemption's ordinary case. + * + * @typedef {() => readonly ['or', typeof option, () => readonly ['array', _OptList]]} _OptList + */ + +/** @type {_OptList} */ +const optList = () => ['or', option, array(optList)] + +/** + * An absence-only cycle: the pure `or` cycle dissolves to the absent bit + * alone, so the rule's present part is empty — the case that shows masking + * a referenced rest would be unsound. + * + * @typedef {() => readonly ['or', typeof option, _AbsCycleB]} _AbsCycleA + * @typedef {() => readonly ['or', _AbsCycleA]} _AbsCycleB + */ + +/** @type {_AbsCycleA} */ +const absCycleA = () => ['or', option, absCycleB] + +/** @type {_AbsCycleB} */ +const absCycleB = () => ['or', absCycleA] + +/** + * A pure `or` cycle normalizing to `or(option, number)` — a *referenced* + * node whose stripped set equals a rest it trails. + * + * @typedef {() => readonly ['or', typeof option, typeof number, _OptNumB]} _OptNumA + * @typedef {() => readonly ['or', _OptNumA]} _OptNumB + */ + +/** @type {_OptNumA} */ +const optNumA = () => ['or', option, number, optNumB] + +/** @type {_OptNumB} */ +const optNumB = () => ['or', optNumA] + const tupleNumber = /** @type {const} */ ([number]) const tupleString = /** @type {const} */ ([string]) const tupleNumberNumber = /** @type {const} */ ([number, number]) @@ -207,18 +246,26 @@ export const proof = { // the `unknown` one, which the two kinds spell differently: a // `rest: unknown` past a tuple's prefix, and no `rest` at all on a // struct. An open tuple declaring nothing is therefore every array - // — and so is one whose every position restates that `rest`, which - // is trimmed away so that one set keeps one spelling + // — and so is one whose every position restates that `rest` as the + // declared-member top `or(option, unknown)`, which is trimmed away + // so that one set keeps one spelling. A position declared plain + // `unknown` is *not* that top — it must be present — so it stays. assertData(toData(open(emptyTuple)))([{}, { array: true }]) - assertData(toData(open(/** @type {const} */ ([unknownRtti]))))([{}, { array: true }]) - assertData(toData(open(/** @type {const} */ ([unknownRtti, unknownRtti]))))([{}, { array: true }]) - assertData(toData(open(/** @type {const} */ ([number, unknownRtti]))))(toData(open(tupleNumber))) + assertData(toData(open(/** @type {const} */ ([or(option, unknownRtti)]))))([{}, { array: true }]) + assertData(toData(open(/** @type {const} */ ([or(option, unknownRtti), or(option, unknownRtti)]))))([{}, { array: true }]) + assertData(toData(open(/** @type {const} */ ([unknownRtti]))))( + [{}, { array: [{ prefix: [unknown], rest: unknown }] }]) + assertData(toData(open(/** @type {const} */ ([number, or(option, unknownRtti)]))))(toData(open(tupleNumber))) assertData(toData(open(/** @type {const} */ ([number, 42]))))( [{}, { array: [{ prefix: [{ number: true }, { number: [42] }], rest: unknown }] }]) assertData(toData(open({})))([{}, { object: true }]) assertData(toData(open({ b: string, a: number })))( [{}, { object: [{ props: { a: { number: true }, b: { string: true } } }] }]) - assertData(toData(open({ a: unknownRtti })))([{}, { object: true }]) + // a key declared `unknown` must be *present*, so it survives even + // under `open` — the droppable declared top is `or(option, unknown)` + assertData(toData(open({ a: unknownRtti })))( + [{}, { object: [{ props: { a: unknown } }] }]) + assertData(toData(open({ a: or(option, unknownRtti) })))([{}, { object: true }]) assertData(toData(/** @type {const} */ ([neverRtti])))([{}, never]) assertData(toData({ a: neverRtti }))([{}, never]) }, @@ -247,10 +294,15 @@ export const proof = { // a `never` member empties the whole pattern, whatever the rest assertData(toData(/** @type {const} */ ([neverRtti])))([{}, never]) assertData(toData({ a: neverRtti }))([{}, never]) - // an unconstrained key is dropped only once the rest is gone: with - // one present, "anything at `a`" says strictly more than leaving - // `a` out, which `open({ a: unknown })` alone does not - assertData(toData(open({ a: unknownRtti })))([{}, { object: true }]) + // an unconstrained key — "anything, or nothing", the declared-member + // top `or(option, unknown)` — is dropped only once the rest is gone: + // with one present, "anything at `a`" says strictly more than + // leaving `a` out, which `open({ a: or(option, unknown) })` alone + // does not. Plain `unknown` at a key is not that top: it requires + // presence, so it is never dropped. + assertData(toData(open({ a: or(option, unknownRtti) })))([{}, { object: true }]) + assertData(toData({ a: or(option, unknownRtti) }))( + [{}, { object: [{ props: { a: { ...unknown, unit: unitBit(null) | unitBit(undefined) | unitBit(false) | unitBit(true) | absentBit } }, rest: never }] }]) assertData(toData({ a: unknownRtti }))( [{}, { object: [{ props: { a: unknown }, rest: never }] }]) // the same container bare and opened are two sets, so the @@ -308,12 +360,76 @@ export const proof = { assertData(toData(or(0, -0)))([{}, { number: [-0, 0] }]) assertData(toData(or('b', 'a')))([{}, { string: ['a', 'b'] }]) assertData(toData(or(2n, 1n)))([{}, { bigint: [1n, 2n] }]) - assertData(toData(option(string)))([{}, { unit: unitBit(undefined), string: true }]) + // absence is the fifth unit bit, merged like any other — and the + // explicit `thunkUnion` case is what keeps `toData(option)` from + // falling into the empty-operand `or` arm and reading as `never` + assertData(toData(option))([{}, { unit: absentBit }]) + assertData(toData(or(option, string)))([{}, { unit: absentBit, string: true }]) + assertData(toData(or(option, number)))([{}, { unit: absentBit, number: true }]) + assertData(toData(or(option, string, undefined)))( + [{}, { unit: unitBit(undefined) | absentBit, string: true }]) + assert(!equal(toData(or(option, number)))(toData(number))) assertData(toData(or(unknownRtti, number)))([{}, unknown]) assertData(toData(or(number, or(string, boolean))))( [{}, { unit: unitBit(false) | unitBit(true), number: true, string: true }]) assertData(toData(or(1, or(1, 2))))([{}, { number: [1, 2] }]) }, + // The normalizations the absent bit changes, pinned so each + // degenerate spelling's normal form stays deliberate. + absence: { + // a rest never sees an absent member, so an inline rest is + // stripped of the bit — on both kinds, and at the top-level + // spelling too + restIsStripped: () => { + assertData(toData(array(or(option, number))))(toData(array(number))) + assertData(toData(record(or(option, number))))(toData(record(number))) + assertData(toData(rest([number], or(option, string))))( + toData(rest([number], string))) + assertData(toData(rest({ a: number }, or(option, string))))( + toData(rest({ a: number }, string))) + // …while a declared *position* keeps its bit: absence is + // observable there + assertData(toData(open([or(option, number)])))( + [{}, { array: [{ prefix: [{ unit: absentBit, number: true }], rest: unknown }] }]) + }, + // `array(option)` has an empty element set once the bit is + // stripped, and a `never` rest is the exact-length set of its + // (empty) prefix: the empty array + arrayOfOptionIsTheEmptyArray: () => { + assertData(toData(array(option)))(toData(/** @type {const} */ ([]))) + assertEq(validate(toData(array(option)))([])[0], 'ok') + assertEq(validate(toData(array(option)))(new Array(1))[0], 'error') + }, + // the redesigned trim: a trailing declared position that admits + // absence and whose stripped set restates the rest is dropped — + // `rest([or(option, number)], number)` and `array(number)` denote + // one set of arrays, so they get one `Node` + trailingPositionRestatingTheRest: () => { + assertData(toData(rest([or(option, number)], number)))(toData(array(number))) + assertData(toData(rest([number, or(option, number)], number)))( + toData(rest([number], number))) + // without the bit the position is "one or more", not restating + assert(!equal(toData(rest([number], number)))(toData(array(number)))) + // and a stripped set differing from the rest is kept + assert(!equal(toData(rest([or(option, string)], number)))(toData(array(number)))) + }, + // `[option]` is not `[]`: its sole position strips to `never` + // like its (empty) rest, and the trim never reaches an empty + // rest — the two differ on `new Array(1)`, a length the first + // admits and the second bounds out + absentOnlyPositionIsNotDropped: () => { + assert(!equal(toData(/** @type {const} */ ([option])))( + toData(/** @type {const} */ ([])))) + const v1 = validate(toData(/** @type {const} */ ([option]))) + assertEq(v1(new Array(1))[0], 'ok') + assertEq(v1([])[0], 'ok') + assertEq(v1([1])[0], 'error') + assertEq(v1([undefined])[0], 'error') + const v0 = validate(toData(/** @type {const} */ ([]))) + assertEq(v0([])[0], 'ok') + assertEq(v0(new Array(1))[0], 'error') + }, + }, orCanonicalIdentity: () => { assertData(toData(or(number, string)))(toData(or(string, number))) const a = array(number) @@ -575,6 +691,80 @@ export const proof = { const oneNumberThenStrings = [{}, { array: [{ prefix: [{ number: true }], rest: { string: true } }] }] assert(!subset(oneOrMoreNumbers)(oneNumberThenStrings)) }, + // A **referenced** rest is left unstripped — the same rule may sit at + // a declared position, where the bit is live — and `subset` resolves + // it rather than masking the bit. The cost is one-way inclusion: the + // stripped form bounds nothing new where the present part is + // non-empty, and bounds the *length* where it is empty, so `equal` + // answers "different spelling" — the structural incompleteness + // `./README.md` records beside rule names. + referencedRest: () => { + // the exemption itself: the rest stays a reference, bit intact + assertData(toData(rest([number], optList)))([ + { optList: { unit: absentBit, array: [{ prefix: [], rest: 'optList' }] } }, + { array: [{ prefix: [{ number: true }], rest: 'optList' }] }, + ]) + // the stripped fixpoint is a *different rule*, not a bit-mask: + // one-way inclusion, resolved coinductively + /** @type {Data} */ + const stripped = [ + { optList0: { array: [{ prefix: [], rest: 'optList0' }] } }, + { array: [{ prefix: [{ number: true }], rest: 'optList0' }] }, + ] + assert(subset(stripped)(toData(rest([number], optList)))) + assert(!subset(toData(rest([number], optList)))(stripped)) + // the absence-only cycle is where masking would be unsound: the + // syntactic form keeps a rest and admits any hole-only array, + // while the stripped form is `never` and bounds the length — two + // sets, not one set spelled twice + const holey = toData(rest([number], absCycleA)) + assertData(holey)([ + { absCycleA: { unit: absentBit } }, + { array: [{ prefix: [{ number: true }], rest: 'absCycleA' }] }, + ]) + assertEq(validate(holey)([1, , , ])[0], 'ok') + assertEq(validate(holey)([1, 2])[0], 'error') + assertEq(validate(toData(tupleNumber))([1, , , ])[0], 'error') + assert(subset(toData(tupleNumber))(holey)) + assert(!subset(holey)(toData(tupleNumber))) + // a referenced **trailing position** is exempt by the same rule: + // `optNumA` normalizes to `or(option, number)`, whose stripped + // set restates the rest — an inline spelling trims — but neither + // `trimPrefix` nor `arraySet` takes a rule set to resolve the + // reference with, so it stays untrimmed, structurally distinct + // from `array(number)`, and still read the same way + const referencedTrailing = toData(rest([optNumA], number)) + assertData(referencedTrailing)([ + { optNumA: { unit: absentBit, number: true } }, + { array: [{ prefix: ['optNumA'], rest: { number: true } }] }, + ]) + assert(!equal(referencedTrailing)(toData(array(number)))) + // the readers still agree on what both spellings accept + assertEq(validate(referencedTrailing)([])[0], 'ok') + assertEq(validate(referencedTrailing)([1, 2])[0], 'ok') + assertEq(validate(referencedTrailing)(['x'])[0], 'error') + }, + // A declared position asks the object kind's two questions: the + // absence-stripped sets compared, and absence implied. This pair is + // what tells the two halves apart — the left's only values are + // `new Array(1)` and `[number]`, which `array(number)` admits (a hole + // is no entry) and the closed `[number]` does not (position 0 is + // required there). + arrayAbsence: () => { + assert(subset(toData(/** @type {const} */ ([or(option, number)])))(toData(array(number)))) + assert(!subset(toData(/** @type {const} */ ([or(option, number)])))(toData(tupleNumber))) + // absence implied by a hole past the right's prefix… + assert(subset(toData(/** @type {const} */ ([number, or(option, number)])))( + toData(rest([number], number)))) + // …or by the right position's own bit + assert(subset(toData(/** @type {const} */ ([or(option, 42)])))( + toData(/** @type {const} */ ([or(option, number)])))) + // and never invented: a stripped-equal pair still fails when the + // right requires presence + assert(!subset(toData(open([or(option, number)])))(toData(open(tupleNumber)))) + // the reverse inclusion is the ordinary pointwise one + assert(subset(toData(tupleNumber))(toData(/** @type {const} */ ([or(option, number)])))) + }, // The closed default makes a `rest`-less array pattern and an object // pattern with an empty `rest` the *ordinary* output of the thunk // form, where hand-written data used to be the only way to reach @@ -603,31 +793,33 @@ export const proof = { assert(subset(toData(closedNode))(toData(closedNode))) }, // A key present holding `undefined` and a key absent are two different - // objects, and the two sides of a pattern read them differently: a - // declared key constrains the value *read* at it, so absence passes - // when its set holds `undefined`; an undeclared key is checked as an - // *entry*, so a present `undefined` must belong to `rest` itself. - // - // One "read set" of `rest ∪ undefined` folded the two together, which - // stayed sound only while a struct's `rest` was `unknown` and failed - // the trailing rest check. A bare struct now supplies `never` there, - // so the fold is reachable from every schema and answered `true` for a - // non-inclusion. + // objects, told apart by two different bits: `unitBit(undefined)` is a + // value the key may hold, `absentBit` is leave-it-out. The per-key + // check asks two questions — the **absence-stripped** present sets + // compared, and absence implied — and neither implies the other. presenceIsNotAbsence: () => { - const p = toData({ a: option(number) }) + // `{ a: or(option, number) }` denotes `{}` and `{ a: number }`, + // both of which `record(number)` admits, so the inclusion holds — + // it is the *stripped* present set that is compared. The old + // `option(number)` spelling admitted `{ a: undefined }` and was + // rightly excluded; that spelling is now `or(option, number, + // undefined)`, and still is. + const p = toData({ a: or(option, number) }) const q = toData(record(number)) - assert(!subset(p)(q)) - // the witness, and the acceptance that makes it one - assertEq(validate(p)({ a: undefined })[0], 'ok') + assert(subset(p)(q)) + assertEq(validate(p)({ a: undefined })[0], 'error') assertEq(validate(q)({ a: undefined })[0], 'error') + assert(!subset(toData({ a: or(option, number, undefined) }))(q)) + assertEq(validate(toData({ a: or(option, number, undefined) }))({ a: undefined })[0], 'ok') // both halves of the per-key check are load-bearing, and neither // implies the other: this pair agrees on every present value and // differs only on whether the key may be missing assert(!subset(toData(record(number)))(toData(rest({ a: number }, number)))) assertEq(validate(toData(record(number)))({})[0], 'ok') assertEq(validate(toData(rest({ a: number }, number)))({})[0], 'error') - // and the open-struct spelling, which was sound before, still is - assert(!subset(toData({ a: option(number) }))(toData(record(number)))) + // present-undefined alone also breaks the inclusion — the absent + // bit is not what carries it + assert(!subset(toData({ a: or(number, undefined) }))(toData(record(number)))) }, objects: () => { assert(subset(toData(open({ a: number })))(toData(open({})))) @@ -638,7 +830,7 @@ export const proof = { assert(!subset(toData(record(or(number, string))))(toData(record(number)))) // a record's keys may be absent, a required key excludes that assert(!subset(toData(record(number)))(toData(open({ a: number })))) - assert(subset(toData(record(number)))(toData(open({ a: option(number) })))) + assert(subset(toData(record(number)))(toData(open({ a: or(option, number) })))) // an open struct leaves undeclared keys unconstrained, a record // does not — while a closed one names them all, so it is included assert(!subset(toData(open({ a: number })))(toData(record(number)))) @@ -726,7 +918,7 @@ export const proof = { assertEq( JSON.stringify(vt([1])), '["error",{"path":["1"],"message":"unexpected value"}]') - const vo = validate(toData(open(/** @type {const} */ ([number, option(string)])))) + const vo = validate(toData(open(/** @type {const} */ ([number, or(option, string)])))) assertEq(vo([1])[0], 'ok') assertEq(vo([1, 'a'])[0], 'ok') assertEq(vo([])[0], 'error') @@ -769,7 +961,7 @@ export const proof = { '["error",{"path":[],"message":"no match"}]') }, objects: () => { - const v = validate(toData(open({ a: number, b: option(string) }))) + const v = validate(toData(open({ a: number, b: or(option, string) }))) assertEq(v({ a: 1 })[0], 'ok') assertEq(v({ a: 1, b: 's' })[0], 'ok') assertEq(v({ a: 1, extra: true })[0], 'ok') diff --git a/fjs/rtti/data/types.ts b/fjs/rtti/data/types.ts index 667aa936e..70239abcd 100644 --- a/fjs/rtti/data/types.ts +++ b/fjs/rtti/data/types.ts @@ -23,9 +23,11 @@ export type KindSet = true | readonly T[] * A set of arrays: a tuple with an optional rest. * * - `prefix` constrains, per leading position, the value *read* at that - * position — a position past the array's end reads as `undefined`, so a - * position is required exactly when its set excludes `undefined`. This is - * the array half of the rule {@link ObjectSet} states for keys. + * position — and whether there needs to be one: a position past the + * array's end, or a hole, is **absent**, and a position is required + * exactly when its set excludes absence (the `absentBit` of its `unit` + * bitset). This is the array half of the rule {@link ObjectSet} states + * for keys. * - `rest` present: the value at every position past the prefix belongs to * `rest`. * - `rest` absent: there is nothing past the prefix. @@ -46,10 +48,12 @@ export type ArraySet = { /** * A set of objects: per-key value sets with an optional rest. * - * - `props` constrains, per declared key, the value *read* at that key — an - * absent property reads as `undefined`, so a key is required exactly when - * its set excludes `undefined`. Keys are canonically sorted, and a key - * whose set is the whole value domain is omitted. + * - `props` constrains, per declared key, the value *read* at that key — and + * whether there needs to be one: a key is required exactly when its set + * excludes **absence** (the `absentBit` of its `unit` bitset), so `{}` and + * `{ a: undefined }` are told apart. Keys are canonically sorted, and a + * key whose set is the whole *declared-member* domain — any value, or + * nothing — is omitted. * - `rest` present: the value at every other *present* key belongs to `rest`. * - `rest` absent: other keys are unconstrained. * @@ -67,10 +71,15 @@ export type ObjectSet = { * with every component at its maximum (see `unknown` in `./module.f.mjs`) * is `unknown`. * - * `unit` is a bitset over the four singleton values; bit `1 << i` stands for - * `unitList[i]` from `./module.f.mjs` (`['null', 'undefined', 'false', - * 'true']`), so `or(true, false)` collapses to the two boolean bits with no - * special-case rule. + * `unit` is a bitset over the four singleton values plus **absence**; bit + * `1 << i` for `i < 4` stands for `unitList[i]` from `./module.f.mjs` + * (`['null', 'undefined', 'false', 'true']`), so `or(true, false)` collapses + * to the two boolean bits with no special-case rule. Bit `16` is + * `absentBit`, rtti's nullary `option`: the member that is not there. It + * maps to no `unitList` entry because absence is not a DJS value — nothing + * reads as absent; a container *position* is absent by having no own or + * inherited key — so a consumer decoding stored data must treat bit `16` as + * the may-be-omitted marker of a declared member, not as a fifth value. */ export type UnionSet = { readonly unit?: number diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index ab24b36bb..ef9d538d5 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -25,7 +25,7 @@ import { assert, assertEq, assertStructurallySame } from '../asserts/module.f.mjs' import { undeclaredMembers } from './common/module.f.mjs' import { toData, validate as dataValidate } from './data/module.f.mjs' -import { array, number, rest, string } from './module.f.mjs' +import { array, number, option, or, rest, string } from './module.f.mjs' import { parse } from './parse/module.f.mjs' import { validate } from './validate/module.f.mjs' @@ -167,4 +167,40 @@ export const proof = { assertOk(read(rest([number], number))(value)) } }, + // A declared member absent by own-key but supplied by the **prototype** is + // present to the readers — HasProperty, the same test `getItem`'s read + // answers to — so the inherited value must satisfy the member's present + // part: `or(option, t)`'s `option` branch rejects any present value, so + // dispatching the read value *is* the present-part check. Without it, + // `validate` would hand back an object whose `.a` reads `'bad'` while the + // rendered type promises `number` — the own-key rule alone would have + // introduced that unsoundness, not inherited it. + inheritedDeclaredMemberMeetsThePresentPart: () => { + const value = Object.create({ a: 'bad' }) + for (const read of [v, p, d]) { + assertError(read({ a: or(option, number) })(value)) + assertOk(read({ a: or(option, string) })(value)) + } + }, + // …and `parse` **materializes** the inherited value as an own member of + // what it builds: against a prototype-supplied index no immutable builder + // can produce the hole — `slice` and `.map` copy by HasProperty, an + // `Object.hasOwn` guard inside a `.map` callback cannot stop the own + // output element from existing, and a fresh `Array(n)` inherits the index + // too — so this is a pinned, bounded divergence, unreachable from + // FunctionalScript (which has neither mutation nor prototype writes). + // `validate` is untouched: it returns the value it was given. + parseMaterializesAnInheritedIndex: () => { + const value = inheritedIndex() + const schema = /** @type {const} */ ([number, or(option, number)]) + const r = p(schema)(value) + assert(r[0] === 'ok', 'expected ok') + const built = /** @type {ReadonlyArray} */ (r[1]) + assert(Object.hasOwn(built, 1), 'the inherited index is an own member of the result') + assertEq(built[1], 99, 'carrying its parsed value') + const rv = v(schema)(value) + assert(rv[0] === 'ok', 'expected ok') + assert(Object.is(rv[1], value), '`validate` hands back the value it was given') + assert(!Object.hasOwn(/** @type {object} */ (rv[1]), 1), 'holes and all') + }, } diff --git a/fjs/rtti/module.f.mjs b/fjs/rtti/module.f.mjs index b9960f16e..500aa3d18 100644 --- a/fjs/rtti/module.f.mjs +++ b/fjs/rtti/module.f.mjs @@ -7,7 +7,7 @@ * @import { Includes } from '../types/array/types.ts' * @import { Assert } from '../asserts/types.ts' * @import { Equal } from '../types/ts/types.ts' - * @import { Tag0, Primitive0, _Type0, Bigint, Unknown, Tag1, _MakeType1, _MakeOpen, _MakeRest, Or, Type } from './types.ts' + * @import { Tag0, Primitive0, _Type0, Bigint, Unknown, Option, Tag1, _MakeType1, _MakeOpen, _MakeRest, Or, Type } from './types.ts' */ import { includes } from '../types/array/module.f.mjs' @@ -16,7 +16,7 @@ const primitive0List = /** @type {const} */ (['bigint', 'boolean', 'number', 'st /** @typedef {Assert>} _Primitive0Pinned */ -export const tag0List = /** @type {const} */ ([...primitive0List, 'unknown']) +export const tag0List = /** @type {const} */ ([...primitive0List, 'unknown', 'option']) const type0 = /** @@ -96,14 +96,27 @@ export const or = (...types) => () => ['or', ...types] /** - * Constructs a schema that validates a value matching `T` or `undefined`. + * Schema denoting **absence** — the member that is not there. A nullary + * schema like {@link boolean} or {@link unknown}: it takes no argument and + * wraps nothing; a member that may be omitted says so by union. * - * @template {Type} const T - * @param {T} t - * @returns {Or} + * ```js + * { a: or(option, number) } // `a` may be absent, or a number + * { a: or(number, undefined) } // `a` must be present, may hold `undefined` + * { a: or(option, number, undefined) } // absent, a number, or a present `undefined` + * [or(option, number), 3] // position 0 may be a hole + * ``` + * + * Absence is not a spelling of the value `undefined`: `{}` and + * `{ a: undefined }` are two distinct values, and only a set admitting + * absence accepts the first. It is observable only at a container position — + * no caller can hand a reader an argument that is not there, so a top-level + * schema admitting absence accepts exactly what the rest of its union + * accepts — and a container's `rest` never sees it, a hole being no member. + * + * @type {Option} */ -export const option = t => - or(t, undefined) +export const option = type0('option') /** * Schema that never matches any value — the empty union, corresponding to TypeScript's `never`. diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 3baa2e18f..c16e22e9b 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -15,10 +15,12 @@ * always returned even if the inner type is a primitive. * * Closedness is about *undeclared* members, and leaves the required/optional - * rule alone: a member is required exactly when its set excludes `undefined` — - * an absent member reads as `undefined`, on both kinds — so a shorter array - * whose trailing position admits `undefined` is accepted and the gap is - * filled. + * rule alone: a member is required exactly when its set excludes **absence** + * — the `option` bit of its union — so a shorter array whose trailing + * position says `or(option, t)` is accepted. An absent member is omitted + * from what is built, never materialized as `undefined`: the struct kind + * drops the key, the array kind keeps a hole a hole and shortens a trailing + * absent run. * * A tuple schema declares by length, so a hole in the *schema* is a declared * position whose schema is `undefined` — see "A hole is a declared position" @@ -57,6 +59,7 @@ import { ok } from '../../types/result/module.f.mjs' import { reverse, toArray } from '../../types/list/module.f.mjs' import { + absentMember, constPrimitiveValidate, eachEntry, isArray, @@ -82,6 +85,46 @@ const arrayRebuild = entries => entries.map(([, v]) => v) /** @type {_Rebuild} */ const recordRebuild = entries => Object.fromEntries(entries) +/** + * Rebuilds a **const** container from the declared members that were + * present, keyed by the same HasProperty test the check dispatched on — + * one per kind, since only the array kind has holes to preserve. + * + * @template C + * @typedef {(value: C, entries: ReadonlyArray) => Unknown} _RebuildDeclared + */ + +/** + * The array kind's rebuild is **slice, then map**: truncate the value to the + * last *present* declared position, then map each present index to its + * parsed result. Mapping alone is not enough — `.map` preserves length, so a + * trailing absent run would survive as a sparse tail and serialize back to + * the `null`s this stage removes — and omitting absent entries from a + * rebuilt list would shift every position after an interior hole. + * `slice` and `.map` both skip a hole, so an interior one survives as a + * hole; both use HasProperty, so an index the value only *inherits* is + * materialized as an own property of the result, carrying its parsed value. + * That divergence is bounded and pinned rather than closed: no immutable + * builder can produce the hole against a prototype-supplied index — + * `.map` creates the own output element whatever the callback returns, and + * a fresh `Array(n)` inherits the index too — and the escapes + * (`Object.assign`, index assignment) are mutation, which FunctionalScript + * forbids. See `../host.proof.mjs`. + * + * @type {_RebuildDeclared>} + */ +const tupleRebuild = (value, entries) => { + if (entries.length === 0) { return [] } + /** @type {StringMap} */ + const byIndex = Object.fromEntries(entries) + const end = Number(entries[entries.length - 1][0]) + 1 + return value.slice(0, end).map((_, i) => byIndex[i]) +} + +/** The struct kind drops an absent key: only the present entries are rebuilt. */ +/** @type {_RebuildDeclared>} */ +const structRebuild = (_value, entries) => Object.fromEntries(entries) + /** `eachEntry`'s accumulator seed: entries are consed on in reverse as they parse. */ /** @type {List} */ const emptyEntries = null @@ -91,6 +134,18 @@ const emptyEntries = null const consEntry = (acc, k, v) => ({ first: [k, v], tail: acc }) +/** + * `eachEntry`'s accumulate step over *declared* members, whose item wraps a + * present member's parsed value in a one-element list and an absent member + * in an empty one: the present value is kept, the absent member leaves no + * entry. The wrapping is what stands in for a sentinel — every value, + * `undefined` included, is a legal parse result, so no value could mark + * absence. + */ +/** @type {(acc: List, k: string, vs: ReadonlyArray) => List} */ +const consPresent = (acc, k, vs) => + vs.length === 0 ? acc : ({ first: [k, vs[0]], tail: acc }) + /** A uniform container declares no member by name, so every one is undeclared. */ /** @type {readonly string[]} */ const noDeclared = [] @@ -166,6 +221,16 @@ const noAccumulate = () => undefined * a member on both, but an array is also *as long as it is*: a hole past the * prefix is no member and would slip through the member check alone, so the * array kind answers with its length as well. + * + * A declared member is **absent** when its key or index is neither an own + * property nor an inherited one — the same HasProperty test + * `../validate/module.f.mjs` dispatches on — and absence is decided here, + * before dispatch, since the recursive reader is handed only the value read. + * An absent member is legal exactly when its schema admits absence, and is + * **omitted** from what is built rather than materialized as `undefined`: + * the struct kind drops the key, and the array kind preserves indices — + * a trailing absent run shortens the result, an interior one stays a hole + * (see `tupleRebuild`). */ const constContainerParse = /** @@ -174,7 +239,7 @@ const constContainerParse = * @param {IsContainer} isContainer * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem - * @param {_Rebuild} rebuild + * @param {_RebuildDeclared} rebuild * @param {Fits} fits * @returns {(rtti: T) => Parse} */ @@ -189,13 +254,20 @@ const constContainerParse = } const r = eachEntry( rttiEntries, - (k, t) => (/** @type {any} */ (parse(t))(getItem(value, k))), + (k, t) => { + if (!(k in value)) { + const a = absentMember(t) + return a[0] === 'error' ? a : ok([]) + } + const p = /** @type {any} */ (parse(t))(getItem(value, k)) + return p[0] === 'error' ? p : ok([p[1]]) + }, emptyEntries, - consEntry, + consPresent, ) if (r[0] === 'error') { return r } return undeclaredMembers(declared, value).length === 0 && fits(value, declared.length) - ? /** @type {any} */ (ok(rebuild(orderedEntries(r[1])))) + ? /** @type {any} */ (ok(rebuild(value, orderedEntries(r[1])))) : verror('unexpected value') } } @@ -204,7 +276,7 @@ const tupleParse = constContainerParse( isArray, tupleSchemaEntries, (value, k) => value[Number(k)], - arrayRebuild, + tupleRebuild, (value, declared) => value.length <= declared, ) @@ -212,7 +284,7 @@ const structParse = constContainerParse( isObject, structSchemaEntries, (value, k) => value[k], - recordRebuild, + structRebuild, () => true, ) @@ -234,7 +306,7 @@ const restContainerParse = * @param {IsContainer} isContainer * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem - * @param {_Rebuild} rebuild + * @param {_RebuildDeclared} rebuild * @param {(rtti: S, r: Type) => Fits} restFits * @returns {(rtti: S, r: Type) => ValidateE} */ @@ -250,20 +322,27 @@ const restContainerParse = } const d = eachEntry( rttiEntries, - (k, t) => (/** @type {any} */ (parse(t))(getItem(value, k))), + (k, t) => { + if (!(k in value)) { + const a = absentMember(t) + return a[0] === 'error' ? a : ok([]) + } + const p = /** @type {any} */ (parse(t))(getItem(value, k)) + return p[0] === 'error' ? p : ok([p[1]]) + }, emptyEntries, - consEntry, + consPresent, ) if (d[0] === 'error') { return d } const extra = undeclaredMembers(declared, value) if (extra.length === 0) { return fits(value, declared.length) - ? ok(rebuild(orderedEntries(d[1]))) + ? ok(rebuild(value, orderedEntries(d[1]))) : verror('unexpected value') } const restParse = /** @type {any} */ (parse(r)) const e = eachEntry(extra, (_k, v) => restParse(v), undefined, noAccumulate) - return e[0] === 'error' ? e : ok(rebuild(orderedEntries(d[1]))) + return e[0] === 'error' ? e : ok(rebuild(value, orderedEntries(d[1]))) } } @@ -271,7 +350,7 @@ const restTupleParse = restContainerParse( isArray, tupleSchemaEntries, (value, k) => value[Number(k)], - arrayRebuild, + tupleRebuild, (rtti, r) => (value, declared) => value.length <= declared || !emptyRest(rtti, r), ) @@ -279,7 +358,7 @@ const restStructParse = restContainerParse( isObject, structSchemaEntries, (value, k) => value[k], - recordRebuild, + structRebuild, () => () => true, ) @@ -338,6 +417,11 @@ const parseVisitor = /** @type {any} */ ({ constPrimitive: constPrimitiveValidate, primitive0: primitive0Validate, unknown: () => ok, + // Absence is decided by the container loop before dispatch, so a value + // that reaches this handler is present — and no present value is absent. + // An ordinary error is what lets `orVisit` try the other members of + // `or(option, t)`. + option: () => () => verror('unexpected value'), }) /** @type {(rtti: T) => Parse} */ diff --git a/fjs/rtti/parse/proof.f.mjs b/fjs/rtti/parse/proof.f.mjs index 18e9b6325..b325d6f29 100644 --- a/fjs/rtti/parse/proof.f.mjs +++ b/fjs/rtti/parse/proof.f.mjs @@ -33,9 +33,9 @@ const unwrap = r => { } /** A container that contains itself: `[number, node?]`. */ -/** @typedef {readonly [number, _Node | undefined]} _Node */ +/** @typedef {readonly [number, _Node?]} _Node */ -const _node = () => /** @type {const} */ (['const', [number, option(_node)]]) +const _node = () => /** @type {const} */ (['const', [number, or(option, _node)]]) /** @type {Phantom} */ const node = _node @@ -182,12 +182,13 @@ export const proof = { assertStructurallySame(unwrap(parse(open([42]))([42, 'extra'])), [42]) assertStructurallySame(unwrap(parse(open([42]))([42, 1, 2, 3])), [42]) }, - // An absent member reads as `undefined`, so a position is required - // exactly when its set excludes `undefined` — the same rule the - // data form states for object keys, applied to arrays. - shortArrayFillsAnOptionalPosition: () => { - const r = parse([number, option(string)])([42]) - assertStructurallySame(unwrap(r), [42, undefined]) + // A position is required exactly when its set excludes absence — + // the same rule the data form states for object keys, applied to + // arrays — and an absent trailing position stays absent: `parse` + // shortens the result rather than materializing `undefined`. + shortArrayLeavesAnOptionalPositionOut: () => { + const r = parse([number, or(option, string)])([42]) + assertStructurallySame(unwrap(r), [42]) }, error: () => { assertError(parse([42])([99])) @@ -316,15 +317,45 @@ export const proof = { }, }, option: { + // At the entry position nothing can be absent — see the same block in + // `../validate/proof.f.mjs`. ok: () => { - const t = option(number) + const t = or(option, number) assertOk(parse(t)(42)) - assertOk(parse(t)(undefined)) + assertOk(parse(or(option, number, undefined))(undefined)) }, error: () => { - const t = option(number) + const t = or(option, number) + assertError(parse(t)(undefined)) assertError(parse(t)(null)) assertError(parse(t)('42')) + assertError(parse(option)(undefined)) + }, + }, + // What `parse` builds around an absent member, asserted on the value + // rather than on acceptance alone: an interior absent position stays a + // hole — materializing `undefined` would denote a different value, and + // omitting it would shift everything after it — and a trailing absent + // run shortens the result. This is the JSON round-trip defect of the old + // design dissolved: no `undefined` is materialized, so nothing turns + // into `null` on the wire. + absentPositions: { + interiorHoleSurvives: () => { + /** @type {ReadonlyArray} */ + const built = unwrap(parse([or(option, number), 3])([, 3])) + assertEq(built.length, 2, 'the hole keeps its position') + assert(!Object.hasOwn(built, 0), 'no own index 0') + assertEq(built[1], 3, 'and `3` stays at index 1') + }, + trailingRunShortens: () => { + /** @type {ReadonlyArray} */ + const built = unwrap(parse([number, or(option, number), or(option, number)])([1, , ])) + assertEq(built.length, 1, 'the trailing absent run is gone') + assertEq(built[0], 1, 'the present prefix survives') + }, + structDropsTheKey: () => { + const built = unwrap(parse({ a: number, b: or(option, string) })({ a: 1 })) + assert(!('b' in built), 'an absent key is not materialized') }, }, path: { @@ -399,11 +430,11 @@ export const proof = { // Nor is a key that is no position at all. nonIndexKeyRejected: () => assertError(parse([number])(Object.assign([1], { foo: 2 }))), - // The rule for a missing member is unchanged: an absent position - // reads as `undefined`. + // A missing member is absent: required where its set excludes + // absence, omitted from what is built where it does not. shortArray: () => { assertError(parse([number])([])) - assertStructurallySame(unwrap(parse([number, option(string)])([1])), [1, undefined]) + assertStructurallySame(unwrap(parse([number, or(option, string)])([1])), [1]) }, empty: () => { assertStructurallySame(unwrap(parse([])([])), []) @@ -462,8 +493,8 @@ export const proof = { // `../ts/types.ts`); it is the *value* half under test here. recursive: () => { const p = parse(node) - assertStructurallySame(unwrap(p([1])), [1, undefined]) - assertStructurallySame(unwrap(p([1, [2]])), [1, [2, undefined]]) + assertStructurallySame(unwrap(p([1])), [1]) + assertStructurallySame(unwrap(p([1, [2]])), [1, [2]]) assertError(p([1, [2], 3])) }, // A cycle through the `rest` itself: every key other than `a` holds @@ -476,7 +507,7 @@ export const proof = { }, }, arrayOptional: () => { - const a = /** @type {const} */([number, option(string)]) + const a = /** @type {const} */([number, or(option, string)]) const v = parse(a) assertOk(v([5])) assertError(v(["n"])) diff --git a/fjs/rtti/proof.f.mjs b/fjs/rtti/proof.f.mjs index 3eeb00e36..de6bd698b 100644 --- a/fjs/rtti/proof.f.mjs +++ b/fjs/rtti/proof.f.mjs @@ -2,7 +2,7 @@ * @import { StringMap } from '../types/object/types.ts' * @import { Assert } from '../asserts/types.ts' * @import { Equal } from '../types/ts/types.ts' - * @import { Or, Rest, Type1, Unknown } from './types.ts' + * @import { Option, Or, Rest, Type1, Unknown } from './types.ts' */ import { assertNotNullish, assertStructurallySame } from '../asserts/module.f.mjs' @@ -21,8 +21,8 @@ const tests = { function: [() => undefined] } -// `or`, `option`, `array`, `record`, `rest` and `open` take `const` type -// parameters, so a literal written at the call site stays a literal: `or(42, string)` +// `or`, `array`, `record`, `rest` and `open` take `const` type parameters, so +// a literal written at the call site stays a literal: `or(42, string)` // describes `42 | string`, not `number | string`. Without the modifier a caller // has to pin every literal with an `@type {const}` cast, and the assertions // below are what fail if one of the modifiers is dropped. Each is paired with @@ -33,9 +33,13 @@ const constInference = () => { /** @typedef {Assert>>} _OrConst */ assertStructurallySame(orConst(), ['or', 42, string]) - const optionConst = option([42, string]) - /** @typedef {Assert>>} _OptionConst */ - assertStructurallySame(optionConst(), ['or', [42, string], undefined]) + // `option` is nullary — absence itself, not a wrapper — so the spelling + // under test is the union that carries it. + const optionUnion = or(option, [42, string]) + /** @typedef {Assert>} _OptionNullary */ + /** @typedef {Assert>>} _OptionUnion */ + assertStructurallySame(option(), ['option']) + assertStructurallySame(optionUnion(), ['or', option, [42, string]]) const arrayConst = array('hello') /** @typedef {Assert>>} _ArrayConst */ diff --git a/fjs/rtti/todo/checked-const-pin.md b/fjs/rtti/todo/checked-const-pin.md index aa5f92e58..32c8b358d 100644 --- a/fjs/rtti/todo/checked-const-pin.md +++ b/fjs/rtti/todo/checked-const-pin.md @@ -11,7 +11,7 @@ available is a cast: ```js export const casAddArgs = /** @type {const} */ ({ content: string, - type: or('text', 'base64', undefined) + type: or(option, 'text', 'base64') }) ``` @@ -33,7 +33,7 @@ A `const` type parameter would do both jobs at once: */ export const type = t => t -export const casAddArgs = type({ content: string, type: or('text', 'base64', undefined) }) +export const casAddArgs = type({ content: string, type: or(option, 'text', 'base64') }) ``` `type` pins exactly as `as const` does — that is what the modifier means — and diff --git a/fjs/rtti/todo/data-validate-admits-non-djs-values.md b/fjs/rtti/todo/data-validate-admits-non-djs-values.md index 8b5ff52e2..59daafe1f 100644 --- a/fjs/rtti/todo/data-validate-admits-non-djs-values.md +++ b/fjs/rtti/todo/data-validate-admits-non-djs-values.md @@ -36,7 +36,7 @@ Against `f = (a, b) => 1`: | `{}` | error | **ok** | `object` | | `record(number)` | error | **ok** | `object` | | `or(number, {})` | error | **ok** | `number,object` | -| `option({})` | error | **ok** | `unit,object` | +| `or(option, {}, undefined)` | error | **ok** | `unit,object` | | `{ length: number }` | error | **ok** | `object` | | `{ name: string }` | error | **ok** | `object` | | `{ length: number, name: string }` | error | **ok** | `object` | @@ -129,7 +129,7 @@ is an investigation, not a plan. [identity-aware-parse](identity-aware-parse.md) needs. - [ ] Whatever lands, make the three readers agree, and cover functions and symbols in tests against `unknown`, `{}`, `record(...)`, - `or(number, {})`, `option({})`, the required-property cases the intrinsics + `or(number, {})`, `or(option, {}, undefined)`, the required-property cases the intrinsics satisfy (`{ length: number }`, `{ name: string }`, `{ description: string }`), their near misses (`{ a: number }`, `{ length: string }`), and — if descent is adopted — nested and cyclic diff --git a/fjs/rtti/todo/option-as-omission.md b/fjs/rtti/todo/option-as-omission.md deleted file mode 100644 index 07bb44a07..000000000 --- a/fjs/rtti/todo/option-as-omission.md +++ /dev/null @@ -1,713 +0,0 @@ -# `option` as omission - -**Priority:** P2 -**Status:** open — stage 1 has landed; stage 2 is what is left - -Two stages, in this order: - -1. ~~a bare `Const` is **closed**; `open(c)` / `rest(c, r)` state otherwise~~ — - **landed.** `close` is gone, `rest(c, r)` and `open(c)` are the spellings, - the readers bound a tuple's length, and `RestTs` renders the tail. What that - stage decided is now stated in the code it changed — - [`../README.md`](../README.md) for the model, - [`emptyRest`](../data/module.f.mjs) for the empty-rest criterion, and - `undeclaredMembers` in [`../common/module.f.mjs`](../common/module.f.mjs) - for how a container's undeclared members are read — so this file no longer - restates it; -2. `option` becomes a **nullary schema denoting absence**, so a member that may - be omitted is `or(option, t)` rather than `or(t, undefined)`. - -One issue rather than two, because stage 2 reads the acceptance tables stage 1 -rewrote. In the other order every table, proof and consumer schema would have -been rewritten twice, and the intermediate state — omission already distinct -while a bare container was still open — had no consumer asking for it. - -## Problem - -### `option(t)` is `or(t, undefined)`, so absence is not describable - -`option` is not a concept today — `../module.f.mjs` defines it as -`or(t, undefined)`, and absence is read as the value `undefined`. Three -consequences: - -**A set the form cannot express.** `undefined` is a DJS value, so `{}` and -`{ a: undefined }` are two distinct DJS values. No schema separates them: a -declared key constrains the value *read* at it, an absent key reads `undefined`, -so every schema admitting one admits the other. For a module whose premise is -that a `Type` denotes a set of values, that is a completeness gap. - -**The rule is already not uniform.** [`../data/README.md`](../data/README.md) -states the asymmetry itself: a *declared* key is checked as a value read, but an -*undeclared* key is checked as an **entry** — `{ props: { a: number }, rest: string }` -rejects `{ a: 1, b: undefined }` and accepts `{ a: 1 }`. The form can already tell -present-`undefined` from absent; it just cannot do so at a declared position. -Stage 2 does not introduce the distinction, it finishes it. - -**Construction has no forced answer.** Given `{ a: number, b: option(string) }` -and `{ a: 1 }`, both `{ a: 1 }` and `{ a: 1, b: undefined }` are correct outputs -and `parse` picks one by fiat — with a real defect on the array kind, where the -pick does not survive JSON (`[42, undefined]` → `'[42,null]'` → rejected). That is -[parse-omits-undefined-members](./parse-omits-undefined-members.md), which stage 2 -dissolves rather than decides. - -TypeScript is on the other side of this already: this repo sets -`exactOptionalPropertyTypes: true` ([`../../../tsconfig.json`](../../../tsconfig.json)), -so `x?: string` and `x: string | undefined` are distinct there while RTTI conflates -them and renders the hybrid `{readonly "x"?: undefined|string}`. - -## Proposal - -### `option` is the absent value - -`option` is a nullary schema like `boolean` or `unknown` — `() => ['option']` — -denoting one thing: **the member that is not there**. It takes no argument and -wraps nothing; a member that may be omitted says so by union. - -```js -{ a: or(option, number) } // `a` may be absent, or a number -{ a: or(number, undefined) } // `a` must be present, may hold `undefined` -{ a: or(option, number, undefined) } // today's `option(number)` -[or(option, number), 3] // position 0 may be a hole -``` - -Absence stops being a spelling of `undefined` and becomes a value in its own -right. Everything else follows from the representation the data form already -has. - -**It costs one bit.** `unitList` in [`../data/module.f.mjs`](../data/module.f.mjs) -is a bitset over `null, undefined, false, true`; absence is a fifth member of -that kind — exactly as [`../data/README.md`](../data/README.md) describes -`or(true, false)` being the two boolean bits rather than a special rule. Union, -`subset`, `cmp`, `equal` and the coverage collapse are bitwise over that kind, so -**the set algebra does not change**. - -The *normalizations* do, and only one of the three is a straight substitution: - -| site | today | stage 2 | -| --- | --- | --- | -| `objectMayOmit` | a key is omittable when its set admits `undefined` | …when its set admits absence — a straight swap | -| `objectSet`'s `isTop` | a declared key whose set is `unknown` is dropped, and only once the `rest` is gone | the rest guard **stays**; `isTop` becomes position-aware — `unknown` for a `rest`, `or(option, unknown)` for a declared member | -| `trimPrefix` | a trailing position restating a `rest` that admits `undefined` is dropped | the rest no longer carries the bit, so the test moves to the trailing **declared position**: drop it when it admits absence and its absence-stripped set equals the rest | - -Neither of the last two can be reached by swapping the bit, and both would -mis-canonicalize if it were: - -- **`trimPrefix`.** Measured today, `rest([option(number)], option(number))` and - `array(option(number))` are the same `Node` — the rest admits `undefined`, so - the trim fires. Its counterpart here is `rest([or(option, number)], number)`: - position 0 may be absent and every present entry is a number, so it denotes the - same arrays as `array(number)`. But a `rest` carries no absent bit, so a bit - test on the rest is dead, the prefix survives, and two spellings of one set get - different `toData` — breaking `equal` and `cmp`. -- **The declared-key drop.** `{ a: or(option, unknown) }` is closed, so it - carries `rest: never` and denotes objects with at most the key `a`. - Dropping `a` would leave the empty object, a different set. `objectSet` already - guards the filter with `r === undefined` ("the rest is gone"); that guard stays - and only the predicate moves. - -That is the structural cost — one bit, one swap, two normalizations to redesign — -and it is still why this shape is preferred over the wrapper `option(t)`: a -wrapper is not a set of values, so it would have -needed a second syntactic category (`Member = Type | Option`, legal only -at a container position) and an `{ optional, node }` pair on every `props` entry -and `prefix` position, with every algebra function and its proof rewritten. - -#### The four rules the bit needs - -**`unknown` excludes it.** `unknown` is the set of DJS values and absence is not -one, so "anything, or nothing" is `or(option, unknown)`. That is the top of a -*declared member*, so the ordering caveat `../data/README.md` records — a -declared key whose set is the top is dropped only once the `rest` is gone — -**stays**, with `or(option, unknown)` as the top it tests. That guard is what -keeps `{ a: or(option, unknown) }` denoting objects with at most the key `a` -rather than the empty object. - -**It is observable only at a container position.** No caller can hand `validate` -an argument that is not there, so a top-level schema admitting absence accepts -exactly what the rest of its union accepts. Nothing has to enforce this: -`unionValidate` is only ever reached with a present value. - -**A `rest` never sees it.** A declared member is checked as the value *read* at -its position; a `rest` is checked against each *present* member. So the absent bit -in a `rest` constrains nothing and normalizes away on both kinds. This is not new -behaviour: `../parse` and `../validate` walk a value with `Object.entries`, which -skips holes, so `array(number)` accepts `[1, , 3]` today. - -**A referenced rest is left alone**, which is the one place the strip cannot be -applied. `trimPrefix` already declines to see through a reference ("reading its -unit bits would need the rule set"), and a rest that resolves to a rule cannot be -stripped in place: the same rule may be used at a declared position, where the -bit is meaningful, so clearing it globally would delete optionality elsewhere. -For `X = or(option, array(X))` used as a rest, the stripped form is not even -inline — it is the fixpoint `X' = array(X')`, a derived rule per rule reachable -at a rest position. - -So stage 2 strips an **inline** rest and leaves a **referenced** one as it is. -The cost is that `rest(c, X)` and `rest(c, X')` are then structurally distinct -while denoting one set, which is exactly the incompleteness -[`../data/README.md`](../data/README.md) already accepts and documents for rule -*names* — semantically equal, structurally distinct, and mutual `subset`s rather -than `equal`. `subset` gets there by **resolving** the rest rather than masking -the bit — it already resolves references coinductively — and comparing present -parts. - -Masking would be unsound, and the case that shows it is reachable: for -`X = or(option, Y)` with `Y = or(X)`, the pure `or` cycle dissolves to the absent -bit alone, so `X`'s present part is empty. Then `rest(c, X)` keeps a `rest` and -admits any hole-only array, while the stripped `X'` is `never`, which normalizes -to no `rest` and so **bounds the length** — the two denote different sets, not one -set spelled twice. A mask would report them as mutual subsets, and `subset` -answering `true` for a non-inclusion is the one thing `../data/README.md` -promises it never does. So the structural distinctness is an accepted -incompleteness where the present part is non-empty, and simply *correct* where it -is empty. - -Materializing derived rules instead would restore full canonicality at the price -of a fixpoint construction over the rule graph, a naming scheme that cannot -collide with user rule names, and memo identities for the derived names — the -bisimulation-grade direction `../data/README.md` deliberately avoids. Revisit -only if a consumer needs `equal` to see through it. - -**Length still bounds a closed array**, which settles the one case the strip -creates rather than leaving it to be discovered. `array(option)` has an empty -element set once the bit is stripped; a `never` rest normalizes to no rest, which -on the array kind is the exact-length set, so `array(option)` is the empty array -— not "hole-only arrays of any length". That is the reading a bare container -already has, and stage 1 made all three readers agree on it: `emptyRest` in -[`../data/module.f.mjs`](../data/module.f.mjs) decides when a stated rest makes -no difference to the canonical form, and the array-kind readers bound their -length by it. So the rows below hold today, `array(or())` included, and nothing -here has to re-establish them: - -| schema | value | thunk `validate` | data `validate` | `parse` | -| --- | --- | --- | --- | --- | -| `[]` | `new Array(1)` | error | error | error | -| `[1]` | `[1, ,]` | error | error | error | -| `array(or())` | `new Array(1)` | error | error | error | - -What this stage adds is one more spelling reaching the same bound: once the rest -is stripped of its absent bit, `array(option)` has an empty element set, so it -denotes the empty array. The criterion is already there to answer it. - -**Absence at a tuple position is "no such own index"** — past the end or a hole, -one rule for both. That makes the value side symmetric with the schema side -settled in #1712, where a hole in a *schema* is a declared `undefined` position. -Construction has to preserve it: an interior absent position must stay a hole, -because materializing it as `undefined` now denotes a different value, and -omitting it from a rebuilt list shifts every position after it. - -#### What it means for the two hard spellings - -Both cases that a wrapper design would have had to forbid are ordinary unions -here, with ordinary meanings: - -| schema | accepts | rejects | -| --- | --- | --- | -| `[or(option, number), 3]` | `[, 3]` | `[undefined, 3]`, `[3]` | -| `or(option, number, string)` at a key | `{}`, `{ a: 1 }`, `{ a: 'x' }` | `{ a: undefined }` | - -`or(option(number), string)` in the old spelling simply flattens to the second -row — `or` is union and the absent bit merges like any other. - -Two sets also become expressible that neither today's design nor a wrapper can -say: `{ a: option }` is "objects with no `a`", and `open({ a: option })` is that -plus anything else — a negative field. - -#### Rendering - -`StructTs` renders a key whose set admits absence as optional, with the absent -bit stripped from what it prints: `or(option, number)` → `readonly a?: number`, -`or(number, undefined)` → `readonly a: number | undefined`. Under -`exactOptionalPropertyTypes` those are already distinct in TypeScript, so the -rendering becomes exact. - -For tuples the trailing run renders optional with the absent bit stripped, and -that rendering is **exact** — the first time `TupleTs` and the schema denote the -same set. `Ts<[1, or(option, number)]>` is `readonly [1, number?]`, and -TypeScript agrees on every row (checked against this repo's `tsc`): - -| value | the schema | `readonly [1, number?]` | -| --- | --- | --- | -| `[1]` | accepts | assignable | -| `[1, 2]` | accepts | assignable | -| `[1, undefined]` | rejects | `TS2322` | -| `[1, 2, 3]` | rejects (closed) | not assignable | - -Reading position `1` still gives `number | undefined`, which is what JavaScript -gives for an index that may not be there, so the type is honest in both -directions. The exactness depends on `exactOptionalPropertyTypes` — with the flag -off, TypeScript accepts `[1, undefined]` at an optional tuple position too -(checked both ways) — and this repo already sets it. - -It takes **both** stages. Stage 1 supplied the length: while a bare tuple was -open, an exact-length rendering was an unsound cast. This stage supplies the -element type: while `option(number)` is `or(number, undefined)`, -the position can only render `(number|undefined)?`, which admits the very -`[1, undefined]` the closed spelling should reject. Together they also make the -two renderers agree — today the runtime printer prints the open tail -(`readonly[number,(undefined|string)?,...readonly(unknown)[]]`, -`../ts/proof.f.mjs`) while `Ts<>` cannot, and afterwards both print -`readonly[1,number?]`. - -There is a **third** renderer over the data form: -`../../media/json/schema/module.f.mjs` derives `required` and `minItems` from -`admitsUndefined`, and drops `undefined` from an optional member's schema with -`stripUndefined`. Stage 2 splits those two uses, which today are one thing: - -- `admitsUndefined` drives `required`/`minItems`, so it asks about **absence** and - moves to the absent bit. Without that, `{ a: or(option, number) }` renders - `required: ["a"]` while RTTI accepts `{}`. -- `stripUndefined` asks what JSON can **carry**, so it stays keyed on `undefined`. - A key of `or(number, undefined)` is then required and renders as `number`: JSON - has no way to write the `undefined` case, so the rendering under-approximates — - the same corner the module already documents for `NaN` and `-0`. - -An *interior* position admitting absence still renders `T | undefined` — -TypeScript forbids a required element after an optional one, and `undefined` is -what TypeScript reading a hole actually gives — so `[or(option, number), 3]` is -`readonly [number | undefined, 3]`. That one stays a rendering limit, not a -narrower set. - -#### The trade, stated - -Legal-but-degenerate spellings replace illegal ones. `array(or(option, number))` -and a top-level `or(option, number)` are meaningless rather than rejected: the -first normalizes to `array(number)`, the second accepts what `number` accepts. -For a set-theoretic form, normalizing beats forbidding — a wrapper would buy -those two errors at the price of a second syntactic category — but it is a trade, -and each degenerate spelling's normal form should be pinned by a proof so it -stays deliberate rather than incidental. - -**The entry node keeps its bit**, and that asymmetry with the rest is deliberate -rather than an oversight. At the entry, `or(option, number)` and `number` accept -exactly the same inputs — nothing can be handed to a call that is not there — yet -they stay structurally distinct, so `equal` is false between them and `subset` -holds only from `number` to the union. They are different *sets*, and this form -compares sets; the entry position simply cannot witness the difference. - -Stripping there instead would cost more than it buys. A rest node has no life -outside its position: it is a field of a pattern, and every value that position -ever sees is a present member, so the bit is vacuous by construction. An entry -node **is the schema** — a `Data` is serializable and a consumer may embed it at -a member position, where the bit is live again. Stripping it at the root would -make `toData` lose information that reappears as a silent meaning change on -reuse, which is worse than an `equal` that answers "different" for two schemas -that behave alike in one position. - -So this joins the same list as the rule-name limit in -[`../data/README.md`](../data/README.md): semantically indistinguishable *here*, -structurally distinct, and content-addressed apart. Stated, not latent. - -On the name: `option` is kept as proposed. It names the modality where `absent` -or `none` would name the value, but as the only spelling it is unambiguous, and -`or(option, number)` reads correctly. Do **not** reintroduce an `option(t)` -helper alongside it — one name, one thing. - -## Tasks - -One PR, now that stage 1 has landed: - -- [ ] `option` as a nullary schema in `../module.f.mjs`/`../types.ts` — a new - `Tag0`, so `visit`'s `Visitor` in `../common/module.f.mjs` gains the case. -- [ ] Give **both** schema-form readers an `option` handler that *rejects* - normally. `orVisit` tries the union's members in order, so for a present - value under `or(option, t)` the `option` branch is reached first and has to - return an ordinary error for `t` to be tried. Extending the `Visitor` type - is not enough to force this: `parseVisitor` (`../parse/module.f.mjs:326`) - and `validateVisitor` (`../validate/module.f.mjs:296`) are both - `/** @type {any} */ ({ … })`, so a missing handler is not a type error but - a `v.option is not a function` throw — and FunctionalScript has no - `try`/`catch` to contain it. Proof: a **present** value under - `or(option, t)`, through both readers. -- [ ] Decide the migration's **semantics** before its spelling. `option(t)` is - `or(t, undefined)` today, so it accepts a present `undefined` — verified, - `validate({ a: number, b: option(string) })({ a: 1, b: undefined })` is - `ok`. Rewriting it to `or(option, t)` therefore **narrows** every migrated - schema; the faithful translation is `or(option, t, undefined)`. This issue - takes the narrowing deliberately — it is what this stage is for, and - `exactOptionalPropertyTypes` already rejects the present-`undefined` - spelling at an optional key — but each production site is reviewed rather - than swept, and the changelog says the schemas got stricter, not that a - spelling changed. -- [ ] Migrate the **documentation and instructions** too, which the compiled-call - sweep does not reach and no checker flags. A missed call is `TS2554` at - build time; a missed doc is a working example that quietly builds the wrong - schema for whoever copies it. Twenty sites across eight files: - `../README.md` (3), `../ts/README.md` (2), `../data/README.md` (3), - `../../protocol/mcp/README.md:71` (a copy-me - `greeting: option(string)`), `../../media/revision/README.md` (5, - including the `option(true)` presence-flag idiom it recommends twice), - `../../media/note/README.md` (2), - `../../media/note/todo/extend-note-format.md` (2), and - `../../AGENTS.md` — `:383` writes `option(...)` among the schema - references, a call form it stops having, while `:405` lists `option` as a - bare name among `rtti`'s exports, so that one is a description to - re-word rather than a spelling to fix. Two near-misses stay out: `option` in - `../../bnf/todo/207.md` is `bnf`'s own combinator, and the `option(s)` - in `../../cas/evo/todo/cache-staleness.md` is English, not code. -- [ ] The **JSDoc** sites, which that list does not cover: it is a markdown - inventory, and a comment is no more compiled than a `.md` file is, so the - two sweeps between them still leave these eleven untouched, in six files. - Two of them are not spellings but *statements of the semantics stage 2 - replaces*, and matter more than the rest: `../ts/proof.f.mjs:27` says - "`option(t)` is `or(t, undefined)`; these are the schema types it - produces", which is the definition this stage retires, and - `../data/module.f.mjs:453` argues a design decision from - "`{ a: option(number) }` a subset of `record(number)`, which admits - `{ a: undefined }` on the left" — the same claim that flips in - `../data/proof.f.mjs:616` above, so the rationale and the row have to move - together or the code will justify itself with a false example. - `../ts/module.f.mjs:325` asserts the printer's output for a schema - (`option(number)` prints `'undefined|number'`), which stops being true. - `../ts/types.ts` (`:80`, `:139`, `:162`, `:163`, `:167`) uses `option(x)` - as the optional-member spelling throughout the `TupleTs`/`OptionalFields` - derivation. `../validate/module.f.mjs` (`:18`, `:298`) publishes - `b: option(string)` in its parse-vs-validate contrast and in the exported - `validate`'s `@example` — copy-me code in the reader's own API docs. - `../../media/revision/proof.f.mjs:125` names the `option(true)` - presence-only idiom its README recommends. Sweep JSDoc explicitly rather - than trusting the markdown pass: the earlier revision of this item said - "twenty sites across eight files" and meant twenty *markdown* sites, which - review caught. The markdown count stands; the scope did not. -- [ ] Audit the members that spell optionality **directly** as `or(…, undefined)`, - which the `option(` sweep does not reach and `checkJs` cannot flag — they - stay syntactically valid and silently become *required*. Verified sites: - `mcp/cas/module.f.mjs:146` (`type: or('text', 'base64', undefined)`, so - `cas_add` would start rejecting `{ content: 'hello' }`), - `media/json/schema/module.f.mjs:56` and `:60` (`type`, `items`), plus - `media/json/schema/proof.f.mjs:121` and `:136`. Each is a decision — add - `option` where omission was intended, leave it where a present `undefined` - was — not a mechanical rewrite. -- [ ] One of those decisions has a **second copy in a surviving todo**: - [checked-const-pin](./checked-const-pin.md) `:14` and `:36` quote - `casAddArgs` — the `mcp/cas/module.f.mjs:146` schema above — twice, as the - motivating example for its own proposal. It is not a call site, so neither - the `option(` sweep nor `checkJs` reaches it, and it outlives stage 2. If - the CAS decision goes to `or(option, 'text', 'base64')`, the todo would be - left arguing from a schema whose `type` key is now *required*, which is - the opposite of what its example illustrates. Rewrite both quotes to - whatever that decision picks, in the same PR — the point it makes about - unchecked `as const` pins is untouched either way. -- [ ] Migrate every `option(t)` call site to `or(option, t)` — 52 of them across - 10 files in 9 modules outside this one (`protocol/mcp` 10, - `media/json/schema` 11 plus 11 in its proof, `ci/common` 5, `mcp/evo` 5, - `protocol/json_rpc` 3, - `media/revision` 2, `media/note` 2, `mcp` 2, `mcp/cas` 1), plus this - module's own proofs. The repo sets `checkJs`, so a missed site is - `TS2554: Expected 0 arguments, but got 1` rather than a silent - absence-only schema — verified — but the schemas are wrong until migrated. -- [ ] `../data/module.f.mjs`: give `thunkUnion` an explicit `'option'` case - returning `{ unit: absentBit }`. Its switch ends in - `default: { return orUnion(state, t, rest) }`, and a nullary tag has an - empty `rest`, so without the case `toData(option)` is the empty union — - `never` — and `toData(or(option, number))` silently loses the bit. Every - data-side rule below then operates on a bit nothing ever sets. The tag - enumerations are independent: adding `option` to `Tag0` does not reach this - switch. Pin `toData(option)` and `toData(or(option, number))`. -- [ ] `../data/types.ts:70-73` states the public contract that stage 2 breaks: - "`unit` is a bitset over the four singleton values; bit `1 << i` stands for - `unitList[i]` … (`['null', 'undefined', 'false', 'true']`)". A fifth bit - maps to no `unitList` entry, and the form is *serializable*, so a consumer - decoding stored data by that sentence cannot read bit 16 at all. Document - the absence bit there and in `unitList`'s own JSDoc, saying why it is not a - `unitList` member — it is not a DJS value. -- [ ] `../data/module.f.mjs`: `absentBit` as the fifth unit bit — `unitBit` stays - value-keyed, since the new bit has no JS value to key on — and `trimPrefix` - and `objectMayOmit` switch to it. `allUnits` stays the four DJS units; - `or(option, unknown)` is the declared-member top. -- [ ] Normalize the absent bit out of an **inline** `rest` on both kinds; pin - `array(or(option, number))` → `array(number)` and the top-level spelling. -- [ ] `objectPresentSet` strips the absent bit too — it answers "what may be - **present** at this key", while `objectMayOmit` answers whether the key may - be missing, and `objectSetSubset` calls both. Left unstripped, the closed - `{ a: or(option, number) }` tests `(Absent | number) ⊆ number` against - `record(number)` and answers false, though its only values are `{}` and - `{ a: number }`, both of which `record(number)` admits — so coverage - collapse stops firing and equivalent unions stay structurally unequal. - `../data/proof.f.mjs:616` is the row that **flips**: - `assert(!subset(toData({ a: option(number) }))(toData(record(number))))`, - correct today because `option(number)` admits `{ a: undefined }`, wrong - once it does not. Its comment — "the open-struct spelling, which was sound - before, still is" — has to change with it. -- [ ] Split the **array** position test the same way, which the struct strip - above does not reach. `arraySetSubset` (`../data/module.f.mjs:425-436`) - hands each left position straight to `nodeSubset` — - `p.prefix.every((el, i) => le(el, qAt(i)))` — so the absent bit is compared - as an ordinary member and closed `[or(option, number)]` ⊆ `array(number)` - answers false, though the left's only values are `new Array(1)` and - `[number]` and `array(number)` admits both (it walks own entries, so it - accepts a hole). Give the position the two questions the object kind - already asks: compare the **absence-stripped** left set against `qAt(i)`, - and separately require that the right admits absence at `i` when the left - does — which it does when `i >= q.prefix.length` (a hole there is no entry) - or when `q.prefix[i]` carries the bit. The left's own `rest` needs neither, - since a rest carries no absent bit after normalization. Pin - `[or(option, number)]` ⊆ `array(number)` as **true** and - `[or(option, number)]` ⊆ `rest([number], never)` as **false**, the pair - that tells the two halves apart. -- [ ] Restate `arraySetSubset`'s doc comment with it. Its "the shortest needs no - test of its own" argument is spelled in terms of `undefined` membership — - "otherwise `undefined` would be a member of `p.prefix[i]` and not of - `q.prefix[i]`" — and after stage 2 the property it needs is the absent bit, - not `undefined`. The absence-implication check above *is* that argument - made explicit, so the comment should point at it rather than restate the - old reason. -- [ ] Leave a **referenced** `rest` unstripped, and have `subset` **resolve** it - rather than mask the bit — masking is unsound where the reference's present - part is empty (see above), so there is no context in which the mask is the - rule. Expect one-way inclusion there, not mutual: `rest(c, X')` ⊆ - `rest(c, X)` when `X` is absence-only, since the stripped form bounds the - length and the syntactic one does not. Pin `X = or(option, array(X))` used - as a rest for the non-empty case, the absence-only cycle for the empty one, - and add both to `../data/README.md`'s list of accepted structural - incompleteness. -- [ ] The same exemption covers a referenced **trailing position**, which the - redesigned `trimPrefix` reaches independently: for mutually recursive - `X`/`Y` where `X` normalizes to `or(option, number)`, - `toData(rest([X], number))` stores the prefix as `"X"`, and neither - `trimPrefix` nor `arraySet` takes a rule set to resolve it with (verified — - both are `(prefix, rest) => …`). So a referenced trailing position is left - untrimmed by the same rule that leaves a referenced rest alone, and - `rest([X], number)` stays structurally distinct from `array(number)`. Pin - it beside the rest case rather than leaving it to be discovered. -- [ ] Redesign `trimPrefix` around the trailing **declared position** — drop it - when it admits absence and its absence-stripped set equals the rest — and - pin `rest([or(option, number)], number)` as `array(number)`. A bit test on - the rest is dead once rests carry no absent bit. -- [ ] Except when **both** the stripped position and the rest are empty. A bare - `[option]` is closed, so its rest is `never` and its sole position strips - to `never` too — the rule above would drop the position and normalize it to - `[]`. Those are different sets: `[option]` accepts `new Array(1)` (index 0 - absent, length within the declared prefix) and `[]` rejects that length, so - the trim would make the data form disagree with the thunk readers and have - `equal`/`cmp` identify two array sets that differ. Pin `[option]` against - `new Array(1)` and `[]`. -- [ ] Make `isTop` position-aware: `or(option, unknown)` for a declared member, - `unknown` for a `rest`. Keep `objectSet`'s `r === undefined` guard, and pin - `{ a: or(option, unknown) }` (closed) as objects with at most the key `a`. -- [ ] Absence is decided by the **container loop, before dispatch** — it cannot - be decided by the recursive reader, which is handed only the value read. - `constContainerValidate`/`constContainerParse` call - `validate(v)(getItem(value, k))`, so an absent key arrives as plain - `undefined`; with the `option` handler rejecting normally (below), both - branches of `or(option, number)` would reject `{}`. So `common` gains an - `admitsAbsence(schema)` predicate and each container loop asks it first: - a member whose key or index is not an own one succeeds iff its schema - admits absence, and only a present member is dispatched. The predicate - **traverses nested unions**, with a visited set for cycles: schema-form - `or` does no - flattening (its own doc says so), so `or(or(option, number), string)` has - no `option` among its direct members while admitting absence, and a - shallow test would reject `{}`. It descends `or` nodes and the thunks they - hold, stops at any other tag, and carries the visited thunks to terminate - on a recursive `X = or(option, X)`. The data form needs none of this - *traversal* — `toData` has already flattened, which is why `objectMayOmit` - can read one bit — so the thunk side pays for being the reader that does no - preprocessing. -- [ ] The data **reader** still needs its own absence path, which `objectMayOmit` - does not supply: that function is used only by `subset` - (`data/module.f.mjs:475`, called once at `:500`), while - `arraySetValidate` and `objectSetValidate` dispatch each declared position - as `nodeValidate(rules)(n)(value[Number(k)])` — the value read, with no - ownership, exactly like the thunk loops. Give both container loops the same - before-dispatch test, or the data reader rejects `{}` and sparse tuples - that both thunk readers accept, and `validate/proof.f.mjs`'s three-reader - table breaks. -- [ ] A member absent by own-key but supplied by the **prototype** must still - satisfy the member's present part, or `validate`'s success type goes - unsound — and this is a regression the own-key rule introduces, not a - corner it inherits. Measured today: - `validate({ a: option(number) })(Object.create({ a: 'bad' }))` is an - **error**, because `getItem` reads through the prototype and checks - `'bad'` against `number`. Under the own-key rule alone it becomes `ok`, - and the returned object — `validate` hands back what it was given, so it - cannot sanitize by rebuilding as `parse` does — reads `.a` as `'bad'` - while `Ts` promises `number | undefined`. So the absence test rejects when - `Object.hasOwn` is false, HasProperty is true, and the inherited value is - outside the member's present set. Proof: that exact value against - `{ a: option(number) }` and `{ a: option(string) }`, which today answer - error and ok respectively. -- [ ] Readers: a declared member is absent when its key or index is not an own - one. `parse` omits an absent member rather than materializing `undefined`: - the struct kind drops the key, and the array kind **preserves indices** — - a trailing absent run shortens the result, an interior one stays a hole. - `arrayRebuild` is `entries => entries.map(([, v]) => v)`, so omitting an - absent entry would rebuild `[, 3]` as `[3]`, shifting `3` to index 0 and - returning a value that fails its own schema. -- [ ] The array kind's rebuild is **slice, then map**, and mapping alone is not - enough: `.map` preserves length, so `[1, or(option, number)]` against - `[1, ,]` would rebuild a sparse two-element array and serialize back to - `[1,null]` — the very defect stage 2 removes. Truncate to the last present - declared position first, then map the *parsed* element results over the - truncated array, so a trailing absent run shortens the result while an - interior hole survives. FunctionalScript's rules leave no other route: - `Array.from({ length }, …)` yields a dense array and there is no index - assignment or mutation. Verified: `[1, , 3].slice(0, 3)` keeps the hole at - 1, `[1, , ,].slice(0, 1)` is `[1]` with length 1, and a `.map` after either - keeps the hole. -- [ ] Drive that rebuild by the **own**-index test the check uses, not by - `slice`/`map` alone. Both use HasProperty, so an index the value only - *inherits* is materialized as an own property of the result — measured, - with `Array.prototype[0]` defined, `[, 3].slice(0, 2)` and - `[, 3].map(v => v)` both give `["PROTO", 3]` with - `Object.hasOwn(result, 0)` true. That contradicts this stage's own rule - and can rebuild a value the schema rejects. The same measurement settles - the test to use: `0 in [, 3]` is **true** once the prototype supplies the - index, so it is `Object.hasOwn`, never `in`, while `Object.entries` stays - own-only. Reachable only from plain JavaScript — FunctionalScript has - neither mutation nor prototype writes — so it constrains the construction - rather than rejecting the slice-then-map shape, on the same footing as the - overridden-`Symbol.iterator` case `../common/module.f.mjs` documents and - the beyond-`length` caveat `../README.md` states. -- [ ] State the bound rather than implying a construction that does not exist: - against a prototype-supplied index, **no** immutable builder can produce - the hole. An `Object.hasOwn` guard *inside* the callback does not help — - `.map` creates the own output element whatever the callback returns - (measured: the guarded map still gives `hasOwn(result, 0)` true) — and a - fresh `Array(n)` inherits the index too, so it is no cleaner source. The - escapes are `Object.assign` or index assignment, both mutation, both - forbidden. So `parse` materializes the inherited value in that case, and - `validate` is untouched because it returns the value it was given. Pin it - as a bounded divergence, unreachable from FunctionalScript, rather than - leaving the task reading as though a sanitized source were available. -- [ ] `../ts/module.f.mjs`, the **runtime printer**: `arraySetToTs` and - `objectSetToTs` decide optionality through their own `admitsUndefined` - (`:159`, `:184`, `:217`), so without this `{ a: or(option, number) }` and - `[1, or(option, number)]` print required members while `Ts<>` and both - readers treat them as optional — and the two-renderer pin below could not - hold. Move them to the absent bit and update `../ts/proof.f.mjs`. -- [ ] `../../media/json/schema/module.f.mjs`: move `admitsUndefined` (and so - `required`/`minItems`) to the absent bit, leave `stripUndefined` on - `undefined`, and update `./proof.f.mjs` — a third renderer over the data - form, and the one whose output is wrong rather than merely imprecise if it - is missed. -- [ ] `../ts/types.ts`: "strip the absent bit" is data-form vocabulary and does - not apply here — `OptionalFields` keys on `undefined extends Ts`, so - the type level sees members already reduced through `Ts`. Map `option` to a - **branded uninhabited marker** (`Absent`, a `unique symbol` brand). Not - `never`, which vanishes in a union and takes the information with it; not - `undefined`, which would make `or(undefined, number)` optional too and - conflate the pair this stage exists to separate. Then `OptionalFields` keys - on **`_AdmitsAbsence`, a structural predicate over the schema**, and - renders `Exclude<_TsRaw, Absent>` for the value. The test cannot be - a subtype query against the rendered union — neither `Absent extends Ts<…>` - (which excludes the marker itself, so it is false for every member) nor - `Absent extends _TsRaw`, which fails in the other direction at - `unknown`: `_TsRaw` is `unknown`, `Absent extends unknown` - is true for any `Absent`, and `unknown | Absent` is `unknown` — all three - measured. So the closed `{ a: unknown }`, which stage 2 *rejects* `{}` for, - would render `a?:`, and would be indistinguishable from - `{ a: or(option, unknown) }`, which the runtime printer does tell apart. - The marker is absorbed by the top and cannot be recovered from the union - it lands in; only the schema still carries the fact. `_AdmitsAbsence` - recurses through `or` — which does no flattening, so - `or(or(option, number), string)` needs the recursion, the same reason the - runtime `admitsAbsence` is not a one-level scan. Pin `{ a: unknown }` - required and `{ a: or(option, unknown) }` optional with - `Assert>`, the pair that fails under either subtype query; - `ArrayTs`/`RecordTs` `Exclude` it from their element type, so - `Ts` stays `readonly number[]` — the type-level - counterpart of "a rest never sees it" — except that `ArrayTs` emits - `readonly []` when the exclusion leaves `never`, since `readonly never[]` - is *not* the empty array: `readonly never[] = new Array(1)` - type-checks and its `.length` is `number`, while `readonly []` rejects it - ("Target allows only 0 element(s)") and its `.length` is `0` — both - measured. `array(option)` is the empty array (see "Length still bounds a - closed array" above), so without the case the compile-time renderer is - wider than the runtime one on the schema that section exists to settle. - Pin `Ts`. `RecordTs` needs no counterpart: - `Record` already admits `{}` and nothing else, because an - object type carries no length to disagree about. Split the transformer to keep the - marker internal: `_TsRaw` preserves it for the container mappings to - read, and the public `Ts` is `Exclude<_TsRaw, Absent>`. Excluding it - only in the reader results would leave `Ts` as - `Absent | number` for direct consumers and for `Check`, a union no runtime - value can inhabit and one the runtime printer has no way to spell. With the - split, `Result` needs no exclusion of its own — "observable only at a - container position" falls out of the public entry. -- [ ] Lower the marker **per position**, not with one outer `Exclude`. A tuple - type is not a union, so `Exclude<_TsRaw, Absent>` never reaches inside - it: `_TsRaw<[or(option, number), 3]>` keeps `Absent | number` at index 0 - and the public `Ts` would hand a consumer the uninhabitable marker. Each - position lowers it for itself — a struct key and a trailing tuple position - that admit absence render optional with `Absent` excluded; an **interior** - tuple position replaces `Absent` with `undefined`, which is what reading a - hole gives and the only spelling TypeScript allows before a required - element; an array/record element excludes it. The runtime printer needs the - same conversion for the interior case: switching `admitsUndefined` to the - absent bit alone makes `arraySetToTs` print `number` where it owes - `number | undefined`. Update the - `_tupleOption`/`_tupleInteriorOption` pins, and `optionalTuplePosition` / - `allOptionalTuple` in `../ts/proof.f.mjs`, which print the `undefined|` - this stage removes. -- [ ] Say which vocabulary a **`Phantom` annotation** is written in. `Ts`'s - phantom branch (`T extends { readonly [phantomKey]?: infer O } ? Exclude`) returns the annotation *before* the thunk walk — that is what - spares recursive schemas TS2589 — so a phantom-wrapped schema whose root - admits absence would otherwise render required. Moving the walk ahead of - the fast path would bring TS2589 back, so instead the annotation is - `_TsRaw`-shaped: it carries `Absent` when the schema's root admits absence, - the branch keeps `Exclude` (which strips the optional-field - artifact, not the marker), and the public `Ts` strips `Absent` as it does - everywhere else. Enforcing it needs a **new assert**: the existing pair - compares through public `Ts`, which strips `Absent` from both sides, so - `Check3` passes even when `_TsRaw` - is `Absent | number` and the annotation says `number` — and the member then - renders required. Add a `_TsRaw`-level check (`CheckRaw = Equal>`) for the raw half, since that is the only half with teeth - here — and update the **contract that mandates the weak pair**: - `../../types/phantom/types.ts:26-38` tells every `Phantom` user to guard with - two `Check`s "or `Check3`, which pairs the two into one assert", both of - which route through public `Ts`. A caller following that documentation - after stage 2 silently renders a wrapped optional member required. The - JSDoc has to require the raw assert and say how a caller spells it, which - means `Absent` and `CheckRaw` become part of the exported surface rather - than internal names. Runtime is untouched: a `Phantom` has no runtime representation, so - `admitsAbsence` - walks the same thunk either way. Proof: an optional `Phantom`-wrapped - member. -- [ ] Pin `Ts<[1, or(option, number)]>` as `readonly[1,number?]` from both - renderers, with the four assignability rows above — the exactness claim is - the point of the two stages and should fail loudly if it regresses. -- [ ] Proofs: `{}` separated from `{ a: undefined }`; `[, 3]` accepted and - `[undefined, 3]` rejected for `[or(option, number), 3]`; the JSON - round-trip case from - [parse-omits-undefined-members](./parse-omits-undefined-members.md); - `{ a: option }` as a negative field. Delete the pin this abolishes: - `../validate/proof.f.mjs:376`, `every(rtti)(assertOk)([undefined, 5])` - commented "the same value, spelled densely", run against - `[option(string), number]` through all three readers and through - `open(t)` — under this stage that value is present-`undefined` at position 0 - and is no longer the same value as `[, 5]`. Assert on the **built value**, not - only acceptance: `parse([or(option, number), 3])([, 3])` has no own index - `0` and carries `3` at index `1`. -- [ ] Delete [parse-omits-undefined-members](./parse-omits-undefined-members.md); - restate the absence rule in `../README.md` and `../data/README.md` as the - absent bit rather than as `undefined`. -- [ ] Changelog: **BREAKING CHANGES:** `option` is a nullary schema denoting - absence. `option(t)` becomes `or(option, t)`, which also **narrows**: a - schema that accepted a present `undefined` at that member no longer does. - `parse` no longer materializes an absent member. - -## Related - -- [parse-omits-undefined-members](./parse-omits-undefined-members.md) — the - construction ambiguity and the array kind's JSON defect; stage 2 dissolves both - and deletes the file. -- [schema-walk-own-indices](./schema-walk-own-indices.md) — how a tuple *schema* - is walked; stage 2 settles the same question for the *value*, so land them in a - consistent order. -- [`../data/README.md`](../data/README.md) — the declared-key/undeclared-entry - asymmetry, which is this stage's premise, and the two kinds' opposite identity - elements, which stage 1's mapping already turned over. -- [`../ts/types.ts`](../ts/types.ts) — `TupleTs`'s derivation and - `OptionalFields`, the two renderings this stage changes; `RestTs` is stage 1's - half of the same rendering. -- [excluded-string-values](./excluded-string-values.md) — the other proposed `Type` - ADT extension, and the bar it sets: a data-form mapping worked out end to end - before code. -- [#1719](https://github.com/functionalscript/functionalscript/pull/1719) — - **collides with both stages.** The epic makes RTTI the single source of truth - for the type system and works its examples in the eDSL as it stands today — - `close([t, t])` and `option(key)` — every one of which this proposal - respells. Its stage list is unaffected; its worked examples are not. diff --git a/fjs/rtti/todo/parse-omits-undefined-members.md b/fjs/rtti/todo/parse-omits-undefined-members.md deleted file mode 100644 index 7d2533f3c..000000000 --- a/fjs/rtti/todo/parse-omits-undefined-members.md +++ /dev/null @@ -1,168 +0,0 @@ -# `parse` builds the members it should omit - -**Priority:** P2 — the array kind's JSON round-trip is a data defect, not just -a canonicality gap -**Status:** open — both halves are now unblocked; what remains is the change -itself (see [The type-level obstacle is gone](#the-type-level-obstacle-is-gone)) - -## Problem - -RTTI has one rule for absence, stated in [`../README.md`](../README.md) for -both container kinds: an absent member reads as `undefined`, so **a member is -required exactly when its set excludes `undefined`**. Absence *is* `undefined` -— the two are one thing, which is why `[number, option(string)]` accepts -`[42]` and `{ a: number, b: option(string) }` accepts `{ a: 1 }`. - -`parse` reads that rule on the way in and then contradicts it on the way out. -It materializes the member it just decided was absent (verified at `d24983a`): - -| schema | value | `parse` builds | -| --- | --- | --- | -| `[number, option(string)]` | `[42]` | `[42, undefined]` | -| `{ a: number, b: option(string) }` | `{ a: 1 }` | `{ a: 1, b: undefined }` | -| `rest([number, option(string)], string)` | `[1]` | `[1, undefined]` | - -Both spellings denote the same RTTI value, and `parse` picks the one that -spells absence as a present member. `validate` has nothing to pick — it returns -what it was handed — so the disagreement is `parse`'s alone. - -### It breaks a JSON round-trip on the array kind - -JSON has no `undefined`, and an array element that holds one serializes as -`null`. So the value `parse` builds does not survive the format it was most -likely read from: - -```js -const s = [number, option(string)] -parse(s)([42]) // ['ok', [42, undefined]] -JSON.stringify([42, undefined]) // '[42,null]' -parse(s)([42, null]) // ['error', { path: ['1'], message: 'no match' }] -``` - -The omitted spelling round-trips: `'[42]'` re-parses to `['ok', [42, undefined]]`. - -The struct kind happens to work, because `JSON.stringify` already drops a key -whose value is `undefined` — it applies the very rule this issue asks `parse` -to apply. So today the two kinds disagree about their own output in a way -nothing in the module states, and the kind that disagrees loses data. - -The same follows for any format without `undefined` (CBOR, and the canonical -byte-level forms `../../cas` hashes): two values that are equal under RTTI -serialize differently, so they address differently. - -## Proposal - -**Omit, don't materialize.** A parsed member whose value is `undefined` is not -written into the result. - -- **Struct kind** — drop the key. `parse({ a: number, b: option(string) })({ a: 1 })` - builds `{ a: 1 }`, and `'b' in result` is false. -- **Tuple kind** — drop the **trailing run** only. An interior position cannot - be dropped without shifting the positions after it, so an interior - `undefined` stays an explicit element: `[number, option(string), number]` - against `[1, undefined, 3]` builds `[1, undefined, 3]` unchanged, while - `[number, bigint, option(string), option(null)]` against `[2, 4n]` builds - `[2, 4n]`. -- The closed forms follow, building their declared members exactly as the open - ones do. - -Where it lands: `arrayRebuild` and `recordRebuild` in -[`../parse/module.f.mjs`](../parse/module.f.mjs) are the two rebuild functions -all the container factories share, so each kind changes in one place. - -Two cases the implementation must answer rather than discover, both settled by -the same rule — `undefined` is absence, whatever put it there: - -- A member whose *input* is explicitly `undefined` (`{ a: 1, b: undefined }`) - and a member declared with a set that is only `undefined` (`{ a: undefined }`, - or a `{ a: unknown }` whose `a` is `undefined`) are dropped too. -- `array`/`record` share those rebuilds, so the rule reaches them unless it is - gated per kind. Uniform is the position this issue takes — `ArrayTs` is an - unbounded `ReadonlyArray` and `RecordTs`'s keys are already optional, so - neither costs anything at the type level — but it is a decision, not a - side effect to leave unstated. - -### The type-level obstacle is gone - -Both halves are now free at the type level. - -The struct half always was. `StructTs` renders an admits-`undefined` key as -optional (`OptionalFields` in [`../ts/types.ts`](../ts/types.ts)) and keeps -`undefined` in the value type, so `{ a: 1 }` and `{ a: 1, b: undefined }` are -both assignable under this repo's `exactOptionalPropertyTypes: true`. - -The tuple half was the blocker: `TupleTs` mapped a schema tuple to a -**required-length** tuple, so a dropped result would not have inhabited its own -declared type — - -``` -error TS2322: Type '[number]' is not assignable to type 'readonly [number, string | undefined]'. - Source has 1 element(s) but target requires 2. -``` - -`TupleTs` now renders the trailing admits-`undefined` positions optional, so -`Ts<[number, bigint, option(boolean), option(string)]>` is -`readonly[number, bigint, (boolean|undefined)?, (string|undefined)?]` and both -spellings — `[1, 2n]` and `[1, 2n, undefined, undefined]` — inhabit it. The -derivation and the three errors it had to defeat are in `TupleTs`'s doc -comment; `_tupleOption` and `_tupleInteriorOption` pin the rendering. - -That was the one thing this issue needed decided before it could proceed. What -is left is the change itself, plus one question it does not settle: -`array`/`record` share `parse`'s rebuilds, so the rule reaches them unless it -is gated per kind (this issue says uniform — `ArrayTs` is an unbounded -`ReadonlyArray` and `RecordTs`'s keys are already optional, so neither costs -anything at the type level). - -Only the *trailing* run renders optional, because TypeScript forbids a required -element after an optional one. That is a spelling limit, not a narrower set: an -interior position admitting `undefined` may still be absent at runtime, which -`../validate/proof.f.mjs`'s `interiorOptionBeforeRequired` pins — -`[option(string), number]` accepts `[, 5]`, the required position after the -hole being present. `optionalPositions` cannot say it: its hole falls *inside* -the trailing omittable run, so no position the renderer marks required follows -it. (Not that truncation explains that row — truncation would predict its -rejection, and all three readers accept it, which is what that proof's own -comment records.) - -## Tasks - -- [x] Decide the tuple half — `TupleTs` renders trailing omittable positions - optional, so neither kind is blocked at the type level any more. -- [ ] Omit in `arrayRebuild`/`recordRebuild`: drop the key on the struct kind, - the trailing `undefined` run on the array kind; open and closed alike. -- [ ] Settle whether `array`/`record` follow (this issue says yes). -- [ ] `../README.md`: the two-readers table row "absent optional member" - (`parse`: "present as `undefined`") and the openness row - `[number, option(string)] | [42] | [42, undefined]`. -- [ ] The proofs that pin the current spelling: - `../parse/proof.f.mjs`'s `shortArrayFillsAnOptionalPosition` and the - closed `shortArray`, and `../validate/proof.f.mjs`'s - `absentOptionalStaysAbsent`, whose contrast assertion is - `'b' in unwrap(parse(schema)(input))`. -- [ ] Add the JSON round-trip above as a proof case, so the defect cannot - return unnoticed. -- [ ] Changelog: **BREAKING** — `parse` no longer materializes an absent - optional member. - -## Related - -- [`../parse/module.f.mjs`](../parse/module.f.mjs) — `arrayRebuild` / - `recordRebuild`, the two rebuild points. -- [`../README.md`](../README.md) — "Structs and tuples are open" states the - absence rule this issue applies to construction, and "The two schema-form - readers" tabulates the row that changes. -- [`../ts/types.ts`](../ts/types.ts) — `TupleTs` (the optional-position - derivation, and the errors it defeats) and `OptionalFields` (the struct - half's). -- The same "a hole and a declared `undefined` are one thing" question from the - *schema* side, which this issue asks from the *value* side. It shipped as - [#1712](https://github.com/functionalscript/functionalscript/pull/1712) — - `parse` and `validate` read a tuple schema by length, so a hole in one is a - declared position whose schema is `undefined`. That settles the schema side - in favour of the reading this issue assumes, and leaves - [schema-walk-own-indices](./schema-walk-own-indices.md) as what remains of - it: whether that walk goes by own indices or by iteration. -- [PR #1708](https://github.com/functionalscript/functionalscript/pull/1708) — - added the acceptance rows for several trailing optional positions, which is - where the construction side came up. diff --git a/fjs/rtti/ts/README.md b/fjs/rtti/ts/README.md index 47c7c3db6..50826ceab 100644 --- a/fjs/rtti/ts/README.md +++ b/fjs/rtti/ts/README.md @@ -68,8 +68,8 @@ export const unknown: WithOut = unknownThunk // unknownConst is defined after `unknown` so it can reference `unknown` recursively. // The thunk defers evaluation, breaking the circular reference at runtime. const unknownConst = { - not: option(unknown), - anyOf: option(array(unknown)), + not: or(option, unknown), + anyOf: or(option, array(unknown)), // ... } as const diff --git a/fjs/rtti/ts/module.f.mjs b/fjs/rtti/ts/module.f.mjs index 1a8adc0f1..bc28a044b 100644 --- a/fjs/rtti/ts/module.f.mjs +++ b/fjs/rtti/ts/module.f.mjs @@ -22,7 +22,7 @@ import { assertNotNullish } from '../../asserts/module.f.mjs' import { reservedWords, strictModeReservedWords } from '../../js/keywords/module.f.mjs' import { at, definedEntries } from '../../types/object/module.f.mjs' import { primitive, union, printer as tsPrinter } from '../../types/ts/module.f.mjs' -import { cmp, never as bottom, toData, unitBit, unknown as top } from '../data/module.f.mjs' +import { absentBit, cmp, never as bottom, toData, unitBit, unknown as top, withoutUnits } from '../data/module.f.mjs' const nullBit = unitBit(null) const undefinedBit = unitBit(undefined) @@ -148,10 +148,14 @@ const unitToTs = bits => [ * `readonly[A,...readonly(R|undefined)[]]`. * * A position the array may simply end before prints optional — the trailing - * run whose sets admit `undefined`, which is exactly what the array may stop + * run whose sets admit **absence**, which is exactly what the array may stop * at, arrays being contiguous. It mirrors the optional key `objectSetToTs` - * prints, and keeps the union rather than stripping `undefined` from it, as - * that one does. + * prints, with the absent bit stripped from what it prints (`unionToTs` + * masks it), so `[1, or(option, number)]` prints `readonly[1,(number)?]` — + * exact under `exactOptionalPropertyTypes`. An *interior* position admitting + * absence prints `undefined | T` instead ({@link interiorToTs}): TypeScript + * forbids a required element after an optional one, and `undefined` is what + * reading a hole gives. * * The **tail** admits `undefined` on top of what the `rest` states, because a * hole past the prefix is no member: the readers check each present member @@ -170,11 +174,9 @@ const unitToTs = bits => [ * @type {(ctx: _Ctx) => (p: ArraySet) => string} */ const arraySetToTs = ctx => p => { - const required = p.prefix.findLastIndex(n => !admitsUndefined(ctx)(n)) + 1 - const items = p.prefix.map((n, i) => { - const ts = nodeToTs(ctx)(n) - return i < required ? ts : `(${ts})?` - }) + const required = p.prefix.findLastIndex(n => !admitsAbsence(ctx)(n)) + 1 + const items = p.prefix.map((n, i) => + i < required ? interiorToTs(ctx)(n) : `(${nodeToTs(ctx)(n)})?`) const { rest } = p if (rest === undefined) { return ctx.ts.tuple(items) } const restTs = nodeToTs(ctx)(rest) @@ -195,13 +197,44 @@ const resolveNode = ctx => n => typeof n === 'string' ? assertNotNullish(at(n)(ctx.rules)) : n /** - * Whether the node's value set admits `undefined` — its unit bit. + * Whether the node's value set admits `undefined` — its unit bit. Still the + * tail's question (`rest([42], string)` accepts `[42, , ]`, and index 1 + * reads `undefined`); optionality of a declared member is + * {@link admitsAbsence}'s. * * @type {(ctx: _Ctx) => (n: Node) => boolean} */ const admitsUndefined = ctx => n => ((resolveNode(ctx)(n).unit ?? 0) & undefinedBit) !== 0 +/** + * Whether the node's set admits **absence** — its absent bit, read through a + * reference if needed. What decides a declared member's optionality. + * + * @type {(ctx: _Ctx) => (n: Node) => boolean} + */ +const admitsAbsence = ctx => n => + ((resolveNode(ctx)(n).unit ?? 0) & absentBit) !== 0 + +/** + * An **interior** tuple position: one that admits absence prints + * `undefined | T` — TypeScript forbids an optional element before a required + * one, and `undefined` is what reading a hole gives — and any other prints + * as it is. An inline node converts by bit, so the `undefined` merges into + * the union's canonical order; a reference prints its identifier with + * `undefined` unioned in front. + * + * @type {(ctx: _Ctx) => (n: Node) => string} + */ +const interiorToTs = ctx => n => { + const bits = resolveNode(ctx)(n).unit ?? 0 + if ((bits & absentBit) === 0) { return nodeToTs(ctx)(n) } + if (typeof n === 'string') { + return union([primitive(undefined), nodeToTs(ctx)(n)]) + } + return unionToTs(ctx)({ ...n, unit: (bits & ~absentBit) | undefinedBit }) +} + /** * Whether the node's value set is empty. * @@ -213,9 +246,11 @@ const isNever = ctx => n => cmp([{}, resolveNode(ctx)(n)])([{}, bottom]) === 0 const dedup = list => list.filter((s, i) => list.indexOf(s) === i) /** - * A struct prints its fields — a key whose value set admits `undefined` may - * also be absent, so it prints optional, mirroring `Ts<>` — and a record - * prints its value type. A props-with-rest set combines them with an + * A struct prints its fields — a key whose value set admits **absence** + * prints optional, with the absent bit stripped from what it prints + * (`unionToTs` masks it), mirroring `Ts<>`: `or(option, number)` is + * `readonly a?: number`, and `or(number, undefined)` is the required + * `readonly a: undefined|number` — and a record prints its value type. A props-with-rest set combines them with an * intersection; TypeScript requires an index signature to cover the * declared keys too, so the index type widens to the union of the rest and * the declared value types — the closest expressible supertype. @@ -232,7 +267,7 @@ const objectSetToTs = ctx => p => { /** @type {readonly StructField[]} */ const fields = definedEntries(p.props).map(([k, v]) => { const ts = nodeToTs(ctx)(v) - return admitsUndefined(ctx)(v) ? [k, ts, true] : [k, ts] + return admitsAbsence(ctx)(v) ? [k, ts, true] : [k, ts] }) const { rest } = p if (rest === undefined || isNever(ctx)(rest)) { return ctx.ts.struct(fields) } @@ -243,8 +278,19 @@ const objectSetToTs = ctx => p => { /** @type {(u: UnionSet) => boolean} */ const isTop = u => cmp([{}, u])([{}, top]) === 0 -/** @type {(ctx: _Ctx) => (u: UnionSet) => string} */ -const unionToTs = ctx => u => { +/** + * The absent bit is **masked** before printing: absence is not a value, so + * it contributes no union member — `or(option, number)` prints `number`, + * `option` alone prints `never`, and `or(option, unknown)` prints `unknown` + * — which is the public `Ts<>` of the same node. Where the bit changes what + * a position *prints*, the position asks first: an optional key or trailing + * position strips it by printing through this, and an interior tuple + * position converts it to `undefined` (`interiorToTs`). + * + * @type {(ctx: _Ctx) => (u: UnionSet) => string} + */ +const unionToTs = ctx => u0 => { + const u = withoutUnits(absentBit)(u0) if (isTop(u)) { return 'unknown' } return union([ ...unitToTs(u.unit ?? 0), @@ -295,9 +341,12 @@ export const dataToTs = mut => ([rules, entry]) => { * * Mirrors the compile-time `Ts` mapped type at runtime, in the data * form's canonical order — union members follow its kind order (e.g. - * `option(number)` prints `'undefined|number'`) and structurally different - * but equivalent schemas print identically (`or(true, false)` prints - * `'boolean'`). Pass `true` to emit mutable (non-`readonly`) types. + * `or(number, undefined)` prints `'undefined|number'`) and structurally + * different but equivalent schemas print identically (`or(true, false)` + * prints `'boolean'`). Absence is not a value, so `or(option, number)` + * prints `'number'` — the public `Ts<>` of the same schema; where it lands + * on a declared member, the member prints optional instead. Pass `true` to + * emit mutable (non-`readonly`) types. * * A recursive schema prints as the identifier of its definition — use * {@link dataToTs} to also obtain the `type = ` diff --git a/fjs/rtti/ts/proof.f.mjs b/fjs/rtti/ts/proof.f.mjs index a23346d87..a531271fe 100644 --- a/fjs/rtti/ts/proof.f.mjs +++ b/fjs/rtti/ts/proof.f.mjs @@ -7,7 +7,7 @@ */ import { assertEq } from '../../asserts/module.f.mjs' -import { toData, unitBit } from '../data/module.f.mjs' +import { absentBit, toData, unitBit } from '../data/module.f.mjs' import { boolean, number, string, bigint, unknown, array, open, record, or, option, rest, never } from '../module.f.mjs' import { dataToTs, printer } from './module.f.mjs' @@ -24,9 +24,10 @@ import { dataToTs, printer } from './module.f.mjs' // schema `readonly []`, and nothing else here would have caught it. /** @typedef {Assert, readonly (number | bigint)[]>>} _NonFixedLength */ -// `option(t)` is `or(t, undefined)`; these are the schema types it produces. -/** @typedef {Or} _OptionBoolean */ -/** @typedef {Or} _OptionString */ +// `or(option, t)` — a member that may be absent; these are the schema types +// the spelling produces. +/** @typedef {Or} _OptionBoolean */ +/** @typedef {Or} _OptionString */ // A variadic tuple is the shape the `length` guard exists for, and the only // one: its peel *succeeds*, binding the unknown-length prefix to `I`, so @@ -67,14 +68,15 @@ import { dataToTs, printer } from './module.f.mjs' // statement about which values the union admits. /** @typedef {readonly [typeof number, _OptionString]} _BranchA */ /** @typedef {readonly [typeof string, _OptionBoolean, _OptionNumber]} _BranchB */ -/** @typedef {Or} _OptionNumber */ +/** @typedef {Or} _OptionNumber */ /** @typedef {Assert ? false : true>} _UnionKeepsBranchCorrelation */ /** @typedef {Assert ? true : false>} _UnionAdmitsItsOwnBranches */ -/** @typedef {Assert, readonly [number, bigint, (boolean | undefined)?, (string | undefined)?]>>} _OptionalTail */ +/** @typedef {Assert, readonly [number, bigint, boolean?, string?]>>} _OptionalTail */ // Only the *trailing* run: TypeScript forbids a required element after an -// optional one, so an interior position that admits `undefined` stays required. +// optional one, so an interior position that admits absence stays required, +// with `undefined` — what reading a hole gives — in its type. /** @typedef {Assert, readonly [string | undefined, number]>>} _InteriorStaysRequired */ const toTs = printer() @@ -170,20 +172,36 @@ export const proof = { // `Ts<>` gives it, which is what makes that cast sound emptyTuple: () => eq([], 'readonly[]'), tuple: () => eq([12, true], 'readonly[12,true]'), - // a position the array may end before prints optional, as the key it - // is the array counterpart of does + // a position the array may end before prints optional, with the + // absent bit stripped from what it prints — exact under + // `exactOptionalPropertyTypes`, as the key it is the array + // counterpart of is optionalTuplePosition: () => eq( - [number, option(string)], - 'readonly[number,(undefined|string)?]', + [number, or(option, string)], + 'readonly[number,(string)?]', ), allOptionalTuple: () => eq( - [option(number)], - 'readonly[(undefined|number)?]', + [or(option, number)], + 'readonly[(number)?]', ), - // a declared `unknown` key is a key the container has, so it is not - // dropped the way an `open` struct's is + // an interior position admitting absence prints `undefined|T` — what + // reading a hole gives, and the only spelling TypeScript allows + // before a required element — while a present-`undefined` member + // needs no conversion + interiorOption: () => eq( + [or(option, string), number], + 'readonly[undefined|string,number]', + ), + interiorUndefined: () => eq( + [or(string, undefined), number], + 'readonly[undefined|string,number]', + ), + // a declared `unknown` key is a key the container has — and one that + // must be *present*, `unknown` excluding absence — so it prints + // required; "anything, or nothing" is `or(option, unknown)` emptyStruct: () => eq({}, '{}'), - unknownProp: () => eq({ a: unknown }, '{readonly"a"?:unknown}'), + unknownProp: () => eq({ a: unknown }, '{readonly"a":unknown}'), + unknownOrAbsentProp: () => eq({ a: or(option, unknown) }, '{readonly"a"?:unknown}'), struct: () => eq( { a: number, b: string }, '{readonly"a":number,readonly"b":string}', @@ -217,14 +235,26 @@ export const proof = { // a tuple has a rest element, so this printer says exactly what the schema // says — `Ts<>` renders the same tail, for the same reason. open: { - // an unconstrained tuple, or struct, is the whole kind + // an unconstrained tuple, or struct, is the whole kind — a position + // is unconstrained when it may hold anything *or nothing*, while a + // plain `unknown` position requires presence and stays emptyTuple: () => eq(open([]), 'readonly(unknown)[]'), - unconstrainedTuple: () => eq(open([unknown]), 'readonly(unknown)[]'), + unconstrainedTuple: () => eq(open([or(option, unknown)]), 'readonly(unknown)[]'), + requiredUnknownTuple: () => eq( + open([unknown]), + 'readonly[unknown,...readonly(unknown)[]]', + ), tuple: () => eq(open([12, true]), 'readonly[12,true,...readonly(unknown)[]]'), emptyStruct: () => eq(open({}), '{readonly[k in string]?:unknown}'), struct: () => eq(open({ a: number }), '{readonly"a":number}'), - // an unconstrained key *is* dropped once the container is open - unknownProp: () => eq(open({ a: unknown }), '{readonly[k in string]?:unknown}'), + // the declared-member top — anything, or nothing — *is* dropped once + // the container is open, while a plain `unknown` key requires + // presence and survives + unknownProp: () => eq(open({ a: unknown }), '{readonly"a":unknown}'), + unknownOrAbsentProp: () => eq( + open({ a: or(option, unknown) }), + '{readonly[k in string]?:unknown}', + ), // a stated rest prints as the rest element / index signature it is. // The tail admits `undefined` because a hole past the prefix is no // member, so a reader skips it and the index reads `undefined`. @@ -251,8 +281,13 @@ export const proof = { // an array with no admissible element is the empty array, and nothing // past a prefix is what prints as an exact-length tuple arrayOfNever: () => eq(array(never), 'readonly[]'), - // union members follow the canonical kind order, `undefined` first - option: () => eq(option(number), 'undefined|number'), + // absence is not a value, so at the entry it prints as the rest of the + // union — the public `Ts<>` of the same schema — and alone as `never` + option: () => { + eq(or(option, number), 'number') + eq(option, 'never') + eq(or(option, unknown), 'unknown') + }, normalization: { booleanFromConsts: () => eq(or(true, false), 'boolean'), literalAbsorbed: () => eq(or(42, number), 'number'), @@ -261,11 +296,13 @@ export const proof = { canonicalIdentity: () => { assertEq(toTs(or(string, number)), toTs(or(number, string))) }, - // a key admitting `undefined` may be absent — it prints optional - optionalProp: () => eq({ x: option(string) }, '{readonly"x"?:undefined|string}'), + // a key admitting absence prints optional with the bit stripped; one + // admitting a present `undefined` prints required with it in the type + optionalProp: () => eq({ x: or(option, string) }, '{readonly"x"?:string}'), + presentUndefinedProp: () => eq({ x: or(string, undefined) }, '{readonly"x":undefined|string}'), mixedProps: () => eq( - { a: number, b: option(number) }, - '{readonly"a":number,readonly"b"?:undefined|number}'), + { a: number, b: or(option, number) }, + '{readonly"a":number,readonly"b"?:number}'), }, recursion: { selfList: () => { @@ -344,12 +381,30 @@ export const proof = { [[], '{readonly"a":string}&{readonly[k in string]?:string}']) }, optionalByReference: () => { + // the absent bit read through a reference decides optionality, + // and is masked from the rule's own definition + eqData([{ r: { unit: unitBit(null) | absentBit, number: true } }, + { object: [{ props: { p: 'r' } }] }], + [[['r', 'null|number']], '{readonly"p"?:r}']) + // `undefined` as a value no longer makes a key optional eqData([{ r: { unit: unitBit(null) | unitBit(undefined), number: true } }, { object: [{ props: { p: 'r' } }] }], - [[['r', 'null|undefined|number']], '{readonly"p"?:r}']) + [[['r', 'null|undefined|number']], '{readonly"p":r}']) eqData([{ r: { number: true } }, { object: [{ props: { p: 'r' } }] }], [[['r', 'number']], '{readonly"p":r}']) }, + interiorOptionByReference: () => { + // an interior reference carrying the bit prints its identifier + // with `undefined` unioned in front — the alias cannot be + // rewritten, so the hole's reading rides beside it + eqData([{ r: { unit: absentBit, number: true } }, + { array: [{ prefix: ['r', { number: true }] }] }], + [[['r', 'number']], 'readonly[undefined|r,number]']) + // and a trailing reference with the bit prints optional + eqData([{ r: { unit: absentBit, number: true } }, + { array: [{ prefix: [{ number: true }, 'r'] }] }], + [[['r', 'number']], 'readonly[number,(r)?]']) + }, wholeKinds: () => { eqData([{}, { array: true, object: true }], [[], 'readonly(unknown)[]|{readonly[k in string]?:unknown}']) diff --git a/fjs/rtti/ts/types.ts b/fjs/rtti/ts/types.ts index 5ed2be2a1..34d348d77 100644 --- a/fjs/rtti/ts/types.ts +++ b/fjs/rtti/ts/types.ts @@ -11,11 +11,76 @@ */ import type { And, Equal } from '../../types/ts/types.ts' -import type { Tag0, Tag1, Const, Or, Boolean as RttiBoolean, Bigint as RttiBigint, Number as RttiNumber, String as RttiString, Unknown as RttiUnknown, Struct, Tuple, Type, ConstObject } from '../types.ts' +import type { Tag0, Tag1, Const, Or, Boolean as RttiBoolean, Bigint as RttiBigint, Number as RttiNumber, String as RttiString, Unknown as RttiUnknown, Option as RttiOption, Struct, Tuple, Type, ConstObject } from '../types.ts' import type { Assert } from '../../asserts/types.ts' -import type { phantomKey } from '../../types/phantom/types.ts' +import type { Phantom, phantomKey } from '../../types/phantom/types.ts' import type { StringMap } from '../../types/object/types.ts' +declare const absentKey: unique symbol + +/** + * The type-level marker for rtti's `option` — **absence**, the member that + * is not there. A branded, uninhabitable object type rather than `never` + * (which vanishes in a union, taking the information with it) or + * `undefined` (which would make `or(undefined, number)` optional too and + * conflate the very pair `option` exists to separate). + * + * It appears only in {@link _TsRaw} results and in a `Phantom` annotation's + * raw shape; the public {@link Ts} strips it, and every container position + * lowers it for itself — a struct key or trailing tuple position renders + * optional, an interior tuple position renders `undefined` (what reading a + * hole gives), an array or record element excludes it. One caveat is + * inherent: the top absorbs it — `Absent` is assignable to `unknown`, and to + * the `Object` arm of {@link Unknown} — so no subtype query over a rendered + * type can recover it. Whether a member may be absent is therefore asked of + * the *schema*, by {@link _AdmitsAbsence}, never of the rendered union. + */ +export type Absent = { readonly [absentKey]: typeof absentKey } + +/** + * Whether the schema type admits **absence** — the type-level counterpart of + * `admitsAbsence` in `../common/module.f.mjs`, and the predicate + * {@link StructTs} and {@link TupleTs} decide optionality with. Structural + * over the schema: it recurses through `or` — which does no flattening, so + * `or(or(option, number), string)` needs the recursion — and reads a + * `Phantom` annotation's raw shape for its `Absent` member. It is *not* a + * subtype query against the rendered type: neither `Absent extends Ts<…>` + * (false for every member — `Ts` strips the marker) nor + * `Absent extends _TsRaw<…>` (true at `unknown`, whose top absorbs the + * marker) can answer it — `{ a: unknown }`, which rejects `{}`, would render + * indistinguishably from `{ a: or(option, unknown) }`, which accepts it. + */ +export type _AdmitsAbsence = + unknown extends T ? false : + true extends _AdmitsAbsence1 ? true : false + +type _AdmitsAbsence1 = + T extends { readonly [phantomKey]?: infer O } ? ([Extract] extends [never] ? false : true) : + T extends () => infer I + ? I extends readonly['option'] ? true + : I extends readonly['or', ...infer A extends readonly Type[]] ? _AdmitsAbsence1 + : false + : false + +/** + * Whether the schema type denotes the empty *value* set — nothing but + * absence: `option`, unions of nothing but it, and the empty union. The one + * consumer is {@link ArrayTs}'s empty-array case; structural for the same + * reason {@link _AdmitsAbsence} is, and additionally because testing + * `[Ts] extends [never]` would force the element type of a recursive + * array schema eagerly and never terminate. + */ +type _IsAbsentOnly = + unknown extends T ? false : + false extends _IsAbsentOnly1 ? false : true + +type _IsAbsentOnly1 = + T extends () => infer I + ? I extends readonly['option'] ? true + : I extends readonly['or', ...infer A extends readonly Type[]] ? _IsAbsentOnly1 + : false + : false + /** * The set of primitive literal types representable as rtti `Const` values. * Defined here rather than imported from `djs` to keep rtti free of djs dependencies @@ -47,13 +112,14 @@ export type Array = readonly Unknown[] /** A read-only record of {@link Unknown} values. */ export type Object = { readonly[k in string]?: Unknown } -/** Maps a `Tag0` to its TypeScript type. */ +/** Maps a `Tag0` to its TypeScript type — `option` to the raw {@link Absent} marker. */ export type Info0Ts = T extends 'boolean' ? boolean : T extends 'number' ? number : T extends 'string' ? string : T extends 'bigint' ? bigint : T extends 'unknown' ? Unknown : + T extends 'option' ? Absent : never /** Maps a `Const` schema to its TypeScript type. */ @@ -68,33 +134,53 @@ export type Info1Ts = K extends 'record' ? RecordTs : never -/** Maps an array schema `T` to `readonly Ts[]`. */ -export type ArrayTs = ReadonlyArray> +/** + * Maps an array schema `T` to `readonly Ts[]` — the element excludes + * {@link Absent}, the type-level counterpart of "a rest never sees it" — + * except that an element set with no *present* value at all is the empty + * array, `readonly []`. `readonly never[]` is not that set: + * `new Array(1)` is assignable to it and its `length` is `number`, + * while `array(option)` (and `array(or())`) accept only `[]` at runtime. + * The emptiness test is structural ({@link _IsAbsentOnly}) so a recursive + * element schema stays lazy. + */ +export type ArrayTs = + _IsAbsentOnly extends true ? readonly [] : ReadonlyArray> -/** Maps a record schema `T` to `{ readonly[K in string]?: Ts }`. */ +/** + * Maps a record schema `T` to `{ readonly[K in string]?: Ts }`. The value + * excludes {@link Absent} through `Ts`; no empty-set counterpart of + * {@link ArrayTs}'s is needed — `Record` already admits `{}` + * and nothing else, an object type carrying no length to disagree about. + */ export type RecordTs = { readonly[K in string]?: Ts } /** * Maps a tuple schema to a readonly tuple of resolved types, with the - * **trailing** positions whose sets admit `undefined` rendered optional: - * `[number, bigint, option(boolean), option(string)]` becomes - * `readonly[number, bigint, (boolean|undefined)?, (string|undefined)?]`. + * **trailing** positions whose sets admit absence rendered optional: + * `[number, bigint, or(option, boolean), or(option, string)]` becomes + * `readonly[number, bigint, boolean?, string?]`. * * That is the same rule {@link StructTs} applies per key — a member is - * required exactly when its set excludes `undefined` — so an array may stop - * at the last required position, which is what `../parse/module.f.mjs` and - * `../validate/module.f.mjs` accept. Only the trailing run: TypeScript - * forbids a required element after an optional one, so a position that admits - * `undefined` with a required one after it stays required with `undefined` in - * its type (see {@link _tupleInteriorOption}). + * required exactly when its set excludes **absence**, decided by + * {@link _AdmitsAbsence} over the schema — so an array may stop at the last + * required position, which is what `../parse/module.f.mjs` and + * `../validate/module.f.mjs` accept. Under `exactOptionalPropertyTypes` + * (which this repository sets) the optional rendering is *exact*: + * `readonly [1, number?]` rejects `[1, undefined]`, exactly as the readers + * reject a present `undefined` under `or(option, number)`. Only the trailing + * run renders optional: TypeScript forbids a required element after an + * optional one, so an *interior* position that admits absence renders + * `T | undefined` instead — `undefined` being what reading a hole gives — + * see {@link _tupleInteriorOption}. * * **Deriving this generically took three specific moves**, each defeating an * error that sank the obvious spellings — do not simplify it back: * - * - `MappedTs` resolves `Ts<>` **once per position**, and the split then walks - * the mapped tuple rather than the schema. Testing `undefined extends - * Ts` during the walk evaluates `Ts<>` twice per position and raises - * TS2589 (excessively deep). + * - `MappedTs` resolves `Ts<>` **once per position**, and the split then + * walks the schema with the structural {@link _AdmitsAbsence} while + * carrying the mapped tuple beside it. Evaluating `Ts<>` again during the + * walk raises TS2589 (excessively deep). * - `Extract<…, readonly unknown[]>` is what makes a mapped type spreadable. * Spreading it directly raises TS2574 ("a rest element type must be an array * type") — TypeScript cannot prove a mapped type over a generic `keyof T` is @@ -118,66 +204,70 @@ export type RecordTs = { readonly[K in string]?: Ts } */ type MappedTs = Extract<{ readonly[K in keyof T]: Ts }, readonly unknown[]> -type RequiredPart = - M extends readonly [...infer I extends readonly unknown[], infer L] - ? undefined extends L ? RequiredPart : M - // `M`, not `readonly []`. The peel needs a *required* last element, so - // a tuple whose last element is already optional does not match it — - // and neither does the empty tuple, where the two coincide. Both keep - // the mapping: an optional position is what this transform produces, - // so one the caller wrote is already in the target form. - // - // Keeping the whole mapping does mean a position *before* the caller's - // optional one is not optionalized even where TypeScript could spell - // it: `[N, option(B), (S)?]` renders `readonly [number, boolean | - // undefined, string?]`, not `(boolean | undefined)?`. That is what the - // homomorphic mapping has always rendered for such a schema, so this - // preserves the behaviour rather than introducing it. - : M - -type OmittablePart = - M extends readonly [...infer I extends readonly unknown[], infer L] - ? undefined extends L ? OmittablePart : Acc - : Acc - type AsOptional = Extract<{ readonly[K in keyof O]+?: O[K] }, readonly unknown[]> -export type TupleTs = - // readonly[...{ readonly[K in keyof T]: Ts }, ...readonly Unknown[]] - MappedTs extends infer M extends readonly unknown[] ? SplitTs : never - /** - * Splits one mapped tuple. `M` is naked in the first conditional on purpose: - * that distributes over a union of tuples, so each member is split and rebuilt - * whole. Splitting the union instead lets `RequiredPart` and `OmittablePart` - * distribute separately, and the spread then recombines every prefix with - * every suffix — a union of `[number, option(string)]` and - * `[string, option(boolean), option(number)]` would admit `[number, boolean]`. + * `T` is naked in the first conditional on purpose: that distributes over a + * union of tuple schemas, so each member is mapped and split whole, with its + * own prefix beside its own suffix. Splitting the mapped union instead lets + * the two halves distribute separately, and the spread then recombines every + * prefix with every suffix — a union of `[number, or(option, string)]` and + * `[string, or(option, boolean), or(option, number)]` would admit + * `[number, boolean]`. * - * Splitting a trailing run off also needs a *fixed* length. A schema array of - * non-fixed length (what `.map()` produces) and a variadic tuple - * (`[...(typeof number)[], option(string)]`) both have `length: number` and no - * last position to peel, so they keep the mapping as it is — splitting them - * would drop the element type and the prefix's shape respectively, and widen - * what `Ts` admits. - */ -type SplitTs = - M extends readonly unknown[] - ? number extends M['length'] - ? M - : RequiredPart extends infer R extends readonly unknown[] - ? OmittablePart extends infer O extends readonly unknown[] - ? readonly [...R, ...AsOptional] - : never - : never + * Splitting a trailing run off also needs a *fixed* length. A schema array + * of non-fixed length (what `.map()` produces) and a variadic tuple + * (`[...(typeof number)[], or(option, string)]`) both have `length: number` + * and no last position to peel, so they keep the mapping as it is — + * splitting them would drop the element type and the prefix's shape + * respectively, and widen what `Ts` admits. + */ +export type TupleTs = + T extends Tuple + ? MappedTs extends infer M extends readonly unknown[] + ? number extends M['length'] ? M : _SplitTs + : never : never +/** + * Peels the trailing absence-admitting run off the schema `T` and the mapped + * tuple `M` in parallel — the schema answers *whether* a position may be + * absent, the mapping supplies its rendered type — then rebuilds: the + * required part with interior absence lowered to `| undefined` + * ({@link _InteriorTs}), the peeled run optional. The peel needs a + * *required* last schema element, so a tuple whose last element is already + * optional does not match it — and neither does the empty tuple, where the + * two coincide. Both keep the mapping: an optional position is what this + * transform produces, so one the caller wrote is already in the target form. + */ +type _SplitTs = + T extends readonly [...infer TI extends readonly Type[], infer TL extends Type] + ? _AdmitsAbsence extends true + ? M extends readonly [...infer MI extends readonly unknown[], infer ML] + ? _SplitTs + : readonly [..._InteriorTs, ...AsOptional] + : readonly [..._InteriorTs, ...AsOptional] + : readonly [..._InteriorTs, ...AsOptional] + +/** + * The required part with each **interior** absence-admitting position + * lowered per position: {@link Absent} was already excluded by the mapping's + * `Ts`, and `undefined` — what reading a hole gives, and the only spelling + * TypeScript allows before a required element — is put in its place. A + * position whose schema excludes absence is carried as mapped. + */ +type _InteriorTs = + Extract<{ + readonly[K in keyof M]: + K extends keyof T ? (_AdmitsAbsence extends true ? M[K] | undefined : M[K]) : M[K] + }, readonly unknown[]> + type OptionalFields = { - readonly[K in keyof T as undefined extends Ts ? K : never]?: Ts + readonly[K in keyof T as _AdmitsAbsence extends true ? K : never]?: Ts } type RequiredFields = { - readonly[K in keyof T as undefined extends Ts ? never : K]: Ts + readonly[K in keyof T as _AdmitsAbsence extends true ? never : K]: Ts } /** @@ -240,7 +330,14 @@ type TupleRestTs = ? readonly [...M, ...ReadonlyArray | undefined>] : never -/** Maps a struct schema to a readonly object of resolved types, with optional fields for schemas that include `undefined`. */ +/** + * Maps a struct schema to a readonly object of resolved types, with a key + * rendered optional exactly when its schema admits **absence** + * ({@link _AdmitsAbsence}) — `or(option, t)` is `readonly k?: Ts`, while + * `or(t, undefined)` stays required with `undefined` in its type. Under + * `exactOptionalPropertyTypes` the two are distinct in TypeScript, so the + * rendering is exact where the old `undefined`-keyed one conflated them. + */ export type StructTs = (keyof OptionalFields extends never ? unknown : OptionalFields) & (keyof RequiredFields extends never ? unknown : RequiredFields) @@ -282,6 +379,18 @@ export type StructTs = * type _Check = Assert> * ``` * + * **A schema whose root admits absence needs one more assert.** The + * annotation is {@link _TsRaw}-shaped, so when the wrapped schema's root is + * `or(option, …)` it must carry the {@link Absent} marker — + * `Phantom` — or the member it is used at + * renders required. The pair above cannot catch the omission: both compare + * through the public `Ts`, which strips `Absent` from both sides. Pin the + * raw half with {@link CheckRaw}: + * + * ```ts + * type _CheckRaw = Assert> + * ``` + * * See `fjs/edag/module.f.mjs` (`_exp`/`exp`) for this in practice. Note also * that the phantom branch below does `Exclude`, so a `MyType` * that includes bare `undefined` at its top level will never satisfy @@ -306,8 +415,10 @@ export type Ts = // and hitting TS2589 (type instantiation excessively deep). unknown extends T ? Unknown : // Phantom output: if the schema carries a phantomKey annotation (via WithOut), return - // it directly — one indexed-access, no structural walk, no TS2589 for recursive schemas. - T extends { readonly [phantomKey]?: infer O } ? Exclude : + // it directly — one indexed-access, no structural walk, no TS2589 for recursive + // schemas. The annotation is `_TsRaw`-shaped, so the `Absent` marker is stripped + // here alongside the optional-field `undefined` artifact. + T extends { readonly [phantomKey]?: infer O } ? Exclude : T extends () => infer I ? ( I extends readonly['const', infer C] ? ConstTs : // Info0 @@ -316,8 +427,12 @@ export type Ts = I extends readonly['string'] ? string : I extends readonly['bigint'] ? bigint : I extends readonly['unknown'] ? Unknown : + // `option` contributes no *value*: at the entry position nothing can be + // absent, so the public rendering is what the rest of the union accepts, + // and `never` vanishes in it. The `Absent`-preserving shape is `_TsRaw`. + I extends readonly['option'] ? never : // Info1 - I extends readonly['array', infer E extends Type] ? readonly Ts[] : + I extends readonly['array', infer E extends Type] ? ArrayTs : I extends readonly['record', infer E extends Type] ? { readonly[k in string]?: Ts } : // Or I extends readonly['or', ...infer A extends readonly Type[]] ? Ts : @@ -328,6 +443,27 @@ export type Ts = ) : ConstTs +/** + * The {@link Absent}-preserving counterpart of {@link Ts}, differing only at + * the **root** of a schema — the one place absence has no container position + * to lower it into: `_TsRaw` is `Absent | number` + * where the public `Ts` is `number`. It walks `or` chains and reads a + * `Phantom` annotation verbatim (minus the optional-field `undefined` + * artifact), and delegates every other form to `Ts` — container positions + * lower the marker for themselves, so below the root the two agree. Its two + * consumers are a `Phantom` annotation's shape and {@link CheckRaw}, which + * pins one. + */ +export type _TsRaw = + unknown extends T ? Unknown : + T extends { readonly [phantomKey]?: infer O } ? Exclude : + T extends () => infer I ? ( + I extends readonly['option'] ? Absent : + I extends readonly['or', ...infer A extends readonly Type[]] ? _TsRaw : + Ts + ) : + Ts + /** * Pins a hand-written TypeScript type `A` against the type an rtti schema `B` * actually derives to — `Assert>` reads as "`A` is `Ts`". @@ -346,6 +482,20 @@ export type Check = Equal> */ export type Check3 = And>, Equal>> +/** + * The **raw** counterpart of {@link Check}: pins `A` against + * {@link _TsRaw}``, the {@link Absent}-preserving shape. This is the + * assert with teeth for a `Phantom` annotation on a schema whose root admits + * absence: {@link Check} and {@link Check3} compare through the public + * {@link Ts}, which strips `Absent` from *both* sides, so they pass even + * when the annotation forgot the marker — and the wrapped member then + * renders required. Spell the annotation `Absent | …` and add + * `Assert>` beside the usual pair; a + * schema whose root excludes absence needs nothing new, `_TsRaw` and `Ts` + * agreeing there. + */ +export type CheckRaw = Equal> + // Fast-path: Ts resolves to Unknown without TS2589 overflow. type _any = Assert> @@ -362,34 +512,86 @@ type _struct = Assert> +/** + * A key that may be **absent** — `or(option, string)` — renders optional + * with the marker stripped, while `or(string, undefined)` is a *required* + * key that may hold `undefined`: under `exactOptionalPropertyTypes` the two + * renderings are distinct in TypeScript exactly as the two schemas are + * distinct at runtime. + */ type _structOption = Assert } +>> +type _structPresentUndefined = Assert } >> +type _structOptionAndUndefined = Assert } +>> + +/** + * The pair no subtype query over the rendered type can tell apart — the top + * absorbs {@link Absent} — and {@link _AdmitsAbsence} over the schema does: + * a key declared `unknown` must be *present*, so the closed `{ a: unknown }` + * rejects `{}`, while `or(option, unknown)` is the declared-member top. + */ +type _structUnknownRequired = Assert> +type _structUnknownOptional = Assert } +>> /** * The tuple counterpart of {@link _structOption}: a trailing position whose - * set admits `undefined` renders **optional**, so an array may stop at the - * last required one — the same rule, on the other kind. + * set admits absence renders **optional**, with the marker stripped, so an + * array may stop at the last required one — the same rule, on the other + * kind. */ type _tupleOption = Assert, Or] + readonly[number, bigint, boolean?, string?], + readonly[RttiNumber, RttiBigint, Or, Or] >> /** * Only the *trailing* run. TypeScript forbids a required element after an - * optional one, so a position that admits `undefined` with a required one - * after it keeps `undefined` in its type and stays required. The runtime rule - * is unchanged — such a position may still be absent, since reading it yields - * `undefined` either way — this is what TypeScript can spell, not a narrower - * set. + * optional one, so a position that admits absence with a required one after + * it renders `T | undefined` — `undefined` is what reading a hole gives, so + * the type is honest, if wider than the set: this is what TypeScript can + * spell, not a narrower rule at runtime. */ type _tupleInteriorOption = Assert, RttiNumber] +>> + +/** + * A present-`undefined` interior position needs no lowering — `undefined` is + * already a member of its set — and stays required. + */ +type _tupleInteriorUndefined = Assert, RttiNumber] >> +/** + * The exactness claim of the two stages, pinned with values: a closed tuple + * with a trailing `or(option, number)` renders `readonly [1, number?]`, and + * under `exactOptionalPropertyTypes` that type and the schema agree on every + * row — `[1]` and `[1, 2]` in, `[1, undefined]` and `[1, 2, 3]` out. + */ +type _tupleExact = Ts]> +type _tupleExactRendering = Assert> +type _tupleExactAdmitsShort = Assert +type _tupleExactAdmitsFull = Assert +type _tupleExactRejectsPresentUndefined = Assert +type _tupleExactRejectsLong = Assert + type _const = Assert readonly['const', 12]>> type _boolean = Assert readonly['boolean']>> @@ -449,8 +651,8 @@ type _restOpen = Assert readonly['rest', readonly[RttiNumber, Or], RttiBoolean]>> + readonly[number, string?, ...readonly (boolean | undefined)[]], + () => readonly['rest', readonly[RttiNumber, Or], RttiBoolean]>> /** * A rest with no prefix is the uniform array, and renders the tail rather than @@ -475,3 +677,48 @@ type _restStruct = Assert readonly['rest', readonly[12], readonly[Or]]>> + +/** + * The top-level `option` and its degenerate unions. At the entry position no + * value can be absent, so the public rendering is what the rest of the union + * accepts — `option` alone is `never` — while {@link _TsRaw} keeps the + * marker, which is what {@link CheckRaw} pins. + */ +type _optionAlone = Assert> +type _optionUnion = Assert>> +type _optionUnionRaw = Assert>> + +/** + * The type-level counterpart of "a rest never sees it": an array or record + * element excludes the marker — and an element set with no present value at + * all is the **empty array**, `readonly []`, not `readonly never[]`, whose + * `length` is `number` and which `new Array(1)` inhabits. + */ +type _arrayOption = Assert readonly['array', Or]>> +type _arrayOptionOnly = Assert readonly['array', RttiOption]>> +type _arrayNever = Assert readonly['array', Or]>> +type _recordOption = Assert readonly['record', Or]>> + +/** `or` does no flattening, so absence is found through nested unions. */ +type _nestedOptionKey = Assert, RttiString]> } +>> + +/** + * A `Phantom` annotation is {@link _TsRaw}-shaped: wrapping a schema whose + * root admits absence, it carries {@link Absent}, which + * {@link _AdmitsAbsence} reads from the annotation and the container + * position lowers — the wrapped member renders optional. {@link CheckRaw} + * is the assert with teeth for the annotation itself: the {@link Check} + * pair passes with or without the marker, both halves stripping it. + */ +type _PhantomOption = Phantom, Absent | number> +type _phantomRaw = Assert>> +type _phantomPublic = Assert> +type _phantomOptionalMember = Assert> diff --git a/fjs/rtti/types.ts b/fjs/rtti/types.ts index c1ae83a44..e250f2185 100644 --- a/fjs/rtti/types.ts +++ b/fjs/rtti/types.ts @@ -16,9 +16,11 @@ * * ## Nullary schemas (no type parameter) * - * `boolean`, `number`, `string`, `bigint`, `unknown` are pre-built `Thunk` values - * that describe primitive types. Each is a `_Type0` — a thunk returning a - * single-element tag tuple. + * `boolean`, `number`, `string`, `bigint`, `unknown`, `option` are pre-built + * `Thunk` values. Each is a `_Type0` — a thunk returning a + * single-element tag tuple. All but `option` describe sets of values; + * `option` denotes **absence**, so `or(option, t)` is a member that may be + * omitted. * * ## Unary schemas (one type parameter) * @@ -92,6 +94,7 @@ export type Type = | readonly['number'] | readonly['string'] | readonly['unknown'] + | readonly['option'] // Info1 | readonly['array', Type] | readonly['record', Type] @@ -132,6 +135,14 @@ export type Bigint = _Type0<'bigint'> /** Schema type for any DJS value (`Primitive | UnknownRecord | UnknownArray`). */ export type Unknown = _Type0<'unknown'> +/** + * Schema type for `option` — the nullary schema denoting **absence**, the + * member that is not there. A member that may be omitted is a union with it: + * `or(option, t)`. `unknown` excludes it — absence is not a DJS value — so + * the top of a declared member is `or(option, unknown)`. + */ +export type Option = _Type0<'option'> + /** Tags for unary (one-parameter) type schemas. */ export type Tag1 = 'array' | 'record' diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index 3914aa5dd..ae482ec7f 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -15,8 +15,8 @@ * object it passed in — same reference, same members, same serialization: * * ```js - * const schema = { a: number, b: option(string) } - * parse(schema)({ a: 1, extra: 'x' }) // ['ok', { a: 1, b: undefined }] + * const schema = open({ a: number, b: or(option, string) }) + * parse(schema)({ a: 1, extra: 'x' }) // ['ok', { a: 1 }] * validate(schema)({ a: 1, extra: 'x' }) // ['ok', { a: 1, extra: 'x' }] * ``` * @@ -36,9 +36,9 @@ * the member check alone. * * Closedness is about *undeclared* members and leaves the required/optional - * rule alone: an absent member reads as `undefined`, so a member is required - * exactly when its set excludes `undefined`, and a schema whose trailing - * position admits it still accepts a shorter array. A tuple schema declares by + * rule alone: a member is required exactly when its set excludes **absence** + * — the `option` bit of its union — so a schema whose trailing position says + * `or(option, t)` still accepts a shorter array. A tuple schema declares by * length, so a hole in the *schema* is a position whose schema is `undefined` * — see "A hole is a declared position" in `../README.md`. * @@ -84,6 +84,7 @@ import { ok } from '../../types/result/module.f.mjs' import { + absentMember, constPrimitiveValidate, eachEntry, isArray, @@ -173,6 +174,16 @@ const recordValidate = containerValidate(isObject, () => () => true) * a member on both, but an array is also *as long as it is*: a hole past the * prefix is no member and would slip through the member check alone, so the * array kind answers with its length as well. + * + * A declared member is **absent** when its key or index is neither an own + * property nor an inherited one — HasProperty, since `getItem` reads through + * the prototype, so a member the prototype supplies is still held to what + * the schema says a present value is. Absence is decided here, before + * dispatch: the recursive reader is handed only the value read, and an + * absent key reads `undefined`, so it cannot tell `{}` from + * `{ a: undefined }`. An absent member is legal exactly when its schema + * admits absence (`admitsAbsence` in `../common/module.f.mjs`); a present + * one is dispatched as before. */ const constContainerValidate = /** @@ -196,7 +207,9 @@ const constContainerValidate = } const r = eachEntry( rttiEntries, - (k, v) => /** @type {any} */ (validate(v))(getItem(value, k)), + (k, v) => k in value + ? /** @type {any} */ (validate(v))(getItem(value, k)) + : absentMember(v), undefined, noAccumulate, ) @@ -257,7 +270,9 @@ const restContainerValidate = } const d = eachEntry( rttiEntries, - (k, v) => /** @type {any} */ (validate(v))(getItem(value, k)), + (k, v) => k in value + ? /** @type {any} */ (validate(v))(getItem(value, k)) + : absentMember(v), undefined, noAccumulate, ) @@ -311,6 +326,11 @@ const validateVisitor = /** @type {any} */ ({ constPrimitive: constPrimitiveValidate, primitive0: primitive0Validate, unknown: () => ok, + // Absence is decided by the container loop before dispatch, so a value + // that reaches this handler is present — and no present value is absent. + // An ordinary error is what lets `orVisit` try the other members of + // `or(option, t)`. + option: () => () => verror('unexpected value'), }) /** @@ -338,7 +358,7 @@ const validateVisitor = /** @type {any} */ ({ * validate({ a: number })({ a: 1, b: 2 }) // ['error', …] * * // an absent optional member stays absent - * validate({ a: number, b: option(string) })({ a: 1 }) // ['ok', { a: 1 }] + * validate({ a: number, b: or(option, string) })({ a: 1 }) // ['ok', { a: 1 }] * * // a stated rest says what the undeclared members may be; `open` says anything * validate(rest({ a: number }, number))({ a: 1, b: 2 }) // ['ok', { a: 1, b: 2 }] diff --git a/fjs/rtti/validate/proof.f.mjs b/fjs/rtti/validate/proof.f.mjs index 4d773bf23..bb8d9ba42 100644 --- a/fjs/rtti/validate/proof.f.mjs +++ b/fjs/rtti/validate/proof.f.mjs @@ -128,9 +128,16 @@ const rows = [ [{ a: /** @type {const} */ (42) }, { a: 42, b: 'x' }], [{ a: /** @type {const} */ (42) }, { a: 42 }], // a key declared `unknown` is a member the schema has, so the canonical - // form must not drop it the way an `open` struct's is dropped + // form must not drop it the way an `open` struct's is dropped — and one + // that must be *present*, `unknown` excluding absence [{ a: unknown }, { a: 1 }], [{ a: unknown }, { a: 1, b: 2 }], + [{ a: unknown }, {}], + // the declared-member top — anything, or nothing — is still closed over + // its undeclared keys + [{ a: or(option, unknown) }, {}], + [{ a: or(option, unknown) }, { a: 1 }], + [{ a: or(option, unknown) }, { a: 1, b: 2 }], // and the same rows under `open`, which is the form that admits them [open([/** @type {const} */ (42)]), [42, 'extra']], [open({ a: /** @type {const} */ (42) }), { a: 42, b: 'x' }], @@ -138,16 +145,16 @@ const rows = [ [open({}), { a: 1 }], // closedness is about *undeclared* members and leaves the short-array rule // alone - [[number, option(string)], [42]], + [[number, or(option, string)], [42]], // the rule is per position, not "the last one": every trailing position // whose set admits `undefined` may be absent, so an array may stop at the // last required one - [[number, bigint, option(string), option(null)], [2, 4n]], - [[number, bigint, option(string), option(null)], [2, 4n, 'x']], - [[number, bigint, option(string), option(null)], [2, 4n, 'x', null]], - [[number, bigint, option(string), option(null)], [2]], - [[number, bigint, option(string), option(null)], [2, 4n, 5]], - [{ a: number, b: option(string) }, { a: 1 }], + [[number, bigint, or(option, string), or(option, null)], [2, 4n]], + [[number, bigint, or(option, string), or(option, null)], [2, 4n, 'x']], + [[number, bigint, or(option, string), or(option, null)], [2, 4n, 'x', null]], + [[number, bigint, or(option, string), or(option, null)], [2]], + [[number, bigint, or(option, string), or(option, null)], [2, 4n, 5]], + [{ a: number, b: or(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 @@ -195,8 +202,8 @@ const rows = [ [rest([selfList0], [selfList1, never]), [undefined, ,]], [or(number, string), true], [or(number, string), 'hello'], - [option(number), undefined], - [option(number), null], + [or(option, number), undefined], + [or(option, number), null], [{ user: { name: string, age: number } }, { user: { name: 'A', age: 'old' } }], ] @@ -210,15 +217,15 @@ export const proof = { // different document. `validate` answers the same question about the // value it was handed and hands it back. verbatim: { - // An absent optional member stays absent. `'b' in out` is the - // assertion, not `out.b === undefined`: `parse` satisfies the latter. + // An absent optional member stays absent — on both readers, absence + // being a member of the set rather than a spelling of `undefined`: + // `parse` omits it from what it builds instead of materializing it. absentOptionalStaysAbsent: () => { - const schema = { a: number, b: option(string) } + const schema = { a: number, b: or(option, string) } const input = { a: 1 } const out = unwrap(validate(schema)(input)) assert(!('b' in out), 'an absent optional member must stay absent') - // The contrast that motivates the module. - assert('b' in unwrap(parse(schema)(input)), 'parse materializes it') + assert(!('b' in unwrap(parse(schema)(input))), 'parse omits it too') }, // An undeclared member survives — where the schema admits one at all. // `parse` accepts the same values and does not carry the member into @@ -301,7 +308,7 @@ export const proof = { // identically. The one case where opening does change the answer is at the // end. optionalPositions: () => { - const t = /** @type {const} */ ([number, bigint, option(string), option(null)]) + const t = /** @type {const} */ ([number, bigint, or(option, string), or(option, null)]) /** @type {(rtti: Type) => (check: (r: readonly [string, unknown]) => void) => (value: Unknown) => void} */ const every = rtti => check => @@ -314,16 +321,17 @@ export const proof = { accepted([2, 4n]) // stops at the last required position accepted([2, 4n, 'x']) // the first optional present accepted([2, 4n, 'x', null]) // both present - // Omission is independent, not just truncation: an absent member - // reads as `undefined` wherever it sits, so position 2 may be - // missing while position 3 is present. A hole and an explicit - // `undefined` are the same value, so both spellings are accepted. + // Omission is independent, not just truncation: a member is + // absent wherever its index is missing, so position 2 may be + // missing while position 3 is present. accepted([2, 4n, , null]) //< a hole at position 2 - accepted([2, 4n, undefined, null]) //< the same value, spelled densely - rejected([2]) // `bigint` excludes `undefined` + // A present `undefined` is a value, not a spelling of absence: + // `or(option, string)` admits the hole above and rejects this. + rejected([2, 4n, undefined, null]) + rejected([2]) // `bigint` excludes absence rejected([2, 4n, 5]) // an optional that is present is still checked - // The mirror of the two rows above: `bigint` excludes `undefined`, - // so omitting position 1 fails however much of the rest is present. + // The mirror of the rows above: `bigint` excludes absence, so + // omitting position 1 fails however much of the rest is present. rejected([2, , 'x', null]) //< a hole at position 1 } // What opening does change: an element past the declared positions is @@ -345,7 +353,7 @@ export const proof = { // positional, not a shift — `[, 5]` holds `5` at position 1 and is // accepted, while `[5]` holds it at position 0 and is not. interiorOptionBeforeRequired: () => { - const t = /** @type {const} */ ([option(string), number]) + const t = /** @type {const} */ ([or(option, string), number]) /** @type {(rtti: Type) => (check: (r: readonly [string, unknown]) => void) => (value: Unknown) => void} */ const every = rtti => check => @@ -357,7 +365,9 @@ export const proof = { // the shapes the trailing-option cases there already put through it. for (const rtti of [t, open(t)]) { every(rtti)(assertOk)([, 5]) //< a hole at position 0 - every(rtti)(assertOk)([undefined, 5]) //< the same value, spelled densely + // `[undefined, 5]` is a *different value*: present-`undefined` at + // position 0, which `or(option, string)` rejects. + every(rtti)(assertError)([undefined, 5]) every(rtti)(assertOk)(['x', 5]) every(rtti)(assertError)([5]) //< `number` at position 1 is required } @@ -520,7 +530,7 @@ export const proof = { // filled in, so the array keeps its length. shortArrayKeepsItsLength: () => { const short = [42] - const out = unwrap(validate([number, option(string)])(short)) + const out = unwrap(validate([number, or(option, string)])(short)) assert(Object.is(out, short), 'expected the original array') assertEq(short.length, 1, 'no gap is filled') }, @@ -627,17 +637,76 @@ export const proof = { }, }, option: { + // At the entry position nothing can be absent, so `or(option, t)` + // accepts exactly what `t` accepts — a present `undefined` included + // in the rejects, unless the union carries it as a value. ok: () => { - const t = option(number) + const t = or(option, number) assertOk(validate(t)(42)) - assertOk(validate(t)(undefined)) + assertOk(validate(or(option, number, undefined))(undefined)) }, error: () => { - const t = option(number) + const t = or(option, number) + assertError(validate(t)(undefined)) assertError(validate(t)(null)) assertError(validate(t)('42')) + // and `option` alone accepts nothing at all + assertError(validate(option)(undefined)) + assertError(validate(option)(42)) }, }, + // Absence became describable: `{}` and `{ a: undefined }` are two + // distinct values, and every pair of the three spellings separates them + // as stage 2 states — `or(option, t)` admits omission only, + // `or(t, undefined)` a present `undefined` only, and the union of all + // three admits both. + absenceIsNotUndefined: () => { + for (const read of [v, p, d]) { + const omittable = read({ a: or(option, number) }) + assertOk(omittable({})) + assertOk(omittable({ a: 1 })) + assertError(omittable({ a: undefined })) + const present = read({ a: or(number, undefined) }) + assertError(present({})) + assertOk(present({ a: undefined })) + const both = read({ a: or(option, number, undefined) }) + assertOk(both({})) + assertOk(both({ a: undefined })) + } + }, + // A negative field: `{ a: option }` is "objects with no `a`" — a set the + // old design could not express at a declared key. + negativeField: () => { + for (const read of [v, p, d]) { + const noA = read(open({ a: option })) + assertOk(noA({})) + assertOk(noA({ b: 1 })) + assertError(noA({ a: 1 })) + assertError(noA({ a: undefined })) + } + }, + // `admitsAbsence` traverses nested unions — the schema-form `or` does no + // flattening, so `or(or(option, number), string)` has no `option` among + // its direct members — and carries a visited set, so a recursive union + // that reaches itself before `option` still terminates. + admitsAbsenceTraversal: () => { + for (const read of [v, p]) { + const nested = read({ a: or(or(option, number), string) }) + assertOk(nested({})) + assertOk(nested({ a: 1 })) + assertOk(nested({ a: 'x' })) + assertError(nested({ a: true })) + } + // The visited set is what terminates this: the cycle reaches itself + // before it reaches `option`. Only the absent path is asked — a pure + // `or` cycle never terminates on a *present* value in the thunk + // readers, the standing limitation `../data/proof.f.mjs` records. + /** @typedef {() => readonly ['or', _Cycle, typeof option]} _Cycle */ + /** @type {_Cycle} */ + const cycle = () => ['or', cycle, option] + assertOk(v({ a: cycle })({})) + assertOk(p({ a: cycle })({})) + }, path: { rootMismatch: () => assertErrorPath([])(validate(number)('not a number')), arrayIndex: () => assertErrorPath(['1'])(validate(array(number))([1, 'two', 3])), @@ -701,7 +770,7 @@ export const proof = { // An absent optional member still stays absent — a container's rest // says nothing about a member it declares. absentOptionalStaysAbsent: () => { - const out = unwrap(validate({ a: number, b: option(string) })({ a: 1 })) + const out = unwrap(validate({ a: number, b: or(option, string) })({ a: 1 })) assert(!('b' in out), 'an absent optional member must stay absent') }, path: () => { @@ -767,11 +836,11 @@ export const proof = { // a rest with nothing present past the prefix admits it. lengthDoesNotBoundTheWalk: () => { const big = new Array(2 ** 32 - 1) - assertError(v([option(string)])(big)) + assertError(v([or(option, string)])(big)) assertOk(v(rest([], string))(big)) }, arrayOptional: () => { - const a = /** @type {const} */([number, option(string)]) + const a = /** @type {const} */([number, or(option, string)]) const v = validate(a) assertOk(v([5])) assertError(v(["n"])) diff --git a/fjs/types/phantom/types.ts b/fjs/types/phantom/types.ts index 944993881..8028eacda 100644 --- a/fjs/types/phantom/types.ts +++ b/fjs/types/phantom/types.ts @@ -37,6 +37,21 @@ export type { phantomKey } * type _Check = Assert> * ``` * + * For an rtti schema the annotation is **`_TsRaw`-shaped**: when the wrapped + * schema's *root* admits absence — `or(option, …)` — `T` must carry the + * `Absent` marker (`Absent | MyType`), or a member the wrapped schema is + * used at silently renders required. The pair above cannot catch the + * omission, both halves comparing through the public `Ts<>`, which strips + * `Absent` from both sides — so such a schema **requires** the raw assert + * beside them, with `CheckRaw` and `Absent` from `fjs/rtti/ts/types.ts`: + * + * ```ts + * type _CheckRaw = Assert> + * ``` + * + * A schema whose root excludes absence needs nothing new — `_TsRaw` and + * `Ts` agree everywhere below a root `or` chain. + * * One phantom per recursive cycle is enough: `fjs/edag` wraps only `exp`, * the union every node kind recurses through, and the node schemas * themselves stay un-phantomed, each pinned with a plain `Check`. diff --git a/todo/rtti-type-system.md b/todo/rtti-type-system.md index bd579ee28..06146a7f4 100644 --- a/todo/rtti-type-system.md +++ b/todo/rtti-type-system.md @@ -139,7 +139,7 @@ import { array, number, option, or, string } from 'functionalscript/fjs/rtti/mod const key = or(number, string) const keys = array(key) -const maybeKey = option(key) +const maybeKey = or(option, key) //: key export const a = 'hello' @@ -547,7 +547,7 @@ rejected by `validate`. That is the exact disagreement this epic exists to remove, surviving inside its own deliverable. (The *tuple* kind has no such gap: a TypeScript tuple is exact-length, so `Ts<>` renders a closed tuple exactly — which is what stage 1 of -[option-as-omission](../fjs/rtti/todo/option-as-omission.md) settled.) +`option` as omission settled; both stages have landed.) Two things keep this from undermining the whole direction, and both need stating rather than assuming: @@ -911,9 +911,8 @@ are stated instead: the model rather than an approximation of it, and the printer prints the same exact tuple. `open(c)` is what admits a longer array, and both renderers emit the tail that says so. This bullet used to record a live - divergence and no longer does; stage 1 of - [option-as-omission](../fjs/rtti/todo/option-as-omission.md) - removed it. + divergence and no longer does; stage 1 of `option` as omission + (landed, both stages) removed it. **A third disagreement runs the other way, and has narrowed.** `RestTs` ([`ts/types.ts`](../fjs/rtti/ts/types.ts)) now renders a stated From 7ada040d5cb297df86bc9e41aeed69e21c76fefd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:17:15 +0000 Subject: [PATCH 02/11] changelog: name the entry by its pull request, #1748 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/{option-as-omission.md => 1748.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/unreleased/{option-as-omission.md => 1748.md} (100%) diff --git a/changelog/unreleased/option-as-omission.md b/changelog/unreleased/1748.md similarity index 100% rename from changelog/unreleased/option-as-omission.md rename to changelog/unreleased/1748.md From 0d297672f6a859a068a5d9756876550d3a7a0f5b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:24:23 +0000 Subject: [PATCH 03/11] rtti: the tuple rebuild never runs a method of the value, and `Phantom` short-circuits `_IsAbsentOnly` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on #1748: - `parse`'s tuple rebuild called `slice`/`map` on the input, so an accepted `Array` subclass overriding either could hand back a result that fails the schema it was parsed against, or throw past the `Result` API. The rebuild is now built from the parsed entries alone — hole-run segments folded with `concat` on a trusted plain array, which appends a spreadable argument element by present element and so keeps the holes — and the value is never consulted. Pinned in `host.proof.mjs` with an array whose prototype supplies a hostile `slice` and a throwing `map`. - `_IsAbsentOnly` (behind `ArrayTs`) walked a phantom-wrapped schema's thunk, re-expanding a recursive union into itself — TS2589 where the annotation exists to prevent it. It now reads the `Phantom` annotation first, as `_AdmitsAbsence` and `Ts` do: the annotation is `_TsRaw`-shaped, so absent-only is `Exclude` being `never`. Pinned with a recursive `X = or(option, number, X)` wrapped and used as an array element, and an absence-only annotation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- fjs/rtti/host.proof.mjs | 27 ++++++++++--- fjs/rtti/parse/module.f.mjs | 81 +++++++++++++++++++------------------ fjs/rtti/parse/proof.f.mjs | 2 + fjs/rtti/ts/types.ts | 35 ++++++++++++++++ 4 files changed, 100 insertions(+), 45 deletions(-) diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index ef9d538d5..23ccaf6ca 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -182,12 +182,29 @@ export const proof = { assertOk(read({ a: or(option, string) })(value)) } }, + // `parse`'s tuple rebuild never runs a method of the value: it is built + // from the parsed entries alone, on trusted plain arrays. An accepted + // `Array` subclass — or, as here, an array whose prototype supplies the + // methods — can override `slice`/`map`; a rebuild that called them was + // handed `['ok', []]` for an input holding `1`, a result that fails the + // very schema it was parsed against, and a throwing override escaped + // the `Result` API entirely. + hostileArrayMethodsDoNotReachTheRebuild: () => { + const value = [1] + Object.setPrototypeOf(value, Object.assign([], { + slice: () => [], + map: () => { throw 'hostile' }, + })) + const r = p([number])(value) + assert(r[0] === 'ok', 'expected ok') + assertStructurallySame(/** @type {readonly unknown[]} */ (r[1]), [1]) + }, // …and `parse` **materializes** the inherited value as an own member of - // what it builds: against a prototype-supplied index no immutable builder - // can produce the hole — `slice` and `.map` copy by HasProperty, an - // `Object.hasOwn` guard inside a `.map` callback cannot stop the own - // output element from existing, and a fresh `Array(n)` inherits the index - // too — so this is a pinned, bounded divergence, unreachable from + // what it builds: the member is *present* — HasProperty is what the + // check dispatched on — so its parsed value is in the entries the + // rebuild is made of, and the output carries what was checked rather + // than a hole at an index the input answered for. A pinned, bounded + // divergence from the input's own/inherited split, unreachable from // FunctionalScript (which has neither mutation nor prototype writes). // `validate` is untouched: it returns the value it was given. parseMaterializesAnInheritedIndex: () => { diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index c16e22e9b..092a91355 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -86,44 +86,45 @@ const arrayRebuild = entries => entries.map(([, v]) => v) const recordRebuild = entries => Object.fromEntries(entries) /** - * Rebuilds a **const** container from the declared members that were - * present, keyed by the same HasProperty test the check dispatched on — - * one per kind, since only the array kind has holes to preserve. + * The **tuple** kind's rebuild over its declared members — only the present + * ones reach `entries` (`recordRebuild` is the struct kind's counterpart, + * where dropping an absent key needs nothing more): the present members at + * their own indices, holes at the absent ones before them, ending at the + * last present position — so a trailing absent run shortens the result and + * an interior hole survives (materializing it as `undefined` would denote a + * different value, and omitting it would shift every position after it). * - * @template C - * @typedef {(value: C, entries: ReadonlyArray) => Unknown} _RebuildDeclared - */ - -/** - * The array kind's rebuild is **slice, then map**: truncate the value to the - * last *present* declared position, then map each present index to its - * parsed result. Mapping alone is not enough — `.map` preserves length, so a - * trailing absent run would survive as a sparse tail and serialize back to - * the `null`s this stage removes — and omitting absent entries from a - * rebuilt list would shift every position after an interior hole. - * `slice` and `.map` both skip a hole, so an interior one survives as a - * hole; both use HasProperty, so an index the value only *inherits* is - * materialized as an own property of the result, carrying its parsed value. - * That divergence is bounded and pinned rather than closed: no immutable - * builder can produce the hole against a prototype-supplied index — - * `.map` creates the own output element whatever the callback returns, and - * a fresh `Array(n)` inherits the index too — and the escapes - * (`Object.assign`, index assignment) are mutation, which FunctionalScript - * forbids. See `../host.proof.mjs`. + * The construction is segments — a fresh `new Array(gap)` of holes before + * each present member, then the member — folded with `concat` on a trusted + * empty array, which appends a spreadable argument element by *present* + * element and so keeps the holes. The input value is never consulted, and + * every array touched is a plain one this module made, which is the point: + * an earlier slice-then-map of the input let an accepted `Array` subclass + * override `slice` and hand `parse` a result that fails the very schema it + * was parsed against. An index the value only *inherits* is a present + * member (HasProperty is what the check dispatched on), so it sits in + * `entries` and is materialized as an own member of the result, carrying + * its parsed value — see `../host.proof.mjs`. * - * @type {_RebuildDeclared>} + * @type {_Rebuild} */ -const tupleRebuild = (value, entries) => { - if (entries.length === 0) { return [] } - /** @type {StringMap} */ - const byIndex = Object.fromEntries(entries) - const end = Number(entries[entries.length - 1][0]) + 1 - return value.slice(0, end).map((_, i) => byIndex[i]) +const tupleRebuild = entries => { + /** @type {readonly (readonly Unknown[])[]} */ + let segments = [] + let next = 0 + for (const [k, v] of entries) { + const i = Number(k) + segments = i === next + ? [...segments, [v]] + : [...segments, new Array(i - next), [v]] + next = i + 1 + } + return emptySegment.concat(...segments) } -/** The struct kind drops an absent key: only the present entries are rebuilt. */ -/** @type {_RebuildDeclared>} */ -const structRebuild = (_value, entries) => Object.fromEntries(entries) +/** `tupleRebuild`'s trusted `concat` receiver — a plain array, so its species is `Array`. */ +/** @type {ReadonlyArray} */ +const emptySegment = [] /** `eachEntry`'s accumulator seed: entries are consed on in reverse as they parse. */ /** @type {List} */ @@ -239,7 +240,7 @@ const constContainerParse = * @param {IsContainer} isContainer * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem - * @param {_RebuildDeclared} rebuild + * @param {_Rebuild} rebuild * @param {Fits} fits * @returns {(rtti: T) => Parse} */ @@ -267,7 +268,7 @@ const constContainerParse = ) if (r[0] === 'error') { return r } return undeclaredMembers(declared, value).length === 0 && fits(value, declared.length) - ? /** @type {any} */ (ok(rebuild(value, orderedEntries(r[1])))) + ? /** @type {any} */ (ok(rebuild(orderedEntries(r[1])))) : verror('unexpected value') } } @@ -284,7 +285,7 @@ const structParse = constContainerParse( isObject, structSchemaEntries, (value, k) => value[k], - structRebuild, + recordRebuild, () => true, ) @@ -306,7 +307,7 @@ const restContainerParse = * @param {IsContainer} isContainer * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem - * @param {_RebuildDeclared} rebuild + * @param {_Rebuild} rebuild * @param {(rtti: S, r: Type) => Fits} restFits * @returns {(rtti: S, r: Type) => ValidateE} */ @@ -337,12 +338,12 @@ const restContainerParse = const extra = undeclaredMembers(declared, value) if (extra.length === 0) { return fits(value, declared.length) - ? ok(rebuild(value, orderedEntries(d[1]))) + ? ok(rebuild(orderedEntries(d[1]))) : verror('unexpected value') } const restParse = /** @type {any} */ (parse(r)) const e = eachEntry(extra, (_k, v) => restParse(v), undefined, noAccumulate) - return e[0] === 'error' ? e : ok(rebuild(value, orderedEntries(d[1]))) + return e[0] === 'error' ? e : ok(rebuild(orderedEntries(d[1]))) } } @@ -358,7 +359,7 @@ const restStructParse = restContainerParse( isObject, structSchemaEntries, (value, k) => value[k], - structRebuild, + recordRebuild, () => () => true, ) diff --git a/fjs/rtti/parse/proof.f.mjs b/fjs/rtti/parse/proof.f.mjs index b325d6f29..6a21d58a3 100644 --- a/fjs/rtti/parse/proof.f.mjs +++ b/fjs/rtti/parse/proof.f.mjs @@ -352,6 +352,8 @@ export const proof = { const built = unwrap(parse([number, or(option, number), or(option, number)])([1, , ])) assertEq(built.length, 1, 'the trailing absent run is gone') assertEq(built[0], 1, 'the present prefix survives') + // nothing present at all rebuilds the empty array + assertStructurallySame(unwrap(parse([or(option, number)])([])), []) }, structDropsTheKey: () => { const built = unwrap(parse({ a: number, b: or(option, string) })({ a: 1 })) diff --git a/fjs/rtti/ts/types.ts b/fjs/rtti/ts/types.ts index 34d348d77..7f58ed042 100644 --- a/fjs/rtti/ts/types.ts +++ b/fjs/rtti/ts/types.ts @@ -69,12 +69,22 @@ type _AdmitsAbsence1 = * reason {@link _AdmitsAbsence} is, and additionally because testing * `[Ts] extends [never]` would force the element type of a recursive * array schema eagerly and never terminate. + * + * A `Phantom` annotation is read **before** the thunk walk, exactly as + * {@link _AdmitsAbsence} and `Ts` read it: a phantom-wrapped schema is still + * a thunk, so descending its `or` chain would re-expand the very recursion + * the annotation exists to spare (TS2589). The annotation is `_TsRaw`-shaped, + * so its present part is what survives `Exclude` — + * the same `Exclude` the public `Ts` applies — and "absent-only" is that + * part being `never`. (`undefined` there is the optional-field artifact the + * `Phantom` contract already excludes from annotations, not a value member.) */ type _IsAbsentOnly = unknown extends T ? false : false extends _IsAbsentOnly1 ? false : true type _IsAbsentOnly1 = + T extends { readonly [phantomKey]?: infer O } ? ([Exclude] extends [never] ? true : false) : T extends () => infer I ? I extends readonly['option'] ? true : I extends readonly['or', ...infer A extends readonly Type[]] ? _IsAbsentOnly1 @@ -722,3 +732,28 @@ type _phantomOptionalMember = Assert> + +/** + * The `Phantom` short-circuit holds at every structural predicate, not only + * in `Ts`: a phantom-wrapped schema is still a thunk, so a predicate that + * walked it — {@link _IsAbsentOnly} behind {@link ArrayTs} was the one that + * did — re-expands a recursive union into itself and raises TS2589 where the + * annotation exists precisely to prevent it. Pinned with a recursive + * `X = or(option, number, X)` used as an array element, and with an + * absence-only annotation, the pair that exercises both answers of the + * phantom branch. + */ +type _PhantomRecThunk = () => readonly['or', RttiOption, RttiNumber, _PhantomRecThunk] +type _PhantomRec = Phantom<_PhantomRecThunk, Absent | number> +type _phantomRecursiveArray = Assert readonly['array', _PhantomRec] +>> +type _phantomRecursiveMember = Assert> +type _phantomAbsentOnlyArray = Assert readonly['array', Phantom] +>> From 9fc4fd93bc01248dc36ec6f8323e6fc125477169 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:40:34 +0000 Subject: [PATCH 04/11] =?UTF-8?q?rtti:=20join=20the=20tuple=20rebuild's=20?= =?UTF-8?q?segments=20pairwise=20=E2=80=94=20linear=20passes,=20no=20argum?= =?UTF-8?q?ent=20spread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #1748, on the previous fix: accumulating segments with a per-entry spread was quadratic — about 13.5 s for a 15,000-position tuple where the parent took 0.37 s — and the one `concat(...segments)` call overflowed the engine's argument limit on a large enough prefix, throwing past the `Result` API. The segments are now collected on the module's O(1)-prepend list and joined pairwise: each round halves the segment count with one-argument `concat` calls, so the rebuild is one linear pass plus a logarithmic number of halving rounds and no call takes a variable argument list at all. Pinned with an odd segment count and with thousands of alternating present/absent positions, every hole and member asserted. (The remaining cost at that scale sits in `undeclaredMembers`' pre-existing linear `declared.some` scan, which predates this PR and matches the parent's 0.37 s.) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- fjs/rtti/parse/module.f.mjs | 59 ++++++++++++++++++++++++------------- fjs/rtti/parse/proof.f.mjs | 38 ++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 092a91355..ff1f46ec0 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -85,6 +85,21 @@ const arrayRebuild = entries => entries.map(([, v]) => v) /** @type {_Rebuild} */ const recordRebuild = entries => Object.fromEntries(entries) +/** + * One pairwise round of {@link tupleRebuild}'s join: adjacent segments are + * `concat`enated — always one argument, so no call ever spreads the segment + * list — halving the count while copying every element once. `concat` + * appends a spreadable operand element by *present* element, which is what + * keeps the holes; each receiver is a trusted plain array, so its species + * is `Array`. + * + * @type {(segments: ReadonlyArray>) => ReadonlyArray>} + */ +const joinSegmentsRound = segments => segments.flatMap((s, i) => + i % 2 !== 0 ? [] + : i + 1 < segments.length ? [s.concat(segments[i + 1])] + : [s]) + /** * The **tuple** kind's rebuild over its declared members — only the present * ones reach `entries` (`recordRebuild` is the struct kind's counterpart, @@ -95,37 +110,41 @@ const recordRebuild = entries => Object.fromEntries(entries) * different value, and omitting it would shift every position after it). * * The construction is segments — a fresh `new Array(gap)` of holes before - * each present member, then the member — folded with `concat` on a trusted - * empty array, which appends a spreadable argument element by *present* - * element and so keeps the holes. The input value is never consulted, and - * every array touched is a plain one this module made, which is the point: - * an earlier slice-then-map of the input let an accepted `Array` subclass - * override `slice` and hand `parse` a result that fails the very schema it - * was parsed against. An index the value only *inherits* is a present - * member (HasProperty is what the check dispatched on), so it sits in - * `entries` and is materialized as an own member of the result, carrying - * its parsed value — see `../host.proof.mjs`. + * each present member, then the member — collected on an O(1)-prepend list + * and joined pairwise ({@link joinSegmentsRound}), so the whole rebuild is + * one linear pass plus a logarithmic number of halving rounds: re-spreading + * the accumulated segments per entry was quadratic, and one + * `concat(...segments)` call overflowed the engine's argument limit on a + * large enough prefix, throwing past the `Result` API. + * + * The input value is never consulted, and every array touched is a plain + * one this module made, which is the point: an earlier slice-then-map of + * the input let an accepted `Array` subclass override `slice` and hand + * `parse` a result that fails the very schema it was parsed against. An + * index the value only *inherits* is a present member (HasProperty is what + * the check dispatched on), so it sits in `entries` and is materialized as + * an own member of the result, carrying its parsed value — see + * `../host.proof.mjs`. * * @type {_Rebuild} */ const tupleRebuild = entries => { - /** @type {readonly (readonly Unknown[])[]} */ - let segments = [] + /** @type {List>} */ + let reversed = null let next = 0 for (const [k, v] of entries) { const i = Number(k) - segments = i === next - ? [...segments, [v]] - : [...segments, new Array(i - next), [v]] + if (i > next) { reversed = { first: new Array(i - next), tail: reversed } } + reversed = { first: [v], tail: reversed } next = i + 1 } - return emptySegment.concat(...segments) + let segments = toArray(reverse(reversed)) + while (segments.length > 1) { + segments = joinSegmentsRound(segments) + } + return segments.length === 0 ? [] : segments[0] } -/** `tupleRebuild`'s trusted `concat` receiver — a plain array, so its species is `Array`. */ -/** @type {ReadonlyArray} */ -const emptySegment = [] - /** `eachEntry`'s accumulator seed: entries are consed on in reverse as they parse. */ /** @type {List} */ const emptyEntries = null diff --git a/fjs/rtti/parse/proof.f.mjs b/fjs/rtti/parse/proof.f.mjs index 6a21d58a3..6531809db 100644 --- a/fjs/rtti/parse/proof.f.mjs +++ b/fjs/rtti/parse/proof.f.mjs @@ -359,6 +359,44 @@ export const proof = { const built = unwrap(parse({ a: number, b: or(option, string) })({ a: 1 })) assert(!('b' in built), 'an absent key is not materialized') }, + // An odd segment count: hole, present, present — three segments, two + // pairwise join rounds, the tail segment carried once unpaired. + oddSegments: () => { + /** @type {ReadonlyArray} */ + const built = unwrap(parse([or(option, number), number, number])([, 2, 4])) + assertEq(built.length, 3, 'the hole keeps its position') + assert(!Object.hasOwn(built, 0), 'no own index 0') + assertEq(built[1], 2, '`2` stays at index 1') + assertEq(built[2], 4, 'and `4` at index 2') + }, + // The join at scale: alternating present and absent positions, so + // thousands of segments go through a dozen halving rounds. The two + // hazards this construction replaced — re-spreading the accumulated + // segments per entry (quadratic) and one spread `concat` call over + // all of them (the engine's argument limit, a throw past the + // `Result` API) — are structurally gone: no call in the rebuild + // takes a variable argument list at all. Correctness of every hole + // and every member is what is asserted. + largeSparse: () => { + const pairs = 2048 + const omittable = or(option, number) + const schema = Array.from( + { length: 2 * pairs }, + (_, i) => i % 2 === 0 ? number : omittable) + // `[7, hole]` chunks: a sparse value FunctionalScript can build + // without mutation, holes at every odd index + /** @type {ReadonlyArray} */ + const chunk = [7].concat(new Array(1)) + /** @type {ReadonlyArray} */ + const none = [] + const value = none.concat(...Array.from({ length: pairs }, () => chunk)) + /** @type {ReadonlyArray} */ + const built = unwrap(parse(schema)(value)) + assertEq(built.length, 2 * pairs - 1, 'ends at the last present position') + assert(Array.from({ length: pairs }, (_, i) => i).every(i => + built[2 * i] === 7 && !Object.hasOwn(built, 2 * i + 1)), + 'every present member survives and every interior hole stays a hole') + }, }, path: { rootMismatch: () => assertErrorPath([])(parse(number)('not a number')), From 465499e520d3c6cca997348a946f57af93b16fbe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:59:51 +0000 Subject: [PATCH 05/11] changelog: hold #1748's entry to the length guideline; todo: the `declared` scan is quadratic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry is rewritten as four short list items — what changed and why, at release-note altitude — so the pull request body's `Changelog:` section can hold exactly the same items, as CONTRIBUTING.md requires. The rationale, measurements and migration detail stay in the pull request description and the module docs, where the changelog README sends them. Review of #1748 also measured a superlinear end-to-end parse at 100k+ tuple positions on both heads. Root-caused to `undeclaredMembers`' per-member `declared.some` linear scan — quadratic on dense tuples, predating this branch — and recorded as `fjs/rtti/todo/undeclared-members-declared-scan.md` with measurements and the O(1)-membership fix sketch, rather than widening this pull request. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/1748.md | 34 ++++++++---------- .../todo/undeclared-members-declared-scan.md | 36 +++++++++++++++++++ 2 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 fjs/rtti/todo/undeclared-members-declared-scan.md diff --git a/changelog/unreleased/1748.md b/changelog/unreleased/1748.md index fc8b0dc8e..8f9c904c7 100644 --- a/changelog/unreleased/1748.md +++ b/changelog/unreleased/1748.md @@ -1,20 +1,14 @@ -- **BREAKING CHANGES:** rtti's `option` is a nullary schema denoting - **absence** — the member that is not there — so a member that may be - omitted is `or(option, t)` and absence stops being a spelling of - `undefined`: `{}` and `{ a: undefined }` are now distinct sets, told apart - by every reader and by `subset`. `option(t)` becomes `or(option, t)`, which - also **narrows**: a schema that accepted a present `undefined` at that - member no longer does — the faithful translation of the old set is - `or(option, t, undefined)`, and every migrated schema in this repository - took the narrowing deliberately. `parse` no longer materializes an absent - member: the struct kind drops the key, the array kind keeps a hole a hole - and shortens a trailing absent run, so an optional member survives a JSON - round-trip. In the data form absence is a fifth `unit` bit (`absentBit`), - excluded from `unknown`; a declared `unknown` member is therefore required - now, and "anything, or nothing" is `or(option, unknown)`. `Ts<>` and the - runtime printer render an omittable member optional with absence stripped - (`readonly a?: number`, `readonly [1, number?]`) — exact under - `exactOptionalPropertyTypes` — and `toJsonSchema` derives `required` and - `minItems` from the absent bit. A `Phantom` annotation on a schema whose - root admits absence must carry the new `Absent` marker, pinned with the - new `CheckRaw`. +- **BREAKING CHANGES:** `rtti`: `option` is a nullary schema denoting + **absence** — a member that may be omitted is `or(option, t)`, and `{}` is + no longer the same set as `{ a: undefined }`. The old `option(t)` set is + `or(option, t, undefined)`; plain `or(option, t)` rejects a present + `undefined`. +- `rtti`: `parse` omits an absent member — the struct kind drops the key, + the array kind keeps holes and shortens a trailing absent run — so an + optional member survives a JSON round-trip. +- `rtti`: `unknown` excludes absence, so a member declared `unknown` is + required and the omittable top is `or(option, unknown)`. `Ts<>`, the + runtime printer and `toJsonSchema` derive optionality (`?`, `required`, + `minItems`) from absence — exact under `exactOptionalPropertyTypes`. +- `rtti`: a `Phantom` annotation on a schema whose root admits absence + carries the new `Absent` marker, pinned with the new `CheckRaw`. diff --git a/fjs/rtti/todo/undeclared-members-declared-scan.md b/fjs/rtti/todo/undeclared-members-declared-scan.md new file mode 100644 index 000000000..c78c3b08e --- /dev/null +++ b/fjs/rtti/todo/undeclared-members-declared-scan.md @@ -0,0 +1,36 @@ +# `undeclaredMembers` scans `declared` linearly per member + +**Priority:** P3 +**Status:** open + +## Problem + +`undeclaredMembers` in [`../common/module.f.mjs`](../common/module.f.mjs) +answers "is `k` declared?" with `declared.some(d => d === k)` — a linear scan +per member of the value. A tuple's own index `i` sits at position `i` of its +`declared` list, so a dense `n`-position tuple pays `Σ i ≈ n²/2` string +comparisons per read, and the whole walk is quadratic while everything around +it is linear. Measured (`undeclaredMembers` alone, dense all-present tuple): +0.7 s at 25 000 positions, 3.3 s at 50 000 — 4× per doubling. Review of #1748 +measured the same shape end to end: ~17–31 s at 100 000 positions, ~48 s at +200 000, identical on the parent commit, so this predates stage 2 of +`option`-as-omission and none of the readers' recent changes moved it. + +Every reader pays it — `parse`, `validate` and the data form's `validate` all +route undeclared members through this one walk (which is the point of the +shared rule; see the function's own doc) — but only the *array* kind at +scale: a struct's `declared` list is its key list, rarely large. + +## Tasks + +- [ ] Answer membership in O(1): build the membership test once from + `declared` (`new Set(declared)` is the §3.1-sanctioned construction) + — or better, once per **schema** rather than per call, since every + caller already hoists `declared` from `rttiEntries` in a per-schema + closure and the data reader can derive it from `p.prefix`/`p.props` + the same way. +- [ ] Keep the observable behavior bit-identical: `undeclaredMembers`' member + *order* and the non-index/beyond-`length` rules are pinned by + `../host.proof.mjs` and the three-reader tables, and must not move. +- [ ] Pin the complexity the way `readIndices`' doc pins its own linear walk: + a measurement in the JSDoc, not a timing assert in a proof. From 293926ffa1baa8920992be6dbbccba8b91ab51ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 07:22:54 +0000 Subject: [PATCH 06/11] changelog: trim #1748's two longest entries to the per-entry guideline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `changelog/README.md` bounds an *entry* — one list item; a pull request with several entries puts them all in its one file — at about three wrapped lines, ~250 characters. Two of the four ran past that; now every item is within it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/1748.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/changelog/unreleased/1748.md b/changelog/unreleased/1748.md index 8f9c904c7..b057e9078 100644 --- a/changelog/unreleased/1748.md +++ b/changelog/unreleased/1748.md @@ -1,14 +1,13 @@ - **BREAKING CHANGES:** `rtti`: `option` is a nullary schema denoting - **absence** — a member that may be omitted is `or(option, t)`, and `{}` is - no longer the same set as `{ a: undefined }`. The old `option(t)` set is - `or(option, t, undefined)`; plain `or(option, t)` rejects a present - `undefined`. + **absence** — an omittable member is `or(option, t)`, which rejects a + present `undefined`; the old `option(t)` set is `or(option, t, undefined)`. + `{}` and `{ a: undefined }` are distinct sets. - `rtti`: `parse` omits an absent member — the struct kind drops the key, the array kind keeps holes and shortens a trailing absent run — so an optional member survives a JSON round-trip. -- `rtti`: `unknown` excludes absence, so a member declared `unknown` is - required and the omittable top is `or(option, unknown)`. `Ts<>`, the - runtime printer and `toJsonSchema` derive optionality (`?`, `required`, - `minItems`) from absence — exact under `exactOptionalPropertyTypes`. +- `rtti`: `unknown` excludes absence — the omittable top is + `or(option, unknown)`. `Ts<>`, the runtime printer and `toJsonSchema` + derive optionality (`?`, `required`, `minItems`) from absence, exact under + `exactOptionalPropertyTypes`. - `rtti`: a `Phantom` annotation on a schema whose root admits absence carries the new `Absent` marker, pinned with the new `CheckRaw`. From 1d8bd77eb481085399d077cf7930cccefb204741 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 07:47:48 +0000 Subject: [PATCH 07/11] rtti: parse's rebuilds dispatch nothing overridable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading a member of the value can run arbitrary code — an accessor — and the rebuild runs after every read, so by then that code may have replaced anything reached by dynamic lookup: an `Array.prototype` method, the array iterator, `Object.fromEntries`, the `Array` binding, or the species lookup inside every array method — even a captured `concat` builds through the receiver's species. A getter that patched `Array.prototype.concat` steered the tuple rebuild into `['ok', []]` for `[1, 2]` against `[number, number]`. The rebuilds now walk the entry cons list the loop already builds — plain literals, property reads only — and place members with `Object.defineProperty` captured at module load, which consults nothing patchable; `eachEntry` walks by index for the same reason, `for..of` dispatching the patchable iterator. The intermediate array and the pairwise `concat` join are gone with the dispatch. Pinned in `host.proof.mjs` across all three container kinds. The verdict path still dispatches overridable operations after a read — recorded as `fjs/rtti/todo/hostile-accessor-hermetic-read-path.md`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- fjs/rtti/common/module.f.mjs | 23 ++- fjs/rtti/host.proof.mjs | 69 +++++++ fjs/rtti/parse/module.f.mjs | 178 +++++++++++------- .../hostile-accessor-hermetic-read-path.md | 54 ++++++ 4 files changed, 251 insertions(+), 73 deletions(-) create mode 100644 fjs/rtti/todo/hostile-accessor-hermetic-read-path.md diff --git a/fjs/rtti/common/module.f.mjs b/fjs/rtti/common/module.f.mjs index e0bdd519f..7a3d07002 100644 --- a/fjs/rtti/common/module.f.mjs +++ b/fjs/rtti/common/module.f.mjs @@ -106,10 +106,18 @@ export const isObject = * final accumulator. * * Used by `parse`'s container builders (array/record/tuple/struct), which - * need the rebuilt `[key, value]` pairs, so they fold them into a `List` (see - * the call site) and convert to an array once at the end. A caller whose - * whole question is "did every entry succeed?" passes `undefined`/`acc => acc` + * need the rebuilt `[key, value]` pairs, so they fold them onto a cons list + * (see the call site) their rebuilds walk directly. A caller whose whole + * question is "did every entry succeed?" passes `undefined`/`acc => acc` * instead and pays no allocation per entry. + * + * The walk is by index rather than `for..of`: `item` reads the value, and a + * read can run an accessor that replaces `Array.prototype`'s iterator — + * which `for..of` and destructuring dispatch on every step, so a later + * step's `[k, v]` was the accessor's to choose. Index and `length` reads + * consult nothing overridable on these plain entry arrays — the same rule + * `parse`'s rebuilds state in full (see `defineProperty` in + * `../parse/module.f.mjs`). */ export const eachEntry = /** @@ -124,12 +132,13 @@ export const eachEntry = */ (entries, item, init, accumulate) => { let acc = init - for (const [k, v] of entries) { - const r = item(k, v) + for (let i = 0; i < entries.length; i += 1) { + const e = entries[i] + const r = item(e[0], e[1]) if (r[0] === 'error') { - return prependPath(k, r) + return prependPath(e[0], r) } - acc = accumulate(acc, k, r[1]) + acc = accumulate(acc, e[0], r[1]) } return ok(acc) } diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index 23ccaf6ca..12bea47b5 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -199,6 +199,75 @@ export const proof = { assert(r[0] === 'ok', 'expected ok') assertStructurallySame(/** @type {readonly unknown[]} */ (r[1]), [1]) }, + // …and never dispatches an overridable operation at all. Reading a + // member can run arbitrary code — an accessor — and the rebuild runs + // after every read, so a getter that patches `Array.prototype.concat` + // (or `map`, `flatMap`, `slice`, `Object.fromEntries`) has patched it + // before any rebuild executes: a rebuild dispatching one of them was + // handed `['ok', []]` for `[1, 2]` against `[number, number]`. The + // fixed rebuilds construct with `defineProperty` captured at module + // load and walk their own cons list by property reads, so none of + // these patches reaches what `parse` builds. (The *verdict* path still + // dispatches overridable operations after a read — that exposure is + // `todo/hostile-accessor-hermetic-read-path.md`, and this fixture + // patches only what corrupts no verdict here.) + hostileIntrinsicPatchesDoNotReachTheRebuild: () => { + const captured = { + concat: Array.prototype.concat, + flatMap: Array.prototype.flatMap, + map: Array.prototype.map, + slice: Array.prototype.slice, + fromEntries: Object.fromEntries, + } + const patch = () => { + Array.prototype.concat = () => [] + Array.prototype.flatMap = () => [] + Array.prototype.map = () => [] + Array.prototype.slice = () => [] + Object.fromEntries = () => ({}) + } + const restore = () => { + Array.prototype.concat = captured.concat + Array.prototype.flatMap = captured.flatMap + Array.prototype.map = captured.map + Array.prototype.slice = captured.slice + Object.fromEntries = captured.fromEntries + } + /** @type {(v: Unknown) => () => Unknown} */ + const patchingGetter = v => () => { patch(); return v } + // The original repro: an index-0 getter that patches and returns `1`. + const tupleValue = [0, 2] + Object.defineProperty(tupleValue, 0, { + get: patchingGetter(1), + enumerable: true, + configurable: true, + }) + const rt = p([number, number])(tupleValue) + restore() + assert(rt[0] === 'ok', 'expected ok') + assertStructurallySame(/** @type {readonly unknown[]} */ (rt[1]), [1, 2]) + // The struct kind's `fromEntries` and the uniform array kind's + // `map` were the same seam. + const structValue = Object.defineProperty({ b: 2 }, 'a', { + get: patchingGetter(1), + enumerable: true, + configurable: true, + }) + const rs = p({ a: number, b: number })(structValue) + restore() + assert(rs[0] === 'ok', 'expected ok') + assertStructurallySame(rs[1], { a: 1, b: 2 }) + const arrayValue = [0, 2] + Object.defineProperty(arrayValue, 0, { + get: patchingGetter(1), + enumerable: true, + configurable: true, + }) + const ra = p(array(number))(arrayValue) + restore() + assert(ra[0] === 'ok', 'expected ok') + assertStructurallySame(/** @type {readonly unknown[]} */ (ra[1]), [1, 2]) + }, // …and `parse` **materializes** the inherited value as an own member of // what it builds: the member is *present* — HasProperty is what the // check dispatched on — so its parsed value is in the entries the diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index ff1f46ec0..aec04cd73 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -50,14 +50,12 @@ * @import { ConstObject, Info1, Tag1, Type } from '../types.ts' * @import { Result as CommonResult } from '../../types/result/types.ts' * @import { StringMap } from '../../types/object/types.ts' - * @import { List } from '../../types/list/types.ts' * @import { Container, Fits, IsContainer, SchemaEntries, ValidateE, ValidationError, Visitor } from '../common/types.ts' * @import { Unknown } from '../ts/types.ts' * @import { Parse } from './types.ts' */ import { ok } from '../../types/result/module.f.mjs' -import { reverse, toArray } from '../../types/list/module.f.mjs' import { absentMember, constPrimitiveValidate, @@ -76,81 +74,134 @@ import { emptyRest } from '../data/module.f.mjs' /** @typedef {CommonResult} _ItemResult */ -/** Rebuilds a parsed container from its `[key, parsedValue]` entries. */ -/** @typedef {(entries: ReadonlyArray) => Unknown} _Rebuild */ +/** + * The parsed `[key, parsedValue]` pairs as {@link consEntry} and + * {@link consPresent} fold them: a cons list in **reverse** member order, so + * its head is the last member parsed — for the array kinds, the highest + * present index. + */ +/** @typedef {null | { readonly first: readonly [string, Unknown], readonly tail: _Entries }} _Entries */ -/** @type {_Rebuild} */ -const arrayRebuild = entries => entries.map(([, v]) => v) +/** Rebuilds a parsed container from its entries. */ +/** @typedef {(entries: _Entries) => Unknown} _Rebuild */ +/** + * The rebuilds' one construction step, captured at module load. + * + * Reading a member of the value can run **arbitrary code** — an accessor — + * and the rebuild runs after every read, so by then that code may have + * replaced anything the language reaches by dynamic lookup: an + * `Array.prototype` method (`concat`, `map`, `flatMap`), the array + * iterator every `for..of` and destructuring dispatches, + * `Object.fromEntries`, the `Array` binding `new Array` resolves, or the + * `constructor`/`@@species` lookup inside every array method — even a + * *captured* `concat` builds its result through the receiver's species. A + * rebuild dispatching any of those was steered into `['ok', …]` values + * failing the very schema they were parsed against — see + * `../host.proof.mjs`. + * + * `defineProperty` on a fresh container consults none of that: it creates + * an own data property directly, the array exotic length update included. + * So the rebuilds walk the entry cons list — plain literals this module + * built — by property reads alone, place members with this one captured + * operation, and perform no other dynamic lookup at all (`+k` is the + * index read, `Number` being a patchable global). + */ +const { defineProperty } = Object + +/** The one `Array` the rebuilds construct with, captured at module load. */ +const PlainArray = Array + +/** The descriptor a literal would create: an enumerable own data property. */ +/** @type {(value: Unknown) => PropertyDescriptor} */ +const enumerableValue = value => + ({ value, writable: true, enumerable: true, configurable: true }) + +/** Restores member order from the reverse-order entries, in one linear pass. */ +/** @type {(entries: _Entries) => _Entries} */ +const reverseEntries = entries => { + /** @type {_Entries} */ + let r = null + for (let n = entries; n !== null; n = n.tail) { + r = { first: n.first, tail: r } + } + return r +} + +/** + * The uniform **array** kind's rebuild: the parsed elements, dense, in + * member order — placed back to front, since the entries arrive reversed. + */ /** @type {_Rebuild} */ -const recordRebuild = entries => Object.fromEntries(entries) +const arrayRebuild = entries => { + let length = 0 + for (let n = entries; n !== null; n = n.tail) { length += 1 } + const result = new PlainArray(length) + let i = length + for (let n = entries; n !== null; n = n.tail) { + i -= 1 + defineProperty(result, i, enumerableValue(n.first[1])) + } + return result +} /** - * One pairwise round of {@link tupleRebuild}'s join: adjacent segments are - * `concat`enated — always one argument, so no call ever spreads the segment - * list — halving the count while copying every element once. `concat` - * appends a spreadable operand element by *present* element, which is what - * keeps the holes; each receiver is a trusted plain array, so its species - * is `Array`. - * - * @type {(segments: ReadonlyArray>) => ReadonlyArray>} + * The **record** and **struct** kinds' rebuild: the parsed members as a fresh + * plain object, in member order — an absent declared member left no entry, + * so dropping its key needs nothing more. A key is *defined*, never + * assigned: assignment dispatches setters up the chain (`'__proto__'` + * among them), which is the same dynamic surface the rebuilds exist to + * avoid. */ -const joinSegmentsRound = segments => segments.flatMap((s, i) => - i % 2 !== 0 ? [] - : i + 1 < segments.length ? [s.concat(segments[i + 1])] - : [s]) +/** @type {_Rebuild} */ +const recordRebuild = entries => { + const result = {} + for (let n = reverseEntries(entries); n !== null; n = n.tail) { + defineProperty(result, n.first[0], enumerableValue(n.first[1])) + } + return result +} /** * The **tuple** kind's rebuild over its declared members — only the present - * ones reach `entries` (`recordRebuild` is the struct kind's counterpart, - * where dropping an absent key needs nothing more): the present members at - * their own indices, holes at the absent ones before them, ending at the - * last present position — so a trailing absent run shortens the result and - * an interior hole survives (materializing it as `undefined` would denote a - * different value, and omitting it would shift every position after it). + * ones reach `entries`: each at its own index, holes at the absent ones + * before them, ending at the last present position — so a trailing absent + * run shortens the result and an interior hole survives (materializing it + * as `undefined` would denote a different value, and omitting it would + * shift every position after it). The reversed entries' head *is* the last + * present position, so the length is known before the walk, and an index + * never defined stays a hole of `new PlainArray`'s making. * - * The construction is segments — a fresh `new Array(gap)` of holes before - * each present member, then the member — collected on an O(1)-prepend list - * and joined pairwise ({@link joinSegmentsRound}), so the whole rebuild is - * one linear pass plus a logarithmic number of halving rounds: re-spreading - * the accumulated segments per entry was quadratic, and one - * `concat(...segments)` call overflowed the engine's argument limit on a - * large enough prefix, throwing past the `Result` API. - * - * The input value is never consulted, and every array touched is a plain - * one this module made, which is the point: an earlier slice-then-map of - * the input let an accepted `Array` subclass override `slice` and hand - * `parse` a result that fails the very schema it was parsed against. An - * index the value only *inherits* is a present member (HasProperty is what - * the check dispatched on), so it sits in `entries` and is materialized as - * an own member of the result, carrying its parsed value — see + * The input value is never consulted, and nothing overridable is + * dispatched — see {@link defineProperty} above for why both matter: an + * accepted value supplied first a `slice` of its own and then, through an + * accessor, a patched `Array.prototype.concat`, and each steered a rebuild + * into a result that fails the schema it was parsed against. An index the + * value only *inherits* is a present member (HasProperty is what the check + * dispatched on), so it sits in `entries` and is materialized as an own + * member of the result, carrying its parsed value — see * `../host.proof.mjs`. * * @type {_Rebuild} */ const tupleRebuild = entries => { - /** @type {List>} */ - let reversed = null - let next = 0 - for (const [k, v] of entries) { - const i = Number(k) - if (i > next) { reversed = { first: new Array(i - next), tail: reversed } } - reversed = { first: [v], tail: reversed } - next = i + 1 - } - let segments = toArray(reverse(reversed)) - while (segments.length > 1) { - segments = joinSegmentsRound(segments) + if (entries === null) { return [] } + const result = new PlainArray(+entries.first[0] + 1) + /** @type {_Entries} */ + let n = entries + while (n !== null) { + defineProperty(result, n.first[0], enumerableValue(n.first[1])) + n = n.tail } - return segments.length === 0 ? [] : segments[0] + return result } /** `eachEntry`'s accumulator seed: entries are consed on in reverse as they parse. */ -/** @type {List} */ +/** @type {_Entries} */ const emptyEntries = null /** `eachEntry`'s accumulate step: an O(1) prepend, unlike rebuilding an array on every entry. */ -/** @type {(acc: List, k: string, v: Unknown) => List} */ +/** @type {(acc: _Entries, k: string, v: Unknown) => _Entries} */ const consEntry = (acc, k, v) => ({ first: [k, v], tail: acc }) @@ -162,7 +213,7 @@ const consEntry = (acc, k, v) => * `undefined` included, is a legal parse result, so no value could mark * absence. */ -/** @type {(acc: List, k: string, vs: ReadonlyArray) => List} */ +/** @type {(acc: _Entries, k: string, vs: ReadonlyArray) => _Entries} */ const consPresent = (acc, k, vs) => vs.length === 0 ? acc : ({ first: [k, vs[0]], tail: acc }) @@ -170,11 +221,6 @@ const consPresent = (acc, k, vs) => /** @type {readonly string[]} */ const noDeclared = [] -/** Restores forward order from `consEntry`'s reverse-order list, in one linear pass. */ -/** @type {(list: List) => ReadonlyArray} */ -const orderedEntries = list => - toArray(reverse(list)) - /** * Builds a parser for `array` or `record` schemas: rebuilds a fresh container * from each item's parsed result. The inner item parser is instantiated lazily @@ -208,12 +254,12 @@ const containerParse = const e = undeclaredMembers(noDeclared, value) if (e.length === 0) { return fits(value, 0) - ? /** @type {any} */ (ok(rebuild([]))) + ? /** @type {any} */ (ok(rebuild(null))) : verror('unexpected value') } const itemParse = /** @type {any} */ (parse(item)) const r = eachEntry(e, (_k, v) => itemParse(v), emptyEntries, consEntry) - return r[0] === 'error' ? r : /** @type {any} */ (ok(rebuild(orderedEntries(r[1])))) + return r[0] === 'error' ? r : /** @type {any} */ (ok(rebuild(r[1]))) } } @@ -287,7 +333,7 @@ const constContainerParse = ) if (r[0] === 'error') { return r } return undeclaredMembers(declared, value).length === 0 && fits(value, declared.length) - ? /** @type {any} */ (ok(rebuild(orderedEntries(r[1])))) + ? /** @type {any} */ (ok(rebuild(r[1]))) : verror('unexpected value') } } @@ -357,12 +403,12 @@ const restContainerParse = const extra = undeclaredMembers(declared, value) if (extra.length === 0) { return fits(value, declared.length) - ? ok(rebuild(orderedEntries(d[1]))) + ? ok(rebuild(d[1])) : verror('unexpected value') } const restParse = /** @type {any} */ (parse(r)) const e = eachEntry(extra, (_k, v) => restParse(v), undefined, noAccumulate) - return e[0] === 'error' ? e : ok(rebuild(orderedEntries(d[1]))) + return e[0] === 'error' ? e : ok(rebuild(d[1])) } } diff --git a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md new file mode 100644 index 000000000..c907c79a1 --- /dev/null +++ b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md @@ -0,0 +1,54 @@ +# The readers' verdict path dispatches overridable operations after a read + +**Priority:** P2 +**Status:** open + +## Problem + +Reading a member of a hostile value can run **arbitrary code** — an accessor +— and everything a reader does after that read trusts whatever the accessor +left behind. `parse`'s *rebuilds* no longer dispatch anything overridable +(see `defineProperty` in [`../parse/module.f.mjs`](../parse/module.f.mjs) +and `hostileIntrinsicPatchesDoNotReachTheRebuild` in +[`../host.proof.mjs`](../host.proof.mjs)), but the **verdict** path still +does, in [`../common/module.f.mjs`](../common/module.f.mjs) and its callers: + +- `undeclaredMembers`/`readIndices` build their member list with + `Object.entries`, `.filter`, `.map`, `.flatMap`, `.toSorted`, `.indexOf` + and array spreads — for the const kinds this runs *after* the declared + members were read, so a patching accessor steers which members the closed + check or a `rest` sees. The tuple length bound catches the simplest + variant, but a `rest` kind can be steered into accepting a value whose + undeclared members were never held to the rest. +- `visit` and `absenceIn` destructure the schema thunk's descriptor + (`const [tag, ...operands] = rtti()`), which dispatches + `Array.prototype[Symbol.iterator]` — patched, the accessor chooses the + tag, and with it the verdict. `absenceIn` also relies on `.some` and an + array spread; `orVisit` iterates its variants with `for..of`. +- `prependPath` spreads `r.path`, so a patched iterator can throw from the + error path, escaping the `Result` API. +- Globals resolved at call time — `Number`, `String`, `Object`, `Array` — + are reassignable through `globalThis` by the same accessor + (`arrayIndex`, `getItem`, `Object.entries` call sites). + +A wrong *accept* here is a plausible wrong value; the boundary is that the +accessor has already run arbitrary code in the host, so this hardening is +about the readers' own answers staying theirs, not about containing the +host. + +## Tasks + +- [ ] Extend the discipline the rebuilds and `eachEntry` state to the + post-read functions of `common/module.f.mjs`: capture the intrinsics + used (`Object.entries`, `Object.getOwnPropertyNames`, + `Object.getPrototypeOf`, `Object.hasOwn`, `Number.isInteger`, the + `Array`/`Number`/`String` bindings) at module load, and replace + `for..of`, destructuring, spreads and array methods on those paths + with index walks and cons/`defineProperty` construction. +- [ ] `readIndices`' sort for inherited indices needs a captured or + hand-rolled ordering; the dedup's shape is pinned by its JSDoc. +- [ ] Keep behavior bit-identical for non-patching values: the three-reader + tables and `../host.proof.mjs` pin member order and the + non-index/beyond-`length` rules. +- [ ] Pin each closed hole in `../host.proof.mjs` the way the rebuild fix + is pinned, restoring every patched intrinsic before asserting. From 5a5825f21a91e36c3f26b3355fc8554ac1c34dbb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 07:49:14 +0000 Subject: [PATCH 08/11] changelog: three wrapped lines per entry for #1748 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/1748.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/changelog/unreleased/1748.md b/changelog/unreleased/1748.md index b057e9078..8c0ac784b 100644 --- a/changelog/unreleased/1748.md +++ b/changelog/unreleased/1748.md @@ -1,13 +1,11 @@ - **BREAKING CHANGES:** `rtti`: `option` is a nullary schema denoting - **absence** — an omittable member is `or(option, t)`, which rejects a - present `undefined`; the old `option(t)` set is `or(option, t, undefined)`. - `{}` and `{ a: undefined }` are distinct sets. + **absence**: an omittable member is `or(option, t)`, which rejects a present + `undefined` — the old `option(t)` set is `or(option, t, undefined)`. - `rtti`: `parse` omits an absent member — the struct kind drops the key, the array kind keeps holes and shortens a trailing absent run — so an optional member survives a JSON round-trip. -- `rtti`: `unknown` excludes absence — the omittable top is - `or(option, unknown)`. `Ts<>`, the runtime printer and `toJsonSchema` - derive optionality (`?`, `required`, `minItems`) from absence, exact under - `exactOptionalPropertyTypes`. +- `rtti`: `unknown` excludes absence, so the omittable top is + `or(option, unknown)`; `Ts<>`, the runtime printer and `toJsonSchema` derive + optionality (`?`, `required`, `minItems`) from absence. - `rtti`: a `Phantom` annotation on a schema whose root admits absence carries the new `Absent` marker, pinned with the new `CheckRaw`. From fed8c16e26030ae6c8f8774b826ca1b44f10c01b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 08:02:44 +0000 Subject: [PATCH 09/11] rtti: parse refuses what prototype pollution makes unbuildable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A getter on a later declared member can install an earlier, already omitted declared key on `Object.prototype` (or an omitted position on `Array.prototype`), and then every fresh container inherits it: the member is present by the same HasProperty rule the readers dispatch on, so no plain container the rebuild could hand back denotes the value that was checked — `parse` returned `['ok', { b: 2 }]` whose payload fails the very schema it was parsed against. The declared-member kinds now check the omission's postcondition after the rebuild: every declared key the entries omitted must still be absent from what was built — `in` plus `Object.hasOwn` captured at module load, both internal operations, so the check itself dispatches nothing overridable — and refuse otherwise, per DESIGN.md §10. Pinned in `host.proof.mjs` for the struct, tuple and rest kinds. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- fjs/rtti/host.proof.mjs | 49 +++++++++++++++++++++++++++++++++++ fjs/rtti/parse/module.f.mjs | 51 +++++++++++++++++++++++++++++++------ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index 12bea47b5..393ce36ee 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -268,6 +268,55 @@ export const proof = { assert(ra[0] === 'ok', 'expected ok') assertStructurallySame(/** @type {readonly unknown[]} */ (ra[1]), [1, 2]) }, + // …and when the accessor **pollutes a prototype** instead, `parse` + // refuses. A getter on a later member can install an earlier, already + // omitted declared key on `Object.prototype` (or an omitted position on + // `Array.prototype`), and then every fresh container *inherits* it: the + // member is present by the same HasProperty rule the readers dispatch + // on, so no plain container the rebuild could hand back denotes the + // value that was checked — a success here carried a payload failing the + // very schema it was parsed against. Refusing is `omittedStillAbsent`'s + // case: the omission's postcondition no longer holds. + parseRefusesWhatPollutionMakesUnbuildable: () => { + const structValue = { b: 0 } + Object.defineProperty(structValue, 'b', { + get: () => { + /** @type {any} */ (Object.prototype).a = 'bad' + return 2 + }, + enumerable: true, + configurable: true, + }) + const rs = p({ a: or(option, number), b: number })(structValue) + delete (/** @type {any} */ (Object.prototype).a) + assertError(rs) + const tupleValue = [, 0] + Object.defineProperty(tupleValue, 1, { + get: () => { + /** @type {any} */ (Array.prototype)[0] = 'bad' + return 2 + }, + enumerable: true, + configurable: true, + }) + const rt = p([or(option, number), number])(tupleValue) + delete (/** @type {any} */ (Array.prototype))[0] + assertError(rt) + // …and the `rest` kinds decide omission the same way, so they hold + // the same postcondition. + const restValue = { b: 0 } + Object.defineProperty(restValue, 'b', { + get: () => { + /** @type {any} */ (Object.prototype).a = 'bad' + return 2 + }, + enumerable: true, + configurable: true, + }) + const rr = p(rest({ a: or(option, number) }, number))(restValue) + delete (/** @type {any} */ (Object.prototype).a) + assertError(rr) + }, // …and `parse` **materializes** the inherited value as an own member of // what it builds: the member is *present* — HasProperty is what the // check dispatched on — so its parsed value is in the entries the diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index aec04cd73..6b18b0779 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -221,6 +221,32 @@ const consPresent = (acc, k, vs) => /** @type {readonly string[]} */ const noDeclared = [] +/** The declared-member kinds' postcondition check's one lookup, captured at module load. */ +const { hasOwn } = Object + +/** + * Whether every declared member the rebuild **omitted** is still absent from + * `built` — the postcondition the omission was decided on. The accessor a + * member read can run may install the omitted key on `Object.prototype` (or + * an omitted position on `Array.prototype`), and then every fresh container + * *inherits* it: the member is present by the same HasProperty rule the + * readers dispatch on, so what was built no longer denotes the value that + * was checked — no plain container can, which is `verror`'s case, not a + * different construction's (see `../host.proof.mjs`). An omitted member + * reads `k in built` false; a present one is the rebuild's own; only an + * inherited declared key is the environment having changed underneath the + * parse. Both operations are internal — `in` runs no accessor — so the + * check itself dispatches nothing overridable. + * + * @type {(declared: readonly string[], built: ReadonlyArray | StringMap) => boolean} + */ +const omittedStillAbsent = (declared, built) => { + for (let i = 0; i < declared.length; i += 1) { + if (declared[i] in built && !hasOwn(built, declared[i])) { return false } + } + return true +} + /** * Builds a parser for `array` or `record` schemas: rebuilds a fresh container * from each item's parsed result. The inner item parser is instantiated lazily @@ -332,8 +358,12 @@ const constContainerParse = consPresent, ) if (r[0] === 'error') { return r } - return undeclaredMembers(declared, value).length === 0 && fits(value, declared.length) - ? /** @type {any} */ (ok(rebuild(r[1]))) + if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { + return verror('unexpected value') + } + const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(r[1])) + return omittedStillAbsent(declared, built) + ? /** @type {any} */ (ok(built)) : verror('unexpected value') } } @@ -402,13 +432,18 @@ const restContainerParse = if (d[0] === 'error') { return d } const extra = undeclaredMembers(declared, value) if (extra.length === 0) { - return fits(value, declared.length) - ? ok(rebuild(d[1])) - : verror('unexpected value') + if (!fits(value, declared.length)) { + return verror('unexpected value') + } + } else { + const restParse = /** @type {any} */ (parse(r)) + const e = eachEntry(extra, (_k, v) => restParse(v), undefined, noAccumulate) + if (e[0] === 'error') { return e } } - const restParse = /** @type {any} */ (parse(r)) - const e = eachEntry(extra, (_k, v) => restParse(v), undefined, noAccumulate) - return e[0] === 'error' ? e : ok(rebuild(d[1])) + const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(d[1])) + return omittedStillAbsent(declared, built) + ? ok(built) + : verror('unexpected value') } } From 08100561691b2d7718362f7630606cd24020ffd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 08:20:02 +0000 Subject: [PATCH 10/11] rtti: a Phantom annotation carries absence in a wrapper, never a union member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Absent | unknown` *is* `unknown`: a union-carried marker drowns when the annotation's present part renders as the top — `Ts<{}>` is native `unknown`, `StructTs`'s intersection identity — so `or(option, {})` behind a `Phantom` silently rendered its member required while the readers accept `{}`, and the union-shaped `CheckRaw` passed anyway, `_TsRaw` collapsing identically on both sides. The annotation now spells absence as the new `AbsentOr` wrapper — the same shape the runtime keeps, the data form's absent bit riding beside the union rather than in it — which survives any present type. `_AdmitsAbsence`, `_IsAbsentOnly`, `Ts` and `_TsRaw` read the wrapper first, and `CheckRaw` pins the flag half separately against the schema's structural answer, so the unwrapped spelling now fails the pin even at the top instead of passing silently. An absent-only root is `AbsentOr`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/1748.md | 4 +- fjs/rtti/ts/types.ts | 179 +++++++++++++++++++++++------------ fjs/types/phantom/types.ts | 19 ++-- 3 files changed, 134 insertions(+), 68 deletions(-) diff --git a/changelog/unreleased/1748.md b/changelog/unreleased/1748.md index 8c0ac784b..fc43b4781 100644 --- a/changelog/unreleased/1748.md +++ b/changelog/unreleased/1748.md @@ -7,5 +7,5 @@ - `rtti`: `unknown` excludes absence, so the omittable top is `or(option, unknown)`; `Ts<>`, the runtime printer and `toJsonSchema` derive optionality (`?`, `required`, `minItems`) from absence. -- `rtti`: a `Phantom` annotation on a schema whose root admits absence - carries the new `Absent` marker, pinned with the new `CheckRaw`. +- `rtti`: a `Phantom` annotation on a schema whose root admits absence wraps + its present part in the new `AbsentOr`, pinned with the new `CheckRaw`. diff --git a/fjs/rtti/ts/types.ts b/fjs/rtti/ts/types.ts index 7f58ed042..ce99fc017 100644 --- a/fjs/rtti/ts/types.ts +++ b/fjs/rtti/ts/types.ts @@ -25,37 +25,60 @@ declare const absentKey: unique symbol * `undefined` (which would make `or(undefined, number)` optional too and * conflate the very pair `option` exists to separate). * - * It appears only in {@link _TsRaw} results and in a `Phantom` annotation's - * raw shape; the public {@link Ts} strips it, and every container position - * lowers it for itself — a struct key or trailing tuple position renders - * optional, an interior tuple position renders `undefined` (what reading a - * hole gives), an array or record element excludes it. One caveat is - * inherent: the top absorbs it — `Absent` is assignable to `unknown`, and to - * the `Object` arm of {@link Unknown} — so no subtype query over a rendered - * type can recover it. Whether a member may be absent is therefore asked of - * the *schema*, by {@link _AdmitsAbsence}, never of the rendered union. + * It appears only in {@link _TsRaw} results; the public {@link Ts} strips + * it, and every container position lowers it for itself — a struct key or + * trailing tuple position renders optional, an interior tuple position + * renders `undefined` (what reading a hole gives), an array or record + * element excludes it. One caveat is inherent: the top absorbs it — + * `Absent` is assignable to `unknown`, and `Absent | unknown` *is* + * `unknown` — so neither a subtype query over a rendered type nor a union + * member can carry absence past a top-rendering present part. Whether a + * member may be absent is therefore asked of the *schema*, by + * {@link _AdmitsAbsence}, never of the rendered union — and a `Phantom` + * annotation carries it in {@link AbsentOr}'s wrapper, never as a union + * member. */ export type Absent = { readonly [absentKey]: typeof absentKey } +/** + * A `Phantom` annotation's spelling for a schema whose **root admits + * absence**: `AbsentOr` wraps the present part instead of unioning + * {@link Absent} into it, because a union member drowns in a top-rendering + * present part — `Absent | unknown` is `unknown`, and `or(option, {})` + * renders its present part as `unknown` (see {@link StructTs}) — while the + * branded wrapper survives any present type. This is the same shape the + * runtime keeps: the data form's absent bit rides *beside* the union, never + * in it. {@link _AdmitsAbsence}, {@link _IsAbsentOnly}, {@link Ts} and + * {@link _TsRaw} all read the wrapper first; {@link CheckRaw} pins its + * presence against the schema. An absent-only root — `option` itself — + * annotates as `AbsentOr`. + */ +export type AbsentOr = { readonly [absentKey]: T } + /** * Whether the schema type admits **absence** — the type-level counterpart of * `admitsAbsence` in `../common/module.f.mjs`, and the predicate * {@link StructTs} and {@link TupleTs} decide optionality with. Structural * over the schema: it recurses through `or` — which does no flattening, so * `or(or(option, number), string)` needs the recursion — and reads a - * `Phantom` annotation's raw shape for its `Absent` member. It is *not* a + * `Phantom` annotation for its {@link AbsentOr} wrapper. It is *not* a * subtype query against the rendered type: neither `Absent extends Ts<…>` * (false for every member — `Ts` strips the marker) nor * `Absent extends _TsRaw<…>` (true at `unknown`, whose top absorbs the * marker) can answer it — `{ a: unknown }`, which rejects `{}`, would render * indistinguishably from `{ a: or(option, unknown) }`, which accepts it. + * Nor is it a union-membership query over the annotation: + * `Extract` read absence out of `Absent | number`, but + * `Absent | unknown` has already collapsed to `unknown` — the marker + * drowned with nothing to extract, and the member rendered required. The + * wrapper is what survives a top-rendering present part. */ export type _AdmitsAbsence = unknown extends T ? false : true extends _AdmitsAbsence1 ? true : false type _AdmitsAbsence1 = - T extends { readonly [phantomKey]?: infer O } ? ([Extract] extends [never] ? false : true) : + T extends { readonly [phantomKey]?: infer O } ? ([O] extends [{ readonly [absentKey]: unknown }] ? true : false) : T extends () => infer I ? I extends readonly['option'] ? true : I extends readonly['or', ...infer A extends readonly Type[]] ? _AdmitsAbsence1 @@ -73,18 +96,20 @@ type _AdmitsAbsence1 = * A `Phantom` annotation is read **before** the thunk walk, exactly as * {@link _AdmitsAbsence} and `Ts` read it: a phantom-wrapped schema is still * a thunk, so descending its `or` chain would re-expand the very recursion - * the annotation exists to spare (TS2589). The annotation is `_TsRaw`-shaped, - * so its present part is what survives `Exclude` — - * the same `Exclude` the public `Ts` applies — and "absent-only" is that - * part being `never`. (`undefined` there is the optional-field artifact the - * `Phantom` contract already excludes from annotations, not a value member.) + * the annotation exists to spare (TS2589). An absence-admitting root + * annotates as {@link AbsentOr}``, so "absent-only" is a wrapper + * whose present part is `never` — `AbsentOr` — and an unwrapped + * annotation admits no absence at all. (`undefined` is stripped as the + * optional-field artifact the `Phantom` contract already excludes from + * annotations, not as a value member.) */ type _IsAbsentOnly = unknown extends T ? false : false extends _IsAbsentOnly1 ? false : true type _IsAbsentOnly1 = - T extends { readonly [phantomKey]?: infer O } ? ([Exclude] extends [never] ? true : false) : + T extends { readonly [phantomKey]?: infer O } + ? ([O] extends [{ readonly [absentKey]: infer P }] ? ([Exclude] extends [never] ? true : false) : false) : T extends () => infer I ? I extends readonly['option'] ? true : I extends readonly['or', ...infer A extends readonly Type[]] ? _IsAbsentOnly1 @@ -389,16 +414,19 @@ export type StructTs = * type _Check = Assert> * ``` * - * **A schema whose root admits absence needs one more assert.** The - * annotation is {@link _TsRaw}-shaped, so when the wrapped schema's root is - * `or(option, …)` it must carry the {@link Absent} marker — - * `Phantom` — or the member it is used at - * renders required. The pair above cannot catch the omission: both compare - * through the public `Ts`, which strips `Absent` from both sides. Pin the - * raw half with {@link CheckRaw}: + * **A schema whose root admits absence needs one more assert.** When the + * wrapped schema's root is `or(option, …)` the annotation must carry the + * flag in {@link AbsentOr}'s wrapper — + * `Phantom>` — or the member it is used at + * renders required. The wrapper, not a union: `Absent | MyType` drowns when + * `MyType` renders as the top (`Absent | unknown` *is* `unknown`), and the + * marker takes the optionality with it. The pair above cannot catch the + * omission either way: both compare through the public `Ts`, which strips + * absence from both sides. Pin the flag and the present part together with + * {@link CheckRaw}: * * ```ts - * type _CheckRaw = Assert> + * type _CheckRaw = Assert, typeof myThunk>> * ``` * * See `fjs/edag/module.f.mjs` (`_exp`/`exp`) for this in practice. Note also @@ -426,9 +454,11 @@ export type Ts = unknown extends T ? Unknown : // Phantom output: if the schema carries a phantomKey annotation (via WithOut), return // it directly — one indexed-access, no structural walk, no TS2589 for recursive - // schemas. The annotation is `_TsRaw`-shaped, so the `Absent` marker is stripped - // here alongside the optional-field `undefined` artifact. - T extends { readonly [phantomKey]?: infer O } ? Exclude : + // schemas. An absence-admitting root annotates as `AbsentOr`, so the + // wrapper is unwrapped here; either way the optional-field `undefined` + // artifact is stripped. + T extends { readonly [phantomKey]?: infer O } + ? ([O] extends [{ readonly [absentKey]: infer P }] ? Exclude : Exclude) : T extends () => infer I ? ( I extends readonly['const', infer C] ? ConstTs : // Info0 @@ -457,16 +487,19 @@ export type Ts = * The {@link Absent}-preserving counterpart of {@link Ts}, differing only at * the **root** of a schema — the one place absence has no container position * to lower it into: `_TsRaw` is `Absent | number` - * where the public `Ts` is `number`. It walks `or` chains and reads a - * `Phantom` annotation verbatim (minus the optional-field `undefined` - * artifact), and delegates every other form to `Ts` — container positions - * lower the marker for themselves, so below the root the two agree. Its two - * consumers are a `Phantom` annotation's shape and {@link CheckRaw}, which - * pins one. + * where the public `Ts` is `number`. It walks `or` chains and unwraps a + * `Phantom` annotation's {@link AbsentOr} back into that union shape (minus + * the optional-field `undefined` artifact), and delegates every other form + * to `Ts` — container positions lower the marker for themselves, so below + * the root the two agree. Note the union shape *collapses at the top* + * (`Absent | unknown` is `unknown`), which is exactly why an annotation + * spells absence as the wrapper and why {@link CheckRaw} pins the flag + * separately rather than through this union. */ export type _TsRaw = unknown extends T ? Unknown : - T extends { readonly [phantomKey]?: infer O } ? Exclude : + T extends { readonly [phantomKey]?: infer O } + ? ([O] extends [{ readonly [absentKey]: infer P }] ? Absent | Exclude : Exclude) : T extends () => infer I ? ( I extends readonly['option'] ? Absent : I extends readonly['or', ...infer A extends readonly Type[]] ? _TsRaw : @@ -493,18 +526,28 @@ export type Check = Equal> export type Check3 = And>, Equal>> /** - * The **raw** counterpart of {@link Check}: pins `A` against - * {@link _TsRaw}``, the {@link Absent}-preserving shape. This is the - * assert with teeth for a `Phantom` annotation on a schema whose root admits - * absence: {@link Check} and {@link Check3} compare through the public - * {@link Ts}, which strips `Absent` from *both* sides, so they pass even - * when the annotation forgot the marker — and the wrapped member then - * renders required. Spell the annotation `Absent | …` and add - * `Assert>` beside the usual pair; a - * schema whose root excludes absence needs nothing new, `_TsRaw` and `Ts` - * agreeing there. - */ -export type CheckRaw = Equal> + * The **raw** counterpart of {@link Check}: pins a `Phantom` annotation `A` + * against the schema `B` in both halves — the **flag** ({@link AbsentOr}'s + * wrapper is present on `A` exactly when `B`'s root admits absence, + * structurally) and the **present part** (`A`'s, against `_TsRaw` with + * the marker stripped). This is the assert with teeth for a schema whose + * root admits absence: {@link Check} and {@link Check3} compare through the + * public {@link Ts}, which strips absence from *both* sides, so they pass + * even when the annotation forgot the wrapper — and the wrapped member then + * renders required. The flag half is deliberately not a comparison through + * `_TsRaw`'s union, where `Absent | unknown` has already collapsed and a + * missing marker passed: spell the annotation `AbsentOr<…>` and add + * `Assert, typeof rawThunk>>` beside the usual pair; a + * schema whose root excludes absence needs nothing new — its annotation is + * unwrapped, and this then agrees with {@link Check} on the raw thunk. + */ +export type CheckRaw = And< + Equal<[A] extends [{ readonly [absentKey]: unknown }] ? true : false, _AdmitsAbsence>, + Equal< + [A] extends [{ readonly [absentKey]: infer P }] ? P : A, + Exclude<_TsRaw, Absent> + > +> // Fast-path: Ts resolves to Unknown without TS2589 overflow. type _any = Assert> @@ -696,7 +739,7 @@ type _restEmptyIndirect = Assert> type _optionUnion = Assert>> -type _optionUnionRaw = Assert>> +type _optionUnionRaw = Assert, Or>> /** * The type-level counterpart of "a rest never sees it": an array or record @@ -718,21 +761,41 @@ type _nestedOptionKey = Assert> /** - * A `Phantom` annotation is {@link _TsRaw}-shaped: wrapping a schema whose - * root admits absence, it carries {@link Absent}, which - * {@link _AdmitsAbsence} reads from the annotation and the container - * position lowers — the wrapped member renders optional. {@link CheckRaw} - * is the assert with teeth for the annotation itself: the {@link Check} - * pair passes with or without the marker, both halves stripping it. + * A `Phantom` annotation on a schema whose root admits absence carries the + * flag in {@link AbsentOr}'s wrapper, which {@link _AdmitsAbsence} reads + * from the annotation and the container position lowers — the wrapped + * member renders optional. {@link CheckRaw} is the assert with teeth for + * the annotation itself: the {@link Check} pair passes with or without the + * wrapper, both halves stripping absence. */ -type _PhantomOption = Phantom, Absent | number> -type _phantomRaw = Assert>> +type _PhantomOption = Phantom, AbsentOr> +type _phantomRaw = Assert, Or>> type _phantomPublic = Assert> type _phantomOptionalMember = Assert> +/** + * The wrapper's reason to exist: a present part that renders as the **top** + * absorbs a union member — `Absent | Ts<{}>` is `unknown`, {@link StructTs} + * rendering the empty struct as its `unknown` intersection identity — so a + * union-carried marker drowned, the member rendered required, and the + * union-shaped `CheckRaw` passed anyway, `_TsRaw` collapsing identically on + * both sides. The wrapper survives the collapse, and the flag half of + * {@link CheckRaw} fails the unwrapped spelling even at the top. + */ +type _PhantomTopOption = Phantom, AbsentOr> +type _phantomTopRaw = Assert, Or>> +type _phantomTopMember = Assert> +type _phantomTopUnwrappedFails = Assert>, + false +>> + /** * The `Phantom` short-circuit holds at every structural predicate, not only * in `Ts`: a phantom-wrapped schema is still a thunk, so a predicate that @@ -744,7 +807,7 @@ type _phantomOptionalMember = Assert readonly['or', RttiOption, RttiNumber, _PhantomRecThunk] -type _PhantomRec = Phantom<_PhantomRecThunk, Absent | number> +type _PhantomRec = Phantom<_PhantomRecThunk, AbsentOr> type _phantomRecursiveArray = Assert readonly['array', _PhantomRec] @@ -755,5 +818,5 @@ type _phantomRecursiveMember = Assert> type _phantomAbsentOnlyArray = Assert readonly['array', Phantom] + () => readonly['array', Phantom>] >> diff --git a/fjs/types/phantom/types.ts b/fjs/types/phantom/types.ts index 8028eacda..ed13afe80 100644 --- a/fjs/types/phantom/types.ts +++ b/fjs/types/phantom/types.ts @@ -37,16 +37,19 @@ export type { phantomKey } * type _Check = Assert> * ``` * - * For an rtti schema the annotation is **`_TsRaw`-shaped**: when the wrapped - * schema's *root* admits absence — `or(option, …)` — `T` must carry the - * `Absent` marker (`Absent | MyType`), or a member the wrapped schema is - * used at silently renders required. The pair above cannot catch the - * omission, both halves comparing through the public `Ts<>`, which strips - * `Absent` from both sides — so such a schema **requires** the raw assert - * beside them, with `CheckRaw` and `Absent` from `fjs/rtti/ts/types.ts`: + * For an rtti schema: when the wrapped schema's *root* admits absence — + * `or(option, …)` — `T` must carry the flag in the `AbsentOr` wrapper + * (`AbsentOr`), or a member the wrapped schema is used at silently + * renders required. A wrapper rather than an `Absent | MyType` union, + * because a union member drowns when `MyType` renders as the top — + * `Absent | unknown` *is* `unknown` — taking the optionality with it. The + * pair above cannot catch the omission, both halves comparing through the + * public `Ts<>`, which strips absence from both sides — so such a schema + * **requires** the raw assert beside them, with `CheckRaw` and `AbsentOr` + * from `fjs/rtti/ts/types.ts`: * * ```ts - * type _CheckRaw = Assert> + * type _CheckRaw = Assert, typeof rawThunk>> * ``` * * A schema whose root excludes absence needs nothing new — `_TsRaw` and From 14918766c3da691cd5312cf3ced40f05debec7c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 09:19:52 +0000 Subject: [PATCH 11/11] rtti: the three readers refuse a mid-read presence flip identically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pollution refusal lived in `parse` alone, so the readers disagreed on exactly that input class: a getter installing an omitted key on `Object.prototype` was refused by `parse` while `validate` and the data form handed back a value that no longer denotes what was checked — and a value one reader accepts and another rejects is a bug in whichever walk differs, by `host.proof.mjs`'s own table. Every declared-member walk now records each member's presence and re-asks it last (`presenceUnchanged` in the shared kernel), after everything that reads the value, refusing on any flip in either direction — an omitted key made present by pollution, or a checked own key deleted by a later accessor. Pinned across all three readers, both directions, all declared-member kinds. The one deliberate residual split is a value with **no prototype**: pollution cannot flip its own absence, so the hands-back readers return it — still a faithful member of the set — while `parse`, whose every plain container now inherits the omitted key, refuses per DESIGN.md §10. Pinned as such. The changelog entry the hardening was missing is added. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/1748.md | 3 + fjs/rtti/common/module.f.mjs | 42 +++++- fjs/rtti/common/types.ts | 8 ++ fjs/rtti/data/module.f.mjs | 75 +++++++---- fjs/rtti/host.proof.mjs | 124 ++++++++++++------ fjs/rtti/parse/module.f.mjs | 41 ++++-- .../hostile-accessor-hermetic-read-path.md | 7 +- fjs/rtti/validate/module.f.mjs | 59 ++++++--- 8 files changed, 261 insertions(+), 98 deletions(-) diff --git a/changelog/unreleased/1748.md b/changelog/unreleased/1748.md index fc43b4781..75e07942a 100644 --- a/changelog/unreleased/1748.md +++ b/changelog/unreleased/1748.md @@ -9,3 +9,6 @@ optionality (`?`, `required`, `minItems`) from absence. - `rtti`: a `Phantom` annotation on a schema whose root admits absence wraps its present part in the new `AbsentOr`, pinned with the new `CheckRaw`. +- `rtti`: `parse` builds its result without dispatching any overridable + operation, and all three readers refuse a value whose accessors flip a + decided member's presence mid-read, instead of answering wrongly. diff --git a/fjs/rtti/common/module.f.mjs b/fjs/rtti/common/module.f.mjs index 7a3d07002..85861c59a 100644 --- a/fjs/rtti/common/module.f.mjs +++ b/fjs/rtti/common/module.f.mjs @@ -36,7 +36,7 @@ * @import { Const, Info0, Primitive0, Struct, Tag1, Tuple, Type } from '../types.ts' * @import { Error, Result as CommonResult } from '../../types/result/types.ts' * @import { StringMap } from '../../types/object/types.ts' - * @import { Validate, Visitor, IsContainer, Container, ResultE, SchemaEntries, ValidateE, ValidationError } from './types.ts' + * @import { Validate, Visitor, IsContainer, Container, Presence, ResultE, SchemaEntries, ValidateE, ValidationError } from './types.ts' */ import { assert } from '../../asserts/module.f.mjs' @@ -143,6 +143,46 @@ export const eachEntry = return ok(acc) } +/** {@link consPresence}'s seed and {@link presenceUnchanged}'s empty walk. */ +/** @type {Presence} */ +export const emptyPresence = null + +/** + * `eachEntry`'s accumulate step recording each declared member's + * **presence** — the item's `ok` payload, `true` for a member the walk saw + * present — one cons per member, newest first. + */ +/** @type {(acc: Presence, k: string, present: boolean) => Presence} */ +export const consPresence = (acc, _k, present) => + ({ first: present, tail: acc }) + +/** + * Whether each declared member's presence is still what the walk saw — the + * postcondition every absence decision was made under. A member's read can + * run an accessor, and a later member's accessor can flip an *earlier*, + * already decided member: install the omitted key on `Object.prototype` + * (or an omitted position on `Array.prototype`) and the member is present + * by the same HasProperty rule the walk dispatched on; delete an own key + * and a checked member is gone. Either way the verdict is stale — a + * hands-back reader would return a value that no longer denotes what was + * checked, and a constructing one built from decisions that no longer hold + * — so every reader re-asks the one question last, after everything that + * reads the value, and refuses on any flip. `reversed` is the walk's + * answers newest-first and exactly one per declared member, so the + * comparison walks `entries` from its end in lockstep; `in` runs no + * accessor, so the recheck itself reads nothing of the value's. + * + * @type {(entries: ReadonlyArray, reversed: Presence, value: ReadonlyArray | StringMap) => boolean} + */ +export const presenceUnchanged = (entries, reversed, value) => { + let i = entries.length + for (let n = reversed; n !== null; n = n.tail) { + i -= 1 + if ((entries[i][0] in value) !== n.first) { return false } + } + return true +} + /** * What a `Tuple` schema declares, read by **length**. * diff --git a/fjs/rtti/common/types.ts b/fjs/rtti/common/types.ts index 8c6055200..7c1079e5e 100644 --- a/fjs/rtti/common/types.ts +++ b/fjs/rtti/common/types.ts @@ -85,5 +85,13 @@ export type Container = K extends 'array' /** `Result` with the payload type erased; avoids instantiating `Ts`. */ export type ResultE = CommonResult +/** + * The presence bits a declared-member walk saw — one boolean per declared + * member, consed newest-first by `consPresence` in `module.f.mjs`, so the + * list is the walk's answers in reverse declared order. `presenceUnchanged` + * is the consumer. + */ +export type Presence = null | { readonly first: boolean, readonly tail: Presence } + /** A `Validate`-shaped function with the payload type erased. */ export type ValidateE = (value: Unknown) => ResultE diff --git a/fjs/rtti/data/module.f.mjs b/fjs/rtti/data/module.f.mjs index 14a137eb6..76e8d8d9a 100644 --- a/fjs/rtti/data/module.f.mjs +++ b/fjs/rtti/data/module.f.mjs @@ -25,7 +25,7 @@ import { assert, assertNotNullish } from '../../asserts/module.f.mjs' import { at, definedEntries, definedValues } from '../../types/object/module.f.mjs' import { ok } from '../../types/result/module.f.mjs' -import { eachEntry, isArray, undeclaredMembers, verror } from '../common/module.f.mjs' +import { consPresence, eachEntry, emptyPresence, isArray, presenceUnchanged, undeclaredMembers, verror } from '../common/module.f.mjs' /** * The unit kind's enumeration: bit `1 << i` of a {@link UnionSet}'s `unit` @@ -1244,13 +1244,18 @@ const patternsValidate = (k, item, value) => { const arraySetValidate = rules => p => value => { const pn = p.prefix.length const { rest } = p + const prefixEntries = Object.entries(p.prefix) const declared = eachEntry( - Object.entries(p.prefix), - (k, n) => k in value - ? nodeValidate(rules)(n)(value[Number(k)]) - : nodeAdmitsAbsence(rules)(n) ? ok(undefined) : verror('unexpected value'), - undefined, - noAccumulate, + prefixEntries, + (k, n) => { + if (!(k in value)) { + return nodeAdmitsAbsence(rules)(n) ? ok(false) : verror('unexpected value') + } + const m = nodeValidate(rules)(n)(value[Number(k)]) + return m[0] === 'error' ? m : ok(true) + }, + emptyPresence, + consPresence, ) if (declared[0] === 'error') { return declared } const extra = undeclaredMembers(p.prefix.map((_, i) => String(i)), value) @@ -1261,34 +1266,52 @@ const arraySetValidate = rules => p => value => { // Schema as `items: false`. A *shorter* array is another matter — the // declared loop above has already held every position it left unfilled // to a set admitting `undefined`. - return extra.length === 0 && value.length <= pn - ? ok(value) - : verror('unexpected value') + if (extra.length !== 0 || value.length > pn) { + return verror('unexpected value') + } + } else { + const r = eachEntry(extra, (_k, v) => nodeValidate(rules)(rest)(v), undefined, noAccumulate) + if (r[0] === 'error') { return r } } - const r = eachEntry(extra, (_k, v) => nodeValidate(rules)(rest)(v), undefined, noAccumulate) - return r[0] === 'error' ? r : ok(value) + // Re-asked last, after everything that reads the value: a member's + // accessor can flip an earlier, already decided position's presence — + // the same postcondition the schema-form readers hold, so the three + // readers refuse the flip identically (see `../host.proof.mjs`). + return presenceUnchanged(prefixEntries, declared[1], value) + ? ok(value) + : verror('unexpected value') } /** @type {(rules: RuleSet) => (p: ObjectSet) => (value: StringMap) => ResultE} */ const objectSetValidate = rules => p => value => { + const propEntries = definedEntries(p.props) const declared = eachEntry( - definedEntries(p.props), - (k, n) => k in value - ? nodeValidate(rules)(n)(value[k]) - : nodeAdmitsAbsence(rules)(n) ? ok(undefined) : verror('unexpected value'), - undefined, - noAccumulate, + propEntries, + (k, n) => { + if (!(k in value)) { + return nodeAdmitsAbsence(rules)(n) ? ok(false) : verror('unexpected value') + } + const m = nodeValidate(rules)(n)(value[k]) + return m[0] === 'error' ? m : ok(true) + }, + emptyPresence, + consPresence, ) if (declared[0] === 'error') { return declared } const { rest } = p - if (rest === undefined) { return ok(value) } - const extra = eachEntry( - Object.entries(value).filter(([k]) => at(k)(p.props) === null), - (_k, v) => nodeValidate(rules)(rest)(v), - undefined, - noAccumulate, - ) - return extra[0] === 'error' ? extra : ok(value) + if (rest !== undefined) { + const extra = eachEntry( + Object.entries(value).filter(([k]) => at(k)(p.props) === null), + (_k, v) => nodeValidate(rules)(rest)(v), + undefined, + noAccumulate, + ) + if (extra[0] === 'error') { return extra } + } + // The same last re-ask as `arraySetValidate`'s, one kind over. + return presenceUnchanged(propEntries, declared[1], value) + ? ok(value) + : verror('unexpected value') } /** @type {(rules: RuleSet) => (u: UnionSet) => (value: Unknown) => ResultE} */ diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index 393ce36ee..0277b0b2b 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -19,6 +19,7 @@ * * @import { Type } from './types.ts' * @import { ValidateE } from './common/types.ts' + * @import { StringMap } from '../types/object/types.ts' * @import { Unknown } from './ts/types.ts' */ @@ -268,54 +269,93 @@ export const proof = { assert(ra[0] === 'ok', 'expected ok') assertStructurallySame(/** @type {readonly unknown[]} */ (ra[1]), [1, 2]) }, - // …and when the accessor **pollutes a prototype** instead, `parse` - // refuses. A getter on a later member can install an earlier, already - // omitted declared key on `Object.prototype` (or an omitted position on - // `Array.prototype`), and then every fresh container *inherits* it: the - // member is present by the same HasProperty rule the readers dispatch - // on, so no plain container the rebuild could hand back denotes the - // value that was checked — a success here carried a payload failing the - // very schema it was parsed against. Refusing is `omittedStillAbsent`'s - // case: the omission's postcondition no longer holds. - parseRefusesWhatPollutionMakesUnbuildable: () => { - const structValue = { b: 0 } - Object.defineProperty(structValue, 'b', { - get: () => { - /** @type {any} */ (Object.prototype).a = 'bad' - return 2 - }, + // …and when the accessor **flips the presence** of an already decided + // member instead, every reader refuses — identically, which is the + // agreement this file's tables exist to hold. A later member's getter + // can install an earlier, omitted key on `Object.prototype` (or an + // omitted position on `Array.prototype`) — the member is then present + // by the same HasProperty rule the walk dispatched on — or delete a + // checked own key; either way the verdict was made under a presence + // that no longer holds: a hands-back reader would return a value that + // no longer denotes what was checked, and the constructing one would + // build from stale decisions. All three re-ask presence last + // (`presenceUnchanged`), after everything that reads the value. + presenceFlipsAreRefusedByAllReaders: () => { + /** @type {(pollute: () => void) => StringMap} */ + const structWith = pollute => Object.defineProperty({ b: 0 }, 'b', { + get: () => { pollute(); return 2 }, enumerable: true, configurable: true, }) - const rs = p({ a: or(option, number), b: number })(structValue) - delete (/** @type {any} */ (Object.prototype).a) - assertError(rs) - const tupleValue = [, 0] - Object.defineProperty(tupleValue, 1, { - get: () => { - /** @type {any} */ (Array.prototype)[0] = 'bad' - return 2 - }, - enumerable: true, - configurable: true, - }) - const rt = p([or(option, number), number])(tupleValue) - delete (/** @type {any} */ (Array.prototype))[0] - assertError(rt) - // …and the `rest` kinds decide omission the same way, so they hold - // the same postcondition. - const restValue = { b: 0 } - Object.defineProperty(restValue, 'b', { - get: () => { - /** @type {any} */ (Object.prototype).a = 'bad' - return 2 - }, + const polluteObject = () => { /** @type {any} */ (Object.prototype).a = 'bad' } + const unpolluteObject = () => { delete (/** @type {any} */ (Object.prototype).a) } + for (const read of [v, p, d]) { + // absent → present, struct + const rs = read({ a: or(option, number), b: number })(structWith(polluteObject)) + unpolluteObject() + assertError(rs) + // absent → present, tuple + const tv = [, 0] + Object.defineProperty(tv, 1, { + get: () => { /** @type {any} */ (Array.prototype)[0] = 'bad'; return 2 }, + enumerable: true, + configurable: true, + }) + const rt = read([or(option, number), number])(tv) + delete (/** @type {any} */ (Array.prototype))[0] + assertError(rt) + // absent → present behind a stated rest — the `rest` kinds + // decide omission the same way, so they hold the same + // postcondition + const rr = read(rest({ a: or(option, number) }, number))(structWith(polluteObject)) + unpolluteObject() + assertError(rr) + // present → absent: a later getter deletes a checked own member + /** @type {any} */ + let dv = { a: 1, b: 0 } + dv = Object.defineProperty(dv, 'b', { + get: () => { delete dv.a; return 2 }, + enumerable: true, + configurable: true, + }) + assertError(read({ a: number, b: number })(dv)) + } + }, + // A value with **no prototype** is the residual split, and a deliberate + // one: pollution cannot flip such a value's own absence — the omitted + // key still reads nothing anywhere on its (empty) chain — so the + // hands-back readers return the value, still a faithful member of the + // set. `parse` builds plain containers, and every plain container now + // *inherits* the omitted key, so nothing it could build denotes the + // value it checked: it refuses (`omittedStillAbsent`), per DESIGN.md + // §10. Each reader honest to its own contract — return what was given, + // or build only what the schema still accepts. + nullPrototypePollutionSplitsByContract: () => { + const schema = { a: or(option, number), b: number } + /** @type {() => StringMap} */ + const make = () => Object.defineProperty(Object.create(null), 'b', { + get: () => { /** @type {any} */ (Object.prototype).a = 'bad'; return 2 }, enumerable: true, configurable: true, }) - const rr = p(rest({ a: or(option, number) }, number))(restValue) - delete (/** @type {any} */ (Object.prototype).a) - assertError(rr) + const unpollute = () => { delete (/** @type {any} */ (Object.prototype).a) } + const rv = v(schema)(make()) + unpollute() + assertOk(rv) + const rd = d(schema)(make()) + unpollute() + assertOk(rd) + const rp = p(schema)(make()) + unpollute() + assertError(rp) + // …and the same split one kind over, behind a stated rest. + const restSchema = rest({ a: or(option, number) }, number) + const rrv = v(restSchema)(make()) + unpollute() + assertOk(rrv) + const rrp = p(restSchema)(make()) + unpollute() + assertError(rrp) }, // …and `parse` **materializes** the inherited value as an own member of // what it builds: the member is *present* — HasProperty is what the diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 6b18b0779..d155ac0c6 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -50,7 +50,7 @@ * @import { ConstObject, Info1, Tag1, Type } from '../types.ts' * @import { Result as CommonResult } from '../../types/result/types.ts' * @import { StringMap } from '../../types/object/types.ts' - * @import { Container, Fits, IsContainer, SchemaEntries, ValidateE, ValidationError, Visitor } from '../common/types.ts' + * @import { Container, Fits, IsContainer, Presence, SchemaEntries, ValidateE, ValidationError, Visitor } from '../common/types.ts' * @import { Unknown } from '../ts/types.ts' * @import { Parse } from './types.ts' */ @@ -63,6 +63,7 @@ import { isArray, isObject, orVisit, + presenceUnchanged, primitive0Validate, structSchemaEntries, tupleSchemaEntries, @@ -76,7 +77,7 @@ import { emptyRest } from '../data/module.f.mjs' /** * The parsed `[key, parsedValue]` pairs as {@link consEntry} and - * {@link consPresent} fold them: a cons list in **reverse** member order, so + * {@link consDeclared} fold them: a cons list in **reverse** member order, so * its head is the last member parsed — for the array kinds, the highest * present index. */ @@ -205,17 +206,27 @@ const emptyEntries = null const consEntry = (acc, k, v) => ({ first: [k, v], tail: acc }) +/** What the declared-member fold carries: the present entries, and every member's presence bit. */ +/** @typedef {{ readonly entries: _Entries, readonly presence: Presence }} _Declared */ + +/** {@link consDeclared}'s seed. */ +/** @type {_Declared} */ +const emptyDeclared = { entries: null, presence: null } + /** * `eachEntry`'s accumulate step over *declared* members, whose item wraps a * present member's parsed value in a one-element list and an absent member * in an empty one: the present value is kept, the absent member leaves no * entry. The wrapping is what stands in for a sentinel — every value, * `undefined` included, is a legal parse result, so no value could mark - * absence. + * absence. The presence bit is kept for every member either way — it is + * what `presenceUnchanged` re-asks after everything that reads the value. */ -/** @type {(acc: _Entries, k: string, vs: ReadonlyArray) => _Entries} */ -const consPresent = (acc, k, vs) => - vs.length === 0 ? acc : ({ first: [k, vs[0]], tail: acc }) +/** @type {(acc: _Declared, k: string, vs: ReadonlyArray) => _Declared} */ +const consDeclared = (acc, k, vs) => ({ + entries: vs.length === 0 ? acc.entries : { first: [k, vs[0]], tail: acc.entries }, + presence: { first: vs.length !== 0, tail: acc.presence }, +}) /** A uniform container declares no member by name, so every one is undeclared. */ /** @type {readonly string[]} */ @@ -354,14 +365,17 @@ const constContainerParse = const p = /** @type {any} */ (parse(t))(getItem(value, k)) return p[0] === 'error' ? p : ok([p[1]]) }, - emptyEntries, - consPresent, + emptyDeclared, + consDeclared, ) if (r[0] === 'error') { return r } if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { return verror('unexpected value') } - const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(r[1])) + if (!presenceUnchanged(rttiEntries, r[1].presence, value)) { + return verror('unexpected value') + } + const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(r[1].entries)) return omittedStillAbsent(declared, built) ? /** @type {any} */ (ok(built)) : verror('unexpected value') @@ -426,8 +440,8 @@ const restContainerParse = const p = /** @type {any} */ (parse(t))(getItem(value, k)) return p[0] === 'error' ? p : ok([p[1]]) }, - emptyEntries, - consPresent, + emptyDeclared, + consDeclared, ) if (d[0] === 'error') { return d } const extra = undeclaredMembers(declared, value) @@ -440,7 +454,10 @@ const restContainerParse = const e = eachEntry(extra, (_k, v) => restParse(v), undefined, noAccumulate) if (e[0] === 'error') { return e } } - const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(d[1])) + if (!presenceUnchanged(rttiEntries, d[1].presence, value)) { + return verror('unexpected value') + } + const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(d[1].entries)) return omittedStillAbsent(declared, built) ? ok(built) : verror('unexpected value') diff --git a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md index c907c79a1..b6f68391f 100644 --- a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md +++ b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md @@ -10,8 +10,11 @@ Reading a member of a hostile value can run **arbitrary code** — an accessor left behind. `parse`'s *rebuilds* no longer dispatch anything overridable (see `defineProperty` in [`../parse/module.f.mjs`](../parse/module.f.mjs) and `hostileIntrinsicPatchesDoNotReachTheRebuild` in -[`../host.proof.mjs`](../host.proof.mjs)), but the **verdict** path still -does, in [`../common/module.f.mjs`](../common/module.f.mjs) and its callers: +[`../host.proof.mjs`](../host.proof.mjs)), and all three readers re-ask +each declared member's presence last (`presenceUnchanged` in +[`../common/module.f.mjs`](../common/module.f.mjs)), but the **verdict** +path still dispatches overridable operations, in the same module and its +callers: - `undeclaredMembers`/`readIndices` build their member list with `Object.entries`, `.filter`, `.map`, `.flatMap`, `.toSorted`, `.indexOf` diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index ae482ec7f..5fd40c9d5 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -85,11 +85,14 @@ import { ok } from '../../types/result/module.f.mjs' import { absentMember, + consPresence, constPrimitiveValidate, eachEntry, + emptyPresence, isArray, isObject, orVisit, + presenceUnchanged, primitive0Validate, structSchemaEntries, tupleSchemaEntries, @@ -184,6 +187,13 @@ const recordValidate = containerValidate(isObject, () => () => true) * `{ a: undefined }`. An absent member is legal exactly when its schema * admits absence (`admitsAbsence` in `../common/module.f.mjs`); a present * one is dispatched as before. + * + * The decisions are **re-asked last** (`presenceUnchanged`): a member's + * read can run an accessor that flips an earlier, already decided member — + * prototype pollution makes an omitted key present, a delete makes a + * checked one absent — and the value handed back would no longer denote + * what was checked. The three readers refuse the flip identically — see + * `../host.proof.mjs`. */ const constContainerValidate = /** @@ -207,16 +217,24 @@ const constContainerValidate = } const r = eachEntry( rttiEntries, - (k, v) => k in value - ? /** @type {any} */ (validate(v))(getItem(value, k)) - : absentMember(v), - undefined, - noAccumulate, + (k, v) => { + if (!(k in value)) { + const a = absentMember(v) + return a[0] === 'error' ? a : ok(false) + } + const m = /** @type {any} */ (validate(v))(getItem(value, k)) + return m[0] === 'error' ? m : ok(true) + }, + emptyPresence, + consPresence, ) if (r[0] === 'error') { return r } + if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { + return verror('unexpected value') + } // `value` is C (Unknown container), but Ts for T extends Tuple|Struct is not // structurally equivalent to C — TypeScript can't narrow element types through the loop. - return undeclaredMembers(declared, value).length === 0 && fits(value, declared.length) + return presenceUnchanged(rttiEntries, r[1], value) ? /** @type {any} */ (ok(value)) : verror('unexpected value') } @@ -270,20 +288,31 @@ const restContainerValidate = } const d = eachEntry( rttiEntries, - (k, v) => k in value - ? /** @type {any} */ (validate(v))(getItem(value, k)) - : absentMember(v), - undefined, - noAccumulate, + (k, v) => { + if (!(k in value)) { + const a = absentMember(v) + return a[0] === 'error' ? a : ok(false) + } + const m = /** @type {any} */ (validate(v))(getItem(value, k)) + return m[0] === 'error' ? m : ok(true) + }, + emptyPresence, + consPresence, ) if (d[0] === 'error') { return d } const extra = undeclaredMembers(declared, value) if (extra.length === 0) { - return fits(value, declared.length) ? ok(value) : verror('unexpected value') + if (!fits(value, declared.length)) { + return verror('unexpected value') + } + } else { + const restValidate = /** @type {any} */ (validate(r)) + const e = eachEntry(extra, (_k, v) => restValidate(v), undefined, noAccumulate) + if (e[0] === 'error') { return e } } - const restValidate = /** @type {any} */ (validate(r)) - const e = eachEntry(extra, (_k, v) => restValidate(v), undefined, noAccumulate) - return e[0] === 'error' ? e : ok(value) + return presenceUnchanged(rttiEntries, d[1], value) + ? ok(value) + : verror('unexpected value') } }