@amritk/lint 0.4.8 → 0.5.1

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.
Files changed (107) hide show
  1. package/AI.md +73 -4
  2. package/README.md +65 -14
  3. package/dist/core/filter-expression.js +17 -4
  4. package/dist/core/filter.js +82 -35
  5. package/dist/core/glob.js +25 -31
  6. package/dist/core/jsonpath.d.ts +9 -1
  7. package/dist/core/jsonpath.js +74 -16
  8. package/dist/core/lint.d.ts +8 -1
  9. package/dist/core/lint.js +4 -18
  10. package/dist/core/order.d.ts +30 -0
  11. package/dist/core/order.js +36 -0
  12. package/dist/core/pointers.d.ts +3 -2
  13. package/dist/core/pointers.js +27 -18
  14. package/dist/core/ruleset.js +62 -20
  15. package/dist/core/runner.js +16 -23
  16. package/dist/core/severity.d.ts +11 -0
  17. package/dist/core/severity.js +13 -0
  18. package/dist/fix/apply.js +6 -1
  19. package/dist/fix/plugin.js +2 -1
  20. package/dist/functions/or.js +1 -1
  21. package/dist/functions/ref-index.d.ts +15 -0
  22. package/dist/functions/ref-index.js +33 -0
  23. package/dist/functions/schema.d.ts +15 -0
  24. package/dist/functions/schema.js +2 -0
  25. package/dist/functions/typed-enum.js +2 -1
  26. package/dist/functions/unreferenced-reusable-object.js +3 -20
  27. package/dist/functions/xor.js +1 -1
  28. package/dist/index.d.ts +16 -16
  29. package/dist/index.js +24 -93
  30. package/dist/rules/asyncapi/asyncapi.d.ts +3 -0
  31. package/dist/rules/asyncapi/asyncapi.js +563 -0
  32. package/dist/rules/asyncapi/formats.d.ts +23 -0
  33. package/dist/rules/asyncapi/formats.js +49 -0
  34. package/dist/rules/asyncapi/functions/aas-server-variables.d.ts +8 -0
  35. package/dist/rules/asyncapi/functions/aas-server-variables.js +5 -0
  36. package/dist/rules/asyncapi/functions/aas-tags-unique.d.ts +2 -0
  37. package/dist/rules/asyncapi/functions/aas-tags-unique.js +5 -0
  38. package/dist/rules/asyncapi/functions/asyncapi-channel-parameters.d.ts +7 -0
  39. package/dist/rules/asyncapi/functions/asyncapi-channel-parameters.js +46 -0
  40. package/dist/rules/asyncapi/functions/asyncapi-channel-servers.d.ts +27 -0
  41. package/dist/rules/asyncapi/functions/asyncapi-channel-servers.js +61 -0
  42. package/dist/rules/asyncapi/functions/asyncapi-document-schema.d.ts +17 -0
  43. package/dist/rules/asyncapi/functions/asyncapi-document-schema.js +12 -0
  44. package/dist/rules/asyncapi/functions/asyncapi-headers-object.d.ts +23 -0
  45. package/dist/rules/asyncapi/functions/asyncapi-headers-object.js +25 -0
  46. package/dist/rules/asyncapi/functions/asyncapi-message-examples.d.ts +8 -0
  47. package/dist/rules/asyncapi/functions/asyncapi-message-examples.js +42 -0
  48. package/dist/rules/asyncapi/functions/asyncapi-message-id-unique.d.ts +3 -0
  49. package/dist/rules/asyncapi/functions/asyncapi-message-id-unique.js +30 -0
  50. package/dist/rules/asyncapi/functions/asyncapi-operation-id-unique.d.ts +3 -0
  51. package/dist/rules/asyncapi/functions/asyncapi-operation-id-unique.js +30 -0
  52. package/dist/rules/asyncapi/functions/asyncapi-payload.d.ts +14 -0
  53. package/dist/rules/asyncapi/functions/asyncapi-payload.js +32 -0
  54. package/dist/rules/asyncapi/functions/asyncapi-schema-validation.d.ts +11 -0
  55. package/dist/rules/asyncapi/functions/asyncapi-schema-validation.js +17 -0
  56. package/dist/rules/asyncapi/functions/asyncapi-security.d.ts +22 -0
  57. package/dist/rules/asyncapi/functions/asyncapi-security.js +78 -0
  58. package/dist/rules/asyncapi/functions/helpers.d.ts +51 -0
  59. package/dist/rules/asyncapi/functions/helpers.js +91 -0
  60. package/dist/rules/asyncapi/functions/index.d.ts +15 -0
  61. package/dist/rules/asyncapi/functions/index.js +53 -0
  62. package/dist/rules/asyncapi/functions/pointer.d.ts +11 -0
  63. package/dist/rules/asyncapi/functions/pointer.js +11 -0
  64. package/dist/rules/asyncapi/functions/schema-format.d.ts +4 -0
  65. package/dist/rules/asyncapi/functions/schema-format.js +7 -0
  66. package/dist/rules/asyncapi/index.d.ts +33 -0
  67. package/dist/rules/asyncapi/index.js +60 -0
  68. package/dist/rules/asyncapi/schemas/aas20.d.ts +2 -0
  69. package/dist/rules/asyncapi/schemas/aas20.js +4 -0
  70. package/dist/rules/asyncapi/schemas/aas21.d.ts +2 -0
  71. package/dist/rules/asyncapi/schemas/aas21.js +4 -0
  72. package/dist/rules/asyncapi/schemas/aas22.d.ts +2 -0
  73. package/dist/rules/asyncapi/schemas/aas22.js +4 -0
  74. package/dist/rules/asyncapi/schemas/aas23.d.ts +2 -0
  75. package/dist/rules/asyncapi/schemas/aas23.js +4 -0
  76. package/dist/rules/asyncapi/schemas/aas24.d.ts +2 -0
  77. package/dist/rules/asyncapi/schemas/aas24.js +4 -0
  78. package/dist/rules/asyncapi/schemas/aas25.d.ts +2 -0
  79. package/dist/rules/asyncapi/schemas/aas25.js +4 -0
  80. package/dist/rules/asyncapi/schemas/aas26.d.ts +2 -0
  81. package/dist/rules/asyncapi/schemas/aas26.js +4 -0
  82. package/dist/rules/asyncapi/schemas/aas30.d.ts +2 -0
  83. package/dist/rules/asyncapi/schemas/aas30.js +4 -0
  84. package/dist/rules/asyncapi/schemas/index.d.ts +20 -0
  85. package/dist/rules/asyncapi/schemas/index.js +45 -0
  86. package/dist/rules/openapi/fixers.d.ts +6 -2
  87. package/dist/rules/openapi/fixers.js +2 -0
  88. package/dist/rules/openapi/functions/oas-discriminator.js +1 -1
  89. package/dist/rules/openapi/functions/oas-mutually-exclusive.js +1 -1
  90. package/dist/rules/openapi/functions/oas-op-security-defined.js +1 -1
  91. package/dist/rules/openapi/functions/oas-server-variables.d.ts +6 -6
  92. package/dist/rules/openapi/functions/oas-server-variables.js +2 -45
  93. package/dist/rules/openapi/functions/oas-tags-unique.d.ts +2 -3
  94. package/dist/rules/openapi/functions/oas-tags-unique.js +2 -16
  95. package/dist/rules/openapi/functions/oas-unused-component.js +3 -25
  96. package/dist/rules/openapi/index.d.ts +9 -2
  97. package/dist/rules/openapi/index.js +15 -70
  98. package/dist/rules/openapi/schemas/index.js +5 -1
  99. package/dist/rules/shared/helpers.d.ts +2 -0
  100. package/dist/rules/shared/helpers.js +4 -0
  101. package/dist/rules/shared/server-variables.d.ts +27 -0
  102. package/dist/rules/shared/server-variables.js +55 -0
  103. package/dist/rules/shared/tags-unique.d.ts +7 -0
  104. package/dist/rules/shared/tags-unique.js +19 -0
  105. package/dist/ruleset-files.d.ts +47 -0
  106. package/dist/ruleset-files.js +92 -0
  107. package/package.json +8 -4
