feat: write structured roadmap projections
This commit is contained in:
parent
c043503400
commit
7224460d47
4 changed files with 223 additions and 1 deletions
|
|
@ -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 ───────────────────────────────────────────
|
||||
/**
|
||||
|
|
|
|||
108
src/resources/extensions/sf/roadmap-json-projection.js
Normal file
108
src/resources/extensions/sf/roadmap-json-projection.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue