Skip to content

A Go static analyzer that identifies variables with unnecessarily wide scope and suggests moving them to tighter scopes.

License

Notifications You must be signed in to change notification settings

fillmore-labs/scopeguard

Repository files navigation

ScopeGuard

Go Reference Test CodeQL Coverage Go Report Card Codeberg CI License

A Go static analyzer that identifies variables with unnecessarily wide scope and suggests moving them into tighter scopes.

Why Narrow Scope Matters

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

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 lines of code.

Narrow scopes address this: variables need not be tracked once their block ends, code extraction becomes simpler with fewer dependencies, and stale data cannot be accidentally reused.

Placing declarations close to their usage makes 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 detects opportunities to apply these idioms by moving declarations closer to their usage.

Features

ScopeGuard identifies three categories of issues:

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. Excludes moves that would cross loop, closure, or labeled statement boundaries.

Shadow detection: Detects variable shadowing (inner variables with the same name as outer ones), which can cause accidental usage of the wrong variable and subtle bugs.

Nested assignments: Identifies variables modified inside closures that are part of their own assignment statement.

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 ./...

Note

Review automated changes before committing. See limitations for cases requiring manual review.

Recommended Workflow

  1. Use -conservative for safer initial refactoring:

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

  3. Run a comprehensive pass:

    scopeguard -fix ./...
  4. Review the remaining changes, manually refactoring the code where needed.

When to Keep Wider Scope

Not every suggestion improves readability. Patterns like early returns that reduce nesting may benefit from a wider scope. Review each suggestion to determine if narrowing 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

Limitations

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

These limitations don't apply with -conservative, except for rare pointer aliasing or closure capture cases.

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.8.0

name: golangci-lint
destination: .

plugins:
  - module: fillmore-labs.com/scopeguard
    import: fillmore-labs.com/scopeguard/gclplugin
    version: v0.0.5

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
          conservative: false
          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.

Related Tools

Links

License

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

About

A Go static analyzer that identifies variables with unnecessarily wide scope and suggests moving them to tighter scopes.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages