fix(ax): clean php structured output modes

This commit is contained in:
Virgil 2026-03-30 08:14:46 +00:00
parent 6d202bb1d9
commit 8c8c6a9d2e
5 changed files with 397 additions and 34 deletions

View file

@ -67,7 +67,7 @@ func addPHPFmtCommand(parent *cli.Command) {
return cli.Err("not a PHP project (no composer.json found)")
}
if !phpFmtJSON {
if !isMachineReadableOutput(phpFmtJSON) {
cli.Print("%s %s\n", headerStyle.Render("PHP Format"), dimStyle.Render("(Pint)"))
cli.Blank()
}
@ -115,7 +115,7 @@ func addPHPStanCommand(parent *cli.Command) {
return cli.Err("no static analyser found (install PHPStan: composer require phpstan/phpstan --dev)")
}
if !phpStanJSON {
if !isMachineReadableOutput(phpStanJSON, phpStanSARIF) {
cli.Print("%s %s\n", headerStyle.Render("PHP Static Analysis"), dimStyle.Render(fmt.Sprintf("(%s)", analyser)))
cli.Blank()
}
@ -131,7 +131,7 @@ func addPHPStanCommand(parent *cli.Command) {
return cli.Err("static analysis found issues")
}
if !phpStanJSON {
if !isMachineReadableOutput(phpStanJSON, phpStanSARIF) {
cli.Blank()
cli.Print("%s\n", successStyle.Render("Static analysis passed"))
}
@ -176,7 +176,7 @@ func addPHPPsalmCommand(parent *cli.Command) {
return cli.Err("Psalm not found (install: composer require vimeo/psalm --dev)")
}
if !phpPsalmJSON {
if !isMachineReadableOutput(phpPsalmJSON, phpPsalmSARIF) {
cli.Print("%s\n", headerStyle.Render("PHP Psalm Analysis"))
cli.Blank()
}
@ -194,7 +194,7 @@ func addPHPPsalmCommand(parent *cli.Command) {
return cli.Err("Psalm found issues")
}
if !phpPsalmJSON {
if !isMachineReadableOutput(phpPsalmJSON, phpPsalmSARIF) {
cli.Blank()
cli.Print("%s\n", successStyle.Render("Psalm analysis passed"))
}
@ -232,7 +232,7 @@ func addPHPAuditCommand(parent *cli.Command) {
return cli.Err("not a PHP project (no composer.json found)")
}
if !phpAuditJSON {
if !isMachineReadableOutput(phpAuditJSON) {
cli.Print("%s\n", headerStyle.Render("Dependency Audit"))
cli.Blank()
}
@ -321,7 +321,7 @@ func addPHPSecurityCommand(parent *cli.Command) {
return cli.Err("not a PHP project (no composer.json found)")
}
if !phpSecurityJSON {
if !isMachineReadableOutput(phpSecurityJSON, phpSecuritySARIF) {
cli.Print("%s\n", headerStyle.Render("Security Checks"))
cli.Blank()
}
@ -339,6 +339,20 @@ func addPHPSecurityCommand(parent *cli.Command) {
result.Checks = sortSecurityChecks(result.Checks)
if phpSecuritySARIF {
data, err := json.MarshalIndent(mapSecurityResultForSARIF(result), "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
summary := result.Summary
if summary.Critical > 0 || summary.High > 0 {
return cli.Err("security checks failed")
}
return nil
}
if phpSecurityJSON {
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
@ -596,8 +610,10 @@ func addPHPTestCommand(parent *cli.Command) {
}
runner := php.DetectTestRunner(cwd)
cli.Print("%s %s\n", headerStyle.Render("PHP Tests"), dimStyle.Render(fmt.Sprintf("(%s)", runner)))
cli.Blank()
if !isMachineReadableOutput(phpTestJUnit) {
cli.Print("%s %s\n", headerStyle.Render("PHP Tests"), dimStyle.Render(fmt.Sprintf("(%s)", runner)))
cli.Blank()
}
var groups []string
if phpTestGroup != "" {
@ -616,8 +632,10 @@ func addPHPTestCommand(parent *cli.Command) {
return cli.Err("tests failed")
}
cli.Blank()
cli.Print("%s\n", successStyle.Render("All tests passed"))
if !isMachineReadableOutput(phpTestJUnit) {
cli.Blank()
cli.Print("%s\n", successStyle.Render("All tests passed"))
}
return nil
},
}
@ -646,3 +664,124 @@ func getSeverityStyle(severity string) *cli.AnsiStyle {
return dimStyle
}
}
func isMachineReadableOutput(flags ...bool) bool {
for _, flag := range flags {
if flag {
return true
}
}
return false
}
type sarifLog struct {
Version string `json:"version"`
Schema string `json:"$schema"`
Runs []sarifRun `json:"runs"`
}
type sarifRun struct {
Tool sarifTool `json:"tool"`
Results []sarifResult `json:"results"`
}
type sarifTool struct {
Driver sarifDriver `json:"driver"`
}
type sarifDriver struct {
Name string `json:"name"`
Rules []sarifRule `json:"rules"`
}
type sarifRule struct {
ID string `json:"id"`
Name string `json:"name"`
ShortDescription sarifMessage `json:"shortDescription"`
FullDescription sarifMessage `json:"fullDescription"`
Help sarifMessage `json:"help,omitempty"`
Properties any `json:"properties,omitempty"`
}
type sarifResult struct {
RuleID string `json:"ruleId"`
Level string `json:"level"`
Message sarifMessage `json:"message"`
Properties any `json:"properties,omitempty"`
}
type sarifMessage struct {
Text string `json:"text"`
}
func mapSecurityResultForSARIF(result *php.SecurityResult) sarifLog {
rules := make([]sarifRule, 0, len(result.Checks))
sarifResults := make([]sarifResult, 0, len(result.Checks))
for _, check := range result.Checks {
rule := sarifRule{
ID: check.ID,
Name: check.Name,
ShortDescription: sarifMessage{Text: check.Name},
FullDescription: sarifMessage{Text: check.Description},
}
if check.Fix != "" {
rule.Help = sarifMessage{Text: check.Fix}
}
if check.CWE != "" {
rule.Properties = map[string]any{"cwe": check.CWE}
}
rules = append(rules, rule)
if check.Passed {
continue
}
message := check.Message
if message == "" {
message = check.Description
}
properties := map[string]any{
"severity": check.Severity,
}
if check.CWE != "" {
properties["cwe"] = check.CWE
}
if check.Fix != "" {
properties["fix"] = check.Fix
}
sarifResults = append(sarifResults, sarifResult{
RuleID: check.ID,
Level: sarifLevel(check.Severity),
Message: sarifMessage{Text: message},
Properties: properties,
})
}
return sarifLog{
Version: "2.1.0",
Schema: "https://json.schemastore.org/sarif-2.1.0.json",
Runs: []sarifRun{{
Tool: sarifTool{
Driver: sarifDriver{
Name: "core qa security",
Rules: rules,
},
},
Results: sarifResults,
}},
}
}
func sarifLevel(severity string) string {
switch strings.ToLower(severity) {
case "critical", "high":
return "error"
case "medium":
return "warning"
default:
return "note"
}
}

View file

@ -1,6 +1,7 @@
package qa
import (
"encoding/json"
"io"
"os"
"path/filepath"
@ -55,6 +56,126 @@ func TestPHPPsalmJSONOutput_DoesNotAppendSuccessBanner(t *testing.T) {
assert.NotContains(t, output, "PHP Psalm Analysis")
}
func TestPHPStanSARIFOutput_DoesNotAppendSuccessBanner(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, filepath.Join(dir, "composer.json"), "{}")
writeExecutable(t, filepath.Join(dir, "vendor", "bin", "phpstan"), "#!/bin/sh\nprintf '%s\\n' '{\"version\":\"2.1.0\",\"runs\":[]}'\n")
restoreWorkingDir(t, dir)
resetPHPStanFlags(t)
parent := &cli.Command{Use: "qa"}
addPHPStanCommand(parent)
command := findSubcommand(t, parent, "stan")
require.NoError(t, command.Flags().Set("sarif", "true"))
output := captureStdout(t, func() {
require.NoError(t, command.RunE(command, nil))
})
assert.Equal(t, "{\"version\":\"2.1.0\",\"runs\":[]}\n", output)
assert.NotContains(t, output, "Static analysis passed")
assert.NotContains(t, output, "PHP Static Analysis")
}
func TestPHPPsalmSARIFOutput_DoesNotAppendSuccessBanner(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, filepath.Join(dir, "composer.json"), "{}")
writeExecutable(t, filepath.Join(dir, "vendor", "bin", "psalm"), "#!/bin/sh\nprintf '%s\\n' '{\"version\":\"2.1.0\",\"runs\":[]}'\n")
restoreWorkingDir(t, dir)
resetPHPPsalmFlags(t)
parent := &cli.Command{Use: "qa"}
addPHPPsalmCommand(parent)
command := findSubcommand(t, parent, "psalm")
require.NoError(t, command.Flags().Set("sarif", "true"))
output := captureStdout(t, func() {
require.NoError(t, command.RunE(command, nil))
})
assert.Equal(t, "{\"version\":\"2.1.0\",\"runs\":[]}\n", output)
assert.NotContains(t, output, "Psalm analysis passed")
assert.NotContains(t, output, "PHP Psalm Analysis")
}
func TestPHPSecurityJSONOutput_UsesMachineFriendlyKeys(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, filepath.Join(dir, "composer.json"), "{}")
writeTestFile(t, filepath.Join(dir, ".env"), "APP_DEBUG=true\nAPP_KEY=short\nAPP_URL=http://example.com\n")
writeExecutable(t, filepath.Join(dir, "bin", "composer"), "#!/bin/sh\nprintf '%s\\n' '{\"advisories\":{}}'\n")
restoreWorkingDir(t, dir)
prependPath(t, filepath.Join(dir, "bin"))
resetPHPSecurityFlags(t)
parent := &cli.Command{Use: "qa"}
addPHPSecurityCommand(parent)
command := findSubcommand(t, parent, "security")
require.NoError(t, command.Flags().Set("json", "true"))
output := captureStdout(t, func() {
require.Error(t, command.RunE(command, nil))
})
assert.Contains(t, output, "\"checks\"")
assert.Contains(t, output, "\"summary\"")
assert.Contains(t, output, "\"app_key_set\"")
assert.NotContains(t, output, "\"Checks\"")
assert.NotContains(t, output, "Security Checks")
}
func TestPHPSecuritySARIFOutput_IsStructuredAndChromeFree(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, filepath.Join(dir, "composer.json"), "{}")
writeTestFile(t, filepath.Join(dir, ".env"), "APP_DEBUG=true\nAPP_KEY=short\nAPP_URL=http://example.com\n")
writeExecutable(t, filepath.Join(dir, "bin", "composer"), "#!/bin/sh\nprintf '%s\\n' '{\"advisories\":{}}'\n")
restoreWorkingDir(t, dir)
prependPath(t, filepath.Join(dir, "bin"))
resetPHPSecurityFlags(t)
parent := &cli.Command{Use: "qa"}
addPHPSecurityCommand(parent)
command := findSubcommand(t, parent, "security")
require.NoError(t, command.Flags().Set("sarif", "true"))
output := captureStdout(t, func() {
require.Error(t, command.RunE(command, nil))
})
var payload map[string]any
require.NoError(t, json.Unmarshal([]byte(output), &payload))
assert.Equal(t, "2.1.0", payload["version"])
assert.Contains(t, output, "\"ruleId\": \"app_key_set\"")
assert.NotContains(t, output, "Security Checks")
assert.NotContains(t, output, "Summary:")
}
func TestPHPTestJUnitOutput_PrintsOnlyXML(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, filepath.Join(dir, "composer.json"), "{}")
writeExecutable(t, filepath.Join(dir, "vendor", "bin", "phpunit"), "#!/bin/sh\njunit=''\nwhile [ $# -gt 0 ]; do\n if [ \"$1\" = \"--log-junit\" ]; then\n shift\n junit=\"$1\"\n fi\n shift\ndone\nprintf '%s\\n' 'human output should be suppressed'\nprintf '%s' '<testsuite tests=\"1\"></testsuite>' > \"$junit\"\n")
restoreWorkingDir(t, dir)
resetPHPTestFlags(t)
parent := &cli.Command{Use: "qa"}
addPHPTestCommand(parent)
command := findSubcommand(t, parent, "test")
require.NoError(t, command.Flags().Set("junit", "true"))
output := captureStdout(t, func() {
require.NoError(t, command.RunE(command, nil))
})
assert.Equal(t, "<testsuite tests=\"1\"></testsuite>\n", output)
assert.NotContains(t, output, "human output should be suppressed")
assert.NotContains(t, output, "PHP Tests")
assert.NotContains(t, output, "All tests passed")
}
func writeTestFile(t *testing.T, path string, content string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
@ -119,6 +240,45 @@ func resetPHPPsalmFlags(t *testing.T) {
})
}
func resetPHPSecurityFlags(t *testing.T) {
t.Helper()
oldSeverity := phpSecuritySeverity
oldJSON := phpSecurityJSON
oldSARIF := phpSecuritySARIF
oldURL := phpSecurityURL
phpSecuritySeverity = ""
phpSecurityJSON = false
phpSecuritySARIF = false
phpSecurityURL = ""
t.Cleanup(func() {
phpSecuritySeverity = oldSeverity
phpSecurityJSON = oldJSON
phpSecuritySARIF = oldSARIF
phpSecurityURL = oldURL
})
}
func resetPHPTestFlags(t *testing.T) {
t.Helper()
oldParallel := phpTestParallel
oldCoverage := phpTestCoverage
oldFilter := phpTestFilter
oldGroup := phpTestGroup
oldJUnit := phpTestJUnit
phpTestParallel = false
phpTestCoverage = false
phpTestFilter = ""
phpTestGroup = ""
phpTestJUnit = false
t.Cleanup(func() {
phpTestParallel = oldParallel
phpTestCoverage = oldCoverage
phpTestFilter = oldFilter
phpTestGroup = oldGroup
phpTestJUnit = oldJUnit
})
}
func findSubcommand(t *testing.T, parent *cli.Command, name string) *cli.Command {
t.Helper()
for _, command := range parent.Commands() {
@ -151,3 +311,12 @@ func captureStdout(t *testing.T, fn func()) string {
require.NoError(t, err)
return string(output)
}
func prependPath(t *testing.T, dir string) {
t.Helper()
oldPath := os.Getenv("PATH")
require.NoError(t, os.Setenv("PATH", dir+string(os.PathListSeparator)+oldPath))
t.Cleanup(func() {
require.NoError(t, os.Setenv("PATH", oldPath))
})
}

View file

@ -22,30 +22,30 @@ type SecurityOptions struct {
// SecurityResult holds the results of security scanning.
type SecurityResult struct {
Checks []SecurityCheck
Summary SecuritySummary
Checks []SecurityCheck `json:"checks"`
Summary SecuritySummary `json:"summary"`
}
// 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
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Severity string `json:"severity"`
Passed bool `json:"passed"`
Message string `json:"message,omitempty"`
Fix string `json:"fix,omitempty"`
CWE string `json:"cwe,omitempty"`
}
// SecuritySummary summarises security check results.
type SecuritySummary struct {
Total int
Passed int
Critical int
High int
Medium int
Low int
Total int `json:"total"`
Passed int `json:"passed"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
}
// capitalise returns s with the first letter upper-cased.

View file

@ -1,6 +1,7 @@
package php
import (
"bytes"
"context"
"io"
"os"
@ -33,6 +34,9 @@ type TestOptions struct {
// JUnit outputs results in JUnit XML format via --log-junit.
JUnit bool
// JUnitPath overrides the JUnit report path. Defaults to test-results.xml.
JUnitPath string
// Output is the writer for test output (defaults to os.Stdout).
Output io.Writer
}
@ -73,6 +77,18 @@ func RunTests(ctx context.Context, opts TestOptions) error {
opts.Output = os.Stdout
}
if opts.JUnit && opts.JUnitPath == "" {
reportFile, err := os.CreateTemp("", "core-qa-junit-*.xml")
if err != nil {
return coreerr.E("php.RunTests", "create JUnit report file", err)
}
if closeErr := reportFile.Close(); closeErr != nil {
return coreerr.E("php.RunTests", "close JUnit report file", closeErr)
}
opts.JUnitPath = reportFile.Name()
defer os.Remove(opts.JUnitPath)
}
// Detect test runner
runner := DetectTestRunner(opts.Dir)
@ -89,14 +105,27 @@ func RunTests(ctx context.Context, opts TestOptions) error {
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Dir = opts.Dir
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
cmd.Stdin = os.Stdin
// Set XDEBUG_MODE=coverage to avoid PHPUnit 11 warning
cmd.Env = append(os.Environ(), "XDEBUG_MODE=coverage")
return cmd.Run()
if !opts.JUnit {
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
return cmd.Run()
}
var machineOutput bytes.Buffer
cmd.Stdout = &machineOutput
cmd.Stderr = &machineOutput
runErr := cmd.Run()
reportErr := emitJUnitReport(opts.Output, opts.JUnitPath)
if runErr != nil {
return runErr
}
return reportErr
}
// RunParallel runs tests in parallel using the appropriate runner.
@ -140,7 +169,7 @@ func buildPestCommand(opts TestOptions) (string, []string) {
}
if opts.JUnit {
args = append(args, "--log-junit", "test-results.xml")
args = append(args, "--log-junit", junitReportPath(opts))
}
return cmdName, args
@ -185,8 +214,34 @@ func buildPHPUnitCommand(opts TestOptions) (string, []string) {
}
if opts.JUnit {
args = append(args, "--log-junit", "test-results.xml", "--testdox")
args = append(args, "--log-junit", junitReportPath(opts))
}
return cmdName, args
}
func junitReportPath(opts TestOptions) string {
if opts.JUnitPath != "" {
return opts.JUnitPath
}
return "test-results.xml"
}
func emitJUnitReport(output io.Writer, reportPath string) error {
report, err := os.ReadFile(reportPath)
if err != nil {
return coreerr.E("php.emitJUnitReport", "read JUnit report", err)
}
if _, err := output.Write(report); err != nil {
return coreerr.E("php.emitJUnitReport", "write JUnit report", err)
}
if len(report) == 0 || report[len(report)-1] != '\n' {
if _, err := io.WriteString(output, "\n"); err != nil {
return coreerr.E("php.emitJUnitReport", "terminate JUnit report", err)
}
}
return nil
}

View file

@ -288,7 +288,7 @@ func TestBuildPHPUnitCommand_Good_JUnit(t *testing.T) {
assert.Contains(t, args, "--log-junit")
assert.Contains(t, args, "test-results.xml")
assert.Contains(t, args, "--testdox")
assert.NotContains(t, args, "--testdox")
}
func TestBuildPHPUnitCommand_Good_AllFlags(t *testing.T) {
@ -313,5 +313,5 @@ func TestBuildPHPUnitCommand_Good_AllFlags(t *testing.T) {
assert.Contains(t, args, "--group")
assert.Contains(t, args, "feature")
assert.Contains(t, args, "--log-junit")
assert.Contains(t, args, "--testdox")
assert.NotContains(t, args, "--testdox")
}