feat(php): add --json and --sarif flags to QA commands (#69)

* feat(github): add issue templates and auto-labeler

- Add bug_report.yml and feature_request.yml templates
- Add config.yml for issue creation options
- Add auto-label.yml workflow to label issues based on content

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(php): add --json and --sarif flags to QA commands

Adds machine-readable output support to PHP quality assurance commands:

- test: --json flag for JUnit XML output
- fmt: --json flag for JSON formatted output from Pint
- stan: --json and --sarif flags for PHPStan output
- psalm: --json and --sarif flags for Psalm output
- qa: --json flag for JSON summary output

SARIF output enables integration with GitHub Security tab for
static analysis results.

Closes #51

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(php): address CodeRabbit review feedback

- Guard progress messages when JSON/SARIF output is enabled
- Guard success messages when JSON/SARIF output is enabled
- Guard QA results display when JSON output is enabled
- Rename misleading JSON field to JUnit in TestOptions (outputs JUnit XML)
- Add mutual exclusion validation for --json and --sarif flags
- Remove empty conditional block in auto-label workflow
- Add i18n translation for json_sarif_exclusive error

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(php): additional CodeRabbit fixes

- Rename test --json flag to --junit (outputs JUnit XML, not JSON)
- Add actual JSON marshaling for QA command JSON output
- Add JSON tags to QARunResult and QACheckRunResult structs
- Add i18n translation for junit flag

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-01 06:32:35 +00:00 committed by GitHub
parent af9fd33b2a
commit ba88455efb
7 changed files with 209 additions and 87 deletions

View file

@ -55,4 +55,4 @@ body:
- "Large - Significant changes, multiple days" - "Large - Significant changes, multiple days"
- "Unknown - Not sure" - "Unknown - Not sure"
validations: validations:
required: false required: false

View file

@ -43,9 +43,6 @@ jobs:
if (content.includes('.go') || content.includes('golang') || content.includes('go mod')) { if (content.includes('.go') || content.includes('golang') || content.includes('go mod')) {
labelsToAdd.push('go'); labelsToAdd.push('go');
} }
if (content.includes('.php') || content.includes('laravel') || content.includes('composer')) {
// Skip - already handled by project:core-php
}
// Priority detection // Priority detection
if (content.includes('critical') || content.includes('urgent') || content.includes('breaking')) { if (content.includes('critical') || content.includes('urgent') || content.includes('breaking')) {

View file

@ -253,14 +253,38 @@
"dev.short": "Start Laravel development environment", "dev.short": "Start Laravel development environment",
"dev.press_ctrl_c": "Press Ctrl+C to stop all services", "dev.press_ctrl_c": "Press Ctrl+C to stop all services",
"test.short": "Run PHP tests (PHPUnit/Pest)", "test.short": "Run PHP tests (PHPUnit/Pest)",
"test.flag.parallel": "Run tests in parallel",
"test.flag.coverage": "Generate code coverage report",
"test.flag.filter": "Filter tests by name pattern",
"test.flag.group": "Run only tests in specified group",
"test.flag.junit": "Output results in JUnit XML format",
"fmt.short": "Format PHP code with Laravel Pint", "fmt.short": "Format PHP code with Laravel Pint",
"fmt.flag.fix": "Apply formatting fixes",
"analyse.short": "Run PHPStan static analysis", "analyse.short": "Run PHPStan static analysis",
"analyse.flag.level": "PHPStan analysis level (0-9)",
"analyse.flag.memory": "Memory limit (e.g., 2G)",
"audit.short": "Security audit for dependencies", "audit.short": "Security audit for dependencies",
"psalm.short": "Run Psalm static analysis", "psalm.short": "Run Psalm static analysis",
"psalm.flag.level": "Psalm error level (1=strictest, 8=lenient)",
"psalm.flag.baseline": "Generate/update baseline file",
"psalm.flag.show_info": "Show info-level issues",
"rector.short": "Automated code refactoring", "rector.short": "Automated code refactoring",
"rector.flag.fix": "Apply refactoring changes",
"rector.flag.diff": "Show detailed diff of changes",
"rector.flag.clear_cache": "Clear cache before running",
"infection.short": "Mutation testing for test quality", "infection.short": "Mutation testing for test quality",
"infection.flag.min_msi": "Minimum mutation score indicator (0-100)",
"infection.flag.min_covered_msi": "Minimum covered mutation score (0-100)",
"infection.flag.threads": "Number of parallel threads",
"infection.flag.filter": "Filter files by pattern",
"infection.flag.only_covered": "Only mutate covered code",
"security.short": "Security vulnerability scanning", "security.short": "Security vulnerability scanning",
"security.flag.severity": "Minimum severity (critical, high, medium, low)",
"security.flag.sarif": "Output as SARIF for GitHub Security tab",
"security.flag.url": "URL to check HTTP security headers",
"qa.short": "Run full QA pipeline", "qa.short": "Run full QA pipeline",
"qa.flag.quick": "Run quick checks only (audit, fmt, stan)",
"qa.flag.full": "Run all stages including slow checks",
"build.short": "Build Docker or LinuxKit image", "build.short": "Build Docker or LinuxKit image",
"deploy.short": "Deploy to Coolify", "deploy.short": "Deploy to Coolify",
"serve.short": "Run production container", "serve.short": "Run production container",
@ -445,6 +469,7 @@
"fix": "Auto-fix issues where possible", "fix": "Auto-fix issues where possible",
"diff": "Show diff of changes", "diff": "Show diff of changes",
"json": "Output as JSON", "json": "Output as JSON",
"sarif": "Output as SARIF for GitHub Security tab",
"verbose": "Show detailed output", "verbose": "Show detailed output",
"registry": "Path to repos.yaml registry file" "registry": "Path to repos.yaml registry file"
}, },
@ -459,7 +484,8 @@
"completed": "{{.Action}} successfully" "completed": "{{.Action}} successfully"
}, },
"error": { "error": {
"failed": "Failed to {{.Action}}" "failed": "Failed to {{.Action}}",
"json_sarif_exclusive": "--json and --sarif flags are mutually exclusive"
}, },
"hint": { "hint": {
"fix_deps": "Update dependencies to fix vulnerabilities" "fix_deps": "Update dependencies to fix vulnerabilities"

View file

@ -294,22 +294,22 @@ func (r *QARunner) GetCheckOutput(check string) []string {
// QARunResult holds the results of running QA checks. // QARunResult holds the results of running QA checks.
type QARunResult struct { type QARunResult struct {
Passed bool Passed bool `json:"passed"`
Duration string Duration string `json:"duration"`
Results []QACheckRunResult Results []QACheckRunResult `json:"results"`
PassedCount int PassedCount int `json:"passed_count"`
FailedCount int FailedCount int `json:"failed_count"`
SkippedCount int SkippedCount int `json:"skipped_count"`
} }
// QACheckRunResult holds the result of a single QA check. // QACheckRunResult holds the result of a single QA check.
type QACheckRunResult struct { type QACheckRunResult struct {
Name string Name string `json:"name"`
Passed bool Passed bool `json:"passed"`
Skipped bool Skipped bool `json:"skipped"`
ExitCode int ExitCode int `json:"exit_code"`
Duration string Duration string `json:"duration"`
Output string Output string `json:"output,omitempty"`
} }
// GetIssueMessage returns an issue message for a check. // GetIssueMessage returns an issue message for a check.

View file

@ -2,11 +2,11 @@ package php
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"os" "os"
"strings" "strings"
"github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n" "github.com/host-uk/core/pkg/i18n"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -17,6 +17,7 @@ var (
testCoverage bool testCoverage bool
testFilter string testFilter string
testGroup string testGroup string
testJSON bool
) )
func addPHPTestCommand(parent *cobra.Command) { func addPHPTestCommand(parent *cobra.Command) {
@ -34,7 +35,9 @@ func addPHPTestCommand(parent *cobra.Command) {
return errors.New(i18n.T("cmd.php.error.not_php")) return errors.New(i18n.T("cmd.php.error.not_php"))
} }
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "tests")) if !testJSON {
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "tests"))
}
ctx := context.Background() ctx := context.Background()
@ -43,6 +46,7 @@ func addPHPTestCommand(parent *cobra.Command) {
Filter: testFilter, Filter: testFilter,
Parallel: testParallel, Parallel: testParallel,
Coverage: testCoverage, Coverage: testCoverage,
JUnit: testJSON,
Output: os.Stdout, Output: os.Stdout,
} }
@ -62,6 +66,7 @@ func addPHPTestCommand(parent *cobra.Command) {
testCmd.Flags().BoolVar(&testCoverage, "coverage", false, i18n.T("cmd.php.test.flag.coverage")) 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(&testFilter, "filter", "", i18n.T("cmd.php.test.flag.filter"))
testCmd.Flags().StringVar(&testGroup, "group", "", i18n.T("cmd.php.test.flag.group")) 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) parent.AddCommand(testCmd)
} }
@ -69,6 +74,7 @@ func addPHPTestCommand(parent *cobra.Command) {
var ( var (
fmtFix bool fmtFix bool
fmtDiff bool fmtDiff bool
fmtJSON bool
) )
func addPHPFmtCommand(parent *cobra.Command) { func addPHPFmtCommand(parent *cobra.Command) {
@ -92,13 +98,15 @@ func addPHPFmtCommand(parent *cobra.Command) {
return errors.New(i18n.T("cmd.php.fmt.no_formatter")) return errors.New(i18n.T("cmd.php.fmt.no_formatter"))
} }
var msg string if !fmtJSON {
if fmtFix { var msg string
msg = i18n.T("cmd.php.fmt.formatting", map[string]interface{}{"Formatter": formatter}) if fmtFix {
} else { msg = i18n.T("cmd.php.fmt.formatting", map[string]interface{}{"Formatter": formatter})
msg = i18n.ProgressSubject("check", "code style") } else {
msg = i18n.ProgressSubject("check", "code style")
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), msg)
} }
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), msg)
ctx := context.Background() ctx := context.Background()
@ -106,6 +114,7 @@ func addPHPFmtCommand(parent *cobra.Command) {
Dir: cwd, Dir: cwd,
Fix: fmtFix, Fix: fmtFix,
Diff: fmtDiff, Diff: fmtDiff,
JSON: fmtJSON,
Output: os.Stdout, Output: os.Stdout,
} }
@ -121,10 +130,12 @@ func addPHPFmtCommand(parent *cobra.Command) {
return cli.Err("%s: %w", i18n.T("cmd.php.error.fmt_issues"), err) return cli.Err("%s: %w", i18n.T("cmd.php.error.fmt_issues"), err)
} }
if fmtFix { if !fmtJSON {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Code formatted"})) if fmtFix {
} else { cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Code formatted"}))
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.fmt.no_issues")) } else {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.fmt.no_issues"))
}
} }
return nil return nil
@ -133,6 +144,7 @@ func addPHPFmtCommand(parent *cobra.Command) {
fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("cmd.php.fmt.flag.fix")) 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(&fmtDiff, "diff", false, i18n.T("common.flag.diff"))
fmtCmd.Flags().BoolVar(&fmtJSON, "json", false, i18n.T("common.flag.json"))
parent.AddCommand(fmtCmd) parent.AddCommand(fmtCmd)
} }
@ -140,6 +152,8 @@ func addPHPFmtCommand(parent *cobra.Command) {
var ( var (
stanLevel int stanLevel int
stanMemory string stanMemory string
stanJSON bool
stanSARIF bool
) )
func addPHPStanCommand(parent *cobra.Command) { func addPHPStanCommand(parent *cobra.Command) {
@ -163,7 +177,13 @@ func addPHPStanCommand(parent *cobra.Command) {
return errors.New(i18n.T("cmd.php.analyse.no_analyser")) return errors.New(i18n.T("cmd.php.analyse.no_analyser"))
} }
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "static analysis")) 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() ctx := context.Background()
@ -171,6 +191,8 @@ func addPHPStanCommand(parent *cobra.Command) {
Dir: cwd, Dir: cwd,
Level: stanLevel, Level: stanLevel,
Memory: stanMemory, Memory: stanMemory,
JSON: stanJSON,
SARIF: stanSARIF,
Output: os.Stdout, Output: os.Stdout,
} }
@ -183,13 +205,17 @@ func addPHPStanCommand(parent *cobra.Command) {
return cli.Err("%s: %w", i18n.T("cmd.php.error.analysis_issues"), err) return cli.Err("%s: %w", i18n.T("cmd.php.error.analysis_issues"), err)
} }
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues")) if !stanJSON && !stanSARIF {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues"))
}
return nil return nil
}, },
} }
stanCmd.Flags().IntVar(&stanLevel, "level", 0, i18n.T("cmd.php.analyse.flag.level")) 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().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) parent.AddCommand(stanCmd)
} }
@ -203,6 +229,8 @@ var (
psalmFix bool psalmFix bool
psalmBaseline bool psalmBaseline bool
psalmShowInfo bool psalmShowInfo bool
psalmJSON bool
psalmSARIF bool
) )
func addPHPPsalmCommand(parent *cobra.Command) { func addPHPPsalmCommand(parent *cobra.Command) {
@ -229,13 +257,19 @@ func addPHPPsalmCommand(parent *cobra.Command) {
return errors.New(i18n.T("cmd.php.error.psalm_not_installed")) return errors.New(i18n.T("cmd.php.error.psalm_not_installed"))
} }
var msg string if psalmJSON && psalmSARIF {
if psalmFix { return errors.New(i18n.T("common.error.json_sarif_exclusive"))
msg = i18n.T("cmd.php.psalm.analysing_fixing") }
} else {
msg = i18n.T("cmd.php.psalm.analysing") 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)
} }
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.psalm")), msg)
ctx := context.Background() ctx := context.Background()
@ -245,6 +279,8 @@ func addPHPPsalmCommand(parent *cobra.Command) {
Fix: psalmFix, Fix: psalmFix,
Baseline: psalmBaseline, Baseline: psalmBaseline,
ShowInfo: psalmShowInfo, ShowInfo: psalmShowInfo,
JSON: psalmJSON,
SARIF: psalmSARIF,
Output: os.Stdout, Output: os.Stdout,
} }
@ -252,7 +288,9 @@ func addPHPPsalmCommand(parent *cobra.Command) {
return cli.Err("%s: %w", i18n.T("cmd.php.error.psalm_issues"), err) return cli.Err("%s: %w", i18n.T("cmd.php.error.psalm_issues"), err)
} }
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues")) if !psalmJSON && !psalmSARIF {
cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues"))
}
return nil return nil
}, },
} }
@ -261,6 +299,8 @@ func addPHPPsalmCommand(parent *cobra.Command) {
psalmCmd.Flags().BoolVar(&psalmFix, "fix", false, i18n.T("common.flag.fix")) 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(&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(&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) parent.AddCommand(psalmCmd)
} }
@ -459,6 +499,7 @@ var (
qaQuick bool qaQuick bool
qaFull bool qaFull bool
qaFix bool qaFix bool
qaJSON bool
) )
func addPHPQACommand(parent *cobra.Command) { func addPHPQACommand(parent *cobra.Command) {
@ -482,11 +523,14 @@ func addPHPQACommand(parent *cobra.Command) {
Quick: qaQuick, Quick: qaQuick,
Full: qaFull, Full: qaFull,
Fix: qaFix, Fix: qaFix,
JSON: qaJSON,
} }
stages := GetQAStages(opts) stages := GetQAStages(opts)
// Print header // Print header
cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "QA pipeline")) if !qaJSON {
cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "QA pipeline"))
}
ctx := context.Background() ctx := context.Background()
@ -502,66 +546,81 @@ func addPHPQACommand(parent *cobra.Command) {
return cli.Err("%s: %w", i18n.T("i18n.fail.run", "QA checks"), err) return cli.Err("%s: %w", i18n.T("i18n.fail.run", "QA checks"), err)
} }
// Display results by stage // Display results by stage (skip when JSON output is enabled)
currentStage := "" if !qaJSON {
for _, checkResult := range result.Results { currentStage := ""
// Determine stage for this check for _, checkResult := range result.Results {
stage := getCheckStage(checkResult.Name, stages, cwd) // Determine stage for this check
if stage != currentStage { stage := getCheckStage(checkResult.Name, stages, cwd)
if currentStage != "" { if stage != currentStage {
cli.Blank() if currentStage != "" {
cli.Blank()
}
currentStage = stage
cli.Print("%s\n", phpQAStageStyle.Render("── "+strings.ToUpper(stage)+" ──"))
} }
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
} }
icon := phpQAPassedStyle.Render("✓") 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"))
status := phpQAPassedStyle.Render(i18n.T("i18n.done.pass"))
if checkResult.Skipped { // Show what needs fixing
icon = dimStyle.Render("-") cli.Print("%s\n", dimStyle.Render(i18n.T("i18n.label.fix")))
status = dimStyle.Render(i18n.T("i18n.done.skip")) for _, checkResult := range result.Results {
} else if !checkResult.Passed { if checkResult.Passed || checkResult.Skipped {
icon = phpQAFailedStyle.Render("✗") continue
status = phpQAFailedStyle.Render(i18n.T("i18n.done.fail")) }
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)
}
} }
cli.Print(" %s %s %s %s\n", icon, checkResult.Name, status, dimStyle.Render(checkResult.Duration)) return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline"))
}
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")) // JSON mode: output results as JSON
output, err := json.MarshalIndent(result, "", " ")
// Show what needs fixing if err != nil {
cli.Print("%s\n", dimStyle.Render(i18n.T("i18n.label.fix"))) return cli.Wrap(err, "marshal JSON output")
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)
}
} }
cli.Text(string(output))
return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline")) if !result.Passed {
return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline"))
}
return nil
}, },
} }
qaCmd.Flags().BoolVar(&qaQuick, "quick", false, "Run quick checks only (audit, fmt, stan)") qaCmd.Flags().BoolVar(&qaQuick, "quick", false, i18n.T("cmd.php.qa.flag.quick"))
qaCmd.Flags().BoolVar(&qaFull, "full", false, "Run all stages including slow checks") qaCmd.Flags().BoolVar(&qaFull, "full", false, i18n.T("cmd.php.qa.flag.full"))
qaCmd.Flags().BoolVar(&qaFix, "fix", false, "Auto-fix issues where possible") 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) parent.AddCommand(qaCmd)
} }

