Skip to content
Merged
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 .codespellignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ MIs
AfterAll
SME
uptodate
enbale
12 changes: 4 additions & 8 deletions .github/workflows/lint-reusable.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,8 @@ jobs:
if [ -n "${{ github.base_ref }}" ]; then
git fetch origin "${{ github.base_ref }}:${{ github.base_ref }}"
fi
- name: Use pre-built lint tools
run: |
if [ -d /opt/lint-tools ]; then
mkdir -p hack/tools/bin
cp /opt/lint-tools/golangci-lint hack/tools/bin/
cp /opt/lint-tools/kube-api-linter.so hack/tools/bin/
touch hack/tools/bin/golangci-lint hack/tools/bin/kube-api-linter.so
fi
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: hack/tools/bin
key: lint-tools-${{ hashFiles('Makefile', 'hack/tools/go.mod', 'hack/tools/go.sum', 'hack/tools/hypershiftlinter/**/*.go') }}
- run: make lint
18 changes: 18 additions & 0 deletions .github/workflows/test-linter-reusable.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Unit Tests (HyperShift Linter) (Reusable)

on:
workflow_call:

permissions:
contents: read

jobs:
test-linter:
name: HyperShift Linter
runs-on: arc-runner-set
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: make test-linter
21 changes: 21 additions & 0 deletions .github/workflows/test-linter.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Unit Tests (HyperShift Linter)

on:
pull_request:
branches:
- main
- release-4.22
paths:
- 'hack/tools/hypershiftlinter/**'
- 'hack/tools/go.mod'
- 'hack/tools/go.sum'
- 'hack/tools/vendor/**'
- 'Makefile'
- '.github/workflows/test-linter.yaml'
- '.github/workflows/test-linter-reusable.yaml'

Comment thread
coderabbitai[bot] marked this conversation as resolved.
jobs:
test-linter:
uses: openshift/hypershift/.github/workflows/test-linter-reusable.yaml@main
permissions:
contents: read
13 changes: 13 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ KUBEAPILINTER_PLUGIN := $(abspath $(TOOLS_BIN_DIR)/kube-api-linter.so)
$(KUBEAPILINTER_PLUGIN): $(TOOLS_DIR)/go.mod # Build kube-api-linter as Go plugin
cd $(TOOLS_DIR); CGO_ENABLED=1 $(GO) build -buildmode=plugin -o $(KUBEAPILINTER_PLUGIN) sigs.k8s.io/kube-api-linter/pkg/plugin

HYPERSHIFTLINTER_PLUGIN := $(abspath $(TOOLS_BIN_DIR)/hypershiftlinter.so)
HYPERSHIFTLINTER_SRC := $(shell find $(TOOLS_DIR)/hypershiftlinter -name '*.go' 2>/dev/null)
$(HYPERSHIFTLINTER_PLUGIN): $(TOOLS_DIR)/go.mod $(HYPERSHIFTLINTER_SRC) # Build hypershiftlinter as Go plugin
cd $(TOOLS_DIR); $(GO) build -a -buildmode=plugin -o $(HYPERSHIFTLINTER_PLUGIN) ./hypershiftlinter/cmd/plugin

# When not otherwise set, diff/lint against the upstream main branch.
# This is always set in OpenShift CI.
UPSTREAM_REMOTE ?= $(shell git remote -v 2>/dev/null | grep 'openshift/hypershift.*fetch' | head -1 | cut -f1)
Expand Down Expand Up @@ -137,6 +142,14 @@ lint-fix: generate
$(GOLANGCI_LINT) run --config ./.golangci.yml --fix -v; main_rc=$$?; \
exit $$(( api_rc > main_rc ? api_rc : main_rc ))

.PHONY: hypershift-lint-all
hypershift-lint-all: $(GOLANGCI_LINT) $(HYPERSHIFTLINTER_PLUGIN)
$(GOLANGCI_LINT) run --config ./.golangci.yml --modules-download-mode=readonly -v --enable-only hypershiftlinter --build-tags e2ev2

.PHONY: test-linter
test-linter:
cd $(TOOLS_DIR) && $(GO) test ./hypershiftlinter/analyzers/... -count=1

