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
50 changes: 50 additions & 0 deletions linters/isolint/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# isolint

golangci-lint module plugin that flags uppercase ISO code string literals (`"USD"`, `"SG"`) and recommends `currency.USD` / `site.SG` package constants instead. Lowercase strings (`"usd"`, `"sg"`) are ignored — only uppercase is considered an intentional ISO reference. Suppress remaining false positives with `//nolint:isolint`.

## Structure

- `analyzer.go` — entry point; walks `*ast.BasicLit` nodes with `inspector.WithStack` for parent context
- `codes.go` — delegates to `currency.IsISO4217()` and `site.Currency()` for code validation (uppercase only)
- `report.go` — diagnostic messages and `SuggestedFix` text edits
- `plugin.go` — golangci-lint module plugin registration
- `cmd/isolint/` — standalone CLI
- `testdata/` — separate Go module with test fixtures and `.golden` files

## Commands

```bash
go test -v ./... # run all tests
go build ./... # compile
go vet ./... # static analysis
revive ./... # lint (catches unused params, etc.)
golangci-lint run --enable-only revive # alternative: revive via golangci-lint
```

## Testing

Uses `golang.org/x/tools/go/analysis/analysistest`. See [docs/testing.md](docs/testing.md) for the full testing guide.

- Files with `// want` annotations assert expected diagnostics (positive tests)
- `valid.go` / `valid_contexts.go` assert zero false positives (negative tests)
- `.go.golden` files verify auto-fix output via `RunWithSuggestedFixes`
- `testdata/` is its own Go module — run `cd testdata && go mod tidy` after changing dependencies

## Key decisions

See [docs/decisions.md](docs/decisions.md) for rationale behind each decision.

- **Uppercase only** — lowercase and mixed case are ignored
- **Code validation delegates to source packages** — `currency.IsISO4217()` and `site.Currency()`, no hardcoded maps
- **Skips definition packages** — [`skipPackages`](analyzer.go) in `analyzer.go`
- **Skips import paths and call arguments** — [`skipMethods`](analyzer.go) in `analyzer.go`
- **Load mode is `LoadModeSyntax`** — only needs string literal values, not type info

## Performance

See [docs/decisions.md](docs/decisions.md) for guard ordering rationale and anti-patterns.

- **`LoadModeSyntax`** — cheapest load mode; type-checker never runs
- **Shared inspector** — `pass.ResultOf[inspect.Analyzer]`
- **Narrow node filter** — `[]ast.Node{(*ast.BasicLit)(nil)}` with `inspector.WithStack`
- **Guard order** — cheapest checks first; allocations (`strconv.Unquote`, `fmt.Sprintf`) only on the reporting path
1 change: 1 addition & 0 deletions linters/isolint/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
11 changes: 11 additions & 0 deletions linters/isolint/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.PHONY: test lint build

test:
go test -v ./...

lint:
revive -formatter friendly ./...
go vet ./...

build:
go build ./...
153 changes: 153 additions & 0 deletions linters/isolint/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# isolint

A Go analyzer that detects raw ISO code string literals and recommends using `github.com/wego/pkg/currency` and `github.com/wego/pkg/iso/site` package constants instead.

## Installation

### Automatic way (recommended)

This follows golangci-lint's "Automatic Way" module plugin flow.

Requirements: Go and git.

1. Create `.custom-gcl.yml` in your project:

```yaml
version: v2.8.0
plugins:
- module: github.com/wego/pkg/linters/isolint
version: v0.1.0
```

2. Build custom golangci-lint:

```bash
golangci-lint custom
```

3. Configure the plugin in `.golangci.yml`:

```yaml
version: "2"

linters:
enable:
- isolint
settings:
custom:
isolint:
type: "module"
description: "Enforces currency/site package constant usage"
```

4. Run the resulting custom binary:

```bash
./custom-gcl run ./...
```

### As a standalone tool

```bash
go install github.com/wego/pkg/linters/isolint/cmd/isolint@latest
isolint ./...
```

## What it detects

### Currency codes (ISO 4217)

| Pattern | Suggestion |
|---------|------------|
| `"USD"` | `currency.USD` |
| `"EUR"` | `currency.EUR` |
| `"SGD"` | `currency.SGD` |
| ... | All 182 ISO 4217 codes |

### Site codes (ISO 3166-1 alpha-2)

| Pattern | Suggestion |
|---------|------------|
| `"SG"` | `site.SG` |
| `"US"` | `site.US` |
| `"JP"` | `site.JP` |
| ... | All 249 ISO 3166-1 alpha-2 codes |

### Detected positions

The linter catches raw ISO code strings in **all** Go expression positions:

- Comparisons: `code == "USD"`, `"SG" != code`
- Assignments: `x := "USD"`, `var x = "SG"`
- Constant/var declarations: `const c = "USD"`
- Switch/case: `case "USD":`, `case "SG":`
- Map keys/values: `map[string]int{"USD": 1}`, `m["SG"]`
- Function arguments: `foo("USD")`, `fmt.Println("SG")`
- Struct fields: `Config{Currency: "USD"}`
- Return values: `return "USD"`
- Slice/array literals: `[]string{"USD", "SGD"}`

### What it skips

- Files in `github.com/wego/pkg/currency` (defines the currency constants)
- Files in `github.com/wego/pkg/iso/site` (defines the site constants)
- Non-ISO strings like `"hello"`, `""`, `"test"`
- Lowercase strings like `"usd"`, `"sg"`
- Code already using constants: `currency.USD`, `site.SG`

## Import convention

```go
import (
"github.com/wego/pkg/currency"
"github.com/wego/pkg/iso/site"
)

// The linter suggests:
if code == currency.USD { ... }
if siteCode == site.SG { ... }
```

