autoconfigure

package module
v0.7.0 Latest Latest
Warning

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

Go to latest
Published: Sep 23, 2026 License: MIT Imports: 13 Imported by: 0

README

linter-autoconfigure-sdk

Shared foundation for linter auto-configuration tools — config round-trip, finding emission for config issues, and a provider spec for BuildFlow integration.

CI Go Reference Go Report Card

pkg.go.dev


Why?

Two existing auto-configurers — golangci-lint-auto-configure and oxlint-auto-configure — diverge on their domain-specific concepts (Go project shape vs JS framework detection, 4-tier priority enum vs profile presets). Those differences are legitimate and stay in each tool.

What they reinvent identically is the surrounding plumbing:

Concern Before (per tool) After (this SDK)
Read/write config files Each tool hand-wraps os.ReadFile + parse + error handling ReadConfig(path) / LoadJSON[T](path) / SaveJSON(path, v)
Marshal/parse/write raw config bytes Marshal options copied per tool; atomic writes hand-rolled MarshalJSONIndented(v) / ParseJSON[T](data) / SaveJSONBytes(path, data)
Diff two config versions Two incompatible Change types (string vs int kinds) DiffMaps / DiffSets / DiffBlobs + Summary / FormatDiff
Discover the config file Per-tool candidate lists and exists-checks ProviderSpec.ConfigFiles + FirstExisting(root, candidates...)
Emit findings for config issues Each tool maps priority → Severity, fix → Suggestion, by hand FindingFromIssue(tool, ConfigIssue{...})
Wire into BuildFlow as Detector + Repairer Each tool writes its own adapter ProviderFromSpec → canonical toolsdk.Spec (go-finding)
Generate a missing config (bootstrap) Hand-rolled ~150-line provider: missing-only Detect, never-overwrite Repair, advisory drift health BootstrapProviderFromSpec[T] derives the whole lifecycle

linter-autoconfigure-sdk owns that plumbing once. Adding a third auto-configurer (e.g. biome-auto-configure) becomes a config-schema exercise, not a from-scratch build.


Installation

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

Resolves to the latest tagged release (v0.7.0 as of 2026-09-23); pin @v0.7.0 explicitly for reproducible builds.

Requires Go 1.27+: go-finding imports encoding/json/v2, which is standard in Go 1.27 (no GOEXPERIMENT needed).

Peer dependencies: the latest go-finding and go-atomic-write modules.


Usage

Read a config file
data, err := autoconfigure.ReadConfig(".golangci.yml")
// YAML parsing stays in each tool (different libraries: yaml.v3 vs go-yaml).
// This helper covers the shared read + existence check.
Round-trip JSON configs
type oxlintConfig struct {
    Plugins []string `json:"plugins"`
}

cfg, err := autoconfigure.LoadJSON[oxlintConfig](".oxlintrc.json")
// ... mutate cfg ...
changed, err := autoconfigure.SaveJSON(".oxlintrc.json", cfg) // creates parent dirs; changed=false when content already matched
Emit findings for config issues
issues := []autoconfigure.ConfigIssue{
    {
        Rule:       "missing-linter",
        Message:    "errcheck is not enabled",
        Severity:   finding.SeverityWarning,
        File:       ".golangci.yml",
        Line:       5,
        Suggestion: "add errcheck to enabled linters",
    },
}
findings, err := autoconfigure.FindingsFromIssues("golangci-autoconfigure", issues)
// → []finding.Finding with FixStrategySuggest attached where Suggestion != ""
BuildFlow provider shape
spec := autoconfigure.ProviderSpec{
    Name:        "golangci-autoconfigure",
    Description: "Optimize .golangci.yml",
    ConfigFile:  ".golangci.yml",
    Analyze: func(ctx context.Context) ([]autoconfigure.ConfigIssue, error) {
        // inspect config, return issues
        return nil, nil
    },
    Repair: func(ctx context.Context) (string, error) {
        // rewrite config, return description of changes
        return "enabled errcheck", nil
    },
}

ProviderFromSpec(spec) wraps this as the canonical BuildFlow provider contract — go-finding's toolsdk.Spec — ready for toolsdk.Register (adjust its Trigger / DependsOn on the returned value first if needed). The underlying analyze/repair closures also work standalone with no BuildFlow wiring.

provider, err := autoconfigure.ProviderFromSpec(spec)
// provider.Detect implements finding.Detector (issues become findings)
// provider.Repair implements toolsdk.Repairer (nil when spec.Repair is nil:
// the canonical suggest-only signal)

API

Config I/O
Function Signature Purpose
ReadConfig(path) ([]byte, *ConfigError) Read raw bytes; YAML parsing stays tool-specific
LoadJSON[T](path) (*T, *ConfigError) Read + unmarshal a JSON config
ParseJSON[T](data) (*T, *ConfigError) Unmarshal JSON bytes you already hold; errors carry no Path
MarshalJSONIndented(v) ([]byte, *ConfigError) Canonical marshal: deterministic map keys, 2-space indent, no trailing newline
SaveJSON(path, v) (changed bool, *ConfigError) Idempotent + crash-durable atomic write of indented JSON; creates parent dirs, skips the write when content is unchanged, and reports whether a write happened
SaveJSONBytes(path, data) (changed bool, *ConfigError) Byte-faithful SaveJSON for raw bytes: the trailing newline is the caller's contract
WorkingDir(ctx) string finding.WorkingDirFromContext with the "." fallback BuildFlow providers hand-roll

