@zocomputer/system-api 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -2
- package/dist/catalog/index.d.ts +106 -0
- package/dist/catalog/index.js +237 -0
- package/dist/core/catalog.d.ts +155 -0
- package/dist/core/catalog.js +24 -0
- package/dist/core/client.d.ts +156 -0
- package/dist/core/client.js +87 -0
- package/dist/core/database.d.ts +124 -0
- package/dist/core/database.js +314 -0
- package/dist/core/envelope.d.ts +45 -0
- package/dist/core/envelope.js +112 -0
- package/dist/core/errors.d.ts +52 -0
- package/dist/core/errors.js +33 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.js +13 -0
- package/dist/core/json-call.d.ts +4 -0
- package/dist/core/json-call.js +109 -0
- package/dist/core/json.d.ts +17 -0
- package/dist/core/json.js +4 -0
- package/dist/core/transport.d.ts +47 -0
- package/dist/core/transport.js +367 -0
- package/dist/core/validator.d.ts +52 -0
- package/dist/core/validator.js +1 -0
- package/dist/generate/cli.d.ts +44 -0
- package/dist/generate/cli.js +358 -0
- package/dist/generate/generate.d.ts +46 -0
- package/dist/generate/generate.js +215 -0
- package/dist/generate/identifiers.d.ts +27 -0
- package/dist/generate/identifiers.js +67 -0
- package/dist/generate/index.d.ts +15 -0
- package/dist/generate/index.js +15 -0
- package/dist/generate/manifest.d.ts +1 -0
- package/dist/generate/manifest.js +1 -0
- package/dist/generate/types.d.ts +57 -0
- package/dist/generate/types.js +357 -0
- package/dist/generated/catalog.d.ts +2 -0
- package/dist/generated/catalog.js +842 -0
- package/dist/generated/index.d.ts +2 -0
- package/dist/generated/index.js +4 -0
- package/dist/generated/manifest.json +789 -0
- package/dist/generated/operations.d.ts +8407 -0
- package/dist/generated/operations.js +3 -0
- package/dist/generated/schema.json +39395 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/json/index.d.ts +81 -0
- package/dist/json/index.js +404 -0
- package/dist/validation/index.d.ts +19 -0
- package/dist/validation/index.js +149 -0
- package/dist/validation/json.d.ts +1 -0
- package/dist/validation/json.js +1 -0
- package/dist/validation/pattern.d.ts +56 -0
- package/dist/validation/pattern.js +382 -0
- package/dist/validation/schema.d.ts +103 -0
- package/dist/validation/schema.js +1300 -0
- package/openapi.json +40698 -0
- package/package.json +93 -4
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, collision-safe TypeScript identifiers and sanitized doc text
|
|
3
|
+
* for generated output. Schema data is never code: a generated type name is
|
|
4
|
+
* derived only from a safe character class, and documentation text is
|
|
5
|
+
* neutralized so it can never terminate a comment or inject TypeScript.
|
|
6
|
+
*/
|
|
7
|
+
/** Longest doc-comment body kept per node; longer text is truncated with an ellipsis. */
|
|
8
|
+
const MAX_DOC_LENGTH = 4096;
|
|
9
|
+
/**
|
|
10
|
+
* PascalCase a raw token from its alphanumeric runs. Non-alphanumeric
|
|
11
|
+
* characters (dots, dashes, underscores, anything else) are treated as segment
|
|
12
|
+
* separators, so `zo.machine.read_file` becomes `ZoMachineReadFile`. A leading
|
|
13
|
+
* digit run is prefixed with `_` so the result is a valid identifier head.
|
|
14
|
+
*/
|
|
15
|
+
export function pascalCase(raw) {
|
|
16
|
+
const segments = raw.split(/[^A-Za-z0-9]+/).filter((s) => s.length > 0);
|
|
17
|
+
const joined = segments
|
|
18
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
19
|
+
.join("");
|
|
20
|
+
if (joined.length === 0)
|
|
21
|
+
return "_";
|
|
22
|
+
return /^[0-9]/.test(joined) ? `_${joined}` : joined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Type name for an action's input or output at a version:
|
|
26
|
+
* `("zo.machine.read_file", 1, "Input")` → `ZoMachineReadFileInputV1`.
|
|
27
|
+
*/
|
|
28
|
+
export function operationTypeName(action, version, kind) {
|
|
29
|
+
return `${pascalCase(action)}${kind}V${version}`;
|
|
30
|
+
}
|
|
31
|
+
/** Type name for a resource type: `("machine", 1)` → `MachineV1`. */
|
|
32
|
+
export function resourceTypeName(type, version) {
|
|
33
|
+
return `${pascalCase(type)}V${version}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Neutralize free-form documentation text so it is safe inside a `/** … *\/`
|
|
37
|
+
* block: drop control characters (except newline/tab), collapse the comment
|
|
38
|
+
* terminator `*\/`, and bound the length. The result contains no sequence that
|
|
39
|
+
* can close the comment or start a new syntactic construct.
|
|
40
|
+
*/
|
|
41
|
+
export function sanitizeDoc(text) {
|
|
42
|
+
// Strip C0/C1 control characters other than newline and tab; they have no
|
|
43
|
+
// place in a comment and can confuse editors and tooling.
|
|
44
|
+
let out = "";
|
|
45
|
+
for (const ch of text) {
|
|
46
|
+
const code = ch.codePointAt(0);
|
|
47
|
+
if (code === 0x09 || code === 0x0a) {
|
|
48
|
+
out += ch;
|
|
49
|
+
}
|
|
50
|
+
else if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
|
|
51
|
+
// drop
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
out += ch;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Neutralize the comment terminator anywhere it appears (including a `*` and
|
|
58
|
+
// `/` split across sanitization) so the doc text cannot close the block.
|
|
59
|
+
out = out.replace(/\*\//g, "*/");
|
|
60
|
+
// Bound the length in Unicode code points, matching the server/SDK annotation
|
|
61
|
+
// bound. A UTF-16 `.length`/`.slice` would truncate a valid astral-character
|
|
62
|
+
// description early and could split a surrogate pair, emitting U+FFFD.
|
|
63
|
+
const points = Array.from(out);
|
|
64
|
+
if (points.length > MAX_DOC_LENGTH)
|
|
65
|
+
out = points.slice(0, MAX_DOC_LENGTH).join("") + "…";
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@zocomputer/system-api/generate` — the programmatic generator entry.
|
|
3
|
+
*
|
|
4
|
+
* Exposes the pure generation pipeline (parse a normalized catalog export →
|
|
5
|
+
* emit the five-file typed-client bundle) without the CLI's I/O. A caller that
|
|
6
|
+
* already has the export bytes can drive generation directly; the
|
|
7
|
+
* `zo-system-api` CLI is a thin wrapper over this surface.
|
|
8
|
+
*
|
|
9
|
+
* This entry pulls in json-schema-to-typescript and its dependencies, so a
|
|
10
|
+
* runtime consumer of the generated client never imports it — it lives behind
|
|
11
|
+
* this separate specifier, distinct from the dependency-light `.` and `./core`.
|
|
12
|
+
*/
|
|
13
|
+
export { BUNDLE_FILE_NAMES, GENERATOR_VERSION, generateBundle, type CatalogSelection, type GenerateInput, type GeneratedBundle, } from "./generate.ts";
|
|
14
|
+
export { CatalogFormatError, loadNormalizedCatalog, parseNormalizedCatalog, toPlainJson, type CatalogSelectionEcho, type NormalizedCatalog, type OperationEntry, type OperationVersion, type ResourceTypeEntry, } from "./manifest.ts";
|
|
15
|
+
export { GeneratorError } from "./types.ts";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@zocomputer/system-api/generate` — the programmatic generator entry.
|
|
3
|
+
*
|
|
4
|
+
* Exposes the pure generation pipeline (parse a normalized catalog export →
|
|
5
|
+
* emit the five-file typed-client bundle) without the CLI's I/O. A caller that
|
|
6
|
+
* already has the export bytes can drive generation directly; the
|
|
7
|
+
* `zo-system-api` CLI is a thin wrapper over this surface.
|
|
8
|
+
*
|
|
9
|
+
* This entry pulls in json-schema-to-typescript and its dependencies, so a
|
|
10
|
+
* runtime consumer of the generated client never imports it — it lives behind
|
|
11
|
+
* this separate specifier, distinct from the dependency-light `.` and `./core`.
|
|
12
|
+
*/
|
|
13
|
+
export { BUNDLE_FILE_NAMES, GENERATOR_VERSION, generateBundle, } from "./generate.js";
|
|
14
|
+
export { CatalogFormatError, loadNormalizedCatalog, parseNormalizedCatalog, toPlainJson, } from "./manifest.js";
|
|
15
|
+
export { GeneratorError } from "./types.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../catalog/index.ts";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../catalog/index.js";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type JsonValue } from "../validation/json.ts";
|
|
2
|
+
/** Thrown when a schema cannot be rendered to safe, exact TypeScript. */
|
|
3
|
+
export declare class GeneratorError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A single, deterministic, collision-proof registry of every top-level
|
|
8
|
+
* TypeScript name the generator emits. `claim` is idempotent per source: the
|
|
9
|
+
* same `sourceKey` always returns the same name, and two distinct sources that
|
|
10
|
+
* want the same base name are separated by a content-addressed suffix derived
|
|
11
|
+
* from the losing source's identity.
|
|
12
|
+
*/
|
|
13
|
+
export declare class NameRegistry {
|
|
14
|
+
private readonly used;
|
|
15
|
+
private readonly assigned;
|
|
16
|
+
constructor(reserved?: Iterable<string>);
|
|
17
|
+
/** True if `name` is already taken (reserved or claimed). */
|
|
18
|
+
has(name: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Return the unique name for `sourceKey`, minting it on first request.
|
|
21
|
+
* `desiredBase` must be a valid identifier; if it is free the source keeps it,
|
|
22
|
+
* otherwise the source receives `${desiredBase}_${hash}` with the shortest
|
|
23
|
+
* hash prefix that is free (extended, then counter-suffixed, only in the
|
|
24
|
+
* astronomically unlikely event of a further clash).
|
|
25
|
+
*/
|
|
26
|
+
claim(sourceKey: string, desiredBase: string): string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Fail if any numeric `enum`/`const` literal in the (lossless) schema would
|
|
30
|
+
* change value when represented as a JS `number`. Those keywords become emitted
|
|
31
|
+
* TypeScript literal types, so a silent round would make the generated type
|
|
32
|
+
* disagree with the contract; the author must string-encode such a value
|
|
33
|
+
* instead. Only `enum`/`const` are checked — other numeric keywords never become
|
|
34
|
+
* literal types.
|
|
35
|
+
*/
|
|
36
|
+
export declare function assertRenderableLiterals(schema: JsonValue, where: string): void;
|
|
37
|
+
/** The result of rendering one schema: emitted text and whether it used JsonValue. */
|
|
38
|
+
export interface RenderedType {
|
|
39
|
+
readonly text: string;
|
|
40
|
+
readonly usesJsonValue: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Render one admitted schema to a named TypeScript declaration (plus its
|
|
44
|
+
* reachable `$defs` interfaces). `rootName` must already be a registry-minted
|
|
45
|
+
* name; `rootSourceKey` scopes this schema's `$defs` names within the registry.
|
|
46
|
+
*/
|
|
47
|
+
export declare function renderSchemaType(schema: JsonValue, rootName: string, rootSourceKey: string, registry: NameRegistry, where: string): Promise<RenderedType>;
|
|
48
|
+
/**
|
|
49
|
+
* Lexically scan `text` for a bare `any` type token. This is comment- and
|
|
50
|
+
* string-aware: an `any` inside a line/block comment or a quoted string
|
|
51
|
+
* (including an `enum`/`const` value literally `"any"`) is skipped, and a `//`
|
|
52
|
+
* or `/*` inside a string literal is not mistaken for a comment. An `any`
|
|
53
|
+
* identifier immediately followed (past whitespace) by `:` or `?` is a property
|
|
54
|
+
* name, not the `any` type, so it is ignored; any other bare `any` token in code
|
|
55
|
+
* is a real `any` type.
|
|
56
|
+
*/
|
|
57
|
+
export declare function containsAnyType(text: string): boolean;
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The schema → TypeScript renderer: turn one admitted Zo-dialect schema into a
|
|
3
|
+
* named, collision-safe TypeScript declaration.
|
|
4
|
+
*
|
|
5
|
+
* Every emitted top-level name — an operation's Input/Output type, a resource
|
|
6
|
+
* type, and every `$defs` interface reachable from a schema — is minted through
|
|
7
|
+
* one shared {@link NameRegistry}, so independently compiled schemas can be
|
|
8
|
+
* concatenated without ever colliding or relying on TypeScript interface
|
|
9
|
+
* merging (which would silently union two unrelated shapes). Naming is fully
|
|
10
|
+
* deterministic: the first claimant of a base name in a fixed traversal order
|
|
11
|
+
* keeps the clean name; a distinct later claimant gets a content-addressed
|
|
12
|
+
* suffix.
|
|
13
|
+
*
|
|
14
|
+
* The pipeline per schema:
|
|
15
|
+
* 1. Admit through the SDK's own {@link compileSchema} — the authoritative
|
|
16
|
+
* "supported dialect" gate. A schema it rejects is rejected here too; we
|
|
17
|
+
* never fall back to emitting `any`.
|
|
18
|
+
* 2. Guard emitted literals: a numeric `enum`/`const` becomes a TypeScript
|
|
19
|
+
* literal type, so it must fail loudly (not silently round) when the value
|
|
20
|
+
* is not representable as a JS number. Ordinary bounds
|
|
21
|
+
* (minimum/maximum/multipleOf) are type-level metadata TypeScript ignores,
|
|
22
|
+
* so they may round harmlessly.
|
|
23
|
+
* 3. Project the lossless tree to plain JSON, then rewrite it for the
|
|
24
|
+
* compiler: strip every `title` (forcing anonymous inlining of nested
|
|
25
|
+
* objects so only registry-named declarations survive), rename `$defs`
|
|
26
|
+
* keys to registry names and rewrite `#/$defs/` refs, sanitize doc text,
|
|
27
|
+
* and rewrite the `x-zo-json-value` marker to the SDK's `JsonValue` type.
|
|
28
|
+
* 4. Compile with json-schema-to-typescript and post-scan the output: any
|
|
29
|
+
* `any` in a type position is a generator bug and throws.
|
|
30
|
+
*/
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
import Decimal from "decimal.js";
|
|
33
|
+
import { compile } from "json-schema-to-typescript";
|
|
34
|
+
import { JsonNumber } from "../validation/json.js";
|
|
35
|
+
import { SchemaDefinitionError, compileSchema } from "../validation/schema.js";
|
|
36
|
+
import { pascalCase, sanitizeDoc } from "./identifiers.js";
|
|
37
|
+
import { toPlainJson } from "./manifest.js";
|
|
38
|
+
/** The local-definition ref prefix, matching the validator's supported dialect. */
|
|
39
|
+
const REF_PREFIX = "#/$defs/";
|
|
40
|
+
/** Options passed to json-schema-to-typescript for every schema. */
|
|
41
|
+
const COMPILE_OPTIONS = {
|
|
42
|
+
// We prepend our own file banner in the orchestrator; none per schema.
|
|
43
|
+
bannerComment: "",
|
|
44
|
+
// Emit every reachable `$defs` interface as its own top-level declaration
|
|
45
|
+
// (named by the registry) rather than inlining it.
|
|
46
|
+
declareExternallyReferenced: true,
|
|
47
|
+
// Objects without an explicit `additionalProperties` are closed; our exporter
|
|
48
|
+
// sets it explicitly anyway.
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
// Deterministic raw output; the orchestrator owns final formatting.
|
|
51
|
+
format: false,
|
|
52
|
+
// Unknown/empty schemas become `unknown`, never `any`.
|
|
53
|
+
unknownAny: true,
|
|
54
|
+
// No `$ref` fetching: our dialect is local-only, and network/file resolution
|
|
55
|
+
// must never happen during generation.
|
|
56
|
+
$refOptions: {},
|
|
57
|
+
};
|
|
58
|
+
/** Thrown when a schema cannot be rendered to safe, exact TypeScript. */
|
|
59
|
+
export class GeneratorError extends Error {
|
|
60
|
+
constructor(message) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = "GeneratorError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A single, deterministic, collision-proof registry of every top-level
|
|
67
|
+
* TypeScript name the generator emits. `claim` is idempotent per source: the
|
|
68
|
+
* same `sourceKey` always returns the same name, and two distinct sources that
|
|
69
|
+
* want the same base name are separated by a content-addressed suffix derived
|
|
70
|
+
* from the losing source's identity.
|
|
71
|
+
*/
|
|
72
|
+
export class NameRegistry {
|
|
73
|
+
used = new Set();
|
|
74
|
+
assigned = new Map();
|
|
75
|
+
constructor(reserved = []) {
|
|
76
|
+
for (const name of reserved)
|
|
77
|
+
this.used.add(name);
|
|
78
|
+
}
|
|
79
|
+
/** True if `name` is already taken (reserved or claimed). */
|
|
80
|
+
has(name) {
|
|
81
|
+
return this.used.has(name);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Return the unique name for `sourceKey`, minting it on first request.
|
|
85
|
+
* `desiredBase` must be a valid identifier; if it is free the source keeps it,
|
|
86
|
+
* otherwise the source receives `${desiredBase}_${hash}` with the shortest
|
|
87
|
+
* hash prefix that is free (extended, then counter-suffixed, only in the
|
|
88
|
+
* astronomically unlikely event of a further clash).
|
|
89
|
+
*/
|
|
90
|
+
claim(sourceKey, desiredBase) {
|
|
91
|
+
const prior = this.assigned.get(sourceKey);
|
|
92
|
+
if (prior !== undefined)
|
|
93
|
+
return prior;
|
|
94
|
+
let name = desiredBase;
|
|
95
|
+
if (this.used.has(name)) {
|
|
96
|
+
const digest = createHash("sha256").update(sourceKey).digest("hex");
|
|
97
|
+
let width = 8;
|
|
98
|
+
name = `${desiredBase}_${digest.slice(0, width)}`;
|
|
99
|
+
while (this.used.has(name)) {
|
|
100
|
+
width += 4;
|
|
101
|
+
if (width > digest.length) {
|
|
102
|
+
let counter = 2;
|
|
103
|
+
while (this.used.has(`${desiredBase}_${digest}_${counter}`))
|
|
104
|
+
counter += 1;
|
|
105
|
+
name = `${desiredBase}_${digest}_${counter}`;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
name = `${desiredBase}_${digest.slice(0, width)}`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
this.used.add(name);
|
|
112
|
+
this.assigned.set(sourceKey, name);
|
|
113
|
+
return name;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function isObject(value) {
|
|
117
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Fail if any numeric `enum`/`const` literal in the (lossless) schema would
|
|
121
|
+
* change value when represented as a JS `number`. Those keywords become emitted
|
|
122
|
+
* TypeScript literal types, so a silent round would make the generated type
|
|
123
|
+
* disagree with the contract; the author must string-encode such a value
|
|
124
|
+
* instead. Only `enum`/`const` are checked — other numeric keywords never become
|
|
125
|
+
* literal types.
|
|
126
|
+
*/
|
|
127
|
+
export function assertRenderableLiterals(schema, where) {
|
|
128
|
+
const visit = (node) => {
|
|
129
|
+
if (Array.isArray(node)) {
|
|
130
|
+
for (const item of node)
|
|
131
|
+
visit(item);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!isObjectTree(node))
|
|
135
|
+
return;
|
|
136
|
+
if ("const" in node)
|
|
137
|
+
assertJsSafeNumbers(node["const"], where, "const");
|
|
138
|
+
if ("enum" in node) {
|
|
139
|
+
const values = node["enum"];
|
|
140
|
+
if (Array.isArray(values))
|
|
141
|
+
for (const value of values)
|
|
142
|
+
assertJsSafeNumbers(value, where, "enum");
|
|
143
|
+
}
|
|
144
|
+
// Recurse only through schema-bearing positions of the supported dialect, so
|
|
145
|
+
// a data value under `default`/`examples` is never mistaken for a keyword.
|
|
146
|
+
for (const key of ["items", "additionalProperties"])
|
|
147
|
+
if (key in node)
|
|
148
|
+
visit(node[key]);
|
|
149
|
+
for (const key of ["anyOf", "oneOf"]) {
|
|
150
|
+
const branches = node[key];
|
|
151
|
+
if (Array.isArray(branches))
|
|
152
|
+
for (const branch of branches)
|
|
153
|
+
visit(branch);
|
|
154
|
+
}
|
|
155
|
+
for (const key of ["properties", "$defs"]) {
|
|
156
|
+
const holder = node[key];
|
|
157
|
+
if (isObjectTree(holder))
|
|
158
|
+
for (const child of Object.values(holder))
|
|
159
|
+
visit(child);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
visit(schema);
|
|
163
|
+
}
|
|
164
|
+
function isObjectTree(value) {
|
|
165
|
+
return (value !== null &&
|
|
166
|
+
typeof value === "object" &&
|
|
167
|
+
!Array.isArray(value) &&
|
|
168
|
+
!(value instanceof JsonNumber));
|
|
169
|
+
}
|
|
170
|
+
/** Deep-check every number inside an `enum`/`const` value for JS-safety. */
|
|
171
|
+
function assertJsSafeNumbers(value, where, keyword) {
|
|
172
|
+
if (value instanceof JsonNumber) {
|
|
173
|
+
const text = value.text;
|
|
174
|
+
if (!new Decimal(text).equals(new Decimal(Number(text))))
|
|
175
|
+
throw new GeneratorError(`${where}: numeric ${keyword} value ${text} is not representable as a ` +
|
|
176
|
+
`JavaScript number and would change if emitted as a TypeScript literal; ` +
|
|
177
|
+
`encode it as a string in the schema instead`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (Array.isArray(value)) {
|
|
181
|
+
for (const item of value)
|
|
182
|
+
assertJsSafeNumbers(item, where, keyword);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (value !== null && typeof value === "object")
|
|
186
|
+
for (const item of Object.values(value))
|
|
187
|
+
assertJsSafeNumbers(item, where, keyword);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Render one admitted schema to a named TypeScript declaration (plus its
|
|
191
|
+
* reachable `$defs` interfaces). `rootName` must already be a registry-minted
|
|
192
|
+
* name; `rootSourceKey` scopes this schema's `$defs` names within the registry.
|
|
193
|
+
*/
|
|
194
|
+
export async function renderSchemaType(schema, rootName, rootSourceKey, registry, where) {
|
|
195
|
+
// 1. Admit through the authoritative dialect gate.
|
|
196
|
+
try {
|
|
197
|
+
compileSchema(schema);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error instanceof SchemaDefinitionError)
|
|
201
|
+
throw new GeneratorError(`${where}: schema is outside the supported dialect: ${error.message}`);
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
// 2. Guard emitted numeric literals against silent rounding.
|
|
205
|
+
assertRenderableLiterals(schema, where);
|
|
206
|
+
// 3. Project to plain JSON and rewrite for the compiler.
|
|
207
|
+
const plain = toPlainJson(schema);
|
|
208
|
+
if (!isObject(plain))
|
|
209
|
+
throw new GeneratorError(`${where}: schema must be a JSON object`);
|
|
210
|
+
const renameMap = buildDefsRenameMap(plain, rootName, rootSourceKey, registry);
|
|
211
|
+
const marker = { used: false };
|
|
212
|
+
const prepared = rewriteForCompile(plain, renameMap, marker);
|
|
213
|
+
// 4. Compile and post-scan for `any`.
|
|
214
|
+
let text;
|
|
215
|
+
try {
|
|
216
|
+
text = await compile(prepared, rootName, COMPILE_OPTIONS);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
throw new GeneratorError(`${where}: json-schema-to-typescript failed: ${error.message}`);
|
|
220
|
+
}
|
|
221
|
+
assertNoAny(text, where);
|
|
222
|
+
return { text: text.trim(), usesJsonValue: marker.used };
|
|
223
|
+
}
|
|
224
|
+
/** Build the `$defs` key → registry-name map for one root schema. */
|
|
225
|
+
function buildDefsRenameMap(plain, rootName, rootSourceKey, registry) {
|
|
226
|
+
const map = new Map();
|
|
227
|
+
const defs = plain["$defs"];
|
|
228
|
+
if (!isObject(defs))
|
|
229
|
+
return map;
|
|
230
|
+
// Sort keys so the first claimant of any clashing base is deterministic.
|
|
231
|
+
for (const key of Object.keys(defs).sort()) {
|
|
232
|
+
const assigned = registry.claim(`def:${rootSourceKey}:${key}`, `${rootName}${pascalCase(key)}`);
|
|
233
|
+
map.set(key, assigned);
|
|
234
|
+
}
|
|
235
|
+
return map;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Rewrite a plain schema for json-schema-to-typescript: strip titles, sanitize
|
|
239
|
+
* descriptions, rewrite `x-zo-json-value` to a `JsonValue` tsType, rename the
|
|
240
|
+
* root `$defs` keys, rewrite `#/$defs/` refs, and drop any schema-supplied
|
|
241
|
+
* `tsType` (never honor raw type injection from schema data) and `discriminator`
|
|
242
|
+
* (OpenAPI metadata the compiler ignores). Objects are built with a null
|
|
243
|
+
* prototype so a property literally named `__proto__` survives.
|
|
244
|
+
*/
|
|
245
|
+
function rewriteForCompile(value, renameMap, marker) {
|
|
246
|
+
if (Array.isArray(value))
|
|
247
|
+
return value.map((item) => rewriteForCompile(item, renameMap, marker));
|
|
248
|
+
if (!isObject(value))
|
|
249
|
+
return value;
|
|
250
|
+
// The arbitrary-JSON marker: emit the SDK's exact wire type. We mint this
|
|
251
|
+
// tsType ourselves — a schema-supplied tsType is never trusted.
|
|
252
|
+
if (value["x-zo-json-value"] === true) {
|
|
253
|
+
marker.used = true;
|
|
254
|
+
const out = Object.create(null);
|
|
255
|
+
out["tsType"] = "JsonValue";
|
|
256
|
+
if (typeof value["description"] === "string")
|
|
257
|
+
out["description"] = sanitizeDoc(value["description"]);
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
const out = Object.create(null);
|
|
261
|
+
for (const [key, item] of Object.entries(value)) {
|
|
262
|
+
if (key === "title" || key === "tsType" || key === "discriminator")
|
|
263
|
+
continue;
|
|
264
|
+
if (key === "description" && typeof item === "string") {
|
|
265
|
+
out[key] = sanitizeDoc(item);
|
|
266
|
+
}
|
|
267
|
+
else if (key === "$ref" && typeof item === "string") {
|
|
268
|
+
out[key] = rewriteRef(item, renameMap);
|
|
269
|
+
}
|
|
270
|
+
else if (key === "$defs" && isObject(item)) {
|
|
271
|
+
const defs = Object.create(null);
|
|
272
|
+
for (const [defKey, defValue] of Object.entries(item))
|
|
273
|
+
defs[renameMap.get(defKey) ?? defKey] = rewriteForCompile(defValue, renameMap, marker);
|
|
274
|
+
out[key] = defs;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
out[key] = rewriteForCompile(item, renameMap, marker);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
function rewriteRef(ref, renameMap) {
|
|
283
|
+
if (!ref.startsWith(REF_PREFIX))
|
|
284
|
+
return ref;
|
|
285
|
+
const name = ref.slice(REF_PREFIX.length);
|
|
286
|
+
const mapped = renameMap.get(name);
|
|
287
|
+
return mapped === undefined ? ref : `${REF_PREFIX}${mapped}`;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Fail if the compiled output contains `any` in a type position. With
|
|
291
|
+
* `unknownAny: true` this should never happen; it is a defense-in-depth check so
|
|
292
|
+
* a compiler regression can never silently widen a generated type to `any`.
|
|
293
|
+
*/
|
|
294
|
+
function assertNoAny(text, where) {
|
|
295
|
+
if (containsAnyType(text))
|
|
296
|
+
throw new GeneratorError(`${where}: generated TypeScript contains 'any'; refusing to emit an unsound type`);
|
|
297
|
+
}
|
|
298
|
+
const IDENT_CHAR = /[A-Za-z0-9_$]/;
|
|
299
|
+
/**
|
|
300
|
+
* Lexically scan `text` for a bare `any` type token. This is comment- and
|
|
301
|
+
* string-aware: an `any` inside a line/block comment or a quoted string
|
|
302
|
+
* (including an `enum`/`const` value literally `"any"`) is skipped, and a `//`
|
|
303
|
+
* or `/*` inside a string literal is not mistaken for a comment. An `any`
|
|
304
|
+
* identifier immediately followed (past whitespace) by `:` or `?` is a property
|
|
305
|
+
* name, not the `any` type, so it is ignored; any other bare `any` token in code
|
|
306
|
+
* is a real `any` type.
|
|
307
|
+
*/
|
|
308
|
+
export function containsAnyType(text) {
|
|
309
|
+
const n = text.length;
|
|
310
|
+
let i = 0;
|
|
311
|
+
while (i < n) {
|
|
312
|
+
const ch = text[i];
|
|
313
|
+
// Line comment.
|
|
314
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
315
|
+
i += 2;
|
|
316
|
+
while (i < n && text[i] !== "\n")
|
|
317
|
+
i += 1;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
// Block comment.
|
|
321
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
322
|
+
i += 2;
|
|
323
|
+
while (i < n && !(text[i] === "*" && text[i + 1] === "/"))
|
|
324
|
+
i += 1;
|
|
325
|
+
i += 2;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
// String literal (single, double, or template): skip to the matching
|
|
329
|
+
// delimiter, honoring backslash escapes.
|
|
330
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
331
|
+
const quote = ch;
|
|
332
|
+
i += 1;
|
|
333
|
+
while (i < n && text[i] !== quote)
|
|
334
|
+
i += text[i] === "\\" ? 2 : 1;
|
|
335
|
+
i += 1;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
// Identifier run in code.
|
|
339
|
+
if (IDENT_CHAR.test(ch)) {
|
|
340
|
+
const start = i;
|
|
341
|
+
while (i < n && IDENT_CHAR.test(text[i]))
|
|
342
|
+
i += 1;
|
|
343
|
+
if (text.slice(start, i) === "any") {
|
|
344
|
+
// A property name (`any:` / `any?:`) is not the `any` type.
|
|
345
|
+
let j = i;
|
|
346
|
+
while (j < n && /\s/.test(text[j]))
|
|
347
|
+
j += 1;
|
|
348
|
+
const next = text[j];
|
|
349
|
+
if (next !== ":" && next !== "?")
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
i += 1;
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
}
|