@intentius/chant 0.97.0 → 0.99.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 (62) hide show
  1. package/dist/cli/commands/import.d.ts +67 -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/cli/handlers/misc.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/mcp/tools/import.d.ts +4 -0
  8. package/dist/cli/mcp/tools/import.d.ts.map +1 -1
  9. package/dist/cli/plugins.d.ts +10 -0
  10. package/dist/cli/plugins.d.ts.map +1 -1
  11. package/dist/deep-observation.d.ts.map +1 -1
  12. package/dist/import/embedded.d.ts +184 -0
  13. package/dist/import/embedded.d.ts.map +1 -0
  14. package/dist/import/generator.d.ts +13 -0
  15. package/dist/import/generator.d.ts.map +1 -1
  16. package/dist/import/parser.d.ts +15 -1
  17. package/dist/import/parser.d.ts.map +1 -1
  18. package/dist/lexicon.d.ts +19 -0
  19. package/dist/lexicon.d.ts.map +1 -1
  20. package/dist/lint/engine.d.ts +6 -1
  21. package/dist/lint/engine.d.ts.map +1 -1
  22. package/dist/lint/rule.d.ts +10 -0
  23. package/dist/lint/rule.d.ts.map +1 -1
  24. package/dist/lint/rules/file-declarable-limit.d.ts.map +1 -1
  25. package/dist/lint/rules/flat-declarations.d.ts.map +1 -1
  26. package/dist/lint/rules/no-unused-declarable.d.ts.map +1 -1
  27. package/dist/lint/rules/property-kind.d.ts +7 -0
  28. package/dist/lint/rules/property-kind.d.ts.map +1 -0
  29. package/dist/workspace/conformance/index.d.ts +9 -0
  30. package/dist/workspace/conformance/index.d.ts.map +1 -1
  31. package/dist/yaml.d.ts +44 -6
  32. package/dist/yaml.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/cli/commands/import-layout.test.ts +142 -0
  35. package/src/cli/commands/import-no-parser.test.ts +70 -0
  36. package/src/cli/commands/import.test.ts +284 -2
  37. package/src/cli/commands/import.ts +325 -86
  38. package/src/cli/commands/lint.ts +21 -8
  39. package/src/cli/handlers/misc.ts +3 -0
  40. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +19 -11
  41. package/src/cli/handlers/operator.ts +17 -3
  42. package/src/cli/main.ts +3 -1
  43. package/src/cli/mcp/tools/import.ts +6 -0
  44. package/src/cli/plugins.ts +41 -2
  45. package/src/deep-observation.test.ts +21 -0
  46. package/src/deep-observation.ts +9 -0
  47. package/src/import/embedded.test.ts +153 -0
  48. package/src/import/embedded.ts +376 -0
  49. package/src/import/generator.ts +14 -0
  50. package/src/import/parser.ts +17 -1
  51. package/src/lexicon.ts +21 -0
  52. package/src/lint/engine.ts +7 -0
  53. package/src/lint/rule.ts +10 -0
  54. package/src/lint/rules/file-declarable-limit.ts +11 -4
  55. package/src/lint/rules/flat-declarations.ts +6 -1
  56. package/src/lint/rules/no-unused-declarable.ts +59 -2
  57. package/src/lint/rules/property-kind.test.ts +99 -0
  58. package/src/lint/rules/property-kind.ts +56 -0
  59. package/src/workspace/conformance/index.mjs +1 -0
  60. package/src/workspace/conformance/index.ts +23 -1
  61. package/src/yaml.test.ts +244 -1
  62. package/src/yaml.ts +443 -241
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,55 @@ 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>;
342
+ }
343
+
344
+ /**
345
+ * Parse one YAML (or JSON) document whose top level may be a sequence
346
+ * (#2965). {@link parseYAML} reads only a top-level mapping and returns `{}`
347
+ * for a file like `- a\n- b`; its callers rely on getting a mapping back, so
348
+ * it keeps doing that. This returns the list instead, and otherwise defers to
349
+ * `parseYAML`.
350
+ */
351
+ export function parseYAMLDocument(content: string): unknown {
352
+ try {
353
+ return JSON.parse(content);
354
+ } catch {
355
+ // Fall through to YAML parsing
356
+ }
357
+
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);
362
+ }
363
+
364
+ /**
365
+ * Split a YAML stream into its documents (#2965). A line holding only `---`
366
+ * starts a document and a line holding only `...` ends one; either may carry
367
+ * a trailing `# comment`. Documents that are empty or hold only comments are
368
+ * dropped. The text of each document is returned unparsed, with `\r\n`
369
+ * normalized to `\n`.
370
+ */
371
+ export function splitYAMLDocuments(content: string): string[] {
372
+ const documents: string[] = [];
373
+ let current: string[] = [];
374
+ const flush = (): void => {
375
+ const text = current.join("\n");
376
+ if (text.replace(/#[^\n]*/g, "").trim() !== "") documents.push(text);
377
+ current = [];
378
+ };
379
+ for (const line of content.replace(/\r\n?/g, "\n").split("\n")) {
380
+ if (DOCUMENT_MARKER.test(line)) flush();
381
+ else current.push(line);
382
+ }
383
+ flush();
384
+ return documents;
194
385
  }
195
386
 
196
387
  /**
@@ -273,7 +464,13 @@ function parseBlockScalar(
273
464
  }
274
465
 
275
466
  /**
276
- * 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.
277
474
  */
278
475
  export function parseYAMLLines(
279
476
  lines: string[],
@@ -281,190 +478,236 @@ export function parseYAMLLines(
281
478
  baseIndent: number,
282
479
  ): ParseResult {
283
480
  const result: Record<string, unknown> = {};
481
+ const merges: unknown[] = [];
284
482
  let i = startIndex;
285
483
 
286
484
  while (i < lines.length) {
287
485
  const line = lines[i];
288
- // Skip empty lines and comments
289
- if (line.trim() === "" || line.trim().startsWith("#")) {
486
+ if (isBlankOrComment(line)) {
290
487
  i++;
291
488
  continue;
292
489
  }
293
-
294
- const indent = line.search(/\S/);
295
- if (indent < baseIndent) break; // Dedented — done with this block
296
- if (indent > baseIndent && startIndex > 0) break; // Unexpected indent
297
-
490
+ if (indentOf(line) !== baseIndent || isSeqEntry(line)) break;
298
491
  const keyMatch = line.match(KEY_LINE);
299
- if (keyMatch) {
300
- const key = keyMatch[2].trim();
301
- const inlineValue = keyMatch[3].trim();
302
-
303
- if (inlineValue === "" || inlineValue.startsWith("#")) {
304
- // Check next line for array or nested object
305
- if (i + 1 < lines.length) {
306
- const nextLine = lines[i + 1];
307
- const nextIndent = nextLine.search(/\S/);
308
- if (nextLine.trimStart().startsWith("- ") && nextIndent >= indent) {
309
- // Same-indent arrays are valid YAML (e.g. controller-gen output):
310
- // versions:
311
- // - name: v1
312
- const arr = parseYAMLArray(lines, i + 1, nextIndent);
313
- result[key] = arr.value;
314
- i = arr.endIndex;
315
- continue;
316
- } else if (nextIndent > indent) {
317
- const nested = parseYAMLLines(lines, i + 1, nextIndent);
318
- result[key] = nested.value;
319
- i = nested.endIndex;
320
- continue;
321
- }
322
- }
323
- result[key] = null;
324
- i++;
325
- } else if (inlineValue.startsWith("[")) {
326
- // Inline array
327
- try {
328
- result[key] = JSON.parse(inlineValue);
329
- } catch {
330
- result[key] = inlineValue;
331
- }
332
- i++;
333
- } else if (inlineValue.startsWith("{")) {
334
- // Inline object
335
- try {
336
- result[key] = JSON.parse(inlineValue);
337
- } catch {
338
- result[key] = inlineValue;
339
- }
340
- i++;
341
- } else {
342
- const header = blockScalarHeader(inlineValue);
343
- if (header) {
344
- const block = parseBlockScalar(lines, i + 1, indent, header);
345
- result[key] = block.value;
346
- i = block.endIndex;
347
- } else {
348
- result[key] = parseScalar(inlineValue);
349
- i++;
350
- }
351
- }
352
- } else if (line.trimStart().startsWith("- ")) {
353
- break;
354
- } else {
355
- i++;
356
- }
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;
357
496
  }
497
+ applyMerges(result, merges, startIndex + 1);
358
498
 
359
499
  return { value: result, endIndex: i };
360
500
  }
361
501
 
362
502
  /**
363
- * Parse the value of a key inside an array item.
364
- * 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
365
530
  */
366
- function parseArrayItemValue(
367
- inlineValue: string,
531
+ function parseValue(
532
+ inline: string,
368
533
  lines: string[],
369
- currentIndex: number,
534
+ at: number,
370
535
  keyIndent: number,
371
- ): unknown {
372
- if (inlineValue !== "" && !inlineValue.startsWith("#")) {
373
- const header = blockScalarHeader(inlineValue);
374
- if (header) {
375
- // Block scalar nested under an array-item key (e.g. `- name: x\n run: |`).
376
- // The body is indented past the key's column. On a `- key: |` line the key
377
- // sits 2 cols past the dash; on its own line it's the line's indent (#910).
378
- const dash = lines[currentIndex].match(/^(\s*)- /);
379
- const keyIndent = dash ? dash[1].length + 2 : lines[currentIndex].search(/\S/);
380
- return parseBlockScalar(lines, currentIndex + 1, keyIndent, header).value;
381
- }
382
- if (inlineValue.startsWith("[")) {
383
- 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);
384
556
  }
385
- if (inlineValue.startsWith("{")) {
386
- 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 };
387
565
  }
388
- return parseScalar(inlineValue);
389
566
  }
390
- // Empty inline value — check for a nested block. `keyIndent` is the key's own
391
- // column, and the two nested shapes do NOT share a threshold (#1311):
392
- //
393
- // - a SEQUENCE may sit at the key's own column (valid YAML, and what
394
- // kubectl and Kubernetes manifests emit);
395
- // - a MAPPING must be indented past it, otherwise the next line is a
396
- // sibling key and this key's value is null:
397
- //
398
- // - name: a
399
- // meta: <- no value
400
- // other: b <- a sibling, NOT meta's content
401
- //
402
- // Testing both against `>= keyIndent` would swallow that sibling; testing
403
- // both against `> keyIndent` loses the same-column sequence.
404
- const nextIdx = currentIndex + 1;
405
- if (nextIdx < lines.length) {
406
- const nextLine = lines[nextIdx];
407
- if (nextLine.trim() !== "" && !nextLine.trim().startsWith("#")) {
408
- const ni = nextLine.search(/\S/);
409
- if (nextLine.trimStart().startsWith("- ")) {
410
- if (ni >= keyIndent) return parseYAMLArray(lines, nextIdx, ni).value;
411
- } else if (ni > keyIndent) {
412
- 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;
413
600
  }
601
+ return i;
414
602
  }
415
603
  }
416
- 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;
417
612
  }
418
613
 
419
614
  /**
420
- * Skip past the value block belonging to a key at column `keyIndent` inside a
421
- * sequence item, returning the first line that is NOT part of it (#1311).
422
- *
423
- * Two shapes, and only the first is a matter of indentation:
424
- *
425
- * - a nested MAPPING is indented past its key, so anything further right
426
- * belongs to it and anything at the key's own column is a sibling;
427
- * - a nested SEQUENCE may sit at the SAME column as its key, which is valid
428
- * YAML and what kubectl and Kubernetes manifests both emit:
429
- *
430
- * - name: web
431
- * ports:
432
- * - containerPort: 80
433
- * env: <- a sibling, at `ports`' own column
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.
434
620
  *
435
- * An indent rule cannot separate those, so the sequence is re-parsed to
436
- * find where it ends. `parseYAMLArray` already reports that as `endIndex`.
621
+ * These continuation lines used to be skipped, keeping only the first line.
437
622
  */
438
- function skipValueBlock(lines: string[], startIndex: number, keyIndent: number): number {
439
- let k = startIndex;
440
- while (k < lines.length && (lines[k].trim() === "" || lines[k].trim().startsWith("#"))) k++;
441
- if (k < lines.length) {
442
- const ni = lines[k].search(/\S/);
443
- if (ni >= keyIndent && lines[k].trimStart().startsWith("- ")) {
444
- 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
+ );
445
650
  }
651
+ text += blanks > 0 ? "\n".repeat(blanks) : " ";
652
+ text += t;
653
+ blanks = 0;
654
+ endIndex = j + 1;
446
655
  }
447
- return skipNestedBlock(lines, startIndex, keyIndent + 1);
656
+ return { value: parseScalar(text), endIndex };
448
657
  }
449
658
 
450
659
  /**
451
- * Skip past a nested block (object or array) starting at startIndex with the given indent.
452
- * 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.
453
665
  */
454
- function skipNestedBlock(lines: string[], startIndex: number, childIndent: number): number {
455
- let j = startIndex;
456
- while (j < lines.length) {
457
- const l = lines[j];
458
- if (l.trim() === "" || l.trim().startsWith("#")) { j++; continue; }
459
- const ni = l.search(/\S/);
460
- if (ni < childIndent) break;
461
- 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 };
462
703
  }
463
- return j;
704
+ throw new YAMLParseError(from, `unterminated ${quote === '"' ? "double" : "single"}-quoted scalar`);
464
705
  }
465
706
 
466
707
  /**
467
- * 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`.
468
711
  */
469
712
  export function parseYAMLArray(
470
713
  lines: string[],
@@ -476,98 +719,57 @@ export function parseYAMLArray(
476
719
 
477
720
  while (i < lines.length) {
478
721
  const line = lines[i];
479
- if (line.trim() === "" || line.trim().startsWith("#")) {
722
+ if (isBlankOrComment(line)) {
480
723
  i++;
481
724
  continue;
482
725
  }
483
-
484
- const indent = line.search(/\S/);
485
- if (indent < baseIndent) break;
486
-
487
- const itemMatch = line.match(/^(\s*)- (.*)$/);
488
- if (itemMatch && indent === baseIndent) {
489
- const itemValue = itemMatch[2].trim();
490
- // Check if it's a key-value pair (object item in array).
491
- // Skip quoted scalars — a quoted string containing a colon (e.g. "80:80")
492
- // must not be treated as a key-value pair.
493
- const isQuotedScalar =
494
- (itemValue.startsWith('"') && itemValue.endsWith('"')) ||
495
- (itemValue.startsWith("'") && itemValue.endsWith("'"));
496
- const kvMatch = !isQuotedScalar && itemValue.match(ITEM_KEY);
497
- if (kvMatch) {
498
- const obj: Record<string, unknown> = {};
499
- obj[kvMatch[1].trim()] = parseArrayItemValue(kvMatch[2].trim(), lines, i, indent + 2);
500
- // Check for more keys at indent+2
501
- const nextIndent = indent + 2;
502
- const firstVal = kvMatch[2].trim();
503
- let j = firstVal === "" || firstVal.startsWith("#")
504
- // The nested block belongs to THIS key and is indented past it, so
505
- // skip lines indented more than the key's own column (#1311). Using
506
- // the key's column itself also swallowed the item's sibling keys,
507
- // which sit at exactly that column:
508
- //
509
- // - context: <- key at column 2
510
- // cluster: c1 <- its block, column 4
511
- // name: n1 <- a SIBLING at column 2, was skipped
512
- //
513
- // Only the item's first key was affected: the sibling loop below
514
- // already skips past its own key's column, and the block-scalar
515
- // branch immediately below has always used `+ 1` for this reason.
516
- ? skipValueBlock(lines, i + 1, nextIndent)
517
- : blockScalarHeader(firstVal)
518
- // Block body is indented past the key (nextIndent); skip it (#910).
519
- ? skipNestedBlock(lines, i + 1, nextIndent + 1)
520
- : i + 1;
521
- while (j < lines.length) {
522
- const nextLine = lines[j];
523
- if (nextLine.trim() === "" || nextLine.trim().startsWith("#")) {
524
- j++;
525
- continue;
526
- }
527
- const ni = nextLine.search(/\S/);
528
- if (ni < nextIndent) break;
529
- if (ni > nextIndent) break; // belongs to a nested block already consumed
530
- const nextKV = nextLine.match(KEY_LINE);
531
- if (nextKV) {
532
- const nextVal = nextKV[3].trim();
533
- obj[nextKV[2].trim()] = parseArrayItemValue(nextVal, lines, j, ni);
534
- if (nextVal === "" || nextVal.startsWith("#")) {
535
- // Same rule as the first key above: past this key's own column,
536
- // or to the end of a same-column sequence (#1311).
537
- j = skipValueBlock(lines, j + 1, ni);
538
- } else if (blockScalarHeader(nextVal)) {
539
- // Skip the block body (indented past this key at `ni`) (#910).
540
- j = skipNestedBlock(lines, j + 1, ni + 1);
541
- } else {
542
- j++;
543
- }
544
- } else {
545
- break;
546
- }
547
- }
548
- result.push(obj);
549
- i = j;
550
- } else {
551
- const header = blockScalarHeader(itemValue);
552
- if (header) {
553
- // A block scalar as the item itself (`- |`). Without this branch the
554
- // header parsed as the literal string "|" and the body lines leaked
555
- // into whatever came next — inside a container list that hoisted the
556
- // sibling keys after `args:` (securityContext, even the following
557
- // `containers:` key) to the document root, so post-synth checks read
558
- // a manifest that had lost them (#1482). The body is indented past
559
- // the dash's column.
560
- const block = parseBlockScalar(lines, i + 1, indent, header);
561
- result.push(block.value);
562
- i = block.endIndex;
563
- } else {
564
- result.push(parseScalar(itemValue));
565
- 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;
566
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;
567
756
  }
568
- } else {
569
- break;
757
+ applyMerges(obj, merges, i + 1);
758
+ result.push(obj);
759
+ i = j;
760
+ continue;
570
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;
571
773
  }
572
774
 
573
775
  return { value: result, endIndex: i };