@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 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="5">Generate only TypeScript type definitions without parser functions. Runtime helper files (validators, isObject) are also omitted since they are only needed for parsers.</td>
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="5">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>
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="5">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>
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="5">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>
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
- /** Locate the @amritk/helpers package on disk so we can copy its runtime
9
- * helper source files into the generated output when in embedded mode. */
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 helpersPkgPath = require.resolve('@amritk/helpers/package.json');
13
- const helpersRoot = dirname(helpersPkgPath);
14
- return readFile(resolvePath(helpersRoot, 'src', `${helper}.ts`), 'utf-8');
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 source = await readHelperSource(helper);
118
- files.push({ filename: `_helpers/${helper}.ts`, content: source });
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 { getDefaultValue } from '../helpers/get-default-value.js';
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
- const cases = [];
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 !== null) {
13
- const schemaDefault = getDefaultValue(schema);
14
- const checks = generateSchemaChecks(accessor, schema);
15
- if (checks.length > 0) {
16
- // checks[0] is safe: we guard with checks.length > 0 above
17
- let combinedChecks = checks[0];
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 (cases.length === 0) {
37
+ if (branches.length === 0) {
29
38
  return isRequired ? `${accessor} ?? ${defaultValue}` : accessor;
30
39
  }
31
- // Chain the cases together with ternary operators using a for loop instead of .reduce()
32
- // cases[0] is safe: we guard with cases.length === 0 above
33
- let result = cases[0];
34
- for (let i = 1; i < cases.length; i++) {
35
- result = cases[i] + ' : ' + result;
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.