diff --git a/cmd/qa/cmd_php.go b/cmd/qa/cmd_php.go new file mode 100644 index 0000000..d492f58 --- /dev/null +++ b/cmd/qa/cmd_php.go @@ -0,0 +1,551 @@ +// 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 + } +} diff --git a/cmd/qa/cmd_qa.go b/cmd/qa/cmd_qa.go index eb7da63..a5ba43d 100644 --- a/cmd/qa/cmd_qa.go +++ b/cmd/qa/cmd_qa.go @@ -36,10 +36,13 @@ func AddQACommands(root *cli.Command) { } root.AddCommand(qaCmd) - // Subcommands + // Go-focused subcommands addWatchCommand(qaCmd) addReviewCommand(qaCmd) addHealthCommand(qaCmd) addIssuesCommand(qaCmd) addDocblockCommand(qaCmd) + + // PHP subcommands + addPHPCommands(qaCmd) }