Files
fusion/scripts/backfill-fn-5696-feature-assertions.mjs
gsxdsm dae4c0ea5d FN-5696: backfill missing mission feature assertion links
Backfill and repair missing mission feature assertion links so validator coverage stays intact for legacy mission data.

- add MissionStore backfillFeatureAssertions with dry-run and mission-scoped repair support
- centralize feature assertion text derivation and reuse it for create/update/backfill flows
- add regression tests for repair behavior, idempotency, and dry-run semantics
- document the operator repair workflow and add a dedicated backfill script for FN-5696

Files changed:
 docs/missions.md                                  |   2 +
 packages/core/src/__tests__/mission-store.test.ts |  84 +++++++++++
 packages/core/src/mission-store.ts                | 116 ++++++++++++++-
 scripts/backfill-fn-5696-feature-assertions.mjs   | 174 ++++++++++++++++++++++
 4 files changed, 373 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-5696

Fusion-Task-Lineage: 483bf2cc-be03-4007-860b-66d841e6aa15
2026-05-29 13:58:13 -07:00

175 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
import path from "node:path";
import process from "node:process";
import { execSync } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
function parseArgs(argv = process.argv.slice(2)) {
const apply = argv.includes("--apply");
const missionArg = argv.find((arg) => arg.startsWith("--mission="));
return {
dryRun: !apply,
apply,
missionId: missionArg ? missionArg.slice("--mission=".length).trim() || undefined : undefined,
};
}
function resolveProjectRoot() {
const commonDir = execSync("git rev-parse --git-common-dir", { encoding: "utf8" }).trim();
return path.resolve(commonDir, "..");
}
function loadFeatures(db, missionId) {
const sql = `
SELECT f.id, f.title, f.description, f.acceptanceCriteria, s.milestoneId
FROM mission_features f
INNER JOIN slices s ON s.id = f.sliceId
INNER JOIN milestones m ON m.id = s.milestoneId
${missionId ? "WHERE m.missionId = ?" : ""}
ORDER BY f.createdAt ASC
`;
return missionId ? db.prepare(sql).all(missionId) : db.prepare(sql).all();
}
function deriveAssertionText(feature) {
const acceptanceCriteria = typeof feature.acceptanceCriteria === "string" ? feature.acceptanceCriteria.trim() : "";
if (acceptanceCriteria.length > 0) {
return { text: acceptanceCriteria, textSource: "acceptanceCriteria" };
}
const description = typeof feature.description === "string" ? feature.description.trim() : "";
if (description.length > 0) {
return { text: description, textSource: "description" };
}
return {
text: `Verify implementation of: ${feature.title}`,
textSource: "fallback",
};
}
function createAssertionId(db) {
const row = db.prepare("SELECT COALESCE(MAX(CAST(SUBSTR(id, 3) AS INTEGER)), 0) AS maxId FROM mission_contract_assertions").get();
const next = Number(row?.maxId ?? 0) + 1;
return `CA${String(next).padStart(4, "0")}`;
}
function hasSourceFeatureIdColumn(db) {
const columns = db.prepare("PRAGMA table_info('mission_contract_assertions')").all();
return columns.some((column) => column?.name === "sourceFeatureId");
}
function backfillFeatureAssertions({ db, dryRun = true, missionId }) {
const features = loadFeatures(db, missionId);
const now = new Date().toISOString();
const report = {
scanned: features.length,
alreadyLinked: 0,
repaired: [],
skippedErrors: [],
};
const listLinks = db.prepare("SELECT assertionId FROM mission_feature_assertions WHERE featureId = ?");
const includeSourceFeatureId = hasSourceFeatureIdColumn(db);
const insertAssertion = includeSourceFeatureId
? db.prepare(`
INSERT INTO mission_contract_assertions
(id, milestoneId, title, assertion, status, orderIndex, sourceFeatureId, createdAt, updatedAt)
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)
`)
: db.prepare(`
INSERT INTO mission_contract_assertions
(id, milestoneId, title, assertion, status, orderIndex, createdAt, updatedAt)
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?)
`);
const nextOrder = db.prepare("SELECT COALESCE(MAX(orderIndex), -1) + 1 AS nextOrder FROM mission_contract_assertions WHERE milestoneId = ?");
const insertLink = db.prepare(
"INSERT OR IGNORE INTO mission_feature_assertions (featureId, assertionId, createdAt) VALUES (?, ?, ?)"
);
for (const feature of features) {
try {
const links = listLinks.all(feature.id);
if (links.length > 0) {
report.alreadyLinked += 1;
continue;
}
const { text, textSource } = deriveAssertionText(feature);
if (dryRun) {
report.repaired.push({
featureId: feature.id,
milestoneId: feature.milestoneId,
assertionId: "(dry-run)",
textSource,
});
continue;
}
const assertionId = createAssertionId(db);
const orderIndex = Number(nextOrder.get(feature.milestoneId)?.nextOrder ?? 0);
if (includeSourceFeatureId) {
insertAssertion.run(assertionId, feature.milestoneId, feature.title, text, orderIndex, feature.id, now, now);
} else {
insertAssertion.run(assertionId, feature.milestoneId, feature.title, text, orderIndex, now, now);
}
insertLink.run(feature.id, assertionId, now);
report.repaired.push({
featureId: feature.id,
milestoneId: feature.milestoneId,
assertionId,
textSource,
});
} catch (error) {
report.skippedErrors.push({
featureId: feature.id,
message: error instanceof Error ? error.message : String(error),
});
}
}
return report;
}
function printReport(report, { dryRun, missionId }) {
console.log(dryRun ? "Mode: DRY RUN" : "Mode: APPLY");
console.log(`Mission scope: ${missionId ?? "all"}`);
console.log(`Scanned: ${report.scanned}`);
console.log(`Already linked: ${report.alreadyLinked}`);
console.log(`Repaired: ${report.repaired.length}`);
for (const row of report.repaired) {
console.log(` - ${row.featureId} -> ${row.assertionId} (${row.textSource})`);
}
console.log(`Errors: ${report.skippedErrors.length}`);
for (const row of report.skippedErrors) {
console.log(` - ${row.featureId}: ${row.message}`);
}
}
function main(argv = process.argv.slice(2)) {
const { dryRun, missionId } = parseArgs(argv);
const projectRoot = resolveProjectRoot();
const dbPath = path.join(projectRoot, ".fusion", "fusion.db");
const db = new DatabaseSync(dbPath);
try {
const report = backfillFeatureAssertions({ db, dryRun, missionId });
printReport(report, { dryRun, missionId });
if (report.skippedErrors.length > 0) {
process.exitCode = 1;
}
} finally {
db.close();
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}