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,32 @@
{
"name": "@logicsrc/openprd",
"version": "0.1.0",
"description": "Reference implementation of the OpenPRD standard: numbered product requirements documents with front-matter, fixed sections, a lifecycle, and a LogicSRC task bridge.",
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/profullstack/logicsrc.git",
"directory": "packages/openprd"
},
"homepage": "https://logicsrc.com/docs/openprd",
"keywords": ["logicsrc", "openprd", "prd", "product-requirements", "standards", "cli"],
"publishConfig": { "access": "public" },
"files": ["dist"],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run src"
},
"dependencies": {
"@logicsrc/validators": "file:../validators",
"yaml": "^2.8.1"
},
"devDependencies": {
"vitest": "^4.0.8"
}
}

View file

@ -0,0 +1,136 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { formatId, parsePrd } from "./parse.js";
import type { PrdCollection, PrdDocument, PrdStatus } from "./types.js";
const TEMPLATE_FILE = "0000-template.md";
const INDEX_FILE = "README.md";
export class PrdCollectionError extends Error {
readonly code = "OP-L-COLLECTION";
constructor(message: string) {
super(message);
this.name = "PrdCollectionError";
}
}
/** Load every `NNNN-*.md` in a `prd/` directory, plus the template and index. */
export function loadPrdCollection(dir: string): PrdCollection {
const base = resolve(dir);
if (!existsSync(base)) {
throw new PrdCollectionError(`No PRD collection at ${base} — run \`logicsrc prd init\` first`);
}
const files = readdirSync(base)
.filter((file) => file.endsWith(".md") && file !== INDEX_FILE)
.sort();
const documents: PrdDocument[] = [];
const unparsed: PrdCollection["unparsed"] = [];
let template: PrdDocument | null = null;
for (const file of files) {
const path = join(base, file);
try {
const doc = parsePrd(readFileSync(path, "utf8"), path);
if (file === TEMPLATE_FILE) template = doc;
else documents.push(doc);
} catch (error) {
unparsed.push({ file, reason: (error as Error).message });
}
}
documents.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
const indexPath = join(base, INDEX_FILE);
return {
dir: base,
template,
documents,
unparsed,
indexRaw: existsSync(indexPath) ? readFileSync(indexPath, "utf8") : null
};
}
/** The next free number: highest existing + 1, never reserved in advance. */
export function nextPrdNumber(collection: PrdCollection): string {
const highest = collection.documents.reduce((max, doc) => {
const n = Number.parseInt(doc.filePrefix ?? "", 10);
return Number.isInteger(n) ? Math.max(max, n) : max;
}, 0);
return formatId(highest + 1);
}
export function findPrd(collection: PrdCollection, ref: string): PrdDocument | undefined {
const normalized = /^\d+$/.test(ref) ? formatId(Number.parseInt(ref, 10)) : ref;
return collection.documents.find(
(doc) =>
doc.frontMatter.id === normalized ||
doc.filePrefix === normalized ||
doc.file === ref ||
doc.slug === ref
);
}
export interface PrdSummary {
id: string;
title: string;
status: PrdStatus | string;
file: string;
authors: string;
tags: string;
requirements: number;
updated: string;
}
export function summarize(doc: PrdDocument): PrdSummary {
const fm = doc.frontMatter;
return {
id: fm.id ?? doc.filePrefix ?? "????",
title: fm.title ?? "(untitled)",
status: fm.status ?? "(none)",
file: doc.file,
authors: (fm.authors ?? []).join(", "),
tags: (fm.tags ?? []).join(", "),
requirements: doc.requirements.length,
updated: fm.updated ?? fm.created ?? ""
};
}
/**
* Render the `prd/README.md` index the standard calls for. Deterministic, so
* `prd index` is idempotent and CI can diff it.
*/
export function renderIndex(collection: PrdCollection, options: { title?: string } = {}): string {
const rows = collection.documents.map(summarize);
const lines = [
`# ${options.title ?? "LogicSRC PRDs"}`,
"",
"Numbered [OpenPRD](../docs/openprd.md) product requirements documents for this repo. One file",
"per PRD at `prd/<id>-<slug>.md`, four-digit ids, no gaps. `0000-template.md` is the copy-paste",
"starting point.",
"",
"Status lives in each file's front-matter and is the source of truth:",
"`Draft → Review → Accepted → Final`, or `Rejected` / `Withdrawn` / `Superseded`.",
"",
"<!-- generated by `logicsrc prd index --write`; edit the PRDs, not this table -->",
"",
"| ID | Title | Status | Tags |",
"| --- | --- | --- | --- |"
];
if (rows.length === 0) {
lines.push("| — | _No PRDs yet. Run `logicsrc prd new \"Title\"`._ | — | — |");
}
for (const row of rows) {
const escape = (value: string) => value.replace(/\|/g, "\\|");
lines.push(
`| [${row.id}](./${row.file}) | ${escape(row.title)} | ${row.status} | ${escape(row.tags)} |`
);
}
return `${lines.join("\n")}\n`;
}
export { TEMPLATE_FILE, INDEX_FILE };

View file

@ -0,0 +1,67 @@
/**
* @logicsrc/openprd reference implementation of the OpenPRD standard.
*
* The standard is docs/openprd.md plus `openprd-prd.schema.json`; this package
* implements it. A PRD is just a Markdown file with front-matter and eight
* sections it needs no service to exist, and none of this code to be valid.
*/
export { OPENPRD_VERSION, SECTIONS, STATUSES } from "./types.js";
export type * from "./types.js";
export {
formatId,
parsePrd,
rewriteFrontMatter,
slugify,
PrdParseError
} from "./parse.js";
export {
canTransition,
checkTransition,
isActive,
nextStatuses,
TRANSITIONS,
type TransitionCheck
} from "./lifecycle.js";
export {
reportFor,
validatePrdCollection,
validatePrdDocument,
type ValidateOptions
} from "./validate.js";
export {
findPrd,
loadPrdCollection,
nextPrdNumber,
renderIndex,
summarize,
INDEX_FILE,
TEMPLATE_FILE,
PrdCollectionError,
type PrdSummary
} from "./collection.js";
export {
createPrd,
initPrdCollection,
writeIndex,
TEMPLATE,
type CreateOptions,
type CreateResult,
type InitResult
} from "./scaffold.js";
export {
deriveCreatorDid,
prdToTasks,
validateTasks,
type TaskDocument,
type ToTasksOptions,
type ToTasksResult
} from "./tasks.js";
export { renderDocument, renderReport, type ReportFormat } from "./render.js";

View file

