@amritk/mjst 0.10.0 → 0.12.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
@@ -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
@@ -151,8 +207,14 @@ With no `-r`, mjst discovers a `.lint.{yaml,yml,json,js,mjs}` ruleset by walking
151
207
  | `-D`, `--display-only-failures` | Only show findings at or above `--fail-severity`. |
152
208
  | `--stdin-filepath` | Path to associate with piped input (labels findings and enables ruleset discovery). |
153
209
  | `--concurrency` | Max documents linted in parallel (default `8`). |
210
+ | `--no-resolve` | Skip `$ref` dereferencing; rules see the raw document. |
211
+ | `--resolve-remote` | Fetch `http(s)` `$ref`s while resolving (off by default; a lint run stays offline). |
212
+ | `--allowed-hosts` | Restrict remote `$ref` fetches to these hosts (implies `--resolve-remote`). |
213
+ | `--allow-private-hosts` | Permit remote `$ref`s to private/loopback hosts (SSRF guard, off by default). |
154
214
  | `-q`, `--quiet` | Suppress the findings report (the exit code still reflects findings). |
155
215
 
216
+ By default `mjst lint` dereferences `$ref`, `$dynamicRef`/`$dynamicAnchor`, and `$recursiveRef`/`$recursiveAnchor` before running rules, so rules with `resolved: true` (the ruleset default) see through references. Internal and local cross-file refs resolve from disk — a finding on a cross-file node reports that file's own `line:column` — while remote refs are only fetched when you opt in with `--resolve-remote` or `--allowed-hosts`.
217
+
156
218
  The exit code is `0` when no finding reaches `--fail-severity`, `1` when one does, and `2` on a usage error. Run `mjst lint --help` for the full list.
157
219
 
158
220
  ---
@@ -234,6 +296,24 @@ The exit code is `0` when no finding reaches `--fail-severity`, `1` when one doe
234
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>
235
297
  </tr>
236
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>
237
317
  <td>🔨 <code>build</code></td>
238
318
  <td><code>--build</code></td>
239
319
  <td><code>boolean</code></td>
