detached 0796ef4

feat(cli): add standalone runner and smoke harness

commit
0796ef405ddee1310fac10e6c782a1cb2d2597bc
author
brice · · 2026-05-30 04:58 UTC
parent
3e9690d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +202 -6
M analyzer/analyzer.go +86 -6
@@ -4,6 +4,8 @@ import (
44 "go/ast"
55 "go/token"
66 "go/types"
7+ "os"
8+ "path/filepath"
79 "strconv"
810 "strings"
911
@@ -15,23 +17,101 @@ const Name = "hexcheck"
1517
1618 type Options struct {
1719 Config *config.Config
20+ ConfigPath string
21+ Root string
1822 ModulePath string
1923 }
2024
2125 func New(opts Options) *analysis.Analyzer {
22- cfg := opts.Config
23- if cfg == nil {
24- cfg = config.Default()
25- }
26- return &analysis.Analyzer{
26+ var configPathFlag string
27+ var rootFlag string
28+ var modulePathFlag string
29+
30+ a := &analysis.Analyzer{
2731 Name: Name,
2832 Doc: "checks hexagonal architecture boundaries",
2933 Run: func(pass *analysis.Pass) (any, error) {
30- r := runner{pass: pass, cfg: cfg, modulePath: opts.ModulePath}
34+ cfg, root, err := resolveConfig(opts, configPathFlag, rootFlag)
35+ if err != nil {
36+ return nil, err
37+ }
38+ modulePath := firstNonEmpty(opts.ModulePath, modulePathFlag)
39+ if modulePath == "" {
40+ modulePath = discoverModulePath(root)
41+ }
42+ r := runner{pass: pass, cfg: cfg, modulePath: modulePath}
3143 r.run()
3244 return nil, nil
3345 },
3446 }
47+ if opts.Config == nil {
48+ a.Flags.StringVar(&configPathFlag, "config", opts.ConfigPath, "path to .hexcheck.yaml")
49+ a.Flags.StringVar(&rootFlag, "root", opts.Root, "project root for config-relative paths")
50+ a.Flags.StringVar(&modulePathFlag, "module", opts.ModulePath, "Go module path; defaults to module in go.mod")
51+ }
52+ return a
53+}
54+
55+func resolveConfig(opts Options, configPathFlag, rootFlag string) (*config.Config, string, error) {
56+ if opts.Config != nil {
57+ return opts.Config, opts.Config.Root, nil
58+ }
59+ root := firstNonEmpty(opts.Root, rootFlag)
60+ configPath := firstNonEmpty(opts.ConfigPath, configPathFlag)
61+ if configPath == "" {
62+ start := root
63+ if start == "" {
64+ start = "."
65+ }
66+ discovered, err := config.DiscoverConfig(start)
67+ if err == nil {
68+ configPath = discovered
69+ }
70+ }
71+ if configPath == "" {
72+ cfg := config.Default()
73+ if root != "" {
74+ cfg.Root = root
75+ }
76+ return cfg, cfg.Root, nil
77+ }
78+ if root != "" && !filepath.IsAbs(configPath) {
79+ configPath = filepath.Join(root, configPath)
80+ }
81+ cfg, err := config.Load(configPath)
82+ if err != nil {
83+ return nil, "", err
84+ }
85+ if root != "" {
86+ cfg.Root = root
87+ }
88+ return cfg, cfg.Root, nil
89+}
90+
91+func discoverModulePath(root string) string {
92+ if root == "" {
93+ root = "."
94+ }
95+ data, err := os.ReadFile(filepath.Join(root, "go.mod"))
96+ if err != nil {
97+ return ""
98+ }
99+ for _, line := range strings.Split(string(data), "\n") {
100+ line = strings.TrimSpace(line)
101+ if strings.HasPrefix(line, "module ") {
102+ return strings.TrimSpace(strings.TrimPrefix(line, "module "))
103+ }
104+ }
105+ return ""
106+}
107+
108+func firstNonEmpty(values ...string) string {
109+ for _, value := range values {
110+ if value != "" {
111+ return value
112+ }
113+ }
114+ return ""
35115 }
36116
37117 type runner struct {
@@ -4,6 +4,8 @@ import (
4 "go/ast"4 "go/ast"
5 "go/token"5 "go/token"
6 "go/types"6 "go/types"
7+ "os"
8+ "path/filepath"
7 "strconv"9 "strconv"
8 "strings"10 "strings"
9 11
@@ -15,23 +17,101 @@ const Name = "hexcheck"
15 17
16 type Options struct {18 type Options struct {
17 Config *config.Config19 Config *config.Config
20+ ConfigPath string
21+ Root string
18 ModulePath string22 ModulePath string
19 }23 }
20 24
21 func New(opts Options) *analysis.Analyzer {25 func New(opts Options) *analysis.Analyzer {
22- cfg := opts.Config26+ var configPathFlag string
23- if cfg == nil {27+ var rootFlag string
24- cfg = config.Default()28+ var modulePathFlag string
25- }29+
26- return &analysis.Analyzer{30+ a := &analysis.Analyzer{
27 Name: Name,31 Name: Name,
28 Doc: "checks hexagonal architecture boundaries",32 Doc: "checks hexagonal architecture boundaries",
29 Run: func(pass *analysis.Pass) (any, error) {33 Run: func(pass *analysis.Pass) (any, error) {
30- r := runner{pass: pass, cfg: cfg, modulePath: opts.ModulePath}34+ cfg, root, err := resolveConfig(opts, configPathFlag, rootFlag)
35+ if err != nil {
36+ return nil, err
37+ }
38+ modulePath := firstNonEmpty(opts.ModulePath, modulePathFlag)
39+ if modulePath == "" {
40+ modulePath = discoverModulePath(root)
41+ }
42+ r := runner{pass: pass, cfg: cfg, modulePath: modulePath}
31 r.run()43 r.run()
32 return nil, nil44 return nil, nil
33 },45 },
34 }46 }
47+ if opts.Config == nil {
48+ a.Flags.StringVar(&configPathFlag, "config", opts.ConfigPath, "path to .hexcheck.yaml")
49+ a.Flags.StringVar(&rootFlag, "root", opts.Root, "project root for config-relative paths")
50+ a.Flags.StringVar(&modulePathFlag, "module", opts.ModulePath, "Go module path; defaults to module in go.mod")
51+ }
52+ return a
53+}
54+
55+func resolveConfig(opts Options, configPathFlag, rootFlag string) (*config.Config, string, error) {
56+ if opts.Config != nil {
57+ return opts.Config, opts.Config.Root, nil
58+ }
59+ root := firstNonEmpty(opts.Root, rootFlag)
60+ configPath := firstNonEmpty(opts.ConfigPath, configPathFlag)
61+ if configPath == "" {
62+ start := root
63+ if start == "" {
64+ start = "."
65+ }
66+ discovered, err := config.DiscoverConfig(start)
67+ if err == nil {
68+ configPath = discovered
69+ }
70+ }
71+ if configPath == "" {
72+ cfg := config.Default()
73+ if root != "" {
74+ cfg.Root = root
75+ }
76+ return cfg, cfg.Root, nil
77+ }
78+ if root != "" && !filepath.IsAbs(configPath) {
79+ configPath = filepath.Join(root, configPath)
80+ }
81+ cfg, err := config.Load(configPath)
82+ if err != nil {
83+ return nil, "", err
84+ }
85+ if root != "" {
86+ cfg.Root = root
87+ }
88+ return cfg, cfg.Root, nil
89+}
90+
91+func discoverModulePath(root string) string {
92+ if root == "" {
93+ root = "."
94+ }
95+ data, err := os.ReadFile(filepath.Join(root, "go.mod"))
96+ if err != nil {
97+ return ""
98+ }
99+ for _, line := range strings.Split(string(data), "\n") {
100+ line = strings.TrimSpace(line)
101+ if strings.HasPrefix(line, "module ") {
102+ return strings.TrimSpace(strings.TrimPrefix(line, "module "))
103+ }
104+ }
105+ return ""
106+}
107+
108+func firstNonEmpty(values ...string) string {
109+ for _, value := range values {
110+ if value != "" {
111+ return value
112+ }
113+ }
114+ return ""
35 }115 }
36 116
37 type runner struct {117 type runner struct {
A cmd/hexcheck/main.go +10 -0
@@ -0,0 +1,10 @@
1+package main
2+
3+import (
4+ "github.com/bnema/hexcheck/analyzer"
5+ "golang.org/x/tools/go/analysis/multichecker"
6+)
7+
8+func main() {
9+ multichecker.Main(analyzer.New(analyzer.Options{}))
10+}
@@ -0,0 +1,10 @@
1+package main
2+
3+import (
4+ "github.com/bnema/hexcheck/analyzer"
5+ "golang.org/x/tools/go/analysis/multichecker"
6+)
7+
8+func main() {
9+ multichecker.Main(analyzer.New(analyzer.Options{}))
10+}
A examples/hexcheck.yaml +66 -0
@@ -0,0 +1,66 @@
1+version: 1
2+components:
3+ core:
4+ role: core
5+ paths:
6+ - internal/domain/**
7+ - internal/core/**
8+ usecase:
9+ role: usecase
10+ paths:
11+ - internal/application/usecase/**
12+ ports:
13+ role: ports
14+ paths:
15+ - internal/application/port/**
16+ adapters:
17+ role: adapter
18+ paths:
19+ - internal/infrastructure/**
20+ - internal/adapters/**
21+ - internal/ui/**
22+ - internal/cli/**
23+ entrypoints:
24+ role: entrypoint
25+ paths:
26+ - cmd/**
27+ generated:
28+ role: ignore
29+ paths:
30+ - '**/mocks/**'
31+ - '**/generated/**'
32+
33+rules:
34+ no-adapter-imports-in-core: error
35+ no-infra-imports-in-usecase: error
36+ no-framework-types-in-core: error
37+ no-infra-types-in-ports: error
38+ no-adapter-to-adapter-imports: warn
39+ suspicious-business-logic-in-adapter: warn
40+ no-local-fakes-for-ports: warn
41+ prefer-generated-mocks: warn
42+
43+externalTypes:
44+ frameworkPackages: []
45+ adapterTypePackages:
46+ - internal/infrastructure/**
47+ - internal/adapters/**
48+
49+heuristics:
50+ businessLogicThreshold: 4
51+ businessKeywords:
52+ - Validate
53+ - Authorize
54+ - Compute
55+ - Calculate
56+ - Apply
57+ - Transition
58+ - Can
59+
60+mocking:
61+ generatedMockPaths:
62+ - internal/mocks/**
63+ - internal/application/mocks/**
64+ generatedMockNamePatterns:
65+ - Mock{{Interface}}
66+ - '{{Interface}}Mock'
@@ -0,0 +1,66 @@
1+version: 1
2+components:
3+ core:
4+ role: core
5+ paths:
6+ - internal/domain/**
7+ - internal/core/**
8+ usecase:
9+ role: usecase
10+ paths:
11+ - internal/application/usecase/**
12+ ports:
13+ role: ports
14+ paths:
15+ - internal/application/port/**
16+ adapters:
17+ role: adapter
18+ paths:
19+ - internal/infrastructure/**
20+ - internal/adapters/**
21+ - internal/ui/**
22+ - internal/cli/**
23+ entrypoints:
24+ role: entrypoint
25+ paths:
26+ - cmd/**
27+ generated:
28+ role: ignore
29+ paths:
30+ - '**/mocks/**'
31+ - '**/generated/**'
32+
33+rules:
34+ no-adapter-imports-in-core: error
35+ no-infra-imports-in-usecase: error
36+ no-framework-types-in-core: error
37+ no-infra-types-in-ports: error
38+ no-adapter-to-adapter-imports: warn
39+ suspicious-business-logic-in-adapter: warn
40+ no-local-fakes-for-ports: warn
41+ prefer-generated-mocks: warn
42+
43+externalTypes:
44+ frameworkPackages: []
45+ adapterTypePackages:
46+ - internal/infrastructure/**
47+ - internal/adapters/**
48+
49+heuristics:
50+ businessLogicThreshold: 4
51+ businessKeywords:
52+ - Validate
53+ - Authorize
54+ - Compute
55+ - Calculate
56+ - Apply
57+ - Transition
58+ - Can
59+
60+mocking:
61+ generatedMockPaths:
62+ - internal/mocks/**
63+ - internal/application/mocks/**
64+ generatedMockNamePatterns:
65+ - Mock{{Interface}}
66+ - '{{Interface}}Mock'
A test/smoke/smoke_test.go +40 -0
@@ -0,0 +1,40 @@
1+package smoke
2+
3+import (
4+ "os"
5+ "os/exec"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestLocalRepositorySmoke(t *testing.T) {
11+ repo := os.Getenv("HEXCHECK_SMOKE_REPO")
12+ if repo == "" {
13+ t.Skip("HEXCHECK_SMOKE_REPO is not set")
14+ }
15+ root := projectRoot(t)
16+ cmd := exec.Command("go", "run", "./cmd/hexcheck", "-hexcheck.config", filepath.Join(root, "examples", "hexcheck.yaml"), "-hexcheck.root", repo, "./...")
17+ cmd.Dir = repo
18+ output, err := cmd.CombinedOutput()
19+ if err != nil {
20+ t.Fatalf("hexcheck smoke command failed: %v\n%s", err, output)
21+ }
22+}
23+
24+func projectRoot(t *testing.T) string {
25+ t.Helper()
26+ dir, err := os.Getwd()
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ for {
31+ if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
32+ return dir
33+ }
34+ parent := filepath.Dir(dir)
35+ if parent == dir {
36+ t.Fatal("go.mod not found")
37+ }
38+ dir = parent
39+ }
40+}
@@ -0,0 +1,40 @@
1+package smoke
2+
3+import (
4+ "os"
5+ "os/exec"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestLocalRepositorySmoke(t *testing.T) {
11+ repo := os.Getenv("HEXCHECK_SMOKE_REPO")
12+ if repo == "" {
13+ t.Skip("HEXCHECK_SMOKE_REPO is not set")
14+ }
15+ root := projectRoot(t)
16+ cmd := exec.Command("go", "run", "./cmd/hexcheck", "-hexcheck.config", filepath.Join(root, "examples", "hexcheck.yaml"), "-hexcheck.root", repo, "./...")
17+ cmd.Dir = repo
18+ output, err := cmd.CombinedOutput()
19+ if err != nil {
20+ t.Fatalf("hexcheck smoke command failed: %v\n%s", err, output)
21+ }
22+}
23+
24+func projectRoot(t *testing.T) string {
25+ t.Helper()
26+ dir, err := os.Getwd()
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ for {
31+ if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
32+ return dir
33+ }
34+ parent := filepath.Dir(dir)
35+ if parent == dir {
36+ t.Fatal("go.mod not found")
37+ }
38+ dir = parent
39+ }
40+}

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