From 9407146b0e22fa7a036d7acecb96d70ab1a6bc7b Mon Sep 17 00:00:00 2001 From: clawedassistant26 <307253840+clawedassistant26@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:30:33 +0000 Subject: [PATCH] fix(prd): stop PRD titles corrupting the README index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit regenerateIndex() passed the new index block to String.replace() as a replacement string, so a PRD title containing a special pattern ($&, $`, $', $$) was interpreted instead of inserted literally — splicing the old index back into itself and corrupting prd/README.md. Use a function replacement (() => block) so the block is written verbatim regardless of title content. Adds a regression test. --- src/prd.mjs | 6 +++++- test/prd.test.mjs | 28 +++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/prd.mjs b/src/prd.mjs index cd4713f..96aee1b 100644 --- a/src/prd.mjs +++ b/src/prd.mjs @@ -217,9 +217,13 @@ export function regenerateIndex(root = process.cwd()) { ? ["| # | Title | Status |", "|---|---|---|", ...prds.map((p) => `| [${p.id}](${p.file}) | ${p.title} | ${p.status} |`)].join("\n") : "_No PRDs yet._"; + // Use a function replacement so `$`-sequences in a PRD title (e.g. `$&`, `$1`, + // `$\`` ) are inserted verbatim instead of being read as String.replace special + // patterns, which would otherwise splice the match back in and corrupt the index. + const block = `${INDEX_START}\n${rows}\n${INDEX_END}`; const next = body.replace( new RegExp(`${INDEX_START}[\\s\\S]*${INDEX_END}`), - `${INDEX_START}\n${rows}\n${INDEX_END}`, + () => block, ); if (next === body) return false; fs.writeFileSync(readme, next); diff --git a/test/prd.test.mjs b/test/prd.test.mjs index b9e5852..45acbcb 100644 --- a/test/prd.test.mjs +++ b/test/prd.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; -import { renderPrd } from "../src/prd.mjs"; +import { createPrd, renderPrd } from "../src/prd.mjs"; test("renderPrd quotes titles so YAML metacharacters stay valid", () => { const body = renderPrd({ @@ -13,3 +16,26 @@ test("renderPrd quotes titles so YAML metacharacters stay valid", () => { assert.match(body, /^title: "Ship CLI: handle \\"quoted\\" flags"$/m); }); + + +test("regenerateIndex keeps the README intact when a title holds a String.replace pattern", () => { + // `$&`, `$\``, `$'`, `$$` are special in a String.replace *replacement string*. + // A PRD title carrying one must not splice the old index block back into itself. + const root = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-prd-")); + try { + createPrd("Add $& live support", root); + createPrd("Improve docs", root); + + const readme = fs.readFileSync(path.join(root, "prd", "README.md"), "utf8"); + const starts = (readme.match(/PRD-INDEX:START/g) || []).length; + const ends = (readme.match(/PRD-INDEX:END/g) || []).length; + + assert.equal(starts, 1, "index start marker must appear exactly once"); + assert.equal(ends, 1, "index end marker must appear exactly once"); + // The literal title text survives; it is not expanded into the match. + assert.match(readme, /\$& live support/); + assert.match(readme, /Improve docs/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});