@amritk/lint 0.4.8 → 0.5.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.
Files changed (107) hide show
  1. package/AI.md +73 -4
  2. package/README.md +65 -14
  3. package/dist/core/filter-expression.js +17 -4
  4. package/dist/core/filter.js +82 -35
  5. package/dist/core/glob.js +25 -31
  6. package/dist/core/jsonpath.d.ts +9 -1
  7. package/dist/core/jsonpath.js +74 -16
  8. package/dist/core/lint.d.ts +8 -1
  9. package/dist/core/lint.js +4 -18
  10. package/dist/core/order.d.ts +30 -0
  11. package/dist/core/order.js +36 -0
  12. package/dist/core/pointers.d.ts +3 -2
  13. package/dist/core/pointers.js +27 -18
  14. package/dist/core/ruleset.js +62 -20
  15. package/dist/core/runner.js +16 -23
  16. package/dist/core/severity.d.ts +11 -0
  17. package/dist/core/severity.js +13 -0
  18. package/dist/fix/apply.js +6 -1
  19. package/dist/fix/plugin.js +2 -1
  20. package/dist/functions/or.js +1 -1
  21. package/dist/functions/ref-index.d.ts +15 -0
  22. package/dist/functions/ref-index.js +33 -0
  23. package/dist/functions/schema.d.ts +15 -0
  24. package/dist/functions/schema.js +2 -0
  25. package/dist/functions/typed-enum.js +2 -1
  26. package/dist/functions/unreferenced-reusable-object.js +3 -20
  27. package/dist/functions/xor.js +1 -1
  28. package/dist/index.d.ts +16 -16
  29. package/dist/index.js +24 -93
  30. package/dist/rules/asyncapi/asyncapi.d.ts +3 -0
  31. package/dist/rules/asyncapi/asyncapi.js +563 -0
  32. package/dist/rules/asyncapi/formats.d.ts +23 -0
  33. package/dist/rules/asyncapi/formats.js +49 -0
  34. package/dist/rules/asyncapi/functions/aas-server-variables.d.ts +8 -0
  35. package/dist/rules/asyncapi/functions/aas-server-variables.js +5 -0
  36. package/dist/rules/asyncapi/functions/aas-tags-unique.d.ts +2 -0
  37. package/dist/rules/asyncapi/functions/aas-tags-unique.js +5 -0
  38. package/dist/rules/asyncapi/functions/asyncapi-channel-parameters.d.ts +7 -0
  39. package/dist/rules/asyncapi/functions/asyncapi-channel-parameters.js +46 -0
  40. package/dist/rules/asyncapi/functions/asyncapi-channel-servers.d.ts +27 -0
  41. package/dist/rules/asyncapi/functions/asyncapi-channel-servers.js +61 -0
  42. package/dist/rules/asyncapi/functions/asyncapi-document-schema.d.ts +17 -0
  43. package/dist/rules/asyncapi/functions/asyncapi-document-schema.js +12 -0
  44. package/dist/rules/asyncapi/functions/asyncapi-headers-object.d.ts +23 -0
  45. package/dist/rules/asyncapi/functions/asyncapi-headers-object.js +25 -0
  46. package/dist/rules/asyncapi/functions/asyncapi-message-examples.d.ts +8 -0
  47. package/dist/rules/asyncapi/functions/asyncapi-message-examples.js +42 -0
  48. package/dist/rules/asyncapi/functions/asyncapi-message-id-unique.d.ts +3 -0
  49. package/dist/rules/asyncapi/functions/asyncapi-message-id-unique.js +30 -0
  50. package/dist/rules/asyncapi/functions/asyncapi-operation-id-unique.d.ts +3 -0
  51. package/dist/rules/asyncapi/functions/asyncapi-operation-id-unique.js +30 -0
  52. package/dist/rules/asyncapi/functions/asyncapi-payload.d.ts +14 -0
  53. package/dist/rules/asyncapi/functions/asyncapi-payload.js +32 -0
  54. package/dist/rules/asyncapi/functions/asyncapi-schema-validation.d.ts +11 -0
  55. package/dist/rules/asyncapi/functions/asyncapi-schema-validation.js +17 -0
  56. package/dist/rules/asyncapi/functions/asyncapi-security.d.ts +22 -0
  57. package/dist/rules/asyncapi/functions/asyncapi-security.js +78 -0
  58. package/dist/rules/asyncapi/functions/helpers.d.ts +51 -0
  59. package/dist/rules/asyncapi/functions/helpers.js +91 -0
  60. package/dist/rules/asyncapi/functions/index.d.ts +15 -0
  61. package/dist/rules/asyncapi/functions/index.js +53 -0
  62. package/dist/rules/asyncapi/functions/pointer.d.ts +11 -0
  63. package/dist/rules/asyncapi/functions/pointer.js +11 -0
  64. package/dist/rules/asyncapi/functions/schema-format.d.ts +4 -0
  65. package/dist/rules/asyncapi/functions/schema-format.js +7 -0
  66. package/dist/rules/asyncapi/index.d.ts +33 -0
  67. package/dist/rules/asyncapi/index.js +60 -0
  68. package/dist/rules/asyncapi/schemas/aas20.d.ts +2 -0
  69. package/dist/rules/asyncapi/schemas/aas20.js +4 -0
  70. package/dist/rules/asyncapi/schemas/aas21.d.ts +2 -0
  71. package/dist/rules/asyncapi/schemas/aas21.js +4 -0
  72. package/dist/rules/asyncapi/schemas/aas22.d.ts +2 -0
  73. package/dist/rules/asyncapi/schemas/aas22.js +4 -0
  74. package/dist/rules/asyncapi/schemas/aas23.d.ts +2 -0
  75. package/dist/rules/asyncapi/schemas/aas23.js +4 -0
  76. package/dist/rules/asyncapi/schemas/aas24.d.ts +2 -0
  77. package/dist/rules/asyncapi/schemas/aas24.js +4 -0
  78. package/dist/rules/asyncapi/schemas/aas25.d.ts +2 -0
  79. package/dist/rules/asyncapi/schemas/aas25.js +4 -0
  80. package/dist/rules/asyncapi/schemas/aas26.d.ts +2 -0
  81. package/dist/rules/asyncapi/schemas/aas26.js +4 -0
  82. package/dist/rules/asyncapi/schemas/aas30.d.ts +2 -0
  83. package/dist/rules/asyncapi/schemas/aas30.js +4 -0
  84. package/dist/rules/asyncapi/schemas/index.d.ts +20 -0
  85. package/dist/rules/asyncapi/schemas/index.js +45 -0
  86. package/dist/rules/openapi/fixers.d.ts +6 -2
  87. package/dist/rules/openapi/fixers.js +2 -0
  88. package/dist/rules/openapi/functions/oas-discriminator.js +1 -1
  89. package/dist/rules/openapi/functions/oas-mutually-exclusive.js +1 -1
  90. package/dist/rules/openapi/functions/oas-op-security-defined.js +1 -1
  91. package/dist/rules/openapi/functions/oas-server-variables.d.ts +6 -6
  92. package/dist/rules/openapi/functions/oas-server-variables.js +2 -45
  93. package/dist/rules/openapi/functions/oas-tags-unique.d.ts +2 -3
  94. package/dist/rules/openapi/functions/oas-tags-unique.js +2 -16
  95. package/dist/rules/openapi/functions/oas-unused-component.js +3 -25
  96. package/dist/rules/openapi/index.d.ts +9 -2
  97. package/dist/rules/openapi/index.js +15 -70
  98. package/dist/rules/openapi/schemas/index.js +5 -1
  99. package/dist/rules/shared/helpers.d.ts +2 -0
  100. package/dist/rules/shared/helpers.js +4 -0
  101. package/dist/rules/shared/server-variables.d.ts +27 -0
  102. package/dist/rules/shared/server-variables.js +55 -0
  103. package/dist/rules/shared/tags-unique.d.ts +7 -0
  104. package/dist/rules/shared/tags-unique.js +19 -0
  105. package/dist/ruleset-files.d.ts +47 -0
  106. package/dist/ruleset-files.js +92 -0
  107. package/package.json +8 -4
