@amritk/mjst 0.9.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
@@ -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,45 @@ 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
+ | `-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
+
119
160
  ## Configuration reference
120
161
 
121
162
  <!-- config-table-start -->
package/dist/cli.js CHANGED
@@ -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;
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/mjst",
3
- "version": "0.9.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",
@@ -44,9 +44,13 @@
44
44
  "@amritk/adapters": "0.2.16",
45
45
  "@amritk/generate-parsers": "0.15.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.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
  }