View file

@ -23,6 +23,9 @@ type FormatOptions struct {
// Diff shows a diff of changes instead of modifying files. // Diff shows a diff of changes instead of modifying files.
Diff bool Diff bool
// JSON outputs results in JSON format.
JSON bool
// Paths limits formatting to specific paths. // Paths limits formatting to specific paths.
Paths []string Paths []string
@ -44,6 +47,12 @@ type AnalyseOptions struct {
// Memory is the memory limit for analysis (e.g., "2G"). // Memory is the memory limit for analysis (e.g., "2G").
Memory string 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 is the writer for output (defaults to os.Stdout).
Output io.Writer Output io.Writer
} }
@ -209,6 +218,10 @@ func buildPintCommand(opts FormatOptions) (string, []string) {
args = append(args, "--diff") args = append(args, "--diff")
} }
if opts.JSON {
args = append(args, "--format=json")
}
// Add specific paths if provided // Add specific paths if provided
args = append(args, opts.Paths...) args = append(args, opts.Paths...)
@ -234,6 +247,13 @@ func buildPHPStanCommand(opts AnalyseOptions) (string, []string) {
args = append(args, "--memory-limit", 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 // Add specific paths if provided
args = append(args, opts.Paths...) args = append(args, opts.Paths...)
@ -251,6 +271,8 @@ type PsalmOptions struct {
Fix bool // Auto-fix issues where possible Fix bool // Auto-fix issues where possible
Baseline bool // Generate/update baseline file Baseline bool // Generate/update baseline file
ShowInfo bool // Show info-level issues ShowInfo bool // Show info-level issues
JSON bool // Output in JSON format
SARIF bool // Output in SARIF format for GitHub Security tab
Output io.Writer Output io.Writer
} }
@ -327,6 +349,13 @@ func RunPsalm(ctx context.Context, opts PsalmOptions) error {
args = append(args, "--show-info=true") 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 := exec.CommandContext(ctx, cmdName, args...)
cmd.Dir = opts.Dir cmd.Dir = opts.Dir
cmd.Stdout = opts.Output cmd.Stdout = opts.Output

View file

@ -30,6 +30,9 @@ type TestOptions struct {
// Groups runs only tests in the specified groups. // Groups runs only tests in the specified groups.
Groups []string Groups []string
// JUnit outputs results in JUnit XML format via --log-junit.
JUnit bool
// Output is the writer for test output (defaults to os.Stdout). // Output is the writer for test output (defaults to os.Stdout).
Output io.Writer Output io.Writer
} }
@ -134,6 +137,10 @@ func buildPestCommand(opts TestOptions) (string, []string) {
args = append(args, "--group", group) args = append(args, "--group", group)
} }
if opts.JUnit {
args = append(args, "--log-junit", "test-results.xml")
}
return cmdName, args return cmdName, args
} }
@ -175,5 +182,9 @@ func buildPHPUnitCommand(opts TestOptions) (string, []string) {
args = append(args, "--group", group) args = append(args, "--group", group)
} }
if opts.JUnit {
args = append(args, "--log-junit", "test-results.xml", "--testdox")
}
return cmdName, args return cmdName, args
} }