All I/O helpers return a *ConfigError (implements error) whose Op (typed: OpRead, OpUnmarshal, OpMarshal, OpMkdir, OpWrite), Path, and Err fields describe the failure. The wrapped cause is reachable via errors.Is / errors.AsType / errors.Unwrap against a *ConfigError (e.g. errors.Is(err, fs.ErrNotExist)), so callers can distinguish missing files from parse or I/O failures without parsing error strings.

Config diff
Function Purpose
DiffMaps(before, after, prefix) One Change per added/removed/modified map key, sorted by path
DiffSets(before, after, prefix) Set compare of slices: order ignored, duplicates collapse
DiffBlobs(before, after, prefix) Canonical-set compare for overrides-style blocks (reorder is not drift)
StringValue(v) Bare strings as-is; structured values as deterministic compact JSON
Summary(changes) "Added: 1, Modified: 2, Removed: 3"
FormatDiff(changes) +/-/~ lines sorted by path; "No changes." when empty

Change is {Kind, Path, Old, New} with Kind one of KindAdded, KindRemoved, KindModified — there is deliberately no unchanged kind: an unchanged setting is the absence of a Change, not a Change with a dead state.

Config discovery
Function Purpose
ProviderSpec.ConfigFiles Discovery candidates in priority order (ConfigFile stays the write target)
FirstExisting(root, candidates...) First existing candidate; falls back to the first path + false when none
Finding emission
Function Signature Purpose
FindingFromIssue(tool, issue) (finding.Finding, error) Convert one ConfigIssue to a finding.Finding (suggest-strategy auto-attached when Suggestion != "")
FindingsFromIssues(tool, issues) ([]finding.Finding, error) Slice version; propagates conversion errors
BuildFlow integration
Function Signature Purpose
ProviderFromSpec(spec) (toolsdk.Spec, error) Convert a ProviderSpec into go-finding's canonical toolsdk.Spec for toolsdk.Register; validates required fields
BootstrapProviderFromSpec[T] (toolsdk.Spec, error) Convert a BootstrapSpec[T] into a full generate-if-missing lifecycle (Detect/Repair/HealthCheck)
(*ProviderSpec).HasRepair() bool Whether the spec supports auto-repair

Validation failures from both converters return exported sentinels (ErrNameRequired, ErrDescriptionRequired, ErrAnalyzeRequired, plus ErrConfigFileRequired / ErrGenerateRequired / ErrCompareRequired for bootstrap specs) — match with errors.Is.

Types
Type Purpose
ConfigError {Op, Path, Err} — typed failure for config I/O; Op is a typed enum; supports Unwrap/Is/As for full error-chain traversal
ConfigIssue {Rule, Message, Severity, File, Line, Suggestion} plus optional Confidence and FixStrategy *finding.FixStrategy overrides (zero values keep the defaults); Rule is finding.RuleName, File is finding.FilePath
ProviderSpec {Name, Description, ConfigFile, ConfigFiles, Analyze, Repair} — auto-configurer declaration; ConfigFile is finding.FilePath; HasRepair() reports repair support
BootstrapSpec[T] {Name, Description, ConfigFile, ConfigFiles, MissingRule, FixCommand, CountLabel, Recognizable, Generate, Marshal, Parse, NormalizeExpected, Compare} — generate-if-missing lifecycle declaration (see below)
Change {Kind, Path, Old, New} — one config difference; Kind is KindAdded/KindRemoved/KindModified
Determinism enforcement (analyzer)

determinism.NewAnalyzer() is a go/analysis analyzer flagging encoding/json/v2 Marshal calls without an explicit json.Deterministic option — the bug class that made SaveJSON byte-unstable until v0.3.1 (json/v2 map-key order changes between calls). json.Deterministic(false) is the deliberate, self-documenting opt-out; opaque opts... spreads are not flagged (the analyzer catches the accidental class, not every hazard).

Run it without any golangci-lint plugin build via the bundled vettool command:

go run github.com/larsartmann/linter-autoconfigure-sdk/cmd/jsondeterminism ./...

This module self-enforces the rule in CI; consumers can wire the same line into their gates.


