@amritk/lint 0.2.0 → 0.3.1

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 (68) hide show
  1. package/README.md +18 -0
  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.js +63 -4
  45. package/dist/rules/openapi/formats.js +11 -4
  46. package/dist/rules/openapi/functions/example-validation.d.ts +16 -3
  47. package/dist/rules/openapi/functions/example-validation.js +102 -39
  48. package/dist/rules/openapi/functions/helpers.d.ts +1 -0
  49. package/dist/rules/openapi/functions/helpers.js +5 -0
  50. package/dist/rules/openapi/functions/index.d.ts +3 -1
  51. package/dist/rules/openapi/functions/index.js +7 -1
  52. package/dist/rules/openapi/functions/oas-additional-operations.js +5 -5
  53. package/dist/rules/openapi/functions/oas-example-external-value.d.ts +11 -0
  54. package/dist/rules/openapi/functions/oas-example-external-value.js +23 -0
  55. package/dist/rules/openapi/functions/oas-no-nullable.d.ts +13 -0
  56. package/dist/rules/openapi/functions/oas-no-nullable.js +22 -0
  57. package/dist/rules/openapi/functions/oas-op-id-unique.js +4 -2
  58. package/dist/rules/openapi/functions/oas-op-params.d.ts +7 -1
  59. package/dist/rules/openapi/functions/oas-op-params.js +35 -10
  60. package/dist/rules/openapi/functions/oas-op-security-defined.js +2 -2
  61. package/dist/rules/openapi/functions/oas-op-success-response.js +6 -1
  62. package/dist/rules/openapi/functions/oas-path-param.d.ts +10 -1
  63. package/dist/rules/openapi/functions/oas-path-param.js +87 -26
  64. package/dist/rules/openapi/functions/oas-server-variables.d.ts +6 -1
  65. package/dist/rules/openapi/functions/oas-server-variables.js +31 -2
  66. package/dist/rules/openapi/functions/oas-unused-component.js +14 -1
  67. package/dist/rules/openapi/oas.js +82 -25
  68. package/package.json +12 -4
@@ -1,11 +1,16 @@
1
- import { isMap, isScalar, isSeq, parseDocument } from '@amritk/yaml';
2
- import { applyEdits, findNodeAtLocation, getNodeValue, modify, parseTree } from 'jsonc-parser';
1
+ import { isAlias, isMap, isScalar, isSeq, parseDocument } from '@amritk/yaml';
2
+ import { applyEdits, findNodeAtLocation, modify, parseTree } from 'jsonc-parser';
3
3
  const splice = (text, start, end, replacement) => text.slice(0, start) + replacement + text.slice(end);
4
- /** Expands `[start, end)` to cover whole lines: back to the line start, forward past the trailing newline. */
5
- const expandLine = (text, start, end) => {
6
- let s = start;
4
+ /** The offset of the start of the line containing `offset`. */
5
+ const lineStart = (text, offset) => {
6
+ let s = offset;
7
7
  while (s > 0 && text.charCodeAt(s - 1) !== 10)
8
8
  s--;
9
+ return s;
10
+ };
11
+ /** Expands `[start, end)` to cover whole lines: back to the line start, forward past the trailing newline. */
12
+ const expandLine = (text, start, end) => {
13
+ const s = lineStart(text, start);
9
14
  let e = end;
10
15
  while (e < text.length && text.charCodeAt(e) !== 10)
11
16
  e++;
@@ -13,8 +18,32 @@ const expandLine = (text, start, end) => {
13
18
  e++;
14
19
  return [s, e];
15
20
  };
21
+ /**
22
+ * The document's dominant line ending. We look at the first break so inserted
23
+ * lines match the file rather than always emitting a bare `\n` (which would leave
24
+ * a CRLF file with mixed endings).
25
+ */
26
+ const detectEol = (text) => {
27
+ const i = text.indexOf('\n');
28
+ return i > 0 && text.charCodeAt(i - 1) === 13 ? '\r\n' : '\n';
29
+ };
16
30
  // --- YAML ------------------------------------------------------------------
17
- const keyName = (key) => (isScalar(key) ? String(key.value) : String(key));
31
+ /**
32
+ * Stringifies a key the way `toJS` does, so the paths we address by match the
33
+ * keys the projected data exposes: a null key is `null`, a bool/number key is its
34
+ * `String()` form, an alias key is `*name`, and a complex (map/seq) key is empty.
35
+ */
36
+ const keyName = (key) => {
37
+ if (isScalar(key)) {
38
+ const v = key.value;
39
+ return typeof v === 'string' ? v : v === null ? 'null' : String(v);
40
+ }
41
+ if (isAlias(key))
42
+ return `*${key.source}`;
43
+ if (isMap(key) || isSeq(key))
44
+ return '';
45
+ return String(key);
46
+ };
18
47
  /** Navigates the YAML CST to the node at `path`, or `undefined` if absent. */
19
48
  const yamlNodeAt = (root, path) => {
20
49
  let current = root ?? undefined;
@@ -23,7 +52,10 @@ const yamlNodeAt = (root, path) => {
23
52
  return undefined;
24
53
  if (isMap(current)) {
25
54
  const target = String(segment);
26
- const pair = current.items.find((item) => keyName(item.key) === target);
55
+ // Last-wins: duplicate keys resolve to the final occurrence, matching how
56
+ // `toJS` and the position index treat them, so an edit is not a silent no-op
57
+ // that lands on a shadowed earlier copy.
58
+ const pair = current.items.findLast((item) => keyName(item.key) === target);
27
59
  current = pair?.value ?? undefined;
28
60
  }
29
61
  else if (isSeq(current)) {
@@ -41,11 +73,25 @@ const yamlPairAt = (root, path) => {
41
73
  if (!parent || !isMap(parent))
42
74
  return {};
43
75
  const last = String(path[path.length - 1]);
44
- const pair = parent.items.find((item) => keyName(item.key) === last);
76
+ const pair = parent.items.findLast((item) => keyName(item.key) === last);
45
77
  if (!pair)
46
78
  return {};
47
79
  return { key: pair.key, ...(pair.value ? { value: pair.value } : {}) };
48
80
  };
81
+ /**
82
+ * Checks that writing `plain` bare into YAML round-trips back to the string
83
+ * `value`. A bare scalar can silently change meaning — `true` becomes a boolean,
84
+ * `1.0` a number, an empty string a null — and text carrying `: ` or ` #` or a
85
+ * newline reshapes the line, so we parse the candidate on its own and require it
86
+ * to come back as exactly the same string before trusting it unquoted.
87
+ */
88
+ const plainStringRoundTrips = (plain, value) => {
89
+ const doc = parseDocument(plain);
90
+ return doc.errors.length === 0 && isScalar(doc.contents) && doc.contents.value === value;
91
+ };
92
+ // Keys made only of these characters are safe to write bare in YAML; anything
93
+ // else (spaces, colons, flow indicators) gets double-quoted to stay valid.
94
+ const SAFE_YAML_KEY = /^[\w./-]+$/;
49
95
  /** Serializes a scalar, preserving the quoting style of the value it replaces. */
50
96
  const yamlScalar = (value, original) => {
51
97
  if (typeof value !== 'string')
@@ -55,7 +101,10 @@ const yamlScalar = (value, original) => {
55
101
  return JSON.stringify(value);
56
102
  if (quote === 39 /* ' */)
57
103
  return `'${value.replace(/'/g, "''")}'`;
58
- return value;
104
+ // Plain (unquoted) original: keep it bare only when the bare text still means
105
+ // this exact string; otherwise fall back to a double-quoted literal so we never
106
+ // turn a string into a bool/number/null or corrupt the line.
107
+ return plainStringRoundTrips(value, value) ? value : JSON.stringify(value);
59
108
  };
60
109
  const yamlKey = (key, original) => {
61
110
  const quote = original.charCodeAt(0);
@@ -63,11 +112,10 @@ const yamlKey = (key, original) => {
63
112
  return JSON.stringify(key);
64
113
  if (quote === 39)
65
114
  return `'${key.replace(/'/g, "''")}'`;
66
- return key;
115
+ // A plain key that would resolve to a non-string (e.g. `true`, `1.0`) or is not
116
+ // otherwise safe bare gets quoted, matching the value-side treatment.
117
+ return SAFE_YAML_KEY.test(key) && plainStringRoundTrips(key, key) ? key : JSON.stringify(key);
67
118
  };
68
- // Keys made only of these characters are safe to write bare in YAML; anything
69
- // else (spaces, colons, flow indicators) gets double-quoted to stay valid.
70
- const SAFE_YAML_KEY = /^[\w./-]+$/;
71
119
  /** Serializes a key for an inserted property, quoting it only when it needs to be. */
72
120
  const yamlInsertKey = (key) => (SAFE_YAML_KEY.test(key) ? key : JSON.stringify(key));
73
121
  /**
@@ -77,23 +125,54 @@ const yamlInsertKey = (key) => (SAFE_YAML_KEY.test(key) ? key : JSON.stringify(k
77
125
  * one line without us having to re-implement a block serializer.
78
126
  */
79
127
  const yamlInsertValue = (value) => JSON.stringify(value) ?? 'null';
128
+ /** Serializes a fresh scalar (no original to mirror), quoting strings only when bare would change meaning. */
129
+ const yamlFreshScalar = (value) => {
130
+ if (value !== null && typeof value === 'object')
131
+ return yamlInsertValue(value);
132
+ if (typeof value === 'string')
133
+ return plainStringRoundTrips(value, value) ? value : JSON.stringify(value);
134
+ return value === null ? 'null' : String(value);
135
+ };
80
136
  /** Returns the leading whitespace of the line containing `offset` (its indentation). */
81
137
  const lineIndent = (text, offset) => {
82
- let start = offset;
83
- while (start > 0 && text.charCodeAt(start - 1) !== 10)
84
- start--;
138
+ const start = lineStart(text, offset);
85
139
  let end = start;
86
140
  while (end < text.length && (text.charCodeAt(end) === 32 || text.charCodeAt(end) === 9))
87
141
  end++;
88
142
  return text.slice(start, end);
89
143
  };
144
+ /**
145
+ * The indentation a new block-sequence item should carry, measured from the
146
+ * column of the first item's own `- ` dash. Using the dash column (not the line's
147
+ * leading whitespace) keeps a nested sequence correct: for `- - 1` the inner
148
+ * item's dash sits at column 2, so a new inner item indents to match it rather
149
+ * than appending to the outer sequence.
150
+ */
151
+ const seqItemIndent = (text, firstItem) => {
152
+ const start = lineStart(text, firstItem.start);
153
+ let dash = firstItem.start - 1;
154
+ while (dash > start && text.charCodeAt(dash) !== 45 /* - */)
155
+ dash--;
156
+ return text.charCodeAt(dash) === 45 ? ' '.repeat(dash - start) : lineIndent(text, firstItem.start);
157
+ };
90
158
  const applyYamlOp = (text, op) => {
91
159
  const root = parseDocument(text).contents;
160
+ const eol = detectEol(text);
92
161
  switch (op.op) {
93
162
  case 'setValue': {
94
163
  const node = yamlNodeAt(root, op.path);
95
- if (!node)
164
+ if (!node) {
165
+ // The target may be an explicit-empty key (`key:` with a null value). The
166
+ // value node does not exist yet, so splice a scalar in right after the
167
+ // colon rather than silently doing nothing.
168
+ const { key, value } = yamlPairAt(root, op.path);
169
+ if (key && value === undefined) {
170
+ const colon = text.indexOf(':', key.end);
171
+ if (colon !== -1)
172
+ return splice(text, colon + 1, colon + 1, ` ${yamlFreshScalar(op.value)}`);
173
+ }
96
174
  return text;
175
+ }
97
176
  // Scalars keep the node's original quoting; objects/arrays are written as
98
177
  // inline flow JSON (valid YAML) so a scalar can be widened to a collection.
99
178
  const replacement = op.value !== null && typeof op.value === 'object'
@@ -112,7 +191,7 @@ const applyYamlOp = (text, op) => {
112
191
  if (!parent || !isMap(parent))
113
192
  return text;
114
193
  const last = String(op.path[op.path.length - 1]);
115
- const index = parent.items.findIndex((item) => keyName(item.key) === last);
194
+ const index = parent.items.findLastIndex((item) => keyName(item.key) === last);
116
195
  if (index === -1)
117
196
  return text;
118
197
  const pair = parent.items[index];
@@ -133,6 +212,17 @@ const applyYamlOp = (text, op) => {
133
212
  const prevEnd = (prev?.value ?? prev?.key).end;
134
213
  return splice(text, prevEnd, (value ?? key).end, '');
135
214
  }
215
+ // Compact sequence-entry map (`- a: 1\n b: 2`): the first pair shares its
216
+ // line with the parent seq's `- ` dash. Dropping the whole line would swallow
217
+ // the dash and merge this item into its sibling, so splice only the pair's own
218
+ // span. A lone key falls through to whole-line removal (the item disappears).
219
+ const dashPrefix = text.slice(lineStart(text, key.start), key.start);
220
+ if (/^\s*-\s+$/.test(dashPrefix) && parent.items.length > 1) {
221
+ const next = parent.items[index + 1]?.key;
222
+ if (next)
223
+ return splice(text, key.start, next.start, '');
224
+ return splice(text, key.start, (value ?? key).end, '');
225
+ }
136
226
  // Block map: drop the whole line(s) the property occupies.
137
227
  const [start, end] = expandLine(text, key.start, value ? value.end : key.end);
138
228
  return splice(text, start, end, '');
@@ -153,7 +243,21 @@ const applyYamlOp = (text, op) => {
153
243
  }
154
244
  case 'insertProperty': {
155
245
  const parent = yamlNodeAt(root, op.path);
156
- if (!parent || !isMap(parent))
246
+ if (!parent) {
247
+ // The target may be an explicit-empty key (`parent:` with a null value).
248
+ // Turn it into a one-key block map on the next line rather than no-op.
249
+ const { key, value } = yamlPairAt(root, op.path);
250
+ if (key && value === undefined) {
251
+ const colon = text.indexOf(':', key.end);
252
+ if (colon !== -1) {
253
+ const indent = ' '.repeat(key.start - lineStart(text, key.start) + 2);
254
+ const pair = `${yamlInsertKey(op.key)}: ${yamlInsertValue(op.value)}`;
255
+ return splice(text, colon + 1, colon + 1, `${eol}${indent}${pair}`);
256
+ }
257
+ }
258
+ return text;
259
+ }
260
+ if (!isMap(parent))
157
261
  return text;
158
262
  // Inserting is additive only; if the key is already there we leave it alone.
159
263
  if (parent.items.some((item) => keyName(item.key) === op.key))
@@ -174,10 +278,15 @@ const applyYamlOp = (text, op) => {
174
278
  const lastKey = last?.key;
175
279
  if (!lastKey)
176
280
  return text;
177
- const indent = lineIndent(text, lastKey.start);
281
+ // Indent from the key's *column*, not the line's leading whitespace: in a
282
+ // compact seq-item map the last key sits after a `- ` dash, so the line
283
+ // indent (2) would place the new key outside the item — use the column (4).
284
+ const keyLineStart = lineStart(text, lastKey.start);
285
+ const prefix = text.slice(keyLineStart, lastKey.start);
286
+ const indent = /^\s*$/.test(prefix) ? prefix : ' '.repeat(lastKey.start - keyLineStart);
178
287
  const [, end] = expandLine(text, lastKey.start, (last?.value ?? lastKey).end);
179
- const lead = end > 0 && text.charCodeAt(end - 1) !== 10 ? '\n' : '';
180
- return splice(text, end, end, `${lead}${indent}${pair}\n`);
288
+ const lead = end > 0 && text.charCodeAt(end - 1) !== 10 ? eol : '';
289
+ return splice(text, end, end, `${lead}${indent}${pair}${eol}`);
181
290
  }
182
291
  case 'insertItem': {
183
292
  const seq = yamlNodeAt(root, op.path);
@@ -192,22 +301,41 @@ const applyYamlOp = (text, op) => {
192
301
  // Block sequence: we need an existing `- item` line to mirror its indentation and style.
193
302
  if (seq.items.length === 0)
194
303
  return text;
195
- const blocks = seq.items.map((item) => expandLine(text, item.start, item.end));
304
+ const blocks = seqBlocks(text, seq.items);
196
305
  const regionStart = blocks[0][0];
197
306
  const regionEnd = blocks[blocks.length - 1][1];
198
307
  const lines = seq.items.map((_, index) => text.slice(...blocks[index]));
199
- lines.splice(clampIndex(op.index, lines.length), 0, `${lineIndent(text, regionStart)}- ${value}\n`);
308
+ const newLine = `${seqItemIndent(text, seq.items[0])}- ${value}${eol}`;
309
+ lines.splice(clampIndex(op.index, lines.length), 0, newLine);
200
310
  return splice(text, regionStart, regionEnd, lines.join(''));
201
311
  }
202
312
  }
203
313
  };
204
314
  /** Clamps an optional insertion index into `[0, length]`, defaulting to an append. */
205
315
  const clampIndex = (index, length) => Math.max(0, Math.min(index ?? length, length));
316
+ /**
317
+ * Whole-line spans for each block-sequence item, with any comment-only or blank
318
+ * lines that precede an item folded into that item's block. Attaching leading
319
+ * comments to the following item means a reorder carries them along and a removal
320
+ * takes only the comments that belong to the dropped item — honoring the module's
321
+ * promise to preserve comments rather than dropping every line between items.
322
+ */
323
+ const seqBlocks = (text, items) => {
324
+ const blocks = [];
325
+ items.forEach((item, i) => {
326
+ const [start, end] = expandLine(text, item.start, item.end);
327
+ // For every item after the first, start the block at the end of the previous
328
+ // item's line so the comment/blank lines between the two travel with this item.
329
+ blocks.push([i === 0 ? start : blocks[i - 1][1], end]);
330
+ });
331
+ return blocks;
332
+ };
206
333
  /**
207
334
  * Rewrites a YAML sequence to contain exactly `newItems` (a subset and/or
208
335
  * reordering of the original nodes), preserving each kept item's own text. Flow
209
336
  * sequences (`[a, b]`) are rebuilt inside their brackets; block sequences (one
210
- * `- item` per line) are rebuilt from their whole-line spans.
337
+ * `- item` per line) are rebuilt from their whole-line spans, carrying each item's
338
+ * preceding comments with it.
211
339
  */
212
340
  const rewriteYamlSeq = (text, seq, newItems) => {
213
341
  const isFlow = text.charCodeAt(seq.start) === 91; /* [ */
@@ -217,7 +345,7 @@ const rewriteYamlSeq = (text, seq, newItems) => {
217
345
  }
218
346
  if (seq.items.length === 0)
219
347
  return text;
220
- const blocks = seq.items.map((item) => expandLine(text, item.start, item.end));
348
+ const blocks = seqBlocks(text, seq.items);
221
349
  const regionStart = blocks[0][0];
222
350
  const regionEnd = blocks[blocks.length - 1][1];
223
351
  const blockText = new Map(seq.items.map((item, index) => [item, text.slice(...blocks[index])]));
@@ -225,7 +353,12 @@ const rewriteYamlSeq = (text, seq, newItems) => {
225
353
  return splice(text, regionStart, regionEnd, rebuilt);
226
354
  };
227
355
  // --- JSON ------------------------------------------------------------------
228
- const JSON_FORMAT = { insertSpaces: true, tabSize: 2, eol: '\n' };
356
+ /** Formatting options for jsonc-parser edits, carrying the file's own line ending. */
357
+ const jsonFormat = (eol) => ({
358
+ insertSpaces: true,
359
+ tabSize: 2,
360
+ eol,
361
+ });
229
362
  /**
230
363
  * jsonc-parser keys path segments by JS type — strings index objects, numbers
231
364
  * index arrays — but a finding's path carries numeric-like object keys (e.g. a
@@ -243,40 +376,76 @@ const normalizeJsonPath = (root, path) => {
243
376
  }
244
377
  return result;
245
378
  };
379
+ /**
380
+ * Rewrites a JSON array to contain exactly `keptChildren`, slicing each element's
381
+ * original source so numeric literals (`1.50`), escapes, and Unicode survive byte
382
+ * for byte. Separators, leading, and trailing whitespace are taken from the
383
+ * original array, so a one-line array stays on one line and a multi-line array
384
+ * keeps its indentation instead of being re-serialized with a hardcoded style.
385
+ */
386
+ const rewriteJsonArray = (text, node, keptChildren) => {
387
+ const children = node.children ?? [];
388
+ const open = node.offset + 1;
389
+ const close = node.offset + node.length - 1;
390
+ if (children.length === 0)
391
+ return text;
392
+ const first = children[0];
393
+ const last = children[children.length - 1];
394
+ if (keptChildren.length === 0)
395
+ return splice(text, open, close, '');
396
+ const leading = text.slice(open, first.offset);
397
+ const trailing = text.slice(last.offset + last.length, close);
398
+ // The separator between two elements, captured from the source so we reuse the
399
+ // file's own comma, newline, and indentation rather than inventing them.
400
+ const separator = children.length > 1 ? text.slice(first.offset + first.length, children[1].offset) : ', ';
401
+ const inner = keptChildren.map((child) => text.slice(child.offset, child.offset + child.length)).join(separator);
402
+ return splice(text, open, close, leading + inner + trailing);
403
+ };
246
404
  const applyJsonOp = (text, op) => {
247
405
  const root = parseTree(text);
248
406
  if (!root)
249
407
  return text;
250
408
  const path = normalizeJsonPath(root, op.path);
409
+ const format = jsonFormat(detectEol(text));
251
410
  switch (op.op) {
252
411
  case 'setValue':
253
- return applyEdits(text, modify(text, path, op.value, { formattingOptions: JSON_FORMAT }));
412
+ // `modify` would happily *create* a missing path (and every ancestor along
413
+ // it), which violates the contract that an unresolved path is a no-op — so
414
+ // only edit when the node actually exists.
415
+ if (!findNodeAtLocation(root, path))
416
+ return text;
417
+ return applyEdits(text, modify(text, path, op.value, { formattingOptions: format }));
254
418
  case 'removeProperty':
255
- return applyEdits(text, modify(text, path, undefined, { formattingOptions: JSON_FORMAT }));
419
+ if (!findNodeAtLocation(root, path))
420
+ return text;
421
+ return applyEdits(text, modify(text, path, undefined, { formattingOptions: format }));
256
422
  case 'renameProperty': {
257
423
  const valueNode = findNodeAtLocation(root, path);
258
- const keyNode = valueNode?.parent?.children?.[0];
424
+ // Only a real object member can be renamed. An array-index path has an
425
+ // `array` parent, whose first child is element 0 — renaming there would
426
+ // overwrite that element, so bail instead.
427
+ if (valueNode?.parent?.type !== 'property')
428
+ return text;
429
+ const keyNode = valueNode.parent.children?.[0];
259
430
  if (!keyNode)
260
431
  return text;
261
432
  return splice(text, keyNode.offset, keyNode.offset + keyNode.length, JSON.stringify(op.newKey));
262
433
  }
263
434
  case 'removeItems': {
264
- // jsonc-parser's per-index array removal is unreliable, so rewrite the
265
- // whole array with the kept elements instead.
266
435
  const node = findNodeAtLocation(root, path);
267
- if (!node)
436
+ if (!node || node.type !== 'array')
268
437
  return text;
269
438
  const removed = new Set(op.indices);
270
- const kept = getNodeValue(node).filter((_, index) => !removed.has(index));
271
- return applyEdits(text, modify(text, path, kept, { formattingOptions: JSON_FORMAT }));
439
+ const kept = (node.children ?? []).filter((_, index) => !removed.has(index));
440
+ return rewriteJsonArray(text, node, kept);
272
441
  }
273
442
  case 'reorderArray': {
274
443
  const node = findNodeAtLocation(root, path);
275
- if (!node)
444
+ if (!node || node.type !== 'array')
276
445
  return text;
277
- const array = getNodeValue(node);
278
- const reordered = op.order.map((index) => array[index]);
279
- return applyEdits(text, modify(text, path, reordered, { formattingOptions: JSON_FORMAT }));
446
+ const children = node.children ?? [];
447
+ const reordered = op.order.map((index) => children[index]).filter((child) => child != null);
448
+ return rewriteJsonArray(text, node, reordered);
280
449
  }
281
450
  case 'insertProperty': {
282
451
  const node = findNodeAtLocation(root, path);
@@ -285,14 +454,14 @@ const applyJsonOp = (text, op) => {
285
454
  // Additive only: writing to an existing key would replace its value, so bail.
286
455
  if (node.children?.some((property) => property.children?.[0]?.value === op.key))
287
456
  return text;
288
- return applyEdits(text, modify(text, [...path, op.key], op.value, { formattingOptions: JSON_FORMAT }));
457
+ return applyEdits(text, modify(text, [...path, op.key], op.value, { formattingOptions: format }));
289
458
  }
290
459
  case 'insertItem': {
291
460
  const node = findNodeAtLocation(root, path);
292
461
  if (!node || node.type !== 'array')
293
462
  return text;
294
463
  const index = clampIndex(op.index, node.children?.length ?? 0);
295
- return applyEdits(text, modify(text, [...path, index], op.value, { formattingOptions: JSON_FORMAT, isArrayInsertion: true }));
464
+ return applyEdits(text, modify(text, [...path, index], op.value, { formattingOptions: format, isArrayInsertion: true }));
296
465
  }
297
466
  }
298
467
  };
@@ -43,8 +43,20 @@ export type IParseResult<T = unknown> = {
43
43
  };
44
44
  /** Tuning for how strictly the parser treats YAML/JSON edge cases. */
45
45
  export type IParserOptions = {
46
- /** Severity for duplicate object keys. Default: error. `false`/`"off"` disables. */
46
+ /**
47
+ * Severity for duplicate object keys. Default: error. `false`/`"off"` disables
48
+ * detection entirely; any {@link DiagnosticSeverity} reports the duplicate at
49
+ * that level instead of the default error.
50
+ */
47
51
  duplicateKeys?: DiagnosticSeverity | 'off' | false;
48
- /** Severity for YAML values incompatible with JSON (e.g. bigints). Default: error. */
52
+ /**
53
+ * Severity for YAML values that cannot round-trip through JSON. The core
54
+ * schema projects `.nan`/`.inf`/`-.inf` to the non-finite numbers `NaN`,
55
+ * `Infinity`, and `-Infinity`, which `JSON.stringify` silently rewrites to
56
+ * `null`; each such value is reported at the configured severity.
57
+ *
58
+ * Detection is opt-in. Default (`undefined`), `false`, and `"off"` disable it;
59
+ * any {@link DiagnosticSeverity} enables detection and reports at that level.
60
+ */
49
61
  incompatibleValues?: DiagnosticSeverity | 'off' | false;
50
62
  };
@@ -2,5 +2,15 @@ import { type IParseResult, type IParserOptions } from './types.js';
2
2
  /**
3
3
  * Parses YAML (a JSON superset, so this handles both) into data plus a source
4
4
  * map, surfacing duplicate-key and incompatible-value diagnostics per `options`.
5
+ *
6
+ * A `---`-separated stream is parsed as multiple documents (via
7
+ * `parseAllDocuments`), each linted independently: `data` becomes an array of
8
+ * per-document values and every position key / finding path is prefixed with the
9
+ * zero-based document index, so a violation in a later document resolves to its
10
+ * own range instead of being silently dropped. A single-document source keeps the
11
+ * flat shape — `data` is the document value and paths are unprefixed — so existing
12
+ * callers and rulesets are unaffected. Node ranges are absolute offsets into the
13
+ * shared source, so diagnostics and positions in later documents are already
14
+ * correct without any per-document offset arithmetic.
5
15
  */
6
16
  export declare const parseYaml: <T = unknown>(source: string, options?: IParserOptions) => IParseResult<T>;