@amritk/generate-parsers 0.12.1 โ†’ 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,28 @@ 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
+ return { ext: 'ts', content: source.replace(/from '(/./[^'".]+)'/g, "from '$1.js'") };
25
+ }
26
+ catch {
27
+ const compiled = await readFile(resolvePath(helpersRoot, 'dist', `${helper}.js`), 'utf-8');
28
+ return { ext: 'js', content: compiled };
29
+ }
15
30
  };
16
31
  /**
17
32
  * Builds all TypeScript files from a JSON Schema by traversing its entire
@@ -114,8 +129,8 @@ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnl
114
129
  // typesOnly skips parser generation entirely, so no runtime helpers are needed.
115
130
  if (helpersMode === 'embedded' && !typesOnly) {
116
131
  for (const helper of usedHelpers) {
117
- const source = await readHelperSource(helper);
118
- files.push({ filename: `_helpers/${helper}.ts`, content: source });
132
+ const { ext, content } = await readHelperSource(helper);
133
+ files.push({ filename: `_helpers/${helper}.${ext}`, content });
119
134
  }
120
135
  }
121
136
  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.
@@ -1,9 +1,13 @@
1
1
  import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
+ import { multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
2
3
  import { refToName } from '@amritk/helpers/ref-to-name';
4
+ import { resolveRef } from '@amritk/helpers/resolve-ref';
3
5
  import { safeAccessor, safeKey } from '@amritk/helpers/safe-accessor';
4
6
  import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
5
7
  import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
8
+ import { findDiscriminator } from '../helpers/find-discriminator.js';
6
9
  import { getDefaultValue } from '../helpers/get-default-value.js';
10
+ import { getDiscriminatorValue } from '../helpers/get-discriminator-value.js';
7
11
  import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion.js';
8
12
  import { generateValidationExpression, scalarItemTypeCheck } from './generate-validation-expression.js';
9
13
  /**
@@ -41,7 +45,10 @@ const generateRequiredRefCall = (key, ref, suffix) => {
41
45
  const generateOptionalRefCall = (key, ref, suffix) => {
42
46
  const acc = safeAccessor('input', key);
43
47
  const parserName = generateParserName(refToName(ref, suffix));
44
- return `...(${acc} && { ${safeKey(key)}: ${parserName}(${acc}) })`;
48
+ // Gate on presence, not truthiness: a `&&` guard skips parsing when the value
49
+ // is `false`/`0`/`""`/`null`, spreading it raw. `!== undefined` still omits an
50
+ // absent optional property but coerces every present one, matching the main path.
51
+ return `...(${acc} !== undefined && { ${safeKey(key)}: ${parserName}(${acc}) })`;
45
52
  };
46
53
  /**
47
54
  * Generates a validateArray call for required array properties with $ref items.
@@ -58,7 +65,8 @@ const generateRequiredArrayRefCall = (key, ref, suffix) => {
58
65
  const generateOptionalArrayRefCall = (key, ref, suffix) => {
59
66
  const parserName = generateParserName(refToName(ref, suffix));
60
67
  const acc = safeAccessor('input', key);
61
- return `...(${acc} && { ${safeKey(key)}: validateArray(${acc}, ${parserName}) })`;
68
+ // Presence gating (not truthiness) so a falsy-but-present value is still parsed.
69
+ return `...(${acc} !== undefined && { ${safeKey(key)}: validateArray(${acc}, ${parserName}) })`;
62
70
  };
63
71
  /**
64
72
  * Generates a validateRecord call for required object properties with additionalProperties $ref.
@@ -76,7 +84,8 @@ const generateRequiredRecordRefCall = (key, ref, suffix) => {
76
84
  const generateOptionalRecordRefCall = (key, ref, suffix) => {
77
85
  const acc = safeAccessor('input', key);
78
86
  const parserName = generateParserName(refToName(ref, suffix));
79
- return `...(${acc} && { ${safeKey(key)}: validateRecord(${acc}, ${parserName}) })`;
87
+ // Presence gating (not truthiness) so a falsy-but-present value is still parsed.
88
+ return `...(${acc} !== undefined && { ${safeKey(key)}: validateRecord(${acc}, ${parserName}) })`;
80
89
  };
81
90
  /**
82
91
  * Generates a spread entry for optional inline properties.
@@ -296,8 +305,75 @@ const scalarDefaultLiteral = (schema) => {
296
305
  }
297
306
  return '{}';
298
307
  };
299
- const generateNonObjectParser = (typeName, schema, strict) => {
308
+ /**
309
+ * Emits a dispatcher for a top-level `oneOf`/`anyOf` whose branches are `$ref`s,
310
+ * when the branches share a discriminator (a `const`/`enum` tag such as `kind`).
311
+ * Each resolved branch's discriminant selects its imported parser, so a recursive
312
+ * discriminated union (e.g. `Expr = Lit | BinOp`, where `BinOp` has `Expr`
313
+ * children) is actually validated and dispatched instead of blindly cast. Returns
314
+ * `null` when it can't be done (no root schema, no discriminator, a non-ref
315
+ * branch, or ref imports disabled), so the caller keeps its passthrough fallback.
316
+ */
317
+ const generateRefUnionDispatch = (functionName, typeName, branches, strict, ctx) => {
318
+ const rootSchema = ctx.rootSchema;
319
+ if (!ctx.useRefImports || rootSchema === undefined)
320
+ return null;
321
+ if (branches.length === 0 || !branches.every((b) => isSchemaObject(b) && hasRef(b)))
322
+ return null;
323
+ // Resolve each branch ref so we can read its discriminant tag and derive its
324
+ // imported parser name.
325
+ const resolved = branches.map((b) => {
326
+ const ref = b.$ref;
327
+ return { parser: generateParserName(refToName(ref, ctx.suffix)), schema: resolveRef(ref, rootSchema) };
328
+ });
329
+ if (resolved.some((r) => !isSchemaObject(r.schema)))
330
+ return null;
331
+ const discriminator = findDiscriminator(resolved.map((r) => r.schema));
332
+ if (discriminator === null)
333
+ return null;
334
+ const cases = [];
335
+ for (const r of resolved) {
336
+ const value = getDiscriminatorValue(r.schema, discriminator);
337
+ if (value === null)
338
+ return null;
339
+ cases.push({ value, parser: r.parser });
340
+ }
341
+ // Read the discriminant safely: `null`/`undefined` is guarded (a property read
342
+ // would throw), and a non-object primitive yields `undefined` (no branch matches).
343
+ const access = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(discriminator)
344
+ ? `.${discriminator}`
345
+ : `[${JSON.stringify(discriminator)}]`;
346
+ const discExpr = `input == null ? undefined : (input as Record<string, unknown>)${access}`;
347
+ // Fold branches into a nested ternary in declaration order:
348
+ // `_disc === "lit" ? parseLit(input) : _disc === "binop" ? parseBinop(input) : <fallback>`.
349
+ // Unmatched: strict throws (not a member of the union); non-strict coerces via the
350
+ // first branch's parser, preserving the coercing parser's lenient contract while
351
+ // still running real validation/coercion instead of a blind cast.
352
+ const fallback = strict
353
+ ? `(() => { throw new Error(${JSON.stringify(`[${typeName}] value does not match any union branch`)}); })()`
354
+ : `${cases[0]?.parser}(input)`;
355
+ let expr = fallback;
356
+ for (let i = cases.length - 1; i >= 0; i--) {
357
+ const c = cases[i];
358
+ if (c)
359
+ expr = `_disc === ${JSON.stringify(c.value)} ? ${c.parser}(input) : ${expr}`;
360
+ }
361
+ return `export const ${functionName} = (input: unknown): ${typeName} => {\n const _disc = ${discExpr};\n return ${expr};\n};`;
362
+ };
363
+ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
300
364
  const functionName = generateParserName(typeName);
