lint/pkg/php/analyse.go
Snider af5c792da8 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>
2026-03-09 13:13:30 +00:00

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()
}