diff --git a/internal/check/deps.go b/internal/check/deps.go index 7360ab3..b6256c4 100644 --- a/internal/check/deps.go +++ b/internal/check/deps.go @@ -28,8 +28,9 @@ var pipCheckRunner = func(pipBin string) error { } type DepsCheck struct { - Dir string - Stack string // "node", "python", or "go" + Dir string + Stack string // "node", "python", or "go" + PackageManager string // "npm", "pnpm", or "yarn" (Node only; defaults to "npm") goCheck func(dir string) error pipFreeze func(pipBin string) ([]byte, error) pipCheck func(pipBin string) error @@ -74,11 +75,15 @@ func (c *DepsCheck) runNode() Result { Message: "node_modules directory exists", } } + pm := c.PackageManager + if pm == "" { + pm = "npm" + } return Result{ Name: c.Name(), Status: StatusFail, Message: "node_modules directory not found", - Fix: "run `npm install` or `pnpm install` to install Node dependencies", + Fix: fmt.Sprintf("run `%s install` to install Node dependencies", pm), } } diff --git a/internal/check/deps_test.go b/internal/check/deps_test.go index 2d000a6..06376a5 100644 --- a/internal/check/deps_test.go +++ b/internal/check/deps_test.go @@ -32,6 +32,42 @@ func TestDepsCheck_Node_PassAndFail(t *testing.T) { } } +func TestDepsCheck_Node_FixMessage_DefaultsToNpm(t *testing.T) { + dir := t.TempDir() + check := &DepsCheck{Dir: dir, Stack: "node"} // no PackageManager set + result := check.Run(context.Background()) + if result.Status != StatusFail { + t.Fatalf("expected fail, got %v", result.Status) + } + if !strings.Contains(result.Fix, "npm install") { + t.Errorf("expected fix to mention 'npm install', got: %s", result.Fix) + } +} + +func TestDepsCheck_Node_FixMessage_Pnpm(t *testing.T) { + dir := t.TempDir() + check := &DepsCheck{Dir: dir, Stack: "node", PackageManager: "pnpm"} + result := check.Run(context.Background()) + if result.Status != StatusFail { + t.Fatalf("expected fail, got %v", result.Status) + } + if !strings.Contains(result.Fix, "pnpm install") { + t.Errorf("expected fix to mention 'pnpm install', got: %s", result.Fix) + } +} + +func TestDepsCheck_Node_FixMessage_Yarn(t *testing.T) { + dir := t.TempDir() + check := &DepsCheck{Dir: dir, Stack: "node", PackageManager: "yarn"} + result := check.Run(context.Background()) + if result.Status != StatusFail { + t.Fatalf("expected fail, got %v", result.Status) + } + if !strings.Contains(result.Fix, "yarn install") { + t.Errorf("expected fix to mention 'yarn install', got: %s", result.Fix) + } +} + func TestDepsCheck_Python_PassAndFail(t *testing.T) { dir := t.TempDir() check := &DepsCheck{Dir: dir, Stack: "python"} diff --git a/internal/check/registry.go b/internal/check/registry.go index b9e0ca4..6ba63c6 100644 --- a/internal/check/registry.go +++ b/internal/check/registry.go @@ -19,10 +19,14 @@ func Build(stack detector.DetectedStack) []Check { cs = append(cs, &DepsCheck{Dir: ".", Stack: "go"}) } if stack.Node { + pm := stack.PackageManager + if pm == "" { + pm = "npm" + } cs = append(cs, &BinaryCheck{Binary: "node"}) - cs = append(cs, &BinaryCheck{Binary: "npm"}) + cs = append(cs, &BinaryCheck{Binary: pm}) cs = append(cs, &NodeVersionCheck{Dir: "."}) - cs = append(cs, &DepsCheck{Dir: ".", Stack: "node"}) + cs = append(cs, &DepsCheck{Dir: ".", Stack: "node", PackageManager: pm}) cs = append(cs, &GitHooksCheck{Dir: ".", Stack: "node"}) } if stack.Python { diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 0b058dd..1d9ab72 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -9,6 +9,9 @@ import ( type DetectedStack struct { Go bool Node bool + // PackageManager is the Node package manager inferred from the lockfile. + // Possible values: "npm", "pnpm", "yarn". Empty string when Node is false. + PackageManager string Python bool Java bool Maven bool @@ -27,6 +30,16 @@ func Detect(dir string) DetectedStack { stack.Go = fileExists(filepath.Join(dir, "go.mod")) stack.Node = fileExists(filepath.Join(dir, "package.json")) + if stack.Node { + switch { + case fileExists(filepath.Join(dir, "pnpm-lock.yaml")): + stack.PackageManager = "pnpm" + case fileExists(filepath.Join(dir, "yarn.lock")): + stack.PackageManager = "yarn" + default: + stack.PackageManager = "npm" + } + } stack.Python = fileExists(filepath.Join(dir, "requirements.txt")) || fileExists(filepath.Join(dir, "pyproject.toml")) stack.Maven = fileExists(filepath.Join(dir, "pom.xml")) diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go new file mode 100644 index 0000000..98b23d7 --- /dev/null +++ b/internal/detector/detector_test.go @@ -0,0 +1,71 @@ +package detector + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetect_PackageManager_npm(t *testing.T) { + dir := t.TempDir() + touch(t, filepath.Join(dir, "package.json")) + // no lockfile → defaults to npm + stack := Detect(dir) + if !stack.Node { + t.Fatal("expected Node to be detected") + } + if stack.PackageManager != "npm" { + t.Errorf("expected PackageManager=npm, got %q", stack.PackageManager) + } +} + +func TestDetect_PackageManager_pnpm(t *testing.T) { + dir := t.TempDir() + touch(t, filepath.Join(dir, "package.json")) + touch(t, filepath.Join(dir, "pnpm-lock.yaml")) + stack := Detect(dir) + if stack.PackageManager != "pnpm" { + t.Errorf("expected PackageManager=pnpm, got %q", stack.PackageManager) + } +} + +func TestDetect_PackageManager_yarn(t *testing.T) { + dir := t.TempDir() + touch(t, filepath.Join(dir, "package.json")) + touch(t, filepath.Join(dir, "yarn.lock")) + stack := Detect(dir) + if stack.PackageManager != "yarn" { + t.Errorf("expected PackageManager=yarn, got %q", stack.PackageManager) + } +} + +func TestDetect_PackageManager_pnpm_takes_priority_over_yarn(t *testing.T) { + // If somehow both lockfiles exist, pnpm wins (checked first). + dir := t.TempDir() + touch(t, filepath.Join(dir, "package.json")) + touch(t, filepath.Join(dir, "pnpm-lock.yaml")) + touch(t, filepath.Join(dir, "yarn.lock")) + stack := Detect(dir) + if stack.PackageManager != "pnpm" { + t.Errorf("expected PackageManager=pnpm when both lockfiles exist, got %q", stack.PackageManager) + } +} + +func TestDetect_PackageManager_empty_when_not_node(t *testing.T) { + dir := t.TempDir() + // no package.json + stack := Detect(dir) + if stack.Node { + t.Fatal("did not expect Node to be detected") + } + if stack.PackageManager != "" { + t.Errorf("expected PackageManager to be empty for non-Node project, got %q", stack.PackageManager) + } +} + +func touch(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatalf("touch %s: %v", path, err) + } +} \ No newline at end of file