linter

package module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 9, 2026 License: MIT Imports: 6 Imported by: 0

README

go-linter-sdk

A small Go library for building linters that plug into a finding-based ecosystem.

Every Go linter reinvents the same scaffolding — a rule interface, a registry, and a converter that bridges the linter's own issue type to the ecosystem's finding type. go-linter-sdk standardizes the first two and eliminates the third: rules emit finding.Finding directly, so there is no converter layer to maintain. A linter that adopts it ships a rules.go and a main.go one-liner; the registry, detector adapters, error attribution, and exit codes are shared.

Go Reference CI

pkg.go.dev

Status: Early. The Rule/Registry/Detector core is stable and tested (98.2% coverage, race-clean). The value proposition — eliminating converter code — is proven by examples/no-go-mod but no production linter has fully migrated yet.


Why?

Every Go linter built on a shared findings format reinvents the same three layers:

Layer What it does
Rule interface Declares a check's identity + Check function
Registry Holds rules, drives execution
Issue → finding.Finding converter Bridges the linter's native type to the ecosystem type

The third row is the killer. When a linter's own issue type predates the ecosystem's finding.Finding, every new finding field means touching the converter. Every refactor cascades. In the LarsArtmann ecosystem this duplication is concrete:

branching-flow erraudit go-structure-linter
Rule interface custom custom custom
Registry custom custom custom
Violation → finding.Finding converter 1,871 LOC 1,214 LOC 0 (type Issue = finding.Finding)

go-structure-linter got it rightest by aliasing Issue = finding.Finding — no converter at all. go-linter-sdk codifies that pattern. A rule emits finding.Finding directly via finding.NewBuilder(...), so there is no intermediate type to convert.

The three linters in the table above are LarsArtmann-internal repositories; they are cited as evidence, not as installable consumers.


Installation

go get github.com/larsartmann/go-linter-sdk

Requires Go 1.26+ and go-finding (see go.mod for the pinned version).


Quick Start

Three steps from zero to a running linter:

  1. Installgo get github.com/larsartmann/go-linter-sdk (requires Go 1.26+ and a recent go-finding; see go.mod)
  2. Define rules — write linter.RuleFunc{Meta: ..., Run: ...} that emits finding.Finding directly
  3. Runregistry.Run(ctx, dir) + linter.ExitCodeFromReport(report)

A complete runnable example is at examples/minimal-linter. See Usage below for the full walkthrough.


Usage

A minimal linter
package main

import (
    "context"
    "os"

    "github.com/larsartmann/go-finding"
    "github.com/larsartmann/go-linter-sdk"
)

var registry = linter.NewRegistry()

func init() {
    registry.Register(linter.RuleFunc{
        Meta: linter.RuleMeta{
            ID:          "no-fmt-println",
            Name:        "no fmt.Println in libraries",
            Description: "fmt.Println is banned in libraries; use a logger",
            Cat:         linter.CategoryStyle,
            Sev:         finding.SeverityWarning,
        },
        Run: func(ctx context.Context, dir string) ([]finding.Finding, error) {
            // ... scan dir for fmt.Println; emit findings directly ...
            return []finding.Finding{
                finding.NewBuilder("no-fmt-println", "my-linter",
                    "fmt.Println is banned in libraries; use a logger",
                    finding.SeverityWarning,
                    finding.Pos(finding.FilePath("example.go"), 1, 1)).
                    MustBuild(),
            }, nil
        },
    })
}

func main() {
    report, _ := registry.Run(context.Background(), ".")
    os.Exit(linter.ExitCodeFromReport(report))
}

A complete, runnable version lives at examples/minimal-linter — run it with GOEXPERIMENT=jsonv2 go run ./examples/minimal-linter [dir].

Plug into BuildFlow's DAG
detector := linter.DetectorFromRegistry(registry, "my-linter")
// Pass `detector` to toolsdk.Spec{ Detect: detector, ... } or directly to
// BuildFlow's domain.DetectorFromFinding(detector, toolName).

The working directory is read via finding.WorkingDirFromContext(ctx), so module fan-out and per-directory runs work transparently.

Plug into go-finding/pipeline (per-rule parallelism)
detectors := linter.DetectorsFromRegistry(registry)
// Pass `detectors` to pipeline.New(config, rootDir, detectors...) for
// per-detector parallelism, timeouts, error isolation, and metrics.
//
// Unlike DetectorFromRegistry (which collapses all rules into one opaque
// detector), each detector is named after the rule's ID — the pipeline
// attributes timing and errors to individual rules.
Building findings with Confidence and FixStrategy

Each finding carries its own Confidence and FixStrategy, set via finding.NewBuilder(...). This is strictly more expressive than rule-level defaults: a single rule can emit findings with different confidence levels and fix strategies depending on the matched pattern.

return []finding.Finding{
    finding.NewBuilder(ruleID, toolName, "unused variable", severity, pos).
        WithConfidence(finding.ConfidenceHigh).
        WithFixStrategy(finding.FixStrategyDirect).
        MustBuild(),
}, nil
Confidence When to use
ConfidenceLow Heuristic match; may be false positive
ConfidenceMedium Likely a real issue
ConfidenceHigh Definitely a real issue
FixStrategy Meaning
FixStrategyNone No fix available
FixStrategySuggest Suggest a fix in output
FixStrategyDirect Can auto-fix programmatically
FixStrategyAI Requires AI to generate a fix
Data flow

Rules emit finding.Finding directly — no intermediate type. The SDK aggregates, reports, and maps to exit codes:

graph LR
    R["Rule.Check(ctx, dir)"] --> F["[]finding.Finding"]
    F --> REP["finding.Report"]
    REP --> EC["ExitCode (0 or 1)"]
Rule.Check(ctx, dir) → []finding.Finding → Report → ExitCode (0 clean / 1 findings)
Two execution paths

The SDK offers two ways to run rules. Pick based on your integration target:

graph LR
    subgraph Run["Registry.Run — standalone CLI"]
        R1["Rule 1"] --> R2["Rule 2"] --> R3["Rule N"] --> RR["*finding.Report"]
    end

    subgraph Det["DetectorsFromRegistry — pipeline"]
        D0["Split"] --> D1["Detector: Rule 1"]
        D0 --> D2["Detector: Rule 2"]
        D0 --> D3["Detector: Rule N"]
        D1 & D2 & D3 -.->|parallel| DR["pipeline.Run"]
    end
ASCII fallback (for pkg.go.dev)
Registry.Run (standalone CLI):     Rule 1 → Rule 2 → Rule N → *finding.Report
DetectorsFromRegistry (pipeline): Split → [Detector: Rule 1 | Rule 2 | Rule N] → pipeline.Run
Registry.Run DetectorsFromRegistry
Execution Sequential Parallel (one goroutine per rule)
Failure policy Fail-fast (default) or ContinueOnError() Per-detector isolation (pipeline handles)
Granularity Single opaque run Per-rule detectors with named metrics
Best for Simple CLI linters go-finding/pipeline, BuildFlow DAG

API

Types
Type Purpose
Rule interface ID() / Name() / Description() / Category() / Severity() / IsEnabledByDefault() / Check(ctx, dir) ([]Finding, error)
RuleFunc struct Adapter: combines a RuleMeta header with a Run closure to satisfy Rule. Enabled by default.
RuleMeta struct Declarative identity: ID (required, stable), Name, Description, Cat, Sev
Category Open string type. 8 recommended values (CategoryDesign, ...); define your own for domain-specific taxonomies
Registry Holds rules; thread-safe with sync.RWMutex
Functions
Function Returns Purpose
NewRegistry(opts…) *Registry Empty registry; pass WithToolName(...) to stamp tool name onto findings
WithToolName(name) RegistryOption Stamp the tool name onto all findings and the report
(*Registry).Register(rule) Add a rule (panics on duplicate ID or empty identity fields)
(*Registry).All() []Rule Snapshot of registered rules
(*Registry).Get(id) Rule, bool Lookup by stable ID
(*Registry).Has(id) bool Check if a rule ID is registered
(*Registry).Deregister(id) bool Remove a rule by ID (returns true if found)
(*Registry).Run(ctx, dir, opts…) *finding.Report, error Run all rules; aggregate findings. Fail-fast by default; pass ContinueOnError() for partial results
ContinueOnError() RunOption Run option: continue past rule failures, collect partial findings, join errors
DetectorFromRegistry(r, toolName) finding.Detector Adapt registry to a single Detector (BuildFlow DAG)
DetectorsFromRegistry(r) []finding.Detector One Detector per rule (go-finding/pipeline: per-rule parallelism, timeouts, error isolation)
ExitCodeFromReport(report) int 0 if clean, 1 if findings — the ecosystem exit-code convention
ExitCodeByConfidence(report, thr) int 0 clean / 1 at-or-above threshold / 2 below threshold (triage mode)
FilterRules(all, enable, disable) []RuleFunc Standard --enable/--disable filtering for CLI and plugin entry points
OptIn(rf) Rule Wrap a RuleFunc as disabled-by-default (opt-in rule; runs only with explicit --enable <id>)
RuleFunc.NewFinding(msg, pos) *finding.Builder Pre-stamped builder from rule metadata (rule ID, tool name, severity, category)
RuleMeta.Validate() error Check required fields (ID, Name, Description, Cat) before construction

