diff --git a/CHANGELOG.md b/CHANGELOG.md index e73dd0df5..0ad1a17f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,18 @@ history. ## Unreleased +- `media/json/schema`: `toJsonSchema` supports recursive schemas — it converts + through `fjs/types/rtti/data` (new `dataToJsonSchema`) and emits named + recursion as `$defs`/`$ref`; output is canonical, so `anyOf` members and + object keys follow the data form's normalized order, and a non-empty tuple + prefix emits `minItems` + [#1542](https://github.com/functionalscript/functionalscript/pull/1542). +- `types/rtti/data`: rule and property lookups are own-property only, so a + name shadowing an `Object.prototype` member (`toString`, …) is a missing + definition for `validate`/`subset` and an ordinary extra key when validating + values; `subset` and `toData`'s coverage collapse now terminate on unions + mixing rest-based and property-based object recursion + [#1542](https://github.com/functionalscript/functionalscript/pull/1542). - `basen/base64`: `decode` drops an overflow check in its padded branch that could never trigger — `head`'s length is always a multiple of 6, so the largest value `stringToVec` can return without overflowing already lands diff --git a/fjs/media/json/schema/module.f.mjs b/fjs/media/json/schema/module.f.mjs index 02c029ca4..fe70c8575 100644 --- a/fjs/media/json/schema/module.f.mjs +++ b/fjs/media/json/schema/module.f.mjs @@ -1,20 +1,32 @@ /** * Converts an rtti schema to a JSON Schema (draft 2020-12) object. * - * Driven by `fjs/types/rtti/common/module.f.mjs`'s `visit`, the same shared - * `Type`-ADT walker used by `validate` and `parse`. + * {@link toJsonSchema} routes through the serializable RTTI data form + * (`fjs/types/rtti/data`): `thunk RTTI → toData → dataToJsonSchema`. The data + * form is a finite graph, so recursive schemas — which the thunk graph + * represents as self-referencing functions with no leaves — terminate: + * every named rule is emitted exactly once under `$defs` and every graph + * edge becomes a local `$ref`. + * + * Graph discovery, canonical identity, and definition naming belong to the + * RTTI data layer; this module only translates the finite graph, so the + * output is deterministic for equal canonical data: `anyOf` members follow + * the data form's kind order (`null`, `undefined`, booleans, numbers, + * strings, bigints, arrays, objects), and `properties`/`required` follow + * its sorted key order. * * @module * - * @import { Struct, Tuple, Type as RttiType } from '../../../types/rtti/types.ts' - * @import { Visitor } from '../../../types/rtti/common/types.ts' - * @import { Primitive } from '../../../djs/types.ts' + * @import { Type as RttiType } from '../../../types/rtti/types.ts' + * @import { ArraySet, Data, KindSet, Node, ObjectSet, RuleSet, UnionSet } from '../../../types/rtti/data/types.ts' * @import { Ts } from '../../../types/rtti/ts/types.ts' * @import { Phantom } from '../../../types/phantom/types.ts' */ -import { array, option, or, record, string } from '../../../types/rtti/module.f.mjs' -import { visit } from '../../../types/rtti/common/module.f.mjs' +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 '../../../types/rtti/module.f.mjs' +import { cmp, toData, unitBit, unknown as top } from '../../../types/rtti/data/module.f.mjs' import { unknown as jsonUnknown } from '../rtti/module.f.mjs' /** @type {() => readonly ['const', typeof unknownConst]} */ @@ -30,12 +42,16 @@ export const unknown = unknownThunk /** @typedef {Ts} Unknown */ 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), @@ -46,100 +62,248 @@ const unknownConst = /** @type {const} */ ({ * * The `?` markers are required even though `Ts<>` already includes `undefined` * in each field type. Without `?`, `Unknown = _UnknownConst` would require all - * 9 fields to be present in every object literal returned by `toJsonSchema`, + * 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. + * 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. * @typedef {{ + * readonly $schema?: Ts + * readonly $ref?: Ts + * readonly $defs?: Ts * readonly type?: Ts * readonly const?: Ts * readonly not?: Ts * readonly anyOf?: Ts * readonly items?: Ts * readonly prefixItems?: Ts + * readonly minItems?: Ts * readonly properties?: Ts * readonly required?: Ts * readonly additionalProperties?: Ts * }} _UnknownConst */ -/** Returns true if the rtti schema admits the value `undefined`. - * @type {(rtti: RttiType) => boolean} +const nullBit = unitBit(null) +const undefinedBit = unitBit(undefined) +const falseBit = unitBit(false) +const trueBit = unitBit(true) +const booleanBits = falseBit | trueBit + +/** + * Encodes a definition name for use inside a local `$ref` URI fragment: + * JSON Pointer escaping first (`~` → `~0`, `/` → `~1`), then + * percent-encoding of the remaining code points for the URI-fragment path + * segment. The order matters: a literal name `%2F` must encode to `%252F`, + * so URI decoding restores the literal `%2F` segment instead of a `/` that + * JSON Pointer evaluation would then misread as a separator. + * + * @type {(name: string) => string} */ -const admitsUndefined = rtti => { - if (rtti === undefined) { return true } - if (typeof rtti !== 'function') { return false } - const [t, ...r] = rtti() - return t === 'or' ? r.some(admitsUndefined) : false +const refEncode = name => { + let result = '' + for (const c of name) { + result += c === '~' ? '~0' : c === '/' ? '~1' : encodeURIComponent(c) + } + return result } -/** Returns the schema with `undefined` removed from any top-level `or`. - * @type {(rtti: RttiType) => RttiType} +/** + * A local `$ref` to a named definition. A reference must name an existing + * definition — a dangling name is malformed data and panics. The lookup is + * own-property only, so a name inherited from `Object.prototype` + * (`toString`, `constructor`, …) is still rejected. + * + * @type {(rules: RuleSet) => (name: string) => Unknown} */ -const stripUndefined = rtti => { - if (typeof rtti !== 'function') { return rtti } - const [t, ...r] = rtti() - if (t !== 'or') { return rtti } - const rest = r.flatMap(t => t !== undefined ? [t] : []) - return rest.length === 1 ? rest[0] : or(...rest) +const refSchema = rules => name => { + assert(at(name)(rules) !== null, `missing definition: ${name}`) + return { $ref: `#/$defs/${refEncode(name)}` } } -// Struct: keys not admitting undefined go into `required`; optional keys have -// undefined stripped from their property schema. additionalProperties is omitted -// (lenient), matching rtti's open-struct validation semantics. -/** @type {(rtti: Struct) => Unknown} */ -const structSchema = rtti => { - const ents = Object.entries(rtti) - const properties = Object.fromEntries( - ents.map(([k, v]) => [k, toJsonSchema(stripUndefined(v))]) - ) - const required = ents - .filter(([, v]) => !admitsUndefined(v)) - .map(([k]) => k) +/** @type {(rules: RuleSet) => (n: Node) => Unknown} */ +const nodeSchema = rules => n => + typeof n === 'string' ? refSchema(rules)(n) : unionSchema(rules)(n) + +/** + * The schemas of one kind component: nothing when absent, the whole kind + * when `true`, one schema per member otherwise. + * + * @template T + * @param {KindSet | undefined} k + * @param {Unknown} whole + * @param {(v: T) => Unknown} item + * @returns {readonly Unknown[]} + */ +const kindSchemas = (k, whole, item) => + k === undefined ? [] : + k === true ? [whole] : + k.map(item) + +/** @type {(v: boolean | number | string | null) => Unknown} */ +const constSchema = v => ({ const: v }) + +/** bigint consts are represented as numbers (lossy for |value| > MAX_SAFE_INTEGER) */ +/** @type {(v: bigint) => Unknown} */ +const bigintConstSchema = v => ({ const: Number(v) }) + +/** + * The unit kind: `null` and `undefined` are their own singletons — no JSON + * value is `undefined`, hence `{ "not": {} }` — and both boolean bits + * together are the `boolean` type with no special-case rule. + * + * @type {(bits: number) => readonly Unknown[]} + */ +const unitSchemas = bits => [ + ...((bits & nullBit) === 0 ? [] : [constSchema(null)]), + ...((bits & undefinedBit) === 0 ? [] : [{ not: {} }]), + ...((bits & booleanBits) === booleanBits ? [{ type: /** @type {const} */ ('boolean') }] + : (bits & falseBit) !== 0 ? [constSchema(false)] + : (bits & trueBit) !== 0 ? [constSchema(true)] + : []), +] + +/** + * A set of arrays: `prefixItems` for the tuple prefix, `items` for the + * elements past it — `false` when the length is exact, so a tuple admits + * nothing beyond its prefix and the empty tuple is `{ "items": false }`. + * `prefixItems` alone constrains only elements that exist (draft 2020-12 + * implies no minimum length), so a non-empty prefix also emits `minItems`. + * + * @type {(rules: RuleSet) => (p: ArraySet) => Unknown} + */ +const arraySetSchema = rules => p => ({ + type: 'array', + ...(p.prefix.length === 0 ? {} : { + prefixItems: p.prefix.map(nodeSchema(rules)), + minItems: p.prefix.length, + }), + items: p.rest === undefined ? false : nodeSchema(rules)(p.rest), +}) + +/** Whether the node's value set admits `undefined` — its unit bit, read + * through a reference if needed. + * @type {(rules: RuleSet) => (n: Node) => boolean} + */ +const admitsUndefined = rules => n => { + const u = typeof n === 'string' ? assertNotNullish(at(n)(rules)) : n + return ((u.unit ?? 0) & undefinedBit) !== 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. + * + * @type {(n: Node) => Node} + */ +const stripUndefined = n => { + if (typeof n === 'string') { return n } + const unit = (n.unit ?? 0) & ~undefinedBit + return { + ...(unit === 0 ? {} : { unit }), + ...(n.number === undefined ? {} : { number: n.number }), + ...(n.string === undefined ? {} : { string: n.string }), + ...(n.bigint === undefined ? {} : { bigint: n.bigint }), + ...(n.array === undefined ? {} : { array: n.array }), + ...(n.object === undefined ? {} : { object: n.object }), + } +} + +/** + * 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. + * + * @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) return { type: 'object', - properties, - ...(required.length > 0 ? { required } : {}), + ...(ents.length === 0 ? {} : { + properties: Object.fromEntries(ents.map( + ([k, n]) => /** @type {const} */ ([k, nodeSchema(rules)(stripUndefined(n))]))), + }), + ...(required.length === 0 ? {} : { required }), + ...(p.rest === undefined ? {} : { additionalProperties: nodeSchema(rules)(p.rest) }), } } -/** @type {(rtti: Primitive) => Unknown} */ -const constPrimitiveSchema = rtti => - rtti === undefined - ? { not: {} } - // bigint consts are represented as numbers (lossy for |value| > MAX_SAFE_INTEGER) - : { const: typeof rtti === 'bigint' ? Number(rtti) : rtti } - -/** @type {Visitor} */ -const visitor = { - tuple: (/** @type {Tuple} */ t) => ({ type: 'array', prefixItems: t.map(toJsonSchema), items: false }), - struct: structSchema, - array: item => ({ type: 'array', items: toJsonSchema(item) }), - record: item => ({ type: 'object', additionalProperties: toJsonSchema(item) }), - or: variants => ({ anyOf: variants.map(toJsonSchema) }), - constPrimitive: constPrimitiveSchema, - // bigint is not representable in JSON Schema; 'integer' is the closest approximation - primitive0: tag => ({ type: tag === 'bigint' ? 'integer' : tag }), - unknown: () => ({}), +/** @type {(u: UnionSet) => boolean} */ +const isTop = u => cmp([{}, u])([{}, top]) === 0 + +/** @type {(rules: RuleSet) => (u: UnionSet) => Unknown} */ +const unionSchema = rules => u => { + if (isTop(u)) { return {} } + const members = [ + ...unitSchemas(u.unit ?? 0), + ...kindSchemas(u.number, { type: 'number' }, constSchema), + ...kindSchemas(u.string, { type: 'string' }, constSchema), + ...kindSchemas(u.bigint, { type: 'integer' }, bigintConstSchema), + ...kindSchemas(u.array, { type: 'array' }, arraySetSchema(rules)), + ...kindSchemas(u.object, { type: 'object' }, objectSetSchema(rules)), + ] + return members.length === 0 ? { not: {} } + : members.length === 1 ? members[0] + : { anyOf: members } } /** - * Converts an rtti `Type` to a JSON Schema (draft 2020-12) object. + * Converts a serializable RTTI {@link Data} (from `toData`) to a JSON Schema + * (draft 2020-12) object. + * + * Every named rule is emitted exactly once under `$defs` and each reference + * becomes a local `$ref` — self- and mutual recursion terminate, and the + * root itself is a `$ref` when the entry is a named definition. Definition + * names come from the data form (deterministic for equal canonical data) + * and are JSON Pointer-escaped, then percent-encoded, for the `$ref` + * fragment. A reference naming a missing definition panics. + * + * @type {(data: Data) => Unknown} + */ +export const dataToJsonSchema = ([rules, entry]) => { + const ruleEntries = definedEntries(rules) + const root = nodeSchema(rules)(entry) + return ruleEntries.length === 0 ? root : { + ...root, + $defs: Object.fromEntries(ruleEntries.map( + ([name, u]) => /** @type {const} */ ([name, unionSchema(rules)(u)]))), + } +} + +/** + * Converts an rtti `Type` to a JSON Schema (draft 2020-12) object, through + * the canonical data form: `toData` first, then {@link dataToJsonSchema}. * * | rtti | JSON Schema | * |-----------------------------------------------|-------------------------------------------------------------------------------------| * | `boolean` / `number` / `string` | `{ "type": "..." }` | * | `bigint` | `{ "type": "integer" }` (lossy; JSON integers are IEEE-754 doubles) | * | `unknown` | `{}` (always-true schema) | - * | primitive const (`42`, `'x'`, `true`, `null`) | `{ "const": }` | + * | `never` / `or()` | `{ "not": {} }` (no JSON value satisfies this) | + * | primitive const (`42`, `'x'`, `null`) | `{ "const": }` | + * | `or(true, false)` | `{ "type": "boolean" }` (the union normalizes to the whole kind) | * | `bigint` const | `{ "const": Number(value) }` (lossy for \|value\| > MAX_SAFE_INTEGER) | - * | `undefined` const | `{ "not": {} }` (no JSON value satisfies this) | + * | `undefined` const | `{ "not": {} }` | * | struct `{ a: T, … }` | `{ "type": "object", "properties": { "a": …T… }, "required": [non-optional keys] }` | - * | tuple `[A, B]` | `{ "type": "array", "prefixItems": […A…, …B…], "items": false }` | + * | tuple `[A, B]` | `{ "type": "array", "prefixItems": […A…, …B…], "minItems": 2, "items": false }` | * | `array(T)` | `{ "type": "array", "items": …T… }` | * | `record(T)` | `{ "type": "object", "additionalProperties": …T… }` | - * | `or(...types)` | `{ "anyOf": […each…] }` | + * | `or(...types)` | `{ "anyOf": […each…] }`, normalized and in canonical kind order | + * | recursive schema | `{ "$ref": "#/$defs/", "$defs": { "": … } }` | + * + * The union rows follow the data form's normalization: operands are merged + * kind-wise, literals covered by their whole kind are absorbed + * (`or(42, number)` is all numbers), subsumed patterns are dropped, and + * duplicates collapse — so structurally different but equivalent thunk + * schemas produce the same JSON Schema. * * @type {(rtti: RttiType) => Unknown} */ -export const toJsonSchema = visit(visitor) +export const toJsonSchema = rtti => dataToJsonSchema(toData(rtti)) diff --git a/fjs/media/json/schema/proof.f.mjs b/fjs/media/json/schema/proof.f.mjs index b2dda3a6f..6e37311e4 100644 --- a/fjs/media/json/schema/proof.f.mjs +++ b/fjs/media/json/schema/proof.f.mjs @@ -1,11 +1,13 @@ /** * @import { Unknown as JsonValue } from '../types.ts' * @import { Unknown } from './module.f.mjs' + * @import { Data } from '../../../types/rtti/data/types.ts' */ -import { boolean, number, string, bigint, unknown, array, record, or, option } from '../../../types/rtti/module.f.mjs' +import { boolean, number, string, bigint, never, unknown, array, record, or, option } from '../../../types/rtti/module.f.mjs' import { stringify } from '../module.f.mjs' -import { toJsonSchema, unknown as schemaUnknown } from './module.f.mjs' +import { dataToJsonSchema, toJsonSchema, unknown as schemaUnknown } from './module.f.mjs' +import { unitBit } from '../../../types/rtti/data/module.f.mjs' import { assert, assertEq } from '../../../asserts/module.f.mjs' /** @type {(v: Unknown) => string} */ @@ -18,6 +20,46 @@ const eq = (rtti, expected) => () => { assertEq(result, exp, [result, exp]) } +/** @type {(data: Data, expected: Unknown) => () => void} */ +const eqData = (data, expected) => () => { + const result = serialize(dataToJsonSchema(data)) + const exp = serialize(expected) + assertEq(result, exp, [result, exp]) +} + +/** A recursive list: `type _List = readonly _List[]`. */ +/** @typedef {() => readonly ['array', _List]} _List */ +/** @type {_List} */ +const list = () => ['array', list] + +/** Mutual recursion through a container. */ +/** @typedef {() => readonly ['or', typeof number, _Forest]} _Tree */ +/** @typedef {() => readonly ['array', _Tree]} _Forest */ +/** @type {_Tree} */ +const tree = () => ['or', number, forest] +/** @type {_Forest} */ +const forest = () => ['array', tree] + +/** The recursive revision lock schema. Its cycle closes through the + * anonymous `or` thunk, which becomes the (empty-string-named) rule. */ +/** @typedef {() => readonly ['record', () => readonly ['or', typeof string, _Lock]]} _Lock */ +/** @type {_Lock} */ +const lock = () => ['record', or(string, lock)] + +/** Self-recursive record. */ +/** @typedef {() => readonly ['record', _Rec]} _Rec */ +/** @type {_Rec} */ +const rec = () => ['record', rec] + +const listRef = /** @type {const} */ ({ $ref: '#/$defs/list' }) +const treeRef = /** @type {const} */ ({ $ref: '#/$defs/tree' }) + +/** @type {Unknown} */ +const listDef = { type: 'array', items: listRef } + +/** @type {Unknown} */ +const treeDef = { anyOf: [{ type: 'number' }, { type: 'array', items: treeRef }] } + export const proof = { tag0: { boolean: eq(boolean, { type: 'boolean' }), @@ -37,10 +79,12 @@ export const proof = { }, array: eq(array(number), { type: 'array', items: { type: 'number' } }), record: eq(record(string), { type: 'object', additionalProperties: { type: 'string' } }), - or: eq(or(string, number), { anyOf: [{ type: 'string' }, { type: 'number' }] }), + // `anyOf` members follow the canonical kind order, not the operand order + or: eq(or(string, number), { anyOf: [{ type: 'number' }, { type: 'string' }] }), tuple: eq(/** @type {const} */ ([number, string]), { type: 'array', prefixItems: [{ type: 'number' }, { type: 'string' }], + minItems: 2, items: false, }), struct: { @@ -58,16 +102,35 @@ export const proof = { type: 'object', properties: { x: { type: 'number' } }, }), - empty: eq(/** @type {const} */ ({}), { type: 'object', properties: {} }), + // an unconstrained struct is the whole object kind + empty: eq(/** @type {const} */ ({}), { type: 'object' }), orOptional: eq(/** @type {const} */ ({ x: or(string, number, undefined) }), { type: 'object', - properties: { x: { anyOf: [{ type: 'string' }, { type: 'number' }] } }, + properties: { x: { anyOf: [{ type: 'number' }, { type: 'string' }] } }, }), withConst: eq(/** @type {const} */ ({ x: null, y: string }), { type: 'object', properties: { x: { const: null }, y: { type: 'string' } }, required: ['x', 'y'], }), + 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), + }), { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'string' }, + c: { type: 'integer' }, + d: { type: 'array', items: { type: 'number' } }, + e: { type: 'object', additionalProperties: { type: 'string' } }, + f: { const: null }, + }, + }), }, schemaUnknownTag: () => { const r = schemaUnknown() @@ -79,15 +142,164 @@ export const proof = { items: { type: 'object', additionalProperties: { type: 'boolean' } }, }), orWithConst: eq(or(null, string, /** @type {const} */ (42)), { - anyOf: [{ const: null }, { type: 'string' }, { const: 42 }], + anyOf: [{ const: null }, { const: 42 }, { type: 'string' }], }), structWithOr: eq(/** @type {const} */ ({ id: or(string, number), name: option(string) }), { type: 'object', properties: { - id: { anyOf: [{ type: 'string' }, { type: 'number' }] }, + id: { anyOf: [{ type: 'number' }, { type: 'string' }] }, name: { type: 'string' }, }, required: ['id'], }), + topInsideTuple: eq(/** @type {const} */ ([unknown, number]), { + type: 'array', + prefixItems: [{}, { type: 'number' }], + minItems: 2, + items: false, + }), + }, + normalization: { + booleanFromConsts: eq(or(true, false), { type: 'boolean' }), + unitMembers: eq(or(null, undefined, true), { + anyOf: [{ const: null }, { not: {} }, { const: true }], + }), + literalAbsorbed: eq(or(/** @type {const} */ (42), number), { type: 'number' }), + duplicateLiteral: eq(or(/** @type {const} */ (1), /** @type {const} */ (1)), { const: 1 }), + never: eq(never, { not: {} }), + emptyTuple: eq(/** @type {const} */ ([]), { type: 'array', items: false }), + // `readonly [number] ⊂ readonly number[]` — the tuple pattern is dropped + coverageCollapse: eq(or(/** @type {const} */ ([number]), array(number)), { + type: 'array', + items: { type: 'number' }, + }), + commutative: () => { + const a = serialize(toJsonSchema(or(string, number))) + const b = serialize(toJsonSchema(or(number, string))) + assertEq(a, b, [a, b]) + }, + }, + recursion: { + selfList: eq(list, { ...listRef, $defs: { list: listDef } }), + mutualEntry: eq(tree, { ...treeRef, $defs: { tree: treeDef } }), + mutualInline: eq(forest, { type: 'array', items: treeRef, $defs: { tree: treeDef } }), + recursiveUnion: eq(or(number, list), { + anyOf: [{ type: 'number' }, { type: 'array', items: listRef }], + $defs: { list: listDef }, + }), + recursiveRecord: eq(rec, { + $ref: '#/$defs/rec', + $defs: { rec: { type: 'object', additionalProperties: { $ref: '#/$defs/rec' } } }, + }), + optionalRecursiveProperty: eq(/** @type {const} */ ({ p: option(list) }), { + type: 'object', + properties: { p: { type: 'array', items: listRef } }, + $defs: { list: listDef }, + }), + revisionLock: eq(lock, { + type: 'object', + additionalProperties: { $ref: '#/$defs/' }, + $defs: { + '': { + anyOf: [ + { type: 'string' }, + { type: 'object', additionalProperties: { $ref: '#/$defs/' } }, + ], + }, + }, + }), + sharedNonRecursive: () => { + // a shared, non-recursive definition is inlined at each use — no `$defs` + const person = /** @type {const} */ ({ name: string }) + /** @type {Unknown} */ + const personSchema = { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + } + eq(/** @type {const} */ ([person, person]), { + type: 'array', + prefixItems: [personSchema, personSchema], + minItems: 2, + items: false, + })() + }, + }, + data: (() => { + /** @type {Data} */ + const tupleWithRest = [{}, { array: [{ prefix: [{ number: true }], rest: { string: true } }] }] + /** @type {Data} */ + const structWithRest = [{}, { + object: [{ props: { a: { number: true } }, rest: { string: true } }], + }] + /** @type {Data} */ + const optionalByReference = [ + { r: { unit: unitBit(null) | unitBit(undefined), number: true } }, + { object: [{ props: { p: 'r' } }] }, + ] + return { + plain: eqData([{}, { number: true }], { type: 'number' }), + tupleWithRest: eqData(tupleWithRest, { + type: 'array', + prefixItems: [{ type: 'number' }], + minItems: 1, + items: { type: 'string' }, + }), + structWithRest: eqData(structWithRest, { + type: 'object', + properties: { a: { type: 'number' } }, + required: ['a'], + additionalProperties: { type: 'string' }, + }), + // a referenced definition admitting `undefined` makes the key + // optional; the reference itself is kept as the property schema + optionalByReference: eqData(optionalByReference, { + type: 'object', + properties: { p: { $ref: '#/$defs/r' } }, + $defs: { r: { anyOf: [{ const: null }, { not: {} }, { type: 'number' }] } }, + }), + } + })(), + refEncoding: (() => { + /** @type {Data} */ + const d = [ + { + 'a~b': { number: true }, + 'a/b': { number: true }, + '%2F': { number: true }, + 'a b': { number: true }, + 'é': { number: true }, + }, + { array: [{ prefix: ['a~b', 'a/b', '%2F', 'a b', 'é'] }] }, + ] + return eqData(d, { + type: 'array', + prefixItems: [ + { $ref: '#/$defs/a~0b' }, + { $ref: '#/$defs/a~1b' }, + { $ref: '#/$defs/%252F' }, + { $ref: '#/$defs/a%20b' }, + { $ref: '#/$defs/%C3%A9' }, + ], + minItems: 5, + items: false, + $defs: { + 'a~b': { type: 'number' }, + 'a/b': { type: 'number' }, + '%2F': { type: 'number' }, + 'a b': { type: 'number' }, + 'é': { type: 'number' }, + }, + }) + })(), + throw: { + missingRootDefinition: () => dataToJsonSchema([{}, 'nope']), + // an `Object.prototype` member name is still a missing definition + missingPrototypeDefinition: () => dataToJsonSchema([{}, 'toString']), + missingNestedDefinition: () => { + /** @type {Data} */ + const d = [{ a: { array: [{ prefix: [], rest: 'missing' }] } }, 'a'] + return dataToJsonSchema(d) + }, }, } diff --git a/fjs/media/json/todo/rtti-recursive-json-schema.md b/fjs/media/json/todo/rtti-recursive-json-schema.md deleted file mode 100644 index a3bfb3972..000000000 --- a/fjs/media/json/todo/rtti-recursive-json-schema.md +++ /dev/null @@ -1,189 +0,0 @@ -## Recursive RTTI to JSON Schema - -**Priority:** P3 -**Status:** blocked - -### Problem - -[`toJsonSchema`](../schema/module.f.mjs) currently walks thunk-based RTTI directly. -That works for finite trees, but recursive RTTI values are graphs: - -```ts -const lock = () => ['record', or(string, lock)] as const -``` - -Following this thunk never reaches a leaf. Recursive consumers therefore need a -shared, finite RTTI graph representation rather than transformer-specific thunk -identity tracking. - -### Depends on - -- [RTTI serializable data form](../../../types/rtti/data/README.md) — landed; - it defines the function-free rule set and named references used to represent - recursion. This task must consume that representation. - -A name-keyed rule set is an open map because a reference may name a missing -definition: - -```ts -type TypeDataSet = StringMap -type TypeData = /* finite data nodes containing definition references */ -``` - -Equivalently: - -```ts -type TypeDataSet = { - readonly [name: string]: TypeData | undefined -} -``` - -Do not use `Readonly>`; it incorrectly types every -possible lookup as present. - -### Proposal - -Add a data-driven transformer: - -```ts -dataToJsonSchema(data) -``` - -Keep the ergonomic public entry point, but route it through RTTI data: - -```text -thunk RTTI -> toData -> dataToJsonSchema -> JSON Schema -``` - -Emit recursive and shared definitions with JSON Schema draft 2020-12 `$defs` -and `$ref`: - -```json -{ - "$ref": "#/$defs/lock", - "$defs": { - "lock": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { "type": "string" }, - { "$ref": "#/$defs/lock" } - ] - } - } - } -} -``` - -Graph discovery, canonical identity, and definition naming belong to the RTTI -data layer. The JSON Schema module only translates the finite graph. - -### Reference encoding - -A local `$ref` is a URI fragment containing a JSON Pointer. Definition names -must therefore be encoded in two steps: - -1. JSON Pointer escaping: `~` becomes `~0`, and `/` becomes `~1`. -2. Percent-encoding for the URI-fragment path segment. - -Apply percent-encoding **after** JSON Pointer escaping. For example, a literal -definition name `%2F` must not be emitted as `#/$defs/%2F`, because URI-fragment -decoding would turn it into `/` before JSON Pointer evaluation. It must be -encoded so URI decoding restores the literal `%2F` segment. - -The implementation may avoid this complexity only if the RTTI data format -formally restricts generated names to a URI-fragment-safe alphabet. Arbitrary -external names still require the two-step encoding above. - -### Reference rules - -- Emit every recursive or shared definition exactly once under `$defs`. -- Emit graph edges as `$ref`, never by recursively expanding definitions. -- Self-recursion and mutual recursion must terminate. -- Definition names must be deterministic for the same canonical RTTI data. -- The root uses `$ref` when represented by a named/indexed definition. -- A missing referenced definition is an error. -- Non-recursive finite RTTI preserves current JSON Schema semantics. - -### JSON Schema RTTI - -Extend the module's emitted-schema `Unknown` RTTI/type with the reference fields -that this transformer emits: - -```ts -type Unknown = { - readonly $schema?: string - readonly $ref?: string - readonly $defs?: StringMap - // existing emitted keywords -} -``` - -Equivalently, `$defs` is an optional open index: - -```ts -readonly $defs?: { - readonly [name: string]: Unknown | undefined -} -``` - -Do not use `Readonly>`; an absent `$defs` entry must be -typed as `undefined` so missing-reference handling cannot be skipped. - -Keep this RTTI limited to keywords emitted by this module. This task does not -implement the complete JSON Schema meta-schema. - -### Compatibility - -The public `toJsonSchema(rtti)` signature can remain unchanged. Finite schemas -should produce equivalent output except where the shared RTTI data -representation requires a `$defs`/`$ref` wrapper. - -Prefer deterministic output over preserving incidental thunk traversal order. -Update proofs and MCP schema snapshots for intentional output-shape changes. - -### Tasks - -- [x] Complete the blocking RTTI serializable-data task - ([`fjs/types/rtti/data`](../../../types/rtti/data/README.md)). -- [ ] Add `dataToJsonSchema` over the RTTI data rule set. -- [ ] Treat name-keyed RTTI definitions as `StringMap` and explicitly - reject absent referenced definitions. -- [ ] Change `toJsonSchema(rtti)` to call `toData` and then - `dataToJsonSchema`. -- [ ] Emit definitions under `$defs` and graph edges as local `$ref` values. -- [ ] Implement deterministic definition naming. -- [ ] Escape each definition name as a JSON Pointer segment and then - percent-encode it for the URI fragment. -- [ ] Extend emitted-schema `Unknown` with `$schema`, `$ref`, and - `$defs?: StringMap`. -- [ ] Preserve existing output semantics for primitives, structs, tuples, - arrays, records, unions, constants, optionals, and `unknown`. -- [ ] Add proofs for self-recursion, mutual recursion, recursive records, - recursive arrays/unions, and shared non-recursive definitions. -- [ ] Add proofs for missing definitions and missing `$defs` lookups. -- [ ] Add reference-encoding proofs for names containing `~`, `/`, `%2F`, - spaces, and non-ASCII characters. -- [ ] Add a proof for the recursive revision lock schema: - `() => ['record', or(string, lock)] as const`. -- [ ] Update MCP schema proofs/snapshots that contain `$defs` and `$ref`. - -### Out of scope - -- A second RTTI graph representation inside the JSON Schema module. -- A complete JSON Schema validator or draft 2020-12 meta-schema. -- Remote `$ref` resolution. -- Resolver semantics for revision lock maps. -- Replacing the thunk-direct RTTI validator/parser. - -### Dependents - -- [`fjs/media/todo/revision-lock-map.md`](../../todo/revision-lock-map.md), - Stage 2. - -### Related - -- [RTTI serializable data form](../../../types/rtti/data/README.md) -- [`fjs/bnf/data`](../../../bnf/data/) -- [`fjs/media/json/schema/module.f.mjs`](../schema/module.f.mjs) -- [`fjs/protocol/mcp`](../../../protocol/mcp/) diff --git a/fjs/media/todo/revision-lock-map.md b/fjs/media/todo/revision-lock-map.md index 16332fca0..0bbe18b24 100644 --- a/fjs/media/todo/revision-lock-map.md +++ b/fjs/media/todo/revision-lock-map.md @@ -256,12 +256,12 @@ other lock maps. ### Blocked by - [Stage 1 flat lock map](#stage-1-flat-lock-map) -- [Recursive RTTI to JSON Schema](../json/todo/rtti-recursive-json-schema.md) -- [RTTI serializable data form](../../types/rtti/data/README.md) — landed +- Recursive RTTI to JSON Schema — landed: `toJsonSchema` routes through the + [RTTI serializable data form](../../types/rtti/data/README.md) and emits + recursion as `$defs`/`$ref` + (see [`fjs/media/json/schema`](../json/schema/module.f.mjs)) -The recursive JSON Schema task consumes the RTTI serializable data form, which -has landed. Stage 2 must not be emitted until Stage 1 and the recursive JSON -Schema task are complete. +Stage 2 must not be emitted until Stage 1 is complete. ### Media schema @@ -375,7 +375,8 @@ reference shared lock content. Both are outside this TODO. ### Related - [Revision format](../revision/README.md) -- [Recursive RTTI to JSON Schema](../json/todo/rtti-recursive-json-schema.md) +- [`fjs/media/json/schema`](../json/schema/module.f.mjs) — recursive RTTI to + JSON Schema via the data form - [RTTI serializable data form](../../types/rtti/data/README.md) - [Evo API](../../cas/evo/README.md) - [MCP Evo](../../mcp/evo/README.md) diff --git a/fjs/types/rtti/data/module.f.mjs b/fjs/types/rtti/data/module.f.mjs index 7c7b0030d..a48242b11 100644 --- a/fjs/types/rtti/data/module.f.mjs +++ b/fjs/types/rtti/data/module.f.mjs @@ -23,7 +23,7 @@ */ import { assertNotNullish } from '../../../asserts/module.f.mjs' -import { definedEntries, definedValues } from '../../object/module.f.mjs' +import { at, definedEntries, definedValues } from '../../object/module.f.mjs' import { ok } from '../../result/module.f.mjs' import { eachEntry, isArray, verror } from '../common/module.f.mjs' @@ -306,13 +306,29 @@ const objectSet = (props, rest) => { /** @typedef {readonly [RuleSet, RuleSet]} _Ctx */ /** - * Reference pairs assumed included while they are being checked — the - * standard coinductive treatment of reference cycles. + * Node pairs assumed included while they are being checked — the standard + * coinductive treatment of reference cycles, keyed by {@link _Keyed} node + * identities. */ /** @typedef {StringMap>} _Assumed */ -/** @type {(rules: RuleSet) => (n: Node) => UnionSet} */ -const resolve = rules => n => typeof n === 'string' ? assertNotNullish(rules[n]) : n +/** + * A node with a canonical identity for the coinductive memo: `r:` a + * rule reference, `u:` a rule's object read-set (its rest plus + * `undefined`), `t` the top set. A node synthesized from inline data has no + * identity (`undefined`) — recursion through it descends its finite tree, + * so every cycle still crosses identified pairs and the memo closes it. + */ +/** @typedef {readonly [Node, string | undefined]} _Keyed */ + +/** + * Own-property lookups only: a `RuleSet`/`props` map is a plain object, so + * reading through the prototype chain would return `Object.prototype` + * members (`toString`, `constructor`, …) for names that are not defined. + * + * @type {(rules: RuleSet) => (n: Node) => UnionSet} + */ +const resolve = rules => n => typeof n === 'string' ? assertNotNullish(at(n)(rules)) : n /** @type {(a: T, b: T) => boolean} */ const strictEqual = (a, b) => a === b @@ -348,20 +364,28 @@ const arraySetSubset = ctx => assumed => (p, q) => { && (p.rest === undefined || le(p.rest, assertNotNullish(q.rest))) } +/** @type {(n: Node) => _Keyed} */ +const keyed = n => [n, typeof n === 'string' ? `r:${n}` : undefined] + /** * The set of values *read* at key `k` from objects of the pattern: the * declared set, else — since the key may also be absent, reading - * `undefined` — the `rest` set plus `undefined`, else anything. + * `undefined` — the `rest` set plus `undefined`, else anything. A read-set + * synthesized from a referenced rest keeps that rule's identity (`u:`), so + * the coinductive memo closes cycles through it. * - * @type {(rules: RuleSet) => (pattern: ObjectSet) => (k: string) => Node} + * @type {(rules: RuleSet) => (pattern: ObjectSet) => (k: string) => _Keyed} */ -const objectAt = rules => pattern => k => { - const n = pattern.props[k] - if (n !== undefined) { return n } +const objectReadSet = rules => pattern => k => { + const n = at(k)(pattern.props) + if (n !== null) { return keyed(n) } const { rest } = pattern return rest === undefined - ? unknown - : merge(resolve(rules)(rest), { unit: unitBit(undefined) }) + ? [unknown, 't'] + : [ + merge(resolve(rules)(rest), { unit: unitBit(undefined) }), + typeof rest === 'string' ? `u:${rest}` : undefined, + ] } /** @type {(list: readonly string[]) => readonly string[]} */ @@ -369,14 +393,14 @@ const dedup = list => list.filter((n, i) => list.indexOf(n) === i) /** @type {(ctx: _Ctx) => (assumed: _Assumed) => (p: ObjectSet, q: ObjectSet) => boolean} */ const objectSetSubset = ctx => assumed => (p, q) => { - const le = nodeSubset(ctx)(assumed) + const le = keyedSubset(ctx)(assumed) const keys = dedup([ ...definedEntries(p.props).map(([k]) => k), ...definedEntries(q.props).map(([k]) => k), ]) - return keys.every(k => le(objectAt(ctx[0])(p)(k), objectAt(ctx[1])(q)(k))) + return keys.every(k => le(objectReadSet(ctx[0])(p)(k), objectReadSet(ctx[1])(q)(k))) // values at the keys declared by neither side - && (q.rest === undefined || le(p.rest ?? unknown, q.rest)) + && (q.rest === undefined || nodeSubset(ctx)(assumed)(p.rest ?? unknown, q.rest)) } /** @type {(ctx: _Ctx) => (assumed: _Assumed) => (a: UnionSet, b: UnionSet) => boolean} */ @@ -388,17 +412,21 @@ const unionSubset = ctx => assumed => (a, b) => && kindSubset(arraySetSubset(ctx)(assumed))(a.array, b.array) && kindSubset(objectSetSubset(ctx)(assumed))(a.object, b.object) -/** @type {(ctx: _Ctx) => (assumed: _Assumed) => (a: Node, b: Node) => boolean} */ -const nodeSubset = ctx => assumed => (a, b) => { +/** @type {(ctx: _Ctx) => (assumed: _Assumed) => (a: _Keyed, b: _Keyed) => boolean} */ +const keyedSubset = ctx => assumed => ([a, aKey], [b, bKey]) => { let assumed1 = assumed - if (typeof a === 'string' && typeof b === 'string') { - const inner = assumed[a] - if (inner !== undefined && inner[b] === true) { return true } - assumed1 = { ...assumed, [a]: inner === undefined ? { [b]: true } : { ...inner, [b]: true } } + if (aKey !== undefined && bKey !== undefined) { + const inner = assumed[aKey] + if (inner !== undefined && inner[bKey] === true) { return true } + assumed1 = { ...assumed, [aKey]: inner === undefined ? { [bKey]: true } : { ...inner, [bKey]: true } } } return unionSubset(ctx)(assumed1)(resolve(ctx[0])(a), resolve(ctx[1])(b)) } +/** @type {(ctx: _Ctx) => (assumed: _Assumed) => (a: Node, b: Node) => boolean} */ +const nodeSubset = ctx => assumed => (a, b) => + keyedSubset(ctx)(assumed)(keyed(a), keyed(b)) + /** * Sound subset test: `true` means every value of `a` is a value of `b`. * Kind-wise on unions, pattern-wise on arrays/objects, coinductive on @@ -966,7 +994,7 @@ const objectSetValidate = rules => p => value => { const { rest } = p if (rest === undefined) { return ok(value) } const extra = eachEntry( - Object.entries(value).filter(([k]) => p.props[k] === undefined), + Object.entries(value).filter(([k]) => at(k)(p.props) === null), (_k, v) => nodeValidate(rules)(rest)(v), undefined, noAccumulate, diff --git a/fjs/types/rtti/data/proof.f.mjs b/fjs/types/rtti/data/proof.f.mjs index 146b0321d..13c9d1056 100644 --- a/fjs/types/rtti/data/proof.f.mjs +++ b/fjs/types/rtti/data/proof.f.mjs @@ -1,4 +1,5 @@ /** + * @import { Or } from '../types.ts' * @import { Data } from './types.ts' */ @@ -85,6 +86,19 @@ const a2 = () => ['array', [a2, b2]] /** @type {_B2} */ const b2 = () => ['array', b2] +/** A self-recursive record: rest-based object recursion. */ +/** @typedef {() => readonly ['record', _RecordSelf]} _RecordSelf */ +/** @type {_RecordSelf} */ +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 */ +/** @type {_Even} */ +const even = () => ['const', { value: number, next: option(odd) }] +/** @type {_Odd} */ +const odd = () => ['const', { value: number, next: option(even) }] + /** @typedef {() => readonly ['array', _Rec]} _Rec */ /** Every call returns a fresh recursive thunk whose function name is `f`. */ /** @type {() => _Rec} */ @@ -406,6 +420,9 @@ export const proof = { assert(subset(toData(record(number)))(toData({ a: option(number) }))) // a struct leaves undeclared keys unconstrained, a record does not assert(!subset(toData({ a: number }))(toData(record(number)))) + // a key inherited from Object.prototype is not a declared prop + assert(!subset(toData({ a: number }))(toData({ toString: number }))) + assert(subset(toData({ toString: /** @type {const} */ (42) }))(toData({ toString: number }))) }, recursion: () => { assert(subset(toData(list))(toData(list))) @@ -414,6 +431,20 @@ export const proof = { assert(!subset(toData(list))(toData(array(number)))) assert(subset(toData(array(neverRtti)))(toData(list))) }, + mixedObjectRecursion: () => { + // rest-based and property-based object recursion compared in one + // union used to overflow the stack: the synthesized `rest ∪ + // undefined` read-sets never reached the coinductive memo + const v = validate(toData(or(recordSelf, even))) + assertEq(v({})[0], 'ok') + assertEq(v({ value: 1 })[0], 'ok') + assertEq(v({ value: 'x' })[0], 'error') + assert(!subset(toData(recordSelf))(toData(even))) + assert(!subset(toData(even))(toData(recordSelf))) + assert(subset(toData(recordSelf))(toData(or(recordSelf, even)))) + assert(subset(toData(even))(toData(or(recordSelf, even)))) + assert(subset(toData(even))(toData(odd))) + }, assumed: () => { // one left rule checked against two right rules on one path /** @type {Data} */ @@ -493,6 +524,9 @@ export const proof = { const vr = validate(toData(record(number))) assertEq(vr({})[0], 'ok') assertEq(vr({ p: 1 })[0], 'ok') + // a key inherited from Object.prototype is still an extra key + assertEq(vr({ toString: 1 })[0], 'ok') + assertEq(vr({ toString: 'x' })[0], 'error') assertEq( JSON.stringify(vr({ p: 'a' })), '["error",{"path":["p"],"message":"unexpected value"}]') @@ -522,5 +556,11 @@ export const proof = { assertEq(vs(5)[0], 'ok') assertEq(vs('x')[0], 'error') }, + throw: { + // a dangling reference is malformed data — even one naming an + // `Object.prototype` member, which own-property lookup rejects + danglingReference: () => validate([{}, 'nope'])(1), + danglingPrototypeReference: () => validate([{}, 'toString'])(1), + }, }, }