From c66674abf491be5ab2b0e177bdc69f1dfc9910f1 Mon Sep 17 00:00:00 2001 From: clawedassistant26 <307253840+clawedassistant26@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:18:03 +0000 Subject: [PATCH] fix(prd): stop body lines being read as a PRD's title and status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listPrds scanned the first sixteen lines of each PRD and kept the last `title:`/`status:` match it found, without stopping at the closing `---`. A PRD whose body starts a line with one of those keys — a YAML sample in a fenced code block, say — therefore had its real front matter overwritten, so `prd list` and the README index showed the sample's values instead. Parse the delimited front-matter block instead. Files with no front matter now fall back to the slug and `?` rather than lifting values out of prose. Existing PRDs render byte-identically. --- src/prd.mjs | 15 +++++++++++- test/prd.test.mjs | 59 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/prd.mjs b/src/prd.mjs index 76bcbda..78b3f01 100644 --- a/src/prd.mjs +++ b/src/prd.mjs @@ -199,6 +199,19 @@ function unquoteYaml(value) { return raw; } +// The front matter is the block between the leading `---` and its closing +// `---`; everything after that is prose. Reading a fixed window of leading +// lines instead let a body line that merely starts with `title:`/`status:` — +// a YAML sample in a fenced code block, say — win, because the scan kept the +// last match it saw anywhere in the window. It also cut off longer front +// matter. Return the block itself, or nothing when the file has none. +function frontMatter(text) { + const lines = text.split(/\r?\n/); + if (lines[0]?.trim() !== "---") return []; + const end = lines.findIndex((l, i) => i > 0 && l.trim() === "---"); + return end === -1 ? [] : lines.slice(1, end); +} + /** List numbered PRDs (NNNN-slug.md, excluding the 0000 template). */ export function listPrds(root = process.cwd()) { const base = prdDir(root); @@ -211,7 +224,7 @@ export function listPrds(root = process.cwd()) { const file = path.join(base, name); let title = m[2], status = "?"; try { - const head = fs.readFileSync(file, "utf8").split(/\r?\n/).slice(0, 16); + const head = frontMatter(fs.readFileSync(file, "utf8")); for (const l of head) { const t = l.match(/^title:\s*(.+)$/); if (t) title = unquoteYaml(t[1]); const s = l.match(/^status:\s*(.+)$/); if (s) status = unquoteYaml(s[1]); diff --git a/test/prd.test.mjs b/test/prd.test.mjs index 2c66dde..638bf89 100644 --- a/test/prd.test.mjs +++ b/test/prd.test.mjs @@ -108,3 +108,62 @@ test("createPrd hides the whole idea even when it contains a comment terminator" fs.rmSync(root, { recursive: true, force: true }); } }); + +test("listPrds ignores body lines that look like front matter", () => { + // A PRD documenting an API often shows a YAML sample in a fenced block. Those + // lines start at column 0 like real keys do, so a scan that runs past the + // closing `---` reads them as the PRD's own title and status. + const root = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-prd-")); + try { + const dir = path.join(root, "prd"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "0001-job-api.md"), [ + "---", + 'title: "Job API v2"', + "status: Accepted", + "---", + "", + "## Requirements", + "", + "- R1 [P0] Return the job record:", + "", + "```yaml", + "title: nightly-import", + "status: queued", + "```", + "", + ].join("\n")); + + const [prd] = listPrds(root); + assert.equal(prd.title, "Job API v2"); + assert.equal(prd.status, "Accepted"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("listPrds does not take a title or status from a PRD with no front matter", () => { + // A doc dropped into prd/ without front matter has no declared status. Prose + // is not front matter, so fall back to the slug and the unknown marker rather + // than lifting whatever a line happens to start with. + const root = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-prd-")); + try { + const dir = path.join(root, "prd"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "0002-import-notes.md"), [ + "# Import notes", + "", + "Fields the importer needs from each upstream row:", + "", + "title: taken from the H1", + "status: derived, never authored by hand", + "", + ].join("\n")); + + const [prd] = listPrds(root); + assert.equal(prd.title, "import-notes"); + assert.equal(prd.status, "?"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});