@amritk/validation 0.3.2 → 0.4.1

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
@@ -44,14 +44,16 @@ Every mode is reachable from `mjst` without dropping to the programmatic API:
44
44
  |:---|:---|
45
45
  | `types` | `--types-only` (alone), otherwise always emitted |
46
46
  | `guard`, `validate` | `--validators`, or `--validators-only` for no parser |
47
- | `check` | `--check` |
48
- | `coerce` | `--coerce` |
49
- | `repair` | `--repair` (emits `coerceX` too) |
47
+ | `check` | `--validators --check` |
48
+ | `coerce` | `--validators --coerce` |
49
+ | `repair` | `--validators --repair` (emits `coerceX` too) |
50
50
  | `parse` | the default on any run that is not `--types-only`/`--validators-only` |
51
51
  | `parseStrict` | `--strict` |
52
52
 
53
53
  `--validators-only` is this package's own default (`['types', 'guard', 'validate']`)
54
54
  spelled as a flag: everything that judges a document, nothing that builds one.
55
+ `--check`, `--coerce` and `--repair` shape the validator half, so the CLI rejects
56
+ any of them without one of those two flags.
55
57
 
56
58
  ## Gotchas — where agents fail
57
59
 
@@ -83,10 +85,26 @@ spelled as a flag: everything that judges a document, nothing that builds one.
83
85
  validateX(v)` pays for both passes and is no faster than `validateX`.
84
86
 
85
87
  8. **It emits each engine's exact bytes.** A single-mode build is byte-identical
86
- to what that engine emitted when it was its own package, so there is no runtime
87
- difference to reason about and no speedup to claim — do not tell a user this is
88
- faster. What it changes is that the whole matrix comes out as one type instead
89
- of two, and that a build asking for no validator mode ships no
88
+ to what the engine that owns that mode emits when called directly, so there is
89
+ no runtime difference to reason about and no speedup to claim — do not tell a
90
+ user this is faster. What it changes is that the whole matrix comes out as one
91
+ type instead of two, and that a build asking for no validator mode ships no
90
92
  `validation-result.ts`.
91
93
 
94
+ 9. **`coerceX` is not Ajv's `coerceTypes`, on purpose.** It never modifies its
95
+ input (no defensive clone needed), never coerces `null` in either direction,
96
+ and reads only clean numerals as numbers (`" "`, `"0x10"`, `"Infinity"` are
97
+ rejected). It coerces inside `anyOf`/`oneOf`/`allOf`/`if`: a union branch the
98
+ value already matches wins uncoerced (so `false` stays `false` under
99
+ `anyOf: [string, { const: false }]`, where Ajv answers `"false"`), and two
100
+ branches that would coerce to different values leave it as written. Do not
101
+ tell a user a value will come out the way Ajv would produce it inside a union.
102
+ 10. **The coercing `parseX` returns exactly what `coerceX` returns** whenever
103
+ `coerceX` accepts the document, and only repairs what `coerceX` would reject.
104
+ A parser file for a definition with `anyOf`/`oneOf`/`allOf`/`if`/`not` (or one
105
+ such a definition reaches) therefore exports `matchesX` and `coerceXInput`
106
+ too, and its repairing parser is private (`_parseXRepair`, kept out of the
107
+ barrel — a leading `_` marks a name for sibling files only). Strict and
108
+ `stripUnknown` parsers are unchanged.
109
+
92
110
  Only the `.` entry. Install: `bun add @amritk/validation`.
package/README.md CHANGED
@@ -74,6 +74,7 @@ so no two can disagree about whether a document is valid.
74
74
  | `types` | — | — | — | — |
75
75
  | `guard` | `isX(input): input is X` | — | a boolean | the first problem |
76
76
  | `validate` | `validateX(input)` | — | every error | the end |
77
+ | `check` | `checkX(input)` | — | the first error | the first problem |
77
78
  | `coerce` | `coerceX(input)` | coerced | every error | the end |
78
79
  | `repair` | `repairX(input)` | repaired | repairs **and** errors | the end |
79
80
  | `parse` | `parseX(input)` | repaired | nothing | never fails |
@@ -84,7 +85,9 @@ it stops at the first thing wrong and builds no error object, which on invalid
84
85
  input is dramatically cheaper than reporting. `validate` is the other half: every
85
86
  error, each with a JSON Pointer, the keyword that rejected the value, and that
86
87
  keyword's own values. Reach for `guard` when the answer is a branch and
87
- `validate` when a human or an API client has to be told what to fix.
88
+ `validate` when a human or an API client has to be told what to fix. `check` sits
89
+ between them: `checkX` returns the same result type as `validateX`, holding only
90
+ the error `validateX` would have reported first, and walks no further.
88
91
 
89
92
  **Coerce and repair, with or without the errors.** `coerceX` and `repairX` hand
90
93
  back the value *and* the diagnostics; `parseX` hands back only the value and
@@ -92,9 +95,103 @@ never fails. Coercion moves a value that is already right but written in the
92
95
  wrong type; repair also substitutes a value the schema supplies for one that
93
96
  cannot be coerced, and reports the validator's own errors as the repairs.
94
97
 
98
+ That makes `repairX` an autofix tool rather than a stricter validator. It accepts
99
+ documents `validateX` rejects (a missing required name gets one; an unknown
100
+ `enum` value becomes the first member), and accepting only `valid` with an empty
101
+ `repairs` gives exactly the verdicts `coerceX` gives. An empty `repairs` alone is
102
+ not enough: a document nothing could repair comes back with none either, and
103
+ `valid: false`. To reject a bad config, use `coerceX`. To hand back a fixed one,
104
+ use `repairX`.
105
+
106
+ **`parseX` agrees with `coerceX`.** Wherever `coerceX` accepts a document, the
107
+ coercing `parseX` returns the very same value — through a union, an `allOf`, an
108
+ `if`, a `$ref`, recursion included — and it only repairs what `coerceX` would
109
+ have rejected. A differential test pins that over random schemas. To get there,
110
+ a definition whose own tree has an `anyOf`/`oneOf`/`allOf`/`if`/`not` carries an
111
+ exact test (`matchesX`) and the coercion walk (`coerceXInput`) in front of its
112
+ repairing parser, and every definition such a one reaches carries them too. A
113
+ schema without combinators emits exactly what it always did; one with them
114
+ emits more — about twice the parser code on a large OpenAPI document — which is
115
+ what a second, exact opinion on each definition costs. A `strict` or
116
+ `stripUnknown` parser has no `coerceX` to agree with and is unchanged.
117
+
95
118
  `parse` and `parseStrict` are the same function name under two contracts, so
96
119
  asking for both is an error rather than a silent choice.
97
120
 
121
+ ### Moving off Ajv
122
+
123
+ `coerceX` is built to replace Ajv compiled with `{ allErrors: true,
124
+ coerceTypes: true }`, and it is better on each count: the answer, the speed and
125
+ the safety of the input.
126
+
127
+ **It accepts only what the schema accepts.** Anything `coerceX` hands back as
128
+ valid, `validateX` has just accepted, and a differential fuzz over random
129
+ schemas with `anyOf`, `oneOf`, `allOf` and `if`/`then`/`else` checks every
130
+ accepted document against an Ajv that does not coerce. Where the two differ, it
131
+ is on purpose:
132
+
133
+ - **`null` is never coerced**, in either direction, and it is the difference
134
+ you will meet most: on a real config corpus it was nearly every case where Ajv
135
+ accepted and `coerceX` did not. Ajv reads `null` as `""`, `0` or `false` for a
136
+ string, number or boolean field, and reads those back as `null`. A `null`
137
+ usually means "not set", and turning it into a real value erases that. Here it
138
+ goes to the validator as written, and the validator rejects it.
139
+ - **Only clean numerals become numbers.** `" "`, `" 1 "`, `"0x10"`, `"Infinity"`
140
+ and `"1."` are rejected. Ajv turns them into `0`, `1`, `16`, a value JSON
141
+ cannot hold, and `1`. `"007"` and `"1e3"` are still coerced.
142
+ - **At a union, a branch the value already matches wins.** Ajv coerces into the
143
+ first branch that will take the value. Given `anyOf: [{ type: 'string' }, {
144
+ const: false }]`, Ajv turns `false` into `"false"`; `coerceX` keeps `false`.
145
+ When no branch matches as written, each branch coerces the value its own way
146
+ and the result is taken only if every branch that then accepts it agrees.
147
+ Two different readings leave the value alone for the validator to report, so
148
+ the order the branches were written in never changes the answer.
149
+ - **A branch that fails leaves nothing behind.** Ajv coerces in place while it
150
+ tries each branch, so under `oneOf: [{ type: 'integer' }, { type: 'string' }]`
151
+ it turns `0` into `"0"` while trying the string branch, sees two matches, and
152
+ rejects a valid document. `coerceX` accepts `0` as written.
153
+ - **Coercion reaches every applicator.** Scalars are coerced through `$ref`,
154
+ `allOf` (each subschema in turn), `if`/`then`/`else` (toward `then` when the
155
+ value matches `if`, `else` otherwise), and union branches, nested to any depth.
156
+
157
+ **It never touches the input.** Ajv coerces in place, so a caller that keeps its
158
+ document has to clone it first. `coerceX` returns a new value that shares
159
+ everything it did not touch, or the input itself when nothing needed coercing.
160
+
161
+ **It is faster where the input has to survive.** Against Ajv cloning first, so
162
+ both leave the caller's document alone (`bun run bench:validators:coerce`, or
163
+ `bench:validators:coerce:node`):
164
+
165
+ | schema | runtime | valid input | needs coercing | cannot be coerced |
166
+ |:--|:--|--:|--:|--:|
167
+ | small (4 fields) | Bun | 5.4× | 1.7× | 1.5× |
168
+ | small (4 fields) | Node | 9.9× | 2.1× | 2.2× |
169
+ | order (nested + array) | Bun | 11× | 1.6× | 1.9× |
170
+ | order (nested + array) | Node | 8.4× | 1.8× | 2.2× |
171
+ | assert-loose | Bun | 19× | 1.4× | 3.0× |
172
+ | assert-loose | Node | 36× | 1.6× | 3.1× |
173
+ | config (unions via `$ref`) | Bun | 7.2× | 1.7× | 3.7× |
174
+ | config (unions via `$ref`) | Node | 5.2× | 1.5× | 4.6× |
175
+
176
+ <sub>Each cell is how many times faster `coerceX` is than Ajv on the same input,
177
+ each engine timed in its own process (Bun 1.3.11, Node 22.22, Linux x64). A
178
+ valid document is the common case and the widest gap: where `isX` is a
179
+ standalone guard, `coerceX` answers it with that guard and hands the input
180
+ straight back, without walking it. On valid input Ajv rewrites nothing, so it can
181
+ also be timed without the clone; `coerceX` is still ahead there on these
182
+ schemas, by 1.2–3.3×, but not on every one: on a large, union-heavy document
183
+ that the caller lets Ajv coerce in place, Ajv can come out modestly ahead. The
184
+ gap above is the clone, so it is real exactly where the caller cannot let Ajv
185
+ mutate its input.
186
+ The config case is the shape `--coerce` exists for: every coercible scalar sits
187
+ inside a union reached through `$ref`, and every union branch is tested by a
188
+ named function rather than a closure built per call.</sub>
189
+
190
+ The errors differ in one way worth knowing if you key diagnostics on
191
+ `path + keyword + params`: an `additionalProperties` error points **at the
192
+ undeclared key** (`/readme/example_requests`) where Ajv points at the object
193
+ holding it (`/readme`). `params.additionalProperty` names the key in both.
194
+
98
195
  ---
99
196
 
100
197
  ## API
@@ -115,10 +212,17 @@ asking for both is an error rather than a silent choice.
115
212
  | `options.readonly` | `boolean` | `false` | Emit `readonly` type members. |
116
213
  | `options.caseInsensitive` | `boolean` | `false` | Normalize a mis-cased string onto an `enum` member it matches. |
117
214
  | `options.helpersMode` | `'package' \| 'embedded'` | `'package'` | Where the parser half gets its runtime helpers. |
215
+ | `options.helpersImportPrefix` | `string` | `'./'` | Prefix on emitted-helper import specifiers, when `helpersMode` is `'embedded'`. |
118
216
  | `options.extensions` | `SchemaExtensions` | — | Schema extensions, passed through to the parser half. |
217
+ | `options.importExt` | `'js' \| 'ts'` | `'js'` | Extension on every emitted relative import. `'ts'` emits the on-disk paths, so the output runs under Node's type stripping with no build step. |
218
+ | `options.logWarnings` | `boolean` | `false` | Print the parser half's warnings about the schema, such as a keyword it cannot enforce. |
119
219
 
120
220
  Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; content: string }`.
