Add project type detection (pkg/detect) and complete PHP quality assurance package (pkg/php) with formatter, analyser, audit, security, refactor, mutation testing, test runner, pipeline stages, and QA runner that builds process.RunSpec for orchestrated execution. Co-Authored-By: Virgil <virgil@lethean.io>
214 lines
4.8 KiB
Go
214 lines
4.8 KiB
Go
package php
|
|
|
|
import (
|
|
"path/filepath"
|
|
|
|
process "forge.lthn.ai/core/go-process"
|
|
)
|
|
|
|
// QARunner builds process run specs for PHP QA checks.
|
|
type QARunner struct {
|
|
dir string
|
|
fix bool
|
|
}
|
|
|
|
// NewQARunner creates a QA runner for the given directory.
|
|
func NewQARunner(dir string, fix bool) *QARunner {
|
|
return &QARunner{dir: dir, fix: fix}
|
|
}
|
|
|
|
// BuildSpecs creates RunSpecs for the given QA checks.
|
|
func (r *QARunner) BuildSpecs(checks []string) []process.RunSpec {
|
|
specs := make([]process.RunSpec, 0, len(checks))
|
|
for _, check := range checks {
|
|
spec := r.buildSpec(check)
|
|
if spec != nil {
|
|
specs = append(specs, *spec)
|
|
}
|
|
}
|
|
return specs
|
|
}
|
|
|
|
// buildSpec creates a RunSpec for a single check.
|
|
func (r *QARunner) buildSpec(check string) *process.RunSpec {
|
|
switch check {
|
|
case "audit":
|
|
return &process.RunSpec{
|
|
Name: "audit",
|
|
Command: "composer",
|
|
Args: []string{"audit", "--format=summary"},
|
|
Dir: r.dir,
|
|
}
|
|
|
|
case "fmt":
|
|
_, found := DetectFormatter(r.dir)
|
|
if !found {
|
|
return nil
|
|
}
|
|
vendorBin := filepath.Join(r.dir, "vendor", "bin", "pint")
|
|
cmd := "pint"
|
|
if fileExists(vendorBin) {
|
|
cmd = vendorBin
|
|
}
|
|
args := []string{}
|
|
if !r.fix {
|
|
args = append(args, "--test")
|
|
}
|
|
return &process.RunSpec{
|
|
Name: "fmt",
|
|
Command: cmd,
|
|
Args: args,
|
|
Dir: r.dir,
|
|
After: []string{"audit"},
|
|
}
|
|
|
|
case "stan":
|
|
_, found := DetectAnalyser(r.dir)
|
|
if !found {
|
|
return nil
|
|
}
|
|
vendorBin := filepath.Join(r.dir, "vendor", "bin", "phpstan")
|
|
cmd := "phpstan"
|
|
if fileExists(vendorBin) {
|
|
cmd = vendorBin
|
|
}
|
|
return &process.RunSpec{
|
|
Name: "stan",
|
|
Command: cmd,
|
|
Args: []string{"analyse", "--no-progress"},
|
|
Dir: r.dir,
|
|
After: []string{"fmt"},
|
|
}
|
|
|
|
case "psalm":
|
|
_, found := DetectPsalm(r.dir)
|
|
if !found {
|
|
return nil
|
|
}
|
|
vendorBin := filepath.Join(r.dir, "vendor", "bin", "psalm")
|
|
cmd := "psalm"
|
|
if fileExists(vendorBin) {
|
|
cmd = vendorBin
|
|
}
|
|
args := []string{"--no-progress"}
|
|
if r.fix {
|
|
args = append(args, "--alter", "--issues=all")
|
|
}
|
|
return &process.RunSpec{
|
|
Name: "psalm",
|
|
Command: cmd,
|
|
Args: args,
|
|
Dir: r.dir,
|
|
After: []string{"stan"},
|
|
}
|
|
|
|
case "test":
|
|
pestBin := filepath.Join(r.dir, "vendor", "bin", "pest")
|
|
phpunitBin := filepath.Join(r.dir, "vendor", "bin", "phpunit")
|
|
var cmd string
|
|
if fileExists(pestBin) {
|
|
cmd = pestBin
|
|
} else if fileExists(phpunitBin) {
|
|
cmd = phpunitBin
|
|
} else {
|
|
return nil
|
|
}
|
|
after := []string{"stan"}
|
|
if _, found := DetectPsalm(r.dir); found {
|
|
after = []string{"psalm"}
|
|
}
|
|
return &process.RunSpec{
|
|
Name: "test",
|
|
Command: cmd,
|
|
Args: []string{},
|
|
Dir: r.dir,
|
|
After: after,
|
|
}
|
|
|
|
case "rector":
|
|
if !DetectRector(r.dir) {
|
|
return nil
|
|
}
|
|
vendorBin := filepath.Join(r.dir, "vendor", "bin", "rector")
|
|
cmd := "rector"
|
|
if fileExists(vendorBin) {
|
|
cmd = vendorBin
|
|
}
|
|
args := []string{"process"}
|
|
if !r.fix {
|
|
args = append(args, "--dry-run")
|
|
}
|
|
return &process.RunSpec{
|
|
Name: "rector",
|
|
Command: cmd,
|
|
Args: args,
|
|
Dir: r.dir,
|
|
After: []string{"test"},
|
|
AllowFailure: true,
|
|
}
|
|
|
|
case "infection":
|
|
if !DetectInfection(r.dir) {
|
|
return nil
|
|
}
|
|
vendorBin := filepath.Join(r.dir, "vendor", "bin", "infection")
|
|
cmd := "infection"
|
|
if fileExists(vendorBin) {
|
|
cmd = vendorBin
|
|
}
|
|
return &process.RunSpec{
|
|
Name: "infection",
|
|
Command: cmd,
|
|
Args: []string{"--min-msi=50", "--min-covered-msi=70", "--threads=4"},
|
|
Dir: r.dir,
|
|
After: []string{"test"},
|
|
AllowFailure: true,
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// QARunResult holds the results of running QA checks.
|
|
type QARunResult struct {
|
|
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 `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 a human-readable issue description for a failed check.
|
|
func (r QACheckRunResult) GetIssueMessage() string {
|
|
if r.Passed || r.Skipped {
|
|
return ""
|
|
}
|
|
switch r.Name {
|
|
case "audit":
|
|
return "found vulnerabilities"
|
|
case "fmt":
|
|
return "found style issues"
|
|
case "stan":
|
|
return "found analysis errors"
|
|
case "psalm":
|
|
return "found type errors"
|
|
case "test":
|
|
return "tests failed"
|
|
case "rector":
|
|
return "found refactoring suggestions"
|
|
case "infection":
|
|
return "mutation testing did not pass"
|
|
default:
|
|
return "found issues"
|
|
}
|
|
}
|