@amritk/mjst 0.11.0 → 0.12.1

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
@@ -85,6 +85,30 @@ imports from that single shared location. `--build` compiles the whole tree in p
85
85
  schema's root type is named after the schema (its `title`, else its filename in PascalCase),
86
86
  so `user.json` yields `User` / `parseUser` and `api/order.json` yields `Order` / `parseOrder`.
87
87
 
88
+ ### Validators
89
+
90
+ Pass `--validators` to also emit runtime validation functions beside the parsers. For every
91
+ generated type `X` you get a `validateX(input)` — returning a rich `ValidationResult` with
92
+ JSON-Pointer error paths — and an `isX(input)` boolean type guard:
93
+
94
+ ```bash
95
+ npx mjst --schema ./schema.json --out-dir ./generated --validators
96
+ ```
97
+
98
+ The validators carry the same schema-derived filenames as the parsers, so they land in a
99
+ `validators/` subdirectory to avoid colliding:
100
+
101
+ ```
102
+ generated/
103
+ schema.ts, index.ts ← parsers + types
104
+ validators/
105
+ schema.ts, index.ts ← validateX / isX
106
+ validation-result.ts ← ValidationResult / ValidationError contract
107
+ ```
108
+
109
+ This works with `--schema-dir` too (the `validators/` tree mirrors the parser layout) and with
110
+ `--build`. It cannot be combined with `--types-only` or `--out-file`, which emit no runtime code.
111
+
88
112
  ### Running the generated output
89
113
 
90
114
  By default mjst emits relative imports with the literal `.ts` extension (e.g.
@@ -107,6 +131,38 @@ since tsc cannot emit from `.ts` specifiers.
107
131
  > giving `parseSpecPlan` / `validateSpecPlanShape`). Pass `--root-type <Name>` to override it for
108
132
  > a single `--schema` run. Update any imports that referenced `Document` / `parseDocument`.
109
133
 
134
+ ### External and remote `$ref`s
135
+
136
+ A schema can split its definitions across files. When a schema references another
137
+ document — `{ "$ref": "./address.json" }` or `{ "$ref": "./common.json#/$defs/id" }` —
138
+ mjst dereferences those cross-file targets before generating, inlining them into a
139
+ single schema so the generated parsers cover the whole shape:
140
+
141
+ ```bash
142
+ npx mjst --schema ./order.json --out-dir ./generated
143
+ ```
144
+
145
+ Same-document refs (`#/$defs/...`) are left untouched — the generator resolves those
146
+ itself into named type files, so nothing changes for schemas that only reference
147
+ themselves. Local cross-file refs resolve from disk with no extra flags. Remote
148
+ (`http(s)`) refs stay **offline by default**: a schema with a remote `$ref` fails
149
+ rather than making a network call unless you opt in.
150
+
151
+ ```bash
152
+ # Allow remote $ref fetches
153
+ npx mjst --schema ./order.json --out-dir ./generated --resolve-remote
154
+
155
+ # …restricted to specific hosts (implies --resolve-remote)
156
+ npx mjst --schema ./order.json --out-dir ./generated --allowed-hosts schemas.example.com
157
+
158
+ # …permitting private/loopback hosts (off by default as an SSRF guard)
159
+ npx mjst --schema ./order.json --out-dir ./generated --resolve-remote --allow-private-hosts
160
+ ```
161
+
162
+ Unresolvable references (a missing file, a refused host, a bad URL) fail the run with
163
+ the underlying reason rather than emitting a half-resolved schema. This works with
164
+ `--schema-dir` too — each schema resolves its own external references.
165
+
110
166
  ### Config file
111
167
 
