Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
288 changes: 226 additions & 62 deletions fjs/media/json/schema/module.f.mjs

Large diffs are not rendered by default.

226 changes: 219 additions & 7 deletions fjs/media/json/schema/proof.f.mjs
Original file line number Diff line number Diff line change
@@ -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} */
Expand All @@ -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' }),
Expand All @@ -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: {
Expand All @@ -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()
Expand All @@ -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)
},
},
}
Loading
Loading