Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
/wormchain/devnet/ @evan-gray
/wormchain/devnet/txverifier @djb15 @johnsaigle @mdulin2 @pleasew8t
/wormchain/ts-sdk/ @evan-gray @kev1n-peters @panoel
/linters/ @djb15 @johnsaigle @mdulin2 @pleasew8t @bemic

# Protobuf for node

Expand Down
17 changes: 14 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -306,10 +306,8 @@ jobs:
run: go install golang.org/x/tools/cmd/goimports@v0.8.0
- name: Formatting checks
run: ./scripts/lint.sh -l -g format
- name: Install linters
run: curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.12.2
- name: Run linters
run: make generate && golangci-lint --version && ./scripts/lint.sh -g lint
run: make generate && ./scripts/lint.sh -g lint
- name: Ensure generated proto matches
run: |
rm -rf node/pkg/proto
Expand Down Expand Up @@ -377,6 +375,19 @@ jobs:
- name: Check coverage against baseline
run: ./coverage-check

# Run unit tests for the custom Go linters
linter-tests:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.25.10"
- name: Run custom linter tests
run: make -C linters test

# Run Rust lints and tests
rust-lint-and-tests:
runs-on: ubuntu-24.04
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ sui/examples/wrapped_coin
/coverage-check
# Only used for internal testing
*.testnet.yaml
coverage.txt
coverage.txt
17 changes: 16 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ linters:
- bidichk
# Ensure that the body is closed on HTTP and websocket conns
- bodyclose
# Blocking channel sends
# - channelcheck
- contextcheck
- depguard
# Duplicate word usage, such as 'and and' in a comment.
Expand Down Expand Up @@ -206,6 +208,18 @@ linters:
#
# Default: false
check-exported: true
custom:
channelcheck:
type: "module"
description: Static analysis for go channel issues
settings:
CheckBlockingSends: true
CheckUnbufferedChannels: false
CheckEmptyDefault: true
# msgC sends are intentionally blocking because we don't want to drop observations
IgnoreChannelsByName:
- msgC
- msgChan
exclusions:
generated: lax
presets:
Expand All @@ -231,6 +245,7 @@ linters:
path: node/hack/
# Ignore test files for these tools.
- linters:
- channelcheck
- contextcheck
- dupWord
- exhaustruct
Expand Down Expand Up @@ -408,4 +423,4 @@ formatters:
paths:
- third_party$
- builtin$
- examples$
- examples$
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ $(BIN)/guardiand: dirs generate
github.com/certusone/wormhole/node

.PHONY: test-coverage
## Run tests with coverage for node and sdk (matches CI)
## Run tests with coverage for node, and sdk
test-coverage:
@echo "Running tests with coverage for node and sdk..."
@echo "Running tests with coverage for node, and sdk..."
@set -o pipefail && (cd node && go test -count=1 -v -timeout 5m -race -cover ./...) 2>&1 | tee coverage.txt
@set -o pipefail && (cd sdk && go test -count=1 -v -timeout 5m -race -cover ./...) 2>&1 | tee -a coverage.txt

Expand Down
8 changes: 8 additions & 0 deletions cspell-custom-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ certusone
chainid
ChainID
Chainlink
channelcheck
channellint
Coinspect
collateralization
colour
Expand Down Expand Up @@ -71,6 +73,7 @@ Finalizer
fogo
Fogo
fogoshim
footguns
forgeable
FQTs
frontends
Expand Down Expand Up @@ -142,8 +145,10 @@ monad
Monad
moonscan
moretags
multichecker
Neodyme
nhooyr
nolint
obsv
Obsv
OP_CHECKMULTISIG
Expand Down Expand Up @@ -222,6 +227,7 @@ superminority
tendermint
Tendermint
terrad
testdata
tokenbridge
tokenfactory
traceback
Expand All @@ -233,6 +239,8 @@ txverifier
uatom
uluna
unbond
Unbuffered
unbuffered
Uncompromised
undelegated
undercollateralization
Expand Down
6 changes: 6 additions & 0 deletions linters/.custom-gcl.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version: v2.12.2
name: wormhole-golangci-lint
destination: ./bin
plugins:
- module: github.com/wormhole-foundation/wormhole/linters/rules/channelcheck
path: ./rules/channelcheck
7 changes: 7 additions & 0 deletions linters/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Built binaries.
/bin/

# Stray root-level builds, e.g. from a bare `go build ./...` (the Makefile
# targets put these under bin/).
/wormhole-lint
/wormhole-golangci-lint
36 changes: 36 additions & 0 deletions linters/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Pin by commit hash rather than tag, so a re-pointed/malleable tag can't change
# what we build. Keep the trailing comment's tag in sync with the hash and with
# the `version:` field in .custom-gcl.yml.
GOLANGCI_LINT_HASH ?= c0d3ddc9cf3faa61a4e378e879ece580256d76e5 # v2.12.2

