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

@@ -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 ───────────────────────────
describe("Loop State & Validator Run Schema (v31)", () => {

View File

@@ -105,6 +105,27 @@ export interface MissionSummary {
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 ─────────────────────────────────────────────────────
export interface MissionStoreEvents {
@@ -1865,6 +1886,23 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
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 {
const slice = this.getSlice(feature.sliceId);
if (!slice) {
@@ -1872,9 +1910,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
}
const milestoneId = slice.milestoneId;
const assertionText = feature.acceptanceCriteria?.trim()
|| feature.description?.trim()
|| `Verify implementation of: ${feature.title}`;
const { assertionText } = this.deriveFeatureAssertion(feature);
const existing = this.listContractAssertions(milestoneId)
.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.
*