816 lines
24 KiB
Go
816 lines
24 KiB
Go
|
|
package php
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"forge.lthn.ai/core/go/pkg/cli"
|
||
|
|
"forge.lthn.ai/core/go/pkg/i18n"
|
||
|
|
"github.com/spf13/cobra"
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
testParallel bool
|
||
|
|
testCoverage bool
|
||
|
|
testFilter string
|
||
|
|
testGroup string
|
||
|
|
testJSON bool
|
||
|
|
)
|
||
|
|
|
||
|
|
func addPHPTestCommand(parent *cobra.Command) {
|
||
|
|
testCmd := &cobra.Command{
|
||
|
|
Use: "test",
|
||
|
|
Short: i18n.T("cmd.php.test.short"),
|
||
|
|
Long: i18n.T("cmd.php.test.long"),
|
||
|
|
RunE: func(cmd *cobra.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 *cobra.Command) {
|
||
|
|
fmtCmd := &cobra.Command{
|
||
|
|
Use: "fmt [paths...]",
|
||
|
|
Short: i18n.T("cmd.php.fmt.short"),
|
||
|
|
Long: i18n.T("cmd.php.fmt.long"),
|
||
|
|
RunE: func(cmd *cobra.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 *cobra.Command) {
|
||
|
|
stanCmd := &cobra.Command{
|
||
|
|
Use: "stan [paths...]",
|
||
|
|
Short: i18n.T("cmd.php.analyse.short"),
|
||
|
|
Long: i18n.T("cmd.php.analyse.long"),
|
||
|
|
RunE: func(cmd *cobra.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 *cobra.Command) {
|
||
|
|
psalmCmd := &cobra.Command{
|
||
|
|
Use: "psalm",
|
||
|
|
Short: i18n.T("cmd.php.psalm.short"),
|
||
|
|
Long: i18n.T("cmd.php.psalm.long"),
|
||
|
|
RunE: func(cmd *cobra.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 *cobra.Command) {
|
||
|
|
auditCmd := &cobra.Command{
|
||
|
|
Use: "audit",
|
||
|
|
Short: i18n.T("cmd.php.audit.short"),
|
||
|
|
Long: i18n.T("cmd.php.audit.long"),
|
||
|
|
RunE: func(cmd *cobra.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 *cobra.Command) {
|
||
|
|
securityCmd := &cobra.Command{
|
||
|
|
Use: "security",
|
||
|
|
Short: i18n.T("cmd.php.security.short"),
|
||
|
|
Long: i18n.T("cmd.php.security.long"),
|
||
|
|
RunE: func(cmd *cobra.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 *cobra.Command) {
|
||
|
|
qaCmd := &cobra.Command{
|
||
|
|
Use: "qa",
|
||
|
|
Short: i18n.T("cmd.php.qa.short"),
|
||
|
|
Long: i18n.T("cmd.php.qa.long"),
|
||
|
|
RunE: func(cmd *cobra.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 *cobra.Command) {
|
||
|
|
rectorCmd := &cobra.Command{
|
||
|
|
Use: "rector",
|
||
|
|
Short: i18n.T("cmd.php.rector.short"),
|
||
|
|
Long: i18n.T("cmd.php.rector.long"),
|
||
|
|
RunE: func(cmd *cobra.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 *cobra.Command) {
|
||
|
|
infectionCmd := &cobra.Command{
|
||
|
|
Use: "infection",
|
||
|
|
Short: i18n.T("cmd.php.infection.short"),
|
||
|
|
Long: i18n.T("cmd.php.infection.long"),
|
||
|
|
RunE: func(cmd *cobra.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
|
||
|
|
}
|
||
|
|
}
|