112
168
  ```bash
@@ -240,6 +296,24 @@ The exit code is `0` when no finding reaches `--fail-severity`, `1` when one doe
240
296
  <td colspan="4">Generate only TypeScript type definitions without parser functions. Useful when you only need the type shapes and do not need runtime validation.</td>
241
297
  </tr>
242
298
  <tr>
299
+ <td>✅ <code>validators</code></td>
300
+ <td><code>--validators</code></td>
301
+ <td><code>boolean</code></td>
302
+ <td align="center"><code>false</code></td>
303
+ </tr>
304
+ <tr>
305
+ <td colspan="4">Also emit validation functions alongside the parsers. For every generated type X the CLI writes a validateX (returning a rich ValidationResult with JSON-Pointer error paths) and an isX boolean type guard. The files land in a validators/ subdirectory of the output so they never collide with the parser files, which share the same schema-derived names. Works with both schema and schemaDir. Incompatible with typesOnly and outFile, which produce no runtime code.</td>
306
+ </tr>
307
+ <tr>
308
+ <td>🎲 <code>examples</code></td>
309
+ <td><code>--examples</code></td>
310
+ <td><code>boolean</code></td>
311
+ <td align="center"><code>false</code></td>
312
+ </tr>
313
+ <tr>
314
+ <td colspan="4">Also emit test-data files for every schema into an examples/ subdirectory of the output destination: a fast-check arbitrary (FooArbitrary) that produces schema-valid values and a concrete fooExample value. Under schemaDir the examples mirror the schema layout so they never collide with the parser output. The generated arbitraries import fast-check, which consumers must install as a (dev) dependency; the static example values have no runtime dependencies.</td>
315
+ </tr>
316
+ <tr>
243
317
  <td>🔨 <code>build</code></td>
244
318
  <td><code>--build</code></td>
245
319
  <td><code>boolean</code></td>
@@ -312,6 +386,15 @@ The exit code is `0` when no finding reaches `--fail-severity`, `1` when one doe
312
386
  <td colspan="4">Suffix appended to every generated type name derived from a $ref (e.g. 'Object' turns Contact into ContactObject). Defaults to no suffix. The root type name is used verbatim and is unaffected.</td>
313
387
  </tr>
314
388
  <tr>
389
+ <td>🪧 <code>banner</code></td>
390
+ <td><code>--banner [text]</code></td>
391
+ <td><code>boolean | string</code></td>
392
+ <td align="center"><code>false</code></td>
393
+ </tr>
394
+ <tr>
395
+ <td colspan="4">Prepend a comment header to every generated file (excluding _helpers/). true uses the default message ('This file was auto-generated by @amritk/mjst…'); a string uses that text as the message body, wrapped in a JSDoc block; false or omitted emits no header. On the CLI, --banner alone enables the default message and --banner &lt;text&gt; sets a custom one.</td>
396
+ </tr>
397
+ <tr>
315
398
  <td>🔗 <code>importExt</code></td>
316
399
  <td><code>--import-ext &lt;ext&gt;</code></td>
317
400
  <td><code>string</code></td>
@@ -330,13 +413,40 @@ The exit code is `0` when no finding reaches `--fail-severity`, `1` when one doe
330
413
  <td colspan="4">Name for the root type of a single --schema run (e.g. 'Program' yields parseProgram / validateProgramShape). When omitted, the name is derived from the schema's title, falling back to the schema filename in PascalCase (spec-plan.json → SpecPlan) and then to 'Document'. Not supported with --schema-dir, where each schema derives its own root type from its filename.<br><strong>Examples:</strong> <code>"Program"</code></td>
331
414
  </tr>
332
415
  <tr>
416
+ <td>🌐 <code>resolveRemote</code></td>
417
+ <td><code>--resolve-remote</code></td>
418
+ <td><code>boolean</code></td>
419
+ <td align="center"><code>false</code></td>
420
+ </tr>
421
+ <tr>
422
+ <td colspan="4">Allow fetching http(s) $refs while dereferencing a schema that contains cross-file or remote references. Off by default, so generation stays offline unless asked: a schema with a remote $ref fails rather than making a network call. Local cross-file $refs are always resolved and do not need this flag. A non-empty allowedHosts implies this.</td>
423
+ </tr>
424
+ <tr>
425
+ <td>🛡️ <code>allowedHosts</code></td>
426
+ <td><code>--allowed-hosts &lt;hosts&gt;</code></td>
427
+ <td><code>array</code></td>
428
+ <td align="center"></td>
429
+ </tr>
430
+ <tr>
431
+ <td colspan="4">Restrict remote $ref fetches to these hosts (e.g. api.example.com). Accepts a comma-separated list or the flag repeated. Providing an allow-list is itself an opt-in to remote fetching (it implies resolveRemote), and each listed host bypasses the private-host guard.<br><strong>Examples:</strong> <code>["schemas.example.com"]</code></td>
432
+ </tr>
433
+ <tr>
434
+ <td>🔓 <code>allowPrivateHosts</code></td>
435
+ <td><code>--allow-private-hosts</code></td>
436
+ <td><code>boolean</code></td>
437
+ <td align="center"><code>false</code></td>
438
+ </tr>
439
+ <tr>
440
+ <td colspan="4">Permit remote $refs to private, loopback, and link-local hosts. Off by default: such targets are refused as a best-effort SSRF guard (notably the 169.254.169.254 cloud-metadata endpoint). An explicit allowedHosts entry always bypasses this guard.</td>
441
+ </tr>
442
+ <tr>
333
443
  <td>⚙️ <code>config</code></td>
334
444
  <td><code>--config &lt;path&gt;</code></td>
335
445
  <td><code>string</code></td>
336
446
  <td align="center"></td>
337
447
  </tr>
338
448
  <tr>
339
- <td colspan="4">Path to a JSON config file. Keys match the option names in this schema (schema, schemaDir, outDir, outFile, typesOnly, build, logWarnings, strict, readonly, helpers, typeSuffix, importExt, rootType). CLI flags take precedence over config file values.<br><strong>Examples:</strong> <code>"./mjst.config.json"</code></td>
449
+ <td colspan="4">Path to a JSON config file. Keys match the option names in this schema (schema, schemaDir, input, export, outDir, outFile, typesOnly, validators, examples, build, logWarnings, strict, stripUnknown, caseInsensitive, readonly, helpers, typeSuffix, banner, importExt, rootType, resolveRemote, allowedHosts, allowPrivateHosts). CLI flags take precedence over config file values.<br><strong>Examples:</strong> <code>"./mjst.config.json"</code></td>
340
450
  </tr>
341
451
  </tbody>
342
452
  </table>
@@ -36,6 +36,25 @@ export type CliConfig = {
36
36
  * Useful when you only need the type shapes and do not need runtime validation.
37
37
  */
38
38
  readonly typesOnly?: boolean;
39
+ /**
40
+ * When true, also emit validation functions alongside the parsers. For every
41
+ * generated type `X` the CLI writes a `validateX` (returning a rich
42
+ * `ValidationResult` with JSON-Pointer error paths) and an `isX` boolean type
43
+ * guard. The files land in a `validators/` subdirectory of the output so they
44
+ * never collide with the parser files, which share the same schema-derived
45
+ * names. Works with both `schema` and `schemaDir`. Incompatible with
46
+ * `typesOnly` and `outFile`, which produce no runtime code.
47
+ */
48
+ readonly validators?: boolean;
49
+ /**
50
+ * When true, also emit test-data files for every schema: a `fast-check`
51
+ * arbitrary (`FooArbitrary`) that produces schema-valid values and a concrete
52
+ * `fooExample` value. The files are written into an `examples/` subdirectory of
53
+ * the output destination (mirroring the schema layout under `--schema-dir`) so
54
+ * they never collide with the parser output. The generated arbitraries import
55
+ * `fast-check`, which consumers must install as a (dev) dependency.
56
+ */
57
+ readonly examples?: boolean;
39
58
  /**
40
59
  * When true, compile the generated TypeScript files to .js and .d.ts output.
41
60
  * The .ts source files are removed after compilation.
@@ -120,4 +139,25 @@ export type CliConfig = {
120
139
  * Not supported with `schemaDir`, where each schema names its own root.
121
140
  */
122
141
  readonly rootType?: string;
142
+ /**
143
+ * Allow fetching `http(s)` `$ref`s while dereferencing a schema that contains
144
+ * cross-file or remote references. Defaults to `false`: generation stays
145
+ * offline unless asked, so a schema with a remote `$ref` fails rather than
146
+ * making a network call. A non-empty `allowedHosts` implies this.
147
+ */
148
+ readonly resolveRemote?: boolean;
149
+ /**
150
+ * If set, only these hosts (e.g. `api.example.com`) may be fetched for remote
151
+ * `$ref`s. Restricting to an allow-list is itself an opt-in to remote fetching
152
+ * (it implies `resolveRemote`), and each listed host bypasses the private-host
153
+ * guard.
154
+ */
155
+ readonly allowedHosts?: readonly string[];
156
+ /**
157
+ * Permit remote `$ref`s to private, loopback, and link-local hosts. Defaults
158
+ * to `false`: such targets are refused as a best-effort SSRF guard (notably the
159
+ * `169.254.169.254` cloud-metadata endpoint). An explicit `allowedHosts` entry
160
+ * always bypasses this guard.
161
+ */
162
+ readonly allowPrivateHosts?: boolean;
123
163
  };
package/dist/cli.js CHANGED
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile } from 'node:child_process';
3
- import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises';
3
+ import { mkdir, readdir, unlink, writeFile } from 'node:fs/promises';
4
4
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
- import { getAdapter } from '@amritk/adapters/get-adapter';
7
6
  import { buildSchema } from '@amritk/generate-parsers';
7
+ import { buildValidatorSchema } from '@amritk/generate-validators';
8
8
  import { deriveRootTypeName } from '@amritk/helpers/derive-root-type-name';
9
9
  const execFileAsync = promisify(execFile);
10
10
  import { combineGeneratedFiles } from './combine-files.js';
11
11
  import { detectHelpersMode } from './detect-helpers-mode.js';
12
+ import { emitExamples } from './emit-examples.js';
12
13
  import { HELP_TEXT } from './help-text.js';
13
14
  import { loadConfig } from './load-config.js';
14
- import { loadSchemaModule } from './load-schema-module.js';
15
+ import { loadSchema } from './load-schema.js';
15
16
  import { parseCliArgs } from './parse-cli-args.js';
16
17
  import { readVersion } from './read-version.js';
17
18
  import { resolveImportExt } from './resolve-import-ext.js';
@@ -52,16 +53,6 @@ const extractConfigPath = (args) => {
52
53
  }
53
54
  return undefined;
54
55
  };
55
- /** Reads a JSON Schema off disk, or loads a module and converts it via its adapter. */
56
- const loadSchema = async (config, schemaPath) => {
57
- const inputFormat = config.input ?? 'json';
58
- if (inputFormat === 'json') {
59
- return JSON.parse(await readFile(schemaPath, 'utf-8'));
60
- }
61
- console.log(`Input format: ${inputFormat}`);
62
- const source = await loadSchemaModule(schemaPath, config.export);
63
- return getAdapter(inputFormat).toJSONSchema(source);
64
- };
65
56
  const DEFAULT_BANNER_TEXT = 'This file was auto-generated by @amritk/mjst.\n * Do not edit it manually — your changes will be overwritten on the next generation.';
66
57
  const resolveBanner = (banner) => {
67
58
  if (!banner)
@@ -75,6 +66,30 @@ const writeGeneratedFile = async (baseDir, filename, content) => {
75
66
  await mkdir(dirname(filePath), { recursive: true });
76
67
  await writeFile(filePath, content, 'utf-8');
77
68
  };
69
+ /**
70
+ * Emits fast-check arbitrary + example files for one schema (delegating to
71
+ * {@link emitExamples}) and logs each written path. The files land in an
72
+ * `examples/` subdirectory of `outputDir` so they never collide with the parser
73
+ * files, which share the same `<name>.ts` / `index.ts` names. `subDir` mirrors a
74
+ * schema's nested location under `--schema-dir`. The example sources are left out
75
+ * of `--build`: they import the `fast-check` peer dependency and are meant to be
76
+ * consumed by tests, not compiled alongside the runtime parsers.
77
+ */
78
+ const runExamples = async (config, schema, rootTypeName, outputDir, subDir = '') => {
79
+ const written = await emitExamples({
80
+ schema: schema,
81
+ rootTypeName,
82
+ outputDir,
83
+ subDir,
84
+ bannerPrefix: config.banner ? resolveBanner(config.banner) : '',
85
+ // Spread conditionally: `exactOptionalPropertyTypes` rejects an explicit
86
+ // `undefined` for the optional `typeSuffix`.
87
+ ...(config.typeSuffix !== undefined ? { typeSuffix: config.typeSuffix } : {}),
88
+ });
89
+ for (const path of written) {
90
+ console.log(`Generated example: ${path}`);
91
+ }
92
+ };
78
93
  /**
79
94
  * Compiles the generated `.ts` files to `.js`/`.d.ts` with a temporary tsconfig,
80
95
  * then removes the intermediate sources. `tsFiles` are paths relative to `outputDir`.
@@ -145,6 +160,25 @@ const findJsonSchemas = async (dir) => {
145
160
  }
146
161
  return results;
147
162
  };
163
+ /**
164
+ * Emits validator files (`validateX` / `isX`) for one schema into `validatorsSubDir`
165
+ * (a path relative to `outputDir`). The validators carry the same schema-derived
166
+ * filenames as the parsers, so they live in their own subdirectory to avoid
167
+ * colliding. Returns the written `.ts` paths relative to `outputDir` so the caller
168
+ * can fold them into a `--build` compilation.
169
+ */
170
+ const runValidators = async (config, schema, rootTypeName, outputDir, validatorsSubDir) => {
171
+ const files = await buildValidatorSchema(schema, rootTypeName, config.typeSuffix);
172
+ const written = [];
173
+ for (const file of files) {
174
+ const content = config.banner ? resolveBanner(config.banner) + file.content : file.content;
175
+ const relFilename = join(validatorsSubDir, file.filename);
176
+ await writeGeneratedFile(outputDir, relFilename, content);
177
+ written.push(relFilename);
178
+ console.log(`Generated: ${relFilename}`);
179
+ }
180
+ return written;
181
+ };
148
182
  /** Generates parsers for a single schema (the original one-schema-in, one-outDir-out flow). */
149
183
  const runSingle = async (config, schemaPath, outputDir) => {
150
184
  const schema = await loadSchema(config, schemaPath);
@@ -159,11 +193,17 @@ const runSingle = async (config, schemaPath, outputDir) => {
159
193
  await writeGeneratedFile(outputDir, file.filename, content);
160
194
  console.log(`Generated: ${file.filename}`);
161
195
  }
196
+ const validatorFiles = config.validators
197
+ ? await runValidators(config, schema, rootTypeName, outputDir, 'validators')
198
+ : [];
199
+ if (config.examples) {
200
+ await runExamples(config, schema, rootTypeName, outputDir);
201
+ }
162
202
  if (config.build) {
163
- await buildOutput(outputDir, files.map((f) => f.filename), config.typesOnly);
203
+ await buildOutput(outputDir, [...files.map((f) => f.filename), ...validatorFiles], config.typesOnly);
164
204
  }
165
205
  else {
166
- console.log(`\nTotal files generated: ${files.length}`);
206
+ console.log(`\nTotal files generated: ${files.length + validatorFiles.length}`);
167
207
  }
168
208
  };
169
209
  /**
@@ -181,6 +221,9 @@ const runSingleFile = async (config, schemaPath, outFilePath) => {
181
221
  const combined = combineGeneratedFiles(files);
182
222
  await writeFile(outFilePath, config.banner ? resolveBanner(config.banner) + combined : combined, 'utf-8');
183
223
  console.log(`Generated: ${relative(process.cwd(), outFilePath)}`);
224
+ if (config.examples) {
225
+ await runExamples(config, schema, rootTypeName, outputDir);
226
+ }
184
227
  if (config.build) {
185
228
  await buildOutput(outputDir, [basename(outFilePath)], config.typesOnly);
186
229
  }
@@ -233,6 +276,16 @@ const runRecursive = async (config, schemaDir, outputDir) => {
233
276
  writtenTsFiles.push(relativeFilename);
234
277
  console.log(`Generated: ${relativeFilename}`);
235
278
  }
279
+ if (config.validators) {
280
+ // Mirror the parser layout under a top-level `validators/` tree so each
281
+ // schema's validators sit beside their parser counterparts without sharing
282
+ // a filename.
283
+ const validatorFiles = await runValidators(config, schema, rootTypeName, outputDir, join('validators', relNoExt));
284
+ writtenTsFiles.push(...validatorFiles);
285
+ }
286
+ if (config.examples) {
287
+ await runExamples(config, schema, rootTypeName, outputDir, relNoExt);
288
+ }
236
289
  }
237
290
  for (const [filename, content] of sharedHelpers) {
238
291
  await writeGeneratedFile(outputDir, filename, content);
@@ -293,6 +346,16 @@ const run = async () => {
293
346
  console.error('Error: --import-ext ts cannot be combined with --build. Drop --build to keep runnable .ts sources.');
294
347
  process.exit(1);
295
348
  }
349
+ // Validators are runtime code (validateX / isX), so they have nothing to emit
350
+ // in the type-only flows.
351
+ if (config.validators && config.typesOnly) {
352
+ console.error('Error: --validators cannot be combined with --types-only, which emits no runtime code.');
353
+ process.exit(1);
354
+ }
355
+ if (config.validators && config.outFile) {
356
+ console.error('Error: --validators cannot be combined with --out-file. Use --out-dir to emit validator files.');
357
+ process.exit(1);
358
+ }
296
359
  // Single-file output is the alternative to a directory of files.
297
360
  if (config.outFile) {
298
361
  if (!config.typesOnly) {
@@ -0,0 +1,36 @@
1
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
+ /**
3
+ * Inputs for {@link emitExamples}. The schema and its root type name are the same
4
+ * ones handed to the parser generator, so the arbitraries/examples line up with
5
+ * the parsers that consume them.
6
+ */
7
+ export type EmitExamplesOptions = {
8
+ readonly schema: JSONSchema;
9
+ readonly rootTypeName: string;
10
+ /** Output root; example files always land under `<outputDir>/examples/`. */
11
+ readonly outputDir: string;
12
+ /**
13
+ * Nested location of the schema relative to `--schema-dir` (e.g. `api/order`),
14
+ * mirrored beneath `examples/` so each schema's test data sits beside where its
15
+ * parsers were emitted. Defaults to `''` (single-schema output at the root).
16
+ */
17
+ readonly subDir?: string;
18
+ /** Suffix appended to every `$ref`-derived type/arbitrary name. Defaults to `''`. */
19
+ readonly typeSuffix?: string;
20
+ /** Header comment (already wrapped in a JSDoc block) prepended to each file, or `''`. */
21
+ readonly bannerPrefix?: string;
22
+ };
23
+ /**
24
+ * Emits fast-check arbitrary + concrete example files for one schema into an
25
+ * `examples/` subdirectory of `outputDir`.
26
+ *
27
+ * The dedicated subdirectory keeps the test-data output from colliding with the
28
+ * parser files, which otherwise share the same `<name>.ts` / `index.ts` names.
29
+ * Each generated file exports a `FooArbitrary` (a `fast-check` arbitrary that
30
+ * produces schema-valid values) and a static `fooExample` value; an `index.ts`
31
+ * barrel re-exports them. The arbitraries import `fast-check`, which consumers
32
+ * must install as a (dev) dependency.
33
+ *
34
+ * @returns The written file paths, relative to `outputDir`.
35
+ */
36
+ export declare const emitExamples: (options: EmitExamplesOptions) => Promise<string[]>;
@@ -0,0 +1,29 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, relative } from 'node:path';
3
+ import { buildExampleSchema } from '@amritk/generate-examples';
4
+ /**
5
+ * Emits fast-check arbitrary + concrete example files for one schema into an
6
+ * `examples/` subdirectory of `outputDir`.
7
+ *
8
+ * The dedicated subdirectory keeps the test-data output from colliding with the
9
+ * parser files, which otherwise share the same `<name>.ts` / `index.ts` names.
10
+ * Each generated file exports a `FooArbitrary` (a `fast-check` arbitrary that
11
+ * produces schema-valid values) and a static `fooExample` value; an `index.ts`
12
+ * barrel re-exports them. The arbitraries import `fast-check`, which consumers
13
+ * must install as a (dev) dependency.
14
+ *
15
+ * @returns The written file paths, relative to `outputDir`.
16
+ */
17
+ export const emitExamples = async (options) => {
18
+ const { schema, rootTypeName, outputDir, subDir = '', typeSuffix, bannerPrefix = '' } = options;
19
+ const files = await buildExampleSchema(schema, rootTypeName, typeSuffix);
20
+ const exampleDir = join(outputDir, 'examples', subDir);
21
+ const written = [];
22
+ for (const file of files) {
23
+ const filePath = join(exampleDir, file.filename);
24
+ await mkdir(dirname(filePath), { recursive: true });
25
+ await writeFile(filePath, bannerPrefix + file.content, 'utf-8');
26
+ written.push(relative(outputDir, filePath));
27
+ }
28
+ return written;
29
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Whether `data` contains a reference into another document (a `$ref` value that
3
+ * is not a same-document `#...` fragment). Same-document references resolve
4
+ * without a disk round-trip, so callers can skip the file-backed resolver — and
5
+ * preserve behavior that depends on those internal refs — unless an external
6
+ * (cross-file or remote) target exists.
7
+ */
8
+ export declare const hasExternalRefs: (data: unknown) => boolean;
@@ -0,0 +1,22 @@
1
+ /** The reference keywords whose value may point at another document (a non-`#` target). */
2
+ const REF_KEYWORDS = ['$ref', '$dynamicRef', '$recursiveRef'];
3
+ /**
4
+ * Whether `data` contains a reference into another document (a `$ref` value that
5
+ * is not a same-document `#...` fragment). Same-document references resolve
6
+ * without a disk round-trip, so callers can skip the file-backed resolver — and
7
+ * preserve behavior that depends on those internal refs — unless an external
8
+ * (cross-file or remote) target exists.
9
+ */
10
+ export const hasExternalRefs = (data) => {
11
+ if (data === null || typeof data !== 'object')
12
+ return false;
13
+ if (Array.isArray(data))
14
+ return data.some(hasExternalRefs);
15
+ const obj = data;
16
+ for (const keyword of REF_KEYWORDS) {
17
+ const value = obj[keyword];
18
+ if (typeof value === 'string' && !value.startsWith('#'))
19
+ return true;
20
+ }
21
+ return Object.values(obj).some(hasExternalRefs);
22
+ };
@@ -4,4 +4,4 @@
4
4
  * documents these flags (config.schema.json) is not shipped in the published
