@amritk/lint 0.7.0 → 0.8.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
@@ -118,7 +118,12 @@ createAsyncApiRuleset({
118
118
  ## Exports
119
119
 
120
120
  - `lintDocument(input, options?)` → findings only. `options.ruleset` is either a
121
- definition to build or an already-built `Ruleset`.
121
+ definition to build or an already-built `Ruleset`. `input` is the text, or
122
+ `{ data, text? }` when the caller has already parsed it: the parse is skipped
123
+ (so no parser diagnostics) and `text` is read only for finding ranges (zero
124
+ ranges without it).
125
+ - A rule's `descent: 'schema'` keeps `..` out of `example`, `examples`,
126
+ `default`, `const` and `enum` values. Use it for rules about schemas.
122
127
  - `lintDocumentWithResult(input, options?)` → `{ diagnostics, output?, pluginData }`.
123
128
  - `fixDocument(input, options?)` → `{ output, fixed, applied, remaining, converged, passes }`
124
129
  (needs `fixers`; `converged: false` means the 10-pass cap was hit with the
package/README.md CHANGED
@@ -62,6 +62,16 @@ const findings = await lintDocument('version: 1\n', { ruleset, source: 'service.
62
62
 
63
63
  `severity` is a `DiagnosticSeverity` — `0` error, `1` warning, `2` info, `3` hint — and `range` is a zero-based `{ line, character }` span you can render a squiggle from.
64
64
 
65
+ ### Lint data you already parsed
66
+
67
+ A caller that has the document parsed already — a loader that read and normalized it — passes the data instead of the text, and the parse is skipped:
68
+
69
+ ```ts
70
+ const findings = await lintDocument({ data: spec, text: source }, { ruleset, source: 'openapi.json' })
71
+ ```
72
+
73
+ `text` is optional and only read to put `line:column` ranges on findings, lazily: JSON text is scanned for just the paths that carry findings, YAML text is parsed on the first lookup. A path the caller's normalization added resolves to its closest ancestor in the text, and without `text` every range is the zero range. Since the data is not parsed, there are no parser diagnostics: `parserOptions` (`duplicateKeys`, `incompatibleValues`) and syntax errors are only reported when linting the text. Plugins that rewrite the document (auto-fix) need `text`. `createDocumentFromData(data, { text })` builds the same document for the lower-level `createLinter(ruleset).run()`.
74
+
65
75
  ### Validate against a JSON Schema
66
76
 
67
77
  The built-in `schema` function runs an arbitrary JSON Schema (Draft 2020-12) over the matched node, via [`@amritk/runtime-validators`](../runtime-validators):
@@ -129,7 +139,7 @@ One thing `restrictTo` does not cover: a regular expression a ruleset writes —
129
139
 
130
140
  ### Auto-fix
131
141
 
132
- `fixDocument` runs the linter and applies a `FixerRegistry` — fixers keyed by rule `code` that map a finding to a formatting-preserving text edit — to a fixpoint, then re-lints:
142
+ `fixDocument` runs the linter and applies a `FixerRegistry` — fixers keyed by rule `code` that map a finding to a formatting-preserving text edit — to a fixpoint, then reports the findings that remain:
133
143
 
134
144
  ```ts
135
145
  import { fixDocument, type FixerRegistry } from '@amritk/lint'
@@ -161,7 +171,7 @@ const { output, applied } = await fixDocument(source, {
161
171
  })
162
172
  ```
163
173
 
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".
174
+ Fixing runs to a fixpoint, capped at 10 passes. `remaining` comes from the pass that found nothing left to fix, so a run that converges does not lint the fixed document a second time; only one that hits the cap does. 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".
165
175
 
166
176
  ### Rendering findings
167
177
 
@@ -193,12 +203,13 @@ A ruleset is a plain object (authored as YAML, JSON, or a JS module):
193
203
 
194
204
  | Field | Description |
195
205
  | --- | --- |
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`. |
206
+ | `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), `descent` (default `all`; `schema` keeps `..` out of `example`, `examples`, `default`, `const` and `enum` values, so a rule about schemas does not match an example payload), and `documentationUrl`. |
197
207
  | `then` | `{ function, field?, functionOptions? }` — `field` narrows the match to a child (`@key` targets the property name). |
198
208
  | `extends` | A ruleset (or list) to inherit rules from: a file path or npm package. `[target, 'recommended' \| 'all' \| 'off']` controls what it contributes. |
199
209
  | `functions` / `functionsDir` | Custom functions to load by name (default dir `functions/`). |
200
210
  | `overrides` | Per-file-glob rule tweaks. |
201
211
  | `aliases` | Reusable `given` fragments referenced as `#alias`. |
212
+ | `parserOptions` | YAML parser strictness, each set to a severity. `duplicateKeys` reports a repeated key (default `error`; `off` allows it, and the last value wins). `incompatibleValues` is off by default and reports `.nan` / `.inf` / `-.inf`, which `JSON.stringify` silently turns into `null`. Parser findings carry the code `parser`. |
202
213
 
203
214
  Built-in functions: `alphabetical`, `casing`, `defined`, `enumeration`, `falsy`, `length`, `or`, `pattern`, `schema`, `truthy`, `undefined`, `unreferencedReusableObject`, `xor`, `typedEnum`.
204
215
 
@@ -223,7 +234,7 @@ Anything outside that grammar is a ruleset error (`createRuleset` throws and nam
223
234
 
224
235
  | Export | What it does |
225
236
  | --- | --- |
226
- | `lintDocument(input, options?)` | Parse `input` and lint it against `options.ruleset` — a definition, or a `Ruleset` you already built; returns `IDiagnostic[]`. |
237
+ | `lintDocument(input, options?)` | Parse `input` and lint it against `options.ruleset` — a definition, or a `Ruleset` you already built; returns `IDiagnostic[]`. `input` is the text, or `{ data, text? }` for a document already parsed. |
227
238
  | `lintDocumentWithResult(input, options?)` | Like `lintDocument`, but returns `{ diagnostics, output?, pluginData }` (including any plugin's rewritten `output`). |
228
239
  | `fixDocument(input, options?)` | Lint and apply `options.fixers` to a fixpoint; returns `{ output, fixed, applied, remaining, converged, passes }`. |
229
240
  | `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. |
@@ -324,6 +335,19 @@ runtime-specific and may come back.
324
335
 
325
336
  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.
326
337
 
338
+ **Large documents.** `bun run bench:large <document.json>` times each phase of a lint run on its own, against any large OpenAPI document (or a synthetic one built from the fixtures when none is given), and fingerprints the findings so two runs can be checked for identical output. On a 26 MB public OpenAPI 3.0 document (3,700 operations, 7,100 component schemas; Bun 1.4.2, 4 vCPUs), medians:
339
+
340
+ | phase | before | after |
341
+ | --- | ---: | ---: |
342
+ | `createDocument` (source-mapped parse) | 1,342 ms | 129 ms |
343
+ | three `$.components.schemas..[?(...)]` rules on parsed data | 608 ms | ~36–60 ms |
344
+ | the same checks as a hand-written walk | 24 ms | 24 ms |
345
+ | `lint(text)`, three rules | 1,570 ms | ~290 ms |
346
+ | `lint({ data, text })`, three rules | — | ~145 ms |
347
+ | recommended OpenAPI preset, `$ref`s resolved | 5.5 s | 2.8 s |
348
+
349
+ The findings — paths, ranges, messages — are byte-identical before and after.
350
+
327
351
  **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.
328
352
 
329
353
  ---
@@ -17,3 +17,28 @@ export type Document<T = unknown> = {
17
17
  };
18
18
  /** Parses `input` into a {@link Document} with a source map for position lookups. */
19
19
  export declare const createDocument: <T = unknown>(input: string, options?: IDocumentOptions) => Document<T>;
20
+ /** Options for {@link createDocumentFromData}: where the data came from, if the caller still has it. */
21
+ export type IDataDocumentOptions = {
22
+ /** Display path carried onto findings (e.g. a path relative to cwd, or a URL). */
23
+ source?: string;
24
+ /**
25
+ * The text the data was parsed from. Findings get line:column ranges from
26
+ * it; without it every range is the zero range. Paths that the caller's own
27
+ * normalization moved resolve to the closest ancestor the text has.
28
+ */
29
+ text?: string;
30
+ /** The format of `text`; detected from its first character when omitted. */
31
+ format?: ParserFormat;
32
+ };
33
+ /**
34
+ * Wraps data a caller has already parsed in a {@link Document}, so a lint run
35
+ * does not parse the text a second time.
36
+ *
37
+ * Positions are worked out only when asked for, which in a lint run means
38
+ * only for the nodes that carry findings. For JSON text that is a scan of
39
+ * just the containers on the way to each path; YAML text is parsed on the
40
+ * first request (YAML positions cannot be found without a parse). The
41
+ * document reports no parser diagnostics: the caller's own parse already had
42
+ * its say on the text.
43
+ */
44
+ export declare const createDocumentFromData: <T = unknown>(data: T, options?: IDataDocumentOptions) => Document<T>;
@@ -1,4 +1,4 @@
1
- import { parseWithPointers } from "../parsers/index.js";
1
+ import { createJsonLocator, detectFormat, parseWithPointers, parseYaml } from "../parsers/index.js";
2
2
  const createDocument = (input, options = {}) => {
3
3
  const parsed = parseWithPointers(input, options);
4
4
  return {
@@ -8,6 +8,21 @@ const createDocument = (input, options = {}) => {
8
8
  getLocationForJsonPath: (path, closest = false) => parsed.getLocationForJsonPath(path, closest)
9
9
  };
10
10
  };
11
+ const createDocumentFromData = (data, options = {}) => {
12
+ const { text } = options;
13
+ let locate;
14
+ const getLocationForJsonPath = (path, closest = false) => {
15
+ if (text === void 0)
16
+ return void 0;
17
+ if (locate === void 0) {
18
+ const format = options.format ?? detectFormat(text);
19
+ locate = format === "json" ? createJsonLocator(text) : parseYaml(text).getLocationForJsonPath;
20
+ }
21
+ return locate(path, closest);
22
+ };
23
+ return { source: options.source, data, diagnostics: [], getLocationForJsonPath };
24
+ };
11
25
  export {
12
- createDocument
26
+ createDocument,
27
+ createDocumentFromData
13
28
  };
@@ -11,6 +11,12 @@ export type FilterFn = (value: unknown, property: string | number | undefined, p
11
11
  export type CompiledFilter = {
12
12
  readonly test: FilterFn;
13
13
  readonly usesPath: boolean;
14
+ /**
15
+ * True when the filter can only match a plain object, worked out from its
16
+ * shape (see {@link matchesOnlyObjects}). A walk may then skip calling it
17
+ * on scalars and arrays, which on a typical document are most nodes.
18
+ */
19
+ readonly objectsOnly: boolean;
14
20
  } | {
15
21
  readonly error: string;
16
22
  };
@@ -1,10 +1,24 @@
1
1
  import { createBoundedCache } from "./bounded-cache.js";
2
2
  import { parseFilterExpression } from "./filter-expression.js";
3
- const filterRuntimeError = (message) => new Error(message);
4
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3
+ let failed = false;
4
+ const fail = () => {
5
+ failed = true;
6
+ return void 0;
7
+ };
5
8
  const readMember = (object, name) => {
6
- if (object === null || object === void 0) {
7
- throw filterRuntimeError(`Cannot read "${name}" of ${object === null ? "null" : "undefined"}`);
9
+ if (object === null || object === void 0)
10
+ return fail();
11
+ if (typeof object === "object") {
12
+ if (Array.isArray(object)) {
13
+ if (name === "length")
14
+ return object.length;
15
+ const index = typeof name === "number" ? name : Number(name);
16
+ return Number.isInteger(index) ? object[index] : void 0;
17
+ }
18
+ const member = object[name];
19
+ if (member === void 0)
20
+ return void 0;
21
+ return Object.prototype.propertyIsEnumerable.call(object, name) ? member : void 0;
8
22
  }
9
23
  if (typeof object === "string") {
10
24
  if (name === "length")
@@ -12,14 +26,6 @@ const readMember = (object, name) => {
12
26
  const index = typeof name === "number" ? name : Number(name);
13
27
  return Number.isInteger(index) ? object[index] : void 0;
14
28
  }
15
- if (Array.isArray(object)) {
16
- if (name === "length")
17
- return object.length;
18
- const index = typeof name === "number" ? name : Number(name);
19
- return Number.isInteger(index) ? object[index] : void 0;
20
- }
21
- if (isPlainObject(object))
22
- return Object.prototype.propertyIsEnumerable.call(object, name) ? object[name] : void 0;
23
29
  return void 0;
24
30
  };
25
31
  const toStringArgument = (value) => {
@@ -28,14 +34,14 @@ const toStringArgument = (value) => {
28
34
  if (typeof value === "number" || typeof value === "boolean" || value === null || value === void 0) {
29
35
  return String(value);
30
36
  }
31
- throw filterRuntimeError("Expected a string-like argument");
37
+ return fail();
32
38
  };
33
39
  const toRegExpArgument = (value) => {
34
40
  if (value instanceof RegExp)
35
41
  return value;
36
42
  if (typeof value === "string")
37
43
  return new RegExp(value);
38
- throw filterRuntimeError("Expected a regular expression argument");
44
+ return fail();
39
45
  };
40
46
  const callMethod = (receiver, name, args) => {
41
47
  const [first] = args;
@@ -47,47 +53,47 @@ const callMethod = (receiver, name, args) => {
47
53
  }
48
54
  if (Array.isArray(receiver))
49
55
  return name === "indexOf" ? receiver.indexOf(first) : receiver.lastIndexOf(first);
50
- throw filterRuntimeError(`"${name}" is not a function`);
56
+ return fail();
51
57
  }
52
58
  case "includes": {
53
59
  if (typeof receiver === "string")
54
60
  return receiver.includes(toStringArgument(first));
55
61
  if (Array.isArray(receiver))
56
62
  return receiver.includes(first);
57
- throw filterRuntimeError('"includes" is not a function');
63
+ return fail();
58
64
  }
59
65
  case "startsWith":
60
66
  if (typeof receiver !== "string")
61
- throw filterRuntimeError('"startsWith" is not a function');
67
+ return fail();
62
68
  return receiver.startsWith(toStringArgument(first));
63
69
  case "endsWith":
64
70
  if (typeof receiver !== "string")
65
- throw filterRuntimeError('"endsWith" is not a function');
71
+ return fail();
66
72
  return receiver.endsWith(toStringArgument(first));
67
73
  case "match":
68
74
  if (typeof receiver !== "string")
69
- throw filterRuntimeError('"match" is not a function');
75
+ return fail();
70
76
  return receiver.match(toRegExpArgument(first));
71
77
  case "test": {
72
78
  if (!(receiver instanceof RegExp))
73
- throw filterRuntimeError('"test" is not a function');
79
+ return fail();
74
80
  receiver.lastIndex = 0;
75
81
  return receiver.test(toStringArgument(first));
76
82
  }
77
83
  case "toLowerCase":
78
84
  if (typeof receiver !== "string")
79
- throw filterRuntimeError('"toLowerCase" is not a function');
85
+ return fail();
80
86
  return receiver.toLowerCase();
81
87
  case "toUpperCase":
82
88
  if (typeof receiver !== "string")
83
- throw filterRuntimeError('"toUpperCase" is not a function');
89
+ return fail();
84
90
  return receiver.toUpperCase();
85
91
  case "trim":
86
92
  if (typeof receiver !== "string")
87
- throw filterRuntimeError('"trim" is not a function');
93
+ return fail();
88
94
  return receiver.trim();
89
95
  default:
90
- throw filterRuntimeError(`"${name}" is not a function`);
96
+ return fail();
91
97
  }
92
98
  };
93
99
  const looseEquals = (left, right) => {
@@ -129,6 +135,44 @@ const compareRelational = (operator, left, right) => {
129
135
  return a >= b;
130
136
  }
131
137
  };
138
+ const memberChain = (node) => {
139
+ const names = [];
140
+ let current = node;
141
+ while (true) {
142
+ if (current.kind === "member")
143
+ names.push(current.name);
144
+ else if (current.kind === "computed" && current.index.kind === "literal" && (typeof current.index.value === "string" || typeof current.index.value === "number")) {
145
+ names.push(current.index.value);
146
+ } else
147
+ break;
148
+ current = current.object;
149
+ }
150
+ if (names.length === 0)
151
+ return void 0;
152
+ names.reverse();
153
+ return { base: current, names };
154
+ };
155
+ const compileMemberChain = ({ base, names }) => {
156
+ const count = names.length;
157
+ const read = (object2) => {
158
+ let current = object2;
159
+ for (let i = 0; i < count; i++) {
160
+ current = readMember(current, names[i]);
161
+ if (failed)
162
+ return void 0;
163
+ }
164
+ return current;
165
+ };
166
+ if (base.kind === "context" && base.ref === "value") {
167
+ if (count === 1) {
168
+ const name = names[0];
169
+ return (value) => readMember(value, name);
170
+ }
171
+ return (value) => read(value);
172
+ }
173
+ const object = compileNode(base);
174
+ return (value, property, parent, parentProperty, path, root) => read(object(value, property, parent, parentProperty, path, root));
175
+ };
132
176
  const compileNode = (node) => {
133
177
  switch (node.kind) {
134
178
  case "literal":
@@ -152,18 +196,18 @@ const compileNode = (node) => {
152
196
  return (_value, _property, _parent, _parentProperty, _path, root) => root;
153
197
  }
154
198
  }
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
- }
199
+ case "member":
160
200
  case "computed": {
161
- const object = compileNode(node.object);
162
- const index = compileNode(node.index);
201
+ const chain = memberChain(node);
202
+ if (chain !== void 0)
203
+ return compileMemberChain(chain);
204
+ const computed = node;
205
+ const object = compileNode(computed.object);
206
+ const index = compileNode(computed.index);
163
207
  return (value, property, parent, parentProperty, path, root) => {
164
208
  const key = index(value, property, parent, parentProperty, path, root);
165
209
  if (typeof key !== "string" && typeof key !== "number") {
166
- throw filterRuntimeError("A computed member must be a string or a number");
210
+ return fail();
167
211
  }
168
212
  return readMember(object(value, property, parent, parentProperty, path, root), key);
169
213
  };
@@ -178,7 +222,10 @@ const compileNode = (node) => {
178
222
  for (let i = 0; i < arity; i++) {
179
223
  values[i] = args[i](value, property, parent, parentProperty, path, root);
180
224
  }
181
- return callMethod(object(value, property, parent, parentProperty, path, root), name, values);
225
+ const receiver = object(value, property, parent, parentProperty, path, root);
226
+ if (failed)
227
+ return void 0;
228
+ return callMethod(receiver, name, values);
182
229
  };
183
230
  }
184
231
  case "unary": {
@@ -195,21 +242,48 @@ const compileNode = (node) => {
195
242
  return (value, property, parent, parentProperty, path, root) => -Number(operand(value, property, parent, parentProperty, path, root));
196
243
  }
197
244
  case "logical": {
198
- const left = compileNode(node.left);
199
- const right = compileNode(node.right);
200
- if (node.operator === "&&") {
245
+ const operator = node.operator;
246
+ const operands = [];
247
+ const collect = (operand) => {
248
+ if (operand.kind === "logical" && operand.operator === operator) {
249
+ collect(operand.left);
250
+ collect(operand.right);
251
+ } else
252
+ operands.push(compileNode(operand));
253
+ };
254
+ collect(node);
255
+ const count = operands.length;
256
+ if (operator === "&&") {
201
257
  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;
258
+ let outcome;
259
+ for (let i = 0; i < count; i++) {
260
+ outcome = operands[i](value, property, parent, parentProperty, path, root);
261
+ if (failed)
262
+ return void 0;
263
+ if (!outcome)
264
+ return outcome;
265
+ }
266
+ return outcome;
204
267
  };
205
268
  }
206
269
  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);
270
+ let outcome;
271
+ for (let i = 0; i < count; i++) {
272
+ outcome = operands[i](value, property, parent, parentProperty, path, root);
273
+ if (failed)
274
+ return void 0;
275
+ if (outcome)
276
+ return outcome;
277
+ }
278
+ return outcome;
209
279
  };
210
280
  }
211
281
  case "comparison": {
212
282
  const left = compileNode(node.left);
283
+ if (node.right.kind === "literal" && (node.operator === "===" || node.operator === "!==")) {
284
+ const constant = node.right.value;
285
+ return node.operator === "===" ? (value, property, parent, parentProperty, path, root) => left(value, property, parent, parentProperty, path, root) === constant : (value, property, parent, parentProperty, path, root) => left(value, property, parent, parentProperty, path, root) !== constant;
286
+ }
213
287
  const right = compileNode(node.right);
214
288
  switch (node.operator) {
215
289
  case "===":
@@ -231,20 +305,66 @@ const compileNode = (node) => {
231
305
  const toFilterFn = (parsed) => {
232
306
  const test = compileNode(parsed.node);
233
307
  return (value, property, parent, root, path, parentProperty) => {
308
+ failed = false;
234
309
  try {
235
- return Boolean(test(value, property, parent, parentProperty, path, root));
310
+ const outcome = test(value, property, parent, parentProperty, path, root);
311
+ return failed ? false : Boolean(outcome);
236
312
  } catch {
237
313
  return false;
314
+ } finally {
315
+ failed = false;
238
316
  }
239
317
  };
240
318
  };
319
+ const objectOnlyName = (name) => typeof name === "string" && name !== "length" && !Number.isInteger(Number(name));
320
+ const readsOffObject = (node) => {
321
+ let current = node;
322
+ while (true) {
323
+ if (current.kind === "call") {
324
+ current = current.object;
325
+ continue;
326
+ }
327
+ const inner = current.kind === "member" || current.kind === "computed" ? current.object : void 0;
328
+ if (inner === void 0)
329
+ return false;
330
+ if (inner.kind === "context" && inner.ref === "value") {
331
+ if (current.kind === "member")
332
+ return objectOnlyName(current.name);
333
+ const index = current.index;
334
+ return index.kind === "literal" && (typeof index.value === "string" || typeof index.value === "number") ? objectOnlyName(index.value) : false;
335
+ }
336
+ current = inner;
337
+ }
338
+ };
339
+ const matchesOnlyObjects = (node) => {
340
+ switch (node.kind) {
341
+ case "member":
342
+ case "computed":
343
+ case "call":
344
+ return readsOffObject(node);
345
+ case "comparison": {
346
+ if (node.operator !== "===" && node.operator !== "==")
347
+ return false;
348
+ const definedLiteral = (side) => side.kind === "literal" ? side.value !== void 0 && side.value !== null : side.kind === "regex";
349
+ if (definedLiteral(node.right))
350
+ return readsOffObject(node.left);
351
+ if (definedLiteral(node.left))
352
+ return readsOffObject(node.right);
353
+ return false;
354
+ }
355
+ case "logical":
356
+ return node.operator === "&&" ? matchesOnlyObjects(node.left) || matchesOnlyObjects(node.right) : matchesOnlyObjects(node.left) && matchesOnlyObjects(node.right);
357
+ default:
358
+ return false;
359
+ }
360
+ };
241
361
  const filterCache = createBoundedCache(500);
242
362
  const compileFilter = (source) => {
243
363
  const cached = filterCache.get(source);
244
364
  if (cached)
245
365
  return cached;
246
366
  const parsed = parseFilterExpression(source);
247
- const compiled = "error" in parsed ? { error: parsed.error } : { test: toFilterFn(parsed), usesPath: parsed.usesPath };
367
+ const compiled = "error" in parsed ? { error: parsed.error } : { test: toFilterFn(parsed), usesPath: parsed.usesPath, objectsOnly: matchesOnlyObjects(parsed.node) };
248
368
  filterCache.set(source, compiled);
249
369
  return compiled;
250
370
  };
@@ -1,10 +1,10 @@
1
1
  export { type BoundedCache, createBoundedCache } from './bounded-cache.js';
2
- export { createDocument, type Document, type IDocumentOptions } from './document.js';
2
+ export { createDocument, createDocumentFromData, type Document, type IDataDocumentOptions, type IDocumentOptions, } from './document.js';
3
3
  export { type CompiledFilter, compileFilter, type FilterFn } from './filter.js';
4
4
  export { detectFormats, type Format } from './formats.js';
5
5
  export { globToRegExp, matchesGlob } from './glob.js';
6
6
  export { type CompiledPath, compileQuery, type IQueryMatch, query, queryCompiled, queryMany } from './jsonpath.js';
7
- export { type LintOptions, type LintResolver, type LintResolverResult, type LintResult, lint, lintWithResult, } from './lint.js';
7
+ export { type LintInput, type LintOptions, type LintResolver, type LintResolverResult, type LintResult, lint, lintWithResult, } from './lint.js';
8
8
  export { type LintPlugin, type LintPluginContext, type LintPluginResult, type PluginRunResult, runPlugins, } from './plugin.js';
9
9
  export { pointerToPath, resolveSourceOrigin, resolveSourceOriginFromMap, resolveSourcePath, } from './pointers.js';
10
10
  export { type AliasDefinition, createRuleset, type ExtendModifier, type ExtendResolver, type ResolvedExtend, type Ruleset, type RulesetOptions, } from './ruleset.js';
@@ -1,5 +1,5 @@
1
1
  import { createBoundedCache } from "./bounded-cache.js";
2
- import { createDocument } from "./document.js";
2
+ import { createDocument, createDocumentFromData } from "./document.js";
3
3
  import { compileFilter } from "./filter.js";
4
4
  import { detectFormats } from "./formats.js";
5
5
  import { globToRegExp, matchesGlob } from "./glob.js";
@@ -15,6 +15,7 @@ export {
15
15
  compileQuery,
16
16
  createBoundedCache,
17
17
  createDocument,
18
+ createDocumentFromData,
18
19
  createLinter,
19
20
  createRuleset,
20
21
  detectFormats,
@@ -37,6 +37,7 @@ type Selector =
37
37
  test: FilterFn;
38
38
  source: string;
39
39
  usesPath: boolean;
40
+ objectsOnly: boolean;
40
41
  } | {
41
42
  kind: 'parent';
42
43
  } | {
@@ -65,19 +66,32 @@ export type CompiledPath = {
65
66
  };
66
67
  /** Compiles a JSONPath `expression` into a {@link CompiledPath}, cached by string so repeats are free. */
67
68
  export declare const compileQuery: (expression: string) => CompiledPath;
69
+ /** How a path's `..` descents read the document. */
70
+ export type QueryOptions = {
71
+ /**
72
+ * Treat the document as JSON Schema (or an OpenAPI/AsyncAPI document) and
73
+ * keep `..` out of instance data: the values of `example`, `examples`,
74
+ * `default`, `const` and `enum` are neither visited nor matched, except
75
+ * where those words are names in a map such as `properties`. A rule that
76
+ * looks at schemas then does not trip over an example payload that happens
77
+ * to read `"type": "object"`, and does not pay to walk it.
78
+ */
79
+ schemaAware?: boolean;
80
+ };
68
81
  /** Evaluates a pre-compiled path against `data`. */
69
- export declare const queryCompiled: (data: unknown, compiled: CompiledPath) => IQueryMatch[];
82
+ export declare const queryCompiled: (data: unknown, compiled: CompiledPath, options?: QueryOptions) => IQueryMatch[];
70
83
  /**
71
- * Evaluates many pre-compiled paths against `data`, sharing a *single* recursive
72
- * descent of the tree across every `$..`-rooted path. The ruleset has ~16
73
- * descent `given`s; walking the (post-deref, ~60k-node) tree once and testing
74
- * each path's first selector at every node — instead of one full traversal per
75
- * path — is the dominant rule-run speedup on large specs. Non-recursive paths
76
- * are evaluated directly (cheap, no descent).
84
+ * Evaluates many pre-compiled paths against `data` with **one** walk of the
85
+ * tree for every path that contains a `..` descent, wherever that descent
86
+ * starts. `$..enum`, `$.paths.*..parameters` and
87
+ * `$.components.schemas..[?(@.deprecated)]` all ride the same traversal: a
88
+ * node is visited once, however many rules want to look at it, and a subtree
89
+ * no descent can reach is not visited at all. Paths without a descent are
90
+ * evaluated directly (cheap, no walk).
77
91
  *
78
92
  * Returns one match array per input path, index-aligned with `compiled`.
79
93
  */
80
- export declare const queryMany: (data: unknown, compiled: CompiledPath[]) => IQueryMatch[][];
94
+ export declare const queryMany: (data: unknown, compiled: CompiledPath[], options?: readonly (QueryOptions | undefined)[]) => IQueryMatch[][];
81
95
  /** Runs a JSONPath expression and returns each match with its concrete path. */
82
- export declare const query: (data: unknown, expression: string) => IQueryMatch[];
96
+ export declare const query: (data: unknown, expression: string, options?: QueryOptions) => IQueryMatch[];
83
97
  export {};