feat(lint): add pkg/detect + pkg/php — project detection and PHP QA toolchain
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 <virgil@lethean.io>
This commit is contained in:
parent
bf06489806
commit
af5c792da8
22 changed files with 3206 additions and 0 deletions
1
go.mod
1
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
|
||||
|
|
|
|||
3
go.sum
3
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=
|
||||
|
|
|
|||
36
pkg/detect/detect.go
Normal file
36
pkg/detect/detect.go
Normal file
|
|
@ -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
|
||||
}
|
||||
46
pkg/detect/detect_test.go
Normal file
46
pkg/detect/detect_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
242
pkg/php/analyse.go
Normal file
242
pkg/php/analyse.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
192
pkg/php/analyse_test.go
Normal file
192
pkg/php/analyse_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
|
||||
158
pkg/php/audit.go
Normal file
158
pkg/php/audit.go
Normal file
|
|
@ -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
|
||||
}
|
||||
232
pkg/php/audit_test.go
Normal file
232
pkg/php/audit_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
129
pkg/php/format.go
Normal file
129
pkg/php/format.go
Normal file
|
|
@ -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
|
||||
}
|
||||
112
pkg/php/format_test.go
Normal file
112
pkg/php/format_test.go
Normal file
|
|
@ -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")))
|
||||
}
|
||||
135
pkg/php/mutation.go
Normal file
135
pkg/php/mutation.go
Normal file
|
|
@ -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
|
||||
}
|
||||
145
pkg/php/mutation_test.go
Normal file
145
pkg/php/mutation_test.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
73
pkg/php/pipeline.go
Normal file
73
pkg/php/pipeline.go
Normal file
|
|
@ -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
|
||||
}
|
||||
69
pkg/php/pipeline_test.go
Normal file
69
pkg/php/pipeline_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
104
pkg/php/refactor.go
Normal file
104
pkg/php/refactor.go
Normal file
|
|
@ -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
|
||||
}
|
||||
122
pkg/php/refactor_test.go
Normal file
122
pkg/php/refactor_test.go
Normal file
|
|
@ -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("<?php\n"), 0644))
|
||||
mkFile(t, filepath.Join(dir, "vendor", "bin", "rector"))
|
||||
|
||||
assert.True(t, DetectRector(dir))
|
||||
}
|
||||
214
pkg/php/runner.go
Normal file
214
pkg/php/runner.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
process "forge.lthn.ai/core/go-process"
|
||||
)
|
||||
|
||||
// QARunner builds process run specs for PHP QA checks.
|
||||
type QARunner struct {
|
||||
dir string
|
||||
fix bool
|
||||
}
|
||||
|
||||
// NewQARunner creates a QA runner for the given directory.
|
||||
func NewQARunner(dir string, fix bool) *QARunner {
|
||||
return &QARunner{dir: dir, fix: fix}
|
||||
}
|
||||
|
||||
// BuildSpecs creates RunSpecs for the given QA checks.
|
||||
func (r *QARunner) BuildSpecs(checks []string) []process.RunSpec {
|
||||
specs := make([]process.RunSpec, 0, len(checks))
|
||||
for _, check := range checks {
|
||||
spec := r.buildSpec(check)
|
||||
if spec != nil {
|
||||
specs = append(specs, *spec)
|
||||
}
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
// buildSpec creates a RunSpec for a single check.
|
||||
func (r *QARunner) buildSpec(check string) *process.RunSpec {
|
||||
switch check {
|
||||
case "audit":
|
||||
return &process.RunSpec{
|
||||
Name: "audit",
|
||||
Command: "composer",
|
||||
Args: []string{"audit", "--format=summary"},
|
||||
Dir: r.dir,
|
||||
}
|
||||
|
||||
case "fmt":
|
||||
_, found := DetectFormatter(r.dir)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "pint")
|
||||
cmd := "pint"
|
||||
if fileExists(vendorBin) {
|
||||
cmd = vendorBin
|
||||
}
|
||||
args := []string{}
|
||||
if !r.fix {
|
||||
args = append(args, "--test")
|
||||
}
|
||||
return &process.RunSpec{
|
||||
Name: "fmt",
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
Dir: r.dir,
|
||||
After: []string{"audit"},
|
||||
}
|
||||
|
||||
case "stan":
|
||||
_, found := DetectAnalyser(r.dir)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "phpstan")
|
||||
cmd := "phpstan"
|
||||
if fileExists(vendorBin) {
|
||||
cmd = vendorBin
|
||||
}
|
||||
return &process.RunSpec{
|
||||
Name: "stan",
|
||||
Command: cmd,
|
||||
Args: []string{"analyse", "--no-progress"},
|
||||
Dir: r.dir,
|
||||
After: []string{"fmt"},
|
||||
}
|
||||
|
||||
case "psalm":
|
||||
_, found := DetectPsalm(r.dir)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "psalm")
|
||||
cmd := "psalm"
|
||||
if fileExists(vendorBin) {
|
||||
cmd = vendorBin
|
||||
}
|
||||
args := []string{"--no-progress"}
|
||||
if r.fix {
|
||||
args = append(args, "--alter", "--issues=all")
|
||||
}
|
||||
return &process.RunSpec{
|
||||
Name: "psalm",
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
Dir: r.dir,
|
||||
After: []string{"stan"},
|
||||
}
|
||||
|
||||
case "test":
|
||||
pestBin := filepath.Join(r.dir, "vendor", "bin", "pest")
|
||||
phpunitBin := filepath.Join(r.dir, "vendor", "bin", "phpunit")
|
||||
var cmd string
|
||||
if fileExists(pestBin) {
|
||||
cmd = pestBin
|
||||
} else if fileExists(phpunitBin) {
|
||||
cmd = phpunitBin
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
after := []string{"stan"}
|
||||
if _, found := DetectPsalm(r.dir); found {
|
||||
after = []string{"psalm"}
|
||||
}
|
||||
return &process.RunSpec{
|
||||
Name: "test",
|
||||
Command: cmd,
|
||||
Args: []string{},
|
||||
Dir: r.dir,
|
||||
After: after,
|
||||
}
|
||||
|
||||
case "rector":
|
||||
if !DetectRector(r.dir) {
|
||||
return nil
|
||||
}
|
||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "rector")
|
||||
cmd := "rector"
|
||||
if fileExists(vendorBin) {
|
||||
cmd = vendorBin
|
||||
}
|
||||
args := []string{"process"}
|
||||
if !r.fix {
|
||||
args = append(args, "--dry-run")
|
||||
}
|
||||
return &process.RunSpec{
|
||||
Name: "rector",
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
Dir: r.dir,
|
||||
After: []string{"test"},
|
||||
AllowFailure: true,
|
||||
}
|
||||
|
||||
case "infection":
|
||||
if !DetectInfection(r.dir) {
|
||||
return nil
|
||||
}
|
||||
vendorBin := filepath.Join(r.dir, "vendor", "bin", "infection")
|
||||
cmd := "infection"
|
||||
if fileExists(vendorBin) {
|
||||
cmd = vendorBin
|
||||
}
|
||||
return &process.RunSpec{
|
||||
Name: "infection",
|
||||
Command: cmd,
|
||||
Args: []string{"--min-msi=50", "--min-covered-msi=70", "--threads=4"},
|
||||
Dir: r.dir,
|
||||
After: []string{"test"},
|
||||
AllowFailure: true,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QARunResult holds the results of running QA checks.
|
||||
type QARunResult struct {
|
||||
Passed bool `json:"passed"`
|
||||
Duration string `json:"duration"`
|
||||
Results []QACheckRunResult `json:"results"`
|
||||
PassedCount int `json:"passed_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
}
|
||||
|
||||
// QACheckRunResult holds the result of a single QA check.
|
||||
type QACheckRunResult struct {
|
||||
Name string `json:"name"`
|
||||
Passed bool `json:"passed"`
|
||||
Skipped bool `json:"skipped"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Duration string `json:"duration"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
// GetIssueMessage returns a human-readable issue description for a failed check.
|
||||
func (r QACheckRunResult) GetIssueMessage() string {
|
||||
if r.Passed || r.Skipped {
|
||||
return ""
|
||||
}
|
||||
switch r.Name {
|
||||
case "audit":
|
||||
return "found vulnerabilities"
|
||||
case "fmt":
|
||||
return "found style issues"
|
||||
case "stan":
|
||||
return "found analysis errors"
|
||||
case "psalm":
|
||||
return "found type errors"
|
||||
case "test":
|
||||
return "tests failed"
|
||||
case "rector":
|
||||
return "found refactoring suggestions"
|
||||
case "infection":
|
||||
return "mutation testing did not pass"
|
||||
default:
|
||||
return "found issues"
|
||||
}
|
||||
}
|
||||
245
pkg/php/runner_test.go
Normal file
245
pkg/php/runner_test.go
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewQARunner(t *testing.T) {
|
||||
runner := NewQARunner("/tmp/test", false)
|
||||
assert.NotNil(t, runner)
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Audit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runner := NewQARunner(dir, false)
|
||||
specs := runner.BuildSpecs([]string{"audit"})
|
||||
require.Len(t, specs, 1)
|
||||
assert.Equal(t, "audit", specs[0].Name)
|
||||
assert.Equal(t, "composer", specs[0].Command)
|
||||
assert.Contains(t, specs[0].Args, "--format=summary")
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Fmt_WithPint(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vendorBin := filepath.Join(dir, "vendor", "bin")
|
||||
os.MkdirAll(vendorBin, 0755)
|
||||
os.WriteFile(filepath.Join(vendorBin, "pint"), []byte("#!/bin/sh"), 0755)
|
||||
|
||||
runner := NewQARunner(dir, false)
|
||||
specs := runner.BuildSpecs([]string{"fmt"})
|
||||
require.Len(t, specs, 1)
|
||||
assert.Equal(t, "fmt", specs[0].Name)
|
||||
assert.Contains(t, specs[0].Args, "--test")
|
||||
assert.Equal(t, []string{"audit"}, specs[0].After)
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Fmt_Fix(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vendorBin := filepath.Join(dir, "vendor", "bin")
|
||||
os.MkdirAll(vendorBin, 0755)
|
||||
os.WriteFile(filepath.Join(vendorBin, "pint"), []byte("#!/bin/sh"), 0755)
|
||||
|
||||
runner := NewQARunner(dir, true) // fix mode
|
||||
specs := runner.BuildSpecs([]string{"fmt"})
|
||||
require.Len(t, specs, 1)
|
||||
assert.NotContains(t, specs[0].Args, "--test")
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Fmt_NoPint(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runner := NewQARunner(dir, false)
|
||||
specs := runner.BuildSpecs([]string{"fmt"})
|
||||
assert.Empty(t, specs)
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Stan_WithPHPStan(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vendorBin := filepath.Join(dir, "vendor", "bin")
|
||||
os.MkdirAll(vendorBin, 0755)
|
||||
os.WriteFile(filepath.Join(vendorBin, "phpstan"), []byte("#!/bin/sh"), 0755)
|
||||
|
||||
runner := NewQARunner(dir, false)
|
||||
specs := runner.BuildSpecs([]string{"stan"})
|
||||
require.Len(t, specs, 1)
|
||||
assert.Equal(t, "stan", specs[0].Name)
|
||||
assert.Contains(t, specs[0].Args, "--no-progress")
|
||||
assert.Equal(t, []string{"fmt"}, specs[0].After)
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Stan_NotDetected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runner := NewQARunner(dir, false)
|
||||
specs := runner.BuildSpecs([]string{"stan"})
|
||||
assert.Empty(t, specs)
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Psalm_WithPsalm(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vendorBin := filepath.Join(dir, "vendor", "bin")
|
||||
os.MkdirAll(vendorBin, 0755)
|
||||
os.WriteFile(filepath.Join(vendorBin, "psalm"), []byte("#!/bin/sh"), 0755)
|
||||
|
||||
runner := NewQARunner(dir, false)
|
||||
specs := runner.BuildSpecs([]string{"psalm"})
|
||||
require.Len(t, specs, 1)
|
||||
assert.Equal(t, "psalm", specs[0].Name)
|
||||
assert.Equal(t, []string{"stan"}, specs[0].After)
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Psalm_Fix(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vendorBin := filepath.Join(dir, "vendor", "bin")
|
||||
os.MkdirAll(vendorBin, 0755)
|
||||
os.WriteFile(filepath.Join(vendorBin, "psalm"), []byte("#!/bin/sh"), 0755)
|
||||
|
||||
runner := NewQARunner(dir, true)
|
||||
specs := runner.BuildSpecs([]string{"psalm"})
|
||||
require.Len(t, specs, 1)
|
||||
assert.Contains(t, specs[0].Args, "--alter")
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Test_Pest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vendorBin := filepath.Join(dir, "vendor", "bin")
|
||||
os.MkdirAll(vendorBin, 0755)
|
||||
os.WriteFile(filepath.Join(vendorBin, "pest"), []byte("#!/bin/sh"), 0755)
|
||||
|
||||
runner := NewQARunner(dir, false)
|
||||
specs := runner.BuildSpecs([]string{"test"})
|
||||
require.Len(t, specs, 1)
|
||||
assert.Equal(t, "test", specs[0].Name)
|
||||
assert.Equal(t, []string{"stan"}, specs[0].After)
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Test_PHPUnit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vendorBin := filepath.Join(dir, "vendor", "bin")
|
||||
os.MkdirAll(vendorBin, 0755)
|
||||
os.WriteFile(filepath.Join(vendorBin, "phpunit"), []byte("#!/bin/sh"), 0755)
|
||||
|
||||
runner := NewQARunner(dir, false)
|
||||
specs := runner.BuildSpecs([]string{"test"})
|
||||
require.Len(t, specs, 1)
|
||||
assert.Contains(t, specs[0].Command, "phpunit")
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Test_WithPsalmDep(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vendorBin := filepath.Join(dir, "vendor", "bin")
|
||||
os.MkdirAll(vendorBin, 0755)
|
||||
os.WriteFile(filepath.Join(vendorBin, "pest"), []byte("#!/bin/sh"), 0755)
|
||||
os.WriteFile(filepath.Join(vendorBin, "psalm"), []byte("#!/bin/sh"), 0755)
|
||||
|
||||
runner := NewQARunner(dir, false)
|
||||
specs := runner.BuildSpecs([]string{"test"})
|
||||
require.Len(t, specs, 1)
|
||||
assert.Equal(t, []string{"psalm"}, specs[0].After)
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Test_NoRunner(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runner := NewQARunner(dir, false)
|
||||
specs := runner.BuildSpecs([]string{"test"})
|
||||
assert.Empty(t, specs)
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Rector(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)
|
||||
|
||||
runner := NewQARunner(dir, false)
|
||||
specs := runner.BuildSpecs([]string{"rector"})
|
||||
require.Len(t, specs, 1)
|
||||
assert.True(t, specs[0].AllowFailure)
|
||||
assert.Contains(t, specs[0].Args, "--dry-run")
|
||||
assert.Equal(t, []string{"test"}, specs[0].After)
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Rector_Fix(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)
|
||||
|
||||
runner := NewQARunner(dir, true)
|
||||
specs := runner.BuildSpecs([]string{"rector"})
|
||||
require.Len(t, specs, 1)
|
||||
assert.NotContains(t, specs[0].Args, "--dry-run")
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Infection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vendorBin := filepath.Join(dir, "vendor", "bin")
|
||||
os.MkdirAll(vendorBin, 0755)
|
||||
os.WriteFile(filepath.Join(vendorBin, "infection"), []byte("#!/bin/sh"), 0755)
|
||||
|
||||
runner := NewQARunner(dir, false)
|
||||
specs := runner.BuildSpecs([]string{"infection"})
|
||||
require.Len(t, specs, 1)
|
||||
assert.True(t, specs[0].AllowFailure)
|
||||
assert.Equal(t, []string{"test"}, specs[0].After)
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Unknown(t *testing.T) {
|
||||
runner := NewQARunner(t.TempDir(), false)
|
||||
specs := runner.BuildSpecs([]string{"unknown"})
|
||||
assert.Empty(t, specs)
|
||||
}
|
||||
|
||||
func TestBuildSpecs_Multiple(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vendorBin := filepath.Join(dir, "vendor", "bin")
|
||||
os.MkdirAll(vendorBin, 0755)
|
||||
os.WriteFile(filepath.Join(vendorBin, "pint"), []byte("#!/bin/sh"), 0755)
|
||||
os.WriteFile(filepath.Join(vendorBin, "phpstan"), []byte("#!/bin/sh"), 0755)
|
||||
|
||||
runner := NewQARunner(dir, false)
|
||||
specs := runner.BuildSpecs([]string{"audit", "fmt", "stan"})
|
||||
assert.Len(t, specs, 3)
|
||||
}
|
||||
|
||||
func TestQACheckRunResult_GetIssueMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result QACheckRunResult
|
||||
expected string
|
||||
}{
|
||||
{"passed returns empty", QACheckRunResult{Passed: true, Name: "audit"}, ""},
|
||||
{"skipped returns empty", QACheckRunResult{Skipped: true, Name: "audit"}, ""},
|
||||
{"audit", QACheckRunResult{Name: "audit"}, "found vulnerabilities"},
|
||||
{"fmt", QACheckRunResult{Name: "fmt"}, "found style issues"},
|
||||
{"stan", QACheckRunResult{Name: "stan"}, "found analysis errors"},
|
||||
{"psalm", QACheckRunResult{Name: "psalm"}, "found type errors"},
|
||||
{"test", QACheckRunResult{Name: "test"}, "tests failed"},
|
||||
{"rector", QACheckRunResult{Name: "rector"}, "found refactoring suggestions"},
|
||||
{"infection", QACheckRunResult{Name: "infection"}, "mutation testing did not pass"},
|
||||
{"unknown", QACheckRunResult{Name: "whatever"}, "found issues"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, tt.result.GetIssueMessage())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQARunResult(t *testing.T) {
|
||||
result := QARunResult{
|
||||
Passed: true,
|
||||
Duration: "1.5s",
|
||||
Results: []QACheckRunResult{
|
||||
{Name: "audit", Passed: true},
|
||||
{Name: "fmt", Passed: true},
|
||||
},
|
||||
PassedCount: 2,
|
||||
}
|
||||
assert.True(t, result.Passed)
|
||||
assert.Equal(t, 2, result.PassedCount)
|
||||
assert.Equal(t, 0, result.FailedCount)
|
||||
}
|
||||
230
pkg/php/security.go
Normal file
230
pkg/php/security.go
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SecurityOptions configures security scanning.
|
||||
type SecurityOptions struct {
|
||||
Dir string
|
||||
Severity string // Minimum severity (critical, high, medium, low)
|
||||
JSON bool // Output in JSON format
|
||||
SARIF bool // Output in SARIF format
|
||||
URL string // URL to check HTTP headers (optional)
|
||||
}
|
||||
|
||||
// SecurityResult holds the results of security scanning.
|
||||
type SecurityResult struct {
|
||||
Checks []SecurityCheck
|
||||
Summary SecuritySummary
|
||||
}
|
||||
|
||||
// SecurityCheck represents a single security check result.
|
||||
type SecurityCheck struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Severity string
|
||||
Passed bool
|
||||
Message string
|
||||
Fix string
|
||||
CWE string
|
||||
}
|
||||
|
||||
// SecuritySummary summarises security check results.
|
||||
type SecuritySummary struct {
|
||||
Total int
|
||||
Passed int
|
||||
Critical int
|
||||
High int
|
||||
Medium int
|
||||
Low int
|
||||
}
|
||||
|
||||
// capitalise returns s with the first letter upper-cased.
|
||||
func capitalise(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
// RunSecurityChecks runs security checks on the project.
|
||||
func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResult, error) {
|
||||
if opts.Dir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get working directory: %w", err)
|
||||
}
|
||||
opts.Dir = cwd
|
||||
}
|
||||
|
||||
result := &SecurityResult{}
|
||||
|
||||
// Run composer audit
|
||||
auditResults, _ := RunAudit(ctx, AuditOptions{Dir: opts.Dir})
|
||||
for _, audit := range auditResults {
|
||||
check := SecurityCheck{
|
||||
ID: audit.Tool + "_audit",
|
||||
Name: capitalise(audit.Tool) + " Security Audit",
|
||||
Description: "Check " + audit.Tool + " dependencies for vulnerabilities",
|
||||
Severity: "critical",
|
||||
Passed: audit.Vulnerabilities == 0 && audit.Error == nil,
|
||||
CWE: "CWE-1395",
|
||||
}
|
||||
if !check.Passed {
|
||||
check.Message = fmt.Sprintf("Found %d vulnerabilities", audit.Vulnerabilities)
|
||||
}
|
||||
result.Checks = append(result.Checks, check)
|
||||
}
|
||||
|
||||
// Check .env file for security issues
|
||||
envChecks := runEnvSecurityChecks(opts.Dir)
|
||||
result.Checks = append(result.Checks, envChecks...)
|
||||
|
||||
// Check filesystem security
|
||||
fsChecks := runFilesystemSecurityChecks(opts.Dir)
|
||||
result.Checks = append(result.Checks, fsChecks...)
|
||||
|
||||
// Calculate summary
|
||||
for _, check := range result.Checks {
|
||||
result.Summary.Total++
|
||||
if check.Passed {
|
||||
result.Summary.Passed++
|
||||
} else {
|
||||
switch check.Severity {
|
||||
case "critical":
|
||||
result.Summary.Critical++
|
||||
case "high":
|
||||
result.Summary.High++
|
||||
case "medium":
|
||||
result.Summary.Medium++
|
||||
case "low":
|
||||
result.Summary.Low++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func runEnvSecurityChecks(dir string) []SecurityCheck {
|
||||
var checks []SecurityCheck
|
||||
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
envBytes, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
return checks
|
||||
}
|
||||
|
||||
envContent := string(envBytes)
|
||||
envLines := strings.Split(envContent, "\n")
|
||||
envMap := make(map[string]string)
|
||||
for _, line := range envLines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
envMap[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Check APP_DEBUG
|
||||
if debug, ok := envMap["APP_DEBUG"]; ok {
|
||||
check := SecurityCheck{
|
||||
ID: "debug_mode",
|
||||
Name: "Debug Mode Disabled",
|
||||
Description: "APP_DEBUG should be false in production",
|
||||
Severity: "critical",
|
||||
Passed: strings.ToLower(debug) != "true",
|
||||
CWE: "CWE-215",
|
||||
}
|
||||
if !check.Passed {
|
||||
check.Message = "Debug mode exposes sensitive information"
|
||||
check.Fix = "Set APP_DEBUG=false in .env"
|
||||
}
|
||||
checks = append(checks, check)
|
||||
}
|
||||
|
||||
// Check APP_KEY
|
||||
if key, ok := envMap["APP_KEY"]; ok {
|
||||
check := SecurityCheck{
|
||||
ID: "app_key_set",
|
||||
Name: "Application Key Set",
|
||||
Description: "APP_KEY must be set and valid",
|
||||
Severity: "critical",
|
||||
Passed: len(key) >= 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
|
||||
}
|
||||
210
pkg/php/security_test.go
Normal file
210
pkg/php/security_test.go
Normal file
|
|
@ -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"))
|
||||
}
|
||||
191
pkg/php/test.go
Normal file
191
pkg/php/test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
317
pkg/php/test_test.go
Normal file
317
pkg/php/test_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue