@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/parsers/yaml.js
CHANGED
|
@@ -1,54 +1,202 @@
|
|
|
1
|
-
import { isMap, isPair, isScalar, isSeq,
|
|
1
|
+
import { isAlias, isMap, isPair, isScalar, isSeq, parseAllDocuments, } from '@amritk/yaml';
|
|
2
2
|
import { createLineMap } from './lines.js';
|
|
3
3
|
import { DiagnosticSeverity, } from './types.js';
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Encodes a path into a lookup key. Each segment is tagged by kind (`.` for a
|
|
6
|
+
* key, `[]` for an index) so distinct paths cannot collide: a plain `join` turns
|
|
7
|
+
* a `null` map key into `''` (colliding with the root path `[]`) and cannot tell
|
|
8
|
+
* the numeric index `0` from the string key `"0"`. The tags keep them apart.
|
|
9
|
+
*/
|
|
10
|
+
const pathKey = (path) => path.map((segment) => (typeof segment === 'number' ? `[${segment}]` : `.${segment}`)).join('');
|
|
11
|
+
/**
|
|
12
|
+
* Canonically serializes a complex (map/seq) mapping key into a stable, distinct
|
|
13
|
+
* segment. `toJS`'s `keyText` collapses every complex key to `''`, so two
|
|
14
|
+
* distinct complex keys (and their whole value subtrees) would share one index
|
|
15
|
+
* slot and clobber each other. A structural serialization keeps them apart:
|
|
16
|
+
* `[a,b]` for a sequence key, `{k:v}` for a mapping key, recursively. Strings are
|
|
17
|
+
* quoted so a scalar member can't be confused with structure, and aliases render
|
|
18
|
+
* as `*name` (never expanded, so this stays bounded regardless of the anchor).
|
|
19
|
+
*/
|
|
20
|
+
const serializeComplexKey = (node) => {
|
|
21
|
+
if (isSeq(node))
|
|
22
|
+
return `[${node.items.map(serializeComplexKey).join(',')}]`;
|
|
23
|
+
if (isMap(node)) {
|
|
24
|
+
return `{${node.items
|
|
25
|
+
.filter(isPair)
|
|
26
|
+
.map((pair) => `${serializeComplexKey(pair.key)}:${pair.value ? serializeComplexKey(pair.value) : 'null'}`)
|
|
27
|
+
.join(',')}}`;
|
|
28
|
+
}
|
|
29
|
+
if (isAlias(node))
|
|
30
|
+
return `*${node.source}`;
|
|
31
|
+
const v = node.value;
|
|
32
|
+
return typeof v === 'string' ? JSON.stringify(v) : v === null ? 'null' : String(v);
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Stringifies a mapping key into an index segment. Scalar, null, and alias keys
|
|
36
|
+
* match `toJS`'s `keyText` (`null`, the `String()` form, `*name`) so scalar-keyed
|
|
37
|
+
* paths line up with the projected data. Complex (map/seq) keys — which `toJS`
|
|
38
|
+
* cannot address individually — get a canonical structural serialization instead
|
|
39
|
+
* of collapsing to `''`, so distinct complex keys occupy distinct index slots.
|
|
40
|
+
*/
|
|
41
|
+
const keyToString = (key) => {
|
|
42
|
+
if (isScalar(key)) {
|
|
43
|
+
const v = key.value;
|
|
44
|
+
return typeof v === 'string' ? v : v === null ? 'null' : String(v);
|
|
45
|
+
}
|
|
46
|
+
if (isAlias(key))
|
|
47
|
+
return `*${key.source}`;
|
|
48
|
+
return serializeComplexKey(key);
|
|
49
|
+
};
|
|
5
50
|
/**
|
|
6
51
|
* Parses YAML (a JSON superset, so this handles both) into data plus a source
|
|
7
52
|
* map, surfacing duplicate-key and incompatible-value diagnostics per `options`.
|
|
53
|
+
*
|
|
54
|
+
* A `---`-separated stream is parsed as multiple documents (via
|
|
55
|
+
* `parseAllDocuments`), each linted independently: `data` becomes an array of
|
|
56
|
+
* per-document values and every position key / finding path is prefixed with the
|
|
57
|
+
* zero-based document index, so a violation in a later document resolves to its
|
|
58
|
+
* own range instead of being silently dropped. A single-document source keeps the
|
|
59
|
+
* flat shape — `data` is the document value and paths are unprefixed — so existing
|
|
60
|
+
* callers and rulesets are unaffected. Node ranges are absolute offsets into the
|
|
61
|
+
* shared source, so diagnostics and positions in later documents are already
|
|
62
|
+
* correct without any per-document offset arithmetic.
|
|
8
63
|
*/
|
|
9
64
|
export const parseYaml = (source, options = {}) => {
|
|
10
65
|
const lineMap = createLineMap(source);
|
|
11
|
-
const
|
|
12
|
-
const
|
|
66
|
+
const duplicateKeys = options.duplicateKeys;
|
|
67
|
+
const dedupe = duplicateKeys === 'off' || duplicateKeys === false;
|
|
68
|
+
// A configured severity (Warning/Information/Hint) still detects duplicates; we
|
|
69
|
+
// just re-map the reported severity below. Only `off`/`false` turns detection off.
|
|
70
|
+
const dupSeverity = typeof duplicateKeys === 'number' ? duplicateKeys : DiagnosticSeverity.Error;
|
|
71
|
+
// Incompatible-value detection is opt-in: it runs only when a severity is
|
|
72
|
+
// configured. `undefined`/`off`/`false` leaves it disabled.
|
|
73
|
+
const incompatibleValues = options.incompatibleValues;
|
|
74
|
+
const incompatSeverity = typeof incompatibleValues === 'number' ? incompatibleValues : undefined;
|
|
75
|
+
const docs = parseAllDocuments(source, { uniqueKeys: !dedupe });
|
|
13
76
|
const index = new Map();
|
|
77
|
+
const diagnostics = [];
|
|
78
|
+
const pushError = (severity, message, start, end, code) => {
|
|
79
|
+
diagnostics.push({
|
|
80
|
+
...(code !== undefined ? { code } : {}),
|
|
81
|
+
message,
|
|
82
|
+
severity,
|
|
83
|
+
range: { start: lineMap.positionAt(start), end: lineMap.positionAt(end) },
|
|
84
|
+
});
|
|
85
|
+
};
|
|
14
86
|
const rangeOf = (node) => ({
|
|
15
87
|
start: lineMap.positionAt(node.start),
|
|
16
88
|
end: lineMap.positionAt(node.end),
|
|
17
89
|
});
|
|
90
|
+
// Aliases are re-expanded into every path that reaches them, so nested aliases
|
|
91
|
+
// (the "billion laughs" shape) can fan out super-linearly. Bound the total
|
|
92
|
+
// nodes walked across the whole stream; on exhaustion we stop extending the
|
|
93
|
+
// index rather than throw — untouched paths simply fall back to the closest
|
|
94
|
+
// indexed ancestor.
|
|
95
|
+
let budget = Math.max(100_000, source.length * 100);
|
|
96
|
+
/** True when a pair is a `<<` merge key, whose value folds into the parent map. */
|
|
97
|
+
const isMergePair = (pair) => isScalar(pair.key) && pair.key.source === '<<';
|
|
98
|
+
/**
|
|
99
|
+
* Indexes the keys of a merged map (or list of maps, reached through the `<<`
|
|
100
|
+
* value) at the parent `path`. A merged key is skipped when the path is already
|
|
101
|
+
* occupied — by an explicit key or an earlier merge — mirroring `toJS`, where
|
|
102
|
+
* explicit keys and earlier merges win over later ones.
|
|
103
|
+
*/
|
|
104
|
+
const walkMerge = (node, path) => {
|
|
105
|
+
const target = node != null && isAlias(node) ? node.target : node;
|
|
106
|
+
if (target == null)
|
|
107
|
+
return;
|
|
108
|
+
if (isSeq(target)) {
|
|
109
|
+
for (const item of target.items)
|
|
110
|
+
walkMerge(item, path);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!isMap(target))
|
|
114
|
+
return;
|
|
115
|
+
for (const item of target.items) {
|
|
116
|
+
if (!isPair(item))
|
|
117
|
+
continue;
|
|
118
|
+
if (isMergePair(item)) {
|
|
119
|
+
walkMerge(item.value, path);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const childPath = [...path, keyToString(item.key)];
|
|
123
|
+
if (!index.has(pathKey(childPath)))
|
|
124
|
+
walk(item.value, childPath);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
18
127
|
const walk = (node, path) => {
|
|
19
|
-
if (node == null)
|
|
128
|
+
if (node == null || budget-- <= 0)
|
|
20
129
|
return;
|
|
21
130
|
index.set(pathKey(path), rangeOf(node));
|
|
22
|
-
if (
|
|
23
|
-
|
|
131
|
+
if (isScalar(node)) {
|
|
132
|
+
// The core schema projects `.nan`/`.inf`/`-.inf` to non-finite JS numbers,
|
|
133
|
+
// which `JSON.stringify` silently rewrites to `null`. Report them when the
|
|
134
|
+
// caller opted in, so a value that won't survive a JSON round-trip is caught.
|
|
135
|
+
const value = node.value;
|
|
136
|
+
if (incompatSeverity !== undefined && typeof value === 'number' && !Number.isFinite(value)) {
|
|
137
|
+
pushError(incompatSeverity, `Value ${String(value)} cannot be represented in JSON and will serialize to null.`, node.start, node.end, 'INCOMPATIBLE_VALUE');
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Follow an alias to its anchor definition so paths reachable only through the
|
|
142
|
+
// alias resolve to the anchored node (the alias itself keeps the range set
|
|
143
|
+
// above); an unresolved alias has no target and simply stops here.
|
|
144
|
+
const target = isAlias(node) ? node.target : node;
|
|
145
|
+
if (target == null)
|
|
146
|
+
return;
|
|
147
|
+
if (isMap(target)) {
|
|
148
|
+
const merges = [];
|
|
149
|
+
for (const item of target.items) {
|
|
24
150
|
if (!isPair(item))
|
|
25
151
|
continue;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
152
|
+
if (isMergePair(item)) {
|
|
153
|
+
merges.push(item.value);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
walk(item.value, [...path, keyToString(item.key)]);
|
|
29
157
|
}
|
|
158
|
+
// Merged keys fill positions the explicit keys above did not claim.
|
|
159
|
+
for (const merge of merges)
|
|
160
|
+
walkMerge(merge, path);
|
|
30
161
|
}
|
|
31
|
-
else if (isSeq(
|
|
32
|
-
|
|
162
|
+
else if (isSeq(target)) {
|
|
163
|
+
target.items.forEach((item, i) => {
|
|
33
164
|
walk(item, [...path, i]);
|
|
34
165
|
});
|
|
35
166
|
}
|
|
36
167
|
};
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
message,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
168
|
+
const collectProblems = (doc) => {
|
|
169
|
+
for (const err of doc.errors) {
|
|
170
|
+
// Duplicate keys honor the configured severity; every other parser error is
|
|
171
|
+
// a hard error.
|
|
172
|
+
const severity = err.code === 'DUPLICATE_KEY' ? dupSeverity : DiagnosticSeverity.Error;
|
|
173
|
+
pushError(severity, err.message, err.start, err.end);
|
|
174
|
+
}
|
|
175
|
+
for (const warn of doc.warnings) {
|
|
176
|
+
pushError(DiagnosticSeverity.Warning, warn.message, warn.start, warn.end);
|
|
177
|
+
}
|
|
46
178
|
};
|
|
47
|
-
|
|
48
|
-
|
|
179
|
+
let data;
|
|
180
|
+
if (docs.length > 1) {
|
|
181
|
+
// Multi-document stream: index each document under its own `[i, …]` prefix and
|
|
182
|
+
// project to an array of per-document values.
|
|
183
|
+
data = docs.map((doc, i) => {
|
|
184
|
+
walk(doc.contents, [i]);
|
|
185
|
+
collectProblems(doc);
|
|
186
|
+
return doc.toJS();
|
|
187
|
+
});
|
|
49
188
|
}
|
|
50
|
-
|
|
51
|
-
|
|
189
|
+
else {
|
|
190
|
+
// Single document (or an empty stream): keep the flat, unprefixed shape.
|
|
191
|
+
const doc = docs[0];
|
|
192
|
+
if (doc) {
|
|
193
|
+
walk(doc.contents, []);
|
|
194
|
+
collectProblems(doc);
|
|
195
|
+
data = doc.toJS();
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
data = null;
|
|
199
|
+
}
|
|
52
200
|
}
|
|
53
201
|
const getLocationForJsonPath = (path, closest = false) => {
|
|
54
202
|
const p = path.slice();
|
|
@@ -61,5 +209,5 @@ export const parseYaml = (source, options = {}) => {
|
|
|
61
209
|
p.pop();
|
|
62
210
|
}
|
|
63
211
|
};
|
|
64
|
-
return { data, diagnostics, getLocationForJsonPath };
|
|
212
|
+
return { data: data, diagnostics, getLocationForJsonPath };
|
|
65
213
|
};
|
|
@@ -10,6 +10,24 @@ const getAtPath = (data, path) => {
|
|
|
10
10
|
};
|
|
11
11
|
const isObject = (value) => value != null && typeof value === 'object' && !Array.isArray(value);
|
|
12
12
|
const stripTrailingSlash = (value) => value.replace(/\/+$/, '');
|
|
13
|
+
/**
|
|
14
|
+
* A deterministic, order-independent serialization used as an equality key. Plain
|
|
15
|
+
* `JSON.stringify` is sensitive to object key order (`{a,b}` vs `{b,a}`), so two
|
|
16
|
+
* deeply-equal enum entries could be seen as different and left un-deduplicated —
|
|
17
|
+
* disagreeing with the `duplicated-entry-in-enum` rule (which compares by value)
|
|
18
|
+
* and preventing `--fix` from converging. Sorting keys recursively fixes that.
|
|
19
|
+
*/
|
|
20
|
+
const canonicalKey = (value) => {
|
|
21
|
+
if (Array.isArray(value))
|
|
22
|
+
return `[${value.map(canonicalKey).join(',')}]`;
|
|
23
|
+
if (value !== null && typeof value === 'object') {
|
|
24
|
+
const entries = Object.keys(value)
|
|
25
|
+
.sort()
|
|
26
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalKey(value[key])}`);
|
|
27
|
+
return `{${entries.join(',')}}`;
|
|
28
|
+
}
|
|
29
|
+
return JSON.stringify(value) ?? 'null';
|
|
30
|
+
};
|
|
13
31
|
/**
|
|
14
32
|
* `oas2-host-trailing-slash` / `oas3-server-trailing-slash`: drop the trailing
|
|
15
33
|
* slash from a string value (host or server URL).
|
|
@@ -29,13 +47,19 @@ const trailingSlashValue = {
|
|
|
29
47
|
/** `path-keys-no-trailing-slash`: rename a `paths` key to drop its trailing slash. */
|
|
30
48
|
const pathKeyTrailingSlash = {
|
|
31
49
|
safe: true,
|
|
32
|
-
fix: ({ diagnostic }) => {
|
|
50
|
+
fix: ({ diagnostic, data }) => {
|
|
33
51
|
const key = diagnostic.path[diagnostic.path.length - 1];
|
|
34
52
|
if (typeof key !== 'string')
|
|
35
53
|
return undefined;
|
|
36
54
|
const stripped = stripTrailingSlash(key);
|
|
37
55
|
if (stripped === key || stripped === '')
|
|
38
56
|
return undefined;
|
|
57
|
+
// Renaming `/foo/` onto an existing `/foo` would collide and silently drop a
|
|
58
|
+
// path, so skip when the stripped key already exists (same guard as
|
|
59
|
+
// `pathKeyQueryString`).
|
|
60
|
+
const paths = getAtPath(data, diagnostic.path.slice(0, -1));
|
|
61
|
+
if (isObject(paths) && stripped in paths)
|
|
62
|
+
return undefined;
|
|
39
63
|
return { op: 'renameProperty', path: diagnostic.path, newKey: stripped };
|
|
40
64
|
},
|
|
41
65
|
};
|
|
@@ -58,7 +82,7 @@ const duplicatedEnum = {
|
|
|
58
82
|
const seen = new Set();
|
|
59
83
|
const duplicates = [];
|
|
60
84
|
array.forEach((item, index) => {
|
|
61
|
-
const key =
|
|
85
|
+
const key = canonicalKey(item);
|
|
62
86
|
if (seen.has(key))
|
|
63
87
|
duplicates.push(index);
|
|
64
88
|
else
|
|
@@ -69,6 +93,14 @@ const duplicatedEnum = {
|
|
|
69
93
|
return { op: 'removeItems', path: diagnostic.path, indices: duplicates };
|
|
70
94
|
},
|
|
71
95
|
};
|
|
96
|
+
// Mirror the `alphabetical` built-in's comparator exactly so that sorting here
|
|
97
|
+
// produces an order the rule considers sorted — otherwise `--fix` could reorder
|
|
98
|
+
// into a sequence the rule still flags and never converge.
|
|
99
|
+
const compareAlphabetical = (a, b) => {
|
|
100
|
+
if (typeof a === 'number' && typeof b === 'number')
|
|
101
|
+
return a - b;
|
|
102
|
+
return String(a).localeCompare(String(b));
|
|
103
|
+
};
|
|
72
104
|
/** `openapi-tags-alphabetical`: reorder the top-level `tags` array by `name`. */
|
|
73
105
|
const tagsAlphabetical = {
|
|
74
106
|
safe: true,
|
|
@@ -78,8 +110,8 @@ const tagsAlphabetical = {
|
|
|
78
110
|
const array = getAtPath(data, arrayPath);
|
|
79
111
|
if (!Array.isArray(array))
|
|
80
112
|
return undefined;
|
|
81
|
-
const nameOf = (item) => item != null && typeof item === 'object' ?
|
|
82
|
-
const order = array.map((_, index) => index).sort((a, b) => nameOf(array[a])
|
|
113
|
+
const nameOf = (item) => item != null && typeof item === 'object' ? item['name'] : item;
|
|
114
|
+
const order = array.map((_, index) => index).sort((a, b) => compareAlphabetical(nameOf(array[a]), nameOf(array[b])));
|
|
83
115
|
if (order.every((value, index) => value === index))
|
|
84
116
|
return undefined;
|
|
85
117
|
return { op: 'reorderArray', path: arrayPath, order };
|
|
@@ -169,6 +201,32 @@ const noNullable = {
|
|
|
169
201
|
return remove;
|
|
170
202
|
},
|
|
171
203
|
};
|
|
204
|
+
/**
|
|
205
|
+
* `oas3_1-schema-example-deprecated`: migrate a Schema Object's singular
|
|
206
|
+
* `example` to the JSON Schema 2020-12 `examples` array (`example: X` →
|
|
207
|
+
* `examples: [X]`). Safe and mechanical, but skipped when an `examples` array is
|
|
208
|
+
* already present so an existing one is never clobbered. The finding points at
|
|
209
|
+
* the `example` key, whose parent is the schema.
|
|
210
|
+
*/
|
|
211
|
+
const schemaExampleDeprecated = {
|
|
212
|
+
safe: true,
|
|
213
|
+
fix: ({ diagnostic, data }) => {
|
|
214
|
+
const schemaPath = diagnostic.path.slice(0, -1);
|
|
215
|
+
const schema = getAtPath(data, schemaPath);
|
|
216
|
+
if (!isObject(schema) || !('example' in schema))
|
|
217
|
+
return undefined;
|
|
218
|
+
// Do not overwrite an already-present `examples` array.
|
|
219
|
+
if ('examples' in schema)
|
|
220
|
+
return undefined;
|
|
221
|
+
return [
|
|
222
|
+
// `insertProperty` (not `setValue`) because `examples` is a new key: an
|
|
223
|
+
// op whose path doesn't already resolve is a no-op, and only
|
|
224
|
+
// `insertProperty` adds a missing key to the existing schema object.
|
|
225
|
+
{ op: 'insertProperty', path: schemaPath, key: 'examples', value: [schema['example']] },
|
|
226
|
+
{ op: 'removeProperty', path: diagnostic.path },
|
|
227
|
+
];
|
|
228
|
+
},
|
|
229
|
+
};
|
|
172
230
|
/**
|
|
173
231
|
* Auto-fixers for the mechanically-repairable OpenAPI rules, keyed by rule
|
|
174
232
|
* code. Pass these to `@amritk/lint`'s `fixDocument` (as its `fixers`), or wrap
|
|
@@ -186,4 +244,5 @@ export const oasFixers = {
|
|
|
186
244
|
'oas3-unused-component': unusedComponent,
|
|
187
245
|
'oas2-unused-definition': unusedComponent,
|
|
188
246
|
'oas3_1-no-nullable': noNullable,
|
|
247
|
+
'oas3_1-schema-example-deprecated': schemaExampleDeprecated,
|
|
189
248
|
};
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
const isObject = (value) => typeof value === 'object' && value !== null;
|
|
2
2
|
const openapiVersion = (document) => isObject(document) && typeof document['openapi'] === 'string' ? document['openapi'] : undefined;
|
|
3
|
+
// Minor versions are matched with an anchored `3.N` followed by a `.` or the
|
|
4
|
+
// end of string, so a future `3.10.x` is not mistaken for `3.1.x` — a plain
|
|
5
|
+
// `startsWith('3.1')` prefix check would misclassify `3.10.0` as OpenAPI 3.1.
|
|
6
|
+
const matchesMinor = (document, minor) => {
|
|
7
|
+
const version = openapiVersion(document);
|
|
8
|
+
return version !== undefined && new RegExp(`^3\\.${minor}(\\.|$)`).test(version);
|
|
9
|
+
};
|
|
3
10
|
/** Matches OpenAPI/Swagger 2.0 (`swagger: "2.0"`). */
|
|
4
11
|
export const oas2 = (document) => isObject(document) && document['swagger'] === '2.0';
|
|
5
12
|
/** Matches any OpenAPI 3.x (`openapi: 3.*`). */
|
|
6
|
-
export const oas3 = (document) => openapiVersion(document)
|
|
13
|
+
export const oas3 = (document) => /^3\.\d/.test(openapiVersion(document) ?? '');
|
|
7
14
|
/** Matches OpenAPI 3.0.x specifically. */
|
|
8
|
-
export const oas3_0 = (document) =>
|
|
15
|
+
export const oas3_0 = (document) => matchesMinor(document, 0);
|
|
9
16
|
/** Matches OpenAPI 3.1.x specifically. */
|
|
10
|
-
export const oas3_1 = (document) =>
|
|
17
|
+
export const oas3_1 = (document) => matchesMinor(document, 1);
|
|
11
18
|
/** Matches OpenAPI 3.2.x specifically. */
|
|
12
|
-
export const oas3_2 = (document) =>
|
|
19
|
+
export const oas3_2 = (document) => matchesMinor(document, 2);
|
|
13
20
|
/** OpenAPI format detectors keyed by Loupe-compatible names. */
|
|
14
21
|
export const oasFormats = {
|
|
15
22
|
oas2,
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import type { RulesetFunction } from '../../../core/index.js';
|
|
2
|
-
/**
|
|
2
|
+
/** Options selecting the OpenAPI major version an example rule runs against. */
|
|
3
|
+
export type IOasExampleOptions = {
|
|
4
|
+
/** 2 for OpenAPI 2.0 (Swagger), 3 for OpenAPI 3.x. Defaults to 3. */
|
|
5
|
+
oasVersion?: number;
|
|
6
|
+
};
|
|
7
|
+
/** Validates a schema object's inline `example` and `default` against the schema itself. */
|
|
3
8
|
export declare const oasSchemaExample: RulesetFunction;
|
|
4
|
-
/**
|
|
5
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Validates a Media Type / Response / Parameter object's examples against its
|
|
11
|
+
* `schema`. Version-split because OpenAPI 2.0 and 3.x model examples differently:
|
|
12
|
+
* - OAS3: a singular `example` value plus an `examples` map of Example Objects,
|
|
13
|
+
* each of which carries the value under `value`.
|
|
14
|
+
* - OAS2: `examples` is a MIME-type → value map (`{ 'application/json': value }`),
|
|
15
|
+
* with no Example Objects and no singular `example` on the media object — so the
|
|
16
|
+
* 3.x logic validated nothing at all for a real 2.0 document.
|
|
17
|
+
*/
|
|
18
|
+
export declare const oasMediaExample: RulesetFunction<unknown, IOasExampleOptions>;
|
|
@@ -7,59 +7,103 @@ import { isObject } from './helpers.js';
|
|
|
7
7
|
// so it is also CSP/edge-runtime safe. Returns undefined for a non-object or a
|
|
8
8
|
// schema the validator can't build (mirrors the old skip-on-compile-failure
|
|
9
9
|
// behavior).
|
|
10
|
+
//
|
|
11
|
+
// Formats are asserted (`formats: 'all'`) to match Spectral, whose example rules
|
|
12
|
+
// run ajv with `ajv-formats` enabled — otherwise a format-violating example (a
|
|
13
|
+
// bad `email`/`date`/`uuid`) would slip through. This mirrors the core `schema`
|
|
14
|
+
// built-in's option exactly; `@amritk/runtime-validators` treats the OAS-specific
|
|
15
|
+
// numeric/binary formats (`int32`/`int64`/`float`/`byte`/`binary`) as non-failing.
|
|
10
16
|
const buildValidator = (schema) => {
|
|
11
17
|
if (!isObject(schema))
|
|
12
18
|
return undefined;
|
|
19
|
+
let validator;
|
|
13
20
|
try {
|
|
14
|
-
|
|
21
|
+
validator = validate(schema, { formats: 'all' });
|
|
15
22
|
}
|
|
16
23
|
catch {
|
|
17
24
|
return undefined;
|
|
18
25
|
}
|
|
26
|
+
// A schema can carry a `$ref` the runtime validator cannot resolve (an external
|
|
27
|
+
// or cyclic ref left behind after `$ref` inlining), which throws at *run* time
|
|
28
|
+
// rather than build time. Treat that as "cannot validate — skip" (returning a
|
|
29
|
+
// valid result) so an unresolvable example schema never crashes the whole lint.
|
|
30
|
+
return (input) => {
|
|
31
|
+
try {
|
|
32
|
+
return validator(input);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
19
38
|
};
|
|
20
39
|
const buildValidatorOrNull = (schema) => buildValidator(schema) ?? null;
|
|
21
40
|
// Keyed by the Schema Object node; `oasSchemaExample` validates a schema's own
|
|
22
|
-
// `example` against the schema minus its `example`/`examples` keywords.
|
|
41
|
+
// `example`/`default` against the schema minus its `example`/`examples` keywords.
|
|
23
42
|
const schemaExampleResults = new WeakMap();
|
|
24
|
-
// Keyed by the Media Type Object node; `oasMediaExample` validates the
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// examples.
|
|
43
|
+
// Keyed by the Media Type / Response Object node; `oasMediaExample` validates the
|
|
44
|
+
// object's example(s) against its `schema`. The node (not the schema) is the cache
|
|
45
|
+
// key because two media objects can share a `schema` but carry different examples.
|
|
28
46
|
const mediaExampleResults = new WeakMap();
|
|
29
47
|
const withPath = (findings, path) => {
|
|
30
48
|
if (findings.length === 0)
|
|
31
49
|
return [];
|
|
32
50
|
return findings.map((finding) => ({ message: finding.message, path: [...path, ...finding.suffix] }));
|
|
33
51
|
};
|
|
34
|
-
|
|
52
|
+
// A schema node reached via `$..[?(...)]` might be a `properties`/`patternProperties`
|
|
53
|
+
// *map* whose keys happen to look like schema keywords (e.g. a property literally
|
|
54
|
+
// named `type`). Those maps are not Schema Objects, so we never validate them.
|
|
55
|
+
const isPropertiesMap = (path) => {
|
|
56
|
+
const tail = path[path.length - 1];
|
|
57
|
+
return tail === 'properties' || tail === 'patternProperties';
|
|
58
|
+
};
|
|
59
|
+
/** Validates a schema object's inline `example` and `default` against the schema itself. */
|
|
35
60
|
export const oasSchemaExample = (schema, _options, context) => {
|
|
36
|
-
if (!isObject(schema)
|
|
61
|
+
if (!isObject(schema))
|
|
62
|
+
return [];
|
|
63
|
+
if (schema['example'] === undefined && schema['default'] === undefined)
|
|
64
|
+
return [];
|
|
65
|
+
if (isPropertiesMap(context.path))
|
|
37
66
|
return [];
|
|
38
67
|
let findings = schemaExampleResults.get(schema);
|
|
39
68
|
if (findings === undefined) {
|
|
40
69
|
findings = [];
|
|
70
|
+
// `example`/`examples` are annotations, not constraints, so drop them before
|
|
71
|
+
// building the validator; `default` stays (it is not a validation keyword).
|
|
41
72
|
const { example, examples, ...rest } = schema;
|
|
42
73
|
void example;
|
|
43
74
|
void examples;
|
|
44
75
|
const check = buildValidatorOrNull(rest);
|
|
45
76
|
if (check) {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
77
|
+
for (const field of ['example', 'default']) {
|
|
78
|
+
if (schema[field] === undefined)
|
|
79
|
+
continue;
|
|
80
|
+
const result = check(schema[field]);
|
|
81
|
+
if (result !== true) {
|
|
82
|
+
for (const error of result.errors)
|
|
83
|
+
findings.push({ message: `"${field}" ${error.message}`.trim(), suffix: [field] });
|
|
84
|
+
}
|
|
50
85
|
}
|
|
51
86
|
}
|
|
52
87
|
schemaExampleResults.set(schema, findings);
|
|
53
88
|
}
|
|
54
89
|
return withPath(findings, context.path);
|
|
55
90
|
};
|
|
56
|
-
/**
|
|
57
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Validates a Media Type / Response / Parameter object's examples against its
|
|
93
|
+
* `schema`. Version-split because OpenAPI 2.0 and 3.x model examples differently:
|
|
94
|
+
* - OAS3: a singular `example` value plus an `examples` map of Example Objects,
|
|
95
|
+
* each of which carries the value under `value`.
|
|
96
|
+
* - OAS2: `examples` is a MIME-type → value map (`{ 'application/json': value }`),
|
|
97
|
+
* with no Example Objects and no singular `example` on the media object — so the
|
|
98
|
+
* 3.x logic validated nothing at all for a real 2.0 document.
|
|
99
|
+
*/
|
|
100
|
+
export const oasMediaExample = (media, options, context) => {
|
|
58
101
|
if (!isObject(media) || !isObject(media['schema']))
|
|
59
102
|
return [];
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
|
|
103
|
+
const oasVersion = options?.oasVersion ?? 3;
|
|
104
|
+
// Skip building a validator when there is nothing to check — most media objects
|
|
105
|
+
// in a large spec have a schema but no example. (OAS2 only has the map form.)
|
|
106
|
+
const hasExample = (oasVersion !== 2 && media['example'] !== undefined) || isObject(media['examples']);
|
|
63
107
|
if (!hasExample)
|
|
64
108
|
return [];
|
|
65
109
|
let findings = mediaExampleResults.get(media);
|
|
@@ -67,31 +111,50 @@ export const oasMediaExample = (media, _options, context) => {
|
|
|
67
111
|
findings = [];
|
|
68
112
|
const check = buildValidatorOrNull(media['schema']);
|
|
69
113
|
if (check) {
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
114
|
+
if (oasVersion === 2)
|
|
115
|
+
collectOas2(media, check, findings);
|
|
116
|
+
else
|
|
117
|
+
collectOas3(media, check, findings);
|
|
118
|
+
}
|
|
119
|
+
mediaExampleResults.set(media, findings);
|
|
120
|
+
}
|
|
121
|
+
return withPath(findings, context.path);
|
|
122
|
+
};
|
|
123
|
+
/** OAS2: validate each `examples[mimeType]` value against the sibling `schema`. */
|
|
124
|
+
const collectOas2 = (media, check, findings) => {
|
|
125
|
+
const examples = isObject(media['examples']) ? media['examples'] : undefined;
|
|
126
|
+
if (!examples)
|
|
127
|
+
return;
|
|
128
|
+
for (const [mimeType, value] of Object.entries(examples)) {
|
|
129
|
+
const result = check(value);
|
|
130
|
+
if (result !== true) {
|
|
131
|
+
for (const error of result.errors) {
|
|
132
|
+
findings.push({ message: `Example "${mimeType}" ${error.message}`.trim(), suffix: ['examples', mimeType] });
|
|
76
133
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
/** OAS3: validate the singular `example` and each `examples[name].value` against the `schema`. */
|
|
138
|
+
const collectOas3 = (media, check, findings) => {
|
|
139
|
+
if (media['example'] !== undefined) {
|
|
140
|
+
const result = check(media['example']);
|
|
141
|
+
if (result !== true) {
|
|
142
|
+
for (const error of result.errors)
|
|
143
|
+
findings.push({ message: `"example" ${error.message}`.trim(), suffix: ['example'] });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const examples = isObject(media['examples']) ? media['examples'] : undefined;
|
|
147
|
+
if (!examples)
|
|
148
|
+
return;
|
|
149
|
+
for (const [name, example] of Object.entries(examples)) {
|
|
150
|
+
// An external-value example is fetched elsewhere, so there is nothing inline to check.
|
|
151
|
+
if (isObject(example) && example['value'] !== undefined) {
|
|
152
|
+
const result = check(example['value']);
|
|
153
|
+
if (result !== true) {
|
|
154
|
+
for (const error of result.errors) {
|
|
155
|
+
findings.push({ message: `Example "${name}" ${error.message}`.trim(), suffix: ['examples', name, 'value'] });
|
|
91
156
|
}
|
|
92
157
|
}
|
|
93
158
|
}
|
|
94
|
-
mediaExampleResults.set(media, findings);
|
|
95
159
|
}
|
|
96
|
-
return withPath(findings, context.path);
|
|
97
160
|
};
|
|
@@ -3,3 +3,8 @@ export const isObject = (value) => typeof value === 'object' && value !== null &
|
|
|
3
3
|
// The eight standard HTTP methods that have a dedicated fixed field on the Path
|
|
4
4
|
// Item Object. Shared by the rules that iterate a path item's operations.
|
|
5
5
|
export const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
|
|
6
|
+
// OpenAPI 3.2 promoted `query` to a fixed Path Item Object operation field, so the
|
|
7
|
+
// rules that walk a path item's operations must consider it too. Including it is
|
|
8
|
+
// harmless on older versions, which never carry a `query` operation, so the same
|
|
9
|
+
// set works across every OpenAPI version.
|
|
10
|
+
export const OPERATION_METHODS = new Set([...HTTP_METHODS, 'query']);
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { FunctionRegistry } from '../../../core/index.js';
|
|
2
|
-
export { oasMediaExample, oasSchemaExample } from './example-validation.js';
|
|
2
|
+
export { type IOasExampleOptions, oasMediaExample, oasSchemaExample } from './example-validation.js';
|
|
3
3
|
export { oasAdditionalOperations } from './oas-additional-operations.js';
|
|
4
4
|
export { oasDiscriminator } from './oas-discriminator.js';
|
|
5
|
+
export { oasExampleExternalValue } from './oas-example-external-value.js';
|
|
5
6
|
export { oasExampleValue } from './oas-example-value.js';
|
|
6
7
|
export { oasMutuallyExclusive } from './oas-mutually-exclusive.js';
|
|
8
|
+
export { oasNoNullable } from './oas-no-nullable.js';
|
|
7
9
|
export { oasOpFormDataConsumeCheck } from './oas-op-form-data-consume-check.js';
|
|
8
10
|
export { oasOpIdUnique } from './oas-op-id-unique.js';
|
|
9
11
|
export { oasOpParams } from './oas-op-params.js';
|