@amritk/generate-parsers 0.13.0 → 0.15.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.
@@ -18,6 +18,23 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
18
18
  * whether `input` is already in the shape produced by the parser's fast path.
19
19
  */
20
20
  export declare const shapeValidatorName: (typeName: string) => string;
21
+ /**
22
+ * Matches an inline nested object property — an object schema written directly
23
+ * under `properties` with its own `properties`, rather than referenced via
24
+ * `$ref`. These get a private sub-parser (and shape predicate) in the same
25
+ * generated file so their fields are actually parsed; anything involving
26
+ * composition, conditionals, enums, or record semantics keeps the existing
27
+ * code paths.
28
+ */
29
+ export declare const isInlineObjectProperty: (propSchema: JSONSchema) => propSchema is JSONSchema.Object;
30
+ /**
31
+ * Matches an array property whose `items` is an inline object schema — the
32
+ * array-items analogue of {@link isInlineObjectProperty}. These get a private
33
+ * item sub-parser (and shape predicate) so element values — including nested
34
+ * enums and `$ref`s — are actually validated instead of only passing an
35
+ * `Array.isArray` check.
36
+ */
37
+ export declare const isInlineObjectArrayProperty: (propSchema: JSONSchema) => boolean;
21
38
  /**
22
39
  * Extracts the branch list of a `oneOf`/`anyOf` union, or `null` when the
23
40
  * schema is not a union (or mixes in other composition keywords we cannot
@@ -25,6 +25,51 @@ import { scalarItemTypeCheck } from './generate-validation-expression.js';
25
25
  * whether `input` is already in the shape produced by the parser's fast path.
26
26
  */
27
27
  export const shapeValidatorName = (typeName) => `validate${typeName}Shape`;
28
+ /**
29
+ * Matches an inline nested object property — an object schema written directly
30
+ * under `properties` with its own `properties`, rather than referenced via
31
+ * `$ref`. These get a private sub-parser (and shape predicate) in the same
32
+ * generated file so their fields are actually parsed; anything involving
33
+ * composition, conditionals, enums, or record semantics keeps the existing
34
+ * code paths.
35
+ */
36
+ export const isInlineObjectProperty = (propSchema) => {
37
+ if (!isSchemaObject(propSchema))
38
+ return false;
39
+ if (hasRef(propSchema) || hasEnum(propSchema) || hasConst(propSchema))
40
+ return false;
41
+ if (hasOneOf(propSchema) || hasAnyOf(propSchema) || hasAllOf(propSchema))
42
+ return false;
43
+ // `then`/`else` must be excluded even without `if`: generateShapeValidator
44
+ // stubs any schema carrying them, so admitting one here would wire an
45
+ // always-false predicate into parent guards — and into validators the
46
+ // strict-union trust walk treats as false-sound, making a strict union
47
+ // reject valid input.
48
+ if ('patternProperties' in propSchema || 'not' in propSchema)
49
+ return false;
50
+ if ('if' in propSchema || 'then' in propSchema || 'else' in propSchema)
51
+ return false;
52
+ // additionalProperties-as-schema records go through the record paths instead
53
+ if (hasAdditionalProperties(propSchema) && typeof propSchema.additionalProperties !== 'boolean')
54
+ return false;
55
+ return isObjectSchema(propSchema) && hasProperties(propSchema);
56
+ };
57
+ /**
58
+ * Matches an array property whose `items` is an inline object schema — the
59
+ * array-items analogue of {@link isInlineObjectProperty}. These get a private
60
+ * item sub-parser (and shape predicate) so element values — including nested
61
+ * enums and `$ref`s — are actually validated instead of only passing an
62
+ * `Array.isArray` check.
63
+ */
64
+ export const isInlineObjectArrayProperty = (propSchema) => {
65
+ if (!isSchemaObject(propSchema))
66
+ return false;
67
+ if (!('type' in propSchema) || propSchema.type !== 'array')
68
+ return false;
69
+ if (!hasItems(propSchema) || Array.isArray(propSchema.items))
70
+ return false;
71
+ return isInlineObjectProperty(propSchema.items);
72
+ };
28
73
  /**
29
74
  * Extracts the branch list of a `oneOf`/`anyOf` union, or `null` when the
30
75
  * schema is not a union (or mixes in other composition keywords we cannot
@@ -295,6 +340,33 @@ const canTrustPropertyCheck = (schema, rootSchema, visiting) => {
295
340
  return false;
296
341
  }
297
342
  };
343
+ /**
344
+ * Mirrors the per-property check {@link generateShapeValidator} emits for a
345
+ * generated file's exported validator, which is *deeper* than
346
+ * {@link generatePropertyTypeCheck}: inline object properties and arrays of
347
+ * inline object items are checked through private sub-predicates generated
348
+ * from the same schema, and a sub-predicate is a conservative `=> false` stub
349
+ * whenever some nested property check cannot be built. A validator built on a
350
+ * stub returns false on *valid* input, so trusting it requires every reachable
351
+ * sub-predicate to be a real check, recursively.
352
+ */
353
+ const canTrustShapeProperty = (propSchema, rootSchema, visiting) => {
354
+ if (!isSchemaObject(propSchema))
355
+ return false;
356
+ // Record-valued additionalProperties: records of refs stub the validator;
357
+ // stay conservative for any schema-valued record, matching the historical
358
+ // bail in canTrustReferencedValidator.
359
+ if (hasAdditionalProperties(propSchema) && typeof propSchema.additionalProperties !== 'boolean')
360
+ return false;
361
+ if (isInlineObjectProperty(propSchema)) {
362
+ return Object.values(propSchema.properties ?? {}).every((sub) => canTrustShapeProperty(sub, rootSchema, visiting));
363
+ }
364
+ if (isInlineObjectArrayProperty(propSchema)) {
365
+ const items = propSchema.items;
366
+ return Object.values(items.properties ?? {}).every((sub) => canTrustShapeProperty(sub, rootSchema, visiting));
367
+ }
368
+ return canTrustPropertyCheck(propSchema, rootSchema, visiting);
369
+ };
298
370
  /** Mirrors {@link generateInlineObjectCheck}: non-null AND every property check is trustworthy. */
