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,
|
resolveTasksDir,
|
||||||
sfRoot,
|
sfRoot,
|
||||||
} from "./paths.js";
|
} from "./paths.js";
|
||||||
|
import { writeRoadmapJsonProjection } from "./roadmap-json-projection.js";
|
||||||
import {
|
import {
|
||||||
getAllMilestones,
|
getAllMilestones,
|
||||||
getArtifact,
|
getArtifact,
|
||||||
|
|
@ -643,7 +644,13 @@ export async function renderRoadmapFromDb(basePath, milestoneId) {
|
||||||
artifact_type: "ROADMAP",
|
artifact_type: "ROADMAP",
|
||||||
milestone_id: milestoneId,
|
milestone_id: milestoneId,
|
||||||
});
|
});
|
||||||
return { roadmapPath: absPath, content };
|
const jsonResult = writeRoadmapJsonProjection(
|
||||||
|
basePath,
|
||||||
|
milestoneId,
|
||||||
|
milestone,
|
||||||
|
slices,
|
||||||
|
);
|
||||||
|
return { roadmapPath: absPath, content, ...jsonResult };
|
||||||
}
|
}
|
||||||
// ─── Roadmap Checkbox Rendering ───────────────────────────────────────────
|
// ─── 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 { existsSync, mkdirSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { atomicWriteSync } from "./atomic-write.js";
|
import { atomicWriteSync } from "./atomic-write.js";
|
||||||
|
import { writeRoadmapJsonProjection } from "./roadmap-json-projection.js";
|
||||||
import {
|
import {
|
||||||
_getAdapter,
|
_getAdapter,
|
||||||
getMilestone,
|
getMilestone,
|
||||||
|
|
@ -389,6 +390,7 @@ export function renderRoadmapProjection(basePath, milestoneId) {
|
||||||
const dir = join(basePath, ".sf", "milestones", milestoneId);
|
const dir = join(basePath, ".sf", "milestones", milestoneId);
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
atomicWriteSync(join(dir, `${milestoneId}-ROADMAP.md`), content);
|
atomicWriteSync(join(dir, `${milestoneId}-ROADMAP.md`), content);
|
||||||
|
writeRoadmapJsonProjection(basePath, milestoneId, milestoneRow, sliceRows);
|
||||||
}
|
}
|
||||||
// ─── SUMMARY.md Projection ──────────────────────────────────────────────
|
// ─── SUMMARY.md Projection ──────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
|
|
@ -748,6 +750,30 @@ export function regenerateIfMissing(basePath, milestoneId, sliceId, fileType) {
|
||||||
}
|
}
|
||||||
return regenerated > 0;
|
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)) {
|
if (existsSync(filePath)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue