@amritk/generate-parsers 0.12.2 โ 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -14
- package/dist/generators/build-schema.js +32 -7
- package/dist/generators/generate-discriminated-union-validation.d.ts +9 -0
- package/dist/generators/generate-discriminated-union-validation.js +33 -22
- package/dist/generators/generate-enum-check.d.ts +8 -0
- package/dist/generators/generate-enum-check.js +14 -0
- package/dist/generators/generate-files.js +1 -0
- package/dist/generators/generate-parser-function.d.ts +6 -0
- package/dist/generators/generate-parser-function.js +167 -124
- package/dist/generators/generate-schema-checks.js +7 -2
- package/dist/generators/generate-strict-assertion.d.ts +16 -1
- package/dist/generators/generate-strict-assertion.js +111 -26
- package/dist/generators/generate-type-checks.d.ts +65 -0
- package/dist/generators/generate-type-checks.js +370 -0
- package/dist/generators/generate-validation-expression.js +61 -2
- package/dist/helpers/collect-helpers.js +3 -1
- package/dist/helpers/collect-imports.js +5 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -97,52 +97,42 @@ Returns: `Promise<GeneratedFile[]>`.
|
|
|
97
97
|
<thead>
|
|
98
98
|
<tr>
|
|
99
99
|
<th>Property</th>
|
|
100
|
-
<th>CLI Flag</th>
|
|
101
100
|
<th>Type</th>
|
|
102
|
-
<th align="center">Required</th>
|
|
103
101
|
<th align="center">Default</th>
|
|
104
102
|
</tr>
|
|
105
103
|
</thead>
|
|
106
104
|
<tbody>
|
|
107
105
|
<tr>
|
|
108
106
|
<td>๐ท๏ธ <code>typesOnly</code></td>
|
|
109
|
-
<td>โ</td>
|
|
110
107
|
<td><code>boolean</code></td>
|
|
111
|
-
<td align="center">โ</td>
|
|
112
108
|
<td align="center"><code>false</code></td>
|
|
113
109
|
</tr>
|
|
114
110
|
<tr>
|
|
115
|
-
<td colspan="
|
|
111
|
+
<td colspan="3">Generate only TypeScript type definitions without parser functions. Runtime helper files (validators, isObject) are also omitted since they are only needed for parsers.</td>
|
|
116
112
|
</tr>
|
|
117
113
|
<tr>
|
|
118
114
|
<td>โ ๏ธ <code>logWarnings</code></td>
|
|
119
|
-
<td>โ</td>
|
|
120
115
|
<td><code>boolean</code></td>
|
|
121
|
-
<td align="center">โ</td>
|
|
122
116
|
<td align="center"><code>false</code></td>
|
|
123
117
|
</tr>
|
|
124
118
|
<tr>
|
|
125
|
-
<td colspan="
|
|
119
|
+
<td colspan="3">Emit a console.warn in the generated parsers for every input key that is not declared in the schema's properties. Useful for detecting schema drift or unexpected data shapes at runtime.</td>
|
|
126
120
|
</tr>
|
|
127
121
|
<tr>
|
|
128
122
|
<td>๐ซ <code>strict</code></td>
|
|
129
|
-
<td>โ</td>
|
|
130
123
|
<td><code>boolean</code></td>
|
|
131
|
-
<td align="center">โ</td>
|
|
132
124
|
<td align="center"><code>false</code></td>
|
|
133
125
|
</tr>
|
|
134
126
|
<tr>
|
|
135
|
-
<td colspan="
|
|
127
|
+
<td colspan="3">Generate parsers that throw on type/shape mismatches (wrong type, missing required property, enum/pattern/min/max violations) instead of coercing invalid input to default values. When a schema sets additionalProperties: false, undeclared keys throw too; otherwise they are still allowed.</td>
|
|
136
128
|
</tr>
|
|
137
129
|
<tr>
|
|
138
130
|
<td>๐งน <code>stripUnknown</code></td>
|
|
139
|
-
<td>โ</td>
|
|
140
131
|
<td><code>boolean</code></td>
|
|
141
|
-
<td align="center">โ</td>
|
|
142
132
|
<td align="center"><code>false</code></td>
|
|
143
133
|
</tr>
|
|
144
134
|
<tr>
|
|
145
|
-
<td colspan="
|
|
135
|
+
<td colspan="3">Build each parser's result from the schema's declared properties only, silently dropping undeclared input keys at every nesting level (zod's .strip()). Extras are never a validation error, so this composes with strict (which still throws on wrong types and missing required properties) and yields to additionalProperties: false, which rejects rather than strips in strict mode.</td>
|
|
146
136
|
</tr>
|
|
147
137
|
</tbody>
|
|
148
138
|
</table>
|
|
@@ -5,13 +5,38 @@ import { generateIndexBarrel } from '@amritk/helpers/generate-index-barrel';
|
|
|
5
5
|
import { walkRefGraph } from '@amritk/helpers/walk-ref-graph';
|
|
6
6
|
import { applySchemaExtensions } from '../helpers/apply-schema-extensions.js';
|
|
7
7
|
import { generateFile } from './generate-files.js';
|
|
8
|
-
/**
|
|
9
|
-
* helper source
|
|
8
|
+
/**
|
|
9
|
+
* Loads a runtime helper's source to copy into `_helpers/` in embedded mode.
|
|
10
|
+
*
|
|
11
|
+
* Prefers the TypeScript source (`src/<helper>.ts`), rewriting any extensionless
|
|
12
|
+
* sibling import (e.g. `validate-record` โ `./is-object`) to `./is-object.js` so
|
|
13
|
+
* the embedded copy resolves under Node ESM, not only Bun. When the resolved
|
|
14
|
+
* `@amritk/helpers` is a published/cached tarball that ships only `dist/` (its
|
|
15
|
+
* `files` historically excluded `src/`, which is exactly why `bunx mjst` crashed
|
|
16
|
+
* on this lookup), it falls back to the always-published compiled `dist/<helper>.js`
|
|
17
|
+
* โ which tsc already emits with `.js`-extension imports โ and writes it verbatim.
|
|
18
|
+
*/
|
|
10
19
|
const readHelperSource = async (helper) => {
|
|
11
20
|
const require = createRequire(import.meta.url);
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
const helpersRoot = dirname(require.resolve('@amritk/helpers/package.json'));
|
|
22
|
+
try {
|
|
23
|
+
const source = await readFile(resolvePath(helpersRoot, 'src', `${helper}.ts`), 'utf-8');
|
|
24
|
+
// The leading `\b` in this pattern is load-bearing beyond its regex meaning.
|
|
25
|
+
// Our build runs tsc-alias --resolveFullPaths over the compiled output, and its
|
|
26
|
+
// scanner rewrites anything that looks like `from '<path>'` โ even inside a
|
|
27
|
+
// regex literal. Without the `\b`, normalize-path turned this pattern's
|
|
28
|
+
// backslashes into slashes and v0.12.3 shipped with an unparseable regex that
|
|
29
|
+
// crashed the CLI on load. The `\b` blocks that scan (in the emitted text,
|
|
30
|
+
// `from` is preceded by the word character `b`, so tsc-alias's own `\bfrom`
|
|
31
|
+
// never matches) and is also semantically correct: an import keyword always
|
|
32
|
+
// sits at a word boundary. The dist smoke test (scripts/dist-smoke.test.ts)
|
|
33
|
+
// fails the build if this ever regresses.
|
|
34
|
+
return { ext: 'ts', content: source.replace(/\bfrom '(\.\/[^'".]+)'/g, "from '$1.js'") };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
const compiled = await readFile(resolvePath(helpersRoot, 'dist', `${helper}.js`), 'utf-8');
|
|
38
|
+
return { ext: 'js', content: compiled };
|
|
39
|
+
}
|
|
15
40
|
};
|
|
16
41
|
/**
|
|
17
42
|
* Builds all TypeScript files from a JSON Schema by traversing its entire
|
|
@@ -114,8 +139,8 @@ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnl
|
|
|
114
139
|
// typesOnly skips parser generation entirely, so no runtime helpers are needed.
|
|
115
140
|
if (helpersMode === 'embedded' && !typesOnly) {
|
|
116
141
|
for (const helper of usedHelpers) {
|
|
117
|
-
const
|
|
118
|
-
files.push({ filename: `_helpers/${helper}
|
|
142
|
+
const { ext, content } = await readHelperSource(helper);
|
|
143
|
+
files.push({ filename: `_helpers/${helper}.${ext}`, content });
|
|
119
144
|
}
|
|
120
145
|
}
|
|
121
146
|
files.push({ filename: 'index.ts', content: generateIndexBarrel(files, { typesOnly: typesOnly ?? false }) });
|
|
@@ -2,5 +2,14 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
|
2
2
|
/**
|
|
3
3
|
* Generates a discriminated union validation expression.
|
|
4
4
|
* Uses a discriminator property to efficiently choose the correct schema.
|
|
5
|
+
*
|
|
6
|
+
* The emitted expression is a single *nested* ternary in schema order โ for
|
|
7
|
+
* branches `dog`, `cat` it reads `disc === "dog" ? v : disc === "cat" ? v :
|
|
8
|
+
* <fallback>`. Each branch tests its discriminant (plus any per-branch shape
|
|
9
|
+
* checks) and, on a match, passes the value through; the innermost `else` is the
|
|
10
|
+
* shared fallback taken when no discriminant matches. The previous
|
|
11
|
+
* `cases[i] + ' : ' + result` folding spliced complete ternaries together
|
|
12
|
+
* (`c1 ? v : d1 : c0 ? v : d0`), which is a parse error for >=2 branches and
|
|
13
|
+
* also evaluated branches in reverse with the wrong per-case default.
|
|
5
14
|
*/
|
|
6
15
|
export declare const generateDiscriminatedUnionValidation: (accessor: string, schemas: readonly JSONSchema[], discriminatorKey: string, defaultValue: string, isRequired: boolean) => string;
|
|
@@ -1,38 +1,49 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
2
2
|
import { getDiscriminatorValue } from '../helpers/get-discriminator-value.js';
|
|
3
3
|
import { generateSchemaChecks } from './generate-schema-checks.js';
|
|
4
4
|
/**
|
|
5
5
|
* Generates a discriminated union validation expression.
|
|
6
6
|
* Uses a discriminator property to efficiently choose the correct schema.
|
|
7
|
+
*
|
|
8
|
+
* The emitted expression is a single *nested* ternary in schema order โ for
|
|
9
|
+
* branches `dog`, `cat` it reads `disc === "dog" ? v : disc === "cat" ? v :
|
|
10
|
+
* <fallback>`. Each branch tests its discriminant (plus any per-branch shape
|
|
11
|
+
* checks) and, on a match, passes the value through; the innermost `else` is the
|
|
12
|
+
* shared fallback taken when no discriminant matches. The previous
|
|
13
|
+
* `cases[i] + ' : ' + result` folding spliced complete ternaries together
|
|
14
|
+
* (`c1 ? v : d1 : c0 ? v : d0`), which is a parse error for >=2 branches and
|
|
15
|
+
* also evaluated branches in reverse with the wrong per-case default.
|
|
7
16
|
*/
|
|
8
17
|
export const generateDiscriminatedUnionValidation = (accessor, schemas, discriminatorKey, defaultValue, isRequired) => {
|
|
9
|
-
|
|
18
|
+
// A non-identifier discriminator key (e.g. `x-type`) must use bracket access;
|
|
19
|
+
// `${accessor}?.${key}` would emit broken TS. `safeAccessor` picks dot vs
|
|
20
|
+
// bracket notation and JSON.stringifies the literal so quotes-in-keys are safe.
|
|
21
|
+
const discAccessor = safeAccessor(`${accessor}?`, discriminatorKey);
|
|
22
|
+
// Each branch contributes its guard condition and the value to yield on a
|
|
23
|
+
// match; the value is `accessor` because `generateSchemaChecks` has already
|
|
24
|
+
// proven the branch's shape (no coercion happens here).
|
|
25
|
+
const branches = [];
|
|
10
26
|
for (const schema of schemas) {
|
|
11
27
|
const discriminatorValue = getDiscriminatorValue(schema, discriminatorKey);
|
|
12
|
-
if (discriminatorValue
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
for (let i = 1; i < checks.length; i++) {
|
|
19
|
-
combinedChecks += ' && ' + checks[i];
|
|
20
|
-
}
|
|
21
|
-
cases.push(`${accessor}?.${discriminatorKey} === ${JSON.stringify(discriminatorValue)} && ${combinedChecks} ? ${accessor} : ${schemaDefault}`);
|
|
22
|
-
}
|
|
23
|
-
else {
|
|
24
|
-
cases.push(`${accessor}?.${discriminatorKey} === ${JSON.stringify(discriminatorValue)} ? ${accessor} : ${schemaDefault}`);
|
|
25
|
-
}
|
|
28
|
+
if (discriminatorValue === null)
|
|
29
|
+
continue;
|
|
30
|
+
let condition = `${discAccessor} === ${JSON.stringify(discriminatorValue)}`;
|
|
31
|
+
const checks = generateSchemaChecks(accessor, schema);
|
|
32
|
+
for (const check of checks) {
|
|
33
|
+
condition += ' && ' + check;
|
|
26
34
|
}
|
|
35
|
+
branches.push(condition);
|
|
27
36
|
}
|
|
28
|
-
if (
|
|
37
|
+
if (branches.length === 0) {
|
|
29
38
|
return isRequired ? `${accessor} ?? ${defaultValue}` : accessor;
|
|
30
39
|
}
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
let result =
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
// When no discriminant matches, a required union coerces to the shared default;
|
|
41
|
+
// an optional one keeps the raw value untouched.
|
|
42
|
+
let result = isRequired ? defaultValue : accessor;
|
|
43
|
+
// Fold from the last branch inward so the final text lists branches in schema
|
|
44
|
+
// order: `cond0 ? accessor : cond1 ? accessor : <fallback>`.
|
|
45
|
+
for (let i = branches.length - 1; i >= 0; i--) {
|
|
46
|
+
result = `${branches[i]} ? ${accessor} : ${result}`;
|
|
36
47
|
}
|
|
37
48
|
return result;
|
|
38
49
|
};
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generates an enum validation check expression.
|
|
3
3
|
* Returns a string that checks if the value is in the allowed enum values.
|
|
4
|
+
*
|
|
5
|
+
* For the common all-primitive case this emits a parenthesized `x === a || x ===
|
|
6
|
+
* b` chain rather than `[a, b].includes(x)`. The `.includes` form allocates a
|
|
7
|
+
* fresh array on every call (this expression runs on the parser hot path); the
|
|
8
|
+
* `===` chain is allocation-free and matches the validators package's
|
|
9
|
+
* `enumMembershipExpr`. It falls back to `.includes` when a member is an
|
|
10
|
+
* object/array (reference equality) or `NaN` (where `includes`'s SameValueZero
|
|
11
|
+
* differs from `===`), so the verdict is unchanged.
|
|
4
12
|
*/
|
|
5
13
|
export declare const generateEnumCheck: (accessor: string, enumValues: readonly unknown[]) => string;
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generates an enum validation check expression.
|
|
3
3
|
* Returns a string that checks if the value is in the allowed enum values.
|
|
4
|
+
*
|
|
5
|
+
* For the common all-primitive case this emits a parenthesized `x === a || x ===
|
|
6
|
+
* b` chain rather than `[a, b].includes(x)`. The `.includes` form allocates a
|
|
7
|
+
* fresh array on every call (this expression runs on the parser hot path); the
|
|
8
|
+
* `===` chain is allocation-free and matches the validators package's
|
|
9
|
+
* `enumMembershipExpr`. It falls back to `.includes` when a member is an
|
|
10
|
+
* object/array (reference equality) or `NaN` (where `includes`'s SameValueZero
|
|
11
|
+
* differs from `===`), so the verdict is unchanged.
|
|
4
12
|
*/
|
|
5
13
|
export const generateEnumCheck = (accessor, enumValues) => {
|
|
14
|
+
const allPrimitive = enumValues.length > 0 &&
|
|
15
|
+
enumValues.every((v) => (v === null || typeof v !== 'object') && typeof v !== 'function') &&
|
|
16
|
+
!enumValues.some((v) => typeof v === 'number' && Number.isNaN(v));
|
|
17
|
+
if (allPrimitive) {
|
|
18
|
+
return `(${enumValues.map((v) => `${accessor} === ${JSON.stringify(v)}`).join(' || ')})`;
|
|
19
|
+
}
|
|
6
20
|
const serializedEnum = JSON.stringify(enumValues);
|
|
7
21
|
return `${serializedEnum}.includes(${accessor} as never)`;
|
|
8
22
|
};
|
|
@@ -55,6 +55,7 @@ export const generateFile = (schema, typeName, options) => {
|
|
|
55
55
|
const parserFunction = generateParserFunction(schema, typeName, {
|
|
56
56
|
useRefImports: true,
|
|
57
57
|
typeSuffix,
|
|
58
|
+
...(rootSchema !== undefined ? { rootSchema } : {}),
|
|
58
59
|
...(options?.logWarnings !== undefined ? { logWarnings: options.logWarnings } : {}),
|
|
59
60
|
...(options?.strict !== undefined ? { strict: options.strict } : {}),
|
|
60
61
|
...(options?.stripUnknown !== undefined ? { stripUnknown: options.stripUnknown } : {}),
|
|
@@ -36,6 +36,12 @@ type GenerateParserOptions = {
|
|
|
36
36
|
* the suffix used when generating the referenced files. Defaults to `''`.
|
|
37
37
|
*/
|
|
38
38
|
readonly typeSuffix?: string;
|
|
39
|
+
/**
|
|
40
|
+
* The root schema document. When present, a top-level `oneOf`/`anyOf` whose
|
|
41
|
+
* branches are `$ref`s can be resolved to find a shared discriminator and emit a
|
|
42
|
+
* dispatch to the branch parsers, instead of falling back to a passthrough cast.
|
|
43
|
+
*/
|
|
44
|
+
readonly rootSchema?: Record<string, unknown>;
|
|
39
45
|
};
|
|
40
46
|
/**
|
|
41
47
|
* Generates a safe parser as an arrow function that never throws.
|