@amritk/mjst 0.25.1 → 0.26.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
@@ -437,7 +437,7 @@ Under `--table` only the content between `<!-- config-table-start -->
437
437
  <td align="center"><code>false</code></td>
438
438
  </tr>
439
439
  <tr>
440
- <td colspan="4">Also emit a coerceX alongside each validateX. It moves scalars toward the type the schema declares and then runs the very same validateX over the result, so a config value written the YAML way reads the same whether it came from a config file or an extension. The input is never modified and nothing is substituted: a value that cannot be coerced into a valid one reaches the validator untouched, so the error names what the caller actually wrote, with the keyword and params that rejected it. The table is Ajv's coerceTypes minus the cells where Ajv guesses — no whitespace-to-zero, no hex or Infinity strings, and nothing coerced to or from null — so every value this coerces, Ajv coerces to the same value, and the rest become errors rather than silent repairs. At a union it coerces only when exactly one of the offered types can take the value, and leaves a value that is already one of them alone, so the answer does not depend on the order the union was written in. Requires validators.</td>
440
+ <td colspan="4">Also emit a coerceX alongside each validateX. It moves scalars toward the type the schema declares and then runs the very same validateX over the result, so a config value written the YAML way reads the same whether it came from a config file or an extension. The input is never modified and nothing is substituted: a value that cannot be coerced into a valid one reaches the validator untouched, so the error names what the caller actually wrote, with the keyword and params that rejected it. The table is Ajv's coerceTypes minus the cells where Ajv guesses — no whitespace-to-zero, no hex or Infinity strings, and nothing coerced to or from null, deliberately, since null usually means not set — so every scalar this coerces, Ajv coerces to the same value, and the rest become errors rather than silent repairs. Coercion reaches through $ref, allOf, if/then/else and unions. At a union a value that already matches a branch as written is left alone; otherwise each branch coerces it its own way, and the result is taken only when every branch that then accepts it agrees on it. So the answer does not depend on the order the union was written in, and a const false branch keeps false where Ajv turns it into the string false through an earlier string branch. A valid document comes back as the very same object, and against Ajv cloning its input first coerceX runs 5–36× faster on valid input and 1.4–2.1× faster on input that needs coercing. Requires validators.</td>
441
441
  </tr>
442
442
  <tr>
443
443
  <td>🔧 <code>repair</code></td>
@@ -96,12 +96,26 @@ export type CliConfig = {
96
96
  *
97
97
  * The table is Ajv's `coerceTypes` minus the cells where Ajv guesses — no
98
98
  * whitespace-to-zero, no `0x`/`Infinity` strings, and nothing coerced to or
99
- * from `null`. Every value this coerces, Ajv coerces to the same value, so
100
- * moving off Ajv never changes a value; it turns some of Ajv's silent repairs
101
- * into errors instead. At a union it coerces only when exactly one of the
102
- * offered types can take the value, and leaves a value that is already one of
103
- * them alone — so the answer does not depend on the order the union is
104
- * written in, which under Ajv it does.
99
+ * from `null`. `null` is left alone deliberately: it usually means "not set",
100
+ * and reading it as `""`, `0` or `false` erases that. Every scalar this
101
+ * coerces, Ajv coerces to the same value; the cells Ajv guesses at become
102
+ * errors instead of silent repairs.
103
+ *
104
+ * Coercion reaches through `$ref`, `allOf`, `if`/`then`/`else` and unions. At
105
+ * a union (`anyOf`, `oneOf`, a `type` array) a value that already matches a
106
+ * branch as written is left alone. Otherwise each branch coerces it its own
107
+ * way, and the result is taken only when every branch that then accepts it
108
+ * agrees on it; two different readings leave the value as written for the
109
+ * validator to report. So the answer never depends on the order the union was
110
+ * written in, which under Ajv it does — Ajv coerces into the first branch that
111
+ * will take the value, and turns `false` into `"false"` through a string
112
+ * branch listed before a `const: false` one.
113
+ *
114
+ * A document that is already valid is answered by `isX` alone where that is a
115
+ * standalone guard, and handed back as the very same object. Against Ajv
116
+ * cloning its input first — which it must to leave the caller's document
117
+ * alone — `coerceX` runs 5–36× faster on valid input and 1.4–2.1× faster on
118
+ * input that needs coercing (`bench:validators:coerce`).
105
119
  *
106
120
  * Requires `validators`. `validateX` and `isX` are unchanged either way.
107
121
  */
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Turns a `ResolveError` message from `@amritk/resolve-refs` into the text the
3
+ * CLI shows.
4
+ *
5
+ * The resolver records a document that failed to load as `String(err)`, so the
6
+ * message opens with the error's class name — `Error: ENOENT: …`, `Error:
7
+ * Failed to parse …` — which tells the reader nothing and pushes the part that
8
+ * does to the right. Only that leading prefix is dropped; the library keeps its
9
+ * wording, since other callers may match on it. A confinement refusal also gets
10
+ * the `--allowed-roots` hint (see {@link withAllowedRootsHint}).
11
+ */
12
+ export declare const describeResolveError: (message: string) => string;
@@ -0,0 +1,5 @@
1
+ import { withAllowedRootsHint } from "./allowed-roots-hint.js";
2
+ const describeResolveError = (message) => withAllowedRootsHint(message.startsWith("Error: ") ? message.slice("Error: ".length) : message);
3
+ export {
4
+ describeResolveError
5
+ };
@@ -27,5 +27,10 @@ export type ResolverOptions = {
27
27
  * refs is resolved from disk **only when its source file reads back exactly as
28
28
  * the linted input** — so a piped document (`--stdin-filepath`) never resolves
29
29
  * against a different on-disk file — with remote fetching gated by `options`.
30
+ *
31
+ * A multi-document root (a YAML `---` stream) is resolved one document at a
32
+ * time, each as a root of its own — `#/…` points into the document the `$ref`
33
+ * is written in, and so does a `$ref` naming the root file — and the results
34
+ * are put back into the array the linter reads the stream as.
30
35
  */
