detached e785caa

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
15 package main
26
37 import (
8+ "errors"
9+ "flag"
10+ "fmt"
11+ "io"
12+ "os"
13+
414 "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
627 )
728
29+// version is set at release time with -ldflags "-X main.version=...".
30+var version = "dev"
31+
832 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
10121 }
@@ -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 main5 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) {
2828 t.Fatalf("build hexcheck: %v\n%s", err, output)
2929 }
3030
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, "./...")
3232 cmd.Dir = tt.repo
3333 output, err := cmd.CombinedOutput()
3434 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.repo32 cmd.Dir = tt.repo
33 output, err := cmd.CombinedOutput()33 output, err := cmd.CombinedOutput()
34 if err == nil {34 if err == nil {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 5

↑↓ moveenter openesc close