diff --git a/src/resources/extensions/sf/db-writer.js b/src/resources/extensions/sf/db-writer.js index 9e3691fea..281db1939 100644 --- a/src/resources/extensions/sf/db-writer.js +++ b/src/resources/extensions/sf/db-writer.js @@ -112,18 +112,72 @@ const STATUS_SECTION_MAP = [ { status: "active", heading: "Active" }, { status: "validated", heading: "Validated" }, { status: "deferred", heading: "Deferred" }, + { status: "cancelled", heading: "Cancelled" }, { status: "out-of-scope", heading: "Out of Scope" }, ]; +const REQUIREMENTS_TITLE = "# Requirements: Autonomous Self-Healing"; +function requirementNumber(id) { + const match = String(id ?? "").match(/^R(\d+)$/); + return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER; +} +function singleLine(value, fallback = "") { + const text = String(value ?? "") + .replace(/\s+/g, " ") + .trim(); + return text.length > 0 ? text : fallback; +} +function requirementHeadingDescription(requirement) { + const firstLine = String(requirement.description ?? "") + .split(/\r?\n/) + .find((line) => line.trim().length > 0); + return singleLine(firstLine, "Untitled"); +} +function hasMappedOwner(requirement) { + const owner = singleLine(requirement.primary_owner).toLowerCase(); + return ( + owner.length > 0 && + owner !== "none" && + owner !== "unmapped" && + !owner.startsWith("unmapped ") + ); +} +function renderRequirementBlock(requirement) { + const stored = String(requirement.full_content ?? "").trim(); + if (stored.startsWith(`### ${requirement.id} `)) { + return stored; + } + const lines = []; + lines.push( + `### ${requirement.id} — ${requirementHeadingDescription(requirement)}`, + ); + lines.push(`- Class: ${singleLine(requirement.class, "unspecified")}`); + lines.push(`- Status: ${singleLine(requirement.status, "active")}`); + lines.push( + `- Description: ${singleLine(requirement.description, "Untitled")}`, + ); + lines.push(`- Why it matters: ${singleLine(requirement.why, "unspecified")}`); + lines.push(`- Source: ${singleLine(requirement.source, "unspecified")}`); + lines.push( + `- Primary owning slice: ${singleLine(requirement.primary_owner, "unmapped")}`, + ); + lines.push( + `- Supporting slices: ${singleLine(requirement.supporting_slices, "none")}`, + ); + lines.push(`- Validation: ${singleLine(requirement.validation, "unmapped")}`); + lines.push(`- Notes: ${singleLine(requirement.notes, "none")}`); + return lines.join("\n"); +} /** * Generate full REQUIREMENTS.md content from an array of Requirement objects. * Groups requirements by status into sections (## Active, ## Validated, etc.), * each containing ### RXXX — Description headings with bullet fields. - * Only emits sections that have content. Appends Traceability table and - * Coverage Summary at the bottom. + * Preserves DB-stored full_content blocks when present so DB→markdown projection + * does not flatten curated requirement text. Appends Traceability and Coverage + * Summary derived from actual owner mappings. */ export function generateRequirementsMd(requirements) { const lines = []; - lines.push("# Requirements"); + lines.push(REQUIREMENTS_TITLE); lines.push(""); lines.push( "This file is the explicit capability and coverage contract for the project.", @@ -131,7 +185,10 @@ export function generateRequirementsMd(requirements) { lines.push(""); // Group by status const byStatus = new Map(); - for (const r of requirements) { + const sortedRequirements = [...requirements].sort( + (a, b) => requirementNumber(a.id) - requirementNumber(b.id), + ); + for (const r of sortedRequirements) { const status = (r.status || "active").toLowerCase(); if (!byStatus.has(status)) byStatus.set(status, []); byStatus.get(status).push(r); @@ -143,19 +200,7 @@ export function generateRequirementsMd(requirements) { lines.push(`## ${heading}`); lines.push(""); for (const r of reqs) { - lines.push(`### ${r.id} — ${r.description || "Untitled"}`); - // Emit bullet fields — only those with content - if (r.class) lines.push(`- Class: ${r.class}`); - if (r.status) lines.push(`- Status: ${r.status}`); - if (r.description) lines.push(`- Description: ${r.description}`); - if (r.why) lines.push(`- Why it matters: ${r.why}`); - if (r.source) lines.push(`- Source: ${r.source}`); - if (r.primary_owner) - lines.push(`- Primary owning slice: ${r.primary_owner}`); - if (r.supporting_slices) - lines.push(`- Supporting slices: ${r.supporting_slices}`); - if (r.validation) lines.push(`- Validation: ${r.validation}`); - if (r.notes) lines.push(`- Notes: ${r.notes}`); + lines.push(renderRequirementBlock(r)); lines.push(""); } } @@ -164,25 +209,29 @@ export function generateRequirementsMd(requirements) { lines.push(""); lines.push("| ID | Class | Status | Primary owner | Supporting | Proof |"); lines.push("|---|---|---|---|---|---|"); - for (const r of requirements) { - const proof = r.validation || "unmapped"; + for (const r of sortedRequirements) { + const proof = singleLine(r.validation, "unmapped"); lines.push( - `| ${r.id} | ${r.class || ""} | ${r.status || ""} | ${r.primary_owner || "none"} | ${r.supporting_slices || "none"} | ${proof} |`, + `| ${r.id} | ${singleLine(r.class)} | ${singleLine(r.status)} | ${singleLine(r.primary_owner, "none")} | ${singleLine(r.supporting_slices, "none")} | ${proof} |`, ); } lines.push(""); // Coverage Summary const activeCount = byStatus.get("active")?.length ?? 0; + const mappedActiveCount = + byStatus.get("active")?.filter((r) => hasMappedOwner(r)).length ?? 0; const validatedReqs = byStatus.get("validated") ?? []; const validatedIds = validatedReqs.map((r) => r.id).join(", "); lines.push("## Coverage Summary"); lines.push(""); lines.push(`- Active requirements: ${activeCount}`); - lines.push(`- Mapped to slices: ${activeCount}`); + lines.push(`- Mapped to slices: ${mappedActiveCount}`); lines.push( `- Validated: ${validatedReqs.length}${validatedIds ? ` (${validatedIds})` : ""}`, ); - lines.push(`- Unmapped active requirements: 0`); + lines.push( + `- Unmapped active requirements: ${activeCount - mappedActiveCount}`, + ); return lines.join("\n") + "\n"; } // ─── Next Decision ID ───────────────────────────────────────────────────── diff --git a/src/resources/extensions/sf/md-importer.js b/src/resources/extensions/sf/md-importer.js index b829c6752..a9eb813a6 100644 --- a/src/resources/extensions/sf/md-importer.js +++ b/src/resources/extensions/sf/md-importer.js @@ -117,6 +117,7 @@ const STATUS_SECTIONS = { "## active": "active", "## validated": "validated", "## deferred": "deferred", + "## cancelled": "cancelled", "## out of scope": "out-of-scope", }; /** diff --git a/src/resources/extensions/sf/tests/db-writer-requirements-generator.test.mjs b/src/resources/extensions/sf/tests/db-writer-requirements-generator.test.mjs new file mode 100644 index 000000000..25eb978a9 --- /dev/null +++ b/src/resources/extensions/sf/tests/db-writer-requirements-generator.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { describe, test } from "vitest"; +import { generateRequirementsMd } from "../db-writer.js"; +import { parseRequirementsSections } from "../md-importer.js"; + +describe("generateRequirementsMd", () => { + test("preserves_full_content_blocks_when_projecting_from_db", () => { + const fullContent = [ + "### R001 — Doctrine Alignment", + "- Class: core-capability", + "- Status: active", + "- Description: Keep the hand-written block intact.", + "- Why it matters: Operators curated this text.", + "- Source: spec", + "- Primary owning slice: M001/S01", + "- Supporting slices: none", + "- Validation: unmapped", + "- Notes: preserve me", + ].join("\n"); + + const markdown = generateRequirementsMd([ + { + id: "R001", + class: "core-capability", + status: "active", + description: "A stale DB summary must not flatten full_content.", + why: "DB import preserved the richer block.", + source: "db", + primary_owner: "M001/S01", + supporting_slices: "", + validation: "", + notes: "", + full_content: fullContent, + }, + ]); + + assert.match(markdown, /^# Requirements: Autonomous Self-Healing/m); + assert.match(markdown, /### R001 — Doctrine Alignment/); + assert.match(markdown, /- Notes: preserve me/); + assert.doesNotMatch(markdown, /A stale DB summary/); + }); + + test("normalizes_multiline_fields_for_round_trippable_generated_blocks", () => { + const markdown = generateRequirementsMd([ + { + id: "R002", + class: "operational", + status: "active", + description: + "Address recurring drift\n\nSource IDs: sf-one, sf-two", + why: "Threshold reached\nacross multiple runs", + source: "sf-promoter", + primary_owner: "", + supporting_slices: "", + validation: "", + notes: "Source IDs: sf-one\nsf-two", + full_content: "", + }, + ]); + + assert.match(markdown, /### R002 — Address recurring drift$/m); + assert.doesNotMatch(markdown, /^Source IDs:/m); + assert.match( + markdown, + /- Description: Address recurring drift Source IDs: sf-one, sf-two/, + ); + assert.match(markdown, /- Primary owning slice: unmapped/); + assert.match(markdown, /- Unmapped active requirements: 1/); + + const parsed = parseRequirementsSections(markdown); + assert.equal(parsed.length, 1); + assert.equal(parsed[0].id, "R002"); + assert.equal( + parsed[0].description, + "Address recurring drift Source IDs: sf-one, sf-two", + ); + assert.equal(parsed[0].primary_owner, "unmapped"); + }); + + test("orders_requirements_numerically_and_reports_real_mapping_counts", () => { + const markdown = generateRequirementsMd([ + { + id: "R010", + class: "capability", + status: "active", + description: "Later", + primary_owner: "M010/S01", + }, + { + id: "R002", + class: "capability", + status: "active", + description: "Earlier", + primary_owner: "", + }, + ]); + + assert.ok(markdown.indexOf("### R002") < markdown.indexOf("### R010")); + assert.match(markdown, /- Active requirements: 2/); + assert.match(markdown, /- Mapped to slices: 1/); + assert.match(markdown, /- Unmapped active requirements: 1/); + }); + + test("round_trips_cancelled_requirements", () => { + const markdown = generateRequirementsMd([ + { + id: "R069", + class: "architecture", + status: "cancelled", + description: "Superseded daemon requirement.", + why: "The architecture moved to embedded web daemon ownership.", + source: "operator-direction", + primary_owner: "M053/S25", + }, + ]); + + assert.match(markdown, /## Cancelled/); + const parsed = parseRequirementsSections(markdown); + assert.equal(parsed.length, 1); + assert.equal(parsed[0].id, "R069"); + assert.equal(parsed[0].status, "cancelled"); + }); +});