5
5
  * package; the help-text test cross-checks that every flag is listed.
6
6
  */
7
- export declare const HELP_TEXT = "mjst \u2014 generate TypeScript parsers and type definitions from JSON Schemas\n\nUsage:\n mjst --schema <path> --out-dir <dir> [options]\n mjst --schema-dir <dir> --out-dir <dir> [options]\n mjst --schema <path> --out-file <file> --types-only [options]\n\nInput:\n --schema <path> Schema to process: a JSON Schema file, or a module when --input is set\n --schema-dir <dir> Directory of JSON Schemas, processed recursively (instead of --schema)\n --input <format> Schema source format: json (default), typebox, zod, valibot, effect\n --export <name> Which export of the schema module to use when --input is not json\n\nOutput:\n --out-dir <dir> Output directory for the generated files\n --out-file <file> Single-file output instead of a directory (requires --types-only)\n --types-only Generate type definitions only, without parser functions\n --build Compile the generated files to .js/.d.ts (implies --import-ext js)\n --import-ext <ext> Extension on generated relative imports: ts (default) or js\n --helpers <mode> Runtime helpers: package or embedded (default: auto-detect from package.json)\n --root-type <name> Root type name for a single --schema run (default: schema title or filename)\n --type-suffix <s> Suffix appended to every $ref-derived type name\n --banner [text] Prepend a header comment to every generated file\n --readonly Emit deeply readonly type definitions\n\nValidation:\n --strict Throw on type/shape mismatches instead of coercing to defaults\n --strip-unknown Silently drop undeclared input keys at every nesting level\n --case-insensitive Normalize mis-cased enum/const strings to their exact casing\n --log-warnings console.warn on input keys not declared in the schema\n\nMisc:\n --config <path> JSON config file with the same keys; CLI flags take precedence\n --version, -v Print the CLI version\n --help, -h Print this help\n\nDocs: https://github.com/amritk/mjst/tree/main/packages/cli#readme\n";
7
+ export declare const HELP_TEXT = "mjst \u2014 generate TypeScript parsers and type definitions from JSON Schemas\n\nUsage:\n mjst --schema <path> --out-dir <dir> [options]\n mjst --schema-dir <dir> --out-dir <dir> [options]\n mjst --schema <path> --out-file <file> --types-only [options]\n\nInput:\n --schema <path> Schema to process: a JSON Schema file, or a module when --input is set\n --schema-dir <dir> Directory of JSON Schemas, processed recursively (instead of --schema)\n --input <format> Schema source format: json (default), typebox, zod, valibot, effect\n --export <name> Which export of the schema module to use when --input is not json\n\nOutput:\n --out-dir <dir> Output directory for the generated files\n --out-file <file> Single-file output instead of a directory (requires --types-only)\n --types-only Generate type definitions only, without parser functions\n --validators Also emit validateX/isX validators into a validators/ subdir\n --examples Also emit fast-check arbitraries + example values under examples/\n --build Compile the generated files to .js/.d.ts (implies --import-ext js)\n --import-ext <ext> Extension on generated relative imports: ts (default) or js\n --helpers <mode> Runtime helpers: package or embedded (default: auto-detect from package.json)\n --root-type <name> Root type name for a single --schema run (default: schema title or filename)\n --type-suffix <s> Suffix appended to every $ref-derived type name\n --banner [text] Prepend a header comment to every generated file\n --readonly Emit deeply readonly type definitions\n\nValidation:\n --strict Throw on type/shape mismatches instead of coercing to defaults\n --strip-unknown Silently drop undeclared input keys at every nesting level\n --case-insensitive Normalize mis-cased enum/const strings to their exact casing\n --log-warnings console.warn on input keys not declared in the schema\n\nReference resolution:\n --resolve-remote Fetch http(s) $refs while resolving cross-file/remote refs (off by default)\n --allowed-hosts <h> Restrict remote $ref fetches to these hosts (comma-separated; implies --resolve-remote)\n --allow-private-hosts Permit remote $refs to private/loopback hosts (SSRF guard, off by default)\n\nMisc:\n --config <path> JSON config file with the same keys; CLI flags take precedence\n --version, -v Print the CLI version\n --help, -h Print this help\n\nDocs: https://github.com/amritk/mjst/tree/main/packages/cli#readme\n";
package/dist/help-text.js CHANGED
@@ -21,6 +21,8 @@ Output:
21
21
  --out-dir <dir> Output directory for the generated files
