@amritk/lint 0.1.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.
Files changed (108) hide show
  1. package/README.md +52 -1
  2. package/dist/core/glob.d.ts +1 -1
  3. package/dist/core/glob.js +89 -5
  4. package/dist/core/index.d.ts +1 -1
  5. package/dist/core/index.js +1 -1
  6. package/dist/core/jsonpath.d.ts +17 -1
  7. package/dist/core/jsonpath.js +218 -23
  8. package/dist/core/lint.d.ts +15 -8
  9. package/dist/core/lint.js +12 -3
  10. package/dist/core/plugin.d.ts +6 -0
  11. package/dist/core/plugin.js +6 -0
  12. package/dist/core/pointers.js +15 -15
  13. package/dist/core/ruleset.js +0 -0
  14. package/dist/core/runner.d.ts +6 -1
  15. package/dist/core/runner.js +127 -43
  16. package/dist/core/types.d.ts +17 -2
  17. package/dist/core/validate-ruleset.js +16 -0
  18. package/dist/fix/apply.d.ts +8 -2
  19. package/dist/fix/apply.js +68 -18
  20. package/dist/functions/alphabetical.js +40 -13
  21. package/dist/functions/casing.js +27 -5
  22. package/dist/functions/enumeration.d.ts +5 -3
  23. package/dist/functions/enumeration.js +18 -1
  24. package/dist/functions/index.d.ts +1 -0
  25. package/dist/functions/index.js +3 -0
  26. package/dist/functions/length.d.ts +13 -3
  27. package/dist/functions/length.js +11 -3
  28. package/dist/functions/or.d.ts +11 -0
  29. package/dist/functions/or.js +25 -0
  30. package/dist/functions/pattern.d.ts +5 -3
  31. package/dist/functions/pattern.js +42 -9
  32. package/dist/functions/schema.d.ts +13 -0
  33. package/dist/functions/schema.js +95 -2
  34. package/dist/functions/typed-enum.js +7 -1
  35. package/dist/functions/unreferenced-reusable-object.d.ts +7 -1
  36. package/dist/functions/unreferenced-reusable-object.js +18 -3
  37. package/dist/functions/xor.js +8 -1
  38. package/dist/index.js +9 -1
  39. package/dist/parsers/edit-model.d.ts +15 -0
  40. package/dist/parsers/edit-model.js +210 -41
  41. package/dist/parsers/types.d.ts +14 -2
  42. package/dist/parsers/yaml.d.ts +10 -0
  43. package/dist/parsers/yaml.js +174 -26
  44. package/dist/rules/openapi/fixers.d.ts +7 -0
  45. package/dist/rules/openapi/fixers.js +248 -0
  46. package/dist/rules/openapi/formats.d.ts +13 -0
  47. package/dist/rules/openapi/formats.js +30 -0
  48. package/dist/rules/openapi/functions/example-validation.d.ts +18 -0
  49. package/dist/rules/openapi/functions/example-validation.js +160 -0
  50. package/dist/rules/openapi/functions/helpers.d.ts +4 -0
  51. package/dist/rules/openapi/functions/helpers.js +10 -0
  52. package/dist/rules/openapi/functions/index.d.ts +26 -0
  53. package/dist/rules/openapi/functions/index.js +73 -0
  54. package/dist/rules/openapi/functions/oas-additional-operations.d.ts +8 -0
  55. package/dist/rules/openapi/functions/oas-additional-operations.js +25 -0
  56. package/dist/rules/openapi/functions/oas-discriminator.d.ts +3 -0
  57. package/dist/rules/openapi/functions/oas-discriminator.js +23 -0
  58. package/dist/rules/openapi/functions/oas-example-external-value.d.ts +11 -0
  59. package/dist/rules/openapi/functions/oas-example-external-value.js +23 -0
  60. package/dist/rules/openapi/functions/oas-example-value.d.ts +3 -0
  61. package/dist/rules/openapi/functions/oas-example-value.js +31 -0
  62. package/dist/rules/openapi/functions/oas-mutually-exclusive.d.ts +10 -0
  63. package/dist/rules/openapi/functions/oas-mutually-exclusive.js +20 -0
  64. package/dist/rules/openapi/functions/oas-no-nullable.d.ts +13 -0
  65. package/dist/rules/openapi/functions/oas-no-nullable.js +22 -0
  66. package/dist/rules/openapi/functions/oas-op-form-data-consume-check.d.ts +3 -0
  67. package/dist/rules/openapi/functions/oas-op-form-data-consume-check.js +21 -0
  68. package/dist/rules/openapi/functions/oas-op-id-unique.d.ts +3 -0
  69. package/dist/rules/openapi/functions/oas-op-id-unique.js +29 -0
  70. package/dist/rules/openapi/functions/oas-op-params.d.ts +9 -0
  71. package/dist/rules/openapi/functions/oas-op-params.js +46 -0
  72. package/dist/rules/openapi/functions/oas-op-security-defined.d.ts +5 -0
  73. package/dist/rules/openapi/functions/oas-op-security-defined.js +44 -0
  74. package/dist/rules/openapi/functions/oas-op-success-response.d.ts +3 -0
  75. package/dist/rules/openapi/functions/oas-op-success-response.js +16 -0
  76. package/dist/rules/openapi/functions/oas-path-param.d.ts +12 -0
  77. package/dist/rules/openapi/functions/oas-path-param.js +106 -0
  78. package/dist/rules/openapi/functions/oas-schema-example-deprecated.d.ts +10 -0
  79. package/dist/rules/openapi/functions/oas-schema-example-deprecated.js +42 -0
  80. package/dist/rules/openapi/functions/oas-schema.d.ts +15 -0
  81. package/dist/rules/openapi/functions/oas-schema.js +15 -0
  82. package/dist/rules/openapi/functions/oas-server-name-unique.d.ts +3 -0
  83. package/dist/rules/openapi/functions/oas-server-name-unique.js +20 -0
  84. package/dist/rules/openapi/functions/oas-server-variables.d.ts +8 -0
  85. package/dist/rules/openapi/functions/oas-server-variables.js +52 -0
  86. package/dist/rules/openapi/functions/oas-tag-defined.d.ts +3 -0
  87. package/dist/rules/openapi/functions/oas-tag-defined.js +21 -0
  88. package/dist/rules/openapi/functions/oas-tag-kind.d.ts +3 -0
  89. package/dist/rules/openapi/functions/oas-tag-kind.js +17 -0
  90. package/dist/rules/openapi/functions/oas-tag-parent-defined.d.ts +7 -0
  91. package/dist/rules/openapi/functions/oas-tag-parent-defined.js +45 -0
  92. package/dist/rules/openapi/functions/oas-tags-unique.d.ts +3 -0
  93. package/dist/rules/openapi/functions/oas-tags-unique.js +17 -0
  94. package/dist/rules/openapi/functions/oas-unused-component.d.ts +7 -0
  95. package/dist/rules/openapi/functions/oas-unused-component.js +65 -0
  96. package/dist/rules/openapi/functions/ref-siblings.d.ts +3 -0
  97. package/dist/rules/openapi/functions/ref-siblings.js +13 -0
  98. package/dist/rules/openapi/index.d.ts +25 -0
  99. package/dist/rules/openapi/index.js +127 -0
  100. package/dist/rules/openapi/oas.d.ts +3 -0
  101. package/dist/rules/openapi/oas.js +549 -0
  102. package/dist/rules/openapi/schemas/index.d.ts +8 -0
  103. package/dist/rules/openapi/schemas/index.js +36 -0
  104. package/dist/rules/openapi/schemas/oas20.json +1592 -0
  105. package/dist/rules/openapi/schemas/oas30.json +1651 -0
  106. package/dist/rules/openapi/schemas/oas31.json +1412 -0
  107. package/dist/rules/openapi/schemas/oas32.json +1684 -0
  108. package/package.json +17 -5
