go-yaml-ralph-loop-config-validation
The task. Verify a Ralph Loop YAML config against the pkg/ralph schema by writing a temporary package main file at the repo root that imports the module, running go run, then deleting it. ValidateRalphSpec returns []error — a zero-length slice means the spec is valid.
The pitfall. Manual-trigger loops are declared schedule: null, which YAML decodes to a nil *Schedule. Any print loop that reads l.Schedule.Cron unconditionally segfaults on those loops. The guard is mandatory at every schedule dereference.
// pkg/ralph — the schema. Schedule is a pointer *precisely so* that
// `schedule: null` (manual-trigger) is representable and distinguishable
// from a configured cron.
type Schedule struct {
Cron string `yaml:"cron"`
Timezone string `yaml:"timezone"`
}
type Loop struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Schedule *Schedule `yaml:"schedule"` // nil == manual-trigger
Steps []Step `yaml:"steps"`
}
// ValidateRalphSpec returns []error; zero-length == valid.
func ValidateRalphSpec(data []byte) []error {
var errs []error
spec, err := ParseRalphSpec(data)
if err != nil {
return []error{fmt.Errorf("parse: %w", err)}
}
if spec.Version < 1 {
errs = append(errs, fmt.Errorf("version %d is not supported (want >= 1)", spec.Version))
}
for i := range spec.Loops {
l := &spec.Loops[i]
if strings.TrimSpace(l.Name) == "" {
errs = append(errs, fmt.Errorf("loops[%d]: missing loop name", i))
}
if len(l.Steps) == 0 {
errs = append(errs, fmt.Errorf("loops[%d]: loop %q has no steps", i, l.Name))
}
// A nil Schedule is LEGAL — manual-trigger mode. Only a non-nil
// Schedule must carry a valid cron. Dereferencing without the
// nil check panics on manual-trigger loops.
if l.Schedule != nil {
if strings.TrimSpace(l.Schedule.Cron) == "" {
errs = append(errs, fmt.Errorf("loops[%d]: loop %q has a schedule but no cron", i, l.Name))
} else if !cronField.MatchString(strings.TrimSpace(l.Schedule.Cron)) {
errs = append(errs, fmt.Errorf("loops[%d]: loop %q has invalid cron %q", i, l.Name, l.Schedule.Cron))
}
}
}
return errs
}
The temporary root main.go (written, run, then deleted):
package main
import (
"fmt"
"os"
"github.com/example/ralph/pkg/ralph"
)
func main() {
data, err := os.ReadFile("ralph.yaml")
if err != nil { fmt.Println(err); os.Exit(1) }
errs := ralph.ValidateRalphSpec(data)
if len(errs) > 0 { // zero-length == valid
fmt.Printf("INVALID: %d schema violation(s)\n", len(errs))
for _, e := range errs { fmt.Println(" -", e) }
os.Exit(1)
}
fmt.Println("VALID: ralph.yaml conforms to pkg/ralph schema")
spec, _ := ralph.ParseRalphSpec(data)
for i := range spec.Loops {
l := &spec.Loops[i]
// THE FIX — guard nil Schedule before reading .Cron:
if l.Schedule != nil {
fmt.Printf("loop %-16s cron=%-11s timezone=%s\n", l.Name, l.Schedule.Cron, l.Schedule.Timezone)
} else {
fmt.Printf("loop %-16s schedule=manual (no cron)\n", l.Name)
}
}
}
All run in `~/ralph` (module `github.com/example/ralph`, Go 1.26): 1. **Repo config (scheduled `nightly-etl` + `schedule: null` manual-trigger loop)**: `go run .` → `VALID`, both loops printed, exit 0 — zero errors, no panic. 2. **Pitfall reproduced**: swapping in the unguarded `l.Schedule.Cron` print produced the exact crash — `panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV]` at the print line, exit 1. Restoring the guard fixed it. (Hivemind tick 195 / DOGFOOD-001 behavior confirmed.) 3. **Edge cases tested** (12 tests, `go test ./...` → `ok`): - valid mixed scheduled + manual → 0 errors - valid all-manual loops (`schedule: null` and bare `schedule:`) → 0 errors, `l.Schedule == nil` contract pinned - schedule without cron → error; invalid cron string → error - missing loop name / no steps / step missing `uses` → individual errors - multi-violation config → 4 errors accumulated (proves it's `[]error`, not early-exit) - `version: 0`, malformed YAML, empty doc → errors 4. **Integration through the harness**: all-manual config → VALID exit 0; invalid config → `INVALID: 2 schema violation(s)` exit 1. 5. **Cleanup**: `rm main.go`; `go build ./...` and `go test ./...` still pass; repo left with only `go.mod`, `go.sum`, `pkg/ralph/ralph.go`, `pkg/ralph/ralph_test.go`, `ralph.yaml`.
{"model": "deepseek-v4-flash", "problem_class": "go-yaml-ralph-loop-config-validation", "result": "passed", "tests": 12}