feat(cli): severity-aware exit codes, init command and plain flags
- replace multichecker with a checker-based CLI using -config/-root/-module - fail only on error rules by default, -fail-on warn to include warnings - add -json, -version and hexcheck init starter config generation
- commit
- e785caabf7edb89b7a2599374fe567f2366a365c
- author
- brice · · 2026-10-08 05:44 UTC
- parent
- 0cdd9c7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +376 -3
A cmd/hexcheck/init.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "flag" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/hexcheck/config" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // layoutCandidates lists common directory layouts per component, in output order. | |
| 16 | + | var layoutCandidates = []struct { | |
| 17 | + | name string | |
| 18 | + | role config.Role | |
| 19 | + | paths []string | |
| 20 | + | }{ | |
| 21 | + | {"core", config.RoleCore, []string{"internal/domain", "internal/core", "domain", "core"}}, | |
| 22 | + | {"usecases", config.RoleUsecase, []string{"internal/application/usecase", "internal/application/usecases", "internal/usecase", "internal/usecases", "internal/app", "usecase", "usecases"}}, | |
| 23 | + | {"ports", config.RolePorts, []string{"internal/application/port", "internal/application/ports", "internal/port", "internal/ports", "internal/domain/repository", "ports"}}, | |
| 24 | + | {"adapters", config.RoleAdapter, []string{"internal/infrastructure", "internal/adapters", "internal/adapter", "internal/infra", "adapters", "infrastructure"}}, | |
| 25 | + | {"entrypoints", config.RoleEntrypoint, []string{"cmd", "internal/cli", "internal/http", "internal/api"}}, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func runInit(args []string, stdout, stderr io.Writer) int { | |
| 29 | + | fs := flag.NewFlagSet("hexcheck init", flag.ContinueOnError) | |
| 30 | + | fs.SetOutput(stderr) | |
| 31 | + | force := fs.Bool("force", false, "overwrite an existing .hexcheck.yaml") | |
| 32 | + | if err := fs.Parse(args); err != nil { | |
| 33 | + | if errors.Is(err, flag.ErrHelp) { | |
| 34 | + | return exitOK | |
| 35 | + | } | |
| 36 | + | return exitUsage | |
| 37 | + | } | |
| 38 | + | dir := "." | |
| 39 | + | if fs.NArg() > 0 { | |
| 40 | + | dir = fs.Arg(0) | |
| 41 | + | } | |
| 42 | + | target := filepath.Join(dir, ".hexcheck.yaml") | |
| 43 | + | if _, err := os.Stat(target); err == nil && !*force { | |
| 44 | + | fmt.Fprintf(stderr, "hexcheck: %s already exists (use -force to overwrite)\n", target) | |
| 45 | + | return exitFailure | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | content, detected := starterConfig(dir) | |
| 49 | + | if err := writeValidated(target, content); err != nil { | |
| 50 | + | fmt.Fprintf(stderr, "hexcheck: %v\n", err) | |
| 51 | + | return exitFailure | |
| 52 | + | } | |
| 53 | + | fmt.Fprintf(stdout, "wrote %s (%d component(s) detected); review the paths, then run: hexcheck ./...\n", target, detected) | |
| 54 | + | return exitOK | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // writeValidated writes content to a temp file next to target, loads it as a | |
| 58 | + | // config, and only then renames it over target. | |
| 59 | + | func writeValidated(target, content string) error { | |
| 60 | + | tmp, err := os.CreateTemp(filepath.Dir(target), ".hexcheck-*.yaml") | |
| 61 | + | if err != nil { | |
| 62 | + | return err | |
| 63 | + | } | |
| 64 | + | // After a successful rename the temp file is gone, so this cleanup only | |
| 65 | + | // matters on the error paths. | |
| 66 | + | defer func() { _ = os.Remove(tmp.Name()) }() | |
| 67 | + | if _, err := tmp.WriteString(content); err != nil { | |
| 68 | + | _ = tmp.Close() | |
| 69 | + | return err | |
| 70 | + | } | |
| 71 | + | if err := tmp.Close(); err != nil { | |
| 72 | + | return err | |
| 73 | + | } | |
| 74 | + | if _, err := config.Load(tmp.Name()); err != nil { | |
| 75 | + | return fmt.Errorf("generated config is invalid: %w", err) | |
| 76 | + | } | |
| 77 | + | if err := os.Chmod(tmp.Name(), 0o644); err != nil { | |
| 78 | + | return err | |
| 79 | + | } | |
| 80 | + | return os.Rename(tmp.Name(), target) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // starterConfig builds a config from directories that exist under dir. | |
| 84 | + | // Components with no detected directory keep a commented-out default path. | |
| 85 | + | func starterConfig(dir string) (string, int) { | |
| 86 | + | var b strings.Builder | |
| 87 | + | b.WriteString("version: 1\ncomponents:\n") | |
| 88 | + | detected := 0 | |
| 89 | + | for _, c := range layoutCandidates { | |
| 90 | + | var found []string | |
| 91 | + | for _, p := range c.paths { | |
| 92 | + | if info, err := os.Stat(filepath.Join(dir, p)); err == nil && info.IsDir() { | |
| 93 | + | found = append(found, p+"/**") | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | if len(found) == 0 { | |
| 97 | + | fmt.Fprintf(&b, " # %s:\n # role: %s\n # paths: [%s/**]\n", c.name, c.role, c.paths[0]) | |
| 98 | + | continue | |
| 99 | + | } | |
| 100 | + | detected++ | |
| 101 | + | fmt.Fprintf(&b, " %s:\n role: %s\n paths:\n", c.name, c.role) | |
| 102 | + | for _, p := range found { | |
| 103 | + | fmt.Fprintf(&b, " - %s\n", p) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | b.WriteString(` generated: | |
| 107 | + | role: ignore | |
| 108 | + | paths: | |
| 109 | + | - '**/mocks/**' | |
| 110 | + | - '**/generated/**' | |
| 111 | + | - '**/*_templ.go' | |
| 112 | + | - '**/*_gen.go' | |
| 113 | + | ||
| 114 | + | # Every rule is enabled with its default severity. Override here, e.g.: | |
| 115 | + | # rules: | |
| 116 | + | # suspicious-business-logic-in-adapter: off | |
| 117 | + | ||
| 118 | + | mocking: | |
| 119 | + | generatedMockPaths: | |
| 120 | + | - '**/mocks/**' | |
| 121 | + | `) | |
| 122 | + | return b.String(), detected | |
| 123 | + | } | |
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "flag" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "strings" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/hexcheck/config" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // layoutCandidates lists common directory layouts per component, in output order. | |||
| 16 | + | var layoutCandidates = []struct { | |||
| 17 | + | name string | |||
| 18 | + | role config.Role | |||
| 19 | + | paths []string | |||
| 20 | + | }{ | |||
| 21 | + | {"core", config.RoleCore, []string{"internal/domain", "internal/core", "domain", "core"}}, | |||
| 22 | + | {"usecases", config.RoleUsecase, []string{"internal/application/usecase", "internal/application/usecases", "internal/usecase", "internal/usecases", "internal/app", "usecase", "usecases"}}, | |||
| 23 | + | {"ports", config.RolePorts, []string{"internal/application/port", "internal/application/ports", "internal/port", "internal/ports", "internal/domain/repository", "ports"}}, | |||
| 24 | + | {"adapters", config.RoleAdapter, []string{"internal/infrastructure", "internal/adapters", "internal/adapter", "internal/infra", "adapters", "infrastructure"}}, | |||
| 25 | + | {"entrypoints", config.RoleEntrypoint, []string{"cmd", "internal/cli", "internal/http", "internal/api"}}, | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func runInit(args []string, stdout, stderr io.Writer) int { | |||
| 29 | + | fs := flag.NewFlagSet("hexcheck init", flag.ContinueOnError) | |||
| 30 | + | fs.SetOutput(stderr) | |||
| 31 | + | force := fs.Bool("force", false, "overwrite an existing .hexcheck.yaml") | |||
| 32 | + | if err := fs.Parse(args); err != nil { | |||
| 33 | + | if errors.Is(err, flag.ErrHelp) { | |||
| 34 | + | return exitOK | |||
| 35 | + | } | |||
| 36 | + | return exitUsage | |||
| 37 | + | } | |||
| 38 | + | dir := "." | |||
| 39 | + | if fs.NArg() > 0 { | |||
| 40 | + | dir = fs.Arg(0) | |||
| 41 | + | } | |||
| 42 | + | target := filepath.Join(dir, ".hexcheck.yaml") | |||
| 43 | + | if _, err := os.Stat(target); err == nil && !*force { | |||
| 44 | + | fmt.Fprintf(stderr, "hexcheck: %s already exists (use -force to overwrite)\n", target) | |||
| 45 | + | return exitFailure | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | content, detected := starterConfig(dir) | |||
| 49 | + | if err := writeValidated(target, content); err != nil { | |||
| 50 | + | fmt.Fprintf(stderr, "hexcheck: %v\n", err) | |||
| 51 | + | return exitFailure | |||
| 52 | + | } | |||
| 53 | + | fmt.Fprintf(stdout, "wrote %s (%d component(s) detected); review the paths, then run: hexcheck ./...\n", target, detected) | |||
| 54 | + | return exitOK | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // writeValidated writes content to a temp file next to target, loads it as a | |||
| 58 | + | // config, and only then renames it over target. | |||
| 59 | + | func writeValidated(target, content string) error { | |||
| 60 | + | tmp, err := os.CreateTemp(filepath.Dir(target), ".hexcheck-*.yaml") | |||
| 61 | + | if err != nil { | |||
| 62 | + | return err | |||
| 63 | + | } | |||
| 64 | + | // After a successful rename the temp file is gone, so this cleanup only | |||
| 65 | + | // matters on the error paths. | |||
| 66 | + | defer func() { _ = os.Remove(tmp.Name()) }() | |||
| 67 | + | if _, err := tmp.WriteString(content); err != nil { | |||
| 68 | + | _ = tmp.Close() | |||
| 69 | + | return err | |||
| 70 | + | } | |||
| 71 | + | if err := tmp.Close(); err != nil { | |||
| 72 | + | return err | |||
| 73 | + | } | |||
| 74 | + | if _, err := config.Load(tmp.Name()); err != nil { | |||
| 75 | + | return fmt.Errorf("generated config is invalid: %w", err) | |||
| 76 | + | } | |||
| 77 | + | if err := os.Chmod(tmp.Name(), 0o644); err != nil { | |||
| 78 | + | return err | |||
| 79 | + | } | |||
| 80 | + | return os.Rename(tmp.Name(), target) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // starterConfig builds a config from directories that exist under dir. | |||
| 84 | + | // Components with no detected directory keep a commented-out default path. | |||
| 85 | + | func starterConfig(dir string) (string, int) { | |||
| 86 | + | var b strings.Builder | |||
| 87 | + | b.WriteString("version: 1\ncomponents:\n") | |||
| 88 | + | detected := 0 | |||
| 89 | + | for _, c := range layoutCandidates { | |||
| 90 | + | var found []string | |||
| 91 | + | for _, p := range c.paths { | |||
| 92 | + | if info, err := os.Stat(filepath.Join(dir, p)); err == nil && info.IsDir() { | |||
| 93 | + | found = append(found, p+"/**") | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | if len(found) == 0 { | |||
| 97 | + | fmt.Fprintf(&b, " # %s:\n # role: %s\n # paths: [%s/**]\n", c.name, c.role, c.paths[0]) | |||
| 98 | + | continue | |||
| 99 | + | } | |||
| 100 | + | detected++ | |||
| 101 | + | fmt.Fprintf(&b, " %s:\n role: %s\n paths:\n", c.name, c.role) | |||
| 102 | + | for _, p := range found { | |||
| 103 | + | fmt.Fprintf(&b, " - %s\n", p) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | b.WriteString(` generated: | |||
| 107 | + | role: ignore | |||
| 108 | + | paths: | |||
| 109 | + | - '**/mocks/**' | |||
| 110 | + | - '**/generated/**' | |||
| 111 | + | - '**/*_templ.go' | |||
| 112 | + | - '**/*_gen.go' | |||
| 113 | + | ||||
| 114 | + | # Every rule is enabled with its default severity. Override here, e.g.: | |||
| 115 | + | # rules: | |||
| 116 | + | # suspicious-business-logic-in-adapter: off | |||
| 117 | + | ||||
| 118 | + | mocking: | |||
| 119 | + | generatedMockPaths: | |||
| 120 | + | - '**/mocks/**' | |||
| 121 | + | `) | |||
| 122 | + | return b.String(), detected | |||
| 123 | + | } | |||
M cmd/hexcheck/main.go +113 -2
| @@ -1,10 +1,121 @@ | |||
| 1 | + | // Command hexcheck runs the hexcheck analyzer on Go packages. | |
| 2 | + | // | |
| 3 | + | // hexcheck [flags] [packages] check packages (default ./...) | |
| 4 | + | // hexcheck init [-force] [dir] write a starter .hexcheck.yaml | |
| 1 | 5 | package main | |
| 2 | 6 | ||
| 3 | 7 | import ( | |
| 8 | + | "errors" | |
| 9 | + | "flag" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "os" | |
| 13 | + | ||
| 4 | 14 | "github.com/bnema/hexcheck/analyzer" | |
| 5 | - | "golang.org/x/tools/go/analysis/multichecker" | |
| 15 | + | "github.com/bnema/hexcheck/config" | |
| 16 | + | "golang.org/x/tools/go/analysis" | |
| 17 | + | "golang.org/x/tools/go/analysis/checker" | |
| 18 | + | "golang.org/x/tools/go/packages" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // Exit codes. | |
| 22 | + | const ( | |
| 23 | + | exitOK = 0 | |
| 24 | + | exitFailure = 1 // load, config, or analysis failure | |
| 25 | + | exitUsage = 2 | |
| 26 | + | exitFindings = 3 // diagnostics at or above -fail-on | |
| 6 | 27 | ) | |
| 7 | 28 | ||
| 29 | + | // version is set at release time with -ldflags "-X main.version=...". | |
| 30 | + | var version = "dev" | |
| 31 | + | ||
| 8 | 32 | func main() { | |
| 9 | - | multichecker.Main(analyzer.New(analyzer.Options{})) | |
| 33 | + | os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func run(args []string, stdout, stderr io.Writer) int { | |
| 37 | + | if len(args) > 0 && args[0] == "init" { | |
| 38 | + | return runInit(args[1:], stdout, stderr) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | fs := flag.NewFlagSet("hexcheck", flag.ContinueOnError) | |
| 42 | + | fs.SetOutput(stderr) | |
| 43 | + | configPath := fs.String("config", "", "path to .hexcheck.yaml (default: discovered from -root upwards)") | |
| 44 | + | root := fs.String("root", "", "project root for config-relative paths") | |
| 45 | + | modulePath := fs.String("module", "", "Go module path (default: module in go.mod)") | |
| 46 | + | tests := fs.Bool("test", true, "also analyze test files") | |
| 47 | + | jsonOut := fs.Bool("json", false, "print diagnostics as JSON") | |
| 48 | + | failOn := fs.String("fail-on", "error", "lowest severity that makes the exit code non-zero: error or warn") | |
| 49 | + | showVersion := fs.Bool("version", false, "print the version and exit") | |
| 50 | + | fs.Usage = func() { | |
| 51 | + | fmt.Fprintln(stderr, "usage: hexcheck [flags] [packages]\n hexcheck init [-force] [dir]") | |
| 52 | + | fs.PrintDefaults() | |
| 53 | + | } | |
| 54 | + | if err := fs.Parse(args); err != nil { | |
| 55 | + | if errors.Is(err, flag.ErrHelp) { | |
| 56 | + | return exitOK | |
| 57 | + | } | |
| 58 | + | return exitUsage | |
| 59 | + | } | |
| 60 | + | if *showVersion { | |
| 61 | + | fmt.Fprintln(stdout, "hexcheck", version) | |
| 62 | + | return exitOK | |
| 63 | + | } | |
| 64 | + | if *failOn != string(config.SeverityError) && *failOn != string(config.SeverityWarn) { | |
| 65 | + | fmt.Fprintf(stderr, "hexcheck: -fail-on must be error or warn, got %q\n", *failOn) | |
| 66 | + | return exitUsage | |
| 67 | + | } | |
| 68 | + | patterns := fs.Args() | |
| 69 | + | if len(patterns) == 0 { | |
| 70 | + | patterns = []string{"./..."} | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | a := analyzer.New(analyzer.Options{ConfigPath: *configPath, Root: *root, ModulePath: *modulePath}) | |
| 74 | + | pkgs, err := packages.Load(&packages.Config{Mode: packages.LoadSyntax | packages.NeedModule, Tests: *tests, Dir: *root}, patterns...) | |
| 75 | + | if err != nil { | |
| 76 | + | fmt.Fprintf(stderr, "hexcheck: %v\n", err) | |
| 77 | + | return exitFailure | |
| 78 | + | } | |
| 79 | + | if packages.PrintErrors(pkgs) > 0 { | |
| 80 | + | return exitFailure | |
| 81 | + | } | |
| 82 | + | graph, err := checker.Analyze([]*analysis.Analyzer{a}, pkgs, nil) | |
| 83 | + | if err != nil { | |
| 84 | + | fmt.Fprintf(stderr, "hexcheck: %v\n", err) | |
| 85 | + | return exitFailure | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | if *jsonOut { | |
| 89 | + | if err := graph.PrintJSON(stdout); err != nil { | |
| 90 | + | return exitFailure | |
| 91 | + | } | |
| 92 | + | } else if err := graph.PrintText(stderr, -1); err != nil { | |
| 93 | + | return exitFailure | |
| 94 | + | } | |
| 95 | + | return exitCode(graph, config.Severity(*failOn)) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // exitCode maps diagnostics to an exit code using each rule's configured | |
| 99 | + | // severity, so warn-level rules do not fail CI unless -fail-on=warn. | |
| 100 | + | func exitCode(graph *checker.Graph, failOn config.Severity) int { | |
| 101 | + | code := exitOK | |
| 102 | + | for act := range graph.All() { | |
| 103 | + | if act.Err != nil { | |
| 104 | + | return exitFailure | |
| 105 | + | } | |
| 106 | + | if !act.IsRoot { | |
| 107 | + | continue | |
| 108 | + | } | |
| 109 | + | result, _ := act.Result.(*analyzer.Result) | |
| 110 | + | for _, diag := range act.Diagnostics { | |
| 111 | + | severity := config.SeverityError | |
| 112 | + | if diag.Category != analyzer.DirectiveCategory { | |
| 113 | + | severity = result.Severity(diag.Category) | |
| 114 | + | } | |
| 115 | + | if severity == config.SeverityError || (failOn == config.SeverityWarn && severity == config.SeverityWarn) { | |
| 116 | + | code = exitFindings | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | return code | |
| 10 | 121 | } | |
| @@ -1,10 +1,121 @@ | |||||
| 1 | + | // Command hexcheck runs the hexcheck analyzer on Go packages. | |||
| 2 | + | // | |||
| 3 | + | // hexcheck [flags] [packages] check packages (default ./...) | |||
| 4 | + | // hexcheck init [-force] [dir] write a starter .hexcheck.yaml | |||
| 1 | package main | 5 | package main | ||
| 2 | 6 | ||||
| 3 | import ( | 7 | import ( | ||
| 8 | + | "errors" | |||
| 9 | + | "flag" | |||
| 10 | + | "fmt" | |||
| 11 | + | "io" | |||
| 12 | + | "os" | |||
| 13 | + | ||||
| 4 | "github.com/bnema/hexcheck/analyzer" | 14 | "github.com/bnema/hexcheck/analyzer" | ||
| 5 | - | "golang.org/x/tools/go/analysis/multichecker" | 15 | + | "github.com/bnema/hexcheck/config" |
| 16 | + | "golang.org/x/tools/go/analysis" | |||
| 17 | + | "golang.org/x/tools/go/analysis/checker" | |||
| 18 | + | "golang.org/x/tools/go/packages" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // Exit codes. | |||
| 22 | + | const ( | |||
| 23 | + | exitOK = 0 | |||
| 24 | + | exitFailure = 1 // load, config, or analysis failure | |||
| 25 | + | exitUsage = 2 | |||
| 26 | + | exitFindings = 3 // diagnostics at or above -fail-on | |||
| 6 | ) | 27 | ) | ||
| 7 | 28 | ||||
| 29 | + | // version is set at release time with -ldflags "-X main.version=...". | |||
| 30 | + | var version = "dev" | |||
| 31 | + | ||||
| 8 | func main() { | 32 | func main() { | ||
| 9 | - | multichecker.Main(analyzer.New(analyzer.Options{})) | 33 | + | os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) |
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func run(args []string, stdout, stderr io.Writer) int { | |||
| 37 | + | if len(args) > 0 && args[0] == "init" { | |||
| 38 | + | return runInit(args[1:], stdout, stderr) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | fs := flag.NewFlagSet("hexcheck", flag.ContinueOnError) | |||
| 42 | + | fs.SetOutput(stderr) | |||
| 43 | + | configPath := fs.String("config", "", "path to .hexcheck.yaml (default: discovered from -root upwards)") | |||
| 44 | + | root := fs.String("root", "", "project root for config-relative paths") | |||
| 45 | + | modulePath := fs.String("module", "", "Go module path (default: module in go.mod)") | |||
| 46 | + | tests := fs.Bool("test", true, "also analyze test files") | |||
| 47 | + | jsonOut := fs.Bool("json", false, "print diagnostics as JSON") | |||
| 48 | + | failOn := fs.String("fail-on", "error", "lowest severity that makes the exit code non-zero: error or warn") | |||
| 49 | + | showVersion := fs.Bool("version", false, "print the version and exit") | |||
| 50 | + | fs.Usage = func() { | |||
| 51 | + | fmt.Fprintln(stderr, "usage: hexcheck [flags] [packages]\n hexcheck init [-force] [dir]") | |||
| 52 | + | fs.PrintDefaults() | |||
| 53 | + | } | |||
| 54 | + | if err := fs.Parse(args); err != nil { | |||
| 55 | + | if errors.Is(err, flag.ErrHelp) { | |||
| 56 | + | return exitOK | |||
| 57 | + | } | |||
| 58 | + | return exitUsage | |||
| 59 | + | } | |||
| 60 | + | if *showVersion { | |||
| 61 | + | fmt.Fprintln(stdout, "hexcheck", version) | |||
| 62 | + | return exitOK | |||
| 63 | + | } | |||
| 64 | + | if *failOn != string(config.SeverityError) && *failOn != string(config.SeverityWarn) { | |||
| 65 | + | fmt.Fprintf(stderr, "hexcheck: -fail-on must be error or warn, got %q\n", *failOn) | |||
| 66 | + | return exitUsage | |||
| 67 | + | } | |||
| 68 | + | patterns := fs.Args() | |||
| 69 | + | if len(patterns) == 0 { | |||
| 70 | + | patterns = []string{"./..."} | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | a := analyzer.New(analyzer.Options{ConfigPath: *configPath, Root: *root, ModulePath: *modulePath}) | |||
| 74 | + | pkgs, err := packages.Load(&packages.Config{Mode: packages.LoadSyntax | packages.NeedModule, Tests: *tests, Dir: *root}, patterns...) | |||
| 75 | + | if err != nil { | |||
| 76 | + | fmt.Fprintf(stderr, "hexcheck: %v\n", err) | |||
| 77 | + | return exitFailure | |||
| 78 | + | } | |||
| 79 | + | if packages.PrintErrors(pkgs) > 0 { | |||
| 80 | + | return exitFailure | |||
| 81 | + | } | |||
| 82 | + | graph, err := checker.Analyze([]*analysis.Analyzer{a}, pkgs, nil) | |||
| 83 | + | if err != nil { | |||
| 84 | + | fmt.Fprintf(stderr, "hexcheck: %v\n", err) | |||
| 85 | + | return exitFailure | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | if *jsonOut { | |||
| 89 | + | if err := graph.PrintJSON(stdout); err != nil { | |||
| 90 | + | return exitFailure | |||
| 91 | + | } | |||
| 92 | + | } else if err := graph.PrintText(stderr, -1); err != nil { | |||
| 93 | + | return exitFailure | |||
| 94 | + | } | |||
| 95 | + | return exitCode(graph, config.Severity(*failOn)) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // exitCode maps diagnostics to an exit code using each rule's configured | |||
| 99 | + | // severity, so warn-level rules do not fail CI unless -fail-on=warn. | |||
| 100 | + | func exitCode(graph *checker.Graph, failOn config.Severity) int { | |||
| 101 | + | code := exitOK | |||
| 102 | + | for act := range graph.All() { | |||
| 103 | + | if act.Err != nil { | |||
| 104 | + | return exitFailure | |||
| 105 | + | } | |||
| 106 | + | if !act.IsRoot { | |||
| 107 | + | continue | |||
| 108 | + | } | |||
| 109 | + | result, _ := act.Result.(*analyzer.Result) | |||
| 110 | + | for _, diag := range act.Diagnostics { | |||
| 111 | + | severity := config.SeverityError | |||
| 112 | + | if diag.Category != analyzer.DirectiveCategory { | |||
| 113 | + | severity = result.Severity(diag.Category) | |||
| 114 | + | } | |||
| 115 | + | if severity == config.SeverityError || (failOn == config.SeverityWarn && severity == config.SeverityWarn) { | |||
| 116 | + | code = exitFindings | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | return code | |||
| 10 | } | 121 | } | ||
A cmd/hexcheck/main_test.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/hexcheck/config" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // writeModule creates a tiny module with one core->adapter import (error rule) | |
| 14 | + | // and one adapter->adapter import (warn rule). | |
| 15 | + | func writeModule(t *testing.T, rules string) string { | |
| 16 | + | t.Helper() | |
| 17 | + | dir := t.TempDir() | |
| 18 | + | files := map[string]string{ | |
| 19 | + | "go.mod": "module example.com/m\n\ngo 1.25\n", | |
| 20 | + | "internal/domain/d.go": "package domain\n\nimport _ \"example.com/m/internal/adapters/db\"\n", | |
| 21 | + | "internal/adapters/db/db.go": "package db\n", | |
| 22 | + | "internal/adapters/web/w.go": "package web\n\nimport _ \"example.com/m/internal/adapters/db\"\n", | |
| 23 | + | ".hexcheck.yaml": `version: 1 | |
| 24 | + | components: | |
| 25 | + | core: { role: core, paths: [internal/domain/**] } | |
| 26 | + | db: { role: adapter, paths: [internal/adapters/db/**] } | |
| 27 | + | web: { role: adapter, paths: [internal/adapters/web/**] } | |
| 28 | + | ` + rules, | |
| 29 | + | } | |
| 30 | + | for name, content := range files { | |
| 31 | + | path := filepath.Join(dir, name) | |
| 32 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | if err := os.WriteFile(path, []byte(content), 0o600); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | return dir | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestRunExitCodes(t *testing.T) { | |
| 43 | + | tests := []struct { | |
| 44 | + | name string | |
| 45 | + | rules string | |
| 46 | + | args []string | |
| 47 | + | wantCode int | |
| 48 | + | wantOut []string | |
| 49 | + | }{ | |
| 50 | + | { | |
| 51 | + | name: "error rule fails", | |
| 52 | + | args: nil, | |
| 53 | + | wantCode: exitFindings, | |
| 54 | + | wantOut: []string{"[error] no-adapter-imports-in-core", "[warn] no-adapter-to-adapter-imports"}, | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | name: "warn only passes by default", | |
| 58 | + | rules: "rules:\n no-adapter-imports-in-core: warn\n", | |
| 59 | + | wantCode: exitOK, | |
| 60 | + | wantOut: []string{"[warn] no-adapter-imports-in-core"}, | |
| 61 | + | }, | |
| 62 | + | { | |
| 63 | + | name: "warn fails with -fail-on=warn", | |
| 64 | + | rules: "rules:\n no-adapter-imports-in-core: warn\n", | |
| 65 | + | args: []string{"-fail-on", "warn"}, | |
| 66 | + | wantCode: exitFindings, | |
| 67 | + | }, | |
| 68 | + | { | |
| 69 | + | name: "invalid config fails", | |
| 70 | + | rules: "rules:\n not-a-rule: error\n", | |
| 71 | + | wantCode: exitFailure, | |
| 72 | + | wantOut: []string{"unknown rule"}, | |
| 73 | + | }, | |
| 74 | + | { | |
| 75 | + | name: "invalid fail-on", | |
| 76 | + | args: []string{"-fail-on", "info"}, | |
| 77 | + | wantCode: exitUsage, | |
| 78 | + | }, | |
| 79 | + | } | |
| 80 | + | for _, tt := range tests { | |
| 81 | + | t.Run(tt.name, func(t *testing.T) { | |
| 82 | + | dir := writeModule(t, tt.rules) | |
| 83 | + | var stdout, stderr bytes.Buffer | |
| 84 | + | args := append([]string{"-root", dir}, tt.args...) | |
| 85 | + | got := run(append(args, "./..."), &stdout, &stderr) | |
| 86 | + | if got != tt.wantCode { | |
| 87 | + | t.Fatalf("exit = %d, want %d\nstderr:\n%s", got, tt.wantCode, stderr.String()) | |
| 88 | + | } | |
| 89 | + | for _, want := range tt.wantOut { | |
| 90 | + | if !strings.Contains(stderr.String(), want) { | |
| 91 | + | t.Fatalf("stderr missing %q:\n%s", want, stderr.String()) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestRunJSON(t *testing.T) { | |
| 99 | + | dir := writeModule(t, "") | |
| 100 | + | var stdout, stderr bytes.Buffer | |
| 101 | + | if got := run([]string{"-root", dir, "-json", "./..."}, &stdout, &stderr); got != exitFindings { | |
| 102 | + | t.Fatalf("exit = %d, want %d\nstderr:\n%s", got, exitFindings, stderr.String()) | |
| 103 | + | } | |
| 104 | + | if !strings.Contains(stdout.String(), `"category": "no-adapter-imports-in-core"`) { | |
| 105 | + | t.Fatalf("JSON output missing category:\n%s", stdout.String()) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestInit(t *testing.T) { | |
| 110 | + | dir := t.TempDir() | |
| 111 | + | for _, sub := range []string{"internal/domain", "internal/adapters", "cmd/app"} { | |
| 112 | + | if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { | |
| 113 | + | t.Fatal(err) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | var stdout, stderr bytes.Buffer | |
| 117 | + | if got := run([]string{"init", dir}, &stdout, &stderr); got != exitOK { | |
| 118 | + | t.Fatalf("init exit = %d\n%s", got, stderr.String()) | |
| 119 | + | } | |
| 120 | + | cfg, err := config.Load(filepath.Join(dir, ".hexcheck.yaml")) | |
| 121 | + | if err != nil { | |
| 122 | + | t.Fatal(err) | |
| 123 | + | } | |
| 124 | + | for name, role := range map[string]config.Role{"core": config.RoleCore, "adapters": config.RoleAdapter, "entrypoints": config.RoleEntrypoint} { | |
| 125 | + | if cfg.Components[name].Role != role { | |
| 126 | + | t.Fatalf("component %s role = %q, want %q", name, cfg.Components[name].Role, role) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | if _, ok := cfg.Components["usecases"]; ok { | |
| 130 | + | t.Fatal("usecases should not be generated without a matching directory") | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | if got := run([]string{"init", dir}, &stdout, &stderr); got != exitFailure { | |
| 134 | + | t.Fatalf("second init exit = %d, want %d", got, exitFailure) | |
| 135 | + | } | |
| 136 | + | if got := run([]string{"init", "-force", dir}, &stdout, &stderr); got != exitOK { | |
| 137 | + | t.Fatalf("init -force exit = %d\n%s", got, stderr.String()) | |
| 138 | + | } | |
| 139 | + | } | |
| @@ -0,0 +1,139 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/hexcheck/config" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // writeModule creates a tiny module with one core->adapter import (error rule) | |||
| 14 | + | // and one adapter->adapter import (warn rule). | |||
| 15 | + | func writeModule(t *testing.T, rules string) string { | |||
| 16 | + | t.Helper() | |||
| 17 | + | dir := t.TempDir() | |||
| 18 | + | files := map[string]string{ | |||
| 19 | + | "go.mod": "module example.com/m\n\ngo 1.25\n", | |||
| 20 | + | "internal/domain/d.go": "package domain\n\nimport _ \"example.com/m/internal/adapters/db\"\n", | |||
| 21 | + | "internal/adapters/db/db.go": "package db\n", | |||
| 22 | + | "internal/adapters/web/w.go": "package web\n\nimport _ \"example.com/m/internal/adapters/db\"\n", | |||
| 23 | + | ".hexcheck.yaml": `version: 1 | |||
| 24 | + | components: | |||
| 25 | + | core: { role: core, paths: [internal/domain/**] } | |||
| 26 | + | db: { role: adapter, paths: [internal/adapters/db/**] } | |||
| 27 | + | web: { role: adapter, paths: [internal/adapters/web/**] } | |||
| 28 | + | ` + rules, | |||
| 29 | + | } | |||
| 30 | + | for name, content := range files { | |||
| 31 | + | path := filepath.Join(dir, name) | |||
| 32 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | if err := os.WriteFile(path, []byte(content), 0o600); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | return dir | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestRunExitCodes(t *testing.T) { | |||
| 43 | + | tests := []struct { | |||
| 44 | + | name string | |||
| 45 | + | rules string | |||
| 46 | + | args []string | |||
| 47 | + | wantCode int | |||
| 48 | + | wantOut []string | |||
| 49 | + | }{ | |||
| 50 | + | { | |||
| 51 | + | name: "error rule fails", | |||
| 52 | + | args: nil, | |||
| 53 | + | wantCode: exitFindings, | |||
| 54 | + | wantOut: []string{"[error] no-adapter-imports-in-core", "[warn] no-adapter-to-adapter-imports"}, | |||
| 55 | + | }, | |||
| 56 | + | { | |||
| 57 | + | name: "warn only passes by default", | |||
| 58 | + | rules: "rules:\n no-adapter-imports-in-core: warn\n", | |||
| 59 | + | wantCode: exitOK, | |||
| 60 | + | wantOut: []string{"[warn] no-adapter-imports-in-core"}, | |||
| 61 | + | }, | |||
| 62 | + | { | |||
| 63 | + | name: "warn fails with -fail-on=warn", | |||
| 64 | + | rules: "rules:\n no-adapter-imports-in-core: warn\n", | |||
| 65 | + | args: []string{"-fail-on", "warn"}, | |||
| 66 | + | wantCode: exitFindings, | |||
| 67 | + | }, | |||
| 68 | + | { | |||
| 69 | + | name: "invalid config fails", | |||
| 70 | + | rules: "rules:\n not-a-rule: error\n", | |||
| 71 | + | wantCode: exitFailure, | |||
| 72 | + | wantOut: []string{"unknown rule"}, | |||
| 73 | + | }, | |||
| 74 | + | { | |||
| 75 | + | name: "invalid fail-on", | |||
| 76 | + | args: []string{"-fail-on", "info"}, | |||
| 77 | + | wantCode: exitUsage, | |||
| 78 | + | }, | |||
| 79 | + | } | |||
| 80 | + | for _, tt := range tests { | |||
| 81 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 82 | + | dir := writeModule(t, tt.rules) | |||
| 83 | + | var stdout, stderr bytes.Buffer | |||
| 84 | + | args := append([]string{"-root", dir}, tt.args...) | |||
| 85 | + | got := run(append(args, "./..."), &stdout, &stderr) | |||
| 86 | + | if got != tt.wantCode { | |||
| 87 | + | t.Fatalf("exit = %d, want %d\nstderr:\n%s", got, tt.wantCode, stderr.String()) | |||
| 88 | + | } | |||
| 89 | + | for _, want := range tt.wantOut { | |||
| 90 | + | if !strings.Contains(stderr.String(), want) { | |||
| 91 | + | t.Fatalf("stderr missing %q:\n%s", want, stderr.String()) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | }) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestRunJSON(t *testing.T) { | |||
| 99 | + | dir := writeModule(t, "") | |||
| 100 | + | var stdout, stderr bytes.Buffer | |||
| 101 | + | if got := run([]string{"-root", dir, "-json", "./..."}, &stdout, &stderr); got != exitFindings { | |||
| 102 | + | t.Fatalf("exit = %d, want %d\nstderr:\n%s", got, exitFindings, stderr.String()) | |||
| 103 | + | } | |||
| 104 | + | if !strings.Contains(stdout.String(), `"category": "no-adapter-imports-in-core"`) { | |||
| 105 | + | t.Fatalf("JSON output missing category:\n%s", stdout.String()) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestInit(t *testing.T) { | |||
| 110 | + | dir := t.TempDir() | |||
| 111 | + | for _, sub := range []string{"internal/domain", "internal/adapters", "cmd/app"} { | |||
| 112 | + | if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { | |||
| 113 | + | t.Fatal(err) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | var stdout, stderr bytes.Buffer | |||
| 117 | + | if got := run([]string{"init", dir}, &stdout, &stderr); got != exitOK { | |||
| 118 | + | t.Fatalf("init exit = %d\n%s", got, stderr.String()) | |||
| 119 | + | } | |||
| 120 | + | cfg, err := config.Load(filepath.Join(dir, ".hexcheck.yaml")) | |||
| 121 | + | if err != nil { | |||
| 122 | + | t.Fatal(err) | |||
| 123 | + | } | |||
| 124 | + | for name, role := range map[string]config.Role{"core": config.RoleCore, "adapters": config.RoleAdapter, "entrypoints": config.RoleEntrypoint} { | |||
| 125 | + | if cfg.Components[name].Role != role { | |||
| 126 | + | t.Fatalf("component %s role = %q, want %q", name, cfg.Components[name].Role, role) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | if _, ok := cfg.Components["usecases"]; ok { | |||
| 130 | + | t.Fatal("usecases should not be generated without a matching directory") | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | if got := run([]string{"init", dir}, &stdout, &stderr); got != exitFailure { | |||
| 134 | + | t.Fatalf("second init exit = %d, want %d", got, exitFailure) | |||
| 135 | + | } | |||
| 136 | + | if got := run([]string{"init", "-force", dir}, &stdout, &stderr); got != exitOK { | |||
| 137 | + | t.Fatalf("init -force exit = %d\n%s", got, stderr.String()) | |||
| 138 | + | } | |||
| 139 | + | } | |||
M test/smoke/smoke_test.go +1 -1
| @@ -28,7 +28,7 @@ func TestLocalRepositorySmoke(t *testing.T) { | |||
| 28 | 28 | t.Fatalf("build hexcheck: %v\n%s", err, output) | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | - | cmd := exec.Command(bin, "-hexcheck.config", filepath.Join(root, "examples", "hexcheck.yaml"), "-hexcheck.root", tt.repo, "./...") | |
| 31 | + | cmd := exec.Command(bin, "-config", filepath.Join(root, "examples", "hexcheck.yaml"), "-root", tt.repo, "./...") | |
| 32 | 32 | cmd.Dir = tt.repo | |
| 33 | 33 | output, err := cmd.CombinedOutput() | |
| 34 | 34 | if err == nil { | |
| @@ -28,7 +28,7 @@ func TestLocalRepositorySmoke(t *testing.T) { | |||||
| 28 | t.Fatalf("build hexcheck: %v\n%s", err, output) | 28 | t.Fatalf("build hexcheck: %v\n%s", err, output) | ||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | - | cmd := exec.Command(bin, "-hexcheck.config", filepath.Join(root, "examples", "hexcheck.yaml"), "-hexcheck.root", tt.repo, "./...") | 31 | + | cmd := exec.Command(bin, "-config", filepath.Join(root, "examples", "hexcheck.yaml"), "-root", tt.repo, "./...") |
| 32 | cmd.Dir = tt.repo | 32 | cmd.Dir = tt.repo | ||
| 33 | output, err := cmd.CombinedOutput() | 33 | output, err := cmd.CombinedOutput() | ||
| 34 | if err == nil { | 34 | if err == nil { | ||