@@ -1,12 +1,27 @@
1
1
  import { createBoundedCache } from "./bounded-cache.js";
2
2
  import { compileFilter } from "./filter.js";
3
3
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4
+ const hasMember = (value, name) => Object.prototype.propertyIsEnumerable.call(value, name);
5
+ const isDigits = (value) => {
6
+ const length = value.length;
7
+ if (length === 0)
8
+ return false;
9
+ for (let i = 0; i < length; i++) {
10
+ const code = value.charCodeAt(i);
11
+ if (code < 48 || code > 57)
12
+ return false;
13
+ }
14
+ return true;
15
+ };
16
+ const childSelector = (name) => ({
17
+ kind: "child",
18
+ name,
19
+ index: isDigits(name) ? Number(name) : void 0
20
+ });
4
21
  const normalizeSegment = (segment) => {
5
22
  if (typeof segment === "number")
6
23
  return segment;
7
- if (segment.length > 0 && /^\d+$/.test(segment))
8
- return Number(segment);
9
- return segment;
24
+ return isDigits(segment) ? Number(segment) : segment;
10
25
  };
11
26
  const normalizePath = (path) => path.map(normalizeSegment);
12
27
  const pathToJsonPathString = (path) => {
@@ -143,7 +158,7 @@ const bracketSelector = (content, onError) => {
143
158
  }
144
159
  if (names.length === 1) {
145
160
  const only = names[0];
146
- return typeof only === "number" ? { kind: "index", index: only } : { kind: "child", name: only };
161
+ return typeof only === "number" ? { kind: "index", index: only } : childSelector(only);
147
162
  }
148
163
  return { kind: "union", names };
149
164
  };
@@ -214,7 +229,7 @@ const compileQuery = (expression) => {
214
229
  continue;
215
230
  }
216
231
  const { name, end } = readName(expression, i);
217
- steps.push({ recursive, selector: { kind: "child", name } });
232
+ steps.push({ recursive, selector: childSelector(name) });
218
233
  recursive = false;
219
234
  i = end;
220
235
  continue;
@@ -252,7 +267,7 @@ const compileQuery = (expression) => {
252
267
  if (recursive) {
253
268
  const { name, end } = readName(expression, i);
254
269
  if (end > i) {
255
- steps.push({ recursive: true, selector: { kind: "child", name } });
270
+ steps.push({ recursive: true, selector: childSelector(name) });
256
271
  recursive = false;
257
272
  i = end;
258
273
  continue;
@@ -285,10 +300,10 @@ const applySelector = (node, selector, root, out) => {
285
300
  switch (selector.kind) {
286
301
  case "child": {
287
302
  if (isObject(value)) {
288
- if (Object.hasOwn(value, selector.name))
303
+ if (hasMember(value, selector.name))
289
304
  out.push({ value: value[selector.name], parent: node, key: selector.name });
290
- } else if (Array.isArray(value) && /^\d+$/.test(selector.name)) {
291
- const idx = Number(selector.name);
305
+ } else if (Array.isArray(value) && selector.index !== void 0) {
306
+ const idx = selector.index;
292
307
  if (idx < value.length)
293
308
  out.push({ value: value[idx], parent: node, key: idx });
294
309
  }
@@ -299,7 +314,7 @@ const applySelector = (node, selector, root, out) => {
299
314
  const idx = selector.index < 0 ? value.length + selector.index : selector.index;
300
315
  if (idx >= 0 && idx < value.length)
301
316
  out.push({ value: value[idx], parent: node, key: idx });
302
- } else if (isObject(value) && Object.hasOwn(value, selector.index)) {
317
+ } else if (isObject(value) && hasMember(value, selector.index)) {
303
318
  out.push({ value: value[selector.index], parent: node, key: selector.index });
304
319
  }
305
320
  return;
@@ -322,7 +337,7 @@ const applySelector = (node, selector, root, out) => {
322
337
  if (idx >= 0 && idx < value.length)
323
338
  out.push({ value: value[idx], parent: node, key: idx });
324
339
  }
325
- } else if (isObject(value) && Object.hasOwn(value, name)) {
340
+ } else if (isObject(value) && hasMember(value, name)) {
326
341
  out.push({ value: value[name], parent: node, key: name });
327
342
  }
328
343
  }
@@ -467,11 +482,7 @@ const queryMany = (data, compiled) => {
467
482
  if (recursive.length > 0) {
468
483
  const firsts = recursive.map((i) => compiled[i].steps[0]);
469
484
  const seeds = recursive.map(() => []);
470
- const root = { value: data, parent: void 0, key: void 0 };
471
- walkDescendants(root, (node) => {
472
- for (let r = 0; r < recursive.length; r++)
473
- applySelector(node, firsts[r].selector, data, seeds[r]);
474
- });
485
+ seedDescents(data, firsts, seeds);
475
486
  for (let r = 0; r < recursive.length; r++) {
476
487
  const c = compiled[recursive[r]];
477
488
  out[recursive[r]] = toMatches(applySteps(data, seeds[r], c.steps.slice(1)));
@@ -479,6 +490,53 @@ const queryMany = (data, compiled) => {
479
490
  }
480
491
  return out;
481
492
  };
493
+ const seedDescents = (data, firsts, seeds) => {
494
+ const byName = /* @__PURE__ */ new Map();
495
+ const numericChild = [];
496
+ const others = [];
497
+ for (let r = 0; r < firsts.length; r++) {
498
+ const selector = firsts[r].selector;
499
+ if (selector.kind !== "child") {
500
+ others.push(r);
501
+ continue;
502
+ }
503
+ const existing = byName.get(selector.name);
504
+ if (existing === void 0)
505
+ byName.set(selector.name, [r]);
506
+ else
507
+ existing.push(r);
508
+ if (selector.index !== void 0)
509
+ numericChild.push(r);
510
+ }
511
+ const stack = [{ value: data, parent: void 0, key: void 0 }];
512
+ while (stack.length > 0) {
513
+ const current = stack.pop();
514
+ const value = current.value;
515
+ if (Array.isArray(value)) {
516
+ for (let idx = value.length - 1; idx >= 0; idx--)
517
+ stack.push({ value: value[idx], parent: current, key: idx });
518
+ for (const r of numericChild)
519
+ applySelector(current, firsts[r].selector, data, seeds[r]);
520
+ } else if (isObject(value)) {
521
+ const base = stack.length;
522
+ for (const key of Object.keys(value)) {
523
+ const child = { value: value[key], parent: current, key };
524
+ stack.push(child);
525
+ const interested = byName.get(key);
526
+ if (interested !== void 0)
527
+ for (const r of interested)
528
+ seeds[r].push(child);
529
+ }
530
+ for (let i = base, j = stack.length - 1; i < j; i++, j--) {
531
+ const held = stack[i];
532
+ stack[i] = stack[j];
533
+ stack[j] = held;
534
+ }
535
+ }
536
+ for (const r of others)
537
+ applySelector(current, firsts[r].selector, data, seeds[r]);
538
+ }
539
+ };
482
540
  const query = (data, expression) => queryCompiled(data, compileQuery(expression));
483
541
  export {
484
542
  compileQuery,
@@ -1,7 +1,7 @@
1
1
  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
- import { type IDiagnostic, type ISourceSet } from './types.js';
4
+ import type { IDiagnostic, ISourceSet } from './types.js';
5
5
  /**
6
6
  * What a {@link LintResolver} returns: the dereferenced tree, an optional source
7
7
  * set mapping inlined nodes back to their origin, and any diagnostics raised
@@ -20,6 +20,13 @@ export type LintResolverResult = {
20
20
  * example wrapping `@amritk/resolve-refs`). Returning no `sources` means
21
21
  * findings map back to the root document only; `diagnostics` reports refs that
22
22
  * failed to resolve.
23
+ *
24
+ * The `resolved` tree must be **acyclic**. Rules are evaluated by walking it, and
25
+ * the walk is depth-unbounded by design (document depth is attacker-controlled,
26
+ * so it keeps its own stack rather than recursing) — which means a cycle is an
27
+ * infinite loop, not a `RangeError`. Shared subtrees are fine and expected;
28
+ * `@amritk/resolve-refs` keeps the `$ref` node in place when a chain would close
29
+ * a cycle, which is what makes its output safe here.
23
30
  */
24
31
  export type LintResolver = (document: Document, context: {
25
32
  input: string;
package/dist/core/lint.js CHANGED
@@ -1,29 +1,15 @@
1
1
  import { detectFormat } from "../parsers/index.js";
2
2
  import { createDocument } from "./document.js";
3
+ import { bySourceThenPosition, withoutDuplicates } from "./order.js";
3
4
  import { runPlugins } from "./plugin.js";
4
5
  import { createLinter } from "./runner.js";
5
- import { DiagnosticSeverity } from "./types.js";
6
+ import { severityFromName } from "./severity.js";
6
7
  const toParserSeverity = (value) => {
7
8
  if (value === void 0)
8
9
  return void 0;
9
10
  if (typeof value === "number")
10
11
  return value;
11
- if (value === "off")
12
- return "off";
13
- const names = {
14
- error: DiagnosticSeverity.Error,
15
- warn: DiagnosticSeverity.Warning,
16
- info: DiagnosticSeverity.Information,
17
- hint: DiagnosticSeverity.Hint
18
- };
19
- return names[value];
20
- };
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;
12
+ return severityFromName(value);
27
13
  };
28
14
  const lintWithResult = async (input, options) => {
29
15
  const { ruleset, resolve, skip, plugins, ...documentOptions } = options;
@@ -59,7 +45,7 @@ const lintWithResult = async (input, options) => {
59
45
  result.source = document.source;
60
46
  return result;
61
47
  });
62
- const diagnostics = [...parserResults, ...resolverDiagnostics, ...ruleResults].sort(byPosition);
48
+ const diagnostics = withoutDuplicates([...parserResults, ...resolverDiagnostics, ...ruleResults].sort(bySourceThenPosition));
63
49
  if (!plugins || plugins.length === 0)
64
50
  return { diagnostics, pluginData: {} };
65
51
  const format = documentOptions.format ?? detectFormat(input);
@@ -0,0 +1,30 @@
1
+ import type { IDiagnostic } from './types.js';
2
+ /**
3
+ * Orders findings by source, then line, then character.
4
+ *
5
+ * Sorting by position alone interleaves findings from different files once a run
6
+ * spans multiple sources; grouping by `source` first keeps each file's findings
7
+ * contiguous. The runner and the lint pipeline both sort — the runner over the
8
+ * rule results, the pipeline over those merged with parser and resolver
9
+ * diagnostics — and they have to agree, so they share this comparator rather
10
+ * than each carrying a copy.
11
+ */
12
+ export declare const bySourceThenPosition: (a: IDiagnostic, b: IDiagnostic) => number;
13
+ /**
14
+ * Drops findings that name the same problem at the same authored node: same
15
+ * rule, severity, message, path, range and source.
16
+ *
17
+ * A rule with `resolved: true` walks the dereferenced tree, where one reusable
18
+ * `components` entry appears once per `$ref` reaching it *and* once at its
19
+ * declaration. Each copy is reported against the authored node it came from (see
20
+ * `locate`), so the copies are identical in every field a reader sees — and the
21
+ * second and third tell them nothing the first did not.
22
+ *
23
+ * The `path` is part of the key, and load-bearing. Ranges alone do not identify
24
+ * a node: `getLocationForJsonPath` falls back to the closest existing ancestor,
25
+ * so findings about *absent* sibling fields — `info.contact` missing `name`,
26
+ * `url` and `email` — all carry the enclosing object's range. They are three
27
+ * distinct problems and keep three distinct paths, so they survive. An earlier
28
+ * version of this keyed on the range alone and collapsed them into one.
29
+ */
30
+ export declare const withoutDuplicates: (diagnostics: readonly IDiagnostic[]) => IDiagnostic[];
@@ -0,0 +1,36 @@
1
+ const bySourceThenPosition = (a, b) => {
2
+ const sourceA = a.source ?? "";
3
+ const sourceB = b.source ?? "";
4
+ if (sourceA !== sourceB)
5
+ return sourceA < sourceB ? -1 : 1;
6
+ return a.range.start.line - b.range.start.line || a.range.start.character - b.range.start.character;
7
+ };
8
+ const withoutDuplicates = (diagnostics) => {
9
+ const seen = /* @__PURE__ */ new Set();
10
+ const kept = [];
11
+ for (const diagnostic of diagnostics) {
12
+ const { start, end } = diagnostic.range;
13
+ const key = [
14
+ diagnostic.source ?? "",
15
+ String(diagnostic.code),
16
+ diagnostic.severity,
17
+ start.line,
18
+ start.character,
19
+ end.line,
20
+ end.character,
21
+ // Segments are joined with a character a JSON key cannot contain
22
+ // unescaped, so `['a.b']` and `['a','b']` do not collide.
23
+ diagnostic.path.map(String).join("\0"),
24
+ diagnostic.message
25
+ ].join("");
26
+ if (seen.has(key))
27
+ continue;
28
+ seen.add(key);
29
+ kept.push(diagnostic);
30
+ }
31
+ return kept;
32
+ };
33
+ export {
34
+ bySourceThenPosition,
35
+ withoutDuplicates
36
+ };
@@ -6,7 +6,7 @@ export declare const pointerToPath: (pointer: string) => JsonPath | undefined;
6
6
  * equivalent path in the *original* source document, following internal `$ref`s.
7
7
  * This lets findings produced against the resolved tree resolve to the exact
8
8
  * line:column of the original `$ref` target. External refs stop the walk at the
9
- * `$ref` site.
9
+ * `$ref` site, and so does a segment written as a sibling of the `$ref`.
10
10
  */
11
11
  export declare const resolveSourcePath: (root: unknown, path: JsonPath) => JsonPath;
12
12
  /**
@@ -19,7 +19,8 @@ export declare const resolveSourcePath: (root: unknown, path: JsonPath) => JsonP
19
19
  *
20
20
  * The walk re-derives the resolver's traversal over the *unresolved* documents,
21
21
  * so a `$ref` whose target file is not in the registry (e.g. it failed to load)
22
- * stops the walk at the last known location rather than guessing. Prefer
22
+ * stops the walk at the last known location rather than guessing, and a segment
23
+ * written as a sibling of the `$ref` is read at the `$ref` site. Prefer
23
24
  * {@link resolveSourceOriginFromMap} when the resolver supplies an origin map —
24
25
  * it avoids re-deriving this traversal.
25
26
  */
@@ -1,5 +1,6 @@
1
1
  import { dirname, resolve as resolvePath } from "node:path";
2
2
  const isContainer = (value) => typeof value === "object" && value !== null;
3
+ const childOf = (node, segment) => Object.hasOwn(node, segment) ? node[segment] : void 0;
3
4
  const decodeSegment = (segment) => {
4
5
  let decoded = segment;
5
6
  try {
@@ -20,8 +21,13 @@ const isRemote = (location) => /^https?:\/\//i.test(location);
20
21
  const joinLocation = (base, ref) => {
21
22
  if (isRemote(ref))
22
23
  return ref;
23
- if (isRemote(base))
24
- return new URL(ref, base).href;
24
+ if (isRemote(base)) {
25
+ try {
26
+ return new URL(ref, base).href;
27
+ } catch {
28
+ return void 0;
29
+ }
30
+ }
25
31
  return resolvePath(dirname(base), ref);
26
32
  };
27
33
  const splitRef = (ref) => {
@@ -41,30 +47,31 @@ const getAtPath = (root, path) => {
41
47
  for (const segment of path) {
42
48
  if (!isContainer(node))
43
49
  return void 0;
44
- node = node[segment];
50
+ node = childOf(node, segment);
45
51
  }
46
52
  return node;
47
53
  };
54
+ const stepsThroughRef = (node, next) => isContainer(node) && !Array.isArray(node) && typeof node["$ref"] === "string" && (next === void 0 || !Object.hasOwn(node, next));
48
55
  const resolveSourcePath = (root, path) => {
49
56
  let originalPath = [];
50
57
  let node = root;
51
- const followRefs = () => {
58
+ const followRefs = (next) => {
52
59
  let guard = 0;
53
- while (isContainer(node) && !Array.isArray(node) && typeof node["$ref"] === "string" && guard++ < 100) {
54
- const target = pointerToPath(node["$ref"]);
60
+ while (stepsThroughRef(node, next) && guard++ < 100) {
61
+ const target = pointerToPath(node.$ref);
55
62
  if (!target)
56
63
  return;
57
64
  originalPath = [...target];
58
65
  node = getAtPath(root, target);
59
66
  }
60
67
  };
61
- followRefs();
62
- for (const segment of path) {
68
+ followRefs(path[0]);
69
+ for (const [index, segment] of path.entries()) {
63
70
  if (!isContainer(node))
64
71
  break;
65
72
  originalPath.push(segment);
66
- node = node[segment];
67
- followRefs();
73
+ node = childOf(node, segment);
74
+ followRefs(path[index + 1]);
68
75
  }
69
76
  return originalPath;
70
77
  };
@@ -73,12 +80,14 @@ const resolveSourceOrigin = (registry, path) => {
73
80
  let root = registry.get(location)?.data;
74
81
  let originalPath = [];
75
82
  let node = root;
76
- const followRefs = () => {
83
+ const followRefs = (next) => {
77
84
  let guard = 0;
78
- while (isContainer(node) && !Array.isArray(node) && typeof node["$ref"] === "string" && guard++ < 1e3) {
79
- const { filePart, fragment } = splitRef(node["$ref"]);
85
+ while (stepsThroughRef(node, next) && guard++ < 1e3) {
86
+ const { filePart, fragment } = splitRef(node.$ref);
80
87
  if (filePart !== "") {
81
88
  const target = joinLocation(location, filePart);
89
+ if (target === void 0)
90
+ return;
82
91
  const doc = registry.get(target);
83
92
  if (!doc)
84
93
  return;
@@ -89,13 +98,13 @@ const resolveSourceOrigin = (registry, path) => {
89
98
  node = getAtPath(root, originalPath);
90
99
  }
91
100
  };
92
- followRefs();
93
- for (const segment of path) {
101
+ followRefs(path[0]);
102
+ for (const [index, segment] of path.entries()) {
94
103
  if (!isContainer(node))
95
104
  break;
96
105
  originalPath.push(segment);
97
- node = node[segment];
98
- followRefs();
106
+ node = childOf(node, segment);
107
+ followRefs(path[index + 1]);
99
108
  }
100
109
  return { location, path: originalPath };
101
110
  };
@@ -106,7 +115,7 @@ const resolveSourceOriginFromMap = (resolved, origins, rootLocation, path) => {
106
115
  for (const segment of path) {
107
116
  if (!isContainer(node))
108
117
  break;
109
- node = node[segment];
118
+ node = childOf(node, segment);
110
119
  const stamp = isContainer(node) ? origins.get(node) : void 0;
111
120
  if (stamp) {
112
121
  location = stamp.location;
@@ -2,19 +2,13 @@ import { DiagnosticSeverity } from "../parsers/index.js";
2
2
  import { matchesGlob } from "./glob.js";
3
3
  import { compileQuery } from "./jsonpath.js";
4
4
  import { ownKey, setOwnKey } from "./own-key.js";
5
- const SEVERITY_NAMES = {
6
- error: DiagnosticSeverity.Error,
7
- warn: DiagnosticSeverity.Warning,
8
- info: DiagnosticSeverity.Information,
9
- hint: DiagnosticSeverity.Hint,
10
- off: "off"
11
- };
5
+ import { severityFromName } from "./severity.js";
12
6
  const parseSeverity = (value) => {
13
7
  if (value === void 0)
14
8
  return { severity: DiagnosticSeverity.Warning, enabled: true };
15
9
  if (typeof value === "number")
16
10
  return { severity: value, enabled: true };
17
- const mapped = ownKey(SEVERITY_NAMES, value);
11
+ const mapped = severityFromName(value);
18
12
  if (mapped === "off")
19
13
  return { severity: DiagnosticSeverity.Warning, enabled: false };
20
14
  if (mapped === void 0)
@@ -22,11 +16,42 @@ const parseSeverity = (value) => {
22
16
  return { severity: mapped, enabled: true };
23
17
  };
24
18
  const toArray = (value) => Array.isArray(value) ? value : [value];
19
+ const isRuleDefinition = (entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry);
20
+ const assertRuleEntry = (name, entry) => {
21
+ if (typeof entry === "boolean" || typeof entry === "number" || typeof entry === "string")
22
+ return;
23
+ if (isRuleDefinition(entry))
24
+ return;
25
+ const kind = entry === null ? "null" : Array.isArray(entry) ? "an array" : `\`${typeof entry}\``;
26
+ throw new Error(`Rule "${name}" must be a rule definition, a boolean, or a severity \u2014 got ${kind}`);
27
+ };
28
+ const assertFormats = (name, formats) => {
29
+ if (formats === void 0 || Array.isArray(formats))
30
+ return;
31
+ throw new Error(`Rule "${name}" has an invalid \`formats\`: expected an array of format names`);
32
+ };
33
+ const assertGiven = (name, given) => {
34
+ if (given.length === 0)
35
+ throw new Error(`Rule "${name}" is missing \`given\``);
36
+ for (const expression of given) {
37
+ if (typeof expression !== "string") {
38
+ const kind = expression === null ? "null" : `\`${typeof expression}\``;
39
+ throw new Error(`Rule "${name}" has an invalid \`given\`: expected a JSONPath string, got ${kind}`);
40
+ }
41
+ if (expression.startsWith("#"))
42
+ continue;
43
+ const compiled = compileQuery(expression);
44
+ if (compiled.error !== void 0) {
45
+ throw new Error(`Rule "${name}" has an invalid \`given\` "${expression}": ${compiled.error}`);
46
+ }
47
+ }
48
+ };
25
49
  const normalizeRule = (name, def, modifier, declaring = {}) => {
26
50
  const { severity, enabled } = parseSeverity(def.severity);
27
51
  const recommended = def.recommended ?? true;
28
52
  const modifierEnabled = modifier === "off" ? false : modifier === "recommended" ? recommended : true;
29
53
  const formats = def.formats ?? declaring.formats;
54
+ assertFormats(name, formats);
30
55
  const documentationUrl = def.documentationUrl ?? (declaring.documentationUrl ? `${declaring.documentationUrl}#${name}` : void 0);
31
56
  return {
32
57
  name,
@@ -34,8 +59,11 @@ const normalizeRule = (name, def, modifier, declaring = {}) => {
34
59
  message: def.message,
35
60
  severity,
36
61
  enabled: enabled && modifierEnabled,
37
- given: toArray(def.given),
38
- then: toArray(def.then),
62
+ // `?? []` so a definition missing either field normalizes to "nothing to do"
63
+ // instead of a one-element `[undefined]` that blows up further downstream;
64
+ // `assertGiven` is what reports a missing `given` as the authoring error it is.
65
+ given: toArray(def.given ?? []),
66
+ then: toArray(def.then ?? []),
39
67
  formats: formats ? new Set(formats) : void 0,
40
68
  recommended,
41
69
  resolved: def.resolved ?? true,
@@ -43,6 +71,7 @@ const normalizeRule = (name, def, modifier, declaring = {}) => {
43
71
  };
44
72
  };
45
73
  const applyEntry = (rules, name, entry, throwOnMissing = false) => {
74
+ assertRuleEntry(name, entry);
46
75
  if (typeof entry === "boolean") {
47
76
  const existing = rules.get(name);
48
77
  if (existing)
@@ -51,7 +80,7 @@ const applyEntry = (rules, name, entry, throwOnMissing = false) => {
51
80
  throw new Error(`Cannot extend non-existing rule "${name}"`);
52
81
  return;
53
82
  }
54
- if (typeof entry === "string") {
83
+ if (typeof entry === "string" || typeof entry === "number") {
55
84
  const existing = rules.get(name);
56
85
  if (!existing) {
57
86
  if (throwOnMissing)
@@ -102,6 +131,10 @@ const collectExtends = (extendsValue, defaultModifier, basePath, ctx) => {
102
131
  } else {
103
132
  target = entry;
104
133
  }
134
+ if (typeof target !== "string" && (typeof target !== "object" || target === null)) {
135
+ const kind = target === null ? "null" : `\`${typeof target}\``;
136
+ throw new Error(`An \`extends\` entry must be a ruleset object or a reference string \u2014 got ${kind}`);
137
+ }
105
138
  if (typeof target === "string") {
106
139
  const key = `${basePath}\0${target}`;
107
140
  if (ctx.seen.has(key))
@@ -132,7 +165,7 @@ const mergeInto = (definition, modifier, extendsModifier, basePath, ctx) => {
132
165
  };
133
166
  if (definition.rules) {
134
167
  for (const [name, entry] of Object.entries(definition.rules)) {
135
- if (typeof entry === "object") {
168
+ if (isRuleDefinition(entry)) {
136
169
  const rule = normalizeRule(name, entry, modifier, declaring);
137
170
  rule.given = rewriteGiven(ctx, definition.aliases ?? {}, name, rule.given);
138
171
  ctx.into.set(name, rule);
@@ -151,9 +184,14 @@ const cloneRule = (rule) => ({
151
184
  formats: rule.formats ? new Set(rule.formats) : void 0
152
185
  });
153
186
  const createRuleset = (definition, options = {}) => {
187
+ if (typeof definition !== "object" || definition === null || Array.isArray(definition)) {
188
+ throw new Error("A ruleset must be an object");
189
+ }
154
190
  const functions = options.functions ?? {};
155
191
  const formats = options.formats ?? {};
156
192
  const overrides = definition.overrides ?? [];
193
+ if (!Array.isArray(overrides))
194
+ throw new Error("`overrides` must be an array");
157
195
  const ctx = {
158
196
  into: /* @__PURE__ */ new Map(),
159
197
  resolve: options.resolve,
@@ -168,14 +206,18 @@ const createRuleset = (definition, options = {}) => {
168
206
  mergeInto(definition, "all", "recommended", options.basePath ?? process.cwd(), ctx);
169
207
  const rules = [...ctx.into.values()];
170
208
  const aliases = ctx.aliases;
171
- for (const rule of rules) {
172
- for (const expression of rule.given) {
173
- if (expression.startsWith("#"))
174
- continue;
175
- const compiled = compileQuery(expression);
176
- if (compiled.error !== void 0) {
177
- throw new Error(`Rule "${rule.name}" has an invalid \`given\` "${expression}": ${compiled.error}`);
178
- }
209
+ for (const rule of rules)
210
+ assertGiven(rule.name, rule.given);
211
+ for (const [index, override] of overrides.entries()) {
212
+ if (!Array.isArray(override?.files)) {
213
+ throw new Error(`Override at index ${index} must have a \`files\` array of globs`);
214
+ }
215
+ if (!override.rules)
216
+ continue;
217
+ for (const [name, entry] of Object.entries(override.rules)) {
218
+ assertRuleEntry(name, entry);
219
+ if (isRuleDefinition(entry))
220
+ assertGiven(name, toArray(entry.given ?? []));
179
221
  }
180
222
  }
181
223
  const resolveAlias = (name, documentFormats) => {
@@ -1,14 +1,11 @@
1
1
  import { detectFormats } from "./formats.js";
2
2
  import { matchesGlob } from "./glob.js";
3
3
  import { compileQuery, query, queryMany } from "./jsonpath.js";
4
+ import { bySourceThenPosition } from "./order.js";
5
+ import { ownKey } from "./own-key.js";
4
6
  import { pointerToPath, resolveSourcePath } from "./pointers.js";
7
+ import { severityFromName } from "./severity.js";
5
8
  import { DiagnosticSeverity } from "./types.js";
6
- const SEVERITY_NAMES = {
7
- error: DiagnosticSeverity.Error,
8
- warn: DiagnosticSeverity.Warning,
9
- info: DiagnosticSeverity.Information,
10
- hint: DiagnosticSeverity.Hint
11
- };
12
9
  const ZERO_RANGE = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
13
10
  const isIndexable = (value) => typeof value === "object" && value !== null;
14
11
  const isThenable = (value) => typeof value?.then === "function";
@@ -27,7 +24,8 @@ const resolveTargets = (value, path, field) => {
27
24
  return query(value, field).map((match) => ({ value: match.value, path: [...path, ...match.path] }));
28
25
  }
29
26
  const key = Array.isArray(value) && /^\d+$/.test(field) ? Number(field) : field;
30
- return [{ value: value[key], path: [...path, key] }];
27
+ const own = Object.hasOwn(value, key) ? value[key] : void 0;
28
+ return [{ value: own, path: [...path, key] }];
31
29
  };
32
30
  const stringify = (value) => {
33
31
  if (value === void 0)
@@ -38,7 +36,7 @@ const stringify = (value) => {
38
36
  };
39
37
  const applyTemplate = (template, ctx) => template.replace(/\{\{([^}]+)\}\}/g, (_match, raw) => {
40
38
  const key = raw.trim();
41
- return key in ctx ? stringify(ctx[key]) : "";
39
+ return Object.hasOwn(ctx, key) ? stringify(ctx[key]) : "";
42
40
  });
43
41
  const locate = (rule, path, document, sources) => {
44
42
  let originDocument = document;
@@ -51,7 +49,7 @@ const locate = (rule, path, document, sources) => {
51
49
  sourcePath = rule.resolved ? resolveSourcePath(document.data, path) : path;
52
50
  }
53
51
  const location = originDocument.getLocationForJsonPath(sourcePath, true);
54
- return { range: location?.range ?? ZERO_RANGE, source: originDocument.source };
52
+ return { sourcePath, range: location?.range ?? ZERO_RANGE, source: originDocument.source };
55
53
  };
56
54
  const errorDiagnostic = (rule, path, message, range, source) => {
57
55
  const diagnostic = {
@@ -135,7 +133,7 @@ const applyScopedOverrides = (ruleset, source, diagnostics) => {
135
133
  for (const { path, rules } of scoped) {
136
134
  if (!isPrefix(path, diagnostic.path))
137
135
  continue;
138
- const entry = rules[String(diagnostic.code)];
136
+ const entry = ownKey(rules, String(diagnostic.code));
139
137
  if (entry === void 0)
140
138
  continue;
141
139
  if (entry === false || entry === "off") {
@@ -144,8 +142,10 @@ const applyScopedOverrides = (ruleset, source, diagnostics) => {
144
142
  }
145
143
  if (typeof entry === "number")
146
144
  diagnostic.severity = entry;
147
- else if (typeof entry === "string" && entry in SEVERITY_NAMES) {
148
- diagnostic.severity = SEVERITY_NAMES[entry];
145
+ else if (typeof entry === "string") {
146
+ const mapped = severityFromName(entry);
147
+ if (typeof mapped === "number")
148
+ diagnostic.severity = mapped;
149
149
  }
150
150
  }
151
151
  if (!dropped)
@@ -171,9 +171,9 @@ const runThen = async (ruleset, rule, then, value, path, document, out, sources)
171
171
  });
172
172
  results = isThenable(raw) ? await raw : raw;
173
173
  } catch (error) {
174
- const { range, source } = locate(rule, target.path, document, sources);
174
+ const { sourcePath, range, source } = locate(rule, target.path, document, sources);
175
175
  const reason = error instanceof Error ? error.message : String(error);
176
- out.push(errorDiagnostic(rule, target.path, `Rule "${rule.name}" threw: ${reason}`, range, source));
176
+ out.push(errorDiagnostic(rule, sourcePath, `Rule "${rule.name}" threw: ${reason}`, range, source));
177
177
  continue;
178
178
  }
179
179
  if (!results)
@@ -193,11 +193,11 @@ const toDiagnostic = (rule, result, target, document, sources) => {
193
193
  error: result.message,
194
194
  description: rule.description ?? ""
195
195
  }) : result.message;
196
- const { range, source } = locate(rule, path, document, sources);
196
+ const { sourcePath, range, source } = locate(rule, path, document, sources);
197
197
  const diagnostic = {
198
198
  code: rule.name,
199
199
  message,
200
- path,
200
+ path: sourcePath,
201
201
  severity: rule.severity,
202
202
  range
203
203
  };
@@ -212,13 +212,6 @@ const hasIntersection = (a, b) => {
212
212
  }
213
213
  return false;
214
214
  };
215
- const bySourceThenPosition = (a, b) => {
216
- const sa = a.source ?? "";
217
- const sb = b.source ?? "";
218
- if (sa !== sb)
219
- return sa < sb ? -1 : 1;
220
- return a.range.start.line - b.range.start.line || a.range.start.character - b.range.start.character;
221
- };
222
215
  const createLinter = (ruleset) => ({
223
216
  run: async (document, options = {}) => {
224
217
  const documentFormats = detectFormats(document.data, ruleset.formats);