BIN_DIR := bin

.PHONY: build build-golangci-lint install-golangci-lint test clean

# Standalone multichecker over every rules/<linter>.Analyzer.
build:
go build -o $(BIN_DIR)/wormhole-lint ./cmd/wormhole-lint

# Pinned upstream golangci-lint, dropped into $(BIN_DIR). Used as the driver
# for `golangci-lint custom`; keep its version aligned with the `version:`
# field in .custom-gcl.yml.
install-golangci-lint:
GOOS= GOARCH= GOBIN=$(abspath $(BIN_DIR)) go install \
github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_HASH)

# Custom golangci-lint with our module plugins baked in. Driven by
# .custom-gcl.yml in this directory (golangci-lint custom reads it from the
# cwd); output goes to ./bin per `destination:`.
build-golangci-lint: install-golangci-lint
$(BIN_DIR)/golangci-lint custom

test:
go test -v ./...
@for mod in $$(find rules -name go.mod); do \
dir=$$(dirname $$mod); \
echo "==> testing $$dir"; \
(cd $$dir && go test -v ./...) || exit $$?; \
done

clean:
rm -rf $(BIN_DIR)
125 changes: 125 additions & 0 deletions linters/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Golang CI Lints

Custom Go linters used on Wormhole CI. Each linter is a [golangci-lint
module plugin](https://golangci-lint.run/plugins/module-plugins/) and lives
as its own Go module under `rules/<linter>/`.

Prefer to use the `release` builds.

Currently supported linters:

| Name | Purpose | Features |
| -------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `channelcheck` | Flag channel usage patterns that can deadlock or block. | • Blocking channel sends outside a `select`<br>• Unbuffered channel creation (`make(chan T)`)<br>• Empty `default:` that silently drops a send (advisory, off by default)<br>• Ignore specific channels |


## Build & test
```
make build # bin/wormhole-lint
make build-golangci-lint # bin/wormhole-golangci-lint
make test # root + each rules/<linter> module
```

`make build-golangci-lint` first installs the pinned upstream
`golangci-lint` into `bin/` (per `GOLANGCI_LINT_HASH`), then runs
`golangci-lint custom`.

## Use

Standalone:

```
bin/wormhole-lint ./...
```

Via the custom golangci-lint:

```
bin/wormhole-golangci-lint run --timeout=10m ./...
```

## Enable a plugin in `.golangci.yml`

Module plugins are addressed under `linters.settings.custom.<name>` and
enabled in `linters.enable` by the plugin's registered name. Example for
`channelcheck`:

```yaml
version: "2"
linters:
enable:
- channelcheck
settings:
custom:
channelcheck:
type: module
description: reports channel blocking issues
settings:
blocking: true
unbuffered: false
bufferMax: 0
emptyDefault: false
ignoreChannelsByName: [errC]
```

`channelcheck` settings:

| Setting | Type | Default | Description |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be better to use actual setting names (CheckBlockingSends) instead of flags (blocking)?

| ---------------------- | ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `blocking` | `bool` | `true` | Flag blocking sends that have no escape (timer, `default:`, or `ctx.Done()`), including sends outside any `select`. |
| `unbuffered` | `bool` | `false` | Flag unbuffered channel creation (`make(chan T)` / `make(chan T, 0)`). |
| `bufferMax` | `uint` | `0` | Flag channel buffers larger than this. `0` disables the check. |
| `emptyDefault` | `bool` | `false` | Advisory: flag an empty `default:` in a `select` with a send, since it silently drops the send. Off by default — it fires on the idiomatic non-blocking/coalescing-send pattern, so enable it only where dropping should always be logged or documented. |
| `ignoreChannelsByName` | `[]string` | `[]` | Channel/field names whose direct sends are exempt from the blocking-send, empty-default, and `ctx.Done()` checks. |

## Development

### Adding a new linter

1. Scaffold the module:
```
mkdir -p rules/<linter> && cd rules/<linter>
go mod init github.com/wormhole-foundation/wormhole/linters/rules/<linter>
```
2. Implement `<linter>.go` following the channelcheck reference
(`rules/channelcheck/channelcheck.go`):
- Export an `Analyzer` of type `*analysis.Analyzer`.
- Define a `Settings` struct and a `New(any) (register.LinterPlugin, error)`
constructor that decodes settings via
`register.DecodeSettings[Settings]`.
- Implement `BuildAnalyzers()` and `GetLoadMode()` on your plugin type.
- In `init()`, call `register.Plugin("<name>", New)` so
`golangci-lint custom` picks it up.
3. Add tests + fixtures under `rules/<linter>/testdata/` following
`rules/channelcheck/channelcheck_test.go`.
4. Wire it into the root module so the aggregator can import it:
- In root `go.mod`, add
`require github.com/wormhole-foundation/wormhole/linters/rules/<linter> v0.0.0` and
`replace github.com/wormhole-foundation/wormhole/linters/rules/<linter> => ./rules/<linter>`.
- In `cmd/wormhole-lint/main.go`, add the import and append
`<linter>.Analyzer` to the `multichecker.Main` call.
5. Wire it into `.custom-gcl.yml` so the custom golangci-lint picks it up:
```yaml
plugins:
- module: github.com/wormhole-foundation/wormhole/linters/rules/<linter>
path: ./rules/<linter>
```
6. `make test && make build && make build-golangci-lint` to verify.
7. Add linter to `.golangci.yml`.
8. Fix linter errors in the monorepo with legitimate changes or a `nolint` comment.

### Layout

```
.custom-gcl.yml # plugin manifest for `golangci-lint custom`
Makefile
go.mod # root module: cmd/* + replaces for each rules/<linter>
cmd/
wormhole-lint/ # multichecker aggregator binary
rules/
channelcheck/ # standalone Go module per linter
channelcheck.go
channelcheck_test.go
go.mod
testdata/
```
16 changes: 16 additions & 0 deletions linters/cmd/wormhole-lint/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Command wormhole-lint runs every custom linter under rules/ as a single
// standalone binary. Each rules/<linter>/ exports an Analyzer; add new
// linters by appending one import + one Analyzer below.
package main

import (
"golang.org/x/tools/go/analysis/multichecker"

"github.com/wormhole-foundation/wormhole/linters/rules/channelcheck"
)

func main() {
multichecker.Main(
channelcheck.Analyzer,
)
}
16 changes: 16 additions & 0 deletions linters/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module github.com/wormhole-foundation/wormhole/linters

go 1.25.10

require (
github.com/wormhole-foundation/wormhole/linters/rules/channelcheck v0.0.0
golang.org/x/tools v0.30.0
)

require (
github.com/golangci/plugin-module-register v0.1.1 // indirect
golang.org/x/mod v0.23.0 // indirect
golang.org/x/sync v0.11.0 // indirect
)

replace github.com/wormhole-foundation/wormhole/linters/rules/channelcheck => ./rules/channelcheck
10 changes: 10 additions & 0 deletions linters/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c=
github.com/golangci/plugin-module-register v0.1.1/go.mod h1:TTpqoB6KkwOJMV8u7+NyXMrkwwESJLOkfl9TxR1DGFc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
24 changes: 24 additions & 0 deletions linters/rules/channelcheck/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Channel Check

Channels are a great feature of Golang but have several footguns that can lead to deadlocks. In particular, if the receiving channel stops processing the messages, a *non-blocking* channel send would fail to continue. In certain mission-critical sections of code, this could lead to a complete deadlock.

This linter currently has three features:
- Identify blocking sends
- Identify non-buffered channel creation
- Identify buffered channel size exceeds maximum size checks

Many of these will lead to false positives or situations where we *want* a blocking channel send. In these cases, `nolint:channelcheck` is easy to add (assuming this is integrated directly with golangci-lint). Regardless, having this issue pointed out automatically is a good way to fix bugs.

## Configuration

Each option can be set two ways: as a golangci-lint module setting (under
`settings.custom.channelcheck.settings:`, using the **Setting** name) or as a
standalone analyzer flag (when running the `wormhole-lint` binary, using the
**Flag** name).

| Setting | Flag | Type | Default | Description |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we include CheckEmptyDefault in the table?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mdulin2 feel free to mark my 2 comments as resolved and ignore, they are non blockers.

| ------------------------- | ------------ | ---------- | ------- | ----------------------------------------------------------------------------------------------- |
| `CheckBlockingSends` | `blocking` | bool | `true` | Flag blocking sends that lack a `default`/timeout/ticker escape in their enclosing `select`. |
| `CheckUnbufferedChannels` | `unbuffered` | bool | `false` | Flag creation of unbuffered channels (`make(chan T)`). |
| `CheckBufferAmount` | `bufferMax` | uint64 | `0` | Flag buffered channels whose size exceeds this max. `0` disables the check. |
| `IgnoreChannelsByName` | *(none)* | []string | `[]` | Channel/field names whose direct sends are exempt from the blocking-send check (e.g. `errC`). Settings-only; no standalone flag. |
Loading
Loading