From 7224460d47a7e181c49b6940a5e876a28fbcd618 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Tue, 5 May 2026 23:08:03 +0200 Subject: [PATCH] feat: write structured roadmap projections --- .../extensions/sf/markdown-renderer.js | 9 +- .../extensions/sf/roadmap-json-projection.js | 108 ++++++++++++++++++ .../sf/tests/roadmap-json-projection.test.mjs | 81 +++++++++++++ .../extensions/sf/workflow-projections.js | 26 +++++ 4 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/sf/roadmap-json-projection.js create mode 100644 src/resources/extensions/sf/tests/roadmap-json-projection.test.mjs diff --git a/src/resources/extensions/sf/markdown-renderer.js b/src/resources/extensions/sf/markdown-renderer.js index 1579dffc2..5712700b4 100644 --- a/src/resources/extensions/sf/markdown-renderer.js +++ b/src/resources/extensions/sf/markdown-renderer.js @@ -21,6 +21,7 @@ import { resolveTasksDir, sfRoot, } from "./paths.js"; +import { writeRoadmapJsonProjection } from "./roadmap-json-projection.js"; import { getAllMilestones, getArtifact, @@ -643,7 +644,13 @@ export async function renderRoadmapFromDb(basePath, milestoneId) { artifact_type: "ROADMAP", milestone_id: milestoneId, }); - return { roadmapPath: absPath, content }; + const jsonResult = writeRoadmapJsonProjection( + basePath, + milestoneId, + milestone, + slices, + ); + return { roadmapPath: absPath, content, ...jsonResult }; } // ─── Roadmap Checkbox Rendering ─────────────────────────────────────────── /** diff --git a/src/resources/extensions/sf/roadmap-json-projection.js b/src/resources/extensions/sf/roadmap-json-projection.js new file mode 100644 index 000000000..68309aa74 --- /dev/null +++ b/src/resources/extensions/sf/roadmap-json-projection.js @@ -0,0 +1,108 @@ +/** + * roadmap-json-projection.js - structured roadmap projection rendering. + * + * Purpose: keep dispatch fallback state machine-readable while ROADMAP.md + * remains a human display artifact. + */ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteSync } from "./atomic-write.js"; + +function normalizeStringArray(value) { + if (!Array.isArray(value)) return []; + return value.filter((item) => typeof item === "string"); +} + +function normalizeMilestone(milestoneRow) { + return { + id: String(milestoneRow.id), + title: String(milestoneRow.title ?? ""), + status: String(milestoneRow.status ?? ""), + vision: String(milestoneRow.vision ?? ""), + dependsOn: normalizeStringArray( + milestoneRow.dependsOn ?? milestoneRow.depends_on, + ), + successCriteria: normalizeStringArray( + milestoneRow.successCriteria ?? milestoneRow.success_criteria, + ), + definitionOfDone: normalizeStringArray( + milestoneRow.definitionOfDone ?? milestoneRow.definition_of_done, + ), + requirementCoverage: String( + milestoneRow.requirementCoverage ?? + milestoneRow.requirement_coverage ?? + "", + ), + boundaryMapMarkdown: String( + milestoneRow.boundaryMapMarkdown ?? + milestoneRow.boundary_map_markdown ?? + "", + ), + }; +} + +function normalizeSlice(sliceRow) { + return { + id: String(sliceRow.id), + title: String(sliceRow.title ?? ""), + status: String(sliceRow.status ?? ""), + risk: String(sliceRow.risk ?? ""), + depends: normalizeStringArray(sliceRow.depends), + demo: String(sliceRow.demo ?? ""), + goal: String(sliceRow.goal ?? ""), + successCriteria: String( + sliceRow.successCriteria ?? sliceRow.success_criteria ?? "", + ), + proofLevel: String(sliceRow.proofLevel ?? sliceRow.proof_level ?? ""), + integrationClosure: String( + sliceRow.integrationClosure ?? sliceRow.integration_closure ?? "", + ), + observabilityImpact: String( + sliceRow.observabilityImpact ?? sliceRow.observability_impact ?? "", + ), + isSketch: sliceRow.isSketch === true || sliceRow.is_sketch === 1, + sketchScope: String(sliceRow.sketchScope ?? sliceRow.sketch_scope ?? ""), + }; +} + +/** + * Render structured ROADMAP.json content from database rows. + * + * Purpose: provide a deterministic fallback projection for dispatch when the + * SQLite database is unavailable. + * + * Consumer: roadmap renderers invoked by planning and reassessment tools. + */ +export function renderRoadmapJsonProjectionContent(milestoneRow, sliceRows) { + return JSON.stringify( + { + schemaVersion: 1, + milestone: normalizeMilestone(milestoneRow), + slices: sliceRows.map(normalizeSlice), + }, + null, + 2, + ); +} + +/** + * Write Mxxx-ROADMAP.json beside the rendered Markdown roadmap. + * + * Purpose: keep human and machine projections refreshed in the same render + * transaction boundary. + * + * Consumer: renderRoadmapFromDb and renderRoadmapProjection. + */ +export function writeRoadmapJsonProjection( + basePath, + milestoneId, + milestoneRow, + sliceRows, +) { + const dir = join(basePath, ".sf", "milestones", milestoneId); + mkdirSync(dir, { recursive: true }); + const content = `${renderRoadmapJsonProjectionContent(milestoneRow, sliceRows)}\n`; + const path = join(dir, `${milestoneId}-ROADMAP.json`); + atomicWriteSync(path, content); + return { roadmapJsonPath: path, roadmapJsonContent: content }; +} diff --git a/src/resources/extensions/sf/tests/roadmap-json-projection.test.mjs b/src/resources/extensions/sf/tests/roadmap-json-projection.test.mjs new file mode 100644 index 000000000..33f8182e7 --- /dev/null +++ b/src/resources/extensions/sf/tests/roadmap-json-projection.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, test } from "vitest"; +import { getCanonicalMilestonePlan } from "../canonical-milestone-plan.js"; +import { renderRoadmapFromDb } from "../markdown-renderer.js"; +import { + closeDatabase, + insertMilestone, + insertSlice, + openDatabase, +} from "../sf-db.js"; + +const tmpDirs = []; + +afterEach(() => { + closeDatabase(); + while (tmpDirs.length > 0) { + rmSync(tmpDirs.pop(), { recursive: true, force: true }); + } +}); + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-roadmap-json-")); + tmpDirs.push(dir); + mkdirSync(join(dir, ".sf"), { recursive: true }); + openDatabase(join(dir, ".sf", "sf.db")); + return dir; +} + +describe("roadmap JSON projection", () => { + test("renderRoadmapFromDb_writes_structured_projection_for_dispatch_fallback", async () => { + const project = makeProject(); + insertMilestone({ + id: "M451", + title: "Structured fallback", + status: "active", + planning: { + vision: "Keep dispatch off rendered Markdown.", + successCriteria: ["JSON projection exists."], + }, + }); + insertSlice({ + milestoneId: "M451", + id: "S01", + title: "Projected slice", + status: "pending", + risk: "medium", + depends: [], + demo: "Dispatch can read JSON.", + sequence: 1, + }); + + const result = await renderRoadmapFromDb(project, "M451"); + + assert.equal(existsSync(result.roadmapPath), true); + assert.equal(existsSync(result.roadmapJsonPath), true); + const json = JSON.parse(readFileSync(result.roadmapJsonPath, "utf-8")); + assert.equal(json.schemaVersion, 1); + assert.equal(json.milestone.id, "M451"); + assert.deepEqual( + json.slices.map((slice) => [slice.id, slice.title, slice.risk]), + [["S01", "Projected slice", "medium"]], + ); + closeDatabase(); + rmSync(join(project, ".sf", "sf.db"), { force: true }); + + const fallback = getCanonicalMilestonePlan(project, "M451"); + + assert.equal(fallback.safe, true); + assert.equal(fallback.source, "projection"); + assert.equal(fallback.slices[0].id, "S01"); + }); +}); diff --git a/src/resources/extensions/sf/workflow-projections.js b/src/resources/extensions/sf/workflow-projections.js index dda9c2536..c741ebd91 100644 --- a/src/resources/extensions/sf/workflow-projections.js +++ b/src/resources/extensions/sf/workflow-projections.js @@ -4,6 +4,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteSync } from "./atomic-write.js"; +import { writeRoadmapJsonProjection } from "./roadmap-json-projection.js"; import { _getAdapter, getMilestone, @@ -389,6 +390,7 @@ export function renderRoadmapProjection(basePath, milestoneId) { const dir = join(basePath, ".sf", "milestones", milestoneId); mkdirSync(dir, { recursive: true }); atomicWriteSync(join(dir, `${milestoneId}-ROADMAP.md`), content); + writeRoadmapJsonProjection(basePath, milestoneId, milestoneRow, sliceRows); } // ─── SUMMARY.md Projection ────────────────────────────────────────────── /** @@ -748,6 +750,30 @@ export function regenerateIfMissing(basePath, milestoneId, sliceId, fileType) { } return regenerated > 0; } + if ( + fileType === "ROADMAP" && + existsSync(filePath) && + !existsSync( + join( + basePath, + ".sf", + "milestones", + milestoneId, + `${milestoneId}-ROADMAP.json`, + ), + ) + ) { + try { + renderRoadmapProjection(basePath, milestoneId); + return true; + } catch (err) { + logWarning( + "projection", + `regenerateIfMissing ROADMAP.json failed for ${milestoneId}: ${err.message}`, + ); + return false; + } + } if (existsSync(filePath)) { return false; }