refactor(php): remove QA CLI commands — moved to core/lint
Some checks failed
CI / PHP 8.3 (push) Failing after 1m55s
CI / PHP 8.4 (push) Failing after 1m55s

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 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-09 13:27:08 +00:00
parent ad8af2fb83
commit a9c1afe492
6 changed files with 1 additions and 2383 deletions

26
cmd.go
View file

@ -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)

View file

@ -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")
}
}

View file

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

View file

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

View file

@ -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("<?php"), 0644)
require.NoError(t, err)
found := DetectRector(dir)
assert.True(t, found)
})
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, "rector"), []byte(""), 0755)
require.NoError(t, err)
found := DetectRector(dir)
assert.True(t, found)
})
}
func TestDetectRector_Bad(t *testing.T) {
t.Run("no rector", func(t *testing.T) {
dir := t.TempDir()
found := DetectRector(dir)
assert.False(t, found)
})
}
// =============================================================================
// Infection Detection Tests
// =============================================================================
func TestDetectInfection_Good(t *testing.T) {
t.Run("detects infection.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "infection.json"), []byte("{}"), 0644)
require.NoError(t, err)
found := DetectInfection(dir)
assert.True(t, found)
})
t.Run("detects infection.json5", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "infection.json5"), []byte("{}"), 0644)
require.NoError(t, err)
found := DetectInfection(dir)
assert.True(t, found)
})
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, "infection"), []byte(""), 0755)
require.NoError(t, err)
found := DetectInfection(dir)
assert.True(t, found)
})
}
func TestDetectInfection_Bad(t *testing.T) {
t.Run("no infection", func(t *testing.T) {
dir := t.TempDir()
found := DetectInfection(dir)
assert.False(t, found)
})
}
// =============================================================================
// QA Pipeline Tests
// =============================================================================
func TestGetQAStages_Good(t *testing.T) {
t.Run("default stages", func(t *testing.T) {
opts := QAOptions{}
stages := GetQAStages(opts)
assert.Equal(t, []QAStage{QAStageQuick, QAStageStandard}, stages)
})
t.Run("quick only", func(t *testing.T) {
opts := QAOptions{Quick: true}
stages := GetQAStages(opts)
assert.Equal(t, []QAStage{QAStageQuick}, stages)
})
t.Run("full stages", func(t *testing.T) {
opts := QAOptions{Full: true}
stages := GetQAStages(opts)
assert.Equal(t, []QAStage{QAStageQuick, QAStageStandard, QAStageFull}, stages)
})
}
func TestGetQAChecks_Good(t *testing.T) {
t.Run("quick stage checks", func(t *testing.T) {
dir := t.TempDir()
checks := GetQAChecks(dir, QAStageQuick)
assert.Contains(t, checks, "audit")
assert.Contains(t, checks, "fmt")
assert.Contains(t, checks, "stan")
})
t.Run("standard stage includes test", func(t *testing.T) {
dir := t.TempDir()
checks := GetQAChecks(dir, QAStageStandard)
assert.Contains(t, checks, "test")
})
t.Run("standard stage includes psalm if available", 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)
checks := GetQAChecks(dir, QAStageStandard)
assert.Contains(t, checks, "psalm")
})
t.Run("full stage includes rector if available", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "rector.php"), []byte("<?php"), 0644)
require.NoError(t, err)
checks := GetQAChecks(dir, QAStageFull)
assert.Contains(t, checks, "rector")
})
}
// =============================================================================
// Security Checks Tests
// =============================================================================
func TestRunEnvSecurityChecks_Good(t *testing.T) {
t.Run("detects debug mode enabled", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_DEBUG=true\nAPP_KEY=base64:abcdefghijklmnopqrstuvwxyz123456\n"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
checks := runEnvSecurityChecks(dir)
var debugCheck *SecurityCheck
for i := range checks {
if checks[i].ID == "debug_mode" {
debugCheck = &checks[i]
break
}
}
require.NotNil(t, debugCheck)
assert.False(t, debugCheck.Passed)
assert.Equal(t, "critical", debugCheck.Severity)
})
t.Run("passes with debug disabled", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_DEBUG=false\nAPP_KEY=base64:abcdefghijklmnopqrstuvwxyz123456\n"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
checks := runEnvSecurityChecks(dir)
var debugCheck *SecurityCheck
for i := range checks {
if checks[i].ID == "debug_mode" {
debugCheck = &checks[i]
break
}
}
require.NotNil(t, debugCheck)
assert.True(t, debugCheck.Passed)
})
t.Run("detects weak app key", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_KEY=short\n"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
checks := runEnvSecurityChecks(dir)
var keyCheck *SecurityCheck
for i := range checks {
if checks[i].ID == "app_key_set" {
keyCheck = &checks[i]
break
}
}
require.NotNil(t, keyCheck)
assert.False(t, keyCheck.Passed)
})
t.Run("detects non-https app url", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_URL=http://example.com\n"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
checks := runEnvSecurityChecks(dir)
var urlCheck *SecurityCheck
for i := range checks {
if checks[i].ID == "https_enforced" {
urlCheck = &checks[i]
break
}
}
require.NotNil(t, urlCheck)
assert.False(t, urlCheck.Passed)
})
}
func TestRunFilesystemSecurityChecks_Good(t *testing.T) {
t.Run("detects .env in public", func(t *testing.T) {
dir := t.TempDir()
publicDir := filepath.Join(dir, "public")
err := os.MkdirAll(publicDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(publicDir, ".env"), []byte(""), 0644)
require.NoError(t, err)
checks := runFilesystemSecurityChecks(dir)
found := false
for _, check := range checks {
if check.ID == "env_not_public" && !check.Passed {
found = true
break
}
}
assert.True(t, found, "should detect .env in public directory")
})
t.Run("detects .git in public", func(t *testing.T) {
dir := t.TempDir()
gitDir := filepath.Join(dir, "public", ".git")
err := os.MkdirAll(gitDir, 0755)
require.NoError(t, err)
checks := runFilesystemSecurityChecks(dir)
found := false
for _, check := range checks {
if check.ID == "git_not_public" && !check.Passed {
found = true
break
}
}
assert.True(t, found, "should detect .git in public directory")
})
t.Run("passes with clean public directory", func(t *testing.T) {
dir := t.TempDir()
publicDir := filepath.Join(dir, "public")
err := os.MkdirAll(publicDir, 0755)
require.NoError(t, err)
// Add only safe files
err = os.WriteFile(filepath.Join(publicDir, "index.php"), []byte("<?php"), 0644)
require.NoError(t, err)
checks := runFilesystemSecurityChecks(dir)
assert.Empty(t, checks, "should not report issues for clean public directory")
})
}

