@amritk/generate-parsers 0.16.3 → 0.17.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 ADDED
@@ -0,0 +1,36 @@
1
+ # @amritk/generate-parsers — notes for AI coding agents
2
+
3
+ Programmatic API: turn a JSON Schema (Draft 2020-12) into TypeScript type
4
+ definitions plus optional runtime parser functions. Full reference is
5
+ [README.md](./README.md). (Most users want the [`mjst` CLI](../cli) instead.)
6
+
7
+ > Pre-alpha: APIs and generated output change pre-1.0.
8
+
9
+ ## Minimal example
10
+
11
+ ```ts
12
+ import { buildSchema } from '@amritk/generate-parsers'
13
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
14
+
15
+ const schema: JSONSchema = { type: 'object', properties: { title: { type: 'string' } } }
16
+
17
+ const files = await buildSchema(schema, 'Document')
18
+ // files → [{ filename: 'document.ts', content: '…' }, { filename: 'index.ts', content: '…' }, …]
19
+ ```
20
+
21
+ ## Gotchas — where agents fail
22
+
23
+ 1. **`buildSchema` is `async` and takes POSITIONAL args, no options object.** The
24
+ full signature is
25
+ `buildSchema(rootSchema, rootTypeName, extensions?, typesOnly?, logWarnings?, strict?, helpersMode?, helpersImportPrefix?, readonly?, stripUnknown?, typeSuffix?, importExt?, caseInsensitive?)`.
26
+ To set a later flag you must pass every intervening positional. (The README's
27
+ short form omits the trailing seven.)
28
+ 2. **It returns files in memory — it does NOT write to disk.** You write the
29
+ `GeneratedFile[]` (`{ filename, content }`) yourself.
30
+ 3. **Output is more than one file per `$def`:** always an `index.ts` barrel, and
31
+ (unless `typesOnly`) runtime helper files.
32
+ 4. **Default parsers COERCE invalid input to defaults** rather than throwing;
33
+ pass `strict: true` (the 6th positional) to make them throw.
34
+
35
+ Only the `.` entry (`buildSchema`, `GeneratedFile`, `ImportExtension`).
36
+ Install: `bun add @amritk/generate-parsers`.
@@ -1,74 +1,47 @@
1
- import { isSchemaObject } from '@amritk/helpers/schema-guards';
2
- import { canMatchSubschema } from './subschema-match.js';
3
- /**
4
- * Throws when a schema (anywhere in its subtree) uses a keyword the *strict*
5
- * parser generator cannot enforce but which narrows the set of valid documents.
6
- * Mirrors generate-validators' `assertNoUnsupportedKeywords`: failing loudly at
7
- * generation time is strictly better than silently emitting a parser that
8
- * accepts input the schema forbids — a strict parser promises to "throw on
9
- * violations".
10
- *
11
- * Two families are rejected:
12
- *
13
- * - `unevaluatedProperties` / `unevaluatedItems` with a constraining value
14
- * (`false` or a subschema). The generator has no support for the "which
15
- * properties/items were evaluated" bookkeeping these require; only the
16
- * runtime interpreter does. A `true` value permits everything and is allowed.
17
- *
18
- * - `contains`, `propertyNames`, or `dependentSchemas` whose subschema uses a
19
- * form {@link canMatchSubschema} cannot prove inline (a `$ref`, a combinator,
20
- * a schema-valued record, …). The parser enforces these keywords only for
21
- * subschemas it can match exactly; anything else would be silently ignored.
22
- *
23
- * This runs for strict generation only — coercing parsers are documented to be
24
- * permissive (they repair rather than reject), so ignoring these keywords there
25
- * is the contract, not a lie.
26
- */
27
- export const assertNoUnsupportedKeywords = (schema, typeName) => {
28
- const fail = (keyword, detail) => {
29
- throw new Error(`[${typeName}] unsupported keyword "${keyword}": the strict parser generator ${detail}, and would ` +
30
- `silently accept documents the schema rejects. Generate a coercing (non-strict) parser, validate this ` +
31
- `schema with @amritk/generate-validators, or remove the keyword.`);
32
- };
33
- const visit = (node) => {
34
- if (typeof node !== 'object' || node === null)
35
- return;
36
- if (Array.isArray(node)) {
37
- for (const item of node)
38
- visit(item);
39
- return;
1
+ import { isSchemaObject } from "@amritk/helpers/schema-guards";
2
+ import { canMatchSubschema } from "./subschema-match.js";
3
+ const assertNoUnsupportedKeywords = (schema, typeName) => {
4
+ const fail = (keyword, detail) => {
5
+ throw new Error(`[${typeName}] unsupported keyword "${keyword}": the strict parser generator ${detail}, and would silently accept documents the schema rejects. Generate a coercing (non-strict) parser, validate this schema with @amritk/generate-validators, or remove the keyword.`);
6
+ };
7
+ const visit = (node) => {
8
+ if (typeof node !== "object" || node === null)
9
+ return;
10
+ if (Array.isArray(node)) {
11
+ for (const item of node)
12
+ visit(item);
13
+ return;
14
+ }
15
+ const record = node;
16
+ if ("unevaluatedProperties" in record && record["unevaluatedProperties"] !== true) {
17
+ fail("unevaluatedProperties", "does not implement it");
18
+ }
19
+ if ("unevaluatedItems" in record && record["unevaluatedItems"] !== true) {
20
+ fail("unevaluatedItems", "does not implement it");
21
+ }
22
+ if ("contains" in record && !canMatchSubschema(record["contains"])) {
23
+ fail("contains", "cannot prove its subschema inline");
24
+ }
25
+ if ("propertyNames" in record && isSchemaObject(record["propertyNames"])) {
26
+ if (!canMatchSubschema(record["propertyNames"])) {
27
+ fail("propertyNames", "cannot prove its subschema inline");
28
+ }
29
+ }
30
+ const dependentSchemas = record["dependentSchemas"];
31
+ if (typeof dependentSchemas === "object" && dependentSchemas !== null && !Array.isArray(dependentSchemas)) {
32
+ for (const sub of Object.values(dependentSchemas)) {
33
+ if (typeof sub === "boolean")
34
+ continue;
35
+ if (!canMatchSubschema(sub)) {
36
+ fail("dependentSchemas", "cannot prove one of its subschemas inline");
40
37
  }
41
- const record = node;
42
- // `true` permits everything → no constraint → safe to ignore.
43
- if ('unevaluatedProperties' in record && record['unevaluatedProperties'] !== true) {
44
- fail('unevaluatedProperties', 'does not implement it');
45
- }
46
- if ('unevaluatedItems' in record && record['unevaluatedItems'] !== true) {
47
- fail('unevaluatedItems', 'does not implement it');
48
- }
49
- if ('contains' in record && !canMatchSubschema(record['contains'])) {
50
- fail('contains', 'cannot prove its subschema inline');
51
- }
52
- if ('propertyNames' in record && isSchemaObject(record['propertyNames'])) {
53
- if (!canMatchSubschema(record['propertyNames'])) {
54
- fail('propertyNames', 'cannot prove its subschema inline');
55
- }
56
- }
57
- const dependentSchemas = record['dependentSchemas'];
58
- if (typeof dependentSchemas === 'object' && dependentSchemas !== null && !Array.isArray(dependentSchemas)) {
59
- for (const sub of Object.values(dependentSchemas)) {
60
- // Boolean subschemas are enforced directly (see generateDependentSchemasChecks).
61
- if (typeof sub === 'boolean')
62
- continue;
63
- if (!canMatchSubschema(sub)) {
64
- fail('dependentSchemas', 'cannot prove one of its subschemas inline');
65
- }
66
- }
67
- }
68
- // `for..in` (not `Object.values`) to recurse without allocating an array per
69
- // node — this walk runs for every strict parser generated.
70
- for (const key in record)
71
- visit(record[key]);
72
- };
73
- visit(schema);
38
+ }
39
+ }
40
+ for (const key in record)
41
+ visit(record[key]);
42
+ };
43
+ visit(schema);
44
+ };
45
+ export {
46
+ assertNoUnsupportedKeywords
74
47
  };
@@ -1,174 +1,62 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { createRequire } from 'node:module';
3
- import { dirname, resolve as resolvePath } from 'node:path';
4
- import { generateIndexBarrel } from '@amritk/helpers/generate-index-barrel';
5
- import { walkRefGraph } from '@amritk/helpers/walk-ref-graph';
6
- import { applySchemaExtensions } from '../helpers/apply-schema-extensions.js';
7
- import { generateFile } from './generate-files.js';
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
- */
1
+ import { readFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, resolve as resolvePath } from "node:path";
4
+ import { generateIndexBarrel } from "@amritk/helpers/generate-index-barrel";
5
+ import { walkRefGraph } from "@amritk/helpers/walk-ref-graph";
6
+ import { applySchemaExtensions } from "../helpers/apply-schema-extensions.js";
7
+ import { generateFile } from "./generate-files.js";
19
8
  const readHelperSource = async (helper, importExt) => {
20
- const require = createRequire(import.meta.url);
21
- const helpersRoot = dirname(require.resolve('@amritk/helpers/package.json'));
22
- try {
23
- const source = await readFile(resolvePath(helpersRoot, 'src', `${helper}.ts`), 'utf-8');
24
- // The leading `\b` in this pattern is load-bearing beyond its regex meaning.
25
- // Our build runs tsc-alias --resolveFullPaths over the compiled output, and its
26
- // scanner rewrites anything that looks like `from '<path>'` — even inside a
27
- // regex literal. Without the `\b`, normalize-path turned this pattern's
28
- // backslashes into slashes and v0.12.3 shipped with an unparseable regex that
29
- // crashed the CLI on load. The `\b` blocks that scan (in the emitted text,
30
- // `from` is preceded by the word character `b`, so tsc-alias's own `\bfrom`
31
- // never matches) and is also semantically correct: an import keyword always
32
- // sits at a word boundary. The dist smoke test (scripts/dist-smoke.test.ts)
33
- // fails the build if this ever regresses.
34
- return { ext: 'ts', content: source.replace(/\bfrom '(\.\/[^'".]+)'/g, `from '$1.${importExt}'`) };
35
- }
36
- catch {
37
- const compiled = await readFile(resolvePath(helpersRoot, 'dist', `${helper}.js`), 'utf-8');
38
- // With `.ts` import specifiers the on-disk file must literally be `.ts`, so
39
- // ship the compiled JS (valid TS) under that name and retarget its own
40
- // sibling imports to match. The pattern keeps the same `\bfrom` shape as
41
- // above so tsc-alias's scanner can't corrupt it (see the comment there).
42
- if (importExt === 'ts') {
43
- return { ext: 'ts', content: compiled.replace(/\bfrom '(\.\/[^'"]+)\.js'/g, "from '$1.ts'") };
44
- }
45
- return { ext: 'js', content: compiled };
9
+ const require2 = createRequire(import.meta.url);
10
+ const helpersRoot = dirname(require2.resolve("@amritk/helpers/package.json"));
11
+ try {
12
+ const source = await readFile(resolvePath(helpersRoot, "src", `${helper}.ts`), "utf-8");
13
+ return { ext: "ts", content: source.replace(/\bfrom '(\.\/[^'".]+)'/g, `from '$1.${importExt}'`) };
14
+ } catch {
15
+ const compiled = await readFile(resolvePath(helpersRoot, "dist", `${helper}.js`), "utf-8");
16
+ if (importExt === "ts") {
17
+ return { ext: "ts", content: compiled.replace(/\bfrom '(\.\/[^'"]+)\.js'/g, "from '$1.ts'") };
46
18
  }
19
+ return { ext: "js", content: compiled };
20
+ }
47
21
  };
48
- /**
49
- * Builds all TypeScript files from a JSON Schema by traversing its entire
50
- * `$ref` / `$dynamicRef` graph (via the shared `@amritk/helpers/walk-ref-graph`
51
- * walker, which also seeds `$dynamicAnchor` definitions and rewrites
52
- * `$dynamicRef` to `$ref`).
53
- *
54
- * For the root schema and each reachable definition this function generates a
55
- * TypeScript file, then emits an `index.ts` barrel re-exporting them all.
56
- *
57
- * @param rootSchema - The root JSON Schema to build from
58
- * @param rootTypeName - The name for the root type (e.g., "Document")
59
- * @param extensions - Optional map of custom extension properties to add to specific definitions.
60
- * Keys are definition names (matching $defs keys), values are records of extension property
61
- * names to their JSON Schema definitions. Extensions are merged as optional properties before
62
- * type and parser generation.
63
- * @param typesOnly - When true, only generate TypeScript type definitions without parser functions.
64
- * @param logWarnings - When true, the generated parsers emit a console.warn for every input key
65
- * that is not declared in the schema's properties.
66
- * @param strict - When true, the generated parsers throw on type/shape mismatches
67
- * (wrong type, missing required property, enum/pattern/min/max violations) instead
68
- * of coercing invalid input to default values.
69
- * @param helpersMode - `'package'` (default) emits `import ... from '@amritk/helpers/...'`.
70
- * `'embedded'` emits `import ... from './_helpers/...'` and appends the helper sources
71
- * as additional `GeneratedFile` entries so the output directory is self-contained.
72
- * @param helpersImportPrefix - Relative path prefix to the `_helpers/` directory in
73
- * embedded mode. Defaults to `'./'`. The recursive multi-schema build passes `'../'`,
74
- * `'../../'`, etc. so nested parsers can import from a single shared `_helpers/`
75
- * directory while the helper sources are emitted once at the output root.
76
- * @param readonly - When true, every property, array, and record in the generated type
77
- * definitions is emitted as `readonly`.
78
- * @param stripUnknown - When true, the generated parsers build their result from declared
79
- * properties only, silently dropping undeclared input keys at every nesting level (zod's
80
- * `.strip()`) without treating extras as a validation error. Composes with `strict` and
81
- * yields to `additionalProperties: false` (which rejects rather than strips in strict mode).
82
- * @param typeSuffix - Suffix appended to every type/parser name derived from a `$ref`
83
- * (e.g. `'Object'` → `ContactObject`). Defaults to `''` (no suffix). The root type name
84
- * is used verbatim and is not affected by this suffix.
85
- * @param importExt - Extension used on every relative import specifier in the generated
86
- * output (cross-file `$ref` imports, the index barrel, and embedded-helper imports).
87
- * `'js'` (this function's default) is the standard TS NodeNext form; `'ts'` emits the
88
- * literal on-disk paths so the generated sources run directly under Node's type
89
- * stripping. Note the `mjst` CLI defaults this to `'ts'` (falling back to `'js'` only
90
- * under `--build`, where tsc must compile the sources) so generated output runs under
91
- * Node without a build step; direct callers of `buildSchema` opt in explicitly.
92
- * @param caseInsensitive - When true, the generated coercing parsers normalize a mis-cased
93
- * string to the exact casing of a declared `enum`/`const` member it matches
94
- * case-insensitively (e.g. `hElLo` → `hello`) instead of coercing to the default. Coerce
95
- * mode only — strict parsers still reject a casing mismatch. The normalization lives on
96
- * the coercion failure branch, so correctly-cased input keeps the exact-match fast path
97
- * and the hot path is unaffected.
98
- * @returns An array of generated TypeScript files
99
- *
100
- * @example
101
- * ```typescript
102
- * const schema = {
103
- * type: "object",
104
- * properties: {
105
- * info: { $ref: "#/$defs/info" }
106
- * },
107
- * $defs: {
108
- * info: {
109
- * type: "object",
110
- * properties: {
111
- * title: { type: "string" }
112
- * }
113
- * }
114
- * }
115
- * };
116
- *
117
- * const files = buildSchema(schema, "Document");
118
- *
119
- * // Types-only mode — no parser functions or runtime helpers included:
120
- * const typesFiles = buildSchema(schema, "Document", undefined, true);
121
- *
122
- * // With extensions:
123
- * const filesWithExtensions = buildSchema(schema, "Document", {
124
- * info: {
125
- * 'x-internal': { type: 'boolean' },
126
- * },
127
- * });
128
- * ```
129
- */
130
- export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '', importExt = 'js', caseInsensitive = false) => {
131
- const files = [];
132
- const usedHelpers = new Set();
133
- walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
134
- // `index` is reserved for the barrel below, so never let a definition of
135
- // that name overwrite it.
136
- if (node.filename === 'index')
137
- return;
138
- // Extensions are keyed by definition name, which is the node's filename for
139
- // both the root (the lowercased root type name) and every `$ref` target.
140
- const extended = extensions ? applySchemaExtensions(node.schema, node.filename, extensions) : node.schema;
141
- const result = generateFile(extended, node.typeName, {
142
- typesOnly: typesOnly ?? false,
143
- rootSchema: node.rootSchema,
144
- helpersMode,
145
- helpersImportPrefix,
146
- readonly,
147
- typeSuffix,
148
- importExt,
149
- isRoot: node.isRoot,
150
- ...(node.ref !== undefined ? { selfRef: node.ref } : {}),
151
- ...(logWarnings !== undefined ? { logWarnings } : {}),
152
- ...(strict !== undefined ? { strict } : {}),
153
- ...(stripUnknown ? { stripUnknown } : {}),
154
- ...(caseInsensitive ? { caseInsensitive } : {}),
155
- });
156
- files.push({ filename: `${node.filename}.ts`, content: result.content });
157
- for (const helper of result.usedHelpers)
158
- usedHelpers.add(helper);
22
+ const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = "package", helpersImportPrefix = "./", readonly = false, stripUnknown = false, typeSuffix = "", importExt = "js", caseInsensitive = false) => {
23
+ const files = [];
24
+ const usedHelpers = /* @__PURE__ */ new Set();
25
+ walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
26
+ if (node.filename === "index")
27
+ return;
28
+ const extended = extensions ? applySchemaExtensions(node.schema, node.filename, extensions) : node.schema;
29
+ const result = generateFile(extended, node.typeName, {
30
+ typesOnly: typesOnly ?? false,
31
+ rootSchema: node.rootSchema,
32
+ helpersMode,
33
+ helpersImportPrefix,
34
+ readonly,
35
+ typeSuffix,
36
+ importExt,
37
+ isRoot: node.isRoot,
38
+ ...node.ref !== void 0 ? { selfRef: node.ref } : {},
39
+ ...logWarnings !== void 0 ? { logWarnings } : {},
40
+ ...strict !== void 0 ? { strict } : {},
41
+ ...stripUnknown ? { stripUnknown } : {},
42
+ ...caseInsensitive ? { caseInsensitive } : {}
159
43
  });
160
- // In embedded mode, ship the runtime helper source files alongside the parsers so
161
- // the output directory is self-contained (no `@amritk/helpers` install required).
162
- // typesOnly skips parser generation entirely, so no runtime helpers are needed.
163
- if (helpersMode === 'embedded' && !typesOnly) {
164
- for (const helper of usedHelpers) {
165
- const { ext, content } = await readHelperSource(helper, importExt);
166
- files.push({ filename: `_helpers/${helper}.${ext}`, content });
167
- }
44
+ files.push({ filename: `${node.filename}.ts`, content: result.content });
45
+ for (const helper of result.usedHelpers)
46
+ usedHelpers.add(helper);
47
+ });
48
+ if (helpersMode === "embedded" && !typesOnly) {
49
+ for (const helper of usedHelpers) {
50
+ const { ext, content } = await readHelperSource(helper, importExt);
51
+ files.push({ filename: `_helpers/${helper}.${ext}`, content });
168
52
  }
169
- files.push({
170
- filename: 'index.ts',
171
- content: generateIndexBarrel(files, { typesOnly: typesOnly ?? false, importExt }),
172
- });
173
- return files;
53
+ }
54
+ files.push({
55
+ filename: "index.ts",
56
+ content: generateIndexBarrel(files, { typesOnly: typesOnly ?? false, importExt })
57
+ });
58
+ return files;
59
+ };
60
+ export {
61
+ buildSchema
174
62
  };
@@ -1,33 +1,26 @@
1
- /**
2
- * Generates a best-guess default value from a regex pattern.
3
- * Recognizes common patterns and returns appropriate defaults.
4
- */
5
- export const generateDefaultFromPattern = (pattern) => {
6
- // Email pattern
7
- if (pattern.includes('@') || (pattern.includes('[a-zA-Z0-9]') && pattern.includes('\\.'))) {
8
- return '"user@example.com"';
1
+ const generateDefaultFromPattern = (pattern) => {
2
+ if (pattern.includes("@") || pattern.includes("[a-zA-Z0-9]") && pattern.includes("\\.")) {
3
+ return '"user@example.com"';
4
+ }
5
+ if (pattern.includes("[0-9a-f]") && pattern.includes("{8}") || pattern.includes("\\-")) {
6
+ if (pattern.match(/[0-9a-f].*\{8\}/i) || pattern.match(/[0-9a-fA-F]{8}/)) {
7
+ return '"00000000-0000-0000-0000-000000000000"';
9
8
  }
10
- // UUID pattern
11
- if ((pattern.includes('[0-9a-f]') && pattern.includes('{8}')) || pattern.includes('\\-')) {
12
- if (pattern.match(/[0-9a-f].*\{8\}/i) || pattern.match(/[0-9a-fA-F]{8}/)) {
13
- return '"00000000-0000-0000-0000-000000000000"';
14
- }
15
- }
16
- // URL pattern
17
- if (pattern.includes('https?') || pattern.includes('http')) {
18
- return '"https://example.com"';
19
- }
20
- // Semver pattern (3.1.x, x.y.z, etc)
21
- if (pattern.match(/\d.*\\\.\.*\d/)) {
22
- return '"1.0.0"';
23
- }
24
- // ISO date pattern
25
- if (pattern.includes('\\d{4}') && pattern.includes('\\d{2}')) {
26
- return '"2000-01-01"';
27
- }
28
- // Phone number patterns
29
- if (pattern.includes('\\d{3}') && (pattern.includes('\\d{4}') || pattern.includes('\\d{7}'))) {
30
- return '"+1234567890"';
31
- }
32
- return null;
9
+ }
10
+ if (pattern.includes("https?") || pattern.includes("http")) {
11
+ return '"https://example.com"';
12
+ }
13
+ if (pattern.match(/\d.*\\\.\.*\d/)) {
14
+ return '"1.0.0"';
15
+ }
16
+ if (pattern.includes("\\d{4}") && pattern.includes("\\d{2}")) {
17
+ return '"2000-01-01"';
18
+ }
19
+ if (pattern.includes("\\d{3}") && (pattern.includes("\\d{4}") || pattern.includes("\\d{7}"))) {
20
+ return '"+1234567890"';
21
+ }
22
+ return null;
23
+ };
24
+ export {
25
+ generateDefaultFromPattern
33
26
  };
@@ -1,49 +1,29 @@
1
- import { safeAccessor } from '@amritk/helpers/safe-accessor';
2
- import { getDiscriminatorValue } from '../helpers/get-discriminator-value.js';
3
- import { generateSchemaChecks } from './generate-schema-checks.js';
4
- /**
5
- * Generates a discriminated union validation expression.
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.
16
- */
17
- export const generateDiscriminatedUnionValidation = (accessor, schemas, discriminatorKey, defaultValue, isRequired) => {
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 = [];
26
- for (const schema of schemas) {
27
- const discriminatorValue = getDiscriminatorValue(schema, discriminatorKey);
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;
34
- }
35
- branches.push(condition);
1
+ import { safeAccessor } from "@amritk/helpers/safe-accessor";
2
+ import { getDiscriminatorValue } from "../helpers/get-discriminator-value.js";
3
+ import { generateSchemaChecks } from "./generate-schema-checks.js";
4
+ const generateDiscriminatedUnionValidation = (accessor, schemas, discriminatorKey, defaultValue, isRequired) => {
5
+ const discAccessor = safeAccessor(`${accessor}?`, discriminatorKey);
6
+ const branches = [];
7
+ for (const schema of schemas) {
8
+ const discriminatorValue = getDiscriminatorValue(schema, discriminatorKey);
9
+ if (discriminatorValue === null)
10
+ continue;
11
+ let condition = `${discAccessor} === ${JSON.stringify(discriminatorValue)}`;
12
+ const checks = generateSchemaChecks(accessor, schema);
13
+ for (const check of checks) {
14
+ condition += " && " + check;
36
15
  }
37
- if (branches.length === 0) {
38
- return isRequired ? `${accessor} ?? ${defaultValue}` : accessor;
39
- }
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}`;
47
- }
48
- return result;
16
+ branches.push(condition);
17
+ }
18
+ if (branches.length === 0) {
19
+ return isRequired ? `${accessor} ?? ${defaultValue}` : accessor;
20
+ }
21
+ let result = isRequired ? defaultValue : accessor;
22
+ for (let i = branches.length - 1; i >= 0; i--) {
23
+ result = `${branches[i]} ? ${accessor} : ${result}`;
24
+ }
25
+ return result;
26
+ };
27
+ export {
28
+ generateDiscriminatedUnionValidation
49
29
  };
@@ -1,63 +1,29 @@
1
- /**
2
- * Generates an enum validation check expression.
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.
12
- */
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
- }
20
- const serializedEnum = JSON.stringify(enumValues);
21
- return `${serializedEnum}.includes(${accessor} as never)`;
1
+ const generateEnumCheck = (accessor, enumValues) => {
2
+ const allPrimitive = enumValues.length > 0 && enumValues.every((v) => (v === null || typeof v !== "object") && typeof v !== "function") && !enumValues.some((v) => typeof v === "number" && Number.isNaN(v));
3
+ if (allPrimitive) {
4
+ return `(${enumValues.map((v) => `${accessor} === ${JSON.stringify(v)}`).join(" || ")})`;
5
+ }
6
+ const serializedEnum = JSON.stringify(enumValues);
7
+ return `${serializedEnum}.includes(${accessor} as never)`;
22
8
  };
23
- /**
24
- * Builds a case-insensitive coercion expression for a string `enum`/`const`
25
- * value whose exact-match check has already failed. Only the *string* members
26
- * participate (a number/boolean/null member has no case to fold): a value that
27
- * matches one case-insensitively normalizes to that member's exact casing, and
28
- * everything else yields `fallback`.
29
- *
30
- * This is emitted ONLY on the already-invalid branch of the coercion ternary, so
31
- * a correctly-cased value never reaches it — the exact `===` fast path (and the
32
- * shape validators / deep guards built on it) is unchanged, so the hot path pays
33
- * nothing. Only a mis-cased value, which today coerces straight to the default,
34
- * now takes a single `toLowerCase()` and an O(1) map lookup instead.
35
- *
36
- * Returns `null` when no member is a string (nothing to fold), so the caller
37
- * keeps its plain fallback and emits no extra code.
38
- */
39
- export const generateEnumCaseInsensitiveCoercion = (accessor, enumValues, fallback) => {
40
- const entries = [];
41
- const seen = new Set();
42
- for (const value of enumValues) {
43
- if (typeof value !== 'string')
44
- continue;
45
- const lower = value.toLowerCase();
46
- // First writer wins, so declaration order breaks ties between members that
47
- // fold to the same key (e.g. `"on"` and `"ON"`). `Set` membership is used
48
- // instead of `lower in obj` so a member folding to an inherited name like
49
- // `constructor` or `toString` is not wrongly treated as already-present.
50
- if (seen.has(lower))
51
- continue;
52
- seen.add(lower);
53
- entries.push([lower, value]);
54
- }
55
- if (entries.length === 0)
56
- return null;
57
- // A `Map` — not a plain object — so a folded key that collides with an
58
- // inherited member (`constructor`, `toString`, `__proto__`, …) resolves to
59
- // `undefined` (→ `fallback`) rather than an `Object.prototype` value. A
60
- // plain-object lookup would return the inherited function/object for such a key.
61
- const map = `new Map(${JSON.stringify(entries)})`;
62
- return `(typeof ${accessor} === "string" ? (${map}.get((${accessor} as string).toLowerCase()) ?? ${fallback}) : ${fallback})`;
9
+ const generateEnumCaseInsensitiveCoercion = (accessor, enumValues, fallback) => {
10
+ const entries = [];
11
+ const seen = /* @__PURE__ */ new Set();
12
+ for (const value of enumValues) {
13
+ if (typeof value !== "string")
14
+ continue;
15
+ const lower = value.toLowerCase();
16
+ if (seen.has(lower))
17
+ continue;
18
+ seen.add(lower);
19
+ entries.push([lower, value]);
20
+ }
21
+ if (entries.length === 0)
22
+ return null;
23
+ const map = `new Map(${JSON.stringify(entries)})`;
24
+ return `(typeof ${accessor} === "string" ? (${map}.get((${accessor} as string).toLowerCase()) ?? ${fallback}) : ${fallback})`;
25
+ };
26
+ export {
27
+ generateEnumCaseInsensitiveCoercion,
28
+ generateEnumCheck
63
29
  };