Skip to content
Fred Souza edited this page Aug 3, 2026 · 1 revision

lanekeep checks .go files. Rules are TypeScript programs matching a Go syntax tree — see Home for why.

Install

go get -tool github.com/fmsouza/lanekeep/cmd/lanekeep
go tool lanekeep check ./...

That pins lanekeep in go.mod beside every other tool. Go's tooling installs and pins only things written in Go, so that package is a small launcher: it fetches the real binary on first use, verifies it against the release's published checksums, and caches it by version.

LANEKEEP_BINARY=/usr/local/bin/lanekeep go tool lanekeep check ./...

skips the fetch entirely — the answer for air-gapped CI. So does having lanekeep installed by any other route; the launcher checks its cache before reaching out.

brew install fmsouza/tap/lanekeep and the releases page work too.

Configure

lanekeep init in a directory with a go.mod writes this for you:

{
  "$schema": "https://raw.githubusercontent.com/fmsouza/lanekeep/main/schema/lanekeep.schema.json",

  "include": ["**/*.go"],
  "exclude": ["**/*_test.go"],

  "rules": [
    "lanekeep/no-package-init",
    "./lanekeep/rules/no-fmt-println.ts"
  ]
}

Generated code carries the same .go extension as everything else, so exclude it by path:

{ "exclude": ["**/*_test.go", "**/*.pb.go", "**/mock_*.go"] }

Built-in rules

lanekeep/no-context-in-struct

// bad
type Client struct { ctx context.Context }

// good
func (c *Client) Do(ctx context.Context) error { return nil }

A stored context outlives the call it was scoped to, so cancellation stops meaning what the caller intended — a long-lived client holds the context of whichever request happened to build it, and cancelling that request cancels unrelated work. Both context.Context and *context.Context are reported.

Known false positive: a package aliased to context and exposing a Context is reported like the standard library's. bindingKind says a name is an import, not which module.

lanekeep/no-package-init

// bad
func init() { registry["pg"] = newPostgres() }

// good
func Register(r map[string]Driver) { r["pg"] = newPostgres() }

init runs at import time in an order the language decides. Nothing calls it, so nothing says when it happens — and two packages registering into a shared map depend on an order neither states. Every init in a file is reported; Go permits several, which is what makes the ordering hard to reason about. A method named init is not reported: it is called explicitly.

Writing a Go rule

import { defineRule } from 'lanekeep'

export default defineRule({
  id: 'local/no-naked-return',
  language: 'go',              // ← required; the default is TypeScript
  severity: 'error',

  card: {
    message: 'naked return in a function with named results',
    remediation: 'return the values explicitly, so the reader does not have to scroll for them',
    examples: {
      bad: 'func f() (err error) {\n\treturn\n}',
      good: 'func f() (err error) {\n\treturn err\n}',
    },
  },

  query: '(function_declaration result: (parameter_list) body: (block)) @fn',

  check(ctx, m) {
    for (const ret of ctx.querySubtree(m.fn, '(return_statement) @r')) {
      if (ctx.namedChildren(ret.r).length === 0) ctx.report(ret.r)
    }
  },
})

language: 'go' is not optional. The default is ['typescript', 'tsx'], and a rule does not run on a file whose language it does not name — so omitting it means the rule silently never fires.

What resolution knows about Go

ctx.bindingKind understands:

Form Kind
import "net/http"http import
var x, x := 1, const c var, var, const
type T struct{}, type T = U type
func (r *Repo) M()r receiver
func F[T any]()T type-param
parameters and named results param
for i := range xs loop

Two Go-specific behaviors worth knowing:

Package-level declarations are order-independent. A function may call one declared two hundred lines below it, and resolution reflects that.

Several statements are scopes without being blocks. if x := f(); x != nil binds x across the condition, the consequence and the else branch. switch t := v.(type) binds a differently-typed t in each case clause.

The blank identifier never resolves. _ discards rather than binds.

Import paths versus names. import "net/http" binds http, while the module recorded is net/http. A rule matching the identifier sees http; one matching the module needs the full path.

Tree-sitter shapes that surprise people

Verified against the grammar, and each cost a round of failing tests when building Go support:

  • A three-clause for nests its parts in a for_clause; the initializer is not a field of for_statement.
  • name is a repeated field. var a, b int has two, and reading only the first loses b.
  • A block holds its statements one level down, in a statement_list.
  • type_spec carries type_parameters for a generic type declaration.

Clone this wiki locally