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 ¶
- Variables
- func DetectorFromRegistry(registry *Registry, toolName string) finding.Detector
- func DetectorsFromRegistry(registry *Registry) []finding.Detector
- func ExitCodeByConfidence(report *finding.Report, threshold finding.Confidence) int
- func ExitCodeFromReport(report *finding.Report) int
- type Category
- type Registry
- func (r *Registry) All() []Rule
- func (r *Registry) Deregister(id string) bool
- func (r *Registry) Get(id string) (Rule, bool)
- func (r *Registry) Has(id string) bool
- func (r *Registry) Register(rule Rule)
- func (r *Registry) Run(ctx context.Context, dir string, opts ...RunOption) (*finding.Report, error)
- type RegistryOption
- type Rule
- type RuleError
- type RuleFunc
- func (r RuleFunc) Category() Category
- func (r RuleFunc) Check(ctx context.Context, dir string) ([]finding.Finding, error)
- func (r RuleFunc) Description() string
- func (r RuleFunc) ID() string
- func (RuleFunc) IsEnabledByDefault() bool
- func (r RuleFunc) Name() string
- func (r RuleFunc) NewFinding(message string, pos finding.Position) *finding.Builder
- func (r RuleFunc) Severity() finding.Severity
- type RuleMeta
- type RunOption
Examples ¶
Constants ¶
This section is empty.
Variables ¶
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
}
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 ¶
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 ¶
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 ¶
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) Deregister ¶ added in v0.2.0
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
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) Register ¶
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 ¶
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 ¶
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 ¶
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 ¶
NewRuleError wraps cause as a RuleError for the rule identified by ruleID.
func RuleErrors ¶ added in v0.2.0
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)
}
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
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) Check ¶
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) IsEnabledByDefault ¶
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) NewFinding ¶ added in v0.2.0
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
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
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. |