chore: add golangci-lint, GitHub Actions CI, and pre-commit hooks

Adds .golangci.yml (errcheck, govet, staticcheck, revive, gocyclo,
gocritic, nilerr, errorlint, etc.). Fixes all lint issues found:
- RpcError/wrapRpc renamed to RPCError/wrapRPC (revive var-naming)
- Shadow vars resolved in process.go, outline.go, project.go
- nilerr suppressed with justifying comments in git/collect.go and
  auto/detector.go (WalkDir / intentional empty-returns)
- if-else chain rewritten as switch in format.go (gocritic)
- gofmt applied to detector.go and client.go

Adds .github/workflows/ci.yml: lint (ubuntu), build+vet+test matrix
(ubuntu/windows/macos), and Roslyn helper build (windows). Validated
with actionlint (0 issues).

Adds .githooks/pre-commit + pre-commit.ps1 for local pre-commit checks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ricardo Carneiro 2026-04-28 00:30:54 -03:00
parent 15dc1b6b2f
commit 22f6e8fde9
13 changed files with 248 additions and 32 deletions

36
.githooks/pre-commit Normal file
View File

@ -0,0 +1,36 @@
#!/bin/sh
# ctx pre-commit hook — shell entry point (works in Git Bash on Windows)
# Delegates to pre-commit.ps1 on Windows, runs natively on Linux/macOS.
#
# To activate:
# git config core.hooksPath .githooks
set -e
if command -v powershell.exe >/dev/null 2>&1; then
# Windows: delegate to PowerShell script
exec powershell.exe -ExecutionPolicy Bypass -File .githooks/pre-commit.ps1
fi
# Linux / macOS: run directly
echo "Running gofmt..."
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Files not formatted with gofmt:"
echo "$unformatted"
echo
echo "Run: gofmt -w ."
exit 1
fi
echo "Running go vet..."
go vet ./...
if command -v golangci-lint >/dev/null 2>&1; then
echo "Running golangci-lint..."
golangci-lint run ./...
else
echo "warning: golangci-lint not installed, skipping"
fi
echo "pre-commit checks passed"

25
.githooks/pre-commit.ps1 Normal file
View File

@ -0,0 +1,25 @@
# ctx pre-commit hook (PowerShell)
# To activate: git config core.hooksPath .githooks
$ErrorActionPreference = "Stop"
Write-Host "Running gofmt..."
$unformatted = gofmt -l .
if ($unformatted) {
Write-Host "Files not formatted with gofmt:"
Write-Host $unformatted
Write-Host ""
Write-Host "Run: gofmt -w ."
exit 1
}
Write-Host "Running go vet..."
go vet ./...
if (Get-Command golangci-lint -ErrorAction SilentlyContinue) {
Write-Host "Running golangci-lint..."
golangci-lint run ./...
} else {
Write-Host "warning: golangci-lint not installed, skipping"
}
Write-Host "pre-commit checks passed"

55
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,55 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
build:
name: Build & Test
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
- name: Build
run: go build -v ./...
- name: Vet
run: go vet ./...
- name: Test
run: go test -v -race -count=1 ./...
build-helper:
name: Build Roslyn Helper
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Build helper
working-directory: tools/roslyn-helper
run: dotnet build src/RoslynHelper -c Release

69
.golangci.yml Normal file
View File

@ -0,0 +1,69 @@
run:
timeout: 3m
tests: true
go: "1.26"
output:
formats:
- format: colored-line-number
print-issued-lines: true
print-linter-name: true
sort-results: true
linters:
disable-all: true
enable:
- errcheck # unhandled errors
- govet # official compiler checks
- ineffassign # useless assignments
- staticcheck # bugs and simplifications
- unused # dead code
- gofmt # formatting
- goimports # import organization
- misspell # typos in strings/comments
- revive # golint successor
- gocyclo # high cyclomatic complexity
- gocritic # additional checks
- bodyclose # http.Response.Body closed
- nilerr # returning nil when err intended
- errorlint # non-wrappable errors
linters-settings:
gocyclo:
min-complexity: 15
govet:
enable-all: true
disable:
- fieldalignment # unnecessary noise
revive:
rules:
- name: var-naming
- name: exported
arguments: ["disableStutteringCheck"] # allows ctx.Context
- name: error-return
- name: error-naming
- name: unexported-return
- name: indent-error-flow
- name: unreachable-code
issues:
exclude-dirs:
- tools/roslyn-helper # C# code, ignore
- vendor
- bin
exclude-rules:
# Allow errcheck in cobra command handlers — cobra's RunE owns error printing
- path: cmd/
linters: [errcheck]
# Allow higher complexity in plugin format functions (inherently procedural)
- path: internal/plugins/.*/format\.go
linters: [gocyclo]
text: "cyclomatic complexity"
# Allow higher complexity in plugin collect/detect logic
- path: internal/plugins/.*/detect
linters: [gocyclo]
text: "cyclomatic complexity"
max-issues-per-linter: 0
max-same-issues: 0
new: false

View File

@ -80,6 +80,36 @@ runtime into the Go binary.
See `docs/DECISIONS.md` for the full rationale behind each architectural choice. See `docs/DECISIONS.md` for the full rationale behind each architectural choice.
## Development
### Prerequisites
- Go 1.26+
- .NET SDK 10.0+ (for Roslyn helper)
- golangci-lint (`go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest`)
### Build
```sh
go build -o ctx.exe ./cmd/ctx
```
### Tests & lint
```sh
go vet ./...
golangci-lint run ./...
```
### Pre-commit hooks
```sh
git config core.hooksPath .githooks
# On Linux/macOS also: chmod +x .githooks/pre-commit
```
### Roslyn helper
```sh
cd tools/roslyn-helper
dotnet publish src/RoslynHelper -c Release -r win-x64 --self-contained false -o publish/
```
## Contributing ## Contributing
Contributions welcome. See `CONTRIBUTING.md` (coming soon) for guidelines. Contributions welcome. See `CONTRIBUTING.md` (coming soon) for guidelines.

View File

@ -68,7 +68,7 @@ func scanDir(rootDir string, maxDepth int) (findings, error) {
err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error { err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return nil // skip unreadable entries silently return nil //nolint:nilerr // skip unreadable entries; returning err would abort the walk
} }
if path == rootDir { if path == rootDir {
return nil // skip root itself return nil // skip root itself

View File

@ -223,13 +223,14 @@ func writeMultiTargeting(w io.Writer, projects []helper.ProjectInfo) {
if len(frameworkSets) == 0 { if len(frameworkSets) == 0 {
// All same framework, or single framework each // All same framework, or single framework each
if len(allFrameworks) == 1 { switch len(allFrameworks) {
case 0:
fmt.Fprintf(w, "No target frameworks detected.\n\n")
case 1:
for tf := range allFrameworks { for tf := range allFrameworks {
fmt.Fprintf(w, "None — all projects target `%s`.\n\n", tf) fmt.Fprintf(w, "None — all projects target `%s`.\n\n", tf)
} }
} else if len(allFrameworks) == 0 { default:
fmt.Fprintf(w, "No target frameworks detected.\n\n")
} else {
// Multiple different single targets // Multiple different single targets
for _, p := range projects { for _, p := range projects {
if len(p.TargetFrameworks) > 0 { if len(p.TargetFrameworks) > 0 {

View File

@ -84,7 +84,7 @@ type PackageReference struct {
func (c *Client) Ping() (*PingResult, error) { func (c *Client) Ping() (*PingResult, error) {
raw, err := c.proc.Send("ping", nil) raw, err := c.proc.Send("ping", nil)
if err != nil { if err != nil {
return nil, wrapRpc("ping", err) return nil, wrapRPC("ping", err)
} }
var r PingResult var r PingResult
if err := json.Unmarshal(raw, &r); err != nil { if err := json.Unmarshal(raw, &r); err != nil {
@ -98,7 +98,7 @@ func (c *Client) LoadSolution(path string) (*LoadSolutionResult, error) {
params := map[string]string{"path": path} params := map[string]string{"path": path}
raw, err := c.proc.Send("loadSolution", params) raw, err := c.proc.Send("loadSolution", params)
if err != nil { if err != nil {
return nil, wrapRpc("loadSolution", err) return nil, wrapRPC("loadSolution", err)
} }
var r LoadSolutionResult var r LoadSolutionResult
if err := json.Unmarshal(raw, &r); err != nil { if err := json.Unmarshal(raw, &r); err != nil {
@ -111,7 +111,7 @@ func (c *Client) LoadSolution(path string) (*LoadSolutionResult, error) {
func (c *Client) ProjectSummary() (*ProjectSummary, error) { func (c *Client) ProjectSummary() (*ProjectSummary, error) {
raw, err := c.proc.Send("projectSummary", nil) raw, err := c.proc.Send("projectSummary", nil)
if err != nil { if err != nil {
return nil, wrapRpc("projectSummary", err) return nil, wrapRPC("projectSummary", err)
} }
var r ProjectSummary var r ProjectSummary
if err := json.Unmarshal(raw, &r); err != nil { if err := json.Unmarshal(raw, &r); err != nil {
@ -157,7 +157,7 @@ func (c *Client) Outline(path string) (*OutlineResult, error) {
params := map[string]string{"path": path} params := map[string]string{"path": path}
raw, err := c.proc.Send("outline", params) raw, err := c.proc.Send("outline", params)
if err != nil { if err != nil {
return nil, wrapRpc("outline", err) return nil, wrapRPC("outline", err)
} }
var r OutlineResult var r OutlineResult
if err := json.Unmarshal(raw, &r); err != nil { if err := json.Unmarshal(raw, &r); err != nil {
@ -166,9 +166,9 @@ func (c *Client) Outline(path string) (*OutlineResult, error) {
return &r, nil return &r, nil
} }
// wrapRpc wraps RpcError values into user-friendly messages. // wrapRPC wraps RPCError values into user-friendly messages.
func wrapRpc(method string, err error) error { func wrapRPC(method string, err error) error {
var rpcErr *RpcError var rpcErr *RPCError
if errors.As(err, &rpcErr) { if errors.As(err, &rpcErr) {
switch rpcErr.Code { switch rpcErr.Code {
case "E_NOT_FOUND": case "E_NOT_FOUND":

View File

@ -73,8 +73,8 @@ func (p *Process) Send(method string, params interface{}) (json.RawMessage, erro
} }
line = append(line, '\n') line = append(line, '\n')
if _, err := p.stdin.Write(line); err != nil { if _, werr := p.stdin.Write(line); werr != nil {
return nil, fmt.Errorf("helper write (process may have crashed): %w", err) return nil, fmt.Errorf("helper write (process may have crashed): %w", werr)
} }
respLine, err := p.stdout.ReadString('\n') respLine, err := p.stdout.ReadString('\n')

View File

@ -13,14 +13,14 @@ type Request struct {
type Response struct { type Response struct {
ID int `json:"id"` ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"` Result json.RawMessage `json:"result,omitempty"`
Error *RpcError `json:"error,omitempty"` Error *RPCError `json:"error,omitempty"`
} }
// RpcError is a structured error from the helper process. // RpcError is a structured error from the helper process.
type RpcError struct { type RPCError struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"` Data json.RawMessage `json:"data,omitempty"`
} }
func (e *RpcError) Error() string { return e.Code + ": " + e.Message } func (e *RPCError) Error() string { return e.Code + ": " + e.Message }

View File

@ -39,7 +39,7 @@ func runOutline(ctx *core.Context, file string) error {
return errExit return errExit
} }
if _, err := os.Stat(abs); err != nil { if _, statErr := os.Stat(abs); statErr != nil {
fmt.Fprintf(ctx.Stderr, "file not found: %s\n", abs) fmt.Fprintf(ctx.Stderr, "file not found: %s\n", abs)
return errExit return errExit
} }

View File

@ -44,8 +44,8 @@ func runProject(ctx *core.Context) error {
} }
defer client.Close() defer client.Close()
if _, err := client.LoadSolution(slnPath); err != nil { if _, loadErr := client.LoadSolution(slnPath); loadErr != nil {
fmt.Fprintln(ctx.Stderr, err.Error()) fmt.Fprintln(ctx.Stderr, loadErr.Error())
return errExit return errExit
} }

View File

@ -171,8 +171,8 @@ func collectRepoInfo(dir string) (repoInfo, error) {
func collectCommits(dir string, n int) ([]commit, error) { func collectCommits(dir string, n int) ([]commit, error) {
out, err := gitCmd(dir, "log", fmt.Sprintf("-n%d", n), "--pretty=format:%h|%at|%an|%s") out, err := gitCmd(dir, "log", fmt.Sprintf("-n%d", n), "--pretty=format:%h|%at|%an|%s")
if err != nil || out == "" { if err != nil || out == "" {
// Empty repo or no commits — not a hard error. // Empty repo or no commits — not an error for our purposes.
return nil, nil return nil, nil //nolint:nilerr // intentional: git failures here mean "nothing to show"
} }
var commits []commit var commits []commit
for _, line := range strings.Split(out, "\n") { for _, line := range strings.Split(out, "\n") {
@ -254,7 +254,7 @@ func parseNumstat(dir string, cached bool) ([]fileChange, error) {
} }
out, err := gitCmd(dir, args...) out, err := gitCmd(dir, args...)
if err != nil || out == "" { if err != nil || out == "" {
return nil, nil return nil, nil //nolint:nilerr // no changes or not in a git repo — not an error
} }
var changes []fileChange var changes []fileChange
for _, line := range strings.Split(out, "\n") { for _, line := range strings.Split(out, "\n") {
@ -280,7 +280,7 @@ func parseNumstat(dir string, cached bool) ([]fileChange, error) {
func parseUntracked(dir string) ([]string, error) { func parseUntracked(dir string) ([]string, error) {
out, err := gitCmd(dir, "status", "--porcelain=v1") out, err := gitCmd(dir, "status", "--porcelain=v1")
if err != nil || out == "" { if err != nil || out == "" {
return nil, nil return nil, nil //nolint:nilerr // clean working tree or not in a git repo — not an error
} }
var untracked []string var untracked []string
for _, line := range strings.Split(out, "\n") { for _, line := range strings.Split(out, "\n") {