365
+ // A ref-branch discriminated union dispatches to its branch parsers in *both*
366
+ // modes (strict throws on an unmatched discriminant; non-strict coerces via the
367
+ // first branch). This runs before the `!strict` coercion block below so a strict
368
+ // recursive union is validated instead of blindly cast.
369
+ if (unionCtx && isSchemaObject(schema) && (hasOneOf(schema) || hasAnyOf(schema))) {
370
+ const branches = hasOneOf(schema) ? schema.oneOf : hasAnyOf(schema) ? schema.anyOf : [];
371
+ if (branches.length > 0 && branches.every((b) => isSchemaObject(b) && hasRef(b))) {
372
+ const dispatch = generateRefUnionDispatch(functionName, typeName, branches, strict, unionCtx);
373
+ if (dispatch !== null)
374
+ return dispatch;
375
+ }
376
+ }
301
377
  // `const`/`enum` define the value space directly (the generated type is a
302
378
  // literal / literal-union), so a non-member must coerce to a valid member โ€”
303
379
  // otherwise the returned value would not be of the declared type. This runs
@@ -322,10 +398,7 @@ const generateNonObjectParser = (typeName, schema, strict) => {
322
398
  }
323
399
  // A top-level union must validate membership: an unmatched value is not of
324
400
  // the declared union type, so coerce it to a member-shaped default. Reuse the