31
36
  export declare const createLintResolver: (options?: ResolverOptions) => LintResolver;
@@ -3,19 +3,20 @@ import { dirname, resolve as resolvePath } from "node:path";
3
3
  import { createDocument, resolveSourceOriginFromMap } from "@amritk/lint";
4
4
  import { DiagnosticSeverity } from "@amritk/lint/types";
5
5
  import { resolveRefs, resolveRefsFromFile } from "@amritk/resolve-refs";
6
- import { parse as parseYaml } from "@amritk/yaml";
7
- import { withAllowedRootsHint } from "../allowed-roots-hint.js";
6
+ import { describeResolveError } from "../describe-resolve-error.js";
8
7
  import { hasExternalRefs } from "../has-external-refs.js";
8
+ import { parseYamlStrict } from "../parse-yaml-strict.js";
9
9
  const isRemote = (location) => /^https?:\/\//i.test(location);
10
+ const parseReferencedYaml = (content, location) => parseYamlStrict(content, location, { what: "a $ref target", singleLine: true, uniqueKeys: false });
10
11
  const parseDoc = (content, location) => {
11
12
  if (/\.ya?ml$/i.test(location))
12
- return parseYaml(content);
13
+ return parseReferencedYaml(content, location);
13
14
  if (/\.json$/i.test(location))
14
15
  return JSON.parse(content);
15
16
  try {
16
17
  return JSON.parse(content);
17
18
  } catch {
18
- return parseYaml(content);
19
+ return parseReferencedYaml(content, location);
19
20
  }
20
21
  };
