@intentius/chant 0.98.0 → 0.100.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 (49) hide show
  1. package/dist/cli/commands/import.d.ts +20 -4
  2. package/dist/cli/commands/import.d.ts.map +1 -1
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/deep-observation.d.ts.map +1 -1
  5. package/dist/import/embedded.d.ts +184 -0
  6. package/dist/import/embedded.d.ts.map +1 -0
  7. package/dist/import/generator.d.ts +13 -0
  8. package/dist/import/generator.d.ts.map +1 -1
  9. package/dist/import/parser.d.ts +15 -1
  10. package/dist/import/parser.d.ts.map +1 -1
  11. package/dist/lexicon.d.ts +19 -0
  12. package/dist/lexicon.d.ts.map +1 -1
  13. package/dist/lint/engine.d.ts +6 -1
  14. package/dist/lint/engine.d.ts.map +1 -1
  15. package/dist/lint/rule.d.ts +10 -0
  16. package/dist/lint/rule.d.ts.map +1 -1
  17. package/dist/lint/rules/file-declarable-limit.d.ts.map +1 -1
  18. package/dist/lint/rules/flat-declarations.d.ts.map +1 -1
  19. package/dist/lint/rules/no-unused-declarable.d.ts.map +1 -1
  20. package/dist/lint/rules/property-kind.d.ts +7 -0
  21. package/dist/lint/rules/property-kind.d.ts.map +1 -0
  22. package/dist/workspace/conformance/index.d.ts +9 -0
  23. package/dist/workspace/conformance/index.d.ts.map +1 -1
  24. package/dist/yaml.d.ts +28 -6
  25. package/dist/yaml.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/cli/commands/import-layout.test.ts +142 -0
  28. package/src/cli/commands/import-no-parser.test.ts +1 -1
  29. package/src/cli/commands/import.test.ts +25 -0
  30. package/src/cli/commands/import.ts +158 -36
  31. package/src/cli/commands/lint.ts +21 -8
  32. package/src/deep-observation.test.ts +21 -0
  33. package/src/deep-observation.ts +9 -0
  34. package/src/import/embedded.test.ts +153 -0
  35. package/src/import/embedded.ts +376 -0
  36. package/src/import/generator.ts +14 -0
  37. package/src/import/parser.ts +17 -1
  38. package/src/lexicon.ts +21 -0
  39. package/src/lint/engine.ts +7 -0
  40. package/src/lint/rule.ts +10 -0
  41. package/src/lint/rules/file-declarable-limit.ts +11 -4
  42. package/src/lint/rules/flat-declarations.ts +6 -1
  43. package/src/lint/rules/no-unused-declarable.ts +59 -2
  44. package/src/lint/rules/property-kind.test.ts +99 -0
  45. package/src/lint/rules/property-kind.ts +56 -0
  46. package/src/workspace/conformance/index.mjs +1 -0
  47. package/src/workspace/conformance/index.ts +23 -1
  48. package/src/yaml.test.ts +192 -1
  49. package/src/yaml.ts +404 -250
package/src/yaml.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  * Lightweight YAML emitter and parser.
3
3
  *
4
4
  * Covers the subset of YAML used by Chant lexicons (scalars, block arrays,
5
- * nested objects, tagged values). Not a full YAML implementation — use a
6
- * dedicated library if you need anchors, multi-document streams, or
7
- * block scalars.
5
+ * nested objects, block scalars, tagged values, anchors, aliases and merge
6
+ * keys). Not a full YAML implementation: flow collections are read only when
7
+ * they are JSON. `splitYAMLDocuments` splits a multi-document stream. The
8
+ * parser throws a `YAMLParseError` on a line it cannot place instead of
9
+ * dropping it (#2991).
8
10
  */
9
11
 
10
12
  // ---------------------------------------------------------------------------
@@ -155,6 +157,29 @@ export interface ParseResult {
155
157
  endIndex: number;
156
158
  }
157
159
 
160
+ /**
161
+ * Input the parser cannot place (#2991). The parser used to skip such a line,
162
+ * or read it as a key of whatever mapping was open at the top level, so a
163
+ * mis-indented or unsupported construct came back as a quietly different
164
+ * document. Now it is an error naming the line.
165
+ */
166
+ export class YAMLParseError extends Error {
167
+ /** 1-based line number within the text handed to the parser. */
168
+ readonly line: number;
169
+
170
+ constructor(line: number, message: string) {
171
+ super(`YAML line ${line}: ${message}`);
172
+ this.name = "YAMLParseError";
173
+ this.line = line;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * A mapping key: a double-quoted or single-quoted scalar, which may hold a
179
+ * colon, or a plain run of text up to the first colon.
180
+ */
181
+ const KEY = String.raw`("(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^\s:"'][^:]*?)`;
182
+
158
183
  /**
159
184
  * `key: value` on a line of its own (`KEY_LINE`, capturing the indent) and the
160
185
  * same after a sequence item's `- ` (`ITEM_KEY`).
@@ -173,14 +198,133 @@ export interface ParseResult {
173
198
  * this file's own emitter had just written as a list of objects (#2013). By the
174
199
  * same rule `- key:value` is the scalar `"key:value"`, not a mapping.
175
200
  */
176
- const KEY_LINE = /^(\s*)([^\s:][^:]*?):(?=\s|$)\s*(.*)$/;
177
- const ITEM_KEY = /^([^\s:][^:]*?):(?=\s|$)\s*(.*)$/;
201
+ const KEY_LINE = new RegExp(String.raw`^(\s*)${KEY}:(?=\s|$)\s*(.*)$`);
202
+ const ITEM_KEY = new RegExp(String.raw`^${KEY}:(?=\s|$)\s*(.*)$`);
203
+
204
+ /**
205
+ * A block sequence entry: `- value`, or a dash alone on its line whose value
206
+ * is the block below it. `-1` and `---` are not entries.
207
+ */
208
+ const SEQ_ENTRY = /^(\s*)-(?:([ \t]+)(.*))?$/;
209
+
210
+ /** A document start (`---`) or end (`...`) marker, optionally followed by a comment. */
211
+ const DOCUMENT_MARKER = /^(?:---|\.\.\.)(?:[ \t]+#.*)?[ \t]*$/;
212
+
213
+ function isBlankOrComment(line: string): boolean {
214
+ const t = line.trim();
215
+ return t === "" || t.startsWith("#");
216
+ }
217
+
218
+ /** The first line at or after `from` that holds content, or `lines.length`. */
219
+ function nextContentLine(lines: string[], from: number): number {
220
+ let i = from;
221
+ while (i < lines.length && isBlankOrComment(lines[i])) i++;
222
+ return i;
223
+ }
224
+
225
+ function indentOf(line: string): number {
226
+ return line.search(/\S/);
227
+ }
228
+
229
+ function isSeqEntry(line: string): boolean {
230
+ return SEQ_ENTRY.test(line);
231
+ }
232
+
233
+ /** A key as written, with the quotes and escapes of a quoted key removed. */
234
+ function keyName(raw: string): string {
235
+ const key = raw.trim();
236
+ return key.startsWith('"') || key.startsWith("'") ? String(parseScalar(key)) : key;
237
+ }
238
+
239
+ /**
240
+ * Split text into lines for the parser: `\r\n` and `\r` become `\n`, and a
241
+ * document marker becomes a blank line. `parseYAML` has always read a
242
+ * `---`-led file (a compose file, a GitLab CI file) by skipping the marker;
243
+ * that is kept, now that a line the parser cannot place is an error.
244
+ */
245
+ function toLines(content: string): string[] {
246
+ return content
247
+ .replace(/\r\n?/g, "\n")
248
+ .split("\n")
249
+ .map((line) => (DOCUMENT_MARKER.test(line) ? "" : line));
250
+ }
251
+
252
+ /**
253
+ * Parse a whole document with `parse`, starting at its first content line
254
+ * `first`, and require it to account for every line (#2991). The recursive
255
+ * parsers stop at a line they cannot place and hand it back to their caller;
256
+ * one that reaches the top is unplaceable, and throws.
257
+ */
258
+ function parseWhole(
259
+ lines: string[],
260
+ first: number,
261
+ parse: (lines: string[], startIndex: number, baseIndent: number) => ParseResult,
262
+ ): unknown {
263
+ const outer = anchors;
264
+ anchors = new Map();
265
+ try {
266
+ const result = parse(lines, first, indentOf(lines[first]));
267
+ const rest = nextContentLine(lines, result.endIndex);
268
+ if (rest < lines.length) {
269
+ throw new YAMLParseError(
270
+ rest + 1,
271
+ `cannot place ${JSON.stringify(lines[rest].trim())} at column ${indentOf(lines[rest]) + 1}; ` +
272
+ "check its indentation, or the construct is one this parser does not read",
273
+ );
274
+ }
275
+ return result.value;
276
+ } finally {
277
+ anchors = outer;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * The anchors (`&name`) seen so far in the document being parsed, for its
283
+ * aliases (`*name`) and merge keys (`<<: *name`). GitLab CI templates and
284
+ * Alertmanager configs use all three; before #2991 an anchored block was
285
+ * hoisted into its parent and an alias read as the string `"*name"`.
286
+ * `parseWhole` gives each document its own map.
287
+ */
288
+ let anchors = new Map<string, unknown>();
289
+
290
+ const ANCHOR = /^&([^\s,[\]{}]+)(?:[ \t]+(.*))?$/;
291
+ const ALIAS = /^\*([^\s,[\]{}]+)(?:[ \t]+#.*)?$/;
292
+ /** A flow sequence of aliases, the usual way to merge several anchors: `<<: [*a, *b]`. */
293
+ const ALIAS_LIST = /^\[\s*\*[^\s,[\]{}]+(?:\s*,\s*\*[^\s,[\]{}]+)*\s*\](?:[ \t]+#.*)?$/;
294
+
295
+ function resolveAlias(name: string, line: number): unknown {
296
+ if (!anchors.has(name)) throw new YAMLParseError(line, `the alias *${name} has no anchor &${name} before it`);
297
+ return structuredClone(anchors.get(name));
298
+ }
299
+
300
+ /** The merge key: its value's mappings fill in the keys a mapping does not set itself. */
301
+ const MERGE_KEY = "<<";
302
+
303
+ /**
304
+ * Set `key` on a mapping under construction. A merge key's sources are held
305
+ * in `merges` and applied by {@link applyMerges} once the mapping is
306
+ * complete, because a key the mapping sets itself wins wherever it appears.
307
+ */
308
+ function setKey(obj: Record<string, unknown>, merges: unknown[], key: string, value: unknown): void {
309
+ if (key === MERGE_KEY) merges.push(...(Array.isArray(value) ? value : [value]));
310
+ else obj[key] = value;
311
+ }
312
+
313
+ function applyMerges(obj: Record<string, unknown>, merges: unknown[], line: number): void {
314
+ for (const source of merges) {
315
+ if (typeof source !== "object" || source === null || Array.isArray(source)) {
316
+ throw new YAMLParseError(line, "a merge key (<<) takes a mapping or a list of mappings");
317
+ }
318
+ for (const [k, v] of Object.entries(source)) if (!(k in obj)) obj[k] = v;
319
+ }
320
+ }
178
321
 
179
322
  /**
180
323
  * Parse a YAML document (or JSON document) into a plain object.
181
324
  *
182
325
  * Tries `JSON.parse` first; falls back to a line-based YAML parser that
183
- * handles the subset of YAML commonly found in CI configuration files.
326
+ * handles the subset of YAML commonly found in CI configuration files. Throws
327
+ * {@link YAMLParseError} on a line it cannot place rather than dropping it.
184
328
  */
185
329
  export function parseYAML(content: string): Record<string, unknown> {
186
330
  try {
@@ -189,8 +333,12 @@ export function parseYAML(content: string): Record<string, unknown> {
189
333
  // Fall through to YAML parsing
190
334
  }
191
335
 
192
- const lines = content.replace(/\r\n?/g, "\n").split("\n");
193
- return parseYAMLLines(lines, 0, 0).value as Record<string, unknown>;
336
+ const lines = toLines(content);
337
+ const first = nextContentLine(lines, 0);
338
+ // A document with no content, or (see parseYAMLDocument) a top-level list,
339
+ // reads as an empty mapping; the callers rely on getting a mapping back.
340
+ if (first === lines.length || isSeqEntry(lines[first])) return {};
341
+ return parseWhole(lines, first, parseYAMLLines) as Record<string, unknown>;
194
342
  }
195
343
 
196
344
  /**
@@ -207,17 +355,12 @@ export function parseYAMLDocument(content: string): unknown {
207
355
  // Fall through to YAML parsing
208
356
  }
209
357
 
210
- const lines = content.replace(/\r\n?/g, "\n").split("\n");
211
- const first = lines.findIndex((line) => line.trim() !== "" && !line.trim().startsWith("#"));
212
- if (first !== -1 && /^\s*- /.test(lines[first])) {
213
- return parseYAMLArray(lines, first, lines[first].search(/\S/)).value;
214
- }
215
- return parseYAMLLines(lines, 0, 0).value;
358
+ const lines = toLines(content);
359
+ const first = nextContentLine(lines, 0);
360
+ if (first === lines.length) return {};
361
+ return parseWhole(lines, first, isSeqEntry(lines[first]) ? parseYAMLArray : parseYAMLLines);
216
362
  }
217
363
 
218
- /** A document start (`---`) or end (`...`) marker, optionally followed by a comment. */
219
- const DOCUMENT_MARKER = /^(?:---|\.\.\.)(?:[ \t]+#.*)?[ \t]*$/;
220
-
221
364
  /**
222
365
  * Split a YAML stream into its documents (#2965). A line holding only `---`
223
366
  * starts a document and a line holding only `...` ends one; either may carry
@@ -321,7 +464,13 @@ function parseBlockScalar(
321
464
  }
322
465
 
323
466
  /**
324
- * Parse indentation-based YAML lines into a key-value object.
467
+ * Parse indentation-based YAML lines into a key-value object: the mapping
468
+ * whose keys sit at column `baseIndent`, starting at `startIndex`.
469
+ *
470
+ * It ends at the first line that is not one of its keys: a line at another
471
+ * column, a sequence entry, or text that is not `key:`. That line goes back
472
+ * to the caller in `endIndex`; if no caller owns it, `parseYAML` reports it
473
+ * (#2991). This function never skips a content line.
325
474
  */
326
475
  export function parseYAMLLines(
327
476
  lines: string[],
@@ -329,190 +478,236 @@ export function parseYAMLLines(
329
478
  baseIndent: number,
330
479
  ): ParseResult {
331
480
  const result: Record<string, unknown> = {};
481
+ const merges: unknown[] = [];
332
482
  let i = startIndex;
333
483
 
334
484
  while (i < lines.length) {
335
485
  const line = lines[i];
336
- // Skip empty lines and comments
337
- if (line.trim() === "" || line.trim().startsWith("#")) {
486
+ if (isBlankOrComment(line)) {
338
487
  i++;
339
488
  continue;
340
489
  }
341
-
342
- const indent = line.search(/\S/);
343
- if (indent < baseIndent) break; // Dedented — done with this block
344
- if (indent > baseIndent && startIndex > 0) break; // Unexpected indent
345
-
490
+ if (indentOf(line) !== baseIndent || isSeqEntry(line)) break;
346
491
  const keyMatch = line.match(KEY_LINE);
347
- if (keyMatch) {
348
- const key = keyMatch[2].trim();
349
- const inlineValue = keyMatch[3].trim();
350
-
351
- if (inlineValue === "" || inlineValue.startsWith("#")) {
352
- // Check next line for array or nested object
353
- if (i + 1 < lines.length) {
354
- const nextLine = lines[i + 1];
355
- const nextIndent = nextLine.search(/\S/);
356
- if (nextLine.trimStart().startsWith("- ") && nextIndent >= indent) {
357
- // Same-indent arrays are valid YAML (e.g. controller-gen output):
358
- // versions:
359
- // - name: v1
360
- const arr = parseYAMLArray(lines, i + 1, nextIndent);
361
- result[key] = arr.value;
362
- i = arr.endIndex;
363
- continue;
364
- } else if (nextIndent > indent) {
365
- const nested = parseYAMLLines(lines, i + 1, nextIndent);
366
- result[key] = nested.value;
367
- i = nested.endIndex;
368
- continue;
369
- }
370
- }
371
- result[key] = null;
372
- i++;
373
- } else if (inlineValue.startsWith("[")) {
374
- // Inline array
375
- try {
376
- result[key] = JSON.parse(inlineValue);
377
- } catch {
378
- result[key] = inlineValue;
379
- }
380
- i++;
381
- } else if (inlineValue.startsWith("{")) {
382
- // Inline object
383
- try {
384
- result[key] = JSON.parse(inlineValue);
385
- } catch {
386
- result[key] = inlineValue;
387
- }
388
- i++;
389
- } else {
390
- const header = blockScalarHeader(inlineValue);
391
- if (header) {
392
- const block = parseBlockScalar(lines, i + 1, indent, header);
393
- result[key] = block.value;
394
- i = block.endIndex;
395
- } else {
396
- result[key] = parseScalar(inlineValue);
397
- i++;
398
- }
399
- }
400
- } else if (line.trimStart().startsWith("- ")) {
401
- break;
402
- } else {
403
- i++;
404
- }
492
+ if (!keyMatch) break;
493
+ const value = parseValue(keyMatch[3].trim(), lines, i, baseIndent);
494
+ setKey(result, merges, keyName(keyMatch[2]), value.value);
495
+ i = value.endIndex;
405
496
  }
497
+ applyMerges(result, merges, startIndex + 1);
406
498
 
407
499
  return { value: result, endIndex: i };
408
500
  }
409
501
 
410
502
  /**
411
- * Parse the value of a key inside an array item.
412
- * If the inline value is empty, look ahead for a nested object or array.
503
+ * Parse the value of the key on line `at`, whose text after the colon is
504
+ * `inline` and whose own column is `keyIndent`. Used for mapping keys and for
505
+ * the keys of a sequence item alike.
506
+ *
507
+ * An empty inline value (or only a comment) means the value is the block on
508
+ * the next content line. Blank, whitespace-only and comment lines between the
509
+ * key and that block do not end it (#2991): Helm renders an empty `{{ if }}`
510
+ * as exactly such a line, and treating it as the end of the value turned
511
+ *
512
+ * spec:
513
+ *
514
+ * serviceAccountName: collector
515
+ *
516
+ * into `spec: null` with `serviceAccountName` hoisted to the parent.
517
+ *
518
+ * The two nested shapes do NOT share a threshold (#1311), except below a
519
+ * sequence entry (`sequenceAtKeyColumn` false), where a `-` at the entry's
520
+ * own column is the next entry:
521
+ *
522
+ * - a SEQUENCE may sit at the key's own column (valid YAML, and what
523
+ * kubectl and Kubernetes manifests emit);
524
+ * - a MAPPING must be indented past it, otherwise the next line is a
525
+ * sibling key and this key's value is null:
526
+ *
527
+ * - name: a
528
+ * meta: <- no value
529
+ * other: b <- a sibling, NOT meta's content
413
530
  */
414
- function parseArrayItemValue(
415
- inlineValue: string,
531
+ function parseValue(
532
+ inline: string,
416
533
  lines: string[],
417
- currentIndex: number,
534
+ at: number,
418
535
  keyIndent: number,
419
- ): unknown {
420
- if (inlineValue !== "" && !inlineValue.startsWith("#")) {
421
- const header = blockScalarHeader(inlineValue);
422
- if (header) {
423
- // Block scalar nested under an array-item key (e.g. `- name: x\n run: |`).
424
- // The body is indented past the key's column. On a `- key: |` line the key
425
- // sits 2 cols past the dash; on its own line it's the line's indent (#910).
426
- const dash = lines[currentIndex].match(/^(\s*)- /);
427
- const keyIndent = dash ? dash[1].length + 2 : lines[currentIndex].search(/\S/);
428
- return parseBlockScalar(lines, currentIndex + 1, keyIndent, header).value;
429
- }
430
- if (inlineValue.startsWith("[")) {
431
- try { return JSON.parse(inlineValue); } catch { return inlineValue; }
536
+ sequenceAtKeyColumn = true,
537
+ ): ParseResult {
538
+ const anchor = inline.match(ANCHOR);
539
+ if (anchor) {
540
+ const value = parseValue((anchor[2] ?? "").trim(), lines, at, keyIndent, sequenceAtKeyColumn);
541
+ anchors.set(anchor[1], structuredClone(value.value));
542
+ return value;
543
+ }
544
+ const alias = inline.match(ALIAS);
545
+ if (alias) return { value: resolveAlias(alias[1], at + 1), endIndex: at + 1 };
546
+ if (ALIAS_LIST.test(inline)) {
547
+ const names = [...inline.matchAll(/\*([^\s,[\]{}]+)/g)].map((m) => m[1]);
548
+ return { value: names.map((name) => resolveAlias(name, at + 1)), endIndex: at + 1 };
549
+ }
550
+ if (inline === "" || inline.startsWith("#")) {
551
+ const k = nextContentLine(lines, at + 1);
552
+ if (k < lines.length) {
553
+ const ni = indentOf(lines[k]);
554
+ const nested = isSeqEntry(lines[k]) && sequenceAtKeyColumn ? ni >= keyIndent : ni > keyIndent;
555
+ if (nested) return parseNode(lines, k, keyIndent);
432
556
  }
433
- if (inlineValue.startsWith("{")) {
434
- try { return JSON.parse(inlineValue); } catch { return inlineValue; }
557
+ return { value: null, endIndex: at + 1 };
558
+ }
559
+ if (inline.startsWith("[") || inline.startsWith("{")) {
560
+ // Inline array or object
561
+ try {
562
+ return { value: JSON.parse(inline), endIndex: at + 1 };
563
+ } catch {
564
+ return { value: inline, endIndex: at + 1 };
435
565
  }
436
- return parseScalar(inlineValue);
437
566
  }
438
- // Empty inline value — check for a nested block. `keyIndent` is the key's own
439
- // column, and the two nested shapes do NOT share a threshold (#1311):
440
- //
441
- // - a SEQUENCE may sit at the key's own column (valid YAML, and what
442
- // kubectl and Kubernetes manifests emit);
443
- // - a MAPPING must be indented past it, otherwise the next line is a
444
- // sibling key and this key's value is null:
445
- //
446
- // - name: a
447
- // meta: <- no value
448
- // other: b <- a sibling, NOT meta's content
449
- //
450
- // Testing both against `>= keyIndent` would swallow that sibling; testing
451
- // both against `> keyIndent` loses the same-column sequence.
452
- const nextIdx = currentIndex + 1;
453
- if (nextIdx < lines.length) {
454
- const nextLine = lines[nextIdx];
455
- if (nextLine.trim() !== "" && !nextLine.trim().startsWith("#")) {
456
- const ni = nextLine.search(/\S/);
457
- if (nextLine.trimStart().startsWith("- ")) {
458
- if (ni >= keyIndent) return parseYAMLArray(lines, nextIdx, ni).value;
459
- } else if (ni > keyIndent) {
460
- return parseYAMLLines(lines, nextIdx, ni).value;
567
+ const header = blockScalarHeader(inline);
568
+ if (header) return parseBlockScalar(lines, at + 1, keyIndent, header);
569
+ return parseFlowScalar(inline, lines, at + 1, keyIndent);
570
+ }
571
+
572
+ /**
573
+ * Parse the node that starts on content line `k`, below a key or a bare `-`
574
+ * at column `parentIndent`: a sequence, a mapping, or a scalar written on the
575
+ * line after its key.
576
+ */
577
+ function parseNode(lines: string[], k: number, parentIndent: number): ParseResult {
578
+ const line = lines[k];
579
+ if (isSeqEntry(line)) return parseYAMLArray(lines, k, indentOf(line));
580
+ if (KEY_LINE.test(line)) return parseYAMLLines(lines, k, indentOf(line));
581
+ return parseValue(line.trim(), lines, k, parentIndent);
582
+ }
583
+
584
+ /**
585
+ * The index of the quote that closes a quoted scalar opened by `quote`,
586
+ * scanning `text` from `from`, or -1. A double-quoted scalar escapes with a
587
+ * backslash, a single-quoted one by doubling the quote.
588
+ */
589
+ function closingQuote(text: string, quote: string, from: number): number {
590
+ for (let i = from; i < text.length; i++) {
591
+ const ch = text[i];
592
+ if (quote === '"' && ch === "\\") {
593
+ i++;
594
+ continue;
595
+ }
596
+ if (ch === quote) {
597
+ if (quote === "'" && text[i + 1] === "'") {
598
+ i++;
599
+ continue;
461
600
  }
601
+ return i;
462
602
  }
463
603
  }
464
- return null;
604
+ return -1;
605
+ }
606
+
607
+ /** Whether `text` is exactly one quoted scalar, e.g. `"80:80"`. */
608
+ function isWholeQuotedScalar(text: string): boolean {
609
+ const quote = text[0];
610
+ if (quote !== '"' && quote !== "'") return false;
611
+ return closingQuote(text, quote, 1) === text.length - 1;
465
612
  }
466
613
 
467
614
  /**
468
- * Skip past the value block belonging to a key at column `keyIndent` inside a
469
- * sequence item, returning the first line that is NOT part of it (#1311).
470
- *
471
- * Two shapes, and only the first is a matter of indentation:
615
+ * Parse a plain or quoted scalar whose first line is `first` and whose
616
+ * continuation lines, if any, start at `from` and are indented past
617
+ * `parentIndent`. YAML lets both kinds span lines, and go-yaml (so Helm's
618
+ * `toYaml`) wraps long strings that way. Lines fold into one: a line break
619
+ * becomes a space, and each blank line in between becomes a newline.
472
620
  *
473
- * - a nested MAPPING is indented past its key, so anything further right
474
- * belongs to it and anything at the key's own column is a sibling;
475
- * - a nested SEQUENCE may sit at the SAME column as its key, which is valid
476
- * YAML and what kubectl and Kubernetes manifests both emit:
477
- *
478
- * - name: web
479
- * ports:
480
- * - containerPort: 80
481
- * env: <- a sibling, at `ports`' own column
482
- *
483
- * An indent rule cannot separate those, so the sequence is re-parsed to
484
- * find where it ends. `parseYAMLArray` already reports that as `endIndex`.
621
+ * These continuation lines used to be skipped, keeping only the first line.
485
622
  */
486
- function skipValueBlock(lines: string[], startIndex: number, keyIndent: number): number {
487
- let k = startIndex;
488
- while (k < lines.length && (lines[k].trim() === "" || lines[k].trim().startsWith("#"))) k++;
489
- if (k < lines.length) {
490
- const ni = lines[k].search(/\S/);
491
- if (ni >= keyIndent && lines[k].trimStart().startsWith("- ")) {
492
- return parseYAMLArray(lines, k, ni).endIndex;
623
+ function parseFlowScalar(first: string, lines: string[], from: number, parentIndent: number): ParseResult {
624
+ const quote = first[0];
625
+ if (quote === '"' || quote === "'") {
626
+ if (closingQuote(first, quote, 1) !== -1) return { value: parseScalar(first), endIndex: from };
627
+ return parseMultilineQuoted(first, lines, from, parentIndent, quote);
628
+ }
629
+
630
+ let text = first;
631
+ let endIndex = from;
632
+ let blanks = 0;
633
+ for (let j = from; j < lines.length; j++) {
634
+ const line = lines[j];
635
+ const t = line.trim();
636
+ if (t === "") {
637
+ blanks++;
638
+ continue;
639
+ }
640
+ // A comment ends a plain scalar; so does a line back at the parent's column.
641
+ if (t.startsWith("#") || indentOf(line) <= parentIndent) break;
642
+ if (KEY_LINE.test(line)) {
643
+ throw new YAMLParseError(
644
+ j + 1,
645
+ /^!\S*$/.test(first)
646
+ ? `the tag ${JSON.stringify(first)} before a nested block is not supported`
647
+ : `${JSON.stringify(t)} is indented under the value ${JSON.stringify(first)}, ` +
648
+ "and a scalar cannot hold a mapping; check its indentation",
649
+ );
493
650
  }
651
+ text += blanks > 0 ? "\n".repeat(blanks) : " ";
652
+ text += t;
653
+ blanks = 0;
654
+ endIndex = j + 1;
494
655
  }
495
- return skipNestedBlock(lines, startIndex, keyIndent + 1);
656
+ return { value: parseScalar(text), endIndex };
496
657
  }
497
658
 
498
659
  /**
499
- * Skip past a nested block (object or array) starting at startIndex with the given indent.
500
- * Returns the index of the first line that is NOT part of the nested block.
660
+ * The rest of a quoted scalar left open on its first line: lines up to the
661
+ * closing quote, folded as {@link parseFlowScalar} describes. For a
662
+ * double-quoted scalar a line ending in `\` is an escaped line break that
663
+ * joins the next line with nothing between; it is kept as `\` + newline for
664
+ * `unescapeDoubleQuoted`, which removes it with the indentation after it.
501
665
  */
502
- function skipNestedBlock(lines: string[], startIndex: number, childIndent: number): number {
503
- let j = startIndex;
504
- while (j < lines.length) {
505
- const l = lines[j];
506
- if (l.trim() === "" || l.trim().startsWith("#")) { j++; continue; }
507
- const ni = l.search(/\S/);
508
- if (ni < childIndent) break;
509
- j++;
666
+ function parseMultilineQuoted(
667
+ first: string,
668
+ lines: string[],
669
+ from: number,
670
+ parentIndent: number,
671
+ quote: string,
672
+ ): ParseResult {
673
+ const segments = [first.slice(1)];
674
+ for (let j = from; j < lines.length; j++) {
675
+ const line = lines[j];
676
+ if (line.trim() !== "" && indentOf(line) <= parentIndent) break;
677
+ const close = closingQuote(line, quote, 0);
678
+ if (close === -1) {
679
+ segments.push(line);
680
+ continue;
681
+ }
682
+ segments.push(line.slice(0, close));
683
+ let body = "";
684
+ let blanks = 0;
685
+ let escapedBreak = false;
686
+ segments.forEach((raw, n) => {
687
+ const isFirst = n === 0;
688
+ const isLast = n === segments.length - 1;
689
+ let s = raw;
690
+ if (!isFirst && !escapedBreak) s = s.replace(/^[ \t]+/, "");
691
+ const endsEscaped = !isLast && quote === '"' && /(?:^|[^\\])(?:\\\\)*\\$/.test(s);
692
+ if (!isLast && !endsEscaped) s = s.replace(/[ \t]+$/, "");
693
+ if (!isFirst && !isLast && s === "" && !escapedBreak) {
694
+ blanks++;
695
+ return;
696
+ }
697
+ if (!isFirst) body += escapedBreak ? "\n" : blanks > 0 ? "\n".repeat(blanks) : " ";
698
+ body += s;
699
+ blanks = 0;
700
+ escapedBreak = endsEscaped;
701
+ });
702
+ return { value: parseScalar(quote + body + quote), endIndex: j + 1 };
510
703
  }
511
- return j;
704
+ throw new YAMLParseError(from, `unterminated ${quote === '"' ? "double" : "single"}-quoted scalar`);
512
705
  }
513
706
 
514
707
  /**
515
- * Parse a block array (lines starting with `- `).
708
+ * Parse a block array: the `- ` entries at column `baseIndent`, starting at
709
+ * `startIndex`. Like {@link parseYAMLLines} it ends at the first line that is
710
+ * not one of its entries and hands that line back in `endIndex`.
516
711
  */
517
712
  export function parseYAMLArray(
518
713
  lines: string[],
@@ -524,98 +719,57 @@ export function parseYAMLArray(
524
719
 
525
720
  while (i < lines.length) {
526
721
  const line = lines[i];
527
- if (line.trim() === "" || line.trim().startsWith("#")) {
722
+ if (isBlankOrComment(line)) {
528
723
  i++;
529
724
  continue;
530
725
  }
531
-
532
- const indent = line.search(/\S/);
533
- if (indent < baseIndent) break;
534
-
535
- const itemMatch = line.match(/^(\s*)- (.*)$/);
536
- if (itemMatch && indent === baseIndent) {
537
- const itemValue = itemMatch[2].trim();
538
- // Check if it's a key-value pair (object item in array).
539
- // Skip quoted scalars — a quoted string containing a colon (e.g. "80:80")
540
- // must not be treated as a key-value pair.
541
- const isQuotedScalar =
542
- (itemValue.startsWith('"') && itemValue.endsWith('"')) ||
543
- (itemValue.startsWith("'") && itemValue.endsWith("'"));
544
- const kvMatch = !isQuotedScalar && itemValue.match(ITEM_KEY);
545
- if (kvMatch) {
546
- const obj: Record<string, unknown> = {};
547
- obj[kvMatch[1].trim()] = parseArrayItemValue(kvMatch[2].trim(), lines, i, indent + 2);
548
- // Check for more keys at indent+2
549
- const nextIndent = indent + 2;
550
- const firstVal = kvMatch[2].trim();
551
- let j = firstVal === "" || firstVal.startsWith("#")
552
- // The nested block belongs to THIS key and is indented past it, so
553
- // skip lines indented more than the key's own column (#1311). Using
554
- // the key's column itself also swallowed the item's sibling keys,
555
- // which sit at exactly that column:
556
- //
557
- // - context: <- key at column 2
558
- // cluster: c1 <- its block, column 4
559
- // name: n1 <- a SIBLING at column 2, was skipped
560
- //
561
- // Only the item's first key was affected: the sibling loop below
562
- // already skips past its own key's column, and the block-scalar
563
- // branch immediately below has always used `+ 1` for this reason.
564
- ? skipValueBlock(lines, i + 1, nextIndent)
565
- : blockScalarHeader(firstVal)
566
- // Block body is indented past the key (nextIndent); skip it (#910).
567
- ? skipNestedBlock(lines, i + 1, nextIndent + 1)
568
- : i + 1;
569
- while (j < lines.length) {
570
- const nextLine = lines[j];
571
- if (nextLine.trim() === "" || nextLine.trim().startsWith("#")) {
572
- j++;
573
- continue;
574
- }
575
- const ni = nextLine.search(/\S/);
576
- if (ni < nextIndent) break;
577
- if (ni > nextIndent) break; // belongs to a nested block already consumed
578
- const nextKV = nextLine.match(KEY_LINE);
579
- if (nextKV) {
580
- const nextVal = nextKV[3].trim();
581
- obj[nextKV[2].trim()] = parseArrayItemValue(nextVal, lines, j, ni);
582
- if (nextVal === "" || nextVal.startsWith("#")) {
583
- // Same rule as the first key above: past this key's own column,
584
- // or to the end of a same-column sequence (#1311).
585
- j = skipValueBlock(lines, j + 1, ni);
586
- } else if (blockScalarHeader(nextVal)) {
587
- // Skip the block body (indented past this key at `ni`) (#910).
588
- j = skipNestedBlock(lines, j + 1, ni + 1);
589
- } else {
590
- j++;
591
- }
592
- } else {
593
- break;
594
- }
595
- }
596
- result.push(obj);
597
- i = j;
598
- } else {
599
- const header = blockScalarHeader(itemValue);
600
- if (header) {
601
- // A block scalar as the item itself (`- |`). Without this branch the
602
- // header parsed as the literal string "|" and the body lines leaked
603
- // into whatever came next — inside a container list that hoisted the
604
- // sibling keys after `args:` (securityContext, even the following
605
- // `containers:` key) to the document root, so post-synth checks read
606
- // a manifest that had lost them (#1482). The body is indented past
607
- // the dash's column.
608
- const block = parseBlockScalar(lines, i + 1, indent, header);
609
- result.push(block.value);
610
- i = block.endIndex;
611
- } else {
612
- result.push(parseScalar(itemValue));
613
- i++;
726
+ const itemMatch = line.match(SEQ_ENTRY);
727
+ if (!itemMatch || indentOf(line) !== baseIndent) break;
728
+ const itemValue = (itemMatch[3] ?? "").trim();
729
+
730
+ // The column the entry's content starts at, which is where the keys of a
731
+ // mapping entry sit: two past the dash for `- key`, more for `- key`.
732
+ const keyIndent = baseIndent + 1 + (itemMatch[2]?.length ?? 1);
733
+ // A quoted scalar holding a colon (e.g. "80:80") is not a key-value pair.
734
+ const kvMatch = isWholeQuotedScalar(itemValue) ? null : itemValue.match(ITEM_KEY);
735
+ if (kvMatch) {
736
+ const obj: Record<string, unknown> = {};
737
+ const merges: unknown[] = [];
738
+ const firstValue = parseValue(kvMatch[2].trim(), lines, i, keyIndent);
739
+ setKey(obj, merges, keyName(kvMatch[1]), firstValue.value);
740
+ // The entry's other keys, at the first key's column. Each value's
741
+ // parse reports where it ended, nested block, same-column sequence
742
+ // (#1311) and block scalar (#910) alike.
743
+ let j = firstValue.endIndex;
744
+ while (j < lines.length) {
745
+ const nextLine = lines[j];
746
+ if (isBlankOrComment(nextLine)) {
747
+ j++;
748
+ continue;
614
749
  }
750
+ if (indentOf(nextLine) !== keyIndent || isSeqEntry(nextLine)) break;
751
+ const nextKV = nextLine.match(KEY_LINE);
752
+ if (!nextKV) break;
753
+ const value = parseValue(nextKV[3].trim(), lines, j, keyIndent);
754
+ setKey(obj, merges, keyName(nextKV[2]), value.value);
755
+ j = value.endIndex;
615
756
  }
616
- } else {
617
- break;
757
+ applyMerges(obj, merges, i + 1);
758
+ result.push(obj);
759
+ i = j;
760
+ continue;
618
761
  }
762
+
763
+ // Anything else is read the way a mapping value is: a scalar, an inline
764
+ // list or object, an anchor or alias, a block scalar, or, for a dash alone
765
+ // on its line (#1286), the block below it after any blank or comment
766
+ // lines, or null. A block scalar's body (`- |`) is indented past the
767
+ // dash's column; before #1482 the header parsed as the literal string "|"
768
+ // and the body lines leaked into whatever came next, hoisting a
769
+ // container's keys after `args:` to the document root.
770
+ const item = parseValue(itemValue, lines, i, baseIndent, false);
771
+ result.push(item.value);
772
+ i = item.endIndex;
619
773
  }
620
774
 
621
775
  return { value: result, endIndex: i };