fix: preserve requirements projection fidelity
This commit is contained in:
parent
4289946e11
commit
f643272a91
3 changed files with 195 additions and 22 deletions
|
|
@ -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 ─────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ const STATUS_SECTIONS = {
|
|||
"## active": "active",
|
||||
"## validated": "validated",
|
||||
"## deferred": "deferred",
|
||||
"## cancelled": "cancelled",
|
||||
"## out of scope": "out-of-scope",
|
||||
};
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue