@amritk/lint 0.4.3 → 0.4.5

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 (38) hide show
  1. package/AI.md +16 -2
  2. package/README.md +43 -6
  3. package/dist/core/bounded-cache.d.ts +18 -0
  4. package/dist/core/bounded-cache.js +20 -0
  5. package/dist/core/filter-expression.d.ts +60 -0
  6. package/dist/core/filter-expression.js +320 -0
  7. package/dist/core/filter.d.ts +25 -0
  8. package/dist/core/filter.js +206 -0
  9. package/dist/core/index.d.ts +2 -0
  10. package/dist/core/index.js +4 -0
  11. package/dist/core/jsonpath.d.ts +1 -1
  12. package/dist/core/jsonpath.js +24 -72
  13. package/dist/functions/alphabetical.js +3 -1
  14. package/dist/functions/casing.js +13 -10
  15. package/dist/functions/pattern.js +2 -1
  16. package/dist/index.d.ts +49 -3
  17. package/dist/index.js +82 -23
  18. package/dist/parsers/depth.d.ts +21 -0
  19. package/dist/parsers/depth.js +28 -0
  20. package/dist/parsers/index.d.ts +1 -0
  21. package/dist/parsers/index.js +3 -0
  22. package/dist/parsers/json.js +16 -0
  23. package/dist/rules/openapi/functions/oas-schema.d.ts +3 -3
  24. package/dist/rules/openapi/schemas/index.d.ts +3 -3
  25. package/dist/rules/openapi/schemas/index.js +10 -8
  26. package/dist/rules/openapi/schemas/oas20.d.ts +2 -0
  27. package/dist/rules/openapi/schemas/oas20.js +4 -0
  28. package/dist/rules/openapi/schemas/oas30.d.ts +2 -0
  29. package/dist/rules/openapi/schemas/oas30.js +4 -0
  30. package/dist/rules/openapi/schemas/oas31.d.ts +2 -0
  31. package/dist/rules/openapi/schemas/oas31.js +4 -0
  32. package/dist/rules/openapi/schemas/oas32.d.ts +2 -0
  33. package/dist/rules/openapi/schemas/oas32.js +4 -0
  34. package/package.json +6 -4
  35. package/dist/rules/openapi/schemas/oas20.json +0 -1
  36. package/dist/rules/openapi/schemas/oas30.json +0 -1
  37. package/dist/rules/openapi/schemas/oas31.json +0 -1
  38. package/dist/rules/openapi/schemas/oas32.json +0 -1
package/AI.md CHANGED
@@ -36,13 +36,27 @@ const findings = await lintDocument('version: 1\n', { ruleset, source: 'service.
36
36
  `rulesetBasePath` (or the ruleset file's own directory).
37
37
  5. **OpenAPI support is a separate subpath**, `@amritk/lint/rules/openapi`
38
38
  (`createOpenApiRuleset`, `oas`, `oasFixers`, …) — not the package root.
39
+ 6. **A ruleset is privileged; a document is not.** Linted documents cannot
40
+ execute anything, and `[?(...)]` filters in a `given` are parsed and
41
+ interpreted rather than evaluated as JavaScript. But `extends` follows any
42
+ path (absolute or `../`-escaping) and `require`s `.js` targets and custom
43
+ functions — that is code execution by design. Load rulesets from sources you
44
+ would trust to run a script, or pass `restrictTo: '<root>'` to confine
45
+ `extends`/`functions` resolution to one directory tree. See the README's
46
+ "Trust boundary" section.
47
+ 7. **`createRuleset` is memoized** per `(definition object, basePath,
48
+ restrictTo)`. Mutating a definition you already passed in will not rebuild the
49
+ ruleset — pass a fresh object instead.
39
50
 
40
51
  ## Exports
41
52
 
42
53
  - `lintDocument(input, options?)` → findings only.
43
54
  - `lintDocumentWithResult(input, options?)` → `{ diagnostics, output?, pluginData }`.
44
- - `fixDocument(input, options?)` → `{ output, fixed, applied, remaining }` (needs `fixers`).
45
- - `createRuleset(def?, basePath?)`, `resolveNamedRuleset(name, basePath?)`,
55
+ - `fixDocument(input, options?)` → `{ output, fixed, applied, remaining, converged, passes }`
56
+ (needs `fixers`; `converged: false` means the 10-pass cap was hit with the
57
+ document still changing).
58
+ - `createRuleset(def?, basePath?, { restrictTo }?)`,
59
+ `resolveNamedRuleset(name, basePath?, { restrictTo }?)`,
46
60
  `builtinFunctions` (`alphabetical`, `casing`, `truthy`, `pattern`, `schema`, …).
47
61
 
48
62
  ## Subpaths
package/README.md CHANGED
@@ -105,6 +105,26 @@ await lintDocument(source, { ruleset: definition, rulesetBasePath: '/path/to/con
105
105
 
106
106
  A custom function has the signature `(value, options, context) => { message: string, path?: JsonPath }[]`.
107
107
 
108
+ ### Trust boundary — who may write a ruleset
109
+
110
+ A **document** being linted is untrusted input: it is parsed, walked, and matched, and nothing in it can execute code. A **ruleset** is not input — it is configuration, and it is as privileged as the process running the linter:
111
+
112
+ - `extends` accepts **any** path. `rulesetBasePath` is where resolution *starts*, not a fence: `../../../elsewhere/rules.yaml` and `/etc/rules.yaml` both resolve and load.
113
+ - `extends` of a `.js` / `.cjs` / `.mjs` file, and every custom function named in `functions`, is `require`d — **that is code execution, by design**. This is how custom functions work.
114
+ - YAML/JSON rulesets are *data*: their `given` filter expressions (`[?(...)]`) are parsed and interpreted, never evaluated as JavaScript, so a data-only ruleset cannot run code. A ruleset that names a `.js` file still can.
115
+
116
+ So: load rulesets from sources you would trust to run a script. If you accept a ruleset from somewhere less trusted (a multi-tenant service, a PR from a fork), pass **`restrictTo`** — an optional root that every `extends` target and custom function must resolve under:
117
+
118
+ ```ts
119
+ await lintDocument(source, {
120
+ ruleset: definition,
121
+ rulesetBasePath: '/srv/rulesets/tenant-42',
122
+ restrictTo: '/srv/rulesets/tenant-42', // refuse anything resolving outside this tree
123
+ })
124
+ ```
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.
127
+
108
128
  ### Auto-fix
109
129
 
110
130
  `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:
@@ -127,6 +147,8 @@ const { output, applied, remaining } = await fixDocument('host: api.example.com/
127
147
 
128
148
  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.
129
149
 
150
+ 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
+
130
152
  ### Rendering findings
131
153
 
132
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).
@@ -166,6 +188,21 @@ A ruleset is a plain object (authored as YAML, JSON, or a JS module):
166
188
 
167
189
  Built-in functions: `alphabetical`, `casing`, `defined`, `enumeration`, `falsy`, `length`, `or`, `pattern`, `schema`, `truthy`, `undefined`, `unreferencedReusableObject`, `xor`, `typedEnum`.
168
190
 
191
+ ### `given` — the JSONPath subset
192
+
193
+ `$`, child and `['child']` access, `..` recursive descent, `[*]` / `.*` wildcards, `[a,b]` unions, `[n]` indices (negative counts from the end), `[start:end:step]` slices, `[(@.length-1)]`, `^` (parent) and `~` (property name), and `[?(...)]` filters.
194
+
195
+ Filters are **parsed and interpreted, not evaluated as JavaScript** — a ruleset is data and cannot execute code. The supported grammar:
196
+
197
+ | | |
198
+ | --- | --- |
199
+ | Context | `@`, `@.name`, `@['name']`, `@[0]`, `@property`, `@parentProperty`, `@parent`, `@path`, `@root`, `$` |
200
+ | Operators | `===` `!==` `==` `!=` `<` `<=` `>` `>=` `&&` `\|\|` `!` `-` (negation), parentheses |
201
+ | Literals | strings, numbers, `true`, `false`, `null`, `undefined`, `void 0`, `/regex/flags` |
202
+ | Members & calls | `.length`, `.indexOf(…)`, `.lastIndexOf(…)`, `.includes(…)`, `.startsWith(…)`, `.endsWith(…)`, `.match(/re/)`, `/re/.test(…)`, `.toLowerCase()`, `.toUpperCase()`, `.trim()` |
203
+
204
+ Anything outside that grammar is a ruleset error (`createRuleset` throws and names the rule) rather than a filter that quietly matches nothing. Member reads see **own properties only**, so `@.constructor` and `@['__proto__']` are plain `undefined`.
205
+
169
206
  ---
170
207
 
171
208
  ## API
@@ -174,9 +211,9 @@ Built-in functions: `alphabetical`, `casing`, `defined`, `enumeration`, `falsy`,
174
211
  | --- | --- |
175
212
  | `lintDocument(input, options?)` | Parse `input` and lint it against `options.ruleset`; returns `IDiagnostic[]`. |
176
213
  | `lintDocumentWithResult(input, options?)` | Like `lintDocument`, but returns `{ diagnostics, output?, pluginData }` (including any plugin's rewritten `output`). |
177
- | `fixDocument(input, options?)` | Lint and apply `options.fixers` to a fixpoint; returns `{ output, fixed, applied, remaining }`. |
178
- | `createRuleset(definition?, basePath?)` | Normalize a ruleset definition into a runnable `Ruleset`, layering the built-in functions and resolving `extends`. |
179
- | `resolveNamedRuleset(name, basePath?)` | Resolve an `extends` reference (file path or npm package) to its definition. |
214
+ | `fixDocument(input, options?)` | Lint and apply `options.fixers` to a fixpoint; returns `{ output, fixed, applied, remaining, converged, passes }`. |
215
+ | `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. |
216
+ | `resolveNamedRuleset(name, basePath?, options?)` | Resolve an `extends` reference (file path or npm package) to its definition. |
180
217
  | `builtinFunctions` | The registry of built-in rule functions. |
181
218
 
182
219
  The engine internals (`createDocument`, `lint`, `query`, `validateRuleset`, `parseWithPointers`, `createFixPlugin`, `DiagnosticSeverity`, and the rule/diagnostic types) are re-exported from the package root for advanced use.
@@ -220,15 +257,15 @@ The `bench/` suite pits `@amritk/lint` head-to-head against **[Spectral](https:/
220
257
 
221
258
  | document | size | mjst | Spectral | speedup | findings (mjst / Spectral) |
222
259
  | --- | ---: | ---: | ---: | ---: | ---: |
223
- | petstore (Swagger) | 17 KB | ~7 ms | ~100 ms | **~14×** | 2 / 2 |
224
- | digitalocean | 105 KB | ~31 ms | ~355 ms | **~12×** | 2411 / 4319 |
260
+ | petstore (Swagger) | 17 KB | ~8 ms | ~93 ms | **~11×** | 2 / 2 |
261
+ | digitalocean | 105 KB | ~31 ms | ~351 ms | **~11×** | 2411 / 4319 |
225
262
  | openai | 2.8 MB | ~1.4 s | errored¹ | — | 1278 / — |
226
263
 
227
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.
228
265
 
229
266
  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.
230
267
 
231
- **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.09 ms**, versus **~0.35 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.
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.
232
269
 
233
270
  ---
234
271
 
@@ -0,0 +1,18 @@
1
+ /** A memoization map with a hard entry cap. */
2
+ export type BoundedCache<K, V> = {
3
+ get(key: K): V | undefined;
4
+ set(key: K, value: V): void;
5
+ /** Current entry count — exposed for tests and diagnostics. */
6
+ readonly size: number;
7
+ };
8
+ /**
9
+ * Builds a cache that never grows past `limit` entries. The keys we memoize on
10
+ * (JSONPath expressions, filter bodies, regex sources) all come from rulesets,
11
+ * and a long-lived service that accepts a ruleset per request would otherwise
12
+ * grow these maps forever — a slow leak that only shows up in production.
13
+ *
14
+ * Eviction is plain insertion-order (a `Map` iterates oldest first), not LRU: the
15
+ * hot entries in a lint run are the handful of expressions a ruleset actually
16
+ * uses, so anything fancier would cost more than it saves.
17
+ */
18
+ export declare const createBoundedCache: <K, V>(limit: number) => BoundedCache<K, V>;
@@ -0,0 +1,20 @@
1
+ const createBoundedCache = (limit) => {
2
+ const entries = /* @__PURE__ */ new Map();
3
+ return {
4
+ get: (key) => entries.get(key),
5
+ set: (key, value) => {
6
+ if (entries.size >= limit && !entries.has(key)) {
7
+ const oldest = entries.keys().next();
8
+ if (!oldest.done)
9
+ entries.delete(oldest.value);
10
+ }
11
+ entries.set(key, value);
12
+ },
13
+ get size() {
14
+ return entries.size;
15
+ }
16
+ };
17
+ };
18
+ export {
19
+ createBoundedCache
20
+ };
@@ -0,0 +1,60 @@
1
+ /** The `@…` tokens a filter can read from its evaluation context. */
2
+ export type ContextRef = 'value' | 'property' | 'parent' | 'parentProperty' | 'path' | 'root';
3
+ /** One node of a parsed filter expression. */
4
+ export type FilterNode = {
5
+ kind: 'literal';
6
+ value: unknown;
7
+ } | {
8
+ kind: 'regex';
9
+ value: RegExp;
10
+ } | {
11
+ kind: 'context';
12
+ ref: ContextRef;
13
+ }
14
+ /** `@.name` — a static member read. */
15
+ | {
16
+ kind: 'member';
17
+ object: FilterNode;
18
+ name: string;
19
+ }
20
+ /** `@['name']` / `@[0]` — a member read through an expression. */
21
+ | {
22
+ kind: 'computed';
23
+ object: FilterNode;
24
+ index: FilterNode;
25
+ }
26
+ /** `@.indexOf('x')` — a call to one of the allowed pure methods. */
27
+ | {
28
+ kind: 'call';
29
+ object: FilterNode;
30
+ name: string;
31
+ args: FilterNode[];
32
+ } | {
33
+ kind: 'unary';
34
+ operator: '!' | '-' | 'void';
35
+ operand: FilterNode;
36
+ } | {
37
+ kind: 'comparison';
38
+ operator: ComparisonOperator;
39
+ left: FilterNode;
40
+ right: FilterNode;
41
+ } | {
42
+ kind: 'logical';
43
+ operator: '&&' | '||';
44
+ left: FilterNode;
45
+ right: FilterNode;
46
+ };
47
+ export type ComparisonOperator = '===' | '!==' | '==' | '!=' | '<' | '<=' | '>' | '>=';
48
+ /** A successfully parsed filter, plus whether it reads `@path` (materializing that is not free). */
49
+ export type ParsedFilter = {
50
+ node: FilterNode;
51
+ usesPath: boolean;
52
+ };
53
+ /**
54
+ * Parses a filter body into a {@link ParsedFilter}, or returns the reason it
55
+ * could not. Precedence follows JavaScript: `||` < `&&` < equality < relational
56
+ * < unary < member/call.
57
+ */
58
+ export declare const parseFilterExpression: (source: string) => ParsedFilter | {
59
+ error: string;
60
+ };
@@ -0,0 +1,320 @@
1
+ const CONTEXT_TOKENS = {
2
+ "@": "value",
3
+ "@property": "property",
4
+ "@parentProperty": "parentProperty",
5
+ "@parent": "parent",
6
+ "@path": "path",
7
+ "@root": "root"
8
+ };
9
+ const KEYWORDS = {
10
+ true: true,
11
+ false: false,
12
+ null: null,
13
+ undefined: void 0
14
+ };
15
+ const PUNCTUATORS = ["===", "!==", "==", "!=", "<=", ">=", "&&", "||", "!", "<", ">", "(", ")", "[", "]", ".", ",", "-"];
16
+ const isNameStart = (ch) => /[A-Za-z_$]/.test(ch);
17
+ const isNameChar = (ch) => /[A-Za-z0-9_$]/.test(ch);
18
+ const isDigit = (ch) => ch >= "0" && ch <= "9";
19
+ const fail = (message, offset) => {
20
+ const error = new Error(message);
21
+ error.offset = offset;
22
+ throw error;
23
+ };
24
+ const readString = (source, start) => {
25
+ const quote = source[start];
26
+ let out = "";
27
+ let i = start + 1;
28
+ while (i < source.length) {
29
+ const ch = source[i];
30
+ if (ch === "\\") {
31
+ const next = source[i + 1];
32
+ if (next === void 0)
33
+ fail("Unterminated string literal", start);
34
+ const escapes = { n: "\n", r: "\r", t: " ", b: "\b", f: "\f", v: "\v", "0": "\0" };
35
+ out += escapes[next] ?? next;
36
+ i += 2;
37
+ continue;
38
+ }
39
+ if (ch === quote)
40
+ return { value: out, end: i + 1 };
41
+ out += ch;
42
+ i++;
43
+ }
44
+ return fail("Unterminated string literal", start);
45
+ };
46
+ const readRegExp = (source, start) => {
47
+ let i = start + 1;
48
+ let inClass = false;
49
+ let pattern = "";
50
+ while (i < source.length) {
51
+ const ch = source[i];
52
+ if (ch === "\\") {
53
+ pattern += ch + (source[i + 1] ?? "");
54
+ i += 2;
55
+ continue;
56
+ }
57
+ if (ch === "[")
58
+ inClass = true;
59
+ else if (ch === "]")
60
+ inClass = false;
61
+ else if (ch === "/" && !inClass)
62
+ break;
63
+ pattern += ch;
64
+ i++;
65
+ }
66
+ if (source[i] !== "/")
67
+ return fail("Unterminated regular expression literal", start);
68
+ i++;
69
+ let flags = "";
70
+ while (i < source.length && isNameChar(source[i])) {
71
+ flags += source[i];
72
+ i++;
73
+ }
74
+ try {
75
+ return { value: new RegExp(pattern, flags), end: i };
76
+ } catch (error) {
77
+ return fail(`Invalid regular expression: ${error.message}`, start);
78
+ }
79
+ };
80
+ const tokenize = (source) => {
81
+ const tokens = [];
82
+ let i = 0;
83
+ while (i < source.length) {
84
+ const ch = source[i];
85
+ if (/\s/.test(ch)) {
86
+ i++;
87
+ continue;
88
+ }
89
+ if (ch === '"' || ch === "'") {
90
+ const { value, end } = readString(source, i);
91
+ tokens.push({ kind: "value", value, start: i });
92
+ i = end;
93
+ continue;
94
+ }
95
+ if (ch === "/") {
96
+ const { value, end } = readRegExp(source, i);
97
+ tokens.push({ kind: "regex", value, start: i });
98
+ i = end;
99
+ continue;
100
+ }
101
+ if (ch === "@") {
102
+ let end = i + 1;
103
+ while (end < source.length && isNameChar(source[end]))
104
+ end++;
105
+ const text = source.slice(i, end);
106
+ const ref = CONTEXT_TOKENS[text];
107
+ if (ref === void 0)
108
+ fail(`Unsupported context token "${text}"`, i);
109
+ tokens.push({ kind: "context", ref, start: i });
110
+ i = end;
111
+ continue;
112
+ }
113
+ if (ch === "$") {
114
+ if (!isNameChar(source[i + 1] ?? "")) {
115
+ tokens.push({ kind: "context", ref: "root", start: i });
116
+ i++;
117
+ continue;
118
+ }
119
+ }
120
+ if (isDigit(ch)) {
121
+ let end = i;
122
+ while (end < source.length && /[0-9.eE]/.test(source[end])) {
123
+ if ((source[end] === "e" || source[end] === "E") && (source[end + 1] === "-" || source[end + 1] === "+"))
124
+ end++;
125
+ end++;
126
+ }
127
+ const text = source.slice(i, end);
128
+ const value = Number(text);
129
+ if (Number.isNaN(value))
130
+ fail(`Invalid number "${text}"`, i);
131
+ tokens.push({ kind: "value", value, start: i });
132
+ i = end;
133
+ continue;
134
+ }
135
+ if (isNameStart(ch)) {
136
+ let end = i;
137
+ while (end < source.length && isNameChar(source[end]))
138
+ end++;
139
+ tokens.push({ kind: "name", text: source.slice(i, end), start: i });
140
+ i = end;
141
+ continue;
142
+ }
143
+ const punct = PUNCTUATORS.find((candidate) => source.startsWith(candidate, i));
144
+ if (punct === void 0)
145
+ fail(`Unexpected character "${ch}"`, i);
146
+ tokens.push({ kind: "punct", text: punct, start: i });
147
+ i += punct.length;
148
+ }
149
+ return tokens;
150
+ };
151
+ const ALLOWED_METHODS = /* @__PURE__ */ new Set([
152
+ "indexOf",
153
+ "lastIndexOf",
154
+ "includes",
155
+ "startsWith",
156
+ "endsWith",
157
+ "match",
158
+ "test",
159
+ "toLowerCase",
160
+ "toUpperCase",
161
+ "trim"
162
+ ]);
163
+ const parseFilterExpression = (source) => {
164
+ let tokens;
165
+ try {
166
+ tokens = tokenize(source);
167
+ } catch (error) {
168
+ const syntax = error;
169
+ return { error: `${syntax.message} at offset ${syntax.offset} in filter "${source}"` };
170
+ }
171
+ let position = 0;
172
+ let usesPath = false;
173
+ const peek = () => tokens[position];
174
+ const offsetOf = (token) => token?.start ?? source.length;
175
+ const eatPunct = (text) => {
176
+ const token = peek();
177
+ if (token?.kind === "punct" && token.text === text) {
178
+ position++;
179
+ return true;
180
+ }
181
+ return false;
182
+ };
183
+ const expectPunct = (text) => {
184
+ if (!eatPunct(text))
185
+ fail(`Expected "${text}"`, offsetOf(peek()));
186
+ };
187
+ const parseArguments = () => {
188
+ const args = [];
189
+ if (eatPunct(")"))
190
+ return args;
191
+ for (; ; ) {
192
+ args.push(parseOr());
193
+ if (eatPunct(")"))
194
+ return args;
195
+ expectPunct(",");
196
+ }
197
+ };
198
+ const parsePostfix = (object) => {
199
+ let node = object;
200
+ for (; ; ) {
201
+ if (eatPunct(".")) {
202
+ const token = peek();
203
+ if (token?.kind !== "name")
204
+ return fail('Expected a property name after "."', offsetOf(token));
205
+ position++;
206
+ if (eatPunct("(")) {
207
+ if (!ALLOWED_METHODS.has(token.text))
208
+ fail(`Unsupported method "${token.text}()"`, token.start);
209
+ node = { kind: "call", object: node, name: token.text, args: parseArguments() };
210
+ continue;
211
+ }
212
+ node = { kind: "member", object: node, name: token.text };
213
+ continue;
214
+ }
215
+ if (eatPunct("[")) {
216
+ const index = parseOr();
217
+ expectPunct("]");
218
+ node = { kind: "computed", object: node, index };
219
+ continue;
220
+ }
221
+ return node;
222
+ }
223
+ };
224
+ const parsePrimary = () => {
225
+ const token = peek();
226
+ if (token === void 0)
227
+ return fail("Unexpected end of filter expression", source.length);
228
+ if (token.kind === "value") {
229
+ position++;
230
+ return parsePostfix({ kind: "literal", value: token.value });
231
+ }
232
+ if (token.kind === "regex") {
233
+ position++;
234
+ return parsePostfix({ kind: "regex", value: token.value });
235
+ }
236
+ if (token.kind === "context") {
237
+ position++;
238
+ if (token.ref === "path")
239
+ usesPath = true;
240
+ return parsePostfix({ kind: "context", ref: token.ref });
241
+ }
242
+ if (token.kind === "name") {
243
+ if (token.text in KEYWORDS) {
244
+ position++;
245
+ return parsePostfix({ kind: "literal", value: KEYWORDS[token.text] });
246
+ }
247
+ return fail(`Unsupported identifier "${token.text}"`, token.start);
248
+ }
249
+ if (token.text === "(") {
250
+ position++;
251
+ const inner = parseOr();
252
+ expectPunct(")");
253
+ return parsePostfix(inner);
254
+ }
255
+ return fail(`Unexpected token "${token.text}"`, token.start);
256
+ };
257
+ const parseUnary = () => {
258
+ const token = peek();
259
+ if (token?.kind === "punct" && (token.text === "!" || token.text === "-")) {
260
+ position++;
261
+ return { kind: "unary", operator: token.text, operand: parseUnary() };
262
+ }
263
+ if (token?.kind === "name" && token.text === "void") {
264
+ position++;
265
+ return { kind: "unary", operator: "void", operand: parseUnary() };
266
+ }
267
+ return parsePrimary();
268
+ };
269
+ const parseRelational = () => {
270
+ let left = parseUnary();
271
+ for (; ; ) {
272
+ const token = peek();
273
+ if (token?.kind !== "punct")
274
+ return left;
275
+ if (token.text !== "<" && token.text !== "<=" && token.text !== ">" && token.text !== ">=")
276
+ return left;
277
+ position++;
278
+ left = { kind: "comparison", operator: token.text, left, right: parseUnary() };
279
+ }
280
+ };
281
+ const parseEquality = () => {
282
+ let left = parseRelational();
283
+ for (; ; ) {
284
+ const token = peek();
285
+ if (token?.kind !== "punct")
286
+ return left;
287
+ if (token.text !== "===" && token.text !== "!==" && token.text !== "==" && token.text !== "!=")
288
+ return left;
289
+ position++;
290
+ left = { kind: "comparison", operator: token.text, left, right: parseRelational() };
291
+ }
292
+ };
293
+ const parseAnd = () => {
294
+ let left = parseEquality();
295
+ while (eatPunct("&&"))
296
+ left = { kind: "logical", operator: "&&", left, right: parseEquality() };
297
+ return left;
298
+ };
299
+ const parseOr = () => {
300
+ let left = parseAnd();
301
+ while (eatPunct("||"))
302
+ left = { kind: "logical", operator: "||", left, right: parseAnd() };
303
+ return left;
304
+ };
305
+ try {
306
+ if (tokens.length === 0)
307
+ return { error: `Empty filter expression "${source}"` };
308
+ const node = parseOr();
309
+ const trailing = peek();
310
+ if (trailing !== void 0)
311
+ fail(`Unexpected token "${"text" in trailing ? trailing.text : ""}"`, trailing.start);
312
+ return { node, usesPath };
313
+ } catch (error) {
314
+ const syntax = error;
315
+ return { error: `${syntax.message} at offset ${syntax.offset} in filter "${source}"` };
316
+ }
317
+ };
318
+ export {
319
+ parseFilterExpression
320
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Evaluates one `[?(...)]` filter against a candidate node. Arguments are passed
3
+ * positionally rather than in a context object because this runs once per node
4
+ * of the document — the allocation would show up on large specs.
5
+ *
6
+ * `path` is the jsonpath-plus string form (`$['a'][0]`) that `@path` exposes, and
7
+ * is only materialized for filters that actually reference it.
8
+ */
9
+ export type FilterFn = (value: unknown, property: string | number | undefined, parent: unknown, root: unknown, path: string, parentProperty: string | number | undefined) => boolean;
10
+ /** A filter that either compiled, or the reason it did not. */
11
+ export type CompiledFilter = {
12
+ readonly test: FilterFn;
13
+ readonly usesPath: boolean;
14
+ } | {
15
+ readonly error: string;
16
+ };
17
+ /**
18
+ * Compiles a `[?(...)]` filter body into a predicate, or reports why it cannot.
19
+ *
20
+ * There is no `eval` and no `new Function` anywhere in this path: the body is
21
+ * parsed into an AST ({@link parseFilterExpression}) and interpreted. A ruleset
22
+ * — which is data, and often YAML written by someone other than the person
23
+ * running the linter — therefore cannot execute code in the host process.
24
+ */
25
+ export declare const compileFilter: (source: string) => CompiledFilter;