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