Add 8 PHP subcommands to the qa parent command: fmt, stan, psalm, audit, security, rector, infection, and test. Each command detects the PHP project and delegates to the pkg/php library functions. Co-Authored-By: Virgil <virgil@lethean.io>
551 lines
16 KiB
Go
551 lines
16 KiB
Go
// cmd_php.go adds PHP quality assurance subcommands to the qa parent command.
|
|
//
|
|
// Commands:
|
|
// - fmt: Format PHP code with Laravel Pint
|
|
// - stan: Run PHPStan static analysis
|
|
// - psalm: Run Psalm static analysis
|
|
// - audit: Check dependency security
|
|
// - security: Run security checks
|
|
// - rector: Automated code refactoring
|
|
// - infection: Mutation testing
|
|
// - test: Run PHPUnit/Pest tests
|
|
|
|
package qa
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"forge.lthn.ai/core/cli/pkg/cli"
|
|
"forge.lthn.ai/core/lint/pkg/detect"
|
|
"forge.lthn.ai/core/lint/pkg/php"
|
|
)
|
|
|
|
// Severity styles for security output.
|
|
var (
|
|
headerStyle = cli.HeaderStyle
|
|
criticalStyle = cli.NewStyle().Bold().Foreground(cli.ColourRed500)
|
|
highStyle = cli.NewStyle().Bold().Foreground(cli.ColourOrange500)
|
|
mediumStyle = cli.NewStyle().Foreground(cli.ColourAmber500)
|
|
lowStyle = cli.NewStyle().Foreground(cli.ColourGray500)
|
|
)
|
|
|
|
// addPHPCommands registers all PHP QA subcommands.
|
|
func addPHPCommands(parent *cli.Command) {
|
|
addPHPFmtCommand(parent)
|
|
addPHPStanCommand(parent)
|
|
addPHPPsalmCommand(parent)
|
|
addPHPAuditCommand(parent)
|
|
addPHPSecurityCommand(parent)
|
|
addPHPRectorCommand(parent)
|
|
addPHPInfectionCommand(parent)
|
|
addPHPTestCommand(parent)
|
|
}
|
|
|
|
// PHP fmt command flags.
|
|
var (
|
|
phpFmtFix bool
|
|
phpFmtDiff bool
|
|
phpFmtJSON bool
|
|
)
|
|
|
|
func addPHPFmtCommand(parent *cli.Command) {
|
|
cmd := &cli.Command{
|
|
Use: "fmt",
|
|
Short: "Format PHP code with Laravel Pint",
|
|
Long: "Run Laravel Pint to check or fix PHP code style. Uses --test mode by default; pass --fix to apply changes.",
|
|
RunE: func(cmd *cli.Command, args []string) error {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !detect.IsPHPProject(cwd) {
|
|
return cli.Err("not a PHP project (no composer.json found)")
|
|
}
|
|
|
|
cli.Print("%s %s\n", headerStyle.Render("PHP Format"), dimStyle.Render("(Pint)"))
|
|
cli.Blank()
|
|
|
|
return php.Format(context.Background(), php.FormatOptions{
|
|
Dir: cwd,
|
|
Fix: phpFmtFix,
|
|
Diff: phpFmtDiff,
|
|
JSON: phpFmtJSON,
|
|
})
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVar(&phpFmtFix, "fix", false, "Apply formatting fixes")
|
|
cmd.Flags().BoolVar(&phpFmtDiff, "diff", false, "Show diff of changes")
|
|
cmd.Flags().BoolVar(&phpFmtJSON, "json", false, "Output results as JSON")
|
|
|
|
parent.AddCommand(cmd)
|
|
}
|
|
|
|
// PHP stan command flags.
|
|
var (
|
|
phpStanLevel int
|
|
phpStanMemory string
|
|
phpStanJSON bool
|
|
phpStanSARIF bool
|
|
)
|
|
|
|
func addPHPStanCommand(parent *cli.Command) {
|
|
cmd := &cli.Command{
|
|
Use: "stan",
|
|
Short: "Run PHPStan static analysis",
|
|
Long: "Run PHPStan (or Larastan) to find bugs in PHP code through static analysis.",
|
|
RunE: func(cmd *cli.Command, args []string) error {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !detect.IsPHPProject(cwd) {
|
|
return cli.Err("not a PHP project (no composer.json found)")
|
|
}
|
|
|
|
analyser, found := php.DetectAnalyser(cwd)
|
|
if !found {
|
|
return cli.Err("no static analyser found (install PHPStan: composer require phpstan/phpstan --dev)")
|
|
}
|
|
|
|
cli.Print("%s %s\n", headerStyle.Render("PHP Static Analysis"), dimStyle.Render(fmt.Sprintf("(%s)", analyser)))
|
|
cli.Blank()
|
|
|
|
err = php.Analyse(context.Background(), php.AnalyseOptions{
|
|
Dir: cwd,
|
|
Level: phpStanLevel,
|
|
Memory: phpStanMemory,
|
|
JSON: phpStanJSON,
|
|
SARIF: phpStanSARIF,
|
|
})
|
|
if err != nil {
|
|
return cli.Err("static analysis found issues")
|
|
}
|
|
|
|
cli.Blank()
|
|
cli.Print("%s\n", successStyle.Render("Static analysis passed"))
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cmd.Flags().IntVar(&phpStanLevel, "level", 0, "Analysis level (0-9, 0 uses config default)")
|
|
cmd.Flags().StringVar(&phpStanMemory, "memory", "", "Memory limit (e.g., 2G)")
|
|
cmd.Flags().BoolVar(&phpStanJSON, "json", false, "Output results as JSON")
|
|
cmd.Flags().BoolVar(&phpStanSARIF, "sarif", false, "Output results in SARIF format")
|
|
|
|
parent.AddCommand(cmd)
|
|
}
|
|
|
|
// PHP psalm command flags.
|
|
var (
|
|
phpPsalmLevel int
|
|
phpPsalmFix bool
|
|
phpPsalmBaseline bool
|
|
phpPsalmShowInfo bool
|
|
phpPsalmJSON bool
|
|
phpPsalmSARIF bool
|
|
)
|
|
|
|
func addPHPPsalmCommand(parent *cli.Command) {
|
|
cmd := &cli.Command{
|
|
Use: "psalm",
|
|
Short: "Run Psalm static analysis",
|
|
Long: "Run Psalm for deep type-level static analysis of PHP code.",
|
|
RunE: func(cmd *cli.Command, args []string) error {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !detect.IsPHPProject(cwd) {
|
|
return cli.Err("not a PHP project (no composer.json found)")
|
|
}
|
|
|
|
_, found := php.DetectPsalm(cwd)
|
|
if !found {
|
|
return cli.Err("Psalm not found (install: composer require vimeo/psalm --dev)")
|
|
}
|
|
|
|
cli.Print("%s\n", headerStyle.Render("PHP Psalm Analysis"))
|
|
cli.Blank()
|
|
|
|
err = php.RunPsalm(context.Background(), php.PsalmOptions{
|
|
Dir: cwd,
|
|
Level: phpPsalmLevel,
|
|
Fix: phpPsalmFix,
|
|
Baseline: phpPsalmBaseline,
|
|
ShowInfo: phpPsalmShowInfo,
|
|
JSON: phpPsalmJSON,
|
|
SARIF: phpPsalmSARIF,
|
|
})
|
|
if err != nil {
|
|
return cli.Err("Psalm found issues")
|
|
}
|
|
|
|
cli.Blank()
|
|
cli.Print("%s\n", successStyle.Render("Psalm analysis passed"))
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cmd.Flags().IntVar(&phpPsalmLevel, "level", 0, "Error level (1=strictest, 8=most lenient)")
|
|
cmd.Flags().BoolVar(&phpPsalmFix, "fix", false, "Auto-fix issues where possible")
|
|
cmd.Flags().BoolVar(&phpPsalmBaseline, "baseline", false, "Generate/update baseline file")
|
|
cmd.Flags().BoolVar(&phpPsalmShowInfo, "show-info", false, "Show info-level issues")
|
|
cmd.Flags().BoolVar(&phpPsalmJSON, "json", false, "Output results as JSON")
|
|
cmd.Flags().BoolVar(&phpPsalmSARIF, "sarif", false, "Output results in SARIF format")
|
|
|
|
parent.AddCommand(cmd)
|
|
}
|
|
|
|
// PHP audit command flags.
|
|
var (
|
|
phpAuditJSON bool
|
|
phpAuditFix bool
|
|
)
|
|
|
|
func addPHPAuditCommand(parent *cli.Command) {
|
|
cmd := &cli.Command{
|
|
Use: "audit",
|
|
Short: "Audit PHP and npm dependencies for vulnerabilities",
|
|
Long: "Run composer audit and npm audit to check dependencies for known security vulnerabilities.",
|
|
RunE: func(cmd *cli.Command, args []string) error {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !detect.IsPHPProject(cwd) {
|
|
return cli.Err("not a PHP project (no composer.json found)")
|
|
}
|
|
|
|
cli.Print("%s\n", headerStyle.Render("Dependency Audit"))
|
|
cli.Blank()
|
|
|
|
results, err := php.RunAudit(context.Background(), php.AuditOptions{
|
|
Dir: cwd,
|
|
JSON: phpAuditJSON,
|
|
Fix: phpAuditFix,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
hasVulns := false
|
|
for _, result := range results {
|
|
if result.Error != nil {
|
|
cli.Print("%s %s: %s\n", warningStyle.Render("!"), result.Tool, result.Error)
|
|
continue
|
|
}
|
|
|
|
if result.Vulnerabilities > 0 {
|
|
hasVulns = true
|
|
cli.Print("%s %s: %d vulnerabilities found\n",
|
|
errorStyle.Render(cli.Glyph(":cross:")),
|
|
result.Tool,
|
|
result.Vulnerabilities)
|
|
for _, adv := range result.Advisories {
|
|
cli.Print(" %s %s: %s\n",
|
|
dimStyle.Render("->"),
|
|
adv.Package,
|
|
adv.Title)
|
|
}
|
|
} else {
|
|
cli.Print("%s %s: no vulnerabilities found\n",
|
|
successStyle.Render(cli.Glyph(":check:")),
|
|
result.Tool)
|
|
}
|
|
}
|
|
|
|
if hasVulns {
|
|
return cli.Err("vulnerabilities found in dependencies")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVar(&phpAuditJSON, "json", false, "Output results as JSON")
|
|
cmd.Flags().BoolVar(&phpAuditFix, "fix", false, "Auto-fix vulnerabilities (npm only)")
|
|
|
|
parent.AddCommand(cmd)
|
|
}
|
|
|
|
// PHP security command flags.
|
|
var (
|
|
phpSecuritySeverity string
|
|
phpSecurityJSON bool
|
|
phpSecuritySARIF bool
|
|
phpSecurityURL string
|
|
)
|
|
|
|
func addPHPSecurityCommand(parent *cli.Command) {
|
|
cmd := &cli.Command{
|
|
Use: "security",
|
|
Short: "Run security checks on the PHP project",
|
|
Long: "Check for common security issues including dependency vulnerabilities, .env exposure, debug mode, and more.",
|
|
RunE: func(cmd *cli.Command, args []string) error {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !detect.IsPHPProject(cwd) {
|
|
return cli.Err("not a PHP project (no composer.json found)")
|
|
}
|
|
|
|
cli.Print("%s\n", headerStyle.Render("Security Checks"))
|
|
cli.Blank()
|
|
|
|
result, err := php.RunSecurityChecks(context.Background(), php.SecurityOptions{
|
|
Dir: cwd,
|
|
Severity: phpSecuritySeverity,
|
|
JSON: phpSecurityJSON,
|
|
SARIF: phpSecuritySARIF,
|
|
URL: phpSecurityURL,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Print each check result
|
|
for _, check := range result.Checks {
|
|
if check.Passed {
|
|
cli.Print("%s %s\n",
|
|
successStyle.Render(cli.Glyph(":check:")),
|
|
check.Name)
|
|
} else {
|
|
style := getSeverityStyle(check.Severity)
|
|
cli.Print("%s %s %s\n",
|
|
errorStyle.Render(cli.Glyph(":cross:")),
|
|
check.Name,
|
|
style.Render(fmt.Sprintf("[%s]", check.Severity)))
|
|
if check.Message != "" {
|
|
cli.Print(" %s %s\n", dimStyle.Render("->"), check.Message)
|
|
}
|
|
if check.Fix != "" {
|
|
cli.Print(" %s Fix: %s\n", dimStyle.Render("->"), check.Fix)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Print summary
|
|
cli.Blank()
|
|
summary := result.Summary
|
|
cli.Print("%s: %d/%d checks passed\n",
|
|
headerStyle.Render("Summary"),
|
|
summary.Passed, summary.Total)
|
|
|
|
if summary.Critical > 0 {
|
|
cli.Print(" %s\n", criticalStyle.Render(fmt.Sprintf("%d critical", summary.Critical)))
|
|
}
|
|
if summary.High > 0 {
|
|
cli.Print(" %s\n", highStyle.Render(fmt.Sprintf("%d high", summary.High)))
|
|
}
|
|
if summary.Medium > 0 {
|
|
cli.Print(" %s\n", mediumStyle.Render(fmt.Sprintf("%d medium", summary.Medium)))
|
|
}
|
|
if summary.Low > 0 {
|
|
cli.Print(" %s\n", lowStyle.Render(fmt.Sprintf("%d low", summary.Low)))
|
|
}
|
|
|
|
if summary.Critical > 0 || summary.High > 0 {
|
|
return cli.Err("security checks failed")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringVar(&phpSecuritySeverity, "severity", "", "Minimum severity to report (critical, high, medium, low)")
|
|
cmd.Flags().BoolVar(&phpSecurityJSON, "json", false, "Output results as JSON")
|
|
cmd.Flags().BoolVar(&phpSecuritySARIF, "sarif", false, "Output results in SARIF format")
|
|
cmd.Flags().StringVar(&phpSecurityURL, "url", "", "URL to check HTTP security headers")
|
|
|
|
parent.AddCommand(cmd)
|
|
}
|
|
|
|
// PHP rector command flags.
|
|
var (
|
|
phpRectorFix bool
|
|
phpRectorDiff bool
|
|
phpRectorClearCache bool
|
|
)
|
|
|
|
func addPHPRectorCommand(parent *cli.Command) {
|
|
cmd := &cli.Command{
|
|
Use: "rector",
|
|
Short: "Run Rector for automated PHP code refactoring",
|
|
Long: "Run Rector to apply automated code refactoring rules. Uses dry-run by default; pass --fix to apply changes.",
|
|
RunE: func(cmd *cli.Command, args []string) error {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !detect.IsPHPProject(cwd) {
|
|
return cli.Err("not a PHP project (no composer.json found)")
|
|
}
|
|
|
|
if !php.DetectRector(cwd) {
|
|
return cli.Err("Rector not found (install: composer require rector/rector --dev)")
|
|
}
|
|
|
|
mode := "dry-run"
|
|
if phpRectorFix {
|
|
mode = "apply"
|
|
}
|
|
cli.Print("%s %s\n", headerStyle.Render("Rector Refactoring"), dimStyle.Render(fmt.Sprintf("(%s)", mode)))
|
|
cli.Blank()
|
|
|
|
err = php.RunRector(context.Background(), php.RectorOptions{
|
|
Dir: cwd,
|
|
Fix: phpRectorFix,
|
|
Diff: phpRectorDiff,
|
|
ClearCache: phpRectorClearCache,
|
|
})
|
|
if err != nil {
|
|
return cli.Err("Rector found refactoring suggestions")
|
|
}
|
|
|
|
cli.Blank()
|
|
cli.Print("%s\n", successStyle.Render("Rector check passed"))
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVar(&phpRectorFix, "fix", false, "Apply refactoring changes")
|
|
cmd.Flags().BoolVar(&phpRectorDiff, "diff", false, "Show detailed diff of changes")
|
|
cmd.Flags().BoolVar(&phpRectorClearCache, "clear-cache", false, "Clear cache before running")
|
|
|
|
parent.AddCommand(cmd)
|
|
}
|
|
|
|
// PHP infection command flags.
|
|
var (
|
|
phpInfectionMinMSI int
|
|
phpInfectionMinCoveredMSI int
|
|
phpInfectionThreads int
|
|
phpInfectionFilter string
|
|
phpInfectionOnlyCovered bool
|
|
)
|
|
|
|
func addPHPInfectionCommand(parent *cli.Command) {
|
|
cmd := &cli.Command{
|
|
Use: "infection",
|
|
Short: "Run Infection mutation testing",
|
|
Long: "Run Infection to test mutation coverage. Mutates code and verifies tests catch the mutations.",
|
|
RunE: func(cmd *cli.Command, args []string) error {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !detect.IsPHPProject(cwd) {
|
|
return cli.Err("not a PHP project (no composer.json found)")
|
|
}
|
|
|
|
if !php.DetectInfection(cwd) {
|
|
return cli.Err("Infection not found (install: composer require infection/infection --dev)")
|
|
}
|
|
|
|
cli.Print("%s\n", headerStyle.Render("Mutation Testing"))
|
|
cli.Blank()
|
|
|
|
err = php.RunInfection(context.Background(), php.InfectionOptions{
|
|
Dir: cwd,
|
|
MinMSI: phpInfectionMinMSI,
|
|
MinCoveredMSI: phpInfectionMinCoveredMSI,
|
|
Threads: phpInfectionThreads,
|
|
Filter: phpInfectionFilter,
|
|
OnlyCovered: phpInfectionOnlyCovered,
|
|
})
|
|
if err != nil {
|
|
return cli.Err("mutation testing did not pass minimum thresholds")
|
|
}
|
|
|
|
cli.Blank()
|
|
cli.Print("%s\n", successStyle.Render("Mutation testing passed"))
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cmd.Flags().IntVar(&phpInfectionMinMSI, "min-msi", 0, "Minimum mutation score indicator (0-100, default 50)")
|
|
cmd.Flags().IntVar(&phpInfectionMinCoveredMSI, "min-covered-msi", 0, "Minimum covered mutation score (0-100, default 70)")
|
|
cmd.Flags().IntVar(&phpInfectionThreads, "threads", 0, "Number of parallel threads (default 4)")
|
|
cmd.Flags().StringVar(&phpInfectionFilter, "filter", "", "Filter files by pattern")
|
|
cmd.Flags().BoolVar(&phpInfectionOnlyCovered, "only-covered", false, "Only mutate covered code")
|
|
|
|
parent.AddCommand(cmd)
|
|
}
|
|
|
|
// PHP test command flags.
|
|
var (
|
|
phpTestParallel bool
|
|
phpTestCoverage bool
|
|
phpTestFilter string
|
|
phpTestGroup string
|
|
phpTestJUnit bool
|
|
)
|
|
|
|
func addPHPTestCommand(parent *cli.Command) {
|
|
cmd := &cli.Command{
|
|
Use: "test",
|
|
Short: "Run PHP tests with Pest or PHPUnit",
|
|
Long: "Detect and run the PHP test suite. Automatically detects Pest or PHPUnit.",
|
|
RunE: func(cmd *cli.Command, args []string) error {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !detect.IsPHPProject(cwd) {
|
|
return cli.Err("not a PHP project (no composer.json found)")
|
|
}
|
|
|
|
runner := php.DetectTestRunner(cwd)
|
|
cli.Print("%s %s\n", headerStyle.Render("PHP Tests"), dimStyle.Render(fmt.Sprintf("(%s)", runner)))
|
|
cli.Blank()
|
|
|
|
var groups []string
|
|
if phpTestGroup != "" {
|
|
groups = strings.Split(phpTestGroup, ",")
|
|
}
|
|
|
|
err = php.RunTests(context.Background(), php.TestOptions{
|
|
Dir: cwd,
|
|
Parallel: phpTestParallel,
|
|
Coverage: phpTestCoverage,
|
|
Filter: phpTestFilter,
|
|
Groups: groups,
|
|
JUnit: phpTestJUnit,
|
|
})
|
|
if err != nil {
|
|
return cli.Err("tests failed")
|
|
}
|
|
|
|
cli.Blank()
|
|
cli.Print("%s\n", successStyle.Render("All tests passed"))
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVar(&phpTestParallel, "parallel", false, "Run tests in parallel")
|
|
cmd.Flags().BoolVar(&phpTestCoverage, "coverage", false, "Generate code coverage")
|
|
cmd.Flags().StringVar(&phpTestFilter, "filter", "", "Filter tests by name pattern")
|
|
cmd.Flags().StringVar(&phpTestGroup, "group", "", "Run only tests in specified groups (comma-separated)")
|
|
cmd.Flags().BoolVar(&phpTestJUnit, "junit", false, "Output results in JUnit XML format")
|
|
|
|
parent.AddCommand(cmd)
|
|
}
|
|
|
|
// getSeverityStyle returns a style for the given severity level.
|
|
func getSeverityStyle(severity string) *cli.AnsiStyle {
|
|
switch strings.ToLower(severity) {
|
|
case "critical":
|
|
return criticalStyle
|
|
case "high":
|
|
return highStyle
|
|
case "medium":
|
|
return mediumStyle
|
|
case "low":
|
|
return lowStyle
|
|
default:
|
|
return dimStyle
|
|
}
|
|
}
|