diff --git a/internal/extension/config.go b/internal/extension/config.go index 2a73d786..78332064 100644 --- a/internal/extension/config.go +++ b/internal/extension/config.go @@ -221,6 +221,8 @@ type ConfigValidation struct { // PhpVersion overrides the PHP version used for linting (e.g. "8.4"). // When set, this takes precedence over the version derived from composer.json or the static Shopware-to-PHP mapping. PhpVersion string `yaml:"php_version,omitempty"` + // PhpstanConfig points PHPStan at an custom config instead of the bundled one. + PhpstanConfig string `yaml:"phpstan_config,omitempty"` } type ConfigValidationList []validation.ToolConfigIgnore @@ -344,6 +346,12 @@ func validateExtensionConfig(config *Config) error { } } + if config.Validation.PhpstanConfig != "" { + if err := validateRelativePath(config.Validation.PhpstanConfig); err != nil { + return fmt.Errorf("validation.phpstan_config: %w", err) + } + } + return nil } diff --git a/internal/extension/config_schema.json b/internal/extension/config_schema.json index 6b471971..d5ad12e1 100644 --- a/internal/extension/config_schema.json +++ b/internal/extension/config_schema.json @@ -556,6 +556,10 @@ "php_version": { "type": "string", "description": "PhpVersion overrides the PHP version used for linting (e.g. \"8.4\").\nWhen set, this takes precedence over the version derived from composer.json or the static Shopware-to-PHP mapping." + }, + "phpstan_config": { + "type": "string", + "description": "PhpstanConfig points PHPStan at an custom config instead of the bundled one." } }, "additionalProperties": false, diff --git a/internal/extension/config_test.go b/internal/extension/config_test.go index bb3ab899..d9582a7b 100644 --- a/internal/extension/config_test.go +++ b/internal/extension/config_test.go @@ -369,6 +369,30 @@ func TestValidateExtensionConfig(t *testing.T) { assert.NoError(t, err) }) + t.Run("accepts a relative validation.phpstan_config", func(t *testing.T) { + config := &Config{Validation: ConfigValidation{PhpstanConfig: "phpstan-verifier.neon"}} + + assert.NoError(t, validateExtensionConfig(config)) + }) + + t.Run("fails when validation.phpstan_config is absolute", func(t *testing.T) { + config := &Config{Validation: ConfigValidation{PhpstanConfig: "/etc/phpstan.neon"}} + err := validateExtensionConfig(config) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "validation.phpstan_config") + assert.Contains(t, err.Error(), "must be relative") + }) + + t.Run("fails when validation.phpstan_config escapes the extension", func(t *testing.T) { + config := &Config{Validation: ConfigValidation{PhpstanConfig: "../phpstan.neon"}} + err := validateExtensionConfig(config) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "validation.phpstan_config") + assert.Contains(t, err.Error(), "must not escape") + }) + t.Run("fails when English tags exceed 5", func(t *testing.T) { tags := []string{"tag1", "tag2", "tag3", "tag4", "tag5", "tag6"} config := &Config{ diff --git a/internal/verifier/extension.go b/internal/verifier/extension.go index d24f7ed7..401948c2 100644 --- a/internal/verifier/extension.go +++ b/internal/verifier/extension.go @@ -13,6 +13,21 @@ import ( ) func ConvertExtensionToToolConfig(ext extension.Extension) (*ToolConfig, error) { + cfg := newToolConfig(ext) + + constraint, err := ext.GetShopwareVersionConstraint() + if err != nil { + return nil, err + } + + if err := determineVersionRange(cfg, constraint); err != nil { + return nil, err + } + + return cfg, nil +} + +func newToolConfig(ext extension.Extension) *ToolConfig { var ignores []validation.ToolConfigIgnore for _, ignore := range ext.GetExtensionConfig().Validation.Ignore { @@ -23,26 +38,16 @@ func ConvertExtensionToToolConfig(ext extension.Extension) (*ToolConfig, error) }) } - cfg := &ToolConfig{ + return &ToolConfig{ ToolDirectory: GetToolDirectory(), Extension: ext, ValidationIgnores: ignores, + PhpstanConfig: ext.GetExtensionConfig().Validation.PhpstanConfig, RootDir: ext.GetPath(), SourceDirectories: ext.GetSourceDirs(), AdminDirectories: getAdminFolders(ext), StorefrontDirectories: getStorefrontFolders(ext), } - - constraint, err := ext.GetShopwareVersionConstraint() - if err != nil { - return nil, err - } - - if err := determineVersionRange(cfg, constraint); err != nil { - return nil, err - } - - return cfg, nil } // getShopwareVersions returns the available Shopware versions. It is a package diff --git a/internal/verifier/extension_test.go b/internal/verifier/extension_test.go new file mode 100644 index 00000000..52b37600 --- /dev/null +++ b/internal/verifier/extension_test.go @@ -0,0 +1,27 @@ +package verifier + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/extension" + "github.com/shopware/shopware-cli/internal/testhelper" +) + +func TestConvertExtensionToToolConfigCopiesPhpstanConfig(t *testing.T) { + pluginDir := filepath.Join(t.TempDir(), "SwagExample") + testhelper.WriteFile(t, filepath.Join(pluginDir, "composer.json"), testhelper.PluginComposer("test/swag-example", "1.0.0", `SwagExample\SwagExample`).String()) + testhelper.WriteFile(t, filepath.Join(pluginDir, ".shopware-extension.yml"), "validation:\n phpstan_config: phpstan-verifier.neon\n") + testhelper.WriteFile(t, filepath.Join(pluginDir, "phpstan-verifier.neon"), "parameters:\n") + + ext, err := extension.GetExtensionByFolder(t.Context(), pluginDir) + require.NoError(t, err) + + cfg := newToolConfig(ext) + + assert.Equal(t, "phpstan-verifier.neon", cfg.PhpstanConfig) + assert.Equal(t, ext.GetPath(), cfg.RootDir) +} diff --git a/internal/verifier/phpstan.go b/internal/verifier/phpstan.go index 97cb1dcc..4753fde4 100644 --- a/internal/verifier/phpstan.go +++ b/internal/verifier/phpstan.go @@ -5,9 +5,11 @@ import ( "context" _ "embed" "encoding/json" + "fmt" "os" "os/exec" "path" + "path/filepath" "regexp" "strings" @@ -62,16 +64,18 @@ func (p PhpStan) Check(ctx context.Context, check *Check, config ToolConfig) err return nil } + configArguments, err := p.configArguments(config) + + if err != nil { + return err + } + if err := installComposerDeps(ctx, config.RootDir, config.CheckAgainst); err != nil { return err } for _, sourceDirectory := range config.SourceDirectories { - phpstanArguments := []string{"-dmemory_limit=2G", path.Join(config.ToolDirectory, "php", "vendor", "bin", "phpstan"), "analyse", "--no-progress", "--no-interaction", "--error-format=json", sourceDirectory} - - if !p.configExists(config.RootDir) { - phpstanArguments = append(phpstanArguments, "--configuration", path.Join(config.ToolDirectory, "php", "configs", "phpstan.neon")) - } + phpstanArguments := append([]string{"-dmemory_limit=2G", path.Join(config.ToolDirectory, "php", "vendor", "bin", "phpstan"), "analyse", "--no-progress", "--no-interaction", "--error-format=json", sourceDirectory}, configArguments...) if logging.IsVerbose(ctx) { phpstanArguments = append(phpstanArguments, "-v") @@ -144,6 +148,31 @@ func (p PhpStan) Check(ctx context.Context, check *Check, config ToolConfig) err return nil } +// configArguments returns the "--configuration" pair PHPStan should run with, or nothing when the +// extension ships a config PHPStan discovers by itself. +func (p PhpStan) configArguments(config ToolConfig) ([]string, error) { + if config.PhpstanConfig != "" { + resolved := filepath.Join(config.RootDir, config.PhpstanConfig) + + info, err := os.Stat(resolved) + if err != nil { + return nil, fmt.Errorf("validation.phpstan_config %q cannot be read: %w", config.PhpstanConfig, err) + } + + if info.IsDir() { + return nil, fmt.Errorf("validation.phpstan_config %q is a directory, expected a config file", config.PhpstanConfig) + } + + return []string{"--configuration", resolved}, nil + } + + if p.configExists(config.RootDir) { + return nil, nil + } + + return []string{"--configuration", path.Join(config.ToolDirectory, "php", "configs", "phpstan.neon")}, nil +} + func isPhpStanNoFilesOutput(output string) bool { return strings.Contains(output, "No files found to analyse") } diff --git a/internal/verifier/phpstan_test.go b/internal/verifier/phpstan_test.go index 3c69efb2..b85b4ddc 100644 --- a/internal/verifier/phpstan_test.go +++ b/internal/verifier/phpstan_test.go @@ -1,9 +1,13 @@ package verifier import ( + "os" + "path" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPhpStan_isUselessDeprecation(t *testing.T) { @@ -102,3 +106,93 @@ func TestIsPhpStanNoFilesOutput(t *testing.T) { }) } } + +func TestPhpStan_configArguments(t *testing.T) { + toolDir := t.TempDir() + bundledConfig := path.Join(toolDir, "php", "configs", "phpstan.neon") + + tests := []struct { + name string + phpstanConfig string + rootFiles []string + rootDirs []string + wantConfig string + wantErr string + }{ + { + name: "extension supplied config is used", + phpstanConfig: "phpstan-verifier.neon", + rootFiles: []string{"phpstan-verifier.neon"}, + wantConfig: "phpstan-verifier.neon", + }, + { + name: "extension supplied config wins over a discovered one", + phpstanConfig: "phpstan-verifier.neon", + rootFiles: []string{"phpstan-verifier.neon", "phpstan.neon.dist"}, + wantConfig: "phpstan-verifier.neon", + }, + { + name: "discovered config is left to phpstan", + rootFiles: []string{"phpstan.neon.dist"}, + }, + { + name: "bundled config when the extension has none", + wantConfig: bundledConfig, + }, + { + name: "unreadable file is reported", + phpstanConfig: "phpstan-verifier.neon", + wantErr: "cannot be read", + }, + { + name: "directory is reported", + phpstanConfig: "configs", + rootDirs: []string{"configs"}, + wantErr: "is a directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootDir := t.TempDir() + + for _, file := range tt.rootFiles { + require.NoError(t, os.WriteFile(path.Join(rootDir, file), []byte("parameters:\n"), 0o600)) + } + + for _, dir := range tt.rootDirs { + require.NoError(t, os.Mkdir(path.Join(rootDir, dir), 0o750)) + } + + arguments, err := PhpStan{}.configArguments(ToolConfig{ + ToolDirectory: toolDir, + RootDir: rootDir, + PhpstanConfig: tt.phpstanConfig, + }) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), "validation.phpstan_config") + assert.Contains(t, err.Error(), tt.wantErr) + + return + } + + require.NoError(t, err) + + if tt.wantConfig == "" { + assert.Empty(t, arguments) + + return + } + + wantConfig := tt.wantConfig + + if !filepath.IsAbs(wantConfig) { + wantConfig = filepath.Join(rootDir, wantConfig) + } + + assert.Equal(t, []string{"--configuration", wantConfig}, arguments) + }) + } +} diff --git a/internal/verifier/tool.go b/internal/verifier/tool.go index b4678942..5410fc93 100644 --- a/internal/verifier/tool.go +++ b/internal/verifier/tool.go @@ -39,6 +39,8 @@ type ToolConfig struct { SourceDirectories []string // Contains a list of identifiers that are ignored ValidationIgnores []validation.ToolConfigIgnore + // Path to an extension-supplied PHPStan config, relative to RootDir. Empty means the bundled config is used. + PhpstanConfig string // Contains a list of directories that are considered as admin code AdminDirectories []string // Contains a list of directories that are considered as storefront code