## Auto-fix

The linter provides suggested fixes that can be applied automatically:

```bash
# With golangci-lint
./custom-gcl run --fix ./...

# With standalone tool
isolint -fix ./...
```

**Note**: Auto-fix replaces the string literal but does not add the import statement. You will need to:
1. Run `goimports` to add missing imports
2. Verify the correct package is imported

## Development

### Local tests

The testdata directory is a standalone module. Run tests from the module root:

```bash
go test -v ./...
```

### Using a commit before tagging

If you need to consume an untagged commit from another repo, use a Go pseudo-version
instead of a raw SHA.

```bash
go list -m -json github.com/wego/pkg/linters/isolint@<commit>
```

Then use the returned `Version` value in `.custom-gcl.yml`:

```yaml
version: v2.8.0
plugins:
- module: github.com/wego/pkg/linters/isolint
version: v0.0.0-20260120hhmmss-abcdef123456
```
160 changes: 160 additions & 0 deletions linters/isolint/analyzer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Package isolint provides a Go analyzer that detects raw ISO code string
// literals and recommends using github.com/wego/pkg/currency and
// github.com/wego/pkg/iso/site package constants instead.
package isolint

import (
"go/ast"
"go/token"
"strconv"

"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"
)

// skipPackages are package import paths whose source files should not be
// checked. These packages define the constants themselves and must use raw
// string literals.
var skipPackages = map[string]bool{
"github.com/wego/pkg/currency": true,
"github.com/wego/pkg/iso/site": true,
}

// skipMethods are method/function names whose string arguments are key or
// column names, not ISO code values. When a string literal appears as an
// argument to one of these calls, it is skipped to reduce false positives.
var skipMethods = map[string]bool{
// HTTP framework methods — args are parameter names.
"Query": true,
"QueryParam": true,
"Param": true,
"FormValue": true,
"GetQuery": true,
"DefaultQuery": true,
"PostForm": true,

// ORM/DB methods — args are column names or SQL fragments.
"Select": true,
"Pluck": true,
"Omit": true,

// Custom filter methods — args are column names.
"Equals": true,
"NotEquals": true,
}

// Analyzer is the isolint analyzer that checks for raw ISO code string literals.
var Analyzer = &analysis.Analyzer{
Name: "isolint",
Doc: "recommends using currency/site package constants over raw ISO code string literals",
URL: "https://github.com/wego/pkg/linters/isolint",
Run: run,
Requires: []*analysis.Analyzer{inspect.Analyzer},
}

func run(pass *analysis.Pass) (any, error) {
// Skip packages that define the constants themselves.
// pass.Pkg may be nil under LoadModeSyntax in golangci-lint.
if pass.Pkg != nil && skipPackages[pass.Pkg.Path()] {
return nil, nil
}

inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

nodeFilter := []ast.Node{
(*ast.BasicLit)(nil),
}

// WithStack gives us ancestor context so we can skip import paths
// and arguments to known key-accepting methods.
inspect.WithStack(nodeFilter, func(n ast.Node, push bool, stack []ast.Node) bool {
if !push {
return true
}

lit := n.(*ast.BasicLit)
if lit.Kind != token.STRING {
return true
}

// Fast path: currency codes are 3 chars ("XXX" = 5 bytes quoted),
// site codes are 2 chars ("XX" = 4 bytes quoted).
// Skip everything else before allocating via Unquote.
vlen := len(lit.Value)
if vlen != 4 && vlen != 5 {
return true
}

// Import path literals (e.g. "io") are not ISO codes.
if isImportPath(stack) {
return true
}

// String arguments to ORM, HTTP, and filter methods are column
// or parameter names (e.g. db.Select("id"), c.Query("to")),
// not ISO code values.
if isArgToSkipMethod(stack) {
return true
}

value, err := strconv.Unquote(lit.Value)
if err != nil {
return true
}

// Route by length — currency and site codes can never overlap.
switch len(value) {
case 3:
if IsCurrencyCode(value) {
reportCurrencyDiagnostic(pass, lit, value)
}
case 2:
if IsSiteCode(value) {
reportSiteDiagnostic(pass, lit, value)
}
}

return true
})

return nil, nil
}

// isImportPath reports whether the BasicLit at the top of the stack is the
// path of an import declaration.
func isImportPath(stack []ast.Node) bool {
if len(stack) < 2 {
return false
}
_, ok := stack[len(stack)-2].(*ast.ImportSpec)
return ok
}

// isArgToSkipMethod reports whether the BasicLit at the top of the stack is
// a direct argument to a call expression whose method/function name appears
// in skipMethods.
func isArgToSkipMethod(stack []ast.Node) bool {
if len(stack) < 2 {
return false
}
call, ok := stack[len(stack)-2].(*ast.CallExpr)
if !ok {
return false
}
return skipMethods[callName(call)]
}

// callName extracts the method or function name from a call expression.
// For selector expressions (x.Method), it returns the method name.
// For plain identifiers (funcName), it returns the function name.
// Returns "" if the pattern doesn't match.
func callName(call *ast.CallExpr) string {
switch fn := call.Fun.(type) {
case *ast.SelectorExpr:
return fn.Sel.Name
case *ast.Ident:
return fn.Name
}
return ""
}
19 changes: 19 additions & 0 deletions linters/isolint/analyzer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package isolint_test

import (
"testing"

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

"github.com/wego/pkg/linters/isolint"
)

func TestAnalyzer(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, isolint.Analyzer, "./example")
}

func TestAnalyzerWithFixes(t *testing.T) {
testdata := analysistest.TestData()
analysistest.RunWithSuggestedFixes(t, testdata, isolint.Analyzer, "./example")
}
Loading
Loading