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