diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index 88a24316..86f45505 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -52,8 +52,8 @@ import { useNavigate } from '@tanstack/react-router'; import { getAncestors, formatAncestorContextForPrompt, - AncestorContext, -} from '@/components/views/graph-view/utils'; + type AncestorContext, +} from '@automaker/dependency-resolver'; interface AddFeatureDialogProps { open: boolean; diff --git a/apps/ui/src/components/views/board-view/shared/ancestor-context-section.tsx b/apps/ui/src/components/views/board-view/shared/ancestor-context-section.tsx index 8d78fa43..bbdeebda 100644 --- a/apps/ui/src/components/views/board-view/shared/ancestor-context-section.tsx +++ b/apps/ui/src/components/views/board-view/shared/ancestor-context-section.tsx @@ -4,7 +4,7 @@ import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { ChevronDown, ChevronRight, Users } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { AncestorContext } from '@/components/views/graph-view/utils'; +import type { AncestorContext } from '@automaker/dependency-resolver'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; interface ParentFeatureContext { diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx index 33874dc9..7435348f 100644 --- a/apps/ui/src/components/views/graph-view/graph-view.tsx +++ b/apps/ui/src/components/views/graph-view/graph-view.tsx @@ -3,7 +3,7 @@ import { Feature, useAppStore } from '@/store/app-store'; import { GraphCanvas } from './graph-canvas'; import { useBoardBackground } from '../board-view/hooks'; import { NodeActionCallbacks } from './hooks'; -import { wouldCreateCircularDependency, dependencyExists } from './utils'; +import { wouldCreateCircularDependency, dependencyExists } from '@automaker/dependency-resolver'; import { toast } from 'sonner'; interface GraphViewProps { diff --git a/apps/ui/src/components/views/graph-view/utils/ancestor-context.ts b/apps/ui/src/components/views/graph-view/utils/ancestor-context.ts deleted file mode 100644 index d0f2abaf..00000000 --- a/apps/ui/src/components/views/graph-view/utils/ancestor-context.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Feature } from '@/store/app-store'; - -export interface AncestorContext { - id: string; - title?: string; - description: string; - spec?: string; - summary?: string; - depth: number; // 0 = immediate parent, 1 = grandparent, etc. -} - -/** - * Traverses the dependency graph to find all ancestors of a feature. - * Returns ancestors ordered by depth (closest first). - * - * @param feature - The feature to find ancestors for - * @param allFeatures - All features in the system - * @param maxDepth - Maximum depth to traverse (prevents infinite loops) - * @returns Array of ancestor contexts, sorted by depth (closest first) - */ -export function getAncestors( - feature: Feature, - allFeatures: Feature[], - maxDepth: number = 10 -): AncestorContext[] { - const featureMap = new Map(allFeatures.map((f) => [f.id, f])); - const ancestors: AncestorContext[] = []; - const visited = new Set(); - - function traverse(featureId: string, depth: number) { - if (depth > maxDepth || visited.has(featureId)) return; - visited.add(featureId); - - const f = featureMap.get(featureId); - if (!f?.dependencies) return; - - for (const depId of f.dependencies) { - const dep = featureMap.get(depId); - if (dep && !visited.has(depId)) { - ancestors.push({ - id: dep.id, - title: dep.title, - description: dep.description, - spec: dep.spec, - summary: dep.summary, - depth, - }); - traverse(depId, depth + 1); - } - } - } - - traverse(feature.id, 0); - - // Sort by depth (closest ancestors first) - return ancestors.sort((a, b) => a.depth - b.depth); -} - -/** - * Formats ancestor context for inclusion in a task description. - * - * @param ancestors - Array of ancestor contexts (including parent) - * @param selectedIds - Set of selected ancestor IDs to include - * @returns Formatted markdown string with ancestor context - */ -export function formatAncestorContextForPrompt( - ancestors: AncestorContext[], - selectedIds: Set -): string { - const selectedAncestors = ancestors.filter((a) => selectedIds.has(a.id)); - if (selectedAncestors.length === 0) return ''; - - const sections = selectedAncestors.map((ancestor) => { - const parts: string[] = []; - const title = ancestor.title || `Task (${ancestor.id.slice(0, 8)})`; - - parts.push(`### ${title}`); - - if (ancestor.description) { - parts.push(`**Description:** ${ancestor.description}`); - } - if (ancestor.spec) { - parts.push(`**Specification:**\n${ancestor.spec}`); - } - if (ancestor.summary) { - parts.push(`**Summary:** ${ancestor.summary}`); - } - - return parts.join('\n\n'); - }); - - return `## Ancestor Context\n\n${sections.join('\n\n---\n\n')}`; -} diff --git a/apps/ui/src/components/views/graph-view/utils/dependency-validation.ts b/apps/ui/src/components/views/graph-view/utils/dependency-validation.ts deleted file mode 100644 index e3da76f8..00000000 --- a/apps/ui/src/components/views/graph-view/utils/dependency-validation.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Feature } from '@/store/app-store'; - -/** - * Checks if adding a dependency from sourceId to targetId would create a circular dependency. - * Uses DFS to detect if targetId can reach sourceId through existing dependencies. - * - * @param features - All features in the system - * @param sourceId - The feature that would become a dependency (the prerequisite) - * @param targetId - The feature that would depend on sourceId - * @returns true if adding this dependency would create a cycle - */ -export function wouldCreateCircularDependency( - features: Feature[], - sourceId: string, - targetId: string -): boolean { - const featureMap = new Map(features.map((f) => [f.id, f])); - const visited = new Set(); - - function canReach(currentId: string, targetId: string): boolean { - if (currentId === targetId) return true; - if (visited.has(currentId)) return false; - - visited.add(currentId); - const feature = featureMap.get(currentId); - if (!feature?.dependencies) return false; - - for (const depId of feature.dependencies) { - if (canReach(depId, targetId)) return true; - } - return false; - } - - // Check if source can reach target through existing dependencies - // If so, adding target -> source would create a cycle - return canReach(sourceId, targetId); -} - -/** - * Checks if a dependency already exists between two features. - * - * @param features - All features in the system - * @param sourceId - The potential dependency (prerequisite) - * @param targetId - The feature that might depend on sourceId - * @returns true if targetId already depends on sourceId - */ -export function dependencyExists(features: Feature[], sourceId: string, targetId: string): boolean { - const targetFeature = features.find((f) => f.id === targetId); - if (!targetFeature?.dependencies) return false; - return targetFeature.dependencies.includes(sourceId); -} diff --git a/apps/ui/src/components/views/graph-view/utils/index.ts b/apps/ui/src/components/views/graph-view/utils/index.ts deleted file mode 100644 index c3c1a6fd..00000000 --- a/apps/ui/src/components/views/graph-view/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './dependency-validation'; -export * from './ancestor-context'; diff --git a/libs/dependency-resolver/src/index.ts b/libs/dependency-resolver/src/index.ts index 5f6d7259..9ecaa487 100644 --- a/libs/dependency-resolver/src/index.ts +++ b/libs/dependency-resolver/src/index.ts @@ -7,5 +7,10 @@ export { resolveDependencies, areDependenciesSatisfied, getBlockingDependencies, + wouldCreateCircularDependency, + dependencyExists, + getAncestors, + formatAncestorContextForPrompt, type DependencyResolutionResult, + type AncestorContext, } from './resolver.js'; diff --git a/libs/dependency-resolver/src/resolver.ts b/libs/dependency-resolver/src/resolver.ts index ec7e8273..c63b1a1a 100644 --- a/libs/dependency-resolver/src/resolver.ts +++ b/libs/dependency-resolver/src/resolver.ts @@ -209,3 +209,148 @@ export function getBlockingDependencies(feature: Feature, allFeatures: Feature[] return dep && dep.status !== 'completed' && dep.status !== 'verified'; }); } + +/** + * Checks if adding a dependency from sourceId to targetId would create a circular dependency. + * Uses DFS to detect if targetId can reach sourceId through existing dependencies. + * + * @param features - All features in the system + * @param sourceId - The feature that would become a dependency (the prerequisite) + * @param targetId - The feature that would depend on sourceId + * @returns true if adding this dependency would create a cycle + */ +export function wouldCreateCircularDependency( + features: Feature[], + sourceId: string, + targetId: string +): boolean { + const featureMap = new Map(features.map((f) => [f.id, f])); + const visited = new Set(); + + function canReach(currentId: string, target: string): boolean { + if (currentId === target) return true; + if (visited.has(currentId)) return false; + + visited.add(currentId); + const feature = featureMap.get(currentId); + if (!feature?.dependencies) return false; + + for (const depId of feature.dependencies) { + if (canReach(depId, target)) return true; + } + return false; + } + + // Check if source can reach target through existing dependencies + // If so, adding target -> source would create a cycle + return canReach(sourceId, targetId); +} + +/** + * Checks if a dependency already exists between two features. + * + * @param features - All features in the system + * @param sourceId - The potential dependency (prerequisite) + * @param targetId - The feature that might depend on sourceId + * @returns true if targetId already depends on sourceId + */ +export function dependencyExists(features: Feature[], sourceId: string, targetId: string): boolean { + const targetFeature = features.find((f) => f.id === targetId); + if (!targetFeature?.dependencies) return false; + return targetFeature.dependencies.includes(sourceId); +} + +/** + * Context information about an ancestor feature in the dependency graph. + */ +export interface AncestorContext { + id: string; + title?: string; + description: string; + spec?: string; + summary?: string; + depth: number; // 0 = immediate parent, 1 = grandparent, etc. +} + +/** + * Traverses the dependency graph to find all ancestors of a feature. + * Returns ancestors ordered by depth (closest first). + * + * @param feature - The feature to find ancestors for + * @param allFeatures - All features in the system + * @param maxDepth - Maximum depth to traverse (prevents infinite loops) + * @returns Array of ancestor contexts, sorted by depth (closest first) + */ +export function getAncestors( + feature: Feature, + allFeatures: Feature[], + maxDepth: number = 10 +): AncestorContext[] { + const featureMap = new Map(allFeatures.map((f) => [f.id, f])); + const ancestors: AncestorContext[] = []; + const visited = new Set(); + + function traverse(featureId: string, depth: number) { + if (depth > maxDepth || visited.has(featureId)) return; + visited.add(featureId); + + const f = featureMap.get(featureId); + if (!f?.dependencies) return; + + for (const depId of f.dependencies) { + const dep = featureMap.get(depId); + if (dep && !visited.has(depId)) { + ancestors.push({ + id: dep.id, + title: dep.title, + description: dep.description, + spec: dep.spec, + summary: dep.summary, + depth, + }); + traverse(depId, depth + 1); + } + } + } + + traverse(feature.id, 0); + + // Sort by depth (closest ancestors first) + return ancestors.sort((a, b) => a.depth - b.depth); +} + +/** + * Formats ancestor context for inclusion in a task description. + * + * @param ancestors - Array of ancestor contexts (including parent) + * @param selectedIds - Set of selected ancestor IDs to include + * @returns Formatted markdown string with ancestor context + */ +export function formatAncestorContextForPrompt( + ancestors: AncestorContext[], + selectedIds: Set +): string { + const selectedAncestors = ancestors.filter((a) => selectedIds.has(a.id)); + if (selectedAncestors.length === 0) return ''; + + const sections = selectedAncestors.map((ancestor) => { + const parts: string[] = []; + const title = ancestor.title || `Task (${ancestor.id.slice(0, 8)})`; + + parts.push(`### ${title}`); + + if (ancestor.description) { + parts.push(`**Description:** ${ancestor.description}`); + } + if (ancestor.spec) { + parts.push(`**Specification:**\n${ancestor.spec}`); + } + if (ancestor.summary) { + parts.push(`**Summary:** ${ancestor.summary}`); + } + + return parts.join('\n\n'); + }); + + return `## Ancestor Context\n\n${sections.join('\n\n---\n\n')}`; +}