From 6000706111ca271c781c47c9d9fb9dfb7770423b Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 1 Sep 2026 11:47:27 -0600 Subject: [PATCH] fix(frontmatter): keep the last character of a bare scalar that ends in a quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unquote` stripped a leading OR a trailing quote independently, so a value that merely ended in one lost its last character. `check: python3 -c "print(1)"` read back as `python3 -c "print(1)` — an unterminated shell quote — and a title ending in `'7'` lost the closing mark. `formatYamlScalar` already refuses to write that shape: `stringNeedsJsonEncoding` returns true for a value matching /["']$/. So no page this writer produced was ever affected, and the loss fell entirely on frontmatter written by another hand. Measured on one such corpus: 1,378 of 3,655 pages, 1,376 of them in a `check` field, which is the command a claim is re-executed by. The rule is now a matched pair, which is what the writer's own encoder assumes. A quoted value still unwraps; an unmatched quote is data. The new test fails on the previous rule and passes on this one; 222 of 222 tests under src/ pass. --- src/frontmatter.test.ts | 18 ++++++++++++++++++ src/frontmatter.ts | 17 ++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/frontmatter.test.ts b/src/frontmatter.test.ts index 5aaee56..a9082e7 100644 --- a/src/frontmatter.test.ts +++ b/src/frontmatter.test.ts @@ -43,6 +43,24 @@ describe('frontmatter round-trip', () => { expect(roundTrip(values)).toEqual(values) }) + it('keeps the last character of a bare scalar that ends in a quote', () => { + // A leading and a trailing quote were stripped independently, so a value that merely ended in + // one lost a character. `check: python3 -c "print(1)"` read back unterminated and every claim + // it graded became unrunnable. + expect( + parseFrontmatter( + `---\ncheck: python3 -c "print(1)"\ntitle: the symbol '7'\nquoted: "still unwrapped"\n---\nBody\n`, + ), + ).toEqual({ + frontmatter: { + check: 'python3 -c "print(1)"', + title: "the symbol '7'", + quoted: 'still unwrapped', + }, + body: 'Body\n', + }) + }) + it('keeps reading the existing simple frontmatter syntax', () => { expect( parseFrontmatter( diff --git a/src/frontmatter.ts b/src/frontmatter.ts index 886c657..8877786 100644 --- a/src/frontmatter.ts +++ b/src/frontmatter.ts @@ -98,6 +98,21 @@ function stringNeedsJsonEncoding(value: string): boolean { return /^[[{"']/.test(value) || /["']$/.test(value) } +/** + * Strip a MATCHED surrounding quote pair, and nothing else. + * + * The previous rule stripped a leading or a trailing quote independently, so a bare scalar that + * merely ended in one lost its last character: `check: python3 -c "print(1)"` read back as + * `python3 -c "print(1)` — an unterminated shell quote that no longer runs. Measured on one + * corpus, 1,378 of 3,655 pages carried that shape, 1,376 of them in a `check` field. + * + * `formatYamlScalar` already refuses to WRITE the shape (`stringNeedsJsonEncoding` returns true + * for a value matching /["']$/), so no page this writer produced was ever affected; the loss fell + * on frontmatter written by any other hand. + */ function unquote(value: string): string { - return value.replace(/^['"]|['"]$/g, '') + if (value.length < 2) return value + const first = value[0] + if ((first === '"' || first === "'") && value.endsWith(first)) return value.slice(1, -1) + return value }