@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.
- package/AI.md +73 -4
- package/README.md +65 -14
- package/dist/core/filter-expression.js +17 -4
- package/dist/core/filter.js +82 -35
- package/dist/core/glob.js +25 -31
- package/dist/core/jsonpath.d.ts +9 -1
- package/dist/core/jsonpath.js +74 -16
- package/dist/core/lint.d.ts +8 -1
- package/dist/core/lint.js +4 -18
- package/dist/core/order.d.ts +30 -0
- package/dist/core/order.js +36 -0
- package/dist/core/pointers.d.ts +3 -2
- package/dist/core/pointers.js +27 -18
- package/dist/core/ruleset.js +62 -20
- package/dist/core/runner.js +16 -23
- package/dist/core/severity.d.ts +11 -0
- package/dist/core/severity.js +13 -0
- package/dist/fix/apply.js +6 -1
- package/dist/fix/plugin.js +2 -1
- package/dist/functions/or.js +1 -1
- package/dist/functions/ref-index.d.ts +15 -0
- package/dist/functions/ref-index.js +33 -0
- package/dist/functions/schema.d.ts +15 -0
- package/dist/functions/schema.js +2 -0
- package/dist/functions/typed-enum.js +2 -1
- package/dist/functions/unreferenced-reusable-object.js +3 -20
- package/dist/functions/xor.js +1 -1
- package/dist/index.d.ts +16 -16
- package/dist/index.js +24 -93
- package/dist/rules/asyncapi/asyncapi.d.ts +3 -0
- package/dist/rules/asyncapi/asyncapi.js +563 -0
- package/dist/rules/asyncapi/formats.d.ts +23 -0
- package/dist/rules/asyncapi/formats.js +49 -0
- package/dist/rules/asyncapi/functions/aas-server-variables.d.ts +8 -0
- package/dist/rules/asyncapi/functions/aas-server-variables.js +5 -0
- package/dist/rules/asyncapi/functions/aas-tags-unique.d.ts +2 -0
- package/dist/rules/asyncapi/functions/aas-tags-unique.js +5 -0
- package/dist/rules/asyncapi/functions/asyncapi-channel-parameters.d.ts +7 -0
- package/dist/rules/asyncapi/functions/asyncapi-channel-parameters.js +46 -0
- package/dist/rules/asyncapi/functions/asyncapi-channel-servers.d.ts +27 -0
- package/dist/rules/asyncapi/functions/asyncapi-channel-servers.js +61 -0
- package/dist/rules/asyncapi/functions/asyncapi-document-schema.d.ts +17 -0
- package/dist/rules/asyncapi/functions/asyncapi-document-schema.js +12 -0
- package/dist/rules/asyncapi/functions/asyncapi-headers-object.d.ts +23 -0
- package/dist/rules/asyncapi/functions/asyncapi-headers-object.js +25 -0
- package/dist/rules/asyncapi/functions/asyncapi-message-examples.d.ts +8 -0
- package/dist/rules/asyncapi/functions/asyncapi-message-examples.js +42 -0
- package/dist/rules/asyncapi/functions/asyncapi-message-id-unique.d.ts +3 -0
- package/dist/rules/asyncapi/functions/asyncapi-message-id-unique.js +30 -0
- package/dist/rules/asyncapi/functions/asyncapi-operation-id-unique.d.ts +3 -0
- package/dist/rules/asyncapi/functions/asyncapi-operation-id-unique.js +30 -0
- package/dist/rules/asyncapi/functions/asyncapi-payload.d.ts +14 -0
- package/dist/rules/asyncapi/functions/asyncapi-payload.js +32 -0
- package/dist/rules/asyncapi/functions/asyncapi-schema-validation.d.ts +11 -0
- package/dist/rules/asyncapi/functions/asyncapi-schema-validation.js +17 -0
- package/dist/rules/asyncapi/functions/asyncapi-security.d.ts +22 -0
- package/dist/rules/asyncapi/functions/asyncapi-security.js +78 -0
- package/dist/rules/asyncapi/functions/helpers.d.ts +51 -0
- package/dist/rules/asyncapi/functions/helpers.js +91 -0
- package/dist/rules/asyncapi/functions/index.d.ts +15 -0
- package/dist/rules/asyncapi/functions/index.js +53 -0
- package/dist/rules/asyncapi/functions/pointer.d.ts +11 -0
- package/dist/rules/asyncapi/functions/pointer.js +11 -0
- package/dist/rules/asyncapi/functions/schema-format.d.ts +4 -0
- package/dist/rules/asyncapi/functions/schema-format.js +7 -0
- package/dist/rules/asyncapi/index.d.ts +33 -0
- package/dist/rules/asyncapi/index.js +60 -0
- package/dist/rules/asyncapi/schemas/aas20.d.ts +2 -0
- package/dist/rules/asyncapi/schemas/aas20.js +4 -0
- package/dist/rules/asyncapi/schemas/aas21.d.ts +2 -0
- package/dist/rules/asyncapi/schemas/aas21.js +4 -0
- package/dist/rules/asyncapi/schemas/aas22.d.ts +2 -0
- package/dist/rules/asyncapi/schemas/aas22.js +4 -0
- package/dist/rules/asyncapi/schemas/aas23.d.ts +2 -0
- package/dist/rules/asyncapi/schemas/aas23.js +4 -0
- package/dist/rules/asyncapi/schemas/aas24.d.ts +2 -0
- package/dist/rules/asyncapi/schemas/aas24.js +4 -0
- package/dist/rules/asyncapi/schemas/aas25.d.ts +2 -0
- package/dist/rules/asyncapi/schemas/aas25.js +4 -0
- package/dist/rules/asyncapi/schemas/aas26.d.ts +2 -0
- package/dist/rules/asyncapi/schemas/aas26.js +4 -0
- package/dist/rules/asyncapi/schemas/aas30.d.ts +2 -0
- package/dist/rules/asyncapi/schemas/aas30.js +4 -0
- package/dist/rules/asyncapi/schemas/index.d.ts +20 -0
- package/dist/rules/asyncapi/schemas/index.js +45 -0
- package/dist/rules/openapi/fixers.d.ts +6 -2
- package/dist/rules/openapi/fixers.js +2 -0
- package/dist/rules/openapi/functions/oas-discriminator.js +1 -1
- package/dist/rules/openapi/functions/oas-mutually-exclusive.js +1 -1
- package/dist/rules/openapi/functions/oas-op-security-defined.js +1 -1
- package/dist/rules/openapi/functions/oas-server-variables.d.ts +6 -6
- package/dist/rules/openapi/functions/oas-server-variables.js +2 -45
- package/dist/rules/openapi/functions/oas-tags-unique.d.ts +2 -3
- package/dist/rules/openapi/functions/oas-tags-unique.js +2 -16
- package/dist/rules/openapi/functions/oas-unused-component.js +3 -25
- package/dist/rules/openapi/index.d.ts +9 -2
- package/dist/rules/openapi/index.js +15 -70
- package/dist/rules/openapi/schemas/index.js +5 -1
- package/dist/rules/shared/helpers.d.ts +2 -0
- package/dist/rules/shared/helpers.js +4 -0
- package/dist/rules/shared/server-variables.d.ts +27 -0
- package/dist/rules/shared/server-variables.js +55 -0
- package/dist/rules/shared/tags-unique.d.ts +7 -0
- package/dist/rules/shared/tags-unique.js +19 -0
- package/dist/ruleset-files.d.ts +47 -0
- package/dist/ruleset-files.js +92 -0
- package/package.json +8 -4
package/dist/core/jsonpath.js
CHANGED
|
@@ -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
|
-
|
|
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 } :
|
|
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:
|
|
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:
|
|
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 (
|
|
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) &&
|
|
291
|
-
const idx =
|
|
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) &&
|
|
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) &&
|
|
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
|
-
|
|
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,
|
package/dist/core/lint.d.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
+
};
|
package/dist/core/pointers.d.ts
CHANGED
|
@@ -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
|
|
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
|
*/
|
package/dist/core/pointers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 (
|
|
54
|
-
const target = pointerToPath(node
|
|
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
|
|
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 (
|
|
79
|
-
const { filePart, fragment } = splitRef(node
|
|
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
|
|
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
|
|
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;
|
package/dist/core/ruleset.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
38
|
-
|
|
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 (
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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) => {
|
package/dist/core/runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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"
|
|
148
|
-
|
|
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,
|
|
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);
|