diff --git a/.gitignore b/.gitignore index 5f55c094..ba9ac788 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ !Dockerfile !AGENTS.md !CLAUDE.md +!schema.json !.oxfmtrc.json !.git-blame-ignore-revs diff --git a/cli/command/init/init.go b/cli/command/init/init.go index 71a6e1b1..412eed36 100644 --- a/cli/command/init/init.go +++ b/cli/command/init/init.go @@ -255,6 +255,8 @@ func runExistingInit(wpmCli command.Cli, opts *initOptions) error { wpmCfg.Name = filepath.Base(cwd) } + removeSelfDependency(wpmCfg) + if err := resolveConfigVersion(wpmCli, wpmCfg, opts, extractedVersion); err != nil { return err } @@ -539,7 +541,6 @@ func buildWpmConfig(opts initOptions, pkgType string, mainFileHeaders any, readm } dependencies := &types.Dependencies{} - cfg.Team = getMetaStringSlice(readmeMeta, "contributors") wpRequires := getMetaString(readmeMeta, "requires", "") phpRequires := getMetaString(readmeMeta, "requires_php", "") testedUpTo := getMetaString(readmeMeta, "tested", "") @@ -552,20 +553,11 @@ func buildWpmConfig(opts initOptions, pkgType string, mainFileHeaders any, readm } cfg.Tags = sanitizeStringList(cfg.Tags, 5, 2, 64) - cfg.Team = sanitizeStringList(cfg.Team, 100, 2, 100) - - // Trim description to max 512 characters if len(cfg.Description) > 512 { cfg.Description = trimMeaningfully(cfg.Description, 512) } - // Validate license length, and set to empty if invalid - if len(cfg.License) < 3 || len(cfg.License) > 100 { - cfg.License = "" - } - requires := buildRequires(wpRequires, phpRequires, testedUpTo) - if len(*dependencies) > 0 { cfg.Dependencies = dependencies } @@ -573,9 +565,50 @@ func buildWpmConfig(opts initOptions, pkgType string, mainFileHeaders any, readm cfg.Requires = requires } + dropInvalidMetadata(cfg) return cfg } +// removeSelfDependency drops a dependency whose name matches the package itself. +func removeSelfDependency(cfg *wpmjson.Config) { + if cfg.Dependencies == nil { + return + } + delete(*cfg.Dependencies, cfg.Name) + if len(*cfg.Dependencies) == 0 { + cfg.Dependencies = nil + } +} + +// dropInvalidMetadata clears optional fields that fail their own validator, +// keeping init's best-effort extraction from emitting an invalid wpm.json. +func dropInvalidMetadata(cfg *wpmjson.Config) { + cfg.Tags = slices.DeleteFunc(cfg.Tags, func(tag string) bool { + return validator.IsSafeString(tag) != nil + }) + if cfg.Description != "" && validator.IsValidDescription(cfg.Description) != nil { + cfg.Description = "" + } + if cfg.License != "" && validator.IsValidLicense(cfg.License) != nil { + cfg.License = "" + } + if cfg.Author != "" && validator.IsValidAuthor(cfg.Author) != nil { + cfg.Author = "" + } + if cfg.Requires == nil { + return + } + if cfg.Requires.WP != "" && validator.IsValidConstraint(cfg.Requires.WP) != nil { + cfg.Requires.WP = "" + } + if cfg.Requires.PHP != "" && validator.IsValidConstraint(cfg.Requires.PHP) != nil { + cfg.Requires.PHP = "" + } + if cfg.Requires.WP == "" && cfg.Requires.PHP == "" { + cfg.Requires = nil + } +} + // applyThemeHeaders fills config fields from a theme's style.css headers, // only overwriting empty values and only forwarding WP/PHP requires when // they weren't already set from readme.txt metadata. @@ -586,8 +619,8 @@ func applyThemeHeaders(cfg *wpmjson.Config, h parser.ThemeFileHeaders, tags []st if cfg.Description == "" || !isMeaningfulText(cfg.Description) { cfg.Description = h.Description } - if len(cfg.Team) == 0 && h.Author != "" { - cfg.Team = []string{h.Author} + if h.Author != "" { + cfg.Author = h.Author } if len(tags) == 0 && len(h.Tags) > 0 { cfg.Tags = h.Tags @@ -614,8 +647,8 @@ func applyPluginHeaders(cfg *wpmjson.Config, h parser.PluginFileHeaders, tags [] if cfg.Description == "" || !isMeaningfulText(cfg.Description) { cfg.Description = h.Description } - if len(cfg.Team) == 0 && h.Author != "" { - cfg.Team = []string{h.Author} + if h.Author != "" { + cfg.Author = h.Author } if len(tags) == 0 && len(h.Tags) > 0 { cfg.Tags = h.Tags @@ -632,6 +665,9 @@ func applyPluginHeaders(cfg *wpmjson.Config, h parser.PluginFileHeaders, tags [] *phpRequires = h.RequiresPHP } for _, reqPlugin := range h.RequiresPlugins { + if len(*dependencies) >= validator.MaxDependencies { + break + } if err := validator.IsValidPackageName(reqPlugin); err != nil { continue } diff --git a/cli/command/init/init_test.go b/cli/command/init/init_test.go new file mode 100644 index 00000000..368c2eab --- /dev/null +++ b/cli/command/init/init_test.go @@ -0,0 +1,87 @@ +package init + +import ( + "fmt" + "testing" + + "go.wpm.so/cli/pkg/pm/wpmjson" + "go.wpm.so/cli/pkg/pm/wpmjson/types" + "go.wpm.so/cli/pkg/pm/wpmjson/validator" + "go.wpm.so/cli/pkg/wp/parser" +) + +func lineSeparator() string { return string(rune(0x2028)) } + +func TestBuildWpmConfigDropsInvalidMetadata(t *testing.T) { + bad := lineSeparator() + headers := parser.PluginFileHeaders{ + Author: "Jane" + bad + "Doe", // unsafe -> dropped + Description: "valid description" + bad, // unsafe -> dropped + License: "x", // too short -> dropped + Tags: []string{"good", "ba" + bad + "d"}, // one unsafe -> filtered + } + + cfg := buildWpmConfig(initOptions{}, "plugin", headers, map[string]any{}) + cfg.Name = "my-plugin" + cfg.Version = "1.0.0" + + if cfg.Author != "" { + t.Errorf("expected unsafe author to be cleared, got %q", cfg.Author) + } + if cfg.Description != "" { + t.Errorf("expected unsafe description to be cleared, got %q", cfg.Description) + } + if cfg.License != "" { + t.Errorf("expected invalid license to be cleared, got %q", cfg.License) + } + for _, tag := range cfg.Tags { + if validator.IsSafeString(tag) != nil { + t.Errorf("unsafe tag survived sanitization: %q", tag) + } + } + + if err := cfg.Validate(); err != nil { + t.Fatalf("config built from adversarial headers should be valid, got: %v", err) + } +} + +func TestBuildWpmConfigCapsAndSanitizesDependencies(t *testing.T) { + required := make([]string, 0, 21) + required = append(required, "my-plugin") // self-reference, must be dropped + for i := range 20 { + required = append(required, fmt.Sprintf("dep-%02d", i)) + } + + headers := parser.PluginFileHeaders{RequiresPlugins: required} + cfg := buildWpmConfig(initOptions{}, "plugin", headers, map[string]any{}) + cfg.Name = "my-plugin" + cfg.Version = "1.0.0" + removeSelfDependency(cfg) + + if cfg.Dependencies == nil { + t.Fatal("expected dependencies to be populated") + } + if n := len(*cfg.Dependencies); n > 16 { + t.Errorf("expected at most 16 dependencies, got %d", n) + } + if _, ok := (*cfg.Dependencies)["my-plugin"]; ok { + t.Error("expected self-dependency to be removed") + } + + if err := cfg.Validate(); err != nil { + t.Fatalf("config with capped dependencies should be valid, got: %v", err) + } +} + +func TestRemoveSelfDependencyClearsEmptyMap(t *testing.T) { + cfg := wpmjson.New() + cfg.Name = "solo" + deps := types.Dependencies{"solo": "*"} + cfg.Dependencies = &deps + + removeSelfDependency(cfg) + + if cfg.Dependencies != nil { + t.Errorf("expected dependencies to be nil after removing the only entry, got %v", *cfg.Dependencies) + } +} diff --git a/cli/command/publish/publish.go b/cli/command/publish/publish.go index f73329ba..43d31625 100644 --- a/cli/command/publish/publish.go +++ b/cli/command/publish/publish.go @@ -297,7 +297,7 @@ func buildManifest(wpmJson *wpmjson.Config, opts publishOptions, visibility type License: wpmJson.License, Homepage: wpmJson.Homepage, Tags: wpmJson.Tags, - Team: wpmJson.Team, + Author: wpmJson.Author, Dependencies: wpmJson.Dependencies, DevDependencies: wpmJson.DevDependencies, Tag: opts.tag, diff --git a/docs/reference/cli/init.md b/docs/reference/cli/init.md index 5be0c2a6..0905cac4 100644 --- a/docs/reference/cli/init.md +++ b/docs/reference/cli/init.md @@ -97,7 +97,7 @@ Fields populated from the project sources: | `description` | header `Description`, falling back to `readme.txt` | | `license` | header `License`, falling back to `readme.txt` `License` | | `homepage` | header `Plugin URI` or `Theme URI` (must be http/https) | -| `team` | header `Author`, falling back to `readme.txt` Contributors | +| `author` | plugin or theme header `Author` | | `tags` | `readme.txt` Tags, falling back to header `Tags` | | `requires.wp` | header `Requires at least` and `readme.txt` Requires | | `requires.php` | header `Requires PHP` and `readme.txt` Requires PHP | @@ -110,15 +110,19 @@ Constraints applied automatically: - `tags` are trimmed to a maximum of 5, each 2 to 64 characters, deduplicated, and sorted. -- `team` is trimmed to a maximum of 100 entries, each 2 to 100 characters, - deduplicated, and sorted. +- `author` is cleared if it falls outside 2 to 164 characters. - `description` is trimmed to at most 512 characters, preferring to cut at a sentence boundary. - `license` is cleared if it falls outside 3 to 100 characters. +- `dependencies` from `Requires Plugins` are capped at 16, and any self-reference + is dropped. - `requires.wp` becomes `>=X` (and `<=Y` when `Tested up to` is also present and different from `Requires`). - `requires.php` becomes `>=X`. +Any extracted field that still fails validation (invalid characters or malformed +values) is dropped so the generated `wpm.json` is always valid. + If no version can be extracted and `--version` is not provided, the command errors. Pass `--version` to recover. diff --git a/pkg/pm/wpmjson/manifest/manifest.go b/pkg/pm/wpmjson/manifest/manifest.go index 61854d55..012d234c 100644 --- a/pkg/pm/wpmjson/manifest/manifest.go +++ b/pkg/pm/wpmjson/manifest/manifest.go @@ -27,9 +27,9 @@ type Package struct { Bin *types.Bin `json:"bin,omitempty"` Requires *types.Requires `json:"requires,omitempty"` License string `json:"license,omitempty"` + Author string `json:"author,omitempty"` Homepage string `json:"homepage,omitempty"` Tags []string `json:"tags,omitempty"` - Team []string `json:"team,omitempty"` Dependencies *types.Dependencies `json:"dependencies,omitempty"` DevDependencies *types.Dependencies `json:"devDependencies,omitempty"` Tag string `json:"tag"` diff --git a/pkg/pm/wpmjson/validator/validator.go b/pkg/pm/wpmjson/validator/validator.go index 157870d5..1486ca37 100644 --- a/pkg/pm/wpmjson/validator/validator.go +++ b/pkg/pm/wpmjson/validator/validator.go @@ -16,6 +16,11 @@ import ( var packageNameRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) +const unsafeStringMsg = "contains invalid control characters or invisible formatting" + +// MaxDependencies is the limit on entries in dependencies or devDependencies. +const MaxDependencies = 16 + // IsValidPackageName checks if the package name adheres to naming conventions. func IsValidPackageName(name string) error { if len(name) < 3 { @@ -30,13 +35,22 @@ func IsValidPackageName(name string) error { return nil } -// IsValidDistTag checks if the dist tag is valid. +// IsValidDistTag checks a dist tag follows package-name formatting and does not +// resemble a semantic version or range, so it can never collide with one. func IsValidDistTag(tag string) error { + if len(tag) < 3 { + return errors.New("must be at least 3 characters") + } if len(tag) > 64 { return errors.New("must be at most 64 characters") } - - return IsValidPackageName(tag) + if !packageNameRegex.MatchString(tag) { + return errors.New("must consist of lowercase alphanumeric characters separated by hyphens") + } + if _, err := semver.NewConstraint(tag); err == nil { + return errors.New("cannot resemble a valid semantic version or range") + } + return nil } // IsValidPackageType checks if the package type is valid. @@ -58,6 +72,9 @@ func IsValidVersion(v string) error { if len(v) > 64 { return errors.New("must be at most 64 characters") } + if v != strings.TrimSpace(v) { + return errors.New("cannot contain leading or trailing whitespace") + } if strings.HasPrefix(v, "v") { return errors.New("cannot start with 'v'") } @@ -107,6 +124,12 @@ func IsValidConstraint(v string) error { if v == "" { return errors.New("constraint cannot be empty") } + if len(v) > 64 { + return errors.New("constraint must be at most 64 characters") + } + if v != strings.TrimSpace(v) { + return errors.New("constraint cannot contain leading or trailing whitespace") + } if v == "*" { return nil } @@ -119,17 +142,22 @@ func IsValidConstraint(v string) error { return nil } -// IsSafeString checks for disallowed control and special unicode characters. +// IsSafeString rejects any character in the Unicode "Other" category (Cc, Cf, +// Co, Cs) outside the allowlist, plus separators and replacement characters +// that are not in that category but still break rendering or eval. func IsSafeString(s string) error { for _, r := range s { - if r == '\u2028' || r == '\u2029' || r == '\uFFFD' || r == '\uFFFC' || r == '\u3164' { - return errors.New("contains invalid unicode characters") + switch r { + case '\t', '\n', '\r', '\u200C', '\u200D': // whitespace and zero-width joiners + continue + case '\u2028', '\u2029', '\uFFFD', '\uFFFC', '\u3164': + return errors.New(unsafeStringMsg) } - if unicode.IsControl(r) { - if r == '\t' || r == '\n' || r == '\r' || r == '\u200D' || (r >= 0xE0020 && r <= 0xE007F) { - continue - } - return errors.New("contains invalid control characters") + if r >= 0xE0020 && r <= 0xE007F { // emoji tag characters + continue + } + if unicode.In(r, unicode.C) { + return errors.New(unsafeStringMsg) } } return nil @@ -162,31 +190,15 @@ func ValidateTags(tags []string) error { return errs.Err() } -// ValidateTeam checks limits, formatting, and uniqueness for team members. -func ValidateTeam(team []string) error { - var errs ErrorList - if len(team) > 100 { - errs.AddMsg("team", "cannot have more than 100 members") +// IsValidAuthor checks the author name length and character safety. +func IsValidAuthor(author string) error { + if len(author) < 2 { + return errors.New("must be at least 2 characters") } - - seen := make(map[string]bool) - for i, member := range team { - field := fmt.Sprintf("team[%d]", i) - - if len(member) < 2 || len(member) > 100 { - errs.AddMsg(field, "must be between 2 and 100 characters") - } - - if err := IsSafeString(member); err != nil { - errs.Add(field, err) - } - - if seen[member] { - errs.AddMsg("team", fmt.Sprintf("duplicate member '%s'", member)) - } - seen[member] = true + if len(author) > 164 { + return errors.New("must be at most 164 characters") } - return errs.Err() + return IsSafeString(author) } // ValidateDependencies checks the validity of dependency names and constraints. @@ -196,8 +208,8 @@ func ValidateDependencies(deps map[string]string, fieldName string) error { } var errs ErrorList - if len(deps) > 16 { - errs.AddMsg(fieldName, "cannot have more than 16 dependencies") + if len(deps) > MaxDependencies { + errs.AddMsg(fieldName, fmt.Sprintf("cannot have more than %d dependencies", MaxDependencies)) } for name, version := range deps { @@ -217,6 +229,29 @@ func ValidateDependencies(deps map[string]string, fieldName string) error { return errs.Err() } +// ValidateDependencyIntegrity checks that a package does not depend on itself +// and that no dependency is listed in both dependencies and devDependencies. +func ValidateDependencyIntegrity(name string, deps, devDeps map[string]string) error { + var errs ErrorList + + if _, ok := deps[name]; ok { + errs.AddMsg("dependencies", "package cannot depend on itself") + } + if _, ok := devDeps[name]; ok { + errs.AddMsg("devDependencies", "package cannot depend on itself") + } + + for dep := range deps { + if _, ok := devDeps[dep]; ok { + errs.AddMsg( + fmt.Sprintf("devDependencies[%s]", dep), + fmt.Sprintf("'%s' cannot be listed in both dependencies and devDependencies", dep), + ) + } + } + return errs.Err() +} + // ValidateRequires checks the validity of the WP and PHP constraints. func ValidateRequires(wp, php string) error { var errs ErrorList diff --git a/pkg/pm/wpmjson/validator/validator_test.go b/pkg/pm/wpmjson/validator/validator_test.go new file mode 100644 index 00000000..0e55ab04 --- /dev/null +++ b/pkg/pm/wpmjson/validator/validator_test.go @@ -0,0 +1,186 @@ +package validator + +import ( + "strings" + "testing" +) + +func TestIsValidPackageName(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"too short", "ab", true}, + {"too long", strings.Repeat("a", 165), true}, + {"valid name", "my-plugin", false}, + {"uppercase rejected", "My-Plugin", true}, + {"underscore rejected", "my_plugin", true}, + {"leading hyphen rejected", "-plugin", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := IsValidPackageName(tc.input) + if (err != nil) != tc.wantErr { + t.Fatalf("IsValidPackageName(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr) + } + }) + } +} + +func TestIsValidDistTag(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"too long", strings.Repeat("a", 65), true}, + {"too short", "ab", true}, + {"plain tag", "latest", false}, + {"hyphenated tag", "next-major", false}, + {"uppercase rejected", "Latest", true}, + {"resembles range two digits", "12", true}, + {"resembles range bare number", "100", true}, + {"resembles range hyphen version", "1-0-0", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := IsValidDistTag(tc.input) + if (err != nil) != tc.wantErr { + t.Fatalf("IsValidDistTag(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr) + } + }) + } +} + +func TestIsValidVersionWhitespace(t *testing.T) { + if err := IsValidVersion(" 1.0.0"); err == nil { + t.Fatal("expected error for leading whitespace") + } + if err := IsValidVersion("1.0.0 "); err == nil { + t.Fatal("expected error for trailing whitespace") + } + if err := IsValidVersion("1.0.0"); err != nil { + t.Fatalf("unexpected error for valid version: %v", err) + } +} + +func TestIsValidConstraint(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"empty", "", true}, + {"wildcard", "*", false}, + {"v prefix", "v1.0.0", true}, + {"range", ">=1.0.0 <2.0.0", false}, + {"leading whitespace", " >=1.0.0", true}, + {"too long", strings.Repeat("1.0.0 ||", 12), true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := IsValidConstraint(tc.input) + if (err != nil) != tc.wantErr { + t.Fatalf("IsValidConstraint(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr) + } + }) + } +} + +// withRune is a helper to create test strings containing specific runes. +func withRune(cp rune) string { + return "a" + string(cp) + "b" +} + +func TestIsSafeString(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"plain ascii", "hello world", false}, + {"tab and newline allowed", "a\tb\nc", false}, + {"zwj allowed", withRune(0x200D), false}, // ZWJ + {"zwnj allowed", withRune(0x200C), false}, // ZWNJ + {"zero width space rejected", withRune(0x200B), true}, // Cf format char + {"soft hyphen rejected", withRune(0x00AD), true}, // Cf format char + {"line separator rejected", withRune(0x2028), true}, + {"replacement char rejected", withRune(0xFFFD), true}, + {"hangul filler rejected", withRune(0x3164), true}, + {"null byte rejected", "a\x00b", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := IsSafeString(tc.input) + if (err != nil) != tc.wantErr { + t.Fatalf("IsSafeString(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr) + } + }) + } +} + +func TestIsValidAuthor(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"valid", "Jane Doe", false}, + {"too short", "a", true}, + {"too long", strings.Repeat("a", 165), true}, + {"max length ok", strings.Repeat("a", 164), false}, + {"unsafe char", "Jane" + string(rune(0x2028)) + "Doe", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := IsValidAuthor(tc.input) + if (err != nil) != tc.wantErr { + t.Fatalf("IsValidAuthor(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr) + } + }) + } +} + +func TestValidateDependencyIntegrity(t *testing.T) { + t.Run("self dependency in deps", func(t *testing.T) { + err := ValidateDependencyIntegrity("my-pkg", map[string]string{"my-pkg": "*"}, nil) + if err == nil || !strings.Contains(err.Error(), "depend on itself") { + t.Fatalf("expected self-dependency error, got %v", err) + } + }) + + t.Run("self dependency in devDeps", func(t *testing.T) { + err := ValidateDependencyIntegrity("my-pkg", nil, map[string]string{"my-pkg": "*"}) + if err == nil || !strings.Contains(err.Error(), "depend on itself") { + t.Fatalf("expected self-dependency error, got %v", err) + } + }) + + t.Run("listed in both", func(t *testing.T) { + err := ValidateDependencyIntegrity( + "my-pkg", + map[string]string{"shared-dep": "1.0.0"}, + map[string]string{"shared-dep": "1.0.0"}, + ) + if err == nil || !strings.Contains(err.Error(), "both dependencies and devDependencies") { + t.Fatalf("expected both-lists error, got %v", err) + } + }) + + t.Run("no conflict", func(t *testing.T) { + err := ValidateDependencyIntegrity( + "my-pkg", + map[string]string{"dep-a": "1.0.0"}, + map[string]string{"dep-b": "2.0.0"}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} diff --git a/pkg/pm/wpmjson/wpmjson.go b/pkg/pm/wpmjson/wpmjson.go index 22e2fd74..8a812bf5 100644 --- a/pkg/pm/wpmjson/wpmjson.go +++ b/pkg/pm/wpmjson/wpmjson.go @@ -24,9 +24,9 @@ type Config struct { Bin *types.Bin `json:"bin,omitempty"` Requires *types.Requires `json:"requires,omitempty"` License string `json:"license,omitempty"` + Author string `json:"author,omitempty"` Homepage string `json:"homepage,omitempty"` Tags []string `json:"tags,omitempty"` - Team []string `json:"team,omitempty"` Dependencies *types.Dependencies `json:"dependencies,omitempty"` DevDependencies *types.Dependencies `json:"devDependencies,omitempty"` Config *types.PackageConfig `json:"config,omitempty"` @@ -89,26 +89,31 @@ func (c *Config) Validate() error { if c.License != "" { errs.Add("license", validator.IsValidLicense(c.License)) } + if c.Author != "" { + errs.Add("author", validator.IsValidAuthor(c.Author)) + } if c.Homepage != "" { errs.Add("homepage", validator.IsValidHomepage(c.Homepage)) } if len(c.Tags) > 0 { errs.MustMerge(validator.ValidateTags(c.Tags)) } - if len(c.Team) > 0 { - errs.MustMerge(validator.ValidateTeam(c.Team)) - } // Core fields if c.Requires != nil { errs.MustMerge(validator.ValidateRequires(c.Requires.WP, c.Requires.PHP)) } + + var deps, devDeps map[string]string if c.Dependencies != nil { - errs.MustMerge(validator.ValidateDependencies(*c.Dependencies, "dependencies")) + deps = *c.Dependencies + errs.MustMerge(validator.ValidateDependencies(deps, "dependencies")) } if c.DevDependencies != nil { - errs.MustMerge(validator.ValidateDependencies(*c.DevDependencies, "devDependencies")) + devDeps = *c.DevDependencies + errs.MustMerge(validator.ValidateDependencies(devDeps, "devDependencies")) } + errs.MustMerge(validator.ValidateDependencyIntegrity(c.Name, deps, devDeps)) // Config field validations if c.Config != nil { diff --git a/pkg/pm/wpmjson/wpmjson_test.go b/pkg/pm/wpmjson/wpmjson_test.go new file mode 100644 index 00000000..175789e4 --- /dev/null +++ b/pkg/pm/wpmjson/wpmjson_test.go @@ -0,0 +1,78 @@ +package wpmjson + +import ( + "strings" + "testing" + + "go.wpm.so/cli/pkg/pm/wpmjson/types" +) + +func baseConfig() *Config { + return &Config{ + Name: "my-plugin", + Version: "1.0.0", + Type: types.TypePlugin, + } +} + +func TestConfigValidateAuthor(t *testing.T) { + tests := []struct { + name string + author string + wantErr bool + }{ + {"empty author skipped", "", false}, + {"valid author", "Jane Doe", false}, + {"too short author", "a", true}, + {"too long author", strings.Repeat("a", 165), true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Author = tc.author + err := cfg.Validate() + if (err != nil) != tc.wantErr { + t.Fatalf("Validate() with author %q error = %v, wantErr %v", tc.author, err, tc.wantErr) + } + }) + } +} + +func TestConfigValidateDependencyIntegrity(t *testing.T) { + t.Run("cannot depend on itself", func(t *testing.T) { + cfg := baseConfig() + deps := types.Dependencies{"my-plugin": "*"} + cfg.Dependencies = &deps + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "depend on itself") { + t.Fatalf("expected self-dependency error, got %v", err) + } + }) + + t.Run("cannot be listed in both", func(t *testing.T) { + cfg := baseConfig() + deps := types.Dependencies{"shared-dep": "1.0.0"} + devDeps := types.Dependencies{"shared-dep": "1.0.0"} + cfg.Dependencies = &deps + cfg.DevDependencies = &devDeps + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "both dependencies and devDependencies") { + t.Fatalf("expected both-lists error, got %v", err) + } + }) + + t.Run("distinct dependencies are valid", func(t *testing.T) { + cfg := baseConfig() + deps := types.Dependencies{"dep-a": "1.0.0"} + devDeps := types.Dependencies{"dep-b": "2.0.0"} + cfg.Dependencies = &deps + cfg.DevDependencies = &devDeps + + if err := cfg.Validate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} diff --git a/schema.json b/schema.json new file mode 100644 index 00000000..d1032159 --- /dev/null +++ b/schema.json @@ -0,0 +1,140 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Schema for wpm configuration file", + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "maxLength": 164, + "minLength": 3, + "description": "The name of the package." + }, + "version": { + "type": "string", + "description": "A Semver-compliant version string." + }, + "type": { + "type": "string", + "enum": ["theme", "plugin", "mu-plugin"], + "description": "The type of package in the wp-content directory." + }, + "description": { + "type": "string", + "description": "A brief summary of what the package is about." + }, + "private": { + "type": "boolean", + "default": false, + "description": "If set to true, wpm will refuse to publish the package." + }, + "bin": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "The binaries associated with the package." + }, + "requires": { + "type": "object", + "properties": { + "wp": { + "type": "string", + "description": "The WordPress versions range supported by the package." + }, + "php": { + "type": "string", + "description": "The PHP versions range supported by the package." + } + }, + "additionalProperties": false, + "description": "The PHP and WordPress version constraints for the package." + }, + "license": { + "type": "string", + "description": "The license under which the package is released. WordPress is licensed under GPLv2 or later." + }, + "homepage": { + "type": "string", + "format": "uri", + "description": "The URL of the package's homepage." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 5, + "description": "Tags that help categorize the package." + }, + "author": { + "type": "string", + "minLength": 2, + "maxLength": 164, + "description": "The author of the package." + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "The dependencies required by the package." + }, + "devDependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "The development dependencies required by the package." + }, + "config": { + "type": "object", + "properties": { + "bin-dir": { + "type": "string", + "default": "wp-bin", + "description": "The path to the directory where the package's binaries are installed." + }, + "content-dir": { + "type": "string", + "default": "wp-content", + "description": "The path to the WordPress content directory." + }, + "runtime": { + "type": "object", + "properties": { + "wp": { + "type": "string", + "description": "The WordPress runtime version where this project is expected to run." + }, + "php": { + "type": "string", + "description": "The PHP runtime version where this project is expected to run." + } + }, + "additionalProperties": false, + "description": "The actual runtime requirements where this project is expected to run." + } + } + }, + "scripts": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Scripts to run during the package lifecycle or development." + } + }, + "required": ["name"], + "if": { + "properties": { + "private": { + "const": true + } + }, + "required": ["private"] + }, + "else": { + "required": ["type", "version"] + } +}