GitHub

Go Reference Test CodeQL Coverage Go Report Card Codeberg CI License

ScopeGuard is a Go static analyzer that identifies variables with unnecessarily wide scopes and suggests moving them closer to their usage for cleaner, more maintainable code.

Why Narrow Scope Matters

Have you ever scrolled through a long function trying to find where a variable was last used, only to discover it was declared 200 lines earlier?

Unnecessarily wide variable scopes increase cognitive overhead and complicate refactoring. When a variable is declared far from its use, readers must track its lifecycle across many unrelated lines of code.

Narrowing scopes solves this: variables don't need to be tracked once their block ends, code extraction becomes simpler with fewer dependencies, and stale data can't be accidentally reused.

By placing declarations close to their usage, you make the relationship between variables and control structures explicit, aligning with patterns from Effective Go and major style guides.

Go's design encourages narrow scoping through the := operator and initialization statements in control structures. ScopeGuard helps you apply these idioms automatically by identifying opportunities to tighten your code.

Features

ScopeGuard identifies three main categories of code quality improvements:

  • Scope narrowing: Moves declarations into initializers of if, for, or switch statements, or into narrower block scopes and case clauses. Supports both short declarations (:=) and explicit variable declarations.
  • Shadow detection: Identifies variables that shadow outer ones in the same function, preventing accidental usage of stale data and subtle bugs.
  • Nested assignments: Flags variables modified within their own assignment statement (e.g., inside a closure), a pattern that is often error-prone during refactoring.

Examples

Before:

func TestProcessor(t *testing.T) {
	// ...
	got, want := spyCC.Charges, charges
	if !cmp.Equal(got, want) {
		t.Errorf("spyCC.Charges = %v, want %v", got, want)
	}
}

After:

func TestProcessor(t *testing.T) {
	// ...
	if got, want := spyCC.Charges, charges; !cmp.Equal(got, want) {
		t.Errorf("spyCC.Charges = %v, want %v", got, want)
	}
}

Variables are moved into the if initializer, scoped exactly where needed; a practice from Go Style Best Practices.

Before:

func process(data []byte) error {
	var config Config
	err := json.Unmarshal(data, &config)
	if err != nil {
		return fmt.Errorf("invalid configuration: %w", err)
	}
	// ... rest of the function
}

After:

func process(data []byte) error {
	var config Config
	if err := json.Unmarshal(data, &config); err != nil {
		return fmt.Errorf("invalid configuration: %w", err)
	}
	// ... rest of the function
}

The err variable is scoped to the error-handling block.

Installation

Choose one of the following:

Go

go install fillmore-labs.com/scopeguard@latest

Homebrew

brew install fillmore-labs/tap/scopeguard

Eget

Install eget, then:

eget fillmore-labs/scopeguard

Usage

Analyze your code:

scopeguard ./...

Apply fixes automatically:

scopeguard -fix ./...

Review automated changes before committing. See Safety and Manual Review for cases requiring careful attention.

Recommended Workflow

  1. Start with the default safe pass:

    scopeguard -fix ./...
  2. Review and commit the changes.

  3. Run a comprehensive pass that also applies fixes flagged as potentially unsafe:

    scopeguard -fix -unsafe ./...
  4. Review the remaining changes carefully, manually refactoring where narrowing isn't straightforward.

See Unsafe Fixes for a detailed explanation of what "unsafe" means in this context.

When to Keep a Wider Scope

Not every suggestion improves readability. Patterns like early returns that reduce nesting may benefit from a wider scope. Always review suggestions to determine if narrowing truly improves clarity.

Advanced Configuration

ScopeGuard provides additional flags for fine-tuning analysis behavior.

Scope Analysis

Flag: -scope (default: true)

The eponymous analysis: this is ScopeGuard's core check. Disable this when you only want to check shadowing.

Shadowing Detection

Flag: -shadow (default: true)

Detects variables used after being shadowed in inner scopes. Although legal in Go, this can cause bugs:

func example() error {
	var err error
	if err := work(); err == nil {
		fmt.Println("work done")
	}
	return err // Returns nil, regardless of what work() returns
}

ScopeGuard's scope analysis never introduces shadowing issues; it only moves variables when safe.

Renaming Shadowed Variables

Flag: -rename (default: true with -fix)

Automatically renames shadowed variables when using -fix:

