@procedures/compiler 0.0.0-stage → 0.2.3
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/LICENSE +21 -0
- package/README.md +254 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/checks.d.ts +24 -0
- package/dist/checks.d.ts.map +1 -0
- package/dist/checks.js +123 -0
- package/dist/checks.js.map +1 -0
- package/dist/codegen-closure.d.ts +19 -0
- package/dist/codegen-closure.d.ts.map +1 -0
- package/dist/codegen-closure.js +200 -0
- package/dist/codegen-closure.js.map +1 -0
- package/dist/codegen-text.d.ts +44 -0
- package/dist/codegen-text.d.ts.map +1 -0
- package/dist/codegen-text.js +360 -0
- package/dist/codegen-text.js.map +1 -0
- package/dist/diagnostics.d.ts +67 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +38 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/effects.d.ts +63 -0
- package/dist/effects.d.ts.map +1 -0
- package/dist/effects.js +185 -0
- package/dist/effects.js.map +1 -0
- package/dist/explain.d.ts +58 -0
- package/dist/explain.d.ts.map +1 -0
- package/dist/explain.js +144 -0
- package/dist/explain.js.map +1 -0
- package/dist/facts.d.ts +66 -0
- package/dist/facts.d.ts.map +1 -0
- package/dist/facts.js +102 -0
- package/dist/facts.js.map +1 -0
- package/dist/fold.d.ts +27 -0
- package/dist/fold.d.ts.map +1 -0
- package/dist/fold.js +128 -0
- package/dist/fold.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/interceptor.d.ts +51 -0
- package/dist/interceptor.d.ts.map +1 -0
- package/dist/interceptor.js +116 -0
- package/dist/interceptor.js.map +1 -0
- package/dist/ir.d.ts +240 -0
- package/dist/ir.d.ts.map +1 -0
- package/dist/ir.js +74 -0
- package/dist/ir.js.map +1 -0
- package/dist/mermaid.d.ts +53 -0
- package/dist/mermaid.d.ts.map +1 -0
- package/dist/mermaid.js +423 -0
- package/dist/mermaid.js.map +1 -0
- package/dist/module.d.ts +20 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +124 -0
- package/dist/module.js.map +1 -0
- package/dist/normalize.d.ts +43 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +529 -0
- package/dist/normalize.js.map +1 -0
- package/dist/openrpc.d.ts +63 -0
- package/dist/openrpc.d.ts.map +1 -0
- package/dist/openrpc.js +57 -0
- package/dist/openrpc.js.map +1 -0
- package/dist/print.d.ts +8 -0
- package/dist/print.d.ts.map +1 -0
- package/dist/print.js +148 -0
- package/dist/print.js.map +1 -0
- package/dist/procedures/program/check.d.ts +14 -0
- package/dist/procedures/program/check.d.ts.map +1 -0
- package/dist/procedures/program/check.js +30 -0
- package/dist/procedures/program/check.js.map +1 -0
- package/dist/procedures/program/compile.d.ts +14 -0
- package/dist/procedures/program/compile.d.ts.map +1 -0
- package/dist/procedures/program/compile.js +35 -0
- package/dist/procedures/program/compile.js.map +1 -0
- package/dist/procedures/program/diagram.d.ts +14 -0
- package/dist/procedures/program/diagram.d.ts.map +1 -0
- package/dist/procedures/program/diagram.js +23 -0
- package/dist/procedures/program/diagram.js.map +1 -0
- package/dist/procedures/program/effects-output.d.ts +8 -0
- package/dist/procedures/program/effects-output.d.ts.map +1 -0
- package/dist/procedures/program/effects-output.js +13 -0
- package/dist/procedures/program/effects-output.js.map +1 -0
- package/dist/procedures/program/explain.d.ts +14 -0
- package/dist/procedures/program/explain.d.ts.map +1 -0
- package/dist/procedures/program/explain.js +43 -0
- package/dist/procedures/program/explain.js.map +1 -0
- package/dist/procedures/program/index.d.ts +5 -0
- package/dist/procedures/program/index.d.ts.map +1 -0
- package/dist/procedures/program/index.js +5 -0
- package/dist/procedures/program/index.js.map +1 -0
- package/dist/procedures/sdk/index.d.ts +3 -0
- package/dist/procedures/sdk/index.d.ts.map +1 -0
- package/dist/procedures/sdk/index.js +3 -0
- package/dist/procedures/sdk/index.js.map +1 -0
- package/dist/procedures/sdk/manifest.d.ts +11 -0
- package/dist/procedures/sdk/manifest.d.ts.map +1 -0
- package/dist/procedures/sdk/manifest.js +15 -0
- package/dist/procedures/sdk/manifest.js.map +1 -0
- package/dist/procedures/sdk/openrpc.d.ts +11 -0
- package/dist/procedures/sdk/openrpc.d.ts.map +1 -0
- package/dist/procedures/sdk/openrpc.js +13 -0
- package/dist/procedures/sdk/openrpc.js.map +1 -0
- package/dist/procedures/sdk/typescript.d.ts +11 -0
- package/dist/procedures/sdk/typescript.d.ts.map +1 -0
- package/dist/procedures/sdk/typescript.js +14 -0
- package/dist/procedures/sdk/typescript.js.map +1 -0
- package/dist/program.d.ts +84 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +108 -0
- package/dist/program.js.map +1 -0
- package/dist/refs.d.ts +9 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +9 -0
- package/dist/refs.js.map +1 -0
- package/dist/register.d.ts +6 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +8 -0
- package/dist/register.js.map +1 -0
- package/dist/runtime.d.ts +68 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +138 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts +41 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +325 -0
- package/dist/schema.js.map +1 -0
- package/dist/scopes.d.ts +25 -0
- package/dist/scopes.d.ts.map +1 -0
- package/dist/scopes.js +202 -0
- package/dist/scopes.js.map +1 -0
- package/dist/types.d.ts +129 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +157 -0
- package/dist/types.js.map +1 -0
- package/dist/typescript-sdk.d.ts +34 -0
- package/dist/typescript-sdk.d.ts.map +1 -0
- package/dist/typescript-sdk.js +231 -0
- package/dist/typescript-sdk.js.map +1 -0
- package/package.json +83 -3
- package/src/checks.ts +128 -0
- package/src/codegen-closure.ts +224 -0
- package/src/codegen-text.ts +396 -0
- package/src/diagnostics.ts +98 -0
- package/src/effects.ts +231 -0
- package/src/explain.ts +194 -0
- package/src/facts.ts +161 -0
- package/src/fold.ts +136 -0
- package/src/index.ts +35 -0
- package/src/interceptor.ts +149 -0
- package/src/ir.ts +330 -0
- package/src/mermaid.ts +460 -0
- package/src/module.ts +171 -0
- package/src/normalize.ts +694 -0
- package/src/openrpc.ts +104 -0
- package/src/print.ts +143 -0
- package/src/procedures/program/check.ts +31 -0
- package/src/procedures/program/compile.ts +36 -0
- package/src/procedures/program/diagram.ts +25 -0
- package/src/procedures/program/effects-output.ts +16 -0
- package/src/procedures/program/explain.ts +46 -0
- package/src/procedures/program/index.ts +4 -0
- package/src/procedures/sdk/index.ts +2 -0
- package/src/procedures/sdk/manifest.ts +17 -0
- package/src/procedures/sdk/openrpc.ts +16 -0
- package/src/procedures/sdk/typescript.ts +17 -0
- package/src/program.ts +181 -0
- package/src/refs.ts +9 -0
- package/src/register.ts +10 -0
- package/src/runtime.ts +188 -0
- package/src/schema.ts +318 -0
- package/src/scopes.ts +196 -0
- package/src/types.ts +318 -0
- package/src/typescript-sdk.ts +282 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime of a compiled program: the small functions that the generated code calls.
|
|
3
|
+
*
|
|
4
|
+
* Each function does one step of the interpreter of `@procedures/client` with the same result and
|
|
5
|
+
* the same error message. The closure code and the source text use the same functions, so the
|
|
6
|
+
* two forms of a program operate the same. The entry `@procedures/compiler/runtime` exports them
|
|
7
|
+
* for the source text. Importing this module registers nothing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createRefScope,
|
|
12
|
+
isAnyProcedureRef,
|
|
13
|
+
isControlFlowPath,
|
|
14
|
+
isPlainObject,
|
|
15
|
+
normalizeRef,
|
|
16
|
+
pathToKey,
|
|
17
|
+
prepareRootCall,
|
|
18
|
+
rawInputRule,
|
|
19
|
+
withScope,
|
|
20
|
+
type AnyProcedureRef,
|
|
21
|
+
type HydrationEvent,
|
|
22
|
+
type ProcedureContext,
|
|
23
|
+
type ProcedurePath,
|
|
24
|
+
type RefScope,
|
|
25
|
+
} from "@procedures/client/browser";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The context that a compiled program needs: the context of a procedure handler gives it. Each
|
|
29
|
+
* call goes through `client.call`, so it keeps its admission, validation, deadline and interceptors.
|
|
30
|
+
*/
|
|
31
|
+
export interface ProgramContext {
|
|
32
|
+
readonly client: Pick<ProcedureContext["client"], "call">;
|
|
33
|
+
/** The registry of the caller: the raw-input rule of a fallback to the interpreter reads it. */
|
|
34
|
+
readonly registry?: ProcedureContext["registry"] | undefined;
|
|
35
|
+
/** The hydration observer of the program: a fallback to the interpreter gives it its events. */
|
|
36
|
+
readonly onRef?: ((event: HydrationEvent) => void) | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A compiled program: `(ctx, input) => output`. */
|
|
40
|
+
export type CompiledProgram = (ctx: ProgramContext, input?: unknown) => Promise<unknown>;
|
|
41
|
+
|
|
42
|
+
/** One frame of a scope for a raw input: the names with their values, and `$last` when the frame has it. */
|
|
43
|
+
export interface RuntimeFrame {
|
|
44
|
+
readonly names: ReadonlyArray<readonly [string, unknown]>;
|
|
45
|
+
readonly last?: readonly [unknown] | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `getPath()` of the client: the own fields of a value, or undefined. */
|
|
49
|
+
export function get(value: unknown, fields: readonly string[]): unknown {
|
|
50
|
+
let current = value;
|
|
51
|
+
for (const field of fields) {
|
|
52
|
+
if (current && typeof current === "object" && Object.hasOwn(current, field)) {
|
|
53
|
+
current = (current as Record<string, unknown>)[field];
|
|
54
|
+
} else {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return current;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The error of the interpreter for a `$ref` name that no scope has. */
|
|
62
|
+
export function unknownName(text: string): never {
|
|
63
|
+
const name = text.split(".")[0];
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Unknown $ref name "${name}" in "${text}": no step, item or input has this name here. ` +
|
|
66
|
+
`For a literal $ref object, write { "$literal": { "$ref": ... } }.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** This function throws an error with the message of the interpreter. */
|
|
71
|
+
export function fail(message: string): never {
|
|
72
|
+
throw new Error(message);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The input of an operand call with the parent context (`cwd`, `node`): `callOperand()` of the client. */
|
|
76
|
+
export function merge(input: unknown, extra: Readonly<Record<string, unknown>>): unknown {
|
|
77
|
+
if (Object.keys(extra).length === 0) return input;
|
|
78
|
+
return { ...(typeof input === "object" && input !== null && !Array.isArray(input) ? input : {}), ...extra };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The truth of a condition of `client.conditional`: the `value` field of an object decides. */
|
|
82
|
+
export function truthy(condition: unknown): boolean {
|
|
83
|
+
if (condition && typeof condition === "object" && "value" in condition) return Boolean((condition as { value: unknown }).value);
|
|
84
|
+
return Boolean(condition);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The items of `client.map` and `client.reduce`: an array, or the error of the interpreter. */
|
|
88
|
+
export function items(value: unknown): unknown[] {
|
|
89
|
+
if (!Array.isArray(value)) throw new Error(`items must be an array, got ${value === null ? "null" : typeof value}`);
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The message of an error, as the control-flow procedures read it. */
|
|
94
|
+
export function message(error: unknown): string {
|
|
95
|
+
return error instanceof Error ? error.message : String(error);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** An object from its entries: each key is an own property, also `__proto__`. */
|
|
99
|
+
export function object(entries: ReadonlyArray<readonly [string, unknown]>): Record<string, unknown> {
|
|
100
|
+
return Object.fromEntries(entries);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A call that waits for its runner: the call object with its hydrated input (`hydrateRef()`). */
|
|
104
|
+
export function deferred(source: Record<string, unknown>, input: unknown): unknown {
|
|
105
|
+
return input === source["input"] ? source : { ...source, input };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The scope of the interpreter for a raw input: the frames from the outermost to the innermost. */
|
|
109
|
+
export function scope(frames: readonly RuntimeFrame[]): RefScope | undefined {
|
|
110
|
+
let current: RefScope | undefined;
|
|
111
|
+
for (const frame of frames) {
|
|
112
|
+
const next = createRefScope(current);
|
|
113
|
+
for (const [name, value] of frame.names) next.outputs.set(name, value);
|
|
114
|
+
if (frame.last) next.last = frame.last[0];
|
|
115
|
+
current = next;
|
|
116
|
+
}
|
|
117
|
+
return current;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A call through the client. */
|
|
121
|
+
export function call(ctx: ProgramContext, path: ProcedurePath, input: unknown): Promise<unknown> {
|
|
122
|
+
return ctx.client.call(path, input);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A call of a procedure that takes its input raw: it gets the scope of the caller (`withScope()`). */
|
|
126
|
+
export function callRaw(ctx: ProgramContext, path: ProcedurePath, input: unknown, frames: readonly RuntimeFrame[] | undefined): Promise<unknown> {
|
|
127
|
+
return ctx.client.call(path, withScope(input, frames ? scope(frames) : undefined));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The result of `client.parallel`. */
|
|
131
|
+
export interface ParallelResult {
|
|
132
|
+
results: unknown[];
|
|
133
|
+
allSucceeded: boolean;
|
|
134
|
+
errors: Array<{ index: number; error: string }>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `client.parallel`: workers take the tasks in order, with `concurrency` and `failFast`. */
|
|
138
|
+
export async function parallel(tasks: ReadonlyArray<() => Promise<unknown>>, concurrency: number | undefined, failFast: boolean): Promise<ParallelResult> {
|
|
139
|
+
const results: unknown[] = new Array(tasks.length);
|
|
140
|
+
const errors: Array<{ index: number; error: string }> = [];
|
|
141
|
+
const limit = typeof concurrency === "number" && concurrency > 0 ? Math.min(concurrency, tasks.length) : tasks.length;
|
|
142
|
+
let nextIndex = 0;
|
|
143
|
+
let aborted = false;
|
|
144
|
+
const worker = async (): Promise<void> => {
|
|
145
|
+
while (!aborted) {
|
|
146
|
+
const index = nextIndex++;
|
|
147
|
+
if (index >= tasks.length) return;
|
|
148
|
+
try {
|
|
149
|
+
results[index] = await tasks[index]!();
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (failFast) {
|
|
152
|
+
aborted = true;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
errors.push({ index, error: message(error) });
|
|
156
|
+
results[index] = undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
await Promise.all(Array.from({ length: Math.max(1, limit) }, () => worker()));
|
|
161
|
+
errors.sort((a, b) => a.index - b.index);
|
|
162
|
+
return { results, allSucceeded: errors.length === 0, errors };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The raw-input rule of a fallback: the registry of the caller, or the rule of the compile time. */
|
|
166
|
+
function rawRule(ctx: ProgramContext, rawKeys: readonly string[]): (path: ProcedurePath) => boolean {
|
|
167
|
+
if (ctx.registry) return rawInputRule(ctx.registry);
|
|
168
|
+
const keys = new Set(rawKeys);
|
|
169
|
+
return (path) => keys.has(pathToKey(path)) || isControlFlowPath(path);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `client.exec(program, input)`: the fields of the input go over the fields of the input of the
|
|
174
|
+
* root call. The program changes, so the interpreter runs it.
|
|
175
|
+
*/
|
|
176
|
+
export async function execWithInput(ctx: ProgramContext, program: unknown, input: unknown, rawKeys: readonly string[]): Promise<unknown> {
|
|
177
|
+
let ref: AnyProcedureRef;
|
|
178
|
+
if (isAnyProcedureRef(program)) {
|
|
179
|
+
const normalized = normalizeRef(program);
|
|
180
|
+
ref = { ...normalized, input: isPlainObject(normalized.input) && isPlainObject(input) ? { ...normalized.input, ...input } : input };
|
|
181
|
+
} else {
|
|
182
|
+
// client.exec(path, input)
|
|
183
|
+
ref = { $proc: program as ProcedurePath, input: input ?? {} };
|
|
184
|
+
}
|
|
185
|
+
const executor = <TIn, TOut>(path: ProcedurePath, value: TIn): Promise<TOut> => ctx.client.call<TIn, TOut>(path, value);
|
|
186
|
+
const root = await prepareRootCall(ref, executor, { rawInput: rawRule(ctx, rawKeys), onRef: ctx.onRef });
|
|
187
|
+
return ctx.client.call(root.path, root.input);
|
|
188
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The check of an input against the JSON Schema of a procedure (the manifest gives draft-07).
|
|
3
|
+
*
|
|
4
|
+
* The compiler knows only a part of an input before the run: the literals, and the keys of an
|
|
5
|
+
* object whose values are calls. This validator works on such a partial value (a shape). It
|
|
6
|
+
* reports only a definite problem: a value that the compiler knows and that the schema refuses.
|
|
7
|
+
* A part that the compiler does not know agrees with each schema.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isPlainObject } from "@procedures/client/browser";
|
|
11
|
+
import { childPointer, type IRNode, type Pointer } from "./ir.js";
|
|
12
|
+
|
|
13
|
+
/** A partial value: what the compiler knows of a value before the run. */
|
|
14
|
+
export type Shape =
|
|
15
|
+
| { readonly kind: "known"; readonly value: unknown; readonly pointer: Pointer }
|
|
16
|
+
| { readonly kind: "unknown"; readonly pointer: Pointer }
|
|
17
|
+
| { readonly kind: "object"; readonly entries: ReadonlyMap<string, Shape>; readonly pointer: Pointer }
|
|
18
|
+
| { readonly kind: "array"; readonly items: readonly Shape[]; readonly pointer: Pointer };
|
|
19
|
+
|
|
20
|
+
/** The shape of the value of a node. */
|
|
21
|
+
export function shapeOf(node: IRNode): Shape {
|
|
22
|
+
switch (node.kind) {
|
|
23
|
+
case "literal":
|
|
24
|
+
return { kind: "known", value: node.value, pointer: node.pointer };
|
|
25
|
+
case "object":
|
|
26
|
+
return { kind: "object", entries: new Map(node.entries.map((entry) => [entry.key, shapeOf(entry.value)])), pointer: node.pointer };
|
|
27
|
+
case "array":
|
|
28
|
+
return { kind: "array", items: node.items.map(shapeOf), pointer: node.pointer };
|
|
29
|
+
default:
|
|
30
|
+
return { kind: "unknown", pointer: node.pointer };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The shape of an input with the parent context of control flow (`callOperand()`). */
|
|
35
|
+
export function withExtra(shape: Shape, extra: Readonly<Record<string, unknown>>): Shape {
|
|
36
|
+
const keys = Object.keys(extra);
|
|
37
|
+
if (keys.length === 0) return shape;
|
|
38
|
+
const extraEntries = keys.map((key): [string, Shape] => [key, { kind: "known", value: extra[key], pointer: shape.pointer }]);
|
|
39
|
+
switch (shape.kind) {
|
|
40
|
+
case "object":
|
|
41
|
+
return { kind: "object", entries: new Map([...shape.entries, ...extraEntries]), pointer: shape.pointer };
|
|
42
|
+
case "known": {
|
|
43
|
+
const value = shape.value;
|
|
44
|
+
const base = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
45
|
+
return { kind: "known", value: { ...base, ...extra }, pointer: shape.pointer };
|
|
46
|
+
}
|
|
47
|
+
case "array":
|
|
48
|
+
return { kind: "known", value: { ...extra }, pointer: shape.pointer };
|
|
49
|
+
case "unknown":
|
|
50
|
+
return shape;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One problem of an input. */
|
|
55
|
+
export interface SchemaIssue {
|
|
56
|
+
/** The pointer of the part of the program. */
|
|
57
|
+
readonly pointer: Pointer;
|
|
58
|
+
/** The path in the input: `filter.status` or `items[2]`. */
|
|
59
|
+
readonly path: string;
|
|
60
|
+
readonly message: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type Schema = boolean | Record<string, unknown>;
|
|
64
|
+
|
|
65
|
+
/** The JSON type of a known value, or undefined for a value that JSON does not have. */
|
|
66
|
+
function jsonType(value: unknown): string | undefined {
|
|
67
|
+
if (value === null) return "null";
|
|
68
|
+
if (Array.isArray(value)) return "array";
|
|
69
|
+
switch (typeof value) {
|
|
70
|
+
case "string":
|
|
71
|
+
return "string";
|
|
72
|
+
case "number":
|
|
73
|
+
return Number.isFinite(value) ? "number" : undefined;
|
|
74
|
+
case "boolean":
|
|
75
|
+
return "boolean";
|
|
76
|
+
case "object":
|
|
77
|
+
return isPlainObject(value) ? "object" : undefined;
|
|
78
|
+
case "undefined":
|
|
79
|
+
return "undefined";
|
|
80
|
+
default:
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
86
|
+
if (Object.is(a, b)) return true;
|
|
87
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
88
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
89
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
|
|
90
|
+
const keysA = Object.keys(a);
|
|
91
|
+
const keysB = Object.keys(b);
|
|
92
|
+
return keysA.length === keysB.length && keysA.every((key) => Object.hasOwn(b, key) && deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pathText(path: readonly (string | number)[]): string {
|
|
96
|
+
let text = "";
|
|
97
|
+
for (const part of path) text += typeof part === "number" ? `[${part}]` : text === "" ? part : `.${part}`;
|
|
98
|
+
return text;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The shape of a known value: an object or an array gives entries, so a problem has a path. */
|
|
102
|
+
function expand(shape: Shape): Shape {
|
|
103
|
+
if (shape.kind !== "known") return shape;
|
|
104
|
+
const value = shape.value;
|
|
105
|
+
if (Array.isArray(value)) {
|
|
106
|
+
return { kind: "array", items: value.map((item, index): Shape => ({ kind: "known", value: item, pointer: childPointer(shape.pointer, index) })), pointer: shape.pointer };
|
|
107
|
+
}
|
|
108
|
+
if (isPlainObject(value)) {
|
|
109
|
+
return {
|
|
110
|
+
kind: "object",
|
|
111
|
+
entries: new Map(Object.entries(value).map(([key, item]): [string, Shape] => [key, { kind: "known", value: item, pointer: childPointer(shape.pointer, key) }])),
|
|
112
|
+
pointer: shape.pointer,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return shape;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The JSON type of a shape, or undefined when the compiler does not know it. */
|
|
119
|
+
function typeOfShape(shape: Shape): string | undefined {
|
|
120
|
+
switch (shape.kind) {
|
|
121
|
+
case "object":
|
|
122
|
+
return "object";
|
|
123
|
+
case "array":
|
|
124
|
+
return "array";
|
|
125
|
+
case "known":
|
|
126
|
+
return jsonType(shape.value);
|
|
127
|
+
case "unknown":
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function typeMatches(actual: string, expected: unknown, value: unknown): boolean {
|
|
133
|
+
const types = Array.isArray(expected) ? expected : [expected];
|
|
134
|
+
return types.some((type) => type === actual || (type === "integer" && actual === "number" && Number.isInteger(value)) || (type === "number" && actual === "integer"));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
class Validator {
|
|
138
|
+
readonly issues: SchemaIssue[] = [];
|
|
139
|
+
private depth = 0;
|
|
140
|
+
|
|
141
|
+
constructor(private readonly root: Schema) {}
|
|
142
|
+
|
|
143
|
+
private issue(shape: Shape, path: readonly (string | number)[], message: string): void {
|
|
144
|
+
this.issues.push({ pointer: shape.pointer, path: pathText(path), message });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private resolve(ref: string): Schema | undefined {
|
|
148
|
+
if (!ref.startsWith("#")) return undefined;
|
|
149
|
+
let current: unknown = this.root;
|
|
150
|
+
for (const part of ref.slice(1).split("/").filter((segment) => segment !== "")) {
|
|
151
|
+
const key = decodeURIComponent(part).replace(/~1/g, "/").replace(/~0/g, "~");
|
|
152
|
+
if (current === null || typeof current !== "object" || !Object.hasOwn(current, key)) return undefined;
|
|
153
|
+
current = (current as Record<string, unknown>)[key];
|
|
154
|
+
}
|
|
155
|
+
return typeof current === "boolean" || (typeof current === "object" && current !== null) ? (current as Schema) : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** True when the shape agrees with the schema (no definite problem). The problems go to `issues`. */
|
|
159
|
+
validate(shape: Shape, schema: Schema | undefined, path: readonly (string | number)[]): boolean {
|
|
160
|
+
if (schema === undefined || schema === true) return true;
|
|
161
|
+
if (schema === false) {
|
|
162
|
+
if (shape.kind === "unknown") return true;
|
|
163
|
+
this.issue(shape, path, "no value is valid here");
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
if (shape.kind === "unknown") return true;
|
|
167
|
+
if (this.depth > 50) return true;
|
|
168
|
+
this.depth++;
|
|
169
|
+
try {
|
|
170
|
+
return this.validateObject(shape, schema, path);
|
|
171
|
+
} finally {
|
|
172
|
+
this.depth--;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private branches(shape: Shape, schemas: unknown, path: readonly (string | number)[]): boolean {
|
|
177
|
+
if (!Array.isArray(schemas)) return true;
|
|
178
|
+
for (const branch of schemas) {
|
|
179
|
+
const probe = new Validator(this.root);
|
|
180
|
+
if (probe.validate(shape, branch as Schema, path)) return true;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private validateObject(shape: Shape, schema: Record<string, unknown>, path: readonly (string | number)[]): boolean {
|
|
186
|
+
const before = this.issues.length;
|
|
187
|
+
const ref = schema["$ref"];
|
|
188
|
+
if (typeof ref === "string") {
|
|
189
|
+
const target = this.resolve(ref);
|
|
190
|
+
if (target !== undefined) this.validate(shape, target, path);
|
|
191
|
+
}
|
|
192
|
+
if (Array.isArray(schema["allOf"])) for (const part of schema["allOf"]) this.validate(shape, part as Schema, path);
|
|
193
|
+
for (const key of ["anyOf", "oneOf"]) {
|
|
194
|
+
if (schema[key] !== undefined && !this.branches(shape, schema[key], path)) {
|
|
195
|
+
this.issue(shape, path, "the value agrees with none of the allowed forms");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const actual = typeOfShape(shape);
|
|
200
|
+
const value = shape.kind === "known" ? shape.value : undefined;
|
|
201
|
+
if (actual === undefined) return this.issues.length === before;
|
|
202
|
+
if (schema["type"] !== undefined && !typeMatches(actual, schema["type"], value)) {
|
|
203
|
+
const expected = Array.isArray(schema["type"]) ? schema["type"].join(" or ") : String(schema["type"]);
|
|
204
|
+
this.issue(shape, path, `expected ${expected}, got ${actual}`);
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
if (shape.kind === "known" && actual !== "object" && actual !== "array") {
|
|
208
|
+
if (Array.isArray(schema["enum"]) && !schema["enum"].some((option) => deepEqual(option, value))) {
|
|
209
|
+
this.issue(shape, path, `expected one of ${schema["enum"].map((option) => JSON.stringify(option)).join(", ")}`);
|
|
210
|
+
}
|
|
211
|
+
if (Object.hasOwn(schema, "const") && !deepEqual(schema["const"], value)) {
|
|
212
|
+
this.issue(shape, path, `expected ${JSON.stringify(schema["const"])}`);
|
|
213
|
+
}
|
|
214
|
+
if (actual === "string") this.string(shape, value as string, schema, path);
|
|
215
|
+
if (actual === "number" || actual === "integer") this.number(shape, value as number, schema, path);
|
|
216
|
+
return this.issues.length === before;
|
|
217
|
+
}
|
|
218
|
+
const expanded = expand(shape);
|
|
219
|
+
if (expanded.kind === "object") this.properties(expanded, schema, path);
|
|
220
|
+
if (expanded.kind === "array") this.items(expanded, schema, path);
|
|
221
|
+
if (shape.kind === "known" && (Array.isArray(schema["enum"]) || Object.hasOwn(schema, "const"))) {
|
|
222
|
+
const options = Array.isArray(schema["enum"]) ? schema["enum"] : [schema["const"]];
|
|
223
|
+
if (!options.some((option) => deepEqual(option, value))) this.issue(shape, path, "the value is not one of the allowed values");
|
|
224
|
+
}
|
|
225
|
+
return this.issues.length === before;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private string(shape: Shape, value: string, schema: Record<string, unknown>, path: readonly (string | number)[]): void {
|
|
229
|
+
const min = schema["minLength"];
|
|
230
|
+
const max = schema["maxLength"];
|
|
231
|
+
if (typeof min === "number" && value.length < min) this.issue(shape, path, `expected a string of ${min} characters or more`);
|
|
232
|
+
if (typeof max === "number" && value.length > max) this.issue(shape, path, `expected a string of ${max} characters or fewer`);
|
|
233
|
+
const pattern = schema["pattern"];
|
|
234
|
+
if (typeof pattern === "string") {
|
|
235
|
+
let regex: RegExp | undefined;
|
|
236
|
+
try {
|
|
237
|
+
regex = new RegExp(pattern);
|
|
238
|
+
} catch {
|
|
239
|
+
regex = undefined;
|
|
240
|
+
}
|
|
241
|
+
if (regex && !regex.test(value)) this.issue(shape, path, `expected a string that matches ${pattern}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private number(shape: Shape, value: number, schema: Record<string, unknown>, path: readonly (string | number)[]): void {
|
|
246
|
+
const minimum = schema["minimum"];
|
|
247
|
+
const maximum = schema["maximum"];
|
|
248
|
+
const exclusiveMinimum = schema["exclusiveMinimum"];
|
|
249
|
+
const exclusiveMaximum = schema["exclusiveMaximum"];
|
|
250
|
+
if (typeof minimum === "number" && (exclusiveMinimum === true ? value <= minimum : value < minimum)) {
|
|
251
|
+
this.issue(shape, path, `expected a number ${exclusiveMinimum === true ? "greater than" : "of"} ${minimum}${exclusiveMinimum === true ? "" : " or more"}`);
|
|
252
|
+
}
|
|
253
|
+
if (typeof maximum === "number" && (exclusiveMaximum === true ? value >= maximum : value > maximum)) {
|
|
254
|
+
this.issue(shape, path, `expected a number ${exclusiveMaximum === true ? "less than" : "of"} ${maximum}${exclusiveMaximum === true ? "" : " or less"}`);
|
|
255
|
+
}
|
|
256
|
+
if (typeof exclusiveMinimum === "number" && value <= exclusiveMinimum) this.issue(shape, path, `expected a number greater than ${exclusiveMinimum}`);
|
|
257
|
+
if (typeof exclusiveMaximum === "number" && value >= exclusiveMaximum) this.issue(shape, path, `expected a number less than ${exclusiveMaximum}`);
|
|
258
|
+
const multipleOf = schema["multipleOf"];
|
|
259
|
+
if (typeof multipleOf === "number" && multipleOf > 0) {
|
|
260
|
+
const quotient = value / multipleOf;
|
|
261
|
+
if (Math.abs(quotient - Math.round(quotient)) > 1e-9) this.issue(shape, path, `expected a multiple of ${multipleOf}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private properties(shape: Extract<Shape, { kind: "object" }>, schema: Record<string, unknown>, path: readonly (string | number)[]): void {
|
|
266
|
+
const properties = isPlainObject(schema["properties"]) ? schema["properties"] : {};
|
|
267
|
+
const present = (key: string): boolean => {
|
|
268
|
+
const entry = shape.entries.get(key);
|
|
269
|
+
return entry !== undefined && !(entry.kind === "known" && entry.value === undefined);
|
|
270
|
+
};
|
|
271
|
+
if (Array.isArray(schema["required"])) {
|
|
272
|
+
for (const key of schema["required"]) {
|
|
273
|
+
if (typeof key === "string" && !present(key)) this.issue(shape, [...path, key], "the input has no value for this required key");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const patterns = isPlainObject(schema["patternProperties"]) ? Object.keys(schema["patternProperties"]) : [];
|
|
277
|
+
const additional = schema["additionalProperties"];
|
|
278
|
+
for (const [key, entry] of shape.entries) {
|
|
279
|
+
if (entry.kind === "known" && entry.value === undefined) continue;
|
|
280
|
+
if (Object.hasOwn(properties, key)) {
|
|
281
|
+
this.validate(entry, properties[key] as Schema, [...path, key]);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (patterns.some((pattern) => safeTest(pattern, key))) continue;
|
|
285
|
+
if (additional === false) this.issue(entry, [...path, key], "the schema does not allow this key");
|
|
286
|
+
else if (typeof additional === "object" && additional !== null) this.validate(entry, additional as Schema, [...path, key]);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private items(shape: Extract<Shape, { kind: "array" }>, schema: Record<string, unknown>, path: readonly (string | number)[]): void {
|
|
291
|
+
const minItems = schema["minItems"];
|
|
292
|
+
const maxItems = schema["maxItems"];
|
|
293
|
+
if (typeof minItems === "number" && shape.items.length < minItems) this.issue(shape, path, `expected ${minItems} items or more`);
|
|
294
|
+
if (typeof maxItems === "number" && shape.items.length > maxItems) this.issue(shape, path, `expected ${maxItems} items or fewer`);
|
|
295
|
+
const prefix = Array.isArray(schema["prefixItems"]) ? schema["prefixItems"] : Array.isArray(schema["items"]) ? schema["items"] : undefined;
|
|
296
|
+
const rest = prefix ? (Array.isArray(schema["items"]) ? schema["additionalItems"] : schema["items"]) : schema["items"];
|
|
297
|
+
shape.items.forEach((item, index) => {
|
|
298
|
+
const itemSchema = prefix && index < prefix.length ? prefix[index] : rest;
|
|
299
|
+
if (itemSchema !== undefined) this.validate(item, itemSchema as Schema, [...path, index]);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function safeTest(pattern: string, value: string): boolean {
|
|
305
|
+
try {
|
|
306
|
+
return new RegExp(pattern).test(value);
|
|
307
|
+
} catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** This function gives the definite problems of a partial input against a JSON Schema. */
|
|
313
|
+
export function checkShape(shape: Shape, schema: unknown): SchemaIssue[] {
|
|
314
|
+
if (schema === undefined || schema === null || (typeof schema !== "object" && typeof schema !== "boolean")) return [];
|
|
315
|
+
const validator = new Validator(schema as Schema);
|
|
316
|
+
validator.validate(shape, schema as Schema, []);
|
|
317
|
+
return validator.issues;
|
|
318
|
+
}
|