mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 14:57:28 +00:00
feat(openprd): implement the OpenPRD standard — engine, CLI, conformance bundle (#100)
OpenPRD has existed as a document (docs/openprd.md), a front-matter schema, a
template, and this repo's prd/ collection. Nothing enforced it. This adds the
reference implementation.
@logicsrc/openprd
- parser: front-matter + the eight `##` sections + numbered requirements.
`###` stays content so a long Requirements section can be organized, and
headings or R#-shaped lines inside code fences are ignored
- validation splits the standard's four conformance rules (filename,
front-matter schema, id-matches-prefix, eight sections in order) from
lint (empty section, missing priority tag, numbering gaps, duplicate R#,
date order, one-sided supersession, stale index). Conformance failures are
errors; --strict promotes the rest. Stable codes, file, line, hint
- collection rules the per-file view cannot see: unique ids, monotonic
numbering with no gaps, 0000 reserved for the template, cross-references
that resolve
- lifecycle enforced rather than advisory: Draft cannot jump to Final,
terminal statuses do not resume, Superseded must name its replacement
- deterministic index generation, so `prd index` is idempotent and CI can
diff it
- front-matter rewriting that leaves the body byte-identical
- the optional LogicSRC task bridge the standard describes: each R# becomes
one logicsrc.task, validated against logicsrc-task.schema.json before it
is emitted; creator DID derived from the author email
CLI: logicsrc prd init|new|list|show|validate|lint|index|status|next|tasks|
export. Exit codes stable for CI (0 ok, 1 invalid, 2 usage, 3 not found).
Conformance bundle: packages/schemas/fixtures/openprd/ — 6 documents that must
validate and 12 that must fail, each naming the error code it must produce.
Several rules depend on the filename, so every fixture records the name it is
validated as.
Docs: an Implementation section in docs/openprd.md (CLI, validation model,
task bridge, conformance bundle), the spec added to the site's docs surface,
nav and sitemap entries, and a README section.
Verification: 76 new tests; full monorepo build and all 451 workspace tests
pass. The suite dogfoods this repo — prd/ validates with zero errors and zero
warnings, the embedded template is byte-identical to docs/openprd/0000-
template.md, and all 210 requirements in PRD 0001 map to schema-valid tasks.
prd/README.md is regenerated by the tool it now ships.
Refs: docs/openprd.md
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
58c942c67f
commit
296775e003
45 changed files with 3605 additions and 5 deletions
188
packages/openprd/src/parse.test.ts
Normal file
188
packages/openprd/src/parse.test.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { formatId, parsePrd, PrdParseError, rewriteFrontMatter, slugify } from "./parse.js";
|
||||
import { SECTIONS } from "./types.js";
|
||||
|
||||
const MINIMAL = `---
|
||||
openprd: "0.2"
|
||||
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._
|
||||
|
||||
## 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 eight 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue