@amritk/generate-examples 0.4.5 → 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 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`). The static `fooExample` values have no runtime
36
- > dependencies.
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`, `const`,
129
- `oneOf`/`anyOf`, `$ref`, and the `x-mjst` extension (`Date`, `bigint`).
130
- Unsupported constructs degrade to `fc.anything()` in arbitraries and `null` in
131
- static examples.
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, hasItems, hasOneOf, hasRef } from '@amritk/helpers/schema-guards';
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
- * Walks one level of the schema and yields all direct $ref strings that should
17
- * become imports: properties, additionalProperties, items, and union branches.
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 collectDirectRefs = (schema) => {
20
- if (typeof schema === 'boolean' || schema === null)
30
+ const collectRefs = (schema) => {
31
+ if (!isSchemaObject(schema))
21
32
  return [];
33
+ if (hasRef(schema))
34
+ return [schema.$ref];
22
35
  const refs = [];
23
- if (hasRef(schema)) {
24
- refs.push(schema.$ref);
25
- return refs;
26
- }
27
- const propSchemas = 'properties' in schema && typeof schema.properties === 'object' && schema.properties !== null
28
- ? Object.values(schema.properties)
29
- : [];
30
- for (const prop of propSchemas) {
31
- if (hasRef(prop))
32
- refs.push(prop.$ref);
33
- if (hasItems(prop) && hasRef(prop.items))
34
- refs.push(prop.items.$ref);
35
- if (hasAdditionalProperties(prop) && hasRef(prop.additionalProperties)) {
36
- refs.push(prop.additionalProperties.$ref);
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
- for (const branch of [
46
- ...(hasOneOf(schema) ? schema.oneOf : []),
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 = collectDirectRefs(schema);
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
- const out = {};
312
- if (hasProperties(schema)) {
313
- for (const [key, propSchema] of Object.entries(schema.properties)) {
314
- out[key] = deriveExample(propSchema, rootSchema, seen);
315
- }
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
- }
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
- // 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
- }
386
+ catch {
387
+ return [];
339
388
  }
340
- return out;
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
- default:
343
- return null;
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
- const base = `fc.double({ ${opts.join(', ')} })`;
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
- const items = hasItems(schema) && isSchemaObject(schema.items) ? arbitraryExpr(schema.items, ctx) : 'fc.anything()';
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 (hasMinItems(schema))
115
- opts.push(`minLength: ${schema.minItems}`);
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
- 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()';
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
- const keys = Object.keys(schema.properties);
134
- const entries = Object.entries(schema.properties).map(([key, propSchema]) => `${JSON.stringify(key)}: ${arbitraryExpr(propSchema, ctx)}`);
135
- if (entries.length === 0)
136
- return 'fc.record({})';
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
- // 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 }))`;
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
- const values = schema.enum.map((value) => JSON.stringify(value)).join(', ');
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
- 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}`;
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}> = ${expr}`;
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.4.5",
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.12.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"