@amritk/lint 0.4.7 → 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/own-key.d.ts +18 -0
- package/dist/core/own-key.js +12 -0
- package/dist/core/pointers.d.ts +3 -2
- package/dist/core/pointers.js +27 -18
- package/dist/core/ruleset.js +74 -27
- 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 +9 -2
- package/dist/fix/plugin.js +2 -1
- package/dist/functions/alphabetical.d.ts +14 -0
- package/dist/functions/alphabetical.js +4 -3
- 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 +25 -94
- 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 +9 -6
- 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-path-param.js +5 -4
- 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 +9 -5
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
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Own-property reads and writes for maps keyed by names the ruleset or the
|
|
3
|
+
* linted document supplies.
|
|
4
|
+
*
|
|
5
|
+
* Those names are author-chosen, so `constructor`, `toString` and `__proto__`
|
|
6
|
+
* are all names someone may legitimately use — and a bare index answers them
|
|
7
|
+
* from `Object.prototype` while a bare assignment on `__proto__` sets the map's
|
|
8
|
+
* prototype instead of adding a key. Neither is ever what the caller meant.
|
|
9
|
+
*
|
|
10
|
+
* `@amritk/helpers` carries the same pair, but `@amritk/lint` depends on
|
|
11
|
+
* nothing beyond `@amritk/runtime-validators` and `@amritk/yaml` by design (see
|
|
12
|
+
* `.claude/architecture.md`), so it keeps its own. One copy inside the package,
|
|
13
|
+
* not one per call site.
|
|
14
|
+
*/
|
|
15
|
+
/** The value at `key`, or `undefined` when the map does not own that name. */
|
|
16
|
+
export declare const ownKey: <T>(source: Readonly<Record<string, T>>, key: string) => T | undefined;
|
|
17
|
+
/** Assigns `value` under `key` as an own data property, `__proto__` included. */
|
|
18
|
+
export declare const setOwnKey: <T>(target: Record<string, T>, key: string, value: NoInfer<T>) => void;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const ownKey = (source, key) => Object.hasOwn(source, key) ? source[key] : void 0;
|
|
2
|
+
const setOwnKey = (target, key, value) => {
|
|
3
|
+
if (key === "__proto__") {
|
|
4
|
+
Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true });
|
|
5
|
+
} else {
|
|
6
|
+
target[key] = value;
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
export {
|
|
10
|
+
ownKey,
|
|
11
|
+
setOwnKey
|
|
12
|
+
};
|
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
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
import { DiagnosticSeverity } from "../parsers/index.js";
|
|
2
2
|
import { matchesGlob } from "./glob.js";
|
|
3
3
|
import { compileQuery } from "./jsonpath.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
warn: DiagnosticSeverity.Warning,
|
|
7
|
-
info: DiagnosticSeverity.Information,
|
|
8
|
-
hint: DiagnosticSeverity.Hint,
|
|
9
|
-
off: "off"
|
|
10
|
-
};
|
|
4
|
+
import { ownKey, setOwnKey } from "./own-key.js";
|
|
5
|
+
import { severityFromName } from "./severity.js";
|
|
11
6
|
const parseSeverity = (value) => {
|
|
12
7
|
if (value === void 0)
|
|
13
8
|
return { severity: DiagnosticSeverity.Warning, enabled: true };
|
|
14
9
|
if (typeof value === "number")
|
|
15
10
|
return { severity: value, enabled: true };
|
|
16
|
-
const mapped =
|
|
11
|
+
const mapped = severityFromName(value);
|
|
17
12
|
if (mapped === "off")
|
|
18
13
|
return { severity: DiagnosticSeverity.Warning, enabled: false };
|
|
19
14
|
if (mapped === void 0)
|
|
@@ -21,11 +16,42 @@ const parseSeverity = (value) => {
|
|
|
21
16
|
return { severity: mapped, enabled: true };
|
|
22
17
|
};
|
|
23
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
|
+
};
|
|
24
49
|
const normalizeRule = (name, def, modifier, declaring = {}) => {
|
|
25
50
|
const { severity, enabled } = parseSeverity(def.severity);
|
|
26
51
|
const recommended = def.recommended ?? true;
|
|
27
52
|
const modifierEnabled = modifier === "off" ? false : modifier === "recommended" ? recommended : true;
|
|
28
53
|
const formats = def.formats ?? declaring.formats;
|
|
54
|
+
assertFormats(name, formats);
|
|
29
55
|
const documentationUrl = def.documentationUrl ?? (declaring.documentationUrl ? `${declaring.documentationUrl}#${name}` : void 0);
|
|
30
56
|
return {
|
|
31
57
|
name,
|
|
@@ -33,8 +59,11 @@ const normalizeRule = (name, def, modifier, declaring = {}) => {
|
|
|
33
59
|
message: def.message,
|
|
34
60
|
severity,
|
|
35
61
|
enabled: enabled && modifierEnabled,
|
|
36
|
-
|
|
37
|
-
|
|
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 ?? []),
|
|
38
67
|
formats: formats ? new Set(formats) : void 0,
|
|
39
68
|
recommended,
|
|
40
69
|
resolved: def.resolved ?? true,
|
|
@@ -42,6 +71,7 @@ const normalizeRule = (name, def, modifier, declaring = {}) => {
|
|
|
42
71
|
};
|
|
43
72
|
};
|
|
44
73
|
const applyEntry = (rules, name, entry, throwOnMissing = false) => {
|
|
74
|
+
assertRuleEntry(name, entry);
|
|
45
75
|
if (typeof entry === "boolean") {
|
|
46
76
|
const existing = rules.get(name);
|
|
47
77
|
if (existing)
|
|
@@ -50,7 +80,7 @@ const applyEntry = (rules, name, entry, throwOnMissing = false) => {
|
|
|
50
80
|
throw new Error(`Cannot extend non-existing rule "${name}"`);
|
|
51
81
|
return;
|
|
52
82
|
}
|
|
53
|
-
if (typeof entry === "string") {
|
|
83
|
+
if (typeof entry === "string" || typeof entry === "number") {
|
|
54
84
|
const existing = rules.get(name);
|
|
55
85
|
if (!existing) {
|
|
56
86
|
if (throwOnMissing)
|
|
@@ -65,19 +95,19 @@ const applyEntry = (rules, name, entry, throwOnMissing = false) => {
|
|
|
65
95
|
rules.set(name, normalizeRule(name, entry, "all"));
|
|
66
96
|
};
|
|
67
97
|
const registerAlias = (ctx, declaringAliases, name, ruleName) => {
|
|
68
|
-
const alias = declaringAliases
|
|
98
|
+
const alias = ownKey(declaringAliases, name);
|
|
69
99
|
if (!alias)
|
|
70
100
|
throw new Error(`Rule "${ruleName}" references undefined alias "#${name}"`);
|
|
71
|
-
const existing = ctx.aliases
|
|
101
|
+
const existing = ownKey(ctx.aliases, name);
|
|
72
102
|
if (existing === void 0 || existing === alias) {
|
|
73
|
-
ctx.aliases
|
|
103
|
+
setOwnKey(ctx.aliases, name, alias);
|
|
74
104
|
return name;
|
|
75
105
|
}
|
|
76
106
|
for (let n = 0; ; n++) {
|
|
77
107
|
const key = `${name}__${n}`;
|
|
78
|
-
const at = ctx.aliases
|
|
108
|
+
const at = ownKey(ctx.aliases, key);
|
|
79
109
|
if (at === void 0 || at === alias) {
|
|
80
|
-
ctx.aliases
|
|
110
|
+
setOwnKey(ctx.aliases, key, alias);
|
|
81
111
|
return key;
|
|
82
112
|
}
|
|
83
113
|
}
|
|
@@ -101,6 +131,10 @@ const collectExtends = (extendsValue, defaultModifier, basePath, ctx) => {
|
|
|
101
131
|
} else {
|
|
102
132
|
target = entry;
|
|
103
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
|
+
}
|
|
104
138
|
if (typeof target === "string") {
|
|
105
139
|
const key = `${basePath}\0${target}`;
|
|
106
140
|
if (ctx.seen.has(key))
|
|
@@ -131,7 +165,7 @@ const mergeInto = (definition, modifier, extendsModifier, basePath, ctx) => {
|
|
|
131
165
|
};
|
|
132
166
|
if (definition.rules) {
|
|
133
167
|
for (const [name, entry] of Object.entries(definition.rules)) {
|
|
134
|
-
if (
|
|
168
|
+
if (isRuleDefinition(entry)) {
|
|
135
169
|
const rule = normalizeRule(name, entry, modifier, declaring);
|
|
136
170
|
rule.given = rewriteGiven(ctx, definition.aliases ?? {}, name, rule.given);
|
|
137
171
|
ctx.into.set(name, rule);
|
|
@@ -150,9 +184,14 @@ const cloneRule = (rule) => ({
|
|
|
150
184
|
formats: rule.formats ? new Set(rule.formats) : void 0
|
|
151
185
|
});
|
|
152
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
|
+
}
|
|
153
190
|
const functions = options.functions ?? {};
|
|
154
191
|
const formats = options.formats ?? {};
|
|
155
192
|
const overrides = definition.overrides ?? [];
|
|
193
|
+
if (!Array.isArray(overrides))
|
|
194
|
+
throw new Error("`overrides` must be an array");
|
|
156
195
|
const ctx = {
|
|
157
196
|
into: /* @__PURE__ */ new Map(),
|
|
158
197
|
resolve: options.resolve,
|
|
@@ -167,18 +206,22 @@ const createRuleset = (definition, options = {}) => {
|
|
|
167
206
|
mergeInto(definition, "all", "recommended", options.basePath ?? process.cwd(), ctx);
|
|
168
207
|
const rules = [...ctx.into.values()];
|
|
169
208
|
const aliases = ctx.aliases;
|
|
170
|
-
for (const rule of rules)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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 ?? []));
|
|
178
221
|
}
|
|
179
222
|
}
|
|
180
223
|
const resolveAlias = (name, documentFormats) => {
|
|
181
|
-
const alias = aliases
|
|
224
|
+
const alias = ownKey(aliases, name);
|
|
182
225
|
if (!alias)
|
|
183
226
|
return [];
|
|
184
227
|
if (Array.isArray(alias))
|
|
@@ -199,7 +242,11 @@ const createRuleset = (definition, options = {}) => {
|
|
|
199
242
|
overrides,
|
|
200
243
|
parserOptions,
|
|
201
244
|
enabledRules: rules.filter((rule) => rule.enabled),
|
|
202
|
-
|
|
245
|
+
// `Object.hasOwn`, not a bare index: `then.function` is ruleset input, so
|
|
246
|
+
// `"toString"` otherwise resolved to `Function.prototype.toString` and ran
|
|
247
|
+
// as a rule function instead of being reported as unknown — its string
|
|
248
|
+
// return value then read as an iterable of per-character diagnostics.
|
|
249
|
+
getFunction: (name) => ownKey(functions, name),
|
|
203
250
|
rulesForSource: (source) => {
|
|
204
251
|
if (!source || !hasOverrides)
|
|
205
252
|
return rules;
|