@amritk/generate-examples
Advanced tools
@@ -12,3 +12,4 @@ import { refToFilename } from '@amritk/helpers/ref-to-filename'; | ||
| const typeName = refToName(ref, suffix); | ||
| return `import { type ${typeName}, ${typeName}Arbitrary } from './${filename}'`; | ||
| // `.js` extension so the emitted import resolves under Node ESM, not only Bun. | ||
| return `import { type ${typeName}, ${typeName}Arbitrary } from './${filename}.js'`; | ||
| }; | ||
@@ -15,0 +16,0 @@ /** |
@@ -14,2 +14,3 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'; | ||
| export declare const deriveExample: (schema: JSONSchema, rootSchema?: Record<string, unknown>, seen?: ReadonlySet<string>) => unknown; | ||
| export declare const mergeAllOf: (schema: JSONSchema) => JSONSchema; | ||
| /** | ||
@@ -16,0 +17,0 @@ * Serializes a derived value into a TypeScript source expression. Handles the |
@@ -475,3 +475,3 @@ import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension'; | ||
| ]); | ||
| const mergeAllOf = (schema) => { | ||
| export const mergeAllOf = (schema) => { | ||
| const branches = hasAllOf(schema) ? schema.allOf : []; | ||
@@ -478,0 +478,0 @@ const merged = {}; |
@@ -5,2 +5,6 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'; | ||
| * | ||
| * A schema that references itself is wrapped in `fc.letrec` so the recursion is | ||
| * tied lazily — a plain `const NodeArbitrary = fc.record({ next: NodeArbitrary })` | ||
| * would throw a TDZ `ReferenceError` the moment the module is imported. | ||
| * | ||
| * @example | ||
@@ -7,0 +11,0 @@ * ```typescript |
| import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension'; | ||
| import { refToName } from '@amritk/helpers/ref-to-name'; | ||
| import { hasAnyOf, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasFormat, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards'; | ||
| import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasFormat, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards'; | ||
| import { mergeAllOf } from './derive-example.js'; | ||
| /** | ||
@@ -9,2 +10,4 @@ * Derives the arbitrary const name from a type name. | ||
| const arbitraryName = (typeName) => `${typeName}Arbitrary`; | ||
| /** The letrec key used for a type's own (self-referential) arbitrary. */ | ||
| const SELF_KEY = 'self'; | ||
| /** Builds a `fc.string({ ... })` expression honouring format and length constraints. */ | ||
@@ -35,4 +38,16 @@ const stringExpr = (schema) => { | ||
| } | ||
| if (hasPattern(schema)) | ||
| return `fc.stringMatching(/${schema.pattern}/)`; | ||
| if (hasPattern(schema)) { | ||
| // Build the regex via `new RegExp(<json-string>)` rather than inlining the | ||
| // pattern into a `/.../ ` literal: a pattern containing `/` (e.g. `^/api/v\d+$`) | ||
| // would otherwise close the literal early and emit invalid TypeScript. | ||
| const base = `fc.stringMatching(new RegExp(${JSON.stringify(schema.pattern)}))`; | ||
| // `stringMatching` takes no length bounds, so honour any min/maxLength with a | ||
| // filter instead of silently dropping them. Only emit it when a bound exists. | ||
| const checks = []; | ||
| if (hasMinLength(schema)) | ||
| checks.push(`s.length >= ${schema.minLength}`); | ||
| if (hasMaxLength(schema)) | ||
| checks.push(`s.length <= ${schema.maxLength}`); | ||
| return checks.length > 0 ? `${base}.filter((s) => ${checks.join(' && ')})` : base; | ||
| } | ||
| const opts = []; | ||
@@ -48,10 +63,19 @@ if (hasMinLength(schema)) | ||
| const opts = []; | ||
| // With both `minimum` and `exclusiveMinimum` present the effective lower bound | ||
| // is the tighter (larger) of the two, so combine them rather than letting one | ||
| // shadow the other via else-if. | ||
| const mins = []; | ||
| if (hasMinimum(schema)) | ||
| opts.push(`min: ${schema.minimum}`); | ||
| else if (hasExclusiveMinimum(schema)) | ||
| opts.push(`min: ${Number(schema.exclusiveMinimum) + 1}`); | ||
| mins.push(Number(schema.minimum)); | ||
| if (hasExclusiveMinimum(schema)) | ||
| mins.push(Number(schema.exclusiveMinimum) + 1); | ||
| if (mins.length > 0) | ||
| opts.push(`min: ${Math.max(...mins)}`); | ||
| const maxs = []; | ||
| if (hasMaximum(schema)) | ||
| opts.push(`max: ${schema.maximum}`); | ||
| else if (hasExclusiveMaximum(schema)) | ||
| opts.push(`max: ${Number(schema.exclusiveMaximum) - 1}`); | ||
| maxs.push(Number(schema.maximum)); | ||
| if (hasExclusiveMaximum(schema)) | ||
| maxs.push(Number(schema.exclusiveMaximum) - 1); | ||
| if (maxs.length > 0) | ||
| opts.push(`max: ${Math.min(...maxs)}`); | ||
| const base = opts.length > 0 ? `fc.integer({ ${opts.join(', ')} })` : 'fc.integer()'; | ||
@@ -74,5 +98,20 @@ return hasMultipleOf(schema) ? `${base}.filter((n) => n % ${schema.multipleOf} === 0)` : base; | ||
| }; | ||
| /** Builds a `fc.array(...)` / `fc.uniqueArray(...)` expression for an array schema. */ | ||
| const arrayExpr = (schema, suffix) => { | ||
| const items = hasItems(schema) && isSchemaObject(schema.items) ? arbitraryExpr(schema.items, suffix) : 'fc.anything()'; | ||
| /** Builds a `fc.array(...)` / `fc.uniqueArray(...)` / `fc.tuple(...)` expression for an array schema. */ | ||
| const arrayExpr = (schema, ctx) => { | ||
| const raw = schema; | ||
| // A tuple is `prefixItems` (2020-12) or the draft-07 array-form `items`. Both | ||
| // describe one schema per position, so map them to `fc.tuple(...)`. Extra items | ||
| // beyond the prefix are unconstrained (or forbidden by `items: false`); the | ||
| // minimal tuple is schema-valid either way. | ||
| const prefixItems = raw['prefixItems']; | ||
| const tuple = Array.isArray(prefixItems) | ||
| ? prefixItems | ||
| : Array.isArray(raw['items']) | ||
| ? raw['items'] | ||
| : undefined; | ||
| if (tuple) { | ||
| const exprs = tuple.map((item) => arbitraryExpr(item, ctx)); | ||
| return `fc.tuple(${exprs.join(', ')})`; | ||
| } | ||
| const items = hasItems(schema) && isSchemaObject(schema.items) ? arbitraryExpr(schema.items, ctx) : 'fc.anything()'; | ||
| const opts = []; | ||
@@ -86,9 +125,16 @@ if (hasMinItems(schema)) | ||
| }; | ||
| /** Builds a `fc.record(...)` expression for an object schema. */ | ||
| const objectExpr = (schema, suffix) => { | ||
| if (!hasProperties(schema)) | ||
| return 'fc.object()'; | ||
| /** Builds a `fc.record(...)` / `fc.dictionary(...)` expression for an object schema. */ | ||
| const objectExpr = (schema, ctx) => { | ||
| // The `additionalProperties` value schema (when it constrains extra keys with a | ||
| // real subschema rather than the boolean true/false form). | ||
| const additional = hasAdditionalProperties(schema) ? schema.additionalProperties : false; | ||
| const additionalArb = isSchemaObject(additional) ? arbitraryExpr(additional, ctx) : undefined; | ||
| if (!hasProperties(schema)) { | ||
| // A map-style object (no declared properties) with a typed `additionalProperties` | ||
| // is a dictionary of that value type; otherwise fall back to a free-form object. | ||
| return additionalArb ? `fc.dictionary(fc.string(), ${additionalArb})` : 'fc.object()'; | ||
| } | ||
| const required = new Set(hasRequired(schema) ? schema.required : []); | ||
| const keys = Object.keys(schema.properties); | ||
| const entries = Object.entries(schema.properties).map(([key, propSchema]) => `${JSON.stringify(key)}: ${arbitraryExpr(propSchema, suffix)}`); | ||
| const entries = Object.entries(schema.properties).map(([key, propSchema]) => `${JSON.stringify(key)}: ${arbitraryExpr(propSchema, ctx)}`); | ||
| if (entries.length === 0) | ||
@@ -99,14 +145,20 @@ return 'fc.record({})'; | ||
| // when at least one property is optional. | ||
| if (keys.every((key) => required.has(key))) | ||
| return `fc.record(${model})`; | ||
| const requiredKeys = [...required].map((key) => JSON.stringify(key)).join(', '); | ||
| return `fc.record(${model}, { requiredKeys: [${requiredKeys}] })`; | ||
| const record = keys.every((key) => required.has(key)) | ||
| ? `fc.record(${model})` | ||
| : `fc.record(${model}, { requiredKeys: [${[...required].map((key) => JSON.stringify(key)).join(', ')}] })`; | ||
| // When both declared properties and a typed `additionalProperties` are present, | ||
| // fold in a dictionary of extra keys so the value exercises the open-map part of | ||
| // the schema too. The declared keys win on collision (merged last). | ||
| if (additionalArb) { | ||
| return `fc.tuple(${record}, fc.dictionary(fc.string(), ${additionalArb})).map(([base, extra]) => ({ ...extra, ...base }))`; | ||
| } | ||
| return record; | ||
| }; | ||
| /** Builds a `fc.oneof(...)` expression from a list of branch schemas. */ | ||
| const oneofExpr = (branches, suffix) => { | ||
| const exprs = branches.map((branch) => arbitraryExpr(branch, suffix)); | ||
| const oneofExpr = (branches, ctx) => { | ||
| const exprs = branches.map((branch) => arbitraryExpr(branch, ctx)); | ||
| return `fc.oneof(${exprs.join(', ')})`; | ||
| }; | ||
| /** Builds the fast-check expression for a single (non-union) JSON Schema type. */ | ||
| const scalarExpr = (type, schema, suffix) => { | ||
| const scalarExpr = (type, schema, ctx) => { | ||
| switch (type) { | ||
@@ -124,5 +176,5 @@ case 'string': | ||
| case 'array': | ||
| return arrayExpr(schema, suffix); | ||
| return arrayExpr(schema, ctx); | ||
| case 'object': | ||
| return objectExpr(schema, suffix); | ||
| return objectExpr(schema, ctx); | ||
| default: | ||
@@ -134,10 +186,19 @@ return 'fc.anything()'; | ||
| * Recursively builds the fast-check arbitrary expression for a schema node. | ||
| * `$ref`s resolve to the referenced file's exported arbitrary; everything else | ||
| * maps to the appropriate `fc.*` combinator. | ||
| * `$ref`s resolve to the referenced file's exported arbitrary; a self-`$ref` | ||
| * resolves to `tie('self')` so recursive schemas tie lazily via `fc.letrec`. | ||
| * Everything else maps to the appropriate `fc.*` combinator. | ||
| */ | ||
| const arbitraryExpr = (schema, suffix) => { | ||
| const arbitraryExpr = (schema, ctx) => { | ||
| if (!isSchemaObject(schema)) | ||
| return 'fc.anything()'; | ||
| if (hasRef(schema)) | ||
| return arbitraryName(refToName(schema.$ref, suffix)); | ||
| if (hasRef(schema)) { | ||
| const name = arbitraryName(refToName(schema.$ref, ctx.suffix)); | ||
| // A reference back to the type being generated must be tied lazily; an eager | ||
| // identifier would touch a still-uninitialized const (TDZ) at import time. | ||
| if (name === ctx.selfArbName) { | ||
| ctx.usedTie.value = true; | ||
| return `tie(${JSON.stringify(SELF_KEY)})`; | ||
| } | ||
| return name; | ||
| } | ||
| if (hasConst(schema)) | ||
@@ -159,12 +220,17 @@ return `fc.constant(${JSON.stringify(schema.const)})`; | ||
| return 'fc.anything()'; | ||
| // `allOf` must satisfy every branch at once. fast-check has no generic | ||
| // intersection combinator, so flatten the branches into one merged schema | ||
| // (tightest bounds, unioned required, merged properties) and generate from it. | ||
| if (hasAllOf(schema)) | ||
| return arbitraryExpr(mergeAllOf(schema), ctx); | ||
| if (hasOneOf(schema)) | ||
| return oneofExpr(schema.oneOf, suffix); | ||
| return oneofExpr(schema.oneOf, ctx); | ||
| if (hasAnyOf(schema)) | ||
| return oneofExpr(schema.anyOf, suffix); | ||
| return oneofExpr(schema.anyOf, ctx); | ||
| if (hasType(schema)) | ||
| return scalarExpr(schema.type, schema, suffix); | ||
| return scalarExpr(schema.type, schema, ctx); | ||
| // Multi-type schemas (`type: ['string', 'null']`) become a oneof over each | ||
| // member type; `hasType` only matches a single string `type`. | ||
| if (Array.isArray(schema.type)) { | ||
| const exprs = schema.type.map((type) => scalarExpr(type, schema, suffix)); | ||
| const exprs = schema.type.map((type) => scalarExpr(type, schema, ctx)); | ||
| return exprs.length === 1 ? exprs[0] : `fc.oneof(${exprs.join(', ')})`; | ||
@@ -177,2 +243,6 @@ } | ||
| * | ||
| * A schema that references itself is wrapped in `fc.letrec` so the recursion is | ||
| * tied lazily — a plain `const NodeArbitrary = fc.record({ next: NodeArbitrary })` | ||
| * would throw a TDZ `ReferenceError` the moment the module is imported. | ||
| * | ||
| * @example | ||
@@ -185,4 +255,9 @@ * ```typescript | ||
| export const generateArbitrary = (schema, typeName, suffix = '') => { | ||
| const expr = arbitraryExpr(schema, suffix); | ||
| return `export const ${arbitraryName(typeName)}: fc.Arbitrary<${typeName}> = ${expr}`; | ||
| const selfArbName = arbitraryName(typeName); | ||
| const ctx = { suffix, selfArbName, usedTie: { value: false } }; | ||
| const expr = arbitraryExpr(schema, ctx); | ||
| if (ctx.usedTie.value) { | ||
| return `export const ${selfArbName}: fc.Arbitrary<${typeName}> = fc.letrec<{ ${SELF_KEY}: ${typeName} }>((tie) => ({\n ${SELF_KEY}: ${expr},\n})).${SELF_KEY}`; | ||
| } | ||
| return `export const ${selfArbName}: fc.Arbitrary<${typeName}> = ${expr}`; | ||
| }; |
+4
-4
| { | ||
| "name": "@amritk/generate-examples", | ||
| "version": "0.4.1", | ||
| "version": "0.4.2", | ||
| "description": "Generate fast-check arbitraries and example values from JSON Schemas.", | ||
@@ -44,4 +44,4 @@ "module": "./dist/index.js", | ||
| ".": { | ||
| "default": "./dist/index.js", | ||
| "types": "./dist/index.d.ts" | ||
| "types": "./dist/index.d.ts", | ||
| "default": "./dist/index.js" | ||
| } | ||
@@ -51,3 +51,3 @@ }, | ||
| "json-schema-typed": "^8.0.1", | ||
| "@amritk/helpers": "0.10.1" | ||
| "@amritk/helpers": "0.10.2" | ||
| }, | ||
@@ -54,0 +54,0 @@ "devDependencies": { |
55788
9.16%1150
7.58%+ Added
- Removed
Updated