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:
parent
15dc1b6b2f
commit
22f6e8fde9
36
.githooks/pre-commit
Normal file
36
.githooks/pre-commit
Normal 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
25
.githooks/pre-commit.ps1
Normal 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
55
.github/workflows/ci.yml
vendored
Normal 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
69
.golangci.yml
Normal 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
|
||||
30
README.md
30
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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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") {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user