Before:

func transform(x int) int {
	switch x {
	case 1:
		x := x + 1
		return x
	case 2:
		x := x + 2
		if x > 2 {
			x := x + 3
			process(x)
		}
		return x
	default:
		x := x + 4
		process(x)
	}
	return x
}

After:

func transform(x_2 int) int {
	switch x_2 {
	case 1:
		x := x_2 + 1
		return x
	case 2:
		x_1 := x_2 + 2
		if x_1 > 2 {
			x := x_1 + 3
			process(x)
		}
		return x_1
	default:
		x := x_2 + 4
		process(x)
	}
	return x_2
}

The fix appends numeric suffixes (_1, _2) to outer variables. Replace these with descriptive names during code review.

This is safe: it only renames variables that already have different scopes, so program semantics don't change.

To disable: scopeguard -fix -rename=false ./...

Note

Variable renaming is skipped in functions where scope-narrowing fixes are applied during the same run. Run scopeguard -fix a second time to rename variables.

Tip

For safe renaming without scope transformations, run scopeguard -scope=false -fix ./...

Manual Shadow Resolution

Some shadowing issues are better resolved manually rather than by renaming:

Flagged code:

func validate(data []byte) ([]byte, error) {
	value, err := retrieve(data)
	if err != nil {
		return nil, err
	}
	if err := check(value); err != nil {
		return nil, err
	}
	return value, err // Flagged: err is used after shadowing
}

At the return statement, err is nil; stale data from previous operations. Explicitly returning nil clarifies the intent:

Fixed:

func validate(data []byte) ([]byte, error) {
	value, err := retrieve(data)
	if err != nil {
		return nil, err
	}
	if err := check(value); err != nil {
		return nil, err
	}
	return value, nil // Explicitly return nil
}

This makes it immediately obvious that this is the success path without needing to trace err backwards through the function.

Nested Assignments

Flag: -nested-assign (default: true)

Detects variables modified within their own assignment expression. This pattern is error-prone when code is parallelized or restructured:

Before:

func example() (string, error) {
	var (
		result string
		err    error
	)
	err = retry(func() error {
		result, err = lookup() // Nested reassignment of variable 'err'
		return err
	})
	return result, err
}

Fix this manually by shadowing err and explicitly assigning the result to the captured outer variable:

Fixed:

func example() (string, error) {
	var result string
	err := retry(func() error {
		res, err := lookup() // Shadow the outer err
		if err != nil {
			return err
		}
		result = res // Explicitly assign the return value only in the success case
		return nil
	})
	return result, err
}

Declaration Combining

Flag: -combine (default: true)

Combines multiple declarations when moving to the same initializer:

Before:

got := f(x)
want := "result"
if got != want {
	t.Errorf("got %q, expected %q", got, want)
}

After:

if got, want := f(x), "result"; got != want {
	t.Errorf("got %q, expected %q", got, want)
}

Set to false to report candidates without combining them.

Analysis Targets

  • -generated (default: false): Include generated files
  • -test (default: true): Include test files
  • -max-lines N (default: unlimited): Skip declarations longer than N lines

Suppressing Diagnostics

Use //nolint:scopeguard to suppress diagnostics on specific lines:

x, err := someFunction() //nolint:scopeguard

Unsafe Fixes

Some scope-narrowing transformations could change program semantics; for example, by reordering evaluation relative to side effects (see Side Effect Dependencies). ScopeGuard classifies these as unsafe and controls them with two flags:

  • -unsafe-diagnostics (default: true): report unsafe candidates as diagnostics without offering an automatic fix.
  • -unsafe (default: false): additionally provide automatic fixes for those candidates. Implies -unsafe-diagnostics.

Without -fix, the two flags are equivalent and both surface the same diagnostics. The difference only matters when applying fixes:

  • scopeguard -fix ./...: apply only safe fixes. Unsafe candidates still appear as diagnostics for manual review.
  • scopeguard -fix -unsafe ./...: apply all fixes, including unsafe ones. Review the resulting diff carefully.
  • scopeguard -unsafe-diagnostics=false ./...: suppress unsafe diagnostics entirely. Useful in CI, where unfixable diagnostics would otherwise add noise.

Safety and Manual Review

Always review automated changes from -fix. In some cases, you may need to restructure your code for the transformation to be semantically correct.