@@ -270,6 +350,15 @@ The exit code is `0` when no finding reaches `--fail-severity`, `1` when one doe
270
350
  <td colspan="4">Build each parser's result from the schema's declared properties only, silently dropping undeclared input keys at every nesting level (zod's .strip()). Extras are never a validation error, so this composes with strict (which still throws on wrong types and missing required properties) and yields to additionalProperties: false, which rejects rather than strips in strict mode.</td>
271
351
  </tr>
272
352
  <tr>
353
+ <td>🔡 <code>caseInsensitive</code></td>
354
+ <td><code>--case-insensitive</code></td>
355
+ <td><code>boolean</code></td>
356
+ <td align="center"><code>false</code></td>
357
+ </tr>
358
+ <tr>
359
+ <td colspan="4">Normalize a mis-cased string to the exact casing of a declared enum/const member it matches case-insensitively (e.g. hElLo → hello) instead of coercing to the default. Coerce mode only — strict parsers still reject a casing mismatch. Correctly-cased input keeps the exact-match fast path, so the hot path is unaffected.</td>
360
+ </tr>
361
+ <tr>
273
362
  <td>🔒 <code>readonly</code></td>
274
363
  <td><code>--readonly</code></td>
275
364
  <td><code>boolean</code></td>
@@ -297,6 +386,15 @@ The exit code is `0` when no finding reaches `--fail-severity`, `1` when one doe
297
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>
298
387
  </tr>
299
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>
300
398
  <td>🔗 <code>importExt</code></td>
301
399
  <td><code>--import-ext &lt;ext&gt;</code></td>
302
400
  <td><code>string</code></td>
@@ -315,13 +413,40 @@ The exit code is `0` when no finding reaches `--fail-severity`, `1` when one doe
315
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>
316
414
  </tr>
317
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>
318
443
  <td>⚙️ <code>config</code></td>
319
444
  <td><code>--config &lt;path&gt;</code></td>
320
445
  <td><code>string</code></td>
321
446
  <td align="center"></td>
322
447
  </tr>
323
448
  <tr>
324
- <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>
325
450
  </tr>
326
451
  </tbody>
327
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.
@@ -61,6 +80,15 @@ export type CliConfig = {
61
80
  * rather than strips in strict mode.
62
81
  */
63
82
  readonly stripUnknown?: boolean;
83
+ /**
84
+ * When true, the generated coercing parsers normalize a mis-cased string to
85
+ * the exact casing of a declared `enum`/`const` member it matches
86
+ * case-insensitively (e.g. `hElLo` → `hello`) instead of coercing to the
87
+ * default. Coerce mode only — `strict` parsers still reject a casing
88
+ * mismatch. Correctly-cased input keeps the exact-match fast path, so the hot
89
+ * path is unaffected.
90
+ */
91
+ readonly caseInsensitive?: boolean;
64
92
  /**
65
93
  * Controls how generated parsers reference their runtime helpers.
66
94
  * - `'package'`: emit `import ... from '@amritk/helpers/...'`.
@@ -111,4 +139,25 @@ export type CliConfig = {
111
139
  * Not supported with `schemaDir`, where each schema names its own root.
112
140
  */
113
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;
114
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);
@@ -152,18 +186,24 @@ const runSingle = async (config, schemaPath, outputDir) => {
152
186
  const helpersMode = resolveHelpersMode(config, outputDir);
153
187
  const rootTypeName = config.rootType ?? deriveRootTypeName(schema, schemaBaseName(schemaPath));
154
188
  console.log(`Root type: ${rootTypeName}`);
155
- const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, helpersMode, undefined, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config));
189
+ const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, helpersMode, undefined, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive);
156
190
  for (const file of files) {
157
191
  const isHelper = file.filename.startsWith('_helpers/');
158
192
  const content = config.banner && !isHelper ? resolveBanner(config.banner) + file.content : file.content;
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
  /**
@@ -177,10 +217,13 @@ const runSingleFile = async (config, schemaPath, outFilePath) => {
177
217
  await mkdir(outputDir, { recursive: true });
178
218
  const rootTypeName = config.rootType ?? deriveRootTypeName(schema, schemaBaseName(schemaPath));
179
219
  console.log(`Root type: ${rootTypeName}`);
180
- const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, config.helpers ?? 'package', undefined, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config));
220
+ const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, config.helpers ?? 'package', undefined, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive);
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
  }
@@ -221,7 +264,7 @@ const runRecursive = async (config, schemaDir, outputDir) => {
221
264
  const schema = await loadSchema(config, schemaFile);
222
265
  const rootTypeName = deriveRootTypeName(schema, schemaBaseName(schemaFile));
223
266
  console.log(`\n${relPath} → ${relNoExt}/ (root type: ${rootTypeName})`);
224
- const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, helpersMode, helpersImportPrefix, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config));
267
+ const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, helpersMode, helpersImportPrefix, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive);
225
268
  for (const file of files) {
226
269
  if (file.filename.startsWith('_helpers/')) {
227
270
  sharedHelpers.set(file.filename, file.content);
@@ -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 --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)
@@ -32,8 +34,14 @@ Output:
32
34
  Validation:
33
35
  --strict Throw on type/shape mismatches instead of coercing to defaults
34
36
  --strip-unknown Silently drop undeclared input keys at every nesting level
37
+ --case-insensitive Normalize mis-cased enum/const strings to their exact casing
35
38
  --log-warnings console.warn on input keys not declared in the schema
36
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
+
37
45
  Misc:
38
46
  --config <path> JSON config file with the same keys; CLI flags take precedence
39
47
  --version, -v Print the CLI version
@@ -0,0 +1,24 @@
1
+ import { type LintResolver } from '@amritk/lint';
2
+ /** How `mjst lint` dereferences `$ref` (and `$dynamicRef`/`$recursiveRef`). */
3
+ export type ResolverOptions = {
4
+ /**
5
+ * Fetch http(s) `$ref`s. Defaults to `false`: a lint run should not make
6
+ * network calls unless asked. A non-empty `allowedHosts` implies `remote`.
7
+ */
8
+ remote?: boolean;
9
+ /** If set, only these hosts may be fetched (and each bypasses the private-host guard). */
10
+ allowedHosts?: string[];
11
+ /** Permit remote refs to private/loopback/link-local hosts (off by default as an SSRF guard). */
12
+ allowPrivateHosts?: boolean;
13
+ };
14
+ /**
15
+ * Builds a {@link LintResolver} backed by `@amritk/resolve-refs`, dereferencing
16
+ * `$ref`, `$dynamicRef`/`$dynamicAnchor`, and `$recursiveRef`/`$recursiveAnchor`
17
+ * so rules with `resolved: true` (the ruleset default) see through references.
18
+ *
19
+ * Internal-only documents resolve in memory. A document with cross-file/remote
20
+ * refs is resolved from disk **only when its source file reads back exactly as
21
+ * the linted input** — so a piped document (`--stdin-filepath`) never resolves
22
+ * against a different on-disk file — with remote fetching gated by `options`.
23
+ */
24
+ export declare const createLintResolver: (options?: ResolverOptions) => LintResolver;
@@ -0,0 +1,126 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve as resolvePath } from 'node:path';
3
+ import { createDocument, DiagnosticSeverity, resolveSourceOriginFromMap, } from '@amritk/lint';
4
+ import { resolveRefs, resolveRefsFromFile } from '@amritk/resolve-refs';
5
+ import { parse as parseYaml } from '@amritk/yaml';
6
+ import { hasExternalRefs } from '../has-external-refs.js';
7
+ const isRemote = (location) => /^https?:\/\//i.test(location);
8
+ /**
9
+ * Parses a referenced document by extension: YAML for `.yaml`/`.yml`, JSON for
10
+ * `.json`, and for anything else (extensionless, remote) tries JSON first, then
11
+ * YAML. YAML is a JSON superset, so this accepts every document the linter does.
12
+ */
13
+ const parseDoc = (content, location) => {
14
+ if (/\.ya?ml$/i.test(location))
15
+ return parseYaml(content);
16
+ if (/\.json$/i.test(location))
17
+ return JSON.parse(content);
18
+ try {
19
+ return JSON.parse(content);
20
+ }
21
+ catch {
22
+ return parseYaml(content);
23
+ }
24
+ };
25
+ /** Whether the file at `absolute` reads back byte-for-byte as `input` (the document we linted). */
26
+ const readsBackAs = (absolute, input) => {
27
+ try {
28
+ return readFileSync(absolute, 'utf8') === input;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ };
34
+ /**
35
+ * Builds the {@link ISourceSet} the runner uses to map a finding on a resolved
36
+ * (dereferenced) node back to the file it came from. Origins from the resolver
37
+ * attribute each inlined node to its source document + path; `get` lazily parses
38
+ * each source file (with a position map) so findings on cross-file nodes report
39
+ * that file's own `line:column`. The root document is already parsed by the
40
+ * linter, so it is reused directly. Remote documents are not re-fetched here, so
41
+ * a finding inlined from a URL falls back to the root document's position.
42
+ */
43
+ const buildSourceSet = (rootDocument, resolved, origins, rootLocation) => {
44
+ const documents = new Map([[rootLocation, rootDocument]]);
45
+ const originMap = origins ?? new Map();
46
+ const load = (location) => {
47
+ if (documents.has(location))
48
+ return documents.get(location);
49
+ let document;
50
+ if (location !== '' && !isRemote(location)) {
51
+ try {
52
+ document = createDocument(readFileSync(location, 'utf8'), { source: location });
53
+ }
54
+ catch {
55
+ document = undefined;
56
+ }
57
+ }
58
+ documents.set(location, document);
59
+ return document;
60
+ };
61
+ return {
62
+ get: load,
63
+ origin: (path) => resolveSourceOriginFromMap(resolved, originMap, rootLocation, path),
64
+ };
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
+ });
88
+ /**
89
+ * Builds a {@link LintResolver} backed by `@amritk/resolve-refs`, dereferencing
90
+ * `$ref`, `$dynamicRef`/`$dynamicAnchor`, and `$recursiveRef`/`$recursiveAnchor`
91
+ * so rules with `resolved: true` (the ruleset default) see through references.
92
+ *
93
+ * Internal-only documents resolve in memory. A document with cross-file/remote
94
+ * refs is resolved from disk **only when its source file reads back exactly as
95
+ * the linted input** — so a piped document (`--stdin-filepath`) never resolves
96
+ * against a different on-disk file — with remote fetching gated by `options`.
97
+ */
98
+ export const createLintResolver = (options = {}) => {
99
+ const fromFileOptions = {
100
+ remote: options.remote ?? false,
101
+ ...(options.allowedHosts ? { allowedHosts: options.allowedHosts } : {}),
102
+ ...(options.allowPrivateHosts ? { allowPrivateHosts: options.allowPrivateHosts } : {}),
103
+ parse: parseDoc,
104
+ trackOrigins: true,
105
+ };
106
+ return async (document, { input }) => {
107
+ const { source } = document;
108
+ if (source && hasExternalRefs(document.data)) {
109
+ const absolute = resolvePath(source);
110
+ if (readsBackAs(absolute, input)) {
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
+ };
117
+ }
118
+ }
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
+ };
125
+ };
126
+ };
package/dist/lint/run.js CHANGED
@@ -3,6 +3,7 @@ import { dirname, isAbsolute, resolve } from 'node:path';
3
3
  import { DiagnosticSeverity, lintDocument, validateRuleset, } from '@amritk/lint';
