@amritk/lint 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/core/glob.d.ts +1 -1
- package/dist/core/glob.js +89 -5
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/jsonpath.d.ts +17 -1
- package/dist/core/jsonpath.js +218 -23
- package/dist/core/lint.d.ts +15 -8
- package/dist/core/lint.js +12 -3
- package/dist/core/plugin.d.ts +6 -0
- package/dist/core/plugin.js +6 -0
- package/dist/core/pointers.js +15 -15
- package/dist/core/ruleset.js +0 -0
- package/dist/core/runner.d.ts +6 -1
- package/dist/core/runner.js +127 -43
- package/dist/core/types.d.ts +17 -2
- package/dist/core/validate-ruleset.js +16 -0
- package/dist/fix/apply.d.ts +8 -2
- package/dist/fix/apply.js +68 -18
- package/dist/functions/alphabetical.js +40 -13
- package/dist/functions/casing.js +27 -5
- package/dist/functions/enumeration.d.ts +5 -3
- package/dist/functions/enumeration.js +18 -1
- package/dist/functions/index.d.ts +1 -0
- package/dist/functions/index.js +3 -0
- package/dist/functions/length.d.ts +13 -3
- package/dist/functions/length.js +11 -3
- package/dist/functions/or.d.ts +11 -0
- package/dist/functions/or.js +25 -0
- package/dist/functions/pattern.d.ts +5 -3
- package/dist/functions/pattern.js +42 -9
- package/dist/functions/schema.d.ts +13 -0
- package/dist/functions/schema.js +95 -2
- package/dist/functions/typed-enum.js +7 -1
- package/dist/functions/unreferenced-reusable-object.d.ts +7 -1
- package/dist/functions/unreferenced-reusable-object.js +18 -3
- package/dist/functions/xor.js +8 -1
- package/dist/index.js +9 -1
- package/dist/parsers/edit-model.d.ts +15 -0
- package/dist/parsers/edit-model.js +210 -41
- package/dist/parsers/types.d.ts +14 -2
- package/dist/parsers/yaml.d.ts +10 -0
- package/dist/parsers/yaml.js +174 -26
- package/dist/rules/openapi/fixers.js +63 -4
- package/dist/rules/openapi/formats.js +11 -4
- package/dist/rules/openapi/functions/example-validation.d.ts +16 -3
- package/dist/rules/openapi/functions/example-validation.js +102 -39
- package/dist/rules/openapi/functions/helpers.d.ts +1 -0
- package/dist/rules/openapi/functions/helpers.js +5 -0
- package/dist/rules/openapi/functions/index.d.ts +3 -1
- package/dist/rules/openapi/functions/index.js +7 -1
- package/dist/rules/openapi/functions/oas-additional-operations.js +5 -5
- package/dist/rules/openapi/functions/oas-example-external-value.d.ts +11 -0
- package/dist/rules/openapi/functions/oas-example-external-value.js +23 -0
- package/dist/rules/openapi/functions/oas-no-nullable.d.ts +13 -0
- package/dist/rules/openapi/functions/oas-no-nullable.js +22 -0
- package/dist/rules/openapi/functions/oas-op-id-unique.js +4 -2
- package/dist/rules/openapi/functions/oas-op-params.d.ts +7 -1
- package/dist/rules/openapi/functions/oas-op-params.js +35 -10
- package/dist/rules/openapi/functions/oas-op-security-defined.js +2 -2
- package/dist/rules/openapi/functions/oas-op-success-response.js +6 -1
- package/dist/rules/openapi/functions/oas-path-param.d.ts +10 -1
- package/dist/rules/openapi/functions/oas-path-param.js +87 -26
- package/dist/rules/openapi/functions/oas-server-variables.d.ts +6 -1
- package/dist/rules/openapi/functions/oas-server-variables.js +31 -2
- package/dist/rules/openapi/functions/oas-unused-component.js +14 -1
- package/dist/rules/openapi/oas.js +82 -25
- package/package.json +12 -4
package/dist/core/plugin.d.ts
CHANGED
|
@@ -55,5 +55,11 @@ export type PluginRunResult = {
|
|
|
55
55
|
* Runs `plugins` in order over `diagnostics`. Each plugin sees the (possibly
|
|
56
56
|
* already transformed) diagnostics from the previous one; when a plugin returns
|
|
57
57
|
* `output`, later plugins and the context see that rewritten text as `input`.
|
|
58
|
+
*
|
|
59
|
+
* Note: only `input` (and the diagnostics) are refreshed between plugins. The
|
|
60
|
+
* `document` and `resolved` tree in the context are the *originally* parsed ones,
|
|
61
|
+
* so after a plugin rewrites `output` those become stale relative to the new
|
|
62
|
+
* text. A plugin that must operate on the up-to-date parsed tree should re-parse
|
|
63
|
+
* `context.input` itself rather than trust `document`/`resolved`.
|
|
58
64
|
*/
|
|
59
65
|
export declare const runPlugins: (plugins: readonly LintPlugin[], diagnostics: IDiagnostic[], context: LintPluginContext) => PluginRunResult;
|
package/dist/core/plugin.js
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
* Runs `plugins` in order over `diagnostics`. Each plugin sees the (possibly
|
|
3
3
|
* already transformed) diagnostics from the previous one; when a plugin returns
|
|
4
4
|
* `output`, later plugins and the context see that rewritten text as `input`.
|
|
5
|
+
*
|
|
6
|
+
* Note: only `input` (and the diagnostics) are refreshed between plugins. The
|
|
7
|
+
* `document` and `resolved` tree in the context are the *originally* parsed ones,
|
|
8
|
+
* so after a plugin rewrites `output` those become stale relative to the new
|
|
9
|
+
* text. A plugin that must operate on the up-to-date parsed tree should re-parse
|
|
10
|
+
* `context.input` itself rather than trust `document`/`resolved`.
|
|
5
11
|
*/
|
|
6
12
|
export const runPlugins = (plugins, diagnostics, context) => {
|
|
7
13
|
let currentDiagnostics = diagnostics;
|
package/dist/core/pointers.js
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import { dirname, resolve as resolvePath } from 'node:path';
|
|
2
2
|
const isContainer = (value) => typeof value === 'object' && value !== null;
|
|
3
|
+
/**
|
|
4
|
+
* Decodes one JSON-pointer segment the way `getByPointer` does: percent-escapes
|
|
5
|
+
* first (pointers arrive inside URI-reference `$ref`s), then the JSON-pointer
|
|
6
|
+
* escapes `~1`/`~0`, then all-digit segments become numbers. Shared by both the
|
|
7
|
+
* `#/...` and fragment parsers so they decode identically.
|
|
8
|
+
*/
|
|
3
9
|
const decodeSegment = (segment) => {
|
|
4
|
-
|
|
10
|
+
let decoded = segment;
|
|
11
|
+
try {
|
|
12
|
+
decoded = decodeURIComponent(segment);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Leave invalid percent-escapes as-is rather than throwing.
|
|
16
|
+
}
|
|
17
|
+
decoded = decoded.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
5
18
|
return /^\d+$/.test(decoded) ? Number(decoded) : decoded;
|
|
6
19
|
};
|
|
7
20
|
/** Parses an internal JSON pointer (`#/a/b`) into a path; returns undefined for external refs. */
|
|
@@ -41,20 +54,7 @@ const splitRef = (ref) => {
|
|
|
41
54
|
const fragmentToPath = (fragment) => {
|
|
42
55
|
if (fragment === '' || fragment === '/')
|
|
43
56
|
return [];
|
|
44
|
-
return fragment
|
|
45
|
-
.replace(/^\//, '')
|
|
46
|
-
.split('/')
|
|
47
|
-
.map((segment) => {
|
|
48
|
-
let decoded = segment;
|
|
49
|
-
try {
|
|
50
|
-
decoded = decodeURIComponent(segment);
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
// leave invalid percent-escapes as-is
|
|
54
|
-
}
|
|
55
|
-
decoded = decoded.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
56
|
-
return /^\d+$/.test(decoded) ? Number(decoded) : decoded;
|
|
57
|
-
});
|
|
57
|
+
return fragment.replace(/^\//, '').split('/').map(decodeSegment);
|
|
58
58
|
};
|
|
59
59
|
const getAtPath = (root, path) => {
|
|
60
60
|
let node = root;
|
package/dist/core/ruleset.js
CHANGED
|
Binary file
|
package/dist/core/runner.d.ts
CHANGED
|
@@ -15,7 +15,12 @@ export type IRunOptions = {
|
|
|
15
15
|
};
|
|
16
16
|
/** Evaluates a normalized ruleset against a document. Produced by {@link createLinter}. */
|
|
17
17
|
export type Linter = {
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Runs the ruleset over `document`. Async because a rule function may return a
|
|
20
|
+
* Promise (Spectral-style async functions); synchronous functions resolve
|
|
21
|
+
* immediately.
|
|
22
|
+
*/
|
|
23
|
+
run(document: Document, options?: IRunOptions): Promise<IDiagnostic[]>;
|
|
19
24
|
};
|
|
20
25
|
/** Creates a {@link Linter} runner bound to a normalized `ruleset`. */
|
|
21
26
|
export declare const createLinter: (ruleset: Ruleset) => Linter;
|
package/dist/core/runner.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { detectFormats } from './formats.js';
|
|
2
2
|
import { matchesGlob } from './glob.js';
|
|
3
|
-
import { compileQuery, queryMany } from './jsonpath.js';
|
|
3
|
+
import { compileQuery, query, queryMany } from './jsonpath.js';
|
|
4
4
|
import { pointerToPath, resolveSourcePath } from './pointers.js';
|
|
5
5
|
import { DiagnosticSeverity, } from './types.js';
|
|
6
6
|
const SEVERITY_NAMES = {
|
|
@@ -10,20 +10,36 @@ const SEVERITY_NAMES = {
|
|
|
10
10
|
hint: DiagnosticSeverity.Hint,
|
|
11
11
|
};
|
|
12
12
|
const ZERO_RANGE = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
|
|
13
|
-
const
|
|
13
|
+
const isIndexable = (value) => typeof value === 'object' && value !== null;
|
|
14
|
+
const isThenable = (value) => typeof value?.then === 'function';
|
|
15
|
+
/**
|
|
16
|
+
* Narrows a matched value to the nodes a `then` runs against, mirroring
|
|
17
|
+
* Spectral's `getLintTargets`. Both objects and arrays are indexable containers,
|
|
18
|
+
* so `@key` yields keys/indices and a numeric field indexes into an array; a
|
|
19
|
+
* field against a primitive lints the matched value itself; a `$`-prefixed field
|
|
20
|
+
* runs as a sub-query.
|
|
21
|
+
*/
|
|
14
22
|
const resolveTargets = (value, path, field) => {
|
|
15
23
|
if (field === undefined)
|
|
16
24
|
return [{ value, path }];
|
|
17
|
-
|
|
25
|
+
// A field only narrows into a container; against a primitive (or null) the
|
|
26
|
+
// matched value itself is the target.
|
|
27
|
+
if (!isIndexable(value))
|
|
18
28
|
return [{ value, path }];
|
|
19
29
|
if (field === '@key') {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return Object.keys(value).map((key) => ({
|
|
30
|
+
// Object.keys over an array yields its indices (as strings); normalize those
|
|
31
|
+
// back to numbers for the path so lookups stay consistent.
|
|
32
|
+
return Object.keys(value).map((key) => ({
|
|
33
|
+
value: key,
|
|
34
|
+
path: [...path, Array.isArray(value) ? Number(key) : key],
|
|
35
|
+
}));
|
|
23
36
|
}
|
|
24
|
-
if (
|
|
25
|
-
return
|
|
26
|
-
|
|
37
|
+
if (field.startsWith('$')) {
|
|
38
|
+
return query(value, field).map((match) => ({ value: match.value, path: [...path, ...match.path] }));
|
|
39
|
+
}
|
|
40
|
+
// A plain property name, or a numeric index into an array.
|
|
41
|
+
const key = Array.isArray(value) && /^\d+$/.test(field) ? Number(field) : field;
|
|
42
|
+
return [{ value: value[key], path: [...path, key] }];
|
|
27
43
|
};
|
|
28
44
|
const stringify = (value) => {
|
|
29
45
|
if (value === undefined)
|
|
@@ -36,18 +52,69 @@ const applyTemplate = (template, ctx) => template.replace(/\{\{([^}]+)\}\}/g, (_
|
|
|
36
52
|
const key = raw.trim();
|
|
37
53
|
return key in ctx ? stringify(ctx[key]) : '';
|
|
38
54
|
});
|
|
55
|
+
/** Resolves where a finding's `path` maps to in the source: its range and originating `source`. */
|
|
56
|
+
const locate = (rule, path, document, sources) => {
|
|
57
|
+
// A resolved finding may sit on a node inlined from another file. With a source
|
|
58
|
+
// set, follow the `$ref` chain across documents so the range and `source` point
|
|
59
|
+
// at the originating file; otherwise fall back to the root document, following
|
|
60
|
+
// internal `$ref`s only.
|
|
61
|
+
let originDocument = document;
|
|
62
|
+
let sourcePath;
|
|
63
|
+
if (rule.resolved && sources) {
|
|
64
|
+
const origin = sources.origin(path);
|
|
65
|
+
originDocument = sources.get(origin.location) ?? document;
|
|
66
|
+
sourcePath = origin.path;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
sourcePath = rule.resolved ? resolveSourcePath(document.data, path) : path;
|
|
70
|
+
}
|
|
71
|
+
const location = originDocument.getLocationForJsonPath(sourcePath, true);
|
|
72
|
+
return { range: location?.range ?? ZERO_RANGE, source: originDocument.source };
|
|
73
|
+
};
|
|
74
|
+
/** Builds an error-severity diagnostic (a failed rule function or an unknown-function reference). */
|
|
75
|
+
const errorDiagnostic = (rule, path, message, range, source) => {
|
|
76
|
+
const diagnostic = {
|
|
77
|
+
code: rule.name,
|
|
78
|
+
message,
|
|
79
|
+
path,
|
|
80
|
+
severity: DiagnosticSeverity.Error,
|
|
81
|
+
range,
|
|
82
|
+
};
|
|
83
|
+
if (source !== undefined)
|
|
84
|
+
diagnostic.source = source;
|
|
85
|
+
return diagnostic;
|
|
86
|
+
};
|
|
87
|
+
/** Returns the name of the first `then` function this rule references that is not registered. */
|
|
88
|
+
const missingFunction = (ruleset, rule) => {
|
|
89
|
+
for (const then of rule.then) {
|
|
90
|
+
if (typeof then?.function === 'string' && !ruleset.getFunction(then.function))
|
|
91
|
+
return then.function;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
};
|
|
39
95
|
/**
|
|
40
96
|
* Builds a deduped query plan for `rules` against `data`: each distinct
|
|
41
97
|
* (expanded) `given` is compiled and evaluated once, then its matches fan out to
|
|
42
98
|
* every rule that shares it. Recursive descents are evaluated with a single
|
|
43
99
|
* shared tree walk inside `queryMany`.
|
|
100
|
+
*
|
|
101
|
+
* A rule that references an unknown named function is reported once (not per
|
|
102
|
+
* node) and skipped; a rule function that throws (or rejects) becomes an
|
|
103
|
+
* error-severity diagnostic on that node so one bad rule cannot abort the run.
|
|
44
104
|
*/
|
|
45
|
-
const runPlan = (ruleset, rules, data, document, formats, out, sources) => {
|
|
105
|
+
const runPlan = async (ruleset, rules, data, document, formats, out, sources) => {
|
|
46
106
|
if (rules.length === 0)
|
|
47
107
|
return;
|
|
48
108
|
const order = [];
|
|
49
109
|
const groups = new Map();
|
|
50
110
|
for (const rule of rules) {
|
|
111
|
+
// Surface an unknown-function reference once for the whole rule, then skip it
|
|
112
|
+
// rather than throwing mid-run at the first matched node.
|
|
113
|
+
const missing = missingFunction(ruleset, rule);
|
|
114
|
+
if (missing !== undefined) {
|
|
115
|
+
out.push(errorDiagnostic(rule, [], `Rule "${rule.name}" references unknown function "${missing}"`, ZERO_RANGE, document.source));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
51
118
|
for (const given of ruleset.expandGiven(rule.given, formats)) {
|
|
52
119
|
let group = groups.get(given);
|
|
53
120
|
if (!group) {
|
|
@@ -68,7 +135,7 @@ const runPlan = (ruleset, rules, data, document, formats, out, sources) => {
|
|
|
68
135
|
for (const match of matches) {
|
|
69
136
|
for (const rule of group.rules) {
|
|
70
137
|
for (const then of rule.then) {
|
|
71
|
-
runThen(ruleset, rule, then, match.value, match.path, document, out, sources);
|
|
138
|
+
await runThen(ruleset, rule, then, match.value, match.path, document, out, sources);
|
|
72
139
|
}
|
|
73
140
|
}
|
|
74
141
|
}
|
|
@@ -96,6 +163,10 @@ const applyScopedOverrides = (ruleset, source, diagnostics) => {
|
|
|
96
163
|
if (scoped.length === 0)
|
|
97
164
|
return diagnostics;
|
|
98
165
|
const isPrefix = (prefix, path) => prefix.every((segment, index) => String(path[index]) === String(segment));
|
|
166
|
+
// Note: a pointer-scoped override can *disable* (`off`/`false`) or *remap the
|
|
167
|
+
// severity* of a finding under its path, but it cannot *enable* a rule — it
|
|
168
|
+
// filters/adjusts findings that were already produced, so a rule turned off
|
|
169
|
+
// elsewhere has no finding here to switch back on.
|
|
99
170
|
const result = [];
|
|
100
171
|
for (const diagnostic of diagnostics) {
|
|
101
172
|
let dropped = false;
|
|
@@ -120,22 +191,38 @@ const applyScopedOverrides = (ruleset, source, diagnostics) => {
|
|
|
120
191
|
}
|
|
121
192
|
return result;
|
|
122
193
|
};
|
|
123
|
-
const runThen = (ruleset, rule, then, value, path, document, out, sources) => {
|
|
194
|
+
const runThen = async (ruleset, rule, then, value, path, document, out, sources) => {
|
|
124
195
|
// A malformed rule (e.g. a `then` with no `function`, which `validateRuleset`
|
|
125
196
|
// warns about) is skipped rather than crashing the whole run.
|
|
126
197
|
if (!then || (typeof then.function !== 'function' && typeof then.function !== 'string'))
|
|
127
198
|
return;
|
|
128
199
|
const fn = typeof then.function === 'function' ? then.function : ruleset.getFunction(then.function);
|
|
200
|
+
// Unknown named functions are handled once per rule in `runPlan`; nothing to do.
|
|
129
201
|
if (!fn)
|
|
130
|
-
|
|
202
|
+
return;
|
|
131
203
|
for (const target of resolveTargets(value, path, then.field)) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
204
|
+
let results;
|
|
205
|
+
try {
|
|
206
|
+
// The declared signature is synchronous, but a function reference may in
|
|
207
|
+
// fact return a Promise (a Spectral-style async function), so treat the raw
|
|
208
|
+
// result as unknown and await it when it is thenable.
|
|
209
|
+
const raw = fn(target.value, then.functionOptions ?? {}, {
|
|
210
|
+
document,
|
|
211
|
+
path: target.path,
|
|
212
|
+
value: target.value,
|
|
213
|
+
rule,
|
|
214
|
+
functionOptions: then.functionOptions,
|
|
215
|
+
});
|
|
216
|
+
results = (isThenable(raw) ? await raw : raw);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
// Isolate the failure: convert it into an error diagnostic on this node so
|
|
220
|
+
// the rest of the run (and already-collected findings) survive.
|
|
221
|
+
const { range, source } = locate(rule, target.path, document, sources);
|
|
222
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
223
|
+
out.push(errorDiagnostic(rule, target.path, `Rule "${rule.name}" threw: ${reason}`, range, source));
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
139
226
|
if (!results)
|
|
140
227
|
continue;
|
|
141
228
|
for (const result of results) {
|
|
@@ -155,30 +242,16 @@ const toDiagnostic = (rule, result, target, document, sources) => {
|
|
|
155
242
|
description: rule.description ?? '',
|
|
156
243
|
})
|
|
157
244
|
: result.message;
|
|
158
|
-
|
|
159
|
-
// set, follow the `$ref` chain across documents so the range and `source` point
|
|
160
|
-
// at the originating file; otherwise fall back to the root document, following
|
|
161
|
-
// internal `$ref`s only.
|
|
162
|
-
let originDocument = document;
|
|
163
|
-
let sourcePath;
|
|
164
|
-
if (rule.resolved && sources) {
|
|
165
|
-
const origin = sources.origin(path);
|
|
166
|
-
originDocument = sources.get(origin.location) ?? document;
|
|
167
|
-
sourcePath = origin.path;
|
|
168
|
-
}
|
|
169
|
-
else {
|
|
170
|
-
sourcePath = rule.resolved ? resolveSourcePath(document.data, path) : path;
|
|
171
|
-
}
|
|
172
|
-
const location = originDocument.getLocationForJsonPath(sourcePath, true);
|
|
245
|
+
const { range, source } = locate(rule, path, document, sources);
|
|
173
246
|
const diagnostic = {
|
|
174
247
|
code: rule.name,
|
|
175
248
|
message,
|
|
176
249
|
path,
|
|
177
250
|
severity: rule.severity,
|
|
178
|
-
range
|
|
251
|
+
range,
|
|
179
252
|
};
|
|
180
|
-
if (
|
|
181
|
-
diagnostic.source =
|
|
253
|
+
if (source !== undefined)
|
|
254
|
+
diagnostic.source = source;
|
|
182
255
|
return diagnostic;
|
|
183
256
|
};
|
|
184
257
|
const hasIntersection = (a, b) => {
|
|
@@ -188,10 +261,21 @@ const hasIntersection = (a, b) => {
|
|
|
188
261
|
}
|
|
189
262
|
return false;
|
|
190
263
|
};
|
|
191
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Orders findings by source, then line, then character. Sorting by position
|
|
266
|
+
* alone interleaves findings from different files once a run spans multiple
|
|
267
|
+
* sources; grouping by `source` first keeps each file's findings contiguous.
|
|
268
|
+
*/
|
|
269
|
+
const bySourceThenPosition = (a, b) => {
|
|
270
|
+
const sa = a.source ?? '';
|
|
271
|
+
const sb = b.source ?? '';
|
|
272
|
+
if (sa !== sb)
|
|
273
|
+
return sa < sb ? -1 : 1;
|
|
274
|
+
return a.range.start.line - b.range.start.line || a.range.start.character - b.range.start.character;
|
|
275
|
+
};
|
|
192
276
|
/** Creates a {@link Linter} runner bound to a normalized `ruleset`. */
|
|
193
277
|
export const createLinter = (ruleset) => ({
|
|
194
|
-
run: (document, options = {}) => {
|
|
278
|
+
run: async (document, options = {}) => {
|
|
195
279
|
const documentFormats = detectFormats(document.data, ruleset.formats);
|
|
196
280
|
const diagnostics = [];
|
|
197
281
|
// Rules split by which document they run against (resolved vs raw). Each
|
|
@@ -213,10 +297,10 @@ export const createLinter = (ruleset) => ({
|
|
|
213
297
|
// Raw rules run against the root's unresolved tree, so positions always come
|
|
214
298
|
// straight from the root document; only resolved rules can land on inlined
|
|
215
299
|
// external nodes and need the source set.
|
|
216
|
-
runPlan(ruleset, rawRules, document.data, document, documentFormats, diagnostics, undefined);
|
|
300
|
+
await runPlan(ruleset, rawRules, document.data, document, documentFormats, diagnostics, undefined);
|
|
217
301
|
if (resolvedRules.length > 0) {
|
|
218
|
-
runPlan(ruleset, resolvedRules, options.resolved, document, documentFormats, diagnostics, options.sources);
|
|
302
|
+
await runPlan(ruleset, resolvedRules, options.resolved, document, documentFormats, diagnostics, options.sources);
|
|
219
303
|
}
|
|
220
|
-
return applyScopedOverrides(ruleset, document.source, diagnostics).sort(
|
|
304
|
+
return applyScopedOverrides(ruleset, document.source, diagnostics).sort(bySourceThenPosition);
|
|
221
305
|
},
|
|
222
306
|
});
|
package/dist/core/types.d.ts
CHANGED
|
@@ -24,8 +24,18 @@ export type IFunctionContext = {
|
|
|
24
24
|
/**
|
|
25
25
|
* A rule function: given the matched `input`, its authored `options`, and the
|
|
26
26
|
* run `context`, returns any findings (or `undefined`/`[]` for none).
|
|
27
|
+
*
|
|
28
|
+
* The declared return type is synchronous because the built-in functions are,
|
|
29
|
+
* but the runner also awaits a thenable result at run time, so a Spectral-style
|
|
30
|
+
* async function works when passed as a reference (see {@link AsyncRulesetFunction}).
|
|
27
31
|
*/
|
|
28
32
|
export type RulesetFunction<I = unknown, O = unknown> = (input: I, options: O, context: IFunctionContext) => IFunctionResult[] | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* A rule function that resolves its findings asynchronously. The runner awaits a
|
|
35
|
+
* thenable result, so such a function may be passed by reference in a JS ruleset;
|
|
36
|
+
* cast it to {@link RulesetFunction} at the `then.function` site.
|
|
37
|
+
*/
|
|
38
|
+
export type AsyncRulesetFunction<I = unknown, O = unknown> = (input: I, options: O, context: IFunctionContext) => Promise<IFunctionResult[] | undefined>;
|
|
29
39
|
/** Functions a ruleset can invoke by name in `then.function`, keyed by that name. */
|
|
30
40
|
export type FunctionRegistry = Record<string, RulesetFunction>;
|
|
31
41
|
/** One action a rule takes on each match: a function plus how to target/configure it. */
|
|
@@ -57,11 +67,16 @@ export type IRuleDefinition = {
|
|
|
57
67
|
};
|
|
58
68
|
/** Shorthand a rule may take in `rules`: a definition, a boolean, or a severity. */
|
|
59
69
|
export type RuleEntry = IRuleDefinition | boolean | HumanReadableSeverity;
|
|
70
|
+
/**
|
|
71
|
+
* A per-file override: `files` globs select the documents it applies to, and
|
|
72
|
+
* `rules` re-toggles or re-severities rules for them. Spectral's `extends` and
|
|
73
|
+
* `formats` on an override are intentionally omitted here — they were never
|
|
74
|
+
* applied by the engine, so carrying them in the type only advertised support
|
|
75
|
+
* that did not exist. Re-add them alongside a real implementation if needed.
|
|
76
|
+
*/
|
|
60
77
|
export type IRulesetOverride = {
|
|
61
78
|
files: string[];
|
|
62
79
|
rules?: Record<string, RuleEntry>;
|
|
63
|
-
extends?: RulesetExtends;
|
|
64
|
-
formats?: string[];
|
|
65
80
|
};
|
|
66
81
|
/**
|
|
67
82
|
* The shape of a ruleset's `extends`: a single target or a list, where each
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { compileQuery } from './jsonpath.js';
|
|
1
2
|
const SEVERITIES = new Set(['error', 'warn', 'info', 'hint', 'off']);
|
|
2
3
|
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
4
|
const isValidSeverity = (value) => {
|
|
@@ -42,6 +43,21 @@ const validateRule = (name, entry, path, problems) => {
|
|
|
42
43
|
else if (typeof entry['given'] !== 'string' && !Array.isArray(entry['given'])) {
|
|
43
44
|
problems.push({ message: `Rule "${name}" \`given\` must be a string or array`, path: [...path, 'given'] });
|
|
44
45
|
}
|
|
46
|
+
else {
|
|
47
|
+
// Flag a malformed JSONPath expression so it does not silently match nothing
|
|
48
|
+
// at run time. Alias references (`#Alias`) are only valid once expanded, so
|
|
49
|
+
// they are skipped here.
|
|
50
|
+
const givens = Array.isArray(entry['given']) ? entry['given'] : [entry['given']];
|
|
51
|
+
givens.forEach((given, index) => {
|
|
52
|
+
if (typeof given !== 'string' || given.startsWith('#'))
|
|
53
|
+
return;
|
|
54
|
+
const error = compileQuery(given).error;
|
|
55
|
+
if (error !== undefined) {
|
|
56
|
+
const at = Array.isArray(entry['given']) ? [...path, 'given', index] : [...path, 'given'];
|
|
57
|
+
problems.push({ message: `Rule "${name}" has an invalid \`given\` "${given}": ${error}`, path: at });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
45
61
|
if (entry['then'] === undefined) {
|
|
46
62
|
problems.push({ message: `Rule "${name}" is missing \`then\``, path: [...path, 'then'] });
|
|
47
63
|
}
|
package/dist/fix/apply.d.ts
CHANGED
|
@@ -15,7 +15,13 @@ export type ApplyFixesOptions = {
|
|
|
15
15
|
* `data` is the *unresolved* parsed document: fixers read the real node at a
|
|
16
16
|
* finding's path to derive the edit, and edits whose path no longer resolves are
|
|
17
17
|
* dropped — so a finding on an inlined `$ref` node simply isn't fixed rather than
|
|
18
|
-
* corrupting the source.
|
|
19
|
-
*
|
|
18
|
+
* corrupting the source.
|
|
19
|
+
*
|
|
20
|
+
* When two ops in one batch would both reshape the same array, only the first is
|
|
21
|
+
* applied this pass; the second is *deferred* and its finding is left unreported,
|
|
22
|
+
* so the surrounding fixpoint loop re-derives it against the already-edited
|
|
23
|
+
* document (where the indices are fresh) on the next pass. A finding is reported
|
|
24
|
+
* in `applied` only when *every* edit it contributed actually changed the text —
|
|
25
|
+
* a partially-applied or deferred fix is retried rather than falsely counted.
|
|
20
26
|
*/
|
|
21
27
|
export declare const applyFixes: (input: string, format: ParserFormat, data: unknown, diagnostics: IDiagnostic[], fixers: FixerRegistry, options?: ApplyFixesOptions) => FixResult;
|
package/dist/fix/apply.js
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { applyEditOpsWithChanges } from '../parsers/index.js';
|
|
2
|
+
/** The ops that structurally reshape an array, invalidating positional indices into it. */
|
|
3
|
+
const STRUCTURAL_ARRAY_OPS = new Set(['removeItems', 'reorderArray', 'insertItem']);
|
|
4
|
+
const isStructuralArrayOp = (op) => STRUCTURAL_ARRAY_OPS.has(op.op);
|
|
5
|
+
/**
|
|
6
|
+
* Whether `path` addresses (or reaches into) an array that an earlier op in this
|
|
7
|
+
* batch already reshaped. After a `removeItems`/`reorderArray`/`insertItem`, the
|
|
8
|
+
* element indices of that array are stale, so a second op that either targets the
|
|
9
|
+
* same array or indexes into it by position would act on the wrong element. Such
|
|
10
|
+
* an op is deferred to the next fixpoint pass, which re-derives indices from the
|
|
11
|
+
* freshly-parsed document.
|
|
12
|
+
*/
|
|
13
|
+
const touchesModifiedArray = (path, modified) => modified.some((array) => {
|
|
14
|
+
if (array.length > path.length)
|
|
15
|
+
return false;
|
|
16
|
+
if (!array.every((segment, i) => segment === path[i]))
|
|
17
|
+
return false;
|
|
18
|
+
// Another structural op on the same array conflicts; a deeper op conflicts
|
|
19
|
+
// only when it indexes the array by position (the stale part).
|
|
20
|
+
return array.length === path.length || typeof path[array.length] === 'number';
|
|
21
|
+
});
|
|
2
22
|
/**
|
|
3
23
|
* Computes the structural edits for every fixable finding in `diagnostics`, then
|
|
4
24
|
* applies them to `input` in one pass. Edits from different findings that come
|
|
@@ -8,15 +28,19 @@ import { applyEditOpsWithChanges } from '../parsers/index.js';
|
|
|
8
28
|
* `data` is the *unresolved* parsed document: fixers read the real node at a
|
|
9
29
|
* finding's path to derive the edit, and edits whose path no longer resolves are
|
|
10
30
|
* dropped — so a finding on an inlined `$ref` node simply isn't fixed rather than
|
|
11
|
-
* corrupting the source.
|
|
12
|
-
*
|
|
31
|
+
* corrupting the source.
|
|
32
|
+
*
|
|
33
|
+
* When two ops in one batch would both reshape the same array, only the first is
|
|
34
|
+
* applied this pass; the second is *deferred* and its finding is left unreported,
|
|
35
|
+
* so the surrounding fixpoint loop re-derives it against the already-edited
|
|
36
|
+
* document (where the indices are fresh) on the next pass. A finding is reported
|
|
37
|
+
* in `applied` only when *every* edit it contributed actually changed the text —
|
|
38
|
+
* a partially-applied or deferred fix is retried rather than falsely counted.
|
|
13
39
|
*/
|
|
14
40
|
export const applyFixes = (input, format, data, diagnostics, fixers, options = {}) => {
|
|
15
41
|
const safeOnly = options.safeOnly !== false;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
// Each candidate finding remembers the edits it contributed (by key) so we can
|
|
19
|
-
// tell afterwards whether any of them actually landed.
|
|
42
|
+
// Gather each candidate finding's ops up front so we can reason about conflicts
|
|
43
|
+
// across the whole batch before lowering anything to text.
|
|
20
44
|
const candidates = [];
|
|
21
45
|
for (const diagnostic of diagnostics) {
|
|
22
46
|
const fixer = fixers[String(diagnostic.code)];
|
|
@@ -27,25 +51,51 @@ export const applyFixes = (input, format, data, diagnostics, fixers, options = {
|
|
|
27
51
|
const produced = fixer.fix({ diagnostic, data, format });
|
|
28
52
|
if (!produced)
|
|
29
53
|
continue;
|
|
30
|
-
const
|
|
31
|
-
|
|
54
|
+
const ops = Array.isArray(produced) ? produced : [produced];
|
|
55
|
+
if (ops.length === 0)
|
|
56
|
+
continue;
|
|
57
|
+
candidates.push({ fix: { code: diagnostic.code, path: diagnostic.path }, ops });
|
|
58
|
+
}
|
|
59
|
+
const ops = [];
|
|
60
|
+
const indexByKey = new Map();
|
|
61
|
+
const modifiedArrays = [];
|
|
62
|
+
// Per candidate: the batch index of each op that made it into this pass, and
|
|
63
|
+
// whether any op had to be deferred (which keeps the finding unreported so it
|
|
64
|
+
// is retried once the earlier structural edit has landed).
|
|
65
|
+
const planned = [];
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
const indices = [];
|
|
68
|
+
let deferred = false;
|
|
69
|
+
for (const op of candidate.ops) {
|
|
32
70
|
const key = JSON.stringify(op);
|
|
33
71
|
// De-duplicate identical edits (e.g. several findings on one array all asking
|
|
34
|
-
// for the same reorder)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
72
|
+
// for the same reorder): the same edit is applied — and counted — once, and
|
|
73
|
+
// is never a conflict with itself.
|
|
74
|
+
const existing = indexByKey.get(key);
|
|
75
|
+
if (existing !== undefined) {
|
|
76
|
+
indices.push(existing);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// A distinct op that would reshape or index into an already-reshaped array
|
|
80
|
+
// is deferred to the next pass, where indices are re-derived from fresh data.
|
|
81
|
+
if (touchesModifiedArray(op.path, modifiedArrays)) {
|
|
82
|
+
deferred = true;
|
|
83
|
+
continue;
|
|
38
84
|
}
|
|
39
|
-
|
|
85
|
+
const index = ops.length;
|
|
86
|
+
indexByKey.set(key, index);
|
|
87
|
+
ops.push(op);
|
|
88
|
+
if (isStructuralArrayOp(op))
|
|
89
|
+
modifiedArrays.push(op.path);
|
|
90
|
+
indices.push(index);
|
|
40
91
|
}
|
|
41
|
-
|
|
42
|
-
candidates.push({ fix: { code: diagnostic.code, path: diagnostic.path }, keys });
|
|
92
|
+
planned.push({ fix: candidate.fix, indices, deferred });
|
|
43
93
|
}
|
|
44
94
|
if (ops.length === 0)
|
|
45
95
|
return { output: input, applied: [], changed: false };
|
|
46
96
|
const { output, changed } = applyEditOpsWithChanges(input, format, ops);
|
|
47
|
-
const applied =
|
|
48
|
-
.filter((
|
|
49
|
-
.map((
|
|
97
|
+
const applied = planned
|
|
98
|
+
.filter((plan) => !plan.deferred && plan.indices.length > 0 && plan.indices.every((index) => changed[index]))
|
|
99
|
+
.map((plan) => plan.fix);
|
|
50
100
|
return { output, applied, changed: output !== input };
|
|
51
101
|
};
|
|
@@ -1,28 +1,55 @@
|
|
|
1
|
+
const isRecord = (value) => typeof value === 'object' && value !== null;
|
|
2
|
+
const isStringOrNumber = (value) => typeof value === 'string' || typeof value === 'number';
|
|
3
|
+
/** A string made up only of digits, e.g. an integer-like object key such as "10". */
|
|
4
|
+
const isIntegerLike = (value) => typeof value === 'string' && /^(?:0|[1-9]\d*)$/.test(value);
|
|
1
5
|
const compare = (a, b) => {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
6
|
+
// Deliberate deviation from Spectral, which relies on source order and falls
|
|
7
|
+
// back to `localeCompare`: JavaScript enumerates integer-like object keys in
|
|
8
|
+
// ascending numeric order ({ "2": …, "10": … }), yet `localeCompare` sorts
|
|
9
|
+
// "10" before "2" and would flag that natural key order as a violation.
|
|
10
|
+
// Comparing integer-like strings numerically avoids that false positive.
|
|
11
|
+
if (isIntegerLike(a) && isIntegerLike(b))
|
|
12
|
+
return Math.sign(Number(a) - Number(b));
|
|
13
|
+
// Match Spectral: when either side is a real number or a numeric string,
|
|
14
|
+
// compare numerically so mixed inputs like [2, "10"] read as ordered.
|
|
15
|
+
if ((typeof a === 'number' || Number.isNaN(Number(a))) && (typeof b === 'number' || !Number.isNaN(Number(b)))) {
|
|
16
|
+
return Math.min(1, Math.max(-1, Number(a) - Number(b)));
|
|
17
|
+
}
|
|
18
|
+
if (typeof a !== 'string' || typeof b !== 'string')
|
|
19
|
+
return 0;
|
|
20
|
+
return a.localeCompare(b);
|
|
5
21
|
};
|
|
6
22
|
/** Flags array items or object keys that are not in ascending (optionally `keyedBy`) order. */
|
|
7
23
|
export const alphabetical = (input, options, context) => {
|
|
8
24
|
if (typeof input !== 'object' || input === null)
|
|
9
25
|
return [];
|
|
10
26
|
const isArray = Array.isArray(input);
|
|
11
|
-
const
|
|
27
|
+
const rawItems = isArray ? input : Object.keys(input);
|
|
12
28
|
const keyedBy = options?.keyedBy;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
29
|
+
// Resolve the actual comparands. With `keyedBy` we read a property off each
|
|
30
|
+
// item, which is only meaningful when every item is an object; otherwise the
|
|
31
|
+
// comparison would silently run against `undefined`. Surface the same explicit
|
|
32
|
+
// findings Spectral does instead of producing a misleading order violation.
|
|
33
|
+
const items = [];
|
|
34
|
+
for (const item of rawItems) {
|
|
17
35
|
if (keyedBy) {
|
|
18
|
-
|
|
19
|
-
|
|
36
|
+
if (!isRecord(item))
|
|
37
|
+
return [{ message: 'The value must be an object' }];
|
|
38
|
+
items.push(item[keyedBy]);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
items.push(item);
|
|
20
42
|
}
|
|
21
|
-
|
|
22
|
-
|
|
43
|
+
}
|
|
44
|
+
if (!items.every(isStringOrNumber)) {
|
|
45
|
+
return [{ message: 'The value must be one of the allowed types: number, string' }];
|
|
46
|
+
}
|
|
47
|
+
const results = [];
|
|
48
|
+
for (let i = 0; i < items.length - 1; i++) {
|
|
49
|
+
if (compare(items[i], items[i + 1]) > 0) {
|
|
50
|
+
const path = isArray ? [...context.path, i + 1] : [...context.path, rawItems[i + 1]];
|
|
23
51
|
results.push({ message: 'The items must be in alphabetical order', path });
|
|
24
52
|
}
|
|
25
53
|
}
|
|
26
54
|
return results;
|
|
27
55
|
};
|
|
28
|
-
const isRecord = (value) => typeof value === 'object' && value !== null;
|