Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/prd.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]);
Expand Down
59 changes: 59 additions & 0 deletions test/prd.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
Loading