// 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 } }