Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
!Dockerfile
!AGENTS.md
!CLAUDE.md
!schema.json
!.oxfmtrc.json
!.git-blame-ignore-revs

Expand Down
64 changes: 50 additions & 14 deletions cli/command/init/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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", "")
Expand All @@ -552,30 +553,62 @@ 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
}
if requires.PHP != "" || requires.WP != "" {
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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
}
Expand Down
87 changes: 87 additions & 0 deletions cli/command/init/init_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion cli/command/publish/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions docs/reference/cli/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion pkg/pm/wpmjson/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading