@amritk/validation 0.2.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/AI.md +92 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/dist/generate.d.ts +135 -0
- package/dist/generate.js +74 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/parsers/generators/assert-supported-keywords.d.ts +30 -0
- package/dist/parsers/generators/assert-supported-keywords.js +87 -0
- package/dist/parsers/generators/build-schema.d.ts +111 -0
- package/dist/parsers/generators/build-schema.js +74 -0
- package/dist/parsers/generators/generate-deep-equal-check.d.ts +24 -0
- package/dist/parsers/generators/generate-deep-equal-check.js +35 -0
- package/dist/parsers/generators/generate-discriminated-union-validation.d.ts +15 -0
- package/dist/parsers/generators/generate-discriminated-union-validation.js +28 -0
- package/dist/parsers/generators/generate-enum-check.d.ts +35 -0
- package/dist/parsers/generators/generate-enum-check.js +31 -0
- package/dist/parsers/generators/generate-files.d.ts +138 -0
- package/dist/parsers/generators/generate-files.js +89 -0
- package/dist/parsers/generators/generate-non-discriminated-union-validation.d.ts +6 -0
- package/dist/parsers/generators/generate-non-discriminated-union-validation.js +26 -0
- package/dist/parsers/generators/generate-parser-function.d.ts +120 -0
- package/dist/parsers/generators/generate-parser-function.js +1973 -0
- package/dist/parsers/generators/generate-schema-checks.d.ts +5 -0
- package/dist/parsers/generators/generate-schema-checks.js +260 -0
- package/dist/parsers/generators/generate-strict-assertion.d.ts +155 -0
- package/dist/parsers/generators/generate-strict-assertion.js +759 -0
- package/dist/parsers/generators/generate-type-checks.d.ts +148 -0
- package/dist/parsers/generators/generate-type-checks.js +454 -0
- package/dist/parsers/generators/generate-unique-items-check.d.ts +16 -0
- package/dist/parsers/generators/generate-unique-items-check.js +32 -0
- package/dist/parsers/generators/generate-validation-expression.d.ts +79 -0
- package/dist/parsers/generators/generate-validation-expression.js +416 -0
- package/dist/parsers/generators/subschema-match.d.ts +48 -0
- package/dist/parsers/generators/subschema-match.js +485 -0
- package/dist/parsers/generators/unevaluated-match.d.ts +19 -0
- package/dist/parsers/generators/unevaluated-match.js +206 -0
- package/dist/parsers/helpers/apply-schema-extensions.d.ts +35 -0
- package/dist/parsers/helpers/apply-schema-extensions.js +20 -0
- package/dist/parsers/helpers/collect-helpers.d.ts +15 -0
- package/dist/parsers/helpers/collect-helpers.js +64 -0
- package/dist/parsers/helpers/collect-imports.d.ts +104 -0
- package/dist/parsers/helpers/collect-imports.js +227 -0
- package/dist/parsers/helpers/find-discriminator.d.ts +6 -0
- package/dist/parsers/helpers/find-discriminator.js +27 -0
- package/dist/parsers/helpers/get-discriminator-value.d.ts +6 -0
- package/dist/parsers/helpers/get-discriminator-value.js +18 -0
- package/dist/parsers/index.d.ts +2 -0
- package/dist/parsers/index.js +4 -0
- package/dist/parsers/types/schema-extensions.d.ts +34 -0
- package/dist/parsers/types/schema-extensions.js +0 -0
- package/dist/rehome-parser-file.d.ts +25 -0
- package/dist/rehome-parser-file.js +74 -0
- package/dist/validators/generators/assert-generatable-refs.d.ts +19 -0
- package/dist/validators/generators/assert-generatable-refs.js +12 -0
- package/dist/validators/generators/assert-unevaluated-generatable.d.ts +30 -0
- package/dist/validators/generators/assert-unevaluated-generatable.js +71 -0
- package/dist/validators/generators/build-schema.d.ts +61 -0
- package/dist/validators/generators/build-schema.js +666 -0
- package/dist/validators/generators/collect-emitted-refs.d.ts +26 -0
- package/dist/validators/generators/collect-emitted-refs.js +112 -0
- package/dist/validators/generators/collect-validator-imports.d.ts +86 -0
- package/dist/validators/generators/collect-validator-imports.js +62 -0
- package/dist/validators/generators/emit-format-checks.d.ts +50 -0
- package/dist/validators/generators/emit-format-checks.js +248 -0
- package/dist/validators/generators/enforced-keywords.d.ts +33 -0
- package/dist/validators/generators/enforced-keywords.js +70 -0
- package/dist/validators/generators/folds-to-constant.d.ts +15 -0
- package/dist/validators/generators/folds-to-constant.js +43 -0
- package/dist/validators/generators/generate-coerce-function.d.ts +22 -0
- package/dist/validators/generators/generate-coerce-function.js +208 -0
- package/dist/validators/generators/generate-files.d.ts +92 -0
- package/dist/validators/generators/generate-files.js +86 -0
- package/dist/validators/generators/generate-repair-function.d.ts +27 -0
- package/dist/validators/generators/generate-repair-function.js +102 -0
- package/dist/validators/generators/generate-validator-function.d.ts +58 -0
- package/dist/validators/generators/generate-validator-function.js +1691 -0
- package/dist/validators/generators/tuple-shape.d.ts +31 -0
- package/dist/validators/generators/tuple-shape.js +20 -0
- package/dist/validators/generators/unevaluated-match.d.ts +29 -0
- package/dist/validators/generators/unevaluated-match.js +223 -0
- package/dist/validators/index.d.ts +2 -0
- package/dist/validators/index.js +4 -0
- package/package.json +92 -0
package/AI.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# @amritk/validation — notes for AI coding agents
|
|
2
|
+
|
|
3
|
+
One generator surface over mjst's types, guards, validators, coercers, repairers
|
|
4
|
+
and parsers. It owns both generator engines: the validator/coercer/repairer one
|
|
5
|
+
and the parser/type one, which used to ship as `@amritk/generate-validators` and
|
|
6
|
+
`@amritk/generate-parsers` and are now internal modules here.
|
|
7
|
+
|
|
8
|
+
> Pre-alpha: APIs and generated output change pre-1.0.
|
|
9
|
+
|
|
10
|
+
## Minimal example
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { generate } from '@amritk/validation'
|
|
14
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
|
|
15
|
+
|
|
16
|
+
const schema: JSONSchema = {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: { title: { type: 'string' } },
|
|
19
|
+
required: ['title'],
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const files = await generate(schema, 'Document', { modes: ['types', 'guard', 'validate', 'repair'] })
|
|
23
|
+
// → document.ts, validation-result.ts, index.ts
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## The modes
|
|
27
|
+
|
|
28
|
+
| mode | emits | value back | tells you | stops at |
|
|
29
|
+
|:---|:---|:---|:---|:---|
|
|
30
|
+
| `types` | `X` | — | — | — |
|
|
31
|
+
| `guard` | `isX` | — | a boolean | first problem |
|
|
32
|
+
| `validate` | `validateX` | — | every error | the end |
|
|
33
|
+
| `check` | `checkX` | — | the first error | first problem |
|
|
34
|
+
| `coerce` | `coerceX` | coerced | every error | the end |
|
|
35
|
+
| `repair` | `repairX` | repaired | repairs **and** errors | the end |
|
|
36
|
+
| `parse` | `parseX` | repaired | nothing (total) | never |
|
|
37
|
+
| `parseStrict` | `parseX` | as given | throws the first | first problem |
|
|
38
|
+
|
|
39
|
+
## The same modes from the CLI
|
|
40
|
+
|
|
41
|
+
Every mode is reachable from `mjst` without dropping to the programmatic API:
|
|
42
|
+
|
|
43
|
+
| mode | flags |
|
|
44
|
+
|:---|:---|
|
|
45
|
+
| `types` | `--types-only` (alone), otherwise always emitted |
|
|
46
|
+
| `guard`, `validate` | `--validators`, or `--validators-only` for no parser |
|
|
47
|
+
| `check` | `--check` |
|
|
48
|
+
| `coerce` | `--coerce` |
|
|
49
|
+
| `repair` | `--repair` (emits `coerceX` too) |
|
|
50
|
+
| `parse` | the default on any run that is not `--types-only`/`--validators-only` |
|
|
51
|
+
| `parseStrict` | `--strict` |
|
|
52
|
+
|
|
53
|
+
`--validators-only` is this package's own default (`['types', 'guard', 'validate']`)
|
|
54
|
+
spelled as a flag: everything that judges a document, nothing that builds one.
|
|
55
|
+
|
|
56
|
+
## Gotchas — where agents fail
|
|
57
|
+
|
|
58
|
+
1. **Options object, not positional booleans.** `generate(schema, typeName, options)`.
|
|
59
|
+
The two engines underneath take long positional argument lists; this is the
|
|
60
|
+
only entry point, and they are not reachable from outside the package.
|
|
61
|
+
2. **The default is `['types', 'guard', 'validate']`** — read-only. Anything that
|
|
62
|
+
rewrites a document (`coerce`, `repair`, `parse`) is opt-in.
|
|
63
|
+
3. **`parse` and `parseStrict` are mutually exclusive** and `generate` throws if
|
|
64
|
+
you ask for both: they are one function name under two contracts.
|
|
65
|
+
4. **One type, two files.** The type is declared once, in `x.ts`, alongside the
|
|
66
|
+
validator half. The parser half lands in `x.parse.ts` and imports the type. Do
|
|
67
|
+
not expect `parseX` in `x.ts`, and do not expect a second `export type X`
|
|
68
|
+
anywhere — there is exactly one.
|
|
69
|
+
5. **`repairX` reports `valid: true` for a *fully repaired* document**, with a
|
|
70
|
+
non-empty `repairs`. `result.valid` alone does not mean the input was clean;
|
|
71
|
+
check `repairs.length` when that matters.
|
|
72
|
+
6. **`guard` is the fail-fast mode and it is much cheaper on bad input** — it
|
|
73
|
+
short-circuits and builds no error object. It is also, counter-intuitively,
|
|
74
|
+
*not* faster than `validateX` on input that is valid, where both must check
|
|
75
|
+
everything. Pick `guard` when the answer is a branch, `validate` when someone
|
|
76
|
+
has to be told what to fix.
|
|
77
|
+
7. **`check` is the middle of those two, and it returns `validateX`'s type.**
|
|
78
|
+
`checkX` hands back the same `true | { valid: false, errors }`, with exactly
|
|
79
|
+
one error in it — the one `validateX` would have reported first, same `path`,
|
|
80
|
+
`keyword` and `params` — so error-handling code is shared between the two.
|
|
81
|
+
Pick it when a failure has to be reported but only the first thing wrong
|
|
82
|
+
matters. Do not build it by hand out of the other two: `isX(v) ? true :
|
|
83
|
+
validateX(v)` pays for both passes and is no faster than `validateX`.
|
|
84
|
+
|
|
85
|
+
8. **It emits each engine's exact bytes.** A single-mode build is byte-identical
|
|
86
|
+
to what that engine emitted when it was its own package, so there is no runtime
|
|
87
|
+
difference to reason about and no speedup to claim — do not tell a user this is
|
|
88
|
+
faster. What it changes is that the whole matrix comes out as one type instead
|
|
89
|
+
of two, and that a build asking for no validator mode ships no
|
|
90
|
+
`validation-result.ts`.
|
|
91
|
+
|
|
92
|
+
Only the `.` entry. Install: `bun add @amritk/validation`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 amritk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# @amritk/validation
|
|
4
|
+
|
|
5
|
+
**One generator surface over mjst's types, guards, validators, coercers, repairers and parsers.**
|
|
6
|
+
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
`@amritk/validation` is the front door. Given a JSON Schema (Draft 2020-12) it emits
|
|
14
|
+
one coherent set of TypeScript files carrying whichever runtime entry points you
|
|
15
|
+
ask for — from a bare type, through a boolean guard and a full error report, to a
|
|
16
|
+
coercer, a repairer and a total parser.
|
|
17
|
+
|
|
18
|
+
Underneath it are **two** generator engines, not one: a validator, coercer and
|
|
19
|
+
repairer engine, and a parser and type engine. They shipped separately as
|
|
20
|
+
`@amritk/generate-validators` and `@amritk/generate-parsers` until this package
|
|
21
|
+
absorbed them, and keeping both is deliberate. They emit genuinely different
|
|
22
|
+
code for the value-producing modes: a parser fuses building the output with
|
|
23
|
+
checking it, and is several times faster for it; a validator keeps the passes
|
|
24
|
+
apart, and can therefore report what was wrong. Collapsing them into one emitter
|
|
25
|
+
would mean giving up one of those properties.
|
|
26
|
+
|
|
27
|
+
What makes one output directory possible is that both derive the TypeScript type
|
|
28
|
+
from the same `@amritk/helpers/generate-type-definition`, so the type is declared
|
|
29
|
+
**once** and every function in the output is talking about it.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bun add @amritk/validation
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { generate } from '@amritk/validation'
|
|
45
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
|
|
46
|
+
|
|
47
|
+
const schema: JSONSchema = {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
retries: { type: 'integer', minimum: 0, maximum: 10, default: 3 },
|
|
51
|
+
name: { type: 'string', minLength: 3 },
|
|
52
|
+
},
|
|
53
|
+
required: ['retries', 'name'],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const files = await generate(schema, 'Config', {
|
|
57
|
+
modes: ['types', 'guard', 'validate', 'coerce', 'repair', 'parse'],
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
for (const file of files) await Bun.write(`./generated/${file.filename}`, file.content)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## The modes
|
|
66
|
+
|
|
67
|
+
Each mode is a different answer to *how much may this do to my document, and what
|
|
68
|
+
do I get told*. They compose: ask for several and you get several functions over
|
|
69
|
+
one type, and every one that judges a value judges it with the same `validateX`,
|
|
70
|
+
so no two can disagree about whether a document is valid.
|
|
71
|
+
|
|
72
|
+
| mode | function | value back | tells you | stops at |
|
|
73
|
+
|:---|:---|:---|:---|:---|
|
|
74
|
+
| `types` | — | — | — | — |
|
|
75
|
+
| `guard` | `isX(input): input is X` | — | a boolean | the first problem |
|
|
76
|
+
| `validate` | `validateX(input)` | — | every error | the end |
|
|
77
|
+
| `coerce` | `coerceX(input)` | coerced | every error | the end |
|
|
78
|
+
| `repair` | `repairX(input)` | repaired | repairs **and** errors | the end |
|
|
79
|
+
| `parse` | `parseX(input)` | repaired | nothing | never fails |
|
|
80
|
+
| `parseStrict` | `parseX(input)` | as given | throws the first | the first problem |
|
|
81
|
+
|
|
82
|
+
**Validate only, fail fast or find every error.** `guard` is the fail-fast half:
|
|
83
|
+
it stops at the first thing wrong and builds no error object, which on invalid
|
|
84
|
+
input is dramatically cheaper than reporting. `validate` is the other half: every
|
|
85
|
+
error, each with a JSON Pointer, the keyword that rejected the value, and that
|
|
86
|
+
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
|
+
|
|
89
|
+
**Coerce and repair, with or without the errors.** `coerceX` and `repairX` hand
|
|
90
|
+
back the value *and* the diagnostics; `parseX` hands back only the value and
|
|
91
|
+
never fails. Coercion moves a value that is already right but written in the
|
|
92
|
+
wrong type; repair also substitutes a value the schema supplies for one that
|
|
93
|
+
cannot be coerced, and reports the validator's own errors as the repairs.
|
|
94
|
+
|
|
95
|
+
`parse` and `parseStrict` are the same function name under two contracts, so
|
|
96
|
+
asking for both is an error rather than a silent choice.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## API
|
|
101
|
+
|
|
102
|
+
### `generate(rootSchema, rootTypeName, options?)`
|
|
103
|
+
|
|
104
|
+
| Parameter | Type | Default | Description |
|
|
105
|
+
|:---|:---|:---|:---|
|
|
106
|
+
| `rootSchema` | `JSONSchema` | — | The root schema. `$ref` and `$dynamicRef` are resolved recursively. |
|
|
107
|
+
| `rootTypeName` | `string` | — | Name for the root type (e.g. `"Config"`). |
|
|
108
|
+
| `options.modes` | `Mode[]` | `['types', 'guard', 'validate']` | Which entry points to emit. The default is read-only; anything that rewrites a document is opt-in. |
|
|
109
|
+
| `options.typeSuffix` | `string` | `''` | Suffix on every `$ref`-derived type name. The root name is untouched. |
|
|
110
|
+
| `options.schemas` | `Record<string, unknown>` | — | Documents you have already loaded, keyed by the URI a `$ref` names them by. |
|
|
111
|
+
| `options.unknownKeys` | `'count-keys' \| 'count-enumerable'` | `'count-keys'` | How a closed object's fast path proves it carries no undeclared key. |
|
|
112
|
+
| `options.formats` | `'all' \| string[]` | — | String `format`s the validators enforce. Unset leaves `format` an annotation. |
|
|
113
|
+
| `options.branchErrors` | `boolean` | `false` | Explain a failing `anyOf` / `oneOf` with the branch it plainly meant. |
|
|
114
|
+
| `options.stripUnknown` | `boolean` | `false` | Build each parsed result from the declared properties only. |
|
|
115
|
+
| `options.readonly` | `boolean` | `false` | Emit `readonly` type members. |
|
|
116
|
+
| `options.caseInsensitive` | `boolean` | `false` | Normalize a mis-cased string onto an `enum` member it matches. |
|
|
117
|
+
| `options.helpersMode` | `'package' \| 'embedded'` | `'package'` | Where the parser half gets its runtime helpers. |
|
|
118
|
+
| `options.extensions` | `SchemaExtensions` | — | Schema extensions, passed through to the parser half. |
|
|
119
|
+
|
|
120
|
+
Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; content: string }`.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Output layout
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
config.ts the type, plus isX / validateX / coerceX / repairX
|
|
128
|
+
config.parse.ts parseX, importing the type from ./config.js
|
|
129
|
+
validation-result.ts the shared error and result types, and the runtime helpers
|
|
130
|
+
index.ts a barrel over all of it
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The type lives in `config.ts` because that is where a reader looks for it. The
|
|
134
|
+
parser half moves to `config.parse.ts` because both generators name their file
|
|
135
|
+
after the schema and one of them has to give way. There is exactly one
|
|
136
|
+
`export type Config` in the output, whatever combination of modes you asked for
|
|
137
|
+
— which the test suite asserts, over a corpus of schema shapes, by compiling the
|
|
138
|
+
result under this repo's own flags.
|
|
139
|
+
|
|
140
|
+
Ask for no validator mode and there is nothing to collide with, so the parser
|
|
141
|
+
keeps both its own filename and the type it already authored, and no
|
|
142
|
+
`validation-result.ts` is emitted at all — a parse-only build has nothing that
|
|
143
|
+
could import its 17 KiB of error types and runtime helpers.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Is it faster?
|
|
148
|
+
|
|
149
|
+
No, and it should not be. Ask this package for one mode and it emits **the exact
|
|
150
|
+
bytes** the engine that owns that mode emits — which the test suite pins per
|
|
151
|
+
mode, by fingerprint, not by reading the output. Identical code cannot
|
|
152
|
+
run at a different speed, so there is no runtime claim to make here and none is
|
|
153
|
+
made. `bun run bench` measures it anyway, and the deltas wander either side of
|
|
154
|
+
zero between runs, which is what process-level variance looks like when there is
|
|
155
|
+
no underlying difference.
|
|
156
|
+
|
|
157
|
+
What does change is the cold side, and only in the direction you would hope:
|
|
158
|
+
|
|
159
|
+
| | before (both engines directly) | after |
|
|
160
|
+
|:---|:---|:---|
|
|
161
|
+
| codegen for the whole matrix | 2.9 ms | 2.7 ms |
|
|
162
|
+
| emitted bytes | 46.0 KiB | 45.8 KiB |
|
|
163
|
+
| files | 7 | 6 |
|
|
164
|
+
| declarations of `Order` | **2** | **1** |
|
|
165
|
+
| per-mode codegen | — | equal or slightly lower |
|
|
166
|
+
| per-mode bytes | — | identical |
|
|
167
|
+
|
|
168
|
+
The one that matters is the last row of the middle block. Reaching every mode
|
|
169
|
+
used to mean running both generators yourself and shipping two trees for one
|
|
170
|
+
schema, including two declarations of the same type — structurally
|
|
171
|
+
interchangeable, but two things to keep in step. Now there is one.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Related packages
|
|
176
|
+
|
|
177
|
+
- [`@amritk/helpers`](../helpers) — the shared type definition and fallback tables both engines read
|
|
178
|
+
- [`@amritk/mjst`](../cli) — the command-line interface
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { type UnknownKeysStrategy } from '@amritk/helpers/unknown-keys-strategy';
|
|
2
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
3
|
+
import { buildSchema } from './parsers/index.js';
|
|
4
|
+
/** The extension emitted on relative import specifiers. */
|
|
5
|
+
export type ImportExtension = 'js' | 'ts';
|
|
6
|
+
/** A generated TypeScript file, as every mjst generator hands one back. */
|
|
7
|
+
export type GeneratedFile = {
|
|
8
|
+
filename: string;
|
|
9
|
+
content: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* One runtime entry point the generated output can carry. Each is a different
|
|
13
|
+
* answer to "how much may this do to my document, and what do I get told".
|
|
14
|
+
*
|
|
15
|
+
* They compose rather than compete: asking for several emits several functions
|
|
16
|
+
* over one shared type, and the ones that judge a value all judge it with the
|
|
17
|
+
* same `validateX`, so no two can disagree about whether a document is valid.
|
|
18
|
+
*/
|
|
19
|
+
export type Mode =
|
|
20
|
+
/** `X` — the TypeScript type, and nothing that runs. Always emitted. */
|
|
21
|
+
'types'
|
|
22
|
+
/**
|
|
23
|
+
* `isX(input): input is X` — a type guard. Stops at the first thing wrong and
|
|
24
|
+
* builds no error object, which on invalid input is dramatically cheaper than
|
|
25
|
+
* any mode that reports: the check that fails is usually the first one tried.
|
|
26
|
+
* Use it when the answer is a branch, not a message.
|
|
27
|
+
*/
|
|
28
|
+
| 'guard'
|
|
29
|
+
/**
|
|
30
|
+
* `validateX(input): ValidationResult` — every error, each with a JSON Pointer,
|
|
31
|
+
* the keyword that rejected the value and that keyword's own values. Use it
|
|
32
|
+
* when a human or an API client has to be told what to fix.
|
|
33
|
+
*/
|
|
34
|
+
| 'validate'
|
|
35
|
+
/**
|
|
36
|
+
* `checkX(input): ValidationResult` — the same result `validateX` returns, with
|
|
37
|
+
* the one error that stopped it. Use it when a failure has to be reported but
|
|
38
|
+
* only the first thing wrong matters: it costs a single error object and walks
|
|
39
|
+
* no further, where `validateX` keeps going to collect the rest.
|
|
40
|
+
*/
|
|
41
|
+
| 'check'
|
|
42
|
+
/**
|
|
43
|
+
* `coerceX(input): CoercionResult<X>` — move a scalar toward the declared type
|
|
44
|
+
* where the schema leaves no choice, then validate. Substitutes nothing, so a
|
|
45
|
+
* value it cannot coerce is rejected as written.
|
|
46
|
+
*/
|
|
47
|
+
| 'coerce'
|
|
48
|
+
/**
|
|
49
|
+
* `repairX(input): RepairResult<X>` — coerce, validate, then repair each
|
|
50
|
+
* rejected position to a value the schema supplies, reporting the validator's
|
|
51
|
+
* own errors as the repairs. Tolerant, and never silently so.
|
|
52
|
+
*/
|
|
53
|
+
| 'repair'
|
|
54
|
+
/**
|
|
55
|
+
* `parseX(input): X` — a total function. Repairs whatever it is given and
|
|
56
|
+
* always returns a valid instance, reporting nothing. The fastest way to get a
|
|
57
|
+
* usable value when you do not care what was wrong with the input.
|
|
58
|
+
*/
|
|
59
|
+
| 'parse'
|
|
60
|
+
/**
|
|
61
|
+
* `parseX(input): X` that throws on the first violation instead of repairing.
|
|
62
|
+
* Mutually exclusive with `'parse'` — they are the same function under two
|
|
63
|
+
* contracts.
|
|
64
|
+
*/
|
|
65
|
+
| 'parseStrict';
|
|
66
|
+
/** Every mode, for a caller who wants the lot. */
|
|
67
|
+
export declare const ALL_MODES: readonly Mode[];
|
|
68
|
+
export type GenerateOptions = {
|
|
69
|
+
/**
|
|
70
|
+
* Which entry points to emit. Defaults to `['types', 'guard', 'validate']` —
|
|
71
|
+
* the modes that only ever read the document. Anything that rewrites a value
|
|
72
|
+
* is opt-in, because a generator that quietly starts repairing documents is
|
|
73
|
+
* not one you can trust by default.
|
|
74
|
+
*/
|
|
75
|
+
readonly modes?: readonly Mode[];
|
|
76
|
+
/** Suffix appended to every `$ref`-derived type name. The root name is untouched. */
|
|
77
|
+
readonly typeSuffix?: string;
|
|
78
|
+
/** Documents already loaded, keyed by the absolute URI a `$ref` names them by. */
|
|
79
|
+
readonly schemas?: Readonly<Record<string, unknown>>;
|
|
80
|
+
/** How a closed object's fast path proves it carries no undeclared key. */
|
|
81
|
+
readonly unknownKeys?: UnknownKeysStrategy;
|
|
82
|
+
/** String `format`s the generated validators enforce. Unset leaves `format` an annotation. */
|
|
83
|
+
readonly formats?: 'all' | readonly string[];
|
|
84
|
+
/** Explain a failing `anyOf` / `oneOf` with the errors of the branch it plainly meant. */
|
|
85
|
+
readonly branchErrors?: boolean;
|
|
86
|
+
/** Build each parsed result from the declared properties only, dropping undeclared keys. */
|
|
87
|
+
readonly stripUnknown?: boolean;
|
|
88
|
+
/** Emit `readonly` type members. */
|
|
89
|
+
readonly readonly?: boolean;
|
|
90
|
+
/** Normalize a mis-cased string onto an `enum` member it matches case-insensitively. */
|
|
91
|
+
readonly caseInsensitive?: boolean;
|
|
92
|
+
/** Where the parser half gets its runtime helpers: the published package, or emitted sources. */
|
|
93
|
+
readonly helpersMode?: 'package' | 'embedded';
|
|
94
|
+
/** Prefix on emitted-helper import specifiers, when `helpersMode` is `'embedded'`. */
|
|
95
|
+
readonly helpersImportPrefix?: string;
|
|
96
|
+
/** Schema extensions, keyed by definition name, passed through to the parser half. */
|
|
97
|
+
readonly extensions?: Parameters<typeof buildSchema>[2];
|
|
98
|
+
/**
|
|
99
|
+
* Extension on every emitted relative import specifier. `'js'` (the default) is
|
|
100
|
+
* the standard NodeNext form a compiled consumer needs; `'ts'` emits the literal
|
|
101
|
+
* on-disk paths, so the output runs directly under Node's type stripping with no
|
|
102
|
+
* build step.
|
|
103
|
+
*
|
|
104
|
+
* It reaches both halves, which is not a detail: they share one directory here,
|
|
105
|
+
* and a set where the parser files say `.ts` and the validator files say `.js`
|
|
106
|
+
* is one that resolves under neither runtime.
|
|
107
|
+
*/
|
|
108
|
+
readonly importExt?: ImportExtension;
|
|
109
|
+
/**
|
|
110
|
+
* Print warnings the parser generator raises about the schema — a keyword it
|
|
111
|
+
* cannot enforce, a construct it had to widen. Off by default because this is a
|
|
112
|
+
* library call, and a library that writes to stdout is a nuisance in anything
|
|
113
|
+
* that embeds it.
|
|
114
|
+
*/
|
|
115
|
+
readonly logWarnings?: boolean;
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Generates one coherent set of files for a schema, carrying whichever entry
|
|
119
|
+
* points were asked for.
|
|
120
|
+
*
|
|
121
|
+
* This composes the two generator engines that live beside it, `./parsers` and
|
|
122
|
+
* `./validators`, rather than reimplementing either. That is deliberate: the two
|
|
123
|
+
* emit genuinely different code for the value-producing modes — a parser fuses
|
|
124
|
+
* building the output with checking it and is several times faster for it, while
|
|
125
|
+
* a validator keeps the passes apart and can therefore report — and collapsing
|
|
126
|
+
* them into one emitter would mean giving up one of those properties. Composing
|
|
127
|
+
* keeps both and spends the cost on reconciling their output instead, which is
|
|
128
|
+
* bounded and testable.
|
|
129
|
+
*
|
|
130
|
+
* What makes the reconciliation honest is that both generators derive the type
|
|
131
|
+
* from the same `@amritk/helpers/generate-type-definition`, so the type is
|
|
132
|
+
* emitted once and the parser half imports it. There is exactly one `X` in the
|
|
133
|
+
* output, and every function in it is talking about the same type.
|
|
134
|
+
*/
|
|
135
|
+
export declare const generate: (rootSchema: JSONSchema, rootTypeName: string, options?: GenerateOptions) => Promise<GeneratedFile[]>;
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { generateIndexBarrel } from "@amritk/helpers/generate-index-barrel";
|
|
2
|
+
import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
|
|
3
|
+
import { buildSchema } from "./parsers/index.js";
|
|
4
|
+
import { buildValidatorSchema } from "./validators/index.js";
|
|
5
|
+
import { rehomeParserFile } from "./rehome-parser-file.js";
|
|
6
|
+
const ALL_MODES = ["types", "guard", "validate", "check", "coerce", "repair", "parse"];
|
|
7
|
+
const VALIDATOR_MODES = /* @__PURE__ */ new Set(["guard", "validate", "check", "coerce", "repair"]);
|
|
8
|
+
const PARSER_MODES = /* @__PURE__ */ new Set(["parse", "parseStrict"]);
|
|
9
|
+
const PARSER_SUFFIX = ".parse";
|
|
10
|
+
const wants = (modes, group) => modes.some((mode) => group.has(mode));
|
|
11
|
+
const generate = async (rootSchema, rootTypeName, options = {}) => {
|
|
12
|
+
const modes = options.modes ?? ["types", "guard", "validate"];
|
|
13
|
+
if (modes.includes("parse") && modes.includes("parseStrict")) {
|
|
14
|
+
throw new Error("modes 'parse' and 'parseStrict' are the same function under two contracts \u2014 one repairs and always returns a value, the other throws on the first violation. Ask for one.");
|
|
15
|
+
}
|
|
16
|
+
const typeSuffix = options.typeSuffix ?? "";
|
|
17
|
+
const unknownKeys = options.unknownKeys ?? DEFAULT_UNKNOWN_KEYS;
|
|
18
|
+
const importExt = options.importExt ?? "js";
|
|
19
|
+
const needsValidator = wants(modes, VALIDATOR_MODES);
|
|
20
|
+
const needsParser = wants(modes, PARSER_MODES);
|
|
21
|
+
const files = [];
|
|
22
|
+
const parserFiles = async () => buildSchema(
|
|
23
|
+
rootSchema,
|
|
24
|
+
rootTypeName,
|
|
25
|
+
options.extensions,
|
|
26
|
+
!needsParser,
|
|
27
|
+
// typesOnly — when the parser is only here to author the type
|
|
28
|
+
options.logWarnings === true,
|
|
29
|
+
modes.includes("parseStrict"),
|
|
30
|
+
options.helpersMode ?? "package",
|
|
31
|
+
options.helpersImportPrefix ?? "./",
|
|
32
|
+
options.readonly === true,
|
|
33
|
+
options.stripUnknown === true,
|
|
34
|
+
typeSuffix,
|
|
35
|
+
importExt,
|
|
36
|
+
options.caseInsensitive === true,
|
|
37
|
+
options.schemas,
|
|
38
|
+
unknownKeys
|
|
39
|
+
);
|
|
40
|
+
if (!needsValidator) {
|
|
41
|
+
for (const file of await parserFiles()) {
|
|
42
|
+
if (file.filename !== "index.ts")
|
|
43
|
+
files.push(file);
|
|
44
|
+
}
|
|
45
|
+
files.push({ filename: "index.ts", content: generateIndexBarrel(files, { importExt }) });
|
|
46
|
+
return files;
|
|
47
|
+
}
|
|
48
|
+
const validatorFiles = await buildValidatorSchema(rootSchema, rootTypeName, typeSuffix, options.schemas, unknownKeys, options.formats, modes.includes("coerce") || modes.includes("repair"), options.branchErrors === true, modes.includes("repair"), importExt, modes.includes("check"));
|
|
49
|
+
for (const file of validatorFiles) {
|
|
50
|
+
if (file.filename !== "index.ts")
|
|
51
|
+
files.push(file);
|
|
52
|
+
}
|
|
53
|
+
if (needsParser) {
|
|
54
|
+
for (const file of await parserFiles()) {
|
|
55
|
+
if (file.filename === "index.ts")
|
|
56
|
+
continue;
|
|
57
|
+
if (file.filename.includes("/")) {
|
|
58
|
+
files.push(file);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const moduleName = file.filename.replace(/\.ts$/, "");
|
|
62
|
+
files.push({
|
|
63
|
+
filename: `${moduleName}${PARSER_SUFFIX}.ts`,
|
|
64
|
+
content: rehomeParserFile(file.content, moduleName, PARSER_SUFFIX, importExt)
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
files.push({ filename: "index.ts", content: generateIndexBarrel(files, { importExt }) });
|
|
69
|
+
return files;
|
|
70
|
+
};
|
|
71
|
+
export {
|
|
72
|
+
ALL_MODES,
|
|
73
|
+
generate
|
|
74
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ALL_MODES, type GeneratedFile, type GenerateOptions, generate, type ImportExtension, type Mode, } from './generate.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
import { type StrictAssertionContext } from './generate-strict-assertion.js';
|
|
3
|
+
/**
|
|
4
|
+
* Throws when a schema (anywhere in its subtree) uses a keyword the *strict*
|
|
5
|
+
* parser generator cannot enforce but which narrows the set of valid documents.
|
|
6
|
+
* Mirrors the validator engine's `assertNoUnsupportedKeywords`: failing loudly at
|
|
7
|
+
* generation time is strictly better than silently emitting a parser that
|
|
8
|
+
* accepts input the schema forbids — a strict parser promises to "throw on
|
|
9
|
+
* violations".
|
|
10
|
+
*
|
|
11
|
+
* Two families are rejected:
|
|
12
|
+
*
|
|
13
|
+
* - `contains`, `not`, `propertyNames`, or `dependentSchemas` whose subschema
|
|
14
|
+
* uses a form {@link canMatchSubschema} cannot prove inline (a cyclic `$ref`,
|
|
15
|
+
* an unknown keyword). The parser enforces these keywords only for subschemas
|
|
16
|
+
* it can match exactly; anything else would be silently ignored.
|
|
17
|
+
*
|
|
18
|
+
* - A node whose enforcement rests on the whole-schema backstop (see
|
|
19
|
+
* {@link backstopReason} — `unevaluatedProperties` / `unevaluatedItems`, a
|
|
20
|
+
* `$ref` nothing delegates, a type-less constraint) when the matcher cannot
|
|
21
|
+
* build that backstop.
|
|
22
|
+
*
|
|
23
|
+
* Everything the matcher *can* prove is enforced rather than refused, which is
|
|
24
|
+
* why `unevaluated*` and `$ref`-carrying subschemas no longer fail outright.
|
|
25
|
+
*
|
|
26
|
+
* This runs for strict generation only — coercing parsers are documented to be
|
|
27
|
+
* permissive (they repair rather than reject), so ignoring these keywords there
|
|
28
|
+
* is the contract, not a lie.
|
|
29
|
+
*/
|
|
30
|
+
export declare const assertNoUnsupportedKeywords: (schema: JSONSchema, typeName: string, context?: StrictAssertionContext) => void;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
2
|
+
import { backstopReason, backstopSchema } from "./generate-strict-assertion.js";
|
|
3
|
+
import { canMatchSubschema } from "./subschema-match.js";
|
|
4
|
+
const SUBSCHEMA_KEYS = [
|
|
5
|
+
"items",
|
|
6
|
+
"prefixItems",
|
|
7
|
+
"additionalItems",
|
|
8
|
+
"contains",
|
|
9
|
+
"additionalProperties",
|
|
10
|
+
"propertyNames",
|
|
11
|
+
"unevaluatedProperties",
|
|
12
|
+
"unevaluatedItems",
|
|
13
|
+
"not",
|
|
14
|
+
"if",
|
|
15
|
+
"then",
|
|
16
|
+
"else",
|
|
17
|
+
"allOf",
|
|
18
|
+
"anyOf",
|
|
19
|
+
"oneOf",
|
|
20
|
+
"contentSchema"
|
|
21
|
+
];
|
|
22
|
+
const SUBSCHEMA_MAP_KEYS = [
|
|
23
|
+
"properties",
|
|
24
|
+
"patternProperties",
|
|
25
|
+
"dependentSchemas",
|
|
26
|
+
"$defs",
|
|
27
|
+
"definitions"
|
|
28
|
+
];
|
|
29
|
+
const assertNoUnsupportedKeywords = (schema, typeName, context = {}) => {
|
|
30
|
+
const rootSchema = context.rootSchema ?? (isSchemaObject(schema) ? schema : void 0);
|
|
31
|
+
const provable = (sub) => canMatchSubschema(sub, rootSchema);
|
|
32
|
+
const fail = (keyword, detail) => {
|
|
33
|
+
throw new Error(`[${typeName}] unsupported keyword "${keyword}": the strict parser generator ${detail}, and would silently accept documents the schema rejects. Generate a coercing (non-strict) parser, validate this schema with a validate mode instead of parseStrict, or remove the keyword.`);
|
|
34
|
+
};
|
|
35
|
+
const visit = (node) => {
|
|
36
|
+
if (typeof node !== "object" || node === null || Array.isArray(node))
|
|
37
|
+
return;
|
|
38
|
+
const record = node;
|
|
39
|
+
if ("contains" in record && !provable(record["contains"])) {
|
|
40
|
+
fail("contains", "cannot prove its subschema inline");
|
|
41
|
+
}
|
|
42
|
+
if ("not" in record && !provable(record["not"])) {
|
|
43
|
+
fail("not", "cannot prove its subschema inline");
|
|
44
|
+
}
|
|
45
|
+
if ("propertyNames" in record && isSchemaObject(record["propertyNames"])) {
|
|
46
|
+
if (!provable(record["propertyNames"])) {
|
|
47
|
+
fail("propertyNames", "cannot prove its subschema inline");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const dependentSchemas = record["dependentSchemas"];
|
|
51
|
+
if (typeof dependentSchemas === "object" && dependentSchemas !== null && !Array.isArray(dependentSchemas)) {
|
|
52
|
+
for (const sub of Object.values(dependentSchemas)) {
|
|
53
|
+
if (typeof sub === "boolean")
|
|
54
|
+
continue;
|
|
55
|
+
if (!provable(sub)) {
|
|
56
|
+
fail("dependentSchemas", "cannot prove one of its subschemas inline");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const reason = backstopReason(node, context);
|
|
61
|
+
if (reason !== null && !provable(backstopSchema(node, reason))) {
|
|
62
|
+
fail(reason, "cannot prove the schema it appears in inline");
|
|
63
|
+
}
|
|
64
|
+
for (const keyword of SUBSCHEMA_KEYS) {
|
|
65
|
+
const value = record[keyword];
|
|
66
|
+
if (value === void 0)
|
|
67
|
+
continue;
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
for (const item of value)
|
|
70
|
+
visit(item);
|
|
71
|
+
} else {
|
|
72
|
+
visit(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const keyword of SUBSCHEMA_MAP_KEYS) {
|
|
76
|
+
const value = record[keyword];
|
|
77
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
78
|
+
continue;
|
|
79
|
+
for (const sub of Object.values(value))
|
|
80
|
+
visit(sub);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
visit(schema);
|
|
84
|
+
};
|
|
85
|
+
export {
|
|
86
|
+
assertNoUnsupportedKeywords
|
|
87
|
+
};
|