Go 98 lines
package workflow
import (
"errors"
"fmt"
"os/exec"
"strings"
)
// buildGate runs the project's declared parse/build gate — the ordered
// command chain in cfg.Gate — step by step, with the repository root as
// the working directory, stopping at the first failure. The chain is the
// profile default (a single step) or the operator's repeatable `gate`
// key in config.local; a project with no gate (the generic profile, or
// a lone `gate=`) is a clean no-op, so the gate is opt-in per project.
//
// Exit-code contract:
// - every step exits 0 -> CodeClean
// - a step exits non-zero -> CodeFinding (chain stops there)
// - a step's command is not on PATH, or -> CodeBlocked
// a step that pre-flighted cannot run
// - no gate declared -> CodeClean
//
// Every step's command is checked on PATH before the first step runs, so
// a chain that cannot complete is reported blocked rather than running
// partway and reporting a finding (a missing tool outranks a finding).
type buildGate struct{}
func (buildGate) Name() string { return "gate" }
func (buildGate) Summary() string {
return "run the project's declared parse/build gate chain"
}
func (buildGate) Run(env Env) (Result, error) {
steps := env.Config.Gate
if len(steps) == 0 {
return Result{Code: CodeClean, Summary: "no gate declared"}, nil
}
// Pre-flight: a chain is runnable only if every step's command is on
// PATH. Report the first missing tool as blocked before running any
// step, so a partly-run chain never masquerades as a finding.
for _, step := range steps {
if len(step) == 0 {
continue
}
if _, err := exec.LookPath(step[0]); err != nil {
return Result{
Code: CodeBlocked,
Summary: fmt.Sprintf("gate step %q is not on PATH", step[0]),
}, nil
}
}
for i, step := range steps {
if len(step) == 0 {
continue
}
label := strings.Join(step, " ")
if env.Out != nil {
fmt.Fprintf(env.Out, "gate step %d/%d: %s\n", i+1, len(steps), label)
}
cmd := exec.Command(step[0], step[1:]...)
cmd.Dir = env.Config.RepoRoot
cmd.Stdout = env.Out
cmd.Stderr = env.Out
runErr := cmd.Run()
if runErr == nil {
continue
}
var ee *exec.ExitError
if errors.As(runErr, &ee) {
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("gate step %d/%d failed: %s", i+1, len(steps), label),
Findings: []Finding{{
Path: label,
Line: 0,
Msg: fmt.Sprintf("exited %d", ee.ExitCode()),
}},
}, nil
}
// The tool was on PATH at pre-flight but still could not run
// (removed mid-run, lost the executable bit): the chain cannot
// complete, so this is blocked rather than a finding.
return Result{
Code: CodeBlocked,
Summary: fmt.Sprintf("gate step %d/%d could not run: %s", i+1, len(steps), label),
}, nil
}
return Result{
Code: CodeClean,
Summary: fmt.Sprintf("%d gate step(s) passed", len(steps)),
}, nil
}