package php import ( "context" "encoding/json" goio "io" "os" "os/exec" "path/filepath" "strings" "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/i18n" ) // FormatOptions configures PHP code formatting. type FormatOptions struct { // Dir is the project directory (defaults to current working directory). Dir string // Fix automatically fixes formatting issues. Fix bool // Diff shows a diff of changes instead of modifying files. Diff bool // JSON outputs results in JSON format. JSON bool // Paths limits formatting to specific paths. Paths []string // Output is the writer for output (defaults to os.Stdout). Output goio.Writer } // AnalyseOptions configures PHP static analysis. type AnalyseOptions struct { // Dir is the project directory (defaults to current working directory). Dir string // Level is the PHPStan analysis level (0-9). Level int // Paths limits analysis to specific paths. Paths []string // Memory is the memory limit for analysis (e.g., "2G"). Memory string // JSON outputs results in JSON format. JSON bool // SARIF outputs results in SARIF format for GitHub Security tab. SARIF bool // Output is the writer for output (defaults to os.Stdout). Output goio.Writer } // FormatterType represents the detected formatter. type FormatterType string // Formatter type constants. const ( // FormatterPint indicates Laravel Pint code formatter. FormatterPint FormatterType = "pint" ) // AnalyserType represents the detected static analyser. type AnalyserType string // Static analyser type constants. const ( // AnalyserPHPStan indicates standard PHPStan analyser. AnalyserPHPStan AnalyserType = "phpstan" // AnalyserLarastan indicates Laravel-specific Larastan analyser. AnalyserLarastan AnalyserType = "larastan" ) // DetectFormatter detects which formatter is available in the project. func DetectFormatter(dir string) (FormatterType, bool) { m := getMedium() // Check for Pint config pintConfig := filepath.Join(dir, "pint.json") if m.Exists(pintConfig) { return FormatterPint, true } // Check for vendor binary pintBin := filepath.Join(dir, "vendor", "bin", "pint") if m.Exists(pintBin) { return FormatterPint, true } return "", false } // DetectAnalyser detects which static analyser is available in the project. func DetectAnalyser(dir string) (AnalyserType, bool) { m := getMedium() // Check for PHPStan config phpstanConfig := filepath.Join(dir, "phpstan.neon") phpstanDistConfig := filepath.Join(dir, "phpstan.neon.dist") hasConfig := m.Exists(phpstanConfig) || m.Exists(phpstanDistConfig) // Check for vendor binary phpstanBin := filepath.Join(dir, "vendor", "bin", "phpstan") hasBin := m.Exists(phpstanBin) if hasConfig || hasBin { // Check if it's Larastan (Laravel-specific PHPStan) larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan") if m.Exists(larastanPath) { return AnalyserLarastan, true } // Also check nunomaduro/larastan larastanPath2 := filepath.Join(dir, "vendor", "nunomaduro", "larastan") if m.Exists(larastanPath2) { return AnalyserLarastan, true } return AnalyserPHPStan, true } return "", false } // Format runs Laravel Pint to format PHP code. func Format(ctx context.Context, opts FormatOptions) error { if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { return cli.WrapVerb(err, "get", "working directory") } opts.Dir = cwd } if opts.Output == nil { opts.Output = os.Stdout } // Check if formatter is available formatter, found := DetectFormatter(opts.Dir) if !found { return cli.Err("no formatter found (install Laravel Pint: composer require laravel/pint --dev)") } var cmdName string var args []string switch formatter { case FormatterPint: cmdName, args = buildPintCommand(opts) } cmd := exec.CommandContext(ctx, cmdName, args...) cmd.Dir = opts.Dir cmd.Stdout = opts.Output cmd.Stderr = opts.Output return cmd.Run() } // Analyse runs PHPStan or Larastan for static analysis. func Analyse(ctx context.Context, opts AnalyseOptions) error { if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { return cli.WrapVerb(err, "get", "working directory") } opts.Dir = cwd } if opts.Output == nil { opts.Output = os.Stdout } // Check if analyser is available analyser, found := DetectAnalyser(opts.Dir) if !found { return cli.Err("no static analyser found (install PHPStan: composer require phpstan/phpstan --dev)") } var cmdName string var args []string switch analyser { case AnalyserPHPStan, AnalyserLarastan: cmdName, args = buildPHPStanCommand(opts) } cmd := exec.CommandContext(ctx, cmdName, args...) cmd.Dir = opts.Dir cmd.Stdout = opts.Output cmd.Stderr = opts.Output return cmd.Run() } // buildPintCommand builds the command for running Laravel Pint. func buildPintCommand(opts FormatOptions) (string, []string) { m := getMedium() // Check for vendor binary first vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pint") cmdName := "pint" if m.Exists(vendorBin) { cmdName = vendorBin } var args []string if !opts.Fix { args = append(args, "--test") } if opts.Diff { args = append(args, "--diff") } if opts.JSON { args = append(args, "--format=json") } // Add specific paths if provided args = append(args, opts.Paths...) return cmdName, args } // buildPHPStanCommand builds the command for running PHPStan. func buildPHPStanCommand(opts AnalyseOptions) (string, []string) { m := getMedium() // Check for vendor binary first vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpstan") cmdName := "phpstan" if m.Exists(vendorBin) { cmdName = vendorBin } args := []string{"analyse"} if opts.Level > 0 { args = append(args, "--level", cli.Sprintf("%d", opts.Level)) } if opts.Memory != "" { args = append(args, "--memory-limit", opts.Memory) } // Output format - SARIF takes precedence over JSON if opts.SARIF { args = append(args, "--error-format=sarif") } else if opts.JSON { args = append(args, "--error-format=json") } // Add specific paths if provided args = append(args, opts.Paths...) return cmdName, args } // ============================================================================= // Psalm Static Analysis // ============================================================================= // PsalmOptions configures Psalm static analysis. type PsalmOptions struct { Dir string Level int // Error level (1=strictest, 8=most lenient) Fix bool // Auto-fix issues where possible Baseline bool // Generate/update baseline file ShowInfo bool // Show info-level issues JSON bool // Output in JSON format SARIF bool // Output in SARIF format for GitHub Security tab Output goio.Writer } // PsalmType represents the detected Psalm configuration. type PsalmType string // Psalm configuration type constants. const ( // PsalmStandard indicates standard Psalm configuration. PsalmStandard PsalmType = "psalm" ) // DetectPsalm checks if Psalm is available in the project. func DetectPsalm(dir string) (PsalmType, bool) { m := getMedium() // Check for psalm.xml config psalmConfig := filepath.Join(dir, "psalm.xml") psalmDistConfig := filepath.Join(dir, "psalm.xml.dist") hasConfig := m.Exists(psalmConfig) || m.Exists(psalmDistConfig) // Check for vendor binary psalmBin := filepath.Join(dir, "vendor", "bin", "psalm") if m.Exists(psalmBin) { return PsalmStandard, true } if hasConfig { return PsalmStandard, true } return "", false } // RunPsalm runs Psalm static analysis. func RunPsalm(ctx context.Context, opts PsalmOptions) error { if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { return cli.WrapVerb(err, "get", "working directory") } opts.Dir = cwd } if opts.Output == nil { opts.Output = os.Stdout } m := getMedium() // Build command vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "psalm") cmdName := "psalm" if m.Exists(vendorBin) { cmdName = vendorBin } args := []string{"--no-progress"} if opts.Level > 0 && opts.Level <= 8 { args = append(args, cli.Sprintf("--error-level=%d", opts.Level)) } if opts.Fix { args = append(args, "--alter", "--issues=all") } if opts.Baseline { args = append(args, "--set-baseline=psalm-baseline.xml") } if opts.ShowInfo { args = append(args, "--show-info=true") } // Output format - SARIF takes precedence over JSON if opts.SARIF { args = append(args, "--output-format=sarif") } else if opts.JSON { args = append(args, "--output-format=json") } cmd := exec.CommandContext(ctx, cmdName, args...) cmd.Dir = opts.Dir cmd.Stdout = opts.Output cmd.Stderr = opts.Output return cmd.Run() } // ============================================================================= // Security Audit // ============================================================================= // AuditOptions configures dependency security auditing. type AuditOptions struct { Dir string JSON bool // Output in JSON format Fix bool // Auto-fix vulnerabilities (npm only) Output goio.Writer } // AuditResult holds the results of a security audit. type AuditResult struct { Tool string Vulnerabilities int Advisories []AuditAdvisory Error error } // AuditAdvisory represents a single security advisory. type AuditAdvisory struct { Package string Severity string Title string URL string Identifiers []string } // RunAudit runs security audits on dependencies. func RunAudit(ctx context.Context, opts AuditOptions) ([]AuditResult, error) { if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { return nil, cli.WrapVerb(err, "get", "working directory") } opts.Dir = cwd } if opts.Output == nil { opts.Output = os.Stdout } var results []AuditResult // Run composer audit composerResult := runComposerAudit(ctx, opts) results = append(results, composerResult) // Run npm audit if package.json exists if getMedium().Exists(filepath.Join(opts.Dir, "package.json")) { npmResult := runNpmAudit(ctx, opts) results = append(results, npmResult) } return results, nil } func runComposerAudit(ctx context.Context, opts AuditOptions) AuditResult { result := AuditResult{Tool: "composer"} args := []string{"audit", "--format=json"} cmd := exec.CommandContext(ctx, "composer", args...) cmd.Dir = opts.Dir output, err := cmd.Output() if err != nil { // composer audit returns non-zero if vulnerabilities found if exitErr, ok := err.(*exec.ExitError); ok { output = append(output, exitErr.Stderr...) } } // Parse JSON output var auditData struct { Advisories map[string][]struct { Title string `json:"title"` Link string `json:"link"` CVE string `json:"cve"` AffectedRanges string `json:"affectedVersions"` } `json:"advisories"` } if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil { for pkg, advisories := range auditData.Advisories { for _, adv := range advisories { result.Advisories = append(result.Advisories, AuditAdvisory{ Package: pkg, Title: adv.Title, URL: adv.Link, Identifiers: []string{adv.CVE}, }) } } result.Vulnerabilities = len(result.Advisories) } else if err != nil { result.Error = err } return result } func runNpmAudit(ctx context.Context, opts AuditOptions) AuditResult { result := AuditResult{Tool: "npm"} args := []string{"audit", "--json"} if opts.Fix { args = []string{"audit", "fix"} } cmd := exec.CommandContext(ctx, "npm", args...) cmd.Dir = opts.Dir output, err := cmd.Output() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { output = append(output, exitErr.Stderr...) } } if !opts.Fix { // Parse JSON output var auditData struct { Metadata struct { Vulnerabilities struct { Total int `json:"total"` } `json:"vulnerabilities"` } `json:"metadata"` Vulnerabilities map[string]struct { Severity string `json:"severity"` Via []any `json:"via"` } `json:"vulnerabilities"` } if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil { result.Vulnerabilities = auditData.Metadata.Vulnerabilities.Total for pkg, vuln := range auditData.Vulnerabilities { result.Advisories = append(result.Advisories, AuditAdvisory{ Package: pkg, Severity: vuln.Severity, }) } } else if err != nil { result.Error = err } } return result } // ============================================================================= // Rector Automated Refactoring // ============================================================================= // RectorOptions configures Rector code refactoring. type RectorOptions struct { Dir string Fix bool // Apply changes (default is dry-run) Diff bool // Show detailed diff ClearCache bool // Clear cache before running Output goio.Writer } // DetectRector checks if Rector is available in the project. func DetectRector(dir string) bool { m := getMedium() // Check for rector.php config rectorConfig := filepath.Join(dir, "rector.php") if m.Exists(rectorConfig) { return true } // Check for vendor binary rectorBin := filepath.Join(dir, "vendor", "bin", "rector") if m.Exists(rectorBin) { return true } return false } // RunRector runs Rector for automated code refactoring. func RunRector(ctx context.Context, opts RectorOptions) error { if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { return cli.WrapVerb(err, "get", "working directory") } opts.Dir = cwd } if opts.Output == nil { opts.Output = os.Stdout } m := getMedium() // Build command vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "rector") cmdName := "rector" if m.Exists(vendorBin) { cmdName = vendorBin } args := []string{"process"} if !opts.Fix { args = append(args, "--dry-run") } if opts.Diff { args = append(args, "--output-format", "diff") } if opts.ClearCache { args = append(args, "--clear-cache") } cmd := exec.CommandContext(ctx, cmdName, args...) cmd.Dir = opts.Dir cmd.Stdout = opts.Output cmd.Stderr = opts.Output return cmd.Run() } // ============================================================================= // Infection Mutation Testing // ============================================================================= // InfectionOptions configures Infection mutation testing. type InfectionOptions struct { Dir string MinMSI int // Minimum mutation score indicator (0-100) MinCoveredMSI int // Minimum covered mutation score (0-100) Threads int // Number of parallel threads Filter string // Filter files by pattern OnlyCovered bool // Only mutate covered code Output goio.Writer } // DetectInfection checks if Infection is available in the project. func DetectInfection(dir string) bool { m := getMedium() // Check for infection config files configs := []string{"infection.json", "infection.json5", "infection.json.dist"} for _, config := range configs { if m.Exists(filepath.Join(dir, config)) { return true } } // Check for vendor binary infectionBin := filepath.Join(dir, "vendor", "bin", "infection") if m.Exists(infectionBin) { return true } return false } // RunInfection runs Infection mutation testing. func RunInfection(ctx context.Context, opts InfectionOptions) error { if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { return cli.WrapVerb(err, "get", "working directory") } opts.Dir = cwd } if opts.Output == nil { opts.Output = os.Stdout } m := getMedium() // Build command vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "infection") cmdName := "infection" if m.Exists(vendorBin) { cmdName = vendorBin } var args []string // Set defaults minMSI := opts.MinMSI if minMSI == 0 { minMSI = 50 } minCoveredMSI := opts.MinCoveredMSI if minCoveredMSI == 0 { minCoveredMSI = 70 } threads := opts.Threads if threads == 0 { threads = 4 } args = append(args, cli.Sprintf("--min-msi=%d", minMSI)) args = append(args, cli.Sprintf("--min-covered-msi=%d", minCoveredMSI)) args = append(args, cli.Sprintf("--threads=%d", threads)) if opts.Filter != "" { args = append(args, "--filter="+opts.Filter) } if opts.OnlyCovered { args = append(args, "--only-covered") } cmd := exec.CommandContext(ctx, cmdName, args...) cmd.Dir = opts.Dir cmd.Stdout = opts.Output cmd.Stderr = opts.Output return cmd.Run() } // ============================================================================= // QA Pipeline // ============================================================================= // QAOptions configures the full QA pipeline. type QAOptions struct { Dir string Quick bool // Only run quick checks Full bool // Run all stages including slow checks Fix bool // Auto-fix issues where possible JSON bool // Output results as JSON } // QAStage represents a stage in the QA pipeline. type QAStage string // QA pipeline stage constants. const ( // QAStageQuick runs fast checks only (audit, fmt, stan). QAStageQuick QAStage = "quick" // QAStageStandard runs standard checks including tests. QAStageStandard QAStage = "standard" // QAStageFull runs all checks including slow security scans. QAStageFull QAStage = "full" ) // QACheckResult holds the result of a single QA check. type QACheckResult struct { Name string Stage QAStage Passed bool Duration string Error error Output string } // QAResult holds the results of the full QA pipeline. type QAResult struct { Stages []QAStage Checks []QACheckResult Passed bool Summary string } // GetQAStages returns the stages to run based on options. func GetQAStages(opts QAOptions) []QAStage { if opts.Quick { return []QAStage{QAStageQuick} } if opts.Full { return []QAStage{QAStageQuick, QAStageStandard, QAStageFull} } // Default: quick + standard return []QAStage{QAStageQuick, QAStageStandard} } // GetQAChecks returns the checks for a given stage. func GetQAChecks(dir string, stage QAStage) []string { switch stage { case QAStageQuick: checks := []string{"audit", "fmt", "stan"} return checks case QAStageStandard: checks := []string{} if _, found := DetectPsalm(dir); found { checks = append(checks, "psalm") } checks = append(checks, "test") return checks case QAStageFull: checks := []string{} if DetectRector(dir) { checks = append(checks, "rector") } if DetectInfection(dir) { checks = append(checks, "infection") } return checks } return nil } // ============================================================================= // Security Checks // ============================================================================= // SecurityOptions configures security scanning. type SecurityOptions struct { Dir string Severity string // Minimum severity (critical, high, medium, low) JSON bool // Output in JSON format SARIF bool // Output in SARIF format URL string // URL to check HTTP headers (optional) Output goio.Writer } // SecurityResult holds the results of security scanning. type SecurityResult struct { Checks []SecurityCheck Summary SecuritySummary } // SecurityCheck represents a single security check result. type SecurityCheck struct { ID string Name string Description string Severity string Passed bool Message string Fix string CWE string } // SecuritySummary summarizes security check results. type SecuritySummary struct { Total int Passed int Critical int High int Medium int Low int } // RunSecurityChecks runs security checks on the project. func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResult, error) { if opts.Dir == "" { cwd, err := os.Getwd() if err != nil { return nil, cli.WrapVerb(err, "get", "working directory") } opts.Dir = cwd } result := &SecurityResult{} // Run composer audit auditResults, _ := RunAudit(ctx, AuditOptions{Dir: opts.Dir}) for _, audit := range auditResults { check := SecurityCheck{ ID: audit.Tool + "_audit", Name: i18n.Title(audit.Tool) + " Security Audit", Description: "Check " + audit.Tool + " dependencies for vulnerabilities", Severity: "critical", Passed: audit.Vulnerabilities == 0 && audit.Error == nil, CWE: "CWE-1395", } if !check.Passed { check.Message = cli.Sprintf("Found %d vulnerabilities", audit.Vulnerabilities) } result.Checks = append(result.Checks, check) } // Check .env file for security issues envChecks := runEnvSecurityChecks(opts.Dir) result.Checks = append(result.Checks, envChecks...) // Check filesystem security fsChecks := runFilesystemSecurityChecks(opts.Dir) result.Checks = append(result.Checks, fsChecks...) // Calculate summary for _, check := range result.Checks { result.Summary.Total++ if check.Passed { result.Summary.Passed++ } else { switch check.Severity { case "critical": result.Summary.Critical++ case "high": result.Summary.High++ case "medium": result.Summary.Medium++ case "low": result.Summary.Low++ } } } return result, nil } func runEnvSecurityChecks(dir string) []SecurityCheck { var checks []SecurityCheck m := getMedium() envPath := filepath.Join(dir, ".env") envContent, err := m.Read(envPath) if err != nil { return checks } envLines := strings.Split(envContent, "\n") envMap := make(map[string]string) for _, line := range envLines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) == 2 { envMap[parts[0]] = parts[1] } } // Check APP_DEBUG if debug, ok := envMap["APP_DEBUG"]; ok { check := SecurityCheck{ ID: "debug_mode", Name: "Debug Mode Disabled", Description: "APP_DEBUG should be false in production", Severity: "critical", Passed: strings.ToLower(debug) != "true", CWE: "CWE-215", } if !check.Passed { check.Message = "Debug mode exposes sensitive information" check.Fix = "Set APP_DEBUG=false in .env" } checks = append(checks, check) } // Check APP_KEY if key, ok := envMap["APP_KEY"]; ok { check := SecurityCheck{ ID: "app_key_set", Name: "Application Key Set", Description: "APP_KEY must be set and valid", Severity: "critical", Passed: len(key) >= 32, CWE: "CWE-321", } if !check.Passed { check.Message = "Missing or weak encryption key" check.Fix = "Run: php artisan key:generate" } checks = append(checks, check) } // Check APP_URL for HTTPS if url, ok := envMap["APP_URL"]; ok { check := SecurityCheck{ ID: "https_enforced", Name: "HTTPS Enforced", Description: "APP_URL should use HTTPS in production", Severity: "high", Passed: strings.HasPrefix(url, "https://"), CWE: "CWE-319", } if !check.Passed { check.Message = "Application not using HTTPS" check.Fix = "Update APP_URL to use https://" } checks = append(checks, check) } return checks } func runFilesystemSecurityChecks(dir string) []SecurityCheck { var checks []SecurityCheck m := getMedium() // Check .env not in public publicEnvPaths := []string{"public/.env", "public_html/.env"} for _, path := range publicEnvPaths { fullPath := filepath.Join(dir, path) if m.Exists(fullPath) { checks = append(checks, SecurityCheck{ ID: "env_not_public", Name: ".env Not Publicly Accessible", Description: ".env file should not be in public directory", Severity: "critical", Passed: false, Message: "Environment file exposed to web at " + path, CWE: "CWE-538", }) } } // Check .git not in public publicGitPaths := []string{"public/.git", "public_html/.git"} for _, path := range publicGitPaths { fullPath := filepath.Join(dir, path) if m.Exists(fullPath) { checks = append(checks, SecurityCheck{ ID: "git_not_public", Name: ".git Not Publicly Accessible", Description: ".git directory should not be in public", Severity: "critical", Passed: false, Message: "Git repository exposed to web (source code leak)", CWE: "CWE-538", }) } } return checks }