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
175 lines
5.8 KiB
JavaScript
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;
|
|
}
|
|
}
|