255 lines
8.6 KiB
Go
Executable File
255 lines
8.6 KiB
Go
Executable File
// 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")
|