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
22 changes: 18 additions & 4 deletions docs/dist/docs/guide/cli/configuration/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ lint:
**Optional.** Specifies the required suffix for enum zero values.

**Type:** `string`
**Default:** `""` (no suffix required)
**Default:** `"UNSPECIFIED"`
**Common values:** `"UNSPECIFIED"`, `"UNKNOWN"`, `"DEFAULT"`

```yaml
Expand All @@ -466,7 +466,7 @@ enum Status {
**Optional.** Specifies the required suffix for service names.

**Type:** `string`
**Default:** `""` (no suffix required)
**Default:** `"Service"`
**Common values:** `"Service"`, `"API"`, `"Svc"`

```yaml
Expand Down Expand Up @@ -501,7 +501,7 @@ Paths are relative to the `easyp.yaml` file location. Supports glob patterns.

#### `lint.except`

**Optional.** Disables specific rules globally across the entire project.
**Optional.** Disables specific rules globally across the entire project. Supports both individual rule names and group names (`MINIMAL`, `BASIC`, `DEFAULT`, `COMMENTS`, `UNARY_RPC`), which are automatically expanded to their constituent rules.

**Type:** `[]string`
**Default:** `[]`
Expand All @@ -512,11 +512,16 @@ lint:
- COMMENT_FIELD
- COMMENT_MESSAGE
- SERVICE_SUFFIX

# Or exclude entire groups:
except:
- COMMENTS # Excludes all COMMENT_* rules
- UNARY_RPC # Excludes RPC_NO_CLIENT_STREAMING, RPC_NO_SERVER_STREAMING
```

#### `lint.allow_comment_ignores`

**Optional.** Enables inline comment-based rule ignoring within proto files.
**Optional.** Enables inline comment-based rule ignoring within proto files. This setting is applied consistently across all commands (`lint`, `breaking`, `generate`, `mod`).

**Type:** `boolean`
**Default:** `false`
Expand Down Expand Up @@ -912,8 +917,15 @@ Error: required field "plugins" is missing (path: generate.plugins)

# Invalid dependency format
Error: invalid dependency format: invalid-repo-url

# Unknown key in breaking section
Warning: unknown key "typo_field" (path: breaking.typo_field)
```

All configuration sections — including `lint`, `generate`, `deps`, and `breaking` — are validated against a strict schema. Unknown keys produce warnings; type mismatches and missing required fields produce errors.

The `easyp init` command also validates the generated configuration before writing it to disk, ensuring that both fresh configs and Buf migrations produce valid `easyp.yaml` files.

Use `easyp --debug` for detailed validation information.

## Migration from Buf
Expand All @@ -926,3 +938,5 @@ EasyP is fully compatible with Buf configurations. To migrate:
4. Review migrated lint/breaking settings and adjust as needed

Most Buf configurations work without changes in EasyP.

The migrated configuration is validated before being written to disk. If the migration produces an invalid config, EasyP will report the validation errors instead of writing a broken file.
13 changes: 11 additions & 2 deletions docs/dist/docs/guide/cli/linter/linter.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ use:
#### `enum_zero_value_suffix` (string)
Defines the required suffix for zero-value enum entries. This enforces a consistent naming pattern for the default enum value.

**Default**: Empty (no suffix required)
**Default**: `"UNSPECIFIED"`
**Common values**: `"UNSPECIFIED"`, `"UNKNOWN"`, `"DEFAULT"`

