@amritk/lint 0.2.0 → 0.3.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/README.md +18 -0
- package/dist/core/glob.d.ts +1 -1
- package/dist/core/glob.js +89 -5
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/jsonpath.d.ts +17 -1
- package/dist/core/jsonpath.js +218 -23
- package/dist/core/lint.d.ts +15 -8
- package/dist/core/lint.js +12 -3
- package/dist/core/plugin.d.ts +6 -0
- package/dist/core/plugin.js +6 -0
- package/dist/core/pointers.js +15 -15
- package/dist/core/ruleset.js +0 -0
- package/dist/core/runner.d.ts +6 -1
- package/dist/core/runner.js +127 -43
- package/dist/core/types.d.ts +17 -2
- package/dist/core/validate-ruleset.js +16 -0
- package/dist/fix/apply.d.ts +8 -2
- package/dist/fix/apply.js +68 -18
- package/dist/functions/alphabetical.js +40 -13
- package/dist/functions/casing.js +27 -5
- package/dist/functions/enumeration.d.ts +5 -3
- package/dist/functions/enumeration.js +18 -1
- package/dist/functions/index.d.ts +1 -0
- package/dist/functions/index.js +3 -0
- package/dist/functions/length.d.ts +13 -3
- package/dist/functions/length.js +11 -3
- package/dist/functions/or.d.ts +11 -0
- package/dist/functions/or.js +25 -0
- package/dist/functions/pattern.d.ts +5 -3
- package/dist/functions/pattern.js +42 -9
- package/dist/functions/schema.d.ts +13 -0
- package/dist/functions/schema.js +95 -2
- package/dist/functions/typed-enum.js +7 -1
- package/dist/functions/unreferenced-reusable-object.d.ts +7 -1
- package/dist/functions/unreferenced-reusable-object.js +18 -3
- package/dist/functions/xor.js +8 -1
- package/dist/index.js +9 -1
- package/dist/parsers/edit-model.d.ts +15 -0
- package/dist/parsers/edit-model.js +210 -41
- package/dist/parsers/types.d.ts +14 -2
- package/dist/parsers/yaml.d.ts +10 -0
- package/dist/parsers/yaml.js +174 -26
- package/dist/rules/openapi/fixers.js +63 -4
- package/dist/rules/openapi/formats.js +11 -4
- package/dist/rules/openapi/functions/example-validation.d.ts +16 -3
- package/dist/rules/openapi/functions/example-validation.js +102 -39
- package/dist/rules/openapi/functions/helpers.d.ts +1 -0
- package/dist/rules/openapi/functions/helpers.js +5 -0
- package/dist/rules/openapi/functions/index.d.ts +3 -1
- package/dist/rules/openapi/functions/index.js +7 -1
- package/dist/rules/openapi/functions/oas-additional-operations.js +5 -5
- package/dist/rules/openapi/functions/oas-example-external-value.d.ts +11 -0
- package/dist/rules/openapi/functions/oas-example-external-value.js +23 -0
- package/dist/rules/openapi/functions/oas-no-nullable.d.ts +13 -0
- package/dist/rules/openapi/functions/oas-no-nullable.js +22 -0
- package/dist/rules/openapi/functions/oas-op-id-unique.js +4 -2
- package/dist/rules/openapi/functions/oas-op-params.d.ts +7 -1
- package/dist/rules/openapi/functions/oas-op-params.js +35 -10
- package/dist/rules/openapi/functions/oas-op-security-defined.js +2 -2
- package/dist/rules/openapi/functions/oas-op-success-response.js +6 -1
- package/dist/rules/openapi/functions/oas-path-param.d.ts +10 -1
- package/dist/rules/openapi/functions/oas-path-param.js +87 -26
- package/dist/rules/openapi/functions/oas-server-variables.d.ts +6 -1
- package/dist/rules/openapi/functions/oas-server-variables.js +31 -2
- package/dist/rules/openapi/functions/oas-unused-component.js +14 -1
- package/dist/rules/openapi/oas.js +82 -25
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -214,6 +214,24 @@ The structural rules validate against the **official `spec.openapis.org` meta-sc
|
|
|
214
214
|
|
|
215
215
|
---
|
|
216
216
|
|
|
217
|
+
## Benchmarks
|
|
218
|
+
|
|
219
|
+
The `bench/` suite pits `@amritk/lint` head-to-head against **[Spectral](https://github.com/stoplightio/spectral)** — the OpenAPI linter this package is modelled on (hence the `spectral:oas` alias) — over the real-world specs the test suite lints: Swagger's petstore, the DigitalOcean API, and the OpenAI API (~17 KB to ~2.8 MB, spanning a small config and a genuinely large document). Both do the same job: **parse → dereference internal `$ref`s → run their recommended OpenAPI ruleset** (mjst dereferences in memory with [`@amritk/resolve-refs`](../resolve-refs), exactly as the CLI does; Spectral uses its own default resolver). Representative numbers (Bun 1.3, Linux x64 — your hardware will differ, run `bun run bench` yourself):
|
|
220
|
+
|
|
221
|
+
| document | size | mjst | Spectral | speedup | findings (mjst / Spectral) |
|
|
222
|
+
| --- | ---: | ---: | ---: | ---: | ---: |
|
|
223
|
+
| petstore (Swagger) | 17 KB | ~6 ms | ~124 ms | **~19×** | 2 / 2 |
|
|
224
|
+
| digitalocean | 105 KB | ~37 ms | ~477 ms | **~13×** | 3507 / 4319 |
|
|
225
|
+
| openai | 2.8 MB | ~1.3 s | errored¹ | — | 1176 / — |
|
|
226
|
+
|
|
227
|
+
¹ 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
|
+
|
|
229
|
+
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
|
+
|
|
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.06 ms**, versus **~0.4 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
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
217
235
|
## License
|
|
218
236
|
|
|
219
237
|
MIT
|
package/dist/core/glob.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Compiles a glob pattern (`**`, `*`,
|
|
1
|
+
/** Compiles a glob pattern (`**`, `*`, `?`, `{a,b}`) into an anchored RegExp, cached by string. */
|
|
2
2
|
export declare const globToRegExp: (glob: string) => RegExp;
|
|
3
3
|
/** Returns true if `path` matches any of the glob patterns. */
|
|
4
4
|
export declare const matchesGlob: (path: string, patterns: string[]) => boolean;
|
package/dist/core/glob.js
CHANGED
|
@@ -1,10 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal glob → RegExp for matching override `files` patterns against document
|
|
3
|
-
* paths. Supports `**` (any path segments), `*` (within a segment), and
|
|
3
|
+
* paths. Supports `**` (any path segments), `*` (within a segment), `?`, and
|
|
4
|
+
* brace expansion (`{a,b}`).
|
|
4
5
|
*/
|
|
5
6
|
const REGEXP_SPECIAL = /[\\^$.*+?()[\]{}|/]/;
|
|
6
|
-
/**
|
|
7
|
-
|
|
7
|
+
/** Splits brace-group content on top-level commas (commas nested in inner braces stay put). */
|
|
8
|
+
const splitTopLevel = (body) => {
|
|
9
|
+
const parts = [];
|
|
10
|
+
let depth = 0;
|
|
11
|
+
let current = '';
|
|
12
|
+
for (const ch of body) {
|
|
13
|
+
if (ch === '{')
|
|
14
|
+
depth++;
|
|
15
|
+
else if (ch === '}')
|
|
16
|
+
depth--;
|
|
17
|
+
if (ch === ',' && depth === 0) {
|
|
18
|
+
parts.push(current);
|
|
19
|
+
current = '';
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
current += ch;
|
|
23
|
+
}
|
|
24
|
+
parts.push(current);
|
|
25
|
+
return parts;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Expands brace alternations (`a.{yaml,yml}` → `a.yaml`, `a.yml`) into concrete
|
|
29
|
+
* globs, cartesian across multiple groups. A group with no top-level comma is
|
|
30
|
+
* left literal (mirroring minimatch), so `{}` in a path does not vanish.
|
|
31
|
+
*/
|
|
32
|
+
const expandBraces = (glob) => {
|
|
33
|
+
const open = glob.indexOf('{');
|
|
34
|
+
if (open === -1)
|
|
35
|
+
return [glob];
|
|
36
|
+
let depth = 0;
|
|
37
|
+
let close = -1;
|
|
38
|
+
for (let i = open; i < glob.length; i++) {
|
|
39
|
+
if (glob[i] === '{')
|
|
40
|
+
depth++;
|
|
41
|
+
else if (glob[i] === '}') {
|
|
42
|
+
depth--;
|
|
43
|
+
if (depth === 0) {
|
|
44
|
+
close = i;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (close === -1)
|
|
50
|
+
return [glob];
|
|
51
|
+
const prefix = glob.slice(0, open);
|
|
52
|
+
const body = glob.slice(open + 1, close);
|
|
53
|
+
const suffix = glob.slice(close + 1);
|
|
54
|
+
const options = splitTopLevel(body);
|
|
55
|
+
if (options.length === 1) {
|
|
56
|
+
// No alternation — keep the braces as literal characters.
|
|
57
|
+
return expandBraces(suffix).map((rest) => `${prefix}{${body}}${rest}`);
|
|
58
|
+
}
|
|
59
|
+
const results = [];
|
|
60
|
+
for (const option of options) {
|
|
61
|
+
for (const expandedOption of expandBraces(option)) {
|
|
62
|
+
for (const expandedSuffix of expandBraces(suffix)) {
|
|
63
|
+
results.push(prefix + expandedOption + expandedSuffix);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return results;
|
|
68
|
+
};
|
|
69
|
+
/** Compiles a single (brace-free) glob into an un-anchored RegExp source. */
|
|
70
|
+
const globInnerSource = (glob) => {
|
|
8
71
|
let source = '';
|
|
9
72
|
for (let i = 0; i < glob.length; i++) {
|
|
10
73
|
const char = glob[i];
|
|
@@ -33,7 +96,20 @@ export const globToRegExp = (glob) => {
|
|
|
33
96
|
source += char;
|
|
34
97
|
}
|
|
35
98
|
}
|
|
36
|
-
return
|
|
99
|
+
return source;
|
|
100
|
+
};
|
|
101
|
+
// Compiled patterns are cached by their glob string; the ruleset's override
|
|
102
|
+
// globs are re-tested against every linted source, so compiling once matters.
|
|
103
|
+
const regexCache = new Map();
|
|
104
|
+
/** Compiles a glob pattern (`**`, `*`, `?`, `{a,b}`) into an anchored RegExp, cached by string. */
|
|
105
|
+
export const globToRegExp = (glob) => {
|
|
106
|
+
const cached = regexCache.get(glob);
|
|
107
|
+
if (cached)
|
|
108
|
+
return cached;
|
|
109
|
+
const alternatives = expandBraces(glob).map(globInnerSource);
|
|
110
|
+
const regex = new RegExp(`^(?:${alternatives.join('|')})$`);
|
|
111
|
+
regexCache.set(glob, regex);
|
|
112
|
+
return regex;
|
|
37
113
|
};
|
|
38
114
|
/** Returns true if `path` matches any of the glob patterns. */
|
|
39
115
|
export const matchesGlob = (path, patterns) => {
|
|
@@ -43,6 +119,14 @@ export const matchesGlob = (path, patterns) => {
|
|
|
43
119
|
if (regex.test(path))
|
|
44
120
|
return true;
|
|
45
121
|
// A pattern without a slash also matches the basename.
|
|
46
|
-
|
|
122
|
+
if (!pattern.includes('/'))
|
|
123
|
+
return regex.test(basename);
|
|
124
|
+
// A relative pattern that contains a slash matches an absolute source by
|
|
125
|
+
// suffix, so `src/api.yaml` matches `/home/user/repo/src/api.yaml` — the
|
|
126
|
+
// minimatch/Spectral behavior for override globs against absolute paths.
|
|
127
|
+
if (!pattern.startsWith('/') && !pattern.startsWith('**')) {
|
|
128
|
+
return globToRegExp(`**/${pattern}`).test(path);
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
47
131
|
});
|
|
48
132
|
};
|
package/dist/core/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { createDocument, type Document, type IDocumentOptions } from './document
|
|
|
2
2
|
export { detectFormats, type Format } from './formats.js';
|
|
3
3
|
export { globToRegExp, matchesGlob } from './glob.js';
|
|
4
4
|
export { type CompiledPath, compileQuery, type IQueryMatch, query, queryCompiled, queryMany } from './jsonpath.js';
|
|
5
|
-
export { type LintOptions, type LintResolver, type LintResult, lint, lintWithResult } from './lint.js';
|
|
5
|
+
export { type LintOptions, type LintResolver, type LintResolverResult, type LintResult, lint, lintWithResult, } from './lint.js';
|
|
6
6
|
export { type LintPlugin, type LintPluginContext, type LintPluginResult, type PluginRunResult, runPlugins, } from './plugin.js';
|
|
7
7
|
export { pointerToPath, resolveSourceOrigin, resolveSourceOriginFromMap, resolveSourcePath, } from './pointers.js';
|
|
8
8
|
export { type AliasDefinition, createRuleset, type ExtendModifier, type ExtendResolver, type ResolvedExtend, type Ruleset, type RulesetOptions, } from './ruleset.js';
|
package/dist/core/index.js
CHANGED
|
@@ -2,7 +2,7 @@ export { createDocument } from './document.js';
|
|
|
2
2
|
export { detectFormats } from './formats.js';
|
|
3
3
|
export { globToRegExp, matchesGlob } from './glob.js';
|
|
4
4
|
export { compileQuery, query, queryCompiled, queryMany } from './jsonpath.js';
|
|
5
|
-
export { lint, lintWithResult } from './lint.js';
|
|
5
|
+
export { lint, lintWithResult, } from './lint.js';
|
|
6
6
|
export { runPlugins, } from './plugin.js';
|
|
7
7
|
export { pointerToPath, resolveSourceOrigin, resolveSourceOriginFromMap, resolveSourcePath, } from './pointers.js';
|
|
8
8
|
export { createRuleset, } from './ruleset.js';
|
package/dist/core/jsonpath.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export type IQueryMatch = {
|
|
|
4
4
|
value: unknown;
|
|
5
5
|
path: JsonPath;
|
|
6
6
|
};
|
|
7
|
-
type FilterFn = (value: unknown, property: string | number | undefined, parent: unknown, root: unknown, path:
|
|
7
|
+
type FilterFn = (value: unknown, property: string | number | undefined, parent: unknown, root: unknown, path: string, parentProperty: string | number | undefined) => boolean;
|
|
8
8
|
type Selector = {
|
|
9
9
|
kind: 'child';
|
|
10
10
|
name: string;
|
|
@@ -16,6 +16,14 @@ type Selector = {
|
|
|
16
16
|
} | {
|
|
17
17
|
kind: 'union';
|
|
18
18
|
names: (string | number)[];
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'slice';
|
|
21
|
+
start?: number;
|
|
22
|
+
end?: number;
|
|
23
|
+
step?: number;
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'scriptIndex';
|
|
26
|
+
offset: number;
|
|
19
27
|
} | {
|
|
20
28
|
kind: 'filter';
|
|
21
29
|
test: FilterFn;
|
|
@@ -25,6 +33,8 @@ type Selector = {
|
|
|
25
33
|
kind: 'parent';
|
|
26
34
|
} | {
|
|
27
35
|
kind: 'keys';
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'none';
|
|
28
38
|
};
|
|
29
39
|
/** One compiled segment of a path: a selector and whether it follows a `..` descent. */
|
|
30
40
|
export type Step = {
|
|
@@ -38,6 +48,12 @@ export type CompiledPath = {
|
|
|
38
48
|
readonly steps: Step[];
|
|
39
49
|
/** True when the path contains at least one `..` step. */
|
|
40
50
|
readonly hasDescent: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* A parse error, when the expression is malformed (an unterminated bracket, a
|
|
53
|
+
* missing `$` root, an unsupported script subscript, …). A compiled path with
|
|
54
|
+
* an error matches nothing; callers such as {@link createRuleset} surface it.
|
|
55
|
+
*/
|
|
56
|
+
readonly error?: string;
|
|
41
57
|
};
|
|
42
58
|
/** Compiles a JSONPath `expression` into a {@link CompiledPath}, cached by string so repeats are free. */
|
|
43
59
|
export declare const compileQuery: (expression: string) => CompiledPath;
|
package/dist/core/jsonpath.js
CHANGED
|
@@ -10,24 +10,89 @@ const normalizeSegment = (segment) => {
|
|
|
10
10
|
return segment;
|
|
11
11
|
};
|
|
12
12
|
const normalizePath = (path) => path.map(normalizeSegment);
|
|
13
|
+
/**
|
|
14
|
+
* Renders a concrete path as the jsonpath-plus string form (`$['a'][0]`). This
|
|
15
|
+
* is what `@path` exposes inside a filter, so ruleset filters authored for
|
|
16
|
+
* Spectral (which run on jsonpath-plus) see the same value.
|
|
17
|
+
*/
|
|
18
|
+
const pathToJsonPathString = (path) => {
|
|
19
|
+
let out = '$';
|
|
20
|
+
for (const segment of path) {
|
|
21
|
+
if (typeof segment === 'number')
|
|
22
|
+
out += `[${segment}]`;
|
|
23
|
+
else
|
|
24
|
+
out += `['${segment.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}']`;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
};
|
|
13
28
|
// ---------------------------------------------------------------------------
|
|
14
29
|
// Compilation
|
|
15
30
|
// ---------------------------------------------------------------------------
|
|
16
31
|
const compileCache = new Map();
|
|
17
32
|
const filterCache = new Map();
|
|
33
|
+
/**
|
|
34
|
+
* Rewrites jsonpath-plus' `@`-context tokens onto real identifiers, skipping any
|
|
35
|
+
* `@` that sits inside a quoted string literal (so an expression like
|
|
36
|
+
* `@.name.indexOf("@")` keeps the literal `@` intact). Longer tokens are matched
|
|
37
|
+
* first so `@parentProperty` is not eaten by `@parent`.
|
|
38
|
+
*/
|
|
39
|
+
const substituteContext = (source) => {
|
|
40
|
+
let out = '';
|
|
41
|
+
let quote = '';
|
|
42
|
+
for (let i = 0; i < source.length; i++) {
|
|
43
|
+
const ch = source[i];
|
|
44
|
+
if (quote) {
|
|
45
|
+
out += ch;
|
|
46
|
+
if (ch === '\\' && i + 1 < source.length) {
|
|
47
|
+
out += source[i + 1];
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
else if (ch === quote) {
|
|
51
|
+
quote = '';
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (ch === '"' || ch === "'") {
|
|
56
|
+
quote = ch;
|
|
57
|
+
out += ch;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (ch === '@') {
|
|
61
|
+
const rest = source.slice(i);
|
|
62
|
+
if (rest.startsWith('@parentProperty')) {
|
|
63
|
+
out += '_pp';
|
|
64
|
+
i += '@parentProperty'.length - 1;
|
|
65
|
+
}
|
|
66
|
+
else if (rest.startsWith('@parent')) {
|
|
67
|
+
out += '_parent';
|
|
68
|
+
i += '@parent'.length - 1;
|
|
69
|
+
}
|
|
70
|
+
else if (rest.startsWith('@property')) {
|
|
71
|
+
out += '_prop';
|
|
72
|
+
i += '@property'.length - 1;
|
|
73
|
+
}
|
|
74
|
+
else if (rest.startsWith('@path')) {
|
|
75
|
+
out += '_path';
|
|
76
|
+
i += '@path'.length - 1;
|
|
77
|
+
}
|
|
78
|
+
else if (rest.startsWith('@root')) {
|
|
79
|
+
out += '_root';
|
|
80
|
+
i += '@root'.length - 1;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
out += '_v';
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
out += ch;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
};
|
|
18
91
|
const compileFilter = (source) => {
|
|
19
92
|
const cached = filterCache.get(source);
|
|
20
93
|
if (cached)
|
|
21
94
|
return cached;
|
|
22
|
-
|
|
23
|
-
// so `@parentProperty` is not eaten by `@parent`.
|
|
24
|
-
const body = source
|
|
25
|
-
.replace(/@parentProperty/g, '_pp')
|
|
26
|
-
.replace(/@parent/g, '_parent')
|
|
27
|
-
.replace(/@property/g, '_prop')
|
|
28
|
-
.replace(/@path/g, '_path')
|
|
29
|
-
.replace(/@root/g, '_root')
|
|
30
|
-
.replace(/@/g, '_v');
|
|
95
|
+
const body = substituteContext(source);
|
|
31
96
|
let fn;
|
|
32
97
|
try {
|
|
33
98
|
// `_pp` (`@parentProperty`) is supplied directly by the caller rather than
|
|
@@ -42,7 +107,7 @@ const compileFilter = (source) => {
|
|
|
42
107
|
filterCache.set(source, fn);
|
|
43
108
|
return fn;
|
|
44
109
|
};
|
|
45
|
-
/** Splits bracket content on top-level commas, respecting quotes. */
|
|
110
|
+
/** Splits bracket content on top-level commas, respecting quotes (and their escapes). */
|
|
46
111
|
const splitUnion = (content) => {
|
|
47
112
|
const parts = [];
|
|
48
113
|
let depth = 0;
|
|
@@ -51,6 +116,11 @@ const splitUnion = (content) => {
|
|
|
51
116
|
for (let i = 0; i < content.length; i++) {
|
|
52
117
|
const ch = content[i];
|
|
53
118
|
if (quote) {
|
|
119
|
+
if (ch === '\\' && i + 1 < content.length) {
|
|
120
|
+
current += ch + content[i + 1];
|
|
121
|
+
i++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
54
124
|
if (ch === quote)
|
|
55
125
|
quote = '';
|
|
56
126
|
current += ch;
|
|
@@ -75,14 +145,54 @@ const splitUnion = (content) => {
|
|
|
75
145
|
parts.push(current);
|
|
76
146
|
return parts;
|
|
77
147
|
};
|
|
148
|
+
/** Unquotes a `'...'` / `"..."` token, honoring backslash escapes; returns null when not a quoted literal. */
|
|
78
149
|
const unquote = (token) => {
|
|
79
150
|
const t = token.trim();
|
|
80
|
-
|
|
81
|
-
|
|
151
|
+
const quote = t[0];
|
|
152
|
+
if (t.length < 2 || (quote !== '"' && quote !== "'"))
|
|
153
|
+
return null;
|
|
154
|
+
let out = '';
|
|
155
|
+
let i = 1;
|
|
156
|
+
let closed = false;
|
|
157
|
+
while (i < t.length) {
|
|
158
|
+
const ch = t[i];
|
|
159
|
+
if (ch === '\\' && i + 1 < t.length) {
|
|
160
|
+
out += t[i + 1];
|
|
161
|
+
i += 2;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (ch === quote) {
|
|
165
|
+
closed = true;
|
|
166
|
+
i++;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
out += ch;
|
|
170
|
+
i++;
|
|
82
171
|
}
|
|
83
|
-
|
|
172
|
+
// A valid literal closes exactly at the end of the token.
|
|
173
|
+
if (!closed || i !== t.length)
|
|
174
|
+
return null;
|
|
175
|
+
return out;
|
|
84
176
|
};
|
|
85
|
-
|
|
177
|
+
// A slice is `start:end` or `start:end:step`, each part optional and possibly
|
|
178
|
+
// negative, e.g. `0:2`, `-1:`, `::2`, `:`.
|
|
179
|
+
const SLICE_RE = /^-?\d*:-?\d*(:-?\d+)?$/;
|
|
180
|
+
const buildSlice = (trimmed) => {
|
|
181
|
+
const [s, e, st] = trimmed.split(':');
|
|
182
|
+
const num = (value) => value === undefined || value === '' ? undefined : Number(value);
|
|
183
|
+
const selector = { kind: 'slice' };
|
|
184
|
+
const start = num(s);
|
|
185
|
+
const end = num(e);
|
|
186
|
+
const step = num(st);
|
|
187
|
+
if (start !== undefined)
|
|
188
|
+
selector.start = start;
|
|
189
|
+
if (end !== undefined)
|
|
190
|
+
selector.end = end;
|
|
191
|
+
if (step !== undefined)
|
|
192
|
+
selector.step = step;
|
|
193
|
+
return selector;
|
|
194
|
+
};
|
|
195
|
+
const bracketSelector = (content, onError) => {
|
|
86
196
|
const trimmed = content.trim();
|
|
87
197
|
if (trimmed === '*')
|
|
88
198
|
return { kind: 'wildcard' };
|
|
@@ -93,6 +203,23 @@ const bracketSelector = (content) => {
|
|
|
93
203
|
const expr = open !== -1 && close > open ? trimmed.slice(open + 1, close) : trimmed.slice(1);
|
|
94
204
|
return { kind: 'filter', test: compileFilter(expr), source: expr, usesPath: expr.includes('@path') };
|
|
95
205
|
}
|
|
206
|
+
if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
|
|
207
|
+
// Script subscript `[(expr)]`. We support the common `(@.length - N)` form
|
|
208
|
+
// (a from-the-end index); anything else is rejected loudly so a typo does not
|
|
209
|
+
// silently match nothing.
|
|
210
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
211
|
+
const lengthOnly = /^@\.length$/.test(inner);
|
|
212
|
+
const withOffset = /^@\.length\s*-\s*(\d+)$/.exec(inner);
|
|
213
|
+
if (lengthOnly)
|
|
214
|
+
return { kind: 'scriptIndex', offset: 0 };
|
|
215
|
+
if (withOffset)
|
|
216
|
+
return { kind: 'scriptIndex', offset: -Number(withOffset[1]) };
|
|
217
|
+
onError(`Unsupported script subscript "[${content}]"`);
|
|
218
|
+
return { kind: 'none' };
|
|
219
|
+
}
|
|
220
|
+
// A slice is only a slice when it is a single (comma-free) `:`-delimited token.
|
|
221
|
+
if (!content.includes(',') && SLICE_RE.test(trimmed))
|
|
222
|
+
return buildSlice(trimmed);
|
|
96
223
|
const parts = splitUnion(content);
|
|
97
224
|
const names = [];
|
|
98
225
|
for (const part of parts) {
|
|
@@ -114,13 +241,18 @@ const bracketSelector = (content) => {
|
|
|
114
241
|
}
|
|
115
242
|
return { kind: 'union', names };
|
|
116
243
|
};
|
|
117
|
-
/** Finds the index of the `]` that closes the `[` at `start`, respecting quotes
|
|
244
|
+
/** Finds the index of the `]` that closes the `[` at `start`, respecting quotes (and escapes) and nesting. */
|
|
118
245
|
const findBracketEnd = (expression, start) => {
|
|
119
246
|
let depth = 0;
|
|
120
247
|
let quote = '';
|
|
121
248
|
for (let i = start; i < expression.length; i++) {
|
|
122
249
|
const ch = expression[i];
|
|
123
250
|
if (quote) {
|
|
251
|
+
if (ch === '\\') {
|
|
252
|
+
// Skip the escaped character so an escaped quote does not end the string.
|
|
253
|
+
i++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
124
256
|
if (ch === quote)
|
|
125
257
|
quote = '';
|
|
126
258
|
continue;
|
|
@@ -151,10 +283,18 @@ export const compileQuery = (expression) => {
|
|
|
151
283
|
if (cached)
|
|
152
284
|
return cached;
|
|
153
285
|
const steps = [];
|
|
286
|
+
const errors = [];
|
|
287
|
+
const onError = (message) => {
|
|
288
|
+
errors.push(message);
|
|
289
|
+
};
|
|
154
290
|
let hasDescent = false;
|
|
155
291
|
let i = 0;
|
|
292
|
+
// Every well-formed JSONPath is rooted at `$`. Without it the expression would
|
|
293
|
+
// otherwise compile to zero steps and match the document root — a silent bug.
|
|
156
294
|
if (expression[0] === '$')
|
|
157
295
|
i = 1;
|
|
296
|
+
else
|
|
297
|
+
onError('JSONPath must start with "$"');
|
|
158
298
|
let recursive = false;
|
|
159
299
|
while (i < expression.length) {
|
|
160
300
|
const ch = expression[i];
|
|
@@ -181,21 +321,27 @@ export const compileQuery = (expression) => {
|
|
|
181
321
|
}
|
|
182
322
|
if (ch === '[') {
|
|
183
323
|
const end = findBracketEnd(expression, i);
|
|
184
|
-
if (end === -1)
|
|
324
|
+
if (end === -1) {
|
|
325
|
+
onError(`Unterminated "[" in "${expression}"`);
|
|
185
326
|
break;
|
|
327
|
+
}
|
|
186
328
|
const content = expression.slice(i + 1, end);
|
|
187
|
-
steps.push({ recursive, selector: bracketSelector(content) });
|
|
329
|
+
steps.push({ recursive, selector: bracketSelector(content, onError) });
|
|
188
330
|
recursive = false;
|
|
189
331
|
i = end + 1;
|
|
190
332
|
continue;
|
|
191
333
|
}
|
|
192
334
|
if (ch === '^') {
|
|
193
|
-
|
|
335
|
+
// Honor a pending `..` so `$..^` selects every node's parent (fixing a
|
|
336
|
+
// flag leak where the descent was dropped and the selector matched nothing).
|
|
337
|
+
steps.push({ recursive, selector: { kind: 'parent' } });
|
|
338
|
+
recursive = false;
|
|
194
339
|
i++;
|
|
195
340
|
continue;
|
|
196
341
|
}
|
|
197
342
|
if (ch === '~') {
|
|
198
|
-
steps.push({ recursive
|
|
343
|
+
steps.push({ recursive, selector: { kind: 'keys' } });
|
|
344
|
+
recursive = false;
|
|
199
345
|
i++;
|
|
200
346
|
continue;
|
|
201
347
|
}
|
|
@@ -217,11 +363,15 @@ export const compileQuery = (expression) => {
|
|
|
217
363
|
}
|
|
218
364
|
i++;
|
|
219
365
|
}
|
|
220
|
-
const compiled = {
|
|
366
|
+
const compiled = {
|
|
367
|
+
expression,
|
|
368
|
+
steps,
|
|
369
|
+
hasDescent,
|
|
370
|
+
...(errors.length > 0 ? { error: errors.join('; ') } : {}),
|
|
371
|
+
};
|
|
221
372
|
compileCache.set(expression, compiled);
|
|
222
373
|
return compiled;
|
|
223
374
|
};
|
|
224
|
-
const EMPTY_PATH = [];
|
|
225
375
|
/** Materializes the concrete (un-normalized) path from root to `node`. */
|
|
226
376
|
const pathOf = (node) => {
|
|
227
377
|
let depth = 0;
|
|
@@ -286,13 +436,48 @@ const applySelector = (node, selector, root, out) => {
|
|
|
286
436
|
}
|
|
287
437
|
return;
|
|
288
438
|
}
|
|
439
|
+
case 'slice': {
|
|
440
|
+
if (!Array.isArray(value))
|
|
441
|
+
return;
|
|
442
|
+
const len = value.length;
|
|
443
|
+
const step = selector.step ?? 1;
|
|
444
|
+
if (step === 0)
|
|
445
|
+
return;
|
|
446
|
+
const clamp = (raw, fallback) => {
|
|
447
|
+
if (raw === undefined)
|
|
448
|
+
return fallback;
|
|
449
|
+
return raw < 0 ? raw + len : raw;
|
|
450
|
+
};
|
|
451
|
+
if (step > 0) {
|
|
452
|
+
const start = Math.max(0, clamp(selector.start, 0));
|
|
453
|
+
const end = Math.min(len, clamp(selector.end, len));
|
|
454
|
+
for (let idx = start; idx < end; idx += step)
|
|
455
|
+
out.push({ value: value[idx], parent: node, key: idx });
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
const start = Math.min(len - 1, clamp(selector.start, len - 1));
|
|
459
|
+
const end = Math.max(-1, clamp(selector.end, -1));
|
|
460
|
+
for (let idx = start; idx > end; idx += step)
|
|
461
|
+
out.push({ value: value[idx], parent: node, key: idx });
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
case 'scriptIndex': {
|
|
466
|
+
// `[(@.length - N)]` indexes from the end; N === 0 (`@.length`) is out of range.
|
|
467
|
+
if (!Array.isArray(value))
|
|
468
|
+
return;
|
|
469
|
+
const idx = value.length + selector.offset;
|
|
470
|
+
if (idx >= 0 && idx < value.length)
|
|
471
|
+
out.push({ value: value[idx], parent: node, key: idx });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
289
474
|
case 'filter': {
|
|
290
475
|
// `@parentProperty` is `node.key`; `@path` is materialized only when used.
|
|
291
476
|
const pp = node.key;
|
|
292
477
|
if (Array.isArray(value)) {
|
|
293
478
|
for (let idx = 0; idx < value.length; idx++) {
|
|
294
479
|
const child = { value: value[idx], parent: node, key: idx };
|
|
295
|
-
const path = selector.usesPath ? pathOf(child) :
|
|
480
|
+
const path = selector.usesPath ? pathToJsonPathString(pathOf(child)) : '';
|
|
296
481
|
if (selector.test(value[idx], idx, value, root, path, pp))
|
|
297
482
|
out.push(child);
|
|
298
483
|
}
|
|
@@ -300,7 +485,7 @@ const applySelector = (node, selector, root, out) => {
|
|
|
300
485
|
else if (isObject(value)) {
|
|
301
486
|
for (const key of Object.keys(value)) {
|
|
302
487
|
const child = { value: value[key], parent: node, key };
|
|
303
|
-
const path = selector.usesPath ? pathOf(child) :
|
|
488
|
+
const path = selector.usesPath ? pathToJsonPathString(pathOf(child)) : '';
|
|
304
489
|
if (selector.test(value[key], key, value, root, path, pp))
|
|
305
490
|
out.push(child);
|
|
306
491
|
}
|
|
@@ -319,6 +504,8 @@ const applySelector = (node, selector, root, out) => {
|
|
|
319
504
|
out.push({ value: node.key, parent: node.parent, key: node.key });
|
|
320
505
|
return;
|
|
321
506
|
}
|
|
507
|
+
case 'none':
|
|
508
|
+
return;
|
|
322
509
|
}
|
|
323
510
|
};
|
|
324
511
|
/** Visits `node` and every descendant (preorder), invoking `visit` on each. */
|
|
@@ -362,6 +549,9 @@ const toMatches = (nodes) => {
|
|
|
362
549
|
};
|
|
363
550
|
/** Evaluates a pre-compiled path against `data`. */
|
|
364
551
|
export const queryCompiled = (data, compiled) => {
|
|
552
|
+
// A malformed path matches nothing rather than falling back to the root.
|
|
553
|
+
if (compiled.error !== undefined)
|
|
554
|
+
return [];
|
|
365
555
|
if (data === null || data === undefined)
|
|
366
556
|
return [];
|
|
367
557
|
return toMatches(runSteps(data, compiled.steps));
|
|
@@ -386,6 +576,11 @@ export const queryMany = (data, compiled) => {
|
|
|
386
576
|
const recursive = [];
|
|
387
577
|
for (let i = 0; i < compiled.length; i++) {
|
|
388
578
|
const c = compiled[i];
|
|
579
|
+
// Skip malformed paths entirely; they contribute no matches.
|
|
580
|
+
if (c.error !== undefined) {
|
|
581
|
+
out[i] = [];
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
389
584
|
const first = c.steps[0];
|
|
390
585
|
if (first?.recursive)
|
|
391
586
|
recursive.push(i);
|
package/dist/core/lint.d.ts
CHANGED
|
@@ -2,21 +2,28 @@ import { type Document, type IDocumentOptions } from './document.js';
|
|
|
2
2
|
import type { LintPlugin } from './plugin.js';
|
|
3
3
|
import type { Ruleset } from './ruleset.js';
|
|
4
4
|
import { type IDiagnostic, type ISourceSet } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* What a {@link LintResolver} returns: the dereferenced tree, an optional source
|
|
7
|
+
* set mapping inlined nodes back to their origin, and any diagnostics raised
|
|
8
|
+
* while resolving (e.g. a `$ref` that could not be dereferenced). Surfacing
|
|
9
|
+
* resolution failures as diagnostics keeps a typo'd ref or a failed fetch
|
|
10
|
+
* visible instead of silently dropping it.
|
|
11
|
+
*/
|
|
12
|
+
export type LintResolverResult = {
|
|
13
|
+
resolved: unknown;
|
|
14
|
+
sources?: ISourceSet;
|
|
15
|
+
diagnostics?: IDiagnostic[];
|
|
16
|
+
};
|
|
5
17
|
/**
|
|
6
18
|
* How a resolved (`$ref`-dereferenced) tree is produced from a parsed document.
|
|
7
19
|
* The engine stays free of any `$ref` resolver: a caller may inject one (for
|
|
8
20
|
* example wrapping `@amritk/resolve-refs`). Returning no `sources` means
|
|
9
|
-
* findings map back to the root document only
|
|
21
|
+
* findings map back to the root document only; `diagnostics` reports refs that
|
|
22
|
+
* failed to resolve.
|
|
10
23
|
*/
|
|
11
24
|
export type LintResolver = (document: Document, context: {
|
|
12
25
|
input: string;
|
|
13
|
-
}) =>
|
|
14
|
-
resolved: unknown;
|
|
15
|
-
sources?: ISourceSet;
|
|
16
|
-
} | Promise<{
|
|
17
|
-
resolved: unknown;
|
|
18
|
-
sources?: ISourceSet;
|
|
19
|
-
}>;
|
|
26
|
+
}) => LintResolverResult | Promise<LintResolverResult>;
|
|
20
27
|
/** Options for {@link lint} / {@link lintWithResult}. */
|
|
21
28
|
export type LintOptions = IDocumentOptions & {
|
|
22
29
|
/** A normalized ruleset to evaluate (built via {@link createRuleset}). */
|
package/dist/core/lint.js
CHANGED
|
@@ -18,7 +18,13 @@ const toParserSeverity = (value) => {
|
|
|
18
18
|
};
|
|
19
19
|
return names[value];
|
|
20
20
|
};
|
|
21
|
-
const byPosition = (a, b) =>
|
|
21
|
+
const byPosition = (a, b) => {
|
|
22
|
+
const sa = a.source ?? '';
|
|
23
|
+
const sb = b.source ?? '';
|
|
24
|
+
if (sa !== sb)
|
|
25
|
+
return sa < sb ? -1 : 1;
|
|
26
|
+
return a.range.start.line - b.range.start.line || a.range.start.character - b.range.start.character;
|
|
27
|
+
};
|
|
22
28
|
/**
|
|
23
29
|
* Lints `input` against a normalized `ruleset`, returning the full
|
|
24
30
|
* {@link LintResult} (findings plus anything the configured `plugins` produced).
|
|
@@ -42,12 +48,15 @@ export const lintWithResult = async (input, options) => {
|
|
|
42
48
|
return { diagnostics: [], pluginData: {} };
|
|
43
49
|
let resolved = document.data;
|
|
44
50
|
let sources;
|
|
51
|
+
let resolverDiagnostics = [];
|
|
45
52
|
if (resolve) {
|
|
46
53
|
const result = await resolve(document, { input });
|
|
47
54
|
resolved = result.resolved;
|
|
48
55
|
sources = result.sources;
|
|
56
|
+
if (result.diagnostics)
|
|
57
|
+
resolverDiagnostics = result.diagnostics;
|
|
49
58
|
}
|
|
50
|
-
const ruleResults = createLinter(ruleset).run(document, { resolved, ...(sources ? { sources } : {}) });
|
|
59
|
+
const ruleResults = await createLinter(ruleset).run(document, { resolved, ...(sources ? { sources } : {}) });
|
|
51
60
|
const parserResults = document.diagnostics.map((diagnostic) => {
|
|
52
61
|
const result = {
|
|
53
62
|
code: 'parser',
|
|
@@ -60,7 +69,7 @@ export const lintWithResult = async (input, options) => {
|
|
|
60
69
|
result.source = document.source;
|
|
61
70
|
return result;
|
|
62
71
|
});
|
|
63
|
-
const diagnostics = [...parserResults, ...ruleResults].sort(byPosition);
|
|
72
|
+
const diagnostics = [...parserResults, ...resolverDiagnostics, ...ruleResults].sort(byPosition);
|
|
64
73
|
if (!plugins || plugins.length === 0)
|
|
65
74
|
return { diagnostics, pluginData: {} };
|
|
66
75
|
const format = documentOptions.format ?? detectFormat(input);
|