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>
242 lines
5.9 KiB
Go
242 lines
5.9 KiB
Go
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()
|
|
}
|