From 9466eebd84d65ff8a7ea723acc3c1cb085fa5db8 Mon Sep 17 00:00:00 2001 From: maxgfr <25312957+maxgfr@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:26:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20add=20--prd=20=E2=80=94=20one?= =?UTF-8?q?=20standalone=20PRD=20per=20functional=20requirement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render --prd also emits requirements/prd/: one PRD-FR-NNN-.md per FR (product context, Given/When/Then acceptance criteria, linked NFRs resolved to their statements, touched entities/interfaces, [E#] grounding citations) plus a README.md index. Rendered from the same SRD.json — FUNCTIONAL.md stays canonical — and the subtree is cleared on re-render, same hygiene as the decisions and design dirs. Off by default; additive, check does not gate on it. Co-Authored-By: Claude Fable 5 --- scripts/construct.mjs | 54 ++++++++++++++++++++++++-- skills/construct/SKILL.md | 8 +++- skills/construct/scripts/construct.mjs | 54 ++++++++++++++++++++++++-- src/cli.ts | 9 +++-- src/render.ts | 15 +++++++ src/templates.ts | 49 ++++++++++++++++++++++- tests/cli.test.ts | 6 +++ tests/render.test.ts | 43 ++++++++++++++++++++ 8 files changed, 226 insertions(+), 12 deletions(-) diff --git a/scripts/construct.mjs b/scripts/construct.mjs index 2cc12bb..483f652 100755 --- a/scripts/construct.mjs +++ b/scripts/construct.mjs @@ -2446,6 +2446,44 @@ function renderFunctional(srd) { } return out.join("\n"); } +function renderFeaturePRD(fr, srd) { + const out = [`# PRD ${fr.id} \u2014 ${fr.title}${cite(fr.rationaleEvidence)}`, ``]; + out.push(`_Priority: ${fr.priority}_ \xB7 _Product: ${srd.product.name}_`, ``); + out.push(`## Context`, ``, srd.product.problem, ``); + out.push(`## Feature`, ``, fr.description, ``); + out.push(`## Acceptance criteria`, ``); + for (const a of fr.acceptance) { + out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`); + } + out.push(``, `## Non-functional requirements`, ``); + if (!fr.nfrs.length) out.push(`_None linked._`); + for (const id of fr.nfrs) { + const nfr = srd.nonFunctional.find((n) => n.id === id); + out.push(nfr ? `- **${nfr.id}** (${nfr.category}): ${nfr.statement}${nfr.metric ? ` \u2014 metric: ${nfr.metric}` : ""}` : `- **${id}**`); + } + out.push(``, `## Data & interfaces`, ``); + out.push(`- Entities: ${fr.entities.length ? fr.entities.join(", ") : "\u2014"}`); + out.push(`- Interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "\u2014"}`); + out.push(``, `## Grounding`, ``); + out.push( + fr.rationaleEvidence.length ? `Evidence:${cite(fr.rationaleEvidence)} \u2014 see ../../evidence/EVIDENCE.md.` : `_Ungrounded \u2014 see the grounding report (construct check)._` + ); + out.push(``); + return out.join("\n"); +} +function renderPRDIndex(srd) { + const out = [`# PRDs \u2014 one per functional requirement`, ``]; + out.push(`Rendered from SRD.json by \`construct render --prd\`. The canonical, always-current`); + out.push(`requirement list is [../FUNCTIONAL.md](../FUNCTIONAL.md); re-render after editing.`, ``); + out.push(`| PRD | Priority | Title |`); + out.push(`|---|---|---|`); + for (const fr of srd.functional) { + const file = `PRD-${fr.id}-${slugTitle(fr.title)}.md`; + out.push(`| [${file}](${file}) | ${cell(fr.priority)} | ${cell(fr.title)} |`); + } + out.push(``); + return out.join("\n"); +} function renderNonFunctional(srd) { const out = [`# Non-functional requirements`, ``]; if (!srd.nonFunctional.length) out.push(`_No non-functional requirements defined._`, ``); @@ -2696,6 +2734,13 @@ function renderSRD(brief, evidence, opts) { writeFile(out, "00-overview/VISION.md", renderVision(srd), files); writeFile(out, "00-overview/SCOPE.md", renderScope(srd), files); writeFile(out, "requirements/FUNCTIONAL.md", renderFunctional(srd), files); + rmSync2(join9(out, "requirements", "prd"), { recursive: true, force: true }); + if (opts.prd) { + for (const fr of srd.functional) { + writeFile(out, `requirements/prd/PRD-${fr.id}-${slugTitle(fr.title)}.md`, renderFeaturePRD(fr, srd), files); + } + writeFile(out, "requirements/prd/README.md", renderPRDIndex(srd), files); + } writeFile(out, "requirements/NON-FUNCTIONAL.md", renderNonFunctional(srd), files); writeFile(out, "architecture/SYSTEM-CONTEXT.md", renderSystemContext(srd), files); writeFile(out, "architecture/DATA-MODEL.md", renderDataModel(srd), files); @@ -3500,7 +3545,7 @@ Usage: construct research --out [--angles market,oss,tech,semantic] [--q ""] [--semantic] construct analyze --out [--json] construct web|oss|tech|so --out [--q ""] [--url ] [--seeds ] - construct render --out [--level light|complex] [--merge] [--no-design] + construct render --out [--level light|complex] [--merge] [--no-design] [--prd] construct check --out [--min-grounding <0-100>] [--semantic] [--json] construct review --out [--apply ] [--max-review N] [--json] construct verify --out [--app ] [--run-tests] [--strict] [--json] @@ -3516,7 +3561,8 @@ Commands: render Render the SRD tree + SRD.json from brief.json + the dossier. At --level complex this also renders a design-system subtree (design/: principles, tokens, components, screens, accessibility); - --no-design opts out. + --no-design opts out. --prd also emits requirements/prd/ \u2014 one + standalone PRD per functional requirement + an index. check Hard structural gate + advisory grounding-coverage report. --semantic also folds in the review verdicts (fails on a claim its cited evidence does not support). @@ -3547,6 +3593,7 @@ Options: --per-source Max evidence items kept per source (default: 6) --merge Also emit a single-file SRD.md bundle --no-design For 'render': skip the design-system subtree (complex only) + --prd For 'render': also emit one PRD file per FR (requirements/prd/) --semantic Rescore evidence with the local embedding model --refresh Force re-clone of mined OSS repos --json Machine-readable output @@ -3579,7 +3626,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([ "apply", "max-review" ]); -var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design"]); +var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd"]); function fail(message) { process.stderr.write(`construct: ${message} `); @@ -3792,6 +3839,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); out, merge: p.bools.has("merge"), noDesign: p.bools.has("no-design"), + prd: p.bools.has("prd"), generatedAt: (/* @__PURE__ */ new Date()).toISOString() }); const design = r.srd.design; diff --git a/skills/construct/SKILL.md b/skills/construct/SKILL.md index d4abdb5..83d7a84 100644 --- a/skills/construct/SKILL.md +++ b/skills/construct/SKILL.md @@ -36,8 +36,11 @@ No `npm install`, no API keys. Run `--help` for the full surface. Key commands: - `web|oss|tech|so --out [--q ""] [--url ...] [--seeds ...] [--docs-url ]` — drill ONE angle to stdout (no dossier). Use these to dig deeper on a thin thread; `--docs-url` grounds known docs pages directly. -- `render --out [--level light|complex] [--merge]` — render the SRD tree + - `SRD.json` from `brief.json` + the dossier. +- `render --out [--level light|complex] [--merge] [--prd]` — render the + SRD tree + `SRD.json` from `brief.json` + the dossier. `--prd` also emits + `requirements/prd/` — one standalone PRD file per functional requirement + (context, acceptance criteria, linked NFRs resolved, [E#] citations) plus an + index, for handing single features to a tracker or an implementation agent. - `check --out [--min-grounding <0-100>] [--semantic] [--json]` — the HARD structural gate (exit ≠ 0 on an incomplete SRD) plus the ADVISORY grounding- coverage report. `--min-grounding N` opts into a second gate that fails below @@ -182,6 +185,7 @@ loop to completion; only pause to ask the user a real decision. 00-overview/ VISION.md · SCOPE.md (+ 🧠 open decisions) requirements/ FUNCTIONAL.md (FR-NNN · priority · Given/When/Then · [E#]) NON-FUNCTIONAL.md (NFR-NNN by category · metric · [E#]) + prd/PRD-FR-NNN-*.md + README.md index (--prd only) architecture/ SYSTEM-CONTEXT.md · DATA-MODEL.md · INTERFACES.md decisions/NNNN-*.md (ADRs) design/ PRINCIPLES.md · DESIGN-TOKENS.md (+ design-tokens.json) · COMPONENTS.md diff --git a/skills/construct/scripts/construct.mjs b/skills/construct/scripts/construct.mjs index 2cc12bb..483f652 100755 --- a/skills/construct/scripts/construct.mjs +++ b/skills/construct/scripts/construct.mjs @@ -2446,6 +2446,44 @@ function renderFunctional(srd) { } return out.join("\n"); } +function renderFeaturePRD(fr, srd) { + const out = [`# PRD ${fr.id} \u2014 ${fr.title}${cite(fr.rationaleEvidence)}`, ``]; + out.push(`_Priority: ${fr.priority}_ \xB7 _Product: ${srd.product.name}_`, ``); + out.push(`## Context`, ``, srd.product.problem, ``); + out.push(`## Feature`, ``, fr.description, ``); + out.push(`## Acceptance criteria`, ``); + for (const a of fr.acceptance) { + out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`); + } + out.push(``, `## Non-functional requirements`, ``); + if (!fr.nfrs.length) out.push(`_None linked._`); + for (const id of fr.nfrs) { + const nfr = srd.nonFunctional.find((n) => n.id === id); + out.push(nfr ? `- **${nfr.id}** (${nfr.category}): ${nfr.statement}${nfr.metric ? ` \u2014 metric: ${nfr.metric}` : ""}` : `- **${id}**`); + } + out.push(``, `## Data & interfaces`, ``); + out.push(`- Entities: ${fr.entities.length ? fr.entities.join(", ") : "\u2014"}`); + out.push(`- Interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "\u2014"}`); + out.push(``, `## Grounding`, ``); + out.push( + fr.rationaleEvidence.length ? `Evidence:${cite(fr.rationaleEvidence)} \u2014 see ../../evidence/EVIDENCE.md.` : `_Ungrounded \u2014 see the grounding report (construct check)._` + ); + out.push(``); + return out.join("\n"); +} +function renderPRDIndex(srd) { + const out = [`# PRDs \u2014 one per functional requirement`, ``]; + out.push(`Rendered from SRD.json by \`construct render --prd\`. The canonical, always-current`); + out.push(`requirement list is [../FUNCTIONAL.md](../FUNCTIONAL.md); re-render after editing.`, ``); + out.push(`| PRD | Priority | Title |`); + out.push(`|---|---|---|`); + for (const fr of srd.functional) { + const file = `PRD-${fr.id}-${slugTitle(fr.title)}.md`; + out.push(`| [${file}](${file}) | ${cell(fr.priority)} | ${cell(fr.title)} |`); + } + out.push(``); + return out.join("\n"); +} function renderNonFunctional(srd) { const out = [`# Non-functional requirements`, ``]; if (!srd.nonFunctional.length) out.push(`_No non-functional requirements defined._`, ``); @@ -2696,6 +2734,13 @@ function renderSRD(brief, evidence, opts) { writeFile(out, "00-overview/VISION.md", renderVision(srd), files); writeFile(out, "00-overview/SCOPE.md", renderScope(srd), files); writeFile(out, "requirements/FUNCTIONAL.md", renderFunctional(srd), files); + rmSync2(join9(out, "requirements", "prd"), { recursive: true, force: true }); + if (opts.prd) { + for (const fr of srd.functional) { + writeFile(out, `requirements/prd/PRD-${fr.id}-${slugTitle(fr.title)}.md`, renderFeaturePRD(fr, srd), files); + } + writeFile(out, "requirements/prd/README.md", renderPRDIndex(srd), files); + } writeFile(out, "requirements/NON-FUNCTIONAL.md", renderNonFunctional(srd), files); writeFile(out, "architecture/SYSTEM-CONTEXT.md", renderSystemContext(srd), files); writeFile(out, "architecture/DATA-MODEL.md", renderDataModel(srd), files); @@ -3500,7 +3545,7 @@ Usage: construct research --out [--angles market,oss,tech,semantic] [--q ""] [--semantic] construct analyze --out [--json] construct web|oss|tech|so --out [--q ""] [--url ] [--seeds ] - construct render --out [--level light|complex] [--merge] [--no-design] + construct render --out [--level light|complex] [--merge] [--no-design] [--prd] construct check --out [--min-grounding <0-100>] [--semantic] [--json] construct review --out [--apply ] [--max-review N] [--json] construct verify --out [--app ] [--run-tests] [--strict] [--json] @@ -3516,7 +3561,8 @@ Commands: render Render the SRD tree + SRD.json from brief.json + the dossier. At --level complex this also renders a design-system subtree (design/: principles, tokens, components, screens, accessibility); - --no-design opts out. + --no-design opts out. --prd also emits requirements/prd/ \u2014 one + standalone PRD per functional requirement + an index. check Hard structural gate + advisory grounding-coverage report. --semantic also folds in the review verdicts (fails on a claim its cited evidence does not support). @@ -3547,6 +3593,7 @@ Options: --per-source Max evidence items kept per source (default: 6) --merge Also emit a single-file SRD.md bundle --no-design For 'render': skip the design-system subtree (complex only) + --prd For 'render': also emit one PRD file per FR (requirements/prd/) --semantic Rescore evidence with the local embedding model --refresh Force re-clone of mined OSS repos --json Machine-readable output @@ -3579,7 +3626,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([ "apply", "max-review" ]); -var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design"]); +var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd"]); function fail(message) { process.stderr.write(`construct: ${message} `); @@ -3792,6 +3839,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); out, merge: p.bools.has("merge"), noDesign: p.bools.has("no-design"), + prd: p.bools.has("prd"), generatedAt: (/* @__PURE__ */ new Date()).toISOString() }); const design = r.srd.design; diff --git a/src/cli.ts b/src/cli.ts index 15ff189..9b554bf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,7 +31,7 @@ Usage: construct research --out [--angles market,oss,tech,semantic] [--q ""] [--semantic] construct analyze --out [--json] construct web|oss|tech|so --out [--q ""] [--url ] [--seeds ] - construct render --out [--level light|complex] [--merge] [--no-design] + construct render --out [--level light|complex] [--merge] [--no-design] [--prd] construct check --out [--min-grounding <0-100>] [--semantic] [--json] construct review --out [--apply ] [--max-review N] [--json] construct verify --out [--app ] [--run-tests] [--strict] [--json] @@ -47,7 +47,8 @@ Commands: render Render the SRD tree + SRD.json from brief.json + the dossier. At --level complex this also renders a design-system subtree (design/: principles, tokens, components, screens, accessibility); - --no-design opts out. + --no-design opts out. --prd also emits requirements/prd/ — one + standalone PRD per functional requirement + an index. check Hard structural gate + advisory grounding-coverage report. --semantic also folds in the review verdicts (fails on a claim its cited evidence does not support). @@ -78,6 +79,7 @@ Options: --per-source Max evidence items kept per source (default: 6) --merge Also emit a single-file SRD.md bundle --no-design For 'render': skip the design-system subtree (complex only) + --prd For 'render': also emit one PRD file per FR (requirements/prd/) --semantic Rescore evidence with the local embedding model --refresh Force re-clone of mined OSS repos --json Machine-readable output @@ -111,7 +113,7 @@ const VALUE_FLAGS = new Set([ "apply", "max-review", ]); -const BOOL_FLAGS = new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design"]); +const BOOL_FLAGS = new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd"]); function fail(message: string): never { process.stderr.write(`construct: ${message}\n`); @@ -354,6 +356,7 @@ async function main(): Promise { out, merge: p.bools.has("merge"), noDesign: p.bools.has("no-design"), + prd: p.bools.has("prd"), generatedAt: new Date().toISOString(), }); const design = r.srd.design; diff --git a/src/render.ts b/src/render.ts index 4b51c43..a305097 100644 --- a/src/render.ts +++ b/src/render.ts @@ -15,6 +15,8 @@ import { renderBuildPlan, renderTraceability, renderMergeBundle, + renderFeaturePRD, + renderPRDIndex, renderDesignPrinciples, renderDesignTokens, renderDesignTokensJson, @@ -39,6 +41,9 @@ export interface RenderOptions { // The design-system subtree renders at `complex` unless opted out. Light never // renders it. Default false (off) when unset. noDesign?: boolean; + // Also emit requirements/prd/ — one standalone PRD per FR + an index. Default + // false (off) when unset. + prd?: boolean; } function writeFile(out: string, rel: string, content: string, files: string[]): void { @@ -71,6 +76,16 @@ export function renderSRD(brief: Brief, evidence: EvidenceItem[], opts: RenderOp writeFile(out, "00-overview/VISION.md", renderVision(srd), files); writeFile(out, "00-overview/SCOPE.md", renderScope(srd), files); writeFile(out, "requirements/FUNCTIONAL.md", renderFunctional(srd), files); + // PRD filenames are id+title-derived and the subtree can toggle off. Clear it + // first — same hygiene as the decisions and design dirs — so a re-render never + // leaves a stale per-feature PRD behind. + rmSync(join(out, "requirements", "prd"), { recursive: true, force: true }); + if (opts.prd) { + for (const fr of srd.functional) { + writeFile(out, `requirements/prd/PRD-${fr.id}-${slugTitle(fr.title)}.md`, renderFeaturePRD(fr, srd), files); + } + writeFile(out, "requirements/prd/README.md", renderPRDIndex(srd), files); + } writeFile(out, "requirements/NON-FUNCTIONAL.md", renderNonFunctional(srd), files); writeFile(out, "architecture/SYSTEM-CONTEXT.md", renderSystemContext(srd), files); writeFile(out, "architecture/DATA-MODEL.md", renderDataModel(srd), files); diff --git a/src/templates.ts b/src/templates.ts index 03540a0..945cded 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -1,5 +1,5 @@ import { DESIGN_TOKENS_SEEDED_BANNER } from "./types.js"; -import type { SRD, ADR, DesignSystem } from "./types.js"; +import type { SRD, ADR, DesignSystem, FR } from "./types.js"; // Markdown rendering for every SRD section. Each function is pure (model slice → // string) so it is trivially golden-testable offline. @@ -104,6 +104,53 @@ export function renderFunctional(srd: SRD): string { return out.join("\n"); } +// One standalone PRD per functional requirement (`render --prd`). A per-feature +// cut of the SAME SRD.json data — FUNCTIONAL.md stays the canonical list — so a +// single feature can be handed to a tracker or an implementation agent whole: +// product context, linked NFRs resolved to their statements, touched entities/ +// interfaces, and the grounding citations. +export function renderFeaturePRD(fr: FR, srd: SRD): string { + const out = [`# PRD ${fr.id} — ${fr.title}${cite(fr.rationaleEvidence)}`, ``]; + out.push(`_Priority: ${fr.priority}_ · _Product: ${srd.product.name}_`, ``); + out.push(`## Context`, ``, srd.product.problem, ``); + out.push(`## Feature`, ``, fr.description, ``); + out.push(`## Acceptance criteria`, ``); + for (const a of fr.acceptance) { + out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`); + } + out.push(``, `## Non-functional requirements`, ``); + if (!fr.nfrs.length) out.push(`_None linked._`); + for (const id of fr.nfrs) { + const nfr = srd.nonFunctional.find((n) => n.id === id); + out.push(nfr ? `- **${nfr.id}** (${nfr.category}): ${nfr.statement}${nfr.metric ? ` — metric: ${nfr.metric}` : ""}` : `- **${id}**`); + } + out.push(``, `## Data & interfaces`, ``); + out.push(`- Entities: ${fr.entities.length ? fr.entities.join(", ") : "—"}`); + out.push(`- Interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "—"}`); + out.push(``, `## Grounding`, ``); + out.push( + fr.rationaleEvidence.length + ? `Evidence:${cite(fr.rationaleEvidence)} — see ../../evidence/EVIDENCE.md.` + : `_Ungrounded — see the grounding report (construct check)._`, + ); + out.push(``); + return out.join("\n"); +} + +export function renderPRDIndex(srd: SRD): string { + const out = [`# PRDs — one per functional requirement`, ``]; + out.push(`Rendered from SRD.json by \`construct render --prd\`. The canonical, always-current`); + out.push(`requirement list is [../FUNCTIONAL.md](../FUNCTIONAL.md); re-render after editing.`, ``); + out.push(`| PRD | Priority | Title |`); + out.push(`|---|---|---|`); + for (const fr of srd.functional) { + const file = `PRD-${fr.id}-${slugTitle(fr.title)}.md`; + out.push(`| [${file}](${file}) | ${cell(fr.priority)} | ${cell(fr.title)} |`); + } + out.push(``); + return out.join("\n"); +} + export function renderNonFunctional(srd: SRD): string { const out = [`# Non-functional requirements`, ``]; if (!srd.nonFunctional.length) out.push(`_No non-functional requirements defined._`, ``); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 1961fea..1a6ef2d 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -79,6 +79,12 @@ describe("parseArgs", () => { expect(p.bools.has("no-design")).toBe(true); }); + it("parses render with --prd as a boolean flag", () => { + const p = parseArgs(["render", "--out", "run", "--level", "complex", "--prd"]); + expect(p.command).toBe("render"); + expect(p.bools.has("prd")).toBe(true); + }); + it("exits on an unknown command", () => { expect(trapExit(() => parseArgs(["frobnicate"])).code).toBe(1); }); diff --git a/tests/render.test.ts b/tests/render.test.ts index d736b5d..f880185 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { renderSRD } from "../src/render.js"; +import { slugTitle } from "../src/templates.js"; import type { Brief, EvidenceItem, SRD } from "../src/types.js"; const FIX = join(dirname(fileURLToPath(import.meta.url)), "fixtures"); @@ -176,3 +177,45 @@ describe("renderSRD design system", () => { expect(trace).toContain("| Requirement | NFRs | ADRs | Entities | Interfaces | Components | Screens |"); }); }); + +describe("renderSRD --prd export", () => { + it("writes one PRD file per FR plus an index listing them when prd is set", () => { + const out = freshDir(); + const r = renderSRD(brief, evidence, { level: "complex", out, merge: false, generatedAt: "T", prd: true }); + const manifest = JSON.parse(readFileSync(join(out, "SRD.json"), "utf8")) as SRD; + expect(manifest.functional.length).toBeGreaterThan(0); + const index = readFileSync(join(out, "requirements/prd/README.md"), "utf8"); + for (const fr of manifest.functional) { + const rel = `requirements/prd/PRD-${fr.id}-${slugTitle(fr.title)}.md`; + expect(existsSync(join(out, rel)), rel).toBe(true); + expect(r.files).toContain(rel); + expect(index).toContain(`PRD-${fr.id}-${slugTitle(fr.title)}.md`); + } + expect(r.files).toContain("requirements/prd/README.md"); + }); + + it("renders priority, Given/When/Then, citations and resolved NFR statements in a PRD", () => { + const out = freshDir(); + renderSRD(brief, evidence, { level: "complex", out, merge: false, generatedAt: "T", prd: true }); + const manifest = JSON.parse(readFileSync(join(out, "SRD.json"), "utf8")) as SRD; + const all = manifest.functional.map((fr) => readFileSync(join(out, `requirements/prd/PRD-${fr.id}-${slugTitle(fr.title)}.md`), "utf8")).join("\n"); + expect(all).toMatch(/_Priority: (must|should|could)_/); + expect(all).toMatch(/\*\*Given\*\* .*\*\*When\*\* .*\*\*Then\*\*/); + expect(all).toMatch(/\[E\d+\]/); // grounding citations survive into the PRDs + // Linked NFRs are resolved to their statements, not left as bare ids. + const withNfr = manifest.functional.find((fr) => fr.nfrs.length > 0); + expect(withNfr, "fixture should yield at least one FR with linked NFRs at complex").toBeDefined(); + const prd = readFileSync(join(out, `requirements/prd/PRD-${withNfr!.id}-${slugTitle(withNfr!.title)}.md`), "utf8"); + const nfr = manifest.nonFunctional.find((n) => n.id === withNfr!.nfrs[0])!; + expect(prd).toContain(nfr.id); + expect(prd).toContain(nfr.statement.slice(0, 40)); + }); + + it("does not write the prd/ subtree by default and clears a stale one on re-render", () => { + const out = freshDir(); + renderSRD(brief, evidence, { level: "complex", out, merge: false, generatedAt: "T", prd: true }); + expect(existsSync(join(out, "requirements/prd/README.md"))).toBe(true); + renderSRD(brief, evidence, { level: "complex", out, merge: false, generatedAt: "T" }); + expect(existsSync(join(out, "requirements/prd"))).toBe(false); + }); +});