Design notes

  • Depends only on go-finding (the ecosystem hub) so any consumer — CLI, library, LSP server, golangci-lint plugin — can adopt it without coupling.
  • Rules emit finding.Finding directly. No intermediate Violation/Issue type. No converter layer. This is the core design decision.
  • Dual identity: ID() + Name(). ID() is the stable identifier (never changes, used for dedup/suppression/filtering); Name() is the display name (mutable). Every rule must declare an explicit ID.
  • Category is an open type. The 8 built-in constants are recommendations. Define your own for domain-specific taxonomies: linter.Category("api").
  • Registry.Register panics on duplicate IDs or empty identity fields — duplicate or missing rule IDs/Names/Descriptions/Categories are programming errors that should surface at startup.
  • Two execution paths. Registry.Run for standalone CLI (sequential, fail-fast or ContinueOnError); DetectorsFromRegistry for go-finding/pipeline (parallel, per-rule isolation).
  • Per-finding Confidence and FixStrategy. Use finding.NewBuilder(...).WithConfidence(...).WithFixStrategy(...) to set these per finding — more expressive than rule-level defaults.
  • ExitCodeFromReport is binary (0 clean / 1 any findings). The ecosystem convention; tools that want severity-tiered exit codes do their own mapping.

Migration path

Existing linters migrate incrementally — one rule at a time:

  1. Add go-linter-sdk as a dependency
  2. Pick one rule; convert its native type to emit finding.Finding via finding.NewBuilder(...)
  3. Wrap it in linter.RuleFunc{Meta: ..., Run: ...} and Register it
  4. Repeat for each rule
  5. Once all rules are migrated, delete the converter package (pkg/finding/ in branching-flow / erraudit)

Each step compiles and runs independently. No big-bang migration.


Consumers

Example consumers in this repo:

  • examples/minimal-linter — minimal linter proving the Rule → finding.Finding path
  • examples/no-go-mod — pilot port of go-structure-linter's NoGoModRule

Planned adoption targets:

  • go-structure-linter — cleanest existing pattern; pilot target
  • branching-flow (1,871 LOC of converters to delete)
  • erraudit (1,214 LOC of converters to delete)
  • Future linters

No external consumers yet.

Status

