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
181 changes: 166 additions & 15 deletions internal/check/deps.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package check

import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)

// goModDownload is a variable so tests can stub it.
Expand All @@ -14,10 +17,22 @@ var goModDownload = func(dir string) error {
return cmd.Run()
}

// pipFreezeRunner is a variable so tests can stub it.
var pipFreezeRunner = func(pipBin string) ([]byte, error) {
return exec.Command(pipBin, "freeze").Output()
}

// pipCheckRunner is a variable so tests can stub it.
var pipCheckRunner = func(pipBin string) error {
return exec.Command(pipBin, "check").Run()
}

type DepsCheck struct {
Dir string
Stack string // "node", "python", or "go"
goCheck func(dir string) error
Dir string
Stack string // "node", "python", or "go"
goCheck func(dir string) error
pipFreeze func(pipBin string) ([]byte, error)
pipCheck func(pipBin string) error
}

func (c *DepsCheck) Name() string {
Expand Down Expand Up @@ -59,7 +74,6 @@ func (c *DepsCheck) runNode() Result {
Message: "node_modules directory exists",
}
}

return Result{
Name: c.Name(),
Status: StatusFail,
Expand All @@ -69,25 +83,166 @@ func (c *DepsCheck) runNode() Result {
}

func (c *DepsCheck) runPython() Result {
venv := filepath.Join(c.Dir, "venv")
dotVenv := filepath.Join(c.Dir, ".venv")
venvDir := c.findVenv()
if venvDir == "" {
return Result{
Name: c.Name(),
Status: StatusFail,
Message: "Python virtual environment directory not found",
Fix: "create a virtual environment (e.g. `python -m venv .venv`) and install dependencies with `pip install -r requirements.txt` or equivalent",
}
}

if dirExists(venv) || dirExists(dotVenv) {
// No requirements.txt — venv existing is sufficient.
reqFile := filepath.Join(c.Dir, "requirements.txt")
if _, err := os.Stat(reqFile); os.IsNotExist(err) {
return Result{
Name: c.Name(),
Status: StatusPass,
Message: "Python virtual environment directory exists",
}
}

pipBin := c.findPipBin(venvDir)

// Run pip check for dependency conflicts first.
checkFn := c.pipCheck
if checkFn == nil {
checkFn = pipCheckRunner
}
if err := checkFn(pipBin); err != nil {
return Result{
Name: c.Name(),
Status: StatusFail,
Message: fmt.Sprintf("pip check reported dependency conflicts: %v", err),
Fix: "run `pip install -r requirements.txt` inside your virtual environment to resolve conflicting or missing packages",
}
}

// Compare pip freeze against requirements.txt to catch missing packages.
freezeFn := c.pipFreeze
if freezeFn == nil {
freezeFn = pipFreezeRunner
}
missing, err := findMissingRequirements(pipBin, reqFile, freezeFn)
if err != nil {
return Result{
Name: c.Name(),
Status: StatusFail,
Message: fmt.Sprintf("could not compare installed packages to requirements.txt: %v", err),
Fix: "ensure pip is available in your virtual environment and requirements.txt is readable",
}
}
if len(missing) > 0 {
return Result{
Name: c.Name(),
Status: StatusFail,
Message: fmt.Sprintf("packages listed in requirements.txt but not installed: %s", strings.Join(missing, ", ")),
Fix: "run `pip install -r requirements.txt` inside your virtual environment",
}
}

return Result{
Name: c.Name(),
Status: StatusFail,
Message: "Python virtual environment directory not found",
Fix: "create a virtual environment (e.g. `python -m venv .venv`) and install dependencies with `pip install -r requirements.txt` or equivalent",
Status: StatusPass,
Message: "Python virtual environment exists and packages match requirements.txt",
}
}

// findVenv returns the path of the first venv directory found, or "".
func (c *DepsCheck) findVenv() string {
for _, name := range []string{"venv", ".venv"} {
p := filepath.Join(c.Dir, name)
if dirExists(p) {
return p
}
}
return ""
}

// findPipBin returns the pip binary path inside venvDir (Unix or Windows layout).
func (c *DepsCheck) findPipBin(venvDir string) string {
unix := filepath.Join(venvDir, "bin", "pip")
if _, err := os.Stat(unix); err == nil {
return unix
}
return filepath.Join(venvDir, "Scripts", "pip.exe")
}

// findMissingRequirements returns package names listed in requirements.txt
// that are absent from `pip freeze` output.
func findMissingRequirements(pipBin, reqFile string, freezeFn func(string) ([]byte, error)) ([]string, error) {
required, err := parseRequirements(reqFile)
if err != nil {
return nil, fmt.Errorf("reading requirements.txt: %w", err)
}
out, err := freezeFn(pipBin)
if err != nil {
return nil, fmt.Errorf("running pip freeze: %w", err)
}
installed := parseFreeze(out)

var missing []string
for pkg := range required {
if _, ok := installed[pkg]; !ok {
missing = append(missing, pkg)
}
}
return missing, nil
}

// parseRequirements reads requirements.txt and returns a set of lowercase
// package names, stripping version specifiers, extras, markers, and comments.
func parseRequirements(path string) (map[string]struct{}, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()

pkgs := make(map[string]struct{})
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip blanks, comments, and pip options (e.g. -r, --index-url).
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") {
continue
}
// Strip inline comments.
if idx := strings.IndexByte(line, '#'); idx >= 0 {
line = strings.TrimSpace(line[:idx])
}
// Extract bare package name before any version specifier, extra, or marker.
name := strings.FieldsFunc(line, func(r rune) bool {
return r == '=' || r == '!' || r == '<' || r == '>' || r == '[' || r == ';' || r == ' '
})[0]
pkgs[strings.ToLower(name)] = struct{}{}
}
return pkgs, scanner.Err()
}

