@amritk/lint 0.5.2 → 0.6.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/AI.md CHANGED
@@ -30,7 +30,7 @@ import { lintDocument } from '@amritk/lint'
30
30
  import { createAsyncApiRuleset } from '@amritk/lint/rules/asyncapi'
31
31
  import { resolveRefs } from '@amritk/resolve-refs' // any resolver will do
32
32
 
33
- // Recommended rules only: 48 of 56. For all 56, pass the `all` modifier:
33
+ // Recommended rules only: 54 of 62. For all 62, pass the `all` modifier:
34
34
  // createAsyncApiRuleset({ extends: [['asyncapi', 'all']] })
35
35
  const ruleset = createAsyncApiRuleset()
36
36
 
@@ -91,7 +91,7 @@ createAsyncApiRuleset({
91
91
  7. **`createRuleset` is memoized** per `(definition object, basePath,
92
92
  restrictTo)`. Mutating a definition you already passed in will not rebuild the
93
93
  ruleset — pass a fresh object instead.
94
- 8. **Most AsyncAPI rules are gated per major** (11 of the 56 apply to both).
94
+ 8. **Most AsyncAPI rules are gated per major** (11 of the 62 apply to both).
95
95
  The 3.x-only rules are named with an `asyncapi-3-` prefix
96
96
  (`asyncapi-3-operation-description`) and the 2.x ones are not, because 3.0
97
97
  moved operations to the top level and tags under `info`. Re-severitying
@@ -133,6 +133,6 @@ createAsyncApiRuleset({
133
133
  |---|---|
134
134
  | `@amritk/lint` | core engine, `lintDocument`/`fixDocument`, built-in functions |
135
135
  | `@amritk/lint/rules/openapi` | OpenAPI 2.0/3.0/3.1/3.2 preset — 66 rules, plus `oasFixers` |
136
- | `@amritk/lint/rules/asyncapi` | AsyncAPI 2.0–2.6 / 3.0 preset — 56 rules, no fixers |
136
+ | `@amritk/lint/rules/asyncapi` | AsyncAPI 2.0–2.6 / 3.0 preset — 62 rules, no fixers |
137
137
 
138
138
  Install: `bun add @amritk/lint`.
package/README.md CHANGED
@@ -193,7 +193,7 @@ A ruleset is a plain object (authored as YAML, JSON, or a JS module):
193
193
 
194
194
  | Field | Description |
195
195
  | --- | --- |
196
- | `rules` | Map of `name → rule`. A rule has `given` (one or more JSONPath expressions), `then` (a function to run, or a list), `severity` (`error`/`warn`/`info`/`hint`/`off`), and optional `message`, `description`, `formats`, `recommended`. |
196
+ | `rules` | Map of `name → rule`. A rule has `given` (one or more JSONPath expressions), `then` (a function to run, or a list), `severity` (`error`/`warn`/`info`/`hint`/`off`), and optional `message`, `description`, `formats`, `recommended`, `resolved` (default `true`; `false` runs the rule against the document as written, before `$ref` dereferencing), and `documentationUrl`. |
197
197
  | `then` | `{ function, field?, functionOptions? }` — `field` narrows the match to a child (`@key` targets the property name). |
198
198
  | `extends` | A ruleset (or list) to inherit rules from: a file path or npm package. `[target, 'recommended' \| 'all' \| 'off']` controls what it contributes. |
199
199
  | `functions` / `functionsDir` | Custom functions to load by name (default dir `functions/`). |
@@ -230,7 +230,7 @@ Anything outside that grammar is a ruleset error (`createRuleset` throws and nam
230
230
  | `resolveNamedRuleset(name, basePath?, options?)` | Resolve an `extends` reference (file path or npm package) to its definition. |
231
231
  | `builtinFunctions` | The registry of built-in rule functions. |
232
232
 
233
- The engine internals (`createDocument`, `lint`, `query`, `validateRuleset`, `parseWithPointers`, `createFixPlugin`, `DiagnosticSeverity`, and the rule/diagnostic types) are re-exported from the package root for advanced use.
233
+ The engine internals (`createDocument`, `lint`, `lintWithResult`, `query`, `validateRuleset`, `parseWithPointers`, `createFixPlugin`, …) are re-exported from the package root for advanced use; `DiagnosticSeverity` and the rule/diagnostic types (`IDiagnostic`, `RulesetDefinition`, `IRuleDefinition`, …) live on the `@amritk/lint/types` subpath.
234
234
 
235
235
  ---
236
236
 
@@ -259,7 +259,7 @@ const findings = await lint(spec, { ruleset })
259
259
  | `oasFixers` | Auto-fixers for the mechanically-repairable OpenAPI rules (pass to `fixDocument` alongside a built OpenAPI ruleset). |
260
260
  | `loadOasSchema(version)` | Lazily load one OpenAPI version's official structural meta-schema (`'2.0'` / `'3.0'` / `'3.1'` / `'3.2'`), vendored as raw `.json` from `spec.openapis.org` (3.0/3.1/3.2 verbatim; 2.0 with its external draft-04 metaschema refs inlined). See [`schemas/README.md`](./src/rules/openapi/schemas/README.md). |
261
261
 
262
- The structural rules validate against the **official `spec.openapis.org` meta-schemas, vendored as raw `.json`** ([`schemas/`](./src/rules/openapi/schemas/)). 3.0/3.1/3.2 are byte-for-byte verbatim; only 2.0 differs (its external draft-04 metaschema refs are inlined, since the offline interpreter never fetches remote refs). OpenAPI 3.1/3.2 express Schema Objects as JSON Schema 2020-12 via a local `$dynamicRef`/`$dynamicAnchor`, which `@amritk/runtime-validators` resolves natively — so the whole document envelope is validated against the official schema with no bundling or dialect engine, while Schema Object internals stay permissive.
262
+ The structural rules validate against the **official `spec.openapis.org` meta-schemas, vendored as raw `.json`** ([`schemas/`](./src/rules/openapi/schemas/)). 3.0/3.1/3.2 are byte-for-byte verbatim; only 2.0 differs (its external draft-04 metaschema refs are inlined, since the offline interpreter never fetches remote refs, and its top-level `id`/`$schema` keys are dropped). OpenAPI 3.1/3.2 express Schema Objects as JSON Schema 2020-12 via a local `$dynamicRef`/`$dynamicAnchor`, which `@amritk/runtime-validators` resolves natively — so the whole document envelope is validated against the official schema with no bundling or dialect engine, while Schema Object internals stay permissive.
263
263
 
264
264
  `$ref` resolution stays the caller's job: the preset doesn't pull in a resolver, so for rules that need the dereferenced document (`resolved: true`) pass a `resolve` function to the core `lintWithResult` (for example wrapping [`@amritk/resolve-refs`](../resolve-refs)). The `mjst lint` CLI already wires one up.
265
265
 
@@ -300,13 +300,21 @@ Structural validation runs **once per document, against the document as written*
300
300
 
301
301
  ## Benchmarks
302
302
 
303
- The `bench/` suite pits `@amritk/lint` head-to-head against **[Spectral](https://github.com/stoplightio/spectral)** — the OpenAPI linter this package is modelled on (hence the `spectral:oas` alias) — over the real-world specs the test suite lints: Swagger's petstore, the DigitalOcean API, and the OpenAI API (~17 KB to ~2.8 MB, spanning a small config and a genuinely large document). Both do the same job: **parse → dereference internal `$ref`s → run their recommended OpenAPI ruleset** (mjst dereferences in memory with [`@amritk/resolve-refs`](../resolve-refs), exactly as the CLI does; Spectral uses its own default resolver). Representative numbers (Bun 1.4, Linux x64 — your hardware will differ, run `bun run bench` yourself):
303
+ The `bench/` suite pits `@amritk/lint` head-to-head against **[Spectral](https://github.com/stoplightio/spectral)** — the OpenAPI linter this package is modelled on (hence the `spectral:oas` alias) — over the real-world specs the test suite lints: Swagger's petstore, the DigitalOcean API, and the OpenAI API (~17 KB to ~2.8 MB, spanning a small config and a genuinely large document). Both do the same job: **parse → dereference internal `$ref`s → run their recommended OpenAPI ruleset** (mjst dereferences in memory with [`@amritk/resolve-refs`](../resolve-refs), exactly as the CLI does; Spectral uses its own default resolver). Medians of three runs on each runtime, one machine (Linux x64, a 4-vCPU cloud box, Bun 1.4.0 and Node 26.8.1 — your hardware will differ, run `bun run bench` or `bun run bench:node` yourself):
304
304
 
305
- | document | size | mjst | Spectral | speedup | findings (mjst / Spectral) |
306
- | --- | ---: | ---: | ---: | ---: | ---: |
307
- | petstore (Swagger) | 17 KB | ~5 ms | ~95 ms | **~19×** | 2 / 2 |
308
- | digitalocean | 105 KB | ~27 ms | ~375 ms | **~14×** | 2411 / 4319 |
309
- | openai | 2.8 MB | ~0.73 s | ~7.4 s | **~10×** | 1278 / 474 |
305
+ | document | size | runtime | mjst | Spectral | speedup | findings (mjst / Spectral) |
306
+ | --- | ---: | --- | ---: | ---: | ---: | ---: |
307
+ | petstore (Swagger) | 17 KB | Bun | ~4 ms | ~87 ms | **~21×** | 2 / 2 |
308
+ | petstore (Swagger) | 17 KB | Node | ~5 ms | ~50 ms | **~9.5×** | 2 / 2 |
309
+ | digitalocean | 105 KB | Bun | ~25 ms | ~318 ms | **~13×** | 2411 / 4319 |
310
+ | digitalocean | 105 KB | Node | ~23 ms | ~276 ms | **~12×** | 2411 / 4319 |
311
+ | openai | 2.8 MB | Bun | ~0.65 s | ~7.9 s | **~12×** | 587 / 474 |
312
+ | openai | 2.8 MB | Node | ~0.75 s | ~5.5 s | **~7.3×** | 587 / 474 |
313
+
314
+ The lead is smaller on Node throughout, and for the same reason in every row:
315
+ Spectral runs materially faster on V8 than on JavaScriptCore — a third quicker
316
+ on the OpenAI spec — while this linter is close to even between the two. The
317
+ ratio is the thing that moves, not our side of it.
310
318
 
311
319
  An earlier revision of this table reported the OpenAI row as mjst-only, because
312
320
  Spectral's JSONPath engine (`nimma`) threw on that spec under Bun. It no longer
@@ -316,7 +324,7 @@ runtime-specific and may come back.
316
324
 
317
325
  Each `lint` figure is the mean wall time of one whole pass — **every rule, not a subset** — dominated by real work: JSONPath matching, the rule functions, and the dereference pass. A fresh document is parsed on every iteration on both sides, matching how the tools are actually called. The finding counts differ because the two rulesets are not byte-identical (different rule implementations and `$ref` resolution), so this is a **throughput** comparison rather than a correctness parity check — but on petstore both land on the same two findings.
318
326
 
319
- **Assembling the ruleset** is timed separately, because a process pays it once and then lints many documents: `createOpenApiRuleset` (compiling every rule's JSONPath and wiring up functions and format detectors) measures **~0.07 ms**, versus **~0.28 ms** for `new Spectral()` + `setRuleset(oas)`. The benchmark warms up before timing and reports the mean over a fixed time budget; micro-benchmark figures vary by machine and runtime.
327
+ **Assembling the ruleset** is timed separately, because a process pays it once and then lints many documents: `createOpenApiRuleset` (compiling every rule's JSONPath and wiring up functions and format detectors) measures **~0.07 ms** on Bun and **~0.06 ms** on Node, versus **~0.26 ms** and **~0.24 ms** for `new Spectral()` + `setRuleset(oas)`. The benchmark warms up before timing and reports the mean over a fixed time budget; micro-benchmark figures vary by machine and runtime.
320
328
 
321
329
  ---
322
330
 
@@ -369,6 +369,7 @@ const V3_MESSAGE_TRAITS = [
369
369
  "$.components.messageTraits[*]"
370
370
  ];
371
371
  const V3_ALL_MESSAGES = [...V3_MESSAGES, ...V3_MESSAGE_TRAITS];
372
+ const V3_PAYLOADS = V3_ALL_MESSAGES.map((given) => `${given}.payload`);
372
373
  const v3Rules = {
373
374
  "asyncapi-3-channel-no-empty-parameter": {
374
375
  description: "Channel address must not have an empty parameter substitution pattern.",
@@ -424,6 +425,16 @@ const v3Rules = {
424
425
  severity: "error",
425
426
  then: { function: "asyncApiHeadersObject", functionOptions: { multiFormat: true } }
426
427
  },
428
+ "asyncapi-3-message-examples": {
429
+ description: "Message examples must be valid against the payload and headers schemas.",
430
+ formats: ["aas3"],
431
+ // Messages only, for the same reason as the 2.x twin: the pass folds traits
432
+ // in and reports against whichever array the merge took, so matching each
433
+ // trait location as well printed the identical finding twice.
434
+ given: V3_MESSAGES,
435
+ severity: "error",
436
+ then: { function: "asyncApiMessageExamples", functionOptions: { multiFormat: true } }
437
+ },
427
438
  "asyncapi-3-operation-description": {
428
439
  description: "Operation must have a description.",
429
440
  formats: ["aas3"],
@@ -438,10 +449,33 @@ const v3Rules = {
438
449
  severity: "error",
439
450
  then: { function: "asyncApiSecurity", functionOptions: { objectType: "Operation" } }
440
451
  },
452
+ "asyncapi-3-payload": {
453
+ description: "Payloads must be valid against the AsyncAPI Schema object.",
454
+ formats: ["aas3"],
455
+ // Given the message rather than the payload, exactly as the 2.x twin is, so
456
+ // traits are folded in before the payload is read.
457
+ given: V3_ALL_MESSAGES,
458
+ severity: "error",
459
+ then: { function: "asyncApiPayload", functionOptions: { multiFormat: true } }
460
+ },
461
+ "asyncapi-3-payload-default": {
462
+ description: "Payload default must be valid against its schema.",
463
+ formats: ["aas3"],
464
+ given: V3_PAYLOADS,
465
+ severity: "error",
466
+ then: { function: "asyncApiSchemaValidation", functionOptions: { type: "default", multiFormat: true } }
467
+ },
468
+ "asyncapi-3-payload-examples": {
469
+ description: "Payload examples must be valid against their schema.",
470
+ formats: ["aas3"],
471
+ given: V3_PAYLOADS,
472
+ severity: "error",
473
+ then: { function: "asyncApiSchemaValidation", functionOptions: { type: "examples", multiFormat: true } }
474
+ },
441
475
  "asyncapi-3-payload-unsupported-schemaFormat": {
442
476
  description: "Message payload validation is only supported with an unspecified schemaFormat.",
443
477
  formats: ["aas3"],
444
- given: V3_ALL_MESSAGES.map((given) => `${given}.payload`),
478
+ given: V3_PAYLOADS,
445
479
  severity: "info",
446
480
  resolved: false,
447
481
  then: {
@@ -450,6 +484,26 @@ const v3Rules = {
450
484
  functionOptions: { match: "^application/vnd\\.aai\\.asyncapi([+;])" }
451
485
  }
452
486
  },
487
+ "asyncapi-3-schema-default": {
488
+ description: "Schema default must be valid against its schema.",
489
+ formats: ["aas3"],
490
+ // No parameter locations here: a 3.0 Channel Parameter Object has no
491
+ // `schema` at all — it carries `enum`, `default` and `examples` as plain
492
+ // strings — so `components.schemas` is the only place a reusable Schema
493
+ // Object is written. Each entry is a Multi Format Schema Object or a bare
494
+ // Schema Object, which is why this unwraps rather than reading `default`
495
+ // straight off the node.
496
+ given: "$.components.schemas[*]",
497
+ severity: "error",
498
+ then: { function: "asyncApiSchemaValidation", functionOptions: { type: "default", multiFormat: true } }
499
+ },
500
+ "asyncapi-3-schema-examples": {
501
+ description: "Schema examples must be valid against their schema.",
502
+ formats: ["aas3"],
503
+ given: "$.components.schemas[*]",
504
+ severity: "error",
505
+ then: { function: "asyncApiSchemaValidation", functionOptions: { type: "examples", multiFormat: true } }
506
+ },
453
507
  "asyncapi-3-server-security": {
454
508
  // 3.0 keeps `security` on the Server Object, in the same
455
509
  // Reference-or-inline-scheme shape `asyncapi-3-operation-security` checks.
@@ -1,4 +1,5 @@
1
1
  import { isObject } from "./helpers.js";
2
+ import { splitMultiFormatSchema } from "./multi-format-schema.js";
2
3
  import { isAsyncApiSchemaFormat } from "./schema-format.js";
3
4
  const MESSAGE = 'Headers schema type must be "object"';
4
5
  const asyncApiHeadersObject = (headers, options, context) => {
@@ -8,15 +9,17 @@ const asyncApiHeadersObject = (headers, options, context) => {
8
9
  return [];
9
10
  if (typeof headers["$ref"] === "string")
10
11
  return [];
11
- if (options?.multiFormat === true && Object.hasOwn(headers, "schema")) {
12
- if (!isAsyncApiSchemaFormat(headers["schemaFormat"]))
13
- return [];
14
- const inner = headers["schema"];
15
- if (!isObject(inner))
16
- return [{ message: MESSAGE, path: [...context.path, "schema"] }];
17
- if (typeof inner["$ref"] === "string" || inner["type"] === "object")
18
- return [];
19
- return [{ message: MESSAGE, path: [...context.path, "schema"] }];
12
+ if (options?.multiFormat === true) {
13
+ const { schemaFormat, schema: inner, path } = splitMultiFormatSchema(headers);
14
+ if (path.length > 0) {
15
+ if (!isAsyncApiSchemaFormat(schemaFormat))
16
+ return [];
17
+ if (!isObject(inner))
18
+ return [{ message: MESSAGE, path: [...context.path, ...path] }];
19
+ if (typeof inner["$ref"] === "string" || inner["type"] === "object")
20
+ return [];
21
+ return [{ message: MESSAGE, path: [...context.path, ...path] }];
22
+ }
20
23
  }
21
24
  return headers["type"] === "object" ? [] : [{ message: MESSAGE, path: [...context.path] }];
22
25
  };
@@ -1,8 +1,17 @@
1
1
  import type { RulesetFunction } from '../../../core/types.js';
2
+ /** Options for {@link asyncApiMessageExamples}. */
3
+ export type IAsyncApiMessageExamplesOptions = {
4
+ /**
5
+ * Whether `payload` and `headers` may each be a Multi Format Schema Object
6
+ * (`{ schemaFormat, schema }`). That shape is 3.0 only, and it is also where
7
+ * that major states the schema language — 2.x states it once, on the message.
8
+ */
9
+ multiFormat?: boolean;
10
+ };
2
11
  /**
3
12
  * Checks every entry of a Message Object's `examples` against the message's own
4
13
  * `payload` and `headers` schemas. Traits are folded in first, so an example is
5
14
  * judged against the message a tool would actually assemble rather than against
6
15
  * the half of it written inline.
7
16
  */
8
- export declare const asyncApiMessageExamples: RulesetFunction;
17
+ export declare const asyncApiMessageExamples: RulesetFunction<unknown, IAsyncApiMessageExamplesOptions | undefined>;
@@ -1,5 +1,6 @@
1
1
  import { schema as schemaFunction } from "../../../functions/index.js";
2
2
  import { isObject, mergeTraits } from "./helpers.js";
3
+ import { splitMultiFormatSchema } from "./multi-format-schema.js";
3
4
  import { isAsyncApiSchemaFormat } from "./schema-format.js";
4
5
  const examplesOrigin = (message) => {
5
6
  const traits = message["traits"];
@@ -12,7 +13,8 @@ const examplesOrigin = (message) => {
12
13
  }
13
14
  return ["examples"];
14
15
  };
15
- const asyncApiMessageExamples = (input, _options, context) => {
16
+ const PARTS = ["payload", "headers"];
17
+ const asyncApiMessageExamples = (input, options, context) => {
16
18
  if (!isObject(input))
17
19
  return [];
18
20
  const message = mergeTraits(input);
@@ -20,17 +22,23 @@ const asyncApiMessageExamples = (input, _options, context) => {
20
22
  if (!Array.isArray(examples))
21
23
  return [];
22
24
  const origin = examplesOrigin(input);
23
- const payloadIsSchema = isAsyncApiSchemaFormat(message["schemaFormat"]);
25
+ const schemaOf = (part) => {
26
+ if (options?.multiFormat !== true) {
27
+ return part === "headers" || isAsyncApiSchemaFormat(message["schemaFormat"]) ? message[part] : void 0;
28
+ }
29
+ const { schemaFormat, schema } = splitMultiFormatSchema(message[part]);
30
+ return isAsyncApiSchemaFormat(schemaFormat) ? schema : void 0;
31
+ };
32
+ const schemas = { payload: schemaOf("payload"), headers: schemaOf("headers") };
24
33
  const results = [];
25
34
  examples.forEach((example, index) => {
26
35
  if (!isObject(example))
27
36
  return;
28
- for (const part of ["payload", "headers"]) {
29
- if (example[part] === void 0)
30
- continue;
31
- if (part === "payload" && !payloadIsSchema)
37
+ for (const part of PARTS) {
38
+ const partSchema = schemas[part];
39
+ if (example[part] === void 0 || partSchema === void 0)
32
40
  continue;
33
- const findings = schemaFunction(example[part], { schema: isObject(message[part]) ? message[part] : {}, allErrors: true, skipUnusableSchema: true }, { ...context, path: [...context.path, ...origin, index, part] });
41
+ const findings = schemaFunction(example[part], { schema: isObject(partSchema) ? partSchema : {}, allErrors: true, skipUnusableSchema: true }, { ...context, path: [...context.path, ...origin, index, part] });
34
42
  if (findings)
35
43
  results.push(...findings);
36
44
  }
@@ -11,4 +11,14 @@ import type { RulesetFunction } from '../../../core/types.js';
11
11
  * payload came to be judged as JSON Schema and reported at error severity.
12
12
  * `asyncapi-payload-unsupported-schemaFormat` reports those separately.
13
13
  */
14
- export declare const asyncApiPayload: RulesetFunction;
14
+ /** Options for {@link asyncApiPayload}. */
15
+ export type IAsyncApiPayloadOptions = {
16
+ /**
17
+ * Whether the payload may be a Multi Format Schema Object (`{ schemaFormat,
18
+ * schema }`). That shape is 3.0 only: the format that used to sit on the
19
+ * message now sits on the payload, so the gate reads a different place — and
20
+ * the schema to judge lives one level further down.
21
+ */
22
+ multiFormat?: boolean;
23
+ };
24
+ export declare const asyncApiPayload: RulesetFunction<unknown, IAsyncApiPayloadOptions | undefined>;
@@ -1,6 +1,7 @@
1
1
  import { schema as schemaFunction } from "../../../functions/index.js";
2
2
  import { asyncApiSchemaVersion, loadAsyncApiSchema } from "../schemas/index.js";
3
3
  import { isObject, mergeTraits } from "./helpers.js";
4
+ import { splitMultiFormatSchema } from "./multi-format-schema.js";
4
5
  import { isAsyncApiSchemaFormat } from "./schema-format.js";
5
6
  const payloadSchemaId = (version) => `http://asyncapi.com/definitions/${version}.0/schema.json`;
6
7
  const payloadSchemas = /* @__PURE__ */ new Map();
@@ -13,19 +14,20 @@ const payloadSchema = (version) => {
13
14
  }
14
15
  return wrapper;
15
16
  };
16
- const asyncApiPayload = (message, _options, context) => {
17
+ const asyncApiPayload = (message, options, context) => {
17
18
  if (!isObject(message))
18
19
  return [];
19
20
  const merged = mergeTraits(message);
20
- if (!isAsyncApiSchemaFormat(merged["schemaFormat"]))
21
+ const written = merged["payload"];
22
+ if (written === void 0)
21
23
  return [];
22
- const payload = merged["payload"];
23
- if (payload === void 0)
24
+ const payload = options?.multiFormat === true ? splitMultiFormatSchema(written) : { schemaFormat: merged["schemaFormat"], schema: written, path: [] };
25
+ if (!isAsyncApiSchemaFormat(payload.schemaFormat))
24
26
  return [];
25
27
  const version = asyncApiSchemaVersion(isObject(context.document.data) ? context.document.data["asyncapi"] : void 0);
26
28
  if (version === void 0)
27
29
  return [];
28
- return schemaFunction(payload, { schema: payloadSchema(version), allErrors: true }, { ...context, path: [...context.path, "payload"] }) ?? [];
30
+ return schemaFunction(payload.schema, { schema: payloadSchema(version), allErrors: true }, { ...context, path: [...context.path, "payload", ...payload.path] }) ?? [];
29
31
  };
30
32
  export {
31
33
  asyncApiPayload
@@ -1,11 +1,22 @@
1
1
  import type { RulesetFunction } from '../../../core/types.js';
2
- /** Options for {@link asyncApiSchemaValidation}: which sibling of the schema to check. */
2
+ /** Options for {@link asyncApiSchemaValidation}: which sibling of the schema to check, and how the schema is wrapped. */
3
3
  export type IAsyncApiSchemaValidationOptions = {
4
4
  type: 'default' | 'examples';
5
+ /**
6
+ * Whether the matched node may be a Multi Format Schema Object (`{
7
+ * schemaFormat, schema }`) rather than a bare Schema Object. That shape is 3.0
8
+ * only, where a payload — and a `components.schemas` entry — can be written in
9
+ * Avro or Protobuf. A `default` under one of those is not JSON Schema data and
10
+ * cannot be judged, so it is left to
11
+ * `asyncapi-3-payload-unsupported-schemaFormat` to mention the format at all.
12
+ */
13
+ multiFormat?: boolean;
5
14
  };
6
15
  /**
7
16
  * Validates a Schema Object's own `default` or `examples` against that same
8
- * schema. The rule targets the schema (via a `^` parent selector), so the input
9
- * here is the schema and the values under test sit inside it.
17
+ * schema. In 2.x the rule targets the schema through a `^` parent selector, so
18
+ * the input is the schema and the values under test sit inside it; in 3.0 the
19
+ * rule targets the payload (or `components.schemas` entry) directly, because the
20
+ * wrapper has to be unwrapped before either can be found.
10
21
  */
11
22
  export declare const asyncApiSchemaValidation: RulesetFunction<unknown, IAsyncApiSchemaValidationOptions>;
@@ -1,12 +1,20 @@
1
1
  import { schema as schemaFunction } from "../../../functions/index.js";
2
2
  import { isObject } from "./helpers.js";
3
+ import { splitMultiFormatSchema } from "./multi-format-schema.js";
4
+ import { isAsyncApiSchemaFormat } from "./schema-format.js";
3
5
  const asyncApiSchemaValidation = (input, options, context) => {
4
- if (!isObject(input) || options?.type === void 0)
6
+ if (options?.type === void 0)
5
7
  return [];
6
- const targets = options.type === "default" ? [{ path: ["default"], value: input["default"] }] : Array.isArray(input["examples"]) ? input["examples"].map((value, index) => ({ path: ["examples", index], value })) : [];
8
+ const source = options.multiFormat === true ? splitMultiFormatSchema(input) : { schemaFormat: void 0, schema: input, path: [] };
9
+ if (!isAsyncApiSchemaFormat(source.schemaFormat))
10
+ return [];
11
+ const schema = source.schema;
12
+ if (!isObject(schema))
13
+ return [];
14
+ const targets = options.type === "default" ? Object.hasOwn(schema, "default") ? [{ path: ["default"], value: schema["default"] }] : [] : Array.isArray(schema["examples"]) ? schema["examples"].map((value, index) => ({ path: ["examples", index], value })) : [];
7
15
  const results = [];
8
16
  for (const target of targets) {
9
- const findings = schemaFunction(target.value, { schema: input, allErrors: true, skipUnusableSchema: true }, { ...context, path: [...context.path, ...target.path] });
17
+ const findings = schemaFunction(target.value, { schema, allErrors: true, skipUnusableSchema: true }, { ...context, path: [...context.path, ...source.path, ...target.path] });
10
18
  if (findings)
11
19
  results.push(...findings);
12
20
  }
@@ -5,11 +5,12 @@ export { asyncApiChannelParameters } from './asyncapi-channel-parameters.js';
5
5
  export { asyncApiChannelServers } from './asyncapi-channel-servers.js';
6
6
  export { asyncApiDocumentSchema } from './asyncapi-document-schema.js';
7
7
  export { asyncApiHeadersObject, type IAsyncApiHeadersOptions } from './asyncapi-headers-object.js';
8
- export { asyncApiMessageExamples } from './asyncapi-message-examples.js';
8
+ export { asyncApiMessageExamples, type IAsyncApiMessageExamplesOptions } from './asyncapi-message-examples.js';
9
9
  export { asyncApiMessageIdUnique } from './asyncapi-message-id-unique.js';
10
10
  export { asyncApiOperationIdUnique } from './asyncapi-operation-id-unique.js';
11
- export { asyncApiPayload } from './asyncapi-payload.js';
11
+ export { asyncApiPayload, type IAsyncApiPayloadOptions } from './asyncapi-payload.js';
12
12
  export { asyncApiSchemaValidation, type IAsyncApiSchemaValidationOptions } from './asyncapi-schema-validation.js';
13
13
  export { asyncApiSecurity, type IAsyncApiSecurityOptions } from './asyncapi-security.js';
14
+ export { type MultiFormatSchema, splitMultiFormatSchema } from './multi-format-schema.js';
14
15
  /** The AsyncAPI-specific custom functions, keyed by name for ruleset `then` references. */
15
16
  export declare const aasFunctions: FunctionRegistry;
@@ -22,6 +22,7 @@ import { asyncApiOperationIdUnique as asyncApiOperationIdUnique2 } from "./async
22
22
  import { asyncApiPayload as asyncApiPayload2 } from "./asyncapi-payload.js";
23
23
  import { asyncApiSchemaValidation as asyncApiSchemaValidation2 } from "./asyncapi-schema-validation.js";
24
24
  import { asyncApiSecurity as asyncApiSecurity2 } from "./asyncapi-security.js";
25
+ import { splitMultiFormatSchema } from "./multi-format-schema.js";
25
26
  const aasFunctions = {
26
27
  aasServerVariables,
27
28
  aasTagsUnique,
@@ -49,5 +50,6 @@ export {
49
50
  asyncApiOperationIdUnique2 as asyncApiOperationIdUnique,
50
51
  asyncApiPayload2 as asyncApiPayload,
51
52
  asyncApiSchemaValidation2 as asyncApiSchemaValidation,
52
- asyncApiSecurity2 as asyncApiSecurity
53
+ asyncApiSecurity2 as asyncApiSecurity,
54
+ splitMultiFormatSchema
53
55
  };
@@ -0,0 +1,25 @@
1
+ import type { JsonPath } from '../../../core/types.js';
2
+ /** An AsyncAPI 3.0 payload or headers node, split into the schema it holds and the language that schema is written in. */
3
+ export type MultiFormatSchema = {
4
+ /** The wrapper's `schemaFormat`, or `undefined` when the node is a bare Schema Object. */
5
+ schemaFormat: unknown;
6
+ /** The Schema Object itself: the wrapper's `schema`, or the whole node when it is bare. */
7
+ schema: unknown;
8
+ /** How to get from the matched node down to `schema` — `['schema']` when wrapped, empty when bare. */
9
+ path: JsonPath;
10
+ };
11
+ /**
12
+ * Splits an AsyncAPI 3.0 Multi Format Schema Object (`{ schemaFormat, schema }`)
13
+ * into its parts, passing a bare Schema Object through untouched.
14
+ *
15
+ * 3.0 moved `schemaFormat` off the message and onto the payload/headers wrapper,
16
+ * so this is where "is this even an AsyncAPI Schema Object?" gets answered for
17
+ * that major — the question 2.x answers once, on the message.
18
+ *
19
+ * An own `schema` key is what makes it a wrapper, which is exactly how the
20
+ * bundled 3.0 meta-schema decides (`anySchema.json`: `if: { required: ['schema']
21
+ * }`). `schemaFormat` is optional on the wrapper and defaults to the AsyncAPI
22
+ * dialect, so demanding it too would leave a plain `{ schema: … }` judged as a
23
+ * schema whose only keyword is one no dialect defines.
24
+ */
25
+ export declare const splitMultiFormatSchema: (node: unknown) => MultiFormatSchema;
@@ -0,0 +1,5 @@
1
+ import { isObject } from "./helpers.js";
2
+ const splitMultiFormatSchema = (node) => isObject(node) && Object.hasOwn(node, "schema") ? { schemaFormat: node["schemaFormat"], schema: node["schema"], path: ["schema"] } : { schemaFormat: void 0, schema: node, path: [] };
3
+ export {
4
+ splitMultiFormatSchema
5
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/lint",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "A fast, format-agnostic JSON/YAML style-guide linter with JSON Schema and custom rules.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -44,8 +44,10 @@
44
44
  "prepublishOnly": "node ../../scripts/check-publishable.mjs",
45
45
  "types:check": "tsgo -p . --noEmit",
46
46
  "test": "NODE_ENV=production vitest run --root ../.. packages/lint/",
47
- "prebench": "bun run --filter='@amritk/runtime-validators' --filter='@amritk/yaml' --filter='@amritk/resolve-refs' build",
48
- "bench": "bun run ./bench/run.ts"
47
+ "prebench": "bun run build && bun run --filter='@amritk/runtime-validators' --filter='@amritk/yaml' --filter='@amritk/resolve-refs' build",
48
+ "bench": "bun run ./bench/run.ts",
49
+ "prebench:node": "bun run build && bun run --filter='@amritk/runtime-validators' --filter='@amritk/yaml' --filter='@amritk/resolve-refs' build",
50
+ "bench:node": "node ./bench/run.ts"
49
51
  },
50
52
  "exports": {
51
53
  "./package.json": "./package.json",
@@ -67,7 +69,7 @@
67
69
  }
68
70
  },
69
71
  "dependencies": {
70
- "@amritk/runtime-validators": "^0.13.0",
72
+ "@amritk/runtime-validators": "^0.14.0",
71
73
  "@amritk/yaml": "^0.7.2",
72
74
  "jsonc-parser": "^3.3.1"
73
75
  },