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:
@@ -446,18 +446,10 @@ The `@fusion/tui` package provides Ink-based React components for terminal UI.
|
|||||||
- Use `setTimeout(resolve, ms)` for async state updates in tests
|
- Use `setTimeout(resolve, ms)` for async state updates in tests
|
||||||
- Track captured handlers via module-level variables for test assertions
|
- Track captured handlers via module-level variables for test assertions
|
||||||
|
|
||||||
## Kimi/Moonshot API Usage (FN-1578)
|
## Kimi/Moonshot API Usage
|
||||||
|
|
||||||
- **Primary endpoint**: `/v1/coding_plan/usage` (underscore) — Codexbar-validated working endpoint.
|
- Kimi usage endpoint uses `/v1/coding_plan/usage` (underscore) as the primary endpoint — this is the Codexbar-validated working endpoint. The hyphen variant (`/v1/coding-plan/usage`) is a legacy fallback that may return 404 `url.not_found` for some accounts.
|
||||||
- **Fallback endpoint**: `/v1/coding-plan/usage` (hyphen) — Legacy endpoint for older accounts/API versions.
|
- When implementing endpoint fallbacks for API providers, test the fallback logic with extra fields in the error payload (e.g., `{"code":5,"error":"url.not_found","message":"没找到对象",...}`) to ensure the fallback trigger remains robust.
|
||||||
- **Fallback trigger**: ANY 404 response triggers fallback (regardless of body content).
|
|
||||||
- **Auth errors (401/403)**: Short-circuit immediately — no fallback for authentication failures.
|
|
||||||
- **Known 404 error shapes**:
|
|
||||||
- `{"code":5,"error":"url.not_found","message":"没找到对象",...}` — endpoint not available (no coding plan active).
|
|
||||||
- `{"error":"url_not_found"}` — alternative format.
|
|
||||||
- **User-facing error**: When last endpoint returns `url.not_found`, show friendly message: "Usage endpoint unavailable — Kimi coding plan may not be active on this account".
|
|
||||||
- **Auth**: Uses `Authorization: Bearer <api_key>` header with `kimi-coding` key from `~/.pi/agent/auth.json`.
|
|
||||||
- **Response parsing**: Supports `data.windows[]` array and flat `data.used/total/remaining` shapes.
|
|
||||||
## FN-1516: Periodic Auto-Merge Sweep
|
## FN-1516: Periodic Auto-Merge Sweep
|
||||||
|
|
||||||
- The `canAutoMergeTask()` function must be defined locally inside `runDashboard()` to work correctly with Vitest mocks. Module-level exports capture the real `getTaskMergeBlocker` at import time, before mocks are applied.
|
- The `canAutoMergeTask()` function must be defined locally inside `runDashboard()` to work correctly with Vitest mocks. Module-level exports capture the real `getTaskMergeBlocker` at import time, before mocks are applied.
|
||||||
@@ -638,3 +630,21 @@ updatedAt=now WHERE column='done' AND columnMovedAt < cutoff` is safe and
|
|||||||
fast — subsequent watch() polls will pick up the changes and emit
|
fast — subsequent watch() polls will pick up the changes and emit
|
||||||
`task:moved` events. (Beware emitting hundreds of events in one cycle if a
|
`task:moved` events. (Beware emitting hundreds of events in one cycle if a
|
||||||
dashboard is connected.)
|
dashboard is connected.)
|
||||||
|
|
||||||
|
## FN-1567: Mission Contract Assertions
|
||||||
|
|
||||||
|
**Schema version bump pattern:** When adding a new schema migration:
|
||||||
|
1. Increment `SCHEMA_VERSION` constant in `db.ts`
|
||||||
|
2. Add `if (version < N)` block with `applyMigration(N, () => { ... })`
|
||||||
|
3. Use `addColumnIfMissing()` for column additions (idempotent)
|
||||||
|
4. Use `CREATE TABLE IF NOT EXISTS` for new tables (idempotent)
|
||||||
|
5. Use `CREATE INDEX IF NOT EXISTS` for indexes (idempotent)
|
||||||
|
6. Update all schema version assertions in test files (`db.test.ts`, `run-audit.test.ts`, `__tests__/task-documents.test.ts`)
|
||||||
|
|
||||||
|
**Optional fields in interfaces:** When adding an optional field to an existing interface (like `validationState?: MilestoneValidationState`), TypeScript may infer `T | undefined` when the field is used in SQL prepared statements. Use type assertions (`as string`) or null coalescing (`?? "default"`) to satisfy SQLite type requirements.
|
||||||
|
|
||||||
|
**Deterministic ordering:** Always use `ORDER BY orderIndex ASC, createdAt ASC, id ASC` for listing items within a parent. This ensures stable ordering even when items have identical timestamps.
|
||||||
|
|
||||||
|
**Event emitter events:** Add new event types to `MissionStoreEvents` interface and emit them from the appropriate methods. The EventEmitter pattern follows the same conventions as `TaskStore`.
|
||||||
|
|
||||||
|
**Many-to-many relationship pattern:** Use a composite primary key `(featureId, assertionId)` to prevent duplicate links. Check for existing links before inserting and throw meaningful errors. Use `hasTable()` guards before creating indexes on new tables.
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
|||||||
|
|
||||||
expect(tableNames.has("task_documents")).toBe(true);
|
expect(tableNames.has("task_documents")).toBe(true);
|
||||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||||
expect(db.getSchemaVersion()).toBe(28);
|
expect(db.getSchemaVersion()).toBe(29);
|
||||||
|
|
||||||
const index = db
|
const index = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ describe("Database", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("seeds schema version", () => {
|
it("seeds schema version", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(28);
|
expect(db.getSchemaVersion()).toBe(29);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeds lastModified", () => {
|
it("seeds lastModified", () => {
|
||||||
@@ -135,7 +135,7 @@ describe("Database", () => {
|
|||||||
|
|
||||||
it("is idempotent - calling init() twice does not fail", () => {
|
it("is idempotent - calling init() twice does not fail", () => {
|
||||||
expect(() => db.init()).not.toThrow();
|
expect(() => db.init()).not.toThrow();
|
||||||
expect(db.getSchemaVersion()).toBe(28);
|
expect(db.getSchemaVersion()).toBe(29);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not overwrite existing config on re-init", () => {
|
it("does not overwrite existing config on re-init", () => {
|
||||||
@@ -741,8 +741,8 @@ describe("schema migrations", () => {
|
|||||||
// Now run init() which should trigger migration
|
// Now run init() which should trigger migration
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 28 (includes v1→v2 through v26→v28)
|
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||||
expect(db.getSchemaVersion()).toBe(28);
|
expect(db.getSchemaVersion()).toBe(29);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
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);
|
const db = new Database(kbDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(28);
|
expect(db.getSchemaVersion()).toBe(29);
|
||||||
|
|
||||||
// Re-init should not fail
|
// Re-init should not fail
|
||||||
db.init();
|
db.init();
|
||||||
expect(db.getSchemaVersion()).toBe(28);
|
expect(db.getSchemaVersion()).toBe(29);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -787,7 +787,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
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" }]);
|
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||||
@@ -811,7 +811,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
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" }]);
|
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||||
@@ -914,8 +914,8 @@ describe("schema migrations", () => {
|
|||||||
// Now run init() which should trigger migrations v2→v3→v4
|
// Now run init() which should trigger migrations v2→v3→v4
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 28
|
// Verify version bumped to 29
|
||||||
expect(db.getSchemaVersion()).toBe(28);
|
expect(db.getSchemaVersion()).toBe(29);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
@@ -1281,7 +1281,7 @@ describe("createDatabase factory", () => {
|
|||||||
const db = createDatabase(kbDir);
|
const db = createDatabase(kbDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(28);
|
expect(db.getSchemaVersion()).toBe(29);
|
||||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
|||||||
|
|
||||||
// ── Schema Definition ────────────────────────────────────────────────
|
// ── Schema Definition ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 28;
|
const SCHEMA_VERSION = 29;
|
||||||
|
|
||||||
function normalizeTaskComments(
|
function normalizeTaskComments(
|
||||||
steeringComments: SteeringComment[] | undefined,
|
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,
|
FeatureEventPayload,
|
||||||
FeatureDeletedPayload,
|
FeatureDeletedPayload,
|
||||||
FeatureLinkedPayload,
|
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";
|
} from "./mission-types.js";
|
||||||
export { MissionStore } from "./mission-store.js";
|
export { MissionStore } from "./mission-store.js";
|
||||||
export type { MissionStoreEvents, MissionSummary } from "./mission-store.js";
|
export type { MissionStoreEvents, MissionSummary } from "./mission-store.js";
|
||||||
|
|||||||
@@ -1917,6 +1917,542 @@ describe("MissionStore", () => {
|
|||||||
expect(updatedF1.taskId).toBeTruthy();
|
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
|
// vi import for vitest mocking
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ import type {
|
|||||||
MissionEventType,
|
MissionEventType,
|
||||||
MissionHealth,
|
MissionHealth,
|
||||||
SlicePlanState,
|
SlicePlanState,
|
||||||
|
MissionContractAssertion,
|
||||||
|
FeatureAssertionLink,
|
||||||
|
MilestoneValidationRollup,
|
||||||
|
ContractAssertionCreateInput,
|
||||||
|
ContractAssertionUpdateInput,
|
||||||
|
MilestoneValidationState,
|
||||||
} from "./mission-types.js";
|
} from "./mission-types.js";
|
||||||
|
|
||||||
// ── Mission Summary Type ─────────────────────────────────────────────
|
// ── Mission Summary Type ─────────────────────────────────────────────
|
||||||
@@ -85,6 +91,18 @@ export interface MissionStoreEvents {
|
|||||||
"feature:linked": [{ feature: MissionFeature; taskId: string }];
|
"feature:linked": [{ feature: MissionFeature; taskId: string }];
|
||||||
/** Emitted when a mission lifecycle event is persisted */
|
/** Emitted when a mission lifecycle event is persisted */
|
||||||
"mission:event": [MissionEvent];
|
"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 ──────────────────────────────────────────────
|
// ── MissionStore Class ──────────────────────────────────────────────
|
||||||
@@ -149,11 +167,39 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
dependencies: fromJson<string[]>(row.dependencies) || [],
|
dependencies: fromJson<string[]>(row.dependencies) || [],
|
||||||
planningNotes: row.planningNotes || undefined,
|
planningNotes: row.planningNotes || undefined,
|
||||||
verification: row.verification || undefined,
|
verification: row.verification || undefined,
|
||||||
|
validationState: (row.validationState as MilestoneValidationState) || "not_started",
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
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.
|
* Convert a database row to a Slice object.
|
||||||
*/
|
*/
|
||||||
@@ -884,13 +930,14 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
dependencies: input.dependencies || [],
|
dependencies: input.dependencies || [],
|
||||||
planningNotes: input.planningNotes,
|
planningNotes: input.planningNotes,
|
||||||
verification: input.verification,
|
verification: input.verification,
|
||||||
|
validationState: "not_started",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
INSERT INTO milestones (id, missionId, title, description, status, orderIndex, interviewState, dependencies, planningNotes, verification, createdAt, updatedAt)
|
INSERT INTO milestones (id, missionId, title, description, status, orderIndex, interviewState, dependencies, planningNotes, verification, validationState, createdAt, updatedAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
milestone.id,
|
milestone.id,
|
||||||
milestone.missionId,
|
milestone.missionId,
|
||||||
@@ -902,6 +949,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
toJson(milestone.dependencies),
|
toJson(milestone.dependencies),
|
||||||
milestone.planningNotes ?? null,
|
milestone.planningNotes ?? null,
|
||||||
milestone.verification ?? null,
|
milestone.verification ?? null,
|
||||||
|
milestone.validationState as string,
|
||||||
milestone.createdAt,
|
milestone.createdAt,
|
||||||
milestone.updatedAt,
|
milestone.updatedAt,
|
||||||
);
|
);
|
||||||
@@ -969,6 +1017,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
dependencies = ?,
|
dependencies = ?,
|
||||||
planningNotes = ?,
|
planningNotes = ?,
|
||||||
verification = ?,
|
verification = ?,
|
||||||
|
validationState = ?,
|
||||||
updatedAt = ?
|
updatedAt = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
@@ -980,6 +1029,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
toJson(updated.dependencies),
|
toJson(updated.dependencies),
|
||||||
updated.planningNotes ?? null,
|
updated.planningNotes ?? null,
|
||||||
updated.verification ?? null,
|
updated.verification ?? null,
|
||||||
|
updated.validationState || "not_started",
|
||||||
updated.updatedAt,
|
updated.updatedAt,
|
||||||
updated.id,
|
updated.id,
|
||||||
);
|
);
|
||||||
@@ -1617,6 +1667,443 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
return this.rowToFeature(row);
|
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 ────────────────────────────────────────────────
|
// ── Triage Operations ────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1629,6 +2116,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
* - Slice: title, description, verification criteria, planning notes
|
* - Slice: title, description, verification criteria, planning notes
|
||||||
* - Feature: description and acceptance criteria
|
* - 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
|
* Only non-empty fields are included in the output. This provides AI agents
|
||||||
* with full context for making informed decisions during task implementation.
|
* 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"));
|
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");
|
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();
|
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||||
return `ME-${timestamp.toString(36).toUpperCase()}-${random}`;
|
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;
|
planningNotes?: string;
|
||||||
/** How to verify milestone completion */
|
/** How to verify milestone completion */
|
||||||
verification?: string;
|
verification?: string;
|
||||||
|
/** Computed validation state from contract assertions (optional, always populated by MissionStore) */
|
||||||
|
validationState?: MilestoneValidationState;
|
||||||
/** ISO-8601 timestamp of creation */
|
/** ISO-8601 timestamp of creation */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** ISO-8601 timestamp of last update */
|
/** ISO-8601 timestamp of last update */
|
||||||
@@ -347,3 +349,165 @@ export interface FeatureLinkedPayload {
|
|||||||
/** ID of the task it was linked to */
|
/** ID of the task it was linked to */
|
||||||
taskId: string;
|
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", () => {
|
it("schema version is bumped to 28", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(28);
|
expect(db.getSchemaVersion()).toBe(29);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user