.PHONY: verify-git-clean
verify-git-clean:
git update-index --refresh
Expand Down
2 changes: 1 addition & 1 deletion hack/tools/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/openshift/api/tools v0.0.0-20250915151906-94481d71bb6f
go.uber.org/mock v0.6.0
golang.org/x/mod v0.35.0
golang.org/x/tools v0.44.0
gotest.tools/gotestsum v1.13.0
honnef.co/go/tools v0.7.0
k8s.io/apiextensions-apiserver v0.34.2
Expand Down Expand Up @@ -300,7 +301,6 @@ require (
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/grpc v1.82.1 // indirect
Expand Down
81 changes: 81 additions & 0 deletions hack/tools/hypershiftlinter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# hypershiftlinter

`hypershiftlinter` is a custom [golangci-lint](https://golangci-lint.run/) plugin
that automatically enforces HyperShift's testing conventions through static
analysis.

## Why this exists

We already document our testing conventions in
[`TESTING.md`](../../../TESTING.md) and
[`test/e2e/v2/AGENTS.md`](../../../test/e2e/v2/AGENTS.md), but until now nothing
enforced them. Conventions that live only in docs get followed inconsistently —
reviewers have to catch violations by hand, and many slip through.

This plugin turns those conventions into machine-enforced checks instead of
relying on reviewer memory. That matters for several reasons:

1. **Machine-enforced consistency.** Conventions become automated checks rather
than tribal knowledge. This is what caught real issues in review — for
example, tests that silently skipped ~60 lines of assertions because guard
strings no longer matched renamed test cases, and vacuously-passing tests.
2. **Better test quality and reliability.** The `vacuouspass` analyzer catches
tests that pass without actually asserting anything, a common source of false
confidence in a test suite.
3. **Cleaner Sippy/CI signal.** Enforcing correct
`[sig-hypershift][Jira:Hypershift]` and `[Feature:X]` annotations keeps our
e2e results properly categorized in Sippy.
4. **Lower review burden.** Reviewers spend less time on mechanical naming and
convention nits and more on substance.

## Analyzers

The plugin ships 7 analyzers, scoped so each rule only fires where it applies.

### Unit test conventions (`TESTING.md`, unit tests only)

| Analyzer | Enforces |
| -------------- | ----------------------------------------------------------------------------------------- |
| `testcasename` | Test case name fields match `When <condition>, it should <expected behavior>`. |
| `testfuncname` | Test functions do not use the `Test_` prefix; use `TestFunctionName` instead. |

### E2E conventions (`test/e2e/v2/` only)

| Analyzer | Enforces |
| ------------------- | ------------------------------------------------------------------------------------------------ |
| `guestcluster` | Bans "guest cluster" terminology; use "hosted cluster" instead. |
| `contextbackground` | Bans `context.Background()` / `context.TODO()` in tests; use `tc.Context` instead. |
| `vacuouspass` | Flags vacuously-passing tests that iterate a collection without asserting it is non-empty. |
| `ipv6url` | Detects `fmt.Sprintf` URL patterns that break with IPv6; use `net.JoinHostPort` instead. |
| `sippyannotation` | Requires the correct Sippy/Jira `[Feature:X]` annotations on Ginkgo `Describe` blocks. |

## How it's built and run

The plugin builds as a Go shared library (`.so`) via
`go build -buildmode=plugin`. The plugin and the golangci-lint host binary must
be compiled from the same `hack/tools/go.mod` — a `golang.org/x/tools` version
mismatch causes `plugin.Open()` to fail at runtime.

Relevant Makefile targets:

- `make hypershiftlinter.so` — build the plugin shared library.
- `make hypershift-lint-all` — opt-in target to run the analyzers against the
current tree.
- `make test-linter` — run the analyzers' own unit tests
(`go test ./hypershiftlinter/analyzers/...`).

Each analyzer has [`analysistest`](https://pkg.go.dev/golang.org/x/tools/go/analysis/analysistest)-based
unit tests with good/bad `testdata/` fixtures.

## Staged rollout

Enablement is intentionally staged. The initial change lands the plugin, the
analyzers, and their unit tests only — **it does not enable enforcement**. A
follow-up wires the plugin into `.golangci.yml` / `make lint` and fixes the
existing violations in the tree.

Splitting it this way keeps the review surface small and lets CI actually run the
analyzers' own tests before enforcement is turned on. (A brand-new reusable
workflow can't get a green pre-merge run on the PR that introduces it, because
GitHub resolves `uses: ...@main` and the `pull_request` trigger from the base
branch — so the foundational plumbing has to land on `main` first.)
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package contextbackground

import (
"go/ast"
"strings"

"github.com/openshift/hypershift/hack/tools/hypershiftlinter/analyzers/pathutil"

"golang.org/x/tools/go/analysis"
)

var Analyzer = &analysis.Analyzer{
Name: "contextbackground",
Doc: "bans context.Background() and context.TODO() in test files; use tc.Context instead",
Run: run,
}

func run(pass *analysis.Pass) (any, error) {
for _, file := range pass.Files {
filename := pass.Fset.File(file.Pos()).Name()
if !pathutil.IsV2E2ETest(filename) || !strings.HasSuffix(filename, "_test.go") {
continue
}

ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}

if !isContextBackgroundOrTODO(call) {
return true
}

if isInsideExemptFunc(file, call) {
return true
}

pass.Report(analysis.Diagnostic{
Pos: call.Pos(),
End: call.End(),
Message: "use tc.Context instead of context.Background()/context.TODO()",
})
return true
})
}
return nil, nil
}

func isContextBackgroundOrTODO(call *ast.CallExpr) bool {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return false
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return false
}
return ident.Name == "context" && (sel.Sel.Name == "Background" || sel.Sel.Name == "TODO")
}

func isInsideExemptFunc(file *ast.File, target *ast.CallExpr) bool {
exempt := false
ast.Inspect(file, func(n ast.Node) bool {
if exempt {
return false
}
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
name := callName(call)
// Only suite-level setup/teardown hooks are exempt: they run before
// TestContext is initialized (or after it would be meaningful), so
// context.Background() is the correct choice there. DeferCleanup is NOT
// exempt — per test/e2e/v2/AGENTS.md, TestContext.Context is initialized
// once in BeforeSuite and is not canceled during cleanup, so cleanup
// callbacks that have access to tc must use tc.Context.
if name == "BeforeSuite" || name == "AfterSuite" || name == "SynchronizedBeforeSuite" || name == "SynchronizedAfterSuite" {
for _, arg := range call.Args {
if containsNode(arg, target) {
exempt = true
return false
}
}
}
return true
})
return exempt
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func callName(call *ast.CallExpr) string {
switch fn := call.Fun.(type) {
case *ast.Ident:
return fn.Name
case *ast.SelectorExpr:
return fn.Sel.Name
}
return ""
}

func containsNode(tree ast.Node, target ast.Node) bool {
found := false
ast.Inspect(tree, func(n ast.Node) bool {
if n == target {
found = true
return false
}
return !found
})
return found
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package contextbackground

import (
"testing"

"golang.org/x/tools/go/analysis/analysistest"
)

func TestAnalyzer(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, Analyzer, "test/e2e/v2/good", "test/e2e/v2/bad")
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading