@amritk/generate-examples 0.3.2 → 0.4.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.
@@ -1,11 +1,173 @@
1
1
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
2
2
  import { resolveRef } from '@amritk/helpers/resolve-ref';
3
- import { hasAnyOf, hasConst, hasDefault, hasEnum, hasExamples, hasFormat, hasItems, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasOneOf, hasProperties, hasRef, hasType, isSchemaObject, } from '@amritk/helpers/schema-guards';
3
+ import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasDefault, hasEnum, hasExamples, hasExclusiveMaximum, hasExclusiveMinimum, hasFormat, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
4
4
  /** Lowercases the first character of a name. e.g. "User" → "user" */
5
5
  const lowerFirst = (name) => name.charAt(0).toLowerCase() + name.slice(1);
6
6
  /** Derives the example const name from a type name. e.g. "User" → "userExample" */
7
7
  const exampleName = (typeName) => `${lowerFirst(typeName)}Example`;
8
- /** Returns a representative string honouring `format` and length constraints. */
8
+ /** A representative character for a single regex atom (class body / escape). */
9
+ const charForClass = (inner) => {
10
+ if (/a-z/.test(inner))
11
+ return 'a';
12
+ if (/A-Z/.test(inner))
13
+ return 'A';
14
+ if (/0-9|\\d/.test(inner))
15
+ return '5';
16
+ const first = inner.replace(/^\^/, '')[0];
17
+ return first && first !== '\\' ? first : 'a';
18
+ };
19
+ const charForEscape = (esc) => {
20
+ if (esc === '\\d')
21
+ return '5';
22
+ if (esc === '\\w')
23
+ return 'a';
24
+ if (esc === '\\s')
25
+ return ' ';
26
+ return esc[1] ?? 'a';
27
+ };
28
+ /** Splits `s` on top-level `|`, respecting `[...]` and `(...)` nesting. */
29
+ const topLevelAlternatives = (s) => {
30
+ const parts = [];
31
+ let depth = 0;
32
+ let inClass = false;
33
+ let cur = '';
34
+ for (let i = 0; i < s.length; i++) {
35
+ const c = s[i];
36
+ if (c === '\\') {
37
+ cur += c + (s[i + 1] ?? '');
38
+ i++;
39
+ continue;
40
+ }
41
+ if (inClass) {
42
+ cur += c;
43
+ if (c === ']')
44
+ inClass = false;
45
+ continue;
46
+ }
47
+ if (c === '[')
48
+ inClass = true;
49
+ else if (c === '(')
50
+ depth++;
51
+ else if (c === ')')
52
+ depth--;
53
+ else if (c === '|' && depth === 0) {
54
+ parts.push(cur);
55
+ cur = '';
56
+ continue;
57
+ }
58
+ cur += c;
59
+ }
60
+ parts.push(cur);
61
+ return parts;
62
+ };
63
+ /**
64
+ * Best-effort generator of a string matching a `pattern`, via recursive descent:
65
+ * anchors, literals, `.`, escapes (`\d`/`\w`/`\s`), character classes, groups
66
+ * (capturing / non-capturing / named), alternation (`a|b` — picks the first
67
+ * usable branch), and the `+`/`*`/`?`/`{n}`/`{n,m}` quantifiers. Lookarounds and
68
+ * backreferences fall through to `undefined`. The caller verifies the result
69
+ * against the real regex and only uses it on a match, so a partial sampler never
70
+ * makes the example worse — it just upgrades the cases it understands.
71
+ */
72
+ const sampleFromPattern = (pattern, minLength) => {
73
+ let body = pattern;
74
+ if (body.startsWith('^'))
75
+ body = body.slice(1);
76
+ if (body.endsWith('$') && !body.endsWith('\\$'))
77
+ body = body.slice(0, -1);
78
+ // Samples one alternation, preferring the first branch that samples cleanly.
79
+ const sampleAlt = (s) => {
80
+ for (const alt of topLevelAlternatives(s)) {
81
+ const r = sampleSeq(alt);
82
+ if (r !== undefined)
83
+ return r;
84
+ }
85
+ return undefined;
86
+ };
87
+ // Samples one concatenation (no top-level `|`).
88
+ const sampleSeq = (seq) => {
89
+ let out = '';
90
+ let i = 0;
91
+ while (i < seq.length) {
92
+ let unit;
93
+ const c = seq[i];
94
+ if (c === '(') {
95
+ // Find the matching close paren.
96
+ let depth = 1;
97
+ let j = i + 1;
98
+ for (; j < seq.length && depth > 0; j++) {
99
+ const cj = seq[j];
100
+ if (cj === '\\') {
101
+ j++;
102
+ continue;
103
+ }
104
+ if (cj === '(')
105
+ depth++;
106
+ else if (cj === ')')
107
+ depth--;
108
+ }
109
+ if (depth !== 0)
110
+ return undefined;
111
+ let inner = seq.slice(i + 1, j - 1);
112
+ if (/^\?[=!]/.test(inner) || /^\?<[=!]/.test(inner))
113
+ return undefined; // lookaround
114
+ inner = inner.replace(/^\?:/, '').replace(/^\?<[^>]*>/, '');
115
+ unit = sampleAlt(inner);
116
+ if (unit === undefined)
117
+ return undefined;
118
+ i = j;
119
+ }
120
+ else if (c === '[') {
121
+ const end = seq.indexOf(']', i + 1);
122
+ if (end === -1)
123
+ return undefined;
124
+ unit = charForClass(seq.slice(i + 1, end));
125
+ i = end + 1;
126
+ }
127
+ else if (c === '\\') {
128
+ const esc = seq.slice(i, i + 2);
129
+ if (/\d/.test(esc[1] ?? ''))
130
+ return undefined; // backreference
131
+ unit = charForEscape(esc);
132
+ i += 2;
133
+ }
134
+ else if (c === '.') {
135
+ unit = 'a';
136
+ i++;
137
+ }
138
+ else {
139
+ unit = c;
140
+ i++;
141
+ }
142
+ // Optional quantifier.
143
+ let reps = 1;
144
+ const q = seq[i];
145
+ if (q === '+') {
146
+ reps = Math.max(1, minLength);
147
+ i++;
148
+ }
149
+ else if (q === '*') {
150
+ reps = Math.max(0, minLength);
151
+ i++;
152
+ }
153
+ else if (q === '?') {
154
+ reps = 1;
155
+ i++;
156
+ }
157
+ else if (q === '{') {
158
+ const end = seq.indexOf('}', i + 1);
159
+ if (end === -1)
160
+ return undefined;
161
+ reps = Number.parseInt(seq.slice(i + 1, end), 10) || 0;
162
+ i = end + 1;
163
+ }
164
+ out += unit.repeat(reps);
165
+ }
166
+ return out;
167
+ };
168
+ return sampleAlt(body);
169
+ };
170
+ /** Returns a representative string honouring `format`, `pattern`, and length. */
9
171
  const exampleString = (schema) => {
10
172
  if (hasFormat(schema)) {
11
173
  switch (schema.format) {
@@ -30,9 +192,18 @@ const exampleString = (schema) => {
30
192
  return '::1';
31
193
  }
32
194
  }
195
+ const minLength = hasMinLength(schema) ? schema.minLength : 0;
196
+ if (hasPattern(schema)) {
197
+ const sampled = sampleFromPattern(schema.pattern, minLength);
198
+ // Only trust the sampler when it actually matches and fits the length bound.
199
+ if (sampled !== undefined && new RegExp(schema.pattern).test(sampled)) {
200
+ if (!(hasMaxLength(schema) && sampled.length > schema.maxLength))
201
+ return sampled;
202
+ }
203
+ }
33
204
  let value = 'string';
34
- if (hasMinLength(schema) && value.length < schema.minLength)
35
- value = value.padEnd(schema.minLength, 'x');
205
+ if (value.length < minLength)
206
+ value = value.padEnd(minLength, 'x');
36
207
  if (hasMaxLength(schema) && value.length > schema.maxLength)
37
208
  value = value.slice(0, schema.maxLength);
38
209
  return value;
@@ -57,8 +228,12 @@ export const deriveExample = (schema, rootSchema, seen = new Set()) => {
57
228
  return schema.examples[0];
58
229
  if (hasDefault(schema))
59
230
  return schema.default;
60
- if (hasEnum(schema) && schema.enum.length > 0)
61
- return schema.enum[0];
231
+ if (hasEnum(schema) && schema.enum.length > 0) {
232
+ // Prefer the first member that also satisfies any sibling length/range
233
+ // constraints (e.g. `enum` + `minLength`), falling back to the first member.
234
+ const fitting = schema.enum.find((value) => satisfiesScalarConstraints(schema, value));
235
+ return fitting !== undefined ? fitting : schema.enum[0];
236
+ }
62
237
  if (hasRef(schema)) {
63
238
  const ref = schema.$ref;
64
239
  if (seen.has(ref) || !rootSchema)
@@ -74,6 +249,11 @@ export const deriveExample = (schema, rootSchema, seen = new Set()) => {
74
249
  const primitive = getMjstPrimitive(schema);
75
250
  if (primitive === 'bigint')
76
251
  return 0n;
252
+ // `allOf` must satisfy every branch at once, so derive from a single schema
253
+ // that merges the branches (and the node's own keywords) rather than picking
254
+ // one branch — picking one would ignore the others' constraints.
255
+ if (hasAllOf(schema))
256
+ return deriveExample(mergeAllOf(schema), rootSchema, seen);
77
257
  if (hasOneOf(schema) && schema.oneOf[0] !== undefined)
78
258
  return deriveExample(schema.oneOf[0], rootSchema, seen);
79
259
  if (hasAnyOf(schema) && schema.anyOf[0] !== undefined)
@@ -87,6 +267,32 @@ export const deriveExample = (schema, rootSchema, seen = new Set()) => {
87
267
  }
88
268
  return null;
89
269
  };
270
+ /**
271
+ * True when a candidate value (e.g. an `enum`/`const` member) satisfies the
272
+ * node's simple string-length and numeric-range constraints. Used to pick an
273
+ * `enum` member that also meets a sibling `minLength`/`minimum`/etc.
274
+ */
275
+ const satisfiesScalarConstraints = (schema, value) => {
276
+ if (typeof value === 'string') {
277
+ if (hasMinLength(schema) && value.length < schema.minLength)
278
+ return false;
279
+ if (hasMaxLength(schema) && value.length > schema.maxLength)
280
+ return false;
281
+ }
282
+ else if (typeof value === 'number') {
283
+ if (hasMinimum(schema) && value < schema.minimum)
284
+ return false;
285
+ if (hasMaximum(schema) && value > schema.maximum)
286
+ return false;
287
+ if (hasExclusiveMinimum(schema) && value <= schema.exclusiveMinimum)
288
+ return false;
289
+ if (hasExclusiveMaximum(schema) && value >= schema.exclusiveMaximum)
290
+ return false;
291
+ if (hasMultipleOf(schema) && schema.multipleOf > 0 && value % schema.multipleOf !== 0)
292
+ return false;
293
+ }
294
+ return true;
295
+ };
90
296
  /** Derives a canonical value for a single declared `type`. */
91
297
  const deriveForType = (type, schema, rootSchema, seen) => {
92
298
  switch (type) {
@@ -94,16 +300,13 @@ const deriveForType = (type, schema, rootSchema, seen) => {
94
300
  return exampleString(schema);
95
301
  case 'number':
96
302
  case 'integer':
97
- return hasMinimum(schema) ? schema.minimum : 0;
303
+ return deriveNumber(schema, type === 'integer');
98
304
  case 'boolean':
99
305
  return true;
100
306
  case 'null':
101
307
  return null;
102
- case 'array': {
103
- const item = hasItems(schema) ? deriveExample(schema.items, rootSchema, seen) : null;
104
- const count = hasMinItems(schema) ? Math.max(schema.minItems, 1) : 1;
105
- return Array.from({ length: count }, () => item);
106
- }
308
+ case 'array':
309
+ return deriveArray(schema, rootSchema, seen);
107
310
  case 'object': {
108
311
  const out = {};
109
312
  if (hasProperties(schema)) {
@@ -111,12 +314,218 @@ const deriveForType = (type, schema, rootSchema, seen) => {
111
314
  out[key] = deriveExample(propSchema, rootSchema, seen);
112
315
  }
113
316
  }
317
+ const additional = hasAdditionalProperties(schema) ? schema.additionalProperties : false;
318
+ const additionalSchema = isSchemaObject(additional) ? additional : undefined;
319
+ // Extra keys are allowed unless `additionalProperties: false` forbids them.
320
+ const extrasAllowed = !(hasAdditionalProperties(schema) && schema.additionalProperties === false);
321
+ // A required key with no `properties` entry still needs a value. Use the
322
+ // `additionalProperties` schema when one constrains it, else a null.
323
+ if (hasRequired(schema)) {
324
+ for (const key of schema.required) {
325
+ if (key in out)
326
+ continue;
327
+ out[key] = additionalSchema ? deriveExample(additionalSchema, rootSchema, seen) : null;
328
+ }
329
+ }
330
+ // Synthesize filler keys to reach `minProperties` when extras are allowed.
331
+ if (hasMinProperties(schema) && extrasAllowed) {
332
+ let n = 0;
333
+ while (Object.keys(out).length < schema.minProperties) {
334
+ const key = `extra${n++}`;
335
+ if (key in out)
336
+ continue;
337
+ out[key] = additionalSchema ? deriveExample(additionalSchema, rootSchema, seen) : null;
338
+ }
339
+ }
114
340
  return out;
115
341
  }
116
342
  default:
117
343
  return null;
118
344
  }
119
345
  };
346
+ /**
347
+ * Picks a number satisfying the node's bounds and `multipleOf`. Starts at the
348
+ * lower bound (or 0 when unbounded), nudges past an exclusive bound, then rounds
349
+ * up to the nearest multiple. An unsatisfiable range (e.g. `minimum > maximum`)
350
+ * can't be met and falls back to the lower bound.
351
+ */
352
+ const deriveNumber = (schema, isInteger) => {
353
+ const step = isInteger ? 1 : 0.5;
354
+ let lo = -Infinity;
355
+ if (hasMinimum(schema))
356
+ lo = Math.max(lo, schema.minimum);
357
+ if (hasExclusiveMinimum(schema))
358
+ lo = Math.max(lo, schema.exclusiveMinimum + step);
359
+ const hi = hasMaximum(schema)
360
+ ? schema.maximum
361
+ : hasExclusiveMaximum(schema)
362
+ ? schema.exclusiveMaximum - step
363
+ : Number.POSITIVE_INFINITY;
364
+ // Base candidate: the lower bound, or 0 (or the upper bound) when unbounded below.
365
+ let value = Number.isFinite(lo) ? lo : Number.isFinite(hi) ? Math.min(0, hi) : 0;
366
+ if (isInteger)
367
+ value = Math.ceil(value);
368
+ if (hasMultipleOf(schema) && schema.multipleOf > 0) {
369
+ const m = schema.multipleOf;
370
+ value = Math.ceil(value / m - 1e-9) * m;
371
+ // Rounding up can overshoot the upper bound; drop to the largest multiple
372
+ // that fits. (If even that falls below `lo`, the range has no multiple — an
373
+ // unsatisfiable schema — and we return the in-range candidate as best effort.)
374
+ if (value > hi && Number.isFinite(hi))
375
+ value = Math.floor(hi / m + 1e-9) * m;
376
+ }
377
+ // `+ 0` normalizes a `-0` (which `Math.ceil`/`Math.floor` can produce) to `0`.
378
+ return (isInteger ? Math.round(value) : value) + 0;
379
+ };
380
+ /**
381
+ * Derives an array value. A tuple schema (`prefixItems`, or the draft-07
382
+ * array-form `items`) derives one value per position; a uniform array repeats a
383
+ * single item value. The count is clamped into `[minItems, maxItems]` so a
384
+ * `maxItems: 0` yields `[]` and a `minItems` is always met.
385
+ */
386
+ const deriveArray = (schema, rootSchema, seen) => {
387
+ const items = hasItems(schema) ? schema.items : undefined;
388
+ const prefixItems = schema['prefixItems'];
389
+ // A tuple is `prefixItems` (2020-12) or the draft-07 array-form `items`.
390
+ const prefix = Array.isArray(prefixItems)
391
+ ? prefixItems
392
+ : Array.isArray(items)
393
+ ? items
394
+ : undefined;
395
+ const min = hasMinItems(schema) ? schema.minItems : 0;
396
+ const max = hasMaxItems(schema) ? schema.maxItems : Number.POSITIVE_INFINITY;
397
+ // The rest/uniform element schema (the singular object-form `items`). A boolean
398
+ // `items` (`true`/`false`) is not a value-producing schema, so it is not `rest`.
399
+ const rest = items !== undefined && !Array.isArray(items) && isSchemaObject(items) ? items : undefined;
400
+ if (prefix) {
401
+ const tuple = prefix.map((item) => deriveExample(item, rootSchema, seen));
402
+ // Pad up to `minItems`: with the rest schema when one is present, otherwise
403
+ // (in 2020-12 additional items past `prefixItems` are unconstrained unless
404
+ // `items: false`) with a plain `null`.
405
+ const itemsClosed = schema['items'] === false;
406
+ while (tuple.length < min && tuple.length < max) {
407
+ if (rest !== undefined)
408
+ tuple.push(deriveExample(rest, rootSchema, seen));
409
+ else if (itemsClosed)
410
+ break;
411
+ else
412
+ tuple.push(null);
413
+ }
414
+ return tuple.length > max ? tuple.slice(0, max) : tuple;
415
+ }
416
+ const raw = schema;
417
+ const containsRaw = raw['contains'];
418
+ const contains = containsRaw !== undefined && isSchemaObject(containsRaw) ? containsRaw : undefined;
419
+ const minContains = contains !== undefined ? (typeof raw['minContains'] === 'number' ? raw['minContains'] : 1) : 0;
420
+ const unique = hasUniqueItems(schema) && schema.uniqueItems === true;
421
+ // The element schema: the uniform `items`, else the `contains` subschema.
422
+ const elem = rest ?? contains;
423
+ // Prefer a non-empty example, satisfy `minItems` and `minContains`, never exceed `maxItems`.
424
+ const count = Math.min(Math.max(min, minContains, max === 0 ? 0 : 1), max);
425
+ const result = [];
426
+ for (let i = 0; i < count; i++) {
427
+ // Make the first `minContains` items satisfy `contains`; the rest use `items`.
428
+ const itemSchema = contains !== undefined && i < minContains ? contains : elem;
429
+ const base = itemSchema !== undefined ? deriveExample(itemSchema, rootSchema, seen) : null;
430
+ result.push(unique ? distinctify(base, i, itemSchema) : base);
431
+ }
432
+ return result;
433
+ };
434
+ /**
435
+ * Returns a value distinct from earlier ones for index `i`, used to satisfy
436
+ * `uniqueItems`, while staying within the item schema's constraints: numbers step
437
+ * by `multipleOf` (so the perturbed values remain valid multiples) rather than by
438
+ * 1, strings are suffixed, booleans alternated. Values that can't be cheaply
439
+ * varied are returned as-is (a best-effort the generated `fast-check` arbitrary
440
+ * covers fully).
441
+ */
442
+ const distinctify = (base, i, itemSchema) => {
443
+ if (i === 0)
444
+ return base;
445
+ if (typeof base === 'number') {
446
+ const step = itemSchema && isSchemaObject(itemSchema) && hasMultipleOf(itemSchema) && itemSchema.multipleOf > 0
447
+ ? itemSchema.multipleOf
448
+ : 1;
449
+ return base + i * step;
450
+ }
451
+ if (typeof base === 'string')
452
+ return `${base}${i}`;
453
+ if (typeof base === 'boolean')
454
+ return i % 2 === 1 ? !base : base;
455
+ return base;
456
+ };
457
+ /**
458
+ * Flattens an `allOf` into a single schema: object `properties` are merged and
459
+ * `required` unioned across branches, while scalar keywords from later branches
460
+ * (and the node's own keywords) win. `allOf` itself is dropped so the merged
461
+ * schema derives directly.
462
+ */
463
+ const TIGHTEST = new Map([
464
+ ['minimum', 'max'],
465
+ ['exclusiveMinimum', 'max'],
466
+ ['minLength', 'max'],
467
+ ['minItems', 'max'],
468
+ ['minProperties', 'max'],
469
+ ['maximum', 'min'],
470
+ ['exclusiveMaximum', 'min'],
471
+ ['maxLength', 'min'],
472
+ ['maxItems', 'min'],
473
+ ['maxProperties', 'min'],
474
+ ]);
475
+ const mergeAllOf = (schema) => {
476
+ const branches = hasAllOf(schema) ? schema.allOf : [];
477
+ const merged = {};
478
+ const properties = {};
479
+ const required = new Set();
480
+ for (const branch of [...branches, schema]) {
481
+ if (!isSchemaObject(branch))
482
+ continue;
483
+ for (const [key, value] of Object.entries(branch)) {
484
+ if (key === 'allOf')
485
+ continue;
486
+ if (key === 'properties' && value && typeof value === 'object') {
487
+ // The same property constrained by several branches must satisfy all of
488
+ // them, so collect each schema and combine them below rather than letting
489
+ // a later branch's schema silently replace an earlier one.
490
+ for (const [prop, propSchema] of Object.entries(value)) {
491
+ const bucket = properties[prop];
492
+ if (bucket)
493
+ bucket.push(propSchema);
494
+ else
495
+ properties[prop] = [propSchema];
496
+ }
497
+ }
498
+ else if (key === 'required' && Array.isArray(value)) {
499
+ for (const r of value)
500
+ required.add(r);
501
+ }
502
+ else if (key === 'enum' && Array.isArray(value)) {
503
+ // A value must be in *every* branch's enum, so intersect rather than let
504
+ // a later branch's enum replace an earlier one (which could pick a member
505
+ // the earlier branch rejects).
506
+ merged['enum'] = Array.isArray(merged['enum'])
507
+ ? merged['enum'].filter((member) => value.includes(member))
508
+ : value;
509
+ }
510
+ else if (TIGHTEST.has(key) && typeof value === 'number' && typeof merged[key] === 'number') {
511
+ // Numeric bounds from different branches combine to the tightest one.
512
+ merged[key] = TIGHTEST.get(key) === 'max' ? Math.max(merged[key], value) : Math.min(merged[key], value);
513
+ }
514
+ else {
515
+ merged[key] = value;
516
+ }
517
+ }
518
+ }
519
+ const mergedProps = {};
520
+ for (const [prop, schemas] of Object.entries(properties)) {
521
+ mergedProps[prop] = schemas.length === 1 ? schemas[0] : { allOf: schemas };
522
+ }
523
+ if (Object.keys(mergedProps).length > 0)
524
+ merged['properties'] = mergedProps;
525
+ if (required.size > 0)
526
+ merged['required'] = [...required];
527
+ return merged;
528
+ };
120
529
  /**
121
530
  * Serializes a derived value into a TypeScript source expression. Handles the
122
531
  * non-JSON values `deriveExample` can produce (`Date`, `bigint`) in addition to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-examples",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Generate fast-check arbitraries and example values from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -49,6 +49,9 @@
49
49
  "json-schema-typed": "^8.0.1",
50
50
  "@amritk/helpers": "0.10.0"
51
51
  },
52
+ "devDependencies": {
53
+ "ajv": "^8.17.1"
54
+ },
52
55
  "peerDependencies": {
53
56
  "fast-check": ">=3"
54
57
  },