// parseFreeze parses `pip freeze` output into a set of lowercase package names.
func parseFreeze(output []byte) map[string]struct{} {
installed := make(map[string]struct{})
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Editable installs: "-e git+...#egg=pkgname"
if strings.HasPrefix(line, "-e ") {
if idx := strings.Index(line, "#egg="); idx >= 0 {
installed[strings.ToLower(strings.TrimSpace(line[idx+5:]))] = struct{}{}
}
continue
}
parts := strings.SplitN(line, "==", 2)
installed[strings.ToLower(strings.TrimSpace(parts[0]))] = struct{}{}
}
return installed
}

func (c *DepsCheck) runGo() Result {
vendorDir := filepath.Join(c.Dir, "vendor")
if dirExists(vendorDir) {
Expand All @@ -97,12 +252,10 @@ func (c *DepsCheck) runGo() Result {
Message: "vendor directory exists; Go dependencies are vendored",
}
}

check := c.goCheck
if check == nil {
check = goModDownload
}

if err := check(c.Dir); err != nil {
return Result{
Name: c.Name(),
Expand All @@ -111,11 +264,9 @@ func (c *DepsCheck) runGo() Result {
Fix: "run `go mod download` to download Go module dependencies",
}
}

return Result{
Name: c.Name(),
Status: StatusPass,
Message: "Go module cache is populated",
}
}

}
113 changes: 113 additions & 0 deletions internal/check/deps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"testing"
"strings"
)

func TestDepsCheck_Node_PassAndFail(t *testing.T) {
Expand Down Expand Up @@ -97,3 +98,115 @@ func TestDepsCheck_Go_PassAndFail(t *testing.T) {
}
}

func TestDepsCheck_Python_VenvNoRequirements_StillPass(t *testing.T) {
// Existing behaviour must be preserved: venv present, no requirements.txt → pass.
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, ".venv"), 0o755); err != nil {
t.Fatalf("mkdir .venv: %v", err)
}
c := &DepsCheck{Dir: dir, Stack: "python"}
r := c.Run(context.Background())
if r.Status != StatusPass {
t.Errorf("expected Pass when no requirements.txt, got %v: %s", r.Status, r.Message)
}
}

