@amritk/generate-examples 0.4.1 → 0.4.2

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.
@@ -9,7 +9,8 @@ import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasItems, hasOneOf, hasRef
9
9
  const buildImport = (ref, suffix) => {
10
10
  const filename = refToFilename(ref);
11
11
  const typeName = refToName(ref, suffix);
12
- return `import { type ${typeName}, ${typeName}Arbitrary } from './${filename}'`;
12
+ // `.js` extension so the emitted import resolves under Node ESM, not only Bun.
13
+ return `import { type ${typeName}, ${typeName}Arbitrary } from './${filename}.js'`;
13
14
  };
14
15
  /**
15
16
  * Walks one level of the schema and yields all direct $ref strings that should
@@ -11,6 +11,7 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
11
11
  * pattern — use the generated arbitrary when pattern fidelity matters.
12
12
  */
13
13
  export declare const deriveExample: (schema: JSONSchema, rootSchema?: Record<string, unknown>, seen?: ReadonlySet<string>) => unknown;
14
+ export declare const mergeAllOf: (schema: JSONSchema) => JSONSchema;
14
15
  /**
15
16
  * Serializes a derived value into a TypeScript source expression. Handles the
16
17
  * non-JSON values `deriveExample` can produce (`Date`, `bigint`) in addition to
@@ -472,7 +472,7 @@ const TIGHTEST = new Map([
472
472
  ['maxItems', 'min'],
473
473
  ['maxProperties', 'min'],
474
474
  ]);
475
- const mergeAllOf = (schema) => {
475
+ export const mergeAllOf = (schema) => {
476
476
  const branches = hasAllOf(schema) ? schema.allOf : [];
477
477
  const merged = {};
478
478
  const properties = {};
@@ -2,6 +2,10 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
2
  /**
3
3
  * Generates a `fast-check` arbitrary that produces schema-valid values.
4
4
  *
5
+ * A schema that references itself is wrapped in `fc.letrec` so the recursion is
6
+ * tied lazily — a plain `const NodeArbitrary = fc.record({ next: NodeArbitrary })`
7
+ * would throw a TDZ `ReferenceError` the moment the module is imported.
8
+ *
5
9
  * @example
6
10
  * ```typescript
7
11
  * generateArbitrary({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, 'Info')
@@ -1,11 +1,14 @@
1
1
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
2
2
  import { refToName } from '@amritk/helpers/ref-to-name';
3
- 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';
3
+ 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';
4
+ import { mergeAllOf } from './derive-example.js';
4
5
  /**
5
6
  * Derives the arbitrary const name from a type name.
6
7
  * e.g. "User" → "UserArbitrary"
7
8
  */
8
9
  const arbitraryName = (typeName) => `${typeName}Arbitrary`;
10
+ /** The letrec key used for a type's own (self-referential) arbitrary. */
11
+ const SELF_KEY = 'self';
9
12
  /** Builds a `fc.string({ ... })` expression honouring format and length constraints. */
