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
14 changes: 14 additions & 0 deletions changelog/unreleased/1748.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions fjs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -402,7 +402,8 @@ validate({ a: 42 }) // the same, with `<const T>`

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.
Expand Down
9 changes: 6 additions & 3 deletions fjs/cas/evo/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -458,23 +458,26 @@ 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,
subject,
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 }
return ok({
...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) }),
})
}

Expand Down
12 changes: 6 additions & 6 deletions fjs/ci/common/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand All @@ -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} */ ({
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions fjs/mcp/cas/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
12 changes: 6 additions & 6 deletions fjs/mcp/evo/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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. */
Expand Down Expand Up @@ -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 ────────────────────────────────────────────────────────────────
Expand Down
6 changes: 3 additions & 3 deletions fjs/mcp/proof.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
110 changes: 65 additions & 45 deletions fjs/media/json/schema/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]} */
Expand All @@ -49,33 +49,35 @@ export const unknown = unknownThunk
/** A JSON Schema (draft 2020-12) document — the subset of keywords that `toJsonSchema` emits. */
/** @typedef {Ts<typeof unknown>} 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<typeof unknownConst.$schema>
* readonly $ref?: Ts<typeof unknownConst.$ref>
Expand Down Expand Up @@ -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}
Expand All @@ -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}
*/
Expand All @@ -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 ? {} : {
Expand All @@ -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),
Expand Down
Loading
Loading