@amritk/generate-examples 0.4.4 → 0.5.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.
- package/README.md +13 -6
- package/dist/generators/build-schema.js +8 -0
- package/dist/generators/collect-example-imports.js +45 -34
- package/dist/generators/derive-example.js +211 -32
- package/dist/generators/find-schema-cycles.d.ts +27 -0
- package/dist/generators/find-schema-cycles.js +124 -0
- package/dist/generators/generate-arbitrary.d.ts +18 -1
- package/dist/generators/generate-arbitrary.js +264 -25
- package/dist/generators/generate-files.d.ts +6 -0
- package/dist/generators/generate-files.js +7 -2
- package/dist/generators/schema-validation.d.ts +20 -0
- package/dist/generators/schema-validation.js +83 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -32,8 +32,11 @@ An `index.ts` barrel re-exports everything.
|
|
|
32
32
|
|
|
33
33
|
> [!NOTE]
|
|
34
34
|
> The generated arbitraries import `fast-check`, so consumers need it installed
|
|
35
|
-
> (`npm i -D fast-check`).
|
|
36
|
-
>
|
|
35
|
+
> (`npm i -D fast-check`). An arbitrary whose schema uses a keyword no `fc.*`
|
|
36
|
+
> combinator captures on its own (`if`/`then`/`else`, `not`, exclusive `oneOf`,
|
|
37
|
+
> and the presence-gated object keywords) also imports `@amritk/runtime-validators`
|
|
38
|
+
> for a post-generation validating filter; files that need no such filter don't.
|
|
39
|
+
> The static `fooExample` values have no runtime dependencies.
|
|
37
40
|
|
|
38
41
|
---
|
|
39
42
|
|
|
@@ -125,10 +128,14 @@ const res = await fetch('/users', { method: 'POST', body: JSON.stringify(userExa
|
|
|
125
128
|
`required`, `items`, `minItems`/`maxItems`, `uniqueItems`,
|
|
126
129
|
`minLength`/`maxLength`, `pattern`, `format` (`email`, `uuid`, `uri`/`url`,
|
|
127
130
|
`date`, `date-time`, `time`, `hostname`, `ipv4`, `ipv6`), `minimum`/`maximum`,
|
|
128
|
-
`exclusiveMinimum`/`exclusiveMaximum`, `multipleOf`, `enum
|
|
129
|
-
`
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
`exclusiveMinimum`/`exclusiveMaximum`, `multipleOf`, `enum` (filtered by sibling
|
|
132
|
+
constraints), `const`, `minProperties`/`maxProperties`, `patternProperties`,
|
|
133
|
+
`propertyNames`, `dependentRequired`, `dependentSchemas`, `contains`,
|
|
134
|
+
`oneOf`/`anyOf`, `if`/`then`/`else`, `not`, `$ref`, and the `x-mjst` extension
|
|
135
|
+
(`Date`, `bigint`). `if`/`then`/`else`, `not`, and `oneOf` exclusivity are
|
|
136
|
+
enforced by validating generated candidates against the schema and
|
|
137
|
+
retrying/rejecting. Unsupported constructs degrade to `fc.anything()` in
|
|
138
|
+
arbitraries and `null` in static examples.
|
|
132
139
|
|
|
133
140
|
> [!TIP]
|
|
134
141
|
> A static example constrained only by `pattern` is not guaranteed to match the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { generateIndexBarrel } from '@amritk/helpers/generate-index-barrel';
|
|
2
2
|
import { walkRefGraph } from '@amritk/helpers/walk-ref-graph';
|
|
3
|
+
import { findSchemaCycles } from './find-schema-cycles.js';
|
|
3
4
|
import { generateExampleFile } from './generate-files.js';
|
|
4
5
|
/**
|
|
5
6
|
* Builds all TypeScript example files from a JSON Schema by traversing all
|
|
@@ -27,15 +28,22 @@ import { generateExampleFile } from './generate-files.js';
|
|
|
27
28
|
*/
|
|
28
29
|
export const buildExampleSchema = async (rootSchema, rootTypeName, typeSuffix = '') => {
|
|
29
30
|
const files = [];
|
|
31
|
+
// Cross-file `$ref` cycles (A→B→A across modules) must emit lazy references
|
|
32
|
+
// for their cycle edges; eager top-level references would crash with a
|
|
33
|
+
// circular-ESM TDZ error at import. Detect the cycles up front so each file
|
|
34
|
+
// knows which siblings to defer.
|
|
35
|
+
const cycles = findSchemaCycles(rootSchema, rootTypeName, typeSuffix);
|
|
30
36
|
walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
|
|
31
37
|
// `index` is reserved for the barrel below, so never let a definition of
|
|
32
38
|
// that name overwrite it.
|
|
33
39
|
if (node.filename === 'index')
|
|
34
40
|
return;
|
|
41
|
+
const lazyRefFilenames = cycles.get(node.filename);
|
|
35
42
|
const content = generateExampleFile(node.schema, node.typeName, {
|
|
36
43
|
rootSchema: node.rootSchema,
|
|
37
44
|
typeSuffix,
|
|
38
45
|
...(node.ref !== undefined ? { selfRef: node.ref } : {}),
|
|
46
|
+
...(lazyRefFilenames !== undefined ? { lazyRefFilenames } : {}),
|
|
39
47
|
});
|
|
40
48
|
files.push({ filename: `${node.filename}.ts`, content });
|
|
41
49
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
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
|
-
import { hasAdditionalProperties, hasAllOf, hasAnyOf,
|
|
4
|
+
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasOneOf, hasProperties, hasRef, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
5
5
|
/**
|
|
6
6
|
* Generates an import statement for a single $ref, importing both the generated
|
|
7
7
|
* type and its arbitrary from the ref's generated file.
|
|
@@ -13,43 +13,54 @@ const buildImport = (ref, suffix) => {
|
|
|
13
13
|
return `import { type ${typeName}, ${typeName}Arbitrary } from './${filename}.js'`;
|
|
14
14
|
};
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Recursively collects every `$ref` string reachable through the schema surface
|
|
17
|
+
* that the type and arbitrary generators traverse. A generated file's single
|
|
18
|
+
* import block must cover every ref those generators emit, so this walks the
|
|
19
|
+
* *same* nested surface `arbitraryExpr` (generate-arbitrary.ts) descends into —
|
|
20
|
+
* combinator branches, object `properties`/`patternProperties`/
|
|
21
|
+
* `additionalProperties`, and array `items`/`prefixItems` (both the single-schema
|
|
22
|
+
* and tuple array forms) — not just the top level. Missing any of these emits a
|
|
23
|
+
* bare `XxxArbitrary` identifier (or a bare `Xxx` type) with no matching import,
|
|
24
|
+
* producing TypeScript that fails to compile.
|
|
25
|
+
*
|
|
26
|
+
* `$ref` nodes short-circuit (matching `arbitraryExpr`, which resolves a `$ref`
|
|
27
|
+
* and ignores sibling keywords), so recursion is bounded by the schema's own
|
|
28
|
+
* structural nesting and cannot loop on a self-referential ref.
|
|
18
29
|
*/
|
|
19
|
-
const
|
|
20
|
-
if (
|
|
30
|
+
const collectRefs = (schema) => {
|
|
31
|
+
if (!isSchemaObject(schema))
|
|
21
32
|
return [];
|
|
33
|
+
if (hasRef(schema))
|
|
34
|
+
return [schema.$ref];
|
|
22
35
|
const refs = [];
|
|
23
|
-
|
|
24
|
-
refs.push(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
if (hasItems(schema) && hasRef(schema.items)) {
|
|
40
|
-
refs.push(schema.items.$ref);
|
|
41
|
-
}
|
|
42
|
-
if (hasAdditionalProperties(schema) && hasRef(schema.additionalProperties)) {
|
|
43
|
-
refs.push(schema.additionalProperties.$ref);
|
|
36
|
+
const visit = (sub) => {
|
|
37
|
+
refs.push(...collectRefs(sub));
|
|
38
|
+
};
|
|
39
|
+
if (hasOneOf(schema))
|
|
40
|
+
schema.oneOf.forEach(visit);
|
|
41
|
+
if (hasAnyOf(schema))
|
|
42
|
+
schema.anyOf.forEach(visit);
|
|
43
|
+
if (hasAllOf(schema))
|
|
44
|
+
schema.allOf.forEach(visit);
|
|
45
|
+
if (hasProperties(schema))
|
|
46
|
+
Object.values(schema.properties).forEach(visit);
|
|
47
|
+
const raw = schema;
|
|
48
|
+
const patternProperties = raw['patternProperties'];
|
|
49
|
+
if (typeof patternProperties === 'object' && patternProperties !== null) {
|
|
50
|
+
Object.values(patternProperties).forEach(visit);
|
|
44
51
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
...(hasAnyOf(schema) ? schema.anyOf : []),
|
|
48
|
-
...(hasAllOf(schema) ? schema.allOf : []),
|
|
49
|
-
]) {
|
|
50
|
-
if (hasRef(branch))
|
|
51
|
-
refs.push(branch.$ref);
|
|
52
|
+
if (hasAdditionalProperties(schema) && isSchemaObject(schema.additionalProperties)) {
|
|
53
|
+
visit(schema.additionalProperties);
|
|
52
54
|
}
|
|
55
|
+
const prefixItems = raw['prefixItems'];
|
|
56
|
+
if (Array.isArray(prefixItems))
|
|
57
|
+
prefixItems.forEach(visit);
|
|
58
|
+
const items = raw['items'];
|
|
59
|
+
// `items` is either a tuple (draft-07 array form) or a single item schema.
|
|
60
|
+
if (Array.isArray(items))
|
|
61
|
+
items.forEach(visit);
|
|
62
|
+
else if (isSchemaObject(items))
|
|
63
|
+
visit(items);
|
|
53
64
|
return refs;
|
|
54
65
|
};
|
|
55
66
|
/**
|
|
@@ -67,7 +78,7 @@ export const collectExampleImports = (schema, options) => {
|
|
|
67
78
|
const selfFilename = options?.selfRef ? refToFilename(options.selfRef) : null;
|
|
68
79
|
const rootSchema = options?.rootSchema;
|
|
69
80
|
const typeSuffix = options?.typeSuffix ?? '';
|
|
70
|
-
const refs =
|
|
81
|
+
const refs = collectRefs(schema);
|
|
71
82
|
const seen = new Set();
|
|
72
83
|
const imports = [];
|
|
73
84
|
for (const ref of refs) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
|
|
2
2
|
import { resolveRef } from '@amritk/helpers/resolve-ref';
|
|
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';
|
|
3
|
+
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasDefault, hasDependentRequired, hasDependentSchemas, hasEnum, hasExamples, hasExclusiveMaximum, hasExclusiveMinimum, hasFormat, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasOneOf, hasPattern, hasPatternProperties, hasProperties, hasPropertyNames, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
4
|
+
import { makeInstanceCheck, needsValidationFilter } from './schema-validation.js';
|
|
4
5
|
/** Lowercases the first character of a name. e.g. "User" → "user" */
|
|
5
6
|
const lowerFirst = (name) => name.charAt(0).toLowerCase() + name.slice(1);
|
|
6
7
|
/** Derives the example const name from a type name. e.g. "User" → "userExample" */
|
|
@@ -220,6 +221,16 @@ const exampleString = (schema) => {
|
|
|
220
221
|
* pattern — use the generated arbitrary when pattern fidelity matters.
|
|
221
222
|
*/
|
|
222
223
|
export const deriveExample = (schema, rootSchema, seen = new Set()) => {
|
|
224
|
+
if (!isSchemaObject(schema))
|
|
225
|
+
return null;
|
|
226
|
+
const base = deriveBase(schema, rootSchema, seen);
|
|
227
|
+
// Keywords the structural deriver can't fully honour (`if`/`then`/`else`,
|
|
228
|
+
// `not`, `oneOf` exclusivity) are reconciled by validating candidates against a
|
|
229
|
+
// real validator and picking the first that passes.
|
|
230
|
+
return needsValidationFilter(schema) ? refineExample(schema, base, rootSchema, seen) : base;
|
|
231
|
+
};
|
|
232
|
+
/** Structural derivation for a node, before any validating refinement. */
|
|
233
|
+
const deriveBase = (schema, rootSchema, seen) => {
|
|
223
234
|
if (!isSchemaObject(schema))
|
|
224
235
|
return null;
|
|
225
236
|
if (hasConst(schema))
|
|
@@ -267,6 +278,50 @@ export const deriveExample = (schema, rootSchema, seen = new Set()) => {
|
|
|
267
278
|
}
|
|
268
279
|
return null;
|
|
269
280
|
};
|
|
281
|
+
/** The applicator keywords whose satisfaction is reconciled by {@link refineExample}. */
|
|
282
|
+
const REFINED_APPLICATORS = ['if', 'then', 'else', 'not', 'oneOf'];
|
|
283
|
+
/** A shallow copy of `schema` with the refined applicator keywords removed. */
|
|
284
|
+
const structuralOnly = (schema) => {
|
|
285
|
+
const clone = { ...schema };
|
|
286
|
+
for (const key of REFINED_APPLICATORS)
|
|
287
|
+
delete clone[key];
|
|
288
|
+
return clone;
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* Reconciles keywords the structural deriver can't satisfy on its own
|
|
292
|
+
* (`if`/`then`/`else`, `not`, `oneOf` exclusivity). It validates the structural
|
|
293
|
+
* `base` against the full schema and, if it fails, tries alternative candidates —
|
|
294
|
+
* each `oneOf` branch, and the `then`/`else` branches merged with the structural
|
|
295
|
+
* siblings — returning the first that validates. Falls back to `base` when none
|
|
296
|
+
* do (a best-effort for schemas that can't be satisfied structurally, e.g. an
|
|
297
|
+
* adversarial `not`).
|
|
298
|
+
*/
|
|
299
|
+
const refineExample = (schema, base, rootSchema, seen) => {
|
|
300
|
+
const check = makeInstanceCheck(schema, rootSchema);
|
|
301
|
+
if (check(base))
|
|
302
|
+
return base;
|
|
303
|
+
const raw = schema;
|
|
304
|
+
const structural = structuralOnly(schema);
|
|
305
|
+
const combine = (branch) => deriveExample({ allOf: [structural, branch] }, rootSchema, seen);
|
|
306
|
+
const candidates = [];
|
|
307
|
+
if (hasOneOf(schema)) {
|
|
308
|
+
for (const branch of schema.oneOf)
|
|
309
|
+
if (branch !== undefined)
|
|
310
|
+
candidates.push(combine(branch));
|
|
311
|
+
}
|
|
312
|
+
if ('if' in raw) {
|
|
313
|
+
if (raw['then'] !== undefined)
|
|
314
|
+
candidates.push(combine(raw['then']));
|
|
315
|
+
if (raw['else'] !== undefined)
|
|
316
|
+
candidates.push(combine(raw['else']));
|
|
317
|
+
// `if` failing (so neither `then` nor a matched `else` applies) is also valid.
|
|
318
|
+
candidates.push(deriveExample(structural, rootSchema, seen));
|
|
319
|
+
}
|
|
320
|
+
for (const candidate of candidates)
|
|
321
|
+
if (check(candidate))
|
|
322
|
+
return candidate;
|
|
323
|
+
return base;
|
|
324
|
+
};
|
|
270
325
|
/**
|
|
271
326
|
* True when a candidate value (e.g. an `enum`/`const` member) satisfies the
|
|
272
327
|
* node's simple string-length and numeric-range constraints. Used to pick an
|
|
@@ -307,40 +362,164 @@ const deriveForType = (type, schema, rootSchema, seen) => {
|
|
|
307
362
|
return null;
|
|
308
363
|
case 'array':
|
|
309
364
|
return deriveArray(schema, rootSchema, seen);
|
|
310
|
-
case 'object':
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
365
|
+
case 'object':
|
|
366
|
+
return deriveObject(schema, rootSchema, seen);
|
|
367
|
+
default:
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
/**
|
|
372
|
+
* Builds an object value honouring `properties`/`required`/`additionalProperties`
|
|
373
|
+
* plus the presence-gated and key-shaped keywords: `patternProperties` and
|
|
374
|
+
* `additionalProperties` pick the value schema for synthesized keys,
|
|
375
|
+
* `propertyNames` constrains those keys, `dependentRequired`/`dependentSchemas`
|
|
376
|
+
* add keys once their trigger is present, and `minProperties`/`maxProperties`
|
|
377
|
+
* bound the key count.
|
|
378
|
+
*/
|
|
379
|
+
const deriveObject = (schema, rootSchema, seen) => {
|
|
380
|
+
const out = {};
|
|
381
|
+
const patternEntries = hasPatternProperties(schema)
|
|
382
|
+
? Object.entries(schema.patternProperties).flatMap(([source, sub]) => {
|
|
383
|
+
try {
|
|
384
|
+
return [[new RegExp(source), sub]];
|
|
329
385
|
}
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
}
|
|
386
|
+
catch {
|
|
387
|
+
return [];
|
|
339
388
|
}
|
|
340
|
-
|
|
389
|
+
})
|
|
390
|
+
: [];
|
|
391
|
+
const additional = hasAdditionalProperties(schema) ? schema.additionalProperties : false;
|
|
392
|
+
const additionalSchema = isSchemaObject(additional) ? additional : undefined;
|
|
393
|
+
const additionalClosed = hasAdditionalProperties(schema) && schema.additionalProperties === false;
|
|
394
|
+
// With `additionalProperties: false`, extra keys are still allowed when they
|
|
395
|
+
// match a `patternProperties` entry; only a fully closed object forbids all.
|
|
396
|
+
const extrasAllowed = !additionalClosed || patternEntries.length > 0;
|
|
397
|
+
const nameCheck = hasPropertyNames(schema) ? makeInstanceCheck(schema.propertyNames, rootSchema) : undefined;
|
|
398
|
+
// The value schema for a key not declared in `properties`: the matching
|
|
399
|
+
// `patternProperties` (intersected when several match), else `additionalProperties`.
|
|
400
|
+
const valueSchemaFor = (key) => {
|
|
401
|
+
const matches = patternEntries.filter(([re]) => re.test(key)).map(([, sub]) => sub);
|
|
402
|
+
if (matches.length === 1)
|
|
403
|
+
return matches[0];
|
|
404
|
+
if (matches.length > 1)
|
|
405
|
+
return { allOf: matches };
|
|
406
|
+
return additionalSchema;
|
|
407
|
+
};
|
|
408
|
+
const addKey = (key) => {
|
|
409
|
+
const sub = valueSchemaFor(key);
|
|
410
|
+
out[key] = sub !== undefined ? deriveExample(sub, rootSchema, seen) : null;
|
|
411
|
+
};
|
|
412
|
+
if (hasProperties(schema)) {
|
|
413
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
414
|
+
out[key] = deriveExample(propSchema, rootSchema, seen);
|
|
341
415
|
}
|
|
342
|
-
|
|
343
|
-
|
|
416
|
+
}
|
|
417
|
+
// A required key with no `properties` entry still needs a value.
|
|
418
|
+
if (hasRequired(schema)) {
|
|
419
|
+
for (const key of schema.required)
|
|
420
|
+
if (!(key in out))
|
|
421
|
+
addKey(key);
|
|
422
|
+
}
|
|
423
|
+
// `dependentRequired`: once a trigger key is present, its dependencies must be too.
|
|
424
|
+
if (hasDependentRequired(schema)) {
|
|
425
|
+
for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
|
|
426
|
+
if (!(trigger in out))
|
|
427
|
+
continue;
|
|
428
|
+
for (const dep of deps)
|
|
429
|
+
if (!(dep in out))
|
|
430
|
+
addKey(dep);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// `dependentSchemas`: once a trigger key is present, the object must also
|
|
434
|
+
// satisfy the dependency subschema — apply its `properties`/`required`.
|
|
435
|
+
if (hasDependentSchemas(schema)) {
|
|
436
|
+
for (const [trigger, sub] of Object.entries(schema.dependentSchemas)) {
|
|
437
|
+
if (trigger in out && isSchemaObject(sub))
|
|
438
|
+
applyDependentSchema(out, sub, rootSchema, seen);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Synthesize filler keys to reach `minProperties` when extras are allowed.
|
|
442
|
+
if (hasMinProperties(schema) && extrasAllowed) {
|
|
443
|
+
let n = 0;
|
|
444
|
+
let guard = 0;
|
|
445
|
+
while (Object.keys(out).length < schema.minProperties && guard++ < schema.minProperties + 50) {
|
|
446
|
+
const key = synthKey(n++, patternEntries, schema, nameCheck);
|
|
447
|
+
if (key === undefined || key in out)
|
|
448
|
+
continue;
|
|
449
|
+
addKey(key);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// Enforce `maxProperties` by dropping keys that aren't required (directly or via
|
|
453
|
+
// a present `dependentRequired` trigger) until the count fits.
|
|
454
|
+
if (hasMaxProperties(schema))
|
|
455
|
+
enforceMaxProperties(out, schema, schema.maxProperties);
|
|
456
|
+
return out;
|
|
457
|
+
};
|
|
458
|
+
/** Applies a `dependentSchemas` branch's object shape (`properties`/`required`) in place. */
|
|
459
|
+
const applyDependentSchema = (out, sub, rootSchema, seen) => {
|
|
460
|
+
if (hasProperties(sub)) {
|
|
461
|
+
for (const [key, propSchema] of Object.entries(sub.properties)) {
|
|
462
|
+
if (!(key in out))
|
|
463
|
+
out[key] = deriveExample(propSchema, rootSchema, seen);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (hasRequired(sub)) {
|
|
467
|
+
const propSchemas = hasProperties(sub) ? sub.properties : {};
|
|
468
|
+
for (const key of sub.required) {
|
|
469
|
+
if (key in out)
|
|
470
|
+
continue;
|
|
471
|
+
const propSchema = propSchemas[key];
|
|
472
|
+
out[key] = propSchema !== undefined ? deriveExample(propSchema, rootSchema, seen) : null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
/**
|
|
477
|
+
* Produces a candidate key name for the i-th synthesized property. Prefers a key
|
|
478
|
+
* matching a `patternProperties` entry (so the entry supplies its value schema),
|
|
479
|
+
* then one matching `propertyNames`, then a plain `extraN`. Returns `undefined`
|
|
480
|
+
* when the candidate can't satisfy a `propertyNames` constraint.
|
|
481
|
+
*/
|
|
482
|
+
const synthKey = (i, patternEntries, schema, nameCheck) => {
|
|
483
|
+
// Distinct keys are produced by *repeating* a base seed (`x-` → `x-x-`) rather
|
|
484
|
+
// than appending a digit: repetition is far likelier to keep matching a key
|
|
485
|
+
// pattern (`^[a-z]+$`, `^x-`) that a digit suffix would break.
|
|
486
|
+
const vary = (seed) => seed.repeat(i + 1);
|
|
487
|
+
const seeds = [];
|
|
488
|
+
for (const [re] of patternEntries) {
|
|
489
|
+
const sampled = sampleFromPattern(re.source, 0);
|
|
490
|
+
if (sampled !== undefined && sampled.length > 0)
|
|
491
|
+
seeds.push(vary(sampled));
|
|
492
|
+
}
|
|
493
|
+
const propertyNames = isSchemaObject(schema) && hasPropertyNames(schema) ? schema.propertyNames : undefined;
|
|
494
|
+
if (propertyNames !== undefined && isSchemaObject(propertyNames) && hasPattern(propertyNames)) {
|
|
495
|
+
const sampled = sampleFromPattern(propertyNames.pattern, 0);
|
|
496
|
+
if (sampled !== undefined && sampled.length > 0)
|
|
497
|
+
seeds.push(vary(sampled));
|
|
498
|
+
}
|
|
499
|
+
seeds.push(vary('extra'));
|
|
500
|
+
for (const seed of seeds) {
|
|
501
|
+
if (!nameCheck || nameCheck(seed))
|
|
502
|
+
return seed;
|
|
503
|
+
}
|
|
504
|
+
return undefined;
|
|
505
|
+
};
|
|
506
|
+
/** Drops non-required keys until `out` has at most `max` properties. */
|
|
507
|
+
const enforceMaxProperties = (out, schema, max) => {
|
|
508
|
+
if (Object.keys(out).length <= max)
|
|
509
|
+
return;
|
|
510
|
+
const protectedKeys = new Set(hasRequired(schema) ? schema.required : []);
|
|
511
|
+
if (hasDependentRequired(schema)) {
|
|
512
|
+
for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
|
|
513
|
+
if (trigger in out)
|
|
514
|
+
for (const dep of deps)
|
|
515
|
+
protectedKeys.add(dep);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
for (const key of Object.keys(out)) {
|
|
519
|
+
if (Object.keys(out).length <= max)
|
|
520
|
+
break;
|
|
521
|
+
if (!protectedKeys.has(key))
|
|
522
|
+
delete out[key];
|
|
344
523
|
}
|
|
345
524
|
};
|
|
346
525
|
/**
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* Maps each generated filename to the set of *other* filenames sharing its
|
|
4
|
+
* strongly connected component (SCC) in the `$ref` graph. A non-empty set means
|
|
5
|
+
* the file takes part in a cross-file reference cycle (e.g. `a → b → a`), so any
|
|
6
|
+
* reference it emits to one of those siblings must be lazy: two modules that
|
|
7
|
+
* eagerly read each other's top-level `const` at import time crash with a
|
|
8
|
+
* circular-ESM TDZ `ReferenceError`.
|
|
9
|
+
*
|
|
10
|
+
* A file that only references *itself* is a single-node SCC and never appears
|
|
11
|
+
* here — that recursion is already tied lazily via `fc.letrec` inside
|
|
12
|
+
* {@link generateArbitrary}.
|
|
13
|
+
*/
|
|
14
|
+
export type SchemaCycles = ReadonlyMap<string, ReadonlySet<string>>;
|
|
15
|
+
/**
|
|
16
|
+
* Detects cross-file `$ref` cycles in a schema's ref graph.
|
|
17
|
+
*
|
|
18
|
+
* Walks the graph via {@link walkRefGraph}, groups files into strongly connected
|
|
19
|
+
* components, and returns — for every file inside a multi-file component — the
|
|
20
|
+
* sibling files it must reference lazily to avoid a circular-ESM TDZ crash.
|
|
21
|
+
* Files not involved in any cross-file cycle are absent from the result.
|
|
22
|
+
*
|
|
23
|
+
* @param rootSchema - The root JSON Schema being built.
|
|
24
|
+
* @param rootTypeName - The name for the root type (e.g. `'Document'`).
|
|
25
|
+
* @param typeSuffix - Suffix appended to every `$ref`-derived name (default `''`).
|
|
26
|
+
*/
|
|
27
|
+
export declare const findSchemaCycles: (rootSchema: JSONSchema, rootTypeName: string, typeSuffix?: string) => SchemaCycles;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { extractRefs } from '@amritk/helpers/extract-refs';
|
|
2
|
+
import { refToFilename } from '@amritk/helpers/ref-to-filename';
|
|
3
|
+
import { walkRefGraph } from '@amritk/helpers/walk-ref-graph';
|
|
4
|
+
/**
|
|
5
|
+
* Collects the ref graph as an adjacency map of filename → referenced
|
|
6
|
+
* filenames, restricted to nodes that are actually generated as files. Self
|
|
7
|
+
* edges are dropped (they are `fc.letrec` recursion, not cross-file cycles).
|
|
8
|
+
*/
|
|
9
|
+
const buildRefGraph = (rootSchema, rootTypeName, typeSuffix) => {
|
|
10
|
+
const schemas = new Map();
|
|
11
|
+
walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
|
|
12
|
+
// `index` is reserved for the barrel and never generated as a definition.
|
|
13
|
+
if (node.filename === 'index')
|
|
14
|
+
return;
|
|
15
|
+
if (!schemas.has(node.filename))
|
|
16
|
+
schemas.set(node.filename, node.schema);
|
|
17
|
+
});
|
|
18
|
+
const graph = new Map();
|
|
19
|
+
for (const [filename, schema] of schemas) {
|
|
20
|
+
const targets = new Set();
|
|
21
|
+
for (const ref of extractRefs(schema)) {
|
|
22
|
+
const target = refToFilename(ref);
|
|
23
|
+
// Only edges to other generated files matter — an unresolved/external ref
|
|
24
|
+
// never becomes an eager cross-module reference.
|
|
25
|
+
if (target !== filename && schemas.has(target))
|
|
26
|
+
targets.add(target);
|
|
27
|
+
}
|
|
28
|
+
graph.set(filename, targets);
|
|
29
|
+
}
|
|
30
|
+
return graph;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Tarjan's strongly-connected-components algorithm, iterative so a deep ref
|
|
34
|
+
* graph cannot overflow the call stack. Returns one array of filenames per SCC.
|
|
35
|
+
*/
|
|
36
|
+
const stronglyConnectedComponents = (graph) => {
|
|
37
|
+
let counter = 0;
|
|
38
|
+
const index = new Map();
|
|
39
|
+
const lowlink = new Map();
|
|
40
|
+
const onStack = new Set();
|
|
41
|
+
const stack = [];
|
|
42
|
+
const sccs = [];
|
|
43
|
+
for (const start of graph.keys()) {
|
|
44
|
+
if (index.has(start))
|
|
45
|
+
continue;
|
|
46
|
+
const callStack = [{ node: start, iter: (graph.get(start) ?? new Set()).values() }];
|
|
47
|
+
index.set(start, counter);
|
|
48
|
+
lowlink.set(start, counter);
|
|
49
|
+
counter++;
|
|
50
|
+
stack.push(start);
|
|
51
|
+
onStack.add(start);
|
|
52
|
+
while (callStack.length > 0) {
|
|
53
|
+
const frame = callStack[callStack.length - 1];
|
|
54
|
+
const node = frame.node;
|
|
55
|
+
let descended = false;
|
|
56
|
+
let next = frame.iter.next();
|
|
57
|
+
while (!next.done) {
|
|
58
|
+
const child = next.value;
|
|
59
|
+
if (!index.has(child)) {
|
|
60
|
+
index.set(child, counter);
|
|
61
|
+
lowlink.set(child, counter);
|
|
62
|
+
counter++;
|
|
63
|
+
stack.push(child);
|
|
64
|
+
onStack.add(child);
|
|
65
|
+
callStack.push({ node: child, iter: (graph.get(child) ?? new Set()).values() });
|
|
66
|
+
descended = true;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
if (onStack.has(child)) {
|
|
70
|
+
lowlink.set(node, Math.min(lowlink.get(node), index.get(child)));
|
|
71
|
+
}
|
|
72
|
+
next = frame.iter.next();
|
|
73
|
+
}
|
|
74
|
+
if (descended)
|
|
75
|
+
continue;
|
|
76
|
+
// All successors visited: close this node.
|
|
77
|
+
if (lowlink.get(node) === index.get(node)) {
|
|
78
|
+
const scc = [];
|
|
79
|
+
let member;
|
|
80
|
+
do {
|
|
81
|
+
member = stack.pop();
|
|
82
|
+
onStack.delete(member);
|
|
83
|
+
scc.push(member);
|
|
84
|
+
} while (member !== node);
|
|
85
|
+
sccs.push(scc);
|
|
86
|
+
}
|
|
87
|
+
callStack.pop();
|
|
88
|
+
const parent = callStack[callStack.length - 1];
|
|
89
|
+
if (parent) {
|
|
90
|
+
lowlink.set(parent.node, Math.min(lowlink.get(parent.node), lowlink.get(node)));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return sccs;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Detects cross-file `$ref` cycles in a schema's ref graph.
|
|
98
|
+
*
|
|
99
|
+
* Walks the graph via {@link walkRefGraph}, groups files into strongly connected
|
|
100
|
+
* components, and returns — for every file inside a multi-file component — the
|
|
101
|
+
* sibling files it must reference lazily to avoid a circular-ESM TDZ crash.
|
|
102
|
+
* Files not involved in any cross-file cycle are absent from the result.
|
|
103
|
+
*
|
|
104
|
+
* @param rootSchema - The root JSON Schema being built.
|
|
105
|
+
* @param rootTypeName - The name for the root type (e.g. `'Document'`).
|
|
106
|
+
* @param typeSuffix - Suffix appended to every `$ref`-derived name (default `''`).
|
|
107
|
+
*/
|
|
108
|
+
export const findSchemaCycles = (rootSchema, rootTypeName, typeSuffix = '') => {
|
|
109
|
+
const graph = buildRefGraph(rootSchema, rootTypeName, typeSuffix);
|
|
110
|
+
const cycles = new Map();
|
|
111
|
+
for (const scc of stronglyConnectedComponents(graph)) {
|
|
112
|
+
// A single-file component is either an isolated node or pure self-recursion;
|
|
113
|
+
// neither needs cross-file lazy references.
|
|
114
|
+
if (scc.length < 2)
|
|
115
|
+
continue;
|
|
116
|
+
const members = new Set(scc);
|
|
117
|
+
for (const member of scc) {
|
|
118
|
+
const siblings = new Set(members);
|
|
119
|
+
siblings.delete(member);
|
|
120
|
+
cycles.set(member, siblings);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return cycles;
|
|
124
|
+
};
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* Local alias the generated file binds `@amritk/runtime-validators`' `validate`
|
|
4
|
+
* to. Namespaced (leading underscores) so it can't collide with a schema-derived
|
|
5
|
+
* type name. {@link generateArbitrary} emits references to it; the file assembler
|
|
6
|
+
* adds the matching import when any arbitrary uses it.
|
|
7
|
+
*/
|
|
8
|
+
export declare const VALIDATE_IMPORT_NAME = "__mjstValidate";
|
|
9
|
+
/**
|
|
10
|
+
* The import line the generated file needs when an arbitrary embeds a validating
|
|
11
|
+
* filter. Emitted by the file assembler only when {@link VALIDATE_IMPORT_NAME}
|
|
12
|
+
* appears in the generated source.
|
|
13
|
+
*/
|
|
14
|
+
export declare const VALIDATE_IMPORT_STATEMENT = "import { validate as __mjstValidate } from '@amritk/runtime-validators'";
|
|
2
15
|
/**
|
|
3
16
|
* Generates a `fast-check` arbitrary that produces schema-valid values.
|
|
4
17
|
*
|
|
@@ -6,10 +19,14 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
|
6
19
|
* tied lazily — a plain `const NodeArbitrary = fc.record({ next: NodeArbitrary })`
|
|
7
20
|
* would throw a TDZ `ReferenceError` the moment the module is imported.
|
|
8
21
|
*
|
|
22
|
+
* `lazyRefFilenames` names the sibling files this type shares a cross-file `$ref`
|
|
23
|
+
* cycle with; references to those are deferred so mutually recursive modules do
|
|
24
|
+
* not read each other's `const` before it is initialized (see {@link ExprCtx}).
|
|
25
|
+
*
|
|
9
26
|
* @example
|
|
10
27
|
* ```typescript
|
|
11
28
|
* generateArbitrary({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, 'Info')
|
|
12
29
|
* // export const InfoArbitrary: fc.Arbitrary<Info> = fc.record({ "name": fc.string() })
|
|
13
30
|
* ```
|
|
14
31
|
*/
|
|
15
|
-
export declare const generateArbitrary: (schema: JSONSchema, typeName: string, suffix?: string) => string;
|
|
32
|
+
export declare const generateArbitrary: (schema: JSONSchema, typeName: string, suffix?: string, lazyRefFilenames?: ReadonlySet<string>, rootSchema?: Record<string, unknown>) => string;
|
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
|
|
2
|
+
import { refToFilename } from '@amritk/helpers/ref-to-filename';
|
|
2
3
|
import { refToName } from '@amritk/helpers/ref-to-name';
|
|
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 { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasContains, hasDependentRequired, hasDependentSchemas, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasFormat, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasOneOf, hasPattern, hasPatternProperties, hasProperties, hasPropertyNames, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
4
5
|
import { mergeAllOf } from './derive-example.js';
|
|
6
|
+
import { needsValidationFilter, withResolvableDefs } from './schema-validation.js';
|
|
5
7
|
/**
|
|
6
8
|
* Derives the arbitrary const name from a type name.
|
|
7
9
|
* e.g. "User" → "UserArbitrary"
|
|
8
10
|
*/
|
|
9
11
|
const arbitraryName = (typeName) => `${typeName}Arbitrary`;
|
|
12
|
+
/**
|
|
13
|
+
* Local alias the generated file binds `@amritk/runtime-validators`' `validate`
|
|
14
|
+
* to. Namespaced (leading underscores) so it can't collide with a schema-derived
|
|
15
|
+
* type name. {@link generateArbitrary} emits references to it; the file assembler
|
|
16
|
+
* adds the matching import when any arbitrary uses it.
|
|
17
|
+
*/
|
|
18
|
+
export const VALIDATE_IMPORT_NAME = '__mjstValidate';
|
|
19
|
+
/**
|
|
20
|
+
* The import line the generated file needs when an arbitrary embeds a validating
|
|
21
|
+
* filter. Emitted by the file assembler only when {@link VALIDATE_IMPORT_NAME}
|
|
22
|
+
* appears in the generated source.
|
|
23
|
+
*/
|
|
24
|
+
export const VALIDATE_IMPORT_STATEMENT = `import { validate as ${VALIDATE_IMPORT_NAME} } from '@amritk/runtime-validators'`;
|
|
25
|
+
/**
|
|
26
|
+
* Wraps a cross-module arbitrary reference so the imported binding is read at
|
|
27
|
+
* generation time rather than at module-init time. `fc.constant(null).chain`
|
|
28
|
+
* stores the thunk and only invokes it when a value is generated — by which
|
|
29
|
+
* point every module in the cycle has finished initializing — so the otherwise
|
|
30
|
+
* eager identifier never touches a `const` in its TDZ.
|
|
31
|
+
*/
|
|
32
|
+
const lazyRef = (arbName) => `fc.constant(null).chain(() => ${arbName})`;
|
|
10
33
|
/** The letrec key used for a type's own (self-referential) arbitrary. */
|
|
11
34
|
const SELF_KEY = 'self';
|
|
12
35
|
/** Builds a `fc.string({ ... })` expression honouring format and length constraints. */
|
|
@@ -78,8 +101,77 @@ const integerExpr = (schema) => {
|
|
|
78
101
|
const base = opts.length > 0 ? `fc.integer({ ${opts.join(', ')} })` : 'fc.integer()';
|
|
79
102
|
return hasMultipleOf(schema) ? `${base}.filter((n) => n % ${schema.multipleOf} === 0)` : base;
|
|
80
103
|
};
|
|
104
|
+
/**
|
|
105
|
+
* Builds a multiple-of-respecting number arbitrary analytically: pick an integer
|
|
106
|
+
* `k` whose multiple `k * multipleOf` lands inside the (possibly exclusive)
|
|
107
|
+
* bounds, then emit that product. Random doubles essentially never satisfy
|
|
108
|
+
* `n % m === 0`, so a `.filter` here starves fast-check ("too many filtered
|
|
109
|
+
* values") at sample time; deriving the multiple directly cannot fail. This
|
|
110
|
+
* mirrors the static path's `deriveNumber`.
|
|
111
|
+
*
|
|
112
|
+
* The trailing `.map` clamps `k * m` back inside the finite bounds to absorb
|
|
113
|
+
* floating-point drift (e.g. `3 * 0.1 === 0.30000000000000004`, which would
|
|
114
|
+
* otherwise slip just past a `maximum` of `0.3`).
|
|
115
|
+
*/
|
|
116
|
+
const numberMultipleOfExpr = (schema) => {
|
|
117
|
+
const m = Number(schema.multipleOf);
|
|
118
|
+
const EPS = 1e-9;
|
|
119
|
+
// Effective lower bound: the tighter (larger) of minimum / exclusiveMinimum,
|
|
120
|
+
// tracking whether the binding bound is exclusive.
|
|
121
|
+
let lo = Number.NEGATIVE_INFINITY;
|
|
122
|
+
let loExclusive = false;
|
|
123
|
+
if (hasMinimum(schema))
|
|
124
|
+
lo = Number(schema.minimum);
|
|
125
|
+
if (hasExclusiveMinimum(schema) && Number(schema.exclusiveMinimum) >= lo) {
|
|
126
|
+
lo = Number(schema.exclusiveMinimum);
|
|
127
|
+
loExclusive = true;
|
|
128
|
+
}
|
|
129
|
+
// Effective upper bound: the tighter (smaller) of maximum / exclusiveMaximum.
|
|
130
|
+
let hi = Number.POSITIVE_INFINITY;
|
|
131
|
+
let hiExclusive = false;
|
|
132
|
+
if (hasMaximum(schema))
|
|
133
|
+
hi = Number(schema.maximum);
|
|
134
|
+
if (hasExclusiveMaximum(schema) && Number(schema.exclusiveMaximum) <= hi) {
|
|
135
|
+
hi = Number(schema.exclusiveMaximum);
|
|
136
|
+
hiExclusive = true;
|
|
137
|
+
}
|
|
138
|
+
// Translate value bounds into integer-`k` bounds, where the emitted value is
|
|
139
|
+
// `k * m`. An exclusive bound must be strictly cleared, so a `k` landing exactly
|
|
140
|
+
// on it is nudged one step inward; `EPS` keeps a mathematically-integer ratio
|
|
141
|
+
// (e.g. `0.3 / 0.1`) from being mis-rounded by floating-point error.
|
|
142
|
+
let kMin;
|
|
143
|
+
let kMax;
|
|
144
|
+
if (Number.isFinite(lo)) {
|
|
145
|
+
const raw = lo / m;
|
|
146
|
+
kMin = loExclusive ? Math.floor(raw + EPS) + 1 : Math.ceil(raw - EPS);
|
|
147
|
+
}
|
|
148
|
+
if (Number.isFinite(hi)) {
|
|
149
|
+
const raw = hi / m;
|
|
150
|
+
kMax = hiExclusive ? Math.ceil(raw - EPS) - 1 : Math.floor(raw + EPS);
|
|
151
|
+
}
|
|
152
|
+
// An unsatisfiable range (no multiple fits) would make `fc.integer` throw on
|
|
153
|
+
// `min > max`; collapse to a single best-effort value instead.
|
|
154
|
+
if (kMin !== undefined && kMax !== undefined && kMin > kMax)
|
|
155
|
+
kMax = kMin;
|
|
156
|
+
const kOpts = [];
|
|
157
|
+
if (kMin !== undefined)
|
|
158
|
+
kOpts.push(`min: ${kMin}`);
|
|
159
|
+
if (kMax !== undefined)
|
|
160
|
+
kOpts.push(`max: ${kMax}`);
|
|
161
|
+
const k = kOpts.length > 0 ? `fc.integer({ ${kOpts.join(', ')} })` : 'fc.integer()';
|
|
162
|
+
let value = `k * ${m}`;
|
|
163
|
+
if (Number.isFinite(lo))
|
|
164
|
+
value = `Math.max(${value}, ${lo})`;
|
|
165
|
+
if (Number.isFinite(hi))
|
|
166
|
+
value = `Math.min(${value}, ${hi})`;
|
|
167
|
+
return `${k}.map((k) => ${value})`;
|
|
168
|
+
};
|
|
81
169
|
/** Builds a `fc.double({ ... })` expression honouring range and multiple-of constraints. */
|
|
82
170
|
const numberExpr = (schema) => {
|
|
171
|
+
// A positive `multipleOf` is satisfied analytically rather than by filtering
|
|
172
|
+
// random doubles, which would starve fast-check at sample time.
|
|
173
|
+
if (hasMultipleOf(schema) && schema.multipleOf > 0)
|
|
174
|
+
return numberMultipleOfExpr(schema);
|
|
83
175
|
const opts = ['noNaN: true', 'noDefaultInfinity: true'];
|
|
84
176
|
if (hasMinimum(schema))
|
|
85
177
|
opts.push(`min: ${schema.minimum}`);
|
|
@@ -89,8 +181,7 @@ const numberExpr = (schema) => {
|
|
|
89
181
|
opts.push(`max: ${schema.maximum}`);
|
|
90
182
|
else if (hasExclusiveMaximum(schema))
|
|
91
183
|
opts.push(`max: ${schema.exclusiveMaximum}`, 'maxExcluded: true');
|
|
92
|
-
|
|
93
|
-
return hasMultipleOf(schema) ? `${base}.filter((n) => n % ${schema.multipleOf} === 0)` : base;
|
|
184
|
+
return `fc.double({ ${opts.join(', ')} })`;
|
|
94
185
|
};
|
|
95
186
|
/** Builds a `fc.array(...)` / `fc.uniqueArray(...)` / `fc.tuple(...)` expression for an array schema. */
|
|
96
187
|
const arrayExpr = (schema, ctx) => {
|
|
@@ -109,42 +200,133 @@ const arrayExpr = (schema, ctx) => {
|
|
|
109
200
|
const exprs = tuple.map((item) => arbitraryExpr(item, ctx));
|
|
110
201
|
return `fc.tuple(${exprs.join(', ')})`;
|
|
111
202
|
}
|
|
112
|
-
|
|
203
|
+
// With no `items`, a `contains` subschema is the only element constraint, so
|
|
204
|
+
// generate from it (and guarantee at least one such element via `minLength`) —
|
|
205
|
+
// otherwise an empty array would fail `contains`.
|
|
206
|
+
const containsSchema = hasContains(schema) && isSchemaObject(schema.contains) ? schema.contains : undefined;
|
|
207
|
+
const items = hasItems(schema) && isSchemaObject(schema.items)
|
|
208
|
+
? arbitraryExpr(schema.items, ctx)
|
|
209
|
+
: containsSchema
|
|
210
|
+
? arbitraryExpr(containsSchema, ctx)
|
|
211
|
+
: 'fc.anything()';
|
|
212
|
+
const minContains = containsSchema !== undefined && typeof raw['minContains'] === 'number' ? raw['minContains'] : 1;
|
|
213
|
+
const minLength = Math.max(hasMinItems(schema) ? schema.minItems : 0, containsSchema !== undefined ? Math.max(1, minContains) : 0);
|
|
113
214
|
const opts = [];
|
|
114
|
-
if (
|
|
115
|
-
opts.push(`minLength: ${
|
|
215
|
+
if (minLength > 0)
|
|
216
|
+
opts.push(`minLength: ${minLength}`);
|
|
116
217
|
if (hasMaxItems(schema))
|
|
117
218
|
opts.push(`maxLength: ${schema.maxItems}`);
|
|
118
219
|
const fn = hasUniqueItems(schema) && schema.uniqueItems === true ? 'fc.uniqueArray' : 'fc.array';
|
|
119
220
|
return opts.length > 0 ? `${fn}(${items}, { ${opts.join(', ')} })` : `${fn}(${items})`;
|
|
120
221
|
};
|
|
222
|
+
/** The arbitrary for keys of the open-map (extra-property) part of an object. */
|
|
223
|
+
const extraKeyArb = (schema, firstPatternSource) => {
|
|
224
|
+
// Keys must satisfy `patternProperties` (so the value schema applies) or, failing
|
|
225
|
+
// that, a `propertyNames` pattern. Both map onto `fc.stringMatching`.
|
|
226
|
+
if (firstPatternSource !== undefined)
|
|
227
|
+
return `fc.stringMatching(new RegExp(${JSON.stringify(firstPatternSource)}))`;
|
|
228
|
+
const propertyNames = hasPropertyNames(schema) ? schema.propertyNames : undefined;
|
|
229
|
+
if (propertyNames !== undefined && isSchemaObject(propertyNames) && hasPattern(propertyNames)) {
|
|
230
|
+
return `fc.stringMatching(new RegExp(${JSON.stringify(propertyNames.pattern)}))`;
|
|
231
|
+
}
|
|
232
|
+
return 'fc.string()';
|
|
233
|
+
};
|
|
121
234
|
/** Builds a `fc.record(...)` / `fc.dictionary(...)` expression for an object schema. */
|
|
122
235
|
const objectExpr = (schema, ctx) => {
|
|
123
236
|
// The `additionalProperties` value schema (when it constrains extra keys with a
|
|
124
237
|
// real subschema rather than the boolean true/false form).
|
|
125
238
|
const additional = hasAdditionalProperties(schema) ? schema.additionalProperties : false;
|
|
126
239
|
const additionalArb = isSchemaObject(additional) ? arbitraryExpr(additional, ctx) : undefined;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
240
|
+
const additionalClosed = hasAdditionalProperties(schema) && schema.additionalProperties === false;
|
|
241
|
+
// The first `patternProperties` entry drives the open-map value/key shape.
|
|
242
|
+
const patternEntries = hasPatternProperties(schema) ? Object.entries(schema.patternProperties) : [];
|
|
243
|
+
const firstPattern = patternEntries[0];
|
|
244
|
+
const patternValueArb = firstPattern && isSchemaObject(firstPattern[1]) ? arbitraryExpr(firstPattern[1], ctx) : undefined;
|
|
245
|
+
// Extra keys are allowed unless a fully closed object (no `additionalProperties`
|
|
246
|
+
// and no `patternProperties` outlet) forbids them.
|
|
247
|
+
const extrasAllowed = !additionalClosed || patternEntries.length > 0;
|
|
248
|
+
const extraValueArb = additionalArb ?? patternValueArb;
|
|
249
|
+
const keyArb = extraKeyArb(schema, firstPattern?.[0]);
|
|
250
|
+
const minProps = hasMinProperties(schema) ? schema.minProperties : undefined;
|
|
251
|
+
const maxProps = hasMaxProperties(schema) ? schema.maxProperties : undefined;
|
|
252
|
+
const dictKeyOpts = (minKeys, maxKeys) => {
|
|
253
|
+
const opts = [];
|
|
254
|
+
if (minKeys !== undefined && minKeys > 0)
|
|
255
|
+
opts.push(`minKeys: ${minKeys}`);
|
|
256
|
+
if (maxKeys !== undefined)
|
|
257
|
+
opts.push(`maxKeys: ${maxKeys}`);
|
|
258
|
+
return opts.length > 0 ? `, { ${opts.join(', ')} }` : '';
|
|
259
|
+
};
|
|
260
|
+
// Each declared key maps to the arbitrary that generates its value.
|
|
261
|
+
const propArbs = new Map();
|
|
262
|
+
if (hasProperties(schema)) {
|
|
263
|
+
for (const [key, propSchema] of Object.entries(schema.properties))
|
|
264
|
+
propArbs.set(key, arbitraryExpr(propSchema, ctx));
|
|
131
265
|
}
|
|
132
266
|
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
267
|
+
// The value arbitrary for a key not declared in `properties` (a dependency key).
|
|
268
|
+
const openValueArb = extraValueArb ?? 'fc.anything()';
|
|
269
|
+
// Fold presence-gated dependency keywords into the always-present set. Requiring
|
|
270
|
+
// a dependency (or a `dependentSchemas` shape) unconditionally is stricter than
|
|
271
|
+
// the keyword — but a value that always carries the dependency is always valid,
|
|
272
|
+
// and it keeps the generated candidate from being rejected by the filter.
|
|
273
|
+
if (hasDependentRequired(schema)) {
|
|
274
|
+
for (const [, deps] of Object.entries(schema.dependentRequired)) {
|
|
275
|
+
for (const dep of deps) {
|
|
276
|
+
if (!propArbs.has(dep))
|
|
277
|
+
propArbs.set(dep, openValueArb);
|
|
278
|
+
required.add(dep);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (hasDependentSchemas(schema)) {
|
|
283
|
+
for (const [, sub] of Object.entries(schema.dependentSchemas)) {
|
|
284
|
+
if (!isSchemaObject(sub))
|
|
285
|
+
continue;
|
|
286
|
+
if (hasProperties(sub)) {
|
|
287
|
+
for (const [key, propSchema] of Object.entries(sub.properties)) {
|
|
288
|
+
if (!propArbs.has(key))
|
|
289
|
+
propArbs.set(key, arbitraryExpr(propSchema, ctx));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (hasRequired(sub))
|
|
293
|
+
for (const key of sub.required)
|
|
294
|
+
required.add(key);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// A `dependentSchemas`/`dependentRequired` key may be required without a declared
|
|
298
|
+
// value schema; give it the open-map value arbitrary.
|
|
299
|
+
for (const key of required)
|
|
300
|
+
if (!propArbs.has(key))
|
|
301
|
+
propArbs.set(key, openValueArb);
|
|
302
|
+
const keys = [...propArbs.keys()];
|
|
303
|
+
// A map-style object (no declared keys) is a dictionary; its bounds come from
|
|
304
|
+
// `min`/`maxProperties` and its value/key shape from `additionalProperties` /
|
|
305
|
+
// `patternProperties` / `propertyNames`.
|
|
306
|
+
if (keys.length === 0) {
|
|
307
|
+
if (extraValueArb)
|
|
308
|
+
return `fc.dictionary(${keyArb}, ${extraValueArb}${dictKeyOpts(minProps, maxProps)})`;
|
|
309
|
+
if (minProps !== undefined || maxProps !== undefined) {
|
|
310
|
+
return `fc.dictionary(${keyArb}, fc.anything()${dictKeyOpts(minProps, maxProps)})`;
|
|
311
|
+
}
|
|
312
|
+
return 'fc.object()';
|
|
313
|
+
}
|
|
314
|
+
const entries = keys.map((key) => `${JSON.stringify(key)}: ${propArbs.get(key)}`);
|
|
137
315
|
const model = `{ ${entries.join(', ')} }`;
|
|
138
316
|
// fc.record treats all keys as required by default. Only emit requiredKeys
|
|
139
317
|
// when at least one property is optional.
|
|
140
318
|
const record = keys.every((key) => required.has(key))
|
|
141
319
|
? `fc.record(${model})`
|
|
142
320
|
: `fc.record(${model}, { requiredKeys: [${[...required].map((key) => JSON.stringify(key)).join(', ')}] })`;
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// the
|
|
146
|
-
|
|
147
|
-
|
|
321
|
+
// Fold in a dictionary of extra keys when the open-map part is typed, or when
|
|
322
|
+
// `minProperties` needs more keys than the declared set guarantees. `minKeys`
|
|
323
|
+
// fills only the gap above the always-present (required) keys so the floor is met
|
|
324
|
+
// without overshooting. Declared keys win on collision (merged last).
|
|
325
|
+
const needExtras = extrasAllowed && (extraValueArb !== undefined || (minProps !== undefined && minProps > required.size));
|
|
326
|
+
if (needExtras) {
|
|
327
|
+
const valueArb = extraValueArb ?? 'fc.anything()';
|
|
328
|
+
const minKeys = minProps !== undefined ? Math.max(0, minProps - required.size) : undefined;
|
|
329
|
+
return `fc.tuple(${record}, fc.dictionary(${keyArb}, ${valueArb}${dictKeyOpts(minKeys, undefined)})).map(([base, extra]) => ({ ...extra, ...base }))`;
|
|
148
330
|
}
|
|
149
331
|
return record;
|
|
150
332
|
};
|
|
@@ -174,6 +356,37 @@ const scalarExpr = (type, schema, ctx) => {
|
|
|
174
356
|
return 'fc.anything()';
|
|
175
357
|
}
|
|
176
358
|
};
|
|
359
|
+
/** True when an `enum` member satisfies the node's sibling length/range/pattern constraints. */
|
|
360
|
+
const enumMemberFits = (schema, value) => {
|
|
361
|
+
if (typeof value === 'string') {
|
|
362
|
+
if (hasMinLength(schema) && value.length < schema.minLength)
|
|
363
|
+
return false;
|
|
364
|
+
if (hasMaxLength(schema) && value.length > schema.maxLength)
|
|
365
|
+
return false;
|
|
366
|
+
if (hasPattern(schema)) {
|
|
367
|
+
try {
|
|
368
|
+
if (!new RegExp(schema.pattern).test(value))
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
// An invalid pattern can't reject anything.
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
else if (typeof value === 'number') {
|
|
377
|
+
if (hasMinimum(schema) && value < schema.minimum)
|
|
378
|
+
return false;
|
|
379
|
+
if (hasMaximum(schema) && value > schema.maximum)
|
|
380
|
+
return false;
|
|
381
|
+
if (hasExclusiveMinimum(schema) && value <= schema.exclusiveMinimum)
|
|
382
|
+
return false;
|
|
383
|
+
if (hasExclusiveMaximum(schema) && value >= schema.exclusiveMaximum)
|
|
384
|
+
return false;
|
|
385
|
+
if (hasMultipleOf(schema) && schema.multipleOf > 0 && value % schema.multipleOf !== 0)
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
return true;
|
|
389
|
+
};
|
|
177
390
|
/**
|
|
178
391
|
* Recursively builds the fast-check arbitrary expression for a schema node.
|
|
179
392
|
* `$ref`s resolve to the referenced file's exported arbitrary; a self-`$ref`
|
|
@@ -191,12 +404,24 @@ const arbitraryExpr = (schema, ctx) => {
|
|
|
191
404
|
ctx.usedTie.value = true;
|
|
192
405
|
return `tie(${JSON.stringify(SELF_KEY)})`;
|
|
193
406
|
}
|
|
407
|
+
// A reference to a sibling this type shares a cross-file cycle with is the
|
|
408
|
+
// same TDZ hazard one module over, and `tie` cannot reach across modules —
|
|
409
|
+
// defer the imported binding until generation time instead.
|
|
410
|
+
if (ctx.lazyRefFilenames.has(refToFilename(schema.$ref))) {
|
|
411
|
+
return lazyRef(name);
|
|
412
|
+
}
|
|
194
413
|
return name;
|
|
195
414
|
}
|
|
196
415
|
if (hasConst(schema))
|
|
197
416
|
return `fc.constant(${JSON.stringify(schema.const)})`;
|
|
198
417
|
if (hasEnum(schema)) {
|
|
199
|
-
|
|
418
|
+
// Drop enum members that violate a sibling length/range/pattern constraint so
|
|
419
|
+
// the arbitrary never emits an out-of-range member. Keep all when none fit
|
|
420
|
+
// (an unsatisfiable schema) rather than emitting an empty `constantFrom`.
|
|
421
|
+
const members = schema.enum;
|
|
422
|
+
const fitting = members.filter((value) => enumMemberFits(schema, value));
|
|
423
|
+
const chosen = fitting.length > 0 ? fitting : members;
|
|
424
|
+
const values = chosen.map((value) => JSON.stringify(value)).join(', ');
|
|
200
425
|
return `fc.constantFrom(${values})`;
|
|
201
426
|
}
|
|
202
427
|
const instanceOf = getMjstInstanceOf(schema);
|
|
@@ -235,18 +460,32 @@ const arbitraryExpr = (schema, ctx) => {
|
|
|
235
460
|
* tied lazily — a plain `const NodeArbitrary = fc.record({ next: NodeArbitrary })`
|
|
236
461
|
* would throw a TDZ `ReferenceError` the moment the module is imported.
|
|
237
462
|
*
|
|
463
|
+
* `lazyRefFilenames` names the sibling files this type shares a cross-file `$ref`
|
|
464
|
+
* cycle with; references to those are deferred so mutually recursive modules do
|
|
465
|
+
* not read each other's `const` before it is initialized (see {@link ExprCtx}).
|
|
466
|
+
*
|
|
238
467
|
* @example
|
|
239
468
|
* ```typescript
|
|
240
469
|
* generateArbitrary({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, 'Info')
|
|
241
470
|
* // export const InfoArbitrary: fc.Arbitrary<Info> = fc.record({ "name": fc.string() })
|
|
242
471
|
* ```
|
|
243
472
|
*/
|
|
244
|
-
export const generateArbitrary = (schema, typeName, suffix = '') => {
|
|
473
|
+
export const generateArbitrary = (schema, typeName, suffix = '', lazyRefFilenames = new Set(), rootSchema) => {
|
|
245
474
|
const selfArbName = arbitraryName(typeName);
|
|
246
|
-
const ctx = { suffix, selfArbName, usedTie: { value: false } };
|
|
475
|
+
const ctx = { suffix, selfArbName, usedTie: { value: false }, lazyRefFilenames };
|
|
247
476
|
const expr = arbitraryExpr(schema, ctx);
|
|
248
|
-
|
|
249
|
-
|
|
477
|
+
const body = ctx.usedTie.value
|
|
478
|
+
? `fc.letrec<{ ${SELF_KEY}: ${typeName} }>((tie) => ({\n ${SELF_KEY}: ${expr},\n})).${SELF_KEY}`
|
|
479
|
+
: expr;
|
|
480
|
+
// Keywords no `fc.*` combinator captures on its own (`if`/`then`/`else`, `not`,
|
|
481
|
+
// `oneOf` exclusivity, the presence-gated object keywords) are enforced by a
|
|
482
|
+
// post-generation filter: the arbitrary samples a candidate and rejects it
|
|
483
|
+
// unless a runtime validator built from the same schema accepts it.
|
|
484
|
+
if (needsValidationFilter(schema)) {
|
|
485
|
+
const validatorName = `${selfArbName}Validator`;
|
|
486
|
+
const embedded = JSON.stringify(withResolvableDefs(schema, rootSchema));
|
|
487
|
+
return (`const ${validatorName} = ${VALIDATE_IMPORT_NAME}(${embedded})\n` +
|
|
488
|
+
`export const ${selfArbName}: fc.Arbitrary<${typeName}> = (${body}).filter((value) => ${validatorName}(value) === true)`);
|
|
250
489
|
}
|
|
251
|
-
return `export const ${selfArbName}: fc.Arbitrary<${typeName}> = ${
|
|
490
|
+
return `export const ${selfArbName}: fc.Arbitrary<${typeName}> = ${body}`;
|
|
252
491
|
};
|
|
@@ -18,6 +18,12 @@ type GenerateExampleFileOptions = {
|
|
|
18
18
|
* Defaults to `''` (no suffix).
|
|
19
19
|
*/
|
|
20
20
|
readonly typeSuffix?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Filenames of the other types this file shares a cross-file `$ref` cycle
|
|
23
|
+
* with. References to them are emitted lazily so mutually recursive modules
|
|
24
|
+
* do not crash with a circular-ESM TDZ error at import. Defaults to empty.
|
|
25
|
+
*/
|
|
26
|
+
readonly lazyRefFilenames?: ReadonlySet<string>;
|
|
21
27
|
};
|
|
22
28
|
/**
|
|
23
29
|
* Generates a complete TypeScript example file from a JSON Schema.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition';
|
|
2
2
|
import { collectExampleImports } from './collect-example-imports.js';
|
|
3
3
|
import { generateExampleConst } from './derive-example.js';
|
|
4
|
-
import { generateArbitrary } from './generate-arbitrary.js';
|
|
4
|
+
import { generateArbitrary, VALIDATE_IMPORT_NAME, VALIDATE_IMPORT_STATEMENT } from './generate-arbitrary.js';
|
|
5
5
|
/**
|
|
6
6
|
* Generates a complete TypeScript example file from a JSON Schema.
|
|
7
7
|
*
|
|
@@ -29,9 +29,14 @@ export const generateExampleFile = (schema, typeName, options) => {
|
|
|
29
29
|
typeSuffix,
|
|
30
30
|
});
|
|
31
31
|
const typeDefinition = generateTypeDefinition(schema, typeName, { typeSuffix });
|
|
32
|
-
const arbitrary = generateArbitrary(schema, typeName, typeSuffix);
|
|
32
|
+
const arbitrary = generateArbitrary(schema, typeName, typeSuffix, options?.lazyRefFilenames, options?.rootSchema);
|
|
33
33
|
const example = generateExampleConst(schema, typeName, options?.rootSchema);
|
|
34
34
|
let result = `import * as fc from 'fast-check'\n`;
|
|
35
|
+
// The arbitrary embeds a runtime validator only for schemas whose keywords no
|
|
36
|
+
// `fc.*` combinator captures; import it just for those files.
|
|
37
|
+
if (arbitrary.includes(`${VALIDATE_IMPORT_NAME}(`)) {
|
|
38
|
+
result += VALIDATE_IMPORT_STATEMENT + '\n';
|
|
39
|
+
}
|
|
35
40
|
for (const imp of refImports) {
|
|
36
41
|
result += imp + '\n';
|
|
37
42
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* True when `schema` (anywhere in its applied subschema tree) uses a keyword the
|
|
4
|
+
* direct generators can't guarantee, so the generated value must be run through a
|
|
5
|
+
* validating filter. `$ref`s are not followed — a referenced definition is emitted
|
|
6
|
+
* as its own self-validating file.
|
|
7
|
+
*/
|
|
8
|
+
export declare const needsValidationFilter: (schema: JSONSchema) => boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Returns `schema` augmented with the root document's `$defs`/`definitions` so its
|
|
11
|
+
* local `$ref`s (`#/$defs/…`) resolve when it is validated in isolation. The
|
|
12
|
+
* schema's own definitions win on collision.
|
|
13
|
+
*/
|
|
14
|
+
export declare const withResolvableDefs: (schema: JSONSchema, rootSchema?: Record<string, unknown>) => Record<string, unknown>;
|
|
15
|
+
/**
|
|
16
|
+
* Compiles a boolean validator for `schema` (with the root document's definitions
|
|
17
|
+
* spliced in so local `$ref`s resolve). Used at generation time to accept/reject
|
|
18
|
+
* candidate example values for keywords the deriver can't satisfy structurally.
|
|
19
|
+
*/
|
|
20
|
+
export declare const makeInstanceCheck: (schema: JSONSchema, rootSchema?: Record<string, unknown>) => ((value: unknown) => boolean);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { isSchemaObject } from '@amritk/helpers/schema-guards';
|
|
2
|
+
import { validateGuard } from '@amritk/runtime-validators';
|
|
3
|
+
/**
|
|
4
|
+
* Keywords the direct generators can't fully honour on their own, so a value they
|
|
5
|
+
* produce must be re-checked against a real validator. `oneOf` is included because
|
|
6
|
+
* neither path enforces its *exactly-one* exclusivity; `anyOf`/`allOf` are not,
|
|
7
|
+
* since a single satisfied branch (already how they are generated) is enough.
|
|
8
|
+
*/
|
|
9
|
+
const FILTER_KEYWORDS = new Set([
|
|
10
|
+
'if',
|
|
11
|
+
'then',
|
|
12
|
+
'else',
|
|
13
|
+
'not',
|
|
14
|
+
'oneOf',
|
|
15
|
+
'patternProperties',
|
|
16
|
+
'propertyNames',
|
|
17
|
+
'dependentRequired',
|
|
18
|
+
'dependentSchemas',
|
|
19
|
+
'dependencies',
|
|
20
|
+
'minProperties',
|
|
21
|
+
'maxProperties',
|
|
22
|
+
'contains',
|
|
23
|
+
]);
|
|
24
|
+
/**
|
|
25
|
+
* Keys whose values are *data* (or reference targets), not applied subschemas, so
|
|
26
|
+
* a `not`/`if`/… appearing inside them must not be mistaken for an applicator.
|
|
27
|
+
* `$defs`/`definitions` hold *unapplied* definitions — a hard keyword there only
|
|
28
|
+
* matters once referenced, and each referenced def gets its own generated file.
|
|
29
|
+
*/
|
|
30
|
+
const SKIP_RECURSE = new Set(['enum', 'const', 'examples', 'default', '$ref', 'required', '$defs', 'definitions']);
|
|
31
|
+
/**
|
|
32
|
+
* True when `schema` (anywhere in its applied subschema tree) uses a keyword the
|
|
33
|
+
* direct generators can't guarantee, so the generated value must be run through a
|
|
34
|
+
* validating filter. `$ref`s are not followed — a referenced definition is emitted
|
|
35
|
+
* as its own self-validating file.
|
|
36
|
+
*/
|
|
37
|
+
export const needsValidationFilter = (schema) => {
|
|
38
|
+
const walk = (node) => {
|
|
39
|
+
if (Array.isArray(node))
|
|
40
|
+
return node.some(walk);
|
|
41
|
+
if (node === null || typeof node !== 'object')
|
|
42
|
+
return false;
|
|
43
|
+
const obj = node;
|
|
44
|
+
for (const key of Object.keys(obj)) {
|
|
45
|
+
if (FILTER_KEYWORDS.has(key))
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
49
|
+
if (SKIP_RECURSE.has(key))
|
|
50
|
+
continue;
|
|
51
|
+
if (walk(value))
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
};
|
|
56
|
+
return walk(schema);
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Returns `schema` augmented with the root document's `$defs`/`definitions` so its
|
|
60
|
+
* local `$ref`s (`#/$defs/…`) resolve when it is validated in isolation. The
|
|
61
|
+
* schema's own definitions win on collision.
|
|
62
|
+
*/
|
|
63
|
+
export const withResolvableDefs = (schema, rootSchema) => {
|
|
64
|
+
const base = isSchemaObject(schema) ? { ...schema } : { const: schema };
|
|
65
|
+
if (!rootSchema)
|
|
66
|
+
return base;
|
|
67
|
+
for (const key of ['$defs', 'definitions']) {
|
|
68
|
+
const rootDefs = rootSchema[key];
|
|
69
|
+
if (rootDefs && typeof rootDefs === 'object') {
|
|
70
|
+
base[key] = { ...rootDefs, ...(base[key] ?? {}) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return base;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Compiles a boolean validator for `schema` (with the root document's definitions
|
|
77
|
+
* spliced in so local `$ref`s resolve). Used at generation time to accept/reject
|
|
78
|
+
* candidate example values for keywords the deriver can't satisfy structurally.
|
|
79
|
+
*/
|
|
80
|
+
export const makeInstanceCheck = (schema, rootSchema) => {
|
|
81
|
+
const guard = validateGuard(withResolvableDefs(schema, rootSchema));
|
|
82
|
+
return (value) => guard(value) === true;
|
|
83
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-examples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Generate fast-check arbitraries and example values from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"json-schema-typed": "^8.0.1",
|
|
50
|
-
"@amritk/helpers": "0.
|
|
50
|
+
"@amritk/helpers": "0.13.0",
|
|
51
|
+
"@amritk/runtime-validators": "0.7.0"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
53
54
|
"ajv": "^8.17.1"
|