4
4
  import fg from 'fast-glob';
5
5
  import yargs from 'yargs';
6
+ import { createLintResolver } from './resolver.js';
6
7
  import { discoverRuleset, loadRuleset } from './ruleset-loader.js';
7
8
  const SEVERITY_BY_NAME = {
8
9
  error: DiagnosticSeverity.Error,
@@ -83,11 +84,40 @@ export const run = async (argv, options = {}) => {
83
84
  type: 'number',
84
85
  default: 8,
85
86
  describe: 'Maximum number of documents to lint in parallel',
87
+ })
88
+ .option('resolve', {
89
+ type: 'boolean',
90
+ default: true,
91
+ describe: 'Dereference $ref / $dynamicRef / $recursiveRef before linting (use --no-resolve to disable)',
92
+ })
93
+ .option('resolve-remote', {
94
+ type: 'boolean',
95
+ default: false,
96
+ describe: 'Allow fetching http(s) $refs while resolving (off by default; a lint run stays offline)',
97
+ })
98
+ .option('allowed-hosts', {
99
+ type: 'string',
100
+ array: true,
101
+ describe: 'Restrict remote $ref fetches to these hosts (implies --resolve-remote)',
102
+ })
103
+ .option('allow-private-hosts', {
104
+ type: 'boolean',
105
+ default: false,
106
+ describe: 'Permit remote $refs to private/loopback hosts (SSRF guard, off by default)',
86
107
  })
