diff --git a/AGENTS.md b/AGENTS.md index f745f5b4c..1f4916f14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -603,6 +603,41 @@ inline-cast position gives it the special const-assertion meaning. This is unlike every other `@type` cast, which works in both positions — don't "clean up" a `@type {const}` inline cast into the declaration form. +#### Mutually recursive constants: cross-reference with `typeof` + +When exported constants refer to each other in a cycle — the usual shape for a +recursive rtti schema, where `unknown` names `object` and `array` and both are +built from `unknown` — pin them with an explicit `@type` whose element types are +`typeof` references to the other constants, **not** with `@type {const}`: + +```js +/** @type {() => readonly['or', typeof primitive, typeof object, typeof array]} */ +export const unknown = () => ['or', primitive, object, array] + +export const object = record(unknown) +export const array = rttiArray(unknown) +``` + +Forward references are fine: `unknown` is annotated in terms of `object` and +`array`, declared below it. + +`@type {const}` is wrong here even though it compiles. It pins the tuple, so +`npx tsc` and `fjs t` both pass — but it gives declaration emit no *name* for +the recursive positions, so the emitter inlines the structure, gives up at +depth, and writes `/*elided*/ any`. On `fjs/media/json/rtti/module.f.mjs` the +const cast emitted 4 `any` and 2 `/*elided*/`; the `typeof` form emitted +neither. Only a consumer type-checking against the published `.d.mts` sees the +difference, which is why this needs to be a rule rather than something review +catches. Omitting the annotation entirely is a third, louder failure: the array +literal widens to `(string | …)[]` and fails `TS2345` outright (see "Pin literal +`const`s" above). + +Pair the annotation with a round-trip assert so it stays checked rather than +merely claimed — `fjs/media/json/types.ts` holds +`Assert>>`. An explicit `@type` on a constant +whose type the compiler would otherwise infer is only as trustworthy as what +verifies it. + #### Avoid type predicates Avoid TypeScript type predicates (`(x: T): x is U`). They are error-prone: the diff --git a/CHANGELOG.md b/CHANGELOG.md index b95ab9e8a..6d22ca0d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,15 @@ history. ## Unreleased +- **BREAKING CHANGES:** the JSON rtti schemas `primitive`, `unknown`, + `object`, and `array` move from `fjs/media/json/module.f.ts` to a new + JSDoc-typed `fjs/media/json/rtti/module.f.mjs`, and the `Primitive`, + `Unknown`, `Object`, and `Array` types to a sibling + `fjs/media/json/types.ts` — importers must use the `rtti/module.f.mjs` + specifier for the schemas and the `types.ts` specifier for the types. + `fjs/media/json/module.f.ts` stays TypeScript and no longer re-exports + either + [#1498](https://github.com/functionalscript/functionalscript/pull/1498) - **BREAKING CHANGES:** `fjs/fsc/json.f.ts` and `fjs/fsc/bnf.f.ts` migrate from authored TypeScript to JSDoc-typed JavaScript (`.f.mjs`) — no local types to split, both use only `Rule`/`TerminalRange` from diff --git a/fjs/djs/module.f.ts b/fjs/djs/module.f.ts index 377d016ee..b492679ed 100644 --- a/fjs/djs/module.f.ts +++ b/fjs/djs/module.f.ts @@ -3,7 +3,7 @@ * * @module */ -import type { Primitive as JsonPrimitive } from '../media/json/module.f.ts' +import type { Primitive as JsonPrimitive } from '../media/json/types.ts' import { transpile } from './transpiler/module.f.ts' import { stringify, stringifyAsTree } from './serializer/module.f.ts' import { sort } from '../types/object/module.f.mjs' diff --git a/fjs/djs/todo/663-json-djs-tree-type.md b/fjs/djs/todo/663-json-djs-tree-type.md index 02634e2d9..ac17f6bfa 100644 --- a/fjs/djs/todo/663-json-djs-tree-type.md +++ b/fjs/djs/todo/663-json-djs-tree-type.md @@ -42,7 +42,7 @@ Keep the names the modules already use and import the shared definitions through a namespace where useful: ```ts -// fjs/media/json/module.f.ts +// fjs/media/json/types.ts import type * as Tree from './common/module.f.ts' export type Primitive = boolean | string | number | null export type Unknown = Tree.Unknown @@ -53,7 +53,7 @@ export type Array = Tree.Array ```ts // fjs/djs/module.f.ts import type * as Tree from '../media/json/common/module.f.ts' -import type { Primitive as JsonPrimitive } from '../media/json/module.f.ts' +import type { Primitive as JsonPrimitive } from '../media/json/types.ts' export type Primitive = JsonPrimitive | bigint | undefined export type Unknown = Tree.Unknown export type Object = Tree.Object @@ -80,6 +80,9 @@ serialization behavior. JSON tree, `undefined` is not a primitive leaf, but `object[key]` can still be `undefined` because the property is absent. - Preserve `readonly` recursive containers. +- Keep `fjs/media/json/types.ts`'s + `Assert>>` pin against the rtti schema in + `fjs/media/json/rtti/module.f.mjs` satisfied by the re-expressed aliases. - Keep the shared module in the JSON/DJS family rather than promoting it to a broader generic types package without another real consumer. - Confirm recursive generic aliases work with `tsc` and the repository's Deno @@ -109,7 +112,7 @@ serialization behavior. - [157](./157.md) — shares JSON/DJS parser value machinery; complementary to sharing the recursive value type. - [197](./197.md) — extracts traversal over the same `Unknown` shape. -- `fjs/media/json/module.f.ts` — current JSON recursive type aliases. +- `fjs/media/json/types.ts` — current JSON recursive type aliases. - `fjs/djs/module.f.ts` — current DJS recursive type aliases. - `fjs/media/json/serializer/module.f.mjs` — currently has no separate recursive generic value aliases and therefore is not part of this migration. diff --git a/fjs/mcp/proof.f.ts b/fjs/mcp/proof.f.ts index 4d4837453..03c599000 100644 --- a/fjs/mcp/proof.f.ts +++ b/fjs/mcp/proof.f.ts @@ -1,8 +1,10 @@ +import type { Unknown } from '../media/json/types.ts' + import { assert, assertEq } from '../asserts/module.f.mjs' import { pure, step } from '../effects/module.f.mjs' import type { Effect, Operation } from '../effects/types.ts' import { create } from '../effects/memory/module.f.mjs' -import { parse as parseJson, type Unknown } from '../media/json/module.f.ts' +import { parse as parseJson } from '../media/json/module.f.ts' import { number as rttiNumber, option, string as rttiString } from '../types/rtti/module.f.mjs' import { parse as rttiParse } from '../types/rtti/parse/module.f.mjs' import type { Response } from '../protocol/json_rpc/module.f.ts' diff --git a/fjs/media/html/todo/665-json-html.md b/fjs/media/html/todo/665-json-html.md index c111a66c0..0c1abbcbb 100644 --- a/fjs/media/html/todo/665-json-html.md +++ b/fjs/media/html/todo/665-json-html.md @@ -87,5 +87,6 @@ Which serialises to: ### Related -- `fjs/media/json/module.f.ts` — the `Unknown` type and `serialize` +- `fjs/media/json/types.ts` — the `Unknown` type +- `fjs/media/json/module.f.ts` — `serialize` - `fjs/media/json/schema/module.f.ts` — sibling JSON-dialect module diff --git a/fjs/media/json/module.f.ts b/fjs/media/json/module.f.ts index 1ea2f8517..b673e1c9d 100644 --- a/fjs/media/json/module.f.ts +++ b/fjs/media/json/module.f.ts @@ -1,13 +1,13 @@ /** - * JSON value types, rtti schemas, and utilities: `serialize`, `stringify`, - * `parse`, and `setProperty` for immutable nested updates. + * JSON utilities: `serialize`, `stringify`, `parse`, and `setProperty` for + * immutable nested updates. * * `parse` is the total, `Result`-returning `text → Unknown` entry point built * on this module's own tokenizer and parser. * - * The JSON value types (`Unknown`, `Primitive`) are derived from the rtti - * schemas defined here, so the schema is the single source of truth — no - * hand-written types to keep in sync. + * The JSON value types (`Unknown`, `Primitive`, `Object`, `Array`) live in + * [`./types.ts`](./types.ts), and the rtti schemas they are pinned against in + * [`./rtti/module.f.mjs`](./rtti/module.f.mjs). * * @module */ @@ -22,46 +22,7 @@ import { at, definedEntries } from '../../types/object/module.f.mjs' import type { Entry as ObjectEntry } from '../../types/object/types.ts' import { compose, fn } from '../../types/function/module.f.mjs' import { objectWrap, arrayWrap, stringSerialize, numberSerialize, nullSerialize, boolSerialize } from './serializer/module.f.mjs' -import { boolean as rttiBoolean, number as rttiNumber, string as rttiString, or, record, array as rttiArray } from '../../types/rtti/module.f.mjs' -import type { Ts } from '../../types/rtti/ts/types.ts' -import type { Assert } from '../../asserts/types.ts' -import type { Equal } from '../../types/ts/types.ts' - -// ── rtti schemas ────────────────────────────────────────────────────────────── - -/** rtti schema matching any JSON primitive: `null`, `boolean`, `number`, or `string`. */ -export const primitive = or(null, rttiBoolean, rttiNumber, rttiString) - -/** - * rtti schema matching any JSON value: a primitive, an array of JSON values, - * or an object whose values are JSON values. Self-referential via a thunk; - * rtti instantiates array/record item validators lazily so recursion terminates - * on acyclic input. - * - * A struct field typed `unknown` is **required when present** — unlike rtti - * core's `unknown`, the JSON `unknown` excludes `undefined`. - */ -export const unknown = () => ['or', primitive, object, array] as const - -/** - * rtti schema matching a JSON object: `{ readonly [k: string]?: Unknown }`. - */ -export const object = record(unknown) - -/** rtti schema matching a JSON array: `readonly Unknown[]`. */ -export const array = rttiArray(unknown) - -// ── TypeScript types (derived from schemas — single source of truth) ────────── - -export type Primitive = Ts - -export type Unknown = Object | Array | Primitive - -export type Object = { readonly[k in string]?: Unknown } - -export type Array = readonly Unknown[] - -type _Unknown = Assert>> +import type { Object, Unknown } from './types.ts' // ── JSON utilities ──────────────────────────────────────────────────────────── diff --git a/fjs/media/json/parser/module.f.ts b/fjs/media/json/parser/module.f.ts index f5c79caf3..54d97d396 100644 --- a/fjs/media/json/parser/module.f.ts +++ b/fjs/media/json/parser/module.f.ts @@ -3,6 +3,9 @@ * * @module */ + +import type { Unknown } from '../types.ts' + import type { Result } from '../../../types/result/types.ts' import { error, ok } from '../../../types/result/module.f.mjs' import type { List } from '../../../types/list/types.ts' @@ -11,7 +14,6 @@ import type { Fold } from '../../../types/function/operator/types.ts' import type { JsonToken } from '../tokenizer/types.ts' import { setReplace } from '../../../types/ordered_map/module.f.mjs' import type { OrderedMap } from '../../../types/ordered_map/types.ts' -import { type Unknown } from '../module.f.ts' import { fromMap } from '../../../types/object/module.f.mjs' import { assertEq } from '../../../asserts/module.f.mjs' diff --git a/fjs/media/json/rtti/module.f.mjs b/fjs/media/json/rtti/module.f.mjs new file mode 100644 index 000000000..8c3e14e4e --- /dev/null +++ b/fjs/media/json/rtti/module.f.mjs @@ -0,0 +1,51 @@ +/** + * rtti schemas describing the JSON data model: `primitive`, `unknown`, + * `object`, and `array`. + * + * The three composite schemas are mutually recursive — `unknown` names + * `object` and `array`, both of which are built from `unknown` — so `unknown` + * carries an explicit `@type` that cross-references its neighbours through + * `typeof`. That spelling is deliberate: `@type {const}` also type-checks here, + * but leaves declaration emit no name for the recursive positions, so it + * inlines the structure, gives up at depth, and degrades the emitted `.d.mts` + * to `any`. See AGENTS.md §6.2. + * + * The TypeScript counterparts live in the sibling + * [`../types.ts`](../types.ts), which pins them against these schemas with + * `Assert>>`. + * + * @module + */ + +import { + boolean as rttiBoolean, + number as rttiNumber, + string as rttiString, + or, + record, + array as rttiArray +} from '../../../types/rtti/module.f.mjs' + +/** rtti schema matching any JSON primitive: `null`, `boolean`, `number`, or `string`. */ +export const primitive = or(null, rttiBoolean, rttiNumber, rttiString) + +/** + * rtti schema matching any JSON value: a primitive, an array of JSON values, + * or an object whose values are JSON values. Self-referential via a thunk; + * rtti instantiates array/record item validators lazily so recursion terminates + * on acyclic input. + * + * A struct field typed `unknown` is **required when present** — unlike rtti + * core's `unknown`, the JSON `unknown` excludes `undefined`. + * + * @type {() => readonly['or', typeof primitive, typeof object, typeof array]} + */ +export const unknown = () => ['or', primitive, object, array] + +/** + * rtti schema matching a JSON object: `{ readonly [k: string]?: Unknown }`. + */ +export const object = record(unknown) + +/** rtti schema matching a JSON array: `readonly Unknown[]`. */ +export const array = rttiArray(unknown) diff --git a/fjs/media/json/rtti/proof.f.mjs b/fjs/media/json/rtti/proof.f.mjs new file mode 100644 index 000000000..78bb662a4 --- /dev/null +++ b/fjs/media/json/rtti/proof.f.mjs @@ -0,0 +1,86 @@ +/** + * Proof for the JSON rtti schemas. + * + * @module + */ + +import { assertEq } from '../../../asserts/module.f.mjs' +import { validate } from '../../../types/rtti/validate/module.f.mjs' +import { primitive, unknown, object, array } from './module.f.mjs' + +/** @import { ValidateE } from '../../../types/rtti/common/types.ts' */ +/** @import { Unknown } from '../../../types/rtti/ts/types.ts' */ + +// Reduces a validation to its `ok`/`error` tag: these schemas are about what is +// accepted, not about the payload, which validation returns unchanged. The +// erased `ValidateE` keeps the shared helper from re-instantiating each +// schema's deep recursive result type (TS2589). +/** @type {(v: ValidateE) => (value: Unknown) => string} */ +const tag = v => value => v(value)[0] + +const primitiveAccepts = tag(validate(primitive)) +const unknownAccepts = tag(validate(unknown)) +const objectAccepts = tag(validate(object)) +const arrayAccepts = tag(validate(array)) + +export const proof = { + primitive: { + accepts: () => { + assertEq(primitiveAccepts(null), 'ok') + assertEq(primitiveAccepts(true), 'ok') + assertEq(primitiveAccepts(0), 'ok') + assertEq(primitiveAccepts(''), 'ok') + }, + rejectsComposite: () => { + assertEq(primitiveAccepts([]), 'error') + assertEq(primitiveAccepts({}), 'error') + }, + }, + object: { + accepts: () => { + assertEq(objectAccepts({}), 'ok') + assertEq(objectAccepts({ a: 1, b: 'two', c: null }), 'ok') + }, + // A record schema descends into its values, so a bad leaf anywhere in + // the tree fails the whole object. + nested: () => { + assertEq(objectAccepts({ a: { b: [1, { c: null }] } }), 'ok') + }, + rejectsNonObject: () => { + assertEq(objectAccepts(1), 'error') + assertEq(objectAccepts([]), 'error') + }, + }, + array: { + accepts: () => { + assertEq(arrayAccepts([]), 'ok') + assertEq(arrayAccepts([null, true, 2, 'three']), 'ok') + }, + nested: () => { + assertEq(arrayAccepts([[{ a: [] }]]), 'ok') + }, + rejectsNonArray: () => { + assertEq(arrayAccepts(1), 'error') + assertEq(arrayAccepts({}), 'error') + }, + }, + unknown: { + // `unknown` is the union of the three above, so it accepts every arm. + acceptsEveryArm: () => { + assertEq(unknownAccepts(null), 'ok') + assertEq(unknownAccepts(false), 'ok') + assertEq(unknownAccepts(1), 'ok') + assertEq(unknownAccepts('s'), 'ok') + assertEq(unknownAccepts([]), 'ok') + assertEq(unknownAccepts({}), 'ok') + }, + // The thunk closes the recursion: an arbitrarily deep acyclic value + // terminates because array/record item validators instantiate lazily. + recurses: () => { + assertEq(unknownAccepts({ a: [{ b: [[{ c: 'd' }]] }] }), 'ok') + }, + rejectsUndefined: () => { + assertEq(unknownAccepts(undefined), 'error') + }, + }, +} diff --git a/fjs/media/json/schema/module.f.ts b/fjs/media/json/schema/module.f.ts index 4022ea694..74517b329 100644 --- a/fjs/media/json/schema/module.f.ts +++ b/fjs/media/json/schema/module.f.ts @@ -13,7 +13,7 @@ import type { Visitor } from '../../../types/rtti/common/types.ts' import type { Primitive } from '../../../djs/module.f.ts' import type { Ts } from '../../../types/rtti/ts/types.ts' import type { Phantom } from '../../../types/phantom/types.ts' -import { unknown as jsonUnknown } from '../module.f.ts' +import { unknown as jsonUnknown } from '../rtti/module.f.mjs' const unknownThunk = () => ['const', unknownConst] as const diff --git a/fjs/media/json/schema/proof.f.ts b/fjs/media/json/schema/proof.f.ts index 3fb6d1c8f..1c50c63bb 100644 --- a/fjs/media/json/schema/proof.f.ts +++ b/fjs/media/json/schema/proof.f.ts @@ -1,5 +1,7 @@ +import { type Unknown as JsonValue } from '../types.ts' + import { boolean, number, string, bigint, unknown, array, record, or, option } from '../../../types/rtti/module.f.mjs' -import { stringify, type Unknown as JsonValue } from '../module.f.ts' +import { stringify } from '../module.f.ts' import { toJsonSchema, type Unknown, unknown as schemaUnknown } from './module.f.ts' import { assert, assertEq } from '../../../asserts/module.f.mjs' diff --git a/fjs/media/json/todo/standard-parse-serialize.md b/fjs/media/json/todo/standard-parse-serialize.md index 4d37c821b..a78cd3439 100644 --- a/fjs/media/json/todo/standard-parse-serialize.md +++ b/fjs/media/json/todo/standard-parse-serialize.md @@ -140,5 +140,6 @@ This keeps parser/serializer policy separate from generic runtime conversion. - [RTTI-aware extended JSON parser](./rtti-parse.md) — another materializer over the same lossless number-token tree. - [Remove native JSON](./remove-native-json.md) — self-hosts serialization. -- [`fjs/media/json/module.f.ts`](../module.f.ts) — current ordinary JSON value - types and `parse` / `stringify` surface. +- [`fjs/media/json/module.f.ts`](../module.f.ts) — current ordinary JSON + `parse` / `stringify` surface. +- [`fjs/media/json/types.ts`](../types.ts) — current ordinary JSON value types. diff --git a/fjs/media/json/types.ts b/fjs/media/json/types.ts new file mode 100644 index 000000000..c4bc5594e --- /dev/null +++ b/fjs/media/json/types.ts @@ -0,0 +1,27 @@ +/** + * TypeScript counterparts of the JSON data model: `Primitive`, `Unknown`, + * `Object`, and `Array`. + * + * `Unknown` is written by hand rather than derived, so that the recursion + * reads directly, and is then pinned against the rtti schemas in the sibling + * [`./rtti/module.f.mjs`](./rtti/module.f.mjs) with + * `Assert>>`. The pin is what keeps the two + * descriptions of the same data model from drifting apart. + * + * @module + */ + +import type { Assert } from "../../asserts/types.ts" +import type { Ts } from "../../types/rtti/ts/types.ts" +import type { Equal } from "../../types/ts/types.ts" +import type { primitive, unknown } from "./rtti/module.f.mjs" + +export type Primitive = Ts + +export type Unknown = Object | Array | Primitive + +export type Object = { readonly[k in string]?: Unknown } + +export type Array = readonly Unknown[] + +type _Unknown = Assert>> diff --git a/fjs/media/revision/module.f.ts b/fjs/media/revision/module.f.ts index 33b28e411..6cce0e3d8 100644 --- a/fjs/media/revision/module.f.ts +++ b/fjs/media/revision/module.f.ts @@ -14,11 +14,14 @@ * * @module */ + +import type { Unknown } from '../json/types.ts' + import { array, number, option, record, string } from '../../types/rtti/module.f.mjs' import { validate as rttiValidate } from '../../types/rtti/validate/module.f.mjs' import type { ValidationError } from '../../types/rtti/common/types.ts' import type { Ts } from '../../types/rtti/ts/types.ts' -import { parse as parseJson, type Unknown } from '../json/module.f.ts' +import { parse as parseJson } from '../json/module.f.ts' import { cBase32ToVec } from '../../basen/cbase32/module.f.mjs' import type { Result } from '../../types/result/types.ts' import { error, ok } from '../../types/result/module.f.mjs' diff --git a/fjs/media/revision/proof.f.ts b/fjs/media/revision/proof.f.ts index 8f2f6c804..a56a4c029 100644 --- a/fjs/media/revision/proof.f.ts +++ b/fjs/media/revision/proof.f.ts @@ -1,5 +1,6 @@ +import type { Object as JsonObject } from '../json/types.ts' + import { assert, assertEq } from '../../asserts/module.f.mjs' -import type { Object as JsonObject } from '../json/module.f.ts' import { dialect, mediaType, isHash, validate, decodeText, encodeText, type LockMap } from './module.f.ts' // Valid cbase32 hashes (round-tripped in fjs/basen/cbase32/proof.f.mjs): single diff --git a/fjs/protocol/json_rpc/module.f.ts b/fjs/protocol/json_rpc/module.f.ts index 6f761b360..7357a7f71 100644 --- a/fjs/protocol/json_rpc/module.f.ts +++ b/fjs/protocol/json_rpc/module.f.ts @@ -14,11 +14,14 @@ * * @module */ + +import type { Unknown } from '../../media/json/types.ts' + import { number, string, or, option } from '../../types/rtti/module.f.mjs' import type { Ts } from '../../types/rtti/ts/types.ts' import { validate } from '../../types/rtti/validate/module.f.mjs' import type { Result } from '../../types/result/types.ts' -import { unknown, type Unknown } from '../../media/json/module.f.ts' +import { unknown } from '../../media/json/rtti/module.f.mjs' export const jsonrpc = '2.0' as const diff --git a/fjs/protocol/mcp/module.f.ts b/fjs/protocol/mcp/module.f.ts index 579a92614..4f6674ad0 100644 --- a/fjs/protocol/mcp/module.f.ts +++ b/fjs/protocol/mcp/module.f.ts @@ -13,8 +13,9 @@ * * @module */ +import type { Unknown } from '../../media/json/types.ts' + import { boolean, string, option, array, record, or } from '../../types/rtti/module.f.mjs' -import { unknown, type Unknown } from '../../media/json/module.f.ts' import type { Ts } from '../../types/rtti/ts/types.ts' import { pure, step } from '../../effects/module.f.mjs' import type { Operation, Effect } from '../../effects/types.ts' @@ -29,6 +30,7 @@ import { import { validate } from '../../types/rtti/validate/module.f.mjs' import { toJsonSchema } from '../../media/json/schema/module.f.ts' import type { Type } from '../../types/rtti/types.ts' +import { unknown } from '../../media/json/rtti/module.f.mjs' // ── Shared ───────────────────────────────────────────────────────────────────── diff --git a/fjs/protocol/mcp/proof.f.ts b/fjs/protocol/mcp/proof.f.ts index 173c53c2a..f0e67b8fe 100644 --- a/fjs/protocol/mcp/proof.f.ts +++ b/fjs/protocol/mcp/proof.f.ts @@ -1,3 +1,5 @@ +import type { Unknown } from '../../media/json/types.ts' + import { assert, assertEq } from '../../asserts/module.f.mjs' import { pure, step } from '../../effects/module.f.mjs' import { eff } from '../../effects/eff/module.f.mjs' @@ -6,7 +8,6 @@ import { run } from '../../effects/mock/module.f.mjs' import type { MemOperationMap } from '../../effects/mock/types.ts' import { asBase, asNominal, create, read } from '../../effects/memory/module.f.mjs' import type { Key, MemOp } from '../../effects/memory/types.ts' -import type { Unknown } from '../../media/json/module.f.ts' import { type ToolsListParams, type ToolsListResult, type ToolsCallParams, type ToolsCallResult, type McpHandlers, type McpConfig, type McpSessionState, diff --git a/fjs/protocol/mcp/stdio/module.f.ts b/fjs/protocol/mcp/stdio/module.f.ts index 3133aaaae..d19f935bb 100644 --- a/fjs/protocol/mcp/stdio/module.f.ts +++ b/fjs/protocol/mcp/stdio/module.f.ts @@ -29,12 +29,14 @@ * * @module */ +import type { Unknown } from '../../../media/json/types.ts' + import { pure, step } from '../../../effects/module.f.mjs' import type { Effect, Operation } from '../../../effects/types.ts' import { readLine, write } from '../../../effects/node/module.f.mjs' import type { IoResult, Read, Write } from '../../../effects/node/types.ts' import { tryUtf8 } from '../../../text/module.f.mjs' -import { parse, stringify, type Unknown } from '../../../media/json/module.f.ts' +import { parse, stringify } from '../../../media/json/module.f.ts' import { sort } from '../../../types/object/module.f.mjs' import { internalError, jsonrpc, parseError, type Response } from '../../json_rpc/module.f.ts' import { error, ok } from '../../../types/result/module.f.mjs' diff --git a/fjs/protocol/mcp/stdio/proof.f.ts b/fjs/protocol/mcp/stdio/proof.f.ts index 04eff280d..7d7ea1494 100644 --- a/fjs/protocol/mcp/stdio/proof.f.ts +++ b/fjs/protocol/mcp/stdio/proof.f.ts @@ -1,9 +1,10 @@ +import type { Unknown } from '../../../media/json/types.ts' + import { assertEq } from '../../../asserts/module.f.mjs' import { pure } from '../../../effects/module.f.mjs' import type { Effect } from '../../../effects/types.ts' import { emptyState, virtual } from '../../../effects/node/virtual/module.f.mjs' import type { State } from '../../../effects/node/virtual/types.ts' -import type { Unknown } from '../../../media/json/module.f.ts' import { stringify } from '../../../media/json/module.f.ts' import { utf8 } from '../../../text/module.f.mjs' import { fromVec } from '../../../types/uint8array/module.f.mjs' diff --git a/fjs/text/utf16/proof.f.ts b/fjs/text/utf16/proof.f.ts index e6fe4f377..81a31b194 100644 --- a/fjs/text/utf16/proof.f.ts +++ b/fjs/text/utf16/proof.f.ts @@ -1,3 +1,5 @@ +import type { Unknown } from '../../media/json/types.ts' + import { toCodePointList, fromCodePointList, @@ -7,7 +9,7 @@ import { codePointListToString, codePointToString } from './module.f.mjs' -import { stringify as jsonStringify, type Unknown } from '../../media/json/module.f.ts' +import { stringify as jsonStringify } from '../../media/json/module.f.ts' import { sort } from '../../types/object/module.f.mjs' import { toArray } from '../../types/list/module.f.mjs' import { assertEq } from '../../asserts/module.f.mjs' diff --git a/fjs/types/btree/find/proof.f.ts b/fjs/types/btree/find/proof.f.ts index 111d71bbb..04054962a 100644 --- a/fjs/types/btree/find/proof.f.ts +++ b/fjs/types/btree/find/proof.f.ts @@ -1,7 +1,9 @@ +import type { Unknown } from '../../../media/json/types.ts' + import type { Result } from './types.ts' import { find as btreeFind } from './module.f.mjs' import { map, toArray } from '../../list/module.f.mjs' -import { stringify, type Unknown } from '../../../media/json/module.f.ts' +import { stringify } from '../../../media/json/module.f.ts' import { sort } from '../../object/module.f.mjs' import type { TNode } from '../types/types.ts' import { cmp } from '../../string/module.f.mjs' diff --git a/fjs/types/btree/proof.f.ts b/fjs/types/btree/proof.f.ts index fc927e915..961fc642a 100644 --- a/fjs/types/btree/proof.f.ts +++ b/fjs/types/btree/proof.f.ts @@ -1,6 +1,8 @@ +import type { Unknown } from '../../media/json/types.ts' + import type { TNode } from './types/types.ts' import { values } from './module.f.mjs' -import { stringify as jsonStringify, type Unknown } from '../../media/json/module.f.ts' +import { stringify as jsonStringify } from '../../media/json/module.f.ts' import { sort } from '../object/module.f.mjs' import { cmp } from '../string/module.f.mjs' import type { List, Result } from '../list/types.ts' diff --git a/fjs/types/byte_set/proof.f.ts b/fjs/types/byte_set/proof.f.ts index 563aff748..87badfd9b 100644 --- a/fjs/types/byte_set/proof.f.ts +++ b/fjs/types/byte_set/proof.f.ts @@ -1,6 +1,8 @@ +import type { Unknown } from '../../media/json/types.ts' + import { has, empty, set, setRange, unset, universe, complement, toRangeMap } from './module.f.mjs' import { every, countdown, map, toArray } from '../list/module.f.mjs' -import { stringify as jsonStringify, type Unknown } from '../../media/json/module.f.ts' +import { stringify as jsonStringify } from '../../media/json/module.f.ts' import { sort } from '../object/module.f.mjs' import { assert, assertEq } from '../../asserts/module.f.mjs' diff --git a/fjs/types/list/proof.f.ts b/fjs/types/list/proof.f.ts index c93e47724..0186e3aa9 100644 --- a/fjs/types/list/proof.f.ts +++ b/fjs/types/list/proof.f.ts @@ -1,6 +1,8 @@ +import type { Unknown } from '../../media/json/types.ts' + import type { List } from './types.ts' import { length, concat, countdown, cycle, drop, dropWhile, entries, every, filter, find, flat, flatMap, map, next, reduce, reverse, scan, some, take, takeWhile, toArray, zip, first, filterMap, isEmpty, equal, tryFold } from './module.f.mjs' -import { stringify, type Unknown } from '../../media/json/module.f.ts' +import { stringify } from '../../media/json/module.f.ts' import { sort } from '../object/module.f.mjs' import { addition, strictEqual, reduceToScan } from '../function/operator/module.f.mjs' import { assert, assertEq, assertNotNullish } from '../../asserts/module.f.mjs' diff --git a/fjs/types/range_map/proof.f.ts b/fjs/types/range_map/proof.f.ts index 2f33d95ca..c2c6ea4f5 100644 --- a/fjs/types/range_map/proof.f.ts +++ b/fjs/types/range_map/proof.f.ts @@ -1,6 +1,8 @@ +import type { Unknown } from '../../media/json/types.ts' + import type { RangeMapArray, Properties, RangeMap } from './types.ts' import { get, merge, fromRange, rangeMap } from './module.f.mjs' -import { stringify, type Unknown } from '../../media/json/module.f.ts' +import { stringify } from '../../media/json/module.f.ts' import { sort } from '../object/module.f.mjs' import { union } from '../sorted_set/module.f.mjs' import type { SortedSet } from '../sorted_set/types.ts' diff --git a/fjs/types/sorted_list/proof.f.ts b/fjs/types/sorted_list/proof.f.ts index 92235a1ae..90cd28554 100644 --- a/fjs/types/sorted_list/proof.f.ts +++ b/fjs/types/sorted_list/proof.f.ts @@ -1,5 +1,7 @@ +import type { Unknown } from '../../media/json/types.ts' + import { find, merge } from './module.f.mjs' -import { stringify, type Unknown } from '../../media/json/module.f.ts' +import { stringify } from '../../media/json/module.f.ts' import { sort } from '../object/module.f.mjs' import { toArray, countdown, length } from '../list/module.f.mjs' import { flip } from '../function/module.f.mjs' diff --git a/fjs/types/sorted_set/proof.f.ts b/fjs/types/sorted_set/proof.f.ts index 4b429c30c..7c4a0a6e5 100644 --- a/fjs/types/sorted_set/proof.f.ts +++ b/fjs/types/sorted_set/proof.f.ts @@ -1,5 +1,7 @@ +import type { Unknown } from '../../media/json/types.ts' + import { has, intersect, union } from './module.f.mjs' -import { stringify, type Unknown } from '../../media/json/module.f.ts' +import { stringify } from '../../media/json/module.f.ts' import { sort } from '../object/module.f.mjs' import { toArray, countdown, length } from '../list/module.f.mjs' import { flip } from '../function/module.f.mjs' diff --git a/todo/migrate-typescript-to-mjs.md b/todo/migrate-typescript-to-mjs.md index 9896c0fc8..4a8e31a02 100644 --- a/todo/migrate-typescript-to-mjs.md +++ b/todo/migrate-typescript-to-mjs.md @@ -448,6 +448,57 @@ non-composing generics already do) when a generic function does not call other independently-generic functions in its body; switch to the per-arrow style once composition breaks inference. +#### Mutually recursive constants need `typeof`, not `@type {const}` + +rtti schemas are values, not types, so a recursive schema is a cycle in the +*value* graph: `unknown` names `object` and `array`, and both are built from +`unknown`. TypeScript source closed that cycle with inference plus `as const`, +which has no declaration-level JSDoc equivalent. The replacement is an explicit +`@type` whose element types are `typeof` references to the other constants: + +```js +/** @type {() => readonly['or', typeof primitive, typeof object, typeof array]} */ +export const unknown = () => ['or', primitive, object, array] + +export const object = record(unknown) +export const array = rttiArray(unknown) +``` + +Forward references are fine — `unknown` is annotated in terms of `object` and +`array`, which are declared below it. + +The failure this avoids is **declaration emit**, not type checking, and the +three candidate spellings fail in different places. Measured on +`fjs/media/json/rtti/module.f.mjs` ([#1498](https://github.com/functionalscript/functionalscript/pull/1498)): + +| form | `npx tsc` | emitted `.d.mts` | +| ---- | --------- | ---------------- | +| no annotation | **TS2345** — literal widens to `(string \| …)[]`, not a `Type` | — | +| `/** @type {const} */(…)` inline cast | clean | 4 `any` + 2 `/*elided*/` | +| `@type {() => readonly[…typeof…]}` | clean | 0 `any`, 0 `elided` | + +The bare form is just the "pin literal `const`s" rule. The interesting case +is the middle one: it type-checks, `fjs t` is green, and the damage is visible +only to a consumer of the published package — the same invisible-in-repository +failure mode as [curried generic exports](#curried-generic-exports-need-an-explicit-returns). +`@type {const}` pins the tuple but gives the emitter no *name* for the +recursive positions, so it inlines the structure and gives up at depth, +collapsing to `/*elided*/ any`. `typeof primitive` / `typeof object` / +`typeof array` are names the emitter can print, so each node of the cycle +refers to its neighbours by name and the emitted declaration stays finite and +exact (it also stays smaller: 1392 vs 2248 characters). + +Keep the round-trip proven rather than asserted. `fjs/media/json/types.ts` +carries `Assert>>`, so the hand-written type +and the schema-derived one are checked against each other; without such an +assert the explicit `@type` is an unverified claim about a schema the compiler +would otherwise have inferred. + +This generalizes beyond JSON. Any mutually recursive group of exported rtti +schemas needs the same treatment, and more of them are expected as rtti use +grows — reach for `typeof` cross-references first, and check the emitted +`.d.mts` for `any` / `elided` before considering the group migrated. + #### Declaration-only TypeScript is not a migration hard case Do not require the migration plan to pre-design a JavaScript/JSDoc @@ -580,6 +631,15 @@ this rename. hoisted to a leading declaration annotation — the declaration-level form fails with `TS2304` because TypeScript resolves `const` as an ordinary type name there, unlike every other `@type` cast. +- [ ] Annotate mutually recursive exported constants (rtti schema groups above + all) with an explicit `@type` that cross-references its neighbours by + `typeof`, not with `/** @type {const} */`. The const cast type-checks but + leaves the emitter no name for the recursive positions, so it inlines the + structure and collapses it to `/*elided*/ any` in the `.d.mts` while the + repository stays green. Expect more of these as rtti spreads; check the + emitted declaration for `any` / `elided` before calling such a group + migrated, and keep an `Assert>>` beside the schema + so the explicit annotation stays verified rather than asserted. - [ ] Decide each JSDoc typedef's visibility at the migration boundary: prefix implementation-only typedefs with `_` and leave publicly useful ones unprefixed, judged by what the module should offer its consumers rather @@ -673,6 +733,10 @@ this rename. composes other independently-generic functions in its body. - `/** @type {const} */` stays an inline cast on the expression it types, never a leading declaration-level annotation. +- Mutually recursive exported constants carry an explicit `@type` that names its + neighbours through `typeof`, and their emitted declarations contain no `any` or + `/*elided*/`; a `Ts` round-trip assert keeps each such annotation + verified against the schema it describes. - Renaming or removing an emitted `_`-prefixed alias is not breaking solely due to that alias being emitted; any resulting change to a public declaration's assignability is still a breaking change.