diff --git a/internal/check/registry.go b/internal/check/registry.go index de5f99d..0f69c9a 100644 --- a/internal/check/registry.go +++ b/internal/check/registry.go @@ -7,10 +7,12 @@ func Build(stack detector.DetectedStack) []Check { if stack.Go { cs = append(cs, &BinaryCheck{Binary: "go"}) + cs = append(cs, &GoVersionCheck{Dir: "."}) } if stack.Node { cs = append(cs, &BinaryCheck{Binary: "node"}) cs = append(cs, &BinaryCheck{Binary: "npm"}) + cs = append(cs, &NodeVersionCheck{Dir: "."}) } if stack.Python { cs = append(cs, &BinaryCheck{Binary: "python3"}) diff --git a/internal/check/version.go b/internal/check/version.go new file mode 100644 index 0000000..9786201 --- /dev/null +++ b/internal/check/version.go @@ -0,0 +1,165 @@ +package check + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +// GoVersionCheck reads the required Go version from go.mod and compares +// it against the installed version. +type GoVersionCheck struct { + Dir string +} + +func (c *GoVersionCheck) Name() string { + return "Go version" +} + +func (c *GoVersionCheck) Run(_ context.Context) Result { + required, err := readGoModVersion(c.Dir + "/go.mod") + if err != nil { + return Result{Name: c.Name(), Status: StatusSkipped, Message: "could not read go.mod"} + } + + out, err := exec.Command("go", "version").Output() + if err != nil { + return Result{Name: c.Name(), Status: StatusFail, Message: "could not run go version"} + } + // output: "go version go1.22.3 darwin/arm64" + parts := strings.Fields(string(out)) + if len(parts) < 3 { + return Result{Name: c.Name(), Status: StatusFail, Message: "unexpected go version output"} + } + installed := strings.TrimPrefix(parts[2], "go") + + if versionLess(installed, required) { + return Result{ + Name: c.Name(), + Status: StatusFail, + Message: fmt.Sprintf("need Go %s, got %s", required, installed), + Fix: fmt.Sprintf("upgrade Go to %s or newer", required), + } + } + return Result{ + Name: c.Name(), + Status: StatusPass, + Message: fmt.Sprintf("Go %s installed (need %s)", installed, required), + } +} + +// NodeVersionCheck reads the required Node version from .nvmrc or +// engines.node in package.json and compares against the installed version. +type NodeVersionCheck struct { + Dir string +} + +func (c *NodeVersionCheck) Name() string { + return "Node version" +} + +func (c *NodeVersionCheck) Run(_ context.Context) Result { + required, err := readNodeRequired(c.Dir) + if err != nil { + return Result{Name: c.Name(), Status: StatusSkipped, Message: "no Node version requirement found"} + } + + out, err := exec.Command("node", "--version").Output() + if err != nil { + return Result{Name: c.Name(), Status: StatusFail, Message: "could not run node --version"} + } + installed := strings.TrimPrefix(strings.TrimSpace(string(out)), "v") + + if versionLess(installed, required) { + return Result{ + Name: c.Name(), + Status: StatusFail, + Message: fmt.Sprintf("need Node %s, got %s", required, installed), + Fix: fmt.Sprintf("upgrade Node to %s or newer", required), + } + } + return Result{ + Name: c.Name(), + Status: StatusPass, + Message: fmt.Sprintf("Node %s installed (need %s)", installed, required), + } +} + +func readGoModVersion(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "go ") { + return strings.TrimPrefix(line, "go "), nil + } + } + return "", fmt.Errorf("go directive not found in go.mod") +} + +func readNodeRequired(dir string) (string, error) { + // .nvmrc takes priority + if data, err := os.ReadFile(dir + "/.nvmrc"); err == nil { + v := strings.TrimSpace(string(data)) + return strings.TrimPrefix(v, "v"), nil + } + + // fall back to engines.node in package.json + data, err := os.ReadFile(dir + "/package.json") + if err != nil { + return "", err + } + var pkg struct { + Engines struct { + Node string `json:"node"` + } `json:"engines"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return "", err + } + if pkg.Engines.Node == "" { + return "", fmt.Errorf("no engines.node in package.json") + } + // strip range operators like >=, ^, ~ + return strings.TrimLeft(pkg.Engines.Node, ">=^~"), nil +} + +// versionLess returns true if a < b. +func versionLess(a, b string) bool { + av := parseVersion(a) + bv := parseVersion(b) + for i := 0; i < len(bv); i++ { + if i >= len(av) { + return true + } + if av[i] < bv[i] { + return true + } + if av[i] > bv[i] { + return false + } + } + return false +} + +func parseVersion(v string) []int { + var result []int + for _, p := range strings.Split(v, ".") { + n, err := strconv.Atoi(p) + if err != nil { + break + } + result = append(result, n) + } + return result +} diff --git a/internal/check/version_test.go b/internal/check/version_test.go new file mode 100644 index 0000000..1ed702d --- /dev/null +++ b/internal/check/version_test.go @@ -0,0 +1,86 @@ +package check + +import ( + "context" + "os" + "testing" +) + +func TestVersionLess(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"1.21.0", "1.22", true}, + {"1.22.0", "1.21", false}, + {"1.22.0", "1.22", false}, + {"20.0.0", "18", false}, + {"16.0.0", "18", true}, + } + for _, tc := range cases { + got := versionLess(tc.a, tc.b) + if got != tc.want { + t.Errorf("versionLess(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + } +} + +func TestGoVersionCheck_Pass(t *testing.T) { + dir := t.TempDir() + os.WriteFile(dir+"/go.mod", []byte("module example\n\ngo 1.1\n"), 0644) + + c := &GoVersionCheck{Dir: dir} + result := c.Run(context.Background()) + if result.Status != StatusPass { + t.Errorf("expected pass, got %v: %s", result.Status, result.Message) + } +} + +func TestGoVersionCheck_Fail(t *testing.T) { + dir := t.TempDir() + os.WriteFile(dir+"/go.mod", []byte("module example\n\ngo 9999.0\n"), 0644) + + c := &GoVersionCheck{Dir: dir} + result := c.Run(context.Background()) + if result.Status != StatusFail { + t.Errorf("expected fail, got %v: %s", result.Status, result.Message) + } +} + +func TestGoVersionCheck_MissingGoMod(t *testing.T) { + c := &GoVersionCheck{Dir: t.TempDir()} + result := c.Run(context.Background()) + if result.Status != StatusSkipped { + t.Errorf("expected skipped, got %v", result.Status) + } +} + +func TestNodeVersionCheck_NvmrcPass(t *testing.T) { + dir := t.TempDir() + os.WriteFile(dir+"/.nvmrc", []byte("1\n"), 0644) + + c := &NodeVersionCheck{Dir: dir} + result := c.Run(context.Background()) + if result.Status != StatusPass { + t.Errorf("expected pass, got %v: %s", result.Status, result.Message) + } +} + +func TestNodeVersionCheck_PackageJsonPass(t *testing.T) { + dir := t.TempDir() + os.WriteFile(dir+"/package.json", []byte(`{"engines":{"node":">=1.0.0"}}`), 0644) + + c := &NodeVersionCheck{Dir: dir} + result := c.Run(context.Background()) + if result.Status != StatusPass { + t.Errorf("expected pass, got %v: %s", result.Status, result.Message) + } +} + +func TestNodeVersionCheck_NoRequirement(t *testing.T) { + c := &NodeVersionCheck{Dir: t.TempDir()} + result := c.Run(context.Background()) + if result.Status != StatusSkipped { + t.Errorf("expected skipped, got %v", result.Status) + } +}