diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 84f2fb62..6bf17f1c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -166,6 +166,7 @@ jobs: - run: ./golangci-lint config - run: ./golangci-lint config path + - run: ./golangci-lint config verify --schema jsonschema/golangci.jsonschema.json - run: ./golangci-lint help - run: ./golangci-lint help linters diff --git a/.golangci.yml b/.golangci.yml index a0146f2c..d820c370 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -163,6 +163,8 @@ issues: text: "SA1019: c.cfg.Run.ShowStats is deprecated: use Output.ShowStats instead." - path: pkg/golinters/govet.go text: "SA1019: cfg.CheckShadowing is deprecated: the linter should be enabled inside `Enable`." + - path: pkg/commands/config.go + text: "SA1019: cfg.Run.UseDefaultSkipDirs is deprecated: use Issues.UseDefaultExcludeDirs instead." - path: pkg/golinters linters: diff --git a/Makefile b/Makefile index 823bf823..9d14ab5c 100644 --- a/Makefile +++ b/Makefile @@ -89,7 +89,7 @@ go.mod: FORCE go.sum: go.mod website_copy_jsonschema: - cp -r ./jsonschema ./docs/static + go run ./scripts/website/copy_jsonschema/ .PHONY: website_copy_jsonschema website_expand_templates: diff --git a/go.mod b/go.mod index de6c898e..ce77869f 100644 --- a/go.mod +++ b/go.mod @@ -80,6 +80,7 @@ require ( github.com/nishanths/exhaustive v0.12.0 github.com/nishanths/predeclared v0.2.2 github.com/nunnatsa/ginkgolinter v0.16.1 + github.com/pelletier/go-toml/v2 v2.1.1 github.com/polyfloyd/go-errorlint v1.4.8 github.com/quasilyte/go-ruleguard/dsl v0.3.22 github.com/ryancurrah/gomodguard v1.3.1 @@ -161,7 +162,6 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pelletier/go-toml/v2 v2.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/prometheus/client_golang v1.12.1 // indirect diff --git a/go.sum b/go.sum index a55996b5..28902b81 100644 --- a/go.sum +++ b/go.sum @@ -410,8 +410,8 @@ github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT9 github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg= -github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas= +github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/pkg/commands/config.go b/pkg/commands/config.go index 3d74c89d..cfb7d67a 100644 --- a/pkg/commands/config.go +++ b/pkg/commands/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -17,13 +18,19 @@ type configCommand struct { viper *viper.Viper cmd *cobra.Command + opts config.LoaderOptions + verifyOpts verifyOptions + + buildInfo BuildInfo + log logutils.Log } -func newConfigCommand(log logutils.Log) *configCommand { +func newConfigCommand(log logutils.Log, info BuildInfo) *configCommand { c := &configCommand{ - viper: viper.New(), - log: log, + viper: viper.New(), + log: log, + buildInfo: info, } configCmd := &cobra.Command{ @@ -33,6 +40,15 @@ func newConfigCommand(log logutils.Log) *configCommand { RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, + PersistentPreRunE: c.preRunE, + } + + verifyCommand := &cobra.Command{ + Use: "verify", + Short: "Verify configuration against JSON schema", + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: c.executeVerify, } configCmd.AddCommand( @@ -41,11 +57,21 @@ func newConfigCommand(log logutils.Log) *configCommand { Short: "Print used config path", Args: cobra.NoArgs, ValidArgsFunction: cobra.NoFileCompletions, - Run: c.execute, - PreRunE: c.preRunE, + Run: c.executePath, }, + verifyCommand, ) + flagSet := configCmd.PersistentFlags() + flagSet.SortFlags = false // sort them as they are defined here + + setupConfigFileFlagSet(flagSet, &c.opts) + + // ex: --schema jsonschema/golangci.next.jsonschema.json + verifyFlagSet := verifyCommand.Flags() + verifyFlagSet.StringVar(&c.verifyOpts.schemaURL, "schema", "", color.GreenString("JSON schema URL")) + _ = verifyFlagSet.MarkHidden("schema") + c.cmd = configCmd return c @@ -54,7 +80,16 @@ func newConfigCommand(log logutils.Log) *configCommand { func (c *configCommand) preRunE(cmd *cobra.Command, _ []string) error { // The command doesn't depend on the real configuration. // It only needs to know the path of the configuration file. - loader := config.NewLoader(c.log.Child(logutils.DebugKeyConfigReader), c.viper, cmd.Flags(), config.LoaderOptions{}, config.NewDefault()) + cfg := config.NewDefault() + + // Hack to hide deprecation messages related to `--skip-dirs-use-default`: + // Flags are not bound then the default values, defined only through flags, are not applied. + // In this command, file path and file information are the only requirements, i.e. it don't need flag values. + // + // TODO(ldez) add an option (check deprecation) to `Loader.Load()` but this require a dedicated PR. + cfg.Run.UseDefaultSkipDirs = true + + loader := config.NewLoader(c.log.Child(logutils.DebugKeyConfigReader), c.viper, cmd.Flags(), c.opts, cfg) if err := loader.Load(); err != nil { return fmt.Errorf("can't load config: %w", err) @@ -63,14 +98,14 @@ func (c *configCommand) preRunE(cmd *cobra.Command, _ []string) error { return nil } -func (c *configCommand) execute(_ *cobra.Command, _ []string) { +func (c *configCommand) executePath(cmd *cobra.Command, _ []string) { usedConfigFile := c.getUsedConfig() if usedConfigFile == "" { c.log.Warnf("No config file detected") os.Exit(exitcodes.NoConfigFileDetected) } - fmt.Println(usedConfigFile) + cmd.Println(usedConfigFile) } // getUsedConfig returns the resolved path to the golangci config file, diff --git a/pkg/commands/config_verify.go b/pkg/commands/config_verify.go new file mode 100644 index 00000000..291c99a0 --- /dev/null +++ b/pkg/commands/config_verify.go @@ -0,0 +1,176 @@ +package commands + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + hcversion "github.com/hashicorp/go-version" + "github.com/pelletier/go-toml/v2" + "github.com/santhosh-tekuri/jsonschema/v5" + _ "github.com/santhosh-tekuri/jsonschema/v5/httploader" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "gopkg.in/yaml.v3" + + "github.com/golangci/golangci-lint/pkg/exitcodes" +) + +type verifyOptions struct { + schemaURL string // For debugging purpose only (Flag only). +} + +func (c *configCommand) executeVerify(cmd *cobra.Command, _ []string) error { + usedConfigFile := c.getUsedConfig() + if usedConfigFile == "" { + c.log.Warnf("No config file detected") + os.Exit(exitcodes.NoConfigFileDetected) + } + + schemaURL, err := createSchemaURL(cmd.Flags(), c.buildInfo) + if err != nil { + return fmt.Errorf("get JSON schema: %w", err) + } + + err = validateConfiguration(schemaURL, usedConfigFile) + if err != nil { + var v *jsonschema.ValidationError + if !errors.As(err, &v) { + return fmt.Errorf("[%s] validate: %w", usedConfigFile, err) + } + + detail := v.DetailedOutput() + + printValidationDetail(cmd, &detail) + + return fmt.Errorf("the configuration contains invalid elements") + } + + return nil +} + +func createSchemaURL(flags *pflag.FlagSet, buildInfo BuildInfo) (string, error) { + schemaURL, err := flags.GetString("schema") + if err != nil { + return "", fmt.Errorf("get schema flag: %w", err) + } + + if schemaURL != "" { + return schemaURL, nil + } + + switch { + case buildInfo.Version != "" && buildInfo.Version != "(devel)": + version, err := hcversion.NewVersion(buildInfo.Version) + if err != nil { + return "", fmt.Errorf("parse version: %w", err) + } + + schemaURL = fmt.Sprintf("https://golangci-lint.run/jsonschema/golangci.v%d.%d.jsonschema.json", + version.Segments()[0], version.Segments()[1]) + + case buildInfo.Commit != "" && buildInfo.Commit != "?": + if buildInfo.Commit == "unknown" { + return "", errors.New("unknown commit information") + } + + commit := buildInfo.Commit + + if strings.HasPrefix(commit, "(") { + c, _, ok := strings.Cut(strings.TrimPrefix(commit, "("), ",") + if !ok { + return "", errors.New("commit information not found") + } + + commit = c + } + + schemaURL = fmt.Sprintf("https://raw.githubusercontent.com/golangci/golangci-lint/%s/jsonschema/golangci.next.jsonschema.json", + commit) + + default: + return "", errors.New("version not found") + } + + return schemaURL, nil +} + +func validateConfiguration(schemaPath, targetFile string) error { + compiler := jsonschema.NewCompiler() + compiler.Draft = jsonschema.Draft7 + + schema, err := compiler.Compile(schemaPath) + if err != nil { + return fmt.Errorf("compile schema: %w", err) + } + + var m any + + switch strings.ToLower(filepath.Ext(targetFile)) { + case ".yaml", ".yml", ".json": + m, err = decodeYamlFile(targetFile) + if err != nil { + return err + } + + case ".toml": + m, err = decodeTomlFile(targetFile) + if err != nil { + return err + } + + default: + // unsupported + return errors.New("unsupported configuration format") + } + + return schema.Validate(m) +} + +func printValidationDetail(cmd *cobra.Command, detail *jsonschema.Detailed) { + if detail.Error != "" { + cmd.PrintErrf("jsonschema: %q does not validate with %q: %s\n", + strings.ReplaceAll(strings.TrimPrefix(detail.InstanceLocation, "/"), "/", "."), detail.KeywordLocation, detail.Error) + } + + for _, d := range detail.Errors { + d := d + printValidationDetail(cmd, &d) + } +} + +func decodeYamlFile(filename string) (any, error) { + file, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("[%s] file open: %w", filename, err) + } + + defer func() { _ = file.Close() }() + + var m any + err = yaml.NewDecoder(file).Decode(&m) + if err != nil { + return nil, fmt.Errorf("[%s] YAML decode: %w", filename, err) + } + + return m, nil +} + +func decodeTomlFile(filename string) (any, error) { + file, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("[%s] file open: %w", filename, err) + } + + defer func() { _ = file.Close() }() + + var m any + err = toml.NewDecoder(file).Decode(&m) + if err != nil { + return nil, fmt.Errorf("[%s] TOML decode: %w", filename, err) + } + + return m, nil +} diff --git a/pkg/commands/config_verify_test.go b/pkg/commands/config_verify_test.go new file mode 100644 index 00000000..81d16e28 --- /dev/null +++ b/pkg/commands/config_verify_test.go @@ -0,0 +1,140 @@ +package commands + +import ( + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_createSchemaURL(t *testing.T) { + testCases := []struct { + desc string + flag string + info BuildInfo + expected string + }{ + { + desc: "schema flag only", + flag: "https://example.com", + expected: "https://example.com", + }, + { + desc: "schema flag and build info", + flag: "https://example.com", + info: BuildInfo{ + Version: "v1.0.0", + Commit: "cd8b11773c6c1f595e8eb98c0d4310af20ae20df", + }, + expected: "https://example.com", + }, + { + desc: "version and commit", + info: BuildInfo{ + Version: "v1.0.0", + Commit: "cd8b11773c6c1f595e8eb98c0d4310af20ae20df", + }, + expected: "https://golangci-lint.run/jsonschema/golangci.v1.0.jsonschema.json", + }, + { + desc: "commit only", + info: BuildInfo{ + Commit: "cd8b11773c6c1f595e8eb98c0d4310af20ae20df", + }, + expected: "https://raw.githubusercontent.com/golangci/golangci-lint/cd8b11773c6c1f595e8eb98c0d4310af20ae20df/jsonschema/golangci.next.jsonschema.json", + }, + { + desc: "version devel and commit", + info: BuildInfo{ + Version: "(devel)", + Commit: "cd8b11773c6c1f595e8eb98c0d4310af20ae20df", + }, + expected: "https://raw.githubusercontent.com/golangci/golangci-lint/cd8b11773c6c1f595e8eb98c0d4310af20ae20df/jsonschema/golangci.next.jsonschema.json", + }, + { + desc: "composite commit info", + info: BuildInfo{ + Version: "", + Commit: `(cd8b11773c6c1f595e8eb98c0d4310af20ae20df, modified: "false", mod sum: "123")`, + }, + expected: "https://raw.githubusercontent.com/golangci/golangci-lint/cd8b11773c6c1f595e8eb98c0d4310af20ae20df/jsonschema/golangci.next.jsonschema.json", + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + flags.String("schema", "", "") + if test.flag != "" { + _ = flags.Set("schema", test.flag) + } + + schemaURL, err := createSchemaURL(flags, test.info) + require.NoError(t, err) + + assert.Equal(t, test.expected, schemaURL) + }) + } +} + +func Test_createSchemaURL_error(t *testing.T) { + testCases := []struct { + desc string + info BuildInfo + expected string + }{ + { + desc: "commit unknown", + info: BuildInfo{ + Commit: "unknown", + }, + expected: "unknown commit information", + }, + { + desc: "commit ?", + info: BuildInfo{ + Commit: "?", + }, + expected: "version not found", + }, + { + desc: "version devel only", + info: BuildInfo{ + Version: "(devel)", + }, + expected: "version not found", + }, + { + desc: "invalid version", + info: BuildInfo{ + Version: "example", + }, + expected: "parse version: Malformed version: example", + }, + { + desc: "invalid composite commit info", + info: BuildInfo{ + Version: "", + Commit: `(cd8b11773c6c1f595e8eb98c0d4310af20ae20df)`, + }, + expected: "commit information not found", + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + flags.String("schema", "", "") + + _, err := createSchemaURL(flags, test.info) + require.EqualError(t, err, test.expected) + }) + } +} diff --git a/pkg/commands/root.go b/pkg/commands/root.go index be6e22ce..cbb838aa 100644 --- a/pkg/commands/root.go +++ b/pkg/commands/root.go @@ -61,7 +61,7 @@ func newRootCommand(info BuildInfo) *rootCommand { newLintersCommand(log).cmd, newRunCommand(log, info).cmd, newCacheCommand().cmd, - newConfigCommand(log).cmd, + newConfigCommand(log, info).cmd, newVersionCommand(info).cmd, newCustomCommand(log).cmd, ) diff --git a/scripts/website/copy_jsonschema/main.go b/scripts/website/copy_jsonschema/main.go new file mode 100644 index 00000000..8708bfcf --- /dev/null +++ b/scripts/website/copy_jsonschema/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + + hcversion "github.com/hashicorp/go-version" + + "github.com/golangci/golangci-lint/scripts/website/github" +) + +func main() { + err := copySchemas() + if err != nil { + log.Fatal(err) + } +} + +func copySchemas() error { + dstDir := filepath.FromSlash("docs/static/jsonschema/") + + err := os.RemoveAll(dstDir) + if err != nil { + return fmt.Errorf("remove dir: %w", err) + } + + err = os.MkdirAll(dstDir, os.ModePerm) + if err != nil { + return fmt.Errorf("make dir: %w", err) + } + + // The key is the destination file. + // The value is the source file. + files := map[string]string{} + + entries, err := os.ReadDir("jsonschema") + if err != nil { + return fmt.Errorf("read dir: %w", err) + } + + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".jsonschema.json") { + files[entry.Name()] = entry.Name() + } + } + + latest, err := github.GetLatestVersion() + if err != nil { + return fmt.Errorf("get latest release version: %w", err) + } + + version, err := hcversion.NewVersion(latest) + if err != nil { + return fmt.Errorf("parse version: %w", err) + } + + versioned := fmt.Sprintf("golangci.v%d.%d.jsonschema.json", version.Segments()[0], version.Segments()[1]) + files[versioned] = "golangci.jsonschema.json" + + for dst, src := range files { + err := copyFile(filepath.Join("jsonschema", src), filepath.Join(dstDir, dst)) + if err != nil { + return fmt.Errorf("copy files: %w", err) + } + } + + return nil +} + +func copyFile(src, dst string) error { + source, err := os.Open(src) + if err != nil { + return fmt.Errorf("open file %s: %w", src, err) + } + + defer func() { _ = source.Close() }() + + info, err := source.Stat() + if err != nil { + return fmt.Errorf("file %s not found: %w", src, err) + } + + destination, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, info.Mode()) + if err != nil { + return fmt.Errorf("create file %s: %w", dst, err) + } + + defer func() { _ = destination.Close() }() + + _, err = io.Copy(destination, source) + if err != nil { + return fmt.Errorf("copy file %s to %s: %w", src, dst, err) + } + + return nil +} diff --git a/scripts/website/expand_templates/main.go b/scripts/website/expand_templates/main.go index 211a9a29..24128a3f 100644 --- a/scripts/website/expand_templates/main.go +++ b/scripts/website/expand_templates/main.go @@ -3,15 +3,13 @@ package main import ( "encoding/json" "fmt" - "io" "log" - "net/http" "os" "path/filepath" "strings" - "time" "github.com/golangci/golangci-lint/internal/renameio" + "github.com/golangci/golangci-lint/scripts/website/github" "github.com/golangci/golangci-lint/scripts/website/types" ) @@ -89,46 +87,6 @@ func processDoc(path string, replacements map[string]string, madeReplacements ma return nil } -type latestRelease struct { - TagName string `json:"tag_name"` -} - -func getLatestVersion() (string, error) { - endpoint := "https://api.github.com/repos/golangci/golangci-lint/releases/latest" - - //nolint:noctx // request timeout handled by the client - req, err := http.NewRequest(http.MethodGet, endpoint, http.NoBody) - if err != nil { - return "", fmt.Errorf("prepare a HTTP request: %w", err) - } - - req.Header.Set("Accept", "application/vnd.github.v3+json") - - client := &http.Client{Timeout: 2 * time.Second} - - resp, err := client.Do(req) - - if err != nil { - return "", fmt.Errorf("get HTTP response for the latest tag: %w", err) - } - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("read a body for the latest tag: %w", err) - } - - release := latestRelease{} - - err = json.Unmarshal(body, &release) - if err != nil { - return "", fmt.Errorf("unmarshal the body for the latest tag: %w", err) - } - - return release.TagName, nil -} - func buildTemplateContext() (map[string]string, error) { snippets, err := getExampleSnippets() if err != nil { @@ -150,7 +108,7 @@ func buildTemplateContext() (map[string]string, error) { return nil, fmt.Errorf("read CHANGELOG.md: %w", err) } - latestVersion, err := getLatestVersion() + latestVersion, err := github.GetLatestVersion() if err != nil { return nil, fmt.Errorf("get the latest version: %w", err) } diff --git a/scripts/website/github/github.go b/scripts/website/github/github.go new file mode 100644 index 00000000..01690f5f --- /dev/null +++ b/scripts/website/github/github.go @@ -0,0 +1,49 @@ +package github + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const endpoint = "https://api.github.com/repos/golangci/golangci-lint/releases/latest" + +type releaseInfo struct { + TagName string `json:"tag_name"` +} + +// GetLatestVersion gets latest release information. +func GetLatestVersion() (string, error) { + //nolint:noctx // request timeout handled by the client + req, err := http.NewRequest(http.MethodGet, endpoint, http.NoBody) + if err != nil { + return "", fmt.Errorf("prepare a HTTP request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + + client := &http.Client{Timeout: 2 * time.Second} + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("get HTTP response for the latest tag: %w", err) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read a body for the latest tag: %w", err) + } + + release := releaseInfo{} + + err = json.Unmarshal(body, &release) + if err != nil { + return "", fmt.Errorf("unmarshal the body for the latest tag: %w", err) + } + + return release.TagName, nil +}