87
108
  .help()
88
109
  .alias('help', 'h')
89
110
  .parse());
90
111
  const failSeverity = SEVERITY_BY_NAME[parsed.failSeverity] ?? DiagnosticSeverity.Error;
112
+ // A non-empty --allowed-hosts is an explicit opt-in to remote fetching.
113
+ const allowRemote = parsed.resolveRemote || (parsed.allowedHosts?.length ?? 0) > 0;
114
+ const resolver = parsed.resolve
115
+ ? createLintResolver({
116
+ remote: allowRemote,
117
+ ...(parsed.allowedHosts ? { allowedHosts: parsed.allowedHosts } : {}),
118
+ allowPrivateHosts: parsed.allowPrivateHosts,
119
+ })
120
+ : undefined;
91
121
  const reportRulesetProblems = (definition, label) => {
92
122
  if (parsed.quiet)
93
123
  return;
@@ -131,6 +161,7 @@ export const run = async (argv, options = {}) => {
131
161
  const opts = {
132
162
  ...(definition ? { ruleset: definition } : {}),
133
163
  ...(basePath !== undefined ? { rulesetBasePath: basePath } : {}),
164
+ ...(resolver && definition ? { resolve: resolver } : {}),
134
165
  source: stdinPath ?? '<stdin>',
135
166
  };
136
167
  allResults.push(...(await lintDocument(content, opts)));
@@ -157,6 +188,7 @@ export const run = async (argv, options = {}) => {
157
188
  ? {
158
189
  ruleset: definition,
159
190
  ...(basePath !== undefined ? { rulesetBasePath: basePath } : {}),
191
+ ...(resolver ? { resolve: resolver } : {}),
160
192
  source: file,
161
193
  }
162
194
  : { source: file };
@@ -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).
@@ -24,10 +25,12 @@ export const loadConfig = async (configPath) => {
24
25
  ...(isSourceFormat(obj['input']) && { input: obj['input'] }),
25
26
  ...(typeof obj['export'] === 'string' && { export: obj['export'] }),
26
27
  ...(typeof obj['typesOnly'] === 'boolean' && { typesOnly: obj['typesOnly'] }),
28
+ ...(typeof obj['examples'] === 'boolean' && { examples: obj['examples'] }),
27
29
  ...(typeof obj['build'] === 'boolean' && { build: obj['build'] }),
28
30
  ...(typeof obj['logWarnings'] === 'boolean' && { logWarnings: obj['logWarnings'] }),
29
31
  ...(typeof obj['strict'] === 'boolean' && { strict: obj['strict'] }),
30
32
  ...(typeof obj['stripUnknown'] === 'boolean' && { stripUnknown: obj['stripUnknown'] }),
33
+ ...(typeof obj['caseInsensitive'] === 'boolean' && { caseInsensitive: obj['caseInsensitive'] }),
31
34
  ...(typeof obj['readonly'] === 'boolean' && { readonly: obj['readonly'] }),
32
35
  ...(isHelpersMode(obj['helpers']) && { helpers: obj['helpers'] }),
33
36
  ...(isImportExt(obj['importExt']) && { importExt: obj['importExt'] }),
@@ -35,5 +38,8 @@ export const loadConfig = async (configPath) => {
35
38
  ...(typeof obj['typeSuffix'] === 'string' && { typeSuffix: obj['typeSuffix'] }),
36
39
  // `banner` is a boolean toggle or a custom header string, mirroring the CLI flag.
37
40
  ...((typeof obj['banner'] === 'boolean' || typeof obj['banner'] === 'string') && { banner: obj['banner'] }),
41
+ ...(typeof obj['resolveRemote'] === 'boolean' && { resolveRemote: obj['resolveRemote'] }),
42
+ ...(isStringArray(obj['allowedHosts']) && { allowedHosts: obj['allowedHosts'] }),
43
+ ...(typeof obj['allowPrivateHosts'] === 'boolean' && { allowPrivateHosts: obj['allowPrivateHosts'] }),
38
44
  };
39
45
  };
@@ -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,11 +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',
11
+ 'caseInsensitive',
9
12
  'readonly',
13
+ 'resolveRemote',
14
+ 'allowPrivateHosts',
10
15
  ]);