299
371
  const canTrustInlineObjectCheck = (schema, rootSchema, visiting) => {
300
372
  if (!isSchemaObject(schema) || !isObjectSchema(schema) || !hasProperties(schema))
@@ -354,15 +426,11 @@ const canTrustReferencedValidator = (ref, rootSchema, visiting) => {
354
426
  'else' in resolved) {
355
427
  return false;
356
428
  }
357
- return Object.values(resolved.properties).every((propSchema) => {
358
- // Records of refs make generateShapeValidator bail to the stub.
359
- if (isSchemaObject(propSchema) &&
360
- hasAdditionalProperties(propSchema) &&
361
- typeof propSchema.additionalProperties !== 'boolean') {
362
- return false;
363
- }
364
- return canTrustPropertyCheck(propSchema, rootSchema, visiting);
365
- });
429
+ // Walk with the deep shape-validator mirror: the emitted validator routes
430
+ // inline object properties (and arrays of inline object items) through
431
+ // private sub-predicates, so trust must recurse the same way instead of
432
+ // stopping at a shallow isObject/Array.isArray reading.
433
+ return Object.values(resolved.properties).every((propSchema) => canTrustShapeProperty(propSchema, rootSchema, visiting));
366
434
  }
367
435
  finally {
368
436
  visiting.delete(ref);
@@ -6,6 +6,13 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
6
6
  * objects, arrays, unions, `$ref`s, and anything richer.
7
7
  */
8
8
  export declare const scalarItemTypeCheck: (itemSchema: JSONSchema, accessor: string) => string | null;
9
+ /**
10
+ * True when an array's `items` schema can be coerced element-by-element by the
11
+ * mapping slow path: single scalar types (via {@link scalarItemTypeCheck}) and
12
+ * enums (whose validation expression coerces a non-member to the first member).
13
+ * Anything richer (objects, unions, `$ref`s) needs a real item parser instead.
14
+ */
15
+ export declare const isCoercibleItemSchema: (itemSchema: JSONSchema) => boolean;
9
16
  /**
10
17
  * Generates a validation expression that checks if a value matches the expected type.
11
18
  * Returns the value if valid, coerces to the correct type if invalid, or uses default if required and missing.
@@ -46,6 +46,17 @@ export const scalarItemTypeCheck = (itemSchema, accessor) => {
46
46
  return null;
47
47
  }
48
48
  };
49
+ /**
50
+ * True when an array's `items` schema can be coerced element-by-element by the
51
+ * mapping slow path: single scalar types (via {@link scalarItemTypeCheck}) and
52
+ * enums (whose validation expression coerces a non-member to the first member).
53
+ * Anything richer (objects, unions, `$ref`s) needs a real item parser instead.
54
+ */
55
+ export const isCoercibleItemSchema = (itemSchema) => {
56
+ if (scalarItemTypeCheck(itemSchema, '_it') !== null)
57
+ return true;
58
+ return isSchemaObject(itemSchema) && hasEnum(itemSchema) && itemSchema.enum.length > 0;
59
+ };
49
60
  /**
50
61
  * A boolean type check for a single JSON Schema primitive type name. Unlike
51
62
  * {@link scalarItemTypeCheck} this also covers `array`/`object` (as inline shape
@@ -228,15 +239,16 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
228
239
  if (!hasType(schema) && !hasEnum(schema)) {
229
240
  return `${accessor} ?? ${defaultValue}`;
230
241
  }
231
- // An array of scalar items: coerce each element so e.g. `number[]` actually
232
- // contains numbers. The fast path only takes a well-typed array, so a mistyped
233
- // element reaches here. ($ref items use the caller's validateArray path;
234
- // object/union items still pass through.)
242
+ // An array of scalar or enum items: coerce each element so e.g. `number[]`
243
+ // actually contains numbers and an enum member set holds. The fast path only
244
+ // takes a well-typed array, so a mistyped element reaches here. ($ref and
245
+ // inline-object items use the caller's validateArray path; union items still
246
+ // pass through.)
235
247
  if (hasType(schema) &&
236
248
  schema.type === 'array' &&
237
249
  hasItems(schema) &&
238
250
  !Array.isArray(schema.items) &&
239
- scalarItemTypeCheck(schema.items, '_it') !== null) {
251
+ isCoercibleItemSchema(schema.items)) {
240
252
  const itemSchema = schema.items;
241
253
  const itemExpr = generateValidationExpression('', itemSchema, getDefaultValue(itemSchema), true, rootSchema, visitedRefs, '_it', true);
242
254
  const mapped = `(Array.isArray(${accessor}) ? (${accessor} as unknown[]).map((_it) => ${itemExpr}) : ${defaultValue})`;
@@ -12,12 +12,4 @@ export type CollectedHelpers = {
12
12
  */
13
13
  readonly used: Set<RuntimeHelperName>;
14
14
  };
