detached 6f2b250

Merge pull request 'harden boundary rules, severity-aware CLI and Forgejo CI' (#1) from feat/hardening-and-dx into main

Reviewed-on: https://git.bnema.dev/bnema/hexcheck/pulls/1
commit
6f2b2506cbb9f4657a4c89e3faaa7623667ebca9
author
bnema · · 2026-10-08 05:47 UTC
merge of
92ac699 + 04ad7a9 · diff against the first parent
signature
unsigned
31 files · +1005 -93
A .forgejo/workflows/ci.yml +39 -0
@@ -0,0 +1,39 @@
1+# CI for the Forgejo runner. Forgejo reads this directory instead of
2+# .github/workflows, so the GitHub mirror keeps its own CI.
3+name: CI
4+
5+on:
6+ push:
7+ branches: [main]
8+ pull_request:
9+
10+jobs:
11+ lint:
12+ name: Lint
13+ runs-on: ubuntu-latest
14+ steps:
15+ - uses: actions/checkout@v6
16+ - uses: actions/setup-go@v5
17+ with:
18+ go-version-file: go.mod
19+ cache: true
20+ - uses: golangci/golangci-lint-action@v9
21+ with:
22+ version: v2.14.0
23+
24+ test:
25+ name: Test
26+ runs-on: ubuntu-latest
27+ steps:
28+ - uses: actions/checkout@v6
29+ - uses: actions/setup-go@v5
30+ with:
31+ go-version-file: go.mod
32+ cache: true
33+ - name: Verify tidy
34+ run: |
35+ go mod tidy
36+ git diff --exit-code -- go.mod go.sum
37+ - run: go test -race ./...
38+ - name: Self-check
39+ run: go run ./cmd/hexcheck -fail-on warn ./...
@@ -0,0 +1,39 @@
1+# CI for the Forgejo runner. Forgejo reads this directory instead of
2+# .github/workflows, so the GitHub mirror keeps its own CI.
3+name: CI
4+
5+on:
6+ push:
7+ branches: [main]
8+ pull_request:
9+
10+jobs:
11+ lint:
12+ name: Lint
13+ runs-on: ubuntu-latest
14+ steps:
15+ - uses: actions/checkout@v6
16+ - uses: actions/setup-go@v5
17+ with:
18+ go-version-file: go.mod
19+ cache: true
20+ - uses: golangci/golangci-lint-action@v9
21+ with:
22+ version: v2.14.0
23+
24+ test:
25+ name: Test
26+ runs-on: ubuntu-latest
27+ steps:
28+ - uses: actions/checkout@v6
29+ - uses: actions/setup-go@v5
30+ with:
31+ go-version-file: go.mod
32+ cache: true
33+ - name: Verify tidy
34+ run: |
35+ go mod tidy
36+ git diff --exit-code -- go.mod go.sum
37+ - run: go test -race ./...
38+ - name: Self-check
39+ run: go run ./cmd/hexcheck -fail-on warn ./...
M .github/workflows/ci.yml +14 -1
@@ -6,6 +6,17 @@ on:
66 pull_request:
77
88 jobs:
9+ lint:
10+ runs-on: ubuntu-latest
11+ steps:
12+ - uses: actions/checkout@v4
13+ - uses: actions/setup-go@v5
14+ with:
15+ go-version-file: go.mod
16+ - uses: golangci/golangci-lint-action@v8
17+ with:
18+ version: v2.14.0
19+
920 test:
1021 runs-on: ubuntu-latest
1122 steps:
@@ -18,4 +29,6 @@ jobs:
1829 go mod tidy
1930 git diff --exit-code -- go.mod go.sum
2031 - name: Test
21- run: go test ./...
32+ run: go test -race ./...
33+ - name: Self-check
34+ run: go run ./cmd/hexcheck -fail-on warn ./...
@@ -6,6 +6,17 @@ on:
6 pull_request:6 pull_request:
7 7
8 jobs:8 jobs:
9+ lint:
10+ runs-on: ubuntu-latest
11+ steps:
12+ - uses: actions/checkout@v4
13+ - uses: actions/setup-go@v5
14+ with:
15+ go-version-file: go.mod
16+ - uses: golangci/golangci-lint-action@v8
17+ with:
18+ version: v2.14.0
19+
9 test:20 test:
10 runs-on: ubuntu-latest21 runs-on: ubuntu-latest
11 steps:22 steps:
@@ -18,4 +29,6 @@ jobs:
18 go mod tidy29 go mod tidy
19 git diff --exit-code -- go.mod go.sum30 git diff --exit-code -- go.mod go.sum
20 - name: Test31 - name: Test
21- run: go test ./...32+ run: go test -race ./...
33+ - name: Self-check
34+ run: go run ./cmd/hexcheck -fail-on warn ./...
A .github/workflows/release.yml +28 -0
@@ -0,0 +1,28 @@
1+name: Release
2+
3+on:
4+ push:
5+ tags:
6+ - 'v*'
7+
8+permissions:
9+ contents: write
10+
11+jobs:
12+ release:
13+ runs-on: ubuntu-latest
14+ steps:
15+ - uses: actions/checkout@v4
16+ with:
17+ fetch-depth: 0
18+ persist-credentials: false
19+ - uses: actions/setup-go@v5
20+ with:
21+ go-version-file: go.mod
22+ - uses: goreleaser/goreleaser-action@v6
23+ with:
24+ distribution: goreleaser
25+ version: "~> v2"
26+ args: release --clean
27+ env:
28+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,28 @@
1+name: Release
2+
3+on:
4+ push:
5+ tags:
6+ - 'v*'
7+
8+permissions:
9+ contents: write
10+
11+jobs:
12+ release:
13+ runs-on: ubuntu-latest
14+ steps:
15+ - uses: actions/checkout@v4
16+ with:
17+ fetch-depth: 0
18+ persist-credentials: false
19+ - uses: actions/setup-go@v5
20+ with:
21+ go-version-file: go.mod
22+ - uses: goreleaser/goreleaser-action@v6
23+ with:
24+ distribution: goreleaser
25+ version: "~> v2"
26+ args: release --clean
27+ env:
28+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
M .gitignore +2 -0
@@ -2,3 +2,5 @@ bin/
22 custom-gcl
33 coverage.out
44 .DS_Store
5+.worktrees/
6+dist/
@@ -2,3 +2,5 @@ bin/
2 custom-gcl2 custom-gcl
3 coverage.out3 coverage.out
4 .DS_Store4 .DS_Store
5+.worktrees/
6+dist/
A .golangci.yml +24 -0
@@ -0,0 +1,24 @@
1+version: "2"
2+
3+linters:
4+ enable:
5+ - errcheck
6+ - govet
7+ - ineffassign
8+ - misspell
9+ - staticcheck
10+ - unconvert
11+ - unused
12+ settings:
13+ errcheck:
14+ # CLI writes to stdout/stderr; a failed write has no useful recovery.
15+ exclude-functions:
16+ - fmt.Fprintf
17+ - fmt.Fprintln
18+ exclusions:
19+ paths:
20+ - analyzer/testdata
21+ rules:
22+ - path: _test\.go
23+ linters:
24+ - errcheck
@@ -0,0 +1,24 @@
1+version: "2"
2+
3+linters:
4+ enable:
5+ - errcheck
6+ - govet
7+ - ineffassign
8+ - misspell
9+ - staticcheck
10+ - unconvert
11+ - unused
12+ settings:
13+ errcheck:
14+ # CLI writes to stdout/stderr; a failed write has no useful recovery.
15+ exclude-functions:
16+ - fmt.Fprintf
17+ - fmt.Fprintln
18+ exclusions:
19+ paths:
20+ - analyzer/testdata
21+ rules:
22+ - path: _test\.go
23+ linters:
24+ - errcheck
A .goreleaser.yaml +34 -0
@@ -0,0 +1,34 @@
1+version: 2
2+
3+release:
4+ draft: true
5+ prerelease: auto
6+
7+builds:
8+ - id: hexcheck
9+ main: ./cmd/hexcheck
10+ binary: hexcheck
11+ goos: [linux, darwin, windows]
12+ goarch: [amd64, arm64]
13+ env:
14+ - CGO_ENABLED=0
15+ ldflags:
16+ - -s -w -X main.version={{.Version}}
17+
18+archives:
19+ - name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
20+ formats: [tar.gz]
21+ format_overrides:
22+ - goos: windows
23+ formats: [zip]
24+
25+checksum:
26+ name_template: checksums.txt
27+
28+changelog:
29+ sort: asc
30+ filters:
31+ exclude:
32+ - "^docs"
33+ - "^test"
34+ - "^chore"
@@ -0,0 +1,34 @@
1+version: 2
2+
3+release:
4+ draft: true
5+ prerelease: auto
6+
7+builds:
8+ - id: hexcheck
9+ main: ./cmd/hexcheck
10+ binary: hexcheck
11+ goos: [linux, darwin, windows]
12+ goarch: [amd64, arm64]
13+ env:
14+ - CGO_ENABLED=0
15+ ldflags:
16+ - -s -w -X main.version={{.Version}}
17+
18+archives:
19+ - name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
20+ formats: [tar.gz]
21+ format_overrides:
22+ - goos: windows
23+ formats: [zip]
24+
25+checksum:
26+ name_template: checksums.txt
27+
28+changelog:
29+ sort: asc
30+ filters:
31+ exclude:
32+ - "^docs"
33+ - "^test"
34+ - "^chore"
A .hexcheck.yaml +23 -0
@@ -0,0 +1,23 @@
1+# hexcheck checks its own layering: config is the core model, analyzer
2+# applies it, and cmd/golangci are the entrypoints.
3+version: 1
4+components:
5+ core:
6+ role: core
7+ paths:
8+ - config/**
9+ - internal/glob/**
10+ analyzer:
11+ role: usecase
12+ paths:
13+ - analyzer/**
14+ entrypoints:
15+ role: entrypoint
16+ paths:
17+ - cmd/**
18+ - golangci/**
19+ ignored:
20+ role: ignore
21+ paths:
22+ - analyzer/testdata/**
23+ - test/**
@@ -0,0 +1,23 @@
1+# hexcheck checks its own layering: config is the core model, analyzer
2+# applies it, and cmd/golangci are the entrypoints.
3+version: 1
4+components:
5+ core:
6+ role: core
7+ paths:
8+ - config/**
9+ - internal/glob/**
10+ analyzer:
11+ role: usecase
12+ paths:
13+ - analyzer/**
14+ entrypoints:
15+ role: entrypoint
16+ paths:
17+ - cmd/**
18+ - golangci/**
19+ ignored:
20+ role: ignore
21+ paths:
22+ - analyzer/testdata/**
23+ - test/**
M CHANGELOG.md +23 -0
@@ -1,5 +1,28 @@
11 # Changelog
22
3+## Unreleased
4+
5+### Breaking
6+
7+- The CLI uses plain flags: `-config`, `-root`, `-module` replace `-hexcheck.config`, `-hexcheck.root`, `-hexcheck.module`.
8+- The config is strict: unknown keys and unknown rule names in `rules`, `ruleSettings`, and `allow` are errors.
9+- Diagnostic messages are prefixed with their severity, e.g. `[error] no-adapter-imports-in-core: ...`.
10+
11+### Added
12+
13+- Rules `no-usecase-imports-in-core`, `no-infra-imports-in-ports`, and `no-entrypoint-imports-in-adapter`. `no-infra-imports-in-usecase` also covers entrypoint imports.
14+- Rule severity drives the CLI exit code: only `error` rules fail by default; `-fail-on warn` also fails on warnings.
15+- `//hexcheck:ignore <rule>[,<rule>] <reason>` inline suppression.
16+- `hexcheck init` writes a starter `.hexcheck.yaml` from detected folders.
17+- `-json` and `-version` CLI flags.
18+- Type-leak rules inspect channels, function signatures, inline structs, aliases, and generic type arguments.
19+- Release builds with GoReleaser.
20+
21+### Fixed
22+
23+- The config is loaded once per run instead of once per package.
24+- The generated-mock index is rebuilt on each run instead of being cached for the process lifetime.
25+
326 ## v0.1.0
427
528 Initial release of `hexcheck`.
@@ -1,5 +1,28 @@
1 # Changelog1 # Changelog
2 2
3+## Unreleased
4+
5+### Breaking
6+
7+- The CLI uses plain flags: `-config`, `-root`, `-module` replace `-hexcheck.config`, `-hexcheck.root`, `-hexcheck.module`.
8+- The config is strict: unknown keys and unknown rule names in `rules`, `ruleSettings`, and `allow` are errors.
9+- Diagnostic messages are prefixed with their severity, e.g. `[error] no-adapter-imports-in-core: ...`.
10+
11+### Added
12+
13+- Rules `no-usecase-imports-in-core`, `no-infra-imports-in-ports`, and `no-entrypoint-imports-in-adapter`. `no-infra-imports-in-usecase` also covers entrypoint imports.
14+- Rule severity drives the CLI exit code: only `error` rules fail by default; `-fail-on warn` also fails on warnings.
15+- `//hexcheck:ignore <rule>[,<rule>] <reason>` inline suppression.
16+- `hexcheck init` writes a starter `.hexcheck.yaml` from detected folders.
17+- `-json` and `-version` CLI flags.
18+- Type-leak rules inspect channels, function signatures, inline structs, aliases, and generic type arguments.
19+- Release builds with GoReleaser.
20+
21+### Fixed
22+
23+- The config is loaded once per run instead of once per package.
24+- The generated-mock index is rebuilt on each run instead of being cached for the process lifetime.
25+
3 ## v0.1.026 ## v0.1.0
4 27
5 Initial release of `hexcheck`.28 Initial release of `hexcheck`.
M Makefile +6 -3
@@ -1,7 +1,7 @@
1-.PHONY: test lint tidy check smoke-local
1+.PHONY: test lint tidy self-check check smoke-local
22
33 test:
4- go test ./...
4+ go test -race ./...
55
66 lint:
77 golangci-lint run ./...
@@ -9,7 +9,10 @@ lint:
99 tidy:
1010 go mod tidy
1111
12-check: tidy test
12+self-check:
13+ go run ./cmd/hexcheck -fail-on warn ./...
14+
15+check: tidy lint test self-check
1316
1417 smoke-local:
1518 @if [ -z "$(HEXCHECK_SMOKE_REPO)" ]; then \
@@ -1,7 +1,7 @@
1-.PHONY: test lint tidy check smoke-local1+.PHONY: test lint tidy self-check check smoke-local
2 2
3 test:3 test:
4- go test ./...4+ go test -race ./...
5 5
6 lint:6 lint:
7 golangci-lint run ./...7 golangci-lint run ./...
@@ -9,7 +9,10 @@ lint:
9 tidy:9 tidy:
10 go mod tidy10 go mod tidy
11 11
12-check: tidy test12+self-check:
13+ go run ./cmd/hexcheck -fail-on warn ./...
14+
15+check: tidy lint test self-check
13 16
14 smoke-local:17 smoke-local:
15 @if [ -z "$(HEXCHECK_SMOKE_REPO)" ]; then \18 @if [ -z "$(HEXCHECK_SMOKE_REPO)" ]; then \
M README.md +55 -8
@@ -66,7 +66,10 @@ components:
6666
6767 rules:
6868 no-adapter-imports-in-core: error
69+ no-usecase-imports-in-core: error
6970 no-infra-imports-in-usecase: error
71+ no-infra-imports-in-ports: error
72+ no-entrypoint-imports-in-adapter: error
7073 no-framework-types-in-core: error
7174 no-infra-types-in-ports: error
7275 no-adapter-to-adapter-imports: warn
@@ -99,20 +102,62 @@ A fuller example lives in [`examples/hexcheck.yaml`](examples/hexcheck.yaml).
99102
100103 Business-logic diagnostics include a confidence level. `audit` mode reports findings at or above `businessLogicMinConfidence`; `ci` mode reports only high-confidence findings.
101104
102-## Standalone CLI
105+## Rules
103106
104-Because `hexcheck` is a `go/analysis` analyzer, standalone flags use the analyzer prefix:
107+| Rule | Default | Reports |
108+| --- | --- | --- |
109+| `no-adapter-imports-in-core` | error | core imports an adapter or entrypoint |
110+| `no-usecase-imports-in-core` | error | core imports a usecase |
111+| `no-infra-imports-in-usecase` | error | usecase imports an adapter or entrypoint |
112+| `no-infra-imports-in-ports` | error | ports import an adapter or entrypoint |
113+| `no-entrypoint-imports-in-adapter` | error | adapter imports an entrypoint |
114+| `no-framework-types-in-core` | error | exported core API exposes an adapter or framework type |
115+| `no-infra-types-in-ports` | error | port interface method exposes an adapter or framework type |
116+| `no-adapter-to-adapter-imports` | warn | adapter imports another adapter component |
117+| `suspicious-business-logic-in-adapter` | warn | adapter or entrypoint function looks like business logic |
118+| `no-local-fakes-for-ports` | warn | test defines a fake for a port that has a generated mock |
119+| `missing-generated-mock-for-port` | warn | port interface has no generated mock |
120+| `prefer-generated-mocks` | warn | usecase/core test imports a concrete adapter |
105121
106-```bash
107-hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
122+Type-leak rules look through pointers, slices, maps, channels, function signatures, inline structs, aliases, and generic type arguments.
123+
124+The config is strict: unknown keys and unknown rule names are errors.
125+
126+## Suppressing a finding
127+
128+Prefer config `allow` entries for whole files or directories. For one line, use an inline directive with a reason:
129+
130+```go
131+type Rows interface {
132+ //hexcheck:ignore no-infra-types-in-ports row type is part of the published contract
133+ Next() sql.Row
134+}
108135 ```
109136
110-During development:
137+The directive covers its own line and the next one. It accepts a comma-separated rule list. A directive without a reason, or with an unknown rule, is reported as `invalid-ignore-directive`.
138+
139+## Standalone CLI
111140
112141 ```bash
113-go run ./cmd/hexcheck -hexcheck.config examples/hexcheck.yaml -hexcheck.root . ./...
142+hexcheck init # write a starter .hexcheck.yaml from detected folders
143+hexcheck ./... # check; config is discovered from the root upwards
144+hexcheck -config .hexcheck.yaml -root . ./...
114145 ```
115146
147+| Flag | Default | Meaning |
148+| --- | --- | --- |
149+| `-config` | discovered | path to `.hexcheck.yaml` |
150+| `-root` | current dir | project root for config-relative paths |
151+| `-module` | from `go.mod` | Go module path |
152+| `-fail-on` | `error` | lowest severity that fails: `error` or `warn` |
153+| `-json` | false | JSON output on stdout |
154+| `-test` | true | also analyze test files |
155+| `-version` | | print the version |
156+
157+Each finding is prefixed with its severity, e.g. `[warn] no-adapter-to-adapter-imports: ...`.
158+
159+Exit codes: `0` no failing findings, `1` load/config/analysis error, `2` usage error, `3` findings at or above `-fail-on`.
160+
116161 ## golangci-lint module plugin
117162
118163 Build a custom golangci-lint binary using the module plugin system:
@@ -149,6 +194,8 @@ linters:
149194 config: .hexcheck.yaml
150195 ```
151196
197+golangci-lint reports every hexcheck finding as an issue, whatever its configured severity. To fail only on `error` rules, set the warn rules to `off` in `.hexcheck.yaml`, or exclude issues whose text matches `^\[warn\]` with `linters.exclusions.rules`.
198+
152199 ## Agent configuration guide
153200
154201 [`SKILL.md`](SKILL.md) explains how an agent should configure `hexcheck` for a new repository, including non-standard layouts such as `core`/`boundaries`.
@@ -156,7 +203,7 @@ linters:
156203 ## Local development
157204
158205 ```bash
159-make test
160-make check
206+make test # go test -race
207+make check # tidy, lint, test, and hexcheck on its own code
161208 HEXCHECK_SMOKE_REPO=/path/to/local/go/repo make smoke-local
162209 ```
@@ -66,7 +66,10 @@ components:
66 66
67 rules:67 rules:
68 no-adapter-imports-in-core: error68 no-adapter-imports-in-core: error
69+ no-usecase-imports-in-core: error
69 no-infra-imports-in-usecase: error70 no-infra-imports-in-usecase: error
71+ no-infra-imports-in-ports: error
72+ no-entrypoint-imports-in-adapter: error
70 no-framework-types-in-core: error73 no-framework-types-in-core: error
71 no-infra-types-in-ports: error74 no-infra-types-in-ports: error
72 no-adapter-to-adapter-imports: warn75 no-adapter-to-adapter-imports: warn
@@ -99,20 +102,62 @@ A fuller example lives in [`examples/hexcheck.yaml`](examples/hexcheck.yaml).
99 102
100 Business-logic diagnostics include a confidence level. `audit` mode reports findings at or above `businessLogicMinConfidence`; `ci` mode reports only high-confidence findings.103 Business-logic diagnostics include a confidence level. `audit` mode reports findings at or above `businessLogicMinConfidence`; `ci` mode reports only high-confidence findings.
101 104
102-## Standalone CLI105+## Rules
103 106
104-Because `hexcheck` is a `go/analysis` analyzer, standalone flags use the analyzer prefix:107+| Rule | Default | Reports |
108+| --- | --- | --- |
109+| `no-adapter-imports-in-core` | error | core imports an adapter or entrypoint |
110+| `no-usecase-imports-in-core` | error | core imports a usecase |
111+| `no-infra-imports-in-usecase` | error | usecase imports an adapter or entrypoint |
112+| `no-infra-imports-in-ports` | error | ports import an adapter or entrypoint |
113+| `no-entrypoint-imports-in-adapter` | error | adapter imports an entrypoint |
114+| `no-framework-types-in-core` | error | exported core API exposes an adapter or framework type |
115+| `no-infra-types-in-ports` | error | port interface method exposes an adapter or framework type |
116+| `no-adapter-to-adapter-imports` | warn | adapter imports another adapter component |
117+| `suspicious-business-logic-in-adapter` | warn | adapter or entrypoint function looks like business logic |
118+| `no-local-fakes-for-ports` | warn | test defines a fake for a port that has a generated mock |
119+| `missing-generated-mock-for-port` | warn | port interface has no generated mock |
120+| `prefer-generated-mocks` | warn | usecase/core test imports a concrete adapter |
105 121
106-```bash122+Type-leak rules look through pointers, slices, maps, channels, function signatures, inline structs, aliases, and generic type arguments.
107-hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...123+
124+The config is strict: unknown keys and unknown rule names are errors.
125+
126+## Suppressing a finding
127+
128+Prefer config `allow` entries for whole files or directories. For one line, use an inline directive with a reason:
129+
130+```go
131+type Rows interface {
132+ //hexcheck:ignore no-infra-types-in-ports row type is part of the published contract
133+ Next() sql.Row
134+}
108 ```135 ```
109 136
110-During development:137+The directive covers its own line and the next one. It accepts a comma-separated rule list. A directive without a reason, or with an unknown rule, is reported as `invalid-ignore-directive`.
138+
139+## Standalone CLI
111 140
112 ```bash141 ```bash
113-go run ./cmd/hexcheck -hexcheck.config examples/hexcheck.yaml -hexcheck.root . ./...142+hexcheck init # write a starter .hexcheck.yaml from detected folders
143+hexcheck ./... # check; config is discovered from the root upwards
144+hexcheck -config .hexcheck.yaml -root . ./...
114 ```145 ```
115 146
147+| Flag | Default | Meaning |
148+| --- | --- | --- |
149+| `-config` | discovered | path to `.hexcheck.yaml` |
150+| `-root` | current dir | project root for config-relative paths |
151+| `-module` | from `go.mod` | Go module path |
152+| `-fail-on` | `error` | lowest severity that fails: `error` or `warn` |
153+| `-json` | false | JSON output on stdout |
154+| `-test` | true | also analyze test files |
155+| `-version` | | print the version |
156+
157+Each finding is prefixed with its severity, e.g. `[warn] no-adapter-to-adapter-imports: ...`.
158+
159+Exit codes: `0` no failing findings, `1` load/config/analysis error, `2` usage error, `3` findings at or above `-fail-on`.
160+
116 ## golangci-lint module plugin161 ## golangci-lint module plugin
117 162
118 Build a custom golangci-lint binary using the module plugin system:163 Build a custom golangci-lint binary using the module plugin system:
@@ -149,6 +194,8 @@ linters:
149 config: .hexcheck.yaml194 config: .hexcheck.yaml
150 ```195 ```
151 196
197+golangci-lint reports every hexcheck finding as an issue, whatever its configured severity. To fail only on `error` rules, set the warn rules to `off` in `.hexcheck.yaml`, or exclude issues whose text matches `^\[warn\]` with `linters.exclusions.rules`.
198+
152 ## Agent configuration guide199 ## Agent configuration guide
153 200
154 [`SKILL.md`](SKILL.md) explains how an agent should configure `hexcheck` for a new repository, including non-standard layouts such as `core`/`boundaries`.201 [`SKILL.md`](SKILL.md) explains how an agent should configure `hexcheck` for a new repository, including non-standard layouts such as `core`/`boundaries`.
@@ -156,7 +203,7 @@ linters:
156 ## Local development203 ## Local development
157 204
158 ```bash205 ```bash
159-make test206+make test # go test -race
160-make check207+make check # tidy, lint, test, and hexcheck on its own code
161 HEXCHECK_SMOKE_REPO=/path/to/local/go/repo make smoke-local208 HEXCHECK_SMOKE_REPO=/path/to/local/go/repo make smoke-local
162 ```209 ```
M SKILL.md +9 -3
@@ -5,7 +5,7 @@ description: Use when configuring or running hexcheck in a Go repo for hexagonal
55
66 # Hexcheck
77
8-Configure `.hexcheck.yaml` by mapping repo paths to roles. Folder names do not matter; roles do.
8+Configure `.hexcheck.yaml` by mapping repo paths to roles. Folder names do not matter; roles do. Start with `hexcheck init`, which writes a starter config from detected folders, then refine it.
99
1010 Roles:
1111 - `core`: domain/core business logic
@@ -35,7 +35,10 @@ Recommended rules:
3535 ```yaml
3636 rules:
3737 no-adapter-imports-in-core: error
38+ no-usecase-imports-in-core: error
3839 no-infra-imports-in-usecase: error
40+ no-infra-imports-in-ports: error
41+ no-entrypoint-imports-in-adapter: error
3942 no-framework-types-in-core: error
4043 no-infra-types-in-ports: error
4144 no-adapter-to-adapter-imports: warn
@@ -66,7 +69,10 @@ mocking:
6669 Run:
6770
6871 ```bash
69-hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
72+hexcheck -config .hexcheck.yaml -root . ./... # exit 3 on error-level findings
73+hexcheck -fail-on warn ./... # also fail on warn-level findings
7074 ```
7175
72-Agent checklist: read repo architecture docs, map paths to roles, ignore generated/mocks, configure mock paths, run once, tune `ruleSettings.*.excludePaths` only after inspecting examples.
76+The config is strict: unknown keys or rule names fail the run. For a single justified exception, use an inline directive with a reason: `//hexcheck:ignore <rule>[,<rule>] <reason>`.
77+
78+Agent checklist: read repo architecture docs, map paths to roles, ignore generated/mocks, configure mock paths, run once, tune `ruleSettings.*.excludePaths` only after inspecting examples, and prefer `allow` entries or inline directives with reasons over turning rules off.
@@ -5,7 +5,7 @@ description: Use when configuring or running hexcheck in a Go repo for hexagonal
5 5
6 # Hexcheck6 # Hexcheck
7 7
8-Configure `.hexcheck.yaml` by mapping repo paths to roles. Folder names do not matter; roles do.8+Configure `.hexcheck.yaml` by mapping repo paths to roles. Folder names do not matter; roles do. Start with `hexcheck init`, which writes a starter config from detected folders, then refine it.
9 9
10 Roles:10 Roles:
11 - `core`: domain/core business logic11 - `core`: domain/core business logic
@@ -35,7 +35,10 @@ Recommended rules:
35 ```yaml35 ```yaml
36 rules:36 rules:
37 no-adapter-imports-in-core: error37 no-adapter-imports-in-core: error
38+ no-usecase-imports-in-core: error
38 no-infra-imports-in-usecase: error39 no-infra-imports-in-usecase: error
40+ no-infra-imports-in-ports: error
41+ no-entrypoint-imports-in-adapter: error
39 no-framework-types-in-core: error42 no-framework-types-in-core: error
40 no-infra-types-in-ports: error43 no-infra-types-in-ports: error
41 no-adapter-to-adapter-imports: warn44 no-adapter-to-adapter-imports: warn
@@ -66,7 +69,10 @@ mocking:
66 Run:69 Run:
67 70
68 ```bash71 ```bash
69-hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...72+hexcheck -config .hexcheck.yaml -root . ./... # exit 3 on error-level findings
73+hexcheck -fail-on warn ./... # also fail on warn-level findings
70 ```74 ```
71 75
72-Agent checklist: read repo architecture docs, map paths to roles, ignore generated/mocks, configure mock paths, run once, tune `ruleSettings.*.excludePaths` only after inspecting examples.76+The config is strict: unknown keys or rule names fail the run. For a single justified exception, use an inline directive with a reason: `//hexcheck:ignore <rule>[,<rule>] <reason>`.
77+
78+Agent checklist: read repo architecture docs, map paths to roles, ignore generated/mocks, configure mock paths, run once, tune `ruleSettings.*.excludePaths` only after inspecting examples, and prefer `allow` entries or inline directives with reasons over turning rules off.
M analyzer/analyzer.go +110 -22
@@ -1,13 +1,16 @@
11 package analyzer
22
33 import (
4+ "fmt"
45 "go/ast"
56 "go/token"
67 "go/types"
78 "os"
89 "path/filepath"
10+ "reflect"
911 "strconv"
1012 "strings"
13+ "sync"
1114
1215 "github.com/bnema/hexcheck/config"
1316 "github.com/bnema/hexcheck/internal/glob"
@@ -23,26 +26,60 @@ type Options struct {
2326 ModulePath string
2427 }
2528
29+// Result is returned for each analyzed package. Drivers use it to map a
30+// diagnostic Category (the rule name) to its configured severity.
31+type Result struct {
32+ Severities map[string]config.Severity
33+}
34+
35+// Severity returns the configured severity of a diagnostic category.
36+func (r *Result) Severity(rule string) config.Severity {
37+ if r == nil {
38+ return config.SeverityOff
39+ }
40+ return r.Severities[rule]
41+}
42+
43+// state is shared by every package analyzed by one Analyzer instance, so the
44+// config and mock index are loaded once per run instead of once per package.
45+type state struct {
46+ once sync.Once
47+ cfg *config.Config
48+ modulePath string
49+ err error
50+ mockOnce sync.Once
51+ mocks map[string]bool
52+}
53+
2654 func New(opts Options) *analysis.Analyzer {
2755 var configPathFlag string
2856 var rootFlag string
2957 var modulePathFlag string
58+ st := &state{}
3059
3160 a := &analysis.Analyzer{
32- Name: Name,
33- Doc: "checks hexagonal architecture boundaries",
61+ Name: Name,
62+ Doc: "checks hexagonal architecture boundaries",
63+ ResultType: reflect.TypeFor[*Result](),
3464 Run: func(pass *analysis.Pass) (any, error) {
35- cfg, root, err := resolveConfig(opts, configPathFlag, rootFlag)
36- if err != nil {
37- return nil, err
38- }
39- modulePath := firstNonEmpty(opts.ModulePath, modulePathFlag)
40- if modulePath == "" {
41- modulePath = discoverModulePath(root)
65+ st.once.Do(func() {
66+ cfg, root, err := resolveConfig(opts, configPathFlag, rootFlag)
67+ if err != nil {
68+ st.err = err
69+ return
70+ }
71+ modulePath := firstNonEmpty(opts.ModulePath, modulePathFlag)
72+ if modulePath == "" {
73+ modulePath = discoverModulePath(root)
74+ }
75+ st.cfg, st.modulePath = cfg, modulePath
76+ })
77+ if st.err != nil {
78+ return nil, st.err
4279 }
43- r := runner{pass: pass, cfg: cfg, modulePath: modulePath}
80+ r := runner{pass: pass, cfg: st.cfg, modulePath: st.modulePath, state: st}
4481 r.run()
45- return nil, nil
82+ return &Result{Severities: st.cfg.Rules}, nil
4683 },
4784 }
4885 if opts.Config == nil {
@@ -138,6 +175,8 @@ type runner struct {
138175 pass *analysis.Pass
139176 cfg *config.Config
140177 modulePath string
178+ state *state
179+ ignores ignoreIndex
141180 }
142181
143182 func (r runner) run() {
@@ -146,12 +185,14 @@ func (r runner) run() {
146185 if !ok || current.Role == config.RoleIgnore {
147186 return
148187 }
188+ r.ignores = ignoreIndex{}
149189 businessDiagnostics := 0
150190 for _, file := range r.pass.Files {
151191 filePath := r.filePath(file)
152192 if isGeneratedFile(file) {
153193 continue
154194 }
195+ r.collectIgnores(file)
155196 r.checkImports(file, filePath, current)
156197 r.checkTypeLeaks(file, filePath, current)
157198 r.checkMissingMocks(file, filePath, current)
@@ -171,8 +212,14 @@ func (r runner) checkImports(file *ast.File, filePath string, current config.Mat
171212 switch {
172213 case current.Role == config.RoleCore && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
173214 r.report(imp.Pos(), "no-adapter-imports-in-core", filePath, "core component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
174- case current.Role == config.RoleUsecase && imported.Role == config.RoleAdapter:
175- r.report(imp.Pos(), "no-infra-imports-in-usecase", filePath, "usecase component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
215+ case current.Role == config.RoleCore && imported.Role == config.RoleUsecase:
216+ r.report(imp.Pos(), "no-usecase-imports-in-core", filePath, "core component %q imports usecase component %q (%s)", current.Name, imported.Name, importPath)
217+ case current.Role == config.RoleUsecase && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
218+ r.report(imp.Pos(), "no-infra-imports-in-usecase", filePath, "usecase component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
219+ case current.Role == config.RolePorts && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
220+ r.report(imp.Pos(), "no-infra-imports-in-ports", filePath, "ports component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
221+ case current.Role == config.RoleAdapter && imported.Role == config.RoleEntrypoint:
222+ r.report(imp.Pos(), "no-entrypoint-imports-in-adapter", filePath, "adapter component %q imports entrypoint component %q (%s)", current.Name, imported.Name, importPath)
176223 case current.Role == config.RoleAdapter && imported.Role == config.RoleAdapter && current.Name != imported.Name:
177224 r.report(imp.Pos(), "no-adapter-to-adapter-imports", filePath, "adapter component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
178225 }
@@ -216,26 +263,61 @@ func (r runner) checkFieldList(fields *ast.FieldList, filePath, rule string) {
216263 return
217264 }
218265 for _, field := range fields.List {
219- if r.isInfraType(r.pass.TypesInfo.TypeOf(field.Type)) {
220- r.report(field.Pos(), rule, filePath, "%s exposes infrastructure/framework type %s", rule, r.pass.TypesInfo.TypeOf(field.Type))
266+ t := r.pass.TypesInfo.TypeOf(field.Type)
267+ if r.isInfraType(t, nil) {
268+ r.report(field.Pos(), rule, filePath, "exposes infrastructure/framework type %s", t)
221269 }
222270 }
223271 }
224272
225-func (r runner) isInfraType(t types.Type) bool {
273+// isInfraType reports whether t, or any type it is composed of, belongs to an
274+// adapter/entrypoint component or a configured external framework package.
275+func (r runner) isInfraType(t types.Type, seen map[types.Type]bool) bool {
226276 if t == nil {
227277 return false
228278 }
279+ if seen[t] {
280+ return false
281+ }
282+ if seen == nil {
283+ seen = map[types.Type]bool{}
284+ }
285+ seen[t] = true
229286 switch tt := t.(type) {
287+ case *types.Alias:
288+ return r.isInfraType(types.Unalias(tt), seen)
230289 case *types.Pointer:
231- return r.isInfraType(tt.Elem())
290+ return r.isInfraType(tt.Elem(), seen)
232291 case *types.Slice:
233- return r.isInfraType(tt.Elem())
292+ return r.isInfraType(tt.Elem(), seen)
234293 case *types.Array:
235- return r.isInfraType(tt.Elem())
294+ return r.isInfraType(tt.Elem(), seen)
295+ case *types.Chan:
296+ return r.isInfraType(tt.Elem(), seen)
236297 case *types.Map:
237- return r.isInfraType(tt.Key()) || r.isInfraType(tt.Elem())
298+ return r.isInfraType(tt.Key(), seen) || r.isInfraType(tt.Elem(), seen)
299+ case *types.Signature:
300+ return r.isInfraType(tt.Params(), seen) || r.isInfraType(tt.Results(), seen)
301+ case *types.Tuple:
302+ for v := range tt.Variables() {
303+ if r.isInfraType(v.Type(), seen) {
304+ return true
305+ }
306+ }
307+ return false
308+ case *types.Struct:
309+ for field := range tt.Fields() {
310+ if r.isInfraType(field.Type(), seen) {
311+ return true
312+ }
313+ }
314+ return false
238315 case *types.Named:
316+ for arg := range tt.TypeArgs().Types() {
317+ if r.isInfraType(arg, seen) {
318+ return true
319+ }
320+ }
239321 obj := tt.Obj()
240322 if obj == nil || obj.Pkg() == nil {
241323 return false
@@ -260,11 +342,17 @@ func (r runner) matchesExternal(pkg string) bool {
260342 return false
261343 }
262344
345+// report emits a diagnostic for rule unless it is disabled, allowed by config,
346+// excluded by path, or suppressed by an inline ignore directive.
263347 func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) bool {
264- if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) {
348+ if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) || r.isIgnoredInline(pos, rule) {
265349 return false
266350 }
267- r.pass.Reportf(pos, rule+": "+format, args...)
351+ r.pass.Report(analysis.Diagnostic{
352+ Pos: pos,
353+ Category: rule,
354+ Message: fmt.Sprintf("[%s] %s: %s", r.cfg.Severity(rule), rule, fmt.Sprintf(format, args...)),
355+ })
268356 return true
269357 }
270358
@@ -1,13 +1,16 @@
1 package analyzer1 package analyzer
2 2
3 import (3 import (
4+ "fmt"
4 "go/ast"5 "go/ast"
5 "go/token"6 "go/token"
6 "go/types"7 "go/types"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
10+ "reflect"
9 "strconv"11 "strconv"
10 "strings"12 "strings"
13+ "sync"
11 14
12 "github.com/bnema/hexcheck/config"15 "github.com/bnema/hexcheck/config"
13 "github.com/bnema/hexcheck/internal/glob"16 "github.com/bnema/hexcheck/internal/glob"
@@ -23,26 +26,60 @@ type Options struct {
23 ModulePath string26 ModulePath string
24 }27 }
25 28
29+// Result is returned for each analyzed package. Drivers use it to map a
30+// diagnostic Category (the rule name) to its configured severity.
31+type Result struct {
32+ Severities map[string]config.Severity
33+}
34+
35+// Severity returns the configured severity of a diagnostic category.
36+func (r *Result) Severity(rule string) config.Severity {
37+ if r == nil {
38+ return config.SeverityOff
39+ }
40+ return r.Severities[rule]
41+}
42+
43+// state is shared by every package analyzed by one Analyzer instance, so the
44+// config and mock index are loaded once per run instead of once per package.
45+type state struct {
46+ once sync.Once
47+ cfg *config.Config
48+ modulePath string
49+ err error
50+ mockOnce sync.Once
51+ mocks map[string]bool
52+}
53+
26 func New(opts Options) *analysis.Analyzer {54 func New(opts Options) *analysis.Analyzer {
27 var configPathFlag string55 var configPathFlag string
28 var rootFlag string56 var rootFlag string
29 var modulePathFlag string57 var modulePathFlag string
58+ st := &state{}
30 59
31 a := &analysis.Analyzer{60 a := &analysis.Analyzer{
32- Name: Name,61+ Name: Name,
33- Doc: "checks hexagonal architecture boundaries",62+ Doc: "checks hexagonal architecture boundaries",
63+ ResultType: reflect.TypeFor[*Result](),
34 Run: func(pass *analysis.Pass) (any, error) {64 Run: func(pass *analysis.Pass) (any, error) {
35- cfg, root, err := resolveConfig(opts, configPathFlag, rootFlag)65+ st.once.Do(func() {
36- if err != nil {66+ cfg, root, err := resolveConfig(opts, configPathFlag, rootFlag)
37- return nil, err67+ if err != nil {
38- }68+ st.err = err
39- modulePath := firstNonEmpty(opts.ModulePath, modulePathFlag)69+ return
40- if modulePath == "" {70+ }
41- modulePath = discoverModulePath(root)71+ modulePath := firstNonEmpty(opts.ModulePath, modulePathFlag)
72+ if modulePath == "" {
73+ modulePath = discoverModulePath(root)
74+ }
75+ st.cfg, st.modulePath = cfg, modulePath
76+ })
77+ if st.err != nil {
78+ return nil, st.err
42 }79 }
43- r := runner{pass: pass, cfg: cfg, modulePath: modulePath}80+ r := runner{pass: pass, cfg: st.cfg, modulePath: st.modulePath, state: st}
44 r.run()81 r.run()
45- return nil, nil82+ return &Result{Severities: st.cfg.Rules}, nil
46 },83 },
47 }84 }
48 if opts.Config == nil {85 if opts.Config == nil {
@@ -138,6 +175,8 @@ type runner struct {
138 pass *analysis.Pass175 pass *analysis.Pass
139 cfg *config.Config176 cfg *config.Config
140 modulePath string177 modulePath string
178+ state *state
179+ ignores ignoreIndex
141 }180 }
142 181
143 func (r runner) run() {182 func (r runner) run() {
@@ -146,12 +185,14 @@ func (r runner) run() {
146 if !ok || current.Role == config.RoleIgnore {185 if !ok || current.Role == config.RoleIgnore {
147 return186 return
148 }187 }
188+ r.ignores = ignoreIndex{}
149 businessDiagnostics := 0189 businessDiagnostics := 0
150 for _, file := range r.pass.Files {190 for _, file := range r.pass.Files {
151 filePath := r.filePath(file)191 filePath := r.filePath(file)
152 if isGeneratedFile(file) {192 if isGeneratedFile(file) {
153 continue193 continue
154 }194 }
195+ r.collectIgnores(file)
155 r.checkImports(file, filePath, current)196 r.checkImports(file, filePath, current)
156 r.checkTypeLeaks(file, filePath, current)197 r.checkTypeLeaks(file, filePath, current)
157 r.checkMissingMocks(file, filePath, current)198 r.checkMissingMocks(file, filePath, current)
@@ -171,8 +212,14 @@ func (r runner) checkImports(file *ast.File, filePath string, current config.Mat
171 switch {212 switch {
172 case current.Role == config.RoleCore && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):213 case current.Role == config.RoleCore && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
173 r.report(imp.Pos(), "no-adapter-imports-in-core", filePath, "core component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)214 r.report(imp.Pos(), "no-adapter-imports-in-core", filePath, "core component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
174- case current.Role == config.RoleUsecase && imported.Role == config.RoleAdapter:215+ case current.Role == config.RoleCore && imported.Role == config.RoleUsecase:
175- r.report(imp.Pos(), "no-infra-imports-in-usecase", filePath, "usecase component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)216+ r.report(imp.Pos(), "no-usecase-imports-in-core", filePath, "core component %q imports usecase component %q (%s)", current.Name, imported.Name, importPath)
217+ case current.Role == config.RoleUsecase && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
218+ r.report(imp.Pos(), "no-infra-imports-in-usecase", filePath, "usecase component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
219+ case current.Role == config.RolePorts && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
220+ r.report(imp.Pos(), "no-infra-imports-in-ports", filePath, "ports component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
221+ case current.Role == config.RoleAdapter && imported.Role == config.RoleEntrypoint:
222+ r.report(imp.Pos(), "no-entrypoint-imports-in-adapter", filePath, "adapter component %q imports entrypoint component %q (%s)", current.Name, imported.Name, importPath)
176 case current.Role == config.RoleAdapter && imported.Role == config.RoleAdapter && current.Name != imported.Name:223 case current.Role == config.RoleAdapter && imported.Role == config.RoleAdapter && current.Name != imported.Name:
177 r.report(imp.Pos(), "no-adapter-to-adapter-imports", filePath, "adapter component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)224 r.report(imp.Pos(), "no-adapter-to-adapter-imports", filePath, "adapter component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
178 }225 }
@@ -216,26 +263,61 @@ func (r runner) checkFieldList(fields *ast.FieldList, filePath, rule string) {
216 return263 return
217 }264 }
218 for _, field := range fields.List {265 for _, field := range fields.List {
219- if r.isInfraType(r.pass.TypesInfo.TypeOf(field.Type)) {266+ t := r.pass.TypesInfo.TypeOf(field.Type)
220- r.report(field.Pos(), rule, filePath, "%s exposes infrastructure/framework type %s", rule, r.pass.TypesInfo.TypeOf(field.Type))267+ if r.isInfraType(t, nil) {
268+ r.report(field.Pos(), rule, filePath, "exposes infrastructure/framework type %s", t)
221 }269 }
222 }270 }
223 }271 }
224 272
225-func (r runner) isInfraType(t types.Type) bool {273+// isInfraType reports whether t, or any type it is composed of, belongs to an
274+// adapter/entrypoint component or a configured external framework package.
275+func (r runner) isInfraType(t types.Type, seen map[types.Type]bool) bool {
226 if t == nil {276 if t == nil {
227 return false277 return false
228 }278 }
279+ if seen[t] {
280+ return false
281+ }
282+ if seen == nil {
283+ seen = map[types.Type]bool{}
284+ }
285+ seen[t] = true
229 switch tt := t.(type) {286 switch tt := t.(type) {
287+ case *types.Alias:
288+ return r.isInfraType(types.Unalias(tt), seen)
230 case *types.Pointer:289 case *types.Pointer:
231- return r.isInfraType(tt.Elem())290+ return r.isInfraType(tt.Elem(), seen)
232 case *types.Slice:291 case *types.Slice:
233- return r.isInfraType(tt.Elem())292+ return r.isInfraType(tt.Elem(), seen)
234 case *types.Array:293 case *types.Array:
235- return r.isInfraType(tt.Elem())294+ return r.isInfraType(tt.Elem(), seen)
295+ case *types.Chan:
296+ return r.isInfraType(tt.Elem(), seen)
236 case *types.Map:297 case *types.Map:
237- return r.isInfraType(tt.Key()) || r.isInfraType(tt.Elem())298+ return r.isInfraType(tt.Key(), seen) || r.isInfraType(tt.Elem(), seen)
299+ case *types.Signature:
300+ return r.isInfraType(tt.Params(), seen) || r.isInfraType(tt.Results(), seen)
301+ case *types.Tuple:
302+ for v := range tt.Variables() {
303+ if r.isInfraType(v.Type(), seen) {
304+ return true
305+ }
306+ }
307+ return false
308+ case *types.Struct:
309+ for field := range tt.Fields() {
310+ if r.isInfraType(field.Type(), seen) {
311+ return true
312+ }
313+ }
314+ return false
238 case *types.Named:315 case *types.Named:
316+ for arg := range tt.TypeArgs().Types() {
317+ if r.isInfraType(arg, seen) {
318+ return true
319+ }
320+ }
239 obj := tt.Obj()321 obj := tt.Obj()
240 if obj == nil || obj.Pkg() == nil {322 if obj == nil || obj.Pkg() == nil {
241 return false323 return false
@@ -260,11 +342,17 @@ func (r runner) matchesExternal(pkg string) bool {
260 return false342 return false
261 }343 }
262 344
345+// report emits a diagnostic for rule unless it is disabled, allowed by config,
346+// excluded by path, or suppressed by an inline ignore directive.
263 func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) bool {347 func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) bool {
264- if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) {348+ if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) || r.isIgnoredInline(pos, rule) {
265 return false349 return false
266 }350 }
267- r.pass.Reportf(pos, rule+": "+format, args...)351+ r.pass.Report(analysis.Diagnostic{
352+ Pos: pos,
353+ Category: rule,
354+ Message: fmt.Sprintf("[%s] %s: %s", r.cfg.Severity(rule), rule, fmt.Sprintf(format, args...)),
355+ })
268 return true356 return true
269 }357 }
270 358
M analyzer/analyzer_test.go +2 -0
@@ -17,7 +17,9 @@ func TestAnalyzer(t *testing.T) {
1717 name: "boundary imports",
1818 patterns: []string{
1919 "example.com/project/internal/domain",
20+ "example.com/project/internal/policy",
2021 "example.com/project/internal/application/usecase",
22+ "example.com/project/internal/infrastructure/sql",
2123 },
2224 },
2325 {
@@ -17,7 +17,9 @@ func TestAnalyzer(t *testing.T) {
17 name: "boundary imports",17 name: "boundary imports",
18 patterns: []string{18 patterns: []string{
19 "example.com/project/internal/domain",19 "example.com/project/internal/domain",
20+ "example.com/project/internal/policy",
20 "example.com/project/internal/application/usecase",21 "example.com/project/internal/application/usecase",
22+ "example.com/project/internal/infrastructure/sql",
21 },23 },
22 },24 },
23 {25 {
M analyzer/business_logic_golden_test.go +26 -26
@@ -8,45 +8,45 @@ import (
88 "golang.org/x/tools/go/analysis/analysistest"
99 )
1010
11+const businessLogicRule = "suspicious-business-logic-in-adapter"
12+
1113 func TestBusinessLogicGoldenCases(t *testing.T) {
1214 result := analysistest.Run(t, analysistest.TestData(), New(Options{Config: testConfig(), ModulePath: "example.com/project"}), "example.com/project/internal/infrastructure/http")
1315
14- messagesByFile := map[string][]string{}
15- for _, diagnostics := range result[0].Diagnostics {
16- file := filepath.ToSlash(result[0].Pass.Fset.Position(diagnostics.Pos).Filename)
17- messagesByFile[file] = append(messagesByFile[file], diagnostics.Message)
16+ // rulesByFile maps each file to the diagnostic categories reported in it.
17+ rulesByFile := map[string][]string{}
18+ for _, diagnostic := range result[0].Diagnostics {
19+ file := filepath.ToSlash(result[0].Pass.Fset.Position(diagnostic.Pos).Filename)
20+ rulesByFile[file] = append(rulesByFile[file], diagnostic.Category)
1821 }
1922
20- assertHasBusinessLogicDiagnostic(t, messagesByFile, "typed_policy.go")
21- assertHasBusinessLogicDiagnostic(t, messagesByFile, "mixed_detection.go")
22- assertNoBusinessLogicDiagnostic(t, messagesByFile, "probing.go")
23-}
24-
25-func assertHasBusinessLogicDiagnostic(t *testing.T, messagesByFile map[string][]string, fileSuffix string) {
26- t.Helper()
27- for file, messages := range messagesByFile {
28- if !strings.HasSuffix(file, fileSuffix) {
29- continue
30- }
31- for _, message := range messages {
32- if strings.HasPrefix(message, "suspicious-business-logic-in-adapter:") {
33- return
23+ tests := []struct {
24+ file string
25+ want bool
26+ }{
27+ {file: "typed_policy.go", want: true},
28+ {file: "mixed_detection.go", want: true},
29+ {file: "probing.go", want: false},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.file, func(t *testing.T) {
33+ if got := hasRuleInFile(rulesByFile, tt.file, businessLogicRule); got != tt.want {
34+ t.Fatalf("business-logic diagnostic in %s = %v, want %v", tt.file, got, tt.want)
3435 }
35- }
36+ })
3637 }
37- t.Fatalf("expected business-logic diagnostic for %s", fileSuffix)
3838 }
3939
40-func assertNoBusinessLogicDiagnostic(t *testing.T, messagesByFile map[string][]string, fileSuffix string) {
41- t.Helper()
42- for file, messages := range messagesByFile {
40+func hasRuleInFile(rulesByFile map[string][]string, fileSuffix, rule string) bool {
41+ for file, rules := range rulesByFile {
4342 if !strings.HasSuffix(file, fileSuffix) {
4443 continue
4544 }
46- for _, message := range messages {
47- if strings.HasPrefix(message, "suspicious-business-logic-in-adapter:") {
48- t.Fatalf("unexpected business-logic diagnostic for %s: %s", fileSuffix, message)
45+ for _, got := range rules {
46+ if got == rule {
47+ return true
4948 }
5049 }
5150 }
51+ return false
5252 }
@@ -8,45 +8,45 @@ import (
8 "golang.org/x/tools/go/analysis/analysistest"8 "golang.org/x/tools/go/analysis/analysistest"
9 )9 )
10 10
11+const businessLogicRule = "suspicious-business-logic-in-adapter"
12+
11 func TestBusinessLogicGoldenCases(t *testing.T) {13 func TestBusinessLogicGoldenCases(t *testing.T) {
12 result := analysistest.Run(t, analysistest.TestData(), New(Options{Config: testConfig(), ModulePath: "example.com/project"}), "example.com/project/internal/infrastructure/http")14 result := analysistest.Run(t, analysistest.TestData(), New(Options{Config: testConfig(), ModulePath: "example.com/project"}), "example.com/project/internal/infrastructure/http")
13 15
14- messagesByFile := map[string][]string{}16+ // rulesByFile maps each file to the diagnostic categories reported in it.
15- for _, diagnostics := range result[0].Diagnostics {17+ rulesByFile := map[string][]string{}
16- file := filepath.ToSlash(result[0].Pass.Fset.Position(diagnostics.Pos).Filename)18+ for _, diagnostic := range result[0].Diagnostics {
17- messagesByFile[file] = append(messagesByFile[file], diagnostics.Message)19+ file := filepath.ToSlash(result[0].Pass.Fset.Position(diagnostic.Pos).Filename)
20+ rulesByFile[file] = append(rulesByFile[file], diagnostic.Category)
18 }21 }
19 22
20- assertHasBusinessLogicDiagnostic(t, messagesByFile, "typed_policy.go")23+ tests := []struct {
21- assertHasBusinessLogicDiagnostic(t, messagesByFile, "mixed_detection.go")24+ file string
22- assertNoBusinessLogicDiagnostic(t, messagesByFile, "probing.go")25+ want bool
23-}26+ }{
24-27+ {file: "typed_policy.go", want: true},
25-func assertHasBusinessLogicDiagnostic(t *testing.T, messagesByFile map[string][]string, fileSuffix string) {28+ {file: "mixed_detection.go", want: true},
26- t.Helper()29+ {file: "probing.go", want: false},
27- for file, messages := range messagesByFile {30+ }
28- if !strings.HasSuffix(file, fileSuffix) {31+ for _, tt := range tests {
29- continue32+ t.Run(tt.file, func(t *testing.T) {
30- }33+ if got := hasRuleInFile(rulesByFile, tt.file, businessLogicRule); got != tt.want {
31- for _, message := range messages {34+ t.Fatalf("business-logic diagnostic in %s = %v, want %v", tt.file, got, tt.want)
32- if strings.HasPrefix(message, "suspicious-business-logic-in-adapter:") {
33- return
34 }35 }
35- }36+ })
36 }37 }
37- t.Fatalf("expected business-logic diagnostic for %s", fileSuffix)
38 }38 }
39 39
40-func assertNoBusinessLogicDiagnostic(t *testing.T, messagesByFile map[string][]string, fileSuffix string) {40+func hasRuleInFile(rulesByFile map[string][]string, fileSuffix, rule string) bool {
41- t.Helper()41+ for file, rules := range rulesByFile {
42- for file, messages := range messagesByFile {
43 if !strings.HasSuffix(file, fileSuffix) {42 if !strings.HasSuffix(file, fileSuffix) {
44 continue43 continue
45 }44 }
46- for _, message := range messages {45+ for _, got := range rules {
47- if strings.HasPrefix(message, "suspicious-business-logic-in-adapter:") {46+ if got == rule {
48- t.Fatalf("unexpected business-logic diagnostic for %s: %s", fileSuffix, message)47+ return true
49 }48 }
50 }49 }
51 }50 }
51+ return false
52 }52 }
A analyzer/ignore.go +66 -0
@@ -0,0 +1,66 @@
1+package analyzer
2+
3+import (
4+ "go/ast"
5+ "go/token"
6+ "strings"
7+
8+ "github.com/bnema/hexcheck/config"
9+ "golang.org/x/tools/go/analysis"
10+)
11+
12+// DirectiveCategory is the diagnostic category used for malformed
13+// //hexcheck:ignore directives.
14+const DirectiveCategory = "invalid-ignore-directive"
15+
16+const ignoreDirective = "//hexcheck:ignore"
17+
18+type ignoreKey struct {
19+ file string
20+ line int
21+ rule string
22+}
23+
24+// ignoreIndex records which rules are suppressed on which lines.
25+type ignoreIndex map[ignoreKey]bool
26+
27+// collectIgnores indexes `//hexcheck:ignore rule[,rule] reason` directives.
28+// A directive suppresses matching diagnostics on its own line and on the
29+// following line. A reason is required, like config allow entries.
30+func (r runner) collectIgnores(file *ast.File) {
31+ known := config.DefaultRuleSeverities()
32+ for _, group := range file.Comments {
33+ for _, comment := range group.List {
34+ rest, ok := strings.CutPrefix(comment.Text, ignoreDirective)
35+ if !ok || (rest != "" && rest[0] != ' ' && rest[0] != '\t') {
36+ continue
37+ }
38+ fields := strings.Fields(rest)
39+ if len(fields) < 2 {
40+ r.reportDirective(comment.Pos(), "hexcheck:ignore needs a rule list and a reason")
41+ continue
42+ }
43+ pos := r.pass.Fset.Position(comment.Pos())
44+ for rule := range strings.SplitSeq(fields[0], ",") {
45+ if _, ok := known[rule]; !ok {
46+ r.reportDirective(comment.Pos(), "hexcheck:ignore names unknown rule "+rule)
47+ continue
48+ }
49+ r.ignores[ignoreKey{file: pos.Filename, line: pos.Line, rule: rule}] = true
50+ r.ignores[ignoreKey{file: pos.Filename, line: pos.Line + 1, rule: rule}] = true
51+ }
52+ }
53+ }
54+}
55+
56+func (r runner) isIgnoredInline(pos token.Pos, rule string) bool {
57+ if r.ignores == nil {
58+ return false
59+ }
60+ p := r.pass.Fset.Position(pos)
61+ return r.ignores[ignoreKey{file: p.Filename, line: p.Line, rule: rule}]
62+}
63+
64+func (r runner) reportDirective(pos token.Pos, msg string) {
65+ r.pass.Report(analysis.Diagnostic{Pos: pos, Category: DirectiveCategory, Message: "[error] " + DirectiveCategory + ": " + msg})
66+}
@@ -0,0 +1,66 @@
1+package analyzer
2+
3+import (
4+ "go/ast"
5+ "go/token"
6+ "strings"
7+
8+ "github.com/bnema/hexcheck/config"
9+ "golang.org/x/tools/go/analysis"
10+)
11+
12+// DirectiveCategory is the diagnostic category used for malformed
13+// //hexcheck:ignore directives.
14+const DirectiveCategory = "invalid-ignore-directive"
15+
16+const ignoreDirective = "//hexcheck:ignore"
17+
18+type ignoreKey struct {
19+ file string
20+ line int
21+ rule string
22+}
23+
24+// ignoreIndex records which rules are suppressed on which lines.
25+type ignoreIndex map[ignoreKey]bool
26+
27+// collectIgnores indexes `//hexcheck:ignore rule[,rule] reason` directives.
28+// A directive suppresses matching diagnostics on its own line and on the
29+// following line. A reason is required, like config allow entries.
30+func (r runner) collectIgnores(file *ast.File) {
31+ known := config.DefaultRuleSeverities()
32+ for _, group := range file.Comments {
33+ for _, comment := range group.List {
34+ rest, ok := strings.CutPrefix(comment.Text, ignoreDirective)
35+ if !ok || (rest != "" && rest[0] != ' ' && rest[0] != '\t') {
36+ continue
37+ }
38+ fields := strings.Fields(rest)
39+ if len(fields) < 2 {
40+ r.reportDirective(comment.Pos(), "hexcheck:ignore needs a rule list and a reason")
41+ continue
42+ }
43+ pos := r.pass.Fset.Position(comment.Pos())
44+ for rule := range strings.SplitSeq(fields[0], ",") {
45+ if _, ok := known[rule]; !ok {
46+ r.reportDirective(comment.Pos(), "hexcheck:ignore names unknown rule "+rule)
47+ continue
48+ }
49+ r.ignores[ignoreKey{file: pos.Filename, line: pos.Line, rule: rule}] = true
50+ r.ignores[ignoreKey{file: pos.Filename, line: pos.Line + 1, rule: rule}] = true
51+ }
52+ }
53+ }
54+}
55+
56+func (r runner) isIgnoredInline(pos token.Pos, rule string) bool {
57+ if r.ignores == nil {
58+ return false
59+ }
60+ p := r.pass.Fset.Position(pos)
61+ return r.ignores[ignoreKey{file: p.Filename, line: p.Line, rule: rule}]
62+}
63+
64+func (r runner) reportDirective(pos token.Pos, msg string) {
65+ r.pass.Report(analysis.Diagnostic{Pos: pos, Category: DirectiveCategory, Message: "[error] " + DirectiveCategory + ": " + msg})
66+}
M analyzer/mocks.go +11 -21
@@ -7,10 +7,8 @@ import (
77 "go/types"
88 "os"
99 "path/filepath"
10- "sort"
1110 "strconv"
1211 "strings"
13- "sync"
1412
1513 "github.com/bnema/hexcheck/config"
1614 "github.com/bnema/hexcheck/internal/glob"
@@ -110,13 +108,11 @@ func (r runner) importedPortInterfaces(file *ast.File) []portInterface {
110108 return out
111109 }
112110
113-var mockIndexCache sync.Map
114-
115111 func (r runner) generatedMockExists(interfaceName string) bool {
116112 if r.cfg.Root == "" || len(r.cfg.Mocking.GeneratedMockPaths) == 0 {
117113 return false
118114 }
119- index := cachedMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)
115+ index := r.mockIndex()
120116 for _, name := range r.expectedMockNames(interfaceName) {
121117 if index[name] {
122118 return true
@@ -125,20 +121,17 @@ func (r runner) generatedMockExists(interfaceName string) bool {
125121 return false
126122 }
127123
128-func cachedMockIndex(root string, patterns []string) map[string]bool {
129- key := mockIndexKey(root, patterns)
130- if cached, ok := mockIndexCache.Load(key); ok {
131- return cached.(map[string]bool)
124+// mockIndex returns the generated mock type names, built once per analyzer
125+// run. The index lives in the analyzer state, not in a process-wide cache, so
126+// each new run sees freshly generated mocks.
127+func (r runner) mockIndex() map[string]bool {
128+ if r.state == nil {
129+ return buildMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)
132130 }
133- index := buildMockIndex(root, patterns)
134- actual, _ := mockIndexCache.LoadOrStore(key, index)
135- return actual.(map[string]bool)
136-}
137-
138-func mockIndexKey(root string, patterns []string) string {
139- patterns = append([]string(nil), patterns...)
140- sort.Strings(patterns)
141- return root + "\x00" + strings.Join(patterns, "\x00")
131+ r.state.mockOnce.Do(func() {
132+ r.state.mocks = buildMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)
133+ })
134+ return r.state.mocks
142135 }
143136
144137 func buildMockIndex(root string, patterns []string) map[string]bool {
@@ -210,9 +203,6 @@ func isTestDoubleName(name string) bool {
210203 }
211204
212205 func hasWordPrefix(name, prefix string) bool {
213- if len(name) >= len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) && len(name) == len(prefix) {
214- return true
215- }
216206 if len(name) < len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) {
217207 return false
218208 }
@@ -7,10 +7,8 @@ import (
7 "go/types"7 "go/types"
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10- "sort"
11 "strconv"10 "strconv"
12 "strings"11 "strings"
13- "sync"
14 12
15 "github.com/bnema/hexcheck/config"13 "github.com/bnema/hexcheck/config"
16 "github.com/bnema/hexcheck/internal/glob"14 "github.com/bnema/hexcheck/internal/glob"
@@ -110,13 +108,11 @@ func (r runner) importedPortInterfaces(file *ast.File) []portInterface {
110 return out108 return out
111 }109 }
112 110
113-var mockIndexCache sync.Map
114-
115 func (r runner) generatedMockExists(interfaceName string) bool {111 func (r runner) generatedMockExists(interfaceName string) bool {
116 if r.cfg.Root == "" || len(r.cfg.Mocking.GeneratedMockPaths) == 0 {112 if r.cfg.Root == "" || len(r.cfg.Mocking.GeneratedMockPaths) == 0 {
117 return false113 return false
118 }114 }
119- index := cachedMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)115+ index := r.mockIndex()
120 for _, name := range r.expectedMockNames(interfaceName) {116 for _, name := range r.expectedMockNames(interfaceName) {
121 if index[name] {117 if index[name] {
122 return true118 return true
@@ -125,20 +121,17 @@ func (r runner) generatedMockExists(interfaceName string) bool {
125 return false121 return false
126 }122 }
127 123
128-func cachedMockIndex(root string, patterns []string) map[string]bool {124+// mockIndex returns the generated mock type names, built once per analyzer
129- key := mockIndexKey(root, patterns)125+// run. The index lives in the analyzer state, not in a process-wide cache, so
130- if cached, ok := mockIndexCache.Load(key); ok {126+// each new run sees freshly generated mocks.
131- return cached.(map[string]bool)127+func (r runner) mockIndex() map[string]bool {
128+ if r.state == nil {
129+ return buildMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)
132 }130 }
133- index := buildMockIndex(root, patterns)131+ r.state.mockOnce.Do(func() {
134- actual, _ := mockIndexCache.LoadOrStore(key, index)132+ r.state.mocks = buildMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)
135- return actual.(map[string]bool)133+ })
136-}134+ return r.state.mocks
137-
138-func mockIndexKey(root string, patterns []string) string {
139- patterns = append([]string(nil), patterns...)
140- sort.Strings(patterns)
141- return root + "\x00" + strings.Join(patterns, "\x00")
142 }135 }
143 136
144 func buildMockIndex(root string, patterns []string) map[string]bool {137 func buildMockIndex(root string, patterns []string) map[string]bool {
@@ -210,9 +203,6 @@ func isTestDoubleName(name string) bool {
210 }203 }
211 204
212 func hasWordPrefix(name, prefix string) bool {205 func hasWordPrefix(name, prefix string) bool {
213- if len(name) >= len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) && len(name) == len(prefix) {
214- return true
215- }
216 if len(name) < len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) {206 if len(name) < len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) {
217 return false207 return false
218 }208 }
A analyzer/testdata/src/example.com/project/cmd/tools/tools.go +3 -0
@@ -0,0 +1,3 @@
1+package tools
2+
3+type Flags struct{ Verbose bool }
@@ -0,0 +1,3 @@
1+package tools
2+
3+type Flags struct{ Verbose bool }
A analyzer/testdata/src/example.com/project/internal/application/port/leaks.go +37 -0
@@ -0,0 +1,37 @@
1+package port
2+
3+import (
4+ "example.com/framework"
5+ "example.com/project/internal/infrastructure/sql" // want "no-infra-imports-in-ports"
6+)
7+
8+type Box[T any] struct{ Value T }
9+
10+type Streams interface {
11+ Rows() <-chan sql.Row // want "no-infra-types-in-ports"
12+ Each(fn func(*sql.Row) error) error // want "no-infra-types-in-ports"
13+ Boxed() Box[*framework.Context] // want "no-infra-types-in-ports"
14+ Inline() struct{ Ctx *framework.Context } // want "no-infra-types-in-ports"
15+ Plain(id string) error
16+}
17+
18+type Ignored interface {
19+ //hexcheck:ignore no-infra-types-in-ports transport row is part of the published contract
20+ Raw() sql.Row
21+}
22+
23+type SameLine interface {
24+ Raw() sql.Row //hexcheck:ignore no-infra-types-in-ports published contract
25+}
26+
27+type BadDirective interface {
28+ Raw() sql.Row /* want "no-infra-types-in-ports" "invalid-ignore-directive: hexcheck:ignore needs a rule list and a reason" */ //hexcheck:ignore no-infra-types-in-ports
29+}
30+
31+type UnknownDirective interface {
32+ Raw() sql.Row /* want "no-infra-types-in-ports" "invalid-ignore-directive: hexcheck:ignore names unknown rule not-a-rule" */ //hexcheck:ignore not-a-rule some reason
33+}
34+
35+type OtherRuleDirective interface {
36+ Raw() sql.Row /* want "no-infra-types-in-ports" */ //hexcheck:ignore no-adapter-imports-in-core wrong rule does not suppress
37+}
@@ -0,0 +1,37 @@
1+package port
2+
3+import (
4+ "example.com/framework"
5+ "example.com/project/internal/infrastructure/sql" // want "no-infra-imports-in-ports"
6+)
7+
8+type Box[T any] struct{ Value T }
9+
10+type Streams interface {
11+ Rows() <-chan sql.Row // want "no-infra-types-in-ports"
12+ Each(fn func(*sql.Row) error) error // want "no-infra-types-in-ports"
13+ Boxed() Box[*framework.Context] // want "no-infra-types-in-ports"
14+ Inline() struct{ Ctx *framework.Context } // want "no-infra-types-in-ports"
15+ Plain(id string) error
16+}
17+
18+type Ignored interface {
19+ //hexcheck:ignore no-infra-types-in-ports transport row is part of the published contract
20+ Raw() sql.Row
21+}
22+
23+type SameLine interface {
24+ Raw() sql.Row //hexcheck:ignore no-infra-types-in-ports published contract
25+}
26+
27+type BadDirective interface {
28+ Raw() sql.Row /* want "no-infra-types-in-ports" "invalid-ignore-directive: hexcheck:ignore needs a rule list and a reason" */ //hexcheck:ignore no-infra-types-in-ports
29+}
30+
31+type UnknownDirective interface {
32+ Raw() sql.Row /* want "no-infra-types-in-ports" "invalid-ignore-directive: hexcheck:ignore names unknown rule not-a-rule" */ //hexcheck:ignore not-a-rule some reason
33+}
34+
35+type OtherRuleDirective interface {
36+ Raw() sql.Row /* want "no-infra-types-in-ports" */ //hexcheck:ignore no-adapter-imports-in-core wrong rule does not suppress
37+}
A analyzer/testdata/src/example.com/project/internal/application/port/mocks/leak_mocks.go +8 -0
@@ -0,0 +1,8 @@
1+package mocks
2+
3+type MockStreams struct{}
4+type MockIgnored struct{}
5+type MockSameLine struct{}
6+type MockBadDirective struct{}
7+type MockUnknownDirective struct{}
8+type MockOtherRuleDirective struct{}
@@ -0,0 +1,8 @@
1+package mocks
2+
3+type MockStreams struct{}
4+type MockIgnored struct{}
5+type MockSameLine struct{}
6+type MockBadDirective struct{}
7+type MockUnknownDirective struct{}
8+type MockOtherRuleDirective struct{}
M analyzer/testdata/src/example.com/project/internal/application/port/port.go +1 -1
@@ -1,6 +1,6 @@
11 package port
22
3-import "example.com/project/internal/infrastructure/sql"
3+import "example.com/project/internal/infrastructure/sql" // want "no-infra-imports-in-ports"
44
55 type UserRepository interface {
66 Find() (*sql.Row, error) // want "no-infra-types-in-ports"
@@ -1,6 +1,6 @@
1 package port1 package port
2 2
3-import "example.com/project/internal/infrastructure/sql"3+import "example.com/project/internal/infrastructure/sql" // want "no-infra-imports-in-ports"
4 4
5 type UserRepository interface {5 type UserRepository interface {
6 Find() (*sql.Row, error) // want "no-infra-types-in-ports"6 Find() (*sql.Row, error) // want "no-infra-types-in-ports"
A analyzer/testdata/src/example.com/project/internal/application/usecase/entrypoint.go +3 -0
@@ -0,0 +1,3 @@
1+package usecase
2+
3+import _ "example.com/project/cmd/tools" // want "no-infra-imports-in-usecase: usecase component \"usecase\" imports entrypoint"
@@ -0,0 +1,3 @@
1+package usecase
2+
3+import _ "example.com/project/cmd/tools" // want "no-infra-imports-in-usecase: usecase component \"usecase\" imports entrypoint"
A analyzer/testdata/src/example.com/project/internal/infrastructure/sql/entrypoint.go +3 -0
@@ -0,0 +1,3 @@
1+package sql
2+
3+import _ "example.com/project/cmd/tools" // want "no-entrypoint-imports-in-adapter"
@@ -0,0 +1,3 @@
1+package sql
2+
3+import _ "example.com/project/cmd/tools" // want "no-entrypoint-imports-in-adapter"
A analyzer/testdata/src/example.com/project/internal/policy/usecase.go +3 -0
@@ -0,0 +1,3 @@
1+package policy
2+
3+import _ "example.com/project/internal/application/usecase" // want "no-usecase-imports-in-core"
@@ -0,0 +1,3 @@
1+package policy
2+
3+import _ "example.com/project/internal/application/usecase" // want "no-usecase-imports-in-core"
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 config/config.go +21 -2
@@ -1,8 +1,10 @@
11 package config
22
33 import (
4+ "bytes"
45 "errors"
56 "fmt"
7+ "io"
68 "os"
79 "path/filepath"
810 "sort"
@@ -90,8 +92,10 @@ func Load(path string) (*Config, error) {
9092 return nil, err
9193 }
9294 var cfg Config
93- if err := yaml.Unmarshal(data, &cfg); err != nil {
94- return nil, err
95+ decoder := yaml.NewDecoder(bytes.NewReader(data))
96+ decoder.KnownFields(true)
97+ if err := decoder.Decode(&cfg); err != nil && !errors.Is(err, io.EOF) {
98+ return nil, fmt.Errorf("%s: %w", path, err)
9599 }
96100 if hasExplicitVersion(data) && cfg.Version == 0 {
97101 return nil, errors.New("version: unsupported value 0")
@@ -185,7 +189,10 @@ func hasExplicitVersion(data []byte) bool {
185189 func DefaultRuleSeverities() map[string]Severity {
186190 return map[string]Severity{
187191 "no-adapter-imports-in-core": SeverityError,
192+ "no-usecase-imports-in-core": SeverityError,
188193 "no-infra-imports-in-usecase": SeverityError,
194+ "no-infra-imports-in-ports": SeverityError,
195+ "no-entrypoint-imports-in-adapter": SeverityError,
189196 "no-framework-types-in-core": SeverityError,
190197 "no-infra-types-in-ports": SeverityError,
191198 "no-adapter-to-adapter-imports": SeverityWarn,
@@ -238,17 +245,29 @@ func (c *Config) Validate() error {
238245 default:
239246 return errors.New("heuristics.businessLogicMinConfidence: must be low, medium, or high")
240247 }
248+ known := DefaultRuleSeverities()
241249 for rule, severity := range c.Rules {
250+ if _, ok := known[rule]; !ok {
251+ return fmt.Errorf("rules.%s: unknown rule", rule)
252+ }
242253 switch severity {
243254 case SeverityOff, SeverityWarn, SeverityError:
244255 default:
245256 return fmt.Errorf("rules.%s: unsupported severity %q", rule, severity)
246257 }
247258 }
259+ for rule := range c.RuleSettings {
260+ if _, ok := known[rule]; !ok {
261+ return fmt.Errorf("ruleSettings.%s: unknown rule", rule)
262+ }
263+ }
248264 for i, allow := range c.Allow {
249265 if allow.Rule == "" || allow.Path == "" || allow.Reason == "" {
250266 return fmt.Errorf("allow[%d]: rule, path, and reason are required", i)
251267 }
268+ if _, ok := known[allow.Rule]; !ok {
269+ return fmt.Errorf("allow[%d].rule: unknown rule %q", i, allow.Rule)
270+ }
252271 }
253272 return nil
254273 }
@@ -1,8 +1,10 @@
1 package config1 package config
2 2
3 import (3 import (
4+ "bytes"
4 "errors"5 "errors"
5 "fmt"6 "fmt"
7+ "io"
6 "os"8 "os"
7 "path/filepath"9 "path/filepath"
8 "sort"10 "sort"
@@ -90,8 +92,10 @@ func Load(path string) (*Config, error) {
90 return nil, err92 return nil, err
91 }93 }
92 var cfg Config94 var cfg Config
93- if err := yaml.Unmarshal(data, &cfg); err != nil {95+ decoder := yaml.NewDecoder(bytes.NewReader(data))
94- return nil, err96+ decoder.KnownFields(true)
97+ if err := decoder.Decode(&cfg); err != nil && !errors.Is(err, io.EOF) {
98+ return nil, fmt.Errorf("%s: %w", path, err)
95 }99 }
96 if hasExplicitVersion(data) && cfg.Version == 0 {100 if hasExplicitVersion(data) && cfg.Version == 0 {
97 return nil, errors.New("version: unsupported value 0")101 return nil, errors.New("version: unsupported value 0")
@@ -185,7 +189,10 @@ func hasExplicitVersion(data []byte) bool {
185 func DefaultRuleSeverities() map[string]Severity {189 func DefaultRuleSeverities() map[string]Severity {
186 return map[string]Severity{190 return map[string]Severity{
187 "no-adapter-imports-in-core": SeverityError,191 "no-adapter-imports-in-core": SeverityError,
192+ "no-usecase-imports-in-core": SeverityError,
188 "no-infra-imports-in-usecase": SeverityError,193 "no-infra-imports-in-usecase": SeverityError,
194+ "no-infra-imports-in-ports": SeverityError,
195+ "no-entrypoint-imports-in-adapter": SeverityError,
189 "no-framework-types-in-core": SeverityError,196 "no-framework-types-in-core": SeverityError,
190 "no-infra-types-in-ports": SeverityError,197 "no-infra-types-in-ports": SeverityError,
191 "no-adapter-to-adapter-imports": SeverityWarn,198 "no-adapter-to-adapter-imports": SeverityWarn,
@@ -238,17 +245,29 @@ func (c *Config) Validate() error {
238 default:245 default:
239 return errors.New("heuristics.businessLogicMinConfidence: must be low, medium, or high")246 return errors.New("heuristics.businessLogicMinConfidence: must be low, medium, or high")
240 }247 }
248+ known := DefaultRuleSeverities()
241 for rule, severity := range c.Rules {249 for rule, severity := range c.Rules {
250+ if _, ok := known[rule]; !ok {
251+ return fmt.Errorf("rules.%s: unknown rule", rule)
252+ }
242 switch severity {253 switch severity {
243 case SeverityOff, SeverityWarn, SeverityError:254 case SeverityOff, SeverityWarn, SeverityError:
244 default:255 default:
245 return fmt.Errorf("rules.%s: unsupported severity %q", rule, severity)256 return fmt.Errorf("rules.%s: unsupported severity %q", rule, severity)
246 }257 }
247 }258 }
259+ for rule := range c.RuleSettings {
260+ if _, ok := known[rule]; !ok {
261+ return fmt.Errorf("ruleSettings.%s: unknown rule", rule)
262+ }
263+ }
248 for i, allow := range c.Allow {264 for i, allow := range c.Allow {
249 if allow.Rule == "" || allow.Path == "" || allow.Reason == "" {265 if allow.Rule == "" || allow.Path == "" || allow.Reason == "" {
250 return fmt.Errorf("allow[%d]: rule, path, and reason are required", i)266 return fmt.Errorf("allow[%d]: rule, path, and reason are required", i)
251 }267 }
268+ if _, ok := known[allow.Rule]; !ok {
269+ return fmt.Errorf("allow[%d].rule: unknown rule %q", i, allow.Rule)
270+ }
252 }271 }
253 return nil272 return nil
254 }273 }
M config/config_test.go +35 -0
@@ -46,6 +46,41 @@ allow:
4646 }
4747 },
4848 },
49+ {
50+ name: "unknown top-level key",
51+ yaml: "version: 1\ncomponent: {}\n",
52+ wantErr: true,
53+ },
54+ {
55+ name: "unknown nested key",
56+ yaml: "version: 1\nheuristics:\n businessLogicTreshold: 3\n",
57+ wantErr: true,
58+ },
59+ {
60+ name: "unknown rule name",
61+ yaml: "version: 1\nrules:\n no-adaptr-imports-in-core: error\n",
62+ wantErr: true,
63+ },
64+ {
65+ name: "unknown rule in ruleSettings",
66+ yaml: "version: 1\nruleSettings:\n typo-rule:\n excludePaths: [cmd/**]\n",
67+ wantErr: true,
68+ },
69+ {
70+ name: "unknown rule in allow",
71+ yaml: "version: 1\nallow:\n - rule: typo-rule\n path: cmd/**\n reason: x\n",
72+ wantErr: true,
73+ },
74+ {
75+ name: "empty file uses defaults",
76+ yaml: "",
77+ check: func(t *testing.T, cfg *Config) {
78+ t.Helper()
79+ if cfg.Severity("no-infra-imports-in-ports") != SeverityError {
80+ t.Fatalf("default severity not applied")
81+ }
82+ },
83+ },
4984 {
5085 name: "invalid role",
5186 yaml: `version: 1
@@ -46,6 +46,41 @@ allow:
46 }46 }
47 },47 },
48 },48 },
49+ {
50+ name: "unknown top-level key",
51+ yaml: "version: 1\ncomponent: {}\n",
52+ wantErr: true,
53+ },
54+ {
55+ name: "unknown nested key",
56+ yaml: "version: 1\nheuristics:\n businessLogicTreshold: 3\n",
57+ wantErr: true,
58+ },
59+ {
60+ name: "unknown rule name",
61+ yaml: "version: 1\nrules:\n no-adaptr-imports-in-core: error\n",
62+ wantErr: true,
63+ },
64+ {
65+ name: "unknown rule in ruleSettings",
66+ yaml: "version: 1\nruleSettings:\n typo-rule:\n excludePaths: [cmd/**]\n",
67+ wantErr: true,
68+ },
69+ {
70+ name: "unknown rule in allow",
71+ yaml: "version: 1\nallow:\n - rule: typo-rule\n path: cmd/**\n reason: x\n",
72+ wantErr: true,
73+ },
74+ {
75+ name: "empty file uses defaults",
76+ yaml: "",
77+ check: func(t *testing.T, cfg *Config) {
78+ t.Helper()
79+ if cfg.Severity("no-infra-imports-in-ports") != SeverityError {
80+ t.Fatalf("default severity not applied")
81+ }
82+ },
83+ },
49 {84 {
50 name: "invalid role",85 name: "invalid role",
51 yaml: `version: 186 yaml: `version: 1
M examples/hexcheck.yaml +3 -0
@@ -40,7 +40,10 @@ components:
4040
4141 rules:
4242 no-adapter-imports-in-core: error
43+ no-usecase-imports-in-core: error
4344 no-infra-imports-in-usecase: error
45+ no-infra-imports-in-ports: error
46+ no-entrypoint-imports-in-adapter: error
4447 no-framework-types-in-core: error
4548 no-infra-types-in-ports: error
4649 no-adapter-to-adapter-imports: warn
@@ -40,7 +40,10 @@ components:
40 40
41 rules:41 rules:
42 no-adapter-imports-in-core: error42 no-adapter-imports-in-core: error
43+ no-usecase-imports-in-core: error
43 no-infra-imports-in-usecase: error44 no-infra-imports-in-usecase: error
45+ no-infra-imports-in-ports: error
46+ no-entrypoint-imports-in-adapter: error
44 no-framework-types-in-core: error47 no-framework-types-in-core: error
45 no-infra-types-in-ports: error48 no-infra-types-in-ports: error
46 no-adapter-to-adapter-imports: warn49 no-adapter-to-adapter-imports: warn
M internal/glob/glob_test.go +40 -3
@@ -10,15 +10,52 @@ func TestMatch(t *testing.T) {
1010 }{
1111 {"internal/domain/**", "internal/domain/user.go", true},
1212 {"internal/domain/**", "internal/domain/entity/user.go", true},
13+ {"internal/domain/**", "internal/domain", true},
1314 {"cmd/**", "cmd/hexcheck/main.go", true},
1415 {"cmd/**", "internal/cmd/main.go", false},
1516 {"internal/*/port/**", "internal/application/port/user.go", true},
1617 {"internal/*/port/**", "internal/application/usecase/user.go", false},
1718 {"internal/foo_test.go", "internal/foo_test.go", true},
19+ {"**/mocks/**", "mocks/m.go", true},
20+ {"**/mocks/**", "internal/a/b/mocks/m.go", true},
21+ {"**/mocks/**", "internal/mocksy/m.go", false},
22+ {"internal/**/repo.go", "internal/repo.go", true},
23+ {"internal/**/repo.go", "internal/a/b/repo.go", true},
24+ {"internal/**/repo.go", "internal/a/b/other.go", false},
25+ {"**/*_gen.go", "a/b/x_gen.go", true},
26+ {"internal/[ab]*/**", "internal/adapters/x.go", true},
27+ {"internal/[ab]*/**", "internal/core/x.go", false},
28+ {"internal/[/**", "internal/x.go", false},
29+ {"./internal/**/", "internal/x.go", true},
30+ {`internal\domain\**`, `internal\domain\user.go`, true},
31+ {"", "", true},
32+ {"", "x", false},
33+ {"internal", "internal/x.go", false},
1834 }
1935 for _, tt := range tests {
20- if got := Match(tt.pattern, tt.name); got != tt.want {
21- t.Fatalf("Match(%q, %q) = %v, want %v", tt.pattern, tt.name, got, tt.want)
22- }
36+ t.Run(tt.pattern+"|"+tt.name, func(t *testing.T) {
37+ if got := Match(tt.pattern, tt.name); got != tt.want {
38+ t.Fatalf("Match(%q, %q) = %v, want %v", tt.pattern, tt.name, got, tt.want)
39+ }
40+ })
41+ }
42+}
43+
44+func TestSpecificity(t *testing.T) {
45+ tests := []struct {
46+ pattern string
47+ want int
48+ }{
49+ {"internal/domain/**", len("internal/domain/")},
50+ {"internal/domain/user.go", len("internal/domain/user.go")},
51+ {"**/mocks/**", 0},
52+ {"./cmd/[ab]", len("cmd/")},
53+ }
54+ for _, tt := range tests {
55+ t.Run(tt.pattern, func(t *testing.T) {
56+ if got := Specificity(tt.pattern); got != tt.want {
57+ t.Fatalf("Specificity(%q) = %d, want %d", tt.pattern, got, tt.want)
58+ }
59+ })
2360 }
2461 }
@@ -10,15 +10,52 @@ func TestMatch(t *testing.T) {
10 }{10 }{
11 {"internal/domain/**", "internal/domain/user.go", true},11 {"internal/domain/**", "internal/domain/user.go", true},
12 {"internal/domain/**", "internal/domain/entity/user.go", true},12 {"internal/domain/**", "internal/domain/entity/user.go", true},
13+ {"internal/domain/**", "internal/domain", true},
13 {"cmd/**", "cmd/hexcheck/main.go", true},14 {"cmd/**", "cmd/hexcheck/main.go", true},
14 {"cmd/**", "internal/cmd/main.go", false},15 {"cmd/**", "internal/cmd/main.go", false},
15 {"internal/*/port/**", "internal/application/port/user.go", true},16 {"internal/*/port/**", "internal/application/port/user.go", true},
16 {"internal/*/port/**", "internal/application/usecase/user.go", false},17 {"internal/*/port/**", "internal/application/usecase/user.go", false},
17 {"internal/foo_test.go", "internal/foo_test.go", true},18 {"internal/foo_test.go", "internal/foo_test.go", true},
19+ {"**/mocks/**", "mocks/m.go", true},
20+ {"**/mocks/**", "internal/a/b/mocks/m.go", true},
21+ {"**/mocks/**", "internal/mocksy/m.go", false},
22+ {"internal/**/repo.go", "internal/repo.go", true},
23+ {"internal/**/repo.go", "internal/a/b/repo.go", true},
24+ {"internal/**/repo.go", "internal/a/b/other.go", false},
25+ {"**/*_gen.go", "a/b/x_gen.go", true},
26+ {"internal/[ab]*/**", "internal/adapters/x.go", true},
27+ {"internal/[ab]*/**", "internal/core/x.go", false},
28+ {"internal/[/**", "internal/x.go", false},
29+ {"./internal/**/", "internal/x.go", true},
30+ {`internal\domain\**`, `internal\domain\user.go`, true},
31+ {"", "", true},
32+ {"", "x", false},
33+ {"internal", "internal/x.go", false},
18 }34 }
19 for _, tt := range tests {35 for _, tt := range tests {
20- if got := Match(tt.pattern, tt.name); got != tt.want {36+ t.Run(tt.pattern+"|"+tt.name, func(t *testing.T) {
21- t.Fatalf("Match(%q, %q) = %v, want %v", tt.pattern, tt.name, got, tt.want)37+ if got := Match(tt.pattern, tt.name); got != tt.want {
22- }38+ t.Fatalf("Match(%q, %q) = %v, want %v", tt.pattern, tt.name, got, tt.want)
39+ }
40+ })
41+ }
42+}
43+
44+func TestSpecificity(t *testing.T) {
45+ tests := []struct {
46+ pattern string
47+ want int
48+ }{
49+ {"internal/domain/**", len("internal/domain/")},
50+ {"internal/domain/user.go", len("internal/domain/user.go")},
51+ {"**/mocks/**", 0},
52+ {"./cmd/[ab]", len("cmd/")},
53+ }
54+ for _, tt := range tests {
55+ t.Run(tt.pattern, func(t *testing.T) {
56+ if got := Specificity(tt.pattern); got != tt.want {
57+ t.Fatalf("Specificity(%q) = %d, want %d", tt.pattern, got, tt.want)
58+ }
59+ })
23 }60 }
24 }61 }
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