@@ -1,54 +1,202 @@
1
- import { isMap, isPair, isScalar, isSeq, parseDocument } from '@amritk/yaml';
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
- const pathKey = (path) => path.join('\0');
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 dedupe = options.duplicateKeys === 'off' || options.duplicateKeys === false;
12
- const doc = parseDocument(source, { uniqueKeys: !dedupe });
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 (isMap(node)) {
23
- for (const item of node.items) {
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
- const key = item.key;
27
- const keyName = isScalar(key) ? key.value : String(key);
28
- walk(item.value, [...path, keyName]);
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(node)) {
32
- node.items.forEach((item, i) => {
162
+ else if (isSeq(target)) {
163
+ target.items.forEach((item, i) => {
33
164
  walk(item, [...path, i]);
34
165
  });
35
166
  }
36
167
  };
37
- walk(doc.contents, []);
38
- const data = doc.toJS();
39
- const diagnostics = [];
40
- const pushError = (severity, message, start, end) => {
41
- diagnostics.push({
42
- message,
43
- severity,
44
- range: { start: lineMap.positionAt(start), end: lineMap.positionAt(end) },
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
- for (const err of doc.errors) {
48
- pushError(DiagnosticSeverity.Error, err.message, err.start, err.end);
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
- for (const warn of doc.warnings) {
51
- pushError(DiagnosticSeverity.Warning, warn.message, warn.start, warn.end);
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
  };
@@ -0,0 +1,7 @@
1
+ import type { FixerRegistry } from '../../fix/index.js';
2
+ /**
3
+ * Auto-fixers for the mechanically-repairable OpenAPI rules, keyed by rule
4
+ * code. Pass these to `@amritk/lint`'s `fixDocument` (as its `fixers`), or wrap
5
+ * them with `createFixPlugin` for a lower-level plugin.
6
+ */
7
+ export declare const oasFixers: FixerRegistry;
@@ -0,0 +1,248 @@
1
+ /** Reads the value at `path` in the parsed document, or `undefined` if absent. */
2
+ const getAtPath = (data, path) => {
3
+ let current = data;
4
+ for (const segment of path) {
5
+ if (current == null || typeof current !== 'object')
6
+ return undefined;
7
+ current = current[segment];
8
+ }
9
+ return current;
10
+ };
11
+ const isObject = (value) => value != null && typeof value === 'object' && !Array.isArray(value);
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
+ };
31
+ /**
32
+ * `oas2-host-trailing-slash` / `oas3-server-trailing-slash`: drop the trailing
33
+ * slash from a string value (host or server URL).
34
+ */
35
+ const trailingSlashValue = {
36
+ safe: true,
37
+ fix: ({ diagnostic, data }) => {
38
+ const value = getAtPath(data, diagnostic.path);
39
+ if (typeof value !== 'string')
40
+ return undefined;
41
+ const stripped = stripTrailingSlash(value);
42
+ if (stripped === value || stripped === '')
43
+ return undefined;
44
+ return { op: 'setValue', path: diagnostic.path, value: stripped };
45
+ },
46
+ };
47
+ /** `path-keys-no-trailing-slash`: rename a `paths` key to drop its trailing slash. */
48
+ const pathKeyTrailingSlash = {
49
+ safe: true,
50
+ fix: ({ diagnostic, data }) => {
51
+ const key = diagnostic.path[diagnostic.path.length - 1];
52
+ if (typeof key !== 'string')
53
+ return undefined;
54
+ const stripped = stripTrailingSlash(key);
55
+ if (stripped === key || stripped === '')
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;
63
+ return { op: 'renameProperty', path: diagnostic.path, newKey: stripped };
64
+ },
65
+ };
66
+ /** `no-$ref-siblings`: delete the sibling key that sits next to a `$ref`. */
67
+ const refSibling = {
68
+ safe: true,
69
+ fix: ({ diagnostic }) => {
70
+ if (diagnostic.path.length === 0)
71
+ return undefined;
72
+ return { op: 'removeProperty', path: diagnostic.path };
73
+ },
74
+ };
75
+ /** `duplicated-entry-in-enum`: remove the later copies of each repeated enum value. */
76
+ const duplicatedEnum = {
77
+ safe: true,
78
+ fix: ({ diagnostic, data }) => {
79
+ const array = getAtPath(data, diagnostic.path);
80
+ if (!Array.isArray(array))
81
+ return undefined;
82
+ const seen = new Set();
83
+ const duplicates = [];
84
+ array.forEach((item, index) => {
85
+ const key = canonicalKey(item);
86
+ if (seen.has(key))
87
+ duplicates.push(index);
88
+ else
89
+ seen.add(key);
90
+ });
91
+ if (duplicates.length === 0)
92
+ return undefined;
93
+ return { op: 'removeItems', path: diagnostic.path, indices: duplicates };
94
+ },
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
+ };
104
+ /** `openapi-tags-alphabetical`: reorder the top-level `tags` array by `name`. */
105
+ const tagsAlphabetical = {
106
+ safe: true,
107
+ fix: ({ diagnostic, data }) => {
108
+ // Findings point at the out-of-order item; the array is its parent.
109
+ const arrayPath = diagnostic.path.slice(0, -1);
110
+ const array = getAtPath(data, arrayPath);
111
+ if (!Array.isArray(array))
112
+ return undefined;
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])));
115
+ if (order.every((value, index) => value === index))
116
+ return undefined;
117
+ return { op: 'reorderArray', path: arrayPath, order };
118
+ },
119
+ };
120
+ /** `path-not-include-query`: drop the `?query` portion from a `paths` key. */
121
+ const pathKeyQueryString = {
122
+ safe: true,
123
+ fix: ({ diagnostic, data }) => {
124
+ const key = diagnostic.path[diagnostic.path.length - 1];
125
+ if (typeof key !== 'string')
126
+ return undefined;
127
+ const stripped = key.replace(/\?.*$/, '');
128
+ if (stripped === key || stripped === '')
129
+ return undefined;
130
+ // Renaming onto an existing path would collide and silently drop a path, so skip.
131
+ const paths = getAtPath(data, diagnostic.path.slice(0, -1));
132
+ if (isObject(paths) && stripped in paths)
133
+ return undefined;
134
+ return { op: 'renameProperty', path: diagnostic.path, newKey: stripped };
135
+ },
136
+ };
137
+ /** `openapi-tags-uniqueness`: remove the later copies of each repeated tag name. */
138
+ const tagsUnique = {
139
+ safe: true,
140
+ fix: ({ diagnostic, data }) => {
141
+ // Findings point at `tags[index].name`, so the array sits two segments up.
142
+ const arrayPath = diagnostic.path.slice(0, -2);
143
+ const array = getAtPath(data, arrayPath);
144
+ if (!Array.isArray(array))
145
+ return undefined;
146
+ const seen = new Set();
147
+ const duplicates = [];
148
+ array.forEach((tag, index) => {
149
+ const name = isObject(tag) ? tag['name'] : undefined;
150
+ if (typeof name !== 'string')
151
+ return;
152
+ if (seen.has(name))
153
+ duplicates.push(index);
154
+ else
155
+ seen.add(name);
156
+ });
157
+ if (duplicates.length === 0)
158
+ return undefined;
159
+ return { op: 'removeItems', path: arrayPath, indices: duplicates };
160
+ },
161
+ };
162
+ /**
163
+ * `oas3-unused-component` / `oas2-unused-definition`: delete the unreferenced
164
+ * component. Marked unsafe because a component can be referenced from another
165
+ * document or resolved dynamically, so removing it may not be semantics-preserving
166
+ * — it only applies under `--fix-unsafe`.
167
+ */
168
+ const unusedComponent = {
169
+ safe: false,
170
+ fix: ({ diagnostic }) => {
171
+ if (diagnostic.path.length === 0)
172
+ return undefined;
173
+ return { op: 'removeProperty', path: diagnostic.path };
174
+ },
175
+ };
176
+ /**
177
+ * `oas3_1-no-nullable`: migrate the 3.0-era `nullable` keyword to its OpenAPI
178
+ * 3.1 (JSON Schema 2020-12) equivalent. `nullable: false` is the schema default,
179
+ * so it is simply dropped; `nullable: true` folds a `"null"` member into the
180
+ * sibling `type` before the keyword is removed. Marked unsafe because it rewrites
181
+ * the schema's `type`, so it only applies under `--fix-unsafe`.
182
+ */
183
+ const noNullable = {
184
+ safe: false,
185
+ fix: ({ diagnostic, data }) => {
186
+ const value = getAtPath(data, diagnostic.path);
187
+ const remove = { op: 'removeProperty', path: diagnostic.path };
188
+ // `nullable: false` (or any non-true value) is the default — drop the keyword.
189
+ if (value !== true)
190
+ return remove;
191
+ const typePath = [...diagnostic.path.slice(0, -1), 'type'];
192
+ const type = getAtPath(data, typePath);
193
+ if (typeof type === 'string' && type !== 'null') {
194
+ return [{ op: 'setValue', path: typePath, value: [type, 'null'] }, remove];
195
+ }
196
+ if (Array.isArray(type) && !type.includes('null')) {
197
+ return [{ op: 'insertItem', path: typePath, value: 'null' }, remove];
198
+ }
199
+ // No type to widen (the schema already permits any value, null included) or
200
+ // `null` is already allowed — removing the keyword is enough.
201
+ return remove;
202
+ },
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
+ };
230
+ /**
231
+ * Auto-fixers for the mechanically-repairable OpenAPI rules, keyed by rule
232
+ * code. Pass these to `@amritk/lint`'s `fixDocument` (as its `fixers`), or wrap
233
+ * them with `createFixPlugin` for a lower-level plugin.
234
+ */
235
+ export const oasFixers = {
236
+ 'oas2-host-trailing-slash': trailingSlashValue,
237
+ 'oas3-server-trailing-slash': trailingSlashValue,
238
+ 'path-keys-no-trailing-slash': pathKeyTrailingSlash,
239
+ 'path-not-include-query': pathKeyQueryString,
240
+ 'no-$ref-siblings': refSibling,
241
+ 'duplicated-entry-in-enum': duplicatedEnum,
242
+ 'openapi-tags-alphabetical': tagsAlphabetical,
243
+ 'openapi-tags-uniqueness': tagsUnique,
244
+ 'oas3-unused-component': unusedComponent,
245
+ 'oas2-unused-definition': unusedComponent,
246
+ 'oas3_1-no-nullable': noNullable,
247
+ 'oas3_1-schema-example-deprecated': schemaExampleDeprecated,
248
+ };
@@ -0,0 +1,13 @@
1
+ import type { Format } from '../../core/index.js';
2
+ /** Matches OpenAPI/Swagger 2.0 (`swagger: "2.0"`). */
3
+ export declare const oas2: Format;
4
+ /** Matches any OpenAPI 3.x (`openapi: 3.*`). */
5
+ export declare const oas3: Format;
6
+ /** Matches OpenAPI 3.0.x specifically. */
7
+ export declare const oas3_0: Format;
8
+ /** Matches OpenAPI 3.1.x specifically. */
9
+ export declare const oas3_1: Format;
10
+ /** Matches OpenAPI 3.2.x specifically. */
11
+ export declare const oas3_2: Format;
12
+ /** OpenAPI format detectors keyed by Loupe-compatible names. */
13
+ export declare const oasFormats: Record<string, Format>;
@@ -0,0 +1,30 @@
1
+ const isObject = (value) => typeof value === 'object' && value !== null;
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
+ };
10
+ /** Matches OpenAPI/Swagger 2.0 (`swagger: "2.0"`). */
11
+ export const oas2 = (document) => isObject(document) && document['swagger'] === '2.0';
12
+ /** Matches any OpenAPI 3.x (`openapi: 3.*`). */
13
+ export const oas3 = (document) => /^3\.\d/.test(openapiVersion(document) ?? '');
14
+ /** Matches OpenAPI 3.0.x specifically. */
15
+ export const oas3_0 = (document) => matchesMinor(document, 0);
16
+ /** Matches OpenAPI 3.1.x specifically. */
17
+ export const oas3_1 = (document) => matchesMinor(document, 1);
18
+ /** Matches OpenAPI 3.2.x specifically. */
19
+ export const oas3_2 = (document) => matchesMinor(document, 2);
20
+ /** OpenAPI format detectors keyed by Loupe-compatible names. */
21
+ export const oasFormats = {
22
+ oas2,
23
+ oas3,
24
+ 'oas3.0': oas3_0,
25
+ oas3_0,
26
+ 'oas3.1': oas3_1,
27
+ oas3_1,
28
+ 'oas3.2': oas3_2,
29
+ oas3_2,
30
+ };
@@ -0,0 +1,18 @@
1
+ import type { RulesetFunction } from '../../../core/index.js';
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. */
8
+ export declare const oasSchemaExample: RulesetFunction;
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>;