325
- // same union validation the property path uses. A `$ref` branch can't be
326
- // validated inline here (the membership test would silently drop it and
327
- // wrongly coerce a valid ref-shaped value), so fall through to a passthrough
328
- // cast rather than discarding valid input.
401
+ // same union validation the property path uses.
329
402
  if (hasOneOf(schema) || hasAnyOf(schema)) {
330
403
  const branches = hasOneOf(schema) ? schema.oneOf : hasAnyOf(schema) ? schema.anyOf : [];
331
404
  const hasRefBranch = branches.some((b) => isSchemaObject(b) && hasRef(b));
@@ -334,6 +407,9 @@ const generateNonObjectParser = (typeName, schema, strict) => {
334
407
  const expr = generateValidationExpression('', schema, fallback, true, undefined, undefined, 'input', true);
335
408
  return `export const ${functionName} = (input: unknown): ${typeName} => (${expr}) as ${typeName};`;
336
409
  }
410
+ // Ref-branch union: a *discriminated* one was already dispatched above (both
411
+ // modes). Anything reaching here is non-discriminated, which can't be validated
412
+ // inline without risking dropping valid input โ€” fall back to a passthrough cast.
337
413
  return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`;
338
414
  }
339
415
  }
@@ -356,8 +432,10 @@ const generateNonObjectParser = (typeName, schema, strict) => {
356
432
  case 'string':
357
433
  return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "string" ? input as ${typeName} : "" as ${typeName};`;
358
434
  case 'number':
359
- case 'integer':
360
435
  return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "number" ? input as ${typeName} : 0 as ${typeName};`;
436
+ case 'integer':
437
+ // `integer` rejects non-integral numbers; a bare typeof would accept `1.5`.
438
+ return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "number" && Number.isInteger(input) ? input as ${typeName} : 0 as ${typeName};`;
361
439
  case 'boolean':
362
440
  return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "boolean" ? input as ${typeName} : false as ${typeName};`;
363
441
  case 'array': {
@@ -492,6 +570,14 @@ const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
492
570
  'not' in schema) {
493
571
  return null;
494
572
  }
573
+ // A `const` property (the usual discriminated-union tag, e.g. `kind: "lit"`) has
574
+ // no `type`, but is fast-path predicable via strict equality. Handling it here is
575
+ // what lets a discriminated branch's shape validator be a real predicate instead
576
+ // of the `=> false` stub. Structural consts need deep equality, so skip those.
577
+ if (hasConst(schema)) {
578
+ const c = schema.const;
579
+ return c === null || typeof c !== 'object' ? `${varName} === ${JSON.stringify(c)}` : null;
580
+ }
495
581
  if (!hasType(schema))
496
582
  return null;
497
583
  // Array of $refs: each item must satisfy the imported shape predicate.
@@ -518,6 +604,10 @@ const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
518
604
  case 'number':
519
605
  case 'integer': {
520
606
  checks.push(`typeof ${varName} === "number"`);
607
+ // `integer` must reject non-integral numbers on the fast path too, otherwise
608
+ // `1.5` would pass through uncoerced.
609
+ if (schema.type === 'integer')
610
+ checks.push(`Number.isInteger(${varName})`);
521
611
  if (hasMinimum(schema))
522
612
  checks.push(`${varName} >= ${schema.minimum}`);
523
613
  if (hasMaximum(schema))
@@ -527,7 +617,7 @@ const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
527
617
  if (hasExclusiveMaximum(schema))
528
618
  checks.push(`${varName} < ${schema.exclusiveMaximum}`);
529
619
  if (hasMultipleOf(schema))
530
- checks.push(`${varName} % ${schema.multipleOf} === 0`);
620
+ checks.push(multipleOfPassExpr(varName, schema.multipleOf));
531
621
  break;
532
622
  }
533
623
  case 'boolean':
@@ -641,9 +731,17 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
641
731
  schema.allOf.some((entry) => isSchemaObject(entry) && hasRef(entry));
642
732
  let canFastPath = !hasAllOfRefParsers;
643
733
  const fastPathChecks = [];
734
+ // `toVarName` collapses distinct keys that differ only in non-identifier chars
735
+ // (e.g. `a-b` and `a.b` both โ†’ `_a_b`), which would emit two `const _a_b`
736
+ // declarations (TS2451). Dedupe by suffixing `_` until unique โ€” the same
737
+ // approach `collectInlineObjectProperties` uses โ€” so each key gets its own var.
738
+ const usedVarNames = new Set();
644
739
  for (const [key, propSchema] of properties) {
645
740
  const isRequired = isPropertyRequired(key, schema);
646
- const varName = toVarName(key);
741
+ let varName = toVarName(key);
742
+ while (usedVarNames.has(varName))
743
+ varName = `${varName}_`;
744
+ usedVarNames.add(varName);
647
745
  propInfo.push({ key, varName, isRequired, propSchema });
648
746
  if (!canFastPath)
649
747
  continue;
@@ -1335,7 +1433,11 @@ const selectParserStrategy = (schema, typeName, options) => {
1335
1433
  (isSchemaObject(schema) && ('patternProperties' in schema || 'additionalProperties' in schema));
1336
1434
  // Handle non-object schemas with type-appropriate validation
1337
1435
  if (!isObjectLikeSchema && !isSchemaObject(schema)) {
1338
- return generateNonObjectParser(typeName, schema, strict);
1436
+ return generateNonObjectParser(typeName, schema, strict, {
1437
+ useRefImports,
1438
+ suffix,
1439
+ ...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
1440
+ });
1339
1441
  }
1340
1442
  // Handle schemas with both properties AND patternProperties.
1341
1443
  // This generates a parser that handles known properties and also iterates
@@ -1362,7 +1464,11 @@ const selectParserStrategy = (schema, typeName, options) => {
1362
1464
  }
1363
1465
  // Handle non-object schemas with type-appropriate validation (no properties, no conditionals)
1364
1466
  if (!isObjectLikeSchema) {
1365
- return generateNonObjectParser(typeName, schema, strict);
1467
+ return generateNonObjectParser(typeName, schema, strict, {
1468
+ useRefImports,
1469
+ suffix,
1470
+ ...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
1471
+ });
1366
1472
  }
1367
1473
  // Handle schemas with patternProperties (but no properties)
1368
1474
  if ('patternProperties' in schema) {
@@ -1,5 +1,6 @@
1
1
  import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
3
+ import { multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
3
4
  import { hasAdditionalProperties, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasPattern, hasProperties, hasRequired, hasType, hasUniqueItems, } from '@amritk/helpers/schema-guards';
4
5
  import { generateEnumCheck } from './generate-enum-check.js';
5
6
  /**
@@ -37,6 +38,10 @@ export const generateSchemaChecks = (accessor, schema) => {
37
38
  case 'number':
38
39
  case 'integer':
39
40
  checks.push(`typeof ${accessor} === "number"`);
41
+ // `integer` must reject non-integral numbers (a bare typeof accepts `1.5`).
42
+ if (schema.type === 'integer') {
43
+ checks.push(`Number.isInteger(${accessor})`);
44
+ }
40
45
  if (hasMinimum(schema)) {
41
46
  checks.push(`${accessor} >= ${schema.minimum}`);
42
47
  }
@@ -50,7 +55,7 @@ export const generateSchemaChecks = (accessor, schema) => {
50
55
  checks.push(`${accessor} < ${schema.exclusiveMaximum}`);
51
56
  }
52
57
  if (hasMultipleOf(schema)) {
53
- checks.push(`${accessor} % ${schema.multipleOf} === 0`);
58
+ checks.push(multipleOfPassExpr(accessor, schema.multipleOf));
54
59
  }
55
60
  break;
56
61
  case 'boolean':
@@ -73,7 +78,7 @@ export const generateSchemaChecks = (accessor, schema) => {
73
78
  // Check required properties exist
74
79
  if (hasRequired(schema) && schema.required.length > 0) {
75
80
  for (const requiredKey of schema.required) {
76
- checks.push(`"${requiredKey}" in ${accessor}`);
81
+ checks.push(`${JSON.stringify(requiredKey)} in ${accessor}`);
77
82
  }
78
83
  }
79
84
  // Check minProperties
@@ -1,7 +1,8 @@
1
1
  import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
3
+ import { multipleOfFailExpr } from '@amritk/helpers/multiple-of-check';
3
4
  import { safeAccessor } from '@amritk/helpers/safe-accessor';
4
- import { hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaximum, hasMaxLength, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, isSchemaObject, } from '@amritk/helpers/schema-guards';
5
+ import { hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
5
6
  /**
6
7
  * Returns the inline condition that is true when `accessor` is the wrong type
7
8
  * for the given JSON Schema primitive type.
@@ -11,8 +12,10 @@ const wrongTypeCondition = (accessor, type) => {
11
12
  case 'string':
12
13
  return `typeof ${accessor} !== "string"`;
13
14
  case 'number':
14
- case 'integer':
15
15
  return `typeof ${accessor} !== "number"`;
16
+ case 'integer':
17
+ // `integer` also rejects non-integral numbers; a bare typeof accepts `1.5`.
18
+ return `(typeof ${accessor} !== "number" || !Number.isInteger(${accessor}))`;
16
19
  case 'boolean':
17
20
  return `typeof ${accessor} !== "boolean"`;
18
21
  case 'array':
@@ -28,6 +31,18 @@ const wrongTypeCondition = (accessor, type) => {
28
31
  * `integer` collapses to `number` since both are validated via `typeof === "number"`.
29
32
  */
30
33
  const typeLabel = (type) => (type === 'integer' ? 'number' : type);
34
+ /**
35
+ * Emits `throw new Error(<message>[ + <suffixExpr>])`. The static message may
36
+ * contain schema-controlled text (property names, patterns, enum values), so it
37
+ * is always serialized with `JSON.stringify` โ€” a key like `it's` or a pattern
38
+ * containing a quote or newline can no longer break out of the string literal or
39
+ * inject code. `suffixExpr`, when given, is a runtime expression appended to the
40
+ * message (e.g. `typeof input`).
41
+ */
42
+ const throwError = (message, suffixExpr) => {
43
+ const literal = JSON.stringify(message);
44
+ return suffixExpr ? `throw new Error(${literal} + (${suffixExpr}))` : `throw new Error(${literal})`;
45
+ };
31
46
  /**
32
47
  * Generates strict-mode constraint checks for a typed property
33
48
  * (pattern, length, min/max, multipleOf).
@@ -37,33 +52,47 @@ const generateConstraintChecks = (acc, propSchema, typeName, key) => {
37
52
  return [];
38
53
  const t = propSchema.type;
39
54
  const lines = [];
55
+ const field = `[${typeName}] field '${key}'`;
40
56
  if (t === 'string') {
41
57
  if (hasPattern(propSchema)) {
42
58
  const pattern = escapeRegexPattern(propSchema.pattern);
43
- lines.push(` if (typeof ${acc} === "string" && !/${pattern}/.test(${acc})) throw new Error('[${typeName}] field "${key}" must match pattern ${propSchema.pattern}');`);
59
+ lines.push(` if (typeof ${acc} === "string" && !/${pattern}/.test(${acc})) ${throwError(`${field} must match pattern ${propSchema.pattern}`)};`);
44
60
  }
45
61
  if (hasMinLength(propSchema)) {
46
- lines.push(` if (typeof ${acc} === "string" && ${acc}.length < ${propSchema.minLength}) throw new Error('[${typeName}] field "${key}" must have at least ${propSchema.minLength} characters');`);
62
+ lines.push(` if (typeof ${acc} === "string" && ${acc}.length < ${propSchema.minLength}) ${throwError(`${field} must have at least ${propSchema.minLength} characters`)};`);
47
63
  }
48
64
  if (hasMaxLength(propSchema)) {
49
- lines.push(` if (typeof ${acc} === "string" && ${acc}.length > ${propSchema.maxLength}) throw new Error('[${typeName}] field "${key}" must have at most ${propSchema.maxLength} characters');`);
65
+ lines.push(` if (typeof ${acc} === "string" && ${acc}.length > ${propSchema.maxLength}) ${throwError(`${field} must have at most ${propSchema.maxLength} characters`)};`);
50
66
  }
51
67
  }
52
68
  if (t === 'number' || t === 'integer') {
53
69
  if (hasMinimum(propSchema)) {
54
- lines.push(` if (typeof ${acc} === "number" && ${acc} < ${propSchema.minimum}) throw new Error('[${typeName}] field "${key}" must be >= ${propSchema.minimum}');`);
70
+ lines.push(` if (typeof ${acc} === "number" && ${acc} < ${propSchema.minimum}) ${throwError(`${field} must be >= ${propSchema.minimum}`)};`);
55
71
  }
56
72
  if (hasMaximum(propSchema)) {
57
- lines.push(` if (typeof ${acc} === "number" && ${acc} > ${propSchema.maximum}) throw new Error('[${typeName}] field "${key}" must be <= ${propSchema.maximum}');`);
73
+ lines.push(` if (typeof ${acc} === "number" && ${acc} > ${propSchema.maximum}) ${throwError(`${field} must be <= ${propSchema.maximum}`)};`);
58
74
  }
59
75
  if (hasExclusiveMinimum(propSchema)) {
60
- lines.push(` if (typeof ${acc} === "number" && ${acc} <= ${propSchema.exclusiveMinimum}) throw new Error('[${typeName}] field "${key}" must be > ${propSchema.exclusiveMinimum}');`);
76
+ lines.push(` if (typeof ${acc} === "number" && ${acc} <= ${propSchema.exclusiveMinimum}) ${throwError(`${field} must be > ${propSchema.exclusiveMinimum}`)};`);
61
77
  }
62
78
  if (hasExclusiveMaximum(propSchema)) {
63
- lines.push(` if (typeof ${acc} === "number" && ${acc} >= ${propSchema.exclusiveMaximum}) throw new Error('[${typeName}] field "${key}" must be < ${propSchema.exclusiveMaximum}');`);
79
+ lines.push(` if (typeof ${acc} === "number" && ${acc} >= ${propSchema.exclusiveMaximum}) ${throwError(`${field} must be < ${propSchema.exclusiveMaximum}`)};`);
64
80
  }
65
81
  if (hasMultipleOf(propSchema)) {
66
- lines.push(` if (typeof ${acc} === "number" && ${acc} % ${propSchema.multipleOf} !== 0) throw new Error('[${typeName}] field "${key}" must be a multiple of ${propSchema.multipleOf}');`);
82
+ lines.push(` if (typeof ${acc} === "number" && ${multipleOfFailExpr(acc, propSchema.multipleOf)}) ${throwError(`${field} must be a multiple of ${propSchema.multipleOf}`)};`);
83
+ }
84
+ }
85
+ if (t === 'array') {
86
+ if (hasMinItems(propSchema)) {
87
+ lines.push(` if (Array.isArray(${acc}) && ${acc}.length < ${propSchema.minItems}) ${throwError(`${field} must have at least ${propSchema.minItems} items`)};`);
88
+ }
89
+ if (hasMaxItems(propSchema)) {
90
+ lines.push(` if (Array.isArray(${acc}) && ${acc}.length > ${propSchema.maxItems}) ${throwError(`${field} must have at most ${propSchema.maxItems} items`)};`);
91
+ }
92
+ if (hasUniqueItems(propSchema) && propSchema.uniqueItems === true) {
93
+ // Deep-equality dedupe via JSON keys, matching the interpreter's semantics โ€”
94
+ // a plain `new Set` would only catch primitive duplicates, not equal objects.
95
+ lines.push(` if (Array.isArray(${acc}) && new Set(${acc}.map((_u) => JSON.stringify(_u))).size !== ${acc}.length) ${throwError(`${field} must NOT have duplicate items`)};`);
67
96
  }
68
97
  }
69
98
  return lines;
@@ -75,9 +104,10 @@ const generateConstraintChecks = (acc, propSchema, typeName, key) => {
75
104
  */
76
105
  const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
77
106
  const acc = safeAccessor('input', key);
107
+ const field = `[${typeName}] field '${key}'`;
78
108
  const lines = [];
79
109
  if (isRequired) {
80
- lines.push(` if (!(${JSON.stringify(key)} in input)) throw new Error('[${typeName}] missing required property "${key}"');`);
110
+ lines.push(` if (!(${JSON.stringify(key)} in input)) ${throwError(`[${typeName}] missing required property '${key}'`)};`);
81
111
  }
82
112
  if (!isSchemaObject(propSchema))
83
113
  return lines;
@@ -86,20 +116,20 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
86
116
  const instanceOf = getMjstInstanceOf(propSchema);
87
117
  if (instanceOf) {
88
118
  if (isRequired) {
89
- lines.push(` if (!(${acc} instanceof ${instanceOf})) throw new Error('[${typeName}] field "${key}" must be ${instanceOf}');`);
119
+ lines.push(` if (!(${acc} instanceof ${instanceOf})) ${throwError(`${field} must be ${instanceOf}`)};`);
90
120
  }
91
121
  else {
92
- lines.push(` if (${acc} !== undefined && !(${acc} instanceof ${instanceOf})) throw new Error('[${typeName}] field "${key}" must be ${instanceOf}');`);
122
+ lines.push(` if (${acc} !== undefined && !(${acc} instanceof ${instanceOf})) ${throwError(`${field} must be ${instanceOf}`)};`);
93
123
  }
94
124
  return lines;
95
125
  }
96
126
  const primitive = getMjstPrimitive(propSchema);
97
127
  if (primitive) {
98
128
  if (isRequired) {
99
- lines.push(` if (typeof ${acc} !== "${primitive}") throw new Error('[${typeName}] field "${key}" must be ${primitive}');`);
129
+ lines.push(` if (typeof ${acc} !== "${primitive}") ${throwError(`${field} must be ${primitive}`)};`);
100
130
  }
101
131
  else {
102
- lines.push(` if (${acc} !== undefined && typeof ${acc} !== "${primitive}") throw new Error('[${typeName}] field "${key}" must be ${primitive}');`);
132
+ lines.push(` if (${acc} !== undefined && typeof ${acc} !== "${primitive}") ${throwError(`${field} must be ${primitive}`)};`);
103
133
  }
104
134
  return lines;
105
135
  }
@@ -107,10 +137,10 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
107
137
  const allowed = JSON.stringify(propSchema.enum);
108
138
  const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
109
139
  if (isRequired) {
110
- lines.push(` if (!(${allowed} as readonly unknown[]).includes(${acc})) throw new Error('[${typeName}] field "${key}" must be one of: ${label}');`);
140
+ lines.push(` if (!(${allowed} as readonly unknown[]).includes(${acc})) ${throwError(`${field} must be one of: ${label}`)};`);
111
141
  }
112
142
  else {
113
- lines.push(` if (${acc} !== undefined && !(${allowed} as readonly unknown[]).includes(${acc})) throw new Error('[${typeName}] field "${key}" must be one of: ${label}');`);
143
+ lines.push(` if (${acc} !== undefined && !(${allowed} as readonly unknown[]).includes(${acc})) ${throwError(`${field} must be one of: ${label}`)};`);
114
144
  }
115
145
  return lines;
116
146
  }