21
22
  const readsBackAs = (absolute, input) => {
@@ -52,7 +53,7 @@ const toFindings = (errors, root) => errors.map((error) => {
52
53
  const location = error.path.length > 0 ? root.getLocationForJsonPath(error.path, true) : void 0;
53
54
  const finding = {
54
55
  code: "unresolved-ref",
55
- message: withAllowedRootsHint(error.message),
56
+ message: describeResolveError(error.message),
56
57
  path: error.path,
57
58
  severity: DiagnosticSeverity.Error,
58
59
  range: location?.range ?? DOCUMENT_START
@@ -61,6 +62,18 @@ const toFindings = (errors, root) => errors.map((error) => {
61
62
  finding.source = root.source;
62
63
  return finding;
63
64
  });
65
+ const isMultiDocument = (document) => Array.isArray(document.data) && document.data.length > 1 && document.getLocationForJsonPath([]) === void 0;
66
+ const rebaseOntoStream = ({ errors, origins, location }, index, rootLocation, into) => {
67
+ if (origins) {
68
+ for (const [node, origin] of origins) {
69
+ if (into.has(node))
70
+ continue;
71
+ const inThisDocument = origin.location === location;
72
+ into.set(node, inThisDocument ? { location: rootLocation, pointer: [index, ...origin.pointer] } : origin);
73
+ }
74
+ }
75
+ return errors.map((error) => ({ message: error.message, path: [index, ...error.path] }));
76
+ };
64
77
  const createLintResolver = (options = {}) => {
65
78
  const fromFileOptions = {
66
79
  remote: options.remote ?? false,
@@ -70,26 +83,41 @@ const createLintResolver = (options = {}) => {
70
83
  trackOrigins: true
71
84
  };
72
85
  const extraRoots = (options.allowedRoots ?? []).map((root) => resolvePath(root));
86
+ const resolveDocument = async (data, absolute) => {
87
+ if (absolute !== void 0 && hasExternalRefs(data)) {
88
+ const result = await resolveRefsFromFile(absolute, {
89
+ ...fromFileOptions,
90
+ // The linter has parsed this very text already; hand that value over
91
+ // rather than have the root read and parsed a second time (the most
92
+ // expensive step of linting a large spec). It also keeps a root with a
93
+ // syntax error of its own (already a `parser` finding) out of the
94
+ // strict parse that referenced files get.
95
+ rootDocument: data,
96
+ ...extraRoots.length > 0 ? { allowedRoots: [dirname(absolute), ...extraRoots] } : {}
97
+ });
98
+ return { ...result, location: absolute };
99
+ }
100
+ return { ...resolveRefs(data, { trackOrigins: true }), location: "" };
101
+ };
73
102
  return async (document, { input }) => {
74
103
  const { source } = document;
75
- if (source && hasExternalRefs(document.data)) {
76
- const absolute = resolvePath(source);
77
- if (readsBackAs(absolute, input)) {
78
- const { resolved: resolved2, origins: origins2, errors: errors2 } = await resolveRefsFromFile(absolute, {
79
- ...fromFileOptions,
80
- ...extraRoots.length > 0 ? { allowedRoots: [dirname(absolute), ...extraRoots] } : {}
81
- });
82
- return {
83
- resolved: resolved2,
84
- sources: buildSourceSet(document, resolved2, origins2, absolute),
85
- ...errors2.length > 0 ? { diagnostics: toFindings(errors2, document) } : {}
86
- };
87
- }
104
+ const absolute = source && hasExternalRefs(document.data) && readsBackAs(resolvePath(source), input) ? resolvePath(source) : void 0;
105
+ if (!isMultiDocument(document)) {
106
+ const { resolved: resolved2, origins: origins2, errors: errors2, location } = await resolveDocument(document.data, absolute);
107
+ return {
108
+ resolved: resolved2,
109
+ sources: buildSourceSet(document, resolved2, origins2, location),
110
+ ...errors2.length > 0 ? { diagnostics: toFindings(errors2, document) } : {}
111
+ };
88
112
  }
89
- const { resolved, origins, errors } = resolveRefs(document.data, { trackOrigins: true });
113
+ const rootLocation = absolute ?? "";
114
+ const results = await Promise.all(document.data.map((data) => resolveDocument(data, absolute)));
115
+ const origins = /* @__PURE__ */ new Map();
116
+ const errors = results.flatMap((result, index) => rebaseOntoStream(result, index, rootLocation, origins));
117
+ const resolved = results.map((result) => result.resolved);
90
118
  return {
91
119
  resolved,
92
- sources: buildSourceSet(document, resolved, origins, ""),
120
+ sources: buildSourceSet(document, resolved, origins, rootLocation),
93
121
  ...errors.length > 0 ? { diagnostics: toFindings(errors, document) } : {}
94
122
  };
95
123
  };
@@ -3,24 +3,19 @@ import { resolve } from "node:path";
3
3
  import { assignKey } from "@amritk/helpers/assign-key";
4
4
  import { readKey } from "@amritk/helpers/read-key";
5
5
  import { resolveRefsFromFile } from "@amritk/resolve-refs";
6
- import { parseDocument } from "@amritk/yaml";
7
6
  import { hasExternalRefs } from "./has-external-refs.js";
7
+ import { parseYamlStrict } from "./parse-yaml-strict.js";
8
8
  import { buildResolveOptions, formatResolveErrors } from "./resolve-policy.js";
9
- const parseDocumentText = (content, location) => {
9
+ const parseDocumentText = (content, location, referenced = false) => {
10
10
  const text = content.charCodeAt(0) === 65279 ? content.slice(1) : content;
11
11
  if (/\.json$/i.test(location))
12
12
  return JSON.parse(text);
13
- const doc = parseDocument(text);
14
- if (doc.errors.length > 0) {
15
- const details = doc.errors.slice(0, 5).map((error) => ` - ${error.message}`).join("\n");
16
- throw new Error(`Failed to parse ${location} as YAML:
17
- ${details}`);
18
- }
19
- if (doc.warnings.some((warning) => warning.code === "MULTIPLE_DOCUMENTS")) {
20
- throw new Error(`${location} contains multiple YAML documents; an AsyncAPI document must be a single-document file.`);
21
- }
22
- return doc.toJS();
13
+ return parseYamlStrict(text, location, {
14
+ what: referenced ? "a $ref target" : "an AsyncAPI document",
15
+ singleLine: referenced
16
+ });
23
17
  };
18
+ const parseReferencedText = (content, location) => parseDocumentText(content, location, true);
24
19
  const nodeAt = (root, pointer) => {
25
20
  let node = root;
26
21
  for (const segment of pointer) {
@@ -77,7 +72,10 @@ const loadAsyncApiDocument = async (config, documentPath) => {
77
72
  return document;
78
73
  const { resolved, errors, origins } = await resolveRefsFromFile(documentPath, {
79
74
  ...buildResolveOptions(config, documentPath),
80
- parse: parseDocumentText,
75
+ // Parsed just above; without this the resolver would read and parse the
76
+ // whole document a second time.
77
+ rootDocument: document,
78
+ parse: parseReferencedText,
81
79
  trackOrigins: true
82
80
  });
83
81
  if (errors.length > 0)
@@ -10,7 +10,10 @@ const loadSchema = async (config, schemaPath) => {
10
10
  const data = JSON.parse(await readFile(schemaPath, "utf-8"));
11
11
  if (!hasExternalRefs(data))
12
12
  return data;
13
- const { resolved, errors } = await resolveRefsFromFile(schemaPath, buildResolveOptions(config, schemaPath));
13
+ const { resolved, errors } = await resolveRefsFromFile(schemaPath, {
14
+ ...buildResolveOptions(config, schemaPath),
15
+ rootDocument: data
16
+ });
14
17
  if (errors.length > 0)
15
18
  throw new Error(formatResolveErrors(schemaPath, errors));
16
19
  return resolved;
@@ -0,0 +1,28 @@
1
+ import { type ParseOptions } from '@amritk/yaml';
2
+ export type ParseYamlStrictOptions = ParseOptions & {
3
+ /**
4
+ * What the file is required to be, for the multi-document message — `an
5
+ * AsyncAPI document`, `a $ref target` — so the reader learns why a perfectly
6
+ * valid YAML stream was refused.
7
+ */
8
+ what: string;
9
+ /**
10
+ * Join the listed problems into one line (`; `) instead of one ` - ` bullet
11
+ * per line. For reports that print one line per finding, where a line break
12
+ * in the message would read as a new, unlabelled finding.
13
+ */
14
+ singleLine?: boolean;
15
+ };
16
+ /**
17
+ * Parses `text` as a single YAML document and returns its value, throwing
18
+ * instead of handing back anything the parser had to guess at.
19
+ *
20
+ * `@amritk/yaml` collects problems rather than throwing, and `parse` returns
21
+ * the salvage of a malformed document as if it were fine — an unclosed `[`
22
+ * swallows the lines after it into a bogus key, and the caller builds on data
23
+ * the file does not contain. It also reads only the first document of a `---`
24
+ * stream, flagging the rest with a `MULTIPLE_DOCUMENTS` warning, so a file
25
+ * whose second half silently vanished would be reported as a success. Both are
26
+ * turned into an error here, naming `location` and each problem's `line:col`.
27
+ */
28
+ export declare const parseYamlStrict: (text: string, location: string, options: ParseYamlStrictOptions) => unknown;
@@ -0,0 +1,31 @@
1
+ import { lineCounter, parseDocument } from "@amritk/yaml";
2
+ const MAX_LISTED_PROBLEMS = 5;
3
+ const describeParseErrors = (errors, text, location, singleLine) => {
4
+ const { linePos } = lineCounter(text);
5
+ const listed = errors.slice(0, MAX_LISTED_PROBLEMS).map((error) => {
6
+ const { line, col } = linePos(error.start);
7
+ return `${location}:${line}:${col}: ${error.message}`;
8
+ });
9
+ if (errors.length > MAX_LISTED_PROBLEMS)
10
+ listed.push(`\u2026and ${errors.length - MAX_LISTED_PROBLEMS} more`);
11
+ const body = singleLine ? ` ${listed.join("; ")}` : listed.map((problem) => `
12
+ - ${problem}`).join("");
13
+ return `Failed to parse YAML:${body}`;
14
+ };
15
+ const describeMultipleDocuments = (warning, text, location, what) => {
16
+ const { line, col } = lineCounter(text).linePos(warning.start);
17
+ return `${location}:${line}:${col}: this file contains multiple YAML documents (another one follows this marker); ${what} must be a single-document file.`;
18
+ };
19
+ const parseYamlStrict = (text, location, options) => {
20
+ const { what, singleLine, ...parseOptions } = options;
21
+ const doc = parseDocument(text, parseOptions);
22
+ if (doc.errors.length > 0)
23
+ throw new Error(describeParseErrors(doc.errors, text, location, singleLine));
24
+ const multiple = doc.warnings.find((warning) => warning.code === "MULTIPLE_DOCUMENTS");
25
+ if (multiple !== void 0)
26
+ throw new Error(describeMultipleDocuments(multiple, text, location, what));
27
+ return doc.toJS();
28
+ };
29
+ export {
30
+ parseYamlStrict
31
+ };
@@ -1,5 +1,5 @@
1
1
  import { dirname, resolve } from "node:path";
2
- import { withAllowedRootsHint } from "./allowed-roots-hint.js";
2
+ import { describeResolveError } from "./describe-resolve-error.js";
3
3
  const buildResolveOptions = (config, documentPath) => {
4
4
  const remote = (config.resolveRemote ?? false) || (config.allowedHosts?.length ?? 0) > 0;
5
5
  const extraRoots = (config.allowedRoots ?? []).map((root) => resolve(root));
@@ -11,7 +11,7 @@ const buildResolveOptions = (config, documentPath) => {
11
11
  };
12
12
  };
13
13
  const formatResolveErrors = (documentPath, errors) => {
14
- const details = errors.map((error) => ` - ${withAllowedRootsHint(error.message)}`).join("\n");
14
+ const details = errors.map((error) => ` - ${describeResolveError(error.message)}`).join("\n");
15
15
  return `Failed to resolve $refs in ${documentPath}:
16
16
  ${details}`;
17
17
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/mjst",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -49,16 +49,16 @@
49
49
  "./package.json": "./package.json"
50
50
  },
51
51
  "dependencies": {
52
- "@amritk/adapters": "^0.6.7",
52
+ "@amritk/adapters": "^0.6.8",
53
53
  "@amritk/api": "^0.16.6",
54
- "@amritk/asyncapi": "^0.3.5",
55
- "@amritk/generate-examples": "^0.8.9",
54
+ "@amritk/asyncapi": "^0.3.6",
55
+ "@amritk/generate-examples": "^0.8.10",
56
56
  "@amritk/generate-markdown": "^0.11.0",
57
- "@amritk/helpers": "^0.23.3",
58
- "@amritk/lint": "^0.6.2",
59
- "@amritk/validation": "^0.3.2",
60
- "@amritk/resolve-refs": "^0.8.0",
61
- "@amritk/yaml": "^0.7.3",
57
+ "@amritk/helpers": "^0.24.0",
58
+ "@amritk/lint": "^0.7.0",
59
+ "@amritk/validation": "^0.4.0",
60
+ "@amritk/resolve-refs": "^0.9.0",
61
+ "@amritk/yaml": "^0.8.0",
62
62
  "fast-glob": "^3.3.2",
63
63
  "json-schema-typed": "^8.0.1",
64
64
  "yargs": "^17.7.2"