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:
Anthony Ettinger 2026-07-28 04:11:07 -07:00 committed by GitHub
parent 58c942c67f
commit 296775e003
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 3605 additions and 5 deletions

View file

@ -0,0 +1,294 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, describe, expect, it } from "vitest";
import { loadPrdCollection, nextPrdNumber, renderIndex } from "./collection.js";
import { parsePrd } from "./parse.js";
import { createPrd, initPrdCollection, TEMPLATE, writeIndex } from "./scaffold.js";
import { deriveCreatorDid, prdToTasks, validateTasks } from "./tasks.js";
import { validatePrdCollection, validatePrdDocument } from "./validate.js";
import { SECTIONS } from "./types.js";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, "../../..");
const dirs: string[] = [];
afterAll(() => {
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
});
function scratch(): string {
const dir = mkdtempSync(join(tmpdir(), "openprd-scaffold-"));
dirs.push(dir);
return dir;
}
describe("init", () => {
it("creates a template and an index", () => {
const dir = scratch();
const result = initPrdCollection(dir);
expect(result.created.sort()).toEqual(["0000-template.md", "README.md"]);
expect(existsSync(join(dir, "0000-template.md"))).toBe(true);
});
it("is idempotent — a second run keeps what is already there", () => {
const dir = scratch();
initPrdCollection(dir);
const second = initPrdCollection(dir);
expect(second.created).toEqual([]);
expect(second.skipped.sort()).toEqual(["0000-template.md", "README.md"]);
});
it("ships a template that itself conforms to the standard", () => {
const doc = parsePrd(TEMPLATE, "0000-template.md");
expect(doc.sections.map((s) => s.name)).toEqual([...SECTIONS]);
const errors = validatePrdDocument(doc).filter((f) => f.severity === "error");
expect(errors).toEqual([]);
});
});
describe("new", () => {
it("assigns the next free number and writes a conforming PRD", () => {
const dir = scratch();
initPrdCollection(dir);
const first = createPrd(dir, { title: "Do the thing", authors: ["a@example.com"], today: "2026-07-26" });
expect(first.id).toBe("0001");
expect(first.file).toBe("0001-do-the-thing.md");
const doc = parsePrd(readFileSync(first.path, "utf8"), first.path);
expect(doc.sections.map((s) => s.name)).toEqual([...SECTIONS]);
expect(validatePrdDocument(doc).filter((f) => f.severity === "error")).toEqual([]);
});
it("numbers from what is on disk rather than reserving in advance", () => {
const dir = scratch();
initPrdCollection(dir);
createPrd(dir, { title: "One", today: "2026-07-26" });
createPrd(dir, { title: "Two", today: "2026-07-26" });
expect(nextPrdNumber(loadPrdCollection(dir))).toBe("0003");
const third = createPrd(dir, { title: "Three", today: "2026-07-26" });
expect(third.id).toBe("0003");
});
it("carries front-matter through from the options", () => {
const dir = scratch();
initPrdCollection(dir);
const created = createPrd(dir, {
title: "Expand the parked-domain service",
authors: ["anthony@profullstack.com"],
repo: "profullstack/logicsrc",
tags: ["growth", "dns"],
today: "2026-07-26"
});
const doc = parsePrd(readFileSync(created.path, "utf8"), created.path);
expect(doc.frontMatter).toMatchObject({
id: "0001",
title: "Expand the parked-domain service",
status: "Draft",
repo: "profullstack/logicsrc",
created: "2026-07-26",
updated: "2026-07-26"
});
expect(doc.frontMatter.tags).toEqual(["growth", "dns"]);
});
it("quotes a title containing YAML-significant characters", () => {
const dir = scratch();
initPrdCollection(dir);
const created = createPrd(dir, { title: "Fix: the thing [again]", today: "2026-07-26" });
const doc = parsePrd(readFileSync(created.path, "utf8"), created.path);
expect(doc.frontMatter.title).toBe("Fix: the thing [again]");
});
it("refuses to overwrite an existing file", () => {
const dir = scratch();
initPrdCollection(dir);
createPrd(dir, { title: "One", today: "2026-07-26" });
expect(() => createPrd(dir, { title: "One", id: "0001", today: "2026-07-26" })).toThrow(/already exists/);
});
it("rejects a title that yields no slug", () => {
const dir = scratch();
initPrdCollection(dir);
expect(() => createPrd(dir, { title: "!!!", today: "2026-07-26" })).toThrow(/slug/);
});
});
describe("index", () => {
it("is deterministic and idempotent", () => {
const dir = scratch();
initPrdCollection(dir);
createPrd(dir, { title: "One", today: "2026-07-26" });
const first = writeIndex(dir);
expect(first.changed).toBe(true);
expect(writeIndex(dir).changed).toBe(false);
expect(renderIndex(loadPrdCollection(dir))).toBe(renderIndex(loadPrdCollection(dir)));
});
it("lists every PRD with its status and links to the file", () => {
const dir = scratch();
initPrdCollection(dir);
createPrd(dir, { title: "One", tags: ["growth"], today: "2026-07-26" });
createPrd(dir, { title: "Two", status: "Review", today: "2026-07-26" });
writeIndex(dir);
const index = readFileSync(join(dir, "README.md"), "utf8");
expect(index).toContain("[0001](./0001-one.md)");
expect(index).toContain("[0002](./0002-two.md)");
expect(index).toContain("Review");
expect(index).toContain("growth");
});
it("renders a placeholder row for an empty collection", () => {
const dir = scratch();
initPrdCollection(dir);
expect(renderIndex(loadPrdCollection(dir))).toContain("No PRDs yet");
});
});
describe("task bridge", () => {
const dir = (() => {
const d = scratch();
initPrdCollection(d);
createPrd(d, {
title: "Expand the parked-domain service",
authors: ["anthony@profullstack.com"],
repo: "profullstack/logicsrc",
today: "2026-07-26"
});
return d;
})();
const doc = () => loadPrdCollection(dir).documents[0]!;
it("derives a LogicSRC DID from an author email", () => {
expect(deriveCreatorDid("anthony@profullstack.com")).toBe("anthony.profullstack");
expect(deriveCreatorDid("already.did")).toBe("already.did");
expect(deriveCreatorDid(undefined)).toBe("openprd.local");
});
it("emits one schema-valid task per requirement", () => {
const { tasks } = prdToTasks(doc());
expect(tasks).toHaveLength(doc().requirements.length);
expect(validateTasks(tasks)).toEqual([]);
expect(tasks[0]).toMatchObject({
type: "logicsrc.task",
board: "/prd/0001",
creator_did: "anthony.profullstack",
github_repo: "profullstack/logicsrc",
status: "draft"
});
});
it("keeps titles inside the schema's 160-character limit", () => {
const long = "x".repeat(400);
const parsed = parsePrd(
`---\nopenprd: "0.2"\nid: "0001"\ntitle: Long\nstatus: Draft\n---\n\n## Requirements\n\n- R1 [P0] ${long}\n`,
"0001-long.md"
);
const { tasks } = prdToTasks(parsed);
expect(tasks[0]!.title.length).toBeLessThanOrEqual(160);
expect(validateTasks(tasks)).toEqual([]);
});
it("filters by priority and reports what it skipped", () => {
const parsed = parsePrd(
`---\nopenprd: "0.2"\nid: "0001"\ntitle: Mixed\nstatus: Draft\n---\n\n## Requirements\n\n- R1 [P0] Must.\n- R2 [P2] Maybe.\n`,
"0001-mixed.md"
);
const { tasks, skipped } = prdToTasks(parsed, { priorities: ["P0"] });
expect(tasks).toHaveLength(1);
expect(skipped[0]).toMatchObject({ requirement: "R2" });
});
it("records where each task came from", () => {
const { tasks } = prdToTasks(doc());
expect(tasks[0]!.description).toMatch(/From OpenPRD 0001 .* line \d+/);
});
});
/**
* Dogfood: this repo's own collection and standard document must satisfy the
* implementation. If the standard changes, these fail first.
*/
describe("this repository", () => {
const prdDir = join(REPO, "prd");
const hasCollection = existsSync(join(prdDir, "0001-add-logicsrc-openontology-spec.md"));
const maybe = hasCollection ? it : it.skip;
maybe("has a conforming prd/ collection with a current index", () => {
const collection = loadPrdCollection(prdDir);
const report = validatePrdCollection(collection, { expectedIndex: renderIndex(collection) });
const problems = report.findings.filter((f) => f.severity === "error" || f.severity === "warning");
expect(problems).toEqual([]);
expect(report.ok).toBe(true);
});
maybe("keeps the embedded template identical to docs/openprd/0000-template.md", () => {
const onDisk = readFileSync(join(REPO, "docs/openprd/0000-template.md"), "utf8");
expect(TEMPLATE).toBe(onDisk);
});
maybe("keeps prd/0000-template.md identical to the embedded template", () => {
expect(readFileSync(join(prdDir, "0000-template.md"), "utf8")).toBe(TEMPLATE);
});
maybe("maps every requirement in PRD 0001 onto a valid task", () => {
const collection = loadPrdCollection(prdDir);
const doc = collection.documents.find((d) => d.frontMatter.id === "0001");
const { tasks } = prdToTasks(doc!);
expect(tasks.length).toBe(doc!.requirements.length);
expect(validateTasks(tasks)).toEqual([]);
});
});
describe("conformance fixtures", () => {
const fixtures = join(REPO, "packages/schemas/fixtures/openprd");
const hasFixtures = existsSync(join(fixtures, "conformance.json"));
const maybe = hasFixtures ? it : it.skip;
maybe("validates every valid fixture and rejects every invalid one", () => {
const manifest = JSON.parse(readFileSync(join(fixtures, "conformance.json"), "utf8")) as {
valid: Array<{ fixture: string; file: string }>;
invalid: Array<{ fixture: string; file: string; code: string; reason: string }>;
};
for (const entry of manifest.valid) {
const doc = parsePrd(readFileSync(join(fixtures, entry.fixture), "utf8"), entry.file);
const errors = validatePrdDocument(doc).filter((f) => f.severity === "error");
expect(errors, `${entry.fixture} should conform`).toEqual([]);
}
for (const entry of manifest.invalid) {
let codes: string[] = [];
try {
const doc = parsePrd(readFileSync(join(fixtures, entry.fixture), "utf8"), entry.file);
codes = validatePrdDocument(doc)
.filter((f) => f.severity === "error")
.map((f) => f.code);
} catch (error) {
codes = [(error as { code?: string }).code ?? "OP-P-PARSE"];
}
expect(codes, `${entry.fixture} should fail with ${entry.code}`).toContain(entry.code);
}
});
});
/** Keeps the scratch helper honest: a collection we build must round-trip. */
describe("round trip", () => {
it("survives init → new → index → load → validate", () => {
const dir = scratch();
initPrdCollection(dir);
createPrd(dir, { title: "Round trip", authors: ["a@example.com"], today: "2026-07-26" });
writeFileSync(join(dir, "notes.txt"), "ignored by the loader", "utf8");
writeIndex(dir);
const collection = loadPrdCollection(dir);
expect(collection.documents).toHaveLength(1);
expect(collection.template).not.toBeNull();
expect(validatePrdCollection(collection, { expectedIndex: renderIndex(collection) }).ok).toBe(true);
});
});