@@ -118,11 +148,12 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
118
148
  const t = propSchema.type;
119
149
  const wrongType = wrongTypeCondition(acc, t);
120
150
  if (wrongType) {
151
+ const expected = throwError(`${field} expected ${typeLabel(t)}, got `, `typeof ${acc}`);
121
152
  if (isRequired) {
122
- lines.push(` if (${wrongType}) throw new Error(\`[${typeName}] field "${key}" expected ${typeLabel(t)}, got \${typeof ${acc}}\`);`);
153
+ lines.push(` if (${wrongType}) ${expected};`);
123
154
  }
124
155
  else {
125
- lines.push(` if (${acc} !== undefined && (${wrongType})) throw new Error(\`[${typeName}] field "${key}" expected ${typeLabel(t)}, got \${typeof ${acc}}\`);`);
156
+ lines.push(` if (${acc} !== undefined && (${wrongType})) ${expected};`);
126
157
  }
127
158
  }
128
159
  lines.push(...generateConstraintChecks(acc, propSchema, typeName, key));
@@ -142,7 +173,7 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
142
173
  */
143
174
  export const generateObjectStrictAssertion = (schema, typeName) => {
144
175
  const lines = [];
145
- lines.push(` if (!isObject(input)) throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);`);
176
+ lines.push(` if (!isObject(input)) ${throwError(`[${typeName}] expected object, got `, 'input === null ? "null" : typeof input')};`);
146
177
  if (!hasProperties(schema) || !isSchemaObject(schema))
147
178
  return lines;
148
179
  const required = new Set(hasRequired(schema) ? schema.required : []);
@@ -156,13 +187,14 @@ export const generateObjectStrictAssertion = (schema, typeName) => {
156
187
  * Returns null when the schema has no type information to assert on.
157
188
  */
158
189
  export const generateScalarStrictAssertion = (schema, typeName) => {
190
+ const got = 'input === null ? "null" : typeof input';
159
191
  const instanceOf = getMjstInstanceOf(schema);
160
192
  if (instanceOf) {
161
- return ` if (!(input instanceof ${instanceOf})) throw new Error(\`[${typeName}] expected ${instanceOf}, got \${input === null ? "null" : typeof input}\`);`;
193
+ return ` if (!(input instanceof ${instanceOf})) ${throwError(`[${typeName}] expected ${instanceOf}, got `, got)};`;
162
194
  }
163
195
  const primitive = getMjstPrimitive(schema);
164
196
  if (primitive) {
165
- return ` if (typeof input !== "${primitive}") throw new Error(\`[${typeName}] expected ${primitive}, got \${input === null ? "null" : typeof input}\`);`;
197
+ return ` if (typeof input !== "${primitive}") ${throwError(`[${typeName}] expected ${primitive}, got `, got)};`;
166
198
  }
167
199
  if (!isSchemaObject(schema) || !hasType(schema))
168
200
  return null;
@@ -170,5 +202,5 @@ export const generateScalarStrictAssertion = (schema, typeName) => {
170
202
  const wrongType = wrongTypeCondition('input', t);
171
203
  if (!wrongType)
172
204
  return null;
173
- return ` if (${wrongType}) throw new Error(\`[${typeName}] expected ${typeLabel(t)}, got \${input === null ? "null" : typeof input}\`);`;
205
+ return ` if (${wrongType}) ${throwError(`[${typeName}] expected ${typeLabel(t)}, got `, got)};`;
174
206
  };
@@ -1,5 +1,6 @@
1
1
  import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
3
+ import { multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
3
4
  import { resolveRef } from '@amritk/helpers/resolve-ref';
4
5
  import { safeAccessor } from '@amritk/helpers/safe-accessor';
5
6
  import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
@@ -33,16 +34,55 @@ export const scalarItemTypeCheck = (itemSchema, accessor) => {
33
34
  case 'string':
34
35
  return `typeof ${accessor} === "string"`;
35
36
  case 'number':
37
+ return `typeof ${accessor} === "number"`;
36
38
  case 'integer':
39
+ // `integer` items reject non-integral numbers; a bare typeof accepts `1.5`.
40
+ return `typeof ${accessor} === "number" && Number.isInteger(${accessor})`;
41
+ case 'boolean':
42
+ return `typeof ${accessor} === "boolean"`;
43
+ case 'null':
44
+ return `${accessor} === null`;
45
+ default:
46
+ return null;
47
+ }
48
+ };
49
+ /**
50
+ * A boolean type check for a single JSON Schema primitive type name. Unlike
51
+ * {@link scalarItemTypeCheck} this also covers `array`/`object` (as inline shape
52
+ * checks, so no `isObject` import is assumed) and enforces `integer` with
53
+ * `Number.isInteger` โ€” a bare `typeof === "number"` accepts `1.5`. Used to build
54
+ * the disjunction for an array-form `type` (e.g. `["string","null"]`).
55
+ */
56
+ const singleTypeCheck = (accessor, type) => {
57
+ switch (type) {
58
+ case 'string':
59
+ return `typeof ${accessor} === "string"`;
60
+ case 'number':
37
61
  return `typeof ${accessor} === "number"`;
62
+ case 'integer':
63
+ return `typeof ${accessor} === "number" && Number.isInteger(${accessor})`;
38
64
  case 'boolean':
39
65
  return `typeof ${accessor} === "boolean"`;
40
66
  case 'null':
41
67
  return `${accessor} === null`;
68
+ case 'array':
69
+ return `Array.isArray(${accessor})`;
70
+ case 'object':
71
+ return `typeof ${accessor} === "object" && ${accessor} !== null && !Array.isArray(${accessor})`;
42
72
  default:
43
73
  return null;
44
74
  }
45
75
  };
76
+ /**
77
+ * Returns the list of type names when `schema.type` is an array (the JSON Schema
78
+ * multi-type / nullable idiom, e.g. `["string","null"]`), or `null` for a single
79
+ * or absent type. Multi-type is validated as a *disjunction* of per-type checks.
80
+ */
81
+ const getTypeArray = (schema) => {
82
+ if (!isSchemaObject(schema) || !('type' in schema) || !Array.isArray(schema.type))
83
+ return null;
84
+ return schema.type;
85
+ };
46
86
  /**
47
87
  * Generates a type coercion expression for converting a value to the expected type.
48
88
  */
@@ -171,6 +211,20 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
171
211
  ? `${accessor} === ${constLiteral} ? ${accessor} : ${constLiteral}`
172
212
  : `${accessor} === ${constLiteral} ? ${accessor} : (${accessor} !== undefined ? ${constLiteral} : undefined)`;
173
213
  }
214
+ // Array-form `type` (multi-type / nullable, e.g. `["string","null"]`). `hasType`
215
+ // is false for an array `type`, so without this the value would fall through
216
+ // every branch below and emit NO validation โ€” not even a required-presence
217
+ // check. Emit a disjunction: the value is accepted when it matches ANY listed
218
+ // type, otherwise it is coerced to the default (required) or dropped (optional).
219
+ // A missing value fails every disjunct, so `required` presence is still enforced.
220
+ const typeArray = getTypeArray(schema);
221
+ if (typeArray && !hasEnum(schema)) {
222
+ const checks = typeArray.map((t) => singleTypeCheck(accessor, t)).filter((c) => c !== null);
223
+ if (checks.length > 0) {
224
+ const combined = checks.map((c) => `(${c})`).join(' || ');
225
+ return isRequired ? `${combined} ? ${accessor} : ${defaultValue}` : `${combined} ? ${accessor} : undefined`;
226
+ }
227
+ }
174
228
  if (!hasType(schema) && !hasEnum(schema)) {
175
229
  return `${accessor} ?? ${defaultValue}`;
176
230
  }
@@ -207,6 +261,11 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
207
261
  case 'number':
208
262
  case 'integer': {
209
263
  checks.push(`typeof ${accessor} === "number"`);
264
+ // `integer` must reject non-integral numbers; a bare `typeof === "number"`
265
+ // accepts `1.5`. Matches the validators package (which uses Number.isInteger).
266
+ if (schema.type === 'integer') {
267
+ checks.push(`Number.isInteger(${accessor})`);
268
+ }
210
269
  if (hasMinimum(schema)) {
211
270
  checks.push(`${accessor} >= ${schema.minimum}`);
212
271
  }
@@ -220,7 +279,7 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
220
279
  checks.push(`${accessor} < ${schema.exclusiveMaximum}`);
221
280
  }
222
281
  if (hasMultipleOf(schema)) {
223
- checks.push(`${accessor} % ${schema.multipleOf} === 0`);
282
+ checks.push(multipleOfPassExpr(accessor, schema.multipleOf));
224
283
  }
225
284
  break;
226
285
  }
@@ -248,7 +307,7 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
248
307
  // Check required properties exist
249
308
  if (hasRequired(schema) && schema.required.length > 0) {
250
309
  for (const requiredKey of schema.required) {
251
- checks.push(`"${requiredKey}" in ${accessor}`);
310
+ checks.push(`${JSON.stringify(requiredKey)} in ${accessor}`);
252
311
  }
253
312
  }
254
313
  // Check minProperties
@@ -11,7 +11,9 @@ const EMBEDDED_NAMED_EXPORTS = {
11
11
  'validate-record': 'validateRecord',
12
12
  'has-ref': 'hasRef',
13
13
  };
14
- const embeddedImport = (helper, prefix) => `import { ${EMBEDDED_NAMED_EXPORTS[helper]} } from '${prefix}_helpers/${helper}';`;
14
+ const embeddedImport = (helper, prefix) =>
15
+ // `.js` extension so the embedded-helper import resolves under Node ESM, not only Bun.
16
+ `import { ${EMBEDDED_NAMED_EXPORTS[helper]} } from '${prefix}_helpers/${helper}.js';`;
15
17
  /**
16
18
  * Detects which runtime helpers a generated parser body references.
17
19
  *
@@ -2,7 +2,11 @@ import { refToFilename } from '@amritk/helpers/ref-to-filename';
2
2
  import { refToName } from '@amritk/helpers/ref-to-name';
3
3
  import { resolveRef } from '@amritk/helpers/resolve-ref';
4
4
  import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasItems, hasOneOf, hasRef } from '@amritk/helpers/schema-guards';
5
- const getImportPathForFilename = (filename) => `./${filename}`;
5
+ // Emit an explicit `.js` extension on the relative specifier. Node's ESM resolver
6
+ // requires it (extensionless relative imports only work under a bundler or Bun),
7
+ // and `./x.js` pointing at a sibling `x.ts` is the standard TS NodeNext form โ€”
8
+ // accepted by Bun, esbuild, webpack, and tsc alike.
9
+ const getImportPathForFilename = (filename) => `./${filename}.js`;
6
10
  /**
7
11
  * Collects all import statements needed for a schema by finding $ref references
8
12
  * that are directly used in the type definition (from properties, items, etc.).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-parsers",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -48,8 +48,8 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "json-schema-typed": "^8.0.1",
51
- "@amritk/helpers": "0.10.1",
52
- "@amritk/generate-markdown": "0.3.0"
51
+ "@amritk/helpers": "0.10.2",
52
+ "@amritk/generate-markdown": "0.4.1"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@sinclair/typebox": "^0.34.49",