@amritk/mjst 0.16.1 → 0.18.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/AI.md +15 -3
- package/README.md +46 -8
- package/dist/cli-config.d.ts +28 -0
- package/dist/cli.js +142 -4
- package/dist/detect-helpers-mode.d.ts +3 -8
- package/dist/detect-helpers-mode.js +2 -29
- package/dist/emit-message-contracts.d.ts +47 -0
- package/dist/emit-message-contracts.js +97 -0
- package/dist/help-text.d.ts +1 -1
- package/dist/help-text.js +6 -1
- package/dist/is-dependency-declared.d.ts +17 -0
- package/dist/is-dependency-declared.js +31 -0
- package/dist/lint/run.js +24 -4
- package/dist/load-asyncapi-document.d.ts +12 -0
- package/dist/load-asyncapi-document.js +91 -0
- package/dist/load-config.js +5 -1
- package/dist/load-schema.js +1 -17
- package/dist/parse-cli-args.js +19 -2
- package/dist/resolve-policy.d.ts +33 -0
- package/dist/resolve-policy.js +21 -0
- package/dist/validate-config.js +5 -1
- package/package.json +10 -9
package/AI.md
CHANGED
|
@@ -41,13 +41,25 @@ mjst compile-api ./routes.ts --out ./dist/handler.ts
|
|
|
41
41
|
point `--schema` at a JS/TS **module**; `avro` points it at a `.avsc` JSON
|
|
42
42
|
document, which is read as data (no `import()`, so `--export` does not
|
|
43
43
|
apply).
|
|
44
|
+
6. **`--input asyncapi`** reads an AsyncAPI 2.x/3.0 document (JSON or YAML) and
|
|
45
|
+
generates from every message payload/headers schema, one subtree per message.
|
|
46
|
+
It rejects `--schema-dir`, `--out-file`, `--root-type`, and `--export`.
|
|
47
|
+
7. **`--message-contracts`** (AsyncAPI only) additionally writes
|
|
48
|
+
`contracts/<channel>.ts` — a `defineMessages({ ... })` per channel, plus a
|
|
49
|
+
barrel. Those files import **`@amritk/api`**, which the *consuming* project
|
|
50
|
+
installs; the CLI does not put it there, and `--build` leaves the contracts
|
|
51
|
+
as `.ts` for that reason. Incompatible with `--types-only`. Use
|
|
52
|
+
`--discriminator <prop>` when the document tags frames with something other
|
|
53
|
+
than `type`; a channel carrying `x-mjst: { discriminator }` keeps its own.
|
|
44
54
|
|
|
45
55
|
## Common flags
|
|
46
56
|
|
|
47
57
|
`--schema` / `--schema-dir`, `--out-dir` / `--out-file`, `--input`,
|
|
48
|
-
`--validators`, `--examples`, `--
|
|
49
|
-
`--
|
|
50
|
-
`--
|
|
58
|
+
`--validators`, `--examples`, `--message-contracts`, `--discriminator`,
|
|
59
|
+
`--types-only`, `--build`, `--strict`, `--strip-unknown`,
|
|
60
|
+
`--unknown-keys count-keys|count-enumerable`, `--readonly`,
|
|
61
|
+
`--import-ext ts|js`, `--config <path>`, `--resolve-remote` /
|
|
62
|
+
`--allowed-hosts` (SSRF-guarded remote `$ref`s).
|
|
51
63
|
|
|
52
64
|
## Lint gotchas
|
|
53
65
|
|
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ yarn add -D @amritk/mjst
|
|
|
37
37
|
bun add -d @amritk/mjst
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
The package ships a `mjst` bin that runs under Node ≥ 20 (or Bun), so you can invoke it via `npx mjst`, `pnpm
|
|
40
|
+
The package ships a `mjst` bin that runs under Node ≥ 20 (or Bun), so you can invoke it via `npx mjst`, `pnpm exec mjst`, `yarn mjst`, `bunx mjst`, or as a script in `package.json`.
|
|
41
41
|
|
|
42
42
|
---
|
|
43
43
|
|
|
@@ -64,7 +64,7 @@ npx mjst --schema ./schema.json --out-file ./generated/schema.ts --types-only
|
|
|
64
64
|
### Recursive — a whole folder of schemas
|
|
65
65
|
|
|
66
66
|
Point `--schema-dir` at a directory of JSON Schemas and mjst generates parsers for every
|
|
67
|
-
`*.json` file it finds, mirroring the directory layout under `--out-dir`:
|
|
67
|
+
`*.json` file it finds (skipping `node_modules/` and dot-directories), mirroring the directory layout under `--out-dir`:
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
70
|
npx mjst --schema-dir ./schemas --out-dir ./generated
|
|
@@ -188,7 +188,7 @@ npx mjst --config ./mjst.config.json
|
|
|
188
188
|
```
|
|
189
189
|
|
|
190
190
|
> [!NOTE]
|
|
191
|
-
> Validate your config against the
|
|
191
|
+
> Validate your config against the repository's JSON Schema: [`config.schema.json`](./config.schema.json) (not included in the npm package)
|
|
192
192
|
|
|
193
193
|
---
|
|
194
194
|
|
|
@@ -220,7 +220,7 @@ With no `-r`, mjst discovers a `.lint.{yaml,yml,json,js,mjs}` ruleset by walking
|
|
|
220
220
|
| Flag | Description |
|
|
221
221
|
| --- | --- |
|
|
222
222
|
| `<documents..>` | Files or globs to lint (positional). |
|
|
223
|
-
| `-r`, `--ruleset` | Path to a ruleset file. Defaults to `.lint.*` discovery. |
|
|
223
|
+
| `-r`, `--ruleset` | Path to a ruleset file, or a built-in preset (`oas`, `asyncapi`). Defaults to `.lint.*` discovery. |
|
|
224
224
|
| `-F`, `--fail-severity` | Minimum severity that fails the run: `error` (default), `warn`, `info`, `hint`. |
|
|
225
225
|
| `-D`, `--display-only-failures` | Only show findings at or above `--fail-severity`. |
|
|
226
226
|
| `--stdin-filepath` | Path to associate with piped input (labels findings and enables ruleset discovery). |
|
|
@@ -230,6 +230,7 @@ With no `-r`, mjst discovers a `.lint.{yaml,yml,json,js,mjs}` ruleset by walking
|
|
|
230
230
|
| `--allowed-hosts` | Restrict remote `$ref` fetches to these hosts (implies `--resolve-remote`). |
|
|
231
231
|
| `--allow-private-hosts` | Permit remote `$ref`s to private/loopback hosts (SSRF guard, off by default). |
|
|
232
232
|
| `--allowed-roots` | Extra directories a local `$ref` may resolve into, on top of the document's own (repeat the flag). |
|
|
233
|
+
| `--encoding` | Input encoding for the linted files (default `utf8`). |
|
|
233
234
|
| `-q`, `--quiet` | Suppress the findings report (the exit code still reflects findings). |
|
|
234
235
|
|
|
235
236
|
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` — as long as they stay inside the linted document's own directory; use `--allowed-roots` to let a document reach a shared sibling folder. Remote refs are only fetched when you opt in with `--resolve-remote` or `--allowed-hosts`.
|
|
@@ -250,7 +251,7 @@ npx mjst compile-api src/routes.ts --out dist/handler.js
|
|
|
250
251
|
npx mjst compile-api src/routes.ts --out dist/handler.js --options compile-options.json
|
|
251
252
|
```
|
|
252
253
|
|
|
253
|
-
Every export of the routes module that looks like a route contract (`method`, `path`, `responses`) is compiled; the generated module imports those contracts back through `--routes-import` (default: the relative path from the out file to the routes module). The routes module is imported at build time, so it must be loadable by the runtime running mjst — run via `bunx` for TypeScript sources, or under Node install a loader such as `tsx` (`node --import tsx`).
|
|
254
|
+
Every *named* export of the routes module that looks like a route contract (`method`, `path`, `responses`) is compiled (a `default` export is ignored, because the generated module imports each contract by name); the generated module imports those contracts back through `--routes-import` (default: the relative path from the out file to the routes module). The routes module is imported at build time, so it must be loadable by the runtime running mjst — run via `bunx` for TypeScript sources, or under Node install a loader such as `tsx` (`node --import tsx`).
|
|
254
255
|
|
|
255
256
|
| Flag | Description |
|
|
256
257
|
| --- | --- |
|
|
@@ -303,7 +304,7 @@ The exit code is `0` on success, `1` when compilation fails (unloadable module,
|
|
|
303
304
|
<td align="center"><code>"json"</code></td>
|
|
304
305
|
</tr>
|
|
305
306
|
<tr>
|
|
306
|
-
<td colspan="4">Source format of the schema. 'json' (default) reads a JSON Schema file directly. 'avro' reads an Apache Avro '.avsc' JSON document and converts it, needing no extra library. Any other format loads 'schema' as a module and converts it to JSON Schema with the matching adapter. Supported: 'typebox', 'zod' (zod v4+), 'valibot' (with @valibot/to-json-schema), and 'effect' — each requires the corresponding library installed in your project.<br><strong>Allowed:</strong> <code>"json"</code>, <code>"typebox"</code>, <code>"zod"</code>, <code>"valibot"</code>, <code>"effect"</code>, <code>"avro"</code></td>
|
|
307
|
+
<td colspan="4">Source format of the schema. 'json' (default) reads a JSON Schema file directly. 'avro' reads an Apache Avro '.avsc' JSON document and converts it, needing no extra library. 'asyncapi' reads an AsyncAPI 2.x/3.0 document (JSON or YAML) and generates from every message payload/headers schema it declares. Any other format loads 'schema' as a module and converts it to JSON Schema with the matching adapter. Supported: 'typebox', 'zod' (zod v4+), 'valibot' (with @valibot/to-json-schema), and 'effect' — each requires the corresponding library installed in your project.<br><strong>Allowed:</strong> <code>"json"</code>, <code>"typebox"</code>, <code>"zod"</code>, <code>"valibot"</code>, <code>"effect"</code>, <code>"avro"</code>, <code>"asyncapi"</code></td>
|
|
307
308
|
</tr>
|
|
308
309
|
<tr>
|
|
309
310
|
<td>📦 <code>export</code></td>
|
|
@@ -312,7 +313,7 @@ The exit code is `0` on success, `1` when compilation fails (unloadable module,
|
|
|
312
313
|
<td align="center"></td>
|
|
313
314
|
</tr>
|
|
314
315
|
<tr>
|
|
315
|
-
<td colspan="4">Which export of the schema module to use when 'input' names a module format (anything but 'json' and '
|
|
316
|
+
<td colspan="4">Which export of the schema module to use when 'input' names a module format (anything but 'json', 'avro', and 'asyncapi', which are read as data). Defaults to the default export, or the sole named export when the module has exactly one.</td>
|
|
316
317
|
</tr>
|
|
317
318
|
<tr>
|
|
318
319
|
<td>📁 <code>outDir</code></td>
|
|
@@ -360,6 +361,24 @@ The exit code is `0` on success, `1` when compilation fails (unloadable module,
|
|
|
360
361
|
<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>
|
|
361
362
|
</tr>
|
|
362
363
|
<tr>
|
|
364
|
+
<td>🔌 <code>messageContracts</code></td>
|
|
365
|
+
<td><code>--message-contracts</code></td>
|
|
366
|
+
<td><code>boolean</code></td>
|
|
367
|
+
<td align="center"><code>false</code></td>
|
|
368
|
+
</tr>
|
|
369
|
+
<tr>
|
|
370
|
+
<td colspan="4">Also emit a @amritk/api channel contract per AsyncAPI channel into a contracts/ subdirectory: one defineMessages({ ... }) module naming every message that channel carries — keyed by the value its discriminator property carries on the wire, with that property stripped out of each payload because the runtime removes it before validating — plus a barrel re-exporting them. Requires input 'asyncapi', which is the only input that has channels and operations to project from. Incompatible with typesOnly: a contract is a runtime value, not a type. The generated modules import @amritk/api, which the consuming project must install; it is not a dependency of the generated parsers.</td>
|
|
371
|
+
</tr>
|
|
372
|
+
<tr>
|
|
373
|
+
<td>🏷️ <code>discriminator</code></td>
|
|
374
|
+
<td><code>--discriminator <prop></code></td>
|
|
375
|
+
<td><code>string</code></td>
|
|
376
|
+
<td align="center"><code>"type"</code></td>
|
|
377
|
+
</tr>
|
|
378
|
+
<tr>
|
|
379
|
+
<td colspan="4">Property name that says which message a frame is, for the contracts messageContracts emits. Defaults to 'type', matching @amritk/api. A channel that declares its own with 'x-mjst': { 'discriminator': '...' } keeps it — one flag covers a whole run, and a run may span channels that disagree. Only meaningful together with messageContracts.<br><strong>Examples:</strong> <code>"event"</code></td>
|
|
380
|
+
</tr>
|
|
381
|
+
<tr>
|
|
363
382
|
<td>🔨 <code>build</code></td>
|
|
364
383
|
<td><code>--build</code></td>
|
|
365
384
|
<td><code>boolean</code></td>
|
|
@@ -423,6 +442,15 @@ The exit code is `0` on success, `1` when compilation fails (unloadable module,
|
|
|
423
442
|
<td colspan="4">Emit every property, array, and record in the generated type definitions as readonly, producing deeply immutable types. Affects type definitions only; the generated parsers still build and return plain objects.</td>
|
|
424
443
|
</tr>
|
|
425
444
|
<tr>
|
|
445
|
+
<td>🔢 <code>unknownKeys</code></td>
|
|
446
|
+
<td><code>--unknown-keys <strategy></code></td>
|
|
447
|
+
<td><code>string</code></td>
|
|
448
|
+
<td align="center"><code>"count-keys"</code></td>
|
|
449
|
+
</tr>
|
|
450
|
+
<tr>
|
|
451
|
+
<td colspan="4">How generated fast paths (parsers and validators alike) prove a closed object — additionalProperties: false, or a stripUnknown build — carries no undeclared key. 'count-keys' compares Object.keys(obj).length and is the faster form on JavaScriptCore (Bun), where a for…in over a non-extensible object takes the engine's slow path and halves strict-parse throughput. 'count-enumerable' counts with for…in and allocates nothing; it was the faster form on Node 22, but on Node 26 'count-keys' ties or wins there too. Keep the default unless you are pinned to an older V8 and have measured your own shapes; the generated code never detects its runtime.<br><strong>Allowed:</strong> <code>"count-enumerable"</code>, <code>"count-keys"</code></td>
|
|
452
|
+
</tr>
|
|
453
|
+
<tr>
|
|
426
454
|
<td>🧰 <code>helpers</code></td>
|
|
427
455
|
<td><code>--helpers <mode></code></td>
|
|
428
456
|
<td><code>string</code></td>
|
|
@@ -510,7 +538,7 @@ The exit code is `0` on success, `1` when compilation fails (unloadable module,
|
|
|
510
538
|
<td align="center"></td>
|
|
511
539
|
</tr>
|
|
512
540
|
<tr>
|
|
513
|
-
<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, force, logWarnings, strict, stripUnknown, caseInsensitive, readonly, helpers, typeSuffix, banner, importExt, rootType, resolveRemote, allowedHosts, allowPrivateHosts, allowedRoots) and are validated: an unknown key or a wrong value type fails the run instead of being ignored. CLI flags take precedence over config file values.<br><strong>Examples:</strong> <code>"./mjst.config.json"</code></td>
|
|
541
|
+
<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, force, logWarnings, strict, stripUnknown, caseInsensitive, readonly, unknownKeys, helpers, typeSuffix, banner, importExt, rootType, resolveRemote, allowedHosts, allowPrivateHosts, allowedRoots) and are validated: an unknown key or a wrong value type fails the run instead of being ignored. CLI flags take precedence over config file values.<br><strong>Examples:</strong> <code>"./mjst.config.json"</code></td>
|
|
514
542
|
</tr>
|
|
515
543
|
</tbody>
|
|
516
544
|
</table>
|
|
@@ -559,6 +587,16 @@ The exit code is `0` on success, `1` when compilation fails (unloadable module,
|
|
|
559
587
|
}
|
|
560
588
|
```
|
|
561
589
|
|
|
590
|
+
**Strict — throw on invalid input**
|
|
591
|
+
|
|
592
|
+
```json
|
|
593
|
+
{
|
|
594
|
+
"schema": "./schema.json",
|
|
595
|
+
"outDir": "./generated",
|
|
596
|
+
"strict": true
|
|
597
|
+
}
|
|
598
|
+
```
|
|
599
|
+
|
|
562
600
|
---
|
|
563
601
|
|
|
564
602
|
## Scripts
|
package/dist/cli-config.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SourceFormat } from '@amritk/adapters/source-format';
|
|
2
|
+
import type { UnknownKeysStrategy } from '@amritk/helpers/unknown-keys-strategy';
|
|
2
3
|
/**
|
|
3
4
|
* Configuration for the CLI tool.
|
|
4
5
|
* These properties map 1:1 with CLI flags and config file keys.
|
|
@@ -59,6 +60,25 @@ export type CliConfig = {
|
|
|
59
60
|
* `fast-check`, which consumers must install as a (dev) dependency.
|
|
60
61
|
*/
|
|
61
62
|
readonly examples?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* When true, also emit a `@amritk/api` channel contract per AsyncAPI channel
|
|
65
|
+
* into a `contracts/` subdirectory: one `defineMessages({ ... })` module
|
|
66
|
+
* naming every message that channel carries, keyed by its wire discriminator
|
|
67
|
+
* value, plus a barrel. Requires `input: 'asyncapi'` — the contracts are
|
|
68
|
+
* projected from the document's channels and operations, which no other input
|
|
69
|
+
* format has. Incompatible with `typesOnly`: a contract is a runtime value.
|
|
70
|
+
*
|
|
71
|
+
* The generated modules import `@amritk/api`, which the *consuming* project
|
|
72
|
+
* must have installed; it is not a dependency of the generated parsers.
|
|
73
|
+
*/
|
|
74
|
+
readonly messageContracts?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Property name that says which message a frame is, for the contracts
|
|
77
|
+
* `messageContracts` emits. Defaults to `'type'`, matching `@amritk/api`.
|
|
78
|
+
* A channel that declares its own via `x-mjst: { discriminator }` keeps it —
|
|
79
|
+
* one flag covers a whole run, and a run may span channels that disagree.
|
|
80
|
+
*/
|
|
81
|
+
readonly discriminator?: string;
|
|
62
82
|
/**
|
|
63
83
|
* When true, compile the generated TypeScript files to .js and .d.ts output.
|
|
64
84
|
* The .ts source files are removed after compilation.
|
|
@@ -105,6 +125,14 @@ export type CliConfig = {
|
|
|
105
125
|
* path is unaffected.
|
|
106
126
|
*/
|
|
107
127
|
readonly caseInsensitive?: boolean;
|
|
128
|
+
/**
|
|
129
|
+
* How generated fast paths prove a closed object (`additionalProperties:
|
|
130
|
+
* false`) carries no undeclared key. `'count-keys'` (the default) compares
|
|
131
|
+
* `Object.keys(obj).length` and is the faster form on JavaScriptCore (Bun);
|
|
132
|
+
* `'count-enumerable'` counts with `for…in` and is the faster form on V8
|
|
133
|
+
* (Node). Applies to parsers and validators alike.
|
|
134
|
+
*/
|
|
135
|
+
readonly unknownKeys?: UnknownKeysStrategy;
|
|
108
136
|
/**
|
|
109
137
|
* Controls how generated parsers reference their runtime helpers.
|
|
110
138
|
* - `'package'`: emit `import ... from '@amritk/helpers/...'`.
|
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
import { 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 { extractAsyncApi, listMessageSchemas } from "@amritk/asyncapi";
|
|
6
7
|
import { buildSchema } from "@amritk/generate-parsers";
|
|
7
8
|
import { buildValidatorSchema } from "@amritk/generate-validators";
|
|
8
9
|
import { deriveRootTypeName } from "@amritk/helpers/derive-root-type-name";
|
|
@@ -11,8 +12,11 @@ import { combineGeneratedFiles } from "./combine-files.js";
|
|
|
11
12
|
import { createOutputWriter } from "./create-output-writer.js";
|
|
12
13
|
import { detectHelpersMode } from "./detect-helpers-mode.js";
|
|
13
14
|
import { emitExamples } from "./emit-examples.js";
|
|
15
|
+
import { CONTRACTS_DIR, CONTRACTS_PEER, emitMessageContracts } from "./emit-message-contracts.js";
|
|
14
16
|
import { ensureOutputDir } from "./ensure-output-dir.js";
|
|
15
17
|
import { HELP_TEXT } from "./help-text.js";
|
|
18
|
+
import { isDependencyDeclared } from "./is-dependency-declared.js";
|
|
19
|
+
import { loadAsyncApiDocument } from "./load-asyncapi-document.js";
|
|
16
20
|
import { loadConfig } from "./load-config.js";
|
|
17
21
|
import { loadSchema } from "./load-schema.js";
|
|
18
22
|
import { parseCliArgs } from "./parse-cli-args.js";
|
|
@@ -20,6 +24,7 @@ import { parseMetaRequest } from "./parse-meta-request.js";
|
|
|
20
24
|
import { readVersion } from "./read-version.js";
|
|
21
25
|
import { resolveImportExt } from "./resolve-import-ext.js";
|
|
22
26
|
const ROOT_TYPE_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
27
|
+
const DISCRIMINATOR_PATTERN = /^[A-Za-z_$][\w$-]*$/;
|
|
23
28
|
const schemaBaseName = (schemaPath) => basename(schemaPath).replace(/\.[^.]+$/, "");
|
|
24
29
|
const resolveHelpersMode = (config, outputDir) => {
|
|
25
30
|
const helpersMode = config.helpers ?? detectHelpersMode(outputDir);
|
|
@@ -134,7 +139,7 @@ const findJsonSchemas = async (dir) => {
|
|
|
134
139
|
return results;
|
|
135
140
|
};
|
|
136
141
|
const runValidators = async (config, schema, rootTypeName, writer, validatorsSubDir) => {
|
|
137
|
-
const files = await buildValidatorSchema(schema, rootTypeName, config.typeSuffix);
|
|
142
|
+
const files = await buildValidatorSchema(schema, rootTypeName, config.typeSuffix, void 0, config.unknownKeys);
|
|
138
143
|
const staged = [];
|
|
139
144
|
for (const file of files) {
|
|
140
145
|
const content = config.banner ? resolveBanner(config.banner) + file.content : file.content;
|
|
@@ -150,7 +155,7 @@ const runSingle = async (config, schemaPath, outputDir) => {
|
|
|
150
155
|
const helpersMode = resolveHelpersMode(config, outputDir);
|
|
151
156
|
const rootTypeName = config.rootType ?? deriveRootTypeName(schema, schemaBaseName(schemaPath));
|
|
152
157
|
console.log(`Root type: ${rootTypeName}`);
|
|
153
|
-
const files = await buildSchema(schema, rootTypeName, void 0, config.typesOnly, config.logWarnings, config.strict, helpersMode, void 0, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive);
|
|
158
|
+
const files = await buildSchema(schema, rootTypeName, void 0, config.typesOnly, config.logWarnings, config.strict, helpersMode, void 0, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive, void 0, config.unknownKeys);
|
|
154
159
|
const writer = await createOutputWriter(outputDir, config.force);
|
|
155
160
|
const written = await commitOrDiscard(writer, async () => {
|
|
156
161
|
for (const file of files) {
|
|
@@ -179,7 +184,7 @@ const runSingleFile = async (config, schemaPath, outFilePath) => {
|
|
|
179
184
|
await ensureOutputDir(outputDir);
|
|
180
185
|
const rootTypeName = config.rootType ?? deriveRootTypeName(schema, schemaBaseName(schemaPath));
|
|
181
186
|
console.log(`Root type: ${rootTypeName}`);
|
|
182
|
-
const files = await buildSchema(schema, rootTypeName, void 0, config.typesOnly, config.logWarnings, config.strict, config.helpers ?? "package", void 0, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive);
|
|
187
|
+
const files = await buildSchema(schema, rootTypeName, void 0, config.typesOnly, config.logWarnings, config.strict, config.helpers ?? "package", void 0, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive, void 0, config.unknownKeys);
|
|
183
188
|
const combined = combineGeneratedFiles(files);
|
|
184
189
|
const writer = await createOutputWriter(outputDir, config.force);
|
|
185
190
|
const written = await commitOrDiscard(writer, async () => {
|
|
@@ -221,7 +226,7 @@ const runRecursive = async (config, schemaDir, outputDir) => {
|
|
|
221
226
|
const rootTypeName = deriveRootTypeName(schema, schemaBaseName(schemaFile));
|
|
222
227
|
console.log(`
|
|
223
228
|
${relPath} \u2192 ${relNoExt}/ (root type: ${rootTypeName})`);
|
|
224
|
-
const files = await buildSchema(schema, rootTypeName, void 0, config.typesOnly, config.logWarnings, config.strict, helpersMode, helpersImportPrefix, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive);
|
|
229
|
+
const files = await buildSchema(schema, rootTypeName, void 0, config.typesOnly, config.logWarnings, config.strict, helpersMode, helpersImportPrefix, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive, void 0, config.unknownKeys);
|
|
225
230
|
for (const file of files) {
|
|
226
231
|
if (file.filename.startsWith("_helpers/")) {
|
|
227
232
|
sharedHelpers.set(file.filename, file.content);
|
|
@@ -253,6 +258,96 @@ ${relPath} \u2192 ${relNoExt}/ (root type: ${rootTypeName})`);
|
|
|
253
258
|
Total files generated: ${writtenTsFiles.length}`);
|
|
254
259
|
}
|
|
255
260
|
};
|
|
261
|
+
const reportContractsPeer = (outputDir) => {
|
|
262
|
+
if (isDependencyDeclared(outputDir, CONTRACTS_PEER))
|
|
263
|
+
return;
|
|
264
|
+
console.log(`Tip: the generated contracts import ${CONTRACTS_PEER}, which is not a declared dependency of the project at ${outputDir}. Install it there (it is a peer of the generated code, not of the mjst CLI).`);
|
|
265
|
+
};
|
|
266
|
+
const runAsyncApi = async (config, documentPath, outputDir) => {
|
|
267
|
+
const document = await loadAsyncApiDocument(config, documentPath);
|
|
268
|
+
const model = extractAsyncApi(document);
|
|
269
|
+
const schemas = listMessageSchemas(model);
|
|
270
|
+
for (const issue of model.issues) {
|
|
271
|
+
console.warn(`Warning: ${issue.path}: ${issue.message}`);
|
|
272
|
+
}
|
|
273
|
+
const contractsPossible = config.messageContracts === true && model.channels.some((c) => c.messages.length > 0);
|
|
274
|
+
if (schemas.length === 0 && !contractsPossible) {
|
|
275
|
+
console.error(`Error: no generatable message schemas found in ${documentPath}. Every message either declares no payload/headers or uses a non-JSON-Schema schemaFormat (see warnings above).`);
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
console.log(`AsyncAPI ${model.version}: ${model.channels.length} channel(s), ${schemas.length} schema(s) to generate`);
|
|
279
|
+
await ensureOutputDir(outputDir);
|
|
280
|
+
const helpersMode = resolveHelpersMode(config, outputDir);
|
|
281
|
+
const sharedHelpers = /* @__PURE__ */ new Map();
|
|
282
|
+
const writer = await createOutputWriter(outputDir, config.force);
|
|
283
|
+
const exampleTasks = [];
|
|
284
|
+
const contractIssues = [];
|
|
285
|
+
let contractMessageCount = 0;
|
|
286
|
+
let contractChannelCount = 0;
|
|
287
|
+
const stageAll = async () => {
|
|
288
|
+
for (const extracted of schemas) {
|
|
289
|
+
const { subDir, rootTypeName, schema } = extracted;
|
|
290
|
+
const depth = subDir.split("/").filter(Boolean).length;
|
|
291
|
+
const helpersImportPrefix = "../".repeat(depth);
|
|
292
|
+
console.log(`
|
|
293
|
+
${subDir}/ (root type: ${rootTypeName})`);
|
|
294
|
+
const files = await buildSchema(schema, rootTypeName, void 0, config.typesOnly, config.logWarnings, config.strict, helpersMode, helpersImportPrefix, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive);
|
|
295
|
+
for (const file of files) {
|
|
296
|
+
if (file.filename.startsWith("_helpers/")) {
|
|
297
|
+
sharedHelpers.set(file.filename, file.content);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const content = config.banner ? resolveBanner(config.banner) + file.content : file.content;
|
|
301
|
+
await writer.stage(join(subDir, file.filename), content);
|
|
302
|
+
}
|
|
303
|
+
if (config.validators) {
|
|
304
|
+
await runValidators(config, schema, rootTypeName, writer, join("validators", subDir));
|
|
305
|
+
}
|
|
306
|
+
if (config.examples)
|
|
307
|
+
exampleTasks.push({ schema, rootTypeName, subDir });
|
|
308
|
+
}
|
|
309
|
+
for (const [filename, content] of sharedHelpers) {
|
|
310
|
+
await writer.stage(filename, content);
|
|
311
|
+
}
|
|
312
|
+
if (config.messageContracts) {
|
|
313
|
+
const contracts = await emitMessageContracts({
|
|
314
|
+
model,
|
|
315
|
+
writer,
|
|
316
|
+
bannerPrefix: config.banner ? resolveBanner(config.banner) : "",
|
|
317
|
+
...config.discriminator !== void 0 ? { discriminator: config.discriminator } : {}
|
|
318
|
+
});
|
|
319
|
+
contractIssues.push(...contracts.issues);
|
|
320
|
+
contractMessageCount = contracts.messageCount;
|
|
321
|
+
contractChannelCount = contracts.channelCount;
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
const writtenTsFiles = await commitOrDiscard(writer, stageAll);
|
|
325
|
+
for (const filename of writtenTsFiles)
|
|
326
|
+
console.log(`Generated: ${filename}`);
|
|
327
|
+
if (config.messageContracts) {
|
|
328
|
+
for (const issue of contractIssues) {
|
|
329
|
+
console.warn(`Warning: ${issue.path}: ${issue.message}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (writtenTsFiles.length === 0) {
|
|
333
|
+
console.error(`Error: nothing was generated from ${documentPath}. No message declares a payload the generators can use, and no channel projected onto a contract (see the warnings above).`);
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
if (config.messageContracts) {
|
|
337
|
+
console.log(`
|
|
338
|
+
Message contracts: ${contractMessageCount} message(s) across ${contractChannelCount} channel(s)`);
|
|
339
|
+
reportContractsPeer(outputDir);
|
|
340
|
+
}
|
|
341
|
+
if (exampleTasks.length > 0) {
|
|
342
|
+
await runExamples(config, outputDir, exampleTasks);
|
|
343
|
+
}
|
|
344
|
+
if (config.build) {
|
|
345
|
+
await buildOutput(outputDir, writtenTsFiles.filter((file) => !file.startsWith(`${CONTRACTS_DIR}/`)), config.typesOnly);
|
|
346
|
+
} else {
|
|
347
|
+
console.log(`
|
|
348
|
+
Total files generated: ${writtenTsFiles.length}`);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
256
351
|
const run = async () => {
|
|
257
352
|
const args = process.argv.slice(2);
|
|
258
353
|
if (args[0] === "lint") {
|
|
@@ -310,6 +405,41 @@ const run = async () => {
|
|
|
310
405
|
console.error("Error: --validators cannot be combined with --out-file. Use --out-dir to emit validator files.");
|
|
311
406
|
process.exit(1);
|
|
312
407
|
}
|
|
408
|
+
if (config.input === "asyncapi") {
|
|
409
|
+
if (config.schemaDir) {
|
|
410
|
+
console.error("Error: --input asyncapi reads a single document via --schema; it cannot be combined with --schema-dir.");
|
|
411
|
+
process.exit(1);
|
|
412
|
+
}
|
|
413
|
+
if (config.outFile) {
|
|
414
|
+
console.error("Error: --input asyncapi generates a directory of files per message; use --out-dir, not --out-file.");
|
|
415
|
+
process.exit(1);
|
|
416
|
+
}
|
|
417
|
+
if (config.rootType) {
|
|
418
|
+
console.error("Error: --root-type cannot be combined with --input asyncapi; each message derives its own root type.");
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
if (config.export) {
|
|
422
|
+
console.error("Error: --export applies to module inputs (typebox, zod, ...); an AsyncAPI document has no exports.");
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
} else if (config.messageContracts) {
|
|
426
|
+
console.error("Error: --message-contracts requires --input asyncapi; there are no channels to project otherwise.");
|
|
427
|
+
process.exit(1);
|
|
428
|
+
}
|
|
429
|
+
if (config.messageContracts && config.typesOnly) {
|
|
430
|
+
console.error("Error: --message-contracts cannot be combined with --types-only, which emits no runtime code.");
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
if (config.discriminator !== void 0) {
|
|
434
|
+
if (!config.messageContracts) {
|
|
435
|
+
console.error("Error: --discriminator only applies to --message-contracts. Add --message-contracts, or drop it.");
|
|
436
|
+
process.exit(1);
|
|
437
|
+
}
|
|
438
|
+
if (!DISCRIMINATOR_PATTERN.test(config.discriminator)) {
|
|
439
|
+
console.error(`Error: invalid --discriminator "${config.discriminator}". Expected a property name of letters, digits, _, $ or -, not starting with a digit.`);
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
313
443
|
if (config.outFile) {
|
|
314
444
|
if (!config.typesOnly) {
|
|
315
445
|
console.error("Error: --out-file currently supports only --types-only output. Add --types-only or use --out-dir.");
|
|
@@ -331,6 +461,14 @@ const run = async () => {
|
|
|
331
461
|
process.exit(1);
|
|
332
462
|
}
|
|
333
463
|
const outputDir = resolve(config.outDir);
|
|
464
|
+
if (config.input === "asyncapi") {
|
|
465
|
+
if (!config.schema) {
|
|
466
|
+
console.error("Error: --schema is required. Provide a path to an AsyncAPI document (JSON or YAML).");
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
await runAsyncApi(config, resolve(config.schema), outputDir);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
334
472
|
if (config.schemaDir) {
|
|
335
473
|
await runRecursive(config, resolve(config.schemaDir), outputDir);
|
|
336
474
|
return;
|
|
@@ -3,13 +3,8 @@
|
|
|
3
3
|
* package (`'package'`) or ship the helper sources alongside them (`'embedded'`).
|
|
4
4
|
*
|
|
5
5
|
* We select `'package'` only when `@amritk/helpers` is a *declared* dependency
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `node_modules` purely as a transitive dependency of `@amritk/mjst`, which
|
|
10
|
-
* makes `import '@amritk/helpers/...'` work locally but break under
|
|
11
|
-
* pnpm/isolated installs where undeclared packages are not reachable. Requiring
|
|
12
|
-
* a declaration keeps auto-detected `'package'` output portable across install
|
|
13
|
-
* layouts; everything else falls back to the self-contained `'embedded'` mode.
|
|
6
|
+
* of the target project's nearest `package.json` above `outputDir` — see
|
|
7
|
+
* {@link isDependencyDeclared} for why merely being resolvable is not enough.
|
|
8
|
+
* Everything else falls back to the self-contained `'embedded'` mode.
|
|
14
9
|
*/
|
|
15
10
|
export declare const detectHelpersMode: (outputDir: string) => 'package' | 'embedded';
|
|
@@ -1,32 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
const readDeclaredDeps = (packageJsonPath) => {
|
|
4
|
-
try {
|
|
5
|
-
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
6
|
-
return /* @__PURE__ */ new Set([
|
|
7
|
-
...Object.keys(pkg.dependencies ?? {}),
|
|
8
|
-
...Object.keys(pkg.devDependencies ?? {}),
|
|
9
|
-
...Object.keys(pkg.peerDependencies ?? {}),
|
|
10
|
-
...Object.keys(pkg.optionalDependencies ?? {})
|
|
11
|
-
]);
|
|
12
|
-
} catch {
|
|
13
|
-
return void 0;
|
|
14
|
-
}
|
|
15
|
-
};
|
|
16
|
-
const detectHelpersMode = (outputDir) => {
|
|
17
|
-
let dir = resolve(outputDir);
|
|
18
|
-
const { root } = parsePath(dir);
|
|
19
|
-
while (true) {
|
|
20
|
-
const declared = readDeclaredDeps(join(dir, "package.json"));
|
|
21
|
-
if (declared) {
|
|
22
|
-
return declared.has("@amritk/helpers") ? "package" : "embedded";
|
|
23
|
-
}
|
|
24
|
-
if (dir === root)
|
|
25
|
-
break;
|
|
26
|
-
dir = dirname(dir);
|
|
27
|
-
}
|
|
28
|
-
return "embedded";
|
|
29
|
-
};
|
|
1
|
+
import { isDependencyDeclared } from "./is-dependency-declared.js";
|
|
2
|
+
const detectHelpersMode = (outputDir) => isDependencyDeclared(outputDir, "@amritk/helpers") ? "package" : "embedded";
|
|
30
3
|
export {
|
|
31
4
|
detectHelpersMode
|
|
32
5
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { AsyncApiModel, ExtractionIssue } from '@amritk/asyncapi';
|
|
2
|
+
import type { OutputWriter } from './create-output-writer.js';
|
|
3
|
+
/** Subdirectory of the output root the contract modules land in. */
|
|
4
|
+
export declare const CONTRACTS_DIR = "contracts";
|
|
5
|
+
/** The package the generated modules import `defineMessages` from. */
|
|
6
|
+
export declare const CONTRACTS_PEER = "@amritk/api";
|
|
7
|
+
export type EmitMessageContractsOptions = {
|
|
8
|
+
readonly model: AsyncApiModel;
|
|
9
|
+
readonly writer: OutputWriter;
|
|
10
|
+
/** Fallback discriminator for channels that do not declare one (`--discriminator`). */
|
|
11
|
+
readonly discriminator?: string;
|
|
12
|
+
/** Header comment prepended to each file, when `--banner` asked for one. */
|
|
13
|
+
readonly bannerPrefix?: string;
|
|
14
|
+
};
|
|
15
|
+
export type EmitMessageContractsResult = {
|
|
16
|
+
/** Paths staged on the writer, relative to its root, in the order they were staged. */
|
|
17
|
+
readonly files: readonly string[];
|
|
18
|
+
/** Everything a channel could not project, for the caller to warn about. */
|
|
19
|
+
readonly issues: readonly ExtractionIssue[];
|
|
20
|
+
/** How many messages made it into a contract, across every channel. */
|
|
21
|
+
readonly messageCount: number;
|
|
22
|
+
/** How many channels got a contract module (the barrel is not one of them). */
|
|
23
|
+
readonly channelCount: number;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Projects every channel of an AsyncAPI document onto a `defineMessages`
|
|
27
|
+
* contract and stages one module per channel under `contracts/`, plus a barrel.
|
|
28
|
+
*
|
|
29
|
+
* These are *runtime values*, not types — which is why the flag that asks for
|
|
30
|
+
* them refuses `--types-only`, and why the generated files import
|
|
31
|
+
* `@amritk/api`. That import is the consumer's dependency to satisfy: the CLI
|
|
32
|
+
* needs the package to have been written, not to be installed where the output
|
|
33
|
+
* lands, and pinning it as a hard dependency of generated code would drag a
|
|
34
|
+
* server framework into projects that only wanted the schemas.
|
|
35
|
+
*
|
|
36
|
+
* A channel that projects to nothing (every message skipped, or a channel with
|
|
37
|
+
* no messages at all) is not written: an exported contract with no messages
|
|
38
|
+
* accepts no frame in either direction, which is a trap rather than a document.
|
|
39
|
+
* The reasons come back in {@link EmitMessageContractsResult.issues}.
|
|
40
|
+
*
|
|
41
|
+
* The barrel's specifiers are always `.ts`, ignoring `--import-ext`: these
|
|
42
|
+
* modules are the one part of the output `--build` leaves uncompiled (they
|
|
43
|
+
* import the `@amritk/api` peer, and handing tsc an import the output's project
|
|
44
|
+
* has not installed would fail the parsers' compilation too). A `.js` specifier
|
|
45
|
+
* would then name a file this run never wrote.
|
|
46
|
+
*/
|
|
47
|
+
export declare const emitMessageContracts: (options: EmitMessageContractsOptions) => Promise<EmitMessageContractsResult>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { buildChannelContract, sanitizeToken } from "@amritk/asyncapi";
|
|
2
|
+
const CONTRACTS_DIR = "contracts";
|
|
3
|
+
const CONTRACTS_PEER = "@amritk/api";
|
|
4
|
+
const renderDirection = (name, direction, indent) => {
|
|
5
|
+
const entries = Object.entries(direction).map(([messageName, schema]) => {
|
|
6
|
+
const literal = JSON.stringify(schema, null, 2).replaceAll("\n", `
|
|
7
|
+
${indent} `);
|
|
8
|
+
return `${indent} ${JSON.stringify(messageName)}: ${literal} as const,`;
|
|
9
|
+
});
|
|
10
|
+
return `${indent}${name}: {
|
|
11
|
+
${entries.join("\n")}
|
|
12
|
+
${indent}},`;
|
|
13
|
+
};
|
|
14
|
+
const renderChannelModule = (contract, channel) => {
|
|
15
|
+
const directions = [
|
|
16
|
+
Object.keys(contract.clientToServer).length > 0 ? renderDirection("clientToServer", contract.clientToServer, " ") : void 0,
|
|
17
|
+
Object.keys(contract.serverToClient).length > 0 ? renderDirection("serverToClient", contract.serverToClient, " ") : void 0
|
|
18
|
+
].filter((entry) => entry !== void 0);
|
|
19
|
+
const address = channel.address !== void 0 && channel.address !== channel.key ? ` (${channel.address})` : "";
|
|
20
|
+
return [
|
|
21
|
+
"/**",
|
|
22
|
+
` * Message contract for the AsyncAPI channel \`${channel.key}\`${address}.`,
|
|
23
|
+
" *",
|
|
24
|
+
` * Requires \`${CONTRACTS_PEER}\` at runtime \u2014 it is a peer dependency of this`,
|
|
25
|
+
" * generated code, not of the mjst CLI that wrote it. Install it in the project",
|
|
26
|
+
" * that imports this file.",
|
|
27
|
+
" *",
|
|
28
|
+
" * Message keys are the wire discriminator values: a frame",
|
|
29
|
+
` * \`{ "${contract.discriminator}": "<key>", ... }\` selects its schema by that key, and the`,
|
|
30
|
+
" * tag is removed before the payload below is validated \u2014 which is why no",
|
|
31
|
+
" * payload here declares it.",
|
|
32
|
+
" */",
|
|
33
|
+
`import { defineMessages } from '${CONTRACTS_PEER}'`,
|
|
34
|
+
"",
|
|
35
|
+
`export const ${contract.exportName} = defineMessages({`,
|
|
36
|
+
` discriminator: '${contract.discriminator}',`,
|
|
37
|
+
...directions,
|
|
38
|
+
"})",
|
|
39
|
+
""
|
|
40
|
+
].join("\n");
|
|
41
|
+
};
|
|
42
|
+
const emitMessageContracts = async (options) => {
|
|
43
|
+
const { model, writer, bannerPrefix = "" } = options;
|
|
44
|
+
const issues = [];
|
|
45
|
+
const files = [];
|
|
46
|
+
const barrel = [];
|
|
47
|
+
const takenTokens = /* @__PURE__ */ new Set();
|
|
48
|
+
const takenExports = /* @__PURE__ */ new Set();
|
|
49
|
+
let messageCount = 0;
|
|
50
|
+
for (const channel of model.channels) {
|
|
51
|
+
const contract = buildChannelContract(channel, {
|
|
52
|
+
...options.discriminator !== void 0 ? { discriminator: options.discriminator } : {}
|
|
53
|
+
});
|
|
54
|
+
issues.push(...contract.issues);
|
|
55
|
+
const count = Object.keys(contract.clientToServer).length + Object.keys(contract.serverToClient).length;
|
|
56
|
+
if (count === 0) {
|
|
57
|
+
issues.push({
|
|
58
|
+
path: `#/channels/${channel.key}`,
|
|
59
|
+
message: "no message projected onto a contract; no contract module written for this channel"
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
messageCount += count;
|
|
64
|
+
const token = claim(sanitizeToken(channel.key, "channel"), takenTokens, "-");
|
|
65
|
+
const exportName = claim(contract.exportName, takenExports, "");
|
|
66
|
+
if (exportName !== contract.exportName) {
|
|
67
|
+
issues.push({
|
|
68
|
+
path: `#/channels/${channel.key}`,
|
|
69
|
+
message: `contract export name "${contract.exportName}" already claimed; using "${exportName}"`
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const filename = `${CONTRACTS_DIR}/${token}.ts`;
|
|
73
|
+
await writer.stage(filename, bannerPrefix + renderChannelModule({ ...contract, exportName }, channel));
|
|
74
|
+
files.push(filename);
|
|
75
|
+
barrel.push(`export { ${exportName} } from './${token}.ts'`);
|
|
76
|
+
}
|
|
77
|
+
if (barrel.length > 0) {
|
|
78
|
+
const filename = `${CONTRACTS_DIR}/index.ts`;
|
|
79
|
+
await writer.stage(filename, `${bannerPrefix + barrel.join("\n")}
|
|
80
|
+
`);
|
|
81
|
+
files.push(filename);
|
|
82
|
+
}
|
|
83
|
+
return { files, issues, messageCount, channelCount: barrel.length };
|
|
84
|
+
};
|
|
85
|
+
const claim = (base, taken, separator) => {
|
|
86
|
+
let candidate = base;
|
|
87
|
+
for (let n = 2; taken.has(candidate); n++) {
|
|
88
|
+
candidate = `${base}${separator}${n}`;
|
|
89
|
+
}
|
|
90
|
+
taken.add(candidate);
|
|
91
|
+
return candidate;
|
|
92
|
+
};
|
|
93
|
+
export {
|
|
94
|
+
CONTRACTS_DIR,
|
|
95
|
+
CONTRACTS_PEER,
|
|
96
|
+
emitMessageContracts
|
|
97
|
+
};
|
package/dist/help-text.d.ts
CHANGED
|
@@ -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\nSubcommands (each with its own flags and --help):\n mjst lint <files> Lint JSON/YAML documents against a ruleset\n mjst compile-api <module> --out <file> Compile @amritk/api route contracts into a fetch-handler module\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, avro\n --export <name> Which export of the schema module to use when --input names a module format\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 --force Overwrite pre-existing files in the output destination\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 --allowed-roots <d> Extra directories a local $ref may resolve into, on top of the schema's own\n (comma-separated; relative to the current directory)\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\nSubcommands (each with its own flags and --help):\n mjst lint <files> Lint JSON/YAML documents against a ruleset\n mjst compile-api <module> --out <file> Compile @amritk/api route contracts into a fetch-handler module\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, avro,\n or asyncapi (an AsyncAPI 2.x/3.0 document, JSON or YAML)\n --export <name> Which export of the schema module to use when --input names a module format\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 --message-contracts Also emit @amritk/api channel contracts under contracts/ (--input asyncapi only)\n --discriminator <p> Property naming which message a frame is, for --message-contracts (default: type)\n --build Compile the generated files to .js/.d.ts (implies --import-ext js)\n --force Overwrite pre-existing files in the output destination\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 --unknown-keys <s> How closed objects are proven free of undeclared keys:\n count-keys (default, fastest on Bun) or count-enumerable (fastest on Node)\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 --allowed-roots <d> Extra directories a local $ref may resolve into, on top of the schema's own\n (comma-separated; relative to the current directory)\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
|
@@ -12,7 +12,8 @@ Subcommands (each with its own flags and --help):
|
|
|
12
12
|
Input:
|
|
13
13
|
--schema <path> Schema to process: a JSON Schema file, or a module when --input is set
|
|
14
14
|
--schema-dir <dir> Directory of JSON Schemas, processed recursively (instead of --schema)
|
|
15
|
-
--input <format> Schema source format: json (default), typebox, zod, valibot, effect, avro
|
|
15
|
+
--input <format> Schema source format: json (default), typebox, zod, valibot, effect, avro,
|
|
16
|
+
or asyncapi (an AsyncAPI 2.x/3.0 document, JSON or YAML)
|
|
16
17
|
--export <name> Which export of the schema module to use when --input names a module format
|
|
17
18
|
|
|
18
19
|
Output:
|
|
@@ -21,6 +22,8 @@ Output:
|
|
|
21
22
|
--types-only Generate type definitions only, without parser functions
|
|
22
23
|
--validators Also emit validateX/isX validators into a validators/ subdir
|
|
23
24
|
--examples Also emit fast-check arbitraries + example values under examples/
|
|
25
|
+
--message-contracts Also emit @amritk/api channel contracts under contracts/ (--input asyncapi only)
|
|
26
|
+
--discriminator <p> Property naming which message a frame is, for --message-contracts (default: type)
|
|
24
27
|
--build Compile the generated files to .js/.d.ts (implies --import-ext js)
|
|
25
28
|
--force Overwrite pre-existing files in the output destination
|
|
26
29
|
--import-ext <ext> Extension on generated relative imports: ts (default) or js
|
|
@@ -35,6 +38,8 @@ Validation:
|
|
|
35
38
|
--strip-unknown Silently drop undeclared input keys at every nesting level
|
|
36
39
|
--case-insensitive Normalize mis-cased enum/const strings to their exact casing
|
|
37
40
|
--log-warnings console.warn on input keys not declared in the schema
|
|
41
|
+
--unknown-keys <s> How closed objects are proven free of undeclared keys:
|
|
42
|
+
count-keys (default, fastest on Bun) or count-enumerable (fastest on Node)
|
|
38
43
|
|
|
39
44
|
Reference resolution:
|
|
40
45
|
--resolve-remote Fetch http(s) $refs while resolving cross-file/remote refs (off by default)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when `packageName` is a *declared* dependency (dependencies /
|
|
3
|
+
* devDependencies / peer / optional) of the nearest readable `package.json` at
|
|
4
|
+
* or above `fromDir`.
|
|
5
|
+
*
|
|
6
|
+
* Declared, not merely resolvable: under npm/bun's hoisted layouts a package can
|
|
7
|
+
* sit in `node_modules` purely as a transitive dependency of `@amritk/mjst`,
|
|
8
|
+
* which makes importing it work locally but break under pnpm/isolated installs
|
|
9
|
+
* where undeclared packages are not reachable. Generated code has to survive
|
|
10
|
+
* both, so the question worth asking is whether the *project* asked for the
|
|
11
|
+
* package, not whether this machine happens to have it.
|
|
12
|
+
*
|
|
13
|
+
* Only the nearest package.json is consulted. A workspace root above it may well
|
|
14
|
+
* declare the package, but a nested package that does not declare it cannot rely
|
|
15
|
+
* on the hoist — which is exactly the case this is meant to catch.
|
|
16
|
+
*/
|
|
17
|
+
export declare const isDependencyDeclared: (fromDir: string, packageName: string) => boolean;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, parse as parsePath, resolve } from "node:path";
|
|
3
|
+
const readDeclaredDeps = (packageJsonPath) => {
|
|
4
|
+
try {
|
|
5
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
6
|
+
return /* @__PURE__ */ new Set([
|
|
7
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
8
|
+
...Object.keys(pkg.devDependencies ?? {}),
|
|
9
|
+
...Object.keys(pkg.peerDependencies ?? {}),
|
|
10
|
+
...Object.keys(pkg.optionalDependencies ?? {})
|
|
11
|
+
]);
|
|
12
|
+
} catch {
|
|
13
|
+
return void 0;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const isDependencyDeclared = (fromDir, packageName) => {
|
|
17
|
+
let dir = resolve(fromDir);
|
|
18
|
+
const { root } = parsePath(dir);
|
|
19
|
+
while (true) {
|
|
20
|
+
const declared = readDeclaredDeps(join(dir, "package.json"));
|
|
21
|
+
if (declared)
|
|
22
|
+
return declared.has(packageName);
|
|
23
|
+
if (dir === root)
|
|
24
|
+
break;
|
|
25
|
+
dir = dirname(dir);
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
};
|
|
29
|
+
export {
|
|
30
|
+
isDependencyDeclared
|
|
31
|
+
};
|
package/dist/lint/run.js
CHANGED
|
@@ -13,6 +13,16 @@ const SEVERITY_BY_NAME = {
|
|
|
13
13
|
hint: DiagnosticSeverity.Hint
|
|
14
14
|
};
|
|
15
15
|
const SEVERITY_LABEL = ["error", "warning", "info", "hint"];
|
|
16
|
+
const buildAsyncApiPreset = async () => (await import("@amritk/lint/rules/asyncapi")).createAsyncApiRuleset();
|
|
17
|
+
const buildOpenApiPreset = async () => (await import("@amritk/lint/rules/openapi")).createOpenApiRuleset();
|
|
18
|
+
const PRESET_RULESETS = /* @__PURE__ */ new Map([
|
|
19
|
+
["asyncapi", buildAsyncApiPreset],
|
|
20
|
+
["loupe:asyncapi", buildAsyncApiPreset],
|
|
21
|
+
["spectral:asyncapi", buildAsyncApiPreset],
|
|
22
|
+
["oas", buildOpenApiPreset],
|
|
23
|
+
["loupe:oas", buildOpenApiPreset],
|
|
24
|
+
["spectral:oas", buildOpenApiPreset]
|
|
25
|
+
]);
|
|
16
26
|
const formatReport = (findings) => {
|
|
17
27
|
if (findings.length === 0)
|
|
18
28
|
return "No problems found\n";
|
|
@@ -51,7 +61,11 @@ const parseArgs = async (argv) => {
|
|
|
51
61
|
try {
|
|
52
62
|
const parsed = await yargs(argv).scriptName("lint").parserConfiguration({ "greedy-arrays": false }).strict().exitProcess(false).fail((message, error) => {
|
|
53
63
|
throw error ?? new Error(message);
|
|
54
|
-
}).usage("$0 [documents..]", "Lint JSON/YAML documents against a ruleset").positional("documents", { describe: "Documents or globs to lint", type: "string", array: true }).option("ruleset", {
|
|
64
|
+
}).usage("$0 [documents..]", "Lint JSON/YAML documents against a ruleset").positional("documents", { describe: "Documents or globs to lint", type: "string", array: true }).option("ruleset", {
|
|
65
|
+
alias: "r",
|
|
66
|
+
type: "string",
|
|
67
|
+
describe: "Path to a ruleset file, or a built-in preset: asyncapi, oas"
|
|
68
|
+
}).option("encoding", { type: "string", default: "utf8", describe: "Input encoding" }).option("fail-severity", { alias: "F", type: "string", default: "error", choices: Object.keys(SEVERITY_BY_NAME) }).option("display-only-failures", { alias: "D", type: "boolean", default: false }).option("verbose", { type: "boolean", default: false }).option("quiet", { alias: "q", type: "boolean", default: false }).option("stdin-filepath", {
|
|
55
69
|
type: "string",
|
|
56
70
|
describe: "Path to associate with stdin input (labels findings and enables ruleset discovery)"
|
|
57
71
|
}).option("concurrency", {
|
|
@@ -127,9 +141,15 @@ Run \`mjst lint --help\` for the supported flags.
|
|
|
127
141
|
let rulesetDefinition;
|
|
128
142
|
let rulesetBasePath;
|
|
129
143
|
if (parsed.ruleset) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
144
|
+
const preset = PRESET_RULESETS.get(parsed.ruleset);
|
|
145
|
+
if (preset) {
|
|
146
|
+
rulesetDefinition = await preset();
|
|
147
|
+
} else {
|
|
148
|
+
const definition = await loadRuleset(parsed.ruleset);
|
|
149
|
+
rulesetDefinition = definition;
|
|
150
|
+
rulesetBasePath = dirname(isAbsolute(parsed.ruleset) ? parsed.ruleset : resolve(process.cwd(), parsed.ruleset));
|
|
151
|
+
reportRulesetProblems(definition, parsed.ruleset);
|
|
152
|
+
}
|
|
133
153
|
}
|
|
134
154
|
const documents = parsed.documents ?? [];
|
|
135
155
|
const targets = await fg(documents, { dot: true, onlyFiles: true });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CliConfig } from './cli-config.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reads and parses an AsyncAPI document (JSON or YAML) off disk. When the
|
|
4
|
+
* document carries cross-file or remote `$ref`s, the whole document is
|
|
5
|
+
* dereferenced with `@amritk/resolve-refs` under the same policy flags as JSON
|
|
6
|
+
* Schema input — offline by default, SSRF-guarded, confined to the document's
|
|
7
|
+
* directory. A document with only same-document `#/...` references (the common
|
|
8
|
+
* case) skips that pass entirely: the extractor follows them itself, and keeps
|
|
9
|
+
* `#/components/schemas/...` pointers *as names* so the generators emit one
|
|
10
|
+
* type per component instead of an inlined blob.
|
|
11
|
+
*/
|
|
12
|
+
export declare const loadAsyncApiDocument: (config: Partial<CliConfig>, documentPath: string) => Promise<unknown>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { assignKey } from "@amritk/helpers/assign-key";
|
|
4
|
+
import { readKey } from "@amritk/helpers/read-key";
|
|
5
|
+
import { resolveRefsFromFile } from "@amritk/resolve-refs";
|
|
6
|
+
import { parseDocument } from "@amritk/yaml";
|
|
7
|
+
import { hasExternalRefs } from "./has-external-refs.js";
|
|
8
|
+
import { buildResolveOptions, formatResolveErrors } from "./resolve-policy.js";
|
|
9
|
+
const parseDocumentText = (content, location) => {
|
|
10
|
+
const text = content.charCodeAt(0) === 65279 ? content.slice(1) : content;
|
|
11
|
+
if (/\.json$/i.test(location))
|
|
12
|
+
return JSON.parse(text);
|
|
13
|
+
const doc = parseDocument(text);
|
|
14
|
+
if (doc.errors.length > 0) {
|
|
15
|
+
const details = doc.errors.slice(0, 5).map((error) => ` - ${error.message}`).join("\n");
|
|
16
|
+
throw new Error(`Failed to parse ${location} as YAML:
|
|
17
|
+
${details}`);
|
|
18
|
+
}
|
|
19
|
+
if (doc.warnings.some((warning) => warning.code === "MULTIPLE_DOCUMENTS")) {
|
|
20
|
+
throw new Error(`${location} contains multiple YAML documents; an AsyncAPI document must be a single-document file.`);
|
|
21
|
+
}
|
|
22
|
+
return doc.toJS();
|
|
23
|
+
};
|
|
24
|
+
const nodeAt = (root, pointer) => {
|
|
25
|
+
let node = root;
|
|
26
|
+
for (const segment of pointer) {
|
|
27
|
+
if (Array.isArray(node)) {
|
|
28
|
+
node = node[typeof segment === "number" ? segment : Number(segment)];
|
|
29
|
+
} else if (typeof node === "object" && node !== null) {
|
|
30
|
+
node = readKey(node, String(segment));
|
|
31
|
+
} else {
|
|
32
|
+
return void 0;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return node;
|
|
36
|
+
};
|
|
37
|
+
const canonicalizeAliases = (resolved, origins, rootLocation) => {
|
|
38
|
+
const canonical = /* @__PURE__ */ new Map();
|
|
39
|
+
for (const [copy, origin] of origins) {
|
|
40
|
+
if (origin.location !== rootLocation)
|
|
41
|
+
continue;
|
|
42
|
+
const target = nodeAt(resolved, origin.pointer);
|
|
43
|
+
if (typeof target === "object" && target !== null && target !== copy)
|
|
44
|
+
canonical.set(copy, target);
|
|
45
|
+
}
|
|
46
|
+
if (canonical.size === 0)
|
|
47
|
+
return;
|
|
48
|
+
const seen = /* @__PURE__ */ new Set();
|
|
49
|
+
const rewrite = (node) => {
|
|
50
|
+
if (typeof node !== "object" || node === null || seen.has(node))
|
|
51
|
+
return;
|
|
52
|
+
seen.add(node);
|
|
53
|
+
if (Array.isArray(node)) {
|
|
54
|
+
for (let index = 0; index < node.length; index++) {
|
|
55
|
+
const value = node[index];
|
|
56
|
+
const replacement = typeof value === "object" && value !== null ? canonical.get(value) : void 0;
|
|
57
|
+
if (replacement !== void 0)
|
|
58
|
+
node[index] = replacement;
|
|
59
|
+
else
|
|
60
|
+
rewrite(value);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const [key, value] of Object.entries(node)) {
|
|
65
|
+
const replacement = typeof value === "object" && value !== null ? canonical.get(value) : void 0;
|
|
66
|
+
if (replacement !== void 0)
|
|
67
|
+
assignKey(node, key, replacement);
|
|
68
|
+
else
|
|
69
|
+
rewrite(value);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
rewrite(resolved);
|
|
73
|
+
};
|
|
74
|
+
const loadAsyncApiDocument = async (config, documentPath) => {
|
|
75
|
+
const document = parseDocumentText(await readFile(documentPath, "utf-8"), documentPath);
|
|
76
|
+
if (!hasExternalRefs(document))
|
|
77
|
+
return document;
|
|
78
|
+
const { resolved, errors, origins } = await resolveRefsFromFile(documentPath, {
|
|
79
|
+
...buildResolveOptions(config, documentPath),
|
|
80
|
+
parse: parseDocumentText,
|
|
81
|
+
trackOrigins: true
|
|
82
|
+
});
|
|
83
|
+
if (errors.length > 0)
|
|
84
|
+
throw new Error(formatResolveErrors(documentPath, errors));
|
|
85
|
+
if (origins !== void 0)
|
|
86
|
+
canonicalizeAliases(resolved, origins, resolve(documentPath));
|
|
87
|
+
return resolved;
|
|
88
|
+
};
|
|
89
|
+
export {
|
|
90
|
+
loadAsyncApiDocument
|
|
91
|
+
};
|
package/dist/load-config.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { isUnknownKeysStrategy } from "@amritk/helpers/unknown-keys-strategy";
|
|
3
4
|
import { validateConfig } from "./validate-config.js";
|
|
4
|
-
const SOURCE_FORMATS = ["json", "typebox", "zod", "valibot", "effect", "avro"];
|
|
5
|
+
const SOURCE_FORMATS = ["json", "typebox", "zod", "valibot", "effect", "avro", "asyncapi"];
|
|
5
6
|
const isSourceFormat = (value) => typeof value === "string" && SOURCE_FORMATS.includes(value);
|
|
6
7
|
const isHelpersMode = (value) => value === "package" || value === "embedded";
|
|
7
8
|
const isImportExt = (value) => value === "js" || value === "ts";
|
|
@@ -29,6 +30,8 @@ const loadConfig = async (configPath) => {
|
|
|
29
30
|
...typeof obj["validators"] === "boolean" && { validators: obj["validators"] },
|
|
30
31
|
...typeof obj["typesOnly"] === "boolean" && { typesOnly: obj["typesOnly"] },
|
|
31
32
|
...typeof obj["examples"] === "boolean" && { examples: obj["examples"] },
|
|
33
|
+
...typeof obj["messageContracts"] === "boolean" && { messageContracts: obj["messageContracts"] },
|
|
34
|
+
...typeof obj["discriminator"] === "string" && { discriminator: obj["discriminator"] },
|
|
32
35
|
...typeof obj["build"] === "boolean" && { build: obj["build"] },
|
|
33
36
|
...typeof obj["force"] === "boolean" && { force: obj["force"] },
|
|
34
37
|
...typeof obj["logWarnings"] === "boolean" && { logWarnings: obj["logWarnings"] },
|
|
@@ -36,6 +39,7 @@ const loadConfig = async (configPath) => {
|
|
|
36
39
|
...typeof obj["stripUnknown"] === "boolean" && { stripUnknown: obj["stripUnknown"] },
|
|
37
40
|
...typeof obj["caseInsensitive"] === "boolean" && { caseInsensitive: obj["caseInsensitive"] },
|
|
38
41
|
...typeof obj["readonly"] === "boolean" && { readonly: obj["readonly"] },
|
|
42
|
+
...isUnknownKeysStrategy(obj["unknownKeys"]) && { unknownKeys: obj["unknownKeys"] },
|
|
39
43
|
...isHelpersMode(obj["helpers"]) && { helpers: obj["helpers"] },
|
|
40
44
|
...isImportExt(obj["importExt"]) && { importExt: obj["importExt"] },
|
|
41
45
|
...typeof obj["rootType"] === "string" && { rootType: obj["rootType"] },
|
package/dist/load-schema.js
CHANGED
|
@@ -1,25 +1,9 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, resolve } from "node:path";
|
|
3
2
|
import { getAdapter } from "@amritk/adapters/get-adapter";
|
|
4
3
|
import { resolveRefsFromFile } from "@amritk/resolve-refs";
|
|
5
|
-
import { withAllowedRootsHint } from "./allowed-roots-hint.js";
|
|
6
4
|
import { hasExternalRefs } from "./has-external-refs.js";
|
|
7
5
|
import { loadSchemaModule } from "./load-schema-module.js";
|
|
8
|
-
|
|
9
|
-
const remote = (config.resolveRemote ?? false) || (config.allowedHosts?.length ?? 0) > 0;
|
|
10
|
-
const extraRoots = (config.allowedRoots ?? []).map((root) => resolve(root));
|
|
11
|
-
return {
|
|
12
|
-
remote,
|
|
13
|
-
...extraRoots.length > 0 ? { allowedRoots: [dirname(resolve(schemaPath)), ...extraRoots] } : {},
|
|
14
|
-
...config.allowedHosts ? { allowedHosts: [...config.allowedHosts] } : {},
|
|
15
|
-
...config.allowPrivateHosts ? { allowPrivateHosts: config.allowPrivateHosts } : {}
|
|
16
|
-
};
|
|
17
|
-
};
|
|
18
|
-
const formatResolveErrors = (schemaPath, errors) => {
|
|
19
|
-
const details = errors.map((error) => ` - ${withAllowedRootsHint(error.message)}`).join("\n");
|
|
20
|
-
return `Failed to resolve $refs in ${schemaPath}:
|
|
21
|
-
${details}`;
|
|
22
|
-
};
|
|
6
|
+
import { buildResolveOptions, formatResolveErrors } from "./resolve-policy.js";
|
|
23
7
|
const loadSchema = async (config, schemaPath) => {
|
|
24
8
|
const inputFormat = config.input ?? "json";
|
|
25
9
|
if (inputFormat === "json") {
|
package/dist/parse-cli-args.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
import { isUnknownKeysStrategy, UNKNOWN_KEYS_STRATEGIES } from "@amritk/helpers/unknown-keys-strategy";
|
|
2
|
+
const SOURCE_FORMATS = ["json", "typebox", "zod", "valibot", "effect", "avro", "asyncapi"];
|
|
2
3
|
const LIST_KEYS = /* @__PURE__ */ new Set(["allowedHosts", "allowedRoots"]);
|
|
3
4
|
const BOOLEAN_KEYS = /* @__PURE__ */ new Set([
|
|
4
5
|
"typesOnly",
|
|
5
6
|
"validators",
|
|
6
7
|
"examples",
|
|
8
|
+
"messageContracts",
|
|
7
9
|
"build",
|
|
8
10
|
"force",
|
|
9
11
|
"logWarnings",
|
|
@@ -21,10 +23,12 @@ const VALUE_KEYS = /* @__PURE__ */ new Set([
|
|
|
21
23
|
"outFile",
|
|
22
24
|
"input",
|
|
23
25
|
"export",
|
|
26
|
+
"discriminator",
|
|
24
27
|
"helpers",
|
|
25
28
|
"typeSuffix",
|
|
26
29
|
"importExt",
|
|
27
|
-
"rootType"
|
|
30
|
+
"rootType",
|
|
31
|
+
"unknownKeys"
|
|
28
32
|
]);
|
|
29
33
|
const EXTERNAL_VALUE_KEYS = /* @__PURE__ */ new Set(["config"]);
|
|
30
34
|
const toCamelCase = (key) => key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
@@ -72,6 +76,9 @@ const assignValue = (config, key, value) => {
|
|
|
72
76
|
case "rootType":
|
|
73
77
|
config.rootType = value;
|
|
74
78
|
return true;
|
|
79
|
+
case "discriminator":
|
|
80
|
+
config.discriminator = value;
|
|
81
|
+
return true;
|
|
75
82
|
case "input": {
|
|
76
83
|
const parsed = parseInputValue(value);
|
|
77
84
|
if (!parsed) {
|
|
@@ -96,6 +103,13 @@ const assignValue = (config, key, value) => {
|
|
|
96
103
|
config.importExt = parsed;
|
|
97
104
|
return true;
|
|
98
105
|
}
|
|
106
|
+
case "unknownKeys": {
|
|
107
|
+
if (!isUnknownKeysStrategy(value)) {
|
|
108
|
+
throw new Error(`Invalid --unknown-keys value "${value}". Expected one of: ${UNKNOWN_KEYS_STRATEGIES.join(", ")}.`);
|
|
109
|
+
}
|
|
110
|
+
config.unknownKeys = value;
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
99
113
|
default:
|
|
100
114
|
return false;
|
|
101
115
|
}
|
|
@@ -111,6 +125,9 @@ const assignBoolean = (config, key, value) => {
|
|
|
111
125
|
case "examples":
|
|
112
126
|
config.examples = value;
|
|
113
127
|
return true;
|
|
128
|
+
case "messageContracts":
|
|
129
|
+
config.messageContracts = value;
|
|
130
|
+
return true;
|
|
114
131
|
case "build":
|
|
115
132
|
config.build = value;
|
|
116
133
|
return true;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ResolveError } from '@amritk/resolve-refs';
|
|
2
|
+
import type { CliConfig } from './cli-config.js';
|
|
3
|
+
/**
|
|
4
|
+
* Builds the `@amritk/resolve-refs` options from the CLI config, mirroring the
|
|
5
|
+
* safety posture of `mjst lint`: remote (`http(s)`) `$ref`s are refused unless
|
|
6
|
+
* opted into, a non-empty `allowedHosts` implies remote fetching, private/
|
|
7
|
+
* loopback hosts stay blocked as an SSRF guard unless explicitly permitted, and
|
|
8
|
+
* a local `$ref` may not leave the document's own directory unless
|
|
9
|
+
* `allowedRoots` names somewhere else.
|
|
10
|
+
*
|
|
11
|
+
* `allowedRoots` is passed only when the user asked for it, and it *adds* to the
|
|
12
|
+
* document's own directory rather than replacing it. The library option replaces
|
|
13
|
+
* the default outright, which would make `--allowed-roots ../common` quietly
|
|
14
|
+
* revoke the folder the document itself lives in — the one directory a user
|
|
15
|
+
* would never think to list. Entries are resolved against the process cwd (both
|
|
16
|
+
* from the flag and from a config file), matching how `schema` and `outDir` are
|
|
17
|
+
* treated in `cli.ts`.
|
|
18
|
+
*
|
|
19
|
+
* Shared by every document loader (plain JSON Schema, AsyncAPI) so one flag set
|
|
20
|
+
* means one policy.
|
|
21
|
+
*/
|
|
22
|
+
export declare const buildResolveOptions: (config: Partial<CliConfig>, documentPath: string) => {
|
|
23
|
+
remote: boolean;
|
|
24
|
+
allowedRoots?: string[];
|
|
25
|
+
allowedHosts?: string[];
|
|
26
|
+
allowPrivateHosts?: true;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Renders resolver failures into a single CLI error message. A confinement
|
|
30
|
+
* refusal is rewritten to name `--allowed-roots`, because the raw library text
|
|
31
|
+
* tells the user to set an option the CLI does not otherwise expose.
|
|
32
|
+
*/
|
|
33
|
+
export declare const formatResolveErrors: (documentPath: string, errors: readonly ResolveError[]) => string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { withAllowedRootsHint } from "./allowed-roots-hint.js";
|
|
3
|
+
const buildResolveOptions = (config, documentPath) => {
|
|
4
|
+
const remote = (config.resolveRemote ?? false) || (config.allowedHosts?.length ?? 0) > 0;
|
|
5
|
+
const extraRoots = (config.allowedRoots ?? []).map((root) => resolve(root));
|
|
6
|
+
return {
|
|
7
|
+
remote,
|
|
8
|
+
...extraRoots.length > 0 ? { allowedRoots: [dirname(resolve(documentPath)), ...extraRoots] } : {},
|
|
9
|
+
...config.allowedHosts ? { allowedHosts: [...config.allowedHosts] } : {},
|
|
10
|
+
...config.allowPrivateHosts ? { allowPrivateHosts: config.allowPrivateHosts } : {}
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
const formatResolveErrors = (documentPath, errors) => {
|
|
14
|
+
const details = errors.map((error) => ` - ${withAllowedRootsHint(error.message)}`).join("\n");
|
|
15
|
+
return `Failed to resolve $refs in ${documentPath}:
|
|
16
|
+
${details}`;
|
|
17
|
+
};
|
|
18
|
+
export {
|
|
19
|
+
buildResolveOptions,
|
|
20
|
+
formatResolveErrors
|
|
21
|
+
};
|
package/dist/validate-config.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { readKey } from "@amritk/helpers/read-key";
|
|
2
|
+
import { UNKNOWN_KEYS_STRATEGIES } from "@amritk/helpers/unknown-keys-strategy";
|
|
2
3
|
const CONFIG_KEYS = {
|
|
3
4
|
schema: { type: "string" },
|
|
4
5
|
schemaDir: { type: "string" },
|
|
5
|
-
input: { type: "string", enum: ["json", "typebox", "zod", "valibot", "effect", "avro"] },
|
|
6
|
+
input: { type: "string", enum: ["json", "typebox", "zod", "valibot", "effect", "avro", "asyncapi"] },
|
|
6
7
|
export: { type: "string" },
|
|
7
8
|
outDir: { type: "string" },
|
|
8
9
|
outFile: { type: "string" },
|
|
9
10
|
typesOnly: { type: "boolean" },
|
|
10
11
|
validators: { type: "boolean" },
|
|
11
12
|
examples: { type: "boolean" },
|
|
13
|
+
messageContracts: { type: "boolean" },
|
|
14
|
+
discriminator: { type: "string" },
|
|
12
15
|
build: { type: "boolean" },
|
|
13
16
|
force: { type: "boolean" },
|
|
14
17
|
logWarnings: { type: "boolean" },
|
|
@@ -16,6 +19,7 @@ const CONFIG_KEYS = {
|
|
|
16
19
|
stripUnknown: { type: "boolean" },
|
|
17
20
|
caseInsensitive: { type: "boolean" },
|
|
18
21
|
readonly: { type: "boolean" },
|
|
22
|
+
unknownKeys: { type: "string", enum: [...UNKNOWN_KEYS_STRATEGIES] },
|
|
19
23
|
helpers: { type: "string", enum: ["package", "embedded"] },
|
|
20
24
|
typeSuffix: { type: "string" },
|
|
21
25
|
banner: { type: "boolean|string" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/mjst",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -49,13 +49,14 @@
|
|
|
49
49
|
"./package.json": "./package.json"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@amritk/adapters": "^0.
|
|
53
|
-
"@amritk/api": "^0.16.
|
|
54
|
-
"@amritk/
|
|
55
|
-
"@amritk/generate-
|
|
56
|
-
"@amritk/generate-
|
|
57
|
-
"@amritk/
|
|
58
|
-
"@amritk/
|
|
52
|
+
"@amritk/adapters": "^0.6.1",
|
|
53
|
+
"@amritk/api": "^0.16.3",
|
|
54
|
+
"@amritk/asyncapi": "^0.2.0",
|
|
55
|
+
"@amritk/generate-examples": "^0.8.3",
|
|
56
|
+
"@amritk/generate-parsers": "^0.21.1",
|
|
57
|
+
"@amritk/generate-validators": "^0.16.1",
|
|
58
|
+
"@amritk/helpers": "^0.20.0",
|
|
59
|
+
"@amritk/lint": "^0.5.3",
|
|
59
60
|
"@amritk/resolve-refs": "^0.7.1",
|
|
60
61
|
"@amritk/yaml": "^0.7.2",
|
|
61
62
|
"fast-glob": "^3.3.2",
|
|
@@ -63,7 +64,7 @@
|
|
|
63
64
|
"yargs": "^17.7.2"
|
|
64
65
|
},
|
|
65
66
|
"devDependencies": {
|
|
66
|
-
"@amritk/generate-markdown": "^0.
|
|
67
|
+
"@amritk/generate-markdown": "^0.6.0",
|
|
67
68
|
"@types/yargs": "^17.0.33"
|
|
68
69
|
}
|
|
69
70
|
}
|