22
22
  --out-file <file> Single-file output instead of a directory (requires --types-only)
23
23
  --types-only Generate type definitions only, without parser functions
24
+ --validators Also emit validateX/isX validators into a validators/ subdir
25
+ --examples Also emit fast-check arbitraries + example values under examples/
24
26
  --build Compile the generated files to .js/.d.ts (implies --import-ext js)
25
27
  --import-ext <ext> Extension on generated relative imports: ts (default) or js
26
28
  --helpers <mode> Runtime helpers: package or embedded (default: auto-detect from package.json)
@@ -35,6 +37,11 @@ Validation:
35
37
  --case-insensitive Normalize mis-cased enum/const strings to their exact casing
36
38
  --log-warnings console.warn on input keys not declared in the schema
37
39
 
40
+ Reference resolution:
41
+ --resolve-remote Fetch http(s) $refs while resolving cross-file/remote refs (off by default)
42
+ --allowed-hosts <h> Restrict remote $ref fetches to these hosts (comma-separated; implies --resolve-remote)
43
+ --allow-private-hosts Permit remote $refs to private/loopback hosts (SSRF guard, off by default)
44
+
38
45
  Misc:
39
46
  --config <path> JSON config file with the same keys; CLI flags take precedence
40
47
  --version, -v Print the CLI version
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { resolve as resolvePath } from 'node:path';
3
- import { createDocument, resolveSourceOriginFromMap, } from '@amritk/lint';
3
+ import { createDocument, DiagnosticSeverity, resolveSourceOriginFromMap, } from '@amritk/lint';
4
4
  import { resolveRefs, resolveRefsFromFile } from '@amritk/resolve-refs';
5
5
  import { parse as parseYaml } from '@amritk/yaml';
6
+ import { hasExternalRefs } from '../has-external-refs.js';
6
7
  const isRemote = (location) => /^https?:\/\//i.test(location);
7
8
  /**
8
9
  * Parses a referenced document by extension: YAML for `.yaml`/`.yml`, JSON for
@@ -21,26 +22,6 @@ const parseDoc = (content, location) => {
21
22
  return parseYaml(content);
22
23
  }
23
24
  };
24
- /** The reference keywords whose value may point at another document (a non-`#` target). */
25
- const REF_KEYWORDS = ['$ref', '$dynamicRef', '$recursiveRef'];
26
- /**
27
- * Whether `data` contains a reference into another document (a `$ref` value that
28
- * is not a same-document `#...` fragment). Internal-only documents resolve fully
29
- * in memory, so we avoid the disk round-trip unless an external target exists.
30
- */
31
- const hasExternalRefs = (data) => {
32
- if (data === null || typeof data !== 'object')
33
- return false;
34
- if (Array.isArray(data))
35
- return data.some(hasExternalRefs);
36
- const obj = data;
37
- for (const keyword of REF_KEYWORDS) {
38
- const value = obj[keyword];
39
- if (typeof value === 'string' && !value.startsWith('#'))
40
- return true;
41
- }
42
- return Object.values(obj).some(hasExternalRefs);
43
- };
44
25
  /** Whether the file at `absolute` reads back byte-for-byte as `input` (the document we linted). */
45
26
  const readsBackAs = (absolute, input) => {
46
27
  try {
@@ -82,6 +63,28 @@ const buildSourceSet = (rootDocument, resolved, origins, rootLocation) => {
82
63
  origin: (path) => resolveSourceOriginFromMap(resolved, originMap, rootLocation, path),
83
64
  };
84
65
  };
66
+ /** A zero-width range at the document start, for resolve errors with no recoverable position. */
67
+ const DOCUMENT_START = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
68
+ /**
69
+ * Maps resolver failures ({@link ResolveError}) into lint findings so an
70
+ * unresolvable `$ref` — a typo'd pointer, a missing file, a refused/failed
71
+ * remote fetch — surfaces as a diagnostic instead of being silently dropped. An
72
+ * error carrying a JSON path is anchored to that node's position in the root
73
+ * document (best effort, `closest`); otherwise the finding is document-level.
74
+ */
75
+ const toFindings = (errors, root) => errors.map((error) => {
76
+ const location = error.path.length > 0 ? root.getLocationForJsonPath(error.path, true) : undefined;
77
+ const finding = {
78
+ code: 'unresolved-ref',
79
+ message: error.message,
80
+ path: error.path,
81
+ severity: DiagnosticSeverity.Error,
82
+ range: location?.range ?? DOCUMENT_START,
83
+ };
84
+ if (root.source !== undefined)
85
+ finding.source = root.source;
86
+ return finding;
87
+ });
85
88
  /**
86
89
  * Builds a {@link LintResolver} backed by `@amritk/resolve-refs`, dereferencing
87
90
  * `$ref`, `$dynamicRef`/`$dynamicAnchor`, and `$recursiveRef`/`$recursiveAnchor`
@@ -105,11 +108,19 @@ export const createLintResolver = (options = {}) => {
105
108
  if (source && hasExternalRefs(document.data)) {
106
109
  const absolute = resolvePath(source);
107
110
  if (readsBackAs(absolute, input)) {
108
- const { resolved, origins } = await resolveRefsFromFile(absolute, fromFileOptions);
109
- return { resolved, sources: buildSourceSet(document, resolved, origins, absolute) };
111
+ const { resolved, origins, errors } = await resolveRefsFromFile(absolute, fromFileOptions);
112
+ return {
113
+ resolved,
114
+ sources: buildSourceSet(document, resolved, origins, absolute),
115
+ ...(errors.length > 0 ? { diagnostics: toFindings(errors, document) } : {}),
116
+ };
110
117
  }
111
118
  }
112
- const { resolved, origins } = resolveRefs(document.data, { trackOrigins: true });
113
- return { resolved, sources: buildSourceSet(document, resolved, origins, '') };
119
+ const { resolved, origins, errors } = resolveRefs(document.data, { trackOrigins: true });
120
+ return {
121
+ resolved,
122
+ sources: buildSourceSet(document, resolved, origins, ''),
123
+ ...(errors.length > 0 ? { diagnostics: toFindings(errors, document) } : {}),
124
+ };
114
125
  };
115
126
  };
@@ -4,6 +4,7 @@ const SOURCE_FORMATS = ['json', 'typebox', 'zod', 'valibot', 'effect'];
4
4
  const isSourceFormat = (value) => typeof value === 'string' && SOURCE_FORMATS.includes(value);
5
5
  const isHelpersMode = (value) => value === 'package' || value === 'embedded';
6
6
  const isImportExt = (value) => value === 'js' || value === 'ts';
7
+ const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === 'string');
7
8
  /**
8
9
  * Loads a JSON config file and returns the relevant CLI config properties.
9
10
  * The config file should have the same keys as the CLI flags (schema, outDir).
@@ -23,7 +24,9 @@ export const loadConfig = async (configPath) => {
23
24
  ...(typeof obj['outFile'] === 'string' && { outFile: obj['outFile'] }),
24
25
  ...(isSourceFormat(obj['input']) && { input: obj['input'] }),
25
26
  ...(typeof obj['export'] === 'string' && { export: obj['export'] }),
27
+ ...(typeof obj['validators'] === 'boolean' && { validators: obj['validators'] }),
26
28
  ...(typeof obj['typesOnly'] === 'boolean' && { typesOnly: obj['typesOnly'] }),
29
+ ...(typeof obj['examples'] === 'boolean' && { examples: obj['examples'] }),
27
30
  ...(typeof obj['build'] === 'boolean' && { build: obj['build'] }),
28
31
  ...(typeof obj['logWarnings'] === 'boolean' && { logWarnings: obj['logWarnings'] }),
29
32
  ...(typeof obj['strict'] === 'boolean' && { strict: obj['strict'] }),
@@ -36,5 +39,8 @@ export const loadConfig = async (configPath) => {
36
39
  ...(typeof obj['typeSuffix'] === 'string' && { typeSuffix: obj['typeSuffix'] }),
37
40
  // `banner` is a boolean toggle or a custom header string, mirroring the CLI flag.
38
41
  ...((typeof obj['banner'] === 'boolean' || typeof obj['banner'] === 'string') && { banner: obj['banner'] }),
42
+ ...(typeof obj['resolveRemote'] === 'boolean' && { resolveRemote: obj['resolveRemote'] }),
43
+ ...(isStringArray(obj['allowedHosts']) && { allowedHosts: obj['allowedHosts'] }),
44
+ ...(typeof obj['allowPrivateHosts'] === 'boolean' && { allowPrivateHosts: obj['allowPrivateHosts'] }),
39
45
  };
40
46
  };
@@ -0,0 +1,14 @@
1
+ import type { CliConfig } from './cli-config.js';
2
+ /**
3
+ * Reads a JSON Schema off disk, or loads a module and converts it via its adapter.
4
+ *
5
+ * For JSON input, a schema whose only references are same-document `#/...`
6
+ * pointers is parsed as-is — the generator resolves those internal `$ref`s
7
+ * itself into named type files, so inlining them here would collapse that
8
+ * structure. When a cross-file or remote `$ref` is present (which the generator
9
+ * cannot follow on its own), the schema is dereferenced with
10
+ * `@amritk/resolve-refs`, inlining every external target into a single document.
11
+ * Any resolve failures are surfaced as a CLI error rather than silently yielding
12
+ * a half-resolved schema.
13
+ */
14
+ export declare const loadSchema: (config: Partial<CliConfig>, schemaPath: string) => Promise<unknown>;
@@ -0,0 +1,52 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { getAdapter } from '@amritk/adapters/get-adapter';
3
+ import { resolveRefsFromFile } from '@amritk/resolve-refs';
4
+ import { hasExternalRefs } from './has-external-refs.js';
5
+ import { loadSchemaModule } from './load-schema-module.js';
6
+ /**
7
+ * Builds the `@amritk/resolve-refs` options from the CLI config, mirroring the
8
+ * safety posture of `mjst lint`: remote (`http(s)`) `$ref`s are refused unless
9
+ * opted into, a non-empty `allowedHosts` implies remote fetching, and private/
10
+ * loopback hosts stay blocked as an SSRF guard unless explicitly permitted.
11
+ */
12
+ const buildResolveOptions = (config) => {
13
+ // A non-empty allow-list is itself an opt-in to remote fetching.
14
+ const remote = (config.resolveRemote ?? false) || (config.allowedHosts?.length ?? 0) > 0;
15
+ return {
16
+ remote,
17
+ ...(config.allowedHosts ? { allowedHosts: [...config.allowedHosts] } : {}),
18
+ ...(config.allowPrivateHosts ? { allowPrivateHosts: config.allowPrivateHosts } : {}),
19
+ };
20
+ };
21
+ /** Renders resolver failures into a single CLI error message. */
22
+ const formatResolveErrors = (schemaPath, errors) => {
23
+ const details = errors.map((error) => ` - ${error.message}`).join('\n');
24
+ return `Failed to resolve $refs in ${schemaPath}:\n${details}`;
25
+ };
26
+ /**
27
+ * Reads a JSON Schema off disk, or loads a module and converts it via its adapter.
28
+ *
29
+ * For JSON input, a schema whose only references are same-document `#/...`
30
+ * pointers is parsed as-is — the generator resolves those internal `$ref`s
31
+ * itself into named type files, so inlining them here would collapse that
32
+ * structure. When a cross-file or remote `$ref` is present (which the generator
33
+ * cannot follow on its own), the schema is dereferenced with
34
+ * `@amritk/resolve-refs`, inlining every external target into a single document.
35
+ * Any resolve failures are surfaced as a CLI error rather than silently yielding
36
+ * a half-resolved schema.
37
+ */
38
+ export const loadSchema = async (config, schemaPath) => {
39
+ const inputFormat = config.input ?? 'json';
40
+ if (inputFormat === 'json') {
41
+ const data = JSON.parse(await readFile(schemaPath, 'utf-8'));
42
+ if (!hasExternalRefs(data))
43
+ return data;
44
+ const { resolved, errors } = await resolveRefsFromFile(schemaPath, buildResolveOptions(config));
45
+ if (errors.length > 0)
46
+ throw new Error(formatResolveErrors(schemaPath, errors));
47
+ return resolved;
48
+ }
49
+ console.log(`Input format: ${inputFormat}`);
50
+ const source = await loadSchemaModule(schemaPath, config.export);
51
+ return getAdapter(inputFormat).toJSONSchema(source);
52
+ };
@@ -2,12 +2,16 @@ const SOURCE_FORMATS = ['json', 'typebox', 'zod', 'valibot', 'effect'];
2
2
  // Boolean flags toggle on by presence and accept `--flag=false` to opt out.
3
3
  const BOOLEAN_KEYS = new Set([
4
4
  'typesOnly',
5
+ 'validators',
6
+ 'examples',
5
7
  'build',
6
8
  'logWarnings',
7
9
  'strict',
8
10
  'stripUnknown',
9
11
  'caseInsensitive',
10
12
  'readonly',
13
+ 'resolveRemote',
14
+ 'allowPrivateHosts',
11
15
  ]);
12
16
  // Value flags consume the following argument (or `--flag=value`).
13
17
  const VALUE_KEYS = new Set([
@@ -40,6 +44,20 @@ const parseImportExtValue = (value) => {
40
44
  return undefined;
41
45
  };
42
46
  const parseInputValue = (value) => SOURCE_FORMATS.includes(value) ? value : undefined;
47
+ /**
48
+ * Appends the hosts in `value` (comma-separated) onto `allowedHosts`. Repeating
49
+ * `--allowed-hosts` accumulates, so `--allowed-hosts a.com --allowed-hosts b.com`
50
+ * and `--allowed-hosts a.com,b.com` are equivalent. Blank entries are dropped.
51
+ */
52
+ const appendAllowedHosts = (config, value) => {
53
+ const hosts = value
54
+ .split(',')
55
+ .map((host) => host.trim())
56
+ .filter((host) => host.length > 0);
57
+ if (hosts.length === 0)
58
+ return;
59
+ config.allowedHosts = [...(config.allowedHosts ?? []), ...hosts];
60
+ };
43
61
  /** Assigns a value-flag onto the config. Returns false for unknown keys. */
44
62
  const assignValue = (config, key, value) => {
45
63
  switch (key) {
@@ -98,6 +116,12 @@ const assignBoolean = (config, key, value) => {
98
116
  case 'typesOnly':
99
117
  config.typesOnly = value;
100
118
  return true;
119
+ case 'validators':
120
+ config.validators = value;
121
+ return true;
122
+ case 'examples':
123
+ config.examples = value;
124
+ return true;
101
125
  case 'build':
102
126
  config.build = value;
103
127
  return true;
@@ -116,6 +140,12 @@ const assignBoolean = (config, key, value) => {
116
140
  case 'readonly':
117
141
  config.readonly = value;
118
142
  return true;
143
+ case 'resolveRemote':
144
+ config.resolveRemote = value;
145
+ return true;
146
+ case 'allowPrivateHosts':
147
+ config.allowPrivateHosts = value;
148
+ return true;
119
149
  default:
120
150
  return false;
121
151
  }
@@ -143,6 +173,9 @@ export const parseCliArgs = (args) => {
143
173
  // --banner=false → false, --banner=true → true, --banner=<text> → custom string
144
174
  config.banner = value === 'false' ? false : value === 'true' ? true : value;
145
175
  }
176
+ else if (key === 'allowedHosts') {
177
+ appendAllowedHosts(config, value);
178
+ }
146
179
  else if (BOOLEAN_KEYS.has(key)) {
147
180
  assignBoolean(config, key, value !== 'false');
148
181
  }
@@ -172,6 +205,16 @@ export const parseCliArgs = (args) => {
172
205
  }
173
206
  continue;
174
207
  }
208
+ // --allowed-hosts takes a value (comma-separated) and accumulates when repeated.
209
+ if (key === 'allowedHosts') {
210
+ const value = args[i + 1];
211
+ if (value === undefined || value.startsWith('--')) {
212
+ throw new Error(`Flag "--${flagName}" expects a value.`);
213
+ }
214
+ appendAllowedHosts(config, value);
215
+ i++;
216
+ continue;
217
+ }
175
218
  // Boolean flag: presence alone enables it, no value needed
176
219
  if (BOOLEAN_KEYS.has(key)) {
177
220
  assignBoolean(config, key, true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/mjst",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,13 +41,15 @@
41
41
  "generate-readme": "bun run ./scripts/generate-readme.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@amritk/adapters": "0.2.16",
45
- "@amritk/generate-parsers": "0.16.0",
44
+ "@amritk/adapters": "0.3.1",
45
+ "@amritk/generate-examples": "0.5.1",
46
+ "@amritk/generate-parsers": "0.16.2",
47
+ "@amritk/generate-validators": "0.11.7",
46
48
  "@amritk/generate-markdown": "0.4.1",
47
- "@amritk/helpers": "0.12.0",
48
- "@amritk/lint": "0.2.0",
49
- "@amritk/resolve-refs": "0.3.0",
50
- "@amritk/yaml": "0.2.3",
49
+ "@amritk/helpers": "0.13.1",
50
+ "@amritk/lint": "0.3.1",
51
+ "@amritk/resolve-refs": "0.4.1",
52
+ "@amritk/yaml": "0.3.1",
51
53
  "fast-glob": "^3.3.2",
52
54
  "yargs": "^17.7.2"
53
55
  },