aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2023-10-24 07:34:00 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2023-10-24 07:34:00 +0200
commit435397de49adc9740bc67416166aba4d0be8ae2a (patch)
tree1642dccae517840f3256f35e29e8d7d1802f700f
parent18476c6cba2d5d2bb3ecf3ad136dacbf41d0360e (diff)
feat: Add cmds
-rwxr-xr-x.githooks/pre-commit13
-rw-r--r--.github/workflows/workflow.yml16
-rwxr-xr-xci-deps.sh9
-rw-r--r--cmd/cvet/check_command.go104
-rw-r--r--cmd/cvet/check_query.go25
-rw-r--r--cmd/cvet/code_smells.go64
-rw-r--r--cmd/cvet/logger.go71
-rw-r--r--cmd/cvet/main.go9
-rw-r--r--cmd/gettext/gettext.go164
-rw-r--r--cmd/gettext/gettext_test.go61
-rw-r--r--go.mod11
-rw-r--r--go.sum20
12 files changed, 559 insertions, 8 deletions
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 0000000..92a43ad
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,13 @@
+#!/bin/sh
+set -ev
+
+templ generate
+gofmt -d -e -s .
+go vet ./...
+go run ./tools/cvet ./...
+staticcheck ./...
+gosec -quiet ./...
+gocyclo -over 10 -ignore '.*_templ\.go' .
+golangci-lint run
+go test ./...
+go mod tidy \ No newline at end of file
diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml
new file mode 100644
index 0000000..090bc8f
--- /dev/null
+++ b/.github/workflows/workflow.yml
@@ -0,0 +1,16 @@
+name: Push action
+on: push
+jobs:
+ checks:
+ runs-on: ubuntu-latest
+ env:
+ GOPATH: /home/runner/work/go
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v4
+ with:
+ go-version-file: go.mod
+ - name: Install additional dependencies
+ run: sh ./ci-deps.sh
+ - name: Run git hooks
+ run: ./.githooks/pre-commit \ No newline at end of file
diff --git a/ci-deps.sh b/ci-deps.sh
new file mode 100755
index 0000000..89b37ab
--- /dev/null
+++ b/ci-deps.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+set -ev
+
+go install github.com/a-h/templ/cmd/templ@latest
+go install github.com/fzipp/gocyclo/cmd/gocyclo@latest
+go install honnef.co/go/tools/cmd/staticcheck@latest
+go install github.com/securego/gosec/v2/cmd/gosec@latest
+go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
+go install github.com/a-h/templ/cmd/templ@latest \ No newline at end of file
diff --git a/cmd/cvet/check_command.go b/cmd/cvet/check_command.go
new file mode 100644
index 0000000..47db8fa
--- /dev/null
+++ b/cmd/cvet/check_command.go
@@ -0,0 +1,104 @@
+package main
+
+import (
+ "fmt"
+ "go/ast"
+ "go/token"
+ "strings"
+
+ "github.com/samber/lo"
+ "golang.org/x/tools/go/analysis"
+)
+
+func isCommandFunc(node *ast.FuncDecl) bool {
+ return strings.HasSuffix(node.Name.Name, "Command")
+}
+
+func loggerCommandKeyValueArgFinder(pass *analysis.Pass, commandName string) func(arg ast.Expr) bool {
+ return func(arg ast.Expr) bool {
+ call, ok := arg.(*ast.CallExpr)
+ if !ok {
+ return false
+ }
+ firstArg, ok := call.Args[0].(*ast.BasicLit)
+ if !ok {
+ return false
+ }
+ if firstArg.Kind == token.STRING && firstArg.Value == `"command"` {
+ secondArg, ok := call.Args[1].(*ast.BasicLit)
+ if !ok {
+ pass.Reportf(arg.Pos(), "logger command name must be a literal")
+ return true
+ }
+ if secondArg.Kind != token.STRING {
+ pass.Reportf(arg.Pos(), "logger command name must be a string literal")
+ return true
+ }
+
+ value := secondArg.Value[1 : len(secondArg.Value)-1]
+ if value != commandName {
+ pass.Reportf(arg.Pos(), fmt.Sprintf("logger command name must be function name %s, but found %s", commandName, value))
+ return true
+ }
+ return true
+ }
+
+ return false
+ }
+}
+
+func checkStmtHaveNoRawSlog(pass *analysis.Pass, stmts []ast.Stmt) {
+ for _, stmt := range stmts[1:] {
+ switch stmt := stmt.(type) {
+ case *ast.ExprStmt:
+ if call, ok := stmt.X.(*ast.CallExpr); ok {
+ if selector, ok := call.Fun.(*ast.SelectorExpr); ok {
+ if isIdent(selector.X, "slog") {
+ pass.Reportf(stmt.Pos(), "logger must be used to log")
+ }
+ }
+ }
+ case *ast.IfStmt:
+ checkStmtHaveNoRawSlog(pass, stmt.Body.List)
+ }
+ }
+}
+
+func checkCommandLogger(pass *analysis.Pass, node *ast.FuncDecl) {
+ commandName := node.Name.Name
+ firstStmt, ok := node.Body.List[0].(*ast.AssignStmt)
+ if !ok || !isIdent(firstStmt.Lhs[0], "logger") {
+ pass.Reportf(node.Pos(), "command first statement must be a logger creation")
+ return
+ }
+
+ call, ok := firstStmt.Rhs[0].(*ast.CallExpr)
+ if !ok || !isSelector(call.Fun, "slog", "With") {
+ pass.Reportf(node.Pos(), "logger creation must use slog.With")
+ return
+ }
+ if !lo.ContainsBy(call.Args, loggerCommandKeyValueArgFinder(pass, commandName)) {
+ pass.Reportf(node.Pos(), "logger creation must contain command arg")
+ }
+
+ checkStmtHaveNoRawSlog(pass, node.Body.List[1:])
+}
+
+func checkCommandFunc(pass *analysis.Pass, node *ast.FuncDecl) {
+ if node.Type.Results.NumFields() != 2 {
+ pass.Reportf(node.Pos(), "command function must have 2 return value")
+ } else {
+ if !isIdent(node.Type.Results.List[0].Type, "int") {
+ pass.Reportf(node.Pos(), "command function must return an int code as the first return value")
+ }
+ if !isIdent(node.Type.Results.List[1].Type, "error") {
+ pass.Reportf(node.Pos(), "command function must return an error as the second return value")
+ }
+ }
+ if lo.ContainsBy(node.Type.Params.List, func(field *ast.Field) bool {
+ return isSelector(field.Type, "auth", "User")
+ }) {
+ pass.Reportf(node.Pos(), "command function must not have an auth.User parameter")
+ }
+ checkCommandLogger(pass, node)
+}
diff --git a/cmd/cvet/check_query.go b/cmd/cvet/check_query.go
new file mode 100644
index 0000000..bde3b9f
--- /dev/null
+++ b/cmd/cvet/check_query.go
@@ -0,0 +1,25 @@
+package main
+
+import (
+ "go/ast"
+ "strings"
+
+ "golang.org/x/tools/go/analysis"
+)
+
+func isQueryFunc(node *ast.FuncDecl) bool {
+ return strings.HasSuffix(node.Name.Name, "Query")
+}
+
+func checkQueryFunc(pass *analysis.Pass, node *ast.FuncDecl) {
+ if node.Type.Results.NumFields() != 3 {
+ pass.Reportf(node.Pos(), "query function must have 3 return values")
+ } else {
+ if !isIdent(node.Type.Results.List[1].Type, "int") {
+ pass.Reportf(node.Pos(), "query function must return an int code as the second return value")
+ }
+ if !isIdent(node.Type.Results.List[2].Type, "error") {
+ pass.Reportf(node.Pos(), "query function must return an error as the third return value")
+ }
+ }
+}
diff --git a/cmd/cvet/code_smells.go b/cmd/cvet/code_smells.go
new file mode 100644
index 0000000..f306d85
--- /dev/null
+++ b/cmd/cvet/code_smells.go
@@ -0,0 +1,64 @@
+package main
+
+import (
+ "go/ast"
+
+ "github.com/samber/lo"
+ "golang.org/x/tools/go/analysis"
+)
+
+var codeSmellsAnalyzer = &analysis.Analyzer{
+ Name: "logging",
+ Doc: "Logging best practices",
+ Run: run,
+}
+
+func isIdent(node ast.Node, name string) bool {
+ if ident, ok := node.(*ast.Ident); ok {
+ return ident.Name == name
+ }
+ return false
+}
+
+func isSelector(node ast.Node, left string, right string) bool {
+ if selector, ok := node.(*ast.SelectorExpr); ok {
+ return isIdent(selector.X, left) && isIdent(selector.Sel, right)
+ }
+ return false
+}
+
+func visit(pass *analysis.Pass) func(node ast.Node) bool {
+ return func(node ast.Node) bool {
+ switch node := node.(type) {
+ case *ast.CallExpr:
+ checkLogUsage(pass, node)
+ case *ast.FuncDecl:
+ if isQueryFunc(node) {
+ checkQueryFunc(pass, node)
+ }
+ if isCommandFunc(node) {
+ checkCommandFunc(pass, node)
+ }
+ if field, ok := lo.Find(node.Type.Params.List, func(field *ast.Field) bool {
+ return isSelector(field.Type, "context", "Context")
+ }); ok {
+ if field != node.Type.Params.List[0] {
+ pass.Reportf(node.Pos(), "context.Context must be the first parameter")
+ }
+ if field.Names[0].Name != "ctx" {
+ pass.Reportf(node.Pos(), "context.Context parameter must be named ctx")
+ }
+ }
+ case *ast.BlockStmt:
+ checkLogBeforePanic(pass, node)
+ }
+ return true
+ }
+}
+
+func run(pass *analysis.Pass) (interface{}, error) {
+ for _, file := range pass.Files {
+ ast.Inspect(file, visit(pass))
+ }
+ return nil, nil //nolint:nilnil
+}
diff --git a/cmd/cvet/logger.go b/cmd/cvet/logger.go
new file mode 100644
index 0000000..0852f38
--- /dev/null
+++ b/cmd/cvet/logger.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+ "go/ast"
+
+ "golang.org/x/tools/go/analysis"
+)
+
+func checkNotLog(pass *analysis.Pass, node *ast.ExprStmt) {
+ prevCall, ok := node.X.(*ast.CallExpr)
+ if !ok {
+ return
+ }
+ selector, ok := prevCall.Fun.(*ast.SelectorExpr)
+ if !ok {
+ return
+ }
+ switch {
+ case isIdent(selector.X, "slog"):
+ case isIdent(selector.X, "logger"):
+ pass.Reportf(node.Pos(), "no log before panic")
+ }
+}
+
+func checkLogBeforePanic(pass *analysis.Pass, node *ast.BlockStmt) {
+ for i, stmt := range node.List {
+ expr, ok := stmt.(*ast.ExprStmt)
+ if !ok {
+ continue
+ }
+ call, ok := expr.X.(*ast.CallExpr)
+ if !ok {
+ continue
+ }
+ if i > 0 && isIdent(call.Fun, "panic") {
+ if prev, ok := node.List[i-1].(*ast.ExprStmt); ok {
+ checkNotLog(pass, prev)
+ }
+ }
+ }
+}
+
+func checkLogWithArgs(node *ast.CallExpr, pass *analysis.Pass) {
+ for _, arg := range node.Args {
+ call, ok := arg.(*ast.CallExpr)
+ if !ok {
+ continue
+ }
+ if selector, ok := call.Fun.(*ast.SelectorExpr); ok {
+ ident, ok := selector.X.(*ast.Ident)
+ if ok && ident.Name != "slog" {
+ pass.Reportf(node.Pos(), "slog.With and logger.With must be called with a slog arg")
+ }
+ } else {
+ pass.Reportf(node.Pos(), "slog.With and logger.With must be called with a slog arg")
+ }
+ }
+}
+
+func checkLogUsage(pass *analysis.Pass, node *ast.CallExpr) {
+ if selector, ok := node.Fun.(*ast.SelectorExpr); ok {
+ if ident, ok := selector.X.(*ast.Ident); ok {
+ if ident.Name == "log" {
+ pass.Reportf(node.Pos(), "found old log usage")
+ }
+ }
+ }
+ if isSelector(node.Fun, "slog", "With") || isSelector(node.Fun, "logger", "With") {
+ checkLogWithArgs(node, pass)
+ }
+}
diff --git a/cmd/cvet/main.go b/cmd/cvet/main.go
new file mode 100644
index 0000000..540515f
--- /dev/null
+++ b/cmd/cvet/main.go
@@ -0,0 +1,9 @@
+package main
+
+import (
+ "golang.org/x/tools/go/analysis/multichecker"
+)
+
+func main() {
+ multichecker.Main(codeSmellsAnalyzer)
+}
diff --git a/cmd/gettext/gettext.go b/cmd/gettext/gettext.go
new file mode 100644
index 0000000..7abb6e2
--- /dev/null
+++ b/cmd/gettext/gettext.go
@@ -0,0 +1,164 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/martinlehoux/kagamigo/core"
+ "golang.org/x/exp/slog"
+ "gopkg.in/yaml.v3"
+)
+
+func isIdentifierRune(char rune) bool {
+ return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || char == '.'
+}
+
+type ArgsParser struct {
+ argsCount int
+ currentBlock string
+ currentNesting int
+ currentStringLiteral bool
+}
+
+func (p *ArgsParser) parseStringLiteral(char rune) {
+ switch char {
+ case '"':
+ p.currentStringLiteral = false
+ p.currentBlock = ""
+ default:
+ p.currentBlock += string(char)
+ }
+}
+
+func (p *ArgsParser) parseNonStringLiteral(char rune) {
+ if isIdentifierRune(char) {
+ p.currentBlock += string(char)
+ return
+ }
+ switch char {
+ case ' ':
+ if p.currentBlock != "" {
+ if p.currentNesting == 0 {
+ p.argsCount++
+ }
+ p.currentBlock = ""
+ }
+ case '(':
+ p.currentBlock = ""
+ p.currentNesting++
+ case ')':
+ p.currentBlock = ""
+ p.currentNesting--
+ p.argsCount++
+ case '"':
+ p.currentStringLiteral = true
+ }
+}
+
+func (p *ArgsParser) Parse(args string) {
+ for _, char := range args {
+ switch p.currentStringLiteral {
+ case true:
+ p.parseStringLiteral(char)
+ case false:
+ p.parseNonStringLiteral(char)
+ }
+ }
+}
+
+func (p ArgsParser) ArgsCount() int {
+ if p.currentBlock != "" {
+ p.argsCount++
+ }
+ return p.argsCount
+}
+
+func extractKeys(content string) map[string]int {
+ extractedKeys := make(map[string]int, 0)
+ reg := regexp.MustCompile(`\{\{\s*call \$?\.T\s*"(\w+)"([\w.\s",:()]*)}}`)
+ matches := reg.FindAllStringSubmatch(content, -1)
+ for _, match := range matches {
+ key := match[1]
+ args := strings.TrimSpace(match[2])
+ parser := ArgsParser{}
+ parser.Parse(args)
+ extractedKeys[key] = parser.ArgsCount()
+ }
+ return extractedKeys
+}
+
+func extractAllKeys() map[string]int {
+ extractedKeys := make(map[string]int, 0)
+
+ err := filepath.Walk("templates", func(path string, info fs.FileInfo, err error) error {
+ if !info.IsDir() && filepath.Ext(path) == ".html" {
+ content, err := os.ReadFile(path)
+ core.Expect(err, "error reading file")
+ for key, value := range extractKeys(string(content)) {
+ extractedKeys[key] = value
+ }
+ }
+ return nil
+ })
+ core.Expect(err, "error walking templates directory")
+
+ return extractedKeys
+}
+
+func main() {
+ write := flag.Bool("write", false, "write new locales")
+ flag.Parse()
+ baseLogger := slog.Default()
+ langs := [...]string{"en-GB", "fr-FR"}
+
+ extractedKeys := extractAllKeys()
+ baseLogger.Info("extracted keys from templates", slog.Int("count", len(extractedKeys)))
+
+ for _, lang := range langs {
+ logger := baseLogger.With(slog.String("lang", lang))
+ currentLocales := make(map[string]string, 0)
+ newLocales := make(map[string]string, 0)
+
+ locales, err := os.ReadFile(filepath.Join("locales", lang, "index.yml"))
+ core.Expect(err, "error reading file")
+ core.Expect(yaml.Unmarshal(locales, &currentLocales), "error unmarshalling yaml")
+
+ correctLocales := 0
+ for key, translation := range currentLocales {
+ currentArgsCount := strings.Count(translation, "%")
+ expectedArgsCount, ok := extractedKeys[key]
+ switch {
+ case !ok:
+ logger.Info(`found unused key`, slog.String("key", key))
+ case currentArgsCount != expectedArgsCount:
+ logger.Info(`found translation with incorrect number of arguments`, slog.String("key", key), slog.Int("current", currentArgsCount), slog.Int("expected", expectedArgsCount))
+ newLocales[key] = ""
+ case translation == "":
+ newLocales[key] = ""
+ default:
+ newLocales[key] = currentLocales[key]
+ correctLocales++
+ }
+ }
+
+ for key := range extractedKeys {
+ if _, ok := currentLocales[key]; !ok {
+ logger.Info(`found missing key`, slog.String("key", key))
+ newLocales[key] = ""
+ }
+ }
+
+ logger.Info("finished checking locales", slog.Int("count", len(newLocales)), slog.Int("correct", correctLocales), slog.String("completion", fmt.Sprintf("%d%%", correctLocales*100/len(extractedKeys))))
+
+ if *write {
+ content, err := yaml.Marshal(newLocales)
+ core.Expect(err, "error marshalling yaml")
+ core.Expect(os.WriteFile(filepath.Join("locales", lang, "index.yml"), content, 0600), "error writing file")
+ }
+ }
+}
diff --git a/cmd/gettext/gettext_test.go b/cmd/gettext/gettext_test.go
new file mode 100644
index 0000000..f1947be
--- /dev/null
+++ b/cmd/gettext/gettext_test.go
@@ -0,0 +1,61 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestExtractKeys(t *testing.T) {
+ content := `{{ call .T "Hello" }}`
+
+ keys := extractKeys(content)
+
+ assert.Len(t, keys, 1)
+ assert.Equal(t, 0, keys["Hello"])
+}
+
+func TestExtractKeysWithoutSpaces(t *testing.T) {
+ content := `{{call .T "Hello"}}`
+
+ keys := extractKeys(content)
+
+ assert.Len(t, keys, 1)
+ assert.Equal(t, 0, keys["Hello"])
+}
+
+func TestExtractKeysWithDollar(t *testing.T) {
+ content := `{{ call $.T "Hello" }}`
+
+ keys := extractKeys(content)
+
+ assert.Len(t, keys, 1)
+ assert.Equal(t, 0, keys["Hello"])
+}
+
+func TestExtractKeysWithOneArgs(t *testing.T) {
+ content := `{{ call .T "approveButton" .Username }}`
+
+ keys := extractKeys(content)
+
+ assert.Len(t, keys, 1)
+ assert.Equal(t, 1, keys["approveButton"])
+}
+
+func TestExtractKeysWithSeveralArgs(t *testing.T) {
+ content := `{{ call .T "approveButton" .Username .Age .Email }}`
+
+ keys := extractKeys(content)
+
+ assert.Len(t, keys, 1)
+ assert.Equal(t, 3, keys["approveButton"])
+}
+
+func TestExtractKeysWithComplexArgs(t *testing.T) {
+ content := `{{ call $.T "raceStart_chosen" (.StartAt.Format "Monday, January 2, 2006 at 15:04") }}`
+
+ keys := extractKeys(content)
+
+ assert.Len(t, keys, 1)
+ assert.Equal(t, 1, keys["raceStart_chosen"])
+}
diff --git a/go.mod b/go.mod
index 03c9fcd..be3e75f 100644
--- a/go.mod
+++ b/go.mod
@@ -6,15 +6,20 @@ require (
github.com/a-h/templ v0.2.408
github.com/gofrs/uuid v4.4.0+incompatible
github.com/kataras/i18n v0.0.8
+ github.com/samber/lo v1.38.1
+ github.com/stretchr/testify v1.8.4
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
+ golang.org/x/tools v0.14.0
+ gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- golang.org/x/net v0.9.0 // indirect
- golang.org/x/text v0.9.0 // indirect
+ golang.org/x/mod v0.13.0 // indirect
+ golang.org/x/net v0.16.0 // indirect
+ golang.org/x/sys v0.13.0 // indirect
+ golang.org/x/text v0.13.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index c3c925b..656458d 100644
--- a/go.sum
+++ b/go.sum
@@ -11,13 +11,23 @@ github.com/kataras/i18n v0.0.8 h1:thiDRqq4fN2sQOK5CwR5Yh1yT7swP6B3wnadILhqjhk=
github.com/kataras/i18n v0.0.8/go.mod h1:M/yRAqQ3Y7z2oSpotxG/+nPrgsJLas6t0kEJfIJk98E=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
+github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
+github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
-golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
-golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
-golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
+golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos=
+golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
+golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc=
+golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=