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:
gsxdsm
2026-05-29 13:58:13 -07:00
parent bac12d1e28
commit dae4c0ea5d
4 changed files with 373 additions and 3 deletions

View File

@@ -137,6 +137,8 @@ Fusion keeps a canonical per-feature assertion invariant in `MissionStore`:
- This applies to all creation paths (interview import, API, CLI, tools). - This applies to all creation paths (interview import, API, CLI, tools).
Assertion text source priority is: `acceptanceCriteria``feature.description` → fallback text (`"Verify implementation of: {feature.title}"`). Assertion text source priority is: `acceptanceCriteria``feature.description` → fallback text (`"Verify implementation of: {feature.title}"`).
**Operator repair note (FN-5696):** Some databases created before the feature-create-path fix could show feature `acceptanceCriteria`/`description` in the UI but still have zero `mission_feature_assertions` links, which caused validator auto-pass short-circuits. Run `node scripts/backfill-fn-5696-feature-assertions.mjs` to preview repairs, then `node scripts/backfill-fn-5696-feature-assertions.mjs --apply` to write links. Use `--mission=<missionId>` for scoped repair (for example the confirmed Goals mission `M-MP32KU9Y-0001-2ADN`).
- **Verification fields**: Milestone and slice verification criteria from the interview are stored in dedicated `verification` fields rather than concatenated into descriptions - **Verification fields**: Milestone and slice verification criteria from the interview are stored in dedicated `verification` fields rather than concatenated into descriptions
- **Milestone acceptanceCriteria derivation**: explicit `milestone.acceptanceCriteria` from interview output is authoritative. When omitted/blank, Fusion derives a deterministic bulleted summary from child features after creation: prefer `feature.acceptanceCriteria`, fall back to `feature.description`, skip empty contributors, and leave milestone acceptance empty when nothing contributes - **Milestone acceptanceCriteria derivation**: explicit `milestone.acceptanceCriteria` from interview output is authoritative. When omitted/blank, Fusion derives a deterministic bulleted summary from child features after creation: prefer `feature.acceptanceCriteria`, fall back to `feature.description`, skip empty contributors, and leave milestone acceptance empty when nothing contributes
- **Partial plans handled**: Auto-generation is robust to partial plans (missing slices/features or empty criteria) without throwing errors - **Partial plans handled**: Auto-generation is robust to partial plans (missing slices/features or empty criteria) without throwing errors

View File

@@ -2948,6 +2948,90 @@ describe("MissionStore", () => {
}); });
}); });
describe("backfillFeatureAssertions", () => {
const makeLegacyFeature = (sliceId: string, input: { title: string; description?: string; acceptanceCriteria?: string }) => {
const feature = store.addFeature(sliceId, input);
const managed = store.listAssertionsForFeature(feature.id);
for (const assertion of managed) {
store.unlinkFeatureFromAssertion(feature.id, assertion.id);
store.deleteContractAssertion(assertion.id);
}
expect(store.listAssertionsForFeature(feature.id)).toHaveLength(0);
return feature;
};
it("repairs missing links using acceptance criteria, description, and fallback text", () => {
const mission = store.createMission({ title: "Repair Mission" });
const milestone = store.addMilestone(mission.id, { title: "MS" });
const slice = store.addSlice(milestone.id, { title: "SL" });
const fromAcceptance = makeLegacyFeature(slice.id, { title: "F-AC", acceptanceCriteria: "Ship AC" });
const fromDescription = makeLegacyFeature(slice.id, { title: "F-DESC", description: "Ship DESC" });
const fromFallback = makeLegacyFeature(slice.id, { title: "F-FALLBACK" });
const report = store.backfillFeatureAssertions({ dryRun: false });
expect(report.scanned).toBe(3);
expect(report.alreadyLinked).toBe(0);
expect(report.skippedErrors).toHaveLength(0);
expect(report.repaired).toHaveLength(3);
const acRow = report.repaired.find((row) => row.featureId === fromAcceptance.id)!;
const descRow = report.repaired.find((row) => row.featureId === fromDescription.id)!;
const fallbackRow = report.repaired.find((row) => row.featureId === fromFallback.id)!;
expect(acRow.milestoneId).toBe(milestone.id);
expect(acRow.textSource).toBe("acceptanceCriteria");
expect(store.listAssertionsForFeature(fromAcceptance.id)[0].assertion).toBe("Ship AC");
expect(descRow.textSource).toBe("description");
expect(store.listAssertionsForFeature(fromDescription.id)[0].assertion).toBe("Ship DESC");
expect(fallbackRow.textSource).toBe("fallback");
expect(store.listAssertionsForFeature(fromFallback.id)[0].assertion).toBe("Verify implementation of: F-FALLBACK");
});
it("skips already linked features and remains idempotent", () => {
const mission = store.createMission({ title: "Repair Mission" });
const milestone = store.addMilestone(mission.id, { title: "MS" });
const slice = store.addSlice(milestone.id, { title: "SL" });
const legacy = makeLegacyFeature(slice.id, { title: "Legacy", acceptanceCriteria: "Legacy AC" });
const alreadyLinked = store.addFeature(slice.id, { title: "Already Linked", acceptanceCriteria: "Keep" });
const firstRun = store.backfillFeatureAssertions({ dryRun: false });
expect(firstRun.scanned).toBe(2);
expect(firstRun.alreadyLinked).toBe(1);
expect(firstRun.repaired).toHaveLength(1);
expect(firstRun.repaired[0]?.featureId).toBe(legacy.id);
const linkedAssertionIds = store.listAssertionsForFeature(alreadyLinked.id).map((assertion) => assertion.id);
expect(linkedAssertionIds).toHaveLength(1);
const secondRun = store.backfillFeatureAssertions({ dryRun: false });
expect(secondRun.scanned).toBe(2);
expect(secondRun.alreadyLinked).toBe(2);
expect(secondRun.repaired).toHaveLength(0);
});
it("supports dry-run mode without writing links", () => {
const mission = store.createMission({ title: "Repair Mission" });
const milestone = store.addMilestone(mission.id, { title: "MS" });
const slice = store.addSlice(milestone.id, { title: "SL" });
const legacy = makeLegacyFeature(slice.id, { title: "Legacy", description: "legacy description" });
const beforeLinks = db.prepare("SELECT COUNT(*) as count FROM mission_feature_assertions").get() as { count: number };
const report = store.backfillFeatureAssertions({ dryRun: true });
expect(report.repaired).toHaveLength(1);
expect(report.repaired[0]?.featureId).toBe(legacy.id);
expect(report.repaired[0]?.assertionId).toBe("(dry-run)");
expect(report.repaired[0]?.textSource).toBe("description");
const afterLinks = db.prepare("SELECT COUNT(*) as count FROM mission_feature_assertions").get() as { count: number };
expect(afterLinks.count).toBe(beforeLinks.count);
expect(store.listAssertionsForFeature(legacy.id)).toHaveLength(0);
});
});
// ── Loop State & Validator Run Schema Tests ─────────────────────────── // ── Loop State & Validator Run Schema Tests ───────────────────────────
describe("Loop State & Validator Run Schema (v31)", () => { describe("Loop State & Validator Run Schema (v31)", () => {

View File

@@ -105,6 +105,27 @@ export interface MissionSummary {
progressPercent: number; progressPercent: number;
} }
export type MissionAssertionTextSource = "acceptanceCriteria" | "description" | "fallback";
export interface MissionAssertionBackfillRepairRow {
featureId: string;
milestoneId: string;
assertionId: string;
textSource: MissionAssertionTextSource;
}
export interface MissionAssertionBackfillErrorRow {
featureId: string;
message: string;
}
export interface MissionAssertionBackfillReport {
scanned: number;
alreadyLinked: number;
repaired: MissionAssertionBackfillRepairRow[];
skippedErrors: MissionAssertionBackfillErrorRow[];
}
// ── Event Types ───────────────────────────────────────────────────── // ── Event Types ─────────────────────────────────────────────────────
export interface MissionStoreEvents { export interface MissionStoreEvents {
@@ -1865,6 +1886,23 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
this.recomputeSliceStatus(sliceId); this.recomputeSliceStatus(sliceId);
} }
private deriveFeatureAssertion(feature: MissionFeature): { assertionText: string; textSource: MissionAssertionTextSource } {
const acceptanceCriteria = feature.acceptanceCriteria?.trim();
if (acceptanceCriteria) {
return { assertionText: acceptanceCriteria, textSource: "acceptanceCriteria" };
}
const description = feature.description?.trim();
if (description) {
return { assertionText: description, textSource: "description" };
}
return {
assertionText: `Verify implementation of: ${feature.title}`,
textSource: "fallback",
};
}
private ensureFeatureAssertion(feature: MissionFeature): void { private ensureFeatureAssertion(feature: MissionFeature): void {
const slice = this.getSlice(feature.sliceId); const slice = this.getSlice(feature.sliceId);
if (!slice) { if (!slice) {
@@ -1872,9 +1910,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
} }
const milestoneId = slice.milestoneId; const milestoneId = slice.milestoneId;
const assertionText = feature.acceptanceCriteria?.trim() const { assertionText } = this.deriveFeatureAssertion(feature);
|| feature.description?.trim()
|| `Verify implementation of: ${feature.title}`;
const existing = this.listContractAssertions(milestoneId) const existing = this.listContractAssertions(milestoneId)
.find((assertion) => assertion.sourceFeatureId === feature.id); .find((assertion) => assertion.sourceFeatureId === feature.id);
@@ -1898,6 +1934,80 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
} }
} }
/**
* Backfill assertion links for legacy features that predate the FN-5695 creation-path fix.
* Reuses deriveFeatureAssertion()/ensureFeatureAssertion text-source rules so create/update
* and repair flows stay aligned on canonical assertion content.
*/
backfillFeatureAssertions(options?: { missionId?: string; dryRun?: boolean }): MissionAssertionBackfillReport {
const dryRun = options?.dryRun ?? true;
const missionFilter = options?.missionId;
const features = missionFilter
? this.listMilestones(missionFilter)
.flatMap((milestone) => this.listSlices(milestone.id))
.flatMap((slice) => this.listFeatures(slice.id))
: this.listMissions()
.flatMap((mission) => this.listMilestones(mission.id))
.flatMap((milestone) => this.listSlices(milestone.id))
.flatMap((slice) => this.listFeatures(slice.id));
const report: MissionAssertionBackfillReport = {
scanned: features.length,
alreadyLinked: 0,
repaired: [],
skippedErrors: [],
};
for (const feature of features) {
try {
const linkedAssertions = this.listAssertionsForFeature(feature.id);
if (linkedAssertions.length > 0) {
report.alreadyLinked += 1;
continue;
}
const slice = this.getSlice(feature.sliceId);
if (!slice) {
throw new Error(`Slice ${feature.sliceId} not found`);
}
const milestoneId = slice.milestoneId;
const { assertionText, textSource } = this.deriveFeatureAssertion(feature);
if (dryRun) {
report.repaired.push({
featureId: feature.id,
milestoneId,
assertionId: "(dry-run)",
textSource,
});
continue;
}
const created = this.addContractAssertion(milestoneId, {
title: feature.title,
assertion: assertionText,
status: "pending",
sourceFeatureId: feature.id,
});
this.linkFeatureToAssertion(feature.id, created.id);
report.repaired.push({
featureId: feature.id,
milestoneId,
assertionId: created.id,
textSource,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
report.skippedErrors.push({ featureId: feature.id, message });
}
}
return report;
}
/** /**
* Resolve the mission hierarchy for a slice. * Resolve the mission hierarchy for a slice.
* *

View 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;
}
}