Design notes

  • YAML parsing is NOT in this SDK. The two existing tools use different YAML libraries (yaml.v3 vs go-yaml) with different semantics. Forcing one would create friction. The SDK covers the shared byte-level read; YAML unmarshaling stays in each tool.
  • Branded types are adopted consistently. ConfigIssue.Rule is finding.RuleName, File is finding.FilePath, ProviderSpec.ConfigFile is finding.FilePath, and FindingFromIssue takes finding.ToolName. The SDK is coupled to go-finding; taking the branded types buys compile-time safety at no extra cost.
  • FindingFromIssue auto-attaches FixStrategySuggest when Suggestion != ""; otherwise sets FixStrategyNone explicitly to avoid the empty-string zero-value split brain. When Line == 0, a file-level position (finding.FilePos) is used instead of fabricating a line number.
  • No ProjectType enum. The two existing tools have incompatible concepts (Go shape vs JS framework); sharing one enum would force false convergence. Each tool keeps its own detection layer.
  • The diff engine has no unchanged kind. An unchanged setting is the absence of a Change, not a Change carrying a dead state; comparators only emit real differences, sorted by path for deterministic output regardless of map iteration order.
  • SaveJSONBytes is byte-faithful. The trailing-newline decision is the caller's contract: tools that end configs with a newline combine MarshalJSONIndented + append(data, '\n'); SaveJSON itself never appends one (its contract since v0.1.0).
  • Bootstrap is a separate spec type, not a Generate field on ProviderSpec. Bootstrap tools (generate-if-missing) and auditing tools (analyze/repair) have different lifecycles; one struct offering both modes would make the chosen mode ambiguous. BootstrapSpec[T] makes the safety invariants structural: Detect only flags missing configs (any discovery name counts, so curated alternative-format configs are never shadowed), Repair never overwrites and honors dry-run, and the HealthCheck is report-only — a consumer cannot express a config-stomping flow through the API.
  • The bootstrap drift sentinel stays unexported. The advisory health-check error wraps an unexported sentinel by design (owner decision 2026-09-22): matching on the message is fine for advisory output, and exporting invites consumers to branch on drift as if it were actionable. Export on concrete demand.

Consumers

Active:

  • oxlint-auto-configure — builds its BuildFlow provider via BootstrapProviderFromSpec (first migrated consumer, 2026-09-11; bootstrap lifecycle since its v0.9.0). Its config I/O, discovery, and diff run on the SDK's helpers too.
  • golangci-lint-auto-configure — emits validate-command health findings via FindingFromIssue and diffs configs with the SDK's Change/Kind vocabulary; its gate enforces deterministic marshals via cmd/jsondeterminism. Its linter-recommendation conversion (missing-linter findings with per-linter categories/tags) intentionally stays app-side: those carry domain metadata ConfigIssue does not model.

Future: biome-auto-configure, etc.

Both consumers build against tagged SDK releases (go get github.com/larsartmann/linter-autoconfigure-sdk@vX.Y.Z); no replace directives remain in the fleet.

Status

v0.7.x — config round-trip, finding emission, the BuildFlow provider bridge, the config diff engine, and the bootstrap provider lifecycle are stable in shape; breaking changes remain acceptable until v1 (pre-1.0). BuildFlow wiring is anchored to go-finding's canonical toolsdk contract (v1.13.0+). Requires Go 1.27+ (encoding/json/v2 is standard).

Security

Report vulnerabilities privately via SECURITY.md.

License

MIT — see LICENSE.

Documentation

Overview

Package autoconfigure provides the shared foundation for linter auto-configuration tools (golangci-lint-auto-configure, oxlint-auto-configure, and future additions like biome-auto-configure).

The two existing tools diverge on their domain-specific concepts — Go project shape (CLI/Library/Web) vs JS framework (React/Next/Vue) — and on their priority models (4-tier enum vs profile presets). Those differences are legitimate and stay in each tool.

What they reinvent identically is the surrounding plumbing:

  • Reading and writing YAML/JSON config files (round-trip)
  • Emitting findings for config issues (priority → Severity, fix → suggestion)
  • Diffing two configs into a typed change list (maps, sets, blobs)
  • Discovering which of several recognized config filenames exists
  • Deriving a generate-if-missing provider lifecycle from one spec (bootstrap)
  • Wiring into BuildFlow as a Detector + Repairer

This package owns that plumbing once. Adding a third auto-configurer becomes a config-schema exercise, not a from-scratch build.

The package uses encoding/json/v2 (standard since Go 1.27).

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	ErrNameRequired        = errors.New("autoconfigure: ProviderFromSpec: Name must not be empty")
	ErrDescriptionRequired = errors.New("autoconfigure: ProviderFromSpec: Description must not be empty")
	ErrAnalyzeRequired     = errors.New("autoconfigure: ProviderFromSpec: Analyze must not be nil")
)

Validation sentinels returned by ProviderFromSpec when a required field is missing. The messages are identical to the pre-sentinel dynamic errors, so callers matching on text keep working; new callers should match with errors.Is instead.

View Source
var (
	ErrConfigFileRequired = errors.New("autoconfigure: BootstrapProviderFromSpec: ConfigFile must not be empty")
	ErrGenerateRequired   = errors.New("autoconfigure: BootstrapProviderFromSpec: Generate must not be nil")
	ErrCompareRequired    = errors.New("autoconfigure: BootstrapProviderFromSpec: Compare must not be nil")
)

