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
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: | |||
| 6 | 6 | pull_request: | |
| 7 | 7 | ||
| 8 | 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 | 20 | test: | |
| 10 | 21 | runs-on: ubuntu-latest | |
| 11 | 22 | steps: | |
| @@ -18,4 +29,6 @@ jobs: | |||
| 18 | 29 | go mod tidy | |
| 19 | 30 | git diff --exit-code -- go.mod go.sum | |
| 20 | 31 | - 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-latest | 21 | runs-on: ubuntu-latest | ||
| 11 | steps: | 22 | steps: | ||
| @@ -18,4 +29,6 @@ jobs: | |||||
| 18 | go mod tidy | 29 | go mod tidy | ||
| 19 | git diff --exit-code -- go.mod go.sum | 30 | git diff --exit-code -- go.mod go.sum | ||
| 20 | - name: Test | 31 | - 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/ | |||
| 2 | 2 | custom-gcl | |
| 3 | 3 | coverage.out | |
| 4 | 4 | .DS_Store | |
| 5 | + | .worktrees/ | |
| 6 | + | dist/ | |
| @@ -2,3 +2,5 @@ bin/ | |||||
| 2 | custom-gcl | 2 | custom-gcl | ||
| 3 | coverage.out | 3 | coverage.out | ||
| 4 | .DS_Store | 4 | .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 @@ | |||
| 1 | 1 | # 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 | 26 | ## v0.1.0 | |
| 4 | 27 | ||
| 5 | 28 | Initial release of `hexcheck`. | |
| @@ -1,5 +1,28 @@ | |||||
| 1 | # Changelog | 1 | # 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.0 | 26 | ## 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 | |
| 2 | 2 | ||
| 3 | 3 | test: | |
| 4 | - | go test ./... | |
| 4 | + | go test -race ./... | |
| 5 | 5 | ||
| 6 | 6 | lint: | |
| 7 | 7 | golangci-lint run ./... | |
| @@ -9,7 +9,10 @@ lint: | |||
| 9 | 9 | tidy: | |
| 10 | 10 | go mod tidy | |
| 11 | 11 | ||
| 12 | - | check: tidy test | |
| 12 | + | self-check: | |
| 13 | + | go run ./cmd/hexcheck -fail-on warn ./... | |
| 14 | + | ||
| 15 | + | check: tidy lint test self-check | |
| 13 | 16 | ||
| 14 | 17 | smoke-local: | |
| 15 | 18 | @if [ -z "$(HEXCHECK_SMOKE_REPO)" ]; then \ | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | - | .PHONY: test lint tidy check smoke-local | 1 | + | .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 tidy | 10 | go mod tidy | ||
| 11 | 11 | ||||
| 12 | - | check: tidy test | 12 | + | 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: | |||
| 66 | 66 | ||
| 67 | 67 | rules: | |
| 68 | 68 | no-adapter-imports-in-core: error | |
| 69 | + | no-usecase-imports-in-core: error | |
| 69 | 70 | no-infra-imports-in-usecase: error | |
| 71 | + | no-infra-imports-in-ports: error | |
| 72 | + | no-entrypoint-imports-in-adapter: error | |
| 70 | 73 | no-framework-types-in-core: error | |
| 71 | 74 | no-infra-types-in-ports: error | |
| 72 | 75 | no-adapter-to-adapter-imports: warn | |
| @@ -99,20 +102,62 @@ A fuller example lives in [`examples/hexcheck.yaml`](examples/hexcheck.yaml). | |||
| 99 | 102 | ||
| 100 | 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 CLI | |
| 105 | + | ## 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 | - | ```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 | + | } | |
| 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 | 141 | ```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 | 161 | ## golangci-lint module plugin | |
| 117 | 162 | ||
| 118 | 163 | Build a custom golangci-lint binary using the module plugin system: | |
| @@ -149,6 +194,8 @@ linters: | |||
| 149 | 194 | 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 | 199 | ## Agent configuration guide | |
| 153 | 200 | ||
| 154 | 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 | 203 | ## Local development | |
| 157 | 204 | ||
| 158 | 205 | ```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 | |
| 161 | 208 | HEXCHECK_SMOKE_REPO=/path/to/local/go/repo make smoke-local | |
| 162 | 209 | ``` | |
| @@ -66,7 +66,10 @@ components: | |||||
| 66 | 66 | ||||
| 67 | rules: | 67 | rules: | ||
| 68 | no-adapter-imports-in-core: error | 68 | no-adapter-imports-in-core: error | ||
| 69 | + | no-usecase-imports-in-core: error | |||
| 69 | no-infra-imports-in-usecase: error | 70 | 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: error | 73 | no-framework-types-in-core: error | ||
| 71 | no-infra-types-in-ports: error | 74 | no-infra-types-in-ports: error | ||
| 72 | no-adapter-to-adapter-imports: warn | 75 | 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 CLI | 105 | + | ## 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 | - | ```bash | 122 | + | 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 | ```bash | 141 | ```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 plugin | 161 | ## 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.yaml | 194 | 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 guide | 199 | ## 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 development | 203 | ## Local development | ||
| 157 | 204 | ||||
| 158 | ```bash | 205 | ```bash | ||
| 159 | - | make test | 206 | + | make test # go test -race |
| 160 | - | make check | 207 | + | make check # tidy, lint, test, and hexcheck on its own code |
| 161 | HEXCHECK_SMOKE_REPO=/path/to/local/go/repo make smoke-local | 208 | 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 | |||
| 5 | 5 | ||
| 6 | 6 | # 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 | 10 | Roles: | |
| 11 | 11 | - `core`: domain/core business logic | |
| @@ -35,7 +35,10 @@ Recommended rules: | |||
| 35 | 35 | ```yaml | |
| 36 | 36 | rules: | |
| 37 | 37 | no-adapter-imports-in-core: error | |
| 38 | + | no-usecase-imports-in-core: error | |
| 38 | 39 | no-infra-imports-in-usecase: error | |
| 40 | + | no-infra-imports-in-ports: error | |
| 41 | + | no-entrypoint-imports-in-adapter: error | |
| 39 | 42 | no-framework-types-in-core: error | |
| 40 | 43 | no-infra-types-in-ports: error | |
| 41 | 44 | no-adapter-to-adapter-imports: warn | |
| @@ -66,7 +69,10 @@ mocking: | |||
| 66 | 69 | Run: | |
| 67 | 70 | ||
| 68 | 71 | ```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. | |
| @@ -5,7 +5,7 @@ description: Use when configuring or running hexcheck in a Go repo for hexagonal | |||||
| 5 | 5 | ||||
| 6 | # Hexcheck | 6 | # 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 logic | 11 | - `core`: domain/core business logic | ||
| @@ -35,7 +35,10 @@ Recommended rules: | |||||
| 35 | ```yaml | 35 | ```yaml | ||
| 36 | rules: | 36 | rules: | ||
| 37 | no-adapter-imports-in-core: error | 37 | no-adapter-imports-in-core: error | ||
| 38 | + | no-usecase-imports-in-core: error | |||
| 38 | no-infra-imports-in-usecase: error | 39 | 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: error | 42 | no-framework-types-in-core: error | ||
| 40 | no-infra-types-in-ports: error | 43 | no-infra-types-in-ports: error | ||
| 41 | no-adapter-to-adapter-imports: warn | 44 | no-adapter-to-adapter-imports: warn | ||
| @@ -66,7 +69,10 @@ mocking: | |||||
| 66 | Run: | 69 | Run: | ||
| 67 | 70 | ||||
| 68 | ```bash | 71 | ```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 @@ | |||
| 1 | 1 | package analyzer | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "go/ast" | |
| 5 | 6 | "go/token" | |
| 6 | 7 | "go/types" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 10 | + | "reflect" | |
| 9 | 11 | "strconv" | |
| 10 | 12 | "strings" | |
| 13 | + | "sync" | |
| 11 | 14 | ||
| 12 | 15 | "github.com/bnema/hexcheck/config" | |
| 13 | 16 | "github.com/bnema/hexcheck/internal/glob" | |
| @@ -23,26 +26,60 @@ type Options struct { | |||
| 23 | 26 | 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 | 54 | func New(opts Options) *analysis.Analyzer { | |
| 27 | 55 | var configPathFlag string | |
| 28 | 56 | var rootFlag string | |
| 29 | 57 | var modulePathFlag string | |
| 58 | + | st := &state{} | |
| 30 | 59 | ||
| 31 | 60 | 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](), | |
| 34 | 64 | 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 | |
| 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 | 81 | r.run() | |
| 45 | - | return nil, nil | |
| 82 | + | return &Result{Severities: st.cfg.Rules}, nil | |
| 46 | 83 | }, | |
| 47 | 84 | } | |
| 48 | 85 | if opts.Config == nil { | |
| @@ -138,6 +175,8 @@ type runner struct { | |||
| 138 | 175 | pass *analysis.Pass | |
| 139 | 176 | cfg *config.Config | |
| 140 | 177 | modulePath string | |
| 178 | + | state *state | |
| 179 | + | ignores ignoreIndex | |
| 141 | 180 | } | |
| 142 | 181 | ||
| 143 | 182 | func (r runner) run() { | |
| @@ -146,12 +185,14 @@ func (r runner) run() { | |||
| 146 | 185 | if !ok || current.Role == config.RoleIgnore { | |
| 147 | 186 | return | |
| 148 | 187 | } | |
| 188 | + | r.ignores = ignoreIndex{} | |
| 149 | 189 | businessDiagnostics := 0 | |
| 150 | 190 | for _, file := range r.pass.Files { | |
| 151 | 191 | filePath := r.filePath(file) | |
| 152 | 192 | if isGeneratedFile(file) { | |
| 153 | 193 | continue | |
| 154 | 194 | } | |
| 195 | + | r.collectIgnores(file) | |
| 155 | 196 | r.checkImports(file, filePath, current) | |
| 156 | 197 | r.checkTypeLeaks(file, filePath, current) | |
| 157 | 198 | r.checkMissingMocks(file, filePath, current) | |
| @@ -171,8 +212,14 @@ func (r runner) checkImports(file *ast.File, filePath string, current config.Mat | |||
| 171 | 212 | switch { | |
| 172 | 213 | case current.Role == config.RoleCore && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint): | |
| 173 | 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: | |
| 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) | |
| 176 | 223 | case current.Role == config.RoleAdapter && imported.Role == config.RoleAdapter && current.Name != imported.Name: | |
| 177 | 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 | 263 | return | |
| 217 | 264 | } | |
| 218 | 265 | 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) | |
| 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 | 276 | if t == nil { | |
| 227 | 277 | 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 | 286 | switch tt := t.(type) { | |
| 287 | + | case *types.Alias: | |
| 288 | + | return r.isInfraType(types.Unalias(tt), seen) | |
| 230 | 289 | case *types.Pointer: | |
| 231 | - | return r.isInfraType(tt.Elem()) | |
| 290 | + | return r.isInfraType(tt.Elem(), seen) | |
| 232 | 291 | case *types.Slice: | |
| 233 | - | return r.isInfraType(tt.Elem()) | |
| 292 | + | return r.isInfraType(tt.Elem(), seen) | |
| 234 | 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 | 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 | 315 | case *types.Named: | |
| 316 | + | for arg := range tt.TypeArgs().Types() { | |
| 317 | + | if r.isInfraType(arg, seen) { | |
| 318 | + | return true | |
| 319 | + | } | |
| 320 | + | } | |
| 239 | 321 | obj := tt.Obj() | |
| 240 | 322 | if obj == nil || obj.Pkg() == nil { | |
| 241 | 323 | return false | |
| @@ -260,11 +342,17 @@ func (r runner) matchesExternal(pkg string) bool { | |||
| 260 | 342 | 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 | 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 | 349 | 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 | 356 | return true | |
| 269 | 357 | } | |
| 270 | 358 | ||
| @@ -1,13 +1,16 @@ | |||||
| 1 | package analyzer | 1 | 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 string | 26 | 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 string | 55 | var configPathFlag string | ||
| 28 | var rootFlag string | 56 | var rootFlag string | ||
| 29 | var modulePathFlag string | 57 | 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, err | 67 | + | 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, nil | 82 | + | 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.Pass | 175 | pass *analysis.Pass | ||
| 139 | cfg *config.Config | 176 | cfg *config.Config | ||
| 140 | modulePath string | 177 | 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 | return | 186 | return | ||
| 148 | } | 187 | } | ||
| 188 | + | r.ignores = ignoreIndex{} | |||
| 149 | businessDiagnostics := 0 | 189 | 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 | continue | 193 | 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 | return | 263 | 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 false | 277 | 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 false | 323 | return false | ||
| @@ -260,11 +342,17 @@ func (r runner) matchesExternal(pkg string) bool { | |||||
| 260 | return false | 342 | 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 false | 349 | 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 true | 356 | return true | ||
| 269 | } | 357 | } | ||
| 270 | 358 | ||||
M analyzer/analyzer_test.go +2 -0
| @@ -17,7 +17,9 @@ func TestAnalyzer(t *testing.T) { | |||
| 17 | 17 | name: "boundary imports", | |
| 18 | 18 | patterns: []string{ | |
| 19 | 19 | "example.com/project/internal/domain", | |
| 20 | + | "example.com/project/internal/policy", | |
| 20 | 21 | "example.com/project/internal/application/usecase", | |
| 22 | + | "example.com/project/internal/infrastructure/sql", | |
| 21 | 23 | }, | |
| 22 | 24 | }, | |
| 23 | 25 | { | |
| @@ -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 ( | |||
| 8 | 8 | "golang.org/x/tools/go/analysis/analysistest" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | const businessLogicRule = "suspicious-business-logic-in-adapter" | |
| 12 | + | ||
| 11 | 13 | func TestBusinessLogicGoldenCases(t *testing.T) { | |
| 12 | 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{} | |
| 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) | |
| 18 | 21 | } | |
| 19 | 22 | ||
| 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) | |
| 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) { | |
| 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 { | |
| 43 | 42 | if !strings.HasSuffix(file, fileSuffix) { | |
| 44 | 43 | continue | |
| 45 | 44 | } | |
| 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 | |
| 49 | 48 | } | |
| 50 | 49 | } | |
| 51 | 50 | } | |
| 51 | + | return false | |
| 52 | 52 | } | |
| @@ -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 | - | continue | 32 | + | 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 | continue | 43 | 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 ( | |||
| 7 | 7 | "go/types" | |
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | - | "sort" | |
| 11 | 10 | "strconv" | |
| 12 | 11 | "strings" | |
| 13 | - | "sync" | |
| 14 | 12 | ||
| 15 | 13 | "github.com/bnema/hexcheck/config" | |
| 16 | 14 | "github.com/bnema/hexcheck/internal/glob" | |
| @@ -110,13 +108,11 @@ func (r runner) importedPortInterfaces(file *ast.File) []portInterface { | |||
| 110 | 108 | return out | |
| 111 | 109 | } | |
| 112 | 110 | ||
| 113 | - | var mockIndexCache sync.Map | |
| 114 | - | ||
| 115 | 111 | func (r runner) generatedMockExists(interfaceName string) bool { | |
| 116 | 112 | if r.cfg.Root == "" || len(r.cfg.Mocking.GeneratedMockPaths) == 0 { | |
| 117 | 113 | return false | |
| 118 | 114 | } | |
| 119 | - | index := cachedMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths) | |
| 115 | + | index := r.mockIndex() | |
| 120 | 116 | for _, name := range r.expectedMockNames(interfaceName) { | |
| 121 | 117 | if index[name] { | |
| 122 | 118 | return true | |
| @@ -125,20 +121,17 @@ func (r runner) generatedMockExists(interfaceName string) bool { | |||
| 125 | 121 | return false | |
| 126 | 122 | } | |
| 127 | 123 | ||
| 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) | |
| 132 | 130 | } | |
| 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 | |
| 142 | 135 | } | |
| 143 | 136 | ||
| 144 | 137 | func buildMockIndex(root string, patterns []string) map[string]bool { | |
| @@ -210,9 +203,6 @@ func isTestDoubleName(name string) bool { | |||
| 210 | 203 | } | |
| 211 | 204 | ||
| 212 | 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 | 206 | if len(name) < len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) { | |
| 217 | 207 | return false | |
| 218 | 208 | } | |
| @@ -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 out | 108 | 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 false | 113 | 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 true | 118 | return true | ||
| @@ -125,20 +121,17 @@ func (r runner) generatedMockExists(interfaceName string) bool { | |||||
| 125 | return false | 121 | 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 false | 207 | 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 @@ | |||
| 1 | 1 | 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 | 5 | type UserRepository interface { | |
| 6 | 6 | Find() (*sql.Row, error) // want "no-infra-types-in-ports" | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | package port | 1 | 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 | |
| 1 | 5 | package main | |
| 2 | 6 | ||
| 3 | 7 | import ( | |
| 8 | + | "errors" | |
| 9 | + | "flag" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "os" | |
| 13 | + | ||
| 4 | 14 | "github.com/bnema/hexcheck/analyzer" | |
| 5 | - | "golang.org/x/tools/go/analysis/multichecker" | |
| 15 | + | "github.com/bnema/hexcheck/config" | |
| 16 | + | "golang.org/x/tools/go/analysis" | |
| 17 | + | "golang.org/x/tools/go/analysis/checker" | |
| 18 | + | "golang.org/x/tools/go/packages" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // Exit codes. | |
| 22 | + | const ( | |
| 23 | + | exitOK = 0 | |
| 24 | + | exitFailure = 1 // load, config, or analysis failure | |
| 25 | + | exitUsage = 2 | |
| 26 | + | exitFindings = 3 // diagnostics at or above -fail-on | |
| 6 | 27 | ) | |
| 7 | 28 | ||
| 29 | + | // version is set at release time with -ldflags "-X main.version=...". | |
| 30 | + | var version = "dev" | |
| 31 | + | ||
| 8 | 32 | func main() { | |
| 9 | - | multichecker.Main(analyzer.New(analyzer.Options{})) | |
| 33 | + | os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func run(args []string, stdout, stderr io.Writer) int { | |
| 37 | + | if len(args) > 0 && args[0] == "init" { | |
| 38 | + | return runInit(args[1:], stdout, stderr) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | fs := flag.NewFlagSet("hexcheck", flag.ContinueOnError) | |
| 42 | + | fs.SetOutput(stderr) | |
| 43 | + | configPath := fs.String("config", "", "path to .hexcheck.yaml (default: discovered from -root upwards)") | |
| 44 | + | root := fs.String("root", "", "project root for config-relative paths") | |
| 45 | + | modulePath := fs.String("module", "", "Go module path (default: module in go.mod)") | |
| 46 | + | tests := fs.Bool("test", true, "also analyze test files") | |
| 47 | + | jsonOut := fs.Bool("json", false, "print diagnostics as JSON") | |
| 48 | + | failOn := fs.String("fail-on", "error", "lowest severity that makes the exit code non-zero: error or warn") | |
| 49 | + | showVersion := fs.Bool("version", false, "print the version and exit") | |
| 50 | + | fs.Usage = func() { | |
| 51 | + | fmt.Fprintln(stderr, "usage: hexcheck [flags] [packages]\n hexcheck init [-force] [dir]") | |
| 52 | + | fs.PrintDefaults() | |
| 53 | + | } | |
| 54 | + | if err := fs.Parse(args); err != nil { | |
| 55 | + | if errors.Is(err, flag.ErrHelp) { | |
| 56 | + | return exitOK | |
| 57 | + | } | |
| 58 | + | return exitUsage | |
| 59 | + | } | |
| 60 | + | if *showVersion { | |
| 61 | + | fmt.Fprintln(stdout, "hexcheck", version) | |
| 62 | + | return exitOK | |
| 63 | + | } | |
| 64 | + | if *failOn != string(config.SeverityError) && *failOn != string(config.SeverityWarn) { | |
| 65 | + | fmt.Fprintf(stderr, "hexcheck: -fail-on must be error or warn, got %q\n", *failOn) | |
| 66 | + | return exitUsage | |
| 67 | + | } | |
| 68 | + | patterns := fs.Args() | |
| 69 | + | if len(patterns) == 0 { | |
| 70 | + | patterns = []string{"./..."} | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | a := analyzer.New(analyzer.Options{ConfigPath: *configPath, Root: *root, ModulePath: *modulePath}) | |
| 74 | + | pkgs, err := packages.Load(&packages.Config{Mode: packages.LoadSyntax | packages.NeedModule, Tests: *tests, Dir: *root}, patterns...) | |
| 75 | + | if err != nil { | |
| 76 | + | fmt.Fprintf(stderr, "hexcheck: %v\n", err) | |
| 77 | + | return exitFailure | |
| 78 | + | } | |
| 79 | + | if packages.PrintErrors(pkgs) > 0 { | |
| 80 | + | return exitFailure | |
| 81 | + | } | |
| 82 | + | graph, err := checker.Analyze([]*analysis.Analyzer{a}, pkgs, nil) | |
| 83 | + | if err != nil { | |
| 84 | + | fmt.Fprintf(stderr, "hexcheck: %v\n", err) | |
| 85 | + | return exitFailure | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | if *jsonOut { | |
| 89 | + | if err := graph.PrintJSON(stdout); err != nil { | |
| 90 | + | return exitFailure | |
| 91 | + | } | |
| 92 | + | } else if err := graph.PrintText(stderr, -1); err != nil { | |
| 93 | + | return exitFailure | |
| 94 | + | } | |
| 95 | + | return exitCode(graph, config.Severity(*failOn)) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // exitCode maps diagnostics to an exit code using each rule's configured | |
| 99 | + | // severity, so warn-level rules do not fail CI unless -fail-on=warn. | |
| 100 | + | func exitCode(graph *checker.Graph, failOn config.Severity) int { | |
| 101 | + | code := exitOK | |
| 102 | + | for act := range graph.All() { | |
| 103 | + | if act.Err != nil { | |
| 104 | + | return exitFailure | |
| 105 | + | } | |
| 106 | + | if !act.IsRoot { | |
| 107 | + | continue | |
| 108 | + | } | |
| 109 | + | result, _ := act.Result.(*analyzer.Result) | |
| 110 | + | for _, diag := range act.Diagnostics { | |
| 111 | + | severity := config.SeverityError | |
| 112 | + | if diag.Category != analyzer.DirectiveCategory { | |
| 113 | + | severity = result.Severity(diag.Category) | |
| 114 | + | } | |
| 115 | + | if severity == config.SeverityError || (failOn == config.SeverityWarn && severity == config.SeverityWarn) { | |
| 116 | + | code = exitFindings | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | return code | |
| 10 | 121 | } | |
| @@ -1,10 +1,121 @@ | |||||
| 1 | + | // Command hexcheck runs the hexcheck analyzer on Go packages. | |||
| 2 | + | // | |||
| 3 | + | // hexcheck [flags] [packages] check packages (default ./...) | |||
| 4 | + | // hexcheck init [-force] [dir] write a starter .hexcheck.yaml | |||
| 1 | package main | 5 | package main | ||
| 2 | 6 | ||||
| 3 | import ( | 7 | import ( | ||
| 8 | + | "errors" | |||
| 9 | + | "flag" | |||
| 10 | + | "fmt" | |||
| 11 | + | "io" | |||
| 12 | + | "os" | |||
| 13 | + | ||||
| 4 | "github.com/bnema/hexcheck/analyzer" | 14 | "github.com/bnema/hexcheck/analyzer" | ||
| 5 | - | "golang.org/x/tools/go/analysis/multichecker" | 15 | + | "github.com/bnema/hexcheck/config" |
| 16 | + | "golang.org/x/tools/go/analysis" | |||
| 17 | + | "golang.org/x/tools/go/analysis/checker" | |||
| 18 | + | "golang.org/x/tools/go/packages" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // Exit codes. | |||
| 22 | + | const ( | |||
| 23 | + | exitOK = 0 | |||
| 24 | + | exitFailure = 1 // load, config, or analysis failure | |||
| 25 | + | exitUsage = 2 | |||
| 26 | + | exitFindings = 3 // diagnostics at or above -fail-on | |||
| 6 | ) | 27 | ) | ||
| 7 | 28 | ||||
| 29 | + | // version is set at release time with -ldflags "-X main.version=...". | |||
| 30 | + | var version = "dev" | |||
| 31 | + | ||||
| 8 | func main() { | 32 | func main() { | ||
| 9 | - | multichecker.Main(analyzer.New(analyzer.Options{})) | 33 | + | os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) |
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func run(args []string, stdout, stderr io.Writer) int { | |||
| 37 | + | if len(args) > 0 && args[0] == "init" { | |||
| 38 | + | return runInit(args[1:], stdout, stderr) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | fs := flag.NewFlagSet("hexcheck", flag.ContinueOnError) | |||
| 42 | + | fs.SetOutput(stderr) | |||
| 43 | + | configPath := fs.String("config", "", "path to .hexcheck.yaml (default: discovered from -root upwards)") | |||
| 44 | + | root := fs.String("root", "", "project root for config-relative paths") | |||
| 45 | + | modulePath := fs.String("module", "", "Go module path (default: module in go.mod)") | |||
| 46 | + | tests := fs.Bool("test", true, "also analyze test files") | |||
| 47 | + | jsonOut := fs.Bool("json", false, "print diagnostics as JSON") | |||
| 48 | + | failOn := fs.String("fail-on", "error", "lowest severity that makes the exit code non-zero: error or warn") | |||
| 49 | + | showVersion := fs.Bool("version", false, "print the version and exit") | |||
| 50 | + | fs.Usage = func() { | |||
| 51 | + | fmt.Fprintln(stderr, "usage: hexcheck [flags] [packages]\n hexcheck init [-force] [dir]") | |||
| 52 | + | fs.PrintDefaults() | |||
| 53 | + | } | |||
| 54 | + | if err := fs.Parse(args); err != nil { | |||
| 55 | + | if errors.Is(err, flag.ErrHelp) { | |||
| 56 | + | return exitOK | |||
| 57 | + | } | |||
| 58 | + | return exitUsage | |||
| 59 | + | } | |||
| 60 | + | if *showVersion { | |||
| 61 | + | fmt.Fprintln(stdout, "hexcheck", version) | |||
| 62 | + | return exitOK | |||
| 63 | + | } | |||
| 64 | + | if *failOn != string(config.SeverityError) && *failOn != string(config.SeverityWarn) { | |||
| 65 | + | fmt.Fprintf(stderr, "hexcheck: -fail-on must be error or warn, got %q\n", *failOn) | |||
| 66 | + | return exitUsage | |||
| 67 | + | } | |||
| 68 | + | patterns := fs.Args() | |||
| 69 | + | if len(patterns) == 0 { | |||
| 70 | + | patterns = []string{"./..."} | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | a := analyzer.New(analyzer.Options{ConfigPath: *configPath, Root: *root, ModulePath: *modulePath}) | |||
| 74 | + | pkgs, err := packages.Load(&packages.Config{Mode: packages.LoadSyntax | packages.NeedModule, Tests: *tests, Dir: *root}, patterns...) | |||
| 75 | + | if err != nil { | |||
| 76 | + | fmt.Fprintf(stderr, "hexcheck: %v\n", err) | |||
| 77 | + | return exitFailure | |||
| 78 | + | } | |||
| 79 | + | if packages.PrintErrors(pkgs) > 0 { | |||
| 80 | + | return exitFailure | |||
| 81 | + | } | |||
| 82 | + | graph, err := checker.Analyze([]*analysis.Analyzer{a}, pkgs, nil) | |||
| 83 | + | if err != nil { | |||
| 84 | + | fmt.Fprintf(stderr, "hexcheck: %v\n", err) | |||
| 85 | + | return exitFailure | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | if *jsonOut { | |||
| 89 | + | if err := graph.PrintJSON(stdout); err != nil { | |||
| 90 | + | return exitFailure | |||
| 91 | + | } | |||
| 92 | + | } else if err := graph.PrintText(stderr, -1); err != nil { | |||
| 93 | + | return exitFailure | |||
| 94 | + | } | |||
| 95 | + | return exitCode(graph, config.Severity(*failOn)) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // exitCode maps diagnostics to an exit code using each rule's configured | |||
| 99 | + | // severity, so warn-level rules do not fail CI unless -fail-on=warn. | |||
| 100 | + | func exitCode(graph *checker.Graph, failOn config.Severity) int { | |||
| 101 | + | code := exitOK | |||
| 102 | + | for act := range graph.All() { | |||
| 103 | + | if act.Err != nil { | |||
| 104 | + | return exitFailure | |||
| 105 | + | } | |||
| 106 | + | if !act.IsRoot { | |||
| 107 | + | continue | |||
| 108 | + | } | |||
| 109 | + | result, _ := act.Result.(*analyzer.Result) | |||
| 110 | + | for _, diag := range act.Diagnostics { | |||
| 111 | + | severity := config.SeverityError | |||
| 112 | + | if diag.Category != analyzer.DirectiveCategory { | |||
| 113 | + | severity = result.Severity(diag.Category) | |||
| 114 | + | } | |||
| 115 | + | if severity == config.SeverityError || (failOn == config.SeverityWarn && severity == config.SeverityWarn) { | |||
| 116 | + | code = exitFindings | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | return code | |||
| 10 | } | 121 | } | ||
A cmd/hexcheck/main_test.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/hexcheck/config" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // writeModule creates a tiny module with one core->adapter import (error rule) | |
| 14 | + | // and one adapter->adapter import (warn rule). | |
| 15 | + | func writeModule(t *testing.T, rules string) string { | |
| 16 | + | t.Helper() | |
| 17 | + | dir := t.TempDir() | |
| 18 | + | files := map[string]string{ | |
| 19 | + | "go.mod": "module example.com/m\n\ngo 1.25\n", | |
| 20 | + | "internal/domain/d.go": "package domain\n\nimport _ \"example.com/m/internal/adapters/db\"\n", | |
| 21 | + | "internal/adapters/db/db.go": "package db\n", | |
| 22 | + | "internal/adapters/web/w.go": "package web\n\nimport _ \"example.com/m/internal/adapters/db\"\n", | |
| 23 | + | ".hexcheck.yaml": `version: 1 | |
| 24 | + | components: | |
| 25 | + | core: { role: core, paths: [internal/domain/**] } | |
| 26 | + | db: { role: adapter, paths: [internal/adapters/db/**] } | |
| 27 | + | web: { role: adapter, paths: [internal/adapters/web/**] } | |
| 28 | + | ` + rules, | |
| 29 | + | } | |
| 30 | + | for name, content := range files { | |
| 31 | + | path := filepath.Join(dir, name) | |
| 32 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | if err := os.WriteFile(path, []byte(content), 0o600); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | return dir | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestRunExitCodes(t *testing.T) { | |
| 43 | + | tests := []struct { | |
| 44 | + | name string | |
| 45 | + | rules string | |
| 46 | + | args []string | |
| 47 | + | wantCode int | |
| 48 | + | wantOut []string | |
| 49 | + | }{ | |
| 50 | + | { | |
| 51 | + | name: "error rule fails", | |
| 52 | + | args: nil, | |
| 53 | + | wantCode: exitFindings, | |
| 54 | + | wantOut: []string{"[error] no-adapter-imports-in-core", "[warn] no-adapter-to-adapter-imports"}, | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | name: "warn only passes by default", | |
| 58 | + | rules: "rules:\n no-adapter-imports-in-core: warn\n", | |
| 59 | + | wantCode: exitOK, | |
| 60 | + | wantOut: []string{"[warn] no-adapter-imports-in-core"}, | |
| 61 | + | }, | |
| 62 | + | { | |
| 63 | + | name: "warn fails with -fail-on=warn", | |
| 64 | + | rules: "rules:\n no-adapter-imports-in-core: warn\n", | |
| 65 | + | args: []string{"-fail-on", "warn"}, | |
| 66 | + | wantCode: exitFindings, | |
| 67 | + | }, | |
| 68 | + | { | |
| 69 | + | name: "invalid config fails", | |
| 70 | + | rules: "rules:\n not-a-rule: error\n", | |
| 71 | + | wantCode: exitFailure, | |
| 72 | + | wantOut: []string{"unknown rule"}, | |
| 73 | + | }, | |
| 74 | + | { | |
| 75 | + | name: "invalid fail-on", | |
| 76 | + | args: []string{"-fail-on", "info"}, | |
| 77 | + | wantCode: exitUsage, | |
| 78 | + | }, | |
| 79 | + | } | |
| 80 | + | for _, tt := range tests { | |
| 81 | + | t.Run(tt.name, func(t *testing.T) { | |
| 82 | + | dir := writeModule(t, tt.rules) | |
| 83 | + | var stdout, stderr bytes.Buffer | |
| 84 | + | args := append([]string{"-root", dir}, tt.args...) | |
| 85 | + | got := run(append(args, "./..."), &stdout, &stderr) | |
| 86 | + | if got != tt.wantCode { | |
| 87 | + | t.Fatalf("exit = %d, want %d\nstderr:\n%s", got, tt.wantCode, stderr.String()) | |
| 88 | + | } | |
| 89 | + | for _, want := range tt.wantOut { | |
| 90 | + | if !strings.Contains(stderr.String(), want) { | |
| 91 | + | t.Fatalf("stderr missing %q:\n%s", want, stderr.String()) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestRunJSON(t *testing.T) { | |
| 99 | + | dir := writeModule(t, "") | |
| 100 | + | var stdout, stderr bytes.Buffer | |
| 101 | + | if got := run([]string{"-root", dir, "-json", "./..."}, &stdout, &stderr); got != exitFindings { | |
| 102 | + | t.Fatalf("exit = %d, want %d\nstderr:\n%s", got, exitFindings, stderr.String()) | |
| 103 | + | } | |
| 104 | + | if !strings.Contains(stdout.String(), `"category": "no-adapter-imports-in-core"`) { | |
| 105 | + | t.Fatalf("JSON output missing category:\n%s", stdout.String()) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestInit(t *testing.T) { | |
| 110 | + | dir := t.TempDir() | |
| 111 | + | for _, sub := range []string{"internal/domain", "internal/adapters", "cmd/app"} { | |
| 112 | + | if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { | |
| 113 | + | t.Fatal(err) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | var stdout, stderr bytes.Buffer | |
| 117 | + | if got := run([]string{"init", dir}, &stdout, &stderr); got != exitOK { | |
| 118 | + | t.Fatalf("init exit = %d\n%s", got, stderr.String()) | |
| 119 | + | } | |
| 120 | + | cfg, err := config.Load(filepath.Join(dir, ".hexcheck.yaml")) | |
| 121 | + | if err != nil { | |
| 122 | + | t.Fatal(err) | |
| 123 | + | } | |
| 124 | + | for name, role := range map[string]config.Role{"core": config.RoleCore, "adapters": config.RoleAdapter, "entrypoints": config.RoleEntrypoint} { | |
| 125 | + | if cfg.Components[name].Role != role { | |
| 126 | + | t.Fatalf("component %s role = %q, want %q", name, cfg.Components[name].Role, role) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | if _, ok := cfg.Components["usecases"]; ok { | |
| 130 | + | t.Fatal("usecases should not be generated without a matching directory") | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | if got := run([]string{"init", dir}, &stdout, &stderr); got != exitFailure { | |
| 134 | + | t.Fatalf("second init exit = %d, want %d", got, exitFailure) | |
| 135 | + | } | |
| 136 | + | if got := run([]string{"init", "-force", dir}, &stdout, &stderr); got != exitOK { | |
| 137 | + | t.Fatalf("init -force exit = %d\n%s", got, stderr.String()) | |
| 138 | + | } | |
| 139 | + | } | |
| @@ -0,0 +1,139 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/hexcheck/config" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // writeModule creates a tiny module with one core->adapter import (error rule) | |||
| 14 | + | // and one adapter->adapter import (warn rule). | |||
| 15 | + | func writeModule(t *testing.T, rules string) string { | |||
| 16 | + | t.Helper() | |||
| 17 | + | dir := t.TempDir() | |||
| 18 | + | files := map[string]string{ | |||
| 19 | + | "go.mod": "module example.com/m\n\ngo 1.25\n", | |||
| 20 | + | "internal/domain/d.go": "package domain\n\nimport _ \"example.com/m/internal/adapters/db\"\n", | |||
| 21 | + | "internal/adapters/db/db.go": "package db\n", | |||
| 22 | + | "internal/adapters/web/w.go": "package web\n\nimport _ \"example.com/m/internal/adapters/db\"\n", | |||
| 23 | + | ".hexcheck.yaml": `version: 1 | |||
| 24 | + | components: | |||
| 25 | + | core: { role: core, paths: [internal/domain/**] } | |||
| 26 | + | db: { role: adapter, paths: [internal/adapters/db/**] } | |||
| 27 | + | web: { role: adapter, paths: [internal/adapters/web/**] } | |||
| 28 | + | ` + rules, | |||
| 29 | + | } | |||
| 30 | + | for name, content := range files { | |||
| 31 | + | path := filepath.Join(dir, name) | |||
| 32 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | if err := os.WriteFile(path, []byte(content), 0o600); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | return dir | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestRunExitCodes(t *testing.T) { | |||
| 43 | + | tests := []struct { | |||
| 44 | + | name string | |||
| 45 | + | rules string | |||
| 46 | + | args []string | |||
| 47 | + | wantCode int | |||
| 48 | + | wantOut []string | |||
| 49 | + | }{ | |||
| 50 | + | { | |||
| 51 | + | name: "error rule fails", | |||
| 52 | + | args: nil, | |||
| 53 | + | wantCode: exitFindings, | |||
| 54 | + | wantOut: []string{"[error] no-adapter-imports-in-core", "[warn] no-adapter-to-adapter-imports"}, | |||
| 55 | + | }, | |||
| 56 | + | { | |||
| 57 | + | name: "warn only passes by default", | |||
| 58 | + | rules: "rules:\n no-adapter-imports-in-core: warn\n", | |||
| 59 | + | wantCode: exitOK, | |||
| 60 | + | wantOut: []string{"[warn] no-adapter-imports-in-core"}, | |||
| 61 | + | }, | |||
| 62 | + | { | |||
| 63 | + | name: "warn fails with -fail-on=warn", | |||
| 64 | + | rules: "rules:\n no-adapter-imports-in-core: warn\n", | |||
| 65 | + | args: []string{"-fail-on", "warn"}, | |||
| 66 | + | wantCode: exitFindings, | |||
| 67 | + | }, | |||
| 68 | + | { | |||
| 69 | + | name: "invalid config fails", | |||
| 70 | + | rules: "rules:\n not-a-rule: error\n", | |||
| 71 | + | wantCode: exitFailure, | |||
| 72 | + | wantOut: []string{"unknown rule"}, | |||
| 73 | + | }, | |||
| 74 | + | { | |||
| 75 | + | name: "invalid fail-on", | |||
| 76 | + | args: []string{"-fail-on", "info"}, | |||
| 77 | + | wantCode: exitUsage, | |||
| 78 | + | }, | |||
| 79 | + | } | |||
| 80 | + | for _, tt := range tests { | |||
| 81 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 82 | + | dir := writeModule(t, tt.rules) | |||
| 83 | + | var stdout, stderr bytes.Buffer | |||
| 84 | + | args := append([]string{"-root", dir}, tt.args...) | |||
| 85 | + | got := run(append(args, "./..."), &stdout, &stderr) | |||
| 86 | + | if got != tt.wantCode { | |||
| 87 | + | t.Fatalf("exit = %d, want %d\nstderr:\n%s", got, tt.wantCode, stderr.String()) | |||
| 88 | + | } | |||
| 89 | + | for _, want := range tt.wantOut { | |||
| 90 | + | if !strings.Contains(stderr.String(), want) { | |||
| 91 | + | t.Fatalf("stderr missing %q:\n%s", want, stderr.String()) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | }) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestRunJSON(t *testing.T) { | |||
| 99 | + | dir := writeModule(t, "") | |||
| 100 | + | var stdout, stderr bytes.Buffer | |||
| 101 | + | if got := run([]string{"-root", dir, "-json", "./..."}, &stdout, &stderr); got != exitFindings { | |||
| 102 | + | t.Fatalf("exit = %d, want %d\nstderr:\n%s", got, exitFindings, stderr.String()) | |||
| 103 | + | } | |||
| 104 | + | if !strings.Contains(stdout.String(), `"category": "no-adapter-imports-in-core"`) { | |||
| 105 | + | t.Fatalf("JSON output missing category:\n%s", stdout.String()) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestInit(t *testing.T) { | |||
| 110 | + | dir := t.TempDir() | |||
| 111 | + | for _, sub := range []string{"internal/domain", "internal/adapters", "cmd/app"} { | |||
| 112 | + | if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { | |||
| 113 | + | t.Fatal(err) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | var stdout, stderr bytes.Buffer | |||
| 117 | + | if got := run([]string{"init", dir}, &stdout, &stderr); got != exitOK { | |||
| 118 | + | t.Fatalf("init exit = %d\n%s", got, stderr.String()) | |||
| 119 | + | } | |||
| 120 | + | cfg, err := config.Load(filepath.Join(dir, ".hexcheck.yaml")) | |||
| 121 | + | if err != nil { | |||
| 122 | + | t.Fatal(err) | |||
| 123 | + | } | |||
| 124 | + | for name, role := range map[string]config.Role{"core": config.RoleCore, "adapters": config.RoleAdapter, "entrypoints": config.RoleEntrypoint} { | |||
| 125 | + | if cfg.Components[name].Role != role { | |||
| 126 | + | t.Fatalf("component %s role = %q, want %q", name, cfg.Components[name].Role, role) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | if _, ok := cfg.Components["usecases"]; ok { | |||
| 130 | + | t.Fatal("usecases should not be generated without a matching directory") | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | if got := run([]string{"init", dir}, &stdout, &stderr); got != exitFailure { | |||
| 134 | + | t.Fatalf("second init exit = %d, want %d", got, exitFailure) | |||
| 135 | + | } | |||
| 136 | + | if got := run([]string{"init", "-force", dir}, &stdout, &stderr); got != exitOK { | |||
| 137 | + | t.Fatalf("init -force exit = %d\n%s", got, stderr.String()) | |||
| 138 | + | } | |||
| 139 | + | } | |||
M config/config.go +21 -2
| @@ -1,8 +1,10 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "fmt" | |
| 7 | + | "io" | |
| 6 | 8 | "os" | |
| 7 | 9 | "path/filepath" | |
| 8 | 10 | "sort" | |
| @@ -90,8 +92,10 @@ func Load(path string) (*Config, error) { | |||
| 90 | 92 | return nil, err | |
| 91 | 93 | } | |
| 92 | 94 | 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) | |
| 95 | 99 | } | |
| 96 | 100 | if hasExplicitVersion(data) && cfg.Version == 0 { | |
| 97 | 101 | return nil, errors.New("version: unsupported value 0") | |
| @@ -185,7 +189,10 @@ func hasExplicitVersion(data []byte) bool { | |||
| 185 | 189 | func DefaultRuleSeverities() map[string]Severity { | |
| 186 | 190 | return map[string]Severity{ | |
| 187 | 191 | "no-adapter-imports-in-core": SeverityError, | |
| 192 | + | "no-usecase-imports-in-core": SeverityError, | |
| 188 | 193 | "no-infra-imports-in-usecase": SeverityError, | |
| 194 | + | "no-infra-imports-in-ports": SeverityError, | |
| 195 | + | "no-entrypoint-imports-in-adapter": SeverityError, | |
| 189 | 196 | "no-framework-types-in-core": SeverityError, | |
| 190 | 197 | "no-infra-types-in-ports": SeverityError, | |
| 191 | 198 | "no-adapter-to-adapter-imports": SeverityWarn, | |
| @@ -238,17 +245,29 @@ func (c *Config) Validate() error { | |||
| 238 | 245 | default: | |
| 239 | 246 | return errors.New("heuristics.businessLogicMinConfidence: must be low, medium, or high") | |
| 240 | 247 | } | |
| 248 | + | known := DefaultRuleSeverities() | |
| 241 | 249 | for rule, severity := range c.Rules { | |
| 250 | + | if _, ok := known[rule]; !ok { | |
| 251 | + | return fmt.Errorf("rules.%s: unknown rule", rule) | |
| 252 | + | } | |
| 242 | 253 | switch severity { | |
| 243 | 254 | case SeverityOff, SeverityWarn, SeverityError: | |
| 244 | 255 | default: | |
| 245 | 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 | 264 | for i, allow := range c.Allow { | |
| 249 | 265 | if allow.Rule == "" || allow.Path == "" || allow.Reason == "" { | |
| 250 | 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 | 272 | return nil | |
| 254 | 273 | } | |
| @@ -1,8 +1,10 @@ | |||||
| 1 | package config | 1 | 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, err | 92 | return nil, err | ||
| 91 | } | 93 | } | ||
| 92 | var cfg Config | 94 | var cfg Config | ||
| 93 | - | if err := yaml.Unmarshal(data, &cfg); err != nil { | 95 | + | decoder := yaml.NewDecoder(bytes.NewReader(data)) |
| 94 | - | return nil, err | 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) | |||
| 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 nil | 272 | return nil | ||
| 254 | } | 273 | } | ||
M config/config_test.go +35 -0
| @@ -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 | 85 | name: "invalid role", | |
| 51 | 86 | 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: 1 | 86 | yaml: `version: 1 | ||
M examples/hexcheck.yaml +3 -0
| @@ -40,7 +40,10 @@ components: | |||
| 40 | 40 | ||
| 41 | 41 | rules: | |
| 42 | 42 | no-adapter-imports-in-core: error | |
| 43 | + | no-usecase-imports-in-core: error | |
| 43 | 44 | no-infra-imports-in-usecase: error | |
| 45 | + | no-infra-imports-in-ports: error | |
| 46 | + | no-entrypoint-imports-in-adapter: error | |
| 44 | 47 | no-framework-types-in-core: error | |
| 45 | 48 | no-infra-types-in-ports: error | |
| 46 | 49 | no-adapter-to-adapter-imports: warn | |
| @@ -40,7 +40,10 @@ components: | |||||
| 40 | 40 | ||||
| 41 | rules: | 41 | rules: | ||
| 42 | no-adapter-imports-in-core: error | 42 | no-adapter-imports-in-core: error | ||
| 43 | + | no-usecase-imports-in-core: error | |||
| 43 | no-infra-imports-in-usecase: error | 44 | 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: error | 47 | no-framework-types-in-core: error | ||
| 45 | no-infra-types-in-ports: error | 48 | no-infra-types-in-ports: error | ||
| 46 | no-adapter-to-adapter-imports: warn | 49 | no-adapter-to-adapter-imports: warn | ||
M internal/glob/glob_test.go +40 -3
| @@ -10,15 +10,52 @@ func TestMatch(t *testing.T) { | |||
| 10 | 10 | }{ | |
| 11 | 11 | {"internal/domain/**", "internal/domain/user.go", true}, | |
| 12 | 12 | {"internal/domain/**", "internal/domain/entity/user.go", true}, | |
| 13 | + | {"internal/domain/**", "internal/domain", true}, | |
| 13 | 14 | {"cmd/**", "cmd/hexcheck/main.go", true}, | |
| 14 | 15 | {"cmd/**", "internal/cmd/main.go", false}, | |
| 15 | 16 | {"internal/*/port/**", "internal/application/port/user.go", true}, | |
| 16 | 17 | {"internal/*/port/**", "internal/application/usecase/user.go", false}, | |
| 17 | 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 | 35 | 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 | + | }) | |
| 23 | 60 | } | |
| 24 | 61 | } | |
| @@ -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) { | |||
| 28 | 28 | t.Fatalf("build hexcheck: %v\n%s", err, output) | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | - | cmd := exec.Command(bin, "-hexcheck.config", filepath.Join(root, "examples", "hexcheck.yaml"), "-hexcheck.root", tt.repo, "./...") | |
| 31 | + | cmd := exec.Command(bin, "-config", filepath.Join(root, "examples", "hexcheck.yaml"), "-root", tt.repo, "./...") | |
| 32 | 32 | cmd.Dir = tt.repo | |
| 33 | 33 | output, err := cmd.CombinedOutput() | |
| 34 | 34 | if err == nil { | |
| @@ -28,7 +28,7 @@ func TestLocalRepositorySmoke(t *testing.T) { | |||||
| 28 | t.Fatalf("build hexcheck: %v\n%s", err, output) | 28 | t.Fatalf("build hexcheck: %v\n%s", err, output) | ||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | - | cmd := exec.Command(bin, "-hexcheck.config", filepath.Join(root, "examples", "hexcheck.yaml"), "-hexcheck.root", tt.repo, "./...") | 31 | + | cmd := exec.Command(bin, "-config", filepath.Join(root, "examples", "hexcheck.yaml"), "-root", tt.repo, "./...") |
| 32 | cmd.Dir = tt.repo | 32 | cmd.Dir = tt.repo | ||
| 33 | output, err := cmd.CombinedOutput() | 33 | output, err := cmd.CombinedOutput() | ||
| 34 | if err == nil { | 34 | if err == nil { | ||