From a9c1afe4924589d25b2a178c3be196418ea89249 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 13:27:08 +0000 Subject: [PATCH] =?UTF-8?q?refactor(php):=20remove=20QA=20CLI=20commands?= =?UTF-8?q?=20=E2=80=94=20moved=20to=20core/lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA subcommands (fmt, stan, psalm, audit, security, rector, infection, test, qa) now live in core/lint cmd/qa/. Library code (quality.go, testing.go) retained for cmd_ci.go. Co-Authored-By: Claude Opus 4.6 --- cmd.go | 26 +- cmd_qa_runner.go | 343 ----------------- cmd_quality.go | 814 --------------------------------------- quality_extended_test.go | 304 --------------- quality_test.go | 517 ------------------------- testing_test.go | 380 ------------------ 6 files changed, 1 insertion(+), 2383 deletions(-) delete mode 100644 cmd_qa_runner.go delete mode 100644 cmd_quality.go delete mode 100644 quality_extended_test.go delete mode 100644 quality_test.go delete mode 100644 testing_test.go diff --git a/cmd.go b/cmd.go index ac5fdfd..14bcd4b 100644 --- a/cmd.go +++ b/cmd.go @@ -49,20 +49,9 @@ var ( phpStatusError = cli.ErrorStyle ) -// QA command styles (from shared) +// QA command styles (from shared) — most moved to core/lint var ( - phpQAPassedStyle = cli.SuccessStyle - phpQAFailedStyle = cli.ErrorStyle phpQAWarningStyle = cli.WarningStyle - phpQAStageStyle = cli.HeaderStyle -) - -// Security severity styles (from shared) -var ( - phpSecurityCriticalStyle = cli.NewStyle().Bold().Foreground(cli.ColourRed500) - phpSecurityHighStyle = cli.NewStyle().Bold().Foreground(cli.ColourOrange500) - phpSecurityMediumStyle = cli.NewStyle().Foreground(cli.ColourAmber500) - phpSecurityLowStyle = cli.NewStyle().Foreground(cli.ColourGray500) ) // AddPHPCommands adds PHP/Laravel development commands. @@ -128,19 +117,6 @@ func AddPHPCommands(root *cli.Command) { addPHPServeCommand(phpCmd) addPHPShellCommand(phpCmd) - // Quality (existing) - addPHPTestCommand(phpCmd) - addPHPFmtCommand(phpCmd) - addPHPStanCommand(phpCmd) - - // Quality (new) - addPHPPsalmCommand(phpCmd) - addPHPAuditCommand(phpCmd) - addPHPSecurityCommand(phpCmd) - addPHPQACommand(phpCmd) - addPHPRectorCommand(phpCmd) - addPHPInfectionCommand(phpCmd) - // CI/CD Integration addPHPCICommand(phpCmd) diff --git a/cmd_qa_runner.go b/cmd_qa_runner.go deleted file mode 100644 index 3d31091..0000000 --- a/cmd_qa_runner.go +++ /dev/null @@ -1,343 +0,0 @@ -package php - -import ( - "context" - "path/filepath" - "strings" - "sync" - - "forge.lthn.ai/core/cli/pkg/cli" - "forge.lthn.ai/core/go/pkg/core" - "forge.lthn.ai/core/go-i18n" - "forge.lthn.ai/core/go-process" -) - -// QARunner orchestrates PHP QA checks using pkg/process. -type QARunner struct { - dir string - fix bool - service *process.Service - core *core.Core - - // Output tracking - outputMu sync.Mutex - checkOutputs map[string][]string -} - -// NewQARunner creates a QA runner for the given directory. -func NewQARunner(dir string, fix bool) (*QARunner, error) { - // Create a Core with process service for the QA session - app, err := core.New( - core.WithName("process", process.NewService(process.Options{})), - ) - if err != nil { - return nil, cli.WrapVerb(err, "create", "process service") - } - - svc, err := core.ServiceFor[*process.Service](app, "process") - if err != nil { - return nil, cli.WrapVerb(err, "get", "process service") - } - - runner := &QARunner{ - dir: dir, - fix: fix, - service: svc, - core: app, - checkOutputs: make(map[string][]string), - } - - return runner, nil -} - -// BuildSpecs creates RunSpecs for the given QA checks. -func (r *QARunner) BuildSpecs(checks []string) []process.RunSpec { - specs := make([]process.RunSpec, 0, len(checks)) - - for _, check := range checks { - spec := r.buildSpec(check) - if spec != nil { - specs = append(specs, *spec) - } - } - - return specs -} - -// buildSpec creates a RunSpec for a single check. -func (r *QARunner) buildSpec(check string) *process.RunSpec { - switch check { - case "audit": - return &process.RunSpec{ - Name: "audit", - Command: "composer", - Args: []string{"audit", "--format=summary"}, - Dir: r.dir, - } - - case "fmt": - m := getMedium() - formatter, found := DetectFormatter(r.dir) - if !found { - return nil - } - if formatter == FormatterPint { - vendorBin := filepath.Join(r.dir, "vendor", "bin", "pint") - cmd := "pint" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - args := []string{} - if !r.fix { - args = append(args, "--test") - } - return &process.RunSpec{ - Name: "fmt", - Command: cmd, - Args: args, - Dir: r.dir, - After: []string{"audit"}, - } - } - return nil - - case "stan": - m := getMedium() - _, found := DetectAnalyser(r.dir) - if !found { - return nil - } - vendorBin := filepath.Join(r.dir, "vendor", "bin", "phpstan") - cmd := "phpstan" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - return &process.RunSpec{ - Name: "stan", - Command: cmd, - Args: []string{"analyse", "--no-progress"}, - Dir: r.dir, - After: []string{"fmt"}, - } - - case "psalm": - m := getMedium() - _, found := DetectPsalm(r.dir) - if !found { - return nil - } - vendorBin := filepath.Join(r.dir, "vendor", "bin", "psalm") - cmd := "psalm" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - args := []string{"--no-progress"} - if r.fix { - args = append(args, "--alter", "--issues=all") - } - return &process.RunSpec{ - Name: "psalm", - Command: cmd, - Args: args, - Dir: r.dir, - After: []string{"stan"}, - } - - case "test": - m := getMedium() - // Check for Pest first, fall back to PHPUnit - pestBin := filepath.Join(r.dir, "vendor", "bin", "pest") - phpunitBin := filepath.Join(r.dir, "vendor", "bin", "phpunit") - - var cmd string - if m.IsFile(pestBin) { - cmd = pestBin - } else if m.IsFile(phpunitBin) { - cmd = phpunitBin - } else { - return nil - } - - // Tests depend on stan (or psalm if available) - after := []string{"stan"} - if _, found := DetectPsalm(r.dir); found { - after = []string{"psalm"} - } - - return &process.RunSpec{ - Name: "test", - Command: cmd, - Args: []string{}, - Dir: r.dir, - After: after, - } - - case "rector": - m := getMedium() - if !DetectRector(r.dir) { - return nil - } - vendorBin := filepath.Join(r.dir, "vendor", "bin", "rector") - cmd := "rector" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - args := []string{"process"} - if !r.fix { - args = append(args, "--dry-run") - } - return &process.RunSpec{ - Name: "rector", - Command: cmd, - Args: args, - Dir: r.dir, - After: []string{"test"}, - AllowFailure: true, // Dry-run returns non-zero if changes would be made - } - - case "infection": - m := getMedium() - if !DetectInfection(r.dir) { - return nil - } - vendorBin := filepath.Join(r.dir, "vendor", "bin", "infection") - cmd := "infection" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - return &process.RunSpec{ - Name: "infection", - Command: cmd, - Args: []string{"--min-msi=50", "--min-covered-msi=70", "--threads=4"}, - Dir: r.dir, - After: []string{"test"}, - AllowFailure: true, - } - } - - return nil -} - -// Run executes all QA checks and returns the results. -func (r *QARunner) Run(ctx context.Context, stages []QAStage) (*QARunResult, error) { - // Collect all checks from all stages - var allChecks []string - for _, stage := range stages { - checks := GetQAChecks(r.dir, stage) - allChecks = append(allChecks, checks...) - } - - if len(allChecks) == 0 { - return &QARunResult{Passed: true}, nil - } - - // Build specs - specs := r.BuildSpecs(allChecks) - if len(specs) == 0 { - return &QARunResult{Passed: true}, nil - } - - // Register output handler - r.core.RegisterAction(func(c *core.Core, msg core.Message) error { - switch m := msg.(type) { - case process.ActionProcessOutput: - r.outputMu.Lock() - // Extract check name from process ID mapping - for _, spec := range specs { - if strings.Contains(m.ID, spec.Name) || m.ID != "" { - // Store output for later display if needed - r.checkOutputs[spec.Name] = append(r.checkOutputs[spec.Name], m.Line) - break - } - } - r.outputMu.Unlock() - } - return nil - }) - - // Create runner and execute - runner := process.NewRunner(r.service) - result, err := runner.RunAll(ctx, specs) - if err != nil { - return nil, err - } - - // Convert to QA result - qaResult := &QARunResult{ - Passed: result.Success(), - Duration: result.Duration.String(), - Results: make([]QACheckRunResult, 0, len(result.Results)), - } - - for _, res := range result.Results { - qaResult.Results = append(qaResult.Results, QACheckRunResult{ - Name: res.Name, - Passed: res.Passed(), - Skipped: res.Skipped, - ExitCode: res.ExitCode, - Duration: res.Duration.String(), - Output: res.Output, - }) - if res.Passed() { - qaResult.PassedCount++ - } else if res.Skipped { - qaResult.SkippedCount++ - } else { - qaResult.FailedCount++ - } - } - - return qaResult, nil -} - -// GetCheckOutput returns captured output for a check. -func (r *QARunner) GetCheckOutput(check string) []string { - r.outputMu.Lock() - defer r.outputMu.Unlock() - return r.checkOutputs[check] -} - -// QARunResult holds the results of running QA checks. -type QARunResult struct { - Passed bool `json:"passed"` - Duration string `json:"duration"` - Results []QACheckRunResult `json:"results"` - PassedCount int `json:"passed_count"` - FailedCount int `json:"failed_count"` - SkippedCount int `json:"skipped_count"` -} - -// QACheckRunResult holds the result of a single QA check. -type QACheckRunResult struct { - Name string `json:"name"` - Passed bool `json:"passed"` - Skipped bool `json:"skipped"` - ExitCode int `json:"exit_code"` - Duration string `json:"duration"` - Output string `json:"output,omitempty"` -} - -// GetIssueMessage returns an issue message for a check. -func (r QACheckRunResult) GetIssueMessage() string { - if r.Passed || r.Skipped { - return "" - } - switch r.Name { - case "audit": - return i18n.T("i18n.done.find", "vulnerabilities") - case "fmt": - return i18n.T("i18n.done.find", "style issues") - case "stan": - return i18n.T("i18n.done.find", "analysis errors") - case "psalm": - return i18n.T("i18n.done.find", "type errors") - case "test": - return i18n.T("i18n.done.fail", "tests") - case "rector": - return i18n.T("i18n.done.find", "refactoring suggestions") - case "infection": - return i18n.T("i18n.fail.pass", "mutation testing") - default: - return i18n.T("i18n.done.find", "issues") - } -} diff --git a/cmd_quality.go b/cmd_quality.go deleted file mode 100644 index 709955e..0000000 --- a/cmd_quality.go +++ /dev/null @@ -1,814 +0,0 @@ -package php - -import ( - "context" - "encoding/json" - "errors" - "os" - "strings" - - "forge.lthn.ai/core/cli/pkg/cli" - "forge.lthn.ai/core/go-i18n" -) - -var ( - testParallel bool - testCoverage bool - testFilter string - testGroup string - testJSON bool -) - -func addPHPTestCommand(parent *cli.Command) { - testCmd := &cli.Command{ - Use: "test", - Short: i18n.T("cmd.php.test.short"), - Long: i18n.T("cmd.php.test.long"), - RunE: func(cmd *cli.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - if !testJSON { - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "tests")) - } - - ctx := context.Background() - - opts := TestOptions{ - Dir: cwd, - Filter: testFilter, - Parallel: testParallel, - Coverage: testCoverage, - JUnit: testJSON, - Output: os.Stdout, - } - - if testGroup != "" { - opts.Groups = []string{testGroup} - } - - if err := RunTests(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.run", "tests"), err) - } - - return nil - }, - } - - testCmd.Flags().BoolVar(&testParallel, "parallel", false, i18n.T("cmd.php.test.flag.parallel")) - testCmd.Flags().BoolVar(&testCoverage, "coverage", false, i18n.T("cmd.php.test.flag.coverage")) - testCmd.Flags().StringVar(&testFilter, "filter", "", i18n.T("cmd.php.test.flag.filter")) - testCmd.Flags().StringVar(&testGroup, "group", "", i18n.T("cmd.php.test.flag.group")) - testCmd.Flags().BoolVar(&testJSON, "junit", false, i18n.T("cmd.php.test.flag.junit")) - - parent.AddCommand(testCmd) -} - -var ( - fmtFix bool - fmtDiff bool - fmtJSON bool -) - -func addPHPFmtCommand(parent *cli.Command) { - fmtCmd := &cli.Command{ - Use: "fmt [paths...]", - Short: i18n.T("cmd.php.fmt.short"), - Long: i18n.T("cmd.php.fmt.long"), - RunE: func(cmd *cli.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Detect formatter - formatter, found := DetectFormatter(cwd) - if !found { - return errors.New(i18n.T("cmd.php.fmt.no_formatter")) - } - - if !fmtJSON { - var msg string - if fmtFix { - msg = i18n.T("cmd.php.fmt.formatting", map[string]interface{}{"Formatter": formatter}) - } else { - msg = i18n.ProgressSubject("check", "code style") - } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), msg) - } - - ctx := context.Background() - - opts := FormatOptions{ - Dir: cwd, - Fix: fmtFix, - Diff: fmtDiff, - JSON: fmtJSON, - Output: os.Stdout, - } - - // Get any additional paths from args - if len(args) > 0 { - opts.Paths = args - } - - if err := Format(ctx, opts); err != nil { - if fmtFix { - return cli.Err("%s: %w", i18n.T("cmd.php.error.fmt_failed"), err) - } - return cli.Err("%s: %w", i18n.T("cmd.php.error.fmt_issues"), err) - } - - if !fmtJSON { - if fmtFix { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Code formatted"})) - } else { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.fmt.no_issues")) - } - } - - return nil - }, - } - - fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("cmd.php.fmt.flag.fix")) - fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, i18n.T("common.flag.diff")) - fmtCmd.Flags().BoolVar(&fmtJSON, "json", false, i18n.T("common.flag.json")) - - parent.AddCommand(fmtCmd) -} - -var ( - stanLevel int - stanMemory string - stanJSON bool - stanSARIF bool -) - -func addPHPStanCommand(parent *cli.Command) { - stanCmd := &cli.Command{ - Use: "stan [paths...]", - Short: i18n.T("cmd.php.analyse.short"), - Long: i18n.T("cmd.php.analyse.long"), - RunE: func(cmd *cli.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Detect analyser - _, found := DetectAnalyser(cwd) - if !found { - return errors.New(i18n.T("cmd.php.analyse.no_analyser")) - } - - if stanJSON && stanSARIF { - return errors.New(i18n.T("common.error.json_sarif_exclusive")) - } - - if !stanJSON && !stanSARIF { - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "static analysis")) - } - - ctx := context.Background() - - opts := AnalyseOptions{ - Dir: cwd, - Level: stanLevel, - Memory: stanMemory, - JSON: stanJSON, - SARIF: stanSARIF, - Output: os.Stdout, - } - - // Get any additional paths from args - if len(args) > 0 { - opts.Paths = args - } - - if err := Analyse(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.analysis_issues"), err) - } - - if !stanJSON && !stanSARIF { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues")) - } - return nil - }, - } - - stanCmd.Flags().IntVar(&stanLevel, "level", 0, i18n.T("cmd.php.analyse.flag.level")) - stanCmd.Flags().StringVar(&stanMemory, "memory", "", i18n.T("cmd.php.analyse.flag.memory")) - stanCmd.Flags().BoolVar(&stanJSON, "json", false, i18n.T("common.flag.json")) - stanCmd.Flags().BoolVar(&stanSARIF, "sarif", false, i18n.T("common.flag.sarif")) - - parent.AddCommand(stanCmd) -} - -// ============================================================================= -// New QA Commands -// ============================================================================= - -var ( - psalmLevel int - psalmFix bool - psalmBaseline bool - psalmShowInfo bool - psalmJSON bool - psalmSARIF bool -) - -func addPHPPsalmCommand(parent *cli.Command) { - psalmCmd := &cli.Command{ - Use: "psalm", - Short: i18n.T("cmd.php.psalm.short"), - Long: i18n.T("cmd.php.psalm.long"), - RunE: func(cmd *cli.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Check if Psalm is available - _, found := DetectPsalm(cwd) - if !found { - cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.psalm.not_found")) - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.psalm.install")) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.setup")), i18n.T("cmd.php.psalm.setup")) - return errors.New(i18n.T("cmd.php.error.psalm_not_installed")) - } - - if psalmJSON && psalmSARIF { - return errors.New(i18n.T("common.error.json_sarif_exclusive")) - } - - if !psalmJSON && !psalmSARIF { - var msg string - if psalmFix { - msg = i18n.T("cmd.php.psalm.analysing_fixing") - } else { - msg = i18n.T("cmd.php.psalm.analysing") - } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.psalm")), msg) - } - - ctx := context.Background() - - opts := PsalmOptions{ - Dir: cwd, - Level: psalmLevel, - Fix: psalmFix, - Baseline: psalmBaseline, - ShowInfo: psalmShowInfo, - JSON: psalmJSON, - SARIF: psalmSARIF, - Output: os.Stdout, - } - - if err := RunPsalm(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.psalm_issues"), err) - } - - if !psalmJSON && !psalmSARIF { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues")) - } - return nil - }, - } - - psalmCmd.Flags().IntVar(&psalmLevel, "level", 0, i18n.T("cmd.php.psalm.flag.level")) - psalmCmd.Flags().BoolVar(&psalmFix, "fix", false, i18n.T("common.flag.fix")) - psalmCmd.Flags().BoolVar(&psalmBaseline, "baseline", false, i18n.T("cmd.php.psalm.flag.baseline")) - psalmCmd.Flags().BoolVar(&psalmShowInfo, "show-info", false, i18n.T("cmd.php.psalm.flag.show_info")) - psalmCmd.Flags().BoolVar(&psalmJSON, "json", false, i18n.T("common.flag.json")) - psalmCmd.Flags().BoolVar(&psalmSARIF, "sarif", false, i18n.T("common.flag.sarif")) - - parent.AddCommand(psalmCmd) -} - -var ( - auditJSONOutput bool - auditFix bool -) - -func addPHPAuditCommand(parent *cli.Command) { - auditCmd := &cli.Command{ - Use: "audit", - Short: i18n.T("cmd.php.audit.short"), - Long: i18n.T("cmd.php.audit.long"), - RunE: func(cmd *cli.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.audit")), i18n.T("cmd.php.audit.scanning")) - - ctx := context.Background() - - results, err := RunAudit(ctx, AuditOptions{ - Dir: cwd, - JSON: auditJSONOutput, - Fix: auditFix, - Output: os.Stdout, - }) - if err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.audit_failed"), err) - } - - // Print results - totalVulns := 0 - hasErrors := false - - for _, result := range results { - icon := successStyle.Render("✓") - status := successStyle.Render(i18n.T("cmd.php.audit.secure")) - - if result.Error != nil { - icon = errorStyle.Render("✗") - status = errorStyle.Render(i18n.T("cmd.php.audit.error")) - hasErrors = true - } else if result.Vulnerabilities > 0 { - icon = errorStyle.Render("✗") - status = errorStyle.Render(i18n.T("cmd.php.audit.vulnerabilities", map[string]interface{}{"Count": result.Vulnerabilities})) - totalVulns += result.Vulnerabilities - } - - cli.Print(" %s %s %s\n", icon, dimStyle.Render(result.Tool+":"), status) - - // Show advisories - for _, adv := range result.Advisories { - severity := adv.Severity - if severity == "" { - severity = "unknown" - } - sevStyle := getSeverityStyle(severity) - cli.Print(" %s %s\n", sevStyle.Render("["+severity+"]"), adv.Package) - if adv.Title != "" { - cli.Print(" %s\n", dimStyle.Render(adv.Title)) - } - } - } - - cli.Blank() - - if totalVulns > 0 { - cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.audit.found_vulns", map[string]interface{}{"Count": totalVulns})) - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("fix")), i18n.T("common.hint.fix_deps")) - return errors.New(i18n.T("cmd.php.error.vulns_found")) - } - - if hasErrors { - return errors.New(i18n.T("cmd.php.audit.completed_errors")) - } - - cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.audit.all_secure")) - return nil - }, - } - - auditCmd.Flags().BoolVar(&auditJSONOutput, "json", false, i18n.T("common.flag.json")) - auditCmd.Flags().BoolVar(&auditFix, "fix", false, i18n.T("cmd.php.audit.flag.fix")) - - parent.AddCommand(auditCmd) -} - -var ( - securitySeverity string - securityJSONOutput bool - securitySarif bool - securityURL string -) - -func addPHPSecurityCommand(parent *cli.Command) { - securityCmd := &cli.Command{ - Use: "security", - Short: i18n.T("cmd.php.security.short"), - Long: i18n.T("cmd.php.security.long"), - RunE: func(cmd *cli.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.security")), i18n.ProgressSubject("run", "security checks")) - - ctx := context.Background() - - result, err := RunSecurityChecks(ctx, SecurityOptions{ - Dir: cwd, - Severity: securitySeverity, - JSON: securityJSONOutput, - SARIF: securitySarif, - URL: securityURL, - Output: os.Stdout, - }) - if err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.security_failed"), err) - } - - // Print results by category - currentCategory := "" - for _, check := range result.Checks { - category := strings.Split(check.ID, "_")[0] - if category != currentCategory { - if currentCategory != "" { - cli.Blank() - } - currentCategory = category - cli.Print(" %s\n", dimStyle.Render(strings.ToUpper(category)+i18n.T("cmd.php.security.checks_suffix"))) - } - - icon := successStyle.Render("✓") - if !check.Passed { - icon = getSeverityStyle(check.Severity).Render("✗") - } - - cli.Print(" %s %s\n", icon, check.Name) - if !check.Passed && check.Message != "" { - cli.Print(" %s\n", dimStyle.Render(check.Message)) - if check.Fix != "" { - cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("fix")), check.Fix) - } - } - } - - cli.Blank() - - // Print summary - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("summary")), i18n.T("cmd.php.security.summary")) - cli.Print(" %s %d/%d\n", dimStyle.Render(i18n.T("cmd.php.security.passed")), result.Summary.Passed, result.Summary.Total) - - if result.Summary.Critical > 0 { - cli.Print(" %s %d\n", phpSecurityCriticalStyle.Render(i18n.T("cmd.php.security.critical")), result.Summary.Critical) - } - if result.Summary.High > 0 { - cli.Print(" %s %d\n", phpSecurityHighStyle.Render(i18n.T("cmd.php.security.high")), result.Summary.High) - } - if result.Summary.Medium > 0 { - cli.Print(" %s %d\n", phpSecurityMediumStyle.Render(i18n.T("cmd.php.security.medium")), result.Summary.Medium) - } - if result.Summary.Low > 0 { - cli.Print(" %s %d\n", phpSecurityLowStyle.Render(i18n.T("cmd.php.security.low")), result.Summary.Low) - } - - if result.Summary.Critical > 0 || result.Summary.High > 0 { - return errors.New(i18n.T("cmd.php.error.critical_high_issues")) - } - - return nil - }, - } - - securityCmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.php.security.flag.severity")) - securityCmd.Flags().BoolVar(&securityJSONOutput, "json", false, i18n.T("common.flag.json")) - securityCmd.Flags().BoolVar(&securitySarif, "sarif", false, i18n.T("cmd.php.security.flag.sarif")) - securityCmd.Flags().StringVar(&securityURL, "url", "", i18n.T("cmd.php.security.flag.url")) - - parent.AddCommand(securityCmd) -} - -var ( - qaQuick bool - qaFull bool - qaFix bool - qaJSON bool -) - -func addPHPQACommand(parent *cli.Command) { - qaCmd := &cli.Command{ - Use: "qa", - Short: i18n.T("cmd.php.qa.short"), - Long: i18n.T("cmd.php.qa.long"), - RunE: func(cmd *cli.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Determine stages - opts := QAOptions{ - Dir: cwd, - Quick: qaQuick, - Full: qaFull, - Fix: qaFix, - JSON: qaJSON, - } - stages := GetQAStages(opts) - - // Print header - if !qaJSON { - cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "QA pipeline")) - } - - ctx := context.Background() - - // Create QA runner using pkg/process - runner, err := NewQARunner(cwd, qaFix) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.create", "QA runner"), err) - } - - // Run all checks with dependency ordering - result, err := runner.Run(ctx, stages) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.run", "QA checks"), err) - } - - // Display results by stage (skip when JSON output is enabled) - if !qaJSON { - currentStage := "" - for _, checkResult := range result.Results { - // Determine stage for this check - stage := getCheckStage(checkResult.Name, stages, cwd) - if stage != currentStage { - if currentStage != "" { - cli.Blank() - } - currentStage = stage - cli.Print("%s\n", phpQAStageStyle.Render("── "+strings.ToUpper(stage)+" ──")) - } - - icon := phpQAPassedStyle.Render("✓") - status := phpQAPassedStyle.Render(i18n.T("i18n.done.pass")) - if checkResult.Skipped { - icon = dimStyle.Render("-") - status = dimStyle.Render(i18n.T("i18n.done.skip")) - } else if !checkResult.Passed { - icon = phpQAFailedStyle.Render("✗") - status = phpQAFailedStyle.Render(i18n.T("i18n.done.fail")) - } - - cli.Print(" %s %s %s %s\n", icon, checkResult.Name, status, dimStyle.Render(checkResult.Duration)) - } - cli.Blank() - - // Print summary - if result.Passed { - cli.Print("%s %s\n", phpQAPassedStyle.Render("QA PASSED:"), i18n.T("i18n.count.check", result.PassedCount)+" "+i18n.T("i18n.done.pass")) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("i18n.label.duration")), result.Duration) - return nil - } - - cli.Print("%s %s\n\n", phpQAFailedStyle.Render("QA FAILED:"), i18n.T("i18n.count.check", result.PassedCount)+"/"+cli.Sprint(len(result.Results))+" "+i18n.T("i18n.done.pass")) - - // Show what needs fixing - cli.Print("%s\n", dimStyle.Render(i18n.T("i18n.label.fix"))) - for _, checkResult := range result.Results { - if checkResult.Passed || checkResult.Skipped { - continue - } - fixCmd := getQAFixCommand(checkResult.Name, qaFix) - issue := checkResult.GetIssueMessage() - if issue == "" { - issue = "issues found" - } - cli.Print(" %s %s\n", phpQAFailedStyle.Render("*"), checkResult.Name+": "+issue) - if fixCmd != "" { - cli.Print(" %s %s\n", dimStyle.Render("->"), fixCmd) - } - } - - return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline")) - } - - // JSON mode: output results as JSON - output, err := json.MarshalIndent(result, "", " ") - if err != nil { - return cli.Wrap(err, "marshal JSON output") - } - cli.Text(string(output)) - - if !result.Passed { - return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline")) - } - return nil - }, - } - - qaCmd.Flags().BoolVar(&qaQuick, "quick", false, i18n.T("cmd.php.qa.flag.quick")) - qaCmd.Flags().BoolVar(&qaFull, "full", false, i18n.T("cmd.php.qa.flag.full")) - qaCmd.Flags().BoolVar(&qaFix, "fix", false, i18n.T("common.flag.fix")) - qaCmd.Flags().BoolVar(&qaJSON, "json", false, i18n.T("common.flag.json")) - - parent.AddCommand(qaCmd) -} - -// getCheckStage determines which stage a check belongs to. -func getCheckStage(checkName string, stages []QAStage, dir string) string { - for _, stage := range stages { - checks := GetQAChecks(dir, stage) - for _, c := range checks { - if c == checkName { - return string(stage) - } - } - } - return "unknown" -} - -func getQAFixCommand(checkName string, fixEnabled bool) string { - switch checkName { - case "audit": - return i18n.T("i18n.progress.update", "dependencies") - case "fmt": - if fixEnabled { - return "" - } - return "core php fmt --fix" - case "stan": - return i18n.T("i18n.progress.fix", "PHPStan errors") - case "psalm": - return i18n.T("i18n.progress.fix", "Psalm errors") - case "test": - return i18n.T("i18n.progress.fix", i18n.T("i18n.done.fail")+" tests") - case "rector": - if fixEnabled { - return "" - } - return "core php rector --fix" - case "infection": - return i18n.T("i18n.progress.improve", "test coverage") - } - return "" -} - -var ( - rectorFix bool - rectorDiff bool - rectorClearCache bool -) - -func addPHPRectorCommand(parent *cli.Command) { - rectorCmd := &cli.Command{ - Use: "rector", - Short: i18n.T("cmd.php.rector.short"), - Long: i18n.T("cmd.php.rector.long"), - RunE: func(cmd *cli.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Check if Rector is available - if !DetectRector(cwd) { - cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.rector.not_found")) - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.rector.install")) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.setup")), i18n.T("cmd.php.rector.setup")) - return errors.New(i18n.T("cmd.php.error.rector_not_installed")) - } - - var msg string - if rectorFix { - msg = i18n.T("cmd.php.rector.refactoring") - } else { - msg = i18n.T("cmd.php.rector.analysing") - } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.rector")), msg) - - ctx := context.Background() - - opts := RectorOptions{ - Dir: cwd, - Fix: rectorFix, - Diff: rectorDiff, - ClearCache: rectorClearCache, - Output: os.Stdout, - } - - if err := RunRector(ctx, opts); err != nil { - if rectorFix { - return cli.Err("%s: %w", i18n.T("cmd.php.error.rector_failed"), err) - } - // Dry-run returns non-zero if changes would be made - cli.Print("\n%s %s\n", phpQAWarningStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.rector.changes_suggested")) - return nil - } - - if rectorFix { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Code refactored"})) - } else { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.rector.no_changes")) - } - return nil - }, - } - - rectorCmd.Flags().BoolVar(&rectorFix, "fix", false, i18n.T("cmd.php.rector.flag.fix")) - rectorCmd.Flags().BoolVar(&rectorDiff, "diff", false, i18n.T("cmd.php.rector.flag.diff")) - rectorCmd.Flags().BoolVar(&rectorClearCache, "clear-cache", false, i18n.T("cmd.php.rector.flag.clear_cache")) - - parent.AddCommand(rectorCmd) -} - -var ( - infectionMinMSI int - infectionMinCoveredMSI int - infectionThreads int - infectionFilter string - infectionOnlyCovered bool -) - -func addPHPInfectionCommand(parent *cli.Command) { - infectionCmd := &cli.Command{ - Use: "infection", - Short: i18n.T("cmd.php.infection.short"), - Long: i18n.T("cmd.php.infection.long"), - RunE: func(cmd *cli.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Check if Infection is available - if !DetectInfection(cwd) { - cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.infection.not_found")) - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.infection.install")) - return errors.New(i18n.T("cmd.php.error.infection_not_installed")) - } - - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.infection")), i18n.ProgressSubject("run", "mutation testing")) - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.infection.note")) - - ctx := context.Background() - - opts := InfectionOptions{ - Dir: cwd, - MinMSI: infectionMinMSI, - MinCoveredMSI: infectionMinCoveredMSI, - Threads: infectionThreads, - Filter: infectionFilter, - OnlyCovered: infectionOnlyCovered, - Output: os.Stdout, - } - - if err := RunInfection(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.infection_failed"), err) - } - - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.infection.complete")) - return nil - }, - } - - infectionCmd.Flags().IntVar(&infectionMinMSI, "min-msi", 0, i18n.T("cmd.php.infection.flag.min_msi")) - infectionCmd.Flags().IntVar(&infectionMinCoveredMSI, "min-covered-msi", 0, i18n.T("cmd.php.infection.flag.min_covered_msi")) - infectionCmd.Flags().IntVar(&infectionThreads, "threads", 0, i18n.T("cmd.php.infection.flag.threads")) - infectionCmd.Flags().StringVar(&infectionFilter, "filter", "", i18n.T("cmd.php.infection.flag.filter")) - infectionCmd.Flags().BoolVar(&infectionOnlyCovered, "only-covered", false, i18n.T("cmd.php.infection.flag.only_covered")) - - parent.AddCommand(infectionCmd) -} - -func getSeverityStyle(severity string) *cli.AnsiStyle { - switch strings.ToLower(severity) { - case "critical": - return phpSecurityCriticalStyle - case "high": - return phpSecurityHighStyle - case "medium": - return phpSecurityMediumStyle - case "low": - return phpSecurityLowStyle - default: - return dimStyle - } -} diff --git a/quality_extended_test.go b/quality_extended_test.go deleted file mode 100644 index 8c1c00e..0000000 --- a/quality_extended_test.go +++ /dev/null @@ -1,304 +0,0 @@ -package php - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFormatOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := FormatOptions{ - Dir: "/project", - Fix: true, - Diff: true, - Paths: []string{"app", "tests"}, - Output: os.Stdout, - } - - assert.Equal(t, "/project", opts.Dir) - assert.True(t, opts.Fix) - assert.True(t, opts.Diff) - assert.Equal(t, []string{"app", "tests"}, opts.Paths) - assert.NotNil(t, opts.Output) - }) -} - -func TestAnalyseOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := AnalyseOptions{ - Dir: "/project", - Level: 5, - Paths: []string{"src"}, - Memory: "2G", - Output: os.Stdout, - } - - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, 5, opts.Level) - assert.Equal(t, []string{"src"}, opts.Paths) - assert.Equal(t, "2G", opts.Memory) - assert.NotNil(t, opts.Output) - }) -} - -func TestFormatterType_Constants(t *testing.T) { - t.Run("constants are defined", func(t *testing.T) { - assert.Equal(t, FormatterType("pint"), FormatterPint) - }) -} - -func TestAnalyserType_Constants(t *testing.T) { - t.Run("constants are defined", func(t *testing.T) { - assert.Equal(t, AnalyserType("phpstan"), AnalyserPHPStan) - assert.Equal(t, AnalyserType("larastan"), AnalyserLarastan) - }) -} - -func TestDetectFormatter_Extended(t *testing.T) { - t.Run("returns not found for empty directory", func(t *testing.T) { - dir := t.TempDir() - _, found := DetectFormatter(dir) - assert.False(t, found) - }) - - t.Run("prefers pint.json over vendor binary", func(t *testing.T) { - dir := t.TempDir() - - // Create pint.json - err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644) - require.NoError(t, err) - - formatter, found := DetectFormatter(dir) - assert.True(t, found) - assert.Equal(t, FormatterPint, formatter) - }) -} - -func TestDetectAnalyser_Extended(t *testing.T) { - t.Run("returns not found for empty directory", func(t *testing.T) { - dir := t.TempDir() - _, found := DetectAnalyser(dir) - assert.False(t, found) - }) - - t.Run("detects phpstan from vendor binary alone", func(t *testing.T) { - dir := t.TempDir() - - // Create vendor binary - binDir := filepath.Join(dir, "vendor", "bin") - err := os.MkdirAll(binDir, 0755) - require.NoError(t, err) - - err = os.WriteFile(filepath.Join(binDir, "phpstan"), []byte(""), 0755) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserPHPStan, analyser) - }) - - t.Run("detects larastan from larastan/larastan vendor path", func(t *testing.T) { - dir := t.TempDir() - - // Create phpstan.neon - err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644) - require.NoError(t, err) - - // Create larastan/larastan path - larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan") - err = os.MkdirAll(larastanPath, 0755) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserLarastan, analyser) - }) - - t.Run("detects larastan from nunomaduro/larastan vendor path", func(t *testing.T) { - dir := t.TempDir() - - // Create phpstan.neon - err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644) - require.NoError(t, err) - - // Create nunomaduro/larastan path - larastanPath := filepath.Join(dir, "vendor", "nunomaduro", "larastan") - err = os.MkdirAll(larastanPath, 0755) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserLarastan, analyser) - }) -} - -func TestBuildPintCommand_Extended(t *testing.T) { - t.Run("uses global pint when no vendor binary", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir} - - cmd, _ := buildPintCommand(opts) - assert.Equal(t, "pint", cmd) - }) - - t.Run("adds test flag when Fix is false", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir, Fix: false} - - _, args := buildPintCommand(opts) - assert.Contains(t, args, "--test") - }) - - t.Run("does not add test flag when Fix is true", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir, Fix: true} - - _, args := buildPintCommand(opts) - assert.NotContains(t, args, "--test") - }) - - t.Run("adds diff flag", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir, Diff: true} - - _, args := buildPintCommand(opts) - assert.Contains(t, args, "--diff") - }) - - t.Run("adds paths", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir, Paths: []string{"app", "tests"}} - - _, args := buildPintCommand(opts) - assert.Contains(t, args, "app") - assert.Contains(t, args, "tests") - }) -} - -func TestBuildPHPStanCommand_Extended(t *testing.T) { - t.Run("uses global phpstan when no vendor binary", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir} - - cmd, _ := buildPHPStanCommand(opts) - assert.Equal(t, "phpstan", cmd) - }) - - t.Run("adds level flag", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Level: 8} - - _, args := buildPHPStanCommand(opts) - assert.Contains(t, args, "--level") - assert.Contains(t, args, "8") - }) - - t.Run("does not add level flag when zero", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Level: 0} - - _, args := buildPHPStanCommand(opts) - assert.NotContains(t, args, "--level") - }) - - t.Run("adds memory limit", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Memory: "4G"} - - _, args := buildPHPStanCommand(opts) - assert.Contains(t, args, "--memory-limit") - assert.Contains(t, args, "4G") - }) - - t.Run("does not add memory flag when empty", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Memory: ""} - - _, args := buildPHPStanCommand(opts) - assert.NotContains(t, args, "--memory-limit") - }) - - t.Run("adds paths", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Paths: []string{"src", "app"}} - - _, args := buildPHPStanCommand(opts) - assert.Contains(t, args, "src") - assert.Contains(t, args, "app") - }) -} - -func TestFormat_Bad(t *testing.T) { - t.Run("fails when no formatter found", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir} - - err := Format(context.TODO(), opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no formatter found") - }) - - t.Run("uses cwd when dir not specified", func(t *testing.T) { - // When no formatter found in cwd, should still fail with "no formatter found" - opts := FormatOptions{Dir: ""} - - err := Format(context.TODO(), opts) - // May or may not find a formatter depending on cwd, but function should not panic - if err != nil { - // Expected - no formatter in cwd - assert.Contains(t, err.Error(), "no formatter") - } - }) - - t.Run("uses stdout when output not specified", func(t *testing.T) { - dir := t.TempDir() - // Create pint.json to enable formatter detection - err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644) - require.NoError(t, err) - - opts := FormatOptions{Dir: dir, Output: nil} - - // Will fail because pint isn't actually installed, but tests the code path - err = Format(context.Background(), opts) - assert.Error(t, err) // Pint not installed - }) -} - -func TestAnalyse_Bad(t *testing.T) { - t.Run("fails when no analyser found", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir} - - err := Analyse(context.TODO(), opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no static analyser found") - }) - - t.Run("uses cwd when dir not specified", func(t *testing.T) { - opts := AnalyseOptions{Dir: ""} - - err := Analyse(context.TODO(), opts) - // May or may not find an analyser depending on cwd - if err != nil { - assert.Contains(t, err.Error(), "no static analyser") - } - }) - - t.Run("uses stdout when output not specified", func(t *testing.T) { - dir := t.TempDir() - // Create phpstan.neon to enable analyser detection - err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644) - require.NoError(t, err) - - opts := AnalyseOptions{Dir: dir, Output: nil} - - // Will fail because phpstan isn't actually installed, but tests the code path - err = Analyse(context.Background(), opts) - assert.Error(t, err) // PHPStan not installed - }) -} diff --git a/quality_test.go b/quality_test.go deleted file mode 100644 index 710e3fa..0000000 --- a/quality_test.go +++ /dev/null @@ -1,517 +0,0 @@ -package php - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDetectFormatter_Good(t *testing.T) { - t.Run("detects pint.json", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644) - require.NoError(t, err) - - formatter, found := DetectFormatter(dir) - assert.True(t, found) - assert.Equal(t, FormatterPint, formatter) - }) - - t.Run("detects vendor binary", func(t *testing.T) { - dir := t.TempDir() - binDir := filepath.Join(dir, "vendor", "bin") - err := os.MkdirAll(binDir, 0755) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(binDir, "pint"), []byte(""), 0755) - require.NoError(t, err) - - formatter, found := DetectFormatter(dir) - assert.True(t, found) - assert.Equal(t, FormatterPint, formatter) - }) -} - -func TestDetectFormatter_Bad(t *testing.T) { - t.Run("no formatter", func(t *testing.T) { - dir := t.TempDir() - _, found := DetectFormatter(dir) - assert.False(t, found) - }) -} - -func TestDetectAnalyser_Good(t *testing.T) { - t.Run("detects phpstan.neon", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserPHPStan, analyser) - }) - - t.Run("detects phpstan.neon.dist", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "phpstan.neon.dist"), []byte(""), 0644) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserPHPStan, analyser) - }) - - t.Run("detects larastan", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644) - require.NoError(t, err) - - larastanDir := filepath.Join(dir, "vendor", "larastan", "larastan") - err = os.MkdirAll(larastanDir, 0755) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserLarastan, analyser) - }) - - t.Run("detects nunomaduro/larastan", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644) - require.NoError(t, err) - - larastanDir := filepath.Join(dir, "vendor", "nunomaduro", "larastan") - err = os.MkdirAll(larastanDir, 0755) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserLarastan, analyser) - }) -} - -func TestBuildPintCommand_Good(t *testing.T) { - t.Run("basic command", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir} - cmd, args := buildPintCommand(opts) - assert.Equal(t, "pint", cmd) - assert.Contains(t, args, "--test") - }) - - t.Run("fix enabled", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir, Fix: true} - _, args := buildPintCommand(opts) - assert.NotContains(t, args, "--test") - }) - - t.Run("diff enabled", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir, Diff: true} - _, args := buildPintCommand(opts) - assert.Contains(t, args, "--diff") - }) - - t.Run("with specific paths", func(t *testing.T) { - dir := t.TempDir() - paths := []string{"app", "tests"} - opts := FormatOptions{Dir: dir, Paths: paths} - _, args := buildPintCommand(opts) - assert.Equal(t, paths, args[len(args)-2:]) - }) - - t.Run("uses vendor binary if exists", func(t *testing.T) { - dir := t.TempDir() - binDir := filepath.Join(dir, "vendor", "bin") - err := os.MkdirAll(binDir, 0755) - require.NoError(t, err) - pintPath := filepath.Join(binDir, "pint") - err = os.WriteFile(pintPath, []byte(""), 0755) - require.NoError(t, err) - - opts := FormatOptions{Dir: dir} - cmd, _ := buildPintCommand(opts) - assert.Equal(t, pintPath, cmd) - }) -} - -func TestBuildPHPStanCommand_Good(t *testing.T) { - t.Run("basic command", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir} - cmd, args := buildPHPStanCommand(opts) - assert.Equal(t, "phpstan", cmd) - assert.Equal(t, []string{"analyse"}, args) - }) - - t.Run("with level", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Level: 5} - _, args := buildPHPStanCommand(opts) - assert.Contains(t, args, "--level") - assert.Contains(t, args, "5") - }) - - t.Run("with memory limit", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Memory: "2G"} - _, args := buildPHPStanCommand(opts) - assert.Contains(t, args, "--memory-limit") - assert.Contains(t, args, "2G") - }) - - t.Run("uses vendor binary if exists", func(t *testing.T) { - dir := t.TempDir() - binDir := filepath.Join(dir, "vendor", "bin") - err := os.MkdirAll(binDir, 0755) - require.NoError(t, err) - phpstanPath := filepath.Join(binDir, "phpstan") - err = os.WriteFile(phpstanPath, []byte(""), 0755) - require.NoError(t, err) - - opts := AnalyseOptions{Dir: dir} - cmd, _ := buildPHPStanCommand(opts) - assert.Equal(t, phpstanPath, cmd) - }) -} - -// ============================================================================= -// Psalm Detection Tests -// ============================================================================= - -func TestDetectPsalm_Good(t *testing.T) { - t.Run("detects psalm.xml", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "psalm.xml"), []byte(""), 0644) - require.NoError(t, err) - - // Also need vendor binary for it to return true - binDir := filepath.Join(dir, "vendor", "bin") - err = os.MkdirAll(binDir, 0755) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755) - require.NoError(t, err) - - psalmType, found := DetectPsalm(dir) - assert.True(t, found) - assert.Equal(t, PsalmStandard, psalmType) - }) - - t.Run("detects psalm.xml.dist", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "psalm.xml.dist"), []byte(""), 0644) - require.NoError(t, err) - - binDir := filepath.Join(dir, "vendor", "bin") - err = os.MkdirAll(binDir, 0755) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755) - require.NoError(t, err) - - _, found := DetectPsalm(dir) - assert.True(t, found) - }) - - t.Run("detects vendor binary only", func(t *testing.T) { - dir := t.TempDir() - binDir := filepath.Join(dir, "vendor", "bin") - err := os.MkdirAll(binDir, 0755) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755) - require.NoError(t, err) - - _, found := DetectPsalm(dir) - assert.True(t, found) - }) -} - -func TestDetectPsalm_Bad(t *testing.T) { - t.Run("no psalm", func(t *testing.T) { - dir := t.TempDir() - _, found := DetectPsalm(dir) - assert.False(t, found) - }) -} - -// ============================================================================= -// Rector Detection Tests -// ============================================================================= - -func TestDetectRector_Good(t *testing.T) { - t.Run("detects rector.php", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "rector.php"), []byte("