func TestDepsCheck_Python_AllPackagesPresent(t *testing.T) {
dir, pipBin := setupPythonDir(t, "requests==2.31.0\nflask>=2.0\n# comment\n")
c := &DepsCheck{
Dir: dir,
Stack: "python",
pipCheck: func(_ string) error { return nil },
pipFreeze: func(_ string) ([]byte, error) {
return []byte("requests==2.31.0\nFlask==2.3.0\n"), nil
},
}
_ = pipBin
r := c.Run(context.Background())
if r.Status != StatusPass {
t.Errorf("expected Pass when all packages present, got %v: %s", r.Status, r.Message)
}
}

func TestDepsCheck_Python_MissingPackage(t *testing.T) {
dir, _ := setupPythonDir(t, "requests==2.31.0\ncelery>=5.0\n")
c := &DepsCheck{
Dir: dir,
Stack: "python",
pipCheck: func(_ string) error { return nil },
pipFreeze: func(_ string) ([]byte, error) {
return []byte("requests==2.31.0\n"), nil // celery absent
},
}
r := c.Run(context.Background())
if r.Status != StatusFail {
t.Fatalf("expected Fail for missing package, got %v: %s", r.Status, r.Message)
}
if !strings.Contains(r.Message, "celery") {
t.Errorf("expected 'celery' in message, got: %s", r.Message)
}
}

func TestDepsCheck_Python_PipCheckConflict(t *testing.T) {
dir, _ := setupPythonDir(t, "requests\n")
c := &DepsCheck{
Dir: dir,
Stack: "python",
pipCheck: func(_ string) error { return errors.New("conflict") },
}
r := c.Run(context.Background())
if r.Status != StatusFail {
t.Errorf("expected Fail when pip check reports conflict, got %v: %s", r.Status, r.Message)
}
}

func TestDepsCheck_Python_CaseInsensitiveMatch(t *testing.T) {
// requirements.txt uses "Requests"; freeze returns "requests" — should still pass.
dir, _ := setupPythonDir(t, "Requests>=2.0\n")
c := &DepsCheck{
Dir: dir,
Stack: "python",
pipCheck: func(_ string) error { return nil },
pipFreeze: func(_ string) ([]byte, error) {
return []byte("requests==2.31.0\n"), nil
},
}
r := c.Run(context.Background())
if r.Status != StatusPass {
t.Errorf("expected Pass for case-insensitive match, got %v: %s", r.Status, r.Message)
}
}

func TestDepsCheck_Python_EditableInstall(t *testing.T) {
dir, _ := setupPythonDir(t, "mylib\n")
c := &DepsCheck{
Dir: dir,
Stack: "python",
pipCheck: func(_ string) error { return nil },
pipFreeze: func(_ string) ([]byte, error) {
return []byte("-e git+https://github.com/org/mylib.git@main#egg=mylib\n"), nil
},
}
r := c.Run(context.Background())
if r.Status != StatusPass {
t.Errorf("expected Pass for editable install, got %v: %s", r.Status, r.Message)
}
}

// setupPythonDir creates a temp dir with a .venv/bin/pip stub and a
// requirements.txt containing the given content, then returns both.
func setupPythonDir(t *testing.T, requirements string) (dir string, pipBin string) {
t.Helper()
dir = t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".venv", "bin"), 0o755); err != nil {
t.Fatalf("mkdir .venv/bin: %v", err)
}
pipBin = filepath.Join(dir, ".venv", "bin", "pip")
if err := os.WriteFile(pipBin, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write pip stub: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte(requirements), 0o644); err != nil {
t.Fatalf("write requirements.txt: %v", err)
}
return dir, pipBin
}
Loading