The scenarios below are examples of what ScopeGuard classifies as unsafe, except for rare cases of pointer aliasing or closure capture, which cannot be detected reliably and can occur even with otherwise safe fixes.

Side Effect Dependencies

ScopeGuard doesn't track implicit side effect dependencies:

Before:

called := false
f := func() string {
	called = true
	return "test"
}
got, want := f(), "test"
if !called {
	t.Error("expected f to be called")
}
if got != want {
	t.Errorf("got %q, expected %q", got, want)
}

After (breaks test):

called := false
f := func() string {
	called = true
	return "test"
}
if !called {
	t.Error("expected f to be called")
}
if got, want := f(), "test"; got != want {
	t.Errorf("got %q, expected %q", got, want)
}

The call to f() moves after the called check, breaking the test.

Fixes:

  • Rework the logic so the side effect is observed at the correct time (e.g., validate the result first, then check the side effect)
  • Use the result before testing the side effect (e.g., _ = got with a comment to document the dependency)
  • Suppress with //nolint:scopeguard

Evaluation Order Changes

Fixes can break code when variables are modified between declaration and use:

const s = "abcd"
i := 1
got, want := s[i], byte('b')
i++
if got != want {
	t.Errorf("got %q, expected %q", got, want)
}

Moving the declaration into the if evaluates s[i] after i++, changing the result.

Implicit Type Changes

Moving declarations can change inferred types when the original specified an explicit type:

var a, b int
a, c := 3.0+1.0, 4.5
fmt.Println(1 / a)
if true {
	b = 5.0
	fmt.Println(b, c)
}

… will be transformed to:

a, c := 3.0+1.0, 4.5
fmt.Println(1 / a)
if true {
	var b int
	b = 5.0
	fmt.Println(b, c)
}

Moving the declaration changes a from int to float64, altering the result of 1 / a (integer vs. float division).

This is rare. To avoid it, declare type-specific variables as narrowly as possible or use //nolint:scopeguard.

Pointer Aliasing

Moving declarations can change behavior with pointer aliasing or closure captures.

Before (prints 2):

x := 1
px, x := &x, 2
if x == 2 {
	fmt.Println(*px)
}

After (prints 1):

x := 1
if px, x := &x, 2; x == 2 {
	fmt.Println(*px)
}

Use //nolint:scopeguard to suppress, or avoid complex aliasing in declarations.

Integration

go vet

go vet -vettool=$(which scopeguard) ./...

golangci-lint Module Plugin

Add a .custom-gcl.yaml file to your project root:

---
version: v2.11.4
name: golangci-lint
destination: .
plugins:
  - module: fillmore-labs.com/scopeguard
    import: fillmore-labs.com/scopeguard/gclplugin
    version: v0.0.7

Then run golangci-lint custom from your project root. This produces a custom golangci-lint executable that can be configured in .golangci.yaml:

---
version: "2"
linters:
  enable:
    - scopeguard
  settings:
    custom:
      scopeguard:
        type: module
        description: >-
          Identifies variables with unnecessarily wide scope and suggests tighter scoping.
        original-url: https://fillmore-labs.com/scopeguard
        settings:
          scope: true
          shadow: true
          nested-assign: true
          unsafe: false
          unsafe-diagnostics: true
          rename: true
          combine: true
          max-lines: 10

Use it like golangci-lint:

./golangci-lint run ./...

The GitHub golangci-lint-action will automatically run the custom golangci-lint.

See also the golangci-lint module plugin system documentation.

MCP Server

The distribution includes an Model Context Protocol (MCP) server designed to help AI agents identify issues or write more idiomatic code.

The MCP server is included in the Homebrew distribution, or you can install it from source:

go install fillmore-labs.com/scopeguard/scopeguard-mcp@latest

You can then add it to Claude Code with:

claude mcp add --transport stdio --scope project ScopeGuard -- scopeguard-mcp

Gemini:

gemini mcp add --transport stdio --scope project ScopeGuard scopeguard-mcp

or adding

{
  "mcpServers": {
    "ScopeGuard": {
      "command": "scopeguard-mcp"
    }
  }
}

to .mcp.json, mcp_config.json or the configuration for your tool of choice.

Related Tools

Links

License

This project is licensed under the Apache License 2.0. See the LICENSE file for details.

Read the original on github.com ↗