diff --git a/apps/server/src/lib/dependency-resolver.ts b/apps/server/src/lib/dependency-resolver.ts index 619d62e4..e263d6da 100644 --- a/apps/server/src/lib/dependency-resolver.ts +++ b/apps/server/src/lib/dependency-resolver.ts @@ -5,16 +5,7 @@ * Uses a modified Kahn's algorithm that respects both dependencies and priorities. */ -interface Feature { - id: string; - category: string; - description: string; - steps?: string[]; - status: string; - priority?: number; - dependencies?: string[]; - [key: string]: unknown; -} +import type { Feature } from "../services/feature-loader.js"; export interface DependencyResolutionResult { orderedFeatures: Feature[]; // Features in dependency-aware order @@ -198,7 +189,7 @@ export function areDependenciesSatisfied( return true; // No dependencies = always ready } - return feature.dependencies.every(depId => { + return feature.dependencies.every((depId: string) => { const dep = allFeatures.find(f => f.id === depId); return dep && (dep.status === 'completed' || dep.status === 'verified'); }); @@ -219,7 +210,7 @@ export function getBlockingDependencies( return []; } - return feature.dependencies.filter(depId => { + return feature.dependencies.filter((depId: string) => { const dep = allFeatures.find(f => f.id === depId); return dep && dep.status !== 'completed' && dep.status !== 'verified'; }); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 9f79ea8b..affb6c6e 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -21,30 +21,10 @@ import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js"; import { createAutoModeOptions } from "../lib/sdk-options.js"; import { isAbortError, classifyError } from "../lib/error-handler.js"; import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js"; +import type { Feature } from "./feature-loader.js"; const execAsync = promisify(exec); -interface Feature { - id: string; - category: string; - description: string; - steps?: string[]; - status: string; - priority?: number; - dependencies?: string[]; // Feature dependencies - spec?: string; - model?: string; // Model to use for this feature - imagePaths?: Array< - | string - | { - path: string; - filename?: string; - mimeType?: string; - [key: string]: unknown; - } - >; -} - interface RunningFeature { featureId: string; projectPath: string; diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 9a90c6eb..534dfa37 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -13,6 +13,10 @@ export interface Feature { steps?: string[]; passes?: boolean; priority?: number; + status?: string; + dependencies?: string[]; + spec?: string; + model?: string; imagePaths?: Array; [key: string]: unknown; } diff --git a/apps/server/tests/unit/lib/dependency-resolver.test.ts b/apps/server/tests/unit/lib/dependency-resolver.test.ts new file mode 100644 index 00000000..772f1fbe --- /dev/null +++ b/apps/server/tests/unit/lib/dependency-resolver.test.ts @@ -0,0 +1,438 @@ +import { describe, it, expect } from "vitest"; +import { + resolveDependencies, + areDependenciesSatisfied, + getBlockingDependencies, + type DependencyResolutionResult, +} from "@/lib/dependency-resolver.js"; +import type { Feature } from "@/services/feature-loader.js"; + +// Helper to create test features +function createFeature( + id: string, + options: { + status?: string; + priority?: number; + dependencies?: string[]; + category?: string; + description?: string; + } = {} +): Feature { + return { + id, + category: options.category || "test", + description: options.description || `Feature ${id}`, + status: options.status || "backlog", + priority: options.priority, + dependencies: options.dependencies, + }; +} + +describe("dependency-resolver.ts", () => { + describe("resolveDependencies", () => { + it("should handle empty feature list", () => { + const result = resolveDependencies([]); + + expect(result.orderedFeatures).toEqual([]); + expect(result.circularDependencies).toEqual([]); + expect(result.missingDependencies.size).toBe(0); + expect(result.blockedFeatures.size).toBe(0); + }); + + it("should handle features with no dependencies", () => { + const features = [ + createFeature("f1", { priority: 1 }), + createFeature("f2", { priority: 2 }), + createFeature("f3", { priority: 3 }), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures).toHaveLength(3); + expect(result.orderedFeatures[0].id).toBe("f1"); // Highest priority first + expect(result.orderedFeatures[1].id).toBe("f2"); + expect(result.orderedFeatures[2].id).toBe("f3"); + expect(result.circularDependencies).toEqual([]); + expect(result.missingDependencies.size).toBe(0); + expect(result.blockedFeatures.size).toBe(0); + }); + + it("should order features by dependencies (simple chain)", () => { + const features = [ + createFeature("f3", { dependencies: ["f2"] }), + createFeature("f1"), + createFeature("f2", { dependencies: ["f1"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures).toHaveLength(3); + expect(result.orderedFeatures[0].id).toBe("f1"); + expect(result.orderedFeatures[1].id).toBe("f2"); + expect(result.orderedFeatures[2].id).toBe("f3"); + expect(result.circularDependencies).toEqual([]); + }); + + it("should respect priority within same dependency level", () => { + const features = [ + createFeature("f1", { priority: 3, dependencies: ["base"] }), + createFeature("f2", { priority: 1, dependencies: ["base"] }), + createFeature("f3", { priority: 2, dependencies: ["base"] }), + createFeature("base"), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures[0].id).toBe("base"); + expect(result.orderedFeatures[1].id).toBe("f2"); // Priority 1 + expect(result.orderedFeatures[2].id).toBe("f3"); // Priority 2 + expect(result.orderedFeatures[3].id).toBe("f1"); // Priority 3 + }); + + it("should use default priority of 2 when not specified", () => { + const features = [ + createFeature("f1", { priority: 1 }), + createFeature("f2"), // No priority = default 2 + createFeature("f3", { priority: 3 }), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures[0].id).toBe("f1"); + expect(result.orderedFeatures[1].id).toBe("f2"); + expect(result.orderedFeatures[2].id).toBe("f3"); + }); + + it("should detect missing dependencies", () => { + const features = [ + createFeature("f1", { dependencies: ["missing1", "missing2"] }), + createFeature("f2", { dependencies: ["f1", "missing3"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.missingDependencies.size).toBe(2); + expect(result.missingDependencies.get("f1")).toEqual(["missing1", "missing2"]); + expect(result.missingDependencies.get("f2")).toEqual(["missing3"]); + expect(result.orderedFeatures).toHaveLength(2); + }); + + it("should detect blocked features (incomplete dependencies)", () => { + const features = [ + createFeature("f1", { status: "in_progress" }), + createFeature("f2", { status: "backlog", dependencies: ["f1"] }), + createFeature("f3", { status: "completed" }), + createFeature("f4", { status: "backlog", dependencies: ["f3"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.blockedFeatures.size).toBe(1); + expect(result.blockedFeatures.get("f2")).toEqual(["f1"]); + expect(result.blockedFeatures.has("f4")).toBe(false); // f3 is completed + }); + + it("should not block features whose dependencies are verified", () => { + const features = [ + createFeature("f1", { status: "verified" }), + createFeature("f2", { status: "backlog", dependencies: ["f1"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.blockedFeatures.size).toBe(0); + }); + + it("should detect circular dependencies (simple cycle)", () => { + const features = [ + createFeature("f1", { dependencies: ["f2"] }), + createFeature("f2", { dependencies: ["f1"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies).toHaveLength(1); + expect(result.circularDependencies[0]).toContain("f1"); + expect(result.circularDependencies[0]).toContain("f2"); + expect(result.orderedFeatures).toHaveLength(2); // Features still included + }); + + it("should detect circular dependencies (multi-node cycle)", () => { + const features = [ + createFeature("f1", { dependencies: ["f3"] }), + createFeature("f2", { dependencies: ["f1"] }), + createFeature("f3", { dependencies: ["f2"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies.length).toBeGreaterThan(0); + expect(result.orderedFeatures).toHaveLength(3); + }); + + it("should handle mixed valid and circular dependencies", () => { + const features = [ + createFeature("base"), + createFeature("f1", { dependencies: ["base", "f2"] }), + createFeature("f2", { dependencies: ["f1"] }), // Circular with f1 + createFeature("f3", { dependencies: ["base"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies.length).toBeGreaterThan(0); + expect(result.orderedFeatures[0].id).toBe("base"); + expect(result.orderedFeatures).toHaveLength(4); + }); + + it("should handle complex dependency graph", () => { + const features = [ + createFeature("ui", { dependencies: ["api", "auth"], priority: 1 }), + createFeature("api", { dependencies: ["db"], priority: 2 }), + createFeature("auth", { dependencies: ["db"], priority: 1 }), + createFeature("db", { priority: 1 }), + createFeature("tests", { dependencies: ["ui"], priority: 3 }), + ]; + + const result = resolveDependencies(features); + + const order = result.orderedFeatures.map(f => f.id); + + expect(order[0]).toBe("db"); + expect(order.indexOf("db")).toBeLessThan(order.indexOf("api")); + expect(order.indexOf("db")).toBeLessThan(order.indexOf("auth")); + expect(order.indexOf("api")).toBeLessThan(order.indexOf("ui")); + expect(order.indexOf("auth")).toBeLessThan(order.indexOf("ui")); + expect(order.indexOf("ui")).toBeLessThan(order.indexOf("tests")); + expect(result.circularDependencies).toEqual([]); + }); + + it("should handle features with empty dependencies array", () => { + const features = [ + createFeature("f1", { dependencies: [] }), + createFeature("f2", { dependencies: [] }), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures).toHaveLength(2); + expect(result.circularDependencies).toEqual([]); + expect(result.blockedFeatures.size).toBe(0); + }); + + it("should track multiple blocking dependencies", () => { + const features = [ + createFeature("f1", { status: "in_progress" }), + createFeature("f2", { status: "backlog" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.blockedFeatures.get("f3")).toEqual(["f1", "f2"]); + }); + + it("should handle self-referencing dependency", () => { + const features = [ + createFeature("f1", { dependencies: ["f1"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies.length).toBeGreaterThan(0); + expect(result.orderedFeatures).toHaveLength(1); + }); + }); + + describe("areDependenciesSatisfied", () => { + it("should return true for feature with no dependencies", () => { + const feature = createFeature("f1"); + const allFeatures = [feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true); + }); + + it("should return true for feature with empty dependencies array", () => { + const feature = createFeature("f1", { dependencies: [] }); + const allFeatures = [feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true); + }); + + it("should return true when all dependencies are completed", () => { + const allFeatures = [ + createFeature("f1", { status: "completed" }), + createFeature("f2", { status: "completed" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true); + }); + + it("should return true when all dependencies are verified", () => { + const allFeatures = [ + createFeature("f1", { status: "verified" }), + createFeature("f2", { status: "verified" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true); + }); + + it("should return true when dependencies are mix of completed and verified", () => { + const allFeatures = [ + createFeature("f1", { status: "completed" }), + createFeature("f2", { status: "verified" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true); + }); + + it("should return false when any dependency is in_progress", () => { + const allFeatures = [ + createFeature("f1", { status: "completed" }), + createFeature("f2", { status: "in_progress" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false); + }); + + it("should return false when any dependency is in backlog", () => { + const allFeatures = [ + createFeature("f1", { status: "completed" }), + createFeature("f2", { status: "backlog" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false); + }); + + it("should return false when dependency is missing", () => { + const allFeatures = [ + createFeature("f1", { status: "backlog", dependencies: ["missing"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[0], allFeatures)).toBe(false); + }); + + it("should return false when multiple dependencies are incomplete", () => { + const allFeatures = [ + createFeature("f1", { status: "backlog" }), + createFeature("f2", { status: "in_progress" }), + createFeature("f3", { status: "waiting_approval" }), + createFeature("f4", { status: "backlog", dependencies: ["f1", "f2", "f3"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[3], allFeatures)).toBe(false); + }); + }); + + describe("getBlockingDependencies", () => { + it("should return empty array for feature with no dependencies", () => { + const feature = createFeature("f1"); + const allFeatures = [feature]; + + expect(getBlockingDependencies(feature, allFeatures)).toEqual([]); + }); + + it("should return empty array for feature with empty dependencies array", () => { + const feature = createFeature("f1", { dependencies: [] }); + const allFeatures = [feature]; + + expect(getBlockingDependencies(feature, allFeatures)).toEqual([]); + }); + + it("should return empty array when all dependencies are completed", () => { + const allFeatures = [ + createFeature("f1", { status: "completed" }), + createFeature("f2", { status: "completed" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]); + }); + + it("should return empty array when all dependencies are verified", () => { + const allFeatures = [ + createFeature("f1", { status: "verified" }), + createFeature("f2", { status: "verified" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]); + }); + + it("should return blocking dependencies in backlog status", () => { + const allFeatures = [ + createFeature("f1", { status: "backlog" }), + createFeature("f2", { status: "completed" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]); + }); + + it("should return blocking dependencies in in_progress status", () => { + const allFeatures = [ + createFeature("f1", { status: "in_progress" }), + createFeature("f2", { status: "verified" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]); + }); + + it("should return blocking dependencies in waiting_approval status", () => { + const allFeatures = [ + createFeature("f1", { status: "waiting_approval" }), + createFeature("f2", { status: "completed" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]); + }); + + it("should return all blocking dependencies", () => { + const allFeatures = [ + createFeature("f1", { status: "backlog" }), + createFeature("f2", { status: "in_progress" }), + createFeature("f3", { status: "waiting_approval" }), + createFeature("f4", { status: "completed" }), + createFeature("f5", { status: "backlog", dependencies: ["f1", "f2", "f3", "f4"] }), + ]; + + const blocking = getBlockingDependencies(allFeatures[4], allFeatures); + expect(blocking).toHaveLength(3); + expect(blocking).toContain("f1"); + expect(blocking).toContain("f2"); + expect(blocking).toContain("f3"); + expect(blocking).not.toContain("f4"); + }); + + it("should handle missing dependencies", () => { + const allFeatures = [ + createFeature("f1", { status: "backlog", dependencies: ["missing"] }), + ]; + + // Missing dependencies won't be in the blocking list since they don't exist + expect(getBlockingDependencies(allFeatures[0], allFeatures)).toEqual([]); + }); + + it("should handle mix of completed, verified, and incomplete dependencies", () => { + const allFeatures = [ + createFeature("f1", { status: "completed" }), + createFeature("f2", { status: "verified" }), + createFeature("f3", { status: "in_progress" }), + createFeature("f4", { status: "backlog" }), + createFeature("f5", { status: "backlog", dependencies: ["f1", "f2", "f3", "f4"] }), + ]; + + const blocking = getBlockingDependencies(allFeatures[4], allFeatures); + expect(blocking).toHaveLength(2); + expect(blocking).toContain("f3"); + expect(blocking).toContain("f4"); + }); + }); +});