Early. The Rule/Registry/Detector core is stable. The migration story is proven by examples/no-go-mod (a pilot port of go-structure-linter's NoGoModRule) but no production linter has fully migrated yet.

Support & security

Open repository: issues and PRs from anyone are welcome, maintained best-effort with no SLAs — see SUPPORT.md. Vulnerabilities go through private advisories, never public issues — see SECURITY.md.

License

MIT — see LICENSE.

Documentation

Overview

Package linter provides the shared scaffolding for LarsArtmann Go linters (branching-flow, erraudit, go-structure-linter, and future additions).

Each of those linters independently reinvented the same three layers:

  • A Rule interface (Name/Description/Severity/Check)
  • A registry of rules
  • A converter from their native domain type to finding.Finding

The last layer — the converter — is the most expensive duplication: branching-flow ships 1,871 LOC of converters, erraudit 1,214 LOC, just to bridge their Violation/ErrorViolation types to finding.Finding. go-structure-linter eliminated this entirely by aliasing `type Issue = finding.Finding`.

This SDK codifies the go-structure-linter pattern: a Rule emits finding.Finding directly, so there is NO converter layer. A linter that adopts this SDK ships a rules.go file and a main.go one-liner — the registry, detection, and exit codes are shared.

Design constraint: depends only on go-finding (the ecosystem hub) so any linter — CLI, library, LSP server, golangci plugin — can adopt it.

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrMissingFields = errors.New("linter: rule has missing required field(s)")

ErrMissingFields is the sentinel error returned by RuleMeta.Validate and Register when a rule has one or more empty required identity fields. Use errors.Is to check:

if errors.Is(err, linter.ErrMissingFields) {
    // rule has missing ID, Name, Description, or Category
}
View Source
var ErrRuleFailed = errors.New("linter: rule failed")

ErrRuleFailed is the sentinel error for a rule execution failure. Use errors.Is(err, linter.ErrRuleFailed) to check whether a Registry.Run or DetectorFromRegistry call failed because a rule returned an error.

Functions

func DetectorFromRegistry

func DetectorFromRegistry(registry *Registry, toolName string) finding.Detector

DetectorFromRegistry adapts a registry to the canonical finding.Detector interface, so a linter plugs into BuildFlow's DAG with zero glue (via the tool-sdk Spec.Detect field). The working directory is read from ctx via finding.WorkingDirFromContext.

For go-finding/pipeline integration with per-rule parallelism, use DetectorsFromRegistry instead — it returns one detector per rule rather than collapsing all rules into a single opaque detector.

func DetectorsFromRegistry

func DetectorsFromRegistry(registry *Registry) []finding.Detector

DetectorsFromRegistry returns one finding.Detector per registered rule, so a pipeline (go-finding/pipeline) can run them with per-detector parallelism, timeouts, and error isolation. Each detector is named after the rule's ID for pipeline metric attribution.

Unlike DetectorFromRegistry, which collapses all rules into a single opaque detector, this function preserves per-rule granularity. The working directory is read from ctx via finding.WorkingDirFromContext.

pipeline.Detector is a type alias for finding.Detector, so the returned slice plugs directly into pipeline.New(config, rootDir, detectors...).

Example

ExampleDetectorsFromRegistry demonstrates the go-finding/pipeline execution path: each rule becomes its own Detector, enabling per-rule parallelism, timeouts, and error isolation.

package main

import (
	"context"
	"fmt"

	"github.com/larsartmann/go-finding"
	"github.com/larsartmann/go-linter-sdk"
)

func main() {
	registry := linter.NewRegistry()

	registry.Register(linter.RuleFunc{
		Meta: linter.RuleMeta{
			ID:          "pipe-rule",
			Name:        "pipeline rule",
			Description: "demonstrates the per-rule detector path",
			Cat:         linter.CategoryStyle,
			Sev:         finding.SeverityInfo,
		},
		Run: func(_ context.Context, _ string) ([]finding.Finding, error) {
			return []finding.Finding{
				finding.NewBuilder("pipe-rule", "example", "pipeline finding",
					finding.SeverityInfo,
					finding.Pos(finding.FilePath("main.go"), 10, 1)).
					MustBuild(),
			}, nil
		},
	})

	detectors := linter.DetectorsFromRegistry(registry)

	fmt.Println("detectors:", len(detectors))
	fmt.Println("name:", detectors[0].Name())

	findings, _ := detectors[0].Detect(context.Background())

	fmt.Println("findings:", len(findings))
}
Output:
detectors: 1
name: pipe-rule
findings: 1

func ExitCodeByConfidence added in v0.2.0

func ExitCodeByConfidence(report *finding.Report, threshold finding.Confidence) int

ExitCodeByConfidence returns a tiered exit code based on finding confidence:

  • 0 when the report is nil or has no findings (clean).
  • 1 when at least one finding is at or above threshold (must fix).
  • 2 when findings exist but all are below threshold (triage).

This lets CI distinguish "please review" from "must fix". For example, exit non-zero only for high-confidence findings:

code := linter.ExitCodeByConfidence(report, finding.ConfidenceHigh)
os.Exit(code)
Example

ExampleExitCodeByConfidence demonstrates tiered exit codes based on finding confidence: 0 (clean), 1 (must fix), 2 (triage).

package main

import (
	"fmt"

	"github.com/larsartmann/go-finding"
	"github.com/larsartmann/go-linter-sdk"
)

func main() {
	report := finding.NewReport(finding.ToolInfo{Name: "demo"})

	low := finding.NewBuilder("r", "demo", "low confidence",
		finding.SeverityInfo,
		finding.Pos(finding.FilePath("a.go"), 1, 1)).
		WithConfidence(finding.ConfidenceLow).
		MustBuild()
	report.AddFindings([]finding.Finding{low})

	code := linter.ExitCodeByConfidence(report, finding.ConfidenceHigh)
	fmt.Println("exit code:", code)
}
Output:
exit code: 2

func ExitCodeFromReport

func ExitCodeFromReport(report *finding.Report) int

ExitCodeFromReport returns the process exit code for a lint run: 0 when there are no findings, 1 otherwise. Critical-severity findings could map to a different code, but the ecosystem convention is binary (clean / not clean).

Types

type Category

type Category string

Category classifies what kind of issue a rule detects, for filtering and reporting. Maps to finding.Category at the finding boundary.

const (
	CategoryDesign        Category = "design"    // design smells (coupling, cohesion)
	CategoryStructure     Category = "structure" // file/package layout
	CategoryErrorHandling Category = "error-handling"
	CategoryCorrectness   Category = "correctness"
	CategoryStyle         Category = "style"
	CategoryPerformance   Category = "performance"
	CategorySecurity      Category = "security"
	CategoryConfiguration Category = "configuration"
)

Category values classify what kind of issue a rule detects, for filtering and reporting. Each maps to finding.Category at the finding boundary.

These are RECOMMENDED values, not a closed set. Define your own for domain-specific taxonomies:

const CategoryAPI Category = "api" // your domain category

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry holds a set of rules. A linter registers all its rules (typically in a rules.go via init() or a constructor) and the registry drives both standalone execution and BuildFlow integration.

func NewRegistry

func NewRegistry(opts ...RegistryOption) *Registry

NewRegistry creates an empty registry. Pass WithToolName to set the tool name that flows into finding identity and report metadata:

r := linter.NewRegistry(linter.WithToolName("my-linter"))

func (*Registry) All

func (r *Registry) All() []Rule

All returns every registered rule, in registration order.

func (*Registry) Deregister added in v0.2.0

func (r *Registry) Deregister(id string) bool

Deregister removes the rule with the given ID. Returns true if a rule was removed, false if no rule with that ID was registered. Safe to call concurrently with Run — Run takes a snapshot of the rule list via All() before iterating, so a rule in the snapshot still executes even if it is deregistered mid-run.

func (*Registry) Get added in v0.2.0

func (r *Registry) Get(id string) (Rule, bool)

Get returns the rule with the given ID and true, or nil and false if no rule with that ID is registered. Lookup is by the stable ID() (not the display Name()), matching Register's deduplication key.

func (*Registry) Has added in v0.2.0

func (r *Registry) Has(id string) bool

Has reports whether a rule with the given ID is registered.

func (*Registry) Register

func (r *Registry) Register(rule Rule)

Register adds a rule. Panics if a rule with the same ID is already registered — duplicate IDs are a programming error that should surface at startup, not silently shadow at runtime. Panics if any required identity field (ID, Name, Description, Category) is empty.

func (*Registry) Run

func (r *Registry) Run(ctx context.Context, dir string, opts ...RunOption) (*finding.Report, error)

Run executes every rule against dir, aggregating findings. This is the standalone execution path (the linter's own CLI). The BuildFlow integration path uses DetectorFromRegistry instead.

By default Run fails fast: the first rule error aborts the run and returns (nil, err). Pass ContinueOnError() to run all rules regardless of individual failures, collecting partial findings and joining all errors:

report, err := registry.Run(ctx, dir, linter.ContinueOnError())

In continue-on-error mode the returned report is always non-nil (it contains findings from every rule that succeeded) and err is the join of all failures (nil if every rule succeeded).

Example

ExampleRegistry_Run demonstrates the standalone execution path: register rules, run them against a directory, and read the aggregated report.

package main

import (
	"context"
	"fmt"

	"github.com/larsartmann/go-finding"
	"github.com/larsartmann/go-linter-sdk"
)

func main() {
	registry := linter.NewRegistry()

	registry.Register(linter.RuleFunc{
		Meta: linter.RuleMeta{
			ID:          "demo-rule",
			Name:        "demo rule",
			Description: "emits a single finding for demonstration",
			Cat:         linter.CategoryStyle,
			Sev:         finding.SeverityWarning,
		},
		Run: func(_ context.Context, _ string) ([]finding.Finding, error) {
			return []finding.Finding{
				finding.NewBuilder("demo-rule", "example", "found a style issue",
					finding.SeverityWarning,
					finding.Pos(finding.FilePath("demo.go"), 1, 1)).
					MustBuild(),
			}, nil
		},
	})

	report, _ := registry.Run(context.Background(), ".")

	fmt.Println("findings:", report.Len())
}
Output:
findings: 1
Example (ContinueOnError)

ExampleRegistry_Run_continueOnError demonstrates the continue-on-error failure policy: all rules run regardless of individual failures, partial findings are collected, and errors are joined.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/larsartmann/go-finding"
	"github.com/larsartmann/go-linter-sdk"
)

func main() {
	registry := linter.NewRegistry()

	registry.Register(linter.RuleFunc{
		Meta: linter.RuleMeta{
			ID:          "good",
			Name:        "good rule",
			Description: "always succeeds",
			Cat:         linter.CategoryStyle,
			Sev:         finding.SeverityWarning,
		},
		Run: func(_ context.Context, _ string) ([]finding.Finding, error) {
			return []finding.Finding{
				finding.NewBuilder("good", "example", "partial result",
					finding.SeverityWarning,
					finding.Pos(finding.FilePath("a.go"), 1, 1)).
					MustBuild(),
			}, nil
		},
	})

	registry.Register(linter.RuleFunc{
		Meta: linter.RuleMeta{
			ID:          "bad",
			Name:        "bad rule",
			Description: "always fails",
			Cat:         linter.CategoryStyle,
			Sev:         finding.SeverityWarning,
		},
		Run: func(_ context.Context, _ string) ([]finding.Finding, error) {
			return nil, errors.New("simulated failure")
		},
	})

	report, err := registry.Run(context.Background(), ".", linter.ContinueOnError())

	fmt.Println("findings:", report.Len())
	fmt.Println("has error:", err != nil)
}
Output:
findings: 1
has error: true

type RegistryOption added in v0.2.0

type RegistryOption func(*Registry)

RegistryOption configures a Registry at construction time.

func WithToolName added in v0.2.0

func WithToolName(name string) RegistryOption

WithToolName sets the tool name that the Registry stamps onto findings and reports. When set, Register auto-fills RuleMeta.ToolName on every RuleFunc (and OptIn rule) that does not already specify one, and Run uses it for the finding.ToolInfo in the aggregated report.

Without this option, the tool name defaults to "linter".

type Rule

type Rule interface {
	ID() string
	Name() string
	Description() string
	Category() Category
	Severity() finding.Severity
	IsEnabledByDefault() bool
	Check(ctx context.Context, dir string) ([]finding.Finding, error)
}

Rule is a single lint check. A linter is a collection of rules. Each rule declares its identity (ID/Name/Description/Category/Severity) and a Check function that emits findings for a target directory.

Dual identity: ID vs Name

Every rule has two identity fields with distinct stability contracts:

  • ID() is the STABLE identifier. It never changes once published. Used for registry deduplication, suppression matching (//tool:ignore ID), filter config (--enable ID), and the finding.RuleName field. Think "G001", "C001", "no-unused-vars".
  • Name() is the DISPLAY name. Human-readable, can change across versions without breaking suppression, filtering, or health-score computation. Think "missing transaction commit", "unused variable".

Rules emit finding.Finding DIRECTLY — there is no intermediate Violation or Issue type to convert. This is the key design decision that eliminates the thousands of LOC of converter code the existing linters carry. Use finding.NewBuilder(...) to construct each finding.

Building findings with Confidence and FixStrategy

finding.Finding already carries Confidence (finding.ConfidenceLow/Medium/High) and FixStrategy (finding.FixStrategyNone/Suggest/Direct/AI) at the per-finding level. Set these on individual findings via finding.NewBuilder(...):

finding.NewBuilder(ruleID, toolName, msg, sev, pos).
    WithConfidence(finding.ConfidenceHigh).
    WithFixStrategy(finding.FixStrategyDirect).
    MustBuild()

Per-finding values are strictly more expressive than rule-level defaults: a single rule can emit findings with different confidence levels and fix strategies depending on the matched pattern. RuleMeta.Sev is the default severity — individual findings can override it via the Builder.

func OptIn

func OptIn(rf RuleFunc) Rule

OptIn returns a Rule that is disabled by default — it only runs when a consumer explicitly enables it (e.g. via --enable <id>). Use for rules that are noisy, experimental, or domain-specific:

r.Register(linter.OptIn(linter.RuleFunc{Meta: ..., Run: ...}))
Example

ExampleOptIn demonstrates creating a rule that is disabled by default — it only runs when a consumer explicitly enables it (e.g. via --enable <id>).

package main

import (
	"context"
	"fmt"

	"github.com/larsartmann/go-finding"
	"github.com/larsartmann/go-linter-sdk"
)

func main() {
	rule := linter.OptIn(linter.RuleFunc{
		Meta: linter.RuleMeta{
			ID:          "experimental",
			Name:        "experimental check",
			Description: "noisy; enable explicitly when needed",
			Cat:         linter.CategoryDesign,
			Sev:         finding.SeverityWarning,
		},
		Run: func(_ context.Context, _ string) ([]finding.Finding, error) { return nil, nil },
	})

	fmt.Println("enabled by default:", rule.IsEnabledByDefault())
	fmt.Println("id:", rule.ID())
}
Output:
enabled by default: false
id: experimental

type RuleError

type RuleError struct {
	RuleID string
	Cause  error
}

RuleError wraps an error from a specific rule, preserving the rule's stable ID so callers can identify which rule in a registry failed. Returned by Registry.Run, RuleFunc.Check, DetectorFromRegistry, and DetectorsFromRegistry when a rule's execution fails.

Use errors.AsType (Go 1.26+) to extract the rule ID:

ruleErr, ok := errors.AsType[*linter.RuleError](err)
if ok {
    log.Printf("rule %s failed", ruleErr.RuleID)
}

To check whether any rule failed (regardless of which), match the sentinel:

if errors.Is(err, linter.ErrRuleFailed) {
    // a rule returned an error
}

func NewRuleError

func NewRuleError(ruleID string, cause error) *RuleError

NewRuleError wraps cause as a RuleError for the rule identified by ruleID.

func RuleErrors added in v0.2.0

func RuleErrors(err error) []*RuleError

RuleErrors extracts every *RuleError from err, handling both single errors and joined errors produced by Registry.Run in ContinueOnError mode. Returns nil if err is nil or contains no *RuleError values.

report, err := registry.Run(ctx, dir, linter.ContinueOnError())
for _, ruleErr := range linter.RuleErrors(err) {
    log.Printf("rule %s failed: %v", ruleErr.RuleID, ruleErr.Cause)
}

func (*RuleError) Error

func (e *RuleError) Error() string

Error implements error.

func (*RuleError) Is

func (*RuleError) Is(target error) bool

Is supports errors.Is against ErrRuleFailed.

func (*RuleError) Unwrap

func (e *RuleError) Unwrap() error

Unwrap returns the underlying cause for errors.Unwrap / errors.Is.

type RuleFunc

type RuleFunc struct {
	Meta RuleMeta
	Run  func(ctx context.Context, dir string) ([]finding.Finding, error)
}

RuleFunc adapts a function to the Rule interface, filling in the identity fields from the supplied RuleMeta. This is the common case: most rules are a metadata header plus a check closure.

func FilterRules added in v0.2.0

func FilterRules(all []RuleFunc, enable, disable map[string]bool) []RuleFunc

FilterRules returns the subset of rules that should run, given the enable and disable sets. When enable is non-empty, only those rules are included (minus any also disabled). When enable is empty, all rules except disabled ones are included. When both are empty, all rules are returned unchanged.

This is the standard --enable/--disable filtering logic shared by CLI and plugin entry points:

rules := linter.FilterRules(humanizelint.AllRules(), enableSet, disableSet)
for _, rule := range rules {
	registry.Register(rule)
}
Example

ExampleFilterRules demonstrates the standard --enable/--disable filtering shared by CLI and plugin entry points.

package main

import (
	"fmt"

	"github.com/larsartmann/go-linter-sdk"
)

func main() {
	all := []linter.RuleFunc{
		{Meta: linter.RuleMeta{ID: "a", Name: "a", Description: "a", Cat: linter.CategoryStyle}},
		{Meta: linter.RuleMeta{ID: "b", Name: "b", Description: "b", Cat: linter.CategoryStyle}},
		{Meta: linter.RuleMeta{ID: "c", Name: "c", Description: "c", Cat: linter.CategoryStyle}},
	}

	enabled := linter.FilterRules(all, map[string]bool{"a": true, "c": true}, nil)

	for _, r := range enabled {
		fmt.Println(r.Meta.ID)
	}
}
Output:
a
c

func (RuleFunc) Category

func (r RuleFunc) Category() Category

Category implements Rule.

func (RuleFunc) Check

func (r RuleFunc) Check(ctx context.Context, dir string) ([]finding.Finding, error)

Check implements Rule. If Run returns an error, it is wrapped into a *RuleError carrying the rule's stable ID, so callers of Registry.Run and DetectorFromRegistry can identify which rule failed via errors.As.

func (RuleFunc) Description

func (r RuleFunc) Description() string

Description implements Rule.

func (RuleFunc) ID

func (r RuleFunc) ID() string

ID implements Rule. Returns the stable identifier from RuleMeta.ID.

func (RuleFunc) IsEnabledByDefault

func (RuleFunc) IsEnabledByDefault() bool

IsEnabledByDefault returns true for RuleFunc. Rules created via RuleFunc are enabled by default — they run unless a consumer explicitly disables them. For opt-in rules, use OptIn().

This is METADATA only — Run, DetectorFromRegistry, and DetectorsFromRegistry execute every registered rule regardless of this value. Consumers that want --enable/--disable semantics must filter the rule set themselves, e.g. via FilterRules.

func (RuleFunc) Name

func (r RuleFunc) Name() string

Name implements Rule.

func (RuleFunc) NewFinding added in v0.2.0

func (r RuleFunc) NewFinding(message string, pos finding.Position) *finding.Builder

NewFinding returns a finding.Builder pre-configured with the rule's identity: rule ID, tool name, default severity, and category — all drawn from RuleMeta. Chain per-finding fields (confidence, suggestion, before/after code, etc.) on the returned builder before calling Build, MustBuild, or BuildOrDefault.

This eliminates the boilerplate of repeating rule ID, tool name, severity, and category in every finding.NewBuilder call inside a rule's Run function.

Example:

f := rule.NewFinding("manual byte formatting", pos).
    WithConfidence(finding.ConfidenceHigh).
    WithSuggestion("use humanize.Bytes").
    MustBuild()
Example

ExampleRuleFunc_NewFinding demonstrates the rule.NewFinding factory: it pre-fills the rule ID, tool name, severity, and category from RuleMeta, so the Run closure only supplies the message and position. Chain per-finding fields (confidence, suggestion, etc.) on the returned builder.

package main

import (
	"context"
	"fmt"

	"github.com/larsartmann/go-finding"
	"github.com/larsartmann/go-linter-sdk"
)

func main() {
	var rule linter.RuleFunc

	rule = linter.RuleFunc{
		Meta: linter.RuleMeta{
			ID:          "demo-rule",
			Name:        "demo rule",
			Description: "emits a single finding for demonstration",
			Cat:         linter.CategoryStyle,
			Sev:         finding.SeverityWarning,
			ToolName:    "example-linter",
		},
		Run: func(_ context.Context, _ string) ([]finding.Finding, error) {
			return []finding.Finding{
				rule.NewFinding("found a style issue",
					finding.Pos(finding.FilePath("demo.go"), 1, 1)).
					WithSuggestion("consider using a constant").
					MustBuild(),
			}, nil
		},
	}

	registry := linter.NewRegistry(linter.WithToolName("example-linter"))
	registry.Register(rule)

	report, _ := registry.Run(context.Background(), ".")

	fmt.Println("findings:", report.Len())
}
Output:
findings: 1

func (RuleFunc) Severity

func (r RuleFunc) Severity() finding.Severity

Severity implements Rule.

type RuleMeta

type RuleMeta struct {
	ID          string
	Name        string
	Description string
	Cat         Category
	Sev         finding.Severity
	// ToolName is the tool name stamped onto findings created via [RuleFunc.NewFinding].
	// When empty, NewFinding falls back to "linter". Set this so findings are attributed
	// to the actual linter, not a generic "linter" string. A [Registry] configured with
	// [WithToolName] auto-stamps this field at registration time if the rule does not set it.
	ToolName finding.ToolName
}

RuleMeta is the declarative identity of a rule: ID, name, description, category, severity. Supplied as a struct literal so rules read like data.

ID is REQUIRED — it is the stable identifier that never changes once published. It is used for registry deduplication, suppression matching, filter config, and the finding.RuleName field.

func (RuleMeta) Validate added in v0.2.0

func (m RuleMeta) Validate() error

Validate returns nil if all required fields are non-empty, or an error listing every missing field. ID, Name, Description, and Cat are required; Sev defaults to the zero value (empty string) which is a valid — if imprecise — severity for rules that set severity per-finding via the Builder.

Call this during rule construction to fail fast on misconfigured rules before they reach the registry:

meta := linter.RuleMeta{ID: "x", Name: "X", ...}
if err := meta.Validate(); err != nil { log.Fatal(err) }

type RunOption added in v0.2.0

type RunOption func(*runConfig)

RunOption configures a Registry.Run invocation.

func ContinueOnError added in v0.2.0

func ContinueOnError() RunOption

ContinueOnError configures Run to continue executing remaining rules after one fails, collecting partial findings and joining all errors. The returned report contains findings from every rule that completed (including partial findings from failed rules); the returned error is the join of all rule failures, each individually wrapped as a *RuleError. Use RuleErrors(err) to enumerate every individual *RuleError from the joined result.

Without this option (the default), Run fails fast: the first rule error aborts the run and returns (nil, err).

Directories

Path Synopsis
examples
minimal-linter command
Command minimal-linter demonstrates the full go-linter-sdk lifecycle: define a rule that emits finding.Finding directly, register it, run the registry, print findings, and exit with the ecosystem exit code.
Command minimal-linter demonstrates the full go-linter-sdk lifecycle: define a rule that emits finding.Finding directly, register it, run the registry, print findings, and exit with the ecosystem exit code.
no-go-mod command
Command no-go-mod is a pilot port of go-structure-linter's NoGoModRule, rewritten to use go-linter-sdk.
Command no-go-mod is a pilot port of go-structure-linter's NoGoModRule, rewritten to use go-linter-sdk.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL