feat: add verify command (#4527)

This commit is contained in:
Ludovic Fernandez 2024-03-19 21:35:21 +01:00 committed by GitHub
parent 6709c974a4
commit eaafdf3623
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 518 additions and 57 deletions

View File

@ -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

View File

@ -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:

View File

@ -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:

2
go.mod
View File

@ -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

4
go.sum generated
View File

@ -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=

View File

@ -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,

View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -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,
)

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}