feat(FN-1567): add mission contract assertions model types and APIs
- Add mission contract assertions model with types: ContractAssertion, ContractAssertionStatus, ContractAssertionType - Add assertions table to schema v29 with migration from v28 - Implement assertion APIs: create, update, link/unlink to features, list, getWithContext - Add rollup computation (assertion counts, pass rates) for missions and milestones - Add many-to-many feature-to-assertion linking via featureAssertions table - Add project memory documentation for assertion lifecycle and patterns - Update schema version assertions in db.test.ts, run-audit.test.ts, and task-documents.test.ts
This commit is contained in:
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(28);
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(28);
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -135,7 +135,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(28);
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -741,8 +741,8 @@ describe("schema migrations", () => {
|
||||
// Now run init() which should trigger migration
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 28 (includes v1→v2 through v26→v28)
|
||||
expect(db.getSchemaVersion()).toBe(28);
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -767,11 +767,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(28);
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(28);
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -787,7 +787,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(28);
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -811,7 +811,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(28);
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -914,8 +914,8 @@ describe("schema migrations", () => {
|
||||
// Now run init() which should trigger migrations v2→v3→v4
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 28
|
||||
expect(db.getSchemaVersion()).toBe(28);
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1281,7 +1281,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(28);
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 28;
|
||||
const SCHEMA_VERSION = 29;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -1031,6 +1031,55 @@ export class Database {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mission contract assertions (FN-1567)
|
||||
// Adds explicit validation contract model for milestone behavioral assertions
|
||||
// with feature linkage tracking and validation state rollup.
|
||||
if (version < 29) {
|
||||
this.applyMigration(29, () => {
|
||||
// Add validationState column to milestones table
|
||||
this.addColumnIfMissing("milestones", "validationState", "TEXT NOT NULL DEFAULT 'not_started'");
|
||||
|
||||
// Create mission_contract_assertions table for milestone validation contracts
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mission_contract_assertions (
|
||||
id TEXT PRIMARY KEY,
|
||||
milestoneId TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
assertion TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (milestoneId) REFERENCES milestones(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Create mission_feature_assertions link table for many-to-many relationships
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mission_feature_assertions (
|
||||
featureId TEXT NOT NULL,
|
||||
assertionId TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
PRIMARY KEY (featureId, assertionId),
|
||||
FOREIGN KEY (featureId) REFERENCES mission_features(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (assertionId) REFERENCES mission_contract_assertions(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Index for deterministic ordering when listing assertions for a milestone
|
||||
// Covers: WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxContractAssertionsMilestoneOrder ON mission_contract_assertions(milestoneId, orderIndex, createdAt, id)`);
|
||||
|
||||
// Index for finding all assertions linked to a feature
|
||||
// Covers: WHERE featureId = ? (from mission_feature_assertions)
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxFeatureAssertionsFeatureId ON mission_feature_assertions(featureId)`);
|
||||
|
||||
// Index for finding all features linked to an assertion
|
||||
// Covers: WHERE assertionId = ? (from mission_feature_assertions)
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxFeatureAssertionsAssertionId ON mission_feature_assertions(assertionId)`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -213,6 +213,22 @@ export type {
|
||||
FeatureEventPayload,
|
||||
FeatureDeletedPayload,
|
||||
FeatureLinkedPayload,
|
||||
// Contract assertion types
|
||||
MISSION_ASSERTION_STATUSES,
|
||||
MILESTONE_VALIDATION_STATES,
|
||||
MissionAssertionStatus,
|
||||
MilestoneValidationState,
|
||||
MissionContractAssertion,
|
||||
FeatureAssertionLink,
|
||||
MilestoneValidationRollup,
|
||||
ContractAssertionCreateInput,
|
||||
ContractAssertionUpdateInput,
|
||||
AssertionCreatedPayload,
|
||||
AssertionUpdatedPayload,
|
||||
AssertionDeletedPayload,
|
||||
AssertionLinkedPayload,
|
||||
AssertionUnlinkedPayload,
|
||||
MilestoneValidationUpdatedPayload,
|
||||
} from "./mission-types.js";
|
||||
export { MissionStore } from "./mission-store.js";
|
||||
export type { MissionStoreEvents, MissionSummary } from "./mission-store.js";
|
||||
|
||||
@@ -1917,6 +1917,542 @@ describe("MissionStore", () => {
|
||||
expect(updatedF1.taskId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Contract Assertion Tests ────────────────────────────────────────
|
||||
|
||||
describe("Contract Assertions", () => {
|
||||
let mission: ReturnType<typeof store.createMission>;
|
||||
let milestone: ReturnType<typeof store.addMilestone>;
|
||||
|
||||
beforeEach(() => {
|
||||
mission = store.createMission({ title: "Test Mission" });
|
||||
milestone = store.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
});
|
||||
|
||||
it("creates an assertion with correct defaults", () => {
|
||||
const assertion = store.addContractAssertion(milestone.id, {
|
||||
title: "Auth works",
|
||||
assertion: "Users can log in and log out",
|
||||
});
|
||||
|
||||
expect(assertion.id).toMatch(/^CA-/);
|
||||
expect(assertion.milestoneId).toBe(milestone.id);
|
||||
expect(assertion.title).toBe("Auth works");
|
||||
expect(assertion.assertion).toBe("Users can log in and log out");
|
||||
expect(assertion.status).toBe("pending");
|
||||
expect(assertion.orderIndex).toBe(0);
|
||||
expect(assertion.createdAt).toBeTruthy();
|
||||
expect(assertion.updatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creates assertions with auto-incrementing orderIndex", () => {
|
||||
const a1 = store.addContractAssertion(milestone.id, {
|
||||
title: "First",
|
||||
assertion: "First assertion",
|
||||
});
|
||||
const a2 = store.addContractAssertion(milestone.id, {
|
||||
title: "Second",
|
||||
assertion: "Second assertion",
|
||||
});
|
||||
const a3 = store.addContractAssertion(milestone.id, {
|
||||
title: "Third",
|
||||
assertion: "Third assertion",
|
||||
});
|
||||
|
||||
expect(a1.orderIndex).toBe(0);
|
||||
expect(a2.orderIndex).toBe(1);
|
||||
expect(a3.orderIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("lists assertions in deterministic order", () => {
|
||||
store.addContractAssertion(milestone.id, {
|
||||
title: "First",
|
||||
assertion: "First assertion",
|
||||
});
|
||||
store.addContractAssertion(milestone.id, {
|
||||
title: "Second",
|
||||
assertion: "Second assertion",
|
||||
});
|
||||
|
||||
const assertions = store.listContractAssertions(milestone.id);
|
||||
|
||||
expect(assertions).toHaveLength(2);
|
||||
expect(assertions[0].title).toBe("First");
|
||||
expect(assertions[1].title).toBe("Second");
|
||||
});
|
||||
|
||||
it("gets an assertion by id", () => {
|
||||
const created = store.addContractAssertion(milestone.id, {
|
||||
title: "Get Test",
|
||||
assertion: "Test assertion",
|
||||
});
|
||||
|
||||
const retrieved = store.getContractAssertion(created.id);
|
||||
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved!.id).toBe(created.id);
|
||||
expect(retrieved!.title).toBe("Get Test");
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent assertion", () => {
|
||||
const result = store.getContractAssertion("CA-NONEXISTENT");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates an assertion", () => {
|
||||
const assertion = store.addContractAssertion(milestone.id, {
|
||||
title: "Original",
|
||||
assertion: "Original assertion",
|
||||
});
|
||||
|
||||
const updated = store.updateContractAssertion(assertion.id, {
|
||||
title: "Updated",
|
||||
status: "passed",
|
||||
});
|
||||
|
||||
expect(updated.id).toBe(assertion.id);
|
||||
expect(updated.title).toBe("Updated");
|
||||
expect(updated.status).toBe("passed");
|
||||
expect(updated.assertion).toBe("Original assertion"); // unchanged
|
||||
});
|
||||
|
||||
it("updates assertion status", () => {
|
||||
const assertion = store.addContractAssertion(milestone.id, {
|
||||
title: "Status Test",
|
||||
assertion: "Test",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
const passed = store.updateContractAssertion(assertion.id, { status: "passed" });
|
||||
expect(passed.status).toBe("passed");
|
||||
|
||||
const failed = store.updateContractAssertion(assertion.id, { status: "failed" });
|
||||
expect(failed.status).toBe("failed");
|
||||
|
||||
const blocked = store.updateContractAssertion(assertion.id, { status: "blocked" });
|
||||
expect(blocked.status).toBe("blocked");
|
||||
});
|
||||
|
||||
it("deletes an assertion", () => {
|
||||
const assertion = store.addContractAssertion(milestone.id, {
|
||||
title: "Delete Test",
|
||||
assertion: "Test",
|
||||
});
|
||||
|
||||
store.deleteContractAssertion(assertion.id);
|
||||
|
||||
const retrieved = store.getContractAssertion(assertion.id);
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reorders assertions", () => {
|
||||
const a1 = store.addContractAssertion(milestone.id, { title: "A", assertion: "A" });
|
||||
const a2 = store.addContractAssertion(milestone.id, { title: "B", assertion: "B" });
|
||||
const a3 = store.addContractAssertion(milestone.id, { title: "C", assertion: "C" });
|
||||
|
||||
store.reorderContractAssertions(milestone.id, [a3.id, a1.id, a2.id]);
|
||||
|
||||
const assertions = store.listContractAssertions(milestone.id);
|
||||
expect(assertions[0].id).toBe(a3.id);
|
||||
expect(assertions[1].id).toBe(a1.id);
|
||||
expect(assertions[2].id).toBe(a2.id);
|
||||
});
|
||||
|
||||
it("throws when reordering with non-existent assertion", () => {
|
||||
expect(() =>
|
||||
store.reorderContractAssertions(milestone.id, ["CA-NONEXISTENT"])
|
||||
).toThrow("Assertion CA-NONEXISTENT not found");
|
||||
});
|
||||
|
||||
it("throws when reordering assertion from different milestone", async () => {
|
||||
const milestone2 = store.addMilestone(mission.id, { title: "Milestone 2" });
|
||||
const a1 = store.addContractAssertion(milestone.id, { title: "A", assertion: "A" });
|
||||
const a2 = store.addContractAssertion(milestone2.id, { title: "B", assertion: "B" });
|
||||
|
||||
expect(() =>
|
||||
store.reorderContractAssertions(milestone.id, [a1.id, a2.id])
|
||||
).toThrow(`Assertion ${a2.id} does not belong to milestone ${milestone.id}`);
|
||||
});
|
||||
|
||||
it("emits assertion:created event", () => {
|
||||
const events: any[] = [];
|
||||
store.on("assertion:created", (a) => events.push(a));
|
||||
|
||||
const assertion = store.addContractAssertion(milestone.id, {
|
||||
title: "Event Test",
|
||||
assertion: "Test",
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].id).toBe(assertion.id);
|
||||
});
|
||||
|
||||
it("emits assertion:updated event", () => {
|
||||
const events: any[] = [];
|
||||
store.on("assertion:updated", (a) => events.push(a));
|
||||
|
||||
const assertion = store.addContractAssertion(milestone.id, {
|
||||
title: "Event Test",
|
||||
assertion: "Test",
|
||||
});
|
||||
store.updateContractAssertion(assertion.id, { status: "passed" });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].status).toBe("passed");
|
||||
});
|
||||
|
||||
it("emits assertion:deleted event", () => {
|
||||
const events: any[] = [];
|
||||
store.on("assertion:deleted", (id) => events.push(id));
|
||||
|
||||
const assertion = store.addContractAssertion(milestone.id, {
|
||||
title: "Event Test",
|
||||
assertion: "Test",
|
||||
});
|
||||
store.deleteContractAssertion(assertion.id);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toBe(assertion.id);
|
||||
});
|
||||
|
||||
it("throws when creating assertion for non-existent milestone", () => {
|
||||
expect(() =>
|
||||
store.addContractAssertion("MS-NONEXISTENT", {
|
||||
title: "Test",
|
||||
assertion: "Test",
|
||||
})
|
||||
).toThrow("Milestone MS-NONEXISTENT not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Feature-Assertion Link Tests ───────────────────────────────────
|
||||
|
||||
describe("Feature-Assertion Links", () => {
|
||||
let mission: ReturnType<typeof store.createMission>;
|
||||
let milestone: ReturnType<typeof store.addMilestone>;
|
||||
let slice: ReturnType<typeof store.addSlice>;
|
||||
let feature: ReturnType<typeof store.addFeature>;
|
||||
let assertion: ReturnType<typeof store.addContractAssertion>;
|
||||
|
||||
beforeEach(() => {
|
||||
mission = store.createMission({ title: "Test Mission" });
|
||||
milestone = store.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
slice = store.addSlice(milestone.id, { title: "Test Slice" });
|
||||
feature = store.addFeature(slice.id, { title: "Test Feature" });
|
||||
assertion = store.addContractAssertion(milestone.id, {
|
||||
title: "Test Assertion",
|
||||
assertion: "Test assertion content",
|
||||
});
|
||||
});
|
||||
|
||||
it("links a feature to an assertion", () => {
|
||||
store.linkFeatureToAssertion(feature.id, assertion.id);
|
||||
|
||||
const linkedAssertions = store.listAssertionsForFeature(feature.id);
|
||||
expect(linkedAssertions).toHaveLength(1);
|
||||
expect(linkedAssertions[0].id).toBe(assertion.id);
|
||||
});
|
||||
|
||||
it("lists assertions for a feature", () => {
|
||||
const a1 = store.addContractAssertion(milestone.id, { title: "A1", assertion: "A1" });
|
||||
const a2 = store.addContractAssertion(milestone.id, { title: "A2", assertion: "A2" });
|
||||
|
||||
store.linkFeatureToAssertion(feature.id, a1.id);
|
||||
store.linkFeatureToAssertion(feature.id, a2.id);
|
||||
|
||||
const linked = store.listAssertionsForFeature(feature.id);
|
||||
expect(linked).toHaveLength(2);
|
||||
expect(linked.map((a) => a.title).sort()).toEqual(["A1", "A2"]);
|
||||
});
|
||||
|
||||
it("lists features for an assertion", () => {
|
||||
const f2 = store.addFeature(slice.id, { title: "Feature 2" });
|
||||
const f3 = store.addFeature(slice.id, { title: "Feature 3" });
|
||||
|
||||
store.linkFeatureToAssertion(feature.id, assertion.id);
|
||||
store.linkFeatureToAssertion(f2.id, assertion.id);
|
||||
store.linkFeatureToAssertion(f3.id, assertion.id);
|
||||
|
||||
const linked = store.listFeaturesForAssertion(assertion.id);
|
||||
expect(linked).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("unlinks a feature from an assertion", () => {
|
||||
store.linkFeatureToAssertion(feature.id, assertion.id);
|
||||
store.unlinkFeatureFromAssertion(feature.id, assertion.id);
|
||||
|
||||
const linked = store.listAssertionsForFeature(feature.id);
|
||||
expect(linked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("throws when linking already-linked feature-assertion pair", () => {
|
||||
store.linkFeatureToAssertion(feature.id, assertion.id);
|
||||
|
||||
expect(() =>
|
||||
store.linkFeatureToAssertion(feature.id, assertion.id)
|
||||
).toThrow("Feature " + feature.id + " is already linked to assertion " + assertion.id);
|
||||
});
|
||||
|
||||
it("throws when unlinking non-existent link", () => {
|
||||
expect(() =>
|
||||
store.unlinkFeatureFromAssertion(feature.id, assertion.id)
|
||||
).toThrow("Feature " + feature.id + " is not linked to assertion " + assertion.id);
|
||||
});
|
||||
|
||||
it("throws when linking non-existent feature", () => {
|
||||
expect(() =>
|
||||
store.linkFeatureToAssertion("F-NONEXISTENT", assertion.id)
|
||||
).toThrow("Feature F-NONEXISTENT not found");
|
||||
});
|
||||
|
||||
it("throws when linking to non-existent assertion", () => {
|
||||
expect(() =>
|
||||
store.linkFeatureToAssertion(feature.id, "CA-NONEXISTENT")
|
||||
).toThrow("Assertion CA-NONEXISTENT not found");
|
||||
});
|
||||
|
||||
it("emits assertion:linked event", () => {
|
||||
const events: any[] = [];
|
||||
store.on("assertion:linked", (e) => events.push(e));
|
||||
|
||||
store.linkFeatureToAssertion(feature.id, assertion.id);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].featureId).toBe(feature.id);
|
||||
expect(events[0].assertionId).toBe(assertion.id);
|
||||
});
|
||||
|
||||
it("emits assertion:unlinked event", () => {
|
||||
store.linkFeatureToAssertion(feature.id, assertion.id);
|
||||
|
||||
const events: any[] = [];
|
||||
store.on("assertion:unlinked", (e) => events.push(e));
|
||||
|
||||
store.unlinkFeatureFromAssertion(feature.id, assertion.id);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].featureId).toBe(feature.id);
|
||||
expect(events[0].assertionId).toBe(assertion.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Validation Rollup Tests ─────────────────────────────────────────
|
||||
|
||||
describe("Validation Rollup", () => {
|
||||
let mission: ReturnType<typeof store.createMission>;
|
||||
let milestone: ReturnType<typeof store.addMilestone>;
|
||||
|
||||
beforeEach(() => {
|
||||
mission = store.createMission({ title: "Test Mission" });
|
||||
milestone = store.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
});
|
||||
|
||||
it("rolls up not_started when no assertions exist", () => {
|
||||
const rollup = store.getMilestoneValidationRollup(milestone.id);
|
||||
|
||||
expect(rollup.milestoneId).toBe(milestone.id);
|
||||
expect(rollup.totalAssertions).toBe(0);
|
||||
expect(rollup.passedAssertions).toBe(0);
|
||||
expect(rollup.failedAssertions).toBe(0);
|
||||
expect(rollup.blockedAssertions).toBe(0);
|
||||
expect(rollup.pendingAssertions).toBe(0);
|
||||
expect(rollup.unlinkedAssertions).toBe(0);
|
||||
expect(rollup.state).toBe("not_started");
|
||||
});
|
||||
|
||||
it("rolls up needs_coverage when assertions are not linked", () => {
|
||||
store.addContractAssertion(milestone.id, {
|
||||
title: "A1",
|
||||
assertion: "Test",
|
||||
});
|
||||
store.addContractAssertion(milestone.id, {
|
||||
title: "A2",
|
||||
assertion: "Test",
|
||||
});
|
||||
|
||||
const rollup = store.getMilestoneValidationRollup(milestone.id);
|
||||
|
||||
expect(rollup.totalAssertions).toBe(2);
|
||||
expect(rollup.unlinkedAssertions).toBe(2);
|
||||
expect(rollup.state).toBe("needs_coverage");
|
||||
});
|
||||
|
||||
it("rolls up ready when assertions are linked but not all passed", () => {
|
||||
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = store.addFeature(slice.id, { title: "Feature" });
|
||||
|
||||
const a1 = store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
|
||||
const a2 = store.addContractAssertion(milestone.id, { title: "A2", assertion: "T" });
|
||||
|
||||
store.linkFeatureToAssertion(feature.id, a1.id);
|
||||
store.linkFeatureToAssertion(feature.id, a2.id);
|
||||
store.updateContractAssertion(a1.id, { status: "passed" });
|
||||
|
||||
const rollup = store.getMilestoneValidationRollup(milestone.id);
|
||||
|
||||
expect(rollup.totalAssertions).toBe(2);
|
||||
expect(rollup.passedAssertions).toBe(1);
|
||||
expect(rollup.unlinkedAssertions).toBe(0);
|
||||
expect(rollup.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("rolls up passed when all assertions are passed", () => {
|
||||
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = store.addFeature(slice.id, { title: "Feature" });
|
||||
|
||||
const a1 = store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
|
||||
const a2 = store.addContractAssertion(milestone.id, { title: "A2", assertion: "T" });
|
||||
|
||||
store.linkFeatureToAssertion(feature.id, a1.id);
|
||||
store.linkFeatureToAssertion(feature.id, a2.id);
|
||||
store.updateContractAssertion(a1.id, { status: "passed" });
|
||||
store.updateContractAssertion(a2.id, { status: "passed" });
|
||||
|
||||
const rollup = store.getMilestoneValidationRollup(milestone.id);
|
||||
|
||||
expect(rollup.state).toBe("passed");
|
||||
});
|
||||
|
||||
it("rolls up failed when any assertion has failed status", () => {
|
||||
store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
|
||||
store.addContractAssertion(milestone.id, { title: "A2", assertion: "T" });
|
||||
|
||||
const [a1] = store.listContractAssertions(milestone.id);
|
||||
store.updateContractAssertion(a1.id, { status: "failed" });
|
||||
|
||||
const rollup = store.getMilestoneValidationRollup(milestone.id);
|
||||
|
||||
expect(rollup.state).toBe("failed");
|
||||
expect(rollup.failedAssertions).toBe(1);
|
||||
});
|
||||
|
||||
it("rolls up blocked when any assertion is blocked (before failed)", () => {
|
||||
const a1 = store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
|
||||
store.updateContractAssertion(a1.id, { status: "failed" });
|
||||
const a2 = store.addContractAssertion(milestone.id, { title: "A2", assertion: "T" });
|
||||
store.updateContractAssertion(a2.id, { status: "blocked" });
|
||||
|
||||
// Failed takes precedence over blocked in the precedence order
|
||||
const rollup = store.getMilestoneValidationRollup(milestone.id);
|
||||
|
||||
expect(rollup.state).toBe("failed");
|
||||
});
|
||||
|
||||
it("rolls up blocked when no failures but has blocked", () => {
|
||||
store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
|
||||
store.addContractAssertion(milestone.id, { title: "A2", assertion: "T" });
|
||||
|
||||
const [a1] = store.listContractAssertions(milestone.id);
|
||||
store.updateContractAssertion(a1.id, { status: "blocked" });
|
||||
|
||||
const rollup = store.getMilestoneValidationRollup(milestone.id);
|
||||
|
||||
expect(rollup.state).toBe("blocked");
|
||||
expect(rollup.blockedAssertions).toBe(1);
|
||||
});
|
||||
|
||||
it("persists validation state on milestone after assertion change", () => {
|
||||
store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
|
||||
store.addContractAssertion(milestone.id, { title: "A2", assertion: "T" });
|
||||
|
||||
// Initial state should be needs_coverage
|
||||
let m = store.getMilestone(milestone.id)!;
|
||||
expect(m.validationState).toBe("needs_coverage");
|
||||
|
||||
// Link all assertions
|
||||
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = store.addFeature(slice.id, { title: "Feature" });
|
||||
const assertions = store.listContractAssertions(milestone.id);
|
||||
for (const a of assertions) {
|
||||
store.linkFeatureToAssertion(feature.id, a.id);
|
||||
}
|
||||
|
||||
// After linking, state should be ready
|
||||
m = store.getMilestone(milestone.id)!;
|
||||
expect(m.validationState).toBe("ready");
|
||||
});
|
||||
|
||||
it("emits milestone:validation:updated when assertions change", () => {
|
||||
const events: any[] = [];
|
||||
store.on("milestone:validation:updated", (e) => events.push(e));
|
||||
|
||||
store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].milestoneId).toBe(milestone.id);
|
||||
expect(events[0].state).toBe("needs_coverage");
|
||||
expect(events[0].rollup.totalAssertions).toBe(1);
|
||||
});
|
||||
|
||||
it("emits milestone:validation:updated when links change", () => {
|
||||
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = store.addFeature(slice.id, { title: "Feature" });
|
||||
const assertion = store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
|
||||
|
||||
const events: any[] = [];
|
||||
store.on("milestone:validation:updated", (e) => events.push(e));
|
||||
|
||||
store.linkFeatureToAssertion(feature.id, assertion.id);
|
||||
|
||||
// Should emit twice: once from assertion add, once from link
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
expect(events[events.length - 1].state).toBe("ready"); // linked but not passed
|
||||
expect(events[events.length - 1].rollup.unlinkedAssertions).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildEnrichedDescription with Assertions Tests ────────────────────
|
||||
|
||||
describe("buildEnrichedDescription with Assertions", () => {
|
||||
it("includes linked assertions in enriched description", () => {
|
||||
const mission = store.createMission({ title: "Auth Mission" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "Core Auth" });
|
||||
const slice = store.addSlice(milestone.id, { title: "Login" });
|
||||
const feature = store.addFeature(slice.id, {
|
||||
title: "Login Form",
|
||||
description: "The login form component",
|
||||
});
|
||||
|
||||
const a1 = store.addContractAssertion(milestone.id, {
|
||||
title: "Validates input",
|
||||
assertion: "The form must validate email and password fields",
|
||||
});
|
||||
const a2 = store.addContractAssertion(milestone.id, {
|
||||
title: "Shows errors",
|
||||
assertion: "Invalid credentials must show an error message",
|
||||
});
|
||||
|
||||
store.linkFeatureToAssertion(feature.id, a1.id);
|
||||
store.linkFeatureToAssertion(feature.id, a2.id);
|
||||
|
||||
const description = store.buildEnrichedDescription(feature.id);
|
||||
|
||||
expect(description).toContain("## Mission: Auth Mission");
|
||||
expect(description).toContain("## Milestone: Core Auth");
|
||||
expect(description).toContain("## Slice: Login");
|
||||
expect(description).toContain("## Feature: Login Form");
|
||||
expect(description).toContain("The login form component");
|
||||
expect(description).toContain("## Contract Assertions");
|
||||
expect(description).toContain("Validates input");
|
||||
expect(description).toContain("Shows errors");
|
||||
expect(description).toContain("The form must validate email and password fields");
|
||||
});
|
||||
|
||||
it("does not include Contract Assertions section when no assertions linked", () => {
|
||||
const mission = store.createMission({ title: "Test Mission" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = store.addFeature(slice.id, { title: "Feature" });
|
||||
|
||||
// Create assertions but don't link them
|
||||
store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
|
||||
|
||||
const description = store.buildEnrichedDescription(feature.id);
|
||||
|
||||
expect(description).toContain("## Feature: Feature");
|
||||
expect(description).not.toContain("## Contract Assertions");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// vi import for vitest mocking
|
||||
|
||||
@@ -34,6 +34,12 @@ import type {
|
||||
MissionEventType,
|
||||
MissionHealth,
|
||||
SlicePlanState,
|
||||
MissionContractAssertion,
|
||||
FeatureAssertionLink,
|
||||
MilestoneValidationRollup,
|
||||
ContractAssertionCreateInput,
|
||||
ContractAssertionUpdateInput,
|
||||
MilestoneValidationState,
|
||||
} from "./mission-types.js";
|
||||
|
||||
// ── Mission Summary Type ─────────────────────────────────────────────
|
||||
@@ -85,6 +91,18 @@ export interface MissionStoreEvents {
|
||||
"feature:linked": [{ feature: MissionFeature; taskId: string }];
|
||||
/** Emitted when a mission lifecycle event is persisted */
|
||||
"mission:event": [MissionEvent];
|
||||
/** Emitted when a contract assertion is created */
|
||||
"assertion:created": [MissionContractAssertion];
|
||||
/** Emitted when a contract assertion is updated */
|
||||
"assertion:updated": [MissionContractAssertion];
|
||||
/** Emitted when a contract assertion is deleted */
|
||||
"assertion:deleted": [string];
|
||||
/** Emitted when a feature is linked to an assertion */
|
||||
"assertion:linked": [{ featureId: string; assertionId: string }];
|
||||
/** Emitted when a feature is unlinked from an assertion */
|
||||
"assertion:unlinked": [{ featureId: string; assertionId: string }];
|
||||
/** Emitted when a milestone's validation state is recomputed */
|
||||
"milestone:validation:updated": [{ milestoneId: string; state: MilestoneValidationState; rollup: MilestoneValidationRollup }];
|
||||
}
|
||||
|
||||
// ── MissionStore Class ──────────────────────────────────────────────
|
||||
@@ -149,11 +167,39 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
dependencies: fromJson<string[]>(row.dependencies) || [],
|
||||
planningNotes: row.planningNotes || undefined,
|
||||
verification: row.verification || undefined,
|
||||
validationState: (row.validationState as MilestoneValidationState) || "not_started",
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database row to a MissionContractAssertion object.
|
||||
*/
|
||||
private rowToAssertion(row: any): MissionContractAssertion {
|
||||
return {
|
||||
id: row.id,
|
||||
milestoneId: row.milestoneId,
|
||||
title: row.title,
|
||||
assertion: row.assertion,
|
||||
status: row.status as import("./mission-types.js").MissionAssertionStatus,
|
||||
orderIndex: row.orderIndex,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database row to a FeatureAssertionLink object.
|
||||
*/
|
||||
private rowToFeatureAssertionLink(row: any): FeatureAssertionLink {
|
||||
return {
|
||||
featureId: row.featureId,
|
||||
assertionId: row.assertionId,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database row to a Slice object.
|
||||
*/
|
||||
@@ -884,13 +930,14 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
dependencies: input.dependencies || [],
|
||||
planningNotes: input.planningNotes,
|
||||
verification: input.verification,
|
||||
validationState: "not_started",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO milestones (id, missionId, title, description, status, orderIndex, interviewState, dependencies, planningNotes, verification, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO milestones (id, missionId, title, description, status, orderIndex, interviewState, dependencies, planningNotes, verification, validationState, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
milestone.id,
|
||||
milestone.missionId,
|
||||
@@ -902,6 +949,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
toJson(milestone.dependencies),
|
||||
milestone.planningNotes ?? null,
|
||||
milestone.verification ?? null,
|
||||
milestone.validationState as string,
|
||||
milestone.createdAt,
|
||||
milestone.updatedAt,
|
||||
);
|
||||
@@ -969,6 +1017,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
dependencies = ?,
|
||||
planningNotes = ?,
|
||||
verification = ?,
|
||||
validationState = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
@@ -980,6 +1029,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
toJson(updated.dependencies),
|
||||
updated.planningNotes ?? null,
|
||||
updated.verification ?? null,
|
||||
updated.validationState || "not_started",
|
||||
updated.updatedAt,
|
||||
updated.id,
|
||||
);
|
||||
@@ -1617,6 +1667,443 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
return this.rowToFeature(row);
|
||||
}
|
||||
|
||||
// ── Contract Assertion Operations ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a contract assertion to a milestone.
|
||||
* Automatically computes the orderIndex (max + 1).
|
||||
*
|
||||
* ## Assertion Lifecycle
|
||||
*
|
||||
* Assertions transition through these statuses:
|
||||
* - `pending` — Initial state, assertion has not been validated
|
||||
* - `passed` — Assertion has been validated and passed
|
||||
* - `failed` — Assertion has been validated and failed
|
||||
* - `blocked` — Assertion cannot be validated due to external blockers
|
||||
*
|
||||
* Status transitions are managed by calling `updateContractAssertion()` with
|
||||
* the appropriate status value.
|
||||
*
|
||||
* @param milestoneId - Parent milestone ID
|
||||
* @param input - Assertion creation input
|
||||
* @returns The created assertion
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
addContractAssertion(milestoneId: string, input: ContractAssertionCreateInput): MissionContractAssertion {
|
||||
const milestone = this.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${milestoneId} not found`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const id = this.generateAssertionId();
|
||||
|
||||
// Compute next orderIndex
|
||||
const existingAssertions = this.listContractAssertions(milestoneId);
|
||||
const orderIndex = existingAssertions.length > 0
|
||||
? Math.max(...existingAssertions.map((a) => a.orderIndex)) + 1
|
||||
: 0;
|
||||
|
||||
const assertion: MissionContractAssertion = {
|
||||
id,
|
||||
milestoneId,
|
||||
title: input.title,
|
||||
assertion: input.assertion,
|
||||
status: input.status || "pending",
|
||||
orderIndex,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO mission_contract_assertions (id, milestoneId, title, assertion, status, orderIndex, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
assertion.id,
|
||||
assertion.milestoneId,
|
||||
assertion.title,
|
||||
assertion.assertion,
|
||||
assertion.status,
|
||||
assertion.orderIndex,
|
||||
assertion.createdAt,
|
||||
assertion.updatedAt,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("assertion:created", assertion);
|
||||
|
||||
// Recompute milestone validation state
|
||||
this.recomputeMilestoneValidation(milestoneId);
|
||||
|
||||
return assertion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a contract assertion by ID.
|
||||
*
|
||||
* @param id - Assertion ID
|
||||
* @returns The assertion, or undefined if not found
|
||||
*/
|
||||
getContractAssertion(id: string): MissionContractAssertion | undefined {
|
||||
const row = this.db.prepare("SELECT * FROM mission_contract_assertions WHERE id = ?").get(id);
|
||||
if (!row) return undefined;
|
||||
return this.rowToAssertion(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* List contract assertions for a milestone, ordered by orderIndex ASC, createdAt ASC, id ASC.
|
||||
*
|
||||
* This ordering is deterministic even when multiple assertions share the same
|
||||
* orderIndex or createdAt timestamp.
|
||||
*
|
||||
* @param milestoneId - Milestone ID
|
||||
* @returns Array of assertions
|
||||
*/
|
||||
listContractAssertions(milestoneId: string): MissionContractAssertion[] {
|
||||
const rows = this.db.prepare(
|
||||
"SELECT * FROM mission_contract_assertions WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC"
|
||||
).all(milestoneId);
|
||||
return (rows as any[]).map((row) => this.rowToAssertion(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a contract assertion.
|
||||
*
|
||||
* @param id - Assertion ID
|
||||
* @param updates - Partial assertion updates
|
||||
* @returns The updated assertion
|
||||
* @throws Error if assertion not found
|
||||
*/
|
||||
updateContractAssertion(id: string, updates: ContractAssertionUpdateInput): MissionContractAssertion {
|
||||
const assertion = this.getContractAssertion(id);
|
||||
if (!assertion) {
|
||||
throw new Error(`Assertion ${id} not found`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated: MissionContractAssertion = {
|
||||
...assertion,
|
||||
title: updates.title ?? assertion.title,
|
||||
assertion: updates.assertion ?? assertion.assertion,
|
||||
status: updates.status ?? assertion.status,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
UPDATE mission_contract_assertions SET
|
||||
title = ?,
|
||||
assertion = ?,
|
||||
status = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
updated.title,
|
||||
updated.assertion,
|
||||
updated.status,
|
||||
updated.updatedAt,
|
||||
updated.id,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("assertion:updated", updated);
|
||||
|
||||
// Recompute milestone validation state
|
||||
this.recomputeMilestoneValidation(updated.milestoneId);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a contract assertion.
|
||||
*
|
||||
* @param id - Assertion ID
|
||||
* @throws Error if assertion not found
|
||||
*/
|
||||
deleteContractAssertion(id: string): void {
|
||||
const assertion = this.getContractAssertion(id);
|
||||
if (!assertion) {
|
||||
throw new Error(`Assertion ${id} not found`);
|
||||
}
|
||||
|
||||
const milestoneId = assertion.milestoneId;
|
||||
|
||||
this.db.prepare("DELETE FROM mission_contract_assertions WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
this.emit("assertion:deleted", id);
|
||||
|
||||
// Recompute milestone validation state
|
||||
this.recomputeMilestoneValidation(milestoneId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder contract assertions within a milestone.
|
||||
*
|
||||
* @param milestoneId - Milestone ID
|
||||
* @param orderedIds - Assertion IDs in the desired order
|
||||
* @throws Error if any assertion is not found or belongs to a different milestone
|
||||
*/
|
||||
reorderContractAssertions(milestoneId: string, orderedIds: string[]): void {
|
||||
this.db.transaction(() => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
const id = orderedIds[i];
|
||||
const assertion = this.getContractAssertion(id);
|
||||
|
||||
if (!assertion) {
|
||||
throw new Error(`Assertion ${id} not found`);
|
||||
}
|
||||
if (assertion.milestoneId !== milestoneId) {
|
||||
throw new Error(`Assertion ${id} does not belong to milestone ${milestoneId}`);
|
||||
}
|
||||
|
||||
this.db.prepare(
|
||||
"UPDATE mission_contract_assertions SET orderIndex = ?, updatedAt = ? WHERE id = ?"
|
||||
).run(i, new Date().toISOString(), id);
|
||||
}
|
||||
});
|
||||
|
||||
this.db.bumpLastModified();
|
||||
}
|
||||
|
||||
// ── Feature-Assertion Link Operations ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Link a feature to a contract assertion.
|
||||
*
|
||||
* ## Linkage Cardinality
|
||||
*
|
||||
* The feature-assertion relationship is many-to-many:
|
||||
* - One feature can satisfy multiple assertions (e.g., a login feature covers
|
||||
* "validates input", "shows errors", and "authenticates users")
|
||||
* - One assertion can be covered by multiple features (e.g., "security check"
|
||||
* requires both the auth module and the session module)
|
||||
*
|
||||
* Links are stored in the `mission_feature_assertions` table with a composite
|
||||
* primary key of (featureId, assertionId) to prevent duplicate links.
|
||||
*
|
||||
* @param featureId - Feature ID
|
||||
* @param assertionId - Assertion ID
|
||||
* @throws Error if feature or assertion not found, or if link already exists
|
||||
*/
|
||||
linkFeatureToAssertion(featureId: string, assertionId: string): void {
|
||||
const feature = this.getFeature(featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
const assertion = this.getContractAssertion(assertionId);
|
||||
if (!assertion) {
|
||||
throw new Error(`Assertion ${assertionId} not found`);
|
||||
}
|
||||
|
||||
// Check if link already exists
|
||||
const existing = this.db.prepare(
|
||||
"SELECT 1 FROM mission_feature_assertions WHERE featureId = ? AND assertionId = ?"
|
||||
).get(featureId, assertionId);
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`Feature ${featureId} is already linked to assertion ${assertionId}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(
|
||||
"INSERT INTO mission_feature_assertions (featureId, assertionId, createdAt) VALUES (?, ?, ?)"
|
||||
).run(featureId, assertionId, now);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("assertion:linked", { featureId, assertionId });
|
||||
|
||||
// Recompute milestone validation state
|
||||
this.recomputeMilestoneValidation(assertion.milestoneId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a feature from a contract assertion.
|
||||
*
|
||||
* @param featureId - Feature ID
|
||||
* @param assertionId - Assertion ID
|
||||
* @throws Error if link not found
|
||||
*/
|
||||
unlinkFeatureFromAssertion(featureId: string, assertionId: string): void {
|
||||
const existing = this.db.prepare(
|
||||
"SELECT 1 FROM mission_feature_assertions WHERE featureId = ? AND assertionId = ?"
|
||||
).get(featureId, assertionId);
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(`Feature ${featureId} is not linked to assertion ${assertionId}`);
|
||||
}
|
||||
|
||||
this.db.prepare(
|
||||
"DELETE FROM mission_feature_assertions WHERE featureId = ? AND assertionId = ?"
|
||||
).run(featureId, assertionId);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("assertion:unlinked", { featureId, assertionId });
|
||||
|
||||
// Recompute milestone validation state for the assertion's milestone
|
||||
const assertion = this.getContractAssertion(assertionId);
|
||||
if (assertion) {
|
||||
this.recomputeMilestoneValidation(assertion.milestoneId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all assertions linked to a feature.
|
||||
*
|
||||
* @param featureId - Feature ID
|
||||
* @returns Array of linked assertions
|
||||
*/
|
||||
listAssertionsForFeature(featureId: string): MissionContractAssertion[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT ca.* FROM mission_contract_assertions ca
|
||||
INNER JOIN mission_feature_assertions fa ON ca.id = fa.assertionId
|
||||
WHERE fa.featureId = ?
|
||||
ORDER BY ca.orderIndex ASC, ca.createdAt ASC, ca.id ASC
|
||||
`).all(featureId);
|
||||
return (rows as any[]).map((row) => this.rowToAssertion(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* List all features linked to an assertion.
|
||||
*
|
||||
* @param assertionId - Assertion ID
|
||||
* @returns Array of linked features
|
||||
*/
|
||||
listFeaturesForAssertion(assertionId: string): MissionFeature[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT mf.* FROM mission_features mf
|
||||
INNER JOIN mission_feature_assertions fa ON mf.id = fa.featureId
|
||||
WHERE fa.assertionId = ?
|
||||
ORDER BY mf.createdAt ASC
|
||||
`).all(assertionId);
|
||||
return (rows as any[]).map((row) => this.rowToFeature(row));
|
||||
}
|
||||
|
||||
// ── Validation Rollup Operations ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the validation rollup for a milestone.
|
||||
* This is a denormalized snapshot that includes counts and computed state.
|
||||
*
|
||||
* ## Rollup Precedence
|
||||
*
|
||||
* The validation state is computed with the following precedence order:
|
||||
*
|
||||
* 1. `not_started` — Milestone has no assertions
|
||||
* 2. `failed` — Any assertion has `failed` status
|
||||
* 3. `blocked` — Any assertion has `blocked` status (only checked if no failures)
|
||||
* 4. `needs_coverage` — Assertions exist but some are not linked to features
|
||||
* 5. `passed` — All assertions have `passed` status
|
||||
* 6. `ready` — Assertions exist and are linked, but not all have passed
|
||||
*
|
||||
* This precedence ensures that:
|
||||
* - A milestone with no assertions shows `not_started`
|
||||
* - Failed assertions immediately mark the milestone as `failed`
|
||||
* - Blocked assertions take precedence over `needs_coverage` but not `failed`
|
||||
* - Unlinked assertions require attention before validation can complete
|
||||
* - A milestone only shows `passed` when all assertions pass
|
||||
*
|
||||
* The rollup state is automatically persisted to the milestone when assertions
|
||||
* or links change, via `recomputeMilestoneValidation()`.
|
||||
*
|
||||
* @param milestoneId - Milestone ID
|
||||
* @returns The validation rollup
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
getMilestoneValidationRollup(milestoneId: string): MilestoneValidationRollup {
|
||||
const milestone = this.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${milestoneId} not found`);
|
||||
}
|
||||
|
||||
const assertions = this.listContractAssertions(milestoneId);
|
||||
const totalAssertions = assertions.length;
|
||||
|
||||
// Count by status
|
||||
let passedAssertions = 0;
|
||||
let failedAssertions = 0;
|
||||
let blockedAssertions = 0;
|
||||
let pendingAssertions = 0;
|
||||
let unlinkedAssertions = 0;
|
||||
|
||||
for (const assertion of assertions) {
|
||||
switch (assertion.status) {
|
||||
case "passed":
|
||||
passedAssertions++;
|
||||
break;
|
||||
case "failed":
|
||||
failedAssertions++;
|
||||
break;
|
||||
case "blocked":
|
||||
blockedAssertions++;
|
||||
break;
|
||||
case "pending":
|
||||
pendingAssertions++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if assertion is linked to any feature
|
||||
const linkedFeatures = this.listFeaturesForAssertion(assertion.id);
|
||||
if (linkedFeatures.length === 0) {
|
||||
unlinkedAssertions++;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute validation state with exact precedence:
|
||||
// 1. totalAssertions === 0 → not_started
|
||||
// 2. failedAssertions > 0 → failed
|
||||
// 3. blockedAssertions > 0 → blocked
|
||||
// 4. unlinkedAssertions > 0 → needs_coverage
|
||||
// 5. passedAssertions === totalAssertions → passed
|
||||
// 6. otherwise → ready
|
||||
let state: MilestoneValidationState;
|
||||
|
||||
if (totalAssertions === 0) {
|
||||
state = "not_started";
|
||||
} else if (failedAssertions > 0) {
|
||||
state = "failed";
|
||||
} else if (blockedAssertions > 0) {
|
||||
state = "blocked";
|
||||
} else if (unlinkedAssertions > 0) {
|
||||
state = "needs_coverage";
|
||||
} else if (passedAssertions === totalAssertions) {
|
||||
state = "passed";
|
||||
} else {
|
||||
state = "ready";
|
||||
}
|
||||
|
||||
return {
|
||||
milestoneId,
|
||||
totalAssertions,
|
||||
passedAssertions,
|
||||
failedAssertions,
|
||||
blockedAssertions,
|
||||
pendingAssertions,
|
||||
unlinkedAssertions,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute and persist the milestone's validation state.
|
||||
* This is called automatically after assertion or link changes.
|
||||
*/
|
||||
private recomputeMilestoneValidation(milestoneId: string): void {
|
||||
const rollup = this.getMilestoneValidationRollup(milestoneId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
this.db.prepare(
|
||||
"UPDATE milestones SET validationState = ?, updatedAt = ? WHERE id = ?"
|
||||
).run(rollup.state, now, milestoneId);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("milestone:validation:updated", {
|
||||
milestoneId,
|
||||
state: rollup.state,
|
||||
rollup,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Triage Operations ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1629,6 +2116,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
* - Slice: title, description, verification criteria, planning notes
|
||||
* - Feature: description and acceptance criteria
|
||||
*
|
||||
* When contract assertions are linked to the feature, they are also included
|
||||
* in the output to provide explicit validation criteria for implementation.
|
||||
*
|
||||
* Only non-empty fields are included in the output. This provides AI agents
|
||||
* with full context for making informed decisions during task implementation.
|
||||
*
|
||||
@@ -1700,6 +2190,20 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
}
|
||||
sections.push(featureSections.join("\n"));
|
||||
|
||||
// Contract assertions context (only if linked to this feature)
|
||||
const linkedAssertions = this.listAssertionsForFeature(featureId);
|
||||
if (linkedAssertions.length > 0) {
|
||||
const assertionSections: string[] = [`## Contract Assertions`];
|
||||
for (const assertion of linkedAssertions) {
|
||||
const statusIcon = assertion.status === "passed" ? "✅" :
|
||||
assertion.status === "failed" ? "❌" :
|
||||
assertion.status === "blocked" ? "🚫" : "⏳";
|
||||
assertionSections.push(`### ${statusIcon} ${assertion.title}`);
|
||||
assertionSections.push(assertion.assertion);
|
||||
}
|
||||
sections.push(assertionSections.join("\n\n"));
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
@@ -1979,4 +2483,10 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `ME-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
|
||||
private generateAssertionId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `CA-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,8 @@ export interface Milestone {
|
||||
planningNotes?: string;
|
||||
/** How to verify milestone completion */
|
||||
verification?: string;
|
||||
/** Computed validation state from contract assertions (optional, always populated by MissionStore) */
|
||||
validationState?: MilestoneValidationState;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
@@ -347,3 +349,165 @@ export interface FeatureLinkedPayload {
|
||||
/** ID of the task it was linked to */
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
// ── Contract Assertion Types ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Status values for a contract assertion's validation state.
|
||||
*
|
||||
* Assertions represent explicit behavioral tests or requirements that can be
|
||||
* validated. They are linked to milestones and optionally to features,
|
||||
* enabling milestone validation rollup.
|
||||
*/
|
||||
export const MISSION_ASSERTION_STATUSES = ["pending", "passed", "failed", "blocked"] as const;
|
||||
export type MissionAssertionStatus = (typeof MISSION_ASSERTION_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Validation states for a milestone's contract coverage.
|
||||
*
|
||||
* The validation state is computed from the milestone's assertions and is
|
||||
* persisted on the milestone for efficient querying without rollup recalculation.
|
||||
*
|
||||
* Precedence (evaluated in order):
|
||||
* 1. `not_started` — milestone has no assertions
|
||||
* 2. `failed` — any assertion has failed
|
||||
* 3. `blocked` — any assertion is blocked
|
||||
* 4. `needs_coverage` — assertions exist but some are not linked to features
|
||||
* 5. `passed` — all assertions have passed
|
||||
* 6. `ready` — assertions exist and are linked, but not all have passed
|
||||
*/
|
||||
export const MILESTONE_VALIDATION_STATES = [
|
||||
"not_started",
|
||||
"needs_coverage",
|
||||
"ready",
|
||||
"passed",
|
||||
"failed",
|
||||
"blocked",
|
||||
] as const;
|
||||
export type MilestoneValidationState = (typeof MILESTONE_VALIDATION_STATES)[number];
|
||||
|
||||
/**
|
||||
* A contract assertion represents an explicit behavioral test or requirement
|
||||
* associated with a milestone. Assertions can be linked to features to track
|
||||
* coverage and validation status.
|
||||
*/
|
||||
export interface MissionContractAssertion {
|
||||
/** Unique identifier (e.g., "CA-A3B7CD-E9F2") */
|
||||
id: string;
|
||||
/** Parent milestone ID */
|
||||
milestoneId: string;
|
||||
/** Human-readable title describing the assertion */
|
||||
title: string;
|
||||
/** The behavioral specification or acceptance test content */
|
||||
assertion: string;
|
||||
/** Current validation status */
|
||||
status: MissionAssertionStatus;
|
||||
/** Order index for sorting within the milestone (0-based) */
|
||||
orderIndex: number;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A feature-assertion link represents the association between a feature
|
||||
* and a contract assertion. This is a many-to-many relationship:
|
||||
* - One feature can satisfy multiple assertions
|
||||
* - One assertion can be covered by multiple features
|
||||
*/
|
||||
export interface FeatureAssertionLink {
|
||||
/** The linked feature ID */
|
||||
featureId: string;
|
||||
/** The linked assertion ID */
|
||||
assertionId: string;
|
||||
/** ISO-8601 timestamp when the link was created */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed validation rollup for a milestone's contract assertions.
|
||||
* This is a denormalized snapshot persisted on the milestone.
|
||||
*/
|
||||
export interface MilestoneValidationRollup {
|
||||
/** The milestone this rollup belongs to */
|
||||
milestoneId: string;
|
||||
/** Total number of assertions */
|
||||
totalAssertions: number;
|
||||
/** Number of assertions in passed status */
|
||||
passedAssertions: number;
|
||||
/** Number of assertions in failed status */
|
||||
failedAssertions: number;
|
||||
/** Number of assertions in blocked status */
|
||||
blockedAssertions: number;
|
||||
/** Number of assertions in pending status */
|
||||
pendingAssertions: number;
|
||||
/** Number of assertions not linked to any feature */
|
||||
unlinkedAssertions: number;
|
||||
/** The computed validation state */
|
||||
state: MilestoneValidationState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for creating a new contract assertion.
|
||||
*/
|
||||
export interface ContractAssertionCreateInput {
|
||||
/** Human-readable title (required) */
|
||||
title: string;
|
||||
/** The behavioral specification or acceptance test content (required) */
|
||||
assertion: string;
|
||||
/** Initial status, defaults to "pending" */
|
||||
status?: MissionAssertionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for updating a contract assertion.
|
||||
*/
|
||||
export interface ContractAssertionUpdateInput {
|
||||
/** Human-readable title */
|
||||
title?: string;
|
||||
/** The behavioral specification */
|
||||
assertion?: string;
|
||||
/** Validation status */
|
||||
status?: MissionAssertionStatus;
|
||||
}
|
||||
|
||||
/** Payload for assertion:created event */
|
||||
export type AssertionCreatedPayload = MissionContractAssertion;
|
||||
|
||||
/** Payload for assertion:updated event */
|
||||
export type AssertionUpdatedPayload = MissionContractAssertion;
|
||||
|
||||
/** Payload for assertion:deleted event */
|
||||
export interface AssertionDeletedPayload {
|
||||
/** ID of the deleted assertion */
|
||||
assertionId: string;
|
||||
/** Parent milestone ID at time of deletion */
|
||||
milestoneId: string;
|
||||
}
|
||||
|
||||
/** Payload for assertion:linked event */
|
||||
export interface AssertionLinkedPayload {
|
||||
/** The feature ID */
|
||||
featureId: string;
|
||||
/** The assertion ID */
|
||||
assertionId: string;
|
||||
}
|
||||
|
||||
/** Payload for assertion:unlinked event */
|
||||
export interface AssertionUnlinkedPayload {
|
||||
/** The feature ID */
|
||||
featureId: string;
|
||||
/** The assertion ID */
|
||||
assertionId: string;
|
||||
}
|
||||
|
||||
/** Payload for milestone:validation:updated event */
|
||||
export interface MilestoneValidationUpdatedPayload {
|
||||
/** The milestone ID */
|
||||
milestoneId: string;
|
||||
/** The new validation state */
|
||||
state: MilestoneValidationState;
|
||||
/** The full validation rollup snapshot */
|
||||
rollup: MilestoneValidationRollup;
|
||||
}
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 28", () => {
|
||||
expect(db.getSchemaVersion()).toBe(28);
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user