From 6a2c120a09b87c635ed41e25d327b3e7f90ab32a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 28 Jul 2026 11:10:42 +0000 Subject: [PATCH] =?UTF-8?q?feat(openprd):=20implement=20the=20OpenPRD=20st?= =?UTF-8?q?andard=20=E2=80=94=20engine,=20CLI,=20conformance=20bundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 19 + apps/logicsrc-web/src/app/sitemap.ts | 1 + .../src/components/site-shell.tsx | 1 + apps/logicsrc-web/src/lib/docs.ts | 1 + apps/logicsrc-web/src/lib/page-markup.ts | 1 + docs/openprd.md | 50 ++- package-lock.json | 17 + package.json | 4 +- packages/cli/package.json | 1 + packages/cli/src/index.ts | 2 + packages/cli/src/prd.ts | 361 ++++++++++++++++ packages/openprd/package.json | 32 ++ packages/openprd/src/collection.ts | 136 ++++++ packages/openprd/src/index.ts | 67 +++ packages/openprd/src/lifecycle.ts | 71 +++ packages/openprd/src/parse.test.ts | 188 ++++++++ packages/openprd/src/parse.ts | 197 +++++++++ packages/openprd/src/render.ts | 89 ++++ packages/openprd/src/scaffold.test.ts | 294 +++++++++++++ packages/openprd/src/scaffold.ts | 209 +++++++++ packages/openprd/src/tasks.ts | 156 +++++++ packages/openprd/src/types.ts | 124 ++++++ packages/openprd/src/validate.test.ts | 315 ++++++++++++++ packages/openprd/src/validate.ts | 409 ++++++++++++++++++ packages/openprd/tsconfig.json | 9 + .../schemas/fixtures/openprd/conformance.json | 104 +++++ .../fixtures/openprd/invalid/bad-filename.md | 41 ++ .../fixtures/openprd/invalid/bad-id-format.md | 41 ++ .../fixtures/openprd/invalid/bad-status.md | 41 ++ .../openprd/invalid/dates-backwards.md | 43 ++ .../openprd/invalid/duplicate-requirement.md | 42 ++ .../fixtures/openprd/invalid/id-mismatch.md | 41 ++ .../openprd/invalid/missing-section.md | 37 ++ .../fixtures/openprd/invalid/missing-title.md | 40 ++ .../openprd/invalid/no-front-matter.md | 33 ++ .../fixtures/openprd/invalid/out-of-order.md | 41 ++ .../invalid/superseded-without-replacement.md | 41 ++ .../fixtures/openprd/invalid/unknown-key.md | 42 ++ .../openprd/valid/bold-requirements.md | 41 ++ .../schemas/fixtures/openprd/valid/full.md | 48 ++ .../schemas/fixtures/openprd/valid/minimal.md | 41 ++ .../fixtures/openprd/valid/none-sections.md | 41 ++ .../fixtures/openprd/valid/subsections.md | 47 ++ .../fixtures/openprd/valid/superseded.md | 42 ++ prd/README.md | 9 +- 45 files changed, 3605 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/prd.ts create mode 100644 packages/openprd/package.json create mode 100644 packages/openprd/src/collection.ts create mode 100644 packages/openprd/src/index.ts create mode 100644 packages/openprd/src/lifecycle.ts create mode 100644 packages/openprd/src/parse.test.ts create mode 100644 packages/openprd/src/parse.ts create mode 100644 packages/openprd/src/render.ts create mode 100644 packages/openprd/src/scaffold.test.ts create mode 100644 packages/openprd/src/scaffold.ts create mode 100644 packages/openprd/src/tasks.ts create mode 100644 packages/openprd/src/types.ts create mode 100644 packages/openprd/src/validate.test.ts create mode 100644 packages/openprd/src/validate.ts create mode 100644 packages/openprd/tsconfig.json create mode 100644 packages/schemas/fixtures/openprd/conformance.json create mode 100644 packages/schemas/fixtures/openprd/invalid/bad-filename.md create mode 100644 packages/schemas/fixtures/openprd/invalid/bad-id-format.md create mode 100644 packages/schemas/fixtures/openprd/invalid/bad-status.md create mode 100644 packages/schemas/fixtures/openprd/invalid/dates-backwards.md create mode 100644 packages/schemas/fixtures/openprd/invalid/duplicate-requirement.md create mode 100644 packages/schemas/fixtures/openprd/invalid/id-mismatch.md create mode 100644 packages/schemas/fixtures/openprd/invalid/missing-section.md create mode 100644 packages/schemas/fixtures/openprd/invalid/missing-title.md create mode 100644 packages/schemas/fixtures/openprd/invalid/no-front-matter.md create mode 100644 packages/schemas/fixtures/openprd/invalid/out-of-order.md create mode 100644 packages/schemas/fixtures/openprd/invalid/superseded-without-replacement.md create mode 100644 packages/schemas/fixtures/openprd/invalid/unknown-key.md create mode 100644 packages/schemas/fixtures/openprd/valid/bold-requirements.md create mode 100644 packages/schemas/fixtures/openprd/valid/full.md create mode 100644 packages/schemas/fixtures/openprd/valid/minimal.md create mode 100644 packages/schemas/fixtures/openprd/valid/none-sections.md create mode 100644 packages/schemas/fixtures/openprd/valid/subsections.md create mode 100644 packages/schemas/fixtures/openprd/valid/superseded.md diff --git a/README.md b/README.md index f6b0848..ac9d511 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ apps/ packages/ cli logicsrc OpenSpec CLI openontology OpenOntology reference engine (entities, claims, queries, change sets) + openprd OpenPRD reference implementation (numbered PRDs, lifecycle, task bridge) logicsrc-mcp @profullstack/logicsrc-mcp standards MCP server sdk SDK contract types and helpers tui terminal UI @@ -52,6 +53,24 @@ npm --workspace @profullstack/logicsrc-mcp run build node packages/logicsrc-mcp/dist/index.js ``` +## OpenPRD + +[OpenPRD](docs/openprd.md) is a lightweight standard for product requirements documents: a repo +keeps a numbered, committed collection under `prd/`, one Markdown file each, with front-matter, a +fixed set of eight sections, and a lifecycle. `@logicsrc/openprd` implements it. + +```bash +npm --workspace @logicsrc/cli run dev -- prd new "Expand the parked-domain service" +npm --workspace @logicsrc/cli run dev -- prd validate ./prd --strict +npm --workspace @logicsrc/cli run dev -- prd status 0001 Review +npm --workspace @logicsrc/cli run dev -- prd tasks 0001 --priority P0 +``` + +Conformance failures (filename, front-matter, id match, the eight sections in order) are errors; +lint findings are warnings that `--strict` promotes. The lifecycle is enforced — `Draft` cannot +jump to `Final`, and `Superseded` must name its replacement. `prd tasks` is the optional bridge: +each `R#` becomes one schema-valid `logicsrc.task`. + ## OpenOntology [LogicSRC OpenOntology](docs/openontology.md) is an open contract for durable, source-backed domain diff --git a/apps/logicsrc-web/src/app/sitemap.ts b/apps/logicsrc-web/src/app/sitemap.ts index 3b41e0e..60e4bda 100644 --- a/apps/logicsrc-web/src/app/sitemap.ts +++ b/apps/logicsrc-web/src/app/sitemap.ts @@ -17,6 +17,7 @@ const STATIC_ROUTES: Array<{ { path: "/", changeFrequency: "weekly", priority: 1.0 }, { path: "/docs", changeFrequency: "weekly", priority: 0.9 }, { path: "/openontology", changeFrequency: "weekly", priority: 0.9 }, + { path: "/docs/openprd", changeFrequency: "weekly", priority: 0.8 }, { path: "/openspec", changeFrequency: "weekly", priority: 0.8 }, { path: "/agent-swarm", changeFrequency: "weekly", priority: 0.8 }, { path: "/agentbyte", changeFrequency: "weekly", priority: 0.8 }, diff --git a/apps/logicsrc-web/src/components/site-shell.tsx b/apps/logicsrc-web/src/components/site-shell.tsx index ff97ffe..0885822 100644 --- a/apps/logicsrc-web/src/components/site-shell.tsx +++ b/apps/logicsrc-web/src/components/site-shell.tsx @@ -9,6 +9,7 @@ const NAV: Array<{ href: string; label: string; external?: boolean }> = [ { href: "/agentbyte", label: "AgentByte" }, { href: "/credential-sharing", label: "Credentials" }, { href: "/openontology", label: "OpenOntology" }, + { href: "/docs/openprd", label: "OpenPRD" }, { href: "/#cli", label: "CLI" }, { href: "/docs", label: "Docs" }, { href: "/blog", label: "Blog" }, diff --git a/apps/logicsrc-web/src/lib/docs.ts b/apps/logicsrc-web/src/lib/docs.ts index 4e906ef..924958e 100644 --- a/apps/logicsrc-web/src/lib/docs.ts +++ b/apps/logicsrc-web/src/lib/docs.ts @@ -8,6 +8,7 @@ const DOCS_DIR = resolve(process.cwd(), "../../docs"); // Curated, public-facing reference docs. Internal notes (roadmap, positioning, // arcade) are intentionally excluded. export const DOC_SLUGS = [ + "openprd", "openontology", "openontology-governance", "openontology-interoperability", diff --git a/apps/logicsrc-web/src/lib/page-markup.ts b/apps/logicsrc-web/src/lib/page-markup.ts index 1c7c49f..d148c8b 100644 --- a/apps/logicsrc-web/src/lib/page-markup.ts +++ b/apps/logicsrc-web/src/lib/page-markup.ts @@ -126,6 +126,7 @@ export function renderPageMarkup(): string { AgentByte Credentials OpenOntology + OpenPRD CLI Docs Blog diff --git a/docs/openprd.md b/docs/openprd.md index 2f114be..bd2e99c 100644 --- a/docs/openprd.md +++ b/docs/openprd.md @@ -4,7 +4,7 @@ OpenPRD is a lightweight, open standard for **product requirements documents** a It borrows the shape of a BIP/EIP/DIP process: a repo keeps a **numbered, committed collection** of PRDs under `prd/`, each one a single Markdown file with a fixed set of sections and a lifecycle. Where [OpenSpec](./openspec-comparison.md) models a *change* as a multi-file bundle, OpenPRD models a *product decision* as **one numbered file** you can read a year from now to recover the *why*. -Tools such as the moshcode CLI consume this standard to publish PRDs into whatever repo you're working in. +Tools such as the moshcode CLI consume this standard to publish PRDs into whatever repo you're working in. LogicSRC ships its own reference implementation — see [Implementation](#implementation). ## When to write one @@ -87,6 +87,54 @@ See [`0000-template.md`](./openprd/0000-template.md) for the copy-paste template OpenPRD is intentionally decoupled from the rest of LogicSRC: a PRD is just a file and needs no service to exist. When coordination is wanted, 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, not in this standard. +## Implementation + +`@logicsrc/openprd` is the reference implementation, exposed through the LogicSRC CLI. A PRD is +still just a file: nothing below is required for a document to conform. + +```bash +logicsrc prd init # create prd/ with the template and an index +logicsrc prd new "Expand the service" # next free number, filled front-matter, eight stub sections +logicsrc prd list # id, title, status, tags, requirement count +logicsrc prd show 0001 # front-matter, sections, and parsed requirements +logicsrc prd validate --strict # conformance + lint, exit 1 on error +logicsrc prd index --write # regenerate prd/README.md from what is on disk +logicsrc prd status 0001 Review # lifecycle move, refusing illegal transitions +logicsrc prd tasks 0001 # the optional LogicSRC task bridge +``` + +Validation separates the four conformance rules below from lint. Conformance failures are errors; +everything else — an empty section, a requirement with no priority tag, numbering that skips, a +stale index, a one-sided supersession link — is a warning or a note, and `--strict` promotes them. +Findings carry stable codes (`OP-C-SECTION-ORDER`, `OP-L-REQ-DUPLICATE`, …), the file, the line, +and a remediation hint. Exit codes: `0` ok, `1` invalid, `2` usage, `3` not found. + +The lifecycle is enforced rather than advisory: `Draft` cannot jump to `Final`, terminal statuses +do not resume, and moving to `Superseded` requires naming the PRD that replaces it. + +Requirement numbering, id uniqueness, and "no gaps" are checked across the whole collection, not +just per file — a repo with `0001` and `0003` and no `0002` fails. + +### Task bridge + +The optional mapping described above lives in tooling: + +```bash +logicsrc prd tasks 0001 --priority P0 --format ndjson +``` + +Each `R#` becomes one `logicsrc.task` document, validated against `logicsrc-task.schema.json` +before it is emitted. The board defaults to `/prd/`, `repo` carries over to `github_repo`, and +the creator DID is derived from the first author (`anthony@profullstack.com` → +`anthony.profullstack`) unless `--creator` says otherwise. + +### Conformance bundle + +`packages/schemas/fixtures/openprd/` holds fixtures a third-party implementation can run: +`conformance.json` lists documents that must validate and documents that must fail, each with the +error code it must produce. Because several rules depend on the filename, each fixture records the +name it must be validated as. + ## Conformance A document conforms to OpenPRD `0.2` when: diff --git a/package-lock.json b/package-lock.json index b1cfd99..9be9042 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1908,6 +1908,10 @@ "resolved": "packages/openontology", "link": true }, + "node_modules/@logicsrc/openprd": { + "resolved": "packages/openprd", + "link": true + }, "node_modules/@logicsrc/plugin-agentgit": { "resolved": "plugins/agentgit", "link": true @@ -7561,6 +7565,7 @@ "dependencies": { "@logicsrc/account-core": "file:../account-core", "@logicsrc/openontology": "file:../openontology", + "@logicsrc/openprd": "file:../openprd", "@logicsrc/plugin-coinpay": "file:../../plugins/coinpay", "@logicsrc/plugin-core": "file:../plugin-core", "@logicsrc/plugin-credential-sharing": "file:../../plugins/credential-sharing", @@ -7607,6 +7612,18 @@ "vitest": "^4.0.8" } }, + "packages/openprd": { + "name": "@logicsrc/openprd", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@logicsrc/validators": "file:../validators", + "yaml": "^2.8.1" + }, + "devDependencies": { + "vitest": "^4.0.8" + } + }, "packages/plugin-core": { "name": "@logicsrc/plugin-core", "version": "0.1.0", diff --git a/package.json b/package.json index 9ce2cdd..b1ae596 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,14 @@ "apps/*" ], "scripts": { - "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/agentad run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/agentswarm run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", + "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/agentad run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/agentswarm run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/openprd run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", "start": "npm --workspace @logicsrc/web run start", "test": "npm run test --workspaces --if-present", "check": "npm run build && npm run test", "schemas:validate": "npm --workspace @logicsrc/validators run validate:fixtures", "test:contract": "npm --workspace @logicsrc/commandboard-api run test:contract && npm --workspace @logicsrc/web run test:contract", "test:e2e": "npm --workspace @logicsrc/commandboard-web run test:e2e && npm --workspace @logicsrc/web run test:e2e", - "build:cli": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build" + "build:cli": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/openprd run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build" }, "devDependencies": { "@types/node": "^24.10.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index 6c89b32..c9e5d96 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,6 +16,7 @@ "dependencies": { "@logicsrc/account-core": "file:../account-core", "@logicsrc/openontology": "file:../openontology", + "@logicsrc/openprd": "file:../openprd", "@logicsrc/plugin-coinpay": "file:../../plugins/coinpay", "@logicsrc/plugin-core": "file:../plugin-core", "@logicsrc/plugin-credential-sharing": "file:../../plugins/credential-sharing", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 423a3ab..87c9f90 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -28,6 +28,7 @@ import { print, type OutputFormat } from "./format.js"; import { parsePositiveInteger } from "./numeric-options.js"; import { exportOpenSpecSummary, importOpenSpec, writeOpenSpecChange } from "./openspec.js"; import { registerOntologyCommands } from "./ontology.js"; +import { registerPrdCommands } from "./prd.js"; import { defaultPluginRegistry } from "./registry.js"; process.stdout.on("error", (error: NodeJS.ErrnoException) => { @@ -831,6 +832,7 @@ async function runYoloArcade(game: string, repo?: string) { } registerOntologyCommands(program); +registerPrdCommands(program); program.parseAsync(process.argv).catch((error: unknown) => { console.error(error instanceof Error ? error.message : String(error)); diff --git a/packages/cli/src/prd.ts b/packages/cli/src/prd.ts new file mode 100644 index 0000000..c87fded --- /dev/null +++ b/packages/cli/src/prd.ts @@ -0,0 +1,361 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { Command } from "commander"; +import { stringify as toYaml } from "yaml"; +import { + checkTransition, + createPrd, + findPrd, + initPrdCollection, + loadPrdCollection, + nextPrdNumber, + nextStatuses, + prdToTasks, + renderDocument, + renderIndex, + renderReport, + reportFor, + rewriteFrontMatter, + summarize, + validatePrdCollection, + validateTasks, + writeIndex, + type PrdDocument, + type PrdStatus, + type Priority, + type ReportFormat +} from "@logicsrc/openprd"; + +/** Stable exit codes for CI: 0 ok · 1 invalid · 2 usage · 3 not found. */ +export const PRD_EXIT = { ok: 0, invalid: 1, usage: 2, notFound: 3 } as const; + +type Format = "table" | "json" | "yaml" | "markdown" | "ndjson"; + +const DEFAULT_DIR = "./prd"; + +function fail(message: string, code: number): never { + console.error(message); + process.exit(code); +} + +function emit(data: unknown, format: Format): void { + switch (format) { + case "json": + console.log(JSON.stringify(data, null, 2)); + return; + case "yaml": + console.log(toYaml(data).trimEnd()); + return; + case "ndjson": + for (const row of Array.isArray(data) ? data : [data]) console.log(JSON.stringify(row)); + return; + case "markdown": { + const rows = (Array.isArray(data) ? data : [data]) as Array>; + if (rows.length === 0) { + console.log("_No rows._"); + return; + } + const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))]; + console.log(`| ${columns.join(" | ")} |`); + console.log(`| ${columns.map(() => "---").join(" | ")} |`); + for (const row of rows) { + console.log(`| ${columns.map((c) => String(row[c] ?? "").replace(/\|/g, "\\|")).join(" | ")} |`); + } + return; + } + default: { + const rows = Array.isArray(data) ? data : [data]; + if (rows.length === 0) { + console.log("(no PRDs)"); + return; + } + console.table(rows); + } + } +} + +function open(dir: string) { + try { + return loadPrdCollection(resolve(dir)); + } catch (error) { + return fail((error as Error).message, PRD_EXIT.usage); + } +} + +function mustFind(dir: string, ref: string): { doc: PrdDocument; dir: string } { + const collection = open(dir); + const doc = findPrd(collection, ref); + if (!doc) { + fail( + `No PRD matching "${ref}" in ${collection.dir}. Known ids: ${ + collection.documents.map((d) => d.frontMatter.id ?? d.filePrefix).join(", ") || "(none)" + }`, + PRD_EXIT.notFound + ); + } + return { doc, dir: collection.dir }; +} + +function splitList(value: string | undefined): string[] | undefined { + const items = value + ?.split(",") + .map((item) => item.trim()) + .filter(Boolean); + return items && items.length > 0 ? items : undefined; +} + +export function registerPrdCommands(program: Command): void { + const prd = program + .command("prd") + .description("OpenPRD: numbered product requirements documents with a fixed shape and lifecycle."); + + prd + .command("init") + .argument("[dir]", "collection directory", DEFAULT_DIR) + .option("--title ", "heading for the generated index") + .description("Create a prd/ collection with the OpenPRD template and an index.") + .action((dir: string, options) => { + const result = initPrdCollection(resolve(dir), { title: options.title }); + for (const file of result.created) console.log(`Created ${result.dir}/${file}`); + for (const file of result.skipped) console.log(`Kept existing ${result.dir}/${file}`); + console.log(`\nNext: logicsrc prd new "Short imperative title"`); + }); + + prd + .command("new") + .argument("", "imperative title — start with a verb") + .option("--dir <dir>", "collection directory", DEFAULT_DIR) + .option("--author <list>", "comma-separated authors") + .option("--status <status>", "initial status", "Draft") + .option("--repo <owner/name>", "target repository") + .option("--tags <list>", "comma-separated tags") + .option("--owner <did>", "accountable owner") + .option("--discussion <url>", "URL of the discussion thread") + .option("--supersedes <id>", "four-digit id this PRD replaces") + .description("Create the next numbered PRD from the template.") + .action((title: string, options) => { + try { + const result = createPrd(resolve(options.dir), { + title, + authors: splitList(options.author), + status: options.status as PrdStatus, + repo: options.repo, + tags: splitList(options.tags), + owner: options.owner, + discussion: options.discussion, + supersedes: options.supersedes + }); + writeIndex(resolve(options.dir)); + console.log(`Created ${result.path}`); + console.log(`Assigned id ${result.id}. Index updated.`); + console.log(`\nNext: fill in the eight sections, then logicsrc prd validate ${options.dir}`); + } catch (error) { + fail((error as Error).message, PRD_EXIT.usage); + } + }); + + prd + .command("list") + .option("--dir <dir>", "collection directory", DEFAULT_DIR) + .option("--status <list>", "comma-separated statuses to include") + .option("--tag <tag>", "only PRDs carrying this tag") + .option("--format <format>", "table, json, yaml, markdown, or ndjson", "table") + .description("List the PRDs in a collection.") + .action((options) => { + const collection = open(options.dir); + const statuses = splitList(options.status); + const rows = collection.documents + .map(summarize) + .filter((row) => !statuses || statuses.includes(row.status)) + .filter((row) => !options.tag || row.tags.split(", ").includes(options.tag)); + emit(rows, options.format as Format); + }); + + prd + .command("show") + .argument("<ref>", "id, number, slug, or filename") + .option("--dir <dir>", "collection directory", DEFAULT_DIR) + .option("--format <format>", "text, json, yaml, or markdown", "text") + .description("Show one PRD: front-matter, sections, and requirements.") + .action((ref: string, options) => { + const { doc } = mustFind(options.dir, ref); + console.log(renderDocument(doc, options.format as "text" | "json" | "yaml" | "markdown")); + }); + + prd + .command("validate") + .argument("[dir]", "collection directory", DEFAULT_DIR) + .option("--strict", "treat lint warnings as errors") + .option("--format <format>", "text, json, yaml, or markdown", "text") + .option("--id <ref>", "validate a single PRD instead of the collection") + .description("Check conformance: filename, front-matter, id match, and the eight sections.") + .action((dir: string, options) => { + if (options.id) { + const { doc } = mustFind(dir, options.id); + const report = reportFor(doc, { strict: options.strict === true }); + console.log(renderReport(report, options.format as ReportFormat)); + if (!report.ok) process.exit(PRD_EXIT.invalid); + return; + } + + const collection = open(dir); + const report = validatePrdCollection(collection, { + strict: options.strict === true, + expectedIndex: renderIndex(collection) + }); + console.log(renderReport(report, options.format as ReportFormat)); + if (!report.ok) process.exit(PRD_EXIT.invalid); + }); + + prd + .command("lint") + .argument("[dir]", "collection directory", DEFAULT_DIR) + .option("--format <format>", "text, json, yaml, or markdown", "text") + .description("Report warnings and suggestions without failing on them.") + .action((dir: string, options) => { + const collection = open(dir); + const report = validatePrdCollection(collection, { expectedIndex: renderIndex(collection) }); + const advisory = { + ...report, + ok: true, + findings: report.findings.filter((finding) => finding.severity !== "error") + }; + console.log(renderReport(advisory, options.format as ReportFormat)); + }); + + prd + .command("index") + .argument("[dir]", "collection directory", DEFAULT_DIR) + .option("--write", "write prd/README.md instead of printing it") + .option("--title <text>", "heading for the index") + .description("Generate the collection index from the PRDs on disk.") + .action((dir: string, options) => { + if (!options.write) { + console.log(renderIndex(open(dir), { title: options.title })); + return; + } + const result = writeIndex(resolve(dir), { title: options.title }); + console.log(result.changed ? `Wrote ${result.path}` : `${result.path} already up to date`); + }); + + prd + .command("status") + .argument("<ref>", "id, number, slug, or filename") + .argument("[status]", "new status; omit to list the allowed next steps") + .option("--dir <dir>", "collection directory", DEFAULT_DIR) + .option("--superseded-by <id>", "required when moving to Superseded") + .option("--dry-run", "show what would change without writing") + .description("Move a PRD through the lifecycle, enforcing the allowed transitions.") + .action((ref: string, status: string | undefined, options) => { + const { doc } = mustFind(options.dir, ref); + const from = doc.frontMatter.status; + + if (!status) { + const allowed = nextStatuses(from); + console.log(`${doc.file} is ${from}.`); + console.log(allowed.length ? `Allowed next: ${allowed.join(", ")}` : "This status is terminal."); + return; + } + + const check = checkTransition(from, status as PrdStatus, { + supersededBy: options.supersededBy ?? doc.frontMatter["superseded-by"] + }); + if (!check.ok) { + fail(`Cannot move ${doc.file} from ${from} to ${status}: ${check.reason}`, PRD_EXIT.usage); + } + + const today = new Date().toISOString().slice(0, 10); + const updates: Record<string, string | null> = { status, updated: today }; + if (options.supersededBy) updates["superseded-by"] = options.supersededBy; + + if (options.dryRun) { + console.log(`${doc.file}: ${from} → ${status} (updated: ${today})`); + if (options.supersededBy) console.log(` superseded-by: ${options.supersededBy}`); + console.log("(dry run; nothing written)"); + return; + } + + const source = readFileSync(doc.path, "utf8"); + writeFileSync(doc.path, rewriteFrontMatter(source, updates), "utf8"); + writeIndex(resolve(options.dir)); + console.log(`${doc.file}: ${from} → ${status}`); + }); + + prd + .command("next") + .argument("[dir]", "collection directory", DEFAULT_DIR) + .description("Print the next free four-digit id.") + .action((dir: string) => { + console.log(nextPrdNumber(open(dir))); + }); + + prd + .command("tasks") + .argument("<ref>", "id, number, slug, or filename") + .option("--dir <dir>", "collection directory", DEFAULT_DIR) + .option("--creator <did>", "LogicSRC DID for the created tasks") + .option("--board <path>", "board path, e.g. /prd/0001") + .option("--priority <list>", "only these priorities, e.g. P0,P1") + .option("--format <format>", "json, ndjson, yaml, or table", "json") + .description("Map each requirement onto a LogicSRC task document (the optional bridge).") + .action((ref: string, options) => { + const { doc } = mustFind(options.dir, ref); + const { tasks, skipped } = prdToTasks(doc, { + creator: options.creator, + board: options.board, + priorities: splitList(options.priority) as Priority[] | undefined + }); + + const problems = validateTasks(tasks); + if (problems.length > 0) { + for (const problem of problems) { + console.error(`task ${problem.index} is not a valid logicsrc.task: ${problem.errors.join("; ")}`); + } + process.exit(PRD_EXIT.invalid); + } + + if (options.format === "table") { + emit( + tasks.map((task) => ({ title: task.title, board: task.board, creator: task.creator_did })), + "table" + ); + } else { + emit(tasks, options.format as Format); + } + + for (const entry of skipped) { + console.error(`skipped ${entry.requirement}: ${entry.reason}`); + } + }); + + prd + .command("export") + .argument("[dir]", "collection directory", DEFAULT_DIR) + .option("--format <format>", "json, ndjson, yaml, or markdown", "json") + .option("--out <file>", "write to a file instead of stdout") + .description("Export the parsed collection for other tools.") + .action((dir: string, options) => { + const collection = open(dir); + const payload = collection.documents.map((doc) => ({ + id: doc.frontMatter.id ?? doc.filePrefix, + file: doc.file, + frontMatter: doc.frontMatter, + sections: doc.sections.map((section) => ({ name: section.name, empty: section.empty })), + requirements: doc.requirements + })); + + if (options.out) { + const text = + options.format === "ndjson" + ? `${payload.map((row) => JSON.stringify(row)).join("\n")}\n` + : options.format === "yaml" + ? `${toYaml(payload)}` + : `${JSON.stringify(payload, null, 2)}\n`; + writeFileSync(resolve(options.out), text, "utf8"); + console.log(`Wrote ${resolve(options.out)}`); + return; + } + + emit(payload, options.format as Format); + }); +} diff --git a/packages/openprd/package.json b/packages/openprd/package.json new file mode 100644 index 0000000..ae6a6be --- /dev/null +++ b/packages/openprd/package.json @@ -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" + } +} diff --git a/packages/openprd/src/collection.ts b/packages/openprd/src/collection.ts new file mode 100644 index 0000000..f9fd562 --- /dev/null +++ b/packages/openprd/src/collection.ts @@ -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 }; diff --git a/packages/openprd/src/index.ts b/packages/openprd/src/index.ts new file mode 100644 index 0000000..ba59d79 --- /dev/null +++ b/packages/openprd/src/index.ts @@ -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"; diff --git a/packages/openprd/src/lifecycle.ts b/packages/openprd/src/lifecycle.ts new file mode 100644 index 0000000..649bf2f --- /dev/null +++ b/packages/openprd/src/lifecycle.ts @@ -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"; +} diff --git a/packages/openprd/src/parse.test.ts b/packages/openprd/src/parse.test.ts new file mode 100644 index 0000000..0cb7b08 --- /dev/null +++ b/packages/openprd/src/parse.test.ts @@ -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"); + }); +}); diff --git a/packages/openprd/src/parse.ts b/packages/openprd/src/parse.ts new file mode 100644 index 0000000..91037d1 --- /dev/null +++ b/packages/openprd/src/parse.ts @@ -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}`; +} diff --git a/packages/openprd/src/render.ts b/packages/openprd/src/render.ts new file mode 100644 index 0000000..c5db250 --- /dev/null +++ b/packages/openprd/src/render.ts @@ -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)}…`; +} diff --git a/packages/openprd/src/scaffold.test.ts b/packages/openprd/src/scaffold.test.ts new file mode 100644 index 0000000..8f14dec --- /dev/null +++ b/packages/openprd/src/scaffold.test.ts @@ -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); + }); +}); diff --git a/packages/openprd/src/scaffold.ts b/packages/openprd/src/scaffold.ts new file mode 100644 index 0000000..01cc23d --- /dev/null +++ b/packages/openprd/src/scaffold.ts @@ -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 }; +} diff --git a/packages/openprd/src/tasks.ts b/packages/openprd/src/tasks.ts new file mode 100644 index 0000000..501ba0b --- /dev/null +++ b/packages/openprd/src/tasks.ts @@ -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; +} diff --git a/packages/openprd/src/types.ts b/packages/openprd/src/types.ts new file mode 100644 index 0000000..f51ae4a --- /dev/null +++ b/packages/openprd/src/types.ts @@ -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; +} diff --git a/packages/openprd/src/validate.test.ts b/packages/openprd/src/validate.test.ts new file mode 100644 index 0000000..1af6aa2 --- /dev/null +++ b/packages/openprd/src/validate.test.ts @@ -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/); + }); +}); diff --git a/packages/openprd/src/validate.ts b/packages/openprd/src/validate.ts new file mode 100644 index 0000000..bbed7fc --- /dev/null +++ b/packages/openprd/src/validate.ts @@ -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 } + }; +} diff --git a/packages/openprd/tsconfig.json b/packages/openprd/tsconfig.json new file mode 100644 index 0000000..d38ac70 --- /dev/null +++ b/packages/openprd/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/schemas/fixtures/openprd/conformance.json b/packages/schemas/fixtures/openprd/conformance.json new file mode 100644 index 0000000..377036f --- /dev/null +++ b/packages/schemas/fixtures/openprd/conformance.json @@ -0,0 +1,104 @@ +{ + "openprdConformance": "0.2", + "description": "Conformance fixtures for OpenPRD 0.2. Every valid fixture must parse and validate with no errors; every invalid fixture must fail with the stated code. `file` is the filename the fixture must be validated as, since the standard's rules depend on it.", + "valid": [ + { + "fixture": "valid/minimal.md", + "file": "0001-expand-the-parked-domain-service.md" + }, + { + "fixture": "valid/full.md", + "file": "0001-expand-the-parked-domain-service.md" + }, + { + "fixture": "valid/bold-requirements.md", + "file": "0002-use-bold-requirement-markers.md" + }, + { + "fixture": "valid/subsections.md", + "file": "0003-organize-a-long-requirements-section.md" + }, + { + "fixture": "valid/superseded.md", + "file": "0004-retire-the-old-flow.md" + }, + { + "fixture": "valid/none-sections.md", + "file": "0005-keep-every-section-even-when-empty.md" + } + ], + "invalid": [ + { + "fixture": "invalid/missing-section.md", + "file": "0001-missing-a-section.md", + "code": "OP-C-SECTION-MISSING", + "reason": "The eight body sections are all required; Users is absent." + }, + { + "fixture": "invalid/out-of-order.md", + "file": "0001-sections-out-of-order.md", + "code": "OP-C-SECTION-ORDER", + "reason": "Sections must appear in the order the standard fixes." + }, + { + "fixture": "invalid/id-mismatch.md", + "file": "0001-id-does-not-match.md", + "code": "OP-C-ID-MISMATCH", + "reason": "front-matter id must equal the filename's numeric prefix." + }, + { + "fixture": "invalid/bad-status.md", + "file": "0001-unknown-status.md", + "code": "OP-C-FRONTMATTER", + "reason": "status must be one of the seven lifecycle values." + }, + { + "fixture": "invalid/missing-title.md", + "file": "0001-no-title.md", + "code": "OP-C-FRONTMATTER", + "reason": "title is required." + }, + { + "fixture": "invalid/bad-id-format.md", + "file": "0001-two-digit-id.md", + "code": "OP-C-FRONTMATTER", + "reason": "id must be four digits, zero-padded." + }, + { + "fixture": "invalid/unknown-key.md", + "file": "0001-unknown-front-matter-key.md", + "code": "OP-C-FRONTMATTER", + "reason": "Unknown front-matter keys are rejected." + }, + { + "fixture": "invalid/superseded-without-replacement.md", + "file": "0001-superseded-with-no-replacement.md", + "code": "OP-L-SUPERSEDED-BY", + "reason": "Superseded must name the PRD that replaces it." + }, + { + "fixture": "invalid/duplicate-requirement.md", + "file": "0001-duplicate-requirement-id.md", + "code": "OP-L-REQ-DUPLICATE", + "reason": "Requirement ids must be unique within a PRD." + }, + { + "fixture": "invalid/dates-backwards.md", + "file": "0001-updated-before-created.md", + "code": "OP-L-DATE-ORDER", + "reason": "updated cannot precede created." + }, + { + "fixture": "invalid/no-front-matter.md", + "file": "0001-no-front-matter.md", + "code": "OP-P-PARSE", + "reason": "A PRD must open with a YAML front-matter block." + }, + { + "fixture": "invalid/bad-filename.md", + "file": "notes.md", + "code": "OP-C-FILENAME", + "reason": "A PRD lives at prd/<id>-<slug>.md with a four-digit id." + } + ] +} diff --git a/packages/schemas/fixtures/openprd/invalid/bad-filename.md b/packages/schemas/fixtures/openprd/invalid/bad-filename.md new file mode 100644 index 0000000..ece0f91 --- /dev/null +++ b/packages/schemas/fixtures/openprd/invalid/bad-filename.md @@ -0,0 +1,41 @@ +--- +openprd: "0.2" +id: "0001" +title: Bad filename +status: Draft +authors: + - a@example.com +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/invalid/bad-id-format.md b/packages/schemas/fixtures/openprd/invalid/bad-id-format.md new file mode 100644 index 0000000..d4e106e --- /dev/null +++ b/packages/schemas/fixtures/openprd/invalid/bad-id-format.md @@ -0,0 +1,41 @@ +--- +openprd: "0.2" +id: "1" +title: Two digit id +status: Draft +authors: + - a@example.com +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/invalid/bad-status.md b/packages/schemas/fixtures/openprd/invalid/bad-status.md new file mode 100644 index 0000000..aec5c5d --- /dev/null +++ b/packages/schemas/fixtures/openprd/invalid/bad-status.md @@ -0,0 +1,41 @@ +--- +openprd: "0.2" +id: "0001" +title: Unknown status +status: Shipped +authors: + - a@example.com +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/invalid/dates-backwards.md b/packages/schemas/fixtures/openprd/invalid/dates-backwards.md new file mode 100644 index 0000000..1201a63 --- /dev/null +++ b/packages/schemas/fixtures/openprd/invalid/dates-backwards.md @@ -0,0 +1,43 @@ +--- +openprd: "0.2" +id: "0001" +title: Updated before created +status: Draft +authors: + - a@example.com +created: 2026-07-20 +updated: 2026-07-01 +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/invalid/duplicate-requirement.md b/packages/schemas/fixtures/openprd/invalid/duplicate-requirement.md new file mode 100644 index 0000000..d828fc1 --- /dev/null +++ b/packages/schemas/fixtures/openprd/invalid/duplicate-requirement.md @@ -0,0 +1,42 @@ +--- +openprd: "0.2" +id: "0001" +title: Duplicate requirement id +status: Draft +authors: + - a@example.com +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First. +- R1 [P1] First again. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/invalid/id-mismatch.md b/packages/schemas/fixtures/openprd/invalid/id-mismatch.md new file mode 100644 index 0000000..e0a6cfa --- /dev/null +++ b/packages/schemas/fixtures/openprd/invalid/id-mismatch.md @@ -0,0 +1,41 @@ +--- +openprd: "0.2" +id: "0042" +title: Id does not match +status: Draft +authors: + - a@example.com +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/invalid/missing-section.md b/packages/schemas/fixtures/openprd/invalid/missing-section.md new file mode 100644 index 0000000..5748625 --- /dev/null +++ b/packages/schemas/fixtures/openprd/invalid/missing-section.md @@ -0,0 +1,37 @@ +--- +openprd: "0.2" +id: "0001" +title: Missing a section +status: Draft +authors: + - a@example.com +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/invalid/missing-title.md b/packages/schemas/fixtures/openprd/invalid/missing-title.md new file mode 100644 index 0000000..7becd05 --- /dev/null +++ b/packages/schemas/fixtures/openprd/invalid/missing-title.md @@ -0,0 +1,40 @@ +--- +openprd: "0.2" +id: "0001" +status: Draft +authors: + - a@example.com +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/invalid/no-front-matter.md b/packages/schemas/fixtures/openprd/invalid/no-front-matter.md new file mode 100644 index 0000000..ef5603b --- /dev/null +++ b/packages/schemas/fixtures/openprd/invalid/no-front-matter.md @@ -0,0 +1,33 @@ +# No front matter + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ diff --git a/packages/schemas/fixtures/openprd/invalid/out-of-order.md b/packages/schemas/fixtures/openprd/invalid/out-of-order.md new file mode 100644 index 0000000..bea5c62 --- /dev/null +++ b/packages/schemas/fixtures/openprd/invalid/out-of-order.md @@ -0,0 +1,41 @@ +--- +openprd: "0.2" +id: "0001" +title: Sections out of order +status: Draft +authors: + - a@example.com +--- + +## Goals + +_None._ + +## Problem + +The thing is broken and it costs us money every week. + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/invalid/superseded-without-replacement.md b/packages/schemas/fixtures/openprd/invalid/superseded-without-replacement.md new file mode 100644 index 0000000..59603a7 --- /dev/null +++ b/packages/schemas/fixtures/openprd/invalid/superseded-without-replacement.md @@ -0,0 +1,41 @@ +--- +openprd: "0.2" +id: "0001" +title: Superseded with no replacement +status: Superseded +authors: + - a@example.com +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/invalid/unknown-key.md b/packages/schemas/fixtures/openprd/invalid/unknown-key.md new file mode 100644 index 0000000..bc1ca5d --- /dev/null +++ b/packages/schemas/fixtures/openprd/invalid/unknown-key.md @@ -0,0 +1,42 @@ +--- +openprd: "0.2" +id: "0001" +title: Unknown front matter key +status: Draft +authors: + - a@example.com +priority: high +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/valid/bold-requirements.md b/packages/schemas/fixtures/openprd/valid/bold-requirements.md new file mode 100644 index 0000000..75ed1ff --- /dev/null +++ b/packages/schemas/fixtures/openprd/valid/bold-requirements.md @@ -0,0 +1,41 @@ +--- +openprd: "0.2" +id: "0002" +title: Use bold requirement markers +status: Review +authors: + - a@example.com +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- **R1 [P0]** Bold markers parse the same as plain ones. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/valid/full.md b/packages/schemas/fixtures/openprd/valid/full.md new file mode 100644 index 0000000..3c97377 --- /dev/null +++ b/packages/schemas/fixtures/openprd/valid/full.md @@ -0,0 +1,48 @@ +--- +openprd: "0.2" +id: "0001" +title: Expand the parked-domain service +status: Draft +authors: + - anthony@profullstack.com +created: 2026-07-12 +updated: 2026-07-12 +repo: profullstack/logicsrc +tags: + - growth +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] The service MUST resolve a parked domain within 200 ms at p95. +- R2 [P1] The service SHOULD report per-domain hit counts. +- R3 [P2] The service MAY expose a JSON feed of recent lookups. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/valid/minimal.md b/packages/schemas/fixtures/openprd/valid/minimal.md new file mode 100644 index 0000000..b076ee0 --- /dev/null +++ b/packages/schemas/fixtures/openprd/valid/minimal.md @@ -0,0 +1,41 @@ +--- +openprd: "0.2" +id: "0001" +title: Expand the parked-domain service +status: Draft +authors: + - anthony@profullstack.com +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/valid/none-sections.md b/packages/schemas/fixtures/openprd/valid/none-sections.md new file mode 100644 index 0000000..a59d6b4 --- /dev/null +++ b/packages/schemas/fixtures/openprd/valid/none-sections.md @@ -0,0 +1,41 @@ +--- +openprd: "0.2" +id: "0005" +title: Keep every section even when empty +status: Accepted +authors: + - a@example.com +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/valid/subsections.md b/packages/schemas/fixtures/openprd/valid/subsections.md new file mode 100644 index 0000000..c72debf --- /dev/null +++ b/packages/schemas/fixtures/openprd/valid/subsections.md @@ -0,0 +1,47 @@ +--- +openprd: "0.2" +id: "0003" +title: Organize a long requirements section +status: Draft +authors: + - a@example.com +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +### Identity + +- R1 [P0] Capability one. + +### Storage + +- R2 [P0] Capability two. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/packages/schemas/fixtures/openprd/valid/superseded.md b/packages/schemas/fixtures/openprd/valid/superseded.md new file mode 100644 index 0000000..ca7e1da --- /dev/null +++ b/packages/schemas/fixtures/openprd/valid/superseded.md @@ -0,0 +1,42 @@ +--- +openprd: "0.2" +id: "0004" +title: Retire the old flow +status: Superseded +authors: + - a@example.com +superseded-by: "0005" +--- + +## Problem + +The thing is broken and it costs us money every week. + +## Goals + +_None._ + +## Non-Goals + +_None._ + +## Users + +_None._ + +## Requirements + +- R1 [P0] First required capability. + +## UX Notes + +_None._ + +## Success Metrics + +_None._ + +## Risks & Open Questions + +_None._ + diff --git a/prd/README.md b/prd/README.md index d2cae8c..cff3687 100644 --- a/prd/README.md +++ b/prd/README.md @@ -1,8 +1,13 @@ # 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. +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`. +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 | | --- | --- | --- | --- |