**Example:**
Expand All @@ -104,7 +104,7 @@ enum Status {
#### `service_suffix` (string)
Specifies the required suffix for service names. This ensures consistent service naming across your project.

**Default**: Empty (no suffix required)
**Default**: `"Service"`
**Common values**: `"Service"`, `"API"`, `"Svc"`

**Example:**
Expand Down Expand Up @@ -141,6 +141,8 @@ ignore:
#### `except` ([]string)
Disables specific rules globally across the entire project. Use this when certain rules don't fit your project's conventions.

You can specify either individual rule names or group names (`MINIMAL`, `BASIC`, `DEFAULT`, `COMMENTS`, `UNARY_RPC`). Group names are automatically expanded to their constituent rules.

**When to use:**
- Legacy projects with established naming conventions
- Projects with specific style requirements
Expand All @@ -153,11 +155,18 @@ except:
- COMMENT_MESSAGE # Don't require message comments
- SERVICE_SUFFIX # Don't enforce service suffix
- ENUM_ZERO_VALUE_SUFFIX # Don't enforce enum zero suffix

# Or exclude entire groups:
except:
- COMMENTS # Disable all comment rules at once
- UNARY_RPC # Disable all streaming restrictions
```

#### `allow_comment_ignores` (bool)
Enables or disables the ability to ignore specific rules using inline comments in proto files.

This setting applies consistently to all commands that process proto files: `lint`, `breaking`, `generate`, and `mod`.

**Default**: `false`
**Recommended**: `true` for flexibility during development

Expand Down
1 change: 0 additions & 1 deletion internal/api/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ func (l Lint) action(ctx *cli.Context, log logger.Logger) error {
if err != nil {
return fmt.Errorf("config.New: %w", err)
}
core.SetAllowCommentIgnores(cfg.Lint.AllowCommentIgnores)

// Walker for Core (lockfile etc) - strictly based on project root
projectWalker := fs.NewFSWalker(projectRoot, ".")
Expand Down
4 changes: 0 additions & 4 deletions internal/api/mod.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"github.com/easyp-tech/easyp/internal/fs/fs"

"github.com/easyp-tech/easyp/internal/config"
"github.com/easyp-tech/easyp/internal/core"
)

var _ Handler = (*Mod)(nil)
Expand Down Expand Up @@ -81,7 +80,6 @@ func (m Mod) Download(ctx *cli.Context) error {
if err != nil {
return fmt.Errorf("config.New: %w", err)
}
core.SetAllowCommentIgnores(cfg.Lint.AllowCommentIgnores)

app, err := buildCore(ctx.Context, log, *cfg, dirWalker)
if err != nil {
Expand Down Expand Up @@ -114,7 +112,6 @@ func (m Mod) Update(ctx *cli.Context) error {
if err != nil {
return fmt.Errorf("config.New: %w", err)
}
core.SetAllowCommentIgnores(cfg.Lint.AllowCommentIgnores)

app, err := buildCore(ctx.Context, log, *cfg, dirWalker)
if err != nil {
Expand Down Expand Up @@ -147,7 +144,6 @@ func (m Mod) Vendor(ctx *cli.Context) error {
if err != nil {
return fmt.Errorf("config.New: %w", err)
}
core.SetAllowCommentIgnores(cfg.Lint.AllowCommentIgnores)

app, err := buildCore(ctx.Context, log, *cfg, dirWalker)
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions internal/api/temporaly_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ func getEasypPath(log logger.Logger) (string, error) {
func buildCore(_ context.Context, log logger.Logger, cfg config.Config, dirWalker core.DirWalker) (*core.Core, error) {
vendorPath := defaultVendorDir // TODO: read from config

// Centralized: set once for all commands (lint, breaking, generate, mod).
core.SetAllowCommentIgnores(cfg.Lint.AllowCommentIgnores)

lintRules, ignoreOnly, err := rules.New(cfg.Lint)
if err != nil {
return nil, fmt.Errorf("cfg.BuildLinterRules: %w", err)
Expand Down
4 changes: 2 additions & 2 deletions internal/config/breaking_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package config

// BreakingCheck is the configuration for `breaking` command
type BreakingCheck struct {
Ignore []string `json:"ignore" yaml:"ignore"`
Ignore []string `json:"ignore,omitempty" yaml:"ignore,omitempty"`
// git ref to compare with
AgainstGitRef string `json:"against_git_ref" yaml:"against_git_ref"`
AgainstGitRef string `json:"against_git_ref,omitempty" yaml:"against_git_ref,omitempty"`
}
8 changes: 4 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,16 +164,16 @@ type InputFilesDir struct {
// Config is the configuration of easyp.
type Config struct {
// LintConfig is the lint configuration.
Lint LintConfig `json:"lint" yaml:"lint"`
Lint LintConfig `json:"lint,omitempty" yaml:"lint,omitempty"`

// Deps is the dependencies repositories
Deps []string `json:"deps" yaml:"deps"`
Deps []string `json:"deps,omitempty" yaml:"deps,omitempty"`

// Generate is the generate configuration.
Generate Generate `json:"generate" yaml:"generate"`
Generate Generate `json:"generate,omitempty" yaml:"generate,omitempty"`

// BreakingCheck `breaking` command's configuration
BreakingCheck BreakingCheck `json:"breaking" yaml:"breaking"`
BreakingCheck BreakingCheck `json:"breaking,omitempty" yaml:"breaking,omitempty"`
}

var errFileNotFound = errors.New("config file not found")
Expand Down
14 changes: 7 additions & 7 deletions internal/config/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ package config

// LintConfig contains linter configuration.
type LintConfig struct {
Use []string `json:"use" yaml:"use" env:"USE"` // Use rules for linter.
EnumZeroValueSuffix string `json:"enum_zero_value_suffix" yaml:"enum_zero_value_suffix" env:"ENUM_ZERO_VALUE_SUFFIX"` // Enum zero value suffix.
ServiceSuffix string `json:"service_suffix" yaml:"service_suffix" env:"SERVICE_SUFFIX"` // Service suffix.
Ignore []string `json:"ignore" yaml:"ignore" env:"IGNORE"` // Ignore dirs with proto file.
Except []string `json:"except" yaml:"except" env:"EXCEPT"` // Except linter rules.
AllowCommentIgnores bool `json:"allow_comment_ignores" yaml:"allow_comment_ignores" env:"ALLOW_COMMENT_IGNORES"` // Allow comment ignore.
IgnoreOnly map[string][]string `json:"ignore_only" yaml:"ignore_only" env:"IGNORE_ONLY"`
Use []string `json:"use,omitempty" yaml:"use,omitempty" env:"USE"` // Use rules for linter.
EnumZeroValueSuffix string `json:"enum_zero_value_suffix,omitempty" yaml:"enum_zero_value_suffix,omitempty" env:"ENUM_ZERO_VALUE_SUFFIX"` // Enum zero value suffix.
ServiceSuffix string `json:"service_suffix,omitempty" yaml:"service_suffix,omitempty" env:"SERVICE_SUFFIX"` // Service suffix.
Ignore []string `json:"ignore,omitempty" yaml:"ignore,omitempty" env:"IGNORE"` // Ignore dirs with proto file.
Except []string `json:"except,omitempty" yaml:"except,omitempty" env:"EXCEPT"` // Except linter rules.
AllowCommentIgnores bool `json:"allow_comment_ignores,omitempty" yaml:"allow_comment_ignores,omitempty" env:"ALLOW_COMMENT_IGNORES"` // Allow comment ignore.
IgnoreOnly map[string][]string `json:"ignore_only,omitempty" yaml:"ignore_only,omitempty" env:"IGNORE_ONLY"`
}
9 changes: 8 additions & 1 deletion internal/config/validate_raw.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,14 @@ func buildSchema() *v.FieldSchema {
UnknownKeyPolicy: v.UnknownKeyWarn,
}

breakingSchema := &v.FieldSchema{Type: v.TypeMap, UnknownKeyPolicy: v.UnknownKeyIgnore}
breakingSchema := &v.FieldSchema{
Type: v.TypeMap,
AllowedKeys: map[string]*v.FieldSchema{
"ignore": stringSeq,
"against_git_ref": {Type: v.TypeString},
},
UnknownKeyPolicy: v.UnknownKeyWarn,
}

return &v.FieldSchema{
Type: v.TypeMap,
Expand Down
39 changes: 39 additions & 0 deletions internal/config/validate_raw_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,42 @@ func containsAny(s string, candidates ...string) bool {
}
return false
}

func TestValidateRaw_BreakingSchemaValidKeys(t *testing.T) {
content := `lint:
use:
- DIRECTORY_SAME_PACKAGE
breaking:
ignore:
- proto/legacy
against_git_ref: main
`

issues, err := ValidateRaw([]byte(content))
require.NoError(t, err)
require.False(t, HasErrors(issues), "valid breaking section should not produce errors, got: %v", issues)
}

func TestValidateRaw_BreakingSchemaUnknownKey(t *testing.T) {
content := `lint:
use:
- DIRECTORY_SAME_PACKAGE
breaking:
ignore:
- proto/legacy
unknown_field: true
`

issues, err := ValidateRaw([]byte(content))
require.NoError(t, err)

// Should produce at least one warning for the unknown key.
var hasWarning bool
for _, issue := range issues {
if issue.Severity == SeverityWarn && strings.Contains(issue.Message, "unknown_field") {
hasWarning = true
break
}
}
require.True(t, hasWarning, "expected warning for unknown key 'unknown_field' in breaking section, got: %v", issues)
}
33 changes: 29 additions & 4 deletions internal/core/init.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package core

import (
"bytes"
"context"
"fmt"

Expand Down Expand Up @@ -117,9 +118,21 @@ func (c *Core) Initialize(ctx context.Context, disk DirWalker, opts InitOptions)
}
}()

if renderErr := renderInitConfig(res, opts.TemplateData); renderErr != nil {
// Render to buffer and validate before writing to disk.
var buf bytes.Buffer
if renderErr := renderInitConfig(&buf, opts.TemplateData); renderErr != nil {
return fmt.Errorf("renderInitConfig: %w", renderErr)
}

if issues, valErr := config.ValidateRaw(buf.Bytes()); valErr != nil {
return fmt.Errorf("config.ValidateRaw: %w", valErr)
} else if config.HasErrors(issues) {
return fmt.Errorf("generated config has validation errors: %v", issues)
}

if _, writeErr := res.Write(buf.Bytes()); writeErr != nil {
return fmt.Errorf("res.Write: %w", writeErr)
}
}

return nil
Expand Down Expand Up @@ -174,6 +187,19 @@ func (c *Core) migrateFromBUF(ctx context.Context, disk FS, path string, default

migratedCfg := buildCfgFromBUF(defaultConfiguration, b)

// Encode to buffer and validate before writing to disk.
var buf bytes.Buffer
err = yaml.NewEncoder(&buf).Encode(migratedCfg)
if err != nil {
return fmt.Errorf("yaml.NewEncoder.Encode: %w", err)
}

if issues, valErr := config.ValidateRaw(buf.Bytes()); valErr != nil {
return fmt.Errorf("config.ValidateRaw: %w", valErr)
} else if config.HasErrors(issues) {
return fmt.Errorf("migrated config has validation errors: %v", issues)
}

res, err := disk.Create("easyp.yaml")
if err != nil {
return fmt.Errorf("disk.Create: %w", err)
Expand All @@ -184,9 +210,8 @@ func (c *Core) migrateFromBUF(ctx context.Context, disk FS, path string, default
}
}()

err = yaml.NewEncoder(res).Encode(migratedCfg)
if err != nil {
return fmt.Errorf("yaml.NewEncoder.Encode: %w", err)
if _, writeErr := res.Write(buf.Bytes()); writeErr != nil {
return fmt.Errorf("res.Write: %w", writeErr)
}

return nil
Expand Down
14 changes: 11 additions & 3 deletions internal/rules/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,15 @@ func New(cfg config.LintConfig) ([]core.Rule, map[string][]string, error) {
// defaultGroup
&EnumValuePrefix{},
&EnumZeroValueSuffix{
Suffix: cfg.EnumZeroValueSuffix,
Suffix: defaultIfEmpty(cfg.EnumZeroValueSuffix, "UNSPECIFIED"),
},
&FileLowerSnakeCase{},
&RPCRequestResponseUnique{},
&RPCRequestStandardName{},
&RPCResponseStandardName{},
&PackageVersionSuffix{},
&ServiceSuffix{
Suffix: cfg.ServiceSuffix,
Suffix: defaultIfEmpty(cfg.ServiceSuffix, "Service"),
},
// commentsGroup
&CommentEnum{},
Expand All @@ -111,7 +111,7 @@ func New(cfg config.LintConfig) ([]core.Rule, map[string][]string, error) {
}

use := unwrapLintGroups(cfg.Use)
use = removeExcept(cfg.Except, use)
use = removeExcept(unwrapLintGroups(cfg.Except), use)

res := make([]core.Rule, len(use))

Expand Down Expand Up @@ -194,6 +194,14 @@ func removeExcept(except, use []string) []string {
})
}

// defaultIfEmpty returns val if non-empty, otherwise fallback.
func defaultIfEmpty(val, fallback string) string {
if val == "" {
return fallback
}
return val
}

func addMinimal(res []string) []string {
res = append(res, core.GetRuleName(&DirectorySamePackage{}))
res = append(res, core.GetRuleName(&PackageDefined{}))
Expand Down
Loading