fix(#1779): emit valid YAML for unsafe scalars in reconstructFrontmatter (#1807)

* fix(#1779): emit valid YAML for unsafe scalars in reconstructFrontmatter

reconstructFrontmatter wraps a scalar or block-array item in double quotes
when it contains a YAML indicator (`:`/`#`, plus `[`/`{` for top-level
scalars), but interpolated the raw value with no escaping. A value carrying
both an indicator and a literal `"` (or `\`) serialized to invalid YAML, e.g.
`upstream: "https://x (Tom; "Git. Ship. Done")"`, which fails under any strict
parser (js-yaml, PyYAML) and corrupts the whole block on the next
syncStateFrontmatter whole-block regen.

- escapeDoubleQuoted() escapes `\`, `"`, and control chars (newline/tab/CR/C0
  controls + DEL → YAML `\n`/`\t`/`\r`/`\xHH`) so any wrapped value is valid.
- scalarNeedsDoubleQuoting() routes values that mis-parse or round-trip lossily
  when bare through that escaped form: the empty string (bare `k:` reloads as
  null), an embedded `"`/`\` or control char, a leading YAML indicator
  (quote / `&`*`!` anchor-alias-tag / `|`>` block scalar / flow `[]{},` / `#` /
  reserved `%`@`backtick / `-`?`:` before a space), or leading/trailing space.
  Applied at all four wrap sites.

Scope stays on serialization correctness; it does NOT broaden the lossy
object-list handling deferred to #1572/#1660. Known limitation: lone UTF-16
surrogates are still lossy through UTF-8 encoding (out of scope, extremely
unlikely in frontmatter values).

Regression test asserts strict js-yaml round-trip across all four wrap sites
plus backslash, control-char (incl. NUL), empty-string, leading-indicator, and
leading/trailing-whitespace classes; test-the-test confirms each fails on the
unescaped output. Frontmatter suite 549/549 green, golden-install-parity 16/16
unchanged (no serialization churn).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#1779): add changeset for frontmatter quote-escaping fix

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Behruz Nassre Esfahani
2026-07-02 08:43:47 -07:00
committed by GitHub
parent 92091d71f2
commit d96c7ef865
4 changed files with 157 additions and 5 deletions

View File

@@ -133,6 +133,47 @@ function extractFrontmatter(content: string): Frontmatter {
return frontmatter;
}
/**
* Escape a string for emission inside a YAML double-quoted scalar (#1779).
* Backslash must be escaped first so the backslashes added for embedded quotes
* (and control chars) are not themselves doubled. Without this, a value
* carrying an indicator (`:`/`#`) that also contains a literal `"` serializes
* to invalid YAML, e.g. `upstream: "https://x (Tom; "Git. Ship. Done")"`. A
* literal newline/tab/control char inside the quotes likewise breaks (or
* silently alters) the scalar, so those are escaped to their YAML forms too.
*/
function escapeDoubleQuoted(s: string): string {
return s
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\t/g, '\\t')
.replace(/\r/g, '\\r')
// Remaining C0 controls + DEL → \xHH (a valid YAML double-quoted escape).
.replace(/[\u0000-\u001f\u007f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
}
/**
* A plain (unquoted) scalar that would mis-parse or round-trip lossily when
* emitted bare must instead go through the double-quoted + escaped form
* (#1779): the empty string (bare `k:` reloads as null), an embedded `"`/`\`
* or control char, a leading YAML indicator (quote, `&`/`*`/`!` anchor/alias/
* tag, `|`/`>` block scalar, flow `[]{},`, `#`, reserved `%`/`@`/backtick, or
* `-`/`?`/`:` before a space), or leading/trailing whitespace. This helper is
* the correctness complement of `escapeDoubleQuoted`: it broadens the *trigger*
* for quoting without broadening the lossy object-list handling deferred to
* #1572/#1660.
*/
function scalarNeedsDoubleQuoting(s: string): boolean {
if (s === '') return true;
if (/["\\\u0000-\u001f\u007f]/.test(s)) return true;
// Always-unsafe leading indicators, or leading/trailing whitespace.
if (/^[,[\]{}#&*!|>'"%@`]/.test(s) || /^\s|\s$/.test(s)) return true;
// `-` `?` `:` only start a plain scalar safely when NOT followed by a space.
if (/^[-?:](\s|$)/.test(s)) return true;
return false;
}
function reconstructFrontmatter(obj: Frontmatter): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
@@ -145,7 +186,7 @@ function reconstructFrontmatter(obj: Frontmatter): string {
} else {
lines.push(`${key}:`);
for (const item of value) {
lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`);
lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuoted(item)}"` : item}`);
}
}
} else if (typeof value === 'object') {
@@ -160,7 +201,7 @@ function reconstructFrontmatter(obj: Frontmatter): string {
} else {
lines.push(` ${subkey}:`);
for (const item of subval) {
lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`);
lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuoted(item)}"` : item}`);
}
}
} else if (typeof subval === 'object') {
@@ -184,13 +225,13 @@ function reconstructFrontmatter(obj: Frontmatter): string {
} else {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const sv = String(subval);
lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') ? `"${sv}"` : sv}`);
lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') || scalarNeedsDoubleQuoting(sv) ? `"${escapeDoubleQuoted(sv)}"` : sv}`);
}
}
} else {
const sv = String(value);
if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{')) {
lines.push(`${key}: "${sv}"`);
if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{') || scalarNeedsDoubleQuoting(sv)) {
lines.push(`${key}: "${escapeDoubleQuoted(sv)}"`);
} else {
lines.push(`${key}: ${sv}`);
}