@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 +6 -1
- package/README.md +28 -4
- package/dist/core/document.d.ts +25 -0
- package/dist/core/document.js +17 -2
- package/dist/core/filter.d.ts +6 -0
- package/dist/core/filter.js +162 -42
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.js +2 -1
- package/dist/core/jsonpath.d.ts +23 -9
- package/dist/core/jsonpath.js +303 -59
- package/dist/core/lint.d.ts +19 -3
- package/dist/core/lint.js +26 -11
- package/dist/core/ruleset.js +1 -0
- package/dist/core/runner.js +8 -6
- package/dist/core/types.d.ts +12 -0
- package/dist/core/validate-ruleset.js +3 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +2 -1
- package/dist/parsers/depth.js +18 -14
- package/dist/parsers/index.d.ts +1 -0
- package/dist/parsers/index.js +2 -0
- package/dist/parsers/json-locator.d.ts +19 -0
- package/dist/parsers/json-locator.js +151 -0
- package/dist/parsers/json.d.ts +8 -1
- package/dist/parsers/json.js +34 -21
- package/package.json +3 -2
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
|
|
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
|
---
|
package/dist/core/document.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/document.js
CHANGED
|
@@ -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
|
};
|
package/dist/core/filter.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/core/filter.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { createBoundedCache } from "./bounded-cache.js";
|
|
2
2
|
import { parseFilterExpression } from "./filter-expression.js";
|
|
3
|
-
|
|
4
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
+
return fail();
|
|
58
64
|
}
|
|
59
65
|
case "startsWith":
|
|
60
66
|
if (typeof receiver !== "string")
|
|
61
|
-
|
|
67
|
+
return fail();
|
|
62
68
|
return receiver.startsWith(toStringArgument(first));
|
|
63
69
|
case "endsWith":
|
|
64
70
|
if (typeof receiver !== "string")
|
|
65
|
-
|
|
71
|
+
return fail();
|
|
66
72
|
return receiver.endsWith(toStringArgument(first));
|
|
67
73
|
case "match":
|
|
68
74
|
if (typeof receiver !== "string")
|
|
69
|
-
|
|
75
|
+
return fail();
|
|
70
76
|
return receiver.match(toRegExpArgument(first));
|
|
71
77
|
case "test": {
|
|
72
78
|
if (!(receiver instanceof RegExp))
|
|
73
|
-
|
|
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
|
-
|
|
85
|
+
return fail();
|
|
80
86
|
return receiver.toLowerCase();
|
|
81
87
|
case "toUpperCase":
|
|
82
88
|
if (typeof receiver !== "string")
|
|
83
|
-
|
|
89
|
+
return fail();
|
|
84
90
|
return receiver.toUpperCase();
|
|
85
91
|
case "trim":
|
|
86
92
|
if (typeof receiver !== "string")
|
|
87
|
-
|
|
93
|
+
return fail();
|
|
88
94
|
return receiver.trim();
|
|
89
95
|
default:
|
|
90
|
-
|
|
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
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
199
|
-
const
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
};
|
package/dist/core/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/core/index.js
CHANGED
|
@@ -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,
|
package/dist/core/jsonpath.d.ts
CHANGED
|
@@ -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
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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 {};
|