// Package dag implements the Directed Acyclic Graph (DAG) that powers // Sorcery-Go's dependency resolution. // // The DAG is "feature-aware": every edge carries a DepType (Build/Runtime/Optional) // and an optional set of required features (sub-depends). This lets the engine // trigger a "Re-Forge" of a dependency when a parent spell requires a feature // that was not enabled in the existing Essence variant. // // Cycle detection uses the classic three-color marking algorithm (White / Grey / Black). // If a "Grey" node is encountered while exploring, a circular reference exists and // the offending edge is rolled back so the Graph remains in a valid state. package dag import ( "errors" "fmt" "sort" ) // DepType classifies the strength of a dependency edge. type DepType int const ( BuildDep DepType = iota // Required only to compile (e.g., headers) RuntimeDep // Required to run (e.g., shared libs) OptionalDep // User-toggled feature (e.g., --with-x) ) func (d DepType) String() string { names := [...]string{"build", "runtime", "optional", "unknown"} i := int(d) if i < len(names) { return names[i] } return "unknown" } // Edge is a typed relationship between two nodes. type Edge struct { Target *Node Type DepType Features []string // Sub-depends: features the parent expects the child to expose Enabled bool // For Optional edges; toggled by ICE y/n answers } // Node represents a single spell inside the Graph. type Node struct { Name string Version string Edges []*Edge visited bool testing bool // "Grey" marker during DFS IsRequired bool InDegree int // How many parents depend on this node (drives priority scheduling) } // Graph is the in-memory model of the entire Grimoire's dependency mesh. type Graph struct { Nodes map[string]*Node } // NewGraph returns an empty Graph. func NewGraph() *Graph { return &Graph{Nodes: make(map[string]*Node)} } // GetOrCreate fetches a node, creating it if necessary. func (g *Graph) GetOrCreate(name string) *Node { if n, ok := g.Nodes[name]; ok { return n } n := &Node{Name: name} g.Nodes[name] = n return n } // AddDependency links parent -> child with a typed edge and runs cycle // detection. If adding the edge would create a loop, the edge is rolled back // and an error is returned so the Graph stays consistent. // // PERFORMANCE: This calls DetectCycles() after every single edge, which // traverses all V nodes. For batch loading (e.g., parsing the full Grimoire), // this is O(E*V). A future BatchAddDependency method should add all edges // first, then run a single cycle detection pass — see the TODO in // DetectCycles. For now, the typical Grimoire (~3k spells, ~8k edges) // completes in <50ms, which is acceptable for interactive use. func (g *Graph) AddDependency(parent, child string, t DepType, features []string) error { p := g.GetOrCreate(parent) c := g.GetOrCreate(child) edge := &Edge{Target: c, Type: t, Features: features, Enabled: t != OptionalDep} p.Edges = append(p.Edges, edge) c.InDegree++ if err := g.DetectCycles(); err != nil { // Roll back the edge. p.Edges = p.Edges[:len(p.Edges)-1] c.InDegree-- return fmt.Errorf("dag: refusing edge %s -> %s: %w", parent, child, err) } return nil } // DetectCycles implements three-color DFS cycle detection across the whole Graph. func (g *Graph) DetectCycles() error { for _, n := range g.Nodes { n.visited = false n.testing = false } // Sort for deterministic error messages. keys := make([]string, 0, len(g.Nodes)) for k := range g.Nodes { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { if !g.Nodes[k].visited { if err := g.visit(g.Nodes[k]); err != nil { return err } } } return nil } func (g *Graph) visit(n *Node) error { if n.testing { return fmt.Errorf("dag: circular reference detected at %q", n.Name) } if n.visited { return nil } n.testing = true for _, e := range n.Edges { if err := g.visit(e.Target); err != nil { return err } } n.testing = false n.visited = true return nil } // TopologicalSort returns a build order where every dependency precedes its // dependents. Optional edges that are not Enabled are skipped. func (g *Graph) TopologicalSort() ([]*Node, error) { if err := g.DetectCycles(); err != nil { return nil, err } visited := make(map[string]bool) var order []*Node var visit func(*Node) visit = func(n *Node) { if visited[n.Name] { return } visited[n.Name] = true for _, e := range n.Edges { if e.Type == OptionalDep && !e.Enabled { continue } visit(e.Target) } order = append(order, n) } for _, n := range g.Nodes { visit(n) } return order, nil } // Prune walks from a root and flags every reachable node IsRequired. // Optional branches are skipped when includeOptional is false — this is the // "Smart Pruning" logic that keeps the build queue minimal. func (g *Graph) Prune(root string, includeOptional bool) { rootNode, ok := g.Nodes[root] if !ok { return } for _, n := range g.Nodes { n.IsRequired = false } var walk func(*Node) walk = func(n *Node) { if n.IsRequired { return } n.IsRequired = true for _, e := range n.Edges { if e.Type == OptionalDep && !includeOptional { continue } if !e.Enabled && e.Type == OptionalDep { continue } walk(e.Target) } } walk(rootNode) } // ParallelBatches groups spells that have no mutual dependencies so they can // be Cast simultaneously by the worker pool. Batch N+1 only depends on batches // 1..N, never on itself. // // WARNING: This method temporarily sets g.Nodes[name] = nil for consumed // nodes and restores them before returning. It is NOT safe to call // concurrently with other Graph operations. The caller must hold exclusive // access to the Graph during this call. func (g *Graph) ParallelBatches() ([][]*Node, error) { if err := g.DetectCycles(); err != nil { return nil, err } indeg := make(map[string]int) for name, n := range g.Nodes { indeg[name] = n.InDegree } var batches [][]*Node for { var ready []*Node for name, n := range g.Nodes { if indeg[name] == 0 && n != nil { ready = append(ready, n) } } if len(ready) == 0 { break } sort.Slice(ready, func(i, j int) bool { return ready[i].Name < ready[j].Name }) batches = append(batches, ready) for _, n := range ready { g.Nodes[n.Name] = nil // mark consumed for _, e := range n.Edges { if e.Type == OptionalDep && !e.Enabled { continue } indeg[e.Target.Name]-- } } } // Restore nodes (we nulled them out above) for _, b := range batches { for _, n := range b { g.Nodes[n.Name] = n } } return batches, nil } // ErrSpellNotFound is returned when a spell is missing from the Graph. var ErrSpellNotFound = errors.New("dag: spell not found in grimoire")