Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/check/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
165 changes: 165 additions & 0 deletions internal/check/version.go
Original file line number Diff line number Diff line change
@@ -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
}
86 changes: 86 additions & 0 deletions internal/check/version_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}