package/AI.md CHANGED
@@ -20,6 +20,43 @@ const ruleset = {
20
20
  const findings = await lintDocument('version: 1\n', { ruleset, source: 'service.yaml' })
21
21
  ```
22
22
 
23
+ ## Presets: OpenAPI and AsyncAPI
24
+
25
+ The core engine knows no spec. Two ready-made rulesets live in subpaths — build
26
+ one and hand the **result** to `lintDocument`:
27
+
28
+ ```ts
29
+ import { lintDocument } from '@amritk/lint'
30
+ import { createAsyncApiRuleset } from '@amritk/lint/rules/asyncapi'
31
+ import { resolveRefs } from '@amritk/resolve-refs' // any resolver will do
32
+
33
+ // Recommended rules only: 48 of 56. For all 56, pass the `all` modifier:
34
+ // createAsyncApiRuleset({ extends: [['asyncapi', 'all']] })
35
+ const ruleset = createAsyncApiRuleset()
36
+
37
+ const findings = await lintDocument(source, {
38
+ ruleset,
39
+ source: 'asyncapi.yaml',
40
+ // Optional, and load-bearing — see the gotcha below.
41
+ resolve: (document) => ({ resolved: resolveRefs(document.data).resolved }),
42
+ })
43
+ ```
44
+
45
+ `createOpenApiRuleset()` is the same shape (66 rules, `oas` / `loupe:oas` /
46
+ `spectral:oas` in `extends`) and additionally ships `oasFixers` for
47
+ `fixDocument`. **There are no AsyncAPI fixers**, so `fixDocument` with an
48
+ AsyncAPI ruleset changes nothing.
49
+
50
+ Rule names match Spectral's, so a `.spectral.yml` that re-severities individual
51
+ rules ports over. Override by name in your own definition:
52
+
53
+ ```ts
54
+ createAsyncApiRuleset({
55
+ extends: [['asyncapi', 'all']],
56
+ rules: { 'asyncapi-info-license': 'off', 'asyncapi-tag-description': 'error' },
57
+ })
58
+ ```
59
+
23
60
  ## Gotchas — where agents fail
24
61
 
25
62
  1. **Two `severity` vocabularies.** In a **ruleset** you author strings
@@ -34,8 +71,15 @@ const findings = await lintDocument('version: 1\n', { ruleset, source: 'service.
34
71
  4. **`extends` targets are file paths or npm packages only** — there are no
35
72
  named built-in rulesets in core. String `extends` resolve relative to
36
73
  `rulesetBasePath` (or the ruleset file's own directory).
37
- 5. **OpenAPI support is a separate subpath**, `@amritk/lint/rules/openapi`
38
- (`createOpenApiRuleset`, `oas`, `oasFixers`, …) — not the package root.
74
+ 5. **OpenAPI and AsyncAPI support are separate subpaths**,
75
+ `@amritk/lint/rules/openapi` (`createOpenApiRuleset`, `oas`, `oasFixers`, …)
76
+ and `@amritk/lint/rules/asyncapi` (`createAsyncApiRuleset`, `asyncapi`, …) —
77
+ not the package root. Build one with `createOpenApiRuleset()` /
78
+ `createAsyncApiRuleset()` and hand the *result* to `lintDocument` /
79
+ `fixDocument` as `ruleset`. Passing the `oas` or `asyncapi` definition as data
80
+ instead silently produces nothing: its custom functions are unknown at the
81
+ package root, and its `formats` gate matches nothing without that spec's
82
+ detectors.
39
83
  6. **A ruleset is privileged; a document is not.** Linted documents cannot
40
84
  execute anything, and `[?(...)]` filters in a `given` are parsed and
41
85
  interpreted rather than evaluated as JavaScript. But `extends` follows any
@@ -47,10 +91,34 @@ const findings = await lintDocument('version: 1\n', { ruleset, source: 'service.
47
91
  7. **`createRuleset` is memoized** per `(definition object, basePath,
48
92
  restrictTo)`. Mutating a definition you already passed in will not rebuild the
49
93
  ruleset — pass a fresh object instead.
94
+ 8. **Most AsyncAPI rules are gated per major** (11 of the 56 apply to both).
95
+ The 3.x-only rules are named with an `asyncapi-3-` prefix
96
+ (`asyncapi-3-operation-description`) and the 2.x ones are not, because 3.0
97
+ moved operations to the top level and tags under `info`. Re-severitying
98
+ `asyncapi-operation-description` does nothing to a 3.0 document. The
99
+ structural rules pick their meta-schema from the `asyncapi` field, and a
100
+ version with no bundled schema (a future `2.7`, say) reports nothing rather
101
+ than being judged against a neighbouring version's.
102
+ 9. **Without a `resolve` hook, nothing behind a `$ref` is checked** — quietly.
103
+ The rules that validate schema *content* (payloads, headers, message
104
+ examples) need the dereferenced tree; given `payload: { $ref: … }` and no
105
+ resolver they see an opaque reference and stay silent rather than guess. You
106
+ get findings, just fewer of them, with no warning that a resolver would have
107
+ found more. Rules that read what the author wrote (addresses, server
108
+ variables, tag names) run unresolved by design and are unaffected.
109
+
110
+ The flip side: with a resolver, a rule that validates content matches a
111
+ reusable `components` definition once per `$ref` reaching it. Each match is
112
+ reported at the definition, so the copies are identical and only the first is
113
+ returned — you do not need to de-duplicate a report yourself. Two findings
114
+ that share a `line:column` are genuinely two problems (the classic case:
115
+ `info.contact` missing `name`, `url` and `email` all carry the `contact`
116
+ object's range), so key on `path` if you need to tell them apart.
50
117
 
51
118
  ## Exports
52
119
 
53
- - `lintDocument(input, options?)` → findings only.
120
+ - `lintDocument(input, options?)` → findings only. `options.ruleset` is either a
121
+ definition to build or an already-built `Ruleset`.
54
122
  - `lintDocumentWithResult(input, options?)` → `{ diagnostics, output?, pluginData }`.
55
123
  - `fixDocument(input, options?)` → `{ output, fixed, applied, remaining, converged, passes }`
56
124
  (needs `fixers`; `converged: false` means the 10-pass cap was hit with the
@@ -64,6 +132,7 @@ const findings = await lintDocument('version: 1\n', { ruleset, source: 'service.
64
132
  | Import | Purpose |
65
133
  |---|---|
66
134
  | `@amritk/lint` | core engine, `lintDocument`/`fixDocument`, built-in functions |
67
- | `@amritk/lint/rules/openapi` | ready-made OpenAPI preset (`createOpenApiRuleset`, `oas`) |
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 |
68
137
 
69
138
  Install: `bun add @amritk/lint`.
package/README.md CHANGED
@@ -21,7 +21,7 @@
21
21
 
22
22
  It is **format-agnostic**: the core engine ships no built-in ruleset and knows nothing about OpenAPI or any other schema — you bring the rules. This is JSON/YAML style-guide linting with JSON Schema and custom rules at its core.
23
23
 
24
- For OpenAPI specifically, the `@amritk/lint/rules/openapi` subpath ships a ready-made preset on top of that engine — see [OpenAPI ruleset](#openapi-ruleset) below.
24
+ For OpenAPI and AsyncAPI specifically, the `@amritk/lint/rules/openapi` and `@amritk/lint/rules/asyncapi` subpaths ship ready-made presets on top of that engine — see [OpenAPI ruleset](#openapi-ruleset) and [AsyncAPI ruleset](#asyncapi-ruleset) below.
25
25
 
26
26
  The CLI lives in the [`mjst`](../cli) binary as `mjst lint`; this package is the programmatic library behind it.
27
27
 
@@ -123,7 +123,9 @@ await lintDocument(source, {
123
123
  })
124
124
  ```
125
125
 
126
- `restrictTo` is off by default and narrows *which* files a ruleset can name. It is not a sandbox: a `.js` file inside the permitted root still runs with full privileges.
126
+ `restrictTo` is off by default and narrows *which* files a ruleset can name. It is not a sandbox: a `.js` file inside the permitted root still runs with full privileges. The OpenAPI preset takes the same option — `createOpenApiRuleset(definition, basePath, { restrictTo })`.
127
+
128
+ One thing `restrictTo` does not cover: a regular expression a ruleset writes — in `pattern`'s `match`/`notMatch`, or as a literal inside a `[?(...)]` filter — is compiled and run against text from the document. An ambiguous pattern (nested quantifiers over overlapping character classes) can backtrack catastrophically on an input crafted to trigger it, so a hostile *document* can hang the linter through a regex the *ruleset* provided. The built-in rules avoid such patterns deliberately (see the note in `casing`); keep the same discipline in rules you write, and prefer anchored patterns with unambiguous alternatives.
127
129
 
128
130
  ### Auto-fix
129
131
 
@@ -147,11 +149,23 @@ const { output, applied, remaining } = await fixDocument('host: api.example.com/
147
149
 
148
150
  The engine ships no built-in fixers (rule codes are yours to define), so the default registry is empty and `fixDocument` is a no-op until you supply one.
149
151
 
152
+ `options.ruleset` takes either a definition (built here with the built-in functions) or a `Ruleset` you built yourself. Pass a built one whenever the rules need functions or format detectors the package root does not have — an OpenAPI preset, for instance, whose rules would otherwise all be skipped as unknown-function/unmatched-format:
153
+
154
+ ```ts
155
+ import { fixDocument } from '@amritk/lint'
156
+ import { createOpenApiRuleset, oasFixers } from '@amritk/lint/rules/openapi'
157
+
158
+ const { output, applied } = await fixDocument(source, {
159
+ ruleset: createOpenApiRuleset(),
160
+ fixers: oasFixers,
161
+ })
162
+ ```
163
+
150
164
  Fixing runs to a fixpoint, capped at 10 passes. The result reports how that ended: **`converged`** is `false` when the cap was hit while the document was still changing (usually two fixers undoing each other), and **`passes`** counts the passes that changed something. `applied` is de-duplicated by rule code and path, so a report can safely say "fixed N problems".
151
165
 
152
166
  ### Rendering findings
153
167
 
154
- `lintDocument` returns structured `IDiagnostic[]` — each with a `code`, `message`, `path`, `severity`, `source`, and a zero-based `range`. **Rendering is the caller's job**: print them, serialize them to JSON, or map them to whatever your editor or CI consumes. The linter deliberately ships no output "formatter" layer (that is not the same thing as `prettier`/`biome format`, which reformat source).
168
+ `lintDocument` returns structured `IDiagnostic[]` — each with a `code`, `message`, `path`, `severity`, `source`, and a zero-based `range`. `source`, `path` and `range` all describe the same node in the same document: the one the author wrote. A rule that runs against the dereferenced tree matches inside an inlined `$ref` target, and its finding is reported at the declaration that target came from, not at the `$ref` site. Two `$ref`s to the same mistake therefore produce one finding, not two. **Rendering is the caller's job**: print them, serialize them to JSON, or map them to whatever your editor or CI consumes. The linter deliberately ships no output "formatter" layer (that is not the same thing as `prettier`/`biome format`, which reformat source).
155
169
 
156
170
  ```ts
157
171
  const findings = await lintDocument(source, { ruleset, source: 'doc.yaml' })
@@ -209,7 +223,7 @@ Anything outside that grammar is a ruleset error (`createRuleset` throws and nam
209
223
 
210
224
  | Export | What it does |
211
225
  | --- | --- |
212
- | `lintDocument(input, options?)` | Parse `input` and lint it against `options.ruleset`; returns `IDiagnostic[]`. |
226
+ | `lintDocument(input, options?)` | Parse `input` and lint it against `options.ruleset` — a definition, or a `Ruleset` you already built; returns `IDiagnostic[]`. |
213
227
  | `lintDocumentWithResult(input, options?)` | Like `lintDocument`, but returns `{ diagnostics, output?, pluginData }` (including any plugin's rewritten `output`). |
214
228
  | `fixDocument(input, options?)` | Lint and apply `options.fixers` to a fixpoint; returns `{ output, fixed, applied, remaining, converged, passes }`. |
215
229
  | `createRuleset(definition?, basePath?, options?)` | Normalize a ruleset definition into a runnable `Ruleset`, layering the built-in functions and resolving `extends`. Memoized per `(definition object, basePath, restrictTo)` — treat a definition you have passed in as frozen. |
@@ -233,16 +247,16 @@ const ruleset = createOpenApiRuleset()
233
247
  const findings = await lint(spec, { ruleset })
234
248
  ```
235
249
 
236
- `createOpenApiRuleset(definition?, basePath?)` builds a runnable `Ruleset` with the OpenAPI functions and format detectors layered over the built-ins, and with `extends` resolution that understands the `oas` / `loupe:oas` / `spectral:oas` names (the last two accepted so existing Spectral-style rulesets extend unchanged). Enable every rule with `createOpenApiRuleset({ extends: [['oas', 'all']] })`, or pass your own definition to override severities, add rules, or point `extends` at a file/npm package.
250
+ `createOpenApiRuleset(definition?, basePath?, options?)` builds a runnable `Ruleset` with the OpenAPI functions and format detectors layered over the built-ins, and with `extends` resolution that understands the `oas` / `loupe:oas` / `spectral:oas` names (the last two accepted so existing Spectral-style rulesets extend unchanged). Enable every rule with `createOpenApiRuleset({ extends: [['oas', 'all']] })`, or pass your own definition to override severities, add rules, or point `extends` at a file/npm package. `options.restrictTo` applies the same [trust boundary](#trust-boundary--who-may-write-a-ruleset) the package root's `restrictTo` does.
237
251
 
238
252
  | Export | What it does |
239
253
  | --- | --- |
240
- | `createOpenApiRuleset(definition?, basePath?)` | Build a runnable OpenAPI `Ruleset` (functions + formats + `extends` resolution). |
241
- | `resolveOpenApiRuleset(name, basePath?)` | Resolve an `extends` reference, including the `oas` / `loupe:oas` / `spectral:oas` names. |
254
+ | `createOpenApiRuleset(definition?, basePath?, options?)` | Build a runnable OpenAPI `Ruleset` (functions + formats + `extends` resolution). |
255
+ | `resolveOpenApiRuleset(name, basePath?, options?)` | Resolve an `extends` reference, including the `oas` / `loupe:oas` / `spectral:oas` names. |
242
256
  | `oas` | The built-in OpenAPI ruleset definition. |
243
257
  | `oasFunctions` / `allFunctions` | The OpenAPI-specific functions; `allFunctions` = built-ins + OpenAPI. |
244
258
  | `oasFormats` | OpenAPI version detectors (`oas2`, `oas3`, `oas3.0`, `oas3.1`, `oas3.2`). |
245
- | `oasFixers` | Auto-fixers for the mechanically-repairable OpenAPI rules (pass to `fixDocument`). |
259
+ | `oasFixers` | Auto-fixers for the mechanically-repairable OpenAPI rules (pass to `fixDocument` alongside a built OpenAPI ruleset). |
246
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). |
247
261
 
248
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.
@@ -251,21 +265,58 @@ The structural rules validate against the **official `spec.openapis.org` meta-sc
251
265
 
252
266
  ---
253
267
 
268
+ ## AsyncAPI ruleset
269
+
270
+ The same treatment for event-driven APIs. The **`@amritk/lint/rules/asyncapi`** subpath ships a preset covering **AsyncAPI 2.0–2.6 and 3.0**, layered on the same engine and adding **no dependencies**.
271
+
272
+ ```ts
273
+ import { lint } from '@amritk/lint'
274
+ import { createAsyncApiRuleset } from '@amritk/lint/rules/asyncapi'
275
+
276
+ // Defaults to `extends: [asyncapi]` (recommended rules only, like `spectral:asyncapi`).
277
+ const ruleset = createAsyncApiRuleset()
278
+ const findings = await lint(document, { ruleset })
279
+ ```
280
+
281
+ `createAsyncApiRuleset(definition?, basePath?, options?)` mirrors its OpenAPI counterpart: functions and format detectors layered over the built-ins, and `extends` resolution that understands the `asyncapi` / `loupe:asyncapi` / `spectral:asyncapi` names (the last two so existing Spectral-style rulesets extend unchanged). Enable every rule with `createAsyncApiRuleset({ extends: [['asyncapi', 'all']] })`. `options.restrictTo` applies the same [trust boundary](#trust-boundary--who-may-write-a-ruleset).
282
+
283
+ | Export | What it does |
284
+ | --- | --- |
285
+ | `createAsyncApiRuleset(definition?, basePath?, options?)` | Build a runnable AsyncAPI `Ruleset` (functions + formats + `extends` resolution). |
286
+ | `resolveAsyncApiRuleset(name, basePath?, options?)` | Resolve an `extends` reference, including the `asyncapi` / `loupe:asyncapi` / `spectral:asyncapi` names. |
287
+ | `asyncapi` | The built-in AsyncAPI ruleset definition. |
288
+ | `aasFunctions` / `allFunctions` | The AsyncAPI-specific functions; `allFunctions` = built-ins + AsyncAPI. |
289
+ | `aasFormats` | AsyncAPI version detectors (`aas2`, `aas2.0`–`aas2.6`, `aas3`, `aas3.0`). |
290
+ | `loadAsyncApiSchema(version)` | Lazily load one version's official structural meta-schema (`'2.0'`–`'2.6'`, `'3.0'`). See [`schemas/README.md`](./src/rules/asyncapi/schemas/README.md). |
291
+ | `ASYNCAPI_VERSIONS` / `LATEST_ASYNCAPI_VERSION` / `asyncApiSchemaVersion(declared)` | The bundled versions, the newest one, and the mapping from a document's `asyncapi` field to the minor whose schema covers it (`'2.6.4'` → `'2.6'`; an unbundled minor → `undefined`). |
292
+
293
+ Both majors are covered, and the rules are gated by format so a 2.x document never picks up a 3.x rule. The 3.0-specific ones carry an `asyncapi-3-` prefix, matching Spectral's names so a `.spectral.yml` that re-severities individual rules keeps working.
294
+
295
+ The structural rules validate against the **official [`asyncapi/spec-json-schemas`](https://github.com/asyncapi/spec-json-schemas) meta-schemas, vendored as raw `.json`** ([`schemas/`](./src/rules/asyncapi/schemas/)). These are draft-07 documents that reference their own subschemas by absolute `$id` URI rather than by JSON Pointer — nothing is fetched, because every target is declared inside the same document. Each file is byte-for-byte upstream apart from **three regular expressions**, one of which is genuinely exponential on a failing input; each is replaced by a provably equivalent pattern with a single unambiguous quantifier, and the test suite asserts the equivalence over a generated corpus. See the [schemas README](./src/rules/asyncapi/schemas/README.md).
296
+
297
+ Structural validation runs **once per document, against the document as written** (`resolved: false`), matching the `oas*-schema` rules above. That is a deliberate choice rather than an oversight: validating the dereferenced tree instead re-checks every `components` entry once per `$ref` that reaches it, so a single mistake in a reusable message reported three times in a document that used it twice. The trade-off is what a `$ref` hides: content pulled in from another file is not structurally checked, and neither is a same-file reference that points at the wrong kind of object (a `messages` entry aimed at `components.schemas`, say) — the reference itself is well-formed, and only the dereferenced tree would show the mismatch. That is the same gap the OpenAPI preset has. Everything written in the document itself, `components` included, is validated exactly once.
298
+
299
+ ---
300
+
254
301
  ## Benchmarks
255
302
 
256
- 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.3, 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). Representative numbers (Bun 1.4, Linux x64 — your hardware will differ, run `bun run bench` yourself):
257
304
 
258
305
  | document | size | mjst | Spectral | speedup | findings (mjst / Spectral) |
259
306
  | --- | ---: | ---: | ---: | ---: | ---: |
260
- | petstore (Swagger) | 17 KB | ~8 ms | ~93 ms | **~11×** | 2 / 2 |
261
- | digitalocean | 105 KB | ~31 ms | ~351 ms | **~11×** | 2411 / 4319 |
262
- | openai | 2.8 MB | ~1.4 s | errored¹ | | 1278 / |
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 |
263
310
 
264
- ¹ Spectral's JSONPath engine (`nimma`) throws on the 2.8 MB OpenAI spec under Bun, so that row is mjst-only; mjst lints it end to end.
311
+ An earlier revision of this table reported the OpenAI row as mjst-only, because
312
+ Spectral's JSONPath engine (`nimma`) threw on that spec under Bun. It no longer
313
+ does, so the row is a real comparison now. The bench still guards the Spectral
314
+ side and reports `errored` rather than aborting the run, since that failure was
315
+ runtime-specific and may come back.
265
316
 
266
317
  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.
267
318
 
268
- **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.27 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.
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.
269
320
 
270
321
  ---
271
322
 
@@ -1,3 +1,4 @@
1
+ const MAX_FILTER_DEPTH = 100;
1
2
  const CONTEXT_TOKENS = {
2
3
  "@": "value",
3
4
  "@property": "property",
@@ -161,15 +162,20 @@ const ALLOWED_METHODS = /* @__PURE__ */ new Set([
161
162
  "trim"
162
163
  ]);
163
164
  const parseFilterExpression = (source) => {
165
+ const quoted = source.length > 120 ? `${source.slice(0, 120)}\u2026` : source;
166
+ const describe = (error) => {
167
+ const syntax = error;
168
+ return { error: `${syntax.message} at offset ${syntax.offset} in filter "${quoted}"` };
169
+ };
164
170
  let tokens;
165
171
  try {
166
172
  tokens = tokenize(source);
167
173
  } catch (error) {
168
- const syntax = error;
169
- return { error: `${syntax.message} at offset ${syntax.offset} in filter "${source}"` };
174
+ return describe(error);
170
175
  }
171
176
  let position = 0;
172
177
  let usesPath = false;
178
+ let depth = 0;
173
179
  const peek = () => tokens[position];
174
180
  const offsetOf = (token) => token?.start ?? source.length;
175
181
  const eatPunct = (text) => {
@@ -255,6 +261,14 @@ const parseFilterExpression = (source) => {
255
261
  return fail(`Unexpected token "${token.text}"`, token.start);
256
262
  };
257
263
  const parseUnary = () => {
264
+ if (++depth > MAX_FILTER_DEPTH) {
265
+ fail(`Filter expression nests deeper than ${MAX_FILTER_DEPTH} levels`, offsetOf(peek()));
266
+ }
267
+ const node = parseUnaryInner();
268
+ depth--;
269
+ return node;
270
+ };
271
+ const parseUnaryInner = () => {
258
272
  const token = peek();
259
273
  if (token?.kind === "punct" && (token.text === "!" || token.text === "-")) {
260
274
  position++;
@@ -311,8 +325,7 @@ const parseFilterExpression = (source) => {
311
325
  fail(`Unexpected token "${"text" in trailing ? trailing.text : ""}"`, trailing.start);
312
326
  return { node, usesPath };
313
327
  } catch (error) {
314
- const syntax = error;
315
- return { error: `${syntax.message} at offset ${syntax.offset} in filter "${source}"` };
328
+ return describe(error);
316
329
  }
317
330
  };
318
331
  export {
@@ -19,7 +19,7 @@ const readMember = (object, name) => {
19
19
  return Number.isInteger(index) ? object[index] : void 0;
20
20
  }
21
21
  if (isPlainObject(object))
22
- return Object.hasOwn(object, name) ? object[name] : void 0;
22
+ return Object.prototype.propertyIsEnumerable.call(object, name) ? object[name] : void 0;
23
23
  return void 0;
24
24
  };
25
25
  const toStringArgument = (value) => {
@@ -129,63 +129,110 @@ const compareRelational = (operator, left, right) => {
129
129
  return a >= b;
130
130
  }
131
131
  };
132
- const evaluate = (node, context) => {
132
+ const compileNode = (node) => {
133
133
  switch (node.kind) {
134
134
  case "literal":
135
- return node.value;
136
- case "regex":
137
- return node.value;
138
- case "context":
139
- return context[node.ref];
140
- case "member":
141
- return readMember(evaluate(node.object, context), node.name);
142
- case "computed": {
143
- const index = evaluate(node.index, context);
144
- if (typeof index !== "string" && typeof index !== "number") {
145
- throw filterRuntimeError("A computed member must be a string or a number");
135
+ case "regex": {
136
+ const constant = node.value;
137
+ return () => constant;
138
+ }
139
+ case "context": {
140
+ switch (node.ref) {
141
+ case "value":
142
+ return (value) => value;
143
+ case "property":
144
+ return (_value, property) => property;
145
+ case "parent":
146
+ return (_value, _property, parent) => parent;
147
+ case "parentProperty":
148
+ return (_value, _property, _parent, parentProperty) => parentProperty;
149
+ case "path":
150
+ return (_value, _property, _parent, _parentProperty, path) => path;
151
+ default:
152
+ return (_value, _property, _parent, _parentProperty, _path, root) => root;
146
153
  }
147
- return readMember(evaluate(node.object, context), index);
148
154
  }
149
- case "call":
150
- return callMethod(evaluate(node.object, context), node.name, node.args.map((argument) => evaluate(argument, context)));
155
+ case "member": {
156
+ const object = compileNode(node.object);
157
+ const name = node.name;
158
+ return (value, property, parent, parentProperty, path, root) => readMember(object(value, property, parent, parentProperty, path, root), name);
159
+ }
160
+ case "computed": {
161
+ const object = compileNode(node.object);
162
+ const index = compileNode(node.index);
163
+ return (value, property, parent, parentProperty, path, root) => {
164
+ const key = index(value, property, parent, parentProperty, path, root);
165
+ if (typeof key !== "string" && typeof key !== "number") {
166
+ throw filterRuntimeError("A computed member must be a string or a number");
167
+ }
168
+ return readMember(object(value, property, parent, parentProperty, path, root), key);
169
+ };
170
+ }
171
+ case "call": {
172
+ const object = compileNode(node.object);
173
+ const name = node.name;
174
+ const args = node.args.map(compileNode);
175
+ const arity = args.length;
176
+ return (value, property, parent, parentProperty, path, root) => {
177
+ const values = new Array(arity);
178
+ for (let i = 0; i < arity; i++) {
179
+ values[i] = args[i](value, property, parent, parentProperty, path, root);
180
+ }
181
+ return callMethod(object(value, property, parent, parentProperty, path, root), name, values);
182
+ };
183
+ }
151
184
  case "unary": {
152
- if (node.operator === "!")
153
- return !evaluate(node.operand, context);
185
+ const operand = compileNode(node.operand);
186
+ if (node.operator === "!") {
187
+ return (value, property, parent, parentProperty, path, root) => !operand(value, property, parent, parentProperty, path, root);
188
+ }
154
189
  if (node.operator === "void") {
155
- evaluate(node.operand, context);
156
- return void 0;
190
+ return (value, property, parent, parentProperty, path, root) => {
191
+ operand(value, property, parent, parentProperty, path, root);
192
+ return void 0;
193
+ };
157
194
  }
158
- return -Number(evaluate(node.operand, context));
195
+ return (value, property, parent, parentProperty, path, root) => -Number(operand(value, property, parent, parentProperty, path, root));
159
196
  }
160
197
  case "logical": {
161
- const left = evaluate(node.left, context);
162
- if (node.operator === "&&")
163
- return left ? evaluate(node.right, context) : left;
164
- return left ? left : evaluate(node.right, context);
198
+ const left = compileNode(node.left);
199
+ const right = compileNode(node.right);
200
+ if (node.operator === "&&") {
201
+ return (value, property, parent, parentProperty, path, root) => {
202
+ const outcome = left(value, property, parent, parentProperty, path, root);
203
+ return outcome ? right(value, property, parent, parentProperty, path, root) : outcome;
204
+ };
205
+ }
206
+ return (value, property, parent, parentProperty, path, root) => {
207
+ const outcome = left(value, property, parent, parentProperty, path, root);
208
+ return outcome ? outcome : right(value, property, parent, parentProperty, path, root);
209
+ };
165
210
  }
166
211
  case "comparison": {
167
- const left = evaluate(node.left, context);
168
- const right = evaluate(node.right, context);
212
+ const left = compileNode(node.left);
213
+ const right = compileNode(node.right);
169
214
  switch (node.operator) {
170
215
  case "===":
171
- return left === right;
216
+ return (value, property, parent, parentProperty, path, root) => left(value, property, parent, parentProperty, path, root) === right(value, property, parent, parentProperty, path, root);
172
217
  case "!==":
173
- return left !== right;
218
+ return (value, property, parent, parentProperty, path, root) => left(value, property, parent, parentProperty, path, root) !== right(value, property, parent, parentProperty, path, root);
174
219
  case "==":
175
- return looseEquals(left, right);
220
+ return (value, property, parent, parentProperty, path, root) => looseEquals(left(value, property, parent, parentProperty, path, root), right(value, property, parent, parentProperty, path, root));
176
221
  case "!=":
177
- return !looseEquals(left, right);
178
- default:
179
- return compareRelational(node.operator, left, right);
222
+ return (value, property, parent, parentProperty, path, root) => !looseEquals(left(value, property, parent, parentProperty, path, root), right(value, property, parent, parentProperty, path, root));
223
+ default: {
224
+ const operator = node.operator;
225
+ return (value, property, parent, parentProperty, path, root) => compareRelational(operator, left(value, property, parent, parentProperty, path, root), right(value, property, parent, parentProperty, path, root));
226
+ }
180
227
  }
181
228
  }
182
229
  }
183
230
  };
184
231
  const toFilterFn = (parsed) => {
185
- const { node } = parsed;
232
+ const test = compileNode(parsed.node);
186
233
  return (value, property, parent, root, path, parentProperty) => {
187
234
  try {
188
- return Boolean(evaluate(node, { value, property, parent, parentProperty, path, root }));
235
+ return Boolean(test(value, property, parent, parentProperty, path, root));
189
236
  } catch {
190
237
  return false;
191
238
  }
package/dist/core/glob.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createBoundedCache } from "./bounded-cache.js";
1
2
  const REGEXP_SPECIAL = /[\\^$.*+?()[\]{}|/]/;
2
3
  const splitTopLevel = (body) => {
3
4
  const parts = [];
@@ -18,46 +19,40 @@ const splitTopLevel = (body) => {
18
19
  parts.push(current);
19
20
  return parts;
20
21
  };
21
- const expandBraces = (glob) => {
22
- const open = glob.indexOf("{");
23
- if (open === -1)
24
- return [glob];
22
+ const findBraceEnd = (glob, open) => {
25
23
  let depth = 0;
26
- let close = -1;
27
24
  for (let i = open; i < glob.length; i++) {
28
25
  if (glob[i] === "{")
29
26
  depth++;
30
27
  else if (glob[i] === "}") {
31
28
  depth--;
32
- if (depth === 0) {
33
- close = i;
34
- break;
35
- }
36
- }
37
- }
38
- if (close === -1)
39
- return [glob];
40
- const prefix = glob.slice(0, open);
41
- const body = glob.slice(open + 1, close);
42
- const suffix = glob.slice(close + 1);
43
- const options = splitTopLevel(body);
44
- if (options.length === 1) {
45
- return expandBraces(suffix).map((rest) => `${prefix}{${body}}${rest}`);
46
- }
47
- const results = [];
48
- for (const option of options) {
49
- for (const expandedOption of expandBraces(option)) {
50
- for (const expandedSuffix of expandBraces(suffix)) {
51
- results.push(prefix + expandedOption + expandedSuffix);
52
- }
29
+ if (depth === 0)
30
+ return i;
53
31
  }
54
32
  }
55
- return results;
33
+ return -1;
56
34
  };
57
- const globInnerSource = (glob) => {
35
+ const globSource = (glob, braces) => {
58
36
  let source = "";
59
37
  for (let i = 0; i < glob.length; i++) {
60
38
  const char = glob[i];
39
+ if (char === "{" && braces) {
40
+ const close = findBraceEnd(glob, i);
41
+ if (close === -1)
42
+ return source + globSource(glob.slice(i), false);
43
+ const body = glob.slice(i + 1, close);
44
+ const options = splitTopLevel(body);
45
+ if (options.length === 1) {
46
+ source += `\\{${globSource(body, false)}\\}`;
47
+ i = close;
48
+ continue;
49
+ }
50
+ const followedBySlash = glob[close + 1] === "/";
51
+ const compile = (option) => globSource(followedBySlash ? `${option}/` : option, true);
52
+ source += `(?:${options.map(compile).join("|")})`;
53
+ i = followedBySlash ? close + 1 : close;
54
+ continue;
55
+ }
61
56
  if (char === "*") {
62
57
  if (glob[i + 1] === "*") {
63
58
  i++;
@@ -80,13 +75,12 @@ const globInnerSource = (glob) => {
80
75
  }
81
76
  return source;
82
77
  };
83
- const regexCache = /* @__PURE__ */ new Map();
78
+ const regexCache = createBoundedCache(500);
84
79
  const globToRegExp = (glob) => {
85
80
  const cached = regexCache.get(glob);
86
81
  if (cached)
87
82
  return cached;
88
- const alternatives = expandBraces(glob).map(globInnerSource);
89
- const regex = new RegExp(`^(?:${alternatives.join("|")})$`);
83
+ const regex = new RegExp(`^(?:${globSource(glob, true)})$`);
90
84
  regexCache.set(glob, regex);
91
85
  return regex;
92
86
  };
@@ -5,9 +5,17 @@ export type IQueryMatch = {
5
5
  value: unknown;
6
6
  path: JsonPath;
7
7
  };
8
- type Selector = {
8
+ type Selector =
9
+ /**
10
+ * `.name` / `['name']`. `index` is the same name read as an array index, or
11
+ * `undefined` when it is not all digits — precomputed here because the check
12
+ * used to run as a regex against the *same* name on every array node the
13
+ * descent reached.
14
+ */
15
+ {
9
16
  kind: 'child';
10
17
  name: string;
18
+ index: number | undefined;
11
19
  } | {
12
20
  kind: 'index';
13
21
  index: number;