ajhahn.de
← eeco
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
}