@amritk/lint 0.4.3 → 0.4.4
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 +16 -2
- package/README.md +43 -6
- package/dist/core/bounded-cache.d.ts +18 -0
- package/dist/core/bounded-cache.js +20 -0
- package/dist/core/filter-expression.d.ts +60 -0
- package/dist/core/filter-expression.js +320 -0
- package/dist/core/filter.d.ts +25 -0
- package/dist/core/filter.js +206 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +4 -0
- package/dist/core/jsonpath.d.ts +1 -1
- package/dist/core/jsonpath.js +24 -72
- package/dist/functions/alphabetical.js +3 -1
- package/dist/functions/casing.js +13 -10
- package/dist/functions/pattern.js +2 -1
- package/dist/index.d.ts +49 -3
- package/dist/index.js +82 -23
- package/dist/parsers/depth.d.ts +21 -0
- package/dist/parsers/depth.js +28 -0
- package/dist/parsers/index.d.ts +1 -0
- package/dist/parsers/index.js +3 -0
- package/dist/parsers/json.js +16 -0
- package/dist/rules/openapi/functions/oas-schema.d.ts +3 -3
- package/dist/rules/openapi/schemas/index.d.ts +3 -3
- package/dist/rules/openapi/schemas/index.js +10 -8
- package/dist/rules/openapi/schemas/oas20.d.ts +2 -0
- package/dist/rules/openapi/schemas/oas20.js +4 -0
- package/dist/rules/openapi/schemas/oas30.d.ts +2 -0
- package/dist/rules/openapi/schemas/oas30.js +4 -0
- package/dist/rules/openapi/schemas/oas31.d.ts +2 -0
- package/dist/rules/openapi/schemas/oas31.js +4 -0
- package/dist/rules/openapi/schemas/oas32.d.ts +2 -0
- package/dist/rules/openapi/schemas/oas32.js +4 -0
- package/package.json +6 -4
- package/dist/rules/openapi/schemas/oas20.json +0 -1
- package/dist/rules/openapi/schemas/oas30.json +0 -1
- package/dist/rules/openapi/schemas/oas31.json +0 -1
- 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
|
|
45
|
-
|
|
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 | ~
|
|
224
|
-
| digitalocean | 105 KB | ~31 ms | ~
|
|
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.
|
|
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;
|