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:
@@ -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)", () => {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user