From 22f6e8fde968410a8c4954ba1c94d9012052c5f3 Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Tue, 28 Apr 2026 00:30:54 -0300 Subject: [PATCH] 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 --- .githooks/pre-commit | 36 +++++++++++ .githooks/pre-commit.ps1 | 25 ++++++++ .github/workflows/ci.yml | 55 +++++++++++++++++ .golangci.yml | 69 ++++++++++++++++++++++ README.md | 30 ++++++++++ internal/plugins/auto/detector.go | 6 +- internal/plugins/csharp/format.go | 9 +-- internal/plugins/csharp/helper/client.go | 26 ++++---- internal/plugins/csharp/helper/process.go | 4 +- internal/plugins/csharp/helper/protocol.go | 6 +- internal/plugins/csharp/outline.go | 2 +- internal/plugins/csharp/project.go | 4 +- internal/plugins/git/collect.go | 8 +-- 13 files changed, 248 insertions(+), 32 deletions(-) create mode 100644 .githooks/pre-commit create mode 100644 .githooks/pre-commit.ps1 create mode 100644 .github/workflows/ci.yml create mode 100644 .golangci.yml diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..46b323a --- /dev/null +++ b/.githooks/pre-commit @@ -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" diff --git a/.githooks/pre-commit.ps1 b/.githooks/pre-commit.ps1 new file mode 100644 index 0000000..89b4dac --- /dev/null +++ b/.githooks/pre-commit.ps1 @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ff49bd5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..8627f15 --- /dev/null +++ b/.golangci.yml @@ -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 diff --git a/README.md b/README.md index 9d84c34..632f800 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,36 @@ runtime into the Go binary. 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 Contributions welcome. See `CONTRIBUTING.md` (coming soon) for guidelines. diff --git a/internal/plugins/auto/detector.go b/internal/plugins/auto/detector.go index 9f23895..d1c7516 100644 --- a/internal/plugins/auto/detector.go +++ b/internal/plugins/auto/detector.go @@ -46,8 +46,8 @@ var skipDirs = map[string]bool{ // findings holds raw evidence gathered during the directory walk. type findings struct { - slnFiles []string // forward-slash relative paths to .sln files - csprojFiles []string // forward-slash relative paths to .csproj/.fsproj/.vbproj files + slnFiles []string // forward-slash relative paths to .sln files + csprojFiles []string // forward-slash relative paths to .csproj/.fsproj/.vbproj files hasGlobalJSON bool packageJSONs []pkgJSON hasGoModRoot bool // go.mod is a direct child of rootDir (depth 1) @@ -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 { 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 { return nil // skip root itself diff --git a/internal/plugins/csharp/format.go b/internal/plugins/csharp/format.go index 78ab54d..b09fa37 100644 --- a/internal/plugins/csharp/format.go +++ b/internal/plugins/csharp/format.go @@ -223,13 +223,14 @@ func writeMultiTargeting(w io.Writer, projects []helper.ProjectInfo) { if len(frameworkSets) == 0 { // 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 { fmt.Fprintf(w, "None — all projects target `%s`.\n\n", tf) } - } else if len(allFrameworks) == 0 { - fmt.Fprintf(w, "No target frameworks detected.\n\n") - } else { + default: // Multiple different single targets for _, p := range projects { if len(p.TargetFrameworks) > 0 { diff --git a/internal/plugins/csharp/helper/client.go b/internal/plugins/csharp/helper/client.go index 45d39b2..dce37ea 100644 --- a/internal/plugins/csharp/helper/client.go +++ b/internal/plugins/csharp/helper/client.go @@ -84,7 +84,7 @@ type PackageReference struct { func (c *Client) Ping() (*PingResult, error) { raw, err := c.proc.Send("ping", nil) if err != nil { - return nil, wrapRpc("ping", err) + return nil, wrapRPC("ping", err) } var r PingResult 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} raw, err := c.proc.Send("loadSolution", params) if err != nil { - return nil, wrapRpc("loadSolution", err) + return nil, wrapRPC("loadSolution", err) } var r LoadSolutionResult 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) { raw, err := c.proc.Send("projectSummary", nil) if err != nil { - return nil, wrapRpc("projectSummary", err) + return nil, wrapRPC("projectSummary", err) } var r ProjectSummary if err := json.Unmarshal(raw, &r); err != nil { @@ -124,12 +124,12 @@ func (c *Client) ProjectSummary() (*ProjectSummary, error) { // OutlineResult is the structural outline of a single .cs file. type OutlineResult struct { - Path string `json:"path"` - Namespace string `json:"namespace"` - LineCount int `json:"lineCount"` - Usings []string `json:"usings"` - Types []OutlineType `json:"types"` - HasSyntaxErrors bool `json:"hasSyntaxErrors"` + Path string `json:"path"` + Namespace string `json:"namespace"` + LineCount int `json:"lineCount"` + Usings []string `json:"usings"` + Types []OutlineType `json:"types"` + HasSyntaxErrors bool `json:"hasSyntaxErrors"` } // OutlineType describes a type (class, interface, struct, record, enum) in the file. @@ -157,7 +157,7 @@ func (c *Client) Outline(path string) (*OutlineResult, error) { params := map[string]string{"path": path} raw, err := c.proc.Send("outline", params) if err != nil { - return nil, wrapRpc("outline", err) + return nil, wrapRPC("outline", err) } var r OutlineResult if err := json.Unmarshal(raw, &r); err != nil { @@ -166,9 +166,9 @@ func (c *Client) Outline(path string) (*OutlineResult, error) { return &r, nil } -// wrapRpc wraps RpcError values into user-friendly messages. -func wrapRpc(method string, err error) error { - var rpcErr *RpcError +// wrapRPC wraps RPCError values into user-friendly messages. +func wrapRPC(method string, err error) error { + var rpcErr *RPCError if errors.As(err, &rpcErr) { switch rpcErr.Code { case "E_NOT_FOUND": diff --git a/internal/plugins/csharp/helper/process.go b/internal/plugins/csharp/helper/process.go index 48f91a2..6e12fdd 100644 --- a/internal/plugins/csharp/helper/process.go +++ b/internal/plugins/csharp/helper/process.go @@ -73,8 +73,8 @@ func (p *Process) Send(method string, params interface{}) (json.RawMessage, erro } line = append(line, '\n') - if _, err := p.stdin.Write(line); err != nil { - return nil, fmt.Errorf("helper write (process may have crashed): %w", err) + if _, werr := p.stdin.Write(line); werr != nil { + return nil, fmt.Errorf("helper write (process may have crashed): %w", werr) } respLine, err := p.stdout.ReadString('\n') diff --git a/internal/plugins/csharp/helper/protocol.go b/internal/plugins/csharp/helper/protocol.go index d380c1a..3e47b16 100644 --- a/internal/plugins/csharp/helper/protocol.go +++ b/internal/plugins/csharp/helper/protocol.go @@ -13,14 +13,14 @@ type Request struct { type Response struct { ID int `json:"id"` 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. -type RpcError struct { +type RPCError struct { Code string `json:"code"` Message string `json:"message"` 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 } diff --git a/internal/plugins/csharp/outline.go b/internal/plugins/csharp/outline.go index b8f44c2..8997767 100644 --- a/internal/plugins/csharp/outline.go +++ b/internal/plugins/csharp/outline.go @@ -39,7 +39,7 @@ func runOutline(ctx *core.Context, file string) error { 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) return errExit } diff --git a/internal/plugins/csharp/project.go b/internal/plugins/csharp/project.go index b5ea28c..7fec715 100644 --- a/internal/plugins/csharp/project.go +++ b/internal/plugins/csharp/project.go @@ -44,8 +44,8 @@ func runProject(ctx *core.Context) error { } defer client.Close() - if _, err := client.LoadSolution(slnPath); err != nil { - fmt.Fprintln(ctx.Stderr, err.Error()) + if _, loadErr := client.LoadSolution(slnPath); loadErr != nil { + fmt.Fprintln(ctx.Stderr, loadErr.Error()) return errExit } diff --git a/internal/plugins/git/collect.go b/internal/plugins/git/collect.go index a02c181..e28fe9a 100644 --- a/internal/plugins/git/collect.go +++ b/internal/plugins/git/collect.go @@ -171,8 +171,8 @@ func collectRepoInfo(dir string) (repoInfo, 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") if err != nil || out == "" { - // Empty repo or no commits — not a hard error. - return nil, nil + // Empty repo or no commits — not an error for our purposes. + return nil, nil //nolint:nilerr // intentional: git failures here mean "nothing to show" } var commits []commit for _, line := range strings.Split(out, "\n") { @@ -254,7 +254,7 @@ func parseNumstat(dir string, cached bool) ([]fileChange, error) { } out, err := gitCmd(dir, args...) 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 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) { out, err := gitCmd(dir, "status", "--porcelain=v1") 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 for _, line := range strings.Split(out, "\n") {