@amritk/mjst 0.10.0 → 0.11.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
@@ -151,8 +151,14 @@ With no `-r`, mjst discovers a `.lint.{yaml,yml,json,js,mjs}` ruleset by walking
151
151
  | `-D`, `--display-only-failures` | Only show findings at or above `--fail-severity`. |
152
152
  | `--stdin-filepath` | Path to associate with piped input (labels findings and enables ruleset discovery). |
153
153
  | `--concurrency` | Max documents linted in parallel (default `8`). |
154
+ | `--no-resolve` | Skip `$ref` dereferencing; rules see the raw document. |
155
+ | `--resolve-remote` | Fetch `http(s)` `$ref`s while resolving (off by default; a lint run stays offline). |
156
+ | `--allowed-hosts` | Restrict remote `$ref` fetches to these hosts (implies `--resolve-remote`). |
157
+ | `--allow-private-hosts` | Permit remote `$ref`s to private/loopback hosts (SSRF guard, off by default). |
154
158
  | `-q`, `--quiet` | Suppress the findings report (the exit code still reflects findings). |
155
159
 
160
+ By default `mjst lint` dereferences `$ref`, `$dynamicRef`/`$dynamicAnchor`, and `$recursiveRef`/`$recursiveAnchor` before running rules, so rules with `resolved: true` (the ruleset default) see through references. Internal and local cross-file refs resolve from disk — a finding on a cross-file node reports that file's own `line:column` — while remote refs are only fetched when you opt in with `--resolve-remote` or `--allowed-hosts`.
161
+
156
162
  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
163
 
158
164
  ---