10
13
  const stringExpr = (schema) => {
11
14
  if (hasFormat(schema)) {
@@ -31,8 +34,20 @@ const stringExpr = (schema) => {
31
34
  return 'fc.ipV6()';
32
35
  }
33
36
  }
34
- if (hasPattern(schema))
35
- return `fc.stringMatching(/${schema.pattern}/)`;
37
+ if (hasPattern(schema)) {
38
+ // Build the regex via `new RegExp(<json-string>)` rather than inlining the
39
+ // pattern into a `/.../ ` literal: a pattern containing `/` (e.g. `^/api/v\d+$`)
40
+ // would otherwise close the literal early and emit invalid TypeScript.
41
+ const base = `fc.stringMatching(new RegExp(${JSON.stringify(schema.pattern)}))`;
42
+ // `stringMatching` takes no length bounds, so honour any min/maxLength with a
43
+ // filter instead of silently dropping them. Only emit it when a bound exists.
44
+ const checks = [];
45
+ if (hasMinLength(schema))
46
+ checks.push(`s.length >= ${schema.minLength}`);
47
+ if (hasMaxLength(schema))
48
+ checks.push(`s.length <= ${schema.maxLength}`);
49
+ return checks.length > 0 ? `${base}.filter((s) => ${checks.join(' && ')})` : base;
50
+ }
36
51
  const opts = [];
37
52
  if (hasMinLength(schema))
38
53
  opts.push(`minLength: ${schema.minLength}`);
@@ -43,14 +58,23 @@ const stringExpr = (schema) => {
43
58
  /** Builds a `fc.integer({ ... })` expression honouring range and multiple-of constraints. */
44
59
  const integerExpr = (schema) => {
45
60
  const opts = [];
61
+ // With both `minimum` and `exclusiveMinimum` present the effective lower bound
62
+ // is the tighter (larger) of the two, so combine them rather than letting one
63
+ // shadow the other via else-if.
64
+ const mins = [];
46
65
  if (hasMinimum(schema))
47
- opts.push(`min: ${schema.minimum}`);
48
- else if (hasExclusiveMinimum(schema))
49
- opts.push(`min: ${Number(schema.exclusiveMinimum) + 1}`);
66
+ mins.push(Number(schema.minimum));
67
+ if (hasExclusiveMinimum(schema))
68
+ mins.push(Number(schema.exclusiveMinimum) + 1);
69
+ if (mins.length > 0)
70
+ opts.push(`min: ${Math.max(...mins)}`);
71
+ const maxs = [];
50
72
  if (hasMaximum(schema))
51
- opts.push(`max: ${schema.maximum}`);
52
- else if (hasExclusiveMaximum(schema))
53
- opts.push(`max: ${Number(schema.exclusiveMaximum) - 1}`);
73
+ maxs.push(Number(schema.maximum));
74
+ if (hasExclusiveMaximum(schema))
75
+ maxs.push(Number(schema.exclusiveMaximum) - 1);
76
+ if (maxs.length > 0)
77
+ opts.push(`max: ${Math.min(...maxs)}`);
54
78
  const base = opts.length > 0 ? `fc.integer({ ${opts.join(', ')} })` : 'fc.integer()';
55
79
  return hasMultipleOf(schema) ? `${base}.filter((n) => n % ${schema.multipleOf} === 0)` : base;
56
80
  };
@@ -68,9 +92,24 @@ const numberExpr = (schema) => {
68
92
  const base = `fc.double({ ${opts.join(', ')} })`;
69
93
  return hasMultipleOf(schema) ? `${base}.filter((n) => n % ${schema.multipleOf} === 0)` : base;
70
94
  };
71
- /** Builds a `fc.array(...)` / `fc.uniqueArray(...)` expression for an array schema. */
72
- const arrayExpr = (schema, suffix) => {
73
- const items = hasItems(schema) && isSchemaObject(schema.items) ? arbitraryExpr(schema.items, suffix) : 'fc.anything()';
95
+ /** Builds a `fc.array(...)` / `fc.uniqueArray(...)` / `fc.tuple(...)` expression for an array schema. */
96
+ const arrayExpr = (schema, ctx) => {
97
+ const raw = schema;
98
+ // A tuple is `prefixItems` (2020-12) or the draft-07 array-form `items`. Both
99
+ // describe one schema per position, so map them to `fc.tuple(...)`. Extra items
100
+ // beyond the prefix are unconstrained (or forbidden by `items: false`); the
101
+ // minimal tuple is schema-valid either way.
102
+ const prefixItems = raw['prefixItems'];
103
+ const tuple = Array.isArray(prefixItems)
104
+ ? prefixItems
105
+ : Array.isArray(raw['items'])
106
+ ? raw['items']
107
+ : undefined;
108
+ if (tuple) {
109
+ const exprs = tuple.map((item) => arbitraryExpr(item, ctx));
110
+ return `fc.tuple(${exprs.join(', ')})`;
111
+ }
112
+ const items = hasItems(schema) && isSchemaObject(schema.items) ? arbitraryExpr(schema.items, ctx) : 'fc.anything()';
74
113
  const opts = [];
75
114
  if (hasMinItems(schema))
76
115
  opts.push(`minLength: ${schema.minItems}`);
@@ -79,30 +118,43 @@ const arrayExpr = (schema, suffix) => {
79
118
  const fn = hasUniqueItems(schema) && schema.uniqueItems === true ? 'fc.uniqueArray' : 'fc.array';
80
119
  return opts.length > 0 ? `${fn}(${items}, { ${opts.join(', ')} })` : `${fn}(${items})`;
81
120
  };
82
- /** Builds a `fc.record(...)` expression for an object schema. */
83
- const objectExpr = (schema, suffix) => {
84
- if (!hasProperties(schema))
85
- return 'fc.object()';
121
+ /** Builds a `fc.record(...)` / `fc.dictionary(...)` expression for an object schema. */
122
+ const objectExpr = (schema, ctx) => {
123
+ // The `additionalProperties` value schema (when it constrains extra keys with a
124
+ // real subschema rather than the boolean true/false form).
125
+ const additional = hasAdditionalProperties(schema) ? schema.additionalProperties : false;
126
+ const additionalArb = isSchemaObject(additional) ? arbitraryExpr(additional, ctx) : undefined;
127
+ if (!hasProperties(schema)) {
128
+ // A map-style object (no declared properties) with a typed `additionalProperties`
129
+ // is a dictionary of that value type; otherwise fall back to a free-form object.
130
+ return additionalArb ? `fc.dictionary(fc.string(), ${additionalArb})` : 'fc.object()';
131
+ }
86
132
  const required = new Set(hasRequired(schema) ? schema.required : []);
87
133
  const keys = Object.keys(schema.properties);
88
- const entries = Object.entries(schema.properties).map(([key, propSchema]) => `${JSON.stringify(key)}: ${arbitraryExpr(propSchema, suffix)}`);
134
+ const entries = Object.entries(schema.properties).map(([key, propSchema]) => `${JSON.stringify(key)}: ${arbitraryExpr(propSchema, ctx)}`);
89
135
  if (entries.length === 0)
90
136
  return 'fc.record({})';
91
137
  const model = `{ ${entries.join(', ')} }`;
92
138
  // fc.record treats all keys as required by default. Only emit requiredKeys
93
139
  // when at least one property is optional.
94
- if (keys.every((key) => required.has(key)))
95
- return `fc.record(${model})`;
96
- const requiredKeys = [...required].map((key) => JSON.stringify(key)).join(', ');
97
- return `fc.record(${model}, { requiredKeys: [${requiredKeys}] })`;
140
+ const record = keys.every((key) => required.has(key))
141
+ ? `fc.record(${model})`
142
+ : `fc.record(${model}, { requiredKeys: [${[...required].map((key) => JSON.stringify(key)).join(', ')}] })`;
143
+ // When both declared properties and a typed `additionalProperties` are present,
144
+ // fold in a dictionary of extra keys so the value exercises the open-map part of
145
+ // the schema too. The declared keys win on collision (merged last).
146
+ if (additionalArb) {
147
+ return `fc.tuple(${record}, fc.dictionary(fc.string(), ${additionalArb})).map(([base, extra]) => ({ ...extra, ...base }))`;
148
+ }
149
+ return record;
98
150
  };
99
151
  /** Builds a `fc.oneof(...)` expression from a list of branch schemas. */
100
- const oneofExpr = (branches, suffix) => {
101
- const exprs = branches.map((branch) => arbitraryExpr(branch, suffix));
152
+ const oneofExpr = (branches, ctx) => {
153
+ const exprs = branches.map((branch) => arbitraryExpr(branch, ctx));
102
154
  return `fc.oneof(${exprs.join(', ')})`;
103
155
  };
104
156
  /** Builds the fast-check expression for a single (non-union) JSON Schema type. */
105
- const scalarExpr = (type, schema, suffix) => {
157
+ const scalarExpr = (type, schema, ctx) => {
106
158
  switch (type) {
107
159
  case 'string':
108
160
  return stringExpr(schema);
@@ -115,23 +167,32 @@ const scalarExpr = (type, schema, suffix) => {
115
167
  case 'null':
116
168
  return 'fc.constant(null)';
117
169
  case 'array':
118
- return arrayExpr(schema, suffix);
170
+ return arrayExpr(schema, ctx);
119
171
  case 'object':
120
- return objectExpr(schema, suffix);
172
+ return objectExpr(schema, ctx);
121
173
  default:
122
174
  return 'fc.anything()';
123
175
  }
124
176
  };
125
177
  /**
126
178
  * Recursively builds the fast-check arbitrary expression for a schema node.
127
- * `$ref`s resolve to the referenced file's exported arbitrary; everything else
128
- * maps to the appropriate `fc.*` combinator.
179
+ * `$ref`s resolve to the referenced file's exported arbitrary; a self-`$ref`
180
+ * resolves to `tie('self')` so recursive schemas tie lazily via `fc.letrec`.
181
+ * Everything else maps to the appropriate `fc.*` combinator.
129
182
  */
130
- const arbitraryExpr = (schema, suffix) => {
183
+ const arbitraryExpr = (schema, ctx) => {
131
184
  if (!isSchemaObject(schema))
132
185
  return 'fc.anything()';
133
- if (hasRef(schema))
134
- return arbitraryName(refToName(schema.$ref, suffix));
186
+ if (hasRef(schema)) {
187
+ const name = arbitraryName(refToName(schema.$ref, ctx.suffix));
188
+ // A reference back to the type being generated must be tied lazily; an eager
189
+ // identifier would touch a still-uninitialized const (TDZ) at import time.
190
+ if (name === ctx.selfArbName) {
191
+ ctx.usedTie.value = true;
192
+ return `tie(${JSON.stringify(SELF_KEY)})`;
193
+ }
194
+ return name;
195
+ }
135
196
  if (hasConst(schema))
136
197
  return `fc.constant(${JSON.stringify(schema.const)})`;
137
198
  if (hasEnum(schema)) {
@@ -148,16 +209,21 @@ const arbitraryExpr = (schema, suffix) => {
148
209
  return 'fc.bigInt()';
149
210
  if (primitive)
150
211
  return 'fc.anything()';
212
+ // `allOf` must satisfy every branch at once. fast-check has no generic
213
+ // intersection combinator, so flatten the branches into one merged schema
214
+ // (tightest bounds, unioned required, merged properties) and generate from it.
215
+ if (hasAllOf(schema))
216
+ return arbitraryExpr(mergeAllOf(schema), ctx);
151
217
  if (hasOneOf(schema))
152
- return oneofExpr(schema.oneOf, suffix);
218
+ return oneofExpr(schema.oneOf, ctx);
153
219
  if (hasAnyOf(schema))
154
- return oneofExpr(schema.anyOf, suffix);
220
+ return oneofExpr(schema.anyOf, ctx);
155
221
  if (hasType(schema))
156
- return scalarExpr(schema.type, schema, suffix);
222
+ return scalarExpr(schema.type, schema, ctx);
157
223
  // Multi-type schemas (`type: ['string', 'null']`) become a oneof over each
158
224
  // member type; `hasType` only matches a single string `type`.
159
225
  if (Array.isArray(schema.type)) {
160
- const exprs = schema.type.map((type) => scalarExpr(type, schema, suffix));
226
+ const exprs = schema.type.map((type) => scalarExpr(type, schema, ctx));
161
227
  return exprs.length === 1 ? exprs[0] : `fc.oneof(${exprs.join(', ')})`;
162
228
  }
163
229
  return 'fc.anything()';
@@ -165,6 +231,10 @@ const arbitraryExpr = (schema, suffix) => {
165
231
  /**
166
232
  * Generates a `fast-check` arbitrary that produces schema-valid values.
167
233
  *
234
+ * A schema that references itself is wrapped in `fc.letrec` so the recursion is
235
+ * tied lazily — a plain `const NodeArbitrary = fc.record({ next: NodeArbitrary })`
236
+ * would throw a TDZ `ReferenceError` the moment the module is imported.
237
+ *
168
238
  * @example
169
239
  * ```typescript
170
240
  * generateArbitrary({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, 'Info')
@@ -172,6 +242,11 @@ const arbitraryExpr = (schema, suffix) => {
172
242
  * ```
173
243
  */
174
244
  export const generateArbitrary = (schema, typeName, suffix = '') => {
175
- const expr = arbitraryExpr(schema, suffix);
176
- return `export const ${arbitraryName(typeName)}: fc.Arbitrary<${typeName}> = ${expr}`;
245
+ const selfArbName = arbitraryName(typeName);
246
+ const ctx = { suffix, selfArbName, usedTie: { value: false } };
247
+ const expr = arbitraryExpr(schema, ctx);
248
+ if (ctx.usedTie.value) {
249
+ return `export const ${selfArbName}: fc.Arbitrary<${typeName}> = fc.letrec<{ ${SELF_KEY}: ${typeName} }>((tie) => ({\n ${SELF_KEY}: ${expr},\n})).${SELF_KEY}`;
250
+ }
251
+ return `export const ${selfArbName}: fc.Arbitrary<${typeName}> = ${expr}`;
177
252
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-examples",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Generate fast-check arbitraries and example values from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -41,13 +41,13 @@
41
41
  },
42
42
  "exports": {
43
43
  ".": {
44
- "default": "./dist/index.js",
45
- "types": "./dist/index.d.ts"
44
+ "types": "./dist/index.d.ts",
45
+ "default": "./dist/index.js"
46
46
  }
47
47
  },
48
48
  "dependencies": {
49
49
  "json-schema-typed": "^8.0.1",
50
- "@amritk/helpers": "0.10.1"
50
+ "@amritk/helpers": "0.10.2"
51
51
  },
52
52
  "devDependencies": {
53
53
  "ajv": "^8.17.1"