15
- /**
16
- * Detects which runtime helpers a generated parser body references.
17
- *
18
- * @param helpersImportPrefix - Relative path prefix to the shared `_helpers/`
19
- * directory in embedded mode. Defaults to `'./'`. The recursive multi-schema
20
- * build passes `'../'`, `'../../'`, etc. so nested parsers can reach a single
21
- * `_helpers/` directory at the output root.
22
- */
23
- export declare const collectHelpers: (parserFunction: string, mode: HelpersMode, helpersImportPrefix?: string) => CollectedHelpers;
15
+ export declare const collectHelpers: (parserFunction: string, mode: HelpersMode, helpersImportPrefix?: string, importExt?: 'js' | 'ts') => CollectedHelpers;
@@ -11,9 +11,9 @@ const EMBEDDED_NAMED_EXPORTS = {
11
11
  'validate-record': 'validateRecord',
12
12
  'has-ref': 'hasRef',
13
13
  };
14
- const embeddedImport = (helper, prefix) =>
15
- // `.js` extension so the embedded-helper import resolves under Node ESM, not only Bun.
16
- `import { ${EMBEDDED_NAMED_EXPORTS[helper]} } from '${prefix}_helpers/${helper}.js';`;
14
+ const embeddedImport = (helper, prefix, ext) =>
15
+ // An explicit extension so the embedded-helper import resolves under Node ESM, not only Bun.
16
+ `import { ${EMBEDDED_NAMED_EXPORTS[helper]} } from '${prefix}_helpers/${helper}.${ext}';`;
17
17
  /**
18
18
  * Detects which runtime helpers a generated parser body references.
19
19
  *
@@ -21,16 +21,43 @@ const embeddedImport = (helper, prefix) =>
21
21
  * directory in embedded mode. Defaults to `'./'`. The recursive multi-schema
22
22
  * build passes `'../'`, `'../../'`, etc. so nested parsers can reach a single
23
23
  * `_helpers/` directory at the output root.
24
+ * @param importExt - Extension used on embedded-helper import specifiers.
25
+ * Defaults to `'js'` (the TS NodeNext form); `'ts'` makes the output runnable
26
+ * under Node's type stripping.
24
27
  */
