Files
gastown/internal/formula/parser.go
max 1b69576573 fix: Address golangci-lint errors (errcheck, gosec) (#76)
Apply PR #76 from dannomayernotabot:

- Add golangci exclusions for internal package false positives
- Tighten file permissions (0644 -> 0600) for sensitive files
- Add ReadHeaderTimeout to HTTP server (slowloris prevention)
- Explicit error ignoring with _ = for intentional cases
- Add //nolint comments with justifications
- Spelling: cancelled -> canceled (US locale)

Co-Authored-By: dannomayernotabot <noreply@github.com>

🤖 Generated with Claude Code
2026-01-03 16:11:55 -08:00

421 lines
9.0 KiB
Go

package formula
import (
"fmt"
"os"
"github.com/BurntSushi/toml"
)
// ParseFile reads and parses a formula.toml file.
func ParseFile(path string) (*Formula, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is from trusted formula directory
if err != nil {
return nil, fmt.Errorf("reading formula file: %w", err)
}
return Parse(data)
}
// Parse parses formula.toml content from bytes.
func Parse(data []byte) (*Formula, error) {
var f Formula
if _, err := toml.Decode(string(data), &f); err != nil {
return nil, fmt.Errorf("parsing TOML: %w", err)
}
// Infer type from content if not explicitly set
f.inferType()
if err := f.Validate(); err != nil {
return nil, err
}
return &f, nil
}
// inferType sets the formula type based on content when not explicitly set.
func (f *Formula) inferType() {
if f.Type != "" {
return // Type already set
}
// Infer from content
if len(f.Steps) > 0 {
f.Type = TypeWorkflow
} else if len(f.Legs) > 0 {
f.Type = TypeConvoy
} else if len(f.Template) > 0 {
f.Type = TypeExpansion
} else if len(f.Aspects) > 0 {
f.Type = TypeAspect
}
}
// Validate checks that the formula has all required fields and valid structure.
func (f *Formula) Validate() error {
// Check required common fields
if f.Name == "" {
return fmt.Errorf("formula field is required")
}
if !f.Type.IsValid() {
return fmt.Errorf("invalid formula type %q (must be convoy, workflow, expansion, or aspect)", f.Type)
}
// Type-specific validation
switch f.Type {
case TypeConvoy:
return f.validateConvoy()
case TypeWorkflow:
return f.validateWorkflow()
case TypeExpansion:
return f.validateExpansion()
case TypeAspect:
return f.validateAspect()
}
return nil
}
func (f *Formula) validateConvoy() error {
if len(f.Legs) == 0 {
return fmt.Errorf("convoy formula requires at least one leg")
}
// Check leg IDs are unique
seen := make(map[string]bool)
for _, leg := range f.Legs {
if leg.ID == "" {
return fmt.Errorf("leg missing required id field")
}
if seen[leg.ID] {
return fmt.Errorf("duplicate leg id: %s", leg.ID)
}
seen[leg.ID] = true
}
// Validate synthesis depends_on references valid legs
if f.Synthesis != nil {
for _, dep := range f.Synthesis.DependsOn {
if !seen[dep] {
return fmt.Errorf("synthesis depends_on references unknown leg: %s", dep)
}
}
}
return nil
}
func (f *Formula) validateWorkflow() error {
if len(f.Steps) == 0 {
return fmt.Errorf("workflow formula requires at least one step")
}
// Check step IDs are unique
seen := make(map[string]bool)
for _, step := range f.Steps {
if step.ID == "" {
return fmt.Errorf("step missing required id field")
}
if seen[step.ID] {
return fmt.Errorf("duplicate step id: %s", step.ID)
}
seen[step.ID] = true
}
// Validate step needs references
for _, step := range f.Steps {
for _, need := range step.Needs {
if !seen[need] {
return fmt.Errorf("step %q needs unknown step: %s", step.ID, need)
}
}
}
// Check for cycles
if err := f.checkCycles(); err != nil {
return err
}
return nil
}
func (f *Formula) validateExpansion() error {
if len(f.Template) == 0 {
return fmt.Errorf("expansion formula requires at least one template")
}
// Check template IDs are unique
seen := make(map[string]bool)
for _, tmpl := range f.Template {
if tmpl.ID == "" {
return fmt.Errorf("template missing required id field")
}
if seen[tmpl.ID] {
return fmt.Errorf("duplicate template id: %s", tmpl.ID)
}
seen[tmpl.ID] = true
}
// Validate template needs references
for _, tmpl := range f.Template {
for _, need := range tmpl.Needs {
if !seen[need] {
return fmt.Errorf("template %q needs unknown template: %s", tmpl.ID, need)
}
}
}
return nil
}
func (f *Formula) validateAspect() error {
if len(f.Aspects) == 0 {
return fmt.Errorf("aspect formula requires at least one aspect")
}
// Check aspect IDs are unique
seen := make(map[string]bool)
for _, aspect := range f.Aspects {
if aspect.ID == "" {
return fmt.Errorf("aspect missing required id field")
}
if seen[aspect.ID] {
return fmt.Errorf("duplicate aspect id: %s", aspect.ID)
}
seen[aspect.ID] = true
}
return nil
}
// checkCycles detects circular dependencies in steps.
func (f *Formula) checkCycles() error {
// Build adjacency list
deps := make(map[string][]string)
for _, step := range f.Steps {
deps[step.ID] = step.Needs
}
// DFS for cycle detection
visited := make(map[string]bool)
inStack := make(map[string]bool)
var visit func(id string) error
visit = func(id string) error {
if inStack[id] {
return fmt.Errorf("cycle detected involving step: %s", id)
}
if visited[id] {
return nil
}
visited[id] = true
inStack[id] = true
for _, dep := range deps[id] {
if err := visit(dep); err != nil {
return err
}
}
inStack[id] = false
return nil
}
for _, step := range f.Steps {
if err := visit(step.ID); err != nil {
return err
}
}
return nil
}
// TopologicalSort returns steps in dependency order (dependencies before dependents).
// Only applicable to workflow and expansion formulas.
// Returns an error if there are cycles.
func (f *Formula) TopologicalSort() ([]string, error) {
var items []string
var deps map[string][]string
switch f.Type {
case TypeWorkflow:
for _, step := range f.Steps {
items = append(items, step.ID)
}
deps = make(map[string][]string)
for _, step := range f.Steps {
deps[step.ID] = step.Needs
}
case TypeExpansion:
for _, tmpl := range f.Template {
items = append(items, tmpl.ID)
}
deps = make(map[string][]string)
for _, tmpl := range f.Template {
deps[tmpl.ID] = tmpl.Needs
}
case TypeConvoy:
// Convoy legs are parallel; return all leg IDs
for _, leg := range f.Legs {
items = append(items, leg.ID)
}
return items, nil
case TypeAspect:
// Aspect aspects are parallel; return all aspect IDs
for _, aspect := range f.Aspects {
items = append(items, aspect.ID)
}
return items, nil
default:
return nil, fmt.Errorf("unsupported formula type for topological sort")
}
// Kahn's algorithm
inDegree := make(map[string]int)
for _, id := range items {
inDegree[id] = 0
}
for _, id := range items {
for _, dep := range deps[id] {
inDegree[id]++
_ = dep // dep already exists (validated)
}
}
// Find all nodes with no dependencies
var queue []string
for _, id := range items {
if inDegree[id] == 0 {
queue = append(queue, id)
}
}
// Build reverse adjacency (who depends on me)
dependents := make(map[string][]string)
for _, id := range items {
for _, dep := range deps[id] {
dependents[dep] = append(dependents[dep], id)
}
}
var result []string
for len(queue) > 0 {
// Pop from queue
id := queue[0]
queue = queue[1:]
result = append(result, id)
// Reduce in-degree of dependents
for _, dependent := range dependents[id] {
inDegree[dependent]--
if inDegree[dependent] == 0 {
queue = append(queue, dependent)
}
}
}
if len(result) != len(items) {
return nil, fmt.Errorf("cycle detected in dependencies")
}
return result, nil
}
// ReadySteps returns steps that have no unmet dependencies.
// completed is a set of step IDs that have been completed.
func (f *Formula) ReadySteps(completed map[string]bool) []string {
var ready []string
switch f.Type {
case TypeWorkflow:
for _, step := range f.Steps {
if completed[step.ID] {
continue
}
allMet := true
for _, need := range step.Needs {
if !completed[need] {
allMet = false
break
}
}
if allMet {
ready = append(ready, step.ID)
}
}
case TypeExpansion:
for _, tmpl := range f.Template {
if completed[tmpl.ID] {
continue
}
allMet := true
for _, need := range tmpl.Needs {
if !completed[need] {
allMet = false
break
}
}
if allMet {
ready = append(ready, tmpl.ID)
}
}
case TypeConvoy:
// All legs are ready unless already completed
for _, leg := range f.Legs {
if !completed[leg.ID] {
ready = append(ready, leg.ID)
}
}
case TypeAspect:
// All aspects are ready unless already completed
for _, aspect := range f.Aspects {
if !completed[aspect.ID] {
ready = append(ready, aspect.ID)
}
}
}
return ready
}
// GetStep returns a step by ID, or nil if not found.
func (f *Formula) GetStep(id string) *Step {
for i := range f.Steps {
if f.Steps[i].ID == id {
return &f.Steps[i]
}
}
return nil
}
// GetLeg returns a leg by ID, or nil if not found.
func (f *Formula) GetLeg(id string) *Leg {
for i := range f.Legs {
if f.Legs[i].ID == id {
return &f.Legs[i]
}
}
return nil
}
// GetTemplate returns a template by ID, or nil if not found.
func (f *Formula) GetTemplate(id string) *Template {
for i := range f.Template {
if f.Template[i].ID == id {
return &f.Template[i]
}
}
return nil
}
// GetAspect returns an aspect by ID, or nil if not found.
func (f *Formula) GetAspect(id string) *Aspect {
for i := range f.Aspects {
if f.Aspects[i].ID == id {
return &f.Aspects[i]
}
}
return nil
}