Validation sentinels returned by BootstrapProviderFromSpec when a required field is missing. Match with errors.Is.

View Source
var ErrNoRepair = errors.New("autoconfigure: tool does not support auto-repair")

ErrNoRepair is the pre-bridge sentinel for "this tool does not support auto-repair", kept so pre-v1 code referencing it keeps compiling.

Deprecated: the canonical suggest-only signal is structural. ProviderFromSpec leaves toolsdk.Spec.Repair nil when ProviderSpec.Repair is nil, and BuildFlow reads that directly. This SDK never returns ErrNoRepair, and returning it from a custom Repairer is a repair failure for BuildFlow, not a suggest-only signal. Removed at v1.

Functions

func BootstrapProviderFromSpec added in v0.5.0

func BootstrapProviderFromSpec[T any](spec BootstrapSpec[T]) (toolsdk.Spec, error)

BootstrapProviderFromSpec converts a BootstrapSpec into the canonical BuildFlow provider contract, deriving the full bootstrap lifecycle:

  • Detect emits exactly one warning when the config is missing under every discovery name AND Recognizable (when set) accepts the project.
  • Repair generates and writes the config only when it is missing, honoring the dry-run flag; an existing config yields a keep description.
  • HealthCheck parses the existing ConfigFile, regenerates the expected config, applies NormalizeExpected, and reports Compare's changes as an advisory error wrapping the drift sentinel with the fix command.

Trigger and DependsOn stay zero; set them on the returned Spec when needed. Inputs derive from ConfigFiles (falling back to ConfigFile), matching ProviderFromSpec.

Example
spec := BootstrapSpec[map[string]string]{
	Name:        "fake-auto-configure",
	Description: "generates .fakerc.json for recognizable projects",
	ConfigFile:  ".fakerc.json",
	ConfigFiles: []finding.FilePath{".fakerc.json", ".fakerc.jsonc"},
	MissingRule: "FAKE_CONFIG_MISSING",
	FixCommand:  "fake-auto-configure configure",
	CountLabel:  "rules",
	Generate: func(ctx context.Context) (map[string]string, int, error) {
		return map[string]string{"plugins": "react"}, 3, nil
	},
	Compare: func(existing, expected map[string]string) []Change {
		return DiffMaps(existing, expected, "")
	},
}

provider, err := BootstrapProviderFromSpec(spec)
if err != nil {
	fmt.Println("error:", err)

	return
}

fmt.Println(provider.Name, provider.Inputs)
fmt.Println(provider.Detect != nil, provider.Repair != nil, provider.HealthCheck != nil)
Output:
fake-auto-configure [.fakerc.json .fakerc.jsonc]
true true true

func FindingFromIssue

func FindingFromIssue(toolName finding.ToolName, issue ConfigIssue) (finding.Finding, error)

