mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-01 20:33:50 +00:00
OpenPRD 0.2 fixed eight body sections, none of which asked what the thing is
built on or how it earns. The stack got chosen in the first implementation PR
instead of at review, and a PRD could be filled out completely without anyone
writing down who pays. PRD 0006 had already grown a hand-rolled
`## Business model` section, which is the gap showing.
0.3 adds two required sections between `UX Notes` and `Success Metrics`:
- Tech Stack — languages, frameworks, datastores, third-party services, and
anything the work must not depend on. It makes the requirements costable.
- Monetization — the revenue model: who pays, for what, how much, and when.
`_None._` stays a valid answer, but it now has to be said out loud.
Adding required sections would normally invalidate every document already
written, so a document is now held to the section list its own `openprd:` key
fixes. A 0.2 document keeps conforming with eight sections, forever; a 0.3
document needs ten. Adoption is per document, and `logicsrc prd validate
--expect-version 0.3` (new flag, wiring up the validator option that already
existed) reports the stragglers as OP-L-VERSION.
The front-matter schema is untouched — both additions are body sections.
Conformance bundle proves both directions: invalid/missing-monetization.md
fails with OP-C-SECTION-MISSING, and valid/legacy-0-2.md passes unedited.
This repo's own PRDs 0001-0006 stay at 0.2 as standing evidence that the
compatibility rule holds. PRD 0007 records the decision at 0.3.
Claude-Session: https://claude.ai/code/session_017XRNNm6pK6nPi7rJ6bJNHu
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
196 lines
6.2 KiB
TypeScript
196 lines
6.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { formatId, parsePrd, PrdParseError, rewriteFrontMatter, slugify } from "./parse.js";
|
|
import { SECTIONS } from "./types.js";
|
|
|
|
const MINIMAL = `---
|
|
openprd: "0.3"
|
|
id: "0007"
|
|
title: Do the thing
|
|
status: Draft
|
|
authors:
|
|
- a@example.com
|
|
---
|
|
|
|
# Do the thing
|
|
|
|
## Problem
|
|
|
|
Something hurts.
|
|
|
|
## Goals
|
|
|
|
Make it stop.
|
|
|
|
## Non-Goals
|
|
|
|
_None._
|
|
|
|
## Users
|
|
|
|
Everyone.
|
|
|
|
## Requirements
|
|
|
|
- R1 [P0] First capability.
|
|
- R2 [P1] Second capability.
|
|
|
|
## UX Notes
|
|
|
|
_None._
|
|
|
|
## Tech Stack
|
|
|
|
Node and Postgres.
|
|
|
|
## Monetization
|
|
|
|
_None._
|
|
|
|
## Success Metrics
|
|
|
|
It stops hurting.
|
|
|
|
## Risks & Open Questions
|
|
|
|
- Might not stop.
|
|
`;
|
|
|
|
describe("parsePrd", () => {
|
|
const doc = parsePrd(MINIMAL, "/repo/prd/0007-do-the-thing.md");
|
|
|
|
it("splits front-matter from body and parses the YAML", () => {
|
|
expect(doc.frontMatter.id).toBe("0007");
|
|
expect(doc.frontMatter.title).toBe("Do the thing");
|
|
expect(doc.frontMatter.authors).toEqual(["a@example.com"]);
|
|
expect(doc.body.startsWith("\n# Do the thing")).toBe(true);
|
|
});
|
|
|
|
it("derives the id prefix and slug from the filename", () => {
|
|
expect(doc.filePrefix).toBe("0007");
|
|
expect(doc.slug).toBe("do-the-thing");
|
|
expect(doc.file).toBe("0007-do-the-thing.md");
|
|
});
|
|
|
|
it("finds all ten sections in order", () => {
|
|
expect(doc.sections.map((s) => s.name)).toEqual([...SECTIONS]);
|
|
});
|
|
|
|
it("captures the H1 heading separately from the sections", () => {
|
|
expect(doc.heading).toBe("Do the thing");
|
|
});
|
|
|
|
it("parses requirements with ids, priorities, and line numbers", () => {
|
|
expect(doc.requirements).toHaveLength(2);
|
|
expect(doc.requirements[0]).toMatchObject({ id: "R1", number: 1, priority: "P0", text: "First capability." });
|
|
expect(doc.requirements[1]?.priority).toBe("P1");
|
|
expect(doc.requirements[0]?.line).toBeGreaterThan(1);
|
|
});
|
|
|
|
it("rejects a file with no front-matter", () => {
|
|
expect(() => parsePrd("# Just markdown\n", "x.md")).toThrow(PrdParseError);
|
|
});
|
|
|
|
it("rejects front-matter that is not a mapping", () => {
|
|
expect(() => parsePrd("---\n- a\n- b\n---\n\n## Problem\n", "x.md")).toThrow(/mapping/);
|
|
});
|
|
|
|
it("reports invalid YAML rather than silently continuing", () => {
|
|
expect(() => parsePrd('---\ntitle: "unterminated\n---\n\nbody\n', "x.md")).toThrow(/not valid YAML/);
|
|
});
|
|
|
|
it("treats ### as content, not as a section boundary", () => {
|
|
const withSub = MINIMAL.replace(
|
|
"## Requirements\n",
|
|
"## Requirements\n\n### Product identity\n\nSome prose.\n"
|
|
);
|
|
const parsed = parsePrd(withSub, "0007-do-the-thing.md");
|
|
expect(parsed.sections.map((s) => s.name)).toEqual([...SECTIONS]);
|
|
expect(parsed.sections.find((s) => s.name === "Requirements")?.content).toContain("### Product identity");
|
|
});
|
|
|
|
it("ignores headings and requirement-shaped lines inside code fences", () => {
|
|
const withFence = MINIMAL.replace(
|
|
"## UX Notes\n",
|
|
"## UX Notes\n\n```txt\n## Not A Section\n- R9 [P0] not a real requirement\n```\n"
|
|
);
|
|
const parsed = parsePrd(withFence, "0007-do-the-thing.md");
|
|
expect(parsed.sections.map((s) => s.name)).toEqual([...SECTIONS]);
|
|
expect(parsed.requirements.map((r) => r.id)).toEqual(["R1", "R2"]);
|
|
});
|
|
|
|
it("accepts the bold requirement style real PRDs use", () => {
|
|
const bold = MINIMAL.replace("- R1 [P0] First capability.", "- **R1 [P0]** First capability.");
|
|
const parsed = parsePrd(bold, "0007-do-the-thing.md");
|
|
expect(parsed.requirements[0]).toMatchObject({ id: "R1", priority: "P0", text: "First capability." });
|
|
});
|
|
|
|
it("still records a requirement that is missing its priority tag", () => {
|
|
const untagged = MINIMAL.replace("- R2 [P1] Second capability.", "- R2 Second capability.");
|
|
const parsed = parsePrd(untagged, "0007-do-the-thing.md");
|
|
expect(parsed.requirements[1]).toMatchObject({ id: "R2", priority: null });
|
|
});
|
|
|
|
it("marks an empty section as empty", () => {
|
|
const emptied = MINIMAL.replace("## UX Notes\n\n_None._\n", "## UX Notes\n\n");
|
|
const parsed = parsePrd(emptied, "0007-do-the-thing.md");
|
|
expect(parsed.sections.find((s) => s.name === "UX Notes")?.empty).toBe(true);
|
|
expect(parsed.sections.find((s) => s.name === "Problem")?.empty).toBe(false);
|
|
});
|
|
|
|
it("flags a malformed filename by leaving the prefix null", () => {
|
|
const parsed = parsePrd(MINIMAL, "notes.md");
|
|
expect(parsed.filePrefix).toBeNull();
|
|
expect(parsed.slug).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("slugify and formatId", () => {
|
|
it("kebab-cases a title", () => {
|
|
expect(slugify("Add the LogicSRC OpenOntology specification")).toBe(
|
|
"add-the-logicsrc-openontology-specification"
|
|
);
|
|
});
|
|
|
|
it("strips punctuation, accents, and repeated separators", () => {
|
|
expect(slugify("Ship “Café” — v2.0!")).toBe("ship-cafe-v2-0");
|
|
});
|
|
|
|
it("never leaves a trailing hyphen after truncation", () => {
|
|
const slug = slugify("a".repeat(80));
|
|
expect(slug.endsWith("-")).toBe(false);
|
|
expect(slug.length).toBeLessThanOrEqual(72);
|
|
});
|
|
|
|
it("zero-pads to four digits", () => {
|
|
expect(formatId(1)).toBe("0001");
|
|
expect(formatId(42)).toBe("0042");
|
|
});
|
|
});
|
|
|
|
describe("rewriteFrontMatter", () => {
|
|
it("updates a key in place and leaves the body byte-identical", () => {
|
|
const updated = rewriteFrontMatter(MINIMAL, { status: "Review" });
|
|
expect(updated).toContain("status: Review");
|
|
expect(updated.split("---\n")[2]).toBe(MINIMAL.split("---\n")[2]);
|
|
});
|
|
|
|
it("appends a key that was not present", () => {
|
|
const updated = rewriteFrontMatter(MINIMAL, { updated: "2026-07-28" });
|
|
expect(updated).toContain("updated: 2026-07-28");
|
|
});
|
|
|
|
it("blanks a key when given null, keeping the line", () => {
|
|
const withRepo = rewriteFrontMatter(MINIMAL, { repo: "owner/name" });
|
|
const cleared = rewriteFrontMatter(withRepo, { repo: null });
|
|
expect(cleared).toContain("repo:");
|
|
expect(cleared).not.toContain("owner/name");
|
|
});
|
|
|
|
it("leaves other keys untouched", () => {
|
|
const updated = rewriteFrontMatter(MINIMAL, { status: "Accepted" });
|
|
const doc = parsePrd(updated, "0007-do-the-thing.md");
|
|
expect(doc.frontMatter.title).toBe("Do the thing");
|
|
expect(doc.frontMatter.authors).toEqual(["a@example.com"]);
|
|
expect(doc.frontMatter.status).toBe("Accepted");
|
|
});
|
|
});
|