feat: write structured roadmap projections

This commit is contained in:
Mikael Hugo 2026-05-05 23:08:03 +02:00
parent c043503400
commit 7224460d47
4 changed files with 223 additions and 1 deletions

View file

@ -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 ───────────────────────────────────────────
/**

View file

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

View file

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

View file

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