linter

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 30, 2026 License: MIT Imports: 5 Imported by: 0

README

go-linter-sdk

Shared scaffolding for LarsArtmann Go linters — Rule interface, registry, detector adapter, exit codes. Eliminates the per-linter converter layer by codifying the go-structure-linter pattern: rules emit finding.Finding directly.

Go Reference Go Report Card

pkg.go.dev


Why?

Three LarsArtmann linters — branching-flow, erraudit, go-structure-linter — each independently reinvented the same three layers:

Layer 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)

The third row is the killer. branching-flow and erraudit each maintain a substantial bridge package purely because their native domain types (Violation, ErrorViolation) predate finding.Finding. Every new finding field requires touching the converter. Every refactor cascades.

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


Installation

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

Requires Go 1.26+ and go-finding v1.4+.


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, build findings via finding.NewBuilder(...) ...
            return nil, nil
        },
    })
}

func main() {
    report, _ := registry.Run(context.Background(), ".")
    os.Exit(linter.ExitCodeFromReport(report))
}
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.

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() *Registry Empty registry
(*Registry).Register(rule) Add a rule (panics on duplicate/empty ID)
(*Registry).All() []Rule Snapshot of registered rules
(*Registry).Run(ctx, dir) *finding.Report, error Run all rules; aggregate findings
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
OptIn(rf) Rule Wrap a RuleFunc as disabled-by-default (opt-in rule; runs only with explicit --enable <id>)

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/empty IDs — duplicate or missing rule IDs are programming errors that should surface at startup.
  • Two execution paths. DetectorFromRegistry for a single opaque detector (simple CLI/BuildFlow); DetectorsFromRegistry for per-rule detectors (go-finding/pipeline with parallelism, timeouts, error 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

Planned:

  • 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 active consumers yet.

Status

Early. The Rule/Registry/Detector core is stable. The migration story is proven in go-structure-linter's existing type Issue = finding.Finding pattern but no linter has been ported yet.

License

MIT — see LarsArtmann/template-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

Constants

This section is empty.

Variables

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

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() *Registry

NewRegistry creates an empty registry.

func (*Registry) All

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

All returns every registered rule, in registration order.

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 the rule's ID is empty.

func (*Registry) Run

func (r *Registry) Run(ctx context.Context, dir string) (*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.

If a rule fails, the returned error is a *RuleError identifying the rule.

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: ...}))

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 (*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 (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().

func (RuleFunc) Name

func (r RuleFunc) Name() string

Name implements Rule.

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
}

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.

Jump to

Keyboard shortcuts

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