@amritk/mjst 0.8.0 → 0.10.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
@@ -9,7 +9,7 @@
9
9
  ![license](https://img.shields.io/badge/license-MIT-22c55e?style=flat-square) 
10
10
  ![JSON Schema](https://img.shields.io/badge/JSON%20Schema-2020--12-f97316?style=flat-square) 
11
11
  ![node](https://img.shields.io/badge/node-%E2%89%A520-339933?style=flat-square&logo=node.js&logoColor=white) 
12
- ![vibe coded](https://img.shields.io/badge/vibe%20coded-24%25-a855f7?style=flat-square)
12
+ ![vibe coded](https://img.shields.io/badge/vibe-coded-a855f7?style=flat-square)
13
13
 
14
14
  </div>
15
15
 
@@ -21,6 +21,8 @@
21
21
 
22
22
  Options can be supplied via CLI flags or a JSON config file. **CLI flags always take precedence over config file values.**
23
23
 
24
+ It also carries a `lint` subcommand — `mjst lint <files>` — a format-agnostic JSON/YAML style-guide linter powered by [`@amritk/lint`](../lint). See [Linting](#linting).
25
+
24
26
  ---
25
27
 
26
28
  ## Installation
@@ -71,15 +73,39 @@ npx mjst --schema-dir ./schemas --out-dir ./generated
71
73
  ```
72
74
  schemas/ generated/
73
75
  user.json ─▶ user/
74
- api/ document.ts, index.ts
76
+ api/ user.ts, index.ts
75
77
  order.json ─▶ api/order/
76
- document.ts, index.ts
78
+ order.ts, index.ts
77
79
  _helpers/ ← shared runtime helpers (embedded mode)
78
80
  ```
79
81
 
80
82
  Each schema lands in its own subdirectory so generated files never collide, and in embedded
81
83
  mode the runtime helpers are emitted **once** into `outDir/_helpers/` — every nested parser
82
- imports from that single shared location. `--build` compiles the whole tree in place.
84
+ imports from that single shared location. `--build` compiles the whole tree in place. Each
85
+ schema's root type is named after the schema (its `title`, else its filename in PascalCase),
86
+ so `user.json` yields `User` / `parseUser` and `api/order.json` yields `Order` / `parseOrder`.
87
+
88
+ ### Running the generated output
89
+
90
+ By default mjst emits relative imports with the literal `.ts` extension (e.g.
91
+ `import { parseUser } from './user.ts'`), so the generated sources run **as-is** — no build
92
+ step — under:
93
+
94
+ - **Bun** (`bun run generated/index.ts`)
95
+ - **Node ≥ 22.6** type stripping (`node generated/index.ts`; unflagged from 22.18/23, else pass
96
+ `--experimental-strip-types`)
97
+ - **tsc**, provided your `tsconfig.json` sets `"allowImportingTsExtensions": true`
98
+
99
+ If you'd rather compile the output first, pass `--build` (mjst switches the imports to the
100
+ NodeNext `./user.js` form and runs `tsc` for you), or generate with `--import-ext js` to emit
101
+ the compile-ready form without building. `--import-ext ts` together with `--build` is rejected,
102
+ since tsc cannot emit from `.ts` specifiers.
103
+
104
+ > [!NOTE]
105
+ > **Breaking change (0.9.0):** the root type is no longer always named `Document`. It is derived
106
+ > from the schema `title`, falling back to the schema filename (`spec-plan.json` → `SpecPlan`,
107
+ > giving `parseSpecPlan` / `validateSpecPlanShape`). Pass `--root-type <Name>` to override it for
108
+ > a single `--schema` run. Update any imports that referenced `Document` / `parseDocument`.
83
109
 
84
110
  ### Config file
85
111
 
@@ -92,6 +118,45 @@ npx mjst --config ./mjst.config.json
92
118
 
93
119
  ---
94
120
 
121
+ ## Linting
122
+
123
+ `mjst lint` lints JSON/YAML documents against a ruleset — JSON Schema validation and custom style rules, with exact `line:column` findings. It has its own flags (and its own `--help`), independent of the generation flags above.
124
+
125
+ ```bash
126
+ # Lint files against an explicit ruleset
127
+ npx mjst lint "**/*.{yaml,json}" -r .lint.yaml
128
+
129
+ # Auto-discover a .lint.* ruleset by walking up from each file
130
+ npx mjst lint config/
131
+
132
+ # Lint piped input
133
+ cat service.yaml | npx mjst lint --stdin-filepath service.yaml
134
+ ```
135
+
136
+ Findings are printed as a compact, editor-clickable report:
137
+
138
+ ```
139
+ config/service.yaml:2:7 error config-schema /port must be integer
140
+
141
+ ✖ 1 problem(s) (1 error(s), 0 warning(s))
142
+ ```
143
+
144
+ With no `-r`, mjst discovers a `.lint.{yaml,yml,json,js,mjs}` ruleset by walking up from each linted file. A ruleset maps a JSONPath (`given`) to a function (`then`) — `schema` (JSON Schema), `casing`, `pattern`, `truthy`, and more, plus your own. The [`@amritk/lint` README](../lint#ruleset-format) documents the ruleset format and built-in functions. For machine-readable output (JSON, SARIF, …), call the [`@amritk/lint`](../lint) library's `lintDocument`, which returns structured findings.
145
+
146
+ | Flag | Description |
147
+ | --- | --- |
148
+ | `<documents..>` | Files or globs to lint (positional). |
149
+ | `-r`, `--ruleset` | Path to a ruleset file. Defaults to `.lint.*` discovery. |
150
+ | `-F`, `--fail-severity` | Minimum severity that fails the run: `error` (default), `warn`, `info`, `hint`. |
151
+ | `-D`, `--display-only-failures` | Only show findings at or above `--fail-severity`. |
152
+ | `--stdin-filepath` | Path to associate with piped input (labels findings and enables ruleset discovery). |
153
+ | `--concurrency` | Max documents linted in parallel (default `8`). |
154
+ | `-q`, `--quiet` | Suppress the findings report (the exit code still reflects findings). |
155
+
156
+ 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
+
158
+ ---
159
+
95
160
  ## Configuration reference
96
161
 
97
162
  <!-- config-table-start -->
@@ -220,7 +285,7 @@ npx mjst --config ./mjst.config.json
220
285
  <td align="center"></td>
221
286
  </tr>
222
287
  <tr>
223
- <td colspan="4">Controls how generated parsers reference their runtime helpers. 'package' emits imports from @amritk/helpers (requires it to be installed in the consumer project). 'embedded' ships the helper source under outDir/_helpers/ so the output is self-contained. When omitted, the CLI auto-detects: it picks 'package' if @amritk/helpers resolves from outDir, otherwise 'embedded'.<br><strong>Allowed:</strong> <code>"package"</code>, <code>"embedded"</code></td>
288
+ <td colspan="4">Controls how generated parsers reference their runtime helpers. 'package' emits imports from @amritk/helpers (requires it to be installed in the consumer project). 'embedded' ships the helper source under outDir/_helpers/ so the output is self-contained. When omitted, the CLI auto-detects: it picks 'package' only when @amritk/helpers is a declared dependency (dependencies/devDependencies) of the nearest package.json above outDir, otherwise 'embedded'. Requiring a declaration (rather than mere resolvability) keeps auto-detected 'package' output portable across pnpm/isolated installs, where an undeclared transitive @amritk/helpers is not reachable at runtime.<br><strong>Allowed:</strong> <code>"package"</code>, <code>"embedded"</code></td>
224
289
  </tr>
225
290
  <tr>
226
291
  <td>🏷️ <code>typeSuffix</code></td>
@@ -235,10 +300,19 @@ npx mjst --config ./mjst.config.json
235
300
  <td>🔗 <code>importExt</code></td>
236
301
  <td><code>--import-ext &lt;ext&gt;</code></td>
237
302
  <td><code>string</code></td>
238
- <td align="center"><code>"js"</code></td>
303
+ <td align="center"><code>"ts"</code></td>
304
+ </tr>
305
+ <tr>
306
+ <td colspan="4">Extension emitted on every relative import specifier in the generated output (cross-file $ref imports, the index barrel, and embedded-helper imports). 'ts' (default) emits the literal on-disk paths so the generated .ts sources load directly under Bun, Node's type stripping (Node 22.6+ with --experimental-strip-types, unflagged from 22.18/23), and tsc when the consumer sets allowImportingTsExtensions — no build step required. 'js' is the standard TS NodeNext form ('./x.js' resolving to a sibling x.ts) for output you compile; it is selected automatically under 'build' since tsc refuses to emit from .ts specifiers. Passing --import-ext ts together with --build is an error.<br><strong>Allowed:</strong> <code>"js"</code>, <code>"ts"</code></td>
307
+ </tr>
308
+ <tr>
309
+ <td>🌱 <code>rootType</code></td>
310
+ <td><code>--root-type &lt;name&gt;</code></td>
311
+ <td><code>string</code></td>
312
+ <td align="center"></td>
239
313
  </tr>
240
314
  <tr>
241
- <td colspan="4">Extension emitted on every relative import specifier in the generated output (cross-file $ref imports, the index barrel, and embedded-helper imports). 'js' (default) is the standard TS NodeNext form ('./x.js' resolving to a sibling x.ts), accepted by tsc, Bun, and bundlers, and required by 'build'. 'ts' emits the literal on-disk paths so the generated .ts sources run directly under Node's type stripping (Node 22.6+ with --experimental-strip-types, on by default from Node 23). Incompatible with 'build' — tsc refuses to emit from .ts specifiers.<br><strong>Allowed:</strong> <code>"js"</code>, <code>"ts"</code></td>
315
+ <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>
242
316
  </tr>
243
317
  <tr>
244
318
  <td>⚙️ <code>config</code></td>
@@ -247,7 +321,7 @@ npx mjst --config ./mjst.config.json
247
321
  <td align="center"></td>
248
322
  </tr>
249
323
  <tr>
250
- <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). CLI flags take precedence over config file values.<br><strong>Examples:</strong> <code>"./mjst.config.json"</code></td>
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>
251
325
  </tr>
252
326
  </tbody>
253
327
  </table>
@@ -93,13 +93,22 @@ export type CliConfig = {
93
93
  * Extension emitted on every relative import specifier in the generated
94
94
  * output (cross-file `$ref` imports, the index barrel, and embedded-helper
95
95
  * imports).
96
- * - `'js'` (default): the standard TS NodeNext form (`./x.js` resolving to a
97
- * sibling `x.ts`), accepted by tsc, Bun, and bundlers, and required by
98
- * `build`.
99
- * - `'ts'`: the literal on-disk paths, so the generated `.ts` sources run
100
- * directly under Node's type stripping (Node 22.6+ with
101
- * `--experimental-strip-types`, default from Node 23). Incompatible with
102
- * `build` — tsc refuses to emit from `.ts` specifiers.
96
+ * - `'ts'` (default): the literal on-disk paths, so the generated `.ts`
97
+ * sources load under Bun, Node's type stripping (Node 22.6+ with
98
+ * `--experimental-strip-types`, unflagged from 22.18/23), and tsc with
99
+ * `allowImportingTsExtensions`. Incompatible with `build` — tsc refuses
100
+ * to emit from `.ts` specifiers.
101
+ * - `'js'`: the standard TS NodeNext form (`./x.js` resolving to a sibling
102
+ * `x.ts`), for output that will be compiled. Used automatically when
103
+ * `build` is set and no extension was chosen explicitly.
103
104
  */
104
105
  readonly importExt?: 'js' | 'ts';
106
+ /**
107
+ * Name for the root type of a single-`schema` run (e.g. `'Program'` yields
108
+ * `parseProgram` / `validateProgramShape`). When omitted, the name is
109
+ * derived from the schema's `title`, falling back to the schema filename in
110
+ * PascalCase (`spec-plan.json` → `SpecPlan`) and then to `'Document'`.
111
+ * Not supported with `schemaDir`, where each schema names its own root.
112
+ */
113
+ readonly rootType?: string;
105
114
  };
package/dist/cli.js CHANGED
@@ -9,12 +9,33 @@ 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 { HELP_TEXT } from './help-text.js';
12
13
  import { loadConfig } from './load-config.js';
13
14
  import { loadSchemaModule } from './load-schema-module.js';
14
15
  import { parseCliArgs } from './parse-cli-args.js';
15
16
  import { readVersion } from './read-version.js';
17
+ import { resolveImportExt } from './resolve-import-ext.js';
16
18
  /** True when the args request the CLI version (`version`, `--version`, or `-v`). */
17
19
  const isVersionRequest = (args) => args[0] === 'version' || args.includes('--version') || args.includes('-v');
20
+ /** True when the args request usage help (`help`, `--help`, or `-h`). */
21
+ const isHelpRequest = (args) => args[0] === 'help' || args.includes('--help') || args.includes('-h');
22
+ /** The schema's base filename without extension, used to derive the root type name. */
23
+ const schemaBaseName = (schemaPath) => basename(schemaPath).replace(/\.[^.]+$/, '');
24
+ /**
25
+ * Resolves and reports the helpers mode. An explicit `--helpers` wins; otherwise
26
+ * it is auto-detected from the nearest package.json. When detection falls back to
27
+ * embedded, nudge the user toward declaring `@amritk/helpers` so multiple
28
+ * generated trees can share a single runtime helper copy.
29
+ */
30
+ const resolveHelpersMode = (config, outputDir) => {
31
+ const helpersMode = config.helpers ?? detectHelpersMode(outputDir);
32
+ console.log(`Helpers mode: ${helpersMode}${config.helpers ? ' (explicit)' : ' (auto-detected)'}`);
33
+ if (!config.helpers && helpersMode === 'embedded') {
34
+ console.log('Tip: add @amritk/helpers as a dependency to share one runtime helper copy across generated trees ' +
35
+ '(or pass --helpers embedded to silence this).');
36
+ }
37
+ return helpersMode;
38
+ };
18
39
  /**
19
40
  * Extracts the --config flag value from process args before full parsing.
20
41
  * We need this early so we can load the config file first, then overlay CLI flags.
@@ -128,11 +149,10 @@ const findJsonSchemas = async (dir) => {
128
149
  const runSingle = async (config, schemaPath, outputDir) => {
129
150
  const schema = await loadSchema(config, schemaPath);
130
151
  await mkdir(outputDir, { recursive: true });
131
- const helpersMode = config.helpers ?? detectHelpersMode(outputDir);
132
- console.log(`Helpers mode: ${helpersMode}${config.helpers ? ' (explicit)' : ' (auto-detected)'}`);
133
- const rootTypeName = deriveRootTypeName(schema);
152
+ const helpersMode = resolveHelpersMode(config, outputDir);
153
+ const rootTypeName = config.rootType ?? deriveRootTypeName(schema, schemaBaseName(schemaPath));
134
154
  console.log(`Root type: ${rootTypeName}`);
135
- const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, helpersMode, undefined, config.readonly, config.stripUnknown, config.typeSuffix, config.importExt);
155
+ const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, helpersMode, undefined, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config));
136
156
  for (const file of files) {
137
157
  const isHelper = file.filename.startsWith('_helpers/');
138
158
  const content = config.banner && !isHelper ? resolveBanner(config.banner) + file.content : file.content;
@@ -155,9 +175,9 @@ const runSingleFile = async (config, schemaPath, outFilePath) => {
155
175
  const schema = await loadSchema(config, schemaPath);
156
176
  const outputDir = dirname(outFilePath);
157
177
  await mkdir(outputDir, { recursive: true });
158
- const rootTypeName = deriveRootTypeName(schema);
178
+ const rootTypeName = config.rootType ?? deriveRootTypeName(schema, schemaBaseName(schemaPath));
159
179
  console.log(`Root type: ${rootTypeName}`);
160
- const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, config.helpers ?? 'package', undefined, config.readonly, config.stripUnknown, config.typeSuffix, config.importExt);
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));
161
181
  const combined = combineGeneratedFiles(files);
162
182
  await writeFile(outFilePath, config.banner ? resolveBanner(config.banner) + combined : combined, 'utf-8');
163
183
  console.log(`Generated: ${relative(process.cwd(), outFilePath)}`);
@@ -185,8 +205,7 @@ const runRecursive = async (config, schemaDir, outputDir) => {
185
205
  process.exit(1);
186
206
  }
187
207
  await mkdir(outputDir, { recursive: true });
188
- const helpersMode = config.helpers ?? detectHelpersMode(outputDir);
189
- console.log(`Helpers mode: ${helpersMode}${config.helpers ? ' (explicit)' : ' (auto-detected)'}`);
208
+ const helpersMode = resolveHelpersMode(config, outputDir);
190
209
  // Helper sources are identical across schemas, so collect them by filename and
191
210
  // write the deduplicated set once at the output root.
192
211
  const sharedHelpers = new Map();
@@ -200,9 +219,9 @@ const runRecursive = async (config, schemaDir, outputDir) => {
200
219
  const depth = relNoExt.split(sep).filter(Boolean).length;
201
220
  const helpersImportPrefix = '../'.repeat(depth);
202
221
  const schema = await loadSchema(config, schemaFile);
203
- const rootTypeName = deriveRootTypeName(schema);
222
+ const rootTypeName = deriveRootTypeName(schema, schemaBaseName(schemaFile));
204
223
  console.log(`\n${relPath} → ${relNoExt}/ (root type: ${rootTypeName})`);
205
- const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, helpersMode, helpersImportPrefix, config.readonly, config.stripUnknown, config.typeSuffix, config.importExt);
224
+ const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, helpersMode, helpersImportPrefix, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config));
206
225
  for (const file of files) {
207
226
  if (file.filename.startsWith('_helpers/')) {
208
227
  sharedHelpers.set(file.filename, file.content);
@@ -230,10 +249,28 @@ const runRecursive = async (config, schemaDir, outputDir) => {
230
249
  const run = async () => {
231
250
  // Skip the first two args (node executable and script path)
232
251
  const args = process.argv.slice(2);
252
+ // `mjst lint <files>` delegates to the linter, which owns its own flag parsing
253
+ // and `--help` (yargs) — so this must run before the shared version/help checks,
254
+ // otherwise `mjst lint --help` would print the codegen help instead.
255
+ if (args[0] === 'lint') {
256
+ const { run: lintRun } = await import('./lint/run.js');
257
+ const result = await lintRun(args.slice(1));
258
+ if (result.stdout)
259
+ process.stdout.write(result.stdout);
260
+ if (result.stderr)
261
+ process.stderr.write(result.stderr);
262
+ process.exit(result.code);
263
+ }
233
264
  if (isVersionRequest(args)) {
234
265
  console.log(await readVersion());
235
266
  return;
236
267
  }
268
+ // Bare `mjst` and `mjst --help` both print usage rather than erroring on a
269
+ // missing --out-dir — a first-run user needs to see the flags, not a failure.
270
+ if (args.length === 0 || isHelpRequest(args)) {
271
+ console.log(HELP_TEXT);
272
+ return;
273
+ }
237
274
  const configPath = extractConfigPath(args);
238
275
  // Start with config file values if provided, then overlay CLI flags on top
239
276
  const fileConfig = configPath ? await loadConfig(configPath) : {};
@@ -243,6 +280,12 @@ const run = async () => {
243
280
  console.error('Error: provide only one of --out-dir or --out-file, not both.');
244
281
  process.exit(1);
245
282
  }
283
+ // --root-type names a single root; under --schema-dir each schema derives its
284
+ // own root name, so a single override would be ambiguous.
285
+ if (config.rootType && config.schemaDir) {
286
+ console.error('Error: --root-type cannot be combined with --schema-dir; each schema derives its own root type.');
287
+ process.exit(1);
288
+ }
246
289
  // tsc cannot emit from `.ts` import specifiers (allowImportingTsExtensions
247
290
  // requires noEmit), so compiled output and stripping-ready sources are
248
291
  // mutually exclusive.
@@ -1,10 +1,15 @@
1
1
  /**
2
- * Detects whether `@amritk/helpers` is resolvable from the consumer's output
3
- * directory using standard Node module resolution (walks up `node_modules`).
2
+ * Detects whether generated parsers should import `@amritk/helpers` from the
3
+ * package (`'package'`) or ship the helper sources alongside them (`'embedded'`).
4
4
  *
5
- * Returns `'package'` when generated parsers can safely
6
- * `import { ... } from '@amritk/helpers/...'` at runtime, or `'embedded'`
7
- * when the helper sources need to be shipped alongside the generated files
8
- * for the output to be self-contained.
5
+ * We select `'package'` only when `@amritk/helpers` is a *declared* dependency
6
+ * (dependencies / devDependencies / peer / optional) of the target project's
7
+ * nearest `package.json` above `outputDir`. Merely being *resolvable* is not
8
+ * enough: under npm/bun's hoisted layouts `@amritk/helpers` can sit in
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.
9
14
  */
10
15
  export declare const detectHelpersMode: (outputDir: string) => 'package' | 'embedded';
@@ -1,21 +1,45 @@
1
- import { createRequire } from 'node:module';
2
- import { resolve } from 'node:path';
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join, parse as parsePath, resolve } from 'node:path';
3
+ /** Reads a package.json's declared dependency names, or `undefined` if unreadable. */
4
+ const readDeclaredDeps = (packageJsonPath) => {
5
+ try {
6
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
7
+ return new Set([
8
+ ...Object.keys(pkg.dependencies ?? {}),
9
+ ...Object.keys(pkg.devDependencies ?? {}),
10
+ ...Object.keys(pkg.peerDependencies ?? {}),
11
+ ...Object.keys(pkg.optionalDependencies ?? {}),
12
+ ]);
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ };
3
18
  /**
4
- * Detects whether `@amritk/helpers` is resolvable from the consumer's output
5
- * directory using standard Node module resolution (walks up `node_modules`).
19
+ * Detects whether generated parsers should import `@amritk/helpers` from the
20
+ * package (`'package'`) or ship the helper sources alongside them (`'embedded'`).
6
21
  *
7
- * Returns `'package'` when generated parsers can safely
8
- * `import { ... } from '@amritk/helpers/...'` at runtime, or `'embedded'`
9
- * when the helper sources need to be shipped alongside the generated files
10
- * for the output to be self-contained.
22
+ * We select `'package'` only when `@amritk/helpers` is a *declared* dependency
23
+ * (dependencies / devDependencies / peer / optional) of the target project's
24
+ * nearest `package.json` above `outputDir`. Merely being *resolvable* is not
25
+ * enough: under npm/bun's hoisted layouts `@amritk/helpers` can sit in
26
+ * `node_modules` purely as a transitive dependency of `@amritk/mjst`, which
27
+ * makes `import '@amritk/helpers/...'` work locally but break under
28
+ * pnpm/isolated installs where undeclared packages are not reachable. Requiring
29
+ * a declaration keeps auto-detected `'package'` output portable across install
30
+ * layouts; everything else falls back to the self-contained `'embedded'` mode.
11
31
  */
12
32
  export const detectHelpersMode = (outputDir) => {
13
- const req = createRequire(resolve(outputDir, 'noop.js'));
14
- try {
15
- req.resolve('@amritk/helpers/is-object');
16
- return 'package';
17
- }
18
- catch {
19
- return 'embedded';
33
+ let dir = resolve(outputDir);
34
+ const { root } = parsePath(dir);
35
+ while (true) {
36
+ const declared = readDeclaredDeps(join(dir, 'package.json'));
37
+ if (declared) {
38
+ return declared.has('@amritk/helpers') ? 'package' : 'embedded';
39
+ }
40
+ if (dir === root)
41
+ break;
42
+ dir = dirname(dir);
20
43
  }
44
+ return 'embedded';
21
45
  };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Usage text printed for `--help`/`-h` and when the CLI is invoked with no
3
+ * arguments. Kept as a hand-written constant because the config schema that
4
+ * documents these flags (config.schema.json) is not shipped in the published
5
+ * package; the help-text test cross-checks that every flag is listed.
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";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Usage text printed for `--help`/`-h` and when the CLI is invoked with no
3
+ * arguments. Kept as a hand-written constant because the config schema that
4
+ * documents these flags (config.schema.json) is not shipped in the published
5
+ * package; the help-text test cross-checks that every flag is listed.
6
+ */
7
+ export const HELP_TEXT = `mjst — generate TypeScript parsers and type definitions from JSON Schemas
8
+
9
+ Usage:
10
+ mjst --schema <path> --out-dir <dir> [options]
11
+ mjst --schema-dir <dir> --out-dir <dir> [options]
12
+ mjst --schema <path> --out-file <file> --types-only [options]
13
+
14
+ Input:
15
+ --schema <path> Schema to process: a JSON Schema file, or a module when --input is set
16
+ --schema-dir <dir> Directory of JSON Schemas, processed recursively (instead of --schema)
17
+ --input <format> Schema source format: json (default), typebox, zod, valibot, effect
18
+ --export <name> Which export of the schema module to use when --input is not json
19
+
20
+ Output:
21
+ --out-dir <dir> Output directory for the generated files
22
+ --out-file <file> Single-file output instead of a directory (requires --types-only)
23
+ --types-only Generate type definitions only, without parser functions
24
+ --build Compile the generated files to .js/.d.ts (implies --import-ext js)
25
+ --import-ext <ext> Extension on generated relative imports: ts (default) or js
26
+ --helpers <mode> Runtime helpers: package or embedded (default: auto-detect from package.json)
27
+ --root-type <name> Root type name for a single --schema run (default: schema title or filename)
28
+ --type-suffix <s> Suffix appended to every $ref-derived type name
29
+ --banner [text] Prepend a header comment to every generated file
30
+ --readonly Emit deeply readonly type definitions
31
+
32
+ Validation:
33
+ --strict Throw on type/shape mismatches instead of coercing to defaults
34
+ --strip-unknown Silently drop undeclared input keys at every nesting level
35
+ --log-warnings console.warn on input keys not declared in the schema
36
+
37
+ Misc:
38
+ --config <path> JSON config file with the same keys; CLI flags take precedence
39
+ --version, -v Print the CLI version
40
+ --help, -h Print this help
41
+
42
+ Docs: https://github.com/amritk/mjst/tree/main/packages/cli#readme
43
+ `;
@@ -0,0 +1,5 @@
1
+ import { type RulesetDefinition } from '@amritk/lint';
2
+ /** Loads a ruleset definition from a YAML, JSON, or JS/MJS file. */
3
+ export declare const loadRuleset: (path: string) => Promise<RulesetDefinition>;
4
+ /** Walks up from a directory looking for a `.lint.*` ruleset file. */
5
+ export declare const discoverRuleset: (startDir: string) => string | undefined;
@@ -0,0 +1,31 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { parseWithPointers } from '@amritk/lint';
6
+ const RULESET_FILENAMES = ['.lint.yaml', '.lint.yml', '.lint.json', '.lint.js', '.lint.mjs'];
7
+ /** Loads a ruleset definition from a YAML, JSON, or JS/MJS file. */
8
+ export const loadRuleset = async (path) => {
9
+ const absolute = isAbsolute(path) ? path : resolve(process.cwd(), path);
10
+ if (/\.(c|m)?js$/.test(absolute)) {
11
+ const module = await import(pathToFileURL(absolute).href);
12
+ return (module.default ?? module);
13
+ }
14
+ const content = await readFile(absolute, 'utf8');
15
+ return parseWithPointers(content).data;
16
+ };
17
+ /** Walks up from a directory looking for a `.lint.*` ruleset file. */
18
+ export const discoverRuleset = (startDir) => {
19
+ let dir = resolve(startDir);
20
+ while (true) {
21
+ for (const name of RULESET_FILENAMES) {
22
+ const candidate = join(dir, name);
23
+ if (existsSync(candidate))
24
+ return candidate;
25
+ }
26
+ const parent = dirname(dir);
27
+ if (parent === dir)
28
+ return undefined;
29
+ dir = parent;
30
+ }
31
+ };
@@ -0,0 +1,15 @@
1
+ /** The outcome of a {@link run}: exit code plus the text it would print. */
2
+ export type RunResult = {
3
+ code: number;
4
+ stdout: string;
5
+ stderr: string;
6
+ };
7
+ /**
8
+ * Runs the linter over `argv`, returning the exit code and the text it would
9
+ * print (rather than writing to the process streams) so it can be driven
10
+ * in-process by tests. `stdin` supplies the piped document when there are no file
11
+ * targets; when omitted it is read from `process.stdin`.
12
+ */
13
+ export declare const run: (argv: string[], options?: {
14
+ stdin?: string;
15
+ }) => Promise<RunResult>;
@@ -0,0 +1,176 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, resolve } from 'node:path';
3
+ import { DiagnosticSeverity, lintDocument, validateRuleset, } from '@amritk/lint';
4
+ import fg from 'fast-glob';
5
+ import yargs from 'yargs';
6
+ import { discoverRuleset, loadRuleset } from './ruleset-loader.js';
7
+ const SEVERITY_BY_NAME = {
8
+ error: DiagnosticSeverity.Error,
9
+ warn: DiagnosticSeverity.Warning,
10
+ info: DiagnosticSeverity.Information,
11
+ hint: DiagnosticSeverity.Hint,
12
+ };
13
+ const SEVERITY_LABEL = ['error', 'warning', 'info', 'hint'];
14
+ /**
15
+ * Renders findings as a compact, dependency-free report — one `file:line:col`
16
+ * line per finding (1-based, editor-clickable), then a count summary. Structured
17
+ * output (JSON, SARIF, …) is a consumer concern: use the `@amritk/lint` library's
18
+ * `lintDocument`, which returns `IDiagnostic[]`.
19
+ */
20
+ const formatReport = (findings) => {
21
+ if (findings.length === 0)
22
+ return 'No problems found\n';
23
+ const lines = findings.map((d) => {
24
+ const loc = `${d.source ?? '<stdin>'}:${d.range.start.line + 1}:${d.range.start.character + 1}`;
25
+ return `${loc} ${SEVERITY_LABEL[d.severity] ?? 'error'} ${d.code} ${d.message}`;
26
+ });
27
+ const errors = findings.filter((d) => d.severity === DiagnosticSeverity.Error).length;
28
+ const warnings = findings.filter((d) => d.severity === DiagnosticSeverity.Warning).length;
29
+ lines.push('', `✖ ${findings.length} problem(s) (${errors} error(s), ${warnings} warning(s))`);
30
+ return `${lines.join('\n')}\n`;
31
+ };
32
+ const readStdin = async () => {
33
+ const chunks = [];
34
+ for await (const chunk of process.stdin)
35
+ chunks.push(chunk);
36
+ return Buffer.concat(chunks).toString('utf8');
37
+ };
38
+ const filterBySeverity = (results, failSeverity) => results.filter((result) => result.severity <= failSeverity);
39
+ /**
40
+ * Maps `items` through `worker` with at most `limit` running at once, preserving
41
+ * input order in the result. Lets the CLI lint a directory of documents
42
+ * concurrently instead of strictly one at a time, without unbounded parallelism.
43
+ */
44
+ const mapWithConcurrency = async (items, limit, worker) => {
45
+ const results = new Array(items.length);
46
+ let next = 0;
47
+ const runners = new Array(Math.max(1, Math.min(limit, items.length))).fill(0).map(async () => {
48
+ while (true) {
49
+ const index = next++;
50
+ if (index >= items.length)
51
+ return;
52
+ results[index] = await worker(items[index]);
53
+ }
54
+ });
55
+ await Promise.all(runners);
56
+ return results;
57
+ };
58
+ /**
59
+ * Runs the linter over `argv`, returning the exit code and the text it would
60
+ * print (rather than writing to the process streams) so it can be driven
61
+ * in-process by tests. `stdin` supplies the piped document when there are no file
62
+ * targets; when omitted it is read from `process.stdin`.
63
+ */
64
+ export const run = async (argv, options = {}) => {
65
+ const out = [];
66
+ const err = [];
67
+ const parsed = (await yargs(argv)
68
+ .scriptName('lint')
69
+ .parserConfiguration({ 'greedy-arrays': false })
70
+ .usage('$0 [documents..]', 'Lint JSON/YAML documents against a ruleset')
71
+ .positional('documents', { describe: 'Documents or globs to lint', type: 'string', array: true })
72
+ .option('ruleset', { alias: 'r', type: 'string', describe: 'Path to a ruleset file' })
73
+ .option('encoding', { type: 'string', default: 'utf8', describe: 'Input encoding' })
74
+ .option('fail-severity', { alias: 'F', type: 'string', default: 'error', choices: Object.keys(SEVERITY_BY_NAME) })
75
+ .option('display-only-failures', { alias: 'D', type: 'boolean', default: false })
76
+ .option('verbose', { type: 'boolean', default: false })
77
+ .option('quiet', { alias: 'q', type: 'boolean', default: false })
78
+ .option('stdin-filepath', {
79
+ type: 'string',
80
+ describe: 'Path to associate with stdin input (labels findings and enables ruleset discovery)',
81
+ })
82
+ .option('concurrency', {
83
+ type: 'number',
84
+ default: 8,
85
+ describe: 'Maximum number of documents to lint in parallel',
86
+ })
87
+ .help()
88
+ .alias('help', 'h')
89
+ .parse());
90
+ const failSeverity = SEVERITY_BY_NAME[parsed.failSeverity] ?? DiagnosticSeverity.Error;
91
+ const reportRulesetProblems = (definition, label) => {
92
+ if (parsed.quiet)
93
+ return;
94
+ for (const problem of validateRuleset(definition)) {
95
+ const at = problem.path.length > 0 ? ` (at ${problem.path.join('.')})` : '';
96
+ err.push(`warning: ruleset ${label}: ${problem.message}${at}\n`);
97
+ }
98
+ };
99
+ const discoverAndLoad = async (dir) => {
100
+ const discovered = discoverRuleset(dir);
101
+ if (!discovered)
102
+ return undefined;
103
+ const definition = await loadRuleset(discovered);
104
+ reportRulesetProblems(definition, discovered);
105
+ return { definition, basePath: dirname(discovered) };
106
+ };
107
+ let rulesetDefinition;
108
+ let rulesetBasePath;
109
+ if (parsed.ruleset) {
110
+ rulesetDefinition = await loadRuleset(parsed.ruleset);
111
+ rulesetBasePath = dirname(isAbsolute(parsed.ruleset) ? parsed.ruleset : resolve(process.cwd(), parsed.ruleset));
112
+ reportRulesetProblems(rulesetDefinition, parsed.ruleset);
113
+ }
114
+ const targets = await fg(parsed.documents ?? [], { dot: true, onlyFiles: true });
115
+ const allResults = [];
116
+ if (targets.length === 0) {
117
+ if (options.stdin === undefined && process.stdin.isTTY) {
118
+ return { code: 2, stdout: '', stderr: 'No documents provided.\n' };
119
+ }
120
+ const content = options.stdin ?? (await readStdin());
121
+ // With --stdin-filepath, label findings with that path and discover a
122
+ // `.lint.*` ruleset by walking up from its directory.
123
+ const stdinPath = parsed.stdinFilepath;
124
+ let definition = rulesetDefinition;
125
+ let basePath = rulesetBasePath;
126
+ if (!definition && !parsed.ruleset && stdinPath) {
127
+ const discovered = await discoverAndLoad(dirname(stdinPath));
128
+ if (discovered)
129
+ ({ definition, basePath } = discovered);
130
+ }
131
+ const opts = {
132
+ ...(definition ? { ruleset: definition } : {}),
133
+ ...(basePath !== undefined ? { rulesetBasePath: basePath } : {}),
134
+ source: stdinPath ?? '<stdin>',
135
+ };
136
+ allResults.push(...(await lintDocument(content, opts)));
137
+ }
138
+ else {
139
+ // Cache discovered/loaded rulesets by directory so a directory of documents
140
+ // that share a `.lint.*` file parses it once, not once per file.
141
+ const rulesetCache = new Map();
142
+ const perFile = await mapWithConcurrency(targets, parsed.concurrency, async (file) => {
143
+ const content = await readFile(file, parsed.encoding);
144
+ let definition = rulesetDefinition;
145
+ let basePath = rulesetBasePath;
146
+ if (!definition && !parsed.ruleset) {
147
+ const dir = dirname(file);
148
+ let discovered = rulesetCache.get(dir);
149
+ if (!rulesetCache.has(dir)) {
150
+ discovered = await discoverAndLoad(dir);
151
+ rulesetCache.set(dir, discovered);
152
+ }
153
+ if (discovered)
154
+ ({ definition, basePath } = discovered);
155
+ }
156
+ const opts = definition
157
+ ? {
158
+ ruleset: definition,
159
+ ...(basePath !== undefined ? { rulesetBasePath: basePath } : {}),
160
+ source: file,
161
+ }
162
+ : { source: file };
163
+ return lintDocument(content, opts);
164
+ });
165
+ for (const remaining of perFile)
166
+ allResults.push(...remaining);
167
+ }
168
+ const displayed = parsed.displayOnlyFailures ? filterBySeverity(allResults, failSeverity) : allResults;
169
+ if (!parsed.quiet)
170
+ out.push(formatReport(displayed));
171
+ return {
172
+ code: filterBySeverity(allResults, failSeverity).length > 0 ? 1 : 0,
173
+ stdout: out.join(''),
174
+ stderr: err.join(''),
175
+ };
176
+ };
@@ -31,6 +31,7 @@ export const loadConfig = async (configPath) => {
31
31
  ...(typeof obj['readonly'] === 'boolean' && { readonly: obj['readonly'] }),
32
32
  ...(isHelpersMode(obj['helpers']) && { helpers: obj['helpers'] }),
33
33
  ...(isImportExt(obj['importExt']) && { importExt: obj['importExt'] }),
34
+ ...(typeof obj['rootType'] === 'string' && { rootType: obj['rootType'] }),
34
35
  ...(typeof obj['typeSuffix'] === 'string' && { typeSuffix: obj['typeSuffix'] }),
35
36
  // `banner` is a boolean toggle or a custom header string, mirroring the CLI flag.
36
37
  ...((typeof obj['banner'] === 'boolean' || typeof obj['banner'] === 'string') && { banner: obj['banner'] }),
@@ -19,6 +19,7 @@ const VALUE_KEYS = new Set([
19
19
  'helpers',
20
20
  'typeSuffix',
21
21
  'importExt',
22
+ 'rootType',
22
23
  ]);
23
24
  // Recognized flags that don't map into CliConfig because they're consumed
24
25
  // earlier in the pipeline (e.g. `--config` is read by `extractConfigPath` to
@@ -59,6 +60,9 @@ const assignValue = (config, key, value) => {
59
60
  case 'typeSuffix':
60
61
  config.typeSuffix = value;
61
62
  return true;
63
+ case 'rootType':
64
+ config.rootType = value;
65
+ return true;
62
66
  case 'input': {
63
67
  const parsed = parseInputValue(value);
64
68
  if (!parsed) {
@@ -0,0 +1,10 @@
1
+ import type { CliConfig } from './cli-config.js';
2
+ /**
3
+ * Resolves the effective extension for relative import specifiers in the
4
+ * generated output. An explicit `importExt` (flag or config file) always wins.
5
+ * Otherwise the default is `'ts'` — the literal on-disk paths, loadable by
6
+ * Bun, Node's type stripping, and tsc with `allowImportingTsExtensions` —
7
+ * except under `build`, where tsc must compile the sources and can only emit
8
+ * from `'js'` specifiers.
9
+ */
10
+ export declare const resolveImportExt: (config: Partial<CliConfig>) => 'js' | 'ts';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Resolves the effective extension for relative import specifiers in the
3
+ * generated output. An explicit `importExt` (flag or config file) always wins.
4
+ * Otherwise the default is `'ts'` — the literal on-disk paths, loadable by
5
+ * Bun, Node's type stripping, and tsc with `allowImportingTsExtensions` —
6
+ * except under `build`, where tsc must compile the sources and can only emit
7
+ * from `'js'` specifiers.
8
+ */
9
+ export const resolveImportExt = (config) => config.importExt ?? (config.build ? 'js' : 'ts');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/mjst",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,12 +41,16 @@
41
41
  "generate-readme": "bun run ./scripts/generate-readme.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@amritk/adapters": "0.2.15",
45
- "@amritk/generate-parsers": "0.14.0",
44
+ "@amritk/adapters": "0.2.16",
45
+ "@amritk/generate-parsers": "0.15.0",
46
46
  "@amritk/generate-markdown": "0.4.1",
47
- "@amritk/helpers": "0.11.0"
47
+ "@amritk/helpers": "0.12.0",
48
+ "@amritk/lint": "0.1.0",
49
+ "fast-glob": "^3.3.2",
50
+ "yargs": "^17.7.2"
48
51
  },
49
52
  "devDependencies": {
50
- "json-schema-typed": "^8.0.1"
53
+ "json-schema-typed": "^8.0.1",
54
+ "@types/yargs": "^17.0.33"
51
55
  }
52
56
  }