121
221
 
222
+ The package also exports `ALL_MODES` (every mode but `parseStrict`, for a caller
223
+ who wants the lot) and the types `Mode`, `GenerateOptions`, `GeneratedFile` and
224
+ `ImportExtension`.
225
+
122
226
  ---
123
227
 
124
228
  ## Output layout
@@ -127,6 +231,8 @@ Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; c
127
231
  config.ts the type, plus isX / validateX / coerceX / repairX
128
232
  config.parse.ts parseX, importing the type from ./config.js
129
233
  validation-result.ts the shared error and result types, and the runtime helpers
234
+ formats.ts the format checks, only when `formats` enforces one the schema names
235
+ _helpers/ the parser's runtime helpers, only when `helpersMode` is 'embedded'
130
236
  index.ts a barrel over all of it
131
237
  ```
132
238
 
@@ -146,7 +252,8 @@ could import its 17 KiB of error types and runtime helpers.
146
252
 
147
253
  ## Is it faster?
148
254
 
149
- No, and it should not be. Ask this package for one mode and it emits **the exact
255
+ Than the two engines it wraps? No, and it should not be. (Than Ajv, yes — see
256
+ [Moving off Ajv](#moving-off-ajv).) Ask this package for one mode and it emits **the exact
150
257
  bytes** the engine that owns that mode emits — which the test suite pins per
151
258
  mode, by fingerprint, not by reading the output. Identical code cannot
152
259
  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
@@ -17,9 +17,8 @@ import { generateBackstopAssertion, generateCompositionChecks, generateContainsC
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;
@@ -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) => {