@amritk/lint 0.0.0 → 0.2.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 +34 -1
- package/dist/core/document.d.ts +19 -0
- package/dist/core/document.js +11 -0
- package/dist/core/formats.d.ts +9 -0
- package/dist/core/formats.js +14 -0
- package/dist/core/glob.d.ts +4 -0
- package/dist/core/glob.js +48 -0
- package/dist/core/index.d.ts +11 -0
- package/dist/core/index.js +11 -0
- package/dist/core/jsonpath.d.ts +59 -0
- package/dist/core/jsonpath.js +415 -0
- package/dist/core/lint.d.ts +57 -0
- package/dist/core/lint.js +82 -0
- package/dist/core/plugin.d.ts +59 -0
- package/dist/core/plugin.js +25 -0
- package/dist/core/pointers.d.ts +35 -0
- package/dist/core/pointers.js +169 -0
- package/dist/core/ruleset.d.ts +52 -0
- package/dist/core/ruleset.js +170 -0
- package/dist/core/runner.d.ts +21 -0
- package/dist/core/runner.js +222 -0
- package/dist/core/types.d.ts +170 -0
- package/dist/core/types.js +1 -0
- package/dist/core/validate-ruleset.d.ts +14 -0
- package/dist/core/validate-ruleset.js +105 -0
- package/dist/fix/apply.d.ts +21 -0
- package/dist/fix/apply.js +51 -0
- package/dist/fix/index.d.ts +3 -0
- package/dist/fix/index.js +2 -0
- package/dist/fix/plugin.d.ts +18 -0
- package/dist/fix/plugin.js +21 -0
- package/dist/fix/types.d.ts +39 -0
- package/dist/fix/types.js +0 -0
- package/dist/functions/alphabetical.d.ts +8 -0
- package/dist/functions/alphabetical.js +28 -0
- package/dist/functions/casing.d.ts +14 -0
- package/dist/functions/casing.js +30 -0
- package/dist/functions/defined.d.ts +3 -0
- package/dist/functions/defined.js +6 -0
- package/dist/functions/enumeration.d.ts +5 -0
- package/dist/functions/enumeration.js +10 -0
- package/dist/functions/falsy.d.ts +3 -0
- package/dist/functions/falsy.js +6 -0
- package/dist/functions/index.d.ts +16 -0
- package/dist/functions/index.js +42 -0
- package/dist/functions/length.d.ts +6 -0
- package/dist/functions/length.js +27 -0
- package/dist/functions/pattern.d.ts +6 -0
- package/dist/functions/pattern.js +20 -0
- package/dist/functions/schema.d.ts +8 -0
- package/dist/functions/schema.js +36 -0
- package/dist/functions/truthy.d.ts +3 -0
- package/dist/functions/truthy.js +6 -0
- package/dist/functions/typed-enum.d.ts +3 -0
- package/dist/functions/typed-enum.js +34 -0
- package/dist/functions/undefined.d.ts +6 -0
- package/dist/functions/undefined.js +9 -0
- package/dist/functions/unreferenced-reusable-object.d.ts +8 -0
- package/dist/functions/unreferenced-reusable-object.js +37 -0
- package/dist/functions/xor.d.ts +7 -0
- package/dist/functions/xor.js +11 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +168 -0
- package/dist/parsers/edit-model.d.ts +69 -0
- package/dist/parsers/edit-model.js +326 -0
- package/dist/parsers/index.d.ts +18 -0
- package/dist/parsers/index.js +21 -0
- package/dist/parsers/json.d.ts +3 -0
- package/dist/parsers/json.js +38 -0
- package/dist/parsers/lines.d.ts +13 -0
- package/dist/parsers/lines.js +28 -0
- package/dist/parsers/types.d.ts +50 -0
- package/dist/parsers/types.js +8 -0
- package/dist/parsers/yaml.d.ts +6 -0
- package/dist/parsers/yaml.js +65 -0
- package/dist/rules/openapi/fixers.d.ts +7 -0
- package/dist/rules/openapi/fixers.js +189 -0
- package/dist/rules/openapi/formats.d.ts +13 -0
- package/dist/rules/openapi/formats.js +23 -0
- package/dist/rules/openapi/functions/example-validation.d.ts +5 -0
- package/dist/rules/openapi/functions/example-validation.js +97 -0
- package/dist/rules/openapi/functions/helpers.d.ts +3 -0
- package/dist/rules/openapi/functions/helpers.js +5 -0
- package/dist/rules/openapi/functions/index.d.ts +24 -0
- package/dist/rules/openapi/functions/index.js +67 -0
- package/dist/rules/openapi/functions/oas-additional-operations.d.ts +8 -0
- package/dist/rules/openapi/functions/oas-additional-operations.js +25 -0
- package/dist/rules/openapi/functions/oas-discriminator.d.ts +3 -0
- package/dist/rules/openapi/functions/oas-discriminator.js +23 -0
- package/dist/rules/openapi/functions/oas-example-value.d.ts +3 -0
- package/dist/rules/openapi/functions/oas-example-value.js +31 -0
- package/dist/rules/openapi/functions/oas-mutually-exclusive.d.ts +10 -0
- package/dist/rules/openapi/functions/oas-mutually-exclusive.js +20 -0
- package/dist/rules/openapi/functions/oas-op-form-data-consume-check.d.ts +3 -0
- package/dist/rules/openapi/functions/oas-op-form-data-consume-check.js +21 -0
- package/dist/rules/openapi/functions/oas-op-id-unique.d.ts +3 -0
- package/dist/rules/openapi/functions/oas-op-id-unique.js +27 -0
- package/dist/rules/openapi/functions/oas-op-params.d.ts +3 -0
- package/dist/rules/openapi/functions/oas-op-params.js +21 -0
- package/dist/rules/openapi/functions/oas-op-security-defined.d.ts +5 -0
- package/dist/rules/openapi/functions/oas-op-security-defined.js +44 -0
- package/dist/rules/openapi/functions/oas-op-success-response.d.ts +3 -0
- package/dist/rules/openapi/functions/oas-op-success-response.js +11 -0
- package/dist/rules/openapi/functions/oas-path-param.d.ts +3 -0
- package/dist/rules/openapi/functions/oas-path-param.js +45 -0
- package/dist/rules/openapi/functions/oas-schema-example-deprecated.d.ts +10 -0
- package/dist/rules/openapi/functions/oas-schema-example-deprecated.js +42 -0
- package/dist/rules/openapi/functions/oas-schema.d.ts +15 -0
- package/dist/rules/openapi/functions/oas-schema.js +15 -0
- package/dist/rules/openapi/functions/oas-server-name-unique.d.ts +3 -0
- package/dist/rules/openapi/functions/oas-server-name-unique.js +20 -0
- package/dist/rules/openapi/functions/oas-server-variables.d.ts +3 -0
- package/dist/rules/openapi/functions/oas-server-variables.js +23 -0
- package/dist/rules/openapi/functions/oas-tag-defined.d.ts +3 -0
- package/dist/rules/openapi/functions/oas-tag-defined.js +21 -0
- package/dist/rules/openapi/functions/oas-tag-kind.d.ts +3 -0
- package/dist/rules/openapi/functions/oas-tag-kind.js +17 -0
- package/dist/rules/openapi/functions/oas-tag-parent-defined.d.ts +7 -0
- package/dist/rules/openapi/functions/oas-tag-parent-defined.js +45 -0
- package/dist/rules/openapi/functions/oas-tags-unique.d.ts +3 -0
- package/dist/rules/openapi/functions/oas-tags-unique.js +17 -0
- package/dist/rules/openapi/functions/oas-unused-component.d.ts +7 -0
- package/dist/rules/openapi/functions/oas-unused-component.js +52 -0
- package/dist/rules/openapi/functions/ref-siblings.d.ts +3 -0
- package/dist/rules/openapi/functions/ref-siblings.js +13 -0
- package/dist/rules/openapi/index.d.ts +25 -0
- package/dist/rules/openapi/index.js +127 -0
- package/dist/rules/openapi/oas.d.ts +3 -0
- package/dist/rules/openapi/oas.js +492 -0
- package/dist/rules/openapi/schemas/index.d.ts +8 -0
- package/dist/rules/openapi/schemas/index.js +36 -0
- package/dist/rules/openapi/schemas/oas20.json +1592 -0
- package/dist/rules/openapi/schemas/oas30.json +1651 -0
- package/dist/rules/openapi/schemas/oas31.json +1412 -0
- package/dist/rules/openapi/schemas/oas32.json +1684 -0
- package/package.json +8 -5
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { detectFormats } from './formats.js';
|
|
2
|
+
import { matchesGlob } from './glob.js';
|
|
3
|
+
import { compileQuery, queryMany } from './jsonpath.js';
|
|
4
|
+
import { pointerToPath, resolveSourcePath } from './pointers.js';
|
|
5
|
+
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
|
+
const ZERO_RANGE = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
|
|
13
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
const resolveTargets = (value, path, field) => {
|
|
15
|
+
if (field === undefined)
|
|
16
|
+
return [{ value, path }];
|
|
17
|
+
if (field === '$')
|
|
18
|
+
return [{ value, path }];
|
|
19
|
+
if (field === '@key') {
|
|
20
|
+
if (!isObject(value))
|
|
21
|
+
return [];
|
|
22
|
+
return Object.keys(value).map((key) => ({ value: key, path: [...path, key] }));
|
|
23
|
+
}
|
|
24
|
+
if (isObject(value))
|
|
25
|
+
return [{ value: value[field], path: [...path, field] }];
|
|
26
|
+
return [];
|
|
27
|
+
};
|
|
28
|
+
const stringify = (value) => {
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
return '';
|
|
31
|
+
if (typeof value === 'string')
|
|
32
|
+
return value;
|
|
33
|
+
return JSON.stringify(value);
|
|
34
|
+
};
|
|
35
|
+
const applyTemplate = (template, ctx) => template.replace(/\{\{([^}]+)\}\}/g, (_match, raw) => {
|
|
36
|
+
const key = raw.trim();
|
|
37
|
+
return key in ctx ? stringify(ctx[key]) : '';
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Builds a deduped query plan for `rules` against `data`: each distinct
|
|
41
|
+
* (expanded) `given` is compiled and evaluated once, then its matches fan out to
|
|
42
|
+
* every rule that shares it. Recursive descents are evaluated with a single
|
|
43
|
+
* shared tree walk inside `queryMany`.
|
|
44
|
+
*/
|
|
45
|
+
const runPlan = (ruleset, rules, data, document, formats, out, sources) => {
|
|
46
|
+
if (rules.length === 0)
|
|
47
|
+
return;
|
|
48
|
+
const order = [];
|
|
49
|
+
const groups = new Map();
|
|
50
|
+
for (const rule of rules) {
|
|
51
|
+
for (const given of ruleset.expandGiven(rule.given, formats)) {
|
|
52
|
+
let group = groups.get(given);
|
|
53
|
+
if (!group) {
|
|
54
|
+
group = { compiled: compileQuery(given), rules: [] };
|
|
55
|
+
groups.set(given, group);
|
|
56
|
+
order.push(given);
|
|
57
|
+
}
|
|
58
|
+
// Guard against a rule listing the same `given` twice.
|
|
59
|
+
if (group.rules[group.rules.length - 1] !== rule)
|
|
60
|
+
group.rules.push(rule);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const compiled = order.map((given) => groups.get(given).compiled);
|
|
64
|
+
const matchesPerGiven = queryMany(data, compiled);
|
|
65
|
+
for (let i = 0; i < order.length; i++) {
|
|
66
|
+
const group = groups.get(order[i]);
|
|
67
|
+
const matches = matchesPerGiven[i];
|
|
68
|
+
for (const match of matches) {
|
|
69
|
+
for (const rule of group.rules) {
|
|
70
|
+
for (const then of rule.then) {
|
|
71
|
+
runThen(ruleset, rule, then, match.value, match.path, document, out, sources);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
/** Applies `overrides` whose `files` entry includes a JSON pointer scope (`file#/path`). */
|
|
78
|
+
const applyScopedOverrides = (ruleset, source, diagnostics) => {
|
|
79
|
+
if (!source)
|
|
80
|
+
return diagnostics;
|
|
81
|
+
const scoped = [];
|
|
82
|
+
for (const override of ruleset.overrides) {
|
|
83
|
+
if (!override.rules)
|
|
84
|
+
continue;
|
|
85
|
+
for (const file of override.files) {
|
|
86
|
+
const hashIndex = file.indexOf('#');
|
|
87
|
+
if (hashIndex === -1)
|
|
88
|
+
continue;
|
|
89
|
+
const glob = file.slice(0, hashIndex);
|
|
90
|
+
const path = pointerToPath(file.slice(hashIndex));
|
|
91
|
+
if (path && (glob === '' || matchesGlob(source, [glob]))) {
|
|
92
|
+
scoped.push({ path, rules: override.rules });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (scoped.length === 0)
|
|
97
|
+
return diagnostics;
|
|
98
|
+
const isPrefix = (prefix, path) => prefix.every((segment, index) => String(path[index]) === String(segment));
|
|
99
|
+
const result = [];
|
|
100
|
+
for (const diagnostic of diagnostics) {
|
|
101
|
+
let dropped = false;
|
|
102
|
+
for (const { path, rules } of scoped) {
|
|
103
|
+
if (!isPrefix(path, diagnostic.path))
|
|
104
|
+
continue;
|
|
105
|
+
const entry = rules[String(diagnostic.code)];
|
|
106
|
+
if (entry === undefined)
|
|
107
|
+
continue;
|
|
108
|
+
if (entry === false || entry === 'off') {
|
|
109
|
+
dropped = true;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
if (typeof entry === 'number')
|
|
113
|
+
diagnostic.severity = entry;
|
|
114
|
+
else if (typeof entry === 'string' && entry in SEVERITY_NAMES) {
|
|
115
|
+
diagnostic.severity = SEVERITY_NAMES[entry];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!dropped)
|
|
119
|
+
result.push(diagnostic);
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
};
|
|
123
|
+
const runThen = (ruleset, rule, then, value, path, document, out, sources) => {
|
|
124
|
+
// A malformed rule (e.g. a `then` with no `function`, which `validateRuleset`
|
|
125
|
+
// warns about) is skipped rather than crashing the whole run.
|
|
126
|
+
if (!then || (typeof then.function !== 'function' && typeof then.function !== 'string'))
|
|
127
|
+
return;
|
|
128
|
+
const fn = typeof then.function === 'function' ? then.function : ruleset.getFunction(then.function);
|
|
129
|
+
if (!fn)
|
|
130
|
+
throw new Error(`Rule "${rule.name}" references unknown function "${String(then.function)}"`);
|
|
131
|
+
for (const target of resolveTargets(value, path, then.field)) {
|
|
132
|
+
const results = fn(target.value, then.functionOptions ?? {}, {
|
|
133
|
+
document,
|
|
134
|
+
path: target.path,
|
|
135
|
+
value: target.value,
|
|
136
|
+
rule,
|
|
137
|
+
functionOptions: then.functionOptions,
|
|
138
|
+
});
|
|
139
|
+
if (!results)
|
|
140
|
+
continue;
|
|
141
|
+
for (const result of results) {
|
|
142
|
+
out.push(toDiagnostic(rule, result, target, document, sources));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const toDiagnostic = (rule, result, target, document, sources) => {
|
|
147
|
+
const path = result.path ?? target.path;
|
|
148
|
+
const property = path.length > 0 ? path[path.length - 1] : undefined;
|
|
149
|
+
const message = rule.message
|
|
150
|
+
? applyTemplate(rule.message, {
|
|
151
|
+
property,
|
|
152
|
+
value: target.value,
|
|
153
|
+
path: path.join('.'),
|
|
154
|
+
error: result.message,
|
|
155
|
+
description: rule.description ?? '',
|
|
156
|
+
})
|
|
157
|
+
: result.message;
|
|
158
|
+
// A resolved finding may sit on a node inlined from another file. With a source
|
|
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);
|
|
173
|
+
const diagnostic = {
|
|
174
|
+
code: rule.name,
|
|
175
|
+
message,
|
|
176
|
+
path,
|
|
177
|
+
severity: rule.severity,
|
|
178
|
+
range: location?.range ?? ZERO_RANGE,
|
|
179
|
+
};
|
|
180
|
+
if (originDocument.source !== undefined)
|
|
181
|
+
diagnostic.source = originDocument.source;
|
|
182
|
+
return diagnostic;
|
|
183
|
+
};
|
|
184
|
+
const hasIntersection = (a, b) => {
|
|
185
|
+
for (const value of a) {
|
|
186
|
+
if (b.has(value))
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
};
|
|
191
|
+
const byPosition = (a, b) => a.range.start.line - b.range.start.line || a.range.start.character - b.range.start.character;
|
|
192
|
+
/** Creates a {@link Linter} runner bound to a normalized `ruleset`. */
|
|
193
|
+
export const createLinter = (ruleset) => ({
|
|
194
|
+
run: (document, options = {}) => {
|
|
195
|
+
const documentFormats = detectFormats(document.data, ruleset.formats);
|
|
196
|
+
const diagnostics = [];
|
|
197
|
+
// Rules split by which document they run against (resolved vs raw). Each
|
|
198
|
+
// dataset is linted with a single deduped query plan so that identical
|
|
199
|
+
// `given`s evaluate once and all `$..` descents share one tree walk.
|
|
200
|
+
const resolvedAvailable = options.resolved !== undefined;
|
|
201
|
+
const rawRules = [];
|
|
202
|
+
const resolvedRules = [];
|
|
203
|
+
for (const rule of ruleset.rulesForSource(document.source)) {
|
|
204
|
+
if (!rule.enabled)
|
|
205
|
+
continue;
|
|
206
|
+
if (rule.formats && !hasIntersection(rule.formats, documentFormats))
|
|
207
|
+
continue;
|
|
208
|
+
if (rule.resolved && resolvedAvailable)
|
|
209
|
+
resolvedRules.push(rule);
|
|
210
|
+
else
|
|
211
|
+
rawRules.push(rule);
|
|
212
|
+
}
|
|
213
|
+
// Raw rules run against the root's unresolved tree, so positions always come
|
|
214
|
+
// straight from the root document; only resolved rules can land on inlined
|
|
215
|
+
// external nodes and need the source set.
|
|
216
|
+
runPlan(ruleset, rawRules, document.data, document, documentFormats, diagnostics, undefined);
|
|
217
|
+
if (resolvedRules.length > 0) {
|
|
218
|
+
runPlan(ruleset, resolvedRules, options.resolved, document, documentFormats, diagnostics, options.sources);
|
|
219
|
+
}
|
|
220
|
+
return applyScopedOverrides(ruleset, document.source, diagnostics).sort(byPosition);
|
|
221
|
+
},
|
|
222
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { DiagnosticSeverity, ILocation, IRange, JsonPath } from '../parsers/index.js';
|
|
2
|
+
import type { Document } from './document.js';
|
|
3
|
+
export type { ILocation, IPosition, IRange, JsonPath } from '../parsers/index.js';
|
|
4
|
+
export { DiagnosticSeverity } from '../parsers/index.js';
|
|
5
|
+
/** The string severities a ruleset author can write, plus `off` to disable a rule. */
|
|
6
|
+
export type HumanReadableSeverity = 'error' | 'warn' | 'info' | 'hint' | 'off';
|
|
7
|
+
/** A single finding produced by a function. */
|
|
8
|
+
export type IFunctionResult = {
|
|
9
|
+
message: string;
|
|
10
|
+
path?: JsonPath;
|
|
11
|
+
};
|
|
12
|
+
/** Context passed to every function invocation. */
|
|
13
|
+
export type IFunctionContext = {
|
|
14
|
+
document: Document;
|
|
15
|
+
/** The path of the value the function was invoked on. */
|
|
16
|
+
path: JsonPath;
|
|
17
|
+
/** The original `given` value the rule matched. */
|
|
18
|
+
value?: unknown;
|
|
19
|
+
/** Rule metadata. */
|
|
20
|
+
rule: ResolvedRule;
|
|
21
|
+
/** Function options as authored in the ruleset. */
|
|
22
|
+
functionOptions?: unknown;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A rule function: given the matched `input`, its authored `options`, and the
|
|
26
|
+
* run `context`, returns any findings (or `undefined`/`[]` for none).
|
|
27
|
+
*/
|
|
28
|
+
export type RulesetFunction<I = unknown, O = unknown> = (input: I, options: O, context: IFunctionContext) => IFunctionResult[] | undefined;
|
|
29
|
+
/** Functions a ruleset can invoke by name in `then.function`, keyed by that name. */
|
|
30
|
+
export type FunctionRegistry = Record<string, RulesetFunction>;
|
|
31
|
+
/** One action a rule takes on each match: a function plus how to target/configure it. */
|
|
32
|
+
export type IThen = {
|
|
33
|
+
/**
|
|
34
|
+
* Narrows the target before running the function. `$` = current value,
|
|
35
|
+
* `@key` = each property key, or a property name.
|
|
36
|
+
*/
|
|
37
|
+
field?: string;
|
|
38
|
+
/**
|
|
39
|
+
* A built-in/registered function name (YAML/JSON rulesets) or a direct function
|
|
40
|
+
* reference (JS rulesets that import their own functions).
|
|
41
|
+
*/
|
|
42
|
+
function: string | RulesetFunction;
|
|
43
|
+
functionOptions?: Record<string, unknown>;
|
|
44
|
+
};
|
|
45
|
+
/** A rule as authored in a ruleset, before normalization. */
|
|
46
|
+
export type IRuleDefinition = {
|
|
47
|
+
description?: string;
|
|
48
|
+
message?: string;
|
|
49
|
+
severity?: DiagnosticSeverity | HumanReadableSeverity;
|
|
50
|
+
given: string | string[];
|
|
51
|
+
then: IThen | IThen[];
|
|
52
|
+
formats?: string[];
|
|
53
|
+
recommended?: boolean;
|
|
54
|
+
/** When false, the rule runs against the unresolved document. Default true. */
|
|
55
|
+
resolved?: boolean;
|
|
56
|
+
documentationUrl?: string;
|
|
57
|
+
};
|
|
58
|
+
/** Shorthand a rule may take in `rules`: a definition, a boolean, or a severity. */
|
|
59
|
+
export type RuleEntry = IRuleDefinition | boolean | HumanReadableSeverity;
|
|
60
|
+
export type IRulesetOverride = {
|
|
61
|
+
files: string[];
|
|
62
|
+
rules?: Record<string, RuleEntry>;
|
|
63
|
+
extends?: RulesetExtends;
|
|
64
|
+
formats?: string[];
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* The shape of a ruleset's `extends`: a single target or a list, where each
|
|
68
|
+
* entry may be paired with a modifier (`all` / `recommended` / `off`) to control
|
|
69
|
+
* which of the extended rules turn on.
|
|
70
|
+
*/
|
|
71
|
+
export type RulesetExtends = string | RulesetDefinition | (string | RulesetDefinition | [string | RulesetDefinition, 'all' | 'recommended' | 'off'])[];
|
|
72
|
+
/** A ruleset as authored: rules, `extends`, custom functions, overrides, and aliases. */
|
|
73
|
+
export type RulesetDefinition = {
|
|
74
|
+
extends?: RulesetExtends;
|
|
75
|
+
rules?: Record<string, RuleEntry>;
|
|
76
|
+
/**
|
|
77
|
+
* Names of custom functions a YAML/JSON ruleset references by string in
|
|
78
|
+
* `then.function`. Loaded from `functionsDir` (default `functions`, relative to
|
|
79
|
+
* the ruleset file) by the CLI/rulesets layer. JS rulesets can instead pass a
|
|
80
|
+
* direct function reference and skip this.
|
|
81
|
+
*/
|
|
82
|
+
functions?: string[];
|
|
83
|
+
/** Directory (relative to the ruleset file) that `functions` are loaded from. Defaults to `functions`. */
|
|
84
|
+
functionsDir?: string;
|
|
85
|
+
overrides?: IRulesetOverride[];
|
|
86
|
+
aliases?: Record<string, string[] | {
|
|
87
|
+
description?: string;
|
|
88
|
+
targets: {
|
|
89
|
+
formats: string[];
|
|
90
|
+
given: string[];
|
|
91
|
+
}[];
|
|
92
|
+
}>;
|
|
93
|
+
formats?: string[];
|
|
94
|
+
documentationUrl?: string;
|
|
95
|
+
parserOptions?: {
|
|
96
|
+
duplicateKeys?: DiagnosticSeverity | HumanReadableSeverity;
|
|
97
|
+
incompatibleValues?: DiagnosticSeverity | HumanReadableSeverity;
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
/** A fully normalized rule ready to execute. */
|
|
101
|
+
export type ResolvedRule = {
|
|
102
|
+
name: string;
|
|
103
|
+
description?: string | undefined;
|
|
104
|
+
message?: string | undefined;
|
|
105
|
+
severity: DiagnosticSeverity;
|
|
106
|
+
enabled: boolean;
|
|
107
|
+
given: string[];
|
|
108
|
+
then: IThen[];
|
|
109
|
+
formats?: Set<string> | undefined;
|
|
110
|
+
recommended: boolean;
|
|
111
|
+
resolved: boolean;
|
|
112
|
+
documentationUrl?: string | undefined;
|
|
113
|
+
};
|
|
114
|
+
/** A single finding emitted by the runner, mapped back to a source range. */
|
|
115
|
+
export type IDiagnostic = {
|
|
116
|
+
code: string | number;
|
|
117
|
+
message: string;
|
|
118
|
+
path: JsonPath;
|
|
119
|
+
severity: DiagnosticSeverity;
|
|
120
|
+
source?: string;
|
|
121
|
+
range: IRange;
|
|
122
|
+
};
|
|
123
|
+
/** A single parsed source document with its own line:column source map. */
|
|
124
|
+
export type ISourceDocument = {
|
|
125
|
+
readonly data: unknown;
|
|
126
|
+
/** Display path used as a finding's `source` (e.g. a path relative to cwd, or a URL). */
|
|
127
|
+
readonly source?: string | undefined;
|
|
128
|
+
getLocationForJsonPath(path: JsonPath, closest?: boolean): ILocation | undefined;
|
|
129
|
+
};
|
|
130
|
+
/** Where a resolved-tree node originated: which source document, and the path within it. */
|
|
131
|
+
export type ISourceOrigin = {
|
|
132
|
+
/** Absolute location (file path or URL) of the originating document. */
|
|
133
|
+
location: string;
|
|
134
|
+
/** The path within that document. */
|
|
135
|
+
path: JsonPath;
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Source documents keyed by absolute location, with a known root. This is the
|
|
139
|
+
* input to the cross-document walk that re-derives a node's origin from the
|
|
140
|
+
* *unresolved* documents (see `resolveSourceOrigin`).
|
|
141
|
+
*/
|
|
142
|
+
export type IDocumentRegistry = {
|
|
143
|
+
/** Absolute location of the root document (the walk's starting point). */
|
|
144
|
+
readonly rootLocation: string;
|
|
145
|
+
get(location: string): ISourceDocument | undefined;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Per-node origin metadata produced by the resolver (`@amritk/resolve-refs`'
|
|
149
|
+
* `trackOrigins`): given an inlined object/array, where it came from. Lets us
|
|
150
|
+
* find a node's origin with a single downward walk of the resolved tree instead
|
|
151
|
+
* of re-deriving the resolver's `$ref` traversal.
|
|
152
|
+
*/
|
|
153
|
+
export type IOriginMap = {
|
|
154
|
+
get(node: object): {
|
|
155
|
+
location: string;
|
|
156
|
+
pointer: JsonPath;
|
|
157
|
+
} | undefined;
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* The runner-facing view of the source documents behind a resolved tree. Lets
|
|
161
|
+
* findings on nodes inlined from external files map back to the correct file's
|
|
162
|
+
* line:column. `get` resolves a location to its document; `origin` maps a
|
|
163
|
+
* resolved-tree path to the document and in-file path it came from (backed by
|
|
164
|
+
* either the resolver's origin map or the cross-document walk). A `Document`
|
|
165
|
+
* satisfies `ISourceDocument`.
|
|
166
|
+
*/
|
|
167
|
+
export type ISourceSet = {
|
|
168
|
+
get(location: string): ISourceDocument | undefined;
|
|
169
|
+
origin(path: JsonPath): ISourceOrigin;
|
|
170
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DiagnosticSeverity } from '../parsers/index.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { JsonPath } from './types.js';
|
|
2
|
+
/** A problem found in a ruleset definition (structural, not a document finding). */
|
|
3
|
+
export type IRulesetProblem = {
|
|
4
|
+
message: string;
|
|
5
|
+
/** Path into the ruleset object, joined with `.` for display. */
|
|
6
|
+
path: JsonPath;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Validates the *shape* of a ruleset definition, returning a list of problems
|
|
10
|
+
* (empty when valid). This is a lightweight structural check — it does not load
|
|
11
|
+
* `extends` targets or verify that referenced functions exist — so a malformed
|
|
12
|
+
* ruleset surfaces actionable diagnostics instead of failing obscurely at runtime.
|
|
13
|
+
*/
|
|
14
|
+
export declare const validateRuleset: (definition: unknown) => IRulesetProblem[];
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const SEVERITIES = new Set(['error', 'warn', 'info', 'hint', 'off']);
|
|
2
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
|
+
const isValidSeverity = (value) => {
|
|
4
|
+
if (typeof value === 'number')
|
|
5
|
+
return Number.isInteger(value) && value >= 0 && value <= 3;
|
|
6
|
+
return typeof value === 'string' && SEVERITIES.has(value);
|
|
7
|
+
};
|
|
8
|
+
const validateThen = (then, path, problems) => {
|
|
9
|
+
const entries = Array.isArray(then) ? then : [then];
|
|
10
|
+
entries.forEach((entry, index) => {
|
|
11
|
+
const at = Array.isArray(then) ? [...path, index] : path;
|
|
12
|
+
if (!isObject(entry)) {
|
|
13
|
+
problems.push({ message: '`then` must be an object (or array of objects)', path: at });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const fn = entry['function'];
|
|
17
|
+
if (typeof fn !== 'string' && typeof fn !== 'function') {
|
|
18
|
+
problems.push({ message: '`then.function` must be a function name or reference', path: [...at, 'function'] });
|
|
19
|
+
}
|
|
20
|
+
if (entry['field'] !== undefined && typeof entry['field'] !== 'string') {
|
|
21
|
+
problems.push({ message: '`then.field` must be a string', path: [...at, 'field'] });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
const validateRule = (name, entry, path, problems) => {
|
|
26
|
+
// Shorthand: boolean toggle or a severity string.
|
|
27
|
+
if (typeof entry === 'boolean')
|
|
28
|
+
return;
|
|
29
|
+
if (typeof entry === 'string') {
|
|
30
|
+
if (!isValidSeverity(entry)) {
|
|
31
|
+
problems.push({ message: `Rule "${name}" has invalid severity "${entry}"`, path });
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!isObject(entry)) {
|
|
36
|
+
problems.push({ message: `Rule "${name}" must be an object, boolean, or severity string`, path });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (entry['given'] === undefined) {
|
|
40
|
+
problems.push({ message: `Rule "${name}" is missing \`given\``, path: [...path, 'given'] });
|
|
41
|
+
}
|
|
42
|
+
else if (typeof entry['given'] !== 'string' && !Array.isArray(entry['given'])) {
|
|
43
|
+
problems.push({ message: `Rule "${name}" \`given\` must be a string or array`, path: [...path, 'given'] });
|
|
44
|
+
}
|
|
45
|
+
if (entry['then'] === undefined) {
|
|
46
|
+
problems.push({ message: `Rule "${name}" is missing \`then\``, path: [...path, 'then'] });
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
validateThen(entry['then'], [...path, 'then'], problems);
|
|
50
|
+
}
|
|
51
|
+
if (entry['severity'] !== undefined && !isValidSeverity(entry['severity'])) {
|
|
52
|
+
problems.push({ message: `Rule "${name}" has invalid severity`, path: [...path, 'severity'] });
|
|
53
|
+
}
|
|
54
|
+
if (entry['formats'] !== undefined && !Array.isArray(entry['formats'])) {
|
|
55
|
+
problems.push({ message: `Rule "${name}" \`formats\` must be an array`, path: [...path, 'formats'] });
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Validates the *shape* of a ruleset definition, returning a list of problems
|
|
60
|
+
* (empty when valid). This is a lightweight structural check — it does not load
|
|
61
|
+
* `extends` targets or verify that referenced functions exist — so a malformed
|
|
62
|
+
* ruleset surfaces actionable diagnostics instead of failing obscurely at runtime.
|
|
63
|
+
*/
|
|
64
|
+
export const validateRuleset = (definition) => {
|
|
65
|
+
const problems = [];
|
|
66
|
+
if (!isObject(definition)) {
|
|
67
|
+
return [{ message: 'Ruleset must be an object', path: [] }];
|
|
68
|
+
}
|
|
69
|
+
if (definition['rules'] !== undefined) {
|
|
70
|
+
if (!isObject(definition['rules'])) {
|
|
71
|
+
problems.push({ message: '`rules` must be an object', path: ['rules'] });
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
for (const [name, entry] of Object.entries(definition['rules'])) {
|
|
75
|
+
validateRule(name, entry, ['rules', name], problems);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const ext = definition['extends'];
|
|
80
|
+
if (ext !== undefined && typeof ext !== 'string' && !Array.isArray(ext) && !isObject(ext)) {
|
|
81
|
+
problems.push({ message: '`extends` must be a string, array, or object', path: ['extends'] });
|
|
82
|
+
}
|
|
83
|
+
if (definition['overrides'] !== undefined && !Array.isArray(definition['overrides'])) {
|
|
84
|
+
problems.push({ message: '`overrides` must be an array', path: ['overrides'] });
|
|
85
|
+
}
|
|
86
|
+
else if (Array.isArray(definition['overrides'])) {
|
|
87
|
+
definition['overrides'].forEach((override, index) => {
|
|
88
|
+
if (!isObject(override) || !Array.isArray(override['files'])) {
|
|
89
|
+
problems.push({ message: 'Each override must have a `files` array', path: ['overrides', index] });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (definition['functions'] !== undefined && !Array.isArray(definition['functions'])) {
|
|
94
|
+
problems.push({ message: '`functions` must be an array of names', path: ['functions'] });
|
|
95
|
+
}
|
|
96
|
+
if (definition['formats'] !== undefined && !Array.isArray(definition['formats'])) {
|
|
97
|
+
problems.push({ message: '`formats` must be an array', path: ['formats'] });
|
|
98
|
+
}
|
|
99
|
+
// A ruleset with neither rules nor extends does nothing — flag it.
|
|
100
|
+
if (definition.rules === undefined &&
|
|
101
|
+
definition.extends === undefined) {
|
|
102
|
+
problems.push({ message: 'Ruleset has no `rules` and no `extends` (it will produce no findings)', path: [] });
|
|
103
|
+
}
|
|
104
|
+
return problems;
|
|
105
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { IDiagnostic } from '../core/index.js';
|
|
2
|
+
import { type ParserFormat } from '../parsers/index.js';
|
|
3
|
+
import type { FixerRegistry, FixResult } from './types.js';
|
|
4
|
+
/** Options for {@link applyFixes}. */
|
|
5
|
+
export type ApplyFixesOptions = {
|
|
6
|
+
/** When true (the default), fixers marked `safe: false` are skipped. */
|
|
7
|
+
safeOnly?: boolean;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Computes the structural edits for every fixable finding in `diagnostics`, then
|
|
11
|
+
* applies them to `input` in one pass. Edits from different findings that come
|
|
12
|
+
* out identical (e.g. several "not alphabetical" findings on one array all asking
|
|
13
|
+
* for the same reorder) are de-duplicated so the edit is applied once.
|
|
14
|
+
*
|
|
15
|
+
* `data` is the *unresolved* parsed document: fixers read the real node at a
|
|
16
|
+
* finding's path to derive the edit, and edits whose path no longer resolves are
|
|
17
|
+
* dropped — so a finding on an inlined `$ref` node simply isn't fixed rather than
|
|
18
|
+
* corrupting the source. A finding is only reported in `applied` when one of its
|
|
19
|
+
* edits actually changed the text, so dropped edits are not mistaken for fixes.
|
|
20
|
+
*/
|
|
21
|
+
export declare const applyFixes: (input: string, format: ParserFormat, data: unknown, diagnostics: IDiagnostic[], fixers: FixerRegistry, options?: ApplyFixesOptions) => FixResult;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { applyEditOpsWithChanges } from '../parsers/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Computes the structural edits for every fixable finding in `diagnostics`, then
|
|
4
|
+
* applies them to `input` in one pass. Edits from different findings that come
|
|
5
|
+
* out identical (e.g. several "not alphabetical" findings on one array all asking
|
|
6
|
+
* for the same reorder) are de-duplicated so the edit is applied once.
|
|
7
|
+
*
|
|
8
|
+
* `data` is the *unresolved* parsed document: fixers read the real node at a
|
|
9
|
+
* finding's path to derive the edit, and edits whose path no longer resolves are
|
|
10
|
+
* dropped — so a finding on an inlined `$ref` node simply isn't fixed rather than
|
|
11
|
+
* corrupting the source. A finding is only reported in `applied` when one of its
|
|
12
|
+
* edits actually changed the text, so dropped edits are not mistaken for fixes.
|
|
13
|
+
*/
|
|
14
|
+
export const applyFixes = (input, format, data, diagnostics, fixers, options = {}) => {
|
|
15
|
+
const safeOnly = options.safeOnly !== false;
|
|
16
|
+
const ops = [];
|
|
17
|
+
const indexByKey = new Map();
|
|
18
|
+
// Each candidate finding remembers the edits it contributed (by key) so we can
|
|
19
|
+
// tell afterwards whether any of them actually landed.
|
|
20
|
+
const candidates = [];
|
|
21
|
+
for (const diagnostic of diagnostics) {
|
|
22
|
+
const fixer = fixers[String(diagnostic.code)];
|
|
23
|
+
if (!fixer)
|
|
24
|
+
continue;
|
|
25
|
+
if (safeOnly && fixer.safe === false)
|
|
26
|
+
continue;
|
|
27
|
+
const produced = fixer.fix({ diagnostic, data, format });
|
|
28
|
+
if (!produced)
|
|
29
|
+
continue;
|
|
30
|
+
const keys = [];
|
|
31
|
+
for (const op of Array.isArray(produced) ? produced : [produced]) {
|
|
32
|
+
const key = JSON.stringify(op);
|
|
33
|
+
// De-duplicate identical edits (e.g. several findings on one array all asking
|
|
34
|
+
// for the same reorder) so the edit is applied — and counted — once.
|
|
35
|
+
if (!indexByKey.has(key)) {
|
|
36
|
+
indexByKey.set(key, ops.length);
|
|
37
|
+
ops.push(op);
|
|
38
|
+
}
|
|
39
|
+
keys.push(key);
|
|
40
|
+
}
|
|
41
|
+
if (keys.length > 0)
|
|
42
|
+
candidates.push({ fix: { code: diagnostic.code, path: diagnostic.path }, keys });
|
|
43
|
+
}
|
|
44
|
+
if (ops.length === 0)
|
|
45
|
+
return { output: input, applied: [], changed: false };
|
|
46
|
+
const { output, changed } = applyEditOpsWithChanges(input, format, ops);
|
|
47
|
+
const applied = candidates
|
|
48
|
+
.filter((candidate) => candidate.keys.some((key) => changed[indexByKey.get(key)]))
|
|
49
|
+
.map((candidate) => candidate.fix);
|
|
50
|
+
return { output, applied, changed: output !== input };
|
|
51
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LintPlugin } from '../core/index.js';
|
|
2
|
+
import { type ApplyFixesOptions } from './apply.js';
|
|
3
|
+
import type { AppliedFix, FixerRegistry } from './types.js';
|
|
4
|
+
/** The name the fix plugin registers under (its `data` is surfaced here). */
|
|
5
|
+
export declare const FIX_PLUGIN_NAME = "fix";
|
|
6
|
+
/** Structured output the fix plugin returns under {@link FIX_PLUGIN_NAME}. */
|
|
7
|
+
export type FixPluginData = {
|
|
8
|
+
applied: AppliedFix[];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Builds the auto-fix {@link LintPlugin} from a {@link FixerRegistry}. As a
|
|
12
|
+
* post-lint plugin it reads the run's findings and the raw document text, applies
|
|
13
|
+
* the fixers' edits, and returns the rewritten text as `output` plus the list of
|
|
14
|
+
* repaired findings as `data`. The core engine stays unaware of fixing — remove
|
|
15
|
+
* this plugin (and the `../fix` dependency) and Linter lints exactly as
|
|
16
|
+
* before.
|
|
17
|
+
*/
|
|
18
|
+
export declare const createFixPlugin: (fixers: FixerRegistry, options?: ApplyFixesOptions) => LintPlugin;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { applyFixes } from './apply.js';
|
|
2
|
+
/** The name the fix plugin registers under (its `data` is surfaced here). */
|
|
3
|
+
export const FIX_PLUGIN_NAME = 'fix';
|
|
4
|
+
/**
|
|
5
|
+
* Builds the auto-fix {@link LintPlugin} from a {@link FixerRegistry}. As a
|
|
6
|
+
* post-lint plugin it reads the run's findings and the raw document text, applies
|
|
7
|
+
* the fixers' edits, and returns the rewritten text as `output` plus the list of
|
|
8
|
+
* repaired findings as `data`. The core engine stays unaware of fixing — remove
|
|
9
|
+
* this plugin (and the `../fix` dependency) and Linter lints exactly as
|
|
10
|
+
* before.
|
|
11
|
+
*/
|
|
12
|
+
export const createFixPlugin = (fixers, options = {}) => ({
|
|
13
|
+
name: FIX_PLUGIN_NAME,
|
|
14
|
+
afterLint: (diagnostics, context) => {
|
|
15
|
+
const result = applyFixes(context.input, context.format, context.document.data, diagnostics, fixers, options);
|
|
16
|
+
if (!result.changed)
|
|
17
|
+
return undefined;
|
|
18
|
+
const data = { applied: result.applied };
|
|
19
|
+
return { output: result.output, data };
|
|
20
|
+
},
|
|
21
|
+
});
|