25
- export const collectHelpers = (parserFunction, mode, helpersImportPrefix = './') => {
28
+ // One alternation pass over the generated source instead of four full-text
29
+ // `.includes` scans — an absent helper name used to cost a complete rescan
30
+ // each, which showed up at several percent of total generation time.
31
+ const HELPER_USAGE = /validateArray|validateRecord|isObject|hasRef\(/g;
32
+ export const collectHelpers = (parserFunction, mode, helpersImportPrefix = './', importExt = 'js') => {
26
33
  const imports = [];
27
34
  const used = new Set();
28
- const importFor = (helper) => mode === 'embedded' ? embeddedImport(helper, helpersImportPrefix) : PACKAGE_IMPORTS[helper];
29
- if (parserFunction.includes('validateArray')) {
35
+ const importFor = (helper) => mode === 'embedded' ? embeddedImport(helper, helpersImportPrefix, importExt) : PACKAGE_IMPORTS[helper];
36
+ let sawValidateArray = false;
37
+ let sawValidateRecord = false;
38
+ let sawIsObject = false;
39
+ let sawHasRef = false;
40
+ HELPER_USAGE.lastIndex = 0;
41
+ let match = HELPER_USAGE.exec(parserFunction);
42
+ while (match !== null) {
43
+ const token = match[0];
44
+ if (token === 'validateArray')
45
+ sawValidateArray = true;
46
+ else if (token === 'validateRecord')
47
+ sawValidateRecord = true;
48
+ else if (token === 'isObject')
49
+ sawIsObject = true;
50
+ else
51
+ sawHasRef = true;
52
+ if (sawValidateArray && sawValidateRecord && sawIsObject && sawHasRef)
53
+ break;
54
+ match = HELPER_USAGE.exec(parserFunction);
55
+ }
56
+ if (sawValidateArray) {
30
57
  imports.push(importFor('validate-array'));
31
58
  used.add('validate-array');
32
59
  }
33
- if (parserFunction.includes('validateRecord')) {
60
+ if (sawValidateRecord) {
34
61
  imports.push(importFor('validate-record'));
35
62
  used.add('validate-record');
36
63
  // The embedded validate-record.ts imports is-object, so it must be shipped too —
@@ -38,11 +65,11 @@ export const collectHelpers = (parserFunction, mode, helpersImportPrefix = './')
38
65
  // parser body itself references `isObject` (handled below).
39
66
  used.add('is-object');
40
67
  }
41
- if (parserFunction.includes('isObject')) {
68
+ if (sawIsObject) {
42
69
  imports.push(importFor('is-object'));
43
70
  used.add('is-object');
44
71
  }
45
- if (parserFunction.includes('hasRef(')) {
72
+ if (sawHasRef) {
46
73
  imports.push(importFor('has-ref'));
47
74
  used.add('has-ref');
48
75
  }
@@ -1,4 +1,6 @@
1
1
  import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
+ /** Extension emitted on every relative import specifier in generated code. */
3
+ export type ImportExtension = 'js' | 'ts';
2
4
  /**
3
5
  * Options for controlling how imports are collected.
4
6
  */
@@ -26,6 +28,12 @@ type CollectImportsOptions = {
26
28
  * Defaults to `''` (no suffix).
27
29
  */
28
30
  readonly typeSuffix?: string;
31
+ /**
32
+ * Extension used on every relative import specifier. Defaults to `'js'`
33
+ * (the TS NodeNext form); `'ts'` makes the output runnable under Node's
34
+ * type stripping.
35
+ */
36
+ readonly importExt?: ImportExtension;
29
37
  };
30
38
  /**
31
39
  * Collects all import statements needed for a schema by finding $ref references
@@ -67,4 +75,11 @@ type CollectImportsOptions = {
67
75
  * ```
68
76
  */
69
77
  export declare const collectImports: (schema: JSONSchema, options?: CollectImportsOptions) => string[];
78
+ /**
79
+ * The type names a generated file will import for its `$ref`s — the same
80
+ * dedup/skip rules as {@link collectImports}. Generators seed their private
81
+ * sub-type naming with this set so a synthesized name (e.g. a root array's
82
+ * `FooItem`) can never shadow an imported identifier.
83
+ */
84
+ export declare const collectImportTypeNames: (schema: JSONSchema, options?: CollectImportsOptions) => Set<string>;
70
85
  export {};
@@ -2,11 +2,12 @@ import { refToFilename } from '@amritk/helpers/ref-to-filename';
2
2
  import { refToName } from '@amritk/helpers/ref-to-name';
3
3
  import { resolveRef } from '@amritk/helpers/resolve-ref';
4
4
  import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasItems, hasOneOf, hasRef } from '@amritk/helpers/schema-guards';
5
- // Emit an explicit `.js` extension on the relative specifier. Node's ESM resolver
6
- // requires it (extensionless relative imports only work under a bundler or Bun),
7
- // and `./x.js` pointing at a sibling `x.ts` is the standard TS NodeNext form —
8
- // accepted by Bun, esbuild, webpack, and tsc alike.
9
- const getImportPathForFilename = (filename) => `./${filename}.js`;
5
+ // Emit an explicit extension on the relative specifier. Node's ESM resolver
6
+ // requires one (extensionless relative imports only work under a bundler or
7
+ // Bun). `./x.js` pointing at a sibling `x.ts` is the standard TS NodeNext form
8
+ // — accepted by Bun, esbuild, webpack, and tsc alike — while `./x.ts` is the
9
+ // literal on-disk path Node's type stripping needs to run the sources directly.
10
+ const getImportPathForFilename = (filename, ext) => `./${filename}.${ext}`;
10
11
  /**
11
12
  * Collects all import statements needed for a schema by finding $ref references
12
13
  * that are directly used in the type definition (from properties, items, etc.).
@@ -48,6 +49,32 @@ const getImportPathForFilename = (filename) => `./${filename}.js`;
48
49
  */
49
50
  export const collectImports = (schema, options) => {
50
51
  const typesOnly = options?.typesOnly === true;
52
+ const importExt = options?.importExt ?? 'js';
53
+ const importMap = new Map();
54
+ for (const [filename, typeName] of collectImportTargets(schema, options)) {
55
+ const importPath = getImportPathForFilename(filename, importExt);
56
+ // In types-only mode, omit the parser function import since there is no parser to call
57
+ const importStatement = typesOnly
58
+ ? `import type { ${typeName} } from '${importPath}';`
59
+ : `import { type ${typeName}, parse${typeName}, validate${typeName}Shape } from '${importPath}';`;
60
+ importMap.set(filename, importStatement);
61
+ }
62
+ return Array.from(importMap.values()).sort();
63
+ };
64
+ /**
65
+ * The type names a generated file will import for its `$ref`s — the same
66
+ * dedup/skip rules as {@link collectImports}. Generators seed their private
67
+ * sub-type naming with this set so a synthesized name (e.g. a root array's
68
+ * `FooItem`) can never shadow an imported identifier.
69
+ */
70
+ export const collectImportTypeNames = (schema, options) => {
71
+ const names = new Set();
72
+ for (const [, typeName] of collectImportTargets(schema, options))
73
+ names.add(typeName);
74
+ return names;
75
+ };
76
+ /** Shared `$ref` walk: filename → derived type name for every import target. */
77
+ const collectImportTargets = (schema, options) => {
51
78
  const selfFilename = options?.selfRef ? refToFilename(options.selfRef) : undefined;
52
79
  const rootSchema = options?.rootSchema;
53
80
  const typeSuffix = options?.typeSuffix;
@@ -194,8 +221,8 @@ export const collectImports = (schema, options) => {
194
221
  if (typeof schema === 'object' && schema !== null && 'else' in schema) {
195
222
  collectRefsFromValue(schema.else);
196
223
  }
197
- // Convert refs to combined import statements, deduplicating by filename
198
- const importMap = new Map();
224
+ // Convert refs to import targets, deduplicating by filename.
225
+ const targets = new Map();
199
226
  for (const ref of refs) {
200
227
  // For URI refs, skip if the base URI is not resolvable in the root schema.
201
228
  // This prevents generating imports for external schemas that were never generated as files.
@@ -209,12 +236,7 @@ export const collectImports = (schema, options) => {
209
236
  if (selfFilename !== undefined && filename === selfFilename) {
210
237
  continue;
211
238
  }
212
- const importPath = getImportPathForFilename(filename);
213
- // In types-only mode, omit the parser function import since there is no parser to call
214
- const importStatement = typesOnly
215
- ? `import type { ${typeName} } from '${importPath}';`
216
- : `import { type ${typeName}, parse${typeName}, validate${typeName}Shape } from '${importPath}';`;
217
- importMap.set(filename, importStatement);
239
+ targets.set(filename, typeName);
218
240
  }
219
- return Array.from(importMap.values()).sort();
241
+ return targets;
220
242
  };
package/dist/index.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  export { buildSchema, type GeneratedFile } from './generators/build-schema.js';
2
+ export type { ImportExtension } from './helpers/collect-imports.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-parsers",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "json-schema-typed": "^8.0.1",
51
- "@amritk/helpers": "0.10.3",
51
+ "@amritk/helpers": "0.12.0",
52
52
  "@amritk/generate-markdown": "0.4.1"
53
53
  },
54
54
  "devDependencies": {