11
16
  // Value flags consume the following argument (or `--flag=value`).
12
17
  const VALUE_KEYS = new Set([
@@ -39,6 +44,20 @@ const parseImportExtValue = (value) => {
39
44
  return undefined;
40
45
  };
41
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
+ };
42
61
  /** Assigns a value-flag onto the config. Returns false for unknown keys. */
43
62
  const assignValue = (config, key, value) => {
44
63
  switch (key) {
@@ -97,6 +116,12 @@ const assignBoolean = (config, key, value) => {
97
116
  case 'typesOnly':
98
117
  config.typesOnly = value;
99
118
  return true;
119
+ case 'validators':
120
+ config.validators = value;
121
+ return true;
122
+ case 'examples':
123
+ config.examples = value;
124
+ return true;
100
125
  case 'build':
101
126
  config.build = value;
102
127
  return true;
@@ -109,9 +134,18 @@ const assignBoolean = (config, key, value) => {
109
134
  case 'stripUnknown':
110
135
  config.stripUnknown = value;
111
136
  return true;
137
+ case 'caseInsensitive':
138
+ config.caseInsensitive = value;
139
+ return true;
112
140
  case 'readonly':
113
141
  config.readonly = value;
114
142
  return true;
143
+ case 'resolveRemote':
144
+ config.resolveRemote = value;
145
+ return true;
146
+ case 'allowPrivateHosts':
147
+ config.allowPrivateHosts = value;
148
+ return true;
115
149
  default:
116
150
  return false;
117
151
  }
@@ -139,6 +173,9 @@ export const parseCliArgs = (args) => {
139
173
  // --banner=false → false, --banner=true → true, --banner=<text> → custom string
140
174
  config.banner = value === 'false' ? false : value === 'true' ? true : value;
141
175
  }
176
+ else if (key === 'allowedHosts') {
177
+ appendAllowedHosts(config, value);
178
+ }
142
179
  else if (BOOLEAN_KEYS.has(key)) {
143
180
  assignBoolean(config, key, value !== 'false');
144
181
  }
@@ -168,6 +205,16 @@ export const parseCliArgs = (args) => {
168
205
  }
169
206
  continue;
170
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
+ }
171
218
  // Boolean flag: presence alone enables it, no value needed
172
219
  if (BOOLEAN_KEYS.has(key)) {
173
220
  assignBoolean(config, key, true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/mjst",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,11 +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.15.0",
44
+ "@amritk/adapters": "0.3.0",
45
+ "@amritk/generate-examples": "0.5.0",
46
+ "@amritk/generate-parsers": "0.16.1",
47
+ "@amritk/generate-validators": "0.11.6",
46
48
  "@amritk/generate-markdown": "0.4.1",
47
- "@amritk/helpers": "0.12.0",
48
- "@amritk/lint": "0.1.0",
49
+ "@amritk/helpers": "0.13.0",
50
+ "@amritk/lint": "0.3.0",
51
+ "@amritk/resolve-refs": "0.4.0",
52
+ "@amritk/yaml": "0.3.0",
49
53
  "fast-glob": "^3.3.2",
50
54
  "yargs": "^17.7.2"
51
55
  },