View file

@ -1,380 +0,0 @@
package php
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDetectTestRunner_Good(t *testing.T) {
t.Run("detects Pest when tests/Pest.php exists", func(t *testing.T) {
dir := t.TempDir()
testsDir := filepath.Join(dir, "tests")
err := os.MkdirAll(testsDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(testsDir, "Pest.php"), []byte("<?php\n"), 0644)
require.NoError(t, err)
runner := DetectTestRunner(dir)
assert.Equal(t, TestRunnerPest, runner)
})
t.Run("returns PHPUnit when no Pest.php", func(t *testing.T) {
dir := t.TempDir()
runner := DetectTestRunner(dir)
assert.Equal(t, TestRunnerPHPUnit, runner)
})
t.Run("returns PHPUnit when tests directory exists but no Pest.php", func(t *testing.T) {
dir := t.TempDir()
testsDir := filepath.Join(dir, "tests")
err := os.MkdirAll(testsDir, 0755)
require.NoError(t, err)
runner := DetectTestRunner(dir)
assert.Equal(t, TestRunnerPHPUnit, runner)
})
}
func TestBuildPestCommand_Good(t *testing.T) {
t.Run("basic command", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir}
cmd, args := buildPestCommand(opts)
assert.Equal(t, "pest", cmd)
assert.Empty(t, args)
})
t.Run("with filter", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Filter: "UserTest"}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--filter")
assert.Contains(t, args, "UserTest")
})
t.Run("with parallel", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Parallel: true}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--parallel")
})
t.Run("with coverage", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Coverage: true}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--coverage")
})
t.Run("with coverage HTML format", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "html"}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--coverage-html")
assert.Contains(t, args, "coverage")
})
t.Run("with coverage clover format", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "clover"}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--coverage-clover")
assert.Contains(t, args, "coverage.xml")
})
t.Run("with groups", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Groups: []string{"unit", "integration"}}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--group")
assert.Contains(t, args, "unit")
assert.Contains(t, args, "integration")
})
t.Run("uses vendor binary when exists", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
pestPath := filepath.Join(binDir, "pest")
err = os.WriteFile(pestPath, []byte("#!/bin/bash"), 0755)
require.NoError(t, err)
opts := TestOptions{Dir: dir}
cmd, _ := buildPestCommand(opts)
assert.Equal(t, pestPath, cmd)
})
t.Run("all options combined", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{
Dir: dir,
Filter: "Test",
Parallel: true,
Coverage: true,
CoverageFormat: "html",
Groups: []string{"unit"},
}
_, args := buildPestCommand(opts)
assert.Contains(t, args, "--filter")
assert.Contains(t, args, "--parallel")
assert.Contains(t, args, "--coverage-html")
assert.Contains(t, args, "--group")
})
}
func TestBuildPHPUnitCommand_Good(t *testing.T) {
t.Run("basic command", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir}
cmd, args := buildPHPUnitCommand(opts)
assert.Equal(t, "phpunit", cmd)
assert.Empty(t, args)
})
t.Run("with filter", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Filter: "UserTest"}
_, args := buildPHPUnitCommand(opts)
assert.Contains(t, args, "--filter")
assert.Contains(t, args, "UserTest")
})
t.Run("with parallel uses paratest", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
paratestPath := filepath.Join(binDir, "paratest")
err = os.WriteFile(paratestPath, []byte("#!/bin/bash"), 0755)
require.NoError(t, err)
opts := TestOptions{Dir: dir, Parallel: true}
cmd, _ := buildPHPUnitCommand(opts)
assert.Equal(t, paratestPath, cmd)
})
t.Run("parallel without paratest stays phpunit", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Parallel: true}
cmd, _ := buildPHPUnitCommand(opts)
assert.Equal(t, "phpunit", cmd)
})
t.Run("with coverage", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Coverage: true}
_, args := buildPHPUnitCommand(opts)
assert.Contains(t, args, "--coverage-text")
})
t.Run("with coverage HTML format", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "html"}
_, args := buildPHPUnitCommand(opts)
assert.Contains(t, args, "--coverage-html")
assert.Contains(t, args, "coverage")
})
t.Run("with coverage clover format", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Coverage: true, CoverageFormat: "clover"}
_, args := buildPHPUnitCommand(opts)
assert.Contains(t, args, "--coverage-clover")
assert.Contains(t, args, "coverage.xml")
})
t.Run("with groups", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{Dir: dir, Groups: []string{"unit", "integration"}}
_, args := buildPHPUnitCommand(opts)
assert.Contains(t, args, "--group")
assert.Contains(t, args, "unit")
assert.Contains(t, args, "integration")
})
t.Run("uses vendor binary when exists", func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "vendor", "bin")
err := os.MkdirAll(binDir, 0755)
require.NoError(t, err)
phpunitPath := filepath.Join(binDir, "phpunit")
err = os.WriteFile(phpunitPath, []byte("#!/bin/bash"), 0755)
require.NoError(t, err)
opts := TestOptions{Dir: dir}
cmd, _ := buildPHPUnitCommand(opts)
assert.Equal(t, phpunitPath, cmd)
})
}
func TestTestOptions_Struct(t *testing.T) {
t.Run("all fields accessible", func(t *testing.T) {
opts := TestOptions{
Dir: "/test",
Filter: "TestName",
Parallel: true,
Coverage: true,
CoverageFormat: "html",
Groups: []string{"unit"},
Output: os.Stdout,
}
assert.Equal(t, "/test", opts.Dir)
assert.Equal(t, "TestName", opts.Filter)
assert.True(t, opts.Parallel)
assert.True(t, opts.Coverage)
assert.Equal(t, "html", opts.CoverageFormat)
assert.Equal(t, []string{"unit"}, opts.Groups)
assert.NotNil(t, opts.Output)
})
}
func TestTestRunner_Constants(t *testing.T) {
t.Run("constants are defined", func(t *testing.T) {
assert.Equal(t, TestRunner("pest"), TestRunnerPest)
assert.Equal(t, TestRunner("phpunit"), TestRunnerPHPUnit)
})
}
func TestRunTests_Bad(t *testing.T) {
t.Skip("requires PHP test runner installed")
}
func TestRunParallel_Bad(t *testing.T) {
t.Skip("requires PHP test runner installed")
}
func TestRunTests_Integration(t *testing.T) {
t.Skip("requires PHP/Pest/PHPUnit installed")
}
func TestBuildPestCommand_CoverageOptions(t *testing.T) {
tests := []struct {
name string
coverageFormat string
expectedArg string
}{
{"default coverage", "", "--coverage"},
{"html coverage", "html", "--coverage-html"},
{"clover coverage", "clover", "--coverage-clover"},
{"unknown format uses default", "unknown", "--coverage"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{
Dir: dir,
Coverage: true,
CoverageFormat: tt.coverageFormat,
}
_, args := buildPestCommand(opts)
// For unknown format, should fall through to default
if tt.coverageFormat == "unknown" {
assert.Contains(t, args, "--coverage")
} else {
assert.Contains(t, args, tt.expectedArg)
}
})
}
}
func TestBuildPHPUnitCommand_CoverageOptions(t *testing.T) {
tests := []struct {
name string
coverageFormat string
expectedArg string
}{
{"default coverage", "", "--coverage-text"},
{"html coverage", "html", "--coverage-html"},
{"clover coverage", "clover", "--coverage-clover"},
{"unknown format uses default", "unknown", "--coverage-text"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{
Dir: dir,
Coverage: true,
CoverageFormat: tt.coverageFormat,
}
_, args := buildPHPUnitCommand(opts)
if tt.coverageFormat == "unknown" {
assert.Contains(t, args, "--coverage-text")
} else {
assert.Contains(t, args, tt.expectedArg)
}
})
}
}
func TestBuildPestCommand_MultipleGroups(t *testing.T) {
t.Run("adds multiple group flags", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{
Dir: dir,
Groups: []string{"unit", "integration", "feature"},
}
_, args := buildPestCommand(opts)
// Should have --group for each group
groupCount := 0
for _, arg := range args {
if arg == "--group" {
groupCount++
}
}
assert.Equal(t, 3, groupCount)
})
}
func TestBuildPHPUnitCommand_MultipleGroups(t *testing.T) {
t.Run("adds multiple group flags", func(t *testing.T) {
dir := t.TempDir()
opts := TestOptions{
Dir: dir,
Groups: []string{"unit", "integration"},
}
_, args := buildPHPUnitCommand(opts)
groupCount := 0
for _, arg := range args {
if arg == "--group" {
groupCount++
}
}
assert.Equal(t, 2, groupCount)
})
}