@amritk/lint 0.2.0 → 0.3.1

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 (68) hide show
  1. package/README.md +18 -0
  2. package/dist/core/glob.d.ts +1 -1
  3. package/dist/core/glob.js +89 -5
  4. package/dist/core/index.d.ts +1 -1
  5. package/dist/core/index.js +1 -1
  6. package/dist/core/jsonpath.d.ts +17 -1
  7. package/dist/core/jsonpath.js +218 -23
  8. package/dist/core/lint.d.ts +15 -8
  9. package/dist/core/lint.js +12 -3
  10. package/dist/core/plugin.d.ts +6 -0
  11. package/dist/core/plugin.js +6 -0
  12. package/dist/core/pointers.js +15 -15
  13. package/dist/core/ruleset.js +0 -0
  14. package/dist/core/runner.d.ts +6 -1
  15. package/dist/core/runner.js +127 -43
  16. package/dist/core/types.d.ts +17 -2
  17. package/dist/core/validate-ruleset.js +16 -0
  18. package/dist/fix/apply.d.ts +8 -2
  19. package/dist/fix/apply.js +68 -18
  20. package/dist/functions/alphabetical.js +40 -13
  21. package/dist/functions/casing.js +27 -5
  22. package/dist/functions/enumeration.d.ts +5 -3
  23. package/dist/functions/enumeration.js +18 -1
  24. package/dist/functions/index.d.ts +1 -0
  25. package/dist/functions/index.js +3 -0
  26. package/dist/functions/length.d.ts +13 -3
  27. package/dist/functions/length.js +11 -3
  28. package/dist/functions/or.d.ts +11 -0
  29. package/dist/functions/or.js +25 -0
  30. package/dist/functions/pattern.d.ts +5 -3
  31. package/dist/functions/pattern.js +42 -9
  32. package/dist/functions/schema.d.ts +13 -0
  33. package/dist/functions/schema.js +95 -2
  34. package/dist/functions/typed-enum.js +7 -1
  35. package/dist/functions/unreferenced-reusable-object.d.ts +7 -1
  36. package/dist/functions/unreferenced-reusable-object.js +18 -3
  37. package/dist/functions/xor.js +8 -1
  38. package/dist/index.js +9 -1
  39. package/dist/parsers/edit-model.d.ts +15 -0
  40. package/dist/parsers/edit-model.js +210 -41
  41. package/dist/parsers/types.d.ts +14 -2
  42. package/dist/parsers/yaml.d.ts +10 -0
  43. package/dist/parsers/yaml.js +174 -26
  44. package/dist/rules/openapi/fixers.js +63 -4
  45. package/dist/rules/openapi/formats.js +11 -4
  46. package/dist/rules/openapi/functions/example-validation.d.ts +16 -3
  47. package/dist/rules/openapi/functions/example-validation.js +102 -39
  48. package/dist/rules/openapi/functions/helpers.d.ts +1 -0
  49. package/dist/rules/openapi/functions/helpers.js +5 -0
  50. package/dist/rules/openapi/functions/index.d.ts +3 -1
  51. package/dist/rules/openapi/functions/index.js +7 -1
  52. package/dist/rules/openapi/functions/oas-additional-operations.js +5 -5
  53. package/dist/rules/openapi/functions/oas-example-external-value.d.ts +11 -0
  54. package/dist/rules/openapi/functions/oas-example-external-value.js +23 -0
  55. package/dist/rules/openapi/functions/oas-no-nullable.d.ts +13 -0
  56. package/dist/rules/openapi/functions/oas-no-nullable.js +22 -0
  57. package/dist/rules/openapi/functions/oas-op-id-unique.js +4 -2
  58. package/dist/rules/openapi/functions/oas-op-params.d.ts +7 -1
  59. package/dist/rules/openapi/functions/oas-op-params.js +35 -10
  60. package/dist/rules/openapi/functions/oas-op-security-defined.js +2 -2
  61. package/dist/rules/openapi/functions/oas-op-success-response.js +6 -1
  62. package/dist/rules/openapi/functions/oas-path-param.d.ts +10 -1
  63. package/dist/rules/openapi/functions/oas-path-param.js +87 -26
  64. package/dist/rules/openapi/functions/oas-server-variables.d.ts +6 -1
  65. package/dist/rules/openapi/functions/oas-server-variables.js +31 -2
  66. package/dist/rules/openapi/functions/oas-unused-component.js +14 -1
  67. package/dist/rules/openapi/oas.js +82 -25
  68. 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 | ~7 ms | ~100 ms | **~14×** | 2 / 2 |
224
+ | digitalocean | 105 KB | ~31 ms | ~355 ms | **~12×** | 2411 / 4319 |
225
+ | openai | 2.8 MB | ~1.4 s | errored¹ | — | 1278 / — |
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.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.
232
+
233
+ ---
234
+
217
235
  ## License
218
236
 
219
237
  MIT
@@ -1,4 +1,4 @@
1
- /** Compiles a glob pattern (`**`, `*`, `?`) into an anchored RegExp. */
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
- /** Compiles a glob pattern (`**`, `*`, `?`) into an anchored RegExp. */
7
- export const globToRegExp = (glob) => {
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 new RegExp(`^${source}$`);
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
- return !pattern.includes('/') && regex.test(basename);
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
  };
@@ -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';
@@ -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';
@@ -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: JsonPath, parentProperty: string | number | undefined) => boolean;
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;
@@ -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
- // Map jsonpath-plus' `@`-context tokens onto real identifiers, longest first
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
- if (t.length >= 2 && (t[0] === '"' || t[0] === "'") && t[t.length - 1] === t[0]) {
81
- return t.slice(1, -1);
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
- return null;
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
- const bracketSelector = (content) => {
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/nesting. */
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
- steps.push({ recursive: false, selector: { kind: 'parent' } });
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: false, selector: { kind: 'keys' } });
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 = { expression, steps, hasDescent };
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) : EMPTY_PATH;
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) : EMPTY_PATH;
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);
@@ -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) => a.range.start.line - b.range.start.line || a.range.start.character - b.range.start.character;
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);