@@ -270,6 +276,15 @@ The exit code is `0` when no finding reaches `--fail-severity`, `1` when one doe
270
276
  <td colspan="4">Build each parser's result from the schema's declared properties only, silently dropping undeclared input keys at every nesting level (zod's .strip()). Extras are never a validation error, so this composes with strict (which still throws on wrong types and missing required properties) and yields to additionalProperties: false, which rejects rather than strips in strict mode.</td>
271
277
  </tr>
272
278
  <tr>
279
+ <td>🔡 <code>caseInsensitive</code></td>
280
+ <td><code>--case-insensitive</code></td>
281
+ <td><code>boolean</code></td>
282
+ <td align="center"><code>false</code></td>
283
+ </tr>
284
+ <tr>
285
+ <td colspan="4">Normalize a mis-cased string to the exact casing of a declared enum/const member it matches case-insensitively (e.g. hElLo → hello) instead of coercing to the default. Coerce mode only — strict parsers still reject a casing mismatch. Correctly-cased input keeps the exact-match fast path, so the hot path is unaffected.</td>
286
+ </tr>
287
+ <tr>
273
288
  <td>🔒 <code>readonly</code></td>
274
289
  <td><code>--readonly</code></td>
275
290
  <td><code>boolean</code></td>
@@ -61,6 +61,15 @@ export type CliConfig = {
61
61
  * rather than strips in strict mode.
62
62
  */
63
63
  readonly stripUnknown?: boolean;
64
+ /**
65
+ * When true, the generated coercing parsers normalize a mis-cased string to
66
+ * the exact casing of a declared `enum`/`const` member it matches
67
+ * case-insensitively (e.g. `hElLo` → `hello`) instead of coercing to the
68
+ * default. Coerce mode only — `strict` parsers still reject a casing
69
+ * mismatch. Correctly-cased input keeps the exact-match fast path, so the hot
70
+ * path is unaffected.
71
+ */
72
+ readonly caseInsensitive?: boolean;
64
73
  /**
65
74
  * Controls how generated parsers reference their runtime helpers.
66
75
  * - `'package'`: emit `import ... from '@amritk/helpers/...'`.
package/dist/cli.js CHANGED
@@ -152,7 +152,7 @@ const runSingle = async (config, schemaPath, outputDir) => {
152
152
  const helpersMode = resolveHelpersMode(config, outputDir);
153
153
  const rootTypeName = config.rootType ?? deriveRootTypeName(schema, schemaBaseName(schemaPath));
154
154
  console.log(`Root type: ${rootTypeName}`);
155
- const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, helpersMode, undefined, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config));
155
+ const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, helpersMode, undefined, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive);
156
156
  for (const file of files) {
157
157
  const isHelper = file.filename.startsWith('_helpers/');
158
158
  const content = config.banner && !isHelper ? resolveBanner(config.banner) + file.content : file.content;
@@ -177,7 +177,7 @@ const runSingleFile = async (config, schemaPath, outFilePath) => {
177
177
  await mkdir(outputDir, { recursive: true });
178
178
  const rootTypeName = config.rootType ?? deriveRootTypeName(schema, schemaBaseName(schemaPath));
179
179
  console.log(`Root type: ${rootTypeName}`);
180
- const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, config.helpers ?? 'package', undefined, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config));
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), config.caseInsensitive);
181
181
  const combined = combineGeneratedFiles(files);
182
182
  await writeFile(outFilePath, config.banner ? resolveBanner(config.banner) + combined : combined, 'utf-8');
183
183
  console.log(`Generated: ${relative(process.cwd(), outFilePath)}`);
@@ -221,7 +221,7 @@ const runRecursive = async (config, schemaDir, outputDir) => {
221
221
  const schema = await loadSchema(config, schemaFile);
222
222
  const rootTypeName = deriveRootTypeName(schema, schemaBaseName(schemaFile));
223
223
  console.log(`\n${relPath} → ${relNoExt}/ (root type: ${rootTypeName})`);
224
- const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, helpersMode, helpersImportPrefix, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config));
224
+ const files = await buildSchema(schema, rootTypeName, undefined, config.typesOnly, config.logWarnings, config.strict, helpersMode, helpersImportPrefix, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive);
225
225
  for (const file of files) {
226
226
  if (file.filename.startsWith('_helpers/')) {
227
227
  sharedHelpers.set(file.filename, file.content);
@@ -4,4 +4,4 @@
4
4
  * documents these flags (config.schema.json) is not shipped in the published
5
5
  * package; the help-text test cross-checks that every flag is listed.
6
6
  */
7
- export declare const HELP_TEXT = "mjst \u2014 generate TypeScript parsers and type definitions from JSON Schemas\n\nUsage:\n mjst --schema <path> --out-dir <dir> [options]\n mjst --schema-dir <dir> --out-dir <dir> [options]\n mjst --schema <path> --out-file <file> --types-only [options]\n\nInput:\n --schema <path> Schema to process: a JSON Schema file, or a module when --input is set\n --schema-dir <dir> Directory of JSON Schemas, processed recursively (instead of --schema)\n --input <format> Schema source format: json (default), typebox, zod, valibot, effect\n --export <name> Which export of the schema module to use when --input is not json\n\nOutput:\n --out-dir <dir> Output directory for the generated files\n --out-file <file> Single-file output instead of a directory (requires --types-only)\n --types-only Generate type definitions only, without parser functions\n --build Compile the generated files to .js/.d.ts (implies --import-ext js)\n --import-ext <ext> Extension on generated relative imports: ts (default) or js\n --helpers <mode> Runtime helpers: package or embedded (default: auto-detect from package.json)\n --root-type <name> Root type name for a single --schema run (default: schema title or filename)\n --type-suffix <s> Suffix appended to every $ref-derived type name\n --banner [text] Prepend a header comment to every generated file\n --readonly Emit deeply readonly type definitions\n\nValidation:\n --strict Throw on type/shape mismatches instead of coercing to defaults\n --strip-unknown Silently drop undeclared input keys at every nesting level\n --log-warnings console.warn on input keys not declared in the schema\n\nMisc:\n --config <path> JSON config file with the same keys; CLI flags take precedence\n --version, -v Print the CLI version\n --help, -h Print this help\n\nDocs: https://github.com/amritk/mjst/tree/main/packages/cli#readme\n";
7
+ export declare const HELP_TEXT = "mjst \u2014 generate TypeScript parsers and type definitions from JSON Schemas\n\nUsage:\n mjst --schema <path> --out-dir <dir> [options]\n mjst --schema-dir <dir> --out-dir <dir> [options]\n mjst --schema <path> --out-file <file> --types-only [options]\n\nInput:\n --schema <path> Schema to process: a JSON Schema file, or a module when --input is set\n --schema-dir <dir> Directory of JSON Schemas, processed recursively (instead of --schema)\n --input <format> Schema source format: json (default), typebox, zod, valibot, effect\n --export <name> Which export of the schema module to use when --input is not json\n\nOutput:\n --out-dir <dir> Output directory for the generated files\n --out-file <file> Single-file output instead of a directory (requires --types-only)\n --types-only Generate type definitions only, without parser functions\n --build Compile the generated files to .js/.d.ts (implies --import-ext js)\n --import-ext <ext> Extension on generated relative imports: ts (default) or js\n --helpers <mode> Runtime helpers: package or embedded (default: auto-detect from package.json)\n --root-type <name> Root type name for a single --schema run (default: schema title or filename)\n --type-suffix <s> Suffix appended to every $ref-derived type name\n --banner [text] Prepend a header comment to every generated file\n --readonly Emit deeply readonly type definitions\n\nValidation:\n --strict Throw on type/shape mismatches instead of coercing to defaults\n --strip-unknown Silently drop undeclared input keys at every nesting level\n --case-insensitive Normalize mis-cased enum/const strings to their exact casing\n --log-warnings console.warn on input keys not declared in the schema\n\nMisc:\n --config <path> JSON config file with the same keys; CLI flags take precedence\n --version, -v Print the CLI version\n --help, -h Print this help\n\nDocs: https://github.com/amritk/mjst/tree/main/packages/cli#readme\n";
package/dist/help-text.js CHANGED
@@ -32,6 +32,7 @@ Output:
32
32
  Validation:
33
33
  --strict Throw on type/shape mismatches instead of coercing to defaults
34
34
  --strip-unknown Silently drop undeclared input keys at every nesting level
35
+ --case-insensitive Normalize mis-cased enum/const strings to their exact casing
35
36
  --log-warnings console.warn on input keys not declared in the schema
36
37
 
37
38
  Misc:
@@ -0,0 +1,24 @@
1
+ import { type LintResolver } from '@amritk/lint';
2
+ /** How `mjst lint` dereferences `$ref` (and `$dynamicRef`/`$recursiveRef`). */
3
+ export type ResolverOptions = {
4
+ /**
5
+ * Fetch http(s) `$ref`s. Defaults to `false`: a lint run should not make
6
+ * network calls unless asked. A non-empty `allowedHosts` implies `remote`.
7
+ */
8
+ remote?: boolean;
9
+ /** If set, only these hosts may be fetched (and each bypasses the private-host guard). */
10
+ allowedHosts?: string[];
11
+ /** Permit remote refs to private/loopback/link-local hosts (off by default as an SSRF guard). */
12
+ allowPrivateHosts?: boolean;
13
+ };
14
+ /**
15
+ * Builds a {@link LintResolver} backed by `@amritk/resolve-refs`, dereferencing
16
+ * `$ref`, `$dynamicRef`/`$dynamicAnchor`, and `$recursiveRef`/`$recursiveAnchor`
17
+ * so rules with `resolved: true` (the ruleset default) see through references.
18
+ *
19
+ * Internal-only documents resolve in memory. A document with cross-file/remote
20
+ * refs is resolved from disk **only when its source file reads back exactly as
21
+ * the linted input** — so a piped document (`--stdin-filepath`) never resolves
22
+ * against a different on-disk file — with remote fetching gated by `options`.
23
+ */
24
+ export declare const createLintResolver: (options?: ResolverOptions) => LintResolver;
@@ -0,0 +1,115 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve as resolvePath } from 'node:path';
3
+ import { createDocument, resolveSourceOriginFromMap, } from '@amritk/lint';
4
+ import { resolveRefs, resolveRefsFromFile } from '@amritk/resolve-refs';
5
+ import { parse as parseYaml } from '@amritk/yaml';
6
+ const isRemote = (location) => /^https?:\/\//i.test(location);
7
+ /**
8
+ * Parses a referenced document by extension: YAML for `.yaml`/`.yml`, JSON for
9
+ * `.json`, and for anything else (extensionless, remote) tries JSON first, then
10
+ * YAML. YAML is a JSON superset, so this accepts every document the linter does.
11
+ */
12
+ const parseDoc = (content, location) => {
13
+ if (/\.ya?ml$/i.test(location))
14
+ return parseYaml(content);
15
+ if (/\.json$/i.test(location))
16
+ return JSON.parse(content);
17
+ try {
18
+ return JSON.parse(content);
19
+ }
20
+ catch {
21
+ return parseYaml(content);
22
+ }
23
+ };
24
+ /** The reference keywords whose value may point at another document (a non-`#` target). */
25
+ const REF_KEYWORDS = ['$ref', '$dynamicRef', '$recursiveRef'];
26
+ /**
27
+ * Whether `data` contains a reference into another document (a `$ref` value that
28
+ * is not a same-document `#...` fragment). Internal-only documents resolve fully
29
+ * in memory, so we avoid the disk round-trip unless an external target exists.
30
+ */
31
+ const hasExternalRefs = (data) => {
32
+ if (data === null || typeof data !== 'object')
33
+ return false;
34
+ if (Array.isArray(data))
35
+ return data.some(hasExternalRefs);
36
+ const obj = data;
37
+ for (const keyword of REF_KEYWORDS) {
38
+ const value = obj[keyword];
39
+ if (typeof value === 'string' && !value.startsWith('#'))
40
+ return true;
41
+ }
42
+ return Object.values(obj).some(hasExternalRefs);
43
+ };
44
+ /** Whether the file at `absolute` reads back byte-for-byte as `input` (the document we linted). */
45
+ const readsBackAs = (absolute, input) => {
46
+ try {
47
+ return readFileSync(absolute, 'utf8') === input;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ };
53
+ /**
54
+ * Builds the {@link ISourceSet} the runner uses to map a finding on a resolved
55
+ * (dereferenced) node back to the file it came from. Origins from the resolver
56
+ * attribute each inlined node to its source document + path; `get` lazily parses
57
+ * each source file (with a position map) so findings on cross-file nodes report
58
+ * that file's own `line:column`. The root document is already parsed by the
59
+ * linter, so it is reused directly. Remote documents are not re-fetched here, so
60
+ * a finding inlined from a URL falls back to the root document's position.
61
+ */
62
+ const buildSourceSet = (rootDocument, resolved, origins, rootLocation) => {
63
+ const documents = new Map([[rootLocation, rootDocument]]);
64
+ const originMap = origins ?? new Map();
65
+ const load = (location) => {
66
+ if (documents.has(location))
67
+ return documents.get(location);
68
+ let document;
69
+ if (location !== '' && !isRemote(location)) {
70
+ try {
71
+ document = createDocument(readFileSync(location, 'utf8'), { source: location });
72
+ }
73
+ catch {
74
+ document = undefined;
75
+ }
76
+ }
77
+ documents.set(location, document);
78
+ return document;
79
+ };
80
+ return {
81
+ get: load,
82
+ origin: (path) => resolveSourceOriginFromMap(resolved, originMap, rootLocation, path),
83
+ };
84
+ };
85
+ /**
86
+ * Builds a {@link LintResolver} backed by `@amritk/resolve-refs`, dereferencing
87
+ * `$ref`, `$dynamicRef`/`$dynamicAnchor`, and `$recursiveRef`/`$recursiveAnchor`
88
+ * so rules with `resolved: true` (the ruleset default) see through references.
89
+ *
90
+ * Internal-only documents resolve in memory. A document with cross-file/remote
91
+ * refs is resolved from disk **only when its source file reads back exactly as
92
+ * the linted input** — so a piped document (`--stdin-filepath`) never resolves
93
+ * against a different on-disk file — with remote fetching gated by `options`.
94
+ */
95
+ export const createLintResolver = (options = {}) => {
96
+ const fromFileOptions = {
97
+ remote: options.remote ?? false,
98
+ ...(options.allowedHosts ? { allowedHosts: options.allowedHosts } : {}),
99
+ ...(options.allowPrivateHosts ? { allowPrivateHosts: options.allowPrivateHosts } : {}),
100
+ parse: parseDoc,
101
+ trackOrigins: true,
102
+ };
103
+ return async (document, { input }) => {
104
+ const { source } = document;
105
+ if (source && hasExternalRefs(document.data)) {
106
+ const absolute = resolvePath(source);
107
+ if (readsBackAs(absolute, input)) {
108
+ const { resolved, origins } = await resolveRefsFromFile(absolute, fromFileOptions);
109
+ return { resolved, sources: buildSourceSet(document, resolved, origins, absolute) };
110
+ }
111
+ }
112
+ const { resolved, origins } = resolveRefs(document.data, { trackOrigins: true });
113
+ return { resolved, sources: buildSourceSet(document, resolved, origins, '') };
114
+ };
115
+ };
package/dist/lint/run.js CHANGED
@@ -3,6 +3,7 @@ import { dirname, isAbsolute, resolve } from 'node:path';
3
3
  import { DiagnosticSeverity, lintDocument, validateRuleset, } from '@amritk/lint';
4
4
  import fg from 'fast-glob';
5
5
  import yargs from 'yargs';
6
+ import { createLintResolver } from './resolver.js';
6
7
  import { discoverRuleset, loadRuleset } from './ruleset-loader.js';
7
8
  const SEVERITY_BY_NAME = {
8
9
  error: DiagnosticSeverity.Error,
@@ -83,11 +84,40 @@ export const run = async (argv, options = {}) => {
83
84
  type: 'number',
84
85
  default: 8,
85
86
  describe: 'Maximum number of documents to lint in parallel',
87
+ })
88
+ .option('resolve', {
89
+ type: 'boolean',
90
+ default: true,
91
+ describe: 'Dereference $ref / $dynamicRef / $recursiveRef before linting (use --no-resolve to disable)',
92
+ })
93
+ .option('resolve-remote', {
94
+ type: 'boolean',
95
+ default: false,
96
+ describe: 'Allow fetching http(s) $refs while resolving (off by default; a lint run stays offline)',
97
+ })
98
+ .option('allowed-hosts', {
99
+ type: 'string',
100
+ array: true,
101
+ describe: 'Restrict remote $ref fetches to these hosts (implies --resolve-remote)',
102
+ })
103
+ .option('allow-private-hosts', {
104
+ type: 'boolean',
105
+ default: false,
106
+ describe: 'Permit remote $refs to private/loopback hosts (SSRF guard, off by default)',
86
107
  })
87
108
  .help()
88
109
  .alias('help', 'h')
89
110
  .parse());
90
111
  const failSeverity = SEVERITY_BY_NAME[parsed.failSeverity] ?? DiagnosticSeverity.Error;
112
+ // A non-empty --allowed-hosts is an explicit opt-in to remote fetching.
113
+ const allowRemote = parsed.resolveRemote || (parsed.allowedHosts?.length ?? 0) > 0;
114
+ const resolver = parsed.resolve
115
+ ? createLintResolver({
116
+ remote: allowRemote,
117
+ ...(parsed.allowedHosts ? { allowedHosts: parsed.allowedHosts } : {}),
118
+ allowPrivateHosts: parsed.allowPrivateHosts,
119
+ })
120
+ : undefined;
91
121
  const reportRulesetProblems = (definition, label) => {
92
122
  if (parsed.quiet)
93
123
  return;
@@ -131,6 +161,7 @@ export const run = async (argv, options = {}) => {
131
161
  const opts = {
132
162
  ...(definition ? { ruleset: definition } : {}),
133
163
  ...(basePath !== undefined ? { rulesetBasePath: basePath } : {}),
164
+ ...(resolver && definition ? { resolve: resolver } : {}),
134
165
  source: stdinPath ?? '<stdin>',
135
166
  };
136
167
  allResults.push(...(await lintDocument(content, opts)));
@@ -157,6 +188,7 @@ export const run = async (argv, options = {}) => {
157
188
  ? {
158
189
  ruleset: definition,
159
190
  ...(basePath !== undefined ? { rulesetBasePath: basePath } : {}),
191
+ ...(resolver ? { resolve: resolver } : {}),
160
192
  source: file,
161
193
  }
162
194
  : { source: file };
@@ -28,6 +28,7 @@ export const loadConfig = async (configPath) => {
28
28
  ...(typeof obj['logWarnings'] === 'boolean' && { logWarnings: obj['logWarnings'] }),
29
29
  ...(typeof obj['strict'] === 'boolean' && { strict: obj['strict'] }),
30
30
  ...(typeof obj['stripUnknown'] === 'boolean' && { stripUnknown: obj['stripUnknown'] }),
31
+ ...(typeof obj['caseInsensitive'] === 'boolean' && { caseInsensitive: obj['caseInsensitive'] }),
31
32
  ...(typeof obj['readonly'] === 'boolean' && { readonly: obj['readonly'] }),
32
33
  ...(isHelpersMode(obj['helpers']) && { helpers: obj['helpers'] }),
33
34
  ...(isImportExt(obj['importExt']) && { importExt: obj['importExt'] }),
@@ -6,6 +6,7 @@ const BOOLEAN_KEYS = new Set([
6
6
  'logWarnings',
7
7
  'strict',
8
8
  'stripUnknown',
9
+ 'caseInsensitive',
9
10
  'readonly',
10
11
  ]);
11
12
  // Value flags consume the following argument (or `--flag=value`).
@@ -109,6 +110,9 @@ const assignBoolean = (config, key, value) => {
109
110
  case 'stripUnknown':
110
111
  config.stripUnknown = value;
111
112
  return true;
113
+ case 'caseInsensitive':
114
+ config.caseInsensitive = value;
115
+ return true;
112
116
  case 'readonly':
113
117
  config.readonly = value;
114
118
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/mjst",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,10 +42,12 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@amritk/adapters": "0.2.16",
45
- "@amritk/generate-parsers": "0.15.0",
45
+ "@amritk/generate-parsers": "0.16.0",
46
46
  "@amritk/generate-markdown": "0.4.1",
47
47
  "@amritk/helpers": "0.12.0",
48
- "@amritk/lint": "0.1.0",
48
+ "@amritk/lint": "0.2.0",
49
+ "@amritk/resolve-refs": "0.3.0",
50
+ "@amritk/yaml": "0.2.3",
49
51
  "fast-glob": "^3.3.2",
50
52
  "yargs": "^17.7.2"
51
53
  },