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

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

View file

@ -253,14 +253,38 @@
"dev.short": "Start Laravel development environment",
"dev.press_ctrl_c": "Press Ctrl+C to stop all services",
"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.flag.fix": "Apply formatting fixes",
"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",
"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.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.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.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.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",
"deploy.short": "Deploy to Coolify",
"serve.short": "Run production container",
@ -445,6 +469,7 @@
"fix": "Auto-fix issues where possible",
"diff": "Show diff of changes",
"json": "Output as JSON",
"sarif": "Output as SARIF for GitHub Security tab",
"verbose": "Show detailed output",
"registry": "Path to repos.yaml registry file"
},
@ -459,7 +484,8 @@
"completed": "{{.Action}} successfully"
},
"error": {
"failed": "Failed to {{.Action}}"
"failed": "Failed to {{.Action}}",
"json_sarif_exclusive": "--json and --sarif flags are mutually exclusive"
},
"hint": {
"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.
type QARunResult struct {
Passed bool
Duration string
Results []QACheckRunResult
PassedCount int
FailedCount int
SkippedCount int
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
Passed bool
Skipped bool
ExitCode int
Duration string
Output string
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.

View file

@ -2,11 +2,11 @@ package php
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"github.com/spf13/cobra"
@ -17,6 +17,7 @@ var (
testCoverage bool
testFilter string
testGroup string
testJSON bool
)
func addPHPTestCommand(parent *cobra.Command) {
@ -34,7 +35,9 @@ func addPHPTestCommand(parent *cobra.Command) {
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()
@ -43,6 +46,7 @@ func addPHPTestCommand(parent *cobra.Command) {
Filter: testFilter,
Parallel: testParallel,
Coverage: testCoverage,
JUnit: testJSON,
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().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)
}
@ -69,6 +74,7 @@ func addPHPTestCommand(parent *cobra.Command) {
var (
fmtFix bool
fmtDiff bool
fmtJSON bool
)
func addPHPFmtCommand(parent *cobra.Command) {
@ -92,13 +98,15 @@ func addPHPFmtCommand(parent *cobra.Command) {
return errors.New(i18n.T("cmd.php.fmt.no_formatter"))
}
var msg string
if fmtFix {
msg = i18n.T("cmd.php.fmt.formatting", map[string]interface{}{"Formatter": formatter})
} else {
msg = i18n.ProgressSubject("check", "code style")
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)
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), msg)
ctx := context.Background()
@ -106,6 +114,7 @@ func addPHPFmtCommand(parent *cobra.Command) {
Dir: cwd,
Fix: fmtFix,
Diff: fmtDiff,
JSON: fmtJSON,
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)
}
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"))
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
@ -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(&fmtDiff, "diff", false, i18n.T("common.flag.diff"))
fmtCmd.Flags().BoolVar(&fmtJSON, "json", false, i18n.T("common.flag.json"))
parent.AddCommand(fmtCmd)
}
@ -140,6 +152,8 @@ func addPHPFmtCommand(parent *cobra.Command) {
var (
stanLevel int
stanMemory string
stanJSON bool
stanSARIF bool
)
func addPHPStanCommand(parent *cobra.Command) {
@ -163,7 +177,13 @@ func addPHPStanCommand(parent *cobra.Command) {
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()
@ -171,6 +191,8 @@ func addPHPStanCommand(parent *cobra.Command) {
Dir: cwd,
Level: stanLevel,
Memory: stanMemory,
JSON: stanJSON,
SARIF: stanSARIF,
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)
}
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
},
}
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)
}
@ -203,6 +229,8 @@ var (
psalmFix bool
psalmBaseline bool
psalmShowInfo bool
psalmJSON bool
psalmSARIF bool
)
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"))
}
var msg string
if psalmFix {
msg = i18n.T("cmd.php.psalm.analysing_fixing")
} else {
msg = i18n.T("cmd.php.psalm.analysing")
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)
}
cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.psalm")), msg)
ctx := context.Background()
@ -245,6 +279,8 @@ func addPHPPsalmCommand(parent *cobra.Command) {
Fix: psalmFix,
Baseline: psalmBaseline,
ShowInfo: psalmShowInfo,
JSON: psalmJSON,
SARIF: psalmSARIF,
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)
}
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
},
}
@ -261,6 +299,8 @@ func addPHPPsalmCommand(parent *cobra.Command) {
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)
}
@ -459,6 +499,7 @@ var (
qaQuick bool
qaFull bool
qaFix bool
qaJSON bool
)
func addPHPQACommand(parent *cobra.Command) {
@ -482,11 +523,14 @@ func addPHPQACommand(parent *cobra.Command) {
Quick: qaQuick,
Full: qaFull,
Fix: qaFix,
JSON: qaJSON,
}
stages := GetQAStages(opts)
// 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()
@ -502,66 +546,81 @@ func addPHPQACommand(parent *cobra.Command) {
return cli.Err("%s: %w", i18n.T("i18n.fail.run", "QA checks"), err)
}
// Display results by stage
currentStage := ""
for _, checkResult := range result.Results {
// Determine stage for this check
stage := getCheckStage(checkResult.Name, stages, cwd)
if stage != currentStage {
if currentStage != "" {
cli.Blank()
// 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)+" ──"))
}
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("✓")
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\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)
}
}
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
return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline"))
}
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)
}
// 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))
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(&qaFull, "full", false, "Run all stages including slow checks")
qaCmd.Flags().BoolVar(&qaFix, "fix", false, "Auto-fix issues where possible")
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)
}

View file

@ -23,6 +23,9 @@ type FormatOptions struct {
// 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
@ -44,6 +47,12 @@ type AnalyseOptions struct {
// 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 io.Writer
}
@ -209,6 +218,10 @@ func buildPintCommand(opts FormatOptions) (string, []string) {
args = append(args, "--diff")
}
if opts.JSON {
args = append(args, "--format=json")
}
// Add specific paths if provided
args = append(args, opts.Paths...)
@ -234,6 +247,13 @@ func buildPHPStanCommand(opts AnalyseOptions) (string, []string) {
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...)
@ -251,6 +271,8 @@ type PsalmOptions struct {
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 io.Writer
}
@ -327,6 +349,13 @@ func RunPsalm(ctx context.Context, opts PsalmOptions) error {
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

View file

@ -30,6 +30,9 @@ type TestOptions struct {
// Groups runs only tests in the specified groups.
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 io.Writer
}
@ -134,6 +137,10 @@ func buildPestCommand(opts TestOptions) (string, []string) {
args = append(args, "--group", group)
}
if opts.JUnit {
args = append(args, "--log-junit", "test-results.xml")
}
return cmdName, args
}
@ -175,5 +182,9 @@ func buildPHPUnitCommand(opts TestOptions) (string, []string) {
args = append(args, "--group", group)
}
if opts.JUnit {
args = append(args, "--log-junit", "test-results.xml", "--testdox")
}
return cmdName, args
}