@ -0,0 +1,71 @@
import type { PrdStatus } from "./types.js";
/**
* The lifecycle from docs/openprd.md:
*
* Draft Review Accepted Final
* Rejected
* Withdrawn
* Superseded by NNNN
*
* Rejected, Withdrawn, and Superseded are terminal the standard keeps them
* on disk because the *why* is part of the record, not because they resume.
* A Final PRD can still be superseded by a follow-up.
*/
export const TRANSITIONS: Record<PrdStatus, PrdStatus[]> = {
Draft: ["Review", "Withdrawn"],
Review: ["Accepted", "Rejected", "Withdrawn", "Draft"],
Accepted: ["Final", "Superseded", "Withdrawn"],
Final: ["Superseded"],
Rejected: [],
Withdrawn: [],
Superseded: []
};
export function nextStatuses(from: PrdStatus): PrdStatus[] {
return TRANSITIONS[from] ?? [];
}
export function canTransition(from: PrdStatus, to: PrdStatus): boolean {
return nextStatuses(from).includes(to);
}
export interface TransitionCheck {
ok: boolean;
reason?: string;
/** Front-matter keys the transition requires alongside `status`. */
requires: string[];
}
export function checkTransition(
from: PrdStatus,
to: PrdStatus,
options: { supersededBy?: string | null } = {}
): TransitionCheck {
if (from === to) {
return { ok: false, reason: `PRD is already ${to}`, requires: [] };
}
if (!canTransition(from, to)) {
const allowed = nextStatuses(from);
return {
ok: false,
reason: allowed.length
? `${from} may only move to ${allowed.join(", ")}`
: `${from} is terminal; open a follow-up PRD instead`,
requires: []
};
}
if (to === "Superseded" && !options.supersededBy) {
return {
ok: false,
reason: "Superseded requires the id of the PRD that replaces this one",
requires: ["superseded-by"]
};
}
return { ok: true, requires: to === "Superseded" ? ["superseded-by"] : [] };
}
/** Statuses whose PRDs are still open work rather than historical record. */
export function isActive(status: PrdStatus): boolean {
return status === "Draft" || status === "Review" || status === "Accepted";
}

View 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");
});
});

View file

@ -0,0 +1,197 @@
import { basename } from "node:path";
import { parse as parseYaml } from "yaml";
import type { PrdDocument, PrdFrontMatter, Requirement, Section } from "./types.js";
export class PrdParseError extends Error {
readonly code = "OP-P-PARSE";
constructor(message: string, readonly file?: string) {
super(message);
this.name = "PrdParseError";
}
}
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
/** `0001-add-the-thing.md` → prefix `0001`, slug `add-the-thing`. */
const FILE_NAME = /^(\d{4})-(.+)\.md$/;
/**
* A requirement line. The standard writes them as `R1 [P0] …`; real PRDs also
* bold the marker (`**R1 [P0]**`) and bullet it. All three parse the same.
*/
const REQUIREMENT = /^\s*(?:[-*+]\s+)?\*{0,2}R(\d+)\*{0,2}\s*\*{0,2}\[(P[012])\]\*{0,2}\s*(.*)$/;
/** A requirement marker with no priority tag — caught as a lint finding. */
const REQUIREMENT_NO_PRIORITY = /^\s*(?:[-*+]\s+)?\*{0,2}R(\d+)\*{0,2}[.:)\s]+(?!\[P[012]\])(.*)$/;
export function parsePrd(source: string, path: string): PrdDocument {
const file = basename(path);
const match = FRONT_MATTER.exec(source);
if (!match) {
throw new PrdParseError(
`${file} has no YAML front-matter block (expected the file to open with '---')`,
file
);
}
const [, frontMatterRaw, body] = match as unknown as [string, string, string];
let frontMatter: PrdFrontMatter;
try {
frontMatter = (parseYaml(frontMatterRaw) ?? {}) as PrdFrontMatter;
} catch (error) {
throw new PrdParseError(`${file} front-matter is not valid YAML — ${(error as Error).message}`, file);
}
if (typeof frontMatter !== "object" || Array.isArray(frontMatter)) {
throw new PrdParseError(`${file} front-matter must be a YAML mapping`, file);
}
const nameMatch = FILE_NAME.exec(file);
// Line 1 is `---`; the body starts after the closing delimiter.
const bodyStartLine = frontMatterRaw.split("\n").length + 3;
return {
path,
file,
filePrefix: nameMatch?.[1] ?? null,
slug: nameMatch?.[2] ?? null,
frontMatter,
frontMatterRaw,
body,
heading: findHeading(body),
sections: findSections(body, bodyStartLine),
requirements: findRequirements(body, bodyStartLine)
};
}
function findHeading(body: string): string | null {
for (const line of body.split("\n")) {
if (line.startsWith("# ")) return line.slice(2).trim();
if (line.startsWith("## ")) return null; // a section started first
}
return null;
}
/**
* Sections are `##` headings only. `###` and deeper are content, so a PRD can
* organize a long Requirements section without inventing new sections.
*/
function findSections(body: string, offset: number): Section[] {
const lines = body.split("\n");
const sections: Section[] = [];
let fenced = false;
lines.forEach((line, index) => {
if (/^\s*(```|~~~)/.test(line)) fenced = !fenced;
if (fenced) return;
const heading = /^##\s+(.+?)\s*$/.exec(line);
if (!heading || line.startsWith("###")) return;
sections.push({
name: (heading[1] as string).trim(),
line: offset + index,
content: "",
empty: true
});
});
// Fill each section's content from its heading to the next one.
const headingIndexes = sections.map((section) => section.line - offset);
sections.forEach((section, i) => {
const from = (headingIndexes[i] as number) + 1;
const to = i + 1 < headingIndexes.length ? (headingIndexes[i + 1] as number) : lines.length;
const content = lines.slice(from, to).join("\n").trim();
section.content = content;
section.empty = content.length === 0;
});
return sections;
}
function findRequirements(body: string, offset: number): Requirement[] {
const requirements: Requirement[] = [];
let fenced = false;
body.split("\n").forEach((line, index) => {
if (/^\s*(```|~~~)/.test(line)) fenced = !fenced;
if (fenced) return;
const match = REQUIREMENT.exec(line);
if (match) {
requirements.push({
id: `R${match[1]}`,
number: Number.parseInt(match[1] as string, 10),
priority: match[2] as Requirement["priority"],
text: (match[3] as string).trim(),
line: offset + index
});
return;
}
const untagged = REQUIREMENT_NO_PRIORITY.exec(line);
if (untagged) {
requirements.push({
id: `R${untagged[1]}`,
number: Number.parseInt(untagged[1] as string, 10),
priority: null,
text: (untagged[2] as string).trim(),
line: offset + index
});
}
});
return requirements;
}
/** Kebab-case slug from a title, matching the filename convention. */
export function slugify(title: string): string {
return title
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 72)
.replace(/-+$/g, "");
}
/** Four-digit, zero-padded id. */
export function formatId(n: number): string {
return String(n).padStart(4, "0");
}
/**
* Rewrite a document's front-matter in place, preserving the body byte for
* byte. Only the keys given are touched; everything else keeps its position,
* comments, and formatting.
*/
export function rewriteFrontMatter(
source: string,
updates: Record<string, string | null>
): string {
const match = FRONT_MATTER.exec(source);
if (!match) throw new PrdParseError("Cannot rewrite front-matter: no block found");
const [, raw, body] = match as unknown as [string, string, string];
const lines = raw.split("\n");
const applied = new Set<string>();
const rendered = lines.map((line) => {
const keyMatch = /^([A-Za-z][A-Za-z0-9_-]*):(.*)$/.exec(line);
if (!keyMatch) return line;
const key = keyMatch[1] as string;
if (!(key in updates)) return line;
applied.add(key);
const value = updates[key];
return value === null || value === "" ? `${key}:` : `${key}: ${value}`;
});
for (const [key, value] of Object.entries(updates)) {
if (applied.has(key)) continue;
if (value === null || value === "") continue;
rendered.push(`${key}: ${value}`);
}
return `---\n${rendered.join("\n")}\n---\n${body}`;
}

View file

@ -0,0 +1,89 @@
import { stringify as toYaml } from "yaml";
import type { PrdDocument, ValidationReport } from "./types.js";
export type ReportFormat = "text" | "json" | "yaml" | "markdown";
export function renderReport(report: ValidationReport, format: ReportFormat = "text"): string {
if (format === "json") return JSON.stringify(report, null, 2);
if (format === "yaml") return toYaml(report).trimEnd();
if (format === "markdown") {
const lines = [
`# OpenPRD validation ${report.ok ? "passed" : "failed"}`,
"",
`- documents: ${report.checked.documents}`,
`- requirements: ${report.checked.requirements}`,
`- errors: ${report.counts.error}`,
`- warnings: ${report.counts.warning}`,
`- info: ${report.counts.info}`,
""
];
if (report.findings.length > 0) {
lines.push("| severity | code | file | line | message |", "| --- | --- | --- | --- | --- |");
for (const f of report.findings) {
lines.push(
`| ${f.severity} | ${f.code} | ${f.file ?? ""} | ${f.line ?? ""} | ${f.message.replace(/\|/g, "\\|")} |`
);
}
}
return lines.join("\n");
}
const lines: string[] = [];
lines.push(`${report.checked.documents} PRD${report.checked.documents === 1 ? "" : "s"}`);
lines.push(`${report.checked.requirements} requirements`);
for (const f of report.findings) {
const mark = f.severity === "error" ? "✗" : f.severity === "warning" ? "!" : "·";
const where = [f.file, f.line ? `line ${f.line}` : null].filter(Boolean).join(":");
lines.push(` ${mark} [${f.severity}] ${f.code} ${where ? `${where}` : ""}${f.message}`);
if (f.hint) lines.push(` hint: ${f.hint}`);
}
lines.push(
report.ok
? "OpenPRD collection is valid."
: `OpenPRD collection is INVALID (${report.counts.error} error(s), ${report.counts.warning} warning(s)).`
);
return lines.join("\n");
}
/** Human-readable single-document view for `logicsrc prd show`. */
export function renderDocument(doc: PrdDocument, format: "text" | "json" | "yaml" | "markdown" = "text"): string {
const fm = doc.frontMatter;
if (format === "json") return JSON.stringify(doc, null, 2);
if (format === "yaml") return toYaml(doc).trimEnd();
if (format === "markdown") return `---\n${doc.frontMatterRaw}\n---\n${doc.body}`;
const lines = [
`${fm.id ?? doc.filePrefix} ${fm.title ?? "(untitled)"}`,
`status: ${fm.status}${fm["superseded-by"] ? ` (superseded by ${fm["superseded-by"]})` : ""}`,
`file: ${doc.file}`,
`authors: ${(fm.authors ?? []).join(", ") || "(none)"}`,
...(fm.repo ? [`repo: ${fm.repo}`] : []),
...(fm.tags?.length ? [`tags: ${fm.tags.join(", ")}`] : []),
...(fm.created || fm.updated ? [`dates: created ${fm.created ?? "?"}, updated ${fm.updated ?? "?"}`] : []),
"",
"sections:"
];
for (const section of doc.sections) {
lines.push(` ${section.empty ? "·" : "✓"} ${section.name}${section.empty ? " (empty)" : ""}`);
}
if (doc.requirements.length > 0) {
lines.push("", `requirements (${doc.requirements.length}):`);
for (const requirement of doc.requirements) {
const priority = requirement.priority ?? "--";
lines.push(` ${requirement.id.padEnd(5)} [${priority}] ${truncate(requirement.text, 90)}`);
}
}
return lines.join("\n");
}
function truncate(value: string, max: number): string {
const plain = value.replace(/\*\*(.*?)\*\*/g, "$1").replace(/`([^`]*)`/g, "$1");
return plain.length <= max ? plain : `${plain.slice(0, max - 1)}`;
}

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);
});
});

View file

@ -0,0 +1,209 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { loadPrdCollection, nextPrdNumber, renderIndex, INDEX_FILE, TEMPLATE_FILE } from "./collection.js";
import { formatId, slugify } from "./parse.js";
import { OPENPRD_VERSION, SECTIONS, type PrdStatus } from "./types.js";
/**
* The canonical OpenPRD template. It lives in code so `prd init` works in any
* repo, with or without a checkout of the standard; `template.test.ts` asserts
* it stays identical to docs/openprd/0000-template.md.
*/
export const TEMPLATE = `---
openprd: "${OPENPRD_VERSION}"
id: "0000"
title: "Short imperative title — start with a verb if possible"
status: Draft
authors:
- you@example.com
created: 2026-01-01
updated: 2026-01-01
repo:
discussion:
implementation:
tags:
supersedes:
superseded-by:
---
## Problem
The user/business problem, and why it matters now. Cite the ask, the incident,
or the constraint not aesthetics.
## Goals
What success looks like, as outcomes (not features).
## Non-Goals
Explicitly out of scope, to bound the work.
## Users
Who this is for; personas or segments.
## Requirements
- R1 [P0] First required capability.
- R2 [P1] Next capability.
## UX Notes
Flows, states, and constraints that shape the experience.
## Success Metrics
How the goals will be measured.
## Risks & Open Questions
- Known risk or decision still owed.
`;
export interface InitResult {
dir: string;
created: string[];
skipped: string[];
}
/** Create a `prd/` collection: the template plus a generated index. */
export function initPrdCollection(dir: string, options: { title?: string } = {}): InitResult {
const base = resolve(dir);
mkdirSync(base, { recursive: true });
const created: string[] = [];
const skipped: string[] = [];
const templatePath = join(base, TEMPLATE_FILE);
if (existsSync(templatePath)) {
skipped.push(TEMPLATE_FILE);
} else {
writeFileSync(templatePath, TEMPLATE, "utf8");
created.push(TEMPLATE_FILE);
}
const indexPath = join(base, INDEX_FILE);
const index = renderIndex(loadPrdCollection(base), options);
if (existsSync(indexPath)) {
skipped.push(INDEX_FILE);
} else {
writeFileSync(indexPath, index, "utf8");
created.push(INDEX_FILE);
}
return { dir: base, created, skipped };
}
export interface CreateOptions {
title: string;
authors?: string[];
status?: PrdStatus;
repo?: string;
tags?: string[];
discussion?: string;
implementation?: string;
owner?: string;
supersedes?: string;
/** Pinned in tests so generated files are byte-identical across runs. */
today?: string;
/** Override the assigned number. Defaults to the next free one. */
id?: string;
}
export interface CreateResult {
id: string;
slug: string;
file: string;
path: string;
}
/**
* Write the next numbered PRD. The number is assigned at creation from what is
* on disk never reserved in advance, per the standard.
*/
export function createPrd(dir: string, options: CreateOptions): CreateResult {
const base = resolve(dir);
if (!existsSync(base)) mkdirSync(base, { recursive: true });
const collection = loadPrdCollection(base);
const id = options.id ? formatId(Number.parseInt(options.id, 10)) : nextPrdNumber(collection);
const slug = slugify(options.title);
if (!slug) throw new Error(`Cannot derive a slug from title ${JSON.stringify(options.title)}`);
const file = `${id}-${slug}.md`;
const path = join(base, file);
if (existsSync(path)) throw new Error(`${file} already exists`);
const today = options.today ?? new Date().toISOString().slice(0, 10);
const authors = options.authors?.length ? options.authors : ["you@example.com"];
const frontMatter = [
"---",
`openprd: "${OPENPRD_VERSION}"`,
`id: "${id}"`,
`title: ${yamlScalar(options.title)}`,
`status: ${options.status ?? "Draft"}`,
"authors:",
...authors.map((author) => ` - ${author}`),
...(options.owner ? [`owner: ${options.owner}`] : []),
`repo: ${options.repo ?? ""}`.trimEnd(),
`created: ${today}`,
`updated: ${today}`,
`discussion: ${options.discussion ?? ""}`.trimEnd(),
`implementation: ${options.implementation ?? ""}`.trimEnd(),
options.tags?.length ? `tags:\n${options.tags.map((tag) => ` - ${tag}`).join("\n")}` : "tags:",
`supersedes: ${options.supersedes ?? ""}`.trimEnd(),
"superseded-by:",
"---",
""
].join("\n");
const body = [
`# ${options.title}`,
"",
...SECTIONS.flatMap((section) => [`## ${section}`, "", placeholder(section), ""])
].join("\n");
writeFileSync(path, `${frontMatter}${body}`, "utf8");
return { id, slug, file, path };
}
function placeholder(section: string): string {
switch (section) {
case "Problem":
return "_TODO: the user/business problem, and why it matters now._";
case "Goals":
return "_TODO: what success looks like, as outcomes._";
case "Non-Goals":
return "_TODO: explicitly out of scope._";
case "Users":
return "_TODO: who this is for._";
case "Requirements":
return "- R1 [P0] _TODO: first required capability._";
case "UX Notes":
return "_TODO: flows, states, and constraints._";
case "Success Metrics":
return "_TODO: how the goals will be measured._";
default:
return "- _TODO: known risk or decision still owed._";
}
}
function yamlScalar(value: string): string {
return /[:#{}[\],&*?|<>=!%@`"']/.test(value) || /^\s|\s$/.test(value)
? JSON.stringify(value)
: value;
}
/** Rewrite `prd/README.md` from what is on disk. Returns true when it changed. */
export function writeIndex(dir: string, options: { title?: string } = {}): { changed: boolean; path: string } {
const base = resolve(dir);
const collection = loadPrdCollection(base);
const index = renderIndex(collection, options);
const path = join(base, INDEX_FILE);
const before = existsSync(path) ? readFileSync(path, "utf8") : null;
if (before === index) return { changed: false, path };
writeFileSync(path, index, "utf8");
return { changed: true, path };
}

View file

@ -0,0 +1,156 @@
import { validate as validateSchema } from "@logicsrc/validators";
import type { PrdDocument, Priority, Requirement } from "./types.js";
/**
* The optional LogicSRC bridge described in docs/openprd.md:
*
* "a PRD's Requirements map cleanly onto LogicSRC task documents
* (each R# one task), and owner/repo reuse LogicSRC identity and repo
* conventions. That bridge is optional and lives in tooling."
*
* So it lives here, in tooling the standard itself stays a file format with
* no service behind it.
*/
export interface TaskDocument {
type: "logicsrc.task";
version: string;
title: string;
description: string;
board: string;
creator_did: string;
status: string;
skills?: string[];
github_repo?: string;
external_links?: string[];
logicsrc_version?: string;
}
export interface ToTasksOptions {
/** LogicSRC DID. Derived from the first author when omitted. */
creator?: string;
/** Board path. Defaults to `/prd/<id>`. */
board?: string;
status?: string;
/** Only convert requirements at these priorities. */
priorities?: Priority[];
}
export interface ToTasksResult {
tasks: TaskDocument[];
skipped: Array<{ requirement: string; reason: string }>;
}
/**
* LogicSRC DIDs look like `name.namespace`. An author email maps onto that
* shape predictably: `anthony@profullstack.com` `anthony.profullstack`.
*/
export function deriveCreatorDid(author: string | undefined): string {
if (!author) return "openprd.local";
const trimmed = author.trim();
if (/^[a-z0-9][a-z0-9._-]*\.[a-z0-9][a-z0-9._-]*$/.test(trimmed) && !trimmed.includes("@")) {
return trimmed;
}
const at = trimmed.indexOf("@");
if (at > 0) {
const local = sanitize(trimmed.slice(0, at));
const domain = trimmed.slice(at + 1);
const org = sanitize(domain.split(".")[0] ?? "local");
if (local && org) return `${local}.${org}`;
}
const fallback = sanitize(trimmed);
return fallback ? `${fallback}.local` : "openprd.local";
}
function sanitize(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9._-]/g, "-")
.replace(/^[^a-z0-9]+/, "")
.replace(/[^a-z0-9]+$/, "");
}
export function prdToTasks(doc: PrdDocument, options: ToTasksOptions = {}): ToTasksResult {
const fm = doc.frontMatter;
const id = fm.id ?? doc.filePrefix ?? "0000";
const creator = options.creator ?? deriveCreatorDid(fm.owner ?? fm.authors?.[0]);
const board = options.board ?? `/prd/${id}`;
const tasks: TaskDocument[] = [];
const skipped: ToTasksResult["skipped"] = [];
for (const requirement of doc.requirements) {
if (options.priorities && (!requirement.priority || !options.priorities.includes(requirement.priority))) {
skipped.push({
requirement: requirement.id,
reason: `priority ${requirement.priority ?? "none"} not in the requested set`
});
continue;
}
if (!requirement.text) {
skipped.push({ requirement: requirement.id, reason: "requirement has no text" });
continue;
}
tasks.push(toTask(doc, requirement, { creator, board, status: options.status ?? "draft", id }));
}
return { tasks, skipped };
}
function toTask(
doc: PrdDocument,
requirement: Requirement,
ctx: { creator: string; board: string; status: string; id: string }
): TaskDocument {
const fm = doc.frontMatter;
const plain = stripMarkdown(requirement.text);
const prefix = `${ctx.id} ${requirement.id}`;
const title = truncate(`${prefix}: ${plain}`, 160);
const task: TaskDocument = {
type: "logicsrc.task",
version: "0.1",
title,
description: `${plain}\n\nFrom OpenPRD ${ctx.id} "${fm.title}" (${doc.file}, line ${requirement.line}).`,
board: ctx.board,
creator_did: ctx.creator,
status: ctx.status
};
if (requirement.priority) task.skills = [requirement.priority.toLowerCase()];
if (fm.repo) task.github_repo = fm.repo;
const links = [fm.discussion, fm.implementation].filter((link): link is string => Boolean(link));
if (links.length) task.external_links = links;
return task;
}
function stripMarkdown(text: string): string {
return text
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.trim();
}
function truncate(value: string, max: number): string {
return value.length <= max ? value : `${value.slice(0, max - 1).trimEnd()}`;
}
/** Validate emitted tasks against the LogicSRC task schema. */
export function validateTasks(tasks: TaskDocument[]): Array<{ index: number; errors: string[] }> {
const problems: Array<{ index: number; errors: string[] }> = [];
tasks.forEach((task, index) => {
const result = validateSchema("task", task);
if (result.ok) return;
problems.push({
index,
errors: result.errors.map((error) => `${error.instancePath || "/"} ${error.message ?? "invalid"}`)
});
});
return problems;
}