FindingFromIssue converts a ConfigIssue to a finding.Finding with the given tool name. The fix strategy is issue.FixStrategy when set; otherwise a non-empty Suggestion yields FixStrategySuggest (so BuildFlow's repair loop can surface it) and an empty one yields FixStrategyNone, set explicitly to avoid the empty-string zero-value split brain.

When issue.Line is 0 (unknown), the finding receives a file-level Position via finding.FilePos rather than a fabricated line number.

Example
issue := ConfigIssue{
	Rule:       finding.RuleName("missing-linter"),
	Message:    "errcheck is not enabled",
	Severity:   finding.SeverityWarning,
	File:       finding.FilePath(".golangci.yml"),
	Line:       5,
	Suggestion: "add errcheck to enabled linters",
}

f, err := FindingFromIssue(finding.ToolName("golangci-autoconfigure"), issue)
if err != nil {
	fmt.Println("error:", err)

	return
}

fmt.Println(f.Rule, f.Severity, f.FixStrategy)
Output:
missing-linter warning suggest

func FindingsFromIssues

func FindingsFromIssues(toolName finding.ToolName, issues []ConfigIssue) ([]finding.Finding, error)

FindingsFromIssues converts a slice of ConfigIssues to findings. If any issue fails to convert, the entire batch fails and the cause is wrapped.

func FirstExisting added in v0.4.0

func FirstExisting(root string, candidates ...string) (string, bool)

FirstExisting joins each candidate with root, in order, and returns the first that exists. When none exists it returns the FIRST candidate joined with root and false, so callers always hold a usable canonical path (the default write target). Returns "", false when no candidate is given.

Example
dir, _ := os.MkdirTemp("", "example")
defer func() { _ = os.RemoveAll(dir) }()

_ = os.WriteFile(filepath.Join(dir, ".oxlintrc.json"), []byte("{}"), 0o644)

path, found := FirstExisting(dir, ".oxlintrc.json", ".oxlintrc.jsonc")
fmt.Println(filepath.Base(path), found)

missing, found := FirstExisting(dir, ".eslintrc.json", ".eslintrc.jsonc")
fmt.Println(filepath.Base(missing), found)
Output:
.oxlintrc.json true
.eslintrc.json false

func FormatDiff added in v0.4.0

func FormatDiff(changes []Change) string

FormatDiff renders changes as a unified-diff-flavored listing, one change per line, sorted by Path: "+ path: new", "- path: old", and "~ path: old → new". Returns "No changes." for an empty slice. Every line ends with a newline.

func ProviderFromSpec

func ProviderFromSpec(spec ProviderSpec) (toolsdk.Spec, error)

ProviderFromSpec converts a ProviderSpec into the canonical BuildFlow provider contract: go-finding's toolsdk.Spec (module github.com/larsartmann/go-finding/toolsdk). The result can be handed to toolsdk.Register for BuildFlow discovery, or its Trigger / DependsOn fields can be adjusted first — the returned Spec is a plain value.

Field mapping:

  • Name, Description pass through verbatim; Name also becomes the tool name stamped onto every finding the Detect adapter emits.
  • ConfigFiles (falling back to ConfigFile alone) becomes Inputs: the config files are what the tool reads. Every recognized filename is an Input, not just the write target, so BuildFlow re-runs the tool when any of them appears or changes — including user-curated formats the tool must never stomp.
  • Analyze is wrapped as a finding.Detector that converts each ConfigIssue via FindingFromIssue.
  • A non-nil Repair is wrapped as a toolsdk.Repairer; a nil Repair stays nil, the canonical signal for a suggest-only tool (suggestions still flow through findings carrying FixStrategySuggest).
  • Trigger and DependsOn have no ProviderSpec equivalent and stay zero; set them on the returned Spec when needed.

Validation errors name the offending field: Name, Description, and Analyze are all required.

Example
spec := ProviderSpec{
	Name:        "golangci-autoconfigure",
	Description: "keeps .golangci.yml aligned with the project shape",
	ConfigFile:  ".golangci.yml",
	Analyze: func(ctx context.Context) ([]ConfigIssue, error) {
		return []ConfigIssue{{Rule: "missing-linter", Message: "errcheck is not enabled"}}, nil
	},
	Repair: func(ctx context.Context) (string, error) {
		return "enabled errcheck", nil
	},
}

provider, err := ProviderFromSpec(spec)
if err != nil {
	fmt.Println("error:", err)

	return
}

fmt.Println(provider.Name, provider.Inputs, provider.Repair != nil)
fmt.Println(provider.Detect.Name())
Output:
golangci-autoconfigure [.golangci.yml] true
golangci-autoconfigure

func StringValue added in v0.4.0

func StringValue(v any) string

StringValue renders a config value for diff display: strings display bare; anything else displays as compact deterministic JSON (map keys sorted, so the same value always renders to the same bytes); values that cannot be marshaled fall back to a %v print.

func Summary added in v0.4.0

func Summary(changes []Change) string

Summary renders a one-line human-readable tally of the changes: "Added: 1, Modified: 2, Removed: 3".

func WorkingDir added in v0.4.0

func WorkingDir(ctx context.Context) string

WorkingDir returns the working directory carried by ctx via finding.WorkingDirFromContext, falling back to "." (the process working directory) when the context carries none — the convention BuildFlow providers follow so the WithWorkingDir fan-out works identically across tools. Analyze/Repair closures should resolve config paths through this helper instead of hand-rolling the fallback.

Example
ctx := finding.WithWorkingDir(context.Background(), "/repo")

fmt.Println(WorkingDir(ctx), WorkingDir(context.Background()))
Output:
/repo .

Types

type BootstrapSpec added in v0.5.0

type BootstrapSpec[T any] struct {
	Name        string
	Description string
	// ConfigFile is the canonical config file the tool writes (the drift
	// health check also reads only this name: other discovery names are
	// user-curated formats the tool never writes).
	ConfigFile finding.FilePath
	// ConfigFiles lists every config filename the tool recognizes, in
	// priority order. An existing file under ANY of these names suppresses
	// Detect and Repair (generating next to a curated alternative-format
	// config could shadow it). Defaults to ConfigFile alone.
	ConfigFiles []finding.FilePath

	// MissingRule names the missing-config Detect finding (domain-specific,
	// e.g. "OXLOPT_CONFIG_MISSING"). Defaults to "CONFIG_MISSING".
	MissingRule finding.RuleName
	// FixCommand is the user-runnable command that regenerates the config
	// (e.g. "oxlint-auto-configure configure"); woven into Detect
	// suggestions and health-check messages. Empty keeps the messages
	// repair-generic.
	FixCommand string
	// CountLabel names Generate's count in repair descriptions (e.g.
	// "rules" renders "wrote .oxlintrc.json (870 rules)"). Empty omits the
	// count.
	CountLabel string

	// Recognizable gates Detect: when it reports false the project is not
	// one this tool configures and no finding is emitted. Nil means every
	// project counts.
	Recognizable func(ctx context.Context) (bool, error)
	// Generate produces the canonical config for the current project plus a
	// domain count for repair descriptions (pass 0 to omit).
	Generate func(ctx context.Context) (T, int, error)
	// Marshal renders T to the exact file bytes, including any trailing
	// newline the tool's format carries. Nil defaults to
	// MarshalJSONIndented (no trailing newline).
	Marshal func(T) ([]byte, *ConfigError)
	// Parse reads existing config bytes into T. Nil defaults to ParseJSON[T].
	Parse func(data []byte) (T, *ConfigError)
	// NormalizeExpected adjusts the freshly generated expected config to
	// honor user customizations carried by the existing config (e.g.
	// preserved external-plugin blocks), so deliberate customization does
	// not read as drift. Nil compares as generated.
	NormalizeExpected func(existing, expected T) T
	// Compare projects two parsed configs onto the diff engine and is what
	// the drift health check reports. Required: the projection is domain
	// knowledge (which fields carry policy meaning) the SDK cannot guess.
	Compare func(existing, expected T) []Change
}

BootstrapSpec describes a bootstrap-mode auto-configurer: a tool whose job is to generate its config file when it is missing, never to modify an existing one, and to report drift advisorially. BootstrapProviderFromSpec derives the full toolsdk lifecycle (Detect / Repair / HealthCheck) from it.

It is deliberately a separate type from ProviderSpec rather than a Generate field on it: Analyze/Repair specs and bootstrap specs are two different lifecycles, and one struct offering both would invite split-brain specs where the chosen mode is ambiguous. A bootstrap tool uses this type; a config-auditing tool uses ProviderSpec.

The zero value is not usable; Name, Description, ConfigFile, Generate, and Compare are required (validation returns exported sentinels matchable with errors.Is).

type Change added in v0.4.0

type Change struct {
	Kind Kind
	Path string
	Old  string
	New  string
}

Change is one difference between two linter config versions. Path is the setting identifier (a dotted path like "rules.no-console" or a prefixed name like "plugin:import" — the comparator's caller chooses the scheme via its prefix argument). Old is empty for KindAdded; New is empty for KindRemoved; both are set for KindModified.

func DiffBlobs added in v0.4.0

func DiffBlobs(before, after []string, prefix string) []Change

DiffBlobs compares two lists of pre-canonicalized blob strings (for example, each config overrides block marshalled to canonical JSON) with set semantics: order is ignored and exact duplicates collapse, because list order carries no policy meaning for these blocks. The canonical blob string itself serves as both the Path suffix and the displayed value. The result is sorted by Path.

func DiffMaps added in v0.4.0

func DiffMaps(before, after map[string]string, prefix string) []Change

DiffMaps compares two string maps and returns one Change per key that was added, removed, or whose value differs. Keys are prefixed with prefix (pass "" for bare keys). The result is sorted by Path, so output is deterministic across runs regardless of map iteration order.

Example
before := map[string]string{"no-console": "off", "no-debugger": "off"}
after := map[string]string{"no-console": "warn"}

changes := DiffMaps(before, after, "rules.")

fmt.Println(Summary(changes))
fmt.Print(FormatDiff(changes))
Output:
Added: 0, Modified: 1, Removed: 1
~ rules.no-console: off → warn
- rules.no-debugger: off

func DiffSets added in v0.4.0

func DiffSets(before, after []string, prefix string) []Change

DiffSets compares two string slices with set semantics: duplicates collapse and order is ignored (a pure reorder is not a change). Each item present in only one side becomes a Change with Path = prefix + item and the item itself as the value. The result is sorted by Path.

type ConfigError

type ConfigError struct {
	// Op is the operation that failed.
	Op Op
	// Path is the config file path involved.
	Path string
	// Err is the underlying cause, never nil for a returned ConfigError.
	Err error
}

ConfigError describes a failure while reading, parsing, or writing a linter config file. It wraps the underlying cause with the operation attempted and the file path, so callers can produce precise diagnostics or branch with errors.Is / errors.As without parsing error strings.

Op follows os.PathError's convention: "read", "unmarshal", "marshal", "mkdir", or "write". The underlying cause is reachable via the exported Err field and through the Is / As methods (see below), so both errors.Is(err, fs.ErrNotExist) and errors.AsType[*jsontext.SyntacticError](err) still work against a *ConfigError. (The jsonv2 unmarshaler emits *jsontext.SyntacticError for malformed input; v1's json.SyntaxError is the legacy equivalent.)

func LoadJSON

func LoadJSON[T any](path string) (*T, *ConfigError)

LoadJSON reads and unmarshals a JSON config file into a new *T.

Example
dir, _ := os.MkdirTemp("", "example")
defer func() { _ = os.RemoveAll(dir) }()

path := filepath.Join(dir, ".oxlintrc.json")
_ = os.WriteFile(path, []byte(`{"linters":["errcheck","gofmt"]}`), 0o644)

cfg, err := LoadJSON[exampleLintConfig](path)
if err != nil {
	fmt.Println("error:", err)

	return
}

fmt.Println(cfg.Linters)
Output:
[errcheck gofmt]

func MarshalJSONIndented added in v0.4.0

func MarshalJSONIndented(v any) ([]byte, *ConfigError)

MarshalJSONIndented marshals v with the SDK's canonical options: deterministic map-key ordering (byte-stable output across runs) and 2-space indentation. It is the helper behind SaveJSON; config writers that need the bytes themselves (for example to append a trailing newline before SaveJSONBytes) use it instead of hand-copying these options.

Example
data, err := MarshalJSONIndented(map[string][]string{"categories": {"correctness"}})
if err != nil {
	fmt.Println("error:", err)

	return
}

fmt.Printf("%s", data)
Output:
{
  "categories": [
    "correctness"
  ]
}

func ParseJSON added in v0.4.0

func ParseJSON[T any](data []byte) (*T, *ConfigError)

ParseJSON unmarshals JSON bytes into a new *T. It is the byte-level counterpart of LoadJSON for callers that already read (or generated) the bytes themselves. The returned *ConfigError uses OpUnmarshal and carries no Path: the bytes did not come from a file this helper knows about.

Example
cfg, err := ParseJSON[exampleLintConfig]([]byte(`{"linters":["errcheck"]}`))
if err != nil {
	fmt.Println("error:", err)

	return
}

fmt.Println(cfg.Linters)
Output:
[errcheck]

func ReadConfig

func ReadConfig(path string) ([]byte, *ConfigError)

ReadConfig reads a config file's raw bytes. YAML parsing is deliberately left to each tool (different YAML libraries: golangci uses yaml.v3 / v4, oxlint may use go-yaml) — this helper covers the shared read + existence check so both tools stop hand-writing os.ReadFile with the same error wrapping.

func SaveJSON

func SaveJSON(path string, v any) (bool, *ConfigError)

SaveJSON marshals v to indented JSON and writes it to path atomically, creating parent directories: MarshalJSONIndented followed by SaveJSONBytes. Map keys are emitted in sorted order (json.Deterministic), so byte output is stable across runs: the write is idempotent — if the marshalled content is byte-identical to the existing file, the write is skipped entirely (no mtime bump, no spurious diff). Otherwise the file is replaced via an fsync'd temp-file + atomic rename, so a crash cannot truncate the config. Race-safe: a concurrent modification between the content check and the rename surfaces as a non-nil *ConfigError wrapping atomicwrite.ErrConcurrentModification.

The changed return reports whether the file was actually written: false means the on-disk content already matched and the write was skipped. Repair flows use it to distinguish "config updated" from "config already correct".

Indented output is used because linter configs are typically human-edited. The output carries no trailing newline; callers whose format needs one use MarshalJSONIndented + SaveJSONBytes directly.

Example
dir, _ := os.MkdirTemp("", "example")
defer func() { _ = os.RemoveAll(dir) }()

path := filepath.Join(dir, "nested", ".oxlintrc.json")

cfg := exampleLintConfig{Linters: []string{"errcheck", "gofmt"}}

changed, err := SaveJSON(path, cfg)
if err != nil {
	fmt.Println("error:", err)

	return
}

fmt.Println("changed:", changed)

data, _ := os.ReadFile(path)
fmt.Println(string(data))
Output:
changed: true
{
  "linters": [
    "errcheck",
    "gofmt"
  ]
}

func SaveJSONBytes added in v0.4.0

func SaveJSONBytes(path string, data []byte) (bool, *ConfigError)

SaveJSONBytes writes raw bytes to path atomically, creating parent directories, and reports whether the file content changed (false means the on-disk content already matched and the write was skipped). It is byte-faithful: no newline is appended or trimmed — whether config files end with a trailing newline is the caller's contract. Combine with MarshalJSONIndented plus a manual '\n' append to reproduce a tool's existing file format exactly.

Crash-durable like SaveJSON: fsync'd temp file + atomic rename, so a crash cannot truncate the config. Race-safe: a concurrent modification between the content check and the rename surfaces as a non-nil *ConfigError wrapping atomicwrite.ErrConcurrentModification.

Example
dir, _ := os.MkdirTemp("", "example")
defer func() { _ = os.RemoveAll(dir) }()

path := filepath.Join(dir, ".oxlintrc.json")

data, err := MarshalJSONIndented(exampleLintConfig{Linters: []string{"errcheck"}})
if err != nil {
	fmt.Println("error:", err)

	return
}

// Trailing newline is the caller's contract: append it to match the
// tool's existing file format.
changed, err := SaveJSONBytes(path, append(data, '\n'))
if err != nil {
	fmt.Println("error:", err)

	return
}

fmt.Println("changed:", changed)
Output:
changed: true

func (*ConfigError) As

func (e *ConfigError) As(target any) bool

func (*ConfigError) Error

func (e *ConfigError) Error() string

func (*ConfigError) Is

func (e *ConfigError) Is(target error) bool

func (*ConfigError) Unwrap

func (e *ConfigError) Unwrap() error

Unwrap, Is, and As expose the wrapped cause for standard error-chain traversal. Callers can use errors.Is(err, fs.ErrNotExist), errors.AsType[*jsontext.SyntacticError](err), or errors.Unwrap(err) interchangeably.

type ConfigIssue

type ConfigIssue struct {
	// Rule is the issue's rule identifier (e.g. "missing-linter", "wrong-priority").
	Rule finding.RuleName

	// Message describes the problem for the user.
	Message string

	// Severity rates how serious the issue is.
	Severity finding.Severity

	// File is the config file path (for Position).
	File finding.FilePath

	// Line is the 1-based line number in the config file (0 if unknown).
	Line int

	// Suggestion is the recommended fix text (empty if no auto-fix).
	Suggestion string

	// Confidence is the issue's confidence. Zero value (ConfidenceNone == 0)
	// means unset: the finding keeps the builder default (ConfidenceFull),
	// matching pre-extension behavior. Note this makes an explicit
	// ConfidenceNone unrepresentable through ConfigIssue; adjust the finding
	// afterwards if that level is genuinely needed.
	Confidence finding.Confidence

	// FixStrategy overrides the default strategy selection: nil (the default)
	// picks FixStrategySuggest when Suggestion is non-empty and
	// FixStrategyNone otherwise. Set it for issues whose repair is directly
	// applicable (FixStrategyDirect) rather than suggestion-only.
	FixStrategy *finding.FixStrategy
}

ConfigIssue describes a single problem found in a linter config file. Auto-configurers produce a slice of these; FindingFromIssue converts each to a finding.Finding that BuildFlow can aggregate and gate repairs on.

type Kind added in v0.4.0

type Kind string

Kind classifies a Change: a setting was added, removed, or modified. There is deliberately no "unchanged" kind: an unchanged setting is the absence of a Change, not a Change with a dead state.

const (
	// KindAdded marks a setting that exists only in the after config.
	KindAdded Kind = "added"
	// KindRemoved marks a setting that exists only in the before config.
	KindRemoved Kind = "removed"
	// KindModified marks a setting that exists in both configs with
	// different values.
	KindModified Kind = "modified"
)

type Op

type Op string

Op identifies the operation that failed during config I/O. It mirrors the convention from os.PathError: a short, lowercase verb.

const (
	OpRead      Op = "read"
	OpUnmarshal Op = "unmarshal"
	OpMarshal   Op = "marshal"
	OpMkdir     Op = "mkdir"
	OpWrite     Op = "write"
)

type ProviderSpec

type ProviderSpec struct {
	Name        string
	Description string
	// ConfigFile is the canonical config file the tool writes (e.g.
	// ".golangci.yml", ".oxlintrc.json"). When ConfigFiles is empty it is
	// also the only discovery candidate.
	ConfigFile finding.FilePath
	// ConfigFiles lists every config filename the tool recognizes, in
	// priority order (e.g. ".oxlintrc.json", ".oxlintrc.jsonc",
	// "oxlint.config.json"). An existing file under ANY of these names means
	// the project already carries a config, including user-curated formats
	// the tool must not stomp. ProviderFromSpec derives Inputs from
	// ConfigFiles when set, falling back to [ConfigFile]. Both fields stay:
	// ConfigFile names the write target, ConfigFiles the discovery set.
	ConfigFiles []finding.FilePath
	// Analyze inspects the config and returns issues. The working directory
	// is available via finding.WorkingDirFromContext(ctx).
	Analyze func(ctx context.Context) ([]ConfigIssue, error)
	// Repair, if non-nil, rewrites the config to fix the issues. Returns a
	// human-readable description of what changed. When nil, the spec is
	// suggest-only: ProviderFromSpec leaves toolsdk.Spec.Repair nil, the
	// canonical signal that BuildFlow should not attempt repairs.
	Repair func(ctx context.Context) (string, error)
}

ProviderSpec is the shape an auto-configurer supplies to wire into BuildFlow. It speaks the auto-configurer's domain language: Analyze reports ConfigIssues against the managed config file, Repair describes what was rewritten.

ProviderFromSpec converts a ProviderSpec into the canonical BuildFlow provider contract, go-finding's toolsdk.Spec, ready for toolsdk.Register. The Analyze and Repair closures are also usable standalone without any BuildFlow wiring.

func (ProviderSpec) HasRepair

func (s ProviderSpec) HasRepair() bool

HasRepair reports whether this spec supports auto-repair.

Directories

Path Synopsis
cmd
jsondeterminism command
Command jsondeterminism runs the SDK's jsondeterminism analyzer as a go vet-compatible vettool, so repositories can enforce byte-stable json.Marshal output without a golangci-lint plugin build:
Command jsondeterminism runs the SDK's jsondeterminism analyzer as a go vet-compatible vettool, so repositories can enforce byte-stable json.Marshal output without a golangci-lint plugin build:
Package determinism provides a go/analysis analyzer that flags encoding/json/v2 Marshal calls lacking an explicit determinism option.
Package determinism provides a go/analysis analyzer that flags encoding/json/v2 Marshal calls lacking an explicit determinism option.

Jump to

Keyboard shortcuts

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