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
This commit is contained in:
174
scripts/backfill-fn-5696-feature-assertions.mjs
Normal file
174
scripts/backfill-fn-5696-feature-assertions.mjs
Normal file
@@ -0,0 +1,174 @@
|
||||
#!/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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user