View file

@ -0,0 +1,124 @@
/**
* TypeScript surface for the OpenPRD standard (docs/openprd.md).
*
* The normative contracts are the standard document plus
* `openprd-prd.schema.json` (front-matter). These types describe the parsed
* document that tooling exchanges the CLI, SDK, and any MCP surface all
* speak this shape.
*/
export const OPENPRD_VERSION = "0.2";
/** The eight `##` sections, in the order the standard requires. */
export const SECTIONS = [
"Problem",
"Goals",
"Non-Goals",
"Users",
"Requirements",
"UX Notes",
"Success Metrics",
"Risks & Open Questions"
] as const;
export type SectionName = (typeof SECTIONS)[number];
export const STATUSES = [
"Draft",
"Review",
"Accepted",
"Final",
"Rejected",
"Withdrawn",
"Superseded"
] as const;
export type PrdStatus = (typeof STATUSES)[number];
export type Priority = "P0" | "P1" | "P2";
/** The YAML front-matter block, validated by openprd-prd.schema.json. */
export interface PrdFrontMatter {
openprd: string;
id: string;
title: string;
status: PrdStatus;
authors?: string[] | null;
owner?: string | null;
repo?: string | null;
created?: string | null;
updated?: string | null;
discussion?: string | null;
implementation?: string | null;
tags?: string[] | null;
supersedes?: string | null;
"superseded-by"?: string | null;
}
export interface Section {
name: string;
/** 1-based line of the `## ` heading. */
line: number;
content: string;
empty: boolean;
}
export interface Requirement {
/** `R1`, `R2`, … as written. */
id: string;
number: number;
priority: Priority | null;
text: string;
line: number;
}
export interface PrdDocument {
/** Path as given (absolute or relative). */
path: string;
/** Basename, e.g. `0001-add-the-thing.md`. */
file: string;
/** Four-digit prefix parsed from the filename, or null when malformed. */
filePrefix: string | null;
slug: string | null;
frontMatter: PrdFrontMatter;
/** Raw front-matter text, for round-trip-safe rewrites. */
frontMatterRaw: string;
body: string;
/** H1 heading immediately after the front-matter, when present. */
heading: string | null;
sections: Section[];
requirements: Requirement[];
}
export type Severity = "error" | "warning" | "info";
export interface Finding {
code: string;
severity: Severity;
message: string;
file?: string;
line?: number;
hint?: string;
}
export interface ValidationReport {
ok: boolean;
findings: Finding[];
counts: Record<Severity, number>;
checked: {
documents: number;
sections: number;
requirements: number;
};
}
export interface PrdCollection {
dir: string;
/** `0000-template.md`, when present. */
template: PrdDocument | null;
documents: PrdDocument[];
/** Files that could not be parsed at all, with the reason. */
unparsed: Array<{ file: string; reason: string }>;
/** Existing `README.md` index contents, when present. */
indexRaw: string | null;
}

View file

@ -0,0 +1,315 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { loadPrdCollection, renderIndex } from "./collection.js";
import { canTransition, checkTransition, nextStatuses } from "./lifecycle.js";
import { parsePrd } from "./parse.js";
import { createPrd, initPrdCollection, writeIndex } from "./scaffold.js";
import { reportFor, validatePrdCollection, validatePrdDocument } from "./validate.js";
import type { PrdStatus } from "./types.js";
const dirs: string[] = [];
afterAll(() => {
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
});
function scratch(): string {
const dir = mkdtempSync(join(tmpdir(), "openprd-"));
dirs.push(dir);
return dir;
}
/** A conforming PRD, which each test then breaks in exactly one way. */
function conforming(overrides: { frontMatter?: string; body?: string } = {}): string {
const frontMatter =
overrides.frontMatter ??
`openprd: "0.2"
id: "0001"
title: Do the thing
status: Draft
authors:
- a@example.com
created: 2026-07-01
updated: 2026-07-02`;
const body =
overrides.body ??
`## Problem
Something hurts.
## Goals
Make it stop.
## Non-Goals
_None._
## Users
Everyone.
## Requirements
- R1 [P0] First capability.
## UX Notes
_None._
## Success Metrics
It stops hurting.
## Risks & Open Questions
- Might not stop.`;
return `---\n${frontMatter}\n---\n\n${body}\n`;
}
const codes = (source: string, file = "0001-do-the-thing.md", options = {}) =>
validatePrdDocument(parsePrd(source, file), options).map((finding) => finding.code);
describe("document conformance", () => {
it("accepts a conforming PRD with no errors", () => {
const report = reportFor(parsePrd(conforming(), "0001-do-the-thing.md"));
expect(report.findings.filter((f) => f.severity === "error")).toEqual([]);
expect(report.ok).toBe(true);
});
it("rejects a filename without a four-digit id", () => {
expect(codes(conforming(), "do-the-thing.md")).toContain("OP-C-FILENAME");
});
it("rejects a non-kebab-case slug", () => {
expect(codes(conforming(), "0001-Do_The_Thing.md")).toContain("OP-C-SLUG-FORM");
});
it("rejects front-matter that fails the schema", () => {
const missingStatus = conforming({
frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Do the thing`
});
expect(codes(missingStatus)).toContain("OP-C-FRONTMATTER");
});
it("rejects an unknown status value", () => {
const bad = conforming({
frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Do the thing\nstatus: Shipped`
});
expect(codes(bad)).toContain("OP-C-FRONTMATTER");
});
it("rejects an id that does not match the filename prefix", () => {
const mismatch = conforming({
frontMatter: `openprd: "0.2"\nid: "0009"\ntitle: Do the thing\nstatus: Draft`
});
expect(codes(mismatch)).toContain("OP-C-ID-MISMATCH");
});
it("rejects a missing section", () => {
const withoutUsers = conforming().replace("## Users\n\nEveryone.\n\n", "");
const found = codes(withoutUsers);
expect(found).toContain("OP-C-SECTION-MISSING");
});
it("rejects sections that are out of order", () => {
const swapped = conforming()
.replace("## Problem\n\nSomething hurts.", "## Goals\n\nMake it stop.")
.replace("## Goals\n\nMake it stop.\n\n## Non-Goals", "## Problem\n\nSomething hurts.\n\n## Non-Goals");
expect(codes(swapped)).toContain("OP-C-SECTION-ORDER");
});
it("treats a non-standard section as info, not an error", () => {
const extra = conforming().replace("## UX Notes", "## Appendix\n\nExtra.\n\n## UX Notes");
const findings = validatePrdDocument(parsePrd(extra, "0001-do-the-thing.md"));
const extraFinding = findings.find((f) => f.code === "OP-L-EXTRA-SECTION");
expect(extraFinding?.severity).toBe("info");
expect(findings.filter((f) => f.severity === "error")).toEqual([]);
});
it("accepts a section whose body is just _None._", () => {
expect(codes(conforming())).not.toContain("OP-L-EMPTY-SECTION");
});
});
describe("document lint", () => {
it("warns about an empty section and escalates it under --strict", () => {
const empty = conforming().replace("## UX Notes\n\n_None._", "## UX Notes\n");
const lenient = validatePrdDocument(parsePrd(empty, "0001-do-the-thing.md"));
const strict = validatePrdDocument(parsePrd(empty, "0001-do-the-thing.md"), { strict: true });
expect(lenient.find((f) => f.code === "OP-L-EMPTY-SECTION")?.severity).toBe("warning");
expect(strict.find((f) => f.code === "OP-L-EMPTY-SECTION")?.severity).toBe("error");
});
it("warns when a requirement has no priority tag", () => {
const untagged = conforming().replace("- R1 [P0] First capability.", "- R1 First capability.");
expect(codes(untagged)).toContain("OP-L-REQ-PRIORITY");
});
it("errors on duplicate requirement ids", () => {
const duplicated = conforming().replace(
"- R1 [P0] First capability.",
"- R1 [P0] First capability.\n- R1 [P1] Same number again."
);
const findings = validatePrdDocument(parsePrd(duplicated, "0001-do-the-thing.md"));
expect(findings.find((f) => f.code === "OP-L-REQ-DUPLICATE")?.severity).toBe("error");
});
it("warns when requirement numbering skips", () => {
const gap = conforming().replace(
"- R1 [P0] First capability.",
"- R1 [P0] First capability.\n- R5 [P1] Jumped."
);
expect(codes(gap)).toContain("OP-L-REQ-NUMBERING");
});
it("warns when a PRD lists no authors", () => {
const noAuthors = conforming({
frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Do the thing\nstatus: Draft`
});
expect(codes(noAuthors)).toContain("OP-L-NO-AUTHOR");
});
it("errors when updated is before created", () => {
const backwards = conforming({
frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Do the thing\nstatus: Draft\nauthors:\n - a@example.com\ncreated: 2026-07-10\nupdated: 2026-07-01`
});
const findings = validatePrdDocument(parsePrd(backwards, "0001-do-the-thing.md"));
expect(findings.find((f) => f.code === "OP-L-DATE-ORDER")?.severity).toBe("error");
});
it("errors when status is Superseded with no replacement named", () => {
const superseded = conforming({
frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Do the thing\nstatus: Superseded\nauthors:\n - a@example.com`
});
expect(codes(superseded)).toContain("OP-L-SUPERSEDED-BY");
});
it("errors when a PRD supersedes itself", () => {
const selfRef = conforming({
frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Do the thing\nstatus: Draft\nauthors:\n - a@example.com\nsupersedes: "0001"`
});
expect(codes(selfRef)).toContain("OP-L-SELF-REFERENCE");
});
it("notes when the slug does not summarize the title", () => {
expect(codes(conforming(), "0001-something-else-entirely.md")).toContain("OP-L-SLUG-DRIFT");
});
});
describe("collection rules", () => {
function collectionWith(files: Record<string, string>): ReturnType<typeof loadPrdCollection> {
const dir = scratch();
initPrdCollection(dir);
for (const [name, contents] of Object.entries(files)) {
writeFileSync(join(dir, name), contents, "utf8");
}
return loadPrdCollection(dir);
}
it("accepts a freshly initialized collection", () => {
const dir = scratch();
initPrdCollection(dir);
createPrd(dir, { title: "Do the thing", authors: ["a@example.com"], today: "2026-07-26" });
writeIndex(dir);
const collection = loadPrdCollection(dir);
const report = validatePrdCollection(collection, { expectedIndex: renderIndex(collection) });
expect(report.findings.filter((f) => f.severity === "error")).toEqual([]);
expect(report.ok).toBe(true);
});
it("errors on a numbering gap", () => {
const collection = collectionWith({
"0001-one.md": conforming(),
"0003-three.md": conforming({
frontMatter: `openprd: "0.2"\nid: "0003"\ntitle: Three\nstatus: Draft\nauthors:\n - a@example.com`
})
});
const report = validatePrdCollection(collection);
expect(report.findings.map((f) => f.code)).toContain("OP-C-NUMBERING-GAP");
expect(report.ok).toBe(false);
});
it("errors on a duplicate id across two files", () => {
const collection = collectionWith({
"0001-one.md": conforming(),
"0002-two.md": conforming({
frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Two\nstatus: Draft\nauthors:\n - a@example.com`
})
});
expect(validatePrdCollection(collection).findings.map((f) => f.code)).toContain("OP-C-DUPLICATE-ID");
});
it("errors when a cross-reference points outside the collection", () => {
const collection = collectionWith({
"0001-one.md": conforming({
frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: One\nstatus: Draft\nauthors:\n - a@example.com\nsupersedes: "0099"`
})
});
expect(validatePrdCollection(collection).findings.map((f) => f.code)).toContain("OP-C-UNKNOWN-REFERENCE");
});
it("warns when supersession is recorded on only one side", () => {
const collection = collectionWith({
"0001-one.md": conforming({
frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: One\nstatus: Superseded\nauthors:\n - a@example.com\nsuperseded-by: "0002"`
}),
"0002-two.md": conforming({
frontMatter: `openprd: "0.2"\nid: "0002"\ntitle: Two\nstatus: Draft\nauthors:\n - a@example.com`
})
});
expect(validatePrdCollection(collection).findings.map((f) => f.code)).toContain("OP-L-ONE-SIDED-REFERENCE");
});
it("reports an unparseable file instead of skipping it", () => {
const collection = collectionWith({ "0001-broken.md": "# no front matter\n" });
const report = validatePrdCollection(collection);
expect(report.findings.map((f) => f.code)).toContain("OP-P-PARSE");
expect(report.ok).toBe(false);
});
it("warns when the index is stale", () => {
const dir = scratch();
initPrdCollection(dir);
createPrd(dir, { title: "Unindexed", authors: ["a@example.com"], today: "2026-07-26" });
const collection = loadPrdCollection(dir);
const report = validatePrdCollection(collection, { expectedIndex: renderIndex(collection) });
expect(report.findings.map((f) => f.code)).toContain("OP-L-INDEX-STALE");
});
});
describe("lifecycle", () => {
it("follows the transitions in the standard", () => {
expect(nextStatuses("Draft")).toEqual(["Review", "Withdrawn"]);
expect(canTransition("Review", "Accepted")).toBe(true);
expect(canTransition("Accepted", "Final")).toBe(true);
expect(canTransition("Final", "Superseded")).toBe(true);
});
it("refuses to skip stages", () => {
expect(canTransition("Draft", "Final")).toBe(false);
expect(canTransition("Draft", "Accepted")).toBe(false);
const check = checkTransition("Draft", "Final");
expect(check.ok).toBe(false);
expect(check.reason).toMatch(/Review, Withdrawn/);
});
it("treats Rejected, Withdrawn, and Superseded as terminal", () => {
for (const status of ["Rejected", "Withdrawn", "Superseded"] as PrdStatus[]) {
expect(nextStatuses(status)).toEqual([]);
expect(checkTransition(status, "Draft").reason).toMatch(/terminal/);
}
});
it("requires a replacement id to mark a PRD Superseded", () => {
expect(checkTransition("Accepted", "Superseded").ok).toBe(false);
expect(checkTransition("Accepted", "Superseded", { supersededBy: "0002" }).ok).toBe(true);
});
it("refuses a no-op transition", () => {
expect(checkTransition("Draft", "Draft").reason).toMatch(/already/);
});
});

View file

@ -0,0 +1,409 @@
import { validate as validateSchema } from "@logicsrc/validators";
import { slugify } from "./parse.js";
import { SECTIONS, type Finding, type PrdCollection, type PrdDocument, type Severity, type ValidationReport } from "./types.js";
export interface ValidateOptions {
/** Promote lint warnings to errors, for CI that wants a clean collection. */
strict?: boolean;
/** The version the collection targets. Mismatches are reported. */
expectedVersion?: string;
}
const TEMPLATE_ID = "0000";
/**
* Conformance, straight from docs/openprd.md:
*
* - lives at prd/<id>-<slug>.md with a four-digit <id>
* - front-matter validates against openprd-prd.schema.json
* - id equals the filename's numeric prefix
* - all eight body sections are present in order
*
* Everything beyond those four is lint: useful, but never the difference
* between conforming and not.
*/
export function validatePrdDocument(doc: PrdDocument, options: ValidateOptions = {}): Finding[] {
const findings: Finding[] = [];
const lint: Severity = options.strict ? "error" : "warning";
const add = (finding: Finding) => findings.push({ file: doc.file, ...finding });
const isTemplate = doc.filePrefix === TEMPLATE_ID;
/* ── 1. Filename ─────────────────────────────────────────────────────── */
if (!doc.filePrefix) {
add({
code: "OP-C-FILENAME",
severity: "error",
message: `${doc.file} is not named <id>-<slug>.md with a four-digit id`,
hint: "Rename to prd/0001-short-kebab-title.md"
});
} else if (doc.slug && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(doc.slug)) {
add({
code: "OP-C-SLUG-FORM",
severity: "error",
message: `${doc.file} slug "${doc.slug}" is not kebab-case`,
hint: "Lowercase letters, digits, and single hyphens only"
});
}
/* ── 2. Front-matter schema ──────────────────────────────────────────── */
const result = validateSchema("openprd-prd", doc.frontMatter);
if (!result.ok) {
for (const error of result.errors) {
add({
code: "OP-C-FRONTMATTER",
severity: "error",
line: 2,
message: `front-matter ${error.instancePath || "/"} ${error.message ?? "failed validation"}`,
hint: "See packages/schemas/schemas/openprd-prd.schema.json"
});
}
}
/* ── 3. id matches the filename prefix ───────────────────────────────── */
if (doc.filePrefix && doc.frontMatter.id && doc.frontMatter.id !== doc.filePrefix) {
add({
code: "OP-C-ID-MISMATCH",
severity: "error",
line: 2,
message: `front-matter id "${doc.frontMatter.id}" does not match filename prefix "${doc.filePrefix}"`
});
}
/* ── 4. The eight sections, present and in order ─────────────────────── */
const present = doc.sections.map((section) => section.name);
const expected = [...SECTIONS];
for (const name of expected) {
if (!present.includes(name)) {
add({
code: "OP-C-SECTION-MISSING",
severity: "error",
message: `missing required section "## ${name}"`,
hint: `The eight sections are: ${expected.join(", ")}`
});
}
}
const required = present.filter((name) => expected.includes(name as (typeof SECTIONS)[number]));
const ordered = expected.filter((name) => required.includes(name));
if (required.length === ordered.length && required.join("|") !== ordered.join("|")) {
add({
code: "OP-C-SECTION-ORDER",
severity: "error",
message: `sections are out of order: found ${required.join(" → ")}, expected ${ordered.join(" → ")}`
});
}
const extra = present.filter((name) => !expected.includes(name as (typeof SECTIONS)[number]));
for (const name of extra) {
add({
code: "OP-L-EXTRA-SECTION",
severity: "info",
line: doc.sections.find((s) => s.name === name)?.line,
message: `"## ${name}" is not one of the eight standard sections`,
hint: "Use a ### subsection inside a standard section instead"
});
}
/* ── Lint from here down ─────────────────────────────────────────────── */
if (options.expectedVersion && doc.frontMatter.openprd !== options.expectedVersion) {
add({
code: "OP-L-VERSION",
severity: lint,
line: 2,
message: `declares openprd "${doc.frontMatter.openprd}" but the collection targets "${options.expectedVersion}"`
});
}
for (const section of doc.sections) {
if (!expected.includes(section.name as (typeof SECTIONS)[number])) continue;
if (!section.empty) continue;
add({
code: "OP-L-EMPTY-SECTION",
severity: isTemplate ? "info" : lint,
line: section.line,
message: `section "## ${section.name}" is empty`,
hint: "A single line such as _None._ is enough"
});
}
if (!isTemplate) {
if ((doc.frontMatter.authors?.length ?? 0) === 0) {
add({
code: "OP-L-NO-AUTHOR",
severity: lint,
line: 2,
message: "no authors listed",
hint: "The standard expects at least one author"
});
}
if (doc.slug && doc.frontMatter.title) {
const fromTitle = slugify(doc.frontMatter.title);
if (fromTitle && doc.slug !== fromTitle && !fromTitle.startsWith(doc.slug) && !doc.slug.startsWith(fromTitle)) {
add({
code: "OP-L-SLUG-DRIFT",
severity: "info",
message: `slug "${doc.slug}" does not summarize the title (expected something like "${fromTitle}")`
});
}
}
if (doc.heading && doc.frontMatter.title && doc.heading !== doc.frontMatter.title) {
add({
code: "OP-L-HEADING-DRIFT",
severity: "info",
message: `H1 "${doc.heading}" differs from front-matter title "${doc.frontMatter.title}"`
});
}
}
/* ── Requirements ────────────────────────────────────────────────────── */
const requirementsSection = doc.sections.find((section) => section.name === "Requirements");
if (requirementsSection && !requirementsSection.empty && doc.requirements.length === 0 && !isTemplate) {
add({
code: "OP-L-NO-REQUIREMENTS",
severity: lint,
line: requirementsSection.line,
message: "Requirements section has no numbered R# entries",
hint: "One capability per line: - R1 [P0] …"
});
}
const seen = new Map<number, number>();
for (const requirement of doc.requirements) {
if (!requirement.priority) {
add({
code: "OP-L-REQ-PRIORITY",
severity: lint,
line: requirement.line,
message: `${requirement.id} has no priority tag`,
hint: "Prefix each requirement with [P0], [P1], or [P2]"
});
}
const first = seen.get(requirement.number);
if (first !== undefined) {
add({
code: "OP-L-REQ-DUPLICATE",
severity: "error",
line: requirement.line,
message: `duplicate requirement id ${requirement.id} (first seen on line ${first})`
});
} else {
seen.set(requirement.number, requirement.line);
}
}
const numbers = [...seen.keys()].sort((a, b) => a - b);
numbers.forEach((n, index) => {
if (n === index + 1) return;
const previous = index === 0 ? 0 : (numbers[index - 1] as number);
if (n === previous + 1) return;
add({
code: "OP-L-REQ-NUMBERING",
severity: lint,
line: seen.get(n),
message: `requirement numbering jumps from R${previous} to R${n}`,
hint: "Number requirements contiguously from R1"
});
});
/* ── Dates and supersession ──────────────────────────────────────────── */
const { created, updated, status } = doc.frontMatter;
if (created && updated && updated < created) {
add({
code: "OP-L-DATE-ORDER",
severity: "error",
line: 2,
message: `updated (${updated}) is before created (${created})`
});
}
const supersededBy = doc.frontMatter["superseded-by"];
if (status === "Superseded" && !supersededBy) {
add({
code: "OP-L-SUPERSEDED-BY",
severity: "error",
line: 2,
message: "status is Superseded but superseded-by names no replacement"
});
}
if (supersededBy && status !== "Superseded") {
add({
code: "OP-L-SUPERSEDED-STATUS",
severity: lint,
line: 2,
message: `superseded-by is set to ${supersededBy} but status is ${status}`
});
}
if (doc.frontMatter.supersedes && doc.frontMatter.supersedes === doc.frontMatter.id) {
add({
code: "OP-L-SELF-REFERENCE",
severity: "error",
line: 2,
message: "supersedes points at this PRD itself"
});
}
return findings;
}
/**
* Collection-level rules: numbering with no gaps, unique ids, resolvable
* cross-references, and an index that matches what is on disk.
*/
export function validatePrdCollection(
collection: PrdCollection,
options: ValidateOptions & { expectedIndex?: string } = {}
): ValidationReport {
const findings: Finding[] = [];
const lint: Severity = options.strict ? "error" : "warning";
for (const { file, reason } of collection.unparsed) {
findings.push({ code: "OP-P-PARSE", severity: "error", file, message: reason });
}
for (const doc of [...(collection.template ? [collection.template] : []), ...collection.documents]) {
findings.push(...validatePrdDocument(doc, options));
}
const byId = new Map<string, PrdDocument[]>();
for (const doc of collection.documents) {
const id = doc.frontMatter.id ?? doc.filePrefix ?? "????";
byId.set(id, [...(byId.get(id) ?? []), doc]);
}
for (const [id, docs] of byId) {
if (docs.length > 1) {
findings.push({
code: "OP-C-DUPLICATE-ID",
severity: "error",
file: docs.map((d) => d.file).join(", "),
message: `id ${id} is used by ${docs.length} files`
});
}
}
// "Four-digit, zero-padded, monotonically increasing, no gaps."
const numbers = collection.documents
.map((doc) => Number.parseInt(doc.filePrefix ?? "", 10))
.filter((n) => Number.isInteger(n))
.sort((a, b) => a - b);
numbers.forEach((n, index) => {
const expected = index + 1;
if (n === expected) return;
const previous = index === 0 ? 0 : (numbers[index - 1] as number);
if (n === previous + 1) return;
findings.push({
code: "OP-C-NUMBERING-GAP",
severity: "error",
message: `numbering jumps from ${String(previous).padStart(4, "0")} to ${String(n).padStart(4, "0")}`,
hint: "Ids are monotonically increasing with no gaps; 0000 is reserved for the template"
});
});
if (collection.documents.some((doc) => doc.filePrefix === "0000")) {
findings.push({
code: "OP-C-TEMPLATE-ID",
severity: "error",
message: "0000 is reserved for the template",
hint: "Rename the PRD to the next free number"
});
}
const ids = new Set(collection.documents.map((doc) => doc.frontMatter.id ?? doc.filePrefix));
for (const doc of collection.documents) {
for (const [field, target] of [
["supersedes", doc.frontMatter.supersedes],
["superseded-by", doc.frontMatter["superseded-by"]]
] as const) {
if (!target) continue;
if (!ids.has(target)) {
findings.push({
code: "OP-C-UNKNOWN-REFERENCE",
severity: "error",
file: doc.file,
line: 2,
message: `${field} points at ${target}, which is not in this collection`
});
continue;
}
const other = collection.documents.find((d) => (d.frontMatter.id ?? d.filePrefix) === target);
const reciprocal = field === "supersedes" ? other?.frontMatter["superseded-by"] : other?.frontMatter.supersedes;
if (reciprocal !== (doc.frontMatter.id ?? doc.filePrefix)) {
findings.push({
code: "OP-L-ONE-SIDED-REFERENCE",
severity: lint,
file: doc.file,
line: 2,
message: `${field}: ${target} is not reciprocated by ${other?.file ?? target}`,
hint: "Supersession should be recorded on both PRDs"
});
}
}
}
if (!collection.template) {
findings.push({
code: "OP-L-NO-TEMPLATE",
severity: "info",
message: "no 0000-template.md in the collection",
hint: "Run `logicsrc prd init` to add the template and index"
});
}
if (options.expectedIndex !== undefined) {
if (collection.indexRaw === null) {
findings.push({
code: "OP-L-NO-INDEX",
severity: "info",
message: "no README.md index in the collection",
hint: "Run `logicsrc prd index --write`"
});
} else if (collection.indexRaw.trim() !== options.expectedIndex.trim()) {
findings.push({
code: "OP-L-INDEX-STALE",
severity: lint,
file: "README.md",
message: "index does not match the PRDs on disk",
hint: "Run `logicsrc prd index --write`"
});
}
}
const counts: Record<Severity, number> = { error: 0, warning: 0, info: 0 };
for (const finding of findings) counts[finding.severity] += 1;
return {
ok: counts.error === 0,
findings,
counts,
checked: {
documents: collection.documents.length,
sections: collection.documents.reduce((n, doc) => n + doc.sections.length, 0),
requirements: collection.documents.reduce((n, doc) => n + doc.requirements.length, 0)
}
};
}
/** Report for a single document, without collection-level rules. */
export function reportFor(doc: PrdDocument, options: ValidateOptions = {}): ValidationReport {
const findings = validatePrdDocument(doc, options);
const counts: Record<Severity, number> = { error: 0, warning: 0, info: 0 };
for (const finding of findings) counts[finding.severity] += 1;
return {
ok: counts.error === 0,
findings,
counts,
checked: { documents: 1, sections: doc.sections.length, requirements: doc.requirements.length }
};
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}