fix: preserve requirements projection fidelity

This commit is contained in:
Mikael Hugo 2026-05-17 15:02:25 +02:00
parent 4289946e11
commit f643272a91
3 changed files with 195 additions and 22 deletions

View file

@ -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 DBmarkdown 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 ─────────────────────────────────────────────────────

View file

@ -117,6 +117,7 @@ const STATUS_SECTIONS = {
"## active": "active",
"## validated": "validated",
"## deferred": "deferred",
"## cancelled": "cancelled",
"## out of scope": "out-of-scope",
};
/**

View file

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