@amritk/validation 0.3.2 → 0.4.1
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/AI.md +25 -7
- package/README.md +109 -2
- package/dist/parsers/generators/build-schema.js +12 -2
- package/dist/parsers/generators/generate-exact-half.d.ts +50 -0
- package/dist/parsers/generators/generate-exact-half.js +165 -0
- package/dist/parsers/generators/generate-files.d.ts +8 -0
- package/dist/parsers/generators/generate-files.js +11 -1
- package/dist/parsers/generators/generate-parser-function.d.ts +6 -0
- package/dist/parsers/generators/generate-parser-function.js +48 -10
- package/dist/parsers/helpers/collect-helpers.d.ts +1 -1
- package/dist/parsers/helpers/collect-helpers.js +25 -2
- package/dist/validators/generators/build-schema.d.ts +1 -1
- package/dist/validators/generators/build-schema.js +46 -12
- package/dist/validators/generators/collect-validator-imports.d.ts +7 -0
- package/dist/validators/generators/collect-validator-imports.js +4 -1
- package/dist/validators/generators/generate-coerce-function.d.ts +46 -1
- package/dist/validators/generators/generate-coerce-function.js +181 -53
- package/dist/validators/generators/generate-files.js +13 -5
- package/dist/validators/generators/generate-repair-function.d.ts +6 -1
- package/dist/validators/generators/generate-repair-function.js +70 -11
- package/dist/validators/generators/generate-validator-function.d.ts +48 -0
- package/dist/validators/generators/generate-validator-function.js +165 -51
- package/package.json +2 -2
package/AI.md
CHANGED
|
@@ -44,14 +44,16 @@ Every mode is reachable from `mjst` without dropping to the programmatic API:
|
|
|
44
44
|
|:---|:---|
|
|
45
45
|
| `types` | `--types-only` (alone), otherwise always emitted |
|
|
46
46
|
| `guard`, `validate` | `--validators`, or `--validators-only` for no parser |
|
|
47
|
-
| `check` | `--check` |
|
|
48
|
-
| `coerce` | `--coerce` |
|
|
49
|
-
| `repair` | `--repair` (emits `coerceX` too) |
|
|
47
|
+
| `check` | `--validators --check` |
|
|
48
|
+
| `coerce` | `--validators --coerce` |
|
|
49
|
+
| `repair` | `--validators --repair` (emits `coerceX` too) |
|
|
50
50
|
| `parse` | the default on any run that is not `--types-only`/`--validators-only` |
|
|
51
51
|
| `parseStrict` | `--strict` |
|
|
52
52
|
|
|
53
53
|
`--validators-only` is this package's own default (`['types', 'guard', 'validate']`)
|
|
54
54
|
spelled as a flag: everything that judges a document, nothing that builds one.
|
|
55
|
+
`--check`, `--coerce` and `--repair` shape the validator half, so the CLI rejects
|
|
56
|
+
any of them without one of those two flags.
|
|
55
57
|
|
|
56
58
|
## Gotchas — where agents fail
|
|
57
59
|
|
|
@@ -83,10 +85,26 @@ spelled as a flag: everything that judges a document, nothing that builds one.
|
|
|
83
85
|
validateX(v)` pays for both passes and is no faster than `validateX`.
|
|
84
86
|
|
|
85
87
|
8. **It emits each engine's exact bytes.** A single-mode build is byte-identical
|
|
86
|
-
to what
|
|
87
|
-
difference to reason about and no speedup to claim — do not tell a
|
|
88
|
-
faster. What it changes is that the whole matrix comes out as one
|
|
89
|
-
of two, and that a build asking for no validator mode ships no
|
|
88
|
+
to what the engine that owns that mode emits when called directly, so there is
|
|
89
|
+
no runtime difference to reason about and no speedup to claim — do not tell a
|
|
90
|
+
user this is faster. What it changes is that the whole matrix comes out as one
|
|
91
|
+
type instead of two, and that a build asking for no validator mode ships no
|
|
90
92
|
`validation-result.ts`.
|
|
91
93
|
|
|
94
|
+
9. **`coerceX` is not Ajv's `coerceTypes`, on purpose.** It never modifies its
|
|
95
|
+
input (no defensive clone needed), never coerces `null` in either direction,
|
|
96
|
+
and reads only clean numerals as numbers (`" "`, `"0x10"`, `"Infinity"` are
|
|
97
|
+
rejected). It coerces inside `anyOf`/`oneOf`/`allOf`/`if`: a union branch the
|
|
98
|
+
value already matches wins uncoerced (so `false` stays `false` under
|
|
99
|
+
`anyOf: [string, { const: false }]`, where Ajv answers `"false"`), and two
|
|
100
|
+
branches that would coerce to different values leave it as written. Do not
|
|
101
|
+
tell a user a value will come out the way Ajv would produce it inside a union.
|
|
102
|
+
10. **The coercing `parseX` returns exactly what `coerceX` returns** whenever
|
|
103
|
+
`coerceX` accepts the document, and only repairs what `coerceX` would reject.
|
|
104
|
+
A parser file for a definition with `anyOf`/`oneOf`/`allOf`/`if`/`not` (or one
|
|
105
|
+
such a definition reaches) therefore exports `matchesX` and `coerceXInput`
|
|
106
|
+
too, and its repairing parser is private (`_parseXRepair`, kept out of the
|
|
107
|
+
barrel — a leading `_` marks a name for sibling files only). Strict and
|
|
108
|
+
`stripUnknown` parsers are unchanged.
|
|
109
|
+
|
|
92
110
|
Only the `.` entry. Install: `bun add @amritk/validation`.
|
package/README.md
CHANGED
|
@@ -74,6 +74,7 @@ so no two can disagree about whether a document is valid.
|
|
|
74
74
|
| `types` | — | — | — | — |
|
|
75
75
|
| `guard` | `isX(input): input is X` | — | a boolean | the first problem |
|
|
76
76
|
| `validate` | `validateX(input)` | — | every error | the end |
|
|
77
|
+
| `check` | `checkX(input)` | — | the first error | the first problem |
|
|
77
78
|
| `coerce` | `coerceX(input)` | coerced | every error | the end |
|
|
78
79
|
| `repair` | `repairX(input)` | repaired | repairs **and** errors | the end |
|
|
79
80
|
| `parse` | `parseX(input)` | repaired | nothing | never fails |
|
|
@@ -84,7 +85,9 @@ it stops at the first thing wrong and builds no error object, which on invalid
|
|
|
84
85
|
input is dramatically cheaper than reporting. `validate` is the other half: every
|
|
85
86
|
error, each with a JSON Pointer, the keyword that rejected the value, and that
|
|
86
87
|
keyword's own values. Reach for `guard` when the answer is a branch and
|
|
87
|
-
`validate` when a human or an API client has to be told what to fix.
|
|
88
|
+
`validate` when a human or an API client has to be told what to fix. `check` sits
|
|
89
|
+
between them: `checkX` returns the same result type as `validateX`, holding only
|
|
90
|
+
the error `validateX` would have reported first, and walks no further.
|
|
88
91
|
|
|
89
92
|
**Coerce and repair, with or without the errors.** `coerceX` and `repairX` hand
|
|
90
93
|
back the value *and* the diagnostics; `parseX` hands back only the value and
|
|
@@ -92,9 +95,103 @@ never fails. Coercion moves a value that is already right but written in the
|
|
|
92
95
|
wrong type; repair also substitutes a value the schema supplies for one that
|
|
93
96
|
cannot be coerced, and reports the validator's own errors as the repairs.
|
|
94
97
|
|
|
98
|
+
That makes `repairX` an autofix tool rather than a stricter validator. It accepts
|
|
99
|
+
documents `validateX` rejects (a missing required name gets one; an unknown
|
|
100
|
+
`enum` value becomes the first member), and accepting only `valid` with an empty
|
|
101
|
+
`repairs` gives exactly the verdicts `coerceX` gives. An empty `repairs` alone is
|
|
102
|
+
not enough: a document nothing could repair comes back with none either, and
|
|
103
|
+
`valid: false`. To reject a bad config, use `coerceX`. To hand back a fixed one,
|
|
104
|
+
use `repairX`.
|
|
105
|
+
|
|
106
|
+
**`parseX` agrees with `coerceX`.** Wherever `coerceX` accepts a document, the
|
|
107
|
+
coercing `parseX` returns the very same value — through a union, an `allOf`, an
|
|
108
|
+
`if`, a `$ref`, recursion included — and it only repairs what `coerceX` would
|
|
109
|
+
have rejected. A differential test pins that over random schemas. To get there,
|
|
110
|
+
a definition whose own tree has an `anyOf`/`oneOf`/`allOf`/`if`/`not` carries an
|
|
111
|
+
exact test (`matchesX`) and the coercion walk (`coerceXInput`) in front of its
|
|
112
|
+
repairing parser, and every definition such a one reaches carries them too. A
|
|
113
|
+
schema without combinators emits exactly what it always did; one with them
|
|
114
|
+
emits more — about twice the parser code on a large OpenAPI document — which is
|
|
115
|
+
what a second, exact opinion on each definition costs. A `strict` or
|
|
116
|
+
`stripUnknown` parser has no `coerceX` to agree with and is unchanged.
|
|
117
|
+
|
|
95
118
|
`parse` and `parseStrict` are the same function name under two contracts, so
|
|
96
119
|
asking for both is an error rather than a silent choice.
|
|
97
120
|
|
|
121
|
+
### Moving off Ajv
|
|
122
|
+
|
|
123
|
+
`coerceX` is built to replace Ajv compiled with `{ allErrors: true,
|
|
124
|
+
coerceTypes: true }`, and it is better on each count: the answer, the speed and
|
|
125
|
+
the safety of the input.
|
|
126
|
+
|
|
127
|
+
**It accepts only what the schema accepts.** Anything `coerceX` hands back as
|
|
128
|
+
valid, `validateX` has just accepted, and a differential fuzz over random
|
|
129
|
+
schemas with `anyOf`, `oneOf`, `allOf` and `if`/`then`/`else` checks every
|
|
130
|
+
accepted document against an Ajv that does not coerce. Where the two differ, it
|
|
131
|
+
is on purpose:
|
|
132
|
+
|
|
133
|
+
- **`null` is never coerced**, in either direction, and it is the difference
|
|
134
|
+
you will meet most: on a real config corpus it was nearly every case where Ajv
|
|
135
|
+
accepted and `coerceX` did not. Ajv reads `null` as `""`, `0` or `false` for a
|
|
136
|
+
string, number or boolean field, and reads those back as `null`. A `null`
|
|
137
|
+
usually means "not set", and turning it into a real value erases that. Here it
|
|
138
|
+
goes to the validator as written, and the validator rejects it.
|
|
139
|
+
- **Only clean numerals become numbers.** `" "`, `" 1 "`, `"0x10"`, `"Infinity"`
|
|
140
|
+
and `"1."` are rejected. Ajv turns them into `0`, `1`, `16`, a value JSON
|
|
141
|
+
cannot hold, and `1`. `"007"` and `"1e3"` are still coerced.
|
|
142
|
+
- **At a union, a branch the value already matches wins.** Ajv coerces into the
|
|
143
|
+
first branch that will take the value. Given `anyOf: [{ type: 'string' }, {
|
|
144
|
+
const: false }]`, Ajv turns `false` into `"false"`; `coerceX` keeps `false`.
|
|
145
|
+
When no branch matches as written, each branch coerces the value its own way
|
|
146
|
+
and the result is taken only if every branch that then accepts it agrees.
|
|
147
|
+
Two different readings leave the value alone for the validator to report, so
|
|
148
|
+
the order the branches were written in never changes the answer.
|
|
149
|
+
- **A branch that fails leaves nothing behind.** Ajv coerces in place while it
|
|
150
|
+
tries each branch, so under `oneOf: [{ type: 'integer' }, { type: 'string' }]`
|
|
151
|
+
it turns `0` into `"0"` while trying the string branch, sees two matches, and
|
|
152
|
+
rejects a valid document. `coerceX` accepts `0` as written.
|
|
153
|
+
- **Coercion reaches every applicator.** Scalars are coerced through `$ref`,
|
|
154
|
+
`allOf` (each subschema in turn), `if`/`then`/`else` (toward `then` when the
|
|
155
|
+
value matches `if`, `else` otherwise), and union branches, nested to any depth.
|
|
156
|
+
|
|
157
|
+
**It never touches the input.** Ajv coerces in place, so a caller that keeps its
|
|
158
|
+
document has to clone it first. `coerceX` returns a new value that shares
|
|
159
|
+
everything it did not touch, or the input itself when nothing needed coercing.
|
|
160
|
+
|
|
161
|
+
**It is faster where the input has to survive.** Against Ajv cloning first, so
|
|
162
|
+
both leave the caller's document alone (`bun run bench:validators:coerce`, or
|
|
163
|
+
`bench:validators:coerce:node`):
|
|
164
|
+
|
|
165
|
+
| schema | runtime | valid input | needs coercing | cannot be coerced |
|
|
166
|
+
|:--|:--|--:|--:|--:|
|
|
167
|
+
| small (4 fields) | Bun | 5.4× | 1.7× | 1.5× |
|
|
168
|
+
| small (4 fields) | Node | 9.9× | 2.1× | 2.2× |
|
|
169
|
+
| order (nested + array) | Bun | 11× | 1.6× | 1.9× |
|
|
170
|
+
| order (nested + array) | Node | 8.4× | 1.8× | 2.2× |
|
|
171
|
+
| assert-loose | Bun | 19× | 1.4× | 3.0× |
|
|
172
|
+
| assert-loose | Node | 36× | 1.6× | 3.1× |
|
|
173
|
+
| config (unions via `$ref`) | Bun | 7.2× | 1.7× | 3.7× |
|
|
174
|
+
| config (unions via `$ref`) | Node | 5.2× | 1.5× | 4.6× |
|
|
175
|
+
|
|
176
|
+
<sub>Each cell is how many times faster `coerceX` is than Ajv on the same input,
|
|
177
|
+
each engine timed in its own process (Bun 1.3.11, Node 22.22, Linux x64). A
|
|
178
|
+
valid document is the common case and the widest gap: where `isX` is a
|
|
179
|
+
standalone guard, `coerceX` answers it with that guard and hands the input
|
|
180
|
+
straight back, without walking it. On valid input Ajv rewrites nothing, so it can
|
|
181
|
+
also be timed without the clone; `coerceX` is still ahead there on these
|
|
182
|
+
schemas, by 1.2–3.3×, but not on every one: on a large, union-heavy document
|
|
183
|
+
that the caller lets Ajv coerce in place, Ajv can come out modestly ahead. The
|
|
184
|
+
gap above is the clone, so it is real exactly where the caller cannot let Ajv
|
|
185
|
+
mutate its input.
|
|
186
|
+
The config case is the shape `--coerce` exists for: every coercible scalar sits
|
|
187
|
+
inside a union reached through `$ref`, and every union branch is tested by a
|
|
188
|
+
named function rather than a closure built per call.</sub>
|
|
189
|
+
|
|
190
|
+
The errors differ in one way worth knowing if you key diagnostics on
|
|
191
|
+
`path + keyword + params`: an `additionalProperties` error points **at the
|
|
192
|
+
undeclared key** (`/readme/example_requests`) where Ajv points at the object
|
|
193
|
+
holding it (`/readme`). `params.additionalProperty` names the key in both.
|
|
194
|
+
|
|
98
195
|
---
|
|
99
196
|
|
|
100
197
|
## API
|
|
@@ -115,10 +212,17 @@ asking for both is an error rather than a silent choice.
|
|
|
115
212
|
| `options.readonly` | `boolean` | `false` | Emit `readonly` type members. |
|
|
116
213
|
| `options.caseInsensitive` | `boolean` | `false` | Normalize a mis-cased string onto an `enum` member it matches. |
|
|
117
214
|
| `options.helpersMode` | `'package' \| 'embedded'` | `'package'` | Where the parser half gets its runtime helpers. |
|
|
215
|
+
| `options.helpersImportPrefix` | `string` | `'./'` | Prefix on emitted-helper import specifiers, when `helpersMode` is `'embedded'`. |
|
|
118
216
|
| `options.extensions` | `SchemaExtensions` | — | Schema extensions, passed through to the parser half. |
|
|
217
|
+
| `options.importExt` | `'js' \| 'ts'` | `'js'` | Extension on every emitted relative import. `'ts'` emits the on-disk paths, so the output runs under Node's type stripping with no build step. |
|
|
218
|
+
| `options.logWarnings` | `boolean` | `false` | Print the parser half's warnings about the schema, such as a keyword it cannot enforce. |
|
|
119
219
|
|
|
120
220
|
Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; content: string }`.
|
|
121
221
|
|
|
222
|
+
The package also exports `ALL_MODES` (every mode but `parseStrict`, for a caller
|
|
223
|
+
who wants the lot) and the types `Mode`, `GenerateOptions`, `GeneratedFile` and
|
|
224
|
+
`ImportExtension`.
|
|
225
|
+
|
|
122
226
|
---
|
|
123
227
|
|
|
124
228
|
## Output layout
|
|
@@ -127,6 +231,8 @@ Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; c
|
|
|
127
231
|
config.ts the type, plus isX / validateX / coerceX / repairX
|
|
128
232
|
config.parse.ts parseX, importing the type from ./config.js
|
|
129
233
|
validation-result.ts the shared error and result types, and the runtime helpers
|
|
234
|
+
formats.ts the format checks, only when `formats` enforces one the schema names
|
|
235
|
+
_helpers/ the parser's runtime helpers, only when `helpersMode` is 'embedded'
|
|
130
236
|
index.ts a barrel over all of it
|
|
131
237
|
```
|
|
132
238
|
|
|
@@ -146,7 +252,8 @@ could import its 17 KiB of error types and runtime helpers.
|
|
|
146
252
|
|
|
147
253
|
## Is it faster?
|
|
148
254
|
|
|
149
|
-
No, and it should not be.
|
|
255
|
+
Than the two engines it wraps? No, and it should not be. (Than Ajv, yes — see
|
|
256
|
+
[Moving off Ajv](#moving-off-ajv).) Ask this package for one mode and it emits **the exact
|
|
150
257
|
bytes** the engine that owns that mode emits — which the test suite pins per
|
|
151
258
|
mode, by fingerprint, not by reading the output. Identical code cannot
|
|
152
259
|
run at a different speed, so there is no runtime claim to make here and none is
|
|
@@ -6,6 +6,7 @@ import { generateIndexBarrel } from "@amritk/helpers/generate-index-barrel";
|
|
|
6
6
|
import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
|
|
7
7
|
import { walkRefGraph } from "@amritk/helpers/walk-ref-graph";
|
|
8
8
|
import { applySchemaExtensions } from "../helpers/apply-schema-extensions.js";
|
|
9
|
+
import { planExactHalves } from "./generate-exact-half.js";
|
|
9
10
|
import { generateFile } from "./generate-files.js";
|
|
10
11
|
const readHelperSource = async (helper, importExt) => {
|
|
11
12
|
const require2 = createRequire(import.meta.url);
|
|
@@ -32,10 +33,18 @@ const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logW
|
|
|
32
33
|
const files = [];
|
|
33
34
|
const usedHelpers = /* @__PURE__ */ new Set();
|
|
34
35
|
const foldedSchema = foldNullable(rootSchema);
|
|
36
|
+
const nodes = [];
|
|
35
37
|
walkRefGraph(foldedSchema, rootTypeName, { typeSuffix, ...schemas !== void 0 ? { schemas } : {} }, (node) => {
|
|
36
38
|
if (node.filename === "index")
|
|
37
39
|
return;
|
|
38
|
-
|
|
40
|
+
nodes.push({
|
|
41
|
+
node,
|
|
42
|
+
schema: extensions ? applySchemaExtensions(node.schema, node.filename, extensions) : node.schema
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
const exactHalves = typesOnly === true || strict === true || stripUnknown ? /* @__PURE__ */ new Map() : planExactHalves(nodes.map(({ node, schema }) => ({ typeName: node.typeName, schema, rootSchema: node.rootSchema })), typeSuffix);
|
|
46
|
+
for (const { node, schema: extended } of nodes) {
|
|
47
|
+
const exactHalf = exactHalves.get(node.typeName);
|
|
39
48
|
const result = generateFile(extended, node.typeName, {
|
|
40
49
|
selfFilename: node.filename,
|
|
41
50
|
typesOnly: typesOnly ?? false,
|
|
@@ -51,12 +60,13 @@ const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logW
|
|
|
51
60
|
...strict !== void 0 ? { strict } : {},
|
|
52
61
|
...stripUnknown ? { stripUnknown } : {},
|
|
53
62
|
...caseInsensitive ? { caseInsensitive } : {},
|
|
63
|
+
...exactHalf !== void 0 ? { exactHalf } : {},
|
|
54
64
|
unknownKeys
|
|
55
65
|
});
|
|
56
66
|
files.push({ filename: `${node.filename}.ts`, content: result.content });
|
|
57
67
|
for (const helper of result.usedHelpers)
|
|
58
68
|
usedHelpers.add(helper);
|
|
59
|
-
}
|
|
69
|
+
}
|
|
60
70
|
if (helpersMode === "embedded" && !typesOnly) {
|
|
61
71
|
for (const helper of usedHelpers) {
|
|
62
72
|
const { ext, content } = await readHelperSource(helper, importExt);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* The exact, coercing half of a coercing parser file: `matchesX`, a yes/no test
|
|
4
|
+
* that agrees with `validateX` on every value, and `coerceXInput`, the walk
|
|
5
|
+
* `coerceX` runs. `wrap` says whether `parseX` itself runs them ahead of its
|
|
6
|
+
* repair, or whether the file only carries them for another file to call.
|
|
7
|
+
*/
|
|
8
|
+
export type ExactHalf = {
|
|
9
|
+
readonly code: string;
|
|
10
|
+
readonly wrap: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** The name of a definition's exact yes/no test. */
|
|
13
|
+
export declare const exactMatcherName: (typeName: string) => string;
|
|
14
|
+
/** The name of a definition's coercion walk, as the parser exports it. */
|
|
15
|
+
export declare const exactCoercerName: (typeName: string) => string;
|
|
16
|
+
/** The name the repairing parser takes when `parseX` is the wrapper in front of it. */
|
|
17
|
+
export declare const repairParserName: (typeName: string) => string;
|
|
18
|
+
/** One definition as the planner sees it. */
|
|
19
|
+
export type ExactHalfNode = {
|
|
20
|
+
readonly typeName: string;
|
|
21
|
+
readonly schema: JSONSchema;
|
|
22
|
+
readonly rootSchema: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Decides which definitions of a coercing parser build carry the exact half,
|
|
26
|
+
* and emits it for each.
|
|
27
|
+
*
|
|
28
|
+
* Every definition whose own tree has a combinator is wrapped. Everything such
|
|
29
|
+
* a definition reaches through `$ref` carries the half without the wrapper,
|
|
30
|
+
* because the wrapped definition's test and walk call into it. A definition the
|
|
31
|
+
* emitters cannot express drops out, and so does everything that would have
|
|
32
|
+
* called into it, until what is left is closed — so a file never imports a half
|
|
33
|
+
* that was not written.
|
|
34
|
+
*/
|
|
35
|
+
export declare const planExactHalves: (nodes: readonly ExactHalfNode[], typeSuffix: string) => ReadonlyMap<string, ExactHalf>;
|
|
36
|
+
/**
|
|
37
|
+
* The imports a file's exact half needs: each `$ref` target's test and walk that
|
|
38
|
+
* the half actually calls.
|
|
39
|
+
*
|
|
40
|
+
* Its own lines rather than names added to the parser's import collector. That
|
|
41
|
+
* collector walks only the positions the repairing parser reads, and the exact
|
|
42
|
+
* half reads them all — a `$ref` under `not`, inside `propertyNames`, beside
|
|
43
|
+
* another `$ref`. Two import statements from one module are fine; the same name
|
|
44
|
+
* imported twice is not, and the two sets of names never meet.
|
|
45
|
+
*/
|
|
46
|
+
export declare const exactHalfImports: (schema: JSONSchema, code: string, options: {
|
|
47
|
+
readonly selfFilename?: string;
|
|
48
|
+
readonly typeSuffix: string;
|
|
49
|
+
readonly importExt: 'js' | 'ts';
|
|
50
|
+
}) => string[];
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { identifierMentions } from "@amritk/helpers/identifier-mentions";
|
|
2
|
+
import { readKey } from "@amritk/helpers/read-key";
|
|
3
|
+
import { refToFilename } from "@amritk/helpers/ref-to-filename";
|
|
4
|
+
import { refToName } from "@amritk/helpers/ref-to-name";
|
|
5
|
+
import { isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
6
|
+
import { generateCoerceWalk } from "../../validators/generators/generate-coerce-function.js";
|
|
7
|
+
const exactMatcherName = (typeName) => `matches${typeName}`;
|
|
8
|
+
const exactCoercerName = (typeName) => `coerce${typeName}Input`;
|
|
9
|
+
const repairParserName = (typeName) => `_parse${typeName}Repair`;
|
|
10
|
+
const COMBINATOR_KEYS = ["anyOf", "oneOf", "allOf", "if", "not"];
|
|
11
|
+
const SINGLE_SUBSCHEMA_KEYS = [
|
|
12
|
+
"additionalProperties",
|
|
13
|
+
"additionalItems",
|
|
14
|
+
"contains",
|
|
15
|
+
"propertyNames",
|
|
16
|
+
"not",
|
|
17
|
+
"if",
|
|
18
|
+
"then",
|
|
19
|
+
"else",
|
|
20
|
+
"unevaluatedProperties",
|
|
21
|
+
"unevaluatedItems"
|
|
22
|
+
];
|
|
23
|
+
const SUBSCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"];
|
|
24
|
+
const SUBSCHEMA_MAP_KEYS = ["properties", "patternProperties", "dependentSchemas", "dependencies"];
|
|
25
|
+
const scanOwnTree = (schema) => {
|
|
26
|
+
const refs = /* @__PURE__ */ new Set();
|
|
27
|
+
let combinator = false;
|
|
28
|
+
const visit = (node) => {
|
|
29
|
+
if (!isSchemaObject(node))
|
|
30
|
+
return;
|
|
31
|
+
const record = node;
|
|
32
|
+
const ref = readKey(record, "$ref");
|
|
33
|
+
if (typeof ref === "string")
|
|
34
|
+
refs.add(ref);
|
|
35
|
+
if (COMBINATOR_KEYS.some((key) => readKey(record, key) !== void 0))
|
|
36
|
+
combinator = true;
|
|
37
|
+
for (const key of SINGLE_SUBSCHEMA_KEYS)
|
|
38
|
+
visit(readKey(record, key));
|
|
39
|
+
const items = readKey(record, "items");
|
|
40
|
+
if (Array.isArray(items))
|
|
41
|
+
items.forEach(visit);
|
|
42
|
+
else
|
|
43
|
+
visit(items);
|
|
44
|
+
for (const key of SUBSCHEMA_LIST_KEYS) {
|
|
45
|
+
const list = readKey(record, key);
|
|
46
|
+
if (Array.isArray(list))
|
|
47
|
+
list.forEach(visit);
|
|
48
|
+
}
|
|
49
|
+
for (const key of SUBSCHEMA_MAP_KEYS) {
|
|
50
|
+
const map = readKey(record, key);
|
|
51
|
+
if (typeof map === "object" && map !== null && !Array.isArray(map)) {
|
|
52
|
+
for (const value of Object.values(map))
|
|
53
|
+
if (!Array.isArray(value))
|
|
54
|
+
visit(value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
visit(schema);
|
|
59
|
+
return { refs, combinator };
|
|
60
|
+
};
|
|
61
|
+
const emitExactHalf = (schema, typeName, typeSuffix, rootSchema, wrap) => {
|
|
62
|
+
let walk;
|
|
63
|
+
try {
|
|
64
|
+
walk = generateCoerceWalk(schema, {
|
|
65
|
+
typeSuffix,
|
|
66
|
+
rootSchema,
|
|
67
|
+
refCoercer: (ref) => exactCoercerName(refToName(ref, typeSuffix)),
|
|
68
|
+
refGuard: exactMatcherName,
|
|
69
|
+
hoistNamespace: "_exact",
|
|
70
|
+
withRootMatch: true
|
|
71
|
+
});
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const { declarations, expression, rootMatch } = walk;
|
|
76
|
+
const matcher = exactMatcherName(typeName);
|
|
77
|
+
const matcherSource = typeof rootMatch === "string" ? [
|
|
78
|
+
`export const ${matcher} = (${/\binput\b/.test(rootMatch) ? "input" : "_input"}: unknown): boolean => {`,
|
|
79
|
+
.../\b_path\b/.test(rootMatch) ? [` const _path = ''`] : [],
|
|
80
|
+
rootMatch,
|
|
81
|
+
`}`
|
|
82
|
+
].join("\n") : `export const ${matcher} = (_input: unknown): boolean => ${rootMatch === true}`;
|
|
83
|
+
const text = [...declarations, matcherSource].join("\n");
|
|
84
|
+
const needsErrorType = /\bValidationError\b/.test(text);
|
|
85
|
+
if (needsErrorType && typeName === "ValidationError")
|
|
86
|
+
return null;
|
|
87
|
+
const coercer = exactCoercerName(typeName);
|
|
88
|
+
const parts = [
|
|
89
|
+
...needsErrorType ? ["type ValidationError = { message: string; path: string; keyword: string; params: Record<string, unknown> }"] : [],
|
|
90
|
+
...declarations,
|
|
91
|
+
matcherSource,
|
|
92
|
+
`export const ${coercer} = (input: unknown): unknown => ${expression ?? "input"}`
|
|
93
|
+
];
|
|
94
|
+
if (wrap) {
|
|
95
|
+
const walked = expression === null ? [] : [` const value = ${coercer}(input)`, ` if (${matcher}(value)) return value as ${typeName}`];
|
|
96
|
+
parts.push([
|
|
97
|
+
`export const parse${typeName} = (input: unknown): ${typeName} => {`,
|
|
98
|
+
` if (${matcher}(input)) return input as ${typeName}`,
|
|
99
|
+
...walked,
|
|
100
|
+
` return ${repairParserName(typeName)}(input)`,
|
|
101
|
+
`}`
|
|
102
|
+
].join("\n"));
|
|
103
|
+
}
|
|
104
|
+
return parts.join("\n\n");
|
|
105
|
+
};
|
|
106
|
+
const planExactHalves = (nodes, typeSuffix) => {
|
|
107
|
+
const byName = new Map(nodes.map((node) => [node.typeName, node]));
|
|
108
|
+
const scans = new Map(nodes.map((node) => [node.typeName, scanOwnTree(node.schema)]));
|
|
109
|
+
const targetsOf = (typeName) => [...scans.get(typeName)?.refs ?? []].map((ref) => refToName(ref, typeSuffix));
|
|
110
|
+
const wrapped = new Set(nodes.filter((node) => scans.get(node.typeName)?.combinator === true).map((n) => n.typeName));
|
|
111
|
+
const closure = /* @__PURE__ */ new Set();
|
|
112
|
+
const queue = [...wrapped];
|
|
113
|
+
while (queue.length > 0) {
|
|
114
|
+
const name = queue.pop();
|
|
115
|
+
if (closure.has(name))
|
|
116
|
+
continue;
|
|
117
|
+
closure.add(name);
|
|
118
|
+
for (const target of targetsOf(name))
|
|
119
|
+
if (!closure.has(target))
|
|
120
|
+
queue.push(target);
|
|
121
|
+
}
|
|
122
|
+
const emitted = /* @__PURE__ */ new Map();
|
|
123
|
+
const failed = /* @__PURE__ */ new Set();
|
|
124
|
+
for (const name of closure) {
|
|
125
|
+
const node = byName.get(name);
|
|
126
|
+
const code = node === void 0 ? null : emitExactHalf(node.schema, name, typeSuffix, node.rootSchema, wrapped.has(name));
|
|
127
|
+
if (code === null)
|
|
128
|
+
failed.add(name);
|
|
129
|
+
else
|
|
130
|
+
emitted.set(name, code);
|
|
131
|
+
}
|
|
132
|
+
let changed = true;
|
|
133
|
+
while (changed) {
|
|
134
|
+
changed = false;
|
|
135
|
+
for (const name of emitted.keys()) {
|
|
136
|
+
if (targetsOf(name).some((target) => failed.has(target) || !byName.has(target))) {
|
|
137
|
+
emitted.delete(name);
|
|
138
|
+
failed.add(name);
|
|
139
|
+
changed = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return new Map([...emitted].map(([name, code]) => [name, { code, wrap: wrapped.has(name) }]));
|
|
144
|
+
};
|
|
145
|
+
const exactHalfImports = (schema, code, options) => {
|
|
146
|
+
const mentions = identifierMentions(code);
|
|
147
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
148
|
+
for (const ref of scanOwnTree(schema).refs) {
|
|
149
|
+
const filename = refToFilename(ref);
|
|
150
|
+
if (filename === options.selfFilename || byFile.has(filename))
|
|
151
|
+
continue;
|
|
152
|
+
const typeName = refToName(ref, options.typeSuffix);
|
|
153
|
+
const names = [exactMatcherName(typeName), exactCoercerName(typeName)].filter((name) => mentions(name));
|
|
154
|
+
if (names.length > 0)
|
|
155
|
+
byFile.set(filename, names);
|
|
156
|
+
}
|
|
157
|
+
return [...byFile].map(([filename, names]) => `import { ${names.join(", ")} } from './${filename}.${options.importExt}';`).sort();
|
|
158
|
+
};
|
|
159
|
+
export {
|
|
160
|
+
exactCoercerName,
|
|
161
|
+
exactHalfImports,
|
|
162
|
+
exactMatcherName,
|
|
163
|
+
planExactHalves,
|
|
164
|
+
repairParserName
|
|
165
|
+
};
|
|
@@ -2,6 +2,7 @@ import { type UnknownKeysStrategy } from '@amritk/helpers/unknown-keys-strategy'
|
|
|
2
2
|
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
3
3
|
import { type HelpersMode, type RuntimeHelperName } from '../helpers/collect-helpers.js';
|
|
4
4
|
import { type ImportExtension } from '../helpers/collect-imports.js';
|
|
5
|
+
import { type ExactHalf } from './generate-exact-half.js';
|
|
5
6
|
/**
|
|
6
7
|
* Options for controlling what gets generated in a file.
|
|
7
8
|
*/
|
|
@@ -99,6 +100,13 @@ type GenerateFileOptions = {
|
|
|
99
100
|
* {@link UnknownKeysStrategy} for the trade-off between the two.
|
|
100
101
|
*/
|
|
101
102
|
readonly unknownKeys?: UnknownKeysStrategy;
|
|
103
|
+
/**
|
|
104
|
+
* The exact, coercing half this definition carries, planned across the whole
|
|
105
|
+
* build by `planExactHalves`: a coercing parser that agrees with `coerceX` on
|
|
106
|
+
* every document `coerceX` accepts. Absent for a strict, stripping or
|
|
107
|
+
* types-only build, and for a definition that needs none.
|
|
108
|
+
*/
|
|
109
|
+
readonly exactHalf?: ExactHalf;
|
|
102
110
|
};
|
|
103
111
|
/** Result of generating a single parser file. */
|
|
104
112
|
export type GeneratedFileContent = {
|
|
@@ -2,6 +2,7 @@ import { generateTypeDefinition } from "@amritk/helpers/generate-type-definition
|
|
|
2
2
|
import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
|
|
3
3
|
import { collectHelpers } from "../helpers/collect-helpers.js";
|
|
4
4
|
import { collectImports, collectImportTypeNames } from "../helpers/collect-imports.js";
|
|
5
|
+
import { exactHalfImports, repairParserName } from "./generate-exact-half.js";
|
|
5
6
|
import { generateParserFunction, generateShapeValidator } from "./generate-parser-function.js";
|
|
6
7
|
const generateFile = (schema, typeName, options) => {
|
|
7
8
|
const typesOnly = options?.typesOnly === true;
|
|
@@ -44,7 +45,9 @@ const generateFile = (schema, typeName, options) => {
|
|
|
44
45
|
});
|
|
45
46
|
const unknownKeys = options?.unknownKeys ?? DEFAULT_UNKNOWN_KEYS;
|
|
46
47
|
const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown, reservedNames, unknownKeys);
|
|
48
|
+
const exactHalf = options?.exactHalf;
|
|
47
49
|
const parserFunction = generateParserFunction(schema, typeName, {
|
|
50
|
+
...exactHalf?.wrap === true ? { entryName: repairParserName(typeName) } : {},
|
|
48
51
|
useRefImports: true,
|
|
49
52
|
typeSuffix,
|
|
50
53
|
reservedNames,
|
|
@@ -59,7 +62,9 @@ const generateFile = (schema, typeName, options) => {
|
|
|
59
62
|
});
|
|
60
63
|
const combinedFunctions = `${shapeValidator}
|
|
61
64
|
|
|
62
|
-
${parserFunction}
|
|
65
|
+
${parserFunction}` + (exactHalf === void 0 ? "" : `
|
|
66
|
+
|
|
67
|
+
${exactHalf.code}`);
|
|
63
68
|
const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix, importExt);
|
|
64
69
|
const imports = [
|
|
65
70
|
...collectImports(schema, {
|
|
@@ -72,6 +77,11 @@ ${parserFunction}`;
|
|
|
72
77
|
// the functions the emitted code calls are one question about one text.
|
|
73
78
|
usedIn: typeDefinition + "\n\n" + combinedFunctions
|
|
74
79
|
}),
|
|
80
|
+
...exactHalf === void 0 ? [] : exactHalfImports(schema, exactHalf.code, {
|
|
81
|
+
...options?.selfFilename !== void 0 ? { selfFilename: options.selfFilename } : {},
|
|
82
|
+
typeSuffix,
|
|
83
|
+
importExt
|
|
84
|
+
}),
|
|
75
85
|
...helpers.imports
|
|
76
86
|
];
|
|
77
87
|
let result = "";
|
|
@@ -4,6 +4,12 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
|
4
4
|
* Options for controlling parser function generation behavior.
|
|
5
5
|
*/
|
|
6
6
|
type GenerateParserOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* The name to emit this definition's parser under, in place of `parseX`. Used
|
|
9
|
+
* when `parseX` is a wrapper that runs the exact, coercing half first and falls
|
|
10
|
+
* back to this parser only for a value no coercion can make valid.
|
|
11
|
+
*/
|
|
12
|
+
readonly entryName?: string;
|
|
7
13
|
/**
|
|
8
14
|
* When true, properties with $ref will call the imported parser function
|
|
9
15
|
* instead of inlining the validation logic. This is used when generating
|
|
@@ -17,9 +17,8 @@ import { generateBackstopAssertion, generateCompositionChecks, generateContainsC
|
|
|
17
17
|
import { canEnforceUnion, generatePropertyTypeCheck, generateUnionCheck, getUnionBranches, hasFastPathBlockingKeyword, isExclusiveUnion, isInlineObjectArrayProperty, isInlineObjectProperty, isInlineUnionProperty, isUnionArrayProperty, shapeValidatorName } from "./generate-type-checks.js";
|
|
18
18
|
import { generateUniqueItemsCheck } from "./generate-unique-items-check.js";
|
|
19
19
|
import { generatePrefixItemsMap, generateValidationExpression, getPrefixItems, isCoercibleItemSchema, prefixItemsCapsLength } from "./generate-validation-expression.js";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
};
|
|
20
|
+
let entryOverride = null;
|
|
21
|
+
const generateParserName = (typeName) => entryOverride !== null && entryOverride.typeName === typeName ? entryOverride.name : `parse${typeName}`;
|
|
23
22
|
const safeLiteralKey = (key) => key === "__proto__" ? '["__proto__"]' : safeKey(key);
|
|
24
23
|
const isPropertyRequired = (key, schema) => {
|
|
25
24
|
if (!isSchemaObject(schema)) {
|
|
@@ -224,6 +223,7 @@ const SCALAR_ROOT_CONSTRAINTS = [
|
|
|
224
223
|
"exclusiveMaximum",
|
|
225
224
|
"multipleOf"
|
|
226
225
|
];
|
|
226
|
+
const COERCED_SCALAR_ROOT_TYPES = /* @__PURE__ */ new Set(["string", "number", "integer", "boolean"]);
|
|
227
227
|
const isConstrainedScalarRoot = (schema) => {
|
|
228
228
|
if (!isSchemaObject(schema) || !hasType(schema))
|
|
229
229
|
return false;
|
|
@@ -584,8 +584,22 @@ ${assertion}
|
|
|
584
584
|
return ${returnExpr};
|
|
585
585
|
};`;
|
|
586
586
|
}
|
|
587
|
-
if (isConstrainedScalarRoot(schema)) {
|
|
588
|
-
const expr = generateValidationExpression(
|
|
587
|
+
if (isConstrainedScalarRoot(schema) || COERCED_SCALAR_ROOT_TYPES.has(schema.type)) {
|
|
588
|
+
const expr = generateValidationExpression(
|
|
589
|
+
"",
|
|
590
|
+
schema,
|
|
591
|
+
getDefaultValue(schema),
|
|
592
|
+
true,
|
|
593
|
+
unionCtx?.rootSchema,
|
|
594
|
+
void 0,
|
|
595
|
+
"input",
|
|
596
|
+
// A parser's `input` can be `undefined` — a parent building the default for
|
|
597
|
+
// a missing required property calls `parseX(undefined)` — and coercing that
|
|
598
|
+
// to the string `"undefined"` is worse than the type's own default. The
|
|
599
|
+
// constrained path keeps what it always passed.
|
|
600
|
+
isConstrainedScalarRoot(schema),
|
|
601
|
+
unionCtx?.caseInsensitive
|
|
602
|
+
);
|
|
589
603
|
return `export const ${functionName} = (input: unknown): ${typeName} => (${expr}) as ${typeName};`;
|
|
590
604
|
}
|
|
591
605
|
switch (schema.type) {
|
|
@@ -847,10 +861,12 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
847
861
|
const propertyKeys = Object.keys(schemaProps);
|
|
848
862
|
const { objects: subTypeNames, arrayItems: subItemNames } = collectInlineSubTypes(schema, typeName, reservedNames);
|
|
849
863
|
const preamble = [];
|
|
864
|
+
const prunable = /* @__PURE__ */ new Map();
|
|
850
865
|
for (const [key, subName] of subTypeNames) {
|
|
851
866
|
const propSchema = schemaProps[key];
|
|
852
867
|
preamble.push(`type ${subName} = NonNullable<${typeName}>[${JSON.stringify(key)}];`);
|
|
853
868
|
const subShapeValidator = generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown, reservedNames, unknownKeys);
|
|
869
|
+
prunable.set(subShapeValidator, `${shapeValidatorName(subName)}(`);
|
|
854
870
|
preamble.push(subShapeValidator);
|
|
855
871
|
preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames, caseInsensitive, subShapeValidator, unknownKeys));
|
|
856
872
|
}
|
|
@@ -859,11 +875,14 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
859
875
|
const itemSchema = propSchema.items;
|
|
860
876
|
preamble.push(`type ${subName} = NonNullable<NonNullable<${typeName}>[${JSON.stringify(key)}]>[number];`);
|
|
861
877
|
const itemShapeValidator = generateShapeValidator(itemSchema, subName, useRefImports, suffix, false, stripUnknown, reservedNames, unknownKeys);
|
|
878
|
+
prunable.set(itemShapeValidator, `${shapeValidatorName(subName)}(`);
|
|
862
879
|
preamble.push(itemShapeValidator);
|
|
863
|
-
|
|
880
|
+
const everyLoop = `const _every${subName} = (arr: readonly unknown[]): boolean => {
|
|
864
881
|
for (let i = 0; i < arr.length; i++) if (!${shapeValidatorName(subName)}(arr[i])) return false;
|
|
865
882
|
return true;
|
|
866
|
-
}
|
|
883
|
+
};`;
|
|
884
|
+
prunable.set(everyLoop, `_every${subName}(`);
|
|
885
|
+
preamble.push(everyLoop);
|
|
867
886
|
const unionBranches = strict ? null : getUnionBranches(itemSchema);
|
|
868
887
|
const scoredItemParser = unionBranches === null ? null : generateScoredUnionParser(generateParserName(subName), subName, unionBranches, {
|
|
869
888
|
useRefImports,
|
|
@@ -1373,9 +1392,21 @@ ${assertBodyLines.join("\n")}
|
|
|
1373
1392
|
}
|
|
1374
1393
|
lines.push(`}`);
|
|
1375
1394
|
const fn = lines.join("\n");
|
|
1376
|
-
|
|
1395
|
+
let kept = preamble;
|
|
1396
|
+
if (shapeValidatorSource !== void 0) {
|
|
1397
|
+
let changed = true;
|
|
1398
|
+
while (changed) {
|
|
1399
|
+
const current = kept;
|
|
1400
|
+
kept = current.filter((entry) => {
|
|
1401
|
+
const call = prunable.get(entry);
|
|
1402
|
+
return call === void 0 || fn.includes(call) || shapeValidatorSource.includes(call) || current.some((other) => other !== entry && other.includes(call));
|
|
1403
|
+
});
|
|
1404
|
+
changed = kept.length !== current.length;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
if (kept.length === 0)
|
|
1377
1408
|
return fn;
|
|
1378
|
-
return `${
|
|
1409
|
+
return `${kept.join("\n\n")}
|
|
1379
1410
|
|
|
1380
1411
|
${fn}`;
|
|
1381
1412
|
};
|
|
@@ -1837,7 +1868,14 @@ const generateParserFunction = (schema, typeName, rawOptions) => {
|
|
|
1837
1868
|
...options.stripUnknown !== void 0 ? { stripUnknown: options.stripUnknown } : {}
|
|
1838
1869
|
});
|
|
1839
1870
|
}
|
|
1840
|
-
|
|
1871
|
+
if (options?.entryName === void 0)
|
|
1872
|
+
return selectParserStrategy(schema, typeName, options);
|
|
1873
|
+
entryOverride = { typeName, name: options.entryName };
|
|
1874
|
+
try {
|
|
1875
|
+
return selectParserStrategy(schema, typeName, options);
|
|
1876
|
+
} finally {
|
|
1877
|
+
entryOverride = null;
|
|
1878
|
+
}
|
|
1841
1879
|
};
|
|
1842
1880
|
const shapeValidatorFunctionName = (typeName) => shapeValidatorName(typeName);
|
|
1843
1881
|
const generateShapeValidator = (schema, typeName, useRefImports, suffix = "", exported = true, stripUnknown = false, reservedNames = NO_RESERVED_NAMES, unknownKeys = DEFAULT_UNKNOWN_KEYS) => {
|