From af5c792da8c1baa224e75632b33199a3c74135c3 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 13:13:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(lint):=20add=20pkg/detect=20+=20pkg/php=20?= =?UTF-8?q?=E2=80=94=20project=20detection=20and=20PHP=20QA=20toolchain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add project type detection (pkg/detect) and complete PHP quality assurance package (pkg/php) with formatter, analyser, audit, security, refactor, mutation testing, test runner, pipeline stages, and QA runner that builds process.RunSpec for orchestrated execution. Co-Authored-By: Virgil --- go.mod | 1 + go.sum | 3 + pkg/detect/detect.go | 36 +++++ pkg/detect/detect_test.go | 46 ++++++ pkg/php/analyse.go | 242 +++++++++++++++++++++++++++++ pkg/php/analyse_test.go | 192 +++++++++++++++++++++++ pkg/php/audit.go | 158 +++++++++++++++++++ pkg/php/audit_test.go | 232 ++++++++++++++++++++++++++++ pkg/php/format.go | 129 ++++++++++++++++ pkg/php/format_test.go | 112 ++++++++++++++ pkg/php/mutation.go | 135 ++++++++++++++++ pkg/php/mutation_test.go | 145 +++++++++++++++++ pkg/php/pipeline.go | 73 +++++++++ pkg/php/pipeline_test.go | 69 +++++++++ pkg/php/refactor.go | 104 +++++++++++++ pkg/php/refactor_test.go | 122 +++++++++++++++ pkg/php/runner.go | 214 +++++++++++++++++++++++++ pkg/php/runner_test.go | 245 +++++++++++++++++++++++++++++ pkg/php/security.go | 230 +++++++++++++++++++++++++++ pkg/php/security_test.go | 210 +++++++++++++++++++++++++ pkg/php/test.go | 191 +++++++++++++++++++++++ pkg/php/test_test.go | 317 ++++++++++++++++++++++++++++++++++++++ 22 files changed, 3206 insertions(+) create mode 100644 pkg/detect/detect.go create mode 100644 pkg/detect/detect_test.go create mode 100644 pkg/php/analyse.go create mode 100644 pkg/php/analyse_test.go create mode 100644 pkg/php/audit.go create mode 100644 pkg/php/audit_test.go create mode 100644 pkg/php/format.go create mode 100644 pkg/php/format_test.go create mode 100644 pkg/php/mutation.go create mode 100644 pkg/php/mutation_test.go create mode 100644 pkg/php/pipeline.go create mode 100644 pkg/php/pipeline_test.go create mode 100644 pkg/php/refactor.go create mode 100644 pkg/php/refactor_test.go create mode 100644 pkg/php/runner.go create mode 100644 pkg/php/runner_test.go create mode 100644 pkg/php/security.go create mode 100644 pkg/php/security_test.go create mode 100644 pkg/php/test.go create mode 100644 pkg/php/test_test.go diff --git a/go.mod b/go.mod index 1f1888a..a073f2e 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( forge.lthn.ai/core/go-devops v0.0.3 // indirect forge.lthn.ai/core/go-help v0.1.2 // indirect forge.lthn.ai/core/go-inference v0.0.2 // indirect + forge.lthn.ai/core/go-process v0.1.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect diff --git a/go.sum b/go.sum index f18fa25..ab906d5 100644 --- a/go.sum +++ b/go.sum @@ -20,7 +20,10 @@ forge.lthn.ai/core/go-io v0.0.3 h1:TlhYpGTyjPgAlbEHyYrVSeUChZPhJXcLZ7D/8IbFqfI= forge.lthn.ai/core/go-io v0.0.3/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0= forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg= forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +forge.lthn.ai/core/go-process v0.1.2 h1:0fdLJq/DPssilN9E5yude/xHNfZRKHghIjo++b5aXgc= +forge.lthn.ai/core/go-process v0.1.2/go.mod h1:9oxVALrZaZCqFe8YDdheIS5bRUV1SBz4tVW/MflAtxM= forge.lthn.ai/core/go-scm v0.0.2 h1:Ue+gS5vxZkDgTvQrqYu9QdaqEezuTV1kZY3TMqM2uho= +forge.lthn.ai/core/go-scm v0.1.0/go.mod h1:QrSFTqkBS/KgFiNrVngrY8nEwS0u41BjUAu/IEpXiRI= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/pkg/detect/detect.go b/pkg/detect/detect.go new file mode 100644 index 0000000..284dee2 --- /dev/null +++ b/pkg/detect/detect.go @@ -0,0 +1,36 @@ +// Package detect identifies project types by examining filesystem markers. +package detect + +import "os" + +// ProjectType identifies a project's language/framework. +type ProjectType string + +const ( + Go ProjectType = "go" + PHP ProjectType = "php" +) + +// IsGoProject returns true if dir contains a go.mod file. +func IsGoProject(dir string) bool { + _, err := os.Stat(dir + "/go.mod") + return err == nil +} + +// IsPHPProject returns true if dir contains a composer.json file. +func IsPHPProject(dir string) bool { + _, err := os.Stat(dir + "/composer.json") + return err == nil +} + +// DetectAll returns all detected project types in the directory. +func DetectAll(dir string) []ProjectType { + var types []ProjectType + if IsGoProject(dir) { + types = append(types, Go) + } + if IsPHPProject(dir) { + types = append(types, PHP) + } + return types +} diff --git a/pkg/detect/detect_test.go b/pkg/detect/detect_test.go new file mode 100644 index 0000000..47bdfa9 --- /dev/null +++ b/pkg/detect/detect_test.go @@ -0,0 +1,46 @@ +package detect + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsGoProject_Good(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) + assert.True(t, IsGoProject(dir)) +} + +func TestIsGoProject_Bad(t *testing.T) { + dir := t.TempDir() + assert.False(t, IsGoProject(dir)) +} + +func TestIsPHPProject_Good(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{}"), 0644) + assert.True(t, IsPHPProject(dir)) +} + +func TestIsPHPProject_Bad(t *testing.T) { + dir := t.TempDir() + assert.False(t, IsPHPProject(dir)) +} + +func TestDetectAll_Good(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) + os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{}"), 0644) + types := DetectAll(dir) + assert.Contains(t, types, Go) + assert.Contains(t, types, PHP) +} + +func TestDetectAll_Empty(t *testing.T) { + dir := t.TempDir() + types := DetectAll(dir) + assert.Empty(t, types) +} diff --git a/pkg/php/analyse.go b/pkg/php/analyse.go new file mode 100644 index 0000000..0f80332 --- /dev/null +++ b/pkg/php/analyse.go @@ -0,0 +1,242 @@ +package php + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +// AnalyseOptions configures PHP static analysis. +type AnalyseOptions struct { + // Dir is the project directory (defaults to current working directory). + Dir string + + // Level is the PHPStan analysis level (0-9). + Level int + + // Paths limits analysis to specific paths. + Paths []string + + // Memory is the memory limit for analysis (e.g., "2G"). + Memory string + + // JSON outputs results in JSON format. + JSON bool + + // SARIF outputs results in SARIF format for GitHub Security tab. + SARIF bool + + // Output is the writer for output (defaults to os.Stdout). + Output io.Writer +} + +// AnalyserType represents the detected static analyser. +type AnalyserType string + +// Static analyser type constants. +const ( + // AnalyserPHPStan indicates standard PHPStan analyser. + AnalyserPHPStan AnalyserType = "phpstan" + // AnalyserLarastan indicates Laravel-specific Larastan analyser. + AnalyserLarastan AnalyserType = "larastan" +) + +// DetectAnalyser detects which static analyser is available in the project. +func DetectAnalyser(dir string) (AnalyserType, bool) { + // Check for PHPStan config + phpstanConfig := filepath.Join(dir, "phpstan.neon") + phpstanDistConfig := filepath.Join(dir, "phpstan.neon.dist") + + hasConfig := fileExists(phpstanConfig) || fileExists(phpstanDistConfig) + + // Check for vendor binary + phpstanBin := filepath.Join(dir, "vendor", "bin", "phpstan") + hasBin := fileExists(phpstanBin) + + if hasConfig || hasBin { + // Check if it's Larastan (Laravel-specific PHPStan) + larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan") + if fileExists(larastanPath) { + return AnalyserLarastan, true + } + // Also check nunomaduro/larastan + larastanPath2 := filepath.Join(dir, "vendor", "nunomaduro", "larastan") + if fileExists(larastanPath2) { + return AnalyserLarastan, true + } + return AnalyserPHPStan, true + } + + return "", false +} + +// Analyse runs PHPStan or Larastan for static analysis. +func Analyse(ctx context.Context, opts AnalyseOptions) error { + if opts.Dir == "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + opts.Dir = cwd + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + // Check if analyser is available + analyser, found := DetectAnalyser(opts.Dir) + if !found { + return fmt.Errorf("no static analyser found (install PHPStan: composer require phpstan/phpstan --dev)") + } + + var cmdName string + var args []string + + switch analyser { + case AnalyserPHPStan, AnalyserLarastan: + cmdName, args = buildPHPStanCommand(opts) + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + return cmd.Run() +} + +// buildPHPStanCommand builds the command for running PHPStan. +func buildPHPStanCommand(opts AnalyseOptions) (string, []string) { + // Check for vendor binary first + vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpstan") + cmdName := "phpstan" + if fileExists(vendorBin) { + cmdName = vendorBin + } + + args := []string{"analyse"} + + if opts.Level > 0 { + args = append(args, "--level", fmt.Sprintf("%d", opts.Level)) + } + + if opts.Memory != "" { + args = append(args, "--memory-limit", opts.Memory) + } + + // Output format - SARIF takes precedence over JSON + if opts.SARIF { + args = append(args, "--error-format=sarif") + } else if opts.JSON { + args = append(args, "--error-format=json") + } + + // Add specific paths if provided + args = append(args, opts.Paths...) + + return cmdName, args +} + +// ============================================================================= +// Psalm Static Analysis +// ============================================================================= + +// PsalmOptions configures Psalm static analysis. +type PsalmOptions struct { + Dir string + Level int // Error level (1=strictest, 8=most lenient) + Fix bool // Auto-fix issues where possible + Baseline bool // Generate/update baseline file + ShowInfo bool // Show info-level issues + JSON bool // Output in JSON format + SARIF bool // Output in SARIF format for GitHub Security tab + Output io.Writer +} + +// PsalmType represents the detected Psalm configuration. +type PsalmType string + +// Psalm configuration type constants. +const ( + // PsalmStandard indicates standard Psalm configuration. + PsalmStandard PsalmType = "psalm" +) + +// DetectPsalm checks if Psalm is available in the project. +func DetectPsalm(dir string) (PsalmType, bool) { + // Check for psalm.xml config + psalmConfig := filepath.Join(dir, "psalm.xml") + psalmDistConfig := filepath.Join(dir, "psalm.xml.dist") + + hasConfig := fileExists(psalmConfig) || fileExists(psalmDistConfig) + + // Check for vendor binary + psalmBin := filepath.Join(dir, "vendor", "bin", "psalm") + if fileExists(psalmBin) { + return PsalmStandard, true + } + + if hasConfig { + return PsalmStandard, true + } + + return "", false +} + +// RunPsalm runs Psalm static analysis. +func RunPsalm(ctx context.Context, opts PsalmOptions) error { + if opts.Dir == "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + opts.Dir = cwd + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + // Build command + vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "psalm") + cmdName := "psalm" + if fileExists(vendorBin) { + cmdName = vendorBin + } + + args := []string{"--no-progress"} + + if opts.Level > 0 && opts.Level <= 8 { + args = append(args, fmt.Sprintf("--error-level=%d", opts.Level)) + } + + if opts.Fix { + args = append(args, "--alter", "--issues=all") + } + + if opts.Baseline { + args = append(args, "--set-baseline=psalm-baseline.xml") + } + + if opts.ShowInfo { + args = append(args, "--show-info=true") + } + + // Output format - SARIF takes precedence over JSON + if opts.SARIF { + args = append(args, "--output-format=sarif") + } else if opts.JSON { + args = append(args, "--output-format=json") + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + return cmd.Run() +} diff --git a/pkg/php/analyse_test.go b/pkg/php/analyse_test.go new file mode 100644 index 0000000..f4b656a --- /dev/null +++ b/pkg/php/analyse_test.go @@ -0,0 +1,192 @@ +package php + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mkFile creates a file (and parent directories) for testing. +func mkFile(t *testing.T, path string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("stub"), 0o755)) +} + +// ============================================================================= +// DetectAnalyser +// ============================================================================= + +func TestDetectAnalyser_Good_PHPStanConfig(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "phpstan.neon")) + + typ, found := DetectAnalyser(dir) + assert.True(t, found) + assert.Equal(t, AnalyserPHPStan, typ) +} + +func TestDetectAnalyser_Good_PHPStanDistConfig(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "phpstan.neon.dist")) + + typ, found := DetectAnalyser(dir) + assert.True(t, found) + assert.Equal(t, AnalyserPHPStan, typ) +} + +func TestDetectAnalyser_Good_PHPStanBinary(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "vendor", "bin", "phpstan")) + + typ, found := DetectAnalyser(dir) + assert.True(t, found) + assert.Equal(t, AnalyserPHPStan, typ) +} + +func TestDetectAnalyser_Good_Larastan(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "phpstan.neon")) + mkFile(t, filepath.Join(dir, "vendor", "larastan", "larastan")) + + typ, found := DetectAnalyser(dir) + assert.True(t, found) + assert.Equal(t, AnalyserLarastan, typ) +} + +func TestDetectAnalyser_Good_LarastanNunomaduro(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "vendor", "bin", "phpstan")) + mkFile(t, filepath.Join(dir, "vendor", "nunomaduro", "larastan")) + + typ, found := DetectAnalyser(dir) + assert.True(t, found) + assert.Equal(t, AnalyserLarastan, typ) +} + +func TestDetectAnalyser_Bad_NoAnalyser(t *testing.T) { + dir := t.TempDir() + + typ, found := DetectAnalyser(dir) + assert.False(t, found) + assert.Equal(t, AnalyserType(""), typ) +} + +// ============================================================================= +// DetectPsalm +// ============================================================================= + +func TestDetectPsalm_Good_PsalmConfig(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "psalm.xml")) + + typ, found := DetectPsalm(dir) + assert.True(t, found) + assert.Equal(t, PsalmStandard, typ) +} + +func TestDetectPsalm_Good_PsalmDistConfig(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "psalm.xml.dist")) + + typ, found := DetectPsalm(dir) + assert.True(t, found) + assert.Equal(t, PsalmStandard, typ) +} + +func TestDetectPsalm_Good_PsalmBinary(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "vendor", "bin", "psalm")) + + typ, found := DetectPsalm(dir) + assert.True(t, found) + assert.Equal(t, PsalmStandard, typ) +} + +func TestDetectPsalm_Bad_NoPsalm(t *testing.T) { + dir := t.TempDir() + + typ, found := DetectPsalm(dir) + assert.False(t, found) + assert.Equal(t, PsalmType(""), typ) +} + +// ============================================================================= +// buildPHPStanCommand +// ============================================================================= + +func TestBuildPHPStanCommand_Good_Defaults(t *testing.T) { + dir := t.TempDir() + opts := AnalyseOptions{Dir: dir} + + cmdName, args := buildPHPStanCommand(opts) + assert.Equal(t, "phpstan", cmdName) + assert.Equal(t, []string{"analyse"}, args) +} + +func TestBuildPHPStanCommand_Good_VendorBinary(t *testing.T) { + dir := t.TempDir() + vendorBin := filepath.Join(dir, "vendor", "bin", "phpstan") + mkFile(t, vendorBin) + + opts := AnalyseOptions{Dir: dir} + cmdName, args := buildPHPStanCommand(opts) + assert.Equal(t, vendorBin, cmdName) + assert.Equal(t, []string{"analyse"}, args) +} + +func TestBuildPHPStanCommand_Good_WithLevel(t *testing.T) { + dir := t.TempDir() + opts := AnalyseOptions{Dir: dir, Level: 5} + + _, args := buildPHPStanCommand(opts) + assert.Contains(t, args, "--level") + assert.Contains(t, args, "5") +} + +func TestBuildPHPStanCommand_Good_WithMemory(t *testing.T) { + dir := t.TempDir() + opts := AnalyseOptions{Dir: dir, Memory: "2G"} + + _, args := buildPHPStanCommand(opts) + assert.Contains(t, args, "--memory-limit") + assert.Contains(t, args, "2G") +} + +func TestBuildPHPStanCommand_Good_SARIF(t *testing.T) { + dir := t.TempDir() + opts := AnalyseOptions{Dir: dir, SARIF: true} + + _, args := buildPHPStanCommand(opts) + assert.Contains(t, args, "--error-format=sarif") +} + +func TestBuildPHPStanCommand_Good_JSON(t *testing.T) { + dir := t.TempDir() + opts := AnalyseOptions{Dir: dir, JSON: true} + + _, args := buildPHPStanCommand(opts) + assert.Contains(t, args, "--error-format=json") +} + +func TestBuildPHPStanCommand_Good_SARIFPrecedence(t *testing.T) { + dir := t.TempDir() + opts := AnalyseOptions{Dir: dir, SARIF: true, JSON: true} + + _, args := buildPHPStanCommand(opts) + assert.Contains(t, args, "--error-format=sarif") + assert.NotContains(t, args, "--error-format=json") +} + +func TestBuildPHPStanCommand_Good_WithPaths(t *testing.T) { + dir := t.TempDir() + opts := AnalyseOptions{Dir: dir, Paths: []string{"src", "app"}} + + _, args := buildPHPStanCommand(opts) + assert.Contains(t, args, "src") + assert.Contains(t, args, "app") +} + diff --git a/pkg/php/audit.go b/pkg/php/audit.go new file mode 100644 index 0000000..8136747 --- /dev/null +++ b/pkg/php/audit.go @@ -0,0 +1,158 @@ +package php + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +// AuditOptions configures dependency security auditing. +type AuditOptions struct { + Dir string + JSON bool // Output in JSON format + Fix bool // Auto-fix vulnerabilities (npm only) + Output io.Writer +} + +// AuditResult holds the results of a security audit. +type AuditResult struct { + Tool string + Vulnerabilities int + Advisories []AuditAdvisory + Error error +} + +// AuditAdvisory represents a single security advisory. +type AuditAdvisory struct { + Package string + Severity string + Title string + URL string + Identifiers []string +} + +// RunAudit runs security audits on dependencies. +func RunAudit(ctx context.Context, opts AuditOptions) ([]AuditResult, error) { + if opts.Dir == "" { + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("get working directory: %w", err) + } + opts.Dir = cwd + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + var results []AuditResult + + // Run composer audit + composerResult := runComposerAudit(ctx, opts) + results = append(results, composerResult) + + // Run npm audit if package.json exists + if fileExists(filepath.Join(opts.Dir, "package.json")) { + npmResult := runNpmAudit(ctx, opts) + results = append(results, npmResult) + } + + return results, nil +} + +func runComposerAudit(ctx context.Context, opts AuditOptions) AuditResult { + result := AuditResult{Tool: "composer"} + + args := []string{"audit", "--format=json"} + + cmd := exec.CommandContext(ctx, "composer", args...) + cmd.Dir = opts.Dir + + output, err := cmd.Output() + if err != nil { + // composer audit returns non-zero if vulnerabilities found + if exitErr, ok := err.(*exec.ExitError); ok { + output = append(output, exitErr.Stderr...) + } + } + + // Parse JSON output + var auditData struct { + Advisories map[string][]struct { + Title string `json:"title"` + Link string `json:"link"` + CVE string `json:"cve"` + AffectedRanges string `json:"affectedVersions"` + } `json:"advisories"` + } + + if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil { + for pkg, advisories := range auditData.Advisories { + for _, adv := range advisories { + result.Advisories = append(result.Advisories, AuditAdvisory{ + Package: pkg, + Title: adv.Title, + URL: adv.Link, + Identifiers: []string{adv.CVE}, + }) + } + } + result.Vulnerabilities = len(result.Advisories) + } else if err != nil { + result.Error = err + } + + return result +} + +func runNpmAudit(ctx context.Context, opts AuditOptions) AuditResult { + result := AuditResult{Tool: "npm"} + + args := []string{"audit", "--json"} + if opts.Fix { + args = []string{"audit", "fix"} + } + + cmd := exec.CommandContext(ctx, "npm", args...) + cmd.Dir = opts.Dir + + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + output = append(output, exitErr.Stderr...) + } + } + + if !opts.Fix { + // Parse JSON output + var auditData struct { + Metadata struct { + Vulnerabilities struct { + Total int `json:"total"` + } `json:"vulnerabilities"` + } `json:"metadata"` + Vulnerabilities map[string]struct { + Severity string `json:"severity"` + Via []any `json:"via"` + } `json:"vulnerabilities"` + } + + if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil { + result.Vulnerabilities = auditData.Metadata.Vulnerabilities.Total + for pkg, vuln := range auditData.Vulnerabilities { + result.Advisories = append(result.Advisories, AuditAdvisory{ + Package: pkg, + Severity: vuln.Severity, + }) + } + } else if err != nil { + result.Error = err + } + } + + return result +} diff --git a/pkg/php/audit_test.go b/pkg/php/audit_test.go new file mode 100644 index 0000000..bf1759f --- /dev/null +++ b/pkg/php/audit_test.go @@ -0,0 +1,232 @@ +package php + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuditResult_Fields(t *testing.T) { + result := AuditResult{ + Tool: "composer", + Vulnerabilities: 2, + Advisories: []AuditAdvisory{ + {Package: "vendor/pkg", Severity: "high", Title: "RCE", URL: "https://example.com/1", Identifiers: []string{"CVE-2025-0001"}}, + {Package: "vendor/other", Severity: "medium", Title: "XSS", URL: "https://example.com/2", Identifiers: []string{"CVE-2025-0002"}}, + }, + } + + assert.Equal(t, "composer", result.Tool) + assert.Equal(t, 2, result.Vulnerabilities) + assert.Len(t, result.Advisories, 2) + assert.Equal(t, "vendor/pkg", result.Advisories[0].Package) + assert.Equal(t, "high", result.Advisories[0].Severity) + assert.Equal(t, "RCE", result.Advisories[0].Title) + assert.Equal(t, "https://example.com/1", result.Advisories[0].URL) + assert.Equal(t, []string{"CVE-2025-0001"}, result.Advisories[0].Identifiers) +} + +func TestAuditAdvisory_Fields(t *testing.T) { + adv := AuditAdvisory{ + Package: "laravel/framework", + Severity: "critical", + Title: "SQL Injection", + URL: "https://example.com/advisory", + Identifiers: []string{"CVE-2025-9999", "GHSA-xxxx"}, + } + + assert.Equal(t, "laravel/framework", adv.Package) + assert.Equal(t, "critical", adv.Severity) + assert.Equal(t, "SQL Injection", adv.Title) + assert.Equal(t, "https://example.com/advisory", adv.URL) + assert.Equal(t, []string{"CVE-2025-9999", "GHSA-xxxx"}, adv.Identifiers) +} + +func TestRunComposerAudit_ParsesJSON(t *testing.T) { + // Test the JSON parsing of composer audit output by verifying + // the struct can be populated from JSON matching composer's format. + composerOutput := `{ + "advisories": { + "vendor/package-a": [ + { + "title": "Remote Code Execution", + "link": "https://example.com/advisory/1", + "cve": "CVE-2025-1234", + "affectedVersions": ">=1.0,<1.5" + } + ], + "vendor/package-b": [ + { + "title": "Cross-Site Scripting", + "link": "https://example.com/advisory/2", + "cve": "CVE-2025-5678", + "affectedVersions": ">=2.0,<2.3" + }, + { + "title": "Open Redirect", + "link": "https://example.com/advisory/3", + "cve": "CVE-2025-9012", + "affectedVersions": ">=2.0,<2.1" + } + ] + } + }` + + var auditData struct { + Advisories map[string][]struct { + Title string `json:"title"` + Link string `json:"link"` + CVE string `json:"cve"` + AffectedRanges string `json:"affectedVersions"` + } `json:"advisories"` + } + + err := json.Unmarshal([]byte(composerOutput), &auditData) + require.NoError(t, err) + + // Simulate the same parsing logic as runComposerAudit + result := AuditResult{Tool: "composer"} + for pkg, advisories := range auditData.Advisories { + for _, adv := range advisories { + result.Advisories = append(result.Advisories, AuditAdvisory{ + Package: pkg, + Title: adv.Title, + URL: adv.Link, + Identifiers: []string{adv.CVE}, + }) + } + } + result.Vulnerabilities = len(result.Advisories) + + assert.Equal(t, "composer", result.Tool) + assert.Equal(t, 3, result.Vulnerabilities) + assert.Len(t, result.Advisories, 3) + + // Build a map of advisories by package for deterministic assertions + byPkg := make(map[string][]AuditAdvisory) + for _, a := range result.Advisories { + byPkg[a.Package] = append(byPkg[a.Package], a) + } + + assert.Len(t, byPkg["vendor/package-a"], 1) + assert.Equal(t, "Remote Code Execution", byPkg["vendor/package-a"][0].Title) + assert.Equal(t, "https://example.com/advisory/1", byPkg["vendor/package-a"][0].URL) + assert.Equal(t, []string{"CVE-2025-1234"}, byPkg["vendor/package-a"][0].Identifiers) + + assert.Len(t, byPkg["vendor/package-b"], 2) +} + +func TestNpmAuditJSON_ParsesCorrectly(t *testing.T) { + // Test npm audit JSON parsing logic + npmOutput := `{ + "metadata": { + "vulnerabilities": { + "total": 2 + } + }, + "vulnerabilities": { + "lodash": { + "severity": "high", + "via": ["prototype pollution"] + }, + "minimist": { + "severity": "low", + "via": ["prototype pollution"] + } + } + }` + + var auditData struct { + Metadata struct { + Vulnerabilities struct { + Total int `json:"total"` + } `json:"vulnerabilities"` + } `json:"metadata"` + Vulnerabilities map[string]struct { + Severity string `json:"severity"` + Via []any `json:"via"` + } `json:"vulnerabilities"` + } + + err := json.Unmarshal([]byte(npmOutput), &auditData) + require.NoError(t, err) + + result := AuditResult{Tool: "npm"} + result.Vulnerabilities = auditData.Metadata.Vulnerabilities.Total + for pkg, vuln := range auditData.Vulnerabilities { + result.Advisories = append(result.Advisories, AuditAdvisory{ + Package: pkg, + Severity: vuln.Severity, + }) + } + + assert.Equal(t, "npm", result.Tool) + assert.Equal(t, 2, result.Vulnerabilities) + assert.Len(t, result.Advisories, 2) + + // Build map for deterministic assertions + byPkg := make(map[string]AuditAdvisory) + for _, a := range result.Advisories { + byPkg[a.Package] = a + } + + assert.Equal(t, "high", byPkg["lodash"].Severity) + assert.Equal(t, "low", byPkg["minimist"].Severity) +} + +func TestRunAudit_SkipsNpmWithoutPackageJSON(t *testing.T) { + // Create a temp directory with no package.json + dir := t.TempDir() + + // RunAudit should only return composer result (npm skipped) + // Note: composer will fail since it's not installed in the test env, + // but the important thing is npm audit is NOT run + results, err := RunAudit(context.Background(), AuditOptions{ + Dir: dir, + Output: os.Stdout, + }) + + // No error from RunAudit itself (individual tool errors are in AuditResult.Error) + assert.NoError(t, err) + assert.Len(t, results, 1, "should only have composer result when no package.json") + assert.Equal(t, "composer", results[0].Tool) +} + +func TestRunAudit_IncludesNpmWithPackageJSON(t *testing.T) { + // Create a temp directory with a package.json + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"test"}`), 0644) + require.NoError(t, err) + + results, runErr := RunAudit(context.Background(), AuditOptions{ + Dir: dir, + Output: os.Stdout, + }) + + // No error from RunAudit itself + assert.NoError(t, runErr) + assert.Len(t, results, 2, "should have both composer and npm results when package.json exists") + assert.Equal(t, "composer", results[0].Tool) + assert.Equal(t, "npm", results[1].Tool) +} + +func TestAuditOptions_Defaults(t *testing.T) { + opts := AuditOptions{} + assert.Empty(t, opts.Dir) + assert.False(t, opts.JSON) + assert.False(t, opts.Fix) + assert.Nil(t, opts.Output) +} + +func TestAuditResult_ZeroValue(t *testing.T) { + result := AuditResult{} + assert.Empty(t, result.Tool) + assert.Equal(t, 0, result.Vulnerabilities) + assert.Nil(t, result.Advisories) + assert.NoError(t, result.Error) +} diff --git a/pkg/php/format.go b/pkg/php/format.go new file mode 100644 index 0000000..d11a47f --- /dev/null +++ b/pkg/php/format.go @@ -0,0 +1,129 @@ +// Package php provides linting and quality tools for PHP projects. +package php + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +// fileExists reports whether the named file or directory exists. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// FormatOptions configures PHP code formatting. +type FormatOptions struct { + // Dir is the project directory (defaults to current working directory). + Dir string + + // Fix automatically fixes formatting issues. + Fix bool + + // Diff shows a diff of changes instead of modifying files. + Diff bool + + // JSON outputs results in JSON format. + JSON bool + + // Paths limits formatting to specific paths. + Paths []string + + // Output is the writer for output (defaults to os.Stdout). + Output io.Writer +} + +// FormatterType represents the detected formatter. +type FormatterType string + +// Formatter type constants. +const ( + // FormatterPint indicates Laravel Pint code formatter. + FormatterPint FormatterType = "pint" +) + +// DetectFormatter detects which formatter is available in the project. +func DetectFormatter(dir string) (FormatterType, bool) { + // Check for Pint config + pintConfig := filepath.Join(dir, "pint.json") + if fileExists(pintConfig) { + return FormatterPint, true + } + + // Check for vendor binary + pintBin := filepath.Join(dir, "vendor", "bin", "pint") + if fileExists(pintBin) { + return FormatterPint, true + } + + return "", false +} + +// Format runs Laravel Pint to format PHP code. +func Format(ctx context.Context, opts FormatOptions) error { + if opts.Dir == "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + opts.Dir = cwd + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + // Check if formatter is available + formatter, found := DetectFormatter(opts.Dir) + if !found { + return fmt.Errorf("no formatter found (install Laravel Pint: composer require laravel/pint --dev)") + } + + var cmdName string + var args []string + + switch formatter { + case FormatterPint: + cmdName, args = buildPintCommand(opts) + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + return cmd.Run() +} + +// buildPintCommand builds the command for running Laravel Pint. +func buildPintCommand(opts FormatOptions) (string, []string) { + // Check for vendor binary first + vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pint") + cmdName := "pint" + if fileExists(vendorBin) { + cmdName = vendorBin + } + + var args []string + + if !opts.Fix { + args = append(args, "--test") + } + + if opts.Diff { + args = append(args, "--diff") + } + + if opts.JSON { + args = append(args, "--format=json") + } + + // Add specific paths if provided + args = append(args, opts.Paths...) + + return cmdName, args +} diff --git a/pkg/php/format_test.go b/pkg/php/format_test.go new file mode 100644 index 0000000..7f74749 --- /dev/null +++ b/pkg/php/format_test.go @@ -0,0 +1,112 @@ +package php + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectFormatter_PintConfig(t *testing.T) { + dir := t.TempDir() + + // Create pint.json + err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644) + require.NoError(t, err) + + ft, found := DetectFormatter(dir) + assert.True(t, found) + assert.Equal(t, FormatterPint, ft) +} + +func TestDetectFormatter_VendorBinary(t *testing.T) { + dir := t.TempDir() + + // Create vendor/bin/pint + binDir := filepath.Join(dir, "vendor", "bin") + err := os.MkdirAll(binDir, 0755) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(binDir, "pint"), []byte("#!/bin/sh\n"), 0755) + require.NoError(t, err) + + ft, found := DetectFormatter(dir) + assert.True(t, found) + assert.Equal(t, FormatterPint, ft) +} + +func TestDetectFormatter_Empty(t *testing.T) { + dir := t.TempDir() + + ft, found := DetectFormatter(dir) + assert.False(t, found) + assert.Equal(t, FormatterType(""), ft) +} + +func TestBuildPintCommand_Defaults(t *testing.T) { + dir := t.TempDir() + + opts := FormatOptions{Dir: dir} + cmdName, args := buildPintCommand(opts) + + // No vendor binary, so fallback to bare "pint" + assert.Equal(t, "pint", cmdName) + // Fix is false by default, so --test should be present + assert.Contains(t, args, "--test") +} + +func TestBuildPintCommand_Fix(t *testing.T) { + dir := t.TempDir() + + opts := FormatOptions{Dir: dir, Fix: true} + cmdName, args := buildPintCommand(opts) + + assert.Equal(t, "pint", cmdName) + assert.NotContains(t, args, "--test") +} + +func TestBuildPintCommand_VendorBinary(t *testing.T) { + dir := t.TempDir() + + binDir := filepath.Join(dir, "vendor", "bin") + require.NoError(t, os.MkdirAll(binDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(binDir, "pint"), []byte("#!/bin/sh\n"), 0755)) + + opts := FormatOptions{Dir: dir, Fix: true} + cmdName, _ := buildPintCommand(opts) + + assert.Equal(t, filepath.Join(dir, "vendor", "bin", "pint"), cmdName) +} + +func TestBuildPintCommand_AllFlags(t *testing.T) { + dir := t.TempDir() + + opts := FormatOptions{ + Dir: dir, + Fix: false, + Diff: true, + JSON: true, + Paths: []string{"src/", "tests/"}, + } + _, args := buildPintCommand(opts) + + assert.Contains(t, args, "--test") + assert.Contains(t, args, "--diff") + assert.Contains(t, args, "--format=json") + assert.Contains(t, args, "src/") + assert.Contains(t, args, "tests/") +} + +func TestFileExists(t *testing.T) { + dir := t.TempDir() + + // Existing file + f := filepath.Join(dir, "exists.txt") + require.NoError(t, os.WriteFile(f, []byte("hi"), 0644)) + assert.True(t, fileExists(f)) + + // Non-existent file + assert.False(t, fileExists(filepath.Join(dir, "nope.txt"))) +} diff --git a/pkg/php/mutation.go b/pkg/php/mutation.go new file mode 100644 index 0000000..21c237f --- /dev/null +++ b/pkg/php/mutation.go @@ -0,0 +1,135 @@ +package php + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +// InfectionOptions configures Infection mutation testing. +type InfectionOptions struct { + Dir string + MinMSI int // Minimum mutation score indicator (0-100) + MinCoveredMSI int // Minimum covered mutation score (0-100) + Threads int // Number of parallel threads + Filter string // Filter files by pattern + OnlyCovered bool // Only mutate covered code + Output io.Writer +} + +// DetectInfection checks if Infection is available in the project. +func DetectInfection(dir string) bool { + // Check for infection config files + configs := []string{"infection.json", "infection.json5", "infection.json.dist"} + for _, config := range configs { + if fileExists(filepath.Join(dir, config)) { + return true + } + } + + // Check for vendor binary + infectionBin := filepath.Join(dir, "vendor", "bin", "infection") + if fileExists(infectionBin) { + return true + } + + return false +} + +// RunInfection runs Infection mutation testing. +func RunInfection(ctx context.Context, opts InfectionOptions) error { + if opts.Dir == "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + opts.Dir = cwd + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + // Build command + vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "infection") + cmdName := "infection" + if fileExists(vendorBin) { + cmdName = vendorBin + } + + var args []string + + // Set defaults + minMSI := opts.MinMSI + if minMSI == 0 { + minMSI = 50 + } + minCoveredMSI := opts.MinCoveredMSI + if minCoveredMSI == 0 { + minCoveredMSI = 70 + } + threads := opts.Threads + if threads == 0 { + threads = 4 + } + + args = append(args, fmt.Sprintf("--min-msi=%d", minMSI)) + args = append(args, fmt.Sprintf("--min-covered-msi=%d", minCoveredMSI)) + args = append(args, fmt.Sprintf("--threads=%d", threads)) + + if opts.Filter != "" { + args = append(args, "--filter="+opts.Filter) + } + + if opts.OnlyCovered { + args = append(args, "--only-covered") + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + return cmd.Run() +} + +// buildInfectionCommand builds the command for running Infection (exported for testing). +func buildInfectionCommand(opts InfectionOptions) (string, []string) { + vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "infection") + cmdName := "infection" + if fileExists(vendorBin) { + cmdName = vendorBin + } + + var args []string + + minMSI := opts.MinMSI + if minMSI == 0 { + minMSI = 50 + } + minCoveredMSI := opts.MinCoveredMSI + if minCoveredMSI == 0 { + minCoveredMSI = 70 + } + threads := opts.Threads + if threads == 0 { + threads = 4 + } + + args = append(args, fmt.Sprintf("--min-msi=%d", minMSI)) + args = append(args, fmt.Sprintf("--min-covered-msi=%d", minCoveredMSI)) + args = append(args, fmt.Sprintf("--threads=%d", threads)) + + if opts.Filter != "" { + args = append(args, "--filter="+opts.Filter) + } + + if opts.OnlyCovered { + args = append(args, "--only-covered") + } + + return cmdName, args +} diff --git a/pkg/php/mutation_test.go b/pkg/php/mutation_test.go new file mode 100644 index 0000000..1534a29 --- /dev/null +++ b/pkg/php/mutation_test.go @@ -0,0 +1,145 @@ +package php + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================= +// DetectInfection +// ============================================================================= + +func TestDetectInfection_Good_InfectionJSON(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "infection.json")) + + assert.True(t, DetectInfection(dir)) +} + +func TestDetectInfection_Good_InfectionJSON5(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "infection.json5")) + + assert.True(t, DetectInfection(dir)) +} + +func TestDetectInfection_Good_InfectionJSONDist(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "infection.json.dist")) + + assert.True(t, DetectInfection(dir)) +} + +func TestDetectInfection_Good_VendorBinary(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "vendor", "bin", "infection")) + + assert.True(t, DetectInfection(dir)) +} + +func TestDetectInfection_Bad_Empty(t *testing.T) { + dir := t.TempDir() + + assert.False(t, DetectInfection(dir)) +} + +// ============================================================================= +// buildInfectionCommand +// ============================================================================= + +func TestBuildInfectionCommand_Good_Defaults(t *testing.T) { + dir := t.TempDir() + opts := InfectionOptions{Dir: dir} + + cmdName, args := buildInfectionCommand(opts) + assert.Equal(t, "infection", cmdName) + // Defaults: minMSI=50, minCoveredMSI=70, threads=4 + assert.Contains(t, args, "--min-msi=50") + assert.Contains(t, args, "--min-covered-msi=70") + assert.Contains(t, args, "--threads=4") +} + +func TestBuildInfectionCommand_Good_CustomThresholds(t *testing.T) { + dir := t.TempDir() + opts := InfectionOptions{ + Dir: dir, + MinMSI: 80, + MinCoveredMSI: 90, + Threads: 8, + } + + _, args := buildInfectionCommand(opts) + assert.Contains(t, args, "--min-msi=80") + assert.Contains(t, args, "--min-covered-msi=90") + assert.Contains(t, args, "--threads=8") +} + +func TestBuildInfectionCommand_Good_VendorBinary(t *testing.T) { + dir := t.TempDir() + vendorBin := filepath.Join(dir, "vendor", "bin", "infection") + mkFile(t, vendorBin) + + opts := InfectionOptions{Dir: dir} + cmdName, _ := buildInfectionCommand(opts) + assert.Equal(t, vendorBin, cmdName) +} + +func TestBuildInfectionCommand_Good_Filter(t *testing.T) { + dir := t.TempDir() + opts := InfectionOptions{Dir: dir, Filter: "src/Models"} + + _, args := buildInfectionCommand(opts) + assert.Contains(t, args, "--filter=src/Models") +} + +func TestBuildInfectionCommand_Good_OnlyCovered(t *testing.T) { + dir := t.TempDir() + opts := InfectionOptions{Dir: dir, OnlyCovered: true} + + _, args := buildInfectionCommand(opts) + assert.Contains(t, args, "--only-covered") +} + +func TestBuildInfectionCommand_Good_AllFlags(t *testing.T) { + dir := t.TempDir() + opts := InfectionOptions{ + Dir: dir, + MinMSI: 60, + MinCoveredMSI: 80, + Threads: 2, + Filter: "app/", + OnlyCovered: true, + } + + _, args := buildInfectionCommand(opts) + assert.Contains(t, args, "--min-msi=60") + assert.Contains(t, args, "--min-covered-msi=80") + assert.Contains(t, args, "--threads=2") + assert.Contains(t, args, "--filter=app/") + assert.Contains(t, args, "--only-covered") +} + +func TestInfectionOptions_Defaults(t *testing.T) { + opts := InfectionOptions{} + assert.Empty(t, opts.Dir) + assert.Equal(t, 0, opts.MinMSI) + assert.Equal(t, 0, opts.MinCoveredMSI) + assert.Equal(t, 0, opts.Threads) + assert.Empty(t, opts.Filter) + assert.False(t, opts.OnlyCovered) + assert.Nil(t, opts.Output) +} + +func TestDetectInfection_Good_BothConfigAndBinary(t *testing.T) { + dir := t.TempDir() + + // Create both config and vendor binary + require.NoError(t, os.WriteFile(filepath.Join(dir, "infection.json5"), []byte("{}"), 0644)) + mkFile(t, filepath.Join(dir, "vendor", "bin", "infection")) + + assert.True(t, DetectInfection(dir)) +} diff --git a/pkg/php/pipeline.go b/pkg/php/pipeline.go new file mode 100644 index 0000000..1d84fc6 --- /dev/null +++ b/pkg/php/pipeline.go @@ -0,0 +1,73 @@ +package php + +// QAOptions configures the full QA pipeline. +type QAOptions struct { + Dir string + Quick bool // Only run quick checks + Full bool // Run all stages including slow checks + Fix bool // Auto-fix issues where possible + JSON bool // Output results as JSON +} + +// QAStage represents a stage in the QA pipeline. +type QAStage string + +const ( + QAStageQuick QAStage = "quick" // fast checks: audit, fmt, stan + QAStageStandard QAStage = "standard" // standard checks + tests + QAStageFull QAStage = "full" // all including slow scans +) + +// QACheckResult holds the result of a single QA check. +type QACheckResult struct { + Name string + Stage QAStage + Passed bool + Duration string + Error error + Output string +} + +// QAResult holds the results of the full QA pipeline. +type QAResult struct { + Stages []QAStage + Checks []QACheckResult + Passed bool + Summary string +} + +// GetQAStages returns the stages to run based on options. +func GetQAStages(opts QAOptions) []QAStage { + if opts.Quick { + return []QAStage{QAStageQuick} + } + if opts.Full { + return []QAStage{QAStageQuick, QAStageStandard, QAStageFull} + } + return []QAStage{QAStageQuick, QAStageStandard} +} + +// GetQAChecks returns the checks for a given stage. +func GetQAChecks(dir string, stage QAStage) []string { + switch stage { + case QAStageQuick: + return []string{"audit", "fmt", "stan"} + case QAStageStandard: + checks := []string{} + if _, found := DetectPsalm(dir); found { + checks = append(checks, "psalm") + } + checks = append(checks, "test") + return checks + case QAStageFull: + checks := []string{} + if DetectRector(dir) { + checks = append(checks, "rector") + } + if DetectInfection(dir) { + checks = append(checks, "infection") + } + return checks + } + return nil +} diff --git a/pkg/php/pipeline_test.go b/pkg/php/pipeline_test.go new file mode 100644 index 0000000..f302b63 --- /dev/null +++ b/pkg/php/pipeline_test.go @@ -0,0 +1,69 @@ +package php + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetQAStages_Default(t *testing.T) { + stages := GetQAStages(QAOptions{}) + assert.Equal(t, []QAStage{QAStageQuick, QAStageStandard}, stages) +} + +func TestGetQAStages_Quick(t *testing.T) { + stages := GetQAStages(QAOptions{Quick: true}) + assert.Equal(t, []QAStage{QAStageQuick}, stages) +} + +func TestGetQAStages_Full(t *testing.T) { + stages := GetQAStages(QAOptions{Full: true}) + assert.Equal(t, []QAStage{QAStageQuick, QAStageStandard, QAStageFull}, stages) +} + +func TestGetQAChecks_Quick(t *testing.T) { + dir := t.TempDir() + checks := GetQAChecks(dir, QAStageQuick) + assert.Equal(t, []string{"audit", "fmt", "stan"}, checks) +} + +func TestGetQAChecks_Standard_NoPsalm(t *testing.T) { + dir := t.TempDir() + checks := GetQAChecks(dir, QAStageStandard) + assert.Equal(t, []string{"test"}, checks) +} + +func TestGetQAChecks_Standard_WithPsalm(t *testing.T) { + dir := t.TempDir() + // Create vendor/bin/psalm + vendorBin := filepath.Join(dir, "vendor", "bin") + os.MkdirAll(vendorBin, 0755) + os.WriteFile(filepath.Join(vendorBin, "psalm"), []byte("#!/bin/sh"), 0755) + checks := GetQAChecks(dir, QAStageStandard) + assert.Contains(t, checks, "psalm") + assert.Contains(t, checks, "test") +} + +func TestGetQAChecks_Full_NothingDetected(t *testing.T) { + dir := t.TempDir() + checks := GetQAChecks(dir, QAStageFull) + assert.Empty(t, checks) +} + +func TestGetQAChecks_Full_WithRectorAndInfection(t *testing.T) { + dir := t.TempDir() + vendorBin := filepath.Join(dir, "vendor", "bin") + os.MkdirAll(vendorBin, 0755) + os.WriteFile(filepath.Join(vendorBin, "rector"), []byte("#!/bin/sh"), 0755) + os.WriteFile(filepath.Join(vendorBin, "infection"), []byte("#!/bin/sh"), 0755) + checks := GetQAChecks(dir, QAStageFull) + assert.Contains(t, checks, "rector") + assert.Contains(t, checks, "infection") +} + +func TestGetQAChecks_InvalidStage(t *testing.T) { + checks := GetQAChecks(t.TempDir(), QAStage("invalid")) + assert.Nil(t, checks) +} diff --git a/pkg/php/refactor.go b/pkg/php/refactor.go new file mode 100644 index 0000000..14f9de5 --- /dev/null +++ b/pkg/php/refactor.go @@ -0,0 +1,104 @@ +package php + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +// RectorOptions configures Rector code refactoring. +type RectorOptions struct { + Dir string + Fix bool // Apply changes (default is dry-run) + Diff bool // Show detailed diff + ClearCache bool // Clear cache before running + Output io.Writer +} + +// DetectRector checks if Rector is available in the project. +func DetectRector(dir string) bool { + // Check for rector.php config + rectorConfig := filepath.Join(dir, "rector.php") + if fileExists(rectorConfig) { + return true + } + + // Check for vendor binary + rectorBin := filepath.Join(dir, "vendor", "bin", "rector") + if fileExists(rectorBin) { + return true + } + + return false +} + +// RunRector runs Rector for automated code refactoring. +func RunRector(ctx context.Context, opts RectorOptions) error { + if opts.Dir == "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + opts.Dir = cwd + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + // Build command + vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "rector") + cmdName := "rector" + if fileExists(vendorBin) { + cmdName = vendorBin + } + + args := []string{"process"} + + if !opts.Fix { + args = append(args, "--dry-run") + } + + if opts.Diff { + args = append(args, "--output-format", "diff") + } + + if opts.ClearCache { + args = append(args, "--clear-cache") + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + + return cmd.Run() +} + +// buildRectorCommand builds the command for running Rector (exported for testing). +func buildRectorCommand(opts RectorOptions) (string, []string) { + vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "rector") + cmdName := "rector" + if fileExists(vendorBin) { + cmdName = vendorBin + } + + args := []string{"process"} + + if !opts.Fix { + args = append(args, "--dry-run") + } + + if opts.Diff { + args = append(args, "--output-format", "diff") + } + + if opts.ClearCache { + args = append(args, "--clear-cache") + } + + return cmdName, args +} diff --git a/pkg/php/refactor_test.go b/pkg/php/refactor_test.go new file mode 100644 index 0000000..62f2896 --- /dev/null +++ b/pkg/php/refactor_test.go @@ -0,0 +1,122 @@ +package php + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================= +// DetectRector +// ============================================================================= + +func TestDetectRector_Good_RectorConfig(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "rector.php")) + + assert.True(t, DetectRector(dir)) +} + +func TestDetectRector_Good_VendorBinary(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "vendor", "bin", "rector")) + + assert.True(t, DetectRector(dir)) +} + +func TestDetectRector_Bad_Empty(t *testing.T) { + dir := t.TempDir() + + assert.False(t, DetectRector(dir)) +} + +// ============================================================================= +// buildRectorCommand +// ============================================================================= + +func TestBuildRectorCommand_Good_Defaults(t *testing.T) { + dir := t.TempDir() + opts := RectorOptions{Dir: dir} + + cmdName, args := buildRectorCommand(opts) + assert.Equal(t, "rector", cmdName) + // Fix is false by default, so --dry-run should be present + assert.Contains(t, args, "process") + assert.Contains(t, args, "--dry-run") +} + +func TestBuildRectorCommand_Good_Fix(t *testing.T) { + dir := t.TempDir() + opts := RectorOptions{Dir: dir, Fix: true} + + cmdName, args := buildRectorCommand(opts) + assert.Equal(t, "rector", cmdName) + assert.Contains(t, args, "process") + assert.NotContains(t, args, "--dry-run") +} + +func TestBuildRectorCommand_Good_VendorBinary(t *testing.T) { + dir := t.TempDir() + vendorBin := filepath.Join(dir, "vendor", "bin", "rector") + mkFile(t, vendorBin) + + opts := RectorOptions{Dir: dir} + cmdName, _ := buildRectorCommand(opts) + assert.Equal(t, vendorBin, cmdName) +} + +func TestBuildRectorCommand_Good_Diff(t *testing.T) { + dir := t.TempDir() + opts := RectorOptions{Dir: dir, Diff: true} + + _, args := buildRectorCommand(opts) + assert.Contains(t, args, "--output-format") + assert.Contains(t, args, "diff") +} + +func TestBuildRectorCommand_Good_ClearCache(t *testing.T) { + dir := t.TempDir() + opts := RectorOptions{Dir: dir, ClearCache: true} + + _, args := buildRectorCommand(opts) + assert.Contains(t, args, "--clear-cache") +} + +func TestBuildRectorCommand_Good_AllFlags(t *testing.T) { + dir := t.TempDir() + opts := RectorOptions{ + Dir: dir, + Fix: true, + Diff: true, + ClearCache: true, + } + + _, args := buildRectorCommand(opts) + assert.Contains(t, args, "process") + assert.NotContains(t, args, "--dry-run") + assert.Contains(t, args, "--output-format") + assert.Contains(t, args, "diff") + assert.Contains(t, args, "--clear-cache") +} + +func TestRectorOptions_Defaults(t *testing.T) { + opts := RectorOptions{} + assert.Empty(t, opts.Dir) + assert.False(t, opts.Fix) + assert.False(t, opts.Diff) + assert.False(t, opts.ClearCache) + assert.Nil(t, opts.Output) +} + +func TestDetectRector_Good_BothConfigAndBinary(t *testing.T) { + dir := t.TempDir() + + // Create both config and vendor binary + require.NoError(t, os.WriteFile(filepath.Join(dir, "rector.php"), []byte("= 32, + CWE: "CWE-321", + } + if !check.Passed { + check.Message = "Missing or weak encryption key" + check.Fix = "Run: php artisan key:generate" + } + checks = append(checks, check) + } + + // Check APP_URL for HTTPS + if url, ok := envMap["APP_URL"]; ok { + check := SecurityCheck{ + ID: "https_enforced", + Name: "HTTPS Enforced", + Description: "APP_URL should use HTTPS in production", + Severity: "high", + Passed: strings.HasPrefix(url, "https://"), + CWE: "CWE-319", + } + if !check.Passed { + check.Message = "Application not using HTTPS" + check.Fix = "Update APP_URL to use https://" + } + checks = append(checks, check) + } + + return checks +} + +func runFilesystemSecurityChecks(dir string) []SecurityCheck { + var checks []SecurityCheck + + // Check .env not in public + publicEnvPaths := []string{"public/.env", "public_html/.env"} + for _, path := range publicEnvPaths { + fullPath := filepath.Join(dir, path) + if fileExists(fullPath) { + checks = append(checks, SecurityCheck{ + ID: "env_not_public", + Name: ".env Not Publicly Accessible", + Description: ".env file should not be in public directory", + Severity: "critical", + Passed: false, + Message: "Environment file exposed to web at " + path, + CWE: "CWE-538", + }) + } + } + + // Check .git not in public + publicGitPaths := []string{"public/.git", "public_html/.git"} + for _, path := range publicGitPaths { + fullPath := filepath.Join(dir, path) + if fileExists(fullPath) { + checks = append(checks, SecurityCheck{ + ID: "git_not_public", + Name: ".git Not Publicly Accessible", + Description: ".git directory should not be in public", + Severity: "critical", + Passed: false, + Message: "Git repository exposed to web (source code leak)", + CWE: "CWE-538", + }) + } + } + + return checks +} diff --git a/pkg/php/security_test.go b/pkg/php/security_test.go new file mode 100644 index 0000000..5dee9ed --- /dev/null +++ b/pkg/php/security_test.go @@ -0,0 +1,210 @@ +package php + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSecurityCheck_Fields(t *testing.T) { + check := SecurityCheck{ + ID: "debug_mode", + Name: "Debug Mode Disabled", + Description: "APP_DEBUG should be false in production", + Severity: "critical", + Passed: false, + Message: "Debug mode exposes sensitive information", + Fix: "Set APP_DEBUG=false in .env", + CWE: "CWE-215", + } + + assert.Equal(t, "debug_mode", check.ID) + assert.Equal(t, "Debug Mode Disabled", check.Name) + assert.Equal(t, "critical", check.Severity) + assert.False(t, check.Passed) + assert.Equal(t, "CWE-215", check.CWE) + assert.Equal(t, "Set APP_DEBUG=false in .env", check.Fix) +} + +func TestSecuritySummary_Fields(t *testing.T) { + summary := SecuritySummary{ + Total: 10, + Passed: 6, + Critical: 2, + High: 1, + Medium: 1, + Low: 0, + } + + assert.Equal(t, 10, summary.Total) + assert.Equal(t, 6, summary.Passed) + assert.Equal(t, 2, summary.Critical) + assert.Equal(t, 1, summary.High) + assert.Equal(t, 1, summary.Medium) + assert.Equal(t, 0, summary.Low) +} + +func TestRunEnvSecurityChecks_DebugTrue(t *testing.T) { + dir := t.TempDir() + envContent := "APP_DEBUG=true\n" + err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) + require.NoError(t, err) + + checks := runEnvSecurityChecks(dir) + + require.Len(t, checks, 1) + assert.Equal(t, "debug_mode", checks[0].ID) + assert.False(t, checks[0].Passed) + assert.Equal(t, "critical", checks[0].Severity) + assert.Equal(t, "Debug mode exposes sensitive information", checks[0].Message) + assert.Equal(t, "Set APP_DEBUG=false in .env", checks[0].Fix) +} + +func TestRunEnvSecurityChecks_AllPass(t *testing.T) { + dir := t.TempDir() + envContent := "APP_DEBUG=false\nAPP_KEY=base64:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=\nAPP_URL=https://example.com\n" + err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) + require.NoError(t, err) + + checks := runEnvSecurityChecks(dir) + + require.Len(t, checks, 3) + + // Build a map by ID for deterministic assertions + byID := make(map[string]SecurityCheck) + for _, c := range checks { + byID[c.ID] = c + } + + assert.True(t, byID["debug_mode"].Passed) + assert.True(t, byID["app_key_set"].Passed) + assert.True(t, byID["https_enforced"].Passed) +} + +func TestRunEnvSecurityChecks_WeakKey(t *testing.T) { + dir := t.TempDir() + envContent := "APP_KEY=short\n" + err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) + require.NoError(t, err) + + checks := runEnvSecurityChecks(dir) + + require.Len(t, checks, 1) + assert.Equal(t, "app_key_set", checks[0].ID) + assert.False(t, checks[0].Passed) + assert.Equal(t, "Missing or weak encryption key", checks[0].Message) +} + +func TestRunEnvSecurityChecks_HttpUrl(t *testing.T) { + dir := t.TempDir() + envContent := "APP_URL=http://example.com\n" + err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) + require.NoError(t, err) + + checks := runEnvSecurityChecks(dir) + + require.Len(t, checks, 1) + assert.Equal(t, "https_enforced", checks[0].ID) + assert.False(t, checks[0].Passed) + assert.Equal(t, "high", checks[0].Severity) + assert.Equal(t, "Application not using HTTPS", checks[0].Message) +} + +func TestRunEnvSecurityChecks_NoEnvFile(t *testing.T) { + dir := t.TempDir() + + checks := runEnvSecurityChecks(dir) + assert.Empty(t, checks) +} + +func TestRunFilesystemSecurityChecks_EnvInPublic(t *testing.T) { + dir := t.TempDir() + + // Create public/.env + publicDir := filepath.Join(dir, "public") + err := os.Mkdir(publicDir, 0755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(publicDir, ".env"), []byte("SECRET=leaked"), 0644) + require.NoError(t, err) + + checks := runFilesystemSecurityChecks(dir) + + require.Len(t, checks, 1) + assert.Equal(t, "env_not_public", checks[0].ID) + assert.False(t, checks[0].Passed) + assert.Equal(t, "critical", checks[0].Severity) + assert.Contains(t, checks[0].Message, "public/.env") +} + +func TestRunFilesystemSecurityChecks_GitInPublic(t *testing.T) { + dir := t.TempDir() + + // Create public/.git directory + gitDir := filepath.Join(dir, "public", ".git") + err := os.MkdirAll(gitDir, 0755) + require.NoError(t, err) + + checks := runFilesystemSecurityChecks(dir) + + require.Len(t, checks, 1) + assert.Equal(t, "git_not_public", checks[0].ID) + assert.False(t, checks[0].Passed) + assert.Contains(t, checks[0].Message, "source code leak") +} + +func TestRunFilesystemSecurityChecks_EmptyDir(t *testing.T) { + dir := t.TempDir() + + checks := runFilesystemSecurityChecks(dir) + assert.Empty(t, checks) +} + +func TestRunSecurityChecks_Summary(t *testing.T) { + dir := t.TempDir() + + // Create .env with debug=true (critical fail) and http URL (high fail) + envContent := "APP_DEBUG=true\nAPP_KEY=base64:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=\nAPP_URL=http://insecure.com\n" + err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) + require.NoError(t, err) + + result, err := RunSecurityChecks(context.Background(), SecurityOptions{Dir: dir}) + require.NoError(t, err) + + // Find the env-related checks by ID + byID := make(map[string]SecurityCheck) + for _, c := range result.Checks { + byID[c.ID] = c + } + + // debug_mode should fail (critical) + assert.False(t, byID["debug_mode"].Passed) + + // app_key_set should pass + assert.True(t, byID["app_key_set"].Passed) + + // https_enforced should fail (high) + assert.False(t, byID["https_enforced"].Passed) + + // Summary should have totals + assert.Greater(t, result.Summary.Total, 0) + assert.Greater(t, result.Summary.Critical, 0) // at least debug_mode fails + assert.Greater(t, result.Summary.High, 0) // at least https_enforced fails +} + +func TestRunSecurityChecks_DefaultsDir(t *testing.T) { + // Test that empty Dir defaults to cwd (should not error) + result, err := RunSecurityChecks(context.Background(), SecurityOptions{}) + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestCapitalise(t *testing.T) { + assert.Equal(t, "Composer", capitalise("composer")) + assert.Equal(t, "Npm", capitalise("npm")) + assert.Equal(t, "", capitalise("")) + assert.Equal(t, "A", capitalise("a")) +} diff --git a/pkg/php/test.go b/pkg/php/test.go new file mode 100644 index 0000000..d54b184 --- /dev/null +++ b/pkg/php/test.go @@ -0,0 +1,191 @@ +package php + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +// TestOptions configures PHP test execution. +type TestOptions struct { + // Dir is the project directory (defaults to current working directory). + Dir string + + // Filter filters tests by name pattern. + Filter string + + // Parallel runs tests in parallel. + Parallel bool + + // Coverage generates code coverage. + Coverage bool + + // CoverageFormat is the coverage output format (text, html, clover). + CoverageFormat string + + // Groups runs only tests in the specified groups. + Groups []string + + // JUnit outputs results in JUnit XML format via --log-junit. + JUnit bool + + // Output is the writer for test output (defaults to os.Stdout). + Output io.Writer +} + +// TestRunner represents the detected test runner. +type TestRunner string + +// Test runner type constants. +const ( + // TestRunnerPest indicates Pest testing framework. + TestRunnerPest TestRunner = "pest" + // TestRunnerPHPUnit indicates PHPUnit testing framework. + TestRunnerPHPUnit TestRunner = "phpunit" +) + +// DetectTestRunner detects which test runner is available in the project. +// Returns Pest if tests/Pest.php exists, otherwise PHPUnit. +func DetectTestRunner(dir string) TestRunner { + pestFile := filepath.Join(dir, "tests", "Pest.php") + if fileExists(pestFile) { + return TestRunnerPest + } + + return TestRunnerPHPUnit +} + +// RunTests runs PHPUnit or Pest tests. +func RunTests(ctx context.Context, opts TestOptions) error { + if opts.Dir == "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + opts.Dir = cwd + } + + if opts.Output == nil { + opts.Output = os.Stdout + } + + // Detect test runner + runner := DetectTestRunner(opts.Dir) + + // Build command based on runner + var cmdName string + var args []string + + switch runner { + case TestRunnerPest: + cmdName, args = buildPestCommand(opts) + default: + cmdName, args = buildPHPUnitCommand(opts) + } + + cmd := exec.CommandContext(ctx, cmdName, args...) + cmd.Dir = opts.Dir + cmd.Stdout = opts.Output + cmd.Stderr = opts.Output + cmd.Stdin = os.Stdin + + // Set XDEBUG_MODE=coverage to avoid PHPUnit 11 warning + cmd.Env = append(os.Environ(), "XDEBUG_MODE=coverage") + + return cmd.Run() +} + +// RunParallel runs tests in parallel using the appropriate runner. +func RunParallel(ctx context.Context, opts TestOptions) error { + opts.Parallel = true + return RunTests(ctx, opts) +} + +// buildPestCommand builds the command for running Pest tests. +func buildPestCommand(opts TestOptions) (string, []string) { + // Check for vendor binary first + vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pest") + cmdName := "pest" + if fileExists(vendorBin) { + cmdName = vendorBin + } + + var args []string + + if opts.Filter != "" { + args = append(args, "--filter", opts.Filter) + } + + if opts.Parallel { + args = append(args, "--parallel") + } + + if opts.Coverage { + switch opts.CoverageFormat { + case "html": + args = append(args, "--coverage-html", "coverage") + case "clover": + args = append(args, "--coverage-clover", "coverage.xml") + default: + args = append(args, "--coverage") + } + } + + for _, group := range opts.Groups { + args = append(args, "--group", group) + } + + if opts.JUnit { + args = append(args, "--log-junit", "test-results.xml") + } + + return cmdName, args +} + +// buildPHPUnitCommand builds the command for running PHPUnit tests. +func buildPHPUnitCommand(opts TestOptions) (string, []string) { + // Check for vendor binary first + vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpunit") + cmdName := "phpunit" + if fileExists(vendorBin) { + cmdName = vendorBin + } + + var args []string + + if opts.Filter != "" { + args = append(args, "--filter", opts.Filter) + } + + if opts.Parallel { + // PHPUnit uses paratest for parallel execution + paratestBin := filepath.Join(opts.Dir, "vendor", "bin", "paratest") + if fileExists(paratestBin) { + cmdName = paratestBin + } + } + + if opts.Coverage { + switch opts.CoverageFormat { + case "html": + args = append(args, "--coverage-html", "coverage") + case "clover": + args = append(args, "--coverage-clover", "coverage.xml") + default: + args = append(args, "--coverage-text") + } + } + + for _, group := range opts.Groups { + args = append(args, "--group", group) + } + + if opts.JUnit { + args = append(args, "--log-junit", "test-results.xml", "--testdox") + } + + return cmdName, args +} diff --git a/pkg/php/test_test.go b/pkg/php/test_test.go new file mode 100644 index 0000000..dc79c08 --- /dev/null +++ b/pkg/php/test_test.go @@ -0,0 +1,317 @@ +package php + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================= +// DetectTestRunner +// ============================================================================= + +func TestDetectTestRunner_Good_Pest(t *testing.T) { + dir := t.TempDir() + + // Create tests/Pest.php + mkFile(t, filepath.Join(dir, "tests", "Pest.php")) + + runner := DetectTestRunner(dir) + assert.Equal(t, TestRunnerPest, runner) +} + +func TestDetectTestRunner_Good_PHPUnit(t *testing.T) { + dir := t.TempDir() + + // No tests/Pest.php → defaults to PHPUnit + runner := DetectTestRunner(dir) + assert.Equal(t, TestRunnerPHPUnit, runner) +} + +func TestDetectTestRunner_Good_PHPUnitWithTestsDir(t *testing.T) { + dir := t.TempDir() + + // tests/ dir exists but no Pest.php + require.NoError(t, os.MkdirAll(filepath.Join(dir, "tests"), 0o755)) + + runner := DetectTestRunner(dir) + assert.Equal(t, TestRunnerPHPUnit, runner) +} + +// ============================================================================= +// buildPestCommand +// ============================================================================= + +func TestBuildPestCommand_Good_Defaults(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir} + cmdName, args := buildPestCommand(opts) + + assert.Equal(t, "pest", cmdName) + assert.Empty(t, args) +} + +func TestBuildPestCommand_Good_VendorBinary(t *testing.T) { + dir := t.TempDir() + vendorBin := filepath.Join(dir, "vendor", "bin", "pest") + mkFile(t, vendorBin) + + opts := TestOptions{Dir: dir} + cmdName, _ := buildPestCommand(opts) + + assert.Equal(t, vendorBin, cmdName) +} + +func TestBuildPestCommand_Good_Filter(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, Filter: "TestLogin"} + _, args := buildPestCommand(opts) + + assert.Contains(t, args, "--filter") + assert.Contains(t, args, "TestLogin") +} + +func TestBuildPestCommand_Good_Parallel(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, Parallel: true} + _, args := buildPestCommand(opts) + + assert.Contains(t, args, "--parallel") +} + +func TestBuildPestCommand_Good_CoverageDefault(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, Coverage: true} + _, args := buildPestCommand(opts) + + assert.Contains(t, args, "--coverage") +} + +func TestBuildPestCommand_Good_CoverageHTML(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "html"} + _, args := buildPestCommand(opts) + + assert.Contains(t, args, "--coverage-html") + assert.Contains(t, args, "coverage") +} + +func TestBuildPestCommand_Good_CoverageClover(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "clover"} + _, args := buildPestCommand(opts) + + assert.Contains(t, args, "--coverage-clover") + assert.Contains(t, args, "coverage.xml") +} + +func TestBuildPestCommand_Good_Groups(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, Groups: []string{"unit", "integration"}} + _, args := buildPestCommand(opts) + + // Should have --group unit --group integration + groupCount := 0 + for _, a := range args { + if a == "--group" { + groupCount++ + } + } + assert.Equal(t, 2, groupCount) + assert.Contains(t, args, "unit") + assert.Contains(t, args, "integration") +} + +func TestBuildPestCommand_Good_JUnit(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, JUnit: true} + _, args := buildPestCommand(opts) + + assert.Contains(t, args, "--log-junit") + assert.Contains(t, args, "test-results.xml") +} + +func TestBuildPestCommand_Good_AllFlags(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{ + Dir: dir, + Filter: "TestFoo", + Parallel: true, + Coverage: true, + CoverageFormat: "clover", + Groups: []string{"smoke"}, + JUnit: true, + } + _, args := buildPestCommand(opts) + + assert.Contains(t, args, "--filter") + assert.Contains(t, args, "TestFoo") + assert.Contains(t, args, "--parallel") + assert.Contains(t, args, "--coverage-clover") + assert.Contains(t, args, "--group") + assert.Contains(t, args, "smoke") + assert.Contains(t, args, "--log-junit") +} + +// ============================================================================= +// buildPHPUnitCommand +// ============================================================================= + +func TestBuildPHPUnitCommand_Good_Defaults(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir} + cmdName, args := buildPHPUnitCommand(opts) + + assert.Equal(t, "phpunit", cmdName) + assert.Empty(t, args) +} + +func TestBuildPHPUnitCommand_Good_VendorBinary(t *testing.T) { + dir := t.TempDir() + vendorBin := filepath.Join(dir, "vendor", "bin", "phpunit") + mkFile(t, vendorBin) + + opts := TestOptions{Dir: dir} + cmdName, _ := buildPHPUnitCommand(opts) + + assert.Equal(t, vendorBin, cmdName) +} + +func TestBuildPHPUnitCommand_Good_Filter(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, Filter: "TestCheckout"} + _, args := buildPHPUnitCommand(opts) + + assert.Contains(t, args, "--filter") + assert.Contains(t, args, "TestCheckout") +} + +func TestBuildPHPUnitCommand_Good_Parallel_WithParatest(t *testing.T) { + dir := t.TempDir() + paratestBin := filepath.Join(dir, "vendor", "bin", "paratest") + mkFile(t, paratestBin) + + opts := TestOptions{Dir: dir, Parallel: true} + cmdName, _ := buildPHPUnitCommand(opts) + + assert.Equal(t, paratestBin, cmdName) +} + +func TestBuildPHPUnitCommand_Good_Parallel_NoParatest(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, Parallel: true} + cmdName, _ := buildPHPUnitCommand(opts) + + // Falls back to phpunit when paratest is not available + assert.Equal(t, "phpunit", cmdName) +} + +func TestBuildPHPUnitCommand_Good_Parallel_VendorPHPUnit_WithParatest(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "vendor", "bin", "phpunit")) + paratestBin := filepath.Join(dir, "vendor", "bin", "paratest") + mkFile(t, paratestBin) + + opts := TestOptions{Dir: dir, Parallel: true} + cmdName, _ := buildPHPUnitCommand(opts) + + // paratest takes precedence over phpunit when parallel is requested + assert.Equal(t, paratestBin, cmdName) +} + +func TestBuildPHPUnitCommand_Good_CoverageDefault(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, Coverage: true} + _, args := buildPHPUnitCommand(opts) + + assert.Contains(t, args, "--coverage-text") +} + +func TestBuildPHPUnitCommand_Good_CoverageHTML(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "html"} + _, args := buildPHPUnitCommand(opts) + + assert.Contains(t, args, "--coverage-html") + assert.Contains(t, args, "coverage") +} + +func TestBuildPHPUnitCommand_Good_CoverageClover(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "clover"} + _, args := buildPHPUnitCommand(opts) + + assert.Contains(t, args, "--coverage-clover") + assert.Contains(t, args, "coverage.xml") +} + +func TestBuildPHPUnitCommand_Good_Groups(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, Groups: []string{"api", "slow"}} + _, args := buildPHPUnitCommand(opts) + + groupCount := 0 + for _, a := range args { + if a == "--group" { + groupCount++ + } + } + assert.Equal(t, 2, groupCount) + assert.Contains(t, args, "api") + assert.Contains(t, args, "slow") +} + +func TestBuildPHPUnitCommand_Good_JUnit(t *testing.T) { + dir := t.TempDir() + + opts := TestOptions{Dir: dir, JUnit: true} + _, args := buildPHPUnitCommand(opts) + + assert.Contains(t, args, "--log-junit") + assert.Contains(t, args, "test-results.xml") + assert.Contains(t, args, "--testdox") +} + +func TestBuildPHPUnitCommand_Good_AllFlags(t *testing.T) { + dir := t.TempDir() + mkFile(t, filepath.Join(dir, "vendor", "bin", "paratest")) + + opts := TestOptions{ + Dir: dir, + Filter: "TestBar", + Parallel: true, + Coverage: true, + CoverageFormat: "html", + Groups: []string{"feature"}, + JUnit: true, + } + cmdName, args := buildPHPUnitCommand(opts) + + assert.Equal(t, filepath.Join(dir, "vendor", "bin", "paratest"), cmdName) + assert.Contains(t, args, "--filter") + assert.Contains(t, args, "TestBar") + assert.Contains(t, args, "--coverage-html") + assert.Contains(t, args, "--group") + assert.Contains(t, args, "feature") + assert.Contains(t, args, "--log-junit") + assert.Contains(t, args, "--testdox") +}