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:
Snider 2026-03-09 13:13:30 +00:00
parent bf06489806
commit af5c792da8
22 changed files with 3206 additions and 0 deletions

1
go.mod
View file

@ -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
View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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")
}