diff --git a/changelog/unreleased/1748.md b/changelog/unreleased/1748.md new file mode 100644 index 000000000..75e07942a --- /dev/null +++ b/changelog/unreleased/1748.md @@ -0,0 +1,14 @@ +- **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)`. +- `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 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 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/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..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' @@ -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,16 +132,57 @@ 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) } +/** {@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**. * @@ -285,6 +334,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 +432,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..7c1079e5e 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 } /** @@ -76,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/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..76e8d8d9a 100644 --- a/fjs/rtti/data/module.f.mjs +++ b/fjs/rtti/data/module.f.mjs @@ -25,17 +25,24 @@ 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` * 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 @@ -1134,11 +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) => nodeValidate(rules)(n)(value[Number(k)]), - 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) @@ -1149,32 +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) => nodeValidate(rules)(n)(value[k]), - 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/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..0277b0b2b 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -19,13 +19,14 @@ * * @import { Type } from './types.ts' * @import { ValidateE } from './common/types.ts' + * @import { StringMap } from '../types/object/types.ts' * @import { Unknown } from './ts/types.ts' */ 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 +168,214 @@ 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)) + } + }, + // `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 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 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 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 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 + // 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: () => { + 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..d155ac0c6 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" @@ -48,20 +50,20 @@ * @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 { Container, Fits, IsContainer, Presence, 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, eachEntry, isArray, isObject, orVisit, + presenceUnchanged, primitive0Validate, structSchemaEntries, tupleSchemaEntries, @@ -73,32 +75,188 @@ 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 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. + */ +/** @typedef {null | { readonly first: readonly [string, Unknown], readonly tail: _Entries }} _Entries */ + +/** 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 arrayRebuild = entries => entries.map(([, v]) => v) +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 +} +/** + * 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. + */ /** @type {_Rebuild} */ -const recordRebuild = entries => Object.fromEntries(entries) +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`: 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 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 => { + 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 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 }) +/** 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. The presence bit is kept for every member either way — it is + * what `presenceUnchanged` re-asks after everything that reads the value. + */ +/** @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[]} */ 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)) +/** 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 @@ -133,12 +291,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]))) } } @@ -166,6 +324,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 = /** @@ -189,13 +357,27 @@ const constContainerParse = } const r = eachEntry( rttiEntries, - (k, t) => (/** @type {any} */ (parse(t))(getItem(value, k))), - emptyEntries, - consEntry, + (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]]) + }, + emptyDeclared, + consDeclared, ) if (r[0] === 'error') { return r } - return undeclaredMembers(declared, value).length === 0 && fits(value, declared.length) - ? /** @type {any} */ (ok(rebuild(orderedEntries(r[1])))) + if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { + return verror('unexpected value') + } + 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') } } @@ -204,7 +386,7 @@ const tupleParse = constContainerParse( isArray, tupleSchemaEntries, (value, k) => value[Number(k)], - arrayRebuild, + tupleRebuild, (value, declared) => value.length <= declared, ) @@ -250,20 +432,35 @@ const restContainerParse = } const d = eachEntry( rttiEntries, - (k, t) => (/** @type {any} */ (parse(t))(getItem(value, k))), - emptyEntries, - consEntry, + (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]]) + }, + emptyDeclared, + consDeclared, ) 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]))) - : 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 } + } + if (!presenceUnchanged(rttiEntries, d[1].presence, value)) { + return 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]))) + const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(d[1].entries)) + return omittedStillAbsent(declared, built) + ? ok(built) + : verror('unexpected value') } } @@ -271,7 +468,7 @@ const restTupleParse = restContainerParse( isArray, tupleSchemaEntries, (value, k) => value[Number(k)], - arrayRebuild, + tupleRebuild, (rtti, r) => (value, declared) => value.length <= declared || !emptyRest(rtti, r), ) @@ -338,6 +535,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..6531809db 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,85 @@ 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') + // 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 })) + 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: { @@ -399,11 +470,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 +533,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 +547,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/hostile-accessor-hermetic-read-path.md b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md new file mode 100644 index 000000000..b6f68391f --- /dev/null +++ b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md @@ -0,0 +1,57 @@ +# 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)), 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` + 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. 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/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. 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..ce99fc017 100644 --- a/fjs/rtti/ts/types.ts +++ b/fjs/rtti/ts/types.ts @@ -11,11 +11,111 @@ */ 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; 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 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 } ? ([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 + : 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. + * + * 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). 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 } + ? ([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 + : 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 +147,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 +169,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 +239,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 +365,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 +414,21 @@ export type StructTs = * type _Check = Assert> * ``` * + * **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, typeof myThunk>> + * ``` + * * 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 +453,12 @@ 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. 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 @@ -316,8 +467,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 +483,30 @@ 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 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 } + ? ([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 : + 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 +525,30 @@ export type Check = Equal> */ export type Check3 = And>, 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> @@ -362,34 +565,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 +704,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 +730,93 @@ 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, Or>> + +/** + * 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 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, 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 + * 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, AbsentOr> +type _phantomRecursiveArray = Assert readonly['array', _PhantomRec] +>> +type _phantomRecursiveMember = Assert> +type _phantomAbsentOnlyArray = Assert readonly['array', Phantom>] +>> 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..5fd40c9d5 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,11 +84,15 @@ import { ok } from '../../types/result/module.f.mjs' import { + absentMember, + consPresence, constPrimitiveValidate, eachEntry, + emptyPresence, isArray, isObject, orVisit, + presenceUnchanged, primitive0Validate, structSchemaEntries, tupleSchemaEntries, @@ -173,6 +177,23 @@ 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. + * + * 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 = /** @@ -196,14 +217,24 @@ const constContainerValidate = } const r = eachEntry( rttiEntries, - (k, v) => /** @type {any} */ (validate(v))(getItem(value, k)), - 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') } @@ -257,18 +288,31 @@ const restContainerValidate = } const d = eachEntry( rttiEntries, - (k, v) => /** @type {any} */ (validate(v))(getItem(value, k)), - 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') } } @@ -311,6 +355,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 +387,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..ed13afe80 100644 --- a/fjs/types/phantom/types.ts +++ b/fjs/types/phantom/types.ts @@ -37,6 +37,24 @@ export type { phantomKey } * type _Check = Assert> * ``` * + * 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, typeof rawThunk>> + * ``` + * + * 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