@amritk/mjst 0.9.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 +56 -0
- package/dist/cli-config.d.ts +9 -0
- package/dist/cli.js +15 -3
- package/dist/help-text.d.ts +1 -1
- package/dist/help-text.js +1 -0
- package/dist/lint/resolver.d.ts +24 -0
- package/dist/lint/resolver.js +115 -0
- package/dist/lint/ruleset-loader.d.ts +5 -0
- package/dist/lint/ruleset-loader.js +31 -0
- package/dist/lint/run.d.ts +15 -0
- package/dist/lint/run.js +208 -0
- package/dist/load-config.js +1 -0
- package/dist/parse-cli-args.js +4 -0
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -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
|
|
@@ -116,6 +118,51 @@ npx mjst --config ./mjst.config.json
|
|
|
116
118
|
|
|
117
119
|
---
|
|
118
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
|
+
| `--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). |
|
|
158
|
+
| `-q`, `--quiet` | Suppress the findings report (the exit code still reflects findings). |
|
|
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
|
+
|
|
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.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
119
166
|
## Configuration reference
|
|
120
167
|
|
|
121
168
|
<!-- config-table-start -->
|
|
@@ -229,6 +276,15 @@ npx mjst --config ./mjst.config.json
|
|
|
229
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>
|
|
230
277
|
</tr>
|
|
231
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>
|
|
232
288
|
<td>🔒 <code>readonly</code></td>
|
|
233
289
|
<td><code>--readonly</code></td>
|
|
234
290
|
<td><code>boolean</code></td>
|
package/dist/cli-config.d.ts
CHANGED
|
@@ -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);
|
|
@@ -249,6 +249,18 @@ const runRecursive = async (config, schemaDir, outputDir) => {
|
|
|
249
249
|
const run = async () => {
|
|
250
250
|
// Skip the first two args (node executable and script path)
|
|
251
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
|
+
}
|
|
252
264
|
if (isVersionRequest(args)) {
|
|
253
265
|
console.log(await readVersion());
|
|
254
266
|
return;
|
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\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
|
+
};
|
|
@@ -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>;
|
package/dist/lint/run.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
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 { createLintResolver } from './resolver.js';
|
|
7
|
+
import { discoverRuleset, loadRuleset } from './ruleset-loader.js';
|
|
8
|
+
const SEVERITY_BY_NAME = {
|
|
9
|
+
error: DiagnosticSeverity.Error,
|
|
10
|
+
warn: DiagnosticSeverity.Warning,
|
|
11
|
+
info: DiagnosticSeverity.Information,
|
|
12
|
+
hint: DiagnosticSeverity.Hint,
|
|
13
|
+
};
|
|
14
|
+
const SEVERITY_LABEL = ['error', 'warning', 'info', 'hint'];
|
|
15
|
+
/**
|
|
16
|
+
* Renders findings as a compact, dependency-free report — one `file:line:col`
|
|
17
|
+
* line per finding (1-based, editor-clickable), then a count summary. Structured
|
|
18
|
+
* output (JSON, SARIF, …) is a consumer concern: use the `@amritk/lint` library's
|
|
19
|
+
* `lintDocument`, which returns `IDiagnostic[]`.
|
|
20
|
+
*/
|
|
21
|
+
const formatReport = (findings) => {
|
|
22
|
+
if (findings.length === 0)
|
|
23
|
+
return 'No problems found\n';
|
|
24
|
+
const lines = findings.map((d) => {
|
|
25
|
+
const loc = `${d.source ?? '<stdin>'}:${d.range.start.line + 1}:${d.range.start.character + 1}`;
|
|
26
|
+
return `${loc} ${SEVERITY_LABEL[d.severity] ?? 'error'} ${d.code} ${d.message}`;
|
|
27
|
+
});
|
|
28
|
+
const errors = findings.filter((d) => d.severity === DiagnosticSeverity.Error).length;
|
|
29
|
+
const warnings = findings.filter((d) => d.severity === DiagnosticSeverity.Warning).length;
|
|
30
|
+
lines.push('', `✖ ${findings.length} problem(s) (${errors} error(s), ${warnings} warning(s))`);
|
|
31
|
+
return `${lines.join('\n')}\n`;
|
|
32
|
+
};
|
|
33
|
+
const readStdin = async () => {
|
|
34
|
+
const chunks = [];
|
|
35
|
+
for await (const chunk of process.stdin)
|
|
36
|
+
chunks.push(chunk);
|
|
37
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
38
|
+
};
|
|
39
|
+
const filterBySeverity = (results, failSeverity) => results.filter((result) => result.severity <= failSeverity);
|
|
40
|
+
/**
|
|
41
|
+
* Maps `items` through `worker` with at most `limit` running at once, preserving
|
|
42
|
+
* input order in the result. Lets the CLI lint a directory of documents
|
|
43
|
+
* concurrently instead of strictly one at a time, without unbounded parallelism.
|
|
44
|
+
*/
|
|
45
|
+
const mapWithConcurrency = async (items, limit, worker) => {
|
|
46
|
+
const results = new Array(items.length);
|
|
47
|
+
let next = 0;
|
|
48
|
+
const runners = new Array(Math.max(1, Math.min(limit, items.length))).fill(0).map(async () => {
|
|
49
|
+
while (true) {
|
|
50
|
+
const index = next++;
|
|
51
|
+
if (index >= items.length)
|
|
52
|
+
return;
|
|
53
|
+
results[index] = await worker(items[index]);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
await Promise.all(runners);
|
|
57
|
+
return results;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Runs the linter over `argv`, returning the exit code and the text it would
|
|
61
|
+
* print (rather than writing to the process streams) so it can be driven
|
|
62
|
+
* in-process by tests. `stdin` supplies the piped document when there are no file
|
|
63
|
+
* targets; when omitted it is read from `process.stdin`.
|
|
64
|
+
*/
|
|
65
|
+
export const run = async (argv, options = {}) => {
|
|
66
|
+
const out = [];
|
|
67
|
+
const err = [];
|
|
68
|
+
const parsed = (await yargs(argv)
|
|
69
|
+
.scriptName('lint')
|
|
70
|
+
.parserConfiguration({ 'greedy-arrays': false })
|
|
71
|
+
.usage('$0 [documents..]', 'Lint JSON/YAML documents against a ruleset')
|
|
72
|
+
.positional('documents', { describe: 'Documents or globs to lint', type: 'string', array: true })
|
|
73
|
+
.option('ruleset', { alias: 'r', type: 'string', describe: 'Path to a ruleset file' })
|
|
74
|
+
.option('encoding', { type: 'string', default: 'utf8', describe: 'Input encoding' })
|
|
75
|
+
.option('fail-severity', { alias: 'F', type: 'string', default: 'error', choices: Object.keys(SEVERITY_BY_NAME) })
|
|
76
|
+
.option('display-only-failures', { alias: 'D', type: 'boolean', default: false })
|
|
77
|
+
.option('verbose', { type: 'boolean', default: false })
|
|
78
|
+
.option('quiet', { alias: 'q', type: 'boolean', default: false })
|
|
79
|
+
.option('stdin-filepath', {
|
|
80
|
+
type: 'string',
|
|
81
|
+
describe: 'Path to associate with stdin input (labels findings and enables ruleset discovery)',
|
|
82
|
+
})
|
|
83
|
+
.option('concurrency', {
|
|
84
|
+
type: 'number',
|
|
85
|
+
default: 8,
|
|
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)',
|
|
107
|
+
})
|
|
108
|
+
.help()
|
|
109
|
+
.alias('help', 'h')
|
|
110
|
+
.parse());
|
|
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;
|
|
121
|
+
const reportRulesetProblems = (definition, label) => {
|
|
122
|
+
if (parsed.quiet)
|
|
123
|
+
return;
|
|
124
|
+
for (const problem of validateRuleset(definition)) {
|
|
125
|
+
const at = problem.path.length > 0 ? ` (at ${problem.path.join('.')})` : '';
|
|
126
|
+
err.push(`warning: ruleset ${label}: ${problem.message}${at}\n`);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const discoverAndLoad = async (dir) => {
|
|
130
|
+
const discovered = discoverRuleset(dir);
|
|
131
|
+
if (!discovered)
|
|
132
|
+
return undefined;
|
|
133
|
+
const definition = await loadRuleset(discovered);
|
|
134
|
+
reportRulesetProblems(definition, discovered);
|
|
135
|
+
return { definition, basePath: dirname(discovered) };
|
|
136
|
+
};
|
|
137
|
+
let rulesetDefinition;
|
|
138
|
+
let rulesetBasePath;
|
|
139
|
+
if (parsed.ruleset) {
|
|
140
|
+
rulesetDefinition = await loadRuleset(parsed.ruleset);
|
|
141
|
+
rulesetBasePath = dirname(isAbsolute(parsed.ruleset) ? parsed.ruleset : resolve(process.cwd(), parsed.ruleset));
|
|
142
|
+
reportRulesetProblems(rulesetDefinition, parsed.ruleset);
|
|
143
|
+
}
|
|
144
|
+
const targets = await fg(parsed.documents ?? [], { dot: true, onlyFiles: true });
|
|
145
|
+
const allResults = [];
|
|
146
|
+
if (targets.length === 0) {
|
|
147
|
+
if (options.stdin === undefined && process.stdin.isTTY) {
|
|
148
|
+
return { code: 2, stdout: '', stderr: 'No documents provided.\n' };
|
|
149
|
+
}
|
|
150
|
+
const content = options.stdin ?? (await readStdin());
|
|
151
|
+
// With --stdin-filepath, label findings with that path and discover a
|
|
152
|
+
// `.lint.*` ruleset by walking up from its directory.
|
|
153
|
+
const stdinPath = parsed.stdinFilepath;
|
|
154
|
+
let definition = rulesetDefinition;
|
|
155
|
+
let basePath = rulesetBasePath;
|
|
156
|
+
if (!definition && !parsed.ruleset && stdinPath) {
|
|
157
|
+
const discovered = await discoverAndLoad(dirname(stdinPath));
|
|
158
|
+
if (discovered)
|
|
159
|
+
({ definition, basePath } = discovered);
|
|
160
|
+
}
|
|
161
|
+
const opts = {
|
|
162
|
+
...(definition ? { ruleset: definition } : {}),
|
|
163
|
+
...(basePath !== undefined ? { rulesetBasePath: basePath } : {}),
|
|
164
|
+
...(resolver && definition ? { resolve: resolver } : {}),
|
|
165
|
+
source: stdinPath ?? '<stdin>',
|
|
166
|
+
};
|
|
167
|
+
allResults.push(...(await lintDocument(content, opts)));
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// Cache discovered/loaded rulesets by directory so a directory of documents
|
|
171
|
+
// that share a `.lint.*` file parses it once, not once per file.
|
|
172
|
+
const rulesetCache = new Map();
|
|
173
|
+
const perFile = await mapWithConcurrency(targets, parsed.concurrency, async (file) => {
|
|
174
|
+
const content = await readFile(file, parsed.encoding);
|
|
175
|
+
let definition = rulesetDefinition;
|
|
176
|
+
let basePath = rulesetBasePath;
|
|
177
|
+
if (!definition && !parsed.ruleset) {
|
|
178
|
+
const dir = dirname(file);
|
|
179
|
+
let discovered = rulesetCache.get(dir);
|
|
180
|
+
if (!rulesetCache.has(dir)) {
|
|
181
|
+
discovered = await discoverAndLoad(dir);
|
|
182
|
+
rulesetCache.set(dir, discovered);
|
|
183
|
+
}
|
|
184
|
+
if (discovered)
|
|
185
|
+
({ definition, basePath } = discovered);
|
|
186
|
+
}
|
|
187
|
+
const opts = definition
|
|
188
|
+
? {
|
|
189
|
+
ruleset: definition,
|
|
190
|
+
...(basePath !== undefined ? { rulesetBasePath: basePath } : {}),
|
|
191
|
+
...(resolver ? { resolve: resolver } : {}),
|
|
192
|
+
source: file,
|
|
193
|
+
}
|
|
194
|
+
: { source: file };
|
|
195
|
+
return lintDocument(content, opts);
|
|
196
|
+
});
|
|
197
|
+
for (const remaining of perFile)
|
|
198
|
+
allResults.push(...remaining);
|
|
199
|
+
}
|
|
200
|
+
const displayed = parsed.displayOnlyFailures ? filterBySeverity(allResults, failSeverity) : allResults;
|
|
201
|
+
if (!parsed.quiet)
|
|
202
|
+
out.push(formatReport(displayed));
|
|
203
|
+
return {
|
|
204
|
+
code: filterBySeverity(allResults, failSeverity).length > 0 ? 1 : 0,
|
|
205
|
+
stdout: out.join(''),
|
|
206
|
+
stderr: err.join(''),
|
|
207
|
+
};
|
|
208
|
+
};
|
package/dist/load-config.js
CHANGED
|
@@ -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'] }),
|
package/dist/parse-cli-args.js
CHANGED
|
@@ -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.
|
|
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,11 +42,17 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@amritk/adapters": "0.2.16",
|
|
45
|
-
"@amritk/generate-parsers": "0.
|
|
45
|
+
"@amritk/generate-parsers": "0.16.0",
|
|
46
46
|
"@amritk/generate-markdown": "0.4.1",
|
|
47
|
-
"@amritk/helpers": "0.12.0"
|
|
47
|
+
"@amritk/helpers": "0.12.0",
|
|
48
|
+
"@amritk/lint": "0.2.0",
|
|
49
|
+
"@amritk/resolve-refs": "0.3.0",
|
|
50
|
+
"@amritk/yaml": "0.2.3",
|
|
51
|
+
"fast-glob": "^3.3.2",
|
|
52
|
+
"yargs": "^17.7.2"
|
|
48
53
|
},
|
|
49
54
|
"devDependencies": {
|
|
50
|
-
"json-schema-typed": "^8.0.1"
|
|
55
|
+
"json-schema-typed": "^8.0.1",
|
|
56
|
+
"@types/yargs": "^17.0.33"
|
|
51
57
|
}
|
|
52
58
|
}
|