goyze

package module
v0.14.0 Latest Latest
Warning

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

Go to latest
Published: Aug 24, 2026 License: MIT Imports: 29 Imported by: 0

README

Documentation

Overview

Whether a disablement request has the owner's approval, and why the answer cannot be produced by anything in this package.

The two names a diagnostic has, and which decisions may read which.

A position resolved through a FileSet honours //line and /*line*/ directives: they exist so generated code can report the location of the input it was generated from, and reporting is exactly what they should govern. But a directive is a COMMENT INSIDE THE JUDGED FILE, so every decision taken from a resolved path is a decision the judged file writes for itself — and the three the framework takes are drop-if-out-of-scope, drop-if-generated, and drop-as-a-duplicate. All three are suppression, which is why one comment line at the top of an ordinary source file could silence the source-only rules for it, and — by borrowing the name of any file that is legitimately generated — every rule in the suite, leaving no marker in the judged file for an inventory to find.

So: a decision ABOUT a file reads the name of the file the toolchain compiled, which no directive rewrites; a value REPORTED about a finding keeps the resolved position, directives and all.

Package goyze is the framework every yze analyzer is built on: the Registration an analyzer declares itself with, the Diagnostic schema it reports in, the driver that runs it, and the machinery that applies what its fixes suggest.

THE ANALYZER CONTRACT

Every analyzer in this suite obeys all of the following. An analyzer that does not is a defect, whatever it reports. These are not preferences: every rule here is here because it was broken, and the breakage was expensive.

This copy lives in SOURCE, in the framework every analyzer imports, because that is the copy that cannot be skipped: it ships inside every module zip, it is what `go doc` prints, and it is in front of anyone who opens the package an analyzer is built on. The same text is in this repository's .claude/CLAUDE.md and in yze's, stickler's and testdata.stickler's, because a rule that lives in one place is a rule that gets missed.

1. One analyzer reports ONE specific thing

An analyzer answers a single question and its name says which. If the thing it reports cannot be stated in one sentence, it is two analyzers. An analyzer must not report anything BEYOND its one thing: scope creep is indistinguishable from a bug to whoever is holding the finding.

2. Three kinds, decided by what the analyzer SEES

  • PER FILE — one file's text or syntax. Markdown, shell, SQL, YAML, and anything judged without a compiler.
  • PER PACKAGE — one Go package per pass. Standards compliance for code.
  • PER REPO — the whole repository. Organization and layout.

A repo-wide analyzer LEARNS the repository first and reports second: it walks the tree, captures what it can determine — what kind of repository this is, what it holds, how it is laid out — and reports only what that knowledge entitles it to decide. Repo-wide analyzers own the questions no single file can answer, including that the standard dot-files exist, that the folder layout matches the repository's TYPE, and, once signing is implemented, that the signatures verify.

Information flows DOWN and never up. A package analyzer may be given repo facts so a package can be checked against the kind of repository it lives in; a file analyzer may be given repo and package facts for the same reason.

3. Defaults never conflict, and every fix CONVERGES

Every analyzer is enabled and they must all be enabled together, so two analyzers must never demand incompatible things. When two claim the same token their fixes must COMPOSE, and applying every fix must reach a fixed point: an agent running --fix to convergence arrives at silence, never at a cycle where one analyzer undoes another's work. An analyzer that reports something its own fix cannot repair leaves a finding that never clears, and that is a defect in the analyzer.

4. --fix MUST NEVER BREAK THE CONTENT, IN ANY WAY

If an analyzer can fix what it reports — in any language — it provides --fix, and the fix must never break the content: not its syntax, not its types, and not what the program DOES. A fix that compiles, passes go vet, satisfies every analyzer in the suite and changes what the program prints is a catastrophic failure, and it has happened here: a released fix turned `closure sees: 2` into `closure sees: 0` by deleting one character, and every check short of RUNNING THE PROGRAM was silent about it. So a fix is proven by running the program — a BEHAVIOUR case declares what the fixture prints, and the harness runs it, fixes it, and runs it again. A fix with no behaviour case is unproven.

5. False positives are MEASURED, and inaccuracy is declared

Every analyzer is evaluated for its false-positive ratio, and a finding count is not a rate — the findings have to be adjudicated. An analyzer that cannot be 100% accurate is MARKED as supporting disablement.

DISABLEMENT IS NOT IMPLEMENTED AND NOTHING MAY BE DISABLED TODAY. When it is built it will be configurable repo-wide, file-wide, type-wide and statement-wide, and every one will require a SIGNED STATEMENT authorizing it. Until then a finding is removed at its root cause, or the analyzer that reported it is fixed.

6. Every analyzer must pass every other analyzer

An analyzer repository is judged by the whole suite like any other. When an analyzer changes it is the FIRST thing that must clear the bar, because an analyzer that cannot satisfy its siblings has no standing to judge anyone else's code.

7. Why this contract exists

An agent cannot be trusted to adhere to these standards from memory and must not be asked to. It has been demonstrated repeatedly that an agent will invent a justification for whatever it already wrote and then write that invention into the tool: one check was inverted, produced 73 findings across 73 repositories with every one false, and was "verified" by a scenario fabricated on the spot.

The analyzers are the gate. They are what an agent must satisfy, and repairing what the gate reports is the one thing an agent can be relied upon to do. The gate decides; the agent complies. An agent arguing with a finding is wrong until the STANDARD says otherwise — not until the agent's reasoning does. A rule that lives only in an agent's context is a rule that will be broken, and it belongs in an analyzer.

The sanctioned disablement facility: the ONE way a finding may be silenced, and the reason every other way is a defect.

Five channels silenced findings before this existed and not one was sanctioned, recorded or justified: the generated-file marker (whole file, all rules, one comment line), a //nolint (statement, and only the golangci half), .git/info/exclude, a build tag or filename suffix, and a per-rule cosmetic rewrite. Each is free to type, invisible to review, and counted by nothing. A rule with no per-instance escape forces the wrong resolution -- the whole repository silenced by a baseline, or correct code reshaped to dodge a check -- and both are worse than the finding.

A directive is a REQUEST rather than a grant

This is the property the whole design turns on, and the one every channel above gets wrong. Writing the directive does not silence anything. It records that somebody wants a finding silenced, at a stated scope, for a stated reason -- and until the owner has AUTHORIZED that exact request, the finding is still reported and the unauthorized directive is reported beside it.

So forging a directive costs its author a second finding rather than buying silence, which is the acquisition test s03 states: an exemption holds only when forging its marker means acquiring the property the exemption exists for. The property here is the owner's approval, and no comment can spell it.

What is deliberately NOT here

There is no signing capability in this package, and no flag, environment variable or code path through which one could be reached. That is a requirement rather than an omission: the agent that writes the directive shares a shell with the owner's signing key, so any design where the tool signs is a design where the agent signs. Authorization is verified here and produced elsewhere, by a person, out of band.

Generated-file exclusion: the gomatic standards commit generated trees (protobuf stubs, ANTLR parsers) but exempt them from every gate by the standard marker. Diagnostics in generated files are dropped, mirroring golangci-lint's default and `go vet`'s community convention: generated code is a build artifact — change the generator input, not the output.

Where generated code is allowed to be, which this file enforces

The standard is that generated code lives under `src/` and NOWHERE else; that a `src/` tree holds ONLY generated code, every file under it carrying the marker; and that `dist/` and `bin/` are generated CONTENT rather than source, skipped entirely. So the exemption belongs to a PATH, and this file keys it on the marker alone.

That gap WAS the largest hole in the suite, and it was measured rather than estimated: two files with identical violations, differing only by a leading `// Code generated by nothing. DO NOT EDIT.`, draw SIX findings and ZERO — across yze/globalvar, yze/errconst, yze/namedtypes, yze/emptyiface and yze/nopanic. One comment line, free to type, silences all 42 analyzers in a file. It acquired the marker and none of the property, which is the acquisition test every exemption in this suite is held to, failed at framework level until the scoping below closed it.

The fix was to scope the match to `src/`, and it is ADOPTED — see [generatedFiles.inGeneratedTree], which [generatedFiles.dropped] requires alongside the marker. The regex still has to hold, and it only counts under that tree, so forging the marker in `internal/` buys nothing: the exemption stopped being a property of a comment anybody can write and became a property of where the file IS. Measured across the 148-repository fleet before proposing it: two repositories have a Go `src/` tree, nine files live under them, nine of nine carry the marker, and zero markers exist anywhere outside `src/`. The rule already held everywhere — scoping locked in a property rather than asking anybody to move a file.

`testdata/corpus/filesize/generated-forgery` was committed RED while that was pending: it plants the marker on a file whose own prose says a person wrote it, and asserts the finding appears anyway, because a case written to encode the silence would have cemented the hole as a guarded invariant. It is green now, and it went green by the framework being fixed rather than by the case being rewritten, which is the whole reason it was committed failing.

Reading a tree's disablement requests once, and answering with them.

Where a disablement request reaches, which is a property of the SYNTAX rather than of the text.

A request above a type has to cover the whole declaration, and only the parser knows where that ends -- so scope is resolved from the tree and not from line arithmetic over comments.

Index

Constants

View Source
const (
	// ErrReadFile reports a source file that could not be read for fixing.
	ErrReadFile errs.Const = "cannot read file for fixing"
	// ErrFormat reports a fixed file that could not be reformatted.
	ErrFormat errs.Const = "cannot format fixed file"
	// ErrWriteFile reports a fixed file that could not be written back.
	ErrWriteFile errs.Const = "cannot write fixed file"
)

File-fix errors.

View Source
const (
	// ErrUnknownSetting reports a setting name an analyzer does not define.
	ErrUnknownSetting errs.Const = "analyzer setting is not supported"
	// ErrInvalidSettingValue reports a value that a known setting rejects when
	// parsed or validated. The setting exists; only the value is bad.
	ErrInvalidSettingValue errs.Const = "analyzer setting value is invalid"
	// ErrUnknownAnalyzer reports a configuration block naming an analyzer that
	// does not exist in the suite being configured.
	ErrUnknownAnalyzer errs.Const = "analyzer named in config does not exist"
)

Configuration errors.

View Source
const (
	// ErrDirectiveRule reports a directive naming no rule.
	ErrDirectiveRule errs.Const = "yze:disable names no rule"
	// ErrDirectiveJustification reports a directive carrying no reason.
	ErrDirectiveJustification errs.Const = "yze:disable carries no justification"
)
View Source
const (
	// ErrOverlappingEdits reports two text edits whose byte ranges intersect.
	ErrOverlappingEdits errs.Const = "overlapping text edits"
	// ErrEditOutOfBounds reports a text edit whose range falls outside the
	// content, or whose start is greater than its end.
	ErrEditOutOfBounds errs.Const = "text edit out of bounds"
)

Sentinel errors emitted by the fix engine.

View Source
const (
	// ErrMissingName reports a Registration with no analyzer name.
	ErrMissingName errs.Const = "registration is missing a name"
	// ErrMissingAnalyzer reports a Registration with no underlying analyzer.
	ErrMissingAnalyzer errs.Const = "registration is missing an analyzer"
	// ErrNameDisagreement reports a Registration whose name is not its analyzer's.
	ErrNameDisagreement errs.Const = "registration name and analyzer name disagree"
	// ErrPrecisionUndeclared reports a registration that states no precision.
	ErrPrecisionUndeclared errs.Const = "registration declares no precision; state exact or judgment"
	// ErrPrecisionUnknown reports a precision that is not one of the declared values.
	ErrPrecisionUnknown errs.Const = "registration declares an unknown precision"
	// ErrURLNotCanonical reports a help URL that is not the rule's own page.
	ErrURLNotCanonical errs.Const = "registration's help URL is not this rule's documentation page"
	// ErrNoCategories reports a registration carrying no category at all.
	ErrNoCategories errs.Const = "registration carries no category, so no --category selects it"
)

Registration validation errors.

View Source
const (
	// ErrNotRegular reports a path that is not a regular file. A fix rewrites
	// source, and a directory, socket, device or named pipe is not source.
	ErrNotRegular errs.Const = "cannot rewrite a path that is not a regular file"
	// ErrReplace reports a replacement file that could not be renamed over the
	// original. The original is untouched.
	ErrReplace errs.Const = "cannot replace the file with its rewrite"
)

Errors the replace itself can emit.

View Source
const (
	// ErrTempCreate reports a replacement file that could not be created beside
	// the file being rewritten.
	ErrTempCreate errs.Const = "cannot create the replacement file"
	// ErrTempWrite reports a replacement file that could not be written, given
	// the original's mode, synced, or closed. The original is untouched.
	ErrTempWrite errs.Const = "cannot write the replacement file"
)

Errors staging a replacement can emit. Each names the step that failed, because the author's next move differs: a replacement that could not be created means nothing was touched, while one that could not be filled means the source is intact and a temporary file may have been left behind.

View Source
const ErrAnalyzer errs.Const = "analyzer failed"

ErrAnalyzer reports an analyzer whose Run returned an error. The checker records a failed Run on its action (Action.Err) rather than failing Analyze — whose own error covers only setup — so without this gate a failed analyzer silently contributes zero diagnostics and the run degrades to a false pass, the same failure mode ErrLoadPackages guards against.

View Source
const ErrDriver errs.Const = "analysis driver failed"

ErrDriver reports that the underlying analysis driver failed to run.

View Source
const ErrInvalidReport errs.Const = "invalid diagnostic report"

ErrInvalidReport reports a payload that is not a well-formed diagnostic report.

View Source
const ErrLoadPackages errs.Const = "failed to load packages"

ErrLoadPackages reports that the package loader produced no usable packages: a non-empty pattern list that matched nothing, or packages carrying load, parse, or type errors. Without this gate the checker silently skips errored packages and the run degrades to a false pass with zero diagnostics — e.g. under an active go.work workspace that does not include the target module, packages.Load returns one placeholder package whose only content is a "directory prefix . does not contain modules listed in go.work" list error.

View Source
const ErrNonUTF8 errs.Const = "report carries non-UTF-8 bytes JSON would silently replace"

ErrNonUTF8 reports a report carrying bytes JSON cannot represent without loss.

View Source
const ErrNotRegularFile errs.Const = "not a regular file"

ErrNotRegularFile reports a named path whose contents cannot be read as source. Reading one is not merely useless: a FIFO blocks forever on open and a character device never ends, so a single such argument hangs the gate instead of failing it — the one outcome nobody can diagnose from a stuck CI job.

View Source
const ErrRollbackFailed errs.Const = "cannot restore a file after a failed fix"

ErrRollbackFailed reports a fix run that failed after writing and could not put every file it had rewritten back the way it found it. It is the one outcome that leaves edits on disk, and it names them.

View Source
const ErrTooLarge errs.Const = "file is too large to analyze"

ErrTooLarge reports a file past the size a rule will read. Source is not gigabytes; a file that big is generated output, a data dump, or a mistake, and reading it costs its own size in memory for a rule that cannot apply.

View Source
const ErrVerifyLoad errs.Const = "cannot reload packages for verification"

ErrVerifyLoad reports that the packages could not be reloaded for post-fix verification.

Variables

This section is empty.

Functions

func ApplyConfig added in v0.2.0

func ApplyConfig(regs []Registration, settings Settings) error

ApplyConfig applies per-analyzer settings to the registrations' analyzer flags. settings is keyed by analyzer name, then by setting (flag) name. An unknown setting name (ErrUnknownSetting) or an invalid value for a known setting (ErrInvalidSettingValue) is an error.

A name that matches no registration is SKIPPED here and is not silently tolerated: this function is handed one set of registrations and cannot know whether it is the whole catalog, so a driver whose suite may be assembled from several sources would fail on a block meant for a sibling. The driver that knows its full catalog — every Go registration AND every other analyzer it runs — asks CheckAnalyzerNames, and that is where a misspelled rule name is refused. Splitting the two is what lets the refusal be total rather than approximate.

func ApplyEdits

func ApplyEdits(content []byte, edits []TextEdit) ([]byte, error)

ApplyEdits applies edits to content and returns the rewritten bytes. Edits may be supplied in any order; exact duplicates collapse to one edit, and the rest are applied as one atomic batch. It reports ErrEditOutOfBounds for a range outside content (or an inverted range) and ErrOverlappingEdits when two distinct ranges intersect. content is never mutated.

func BoundedRead added in v0.12.0

func BoundedRead(files FileSystem, path FilePath, limit ByteCount) ([]byte, error)

BoundedRead reads a file only as far as it could still be source.

The bound is on the READ, not on a size asked for beforehand. Asking first answers a different question than the one that matters: it describes the path rather than the bytes, so a stat that did not follow a symlink reported the link's own few bytes and read the two gigabytes behind it, and a file that grew between the question and the answer was read at its new size. One extra byte is read past the limit purely to tell "at the limit" from "over it"; nothing larger is ever resident.

func CheckAnalyzerNames added in v0.14.0

func CheckAnalyzerNames(settings Settings, known []RuleID) error

CheckAnalyzerNames reports ErrUnknownAnalyzer for every settings key that names none of the known rules, listing them all in one error and in sorted order so two runs over one config say the same thing.

It exists because a configuration channel that accepts a name nothing answers to is a channel that can be typed wrong and never say so: `yze/jsontags` for `yze/jsontag` configures nothing, reports nothing, and leaves the author believing a setting is in force. An unknown SETTING was already an error; an unknown ANALYZER was the same mistake one level up, and silent.

func Cleaned added in v0.12.0

func Cleaned(path FilePath) string

Cleaned is a path as a finding should name it: the spelling the caller wrote, normalised. A path reaching a report with `..` still in it names a file correctly and reads as a different one.

func GitCheckIgnore added in v0.11.0

func GitCheckIgnore(dir RepoDir, paths []string) (map[string]bool, error)

GitCheckIgnore is the default CheckIgnore.

The invocation is deliberate in three ways, each of which was a defect first. It runs from INSIDE the repository, because git asked from wherever the process happened to start answers "not in a repository" for every path and the filter silently does nothing. It is NUL-framed, because a path may contain a newline — such a path split into two questions and came back C-quoted, so the one file git had answered about was the one that escaped. And core.excludesFile is neutralised, because that file is in no repository: with it, one developer's personal excludes made a tracked file invisible locally and a finding in CI, so the gate's verdict depended on the machine.

That neutralisation is PARTIAL, and saying otherwise would be the overclaim. `$GIT_DIR/info/exclude` is equally per-clone, equally uncommitted, and equally invisible in a diff, and git offers no way to switch it off — check-ignore honours it the way the rest of git does, so a line there hides a file from this gate on one machine and not another. That is a description of git's behaviour rather than a property of this function: there is no argument, branch or output here that carries it, and no test in this package can reach it, because the subprocess seam is stubbed on principle (see TestIgnoreHelperProcess). Anyone auditing why CI and a workstation disagree should look there first.

func GoFormat

func GoFormat(src []byte) ([]byte, error)

GoFormat is the default Formatter: gofmt-canonical Go source.

func MarshalReport

func MarshalReport(r Report) ([]byte, error)

MarshalReport serializes a Report to the native stickler-json encoding, REFUSING any report carrying bytes JSON cannot represent.

encoding/json replaces invalid UTF-8 with U+FFFD and returns no error, so the corruption is silent and unrecoverable — the reader cannot tell a replacement character from one the source really contained. Two consequences, and the second is why this refuses rather than repairs:

  • A PATH THAT DOES NOT EXIST. POSIX filenames are byte strings, so a repository holding a Latin-1-named file gets a report naming a path nothing can open, and every path-keyed consumer downstream inherits it.
  • SOURCE CORRUPTION THROUGH THE FIX PATH. A TextEdit.NewText carrying bytes copied out of a non-UTF-8 file — a Latin-1 shell script or Markdown document, exactly the population the source analyzers exist for — is written back into the author's file with those bytes replaced. That is [contract] rule 4 broken by the transport rather than by an analyzer.

Refusing is the only safe answer. Repairing would mean choosing an encoding on the author's behalf, and a report that fails loudly costs a run; a fix that writes U+FFFD into source costs the file.

func ReadFile added in v0.14.0

func ReadFile(path FilePath) ([]byte, error)

ReadFile is the default FileReader. It exists so that every caller spells the read the same way and none has to adapt os.ReadFile at the seam: an adapter written once here is an adapter not written differently in each driver and in each of that driver's tests, where the differences are what nobody reviews.

func ReplaceFile added in v0.14.0

func ReplaceFile(path FilePath, data []byte) error

ReplaceFile is the default FileWriter: it replaces an EXISTING file's contents ATOMICALLY, preserving its mode.

The bytes go to a temporary file in the SAME directory — the same filesystem, which is what makes the rename atomic — and are synced to disk before that file is renamed over the original. A failure at any point before the rename leaves the original byte-identical, and the rename either happens completely or not at all.

os.WriteFile cannot offer that. It opens with O_TRUNC, so the file is emptied at open and refilled by the write: a write that fails part-way — ENOSPC, EIO, an RLIMIT_FSIZE cap, a full container layer — leaves the file SHORTER than it was and returns an error, and the source is destroyed with the original nowhere. Measured, not argued from the man page: a 1500-byte file written under `ulimit -f 1` came back 512 bytes.

The stat is not a precondition check that could be dropped — it is what makes this a rewrite rather than a create. A create would materialise a file that was not there, so a run whose path vanished between analysis and write would invent one, and a fix would have produced a file no analyzer ever read. The mode comes from the file rather than a fixed perm so an executable script or a mode-tightened source keeps what it had.

The regular-file check is what keeps the writability probe from being a deadlock. Opening a named pipe O_WRONLY BLOCKS until something opens the read end, and in a fix run nothing ever will: a single file named `x.go` that happens to be a FIFO would hang the whole run with a staged replacement already on disk. A directory, a socket and a device are refused by the same check for the same reason it exists — a fix rewrites source, and none of those is source.

The writability check is a real open rather than a mode comparison, so the kernel answers with its own rules — owner, group, ACLs and a read-only mount included. A truncating write asked the file for permission and a rename does not: it needs only the DIRECTORY, so without this a fix would silently rewrite a file its author had made read-only. The cost runs the other way and is stated rather than hidden: a writable file in a read-only directory could be rewritten before and cannot be now, because there is nowhere to stage the replacement.

The symlink resolution is what keeps the replace equivalent to the write it replaces: a rename over a symlink would replace the LINK with a regular file, where a truncating write follows it and rewrites the target. Two costs remain and are stated rather than hidden — a rename gives the file a fresh inode, so any hard link to it keeps the old contents, and the replacement is owned by whoever ran the fix.

ATOMIC IS NOT DURABLE, and the boundary is stated rather than implied. The replacement's bytes are synced before the rename, so the file a reader opens is never half-written; the directory entry is not synced afterwards, so a machine that loses power immediately after a run may come back with some rewrites missing. What it cannot come back with is a rewrite that is half applied. Syncing the directory would narrow that window and could not close it — a crash BETWEEN two files' renames leaves a partly-fixed tree whatever each individual rename did, and only a journal fixes that — while the loss it prevents is a fix the author re-runs. The run's own failures are the ones this is atomic against, and they are the ones that happen.

func RestoreOriginals added in v0.14.0

func RestoreOriginals(write FileWriter, originals map[FilePath][]byte) error

RestoreOriginals puts every recorded file back, in sorted path order, and reports ErrRollbackFailed naming the ones it could not. It attempts every path before reporting: stopping at the first failure would leave files restorable and unrestored for no reason.

func Tracked added in v0.11.0

func Tracked(check CheckIgnore, paths []string) []string

Tracked drops the paths git ignores, asking each repository separately.

It FAILS OPEN, per repository: a tree that is not a checkout, or a machine with no git, yields its own paths unchanged while the others are still filtered. Treating "cannot answer" as "ignore everything" would turn a missing tool into a silent clean pass, which is the one result a gate must never produce.

Types

type AnalyzerName added in v0.4.0

type AnalyzerName string

AnalyzerName is an analyzer's stable identifier, used as its rule-id suffix and as the key a Settings map targets.

type AnalyzerSettings added in v0.4.0

type AnalyzerSettings map[SettingName]SettingValue

AnalyzerSettings maps each of one analyzer's setting names to its raw value.

type ByteCount added in v0.12.0

type ByteCount int64

ByteCount is a size in bytes.

type Category

type Category string

Category is a many-to-many semantic tag carried as metadata. An analyzer may belong to several categories; categories drive filtering and documentation.

type CheckIgnore added in v0.11.0

type CheckIgnore func(dir RepoDir, paths []string) (map[string]bool, error)

CheckIgnore reports which of the given paths git ignores, asked from within dir. It is the seam a test replaces to drive the filter with no git installed and from outside any repository — a unit test that needs a real checkout is an integration test wearing one's name.

type Claim added in v0.12.0

type Claim func(path FilePath) bool

Claim reports whether a discovery judges this file. It is asked only about paths that are already readable regular files, so it may open the file to look at its contents.

type Diagnostic

type Diagnostic struct {
	Tool     string   `json:"tool"`
	Rule     RuleID   `json:"rule"`
	Path     string   `json:"path"`
	Severity Severity `json:"severity"`
	Message  string   `json:"message"`
	URL      string   `json:"url,omitempty"`
	Fixes    []Fix    `json:"fixes,omitempty"`
	Line     int      `json:"line"`
	Col      int      `json:"col"`
	EndLine  int      `json:"end_line,omitempty"`
	EndCol   int      `json:"end_col,omitempty"`
}

Diagnostic is the lean, normalized finding that every tool's output is mapped into. It is the single contract shared by the yze analyzers (producers) and the stickler runner (consumer).

func ToDiagnostic

func ToDiagnostic(fset *token.FileSet, reg Registration, d analysis.Diagnostic) Diagnostic

ToDiagnostic normalizes a go/analysis diagnostic into the lean Diagnostic schema, resolving token positions through fset and stamping the registration's rule id and help URL. Analyzer findings are always reported at error severity.

Path, Line and Col are the RESOLVED position, line directives applied: that is what a directive is for, and under cgo it is the only one of the two names that points at a file the author wrote (the compiled name is a build-cache hash). Nothing may be SUPPRESSED on that basis — see compiled.go.

A resolved name OUTSIDE the module under analysis is corrected, and that is done by [kept] rather than here, because deciding it needs a module root and this function has a FileSet. See [generatedFiles.inTreeUnderAnalysis].

type DirName added in v0.12.0

type DirName string

DirName is a single path element — one directory's own name.

type Discovery added in v0.12.0

type Discovery struct {
	Files  FileSystem
	Claims Claim
	Prunes Prune
}

Discovery turns a source analyzer's arguments into the files it judges.

func (Discovery) Expand added in v0.12.0

func (d Discovery) Expand(args []string) (Expansion, error)

Expand resolves each argument: a directory contributes the files beneath it, and any other path is taken verbatim.

A file NAMED outright is analyzed verbatim, and a file found by WALKING is passed through the repository's own ignore rules first. The filter exists to keep a walk from claiming files the repository does not own; it does not overrule an author who asked about one by name — doing so answered a deliberate request with a silent clean pass.

type Driver

type Driver func(regs []Registration, patterns []Pattern) (*token.FileSet, []DriverResult, error)

Driver runs the registered analyzers over the given package patterns and returns the shared FileSet plus per-analyzer findings. It is the seam between the framework and a concrete analysis backend (the default is CheckerDriver).

type DriverResult

type DriverResult struct {
	Registration Registration
	Diagnostics  []analysis.Diagnostic
}

DriverResult is one analyzer's findings from a driver run, paired with the registration that produced them so positions and metadata can be normalized.

func CheckerDriver

func CheckerDriver(regs []Registration, patterns []Pattern) (*token.FileSet, []DriverResult, error)

CheckerDriver is the default Driver: it loads the patterns' packages and runs the registered analyzers through the go/analysis checker.

type Expansion added in v0.12.0

type Expansion struct {
	Files      []string
	Names      []string
	Unreadable []string
}

Expansion is what a Discovery made of its arguments: the files to analyze, every name they were reached by, and the paths it could not look inside.

FILES and NAMES are the same tree under two different questions, and the distinction is the whole reason both are here.

  • A rule that reads BYTES asks Files: one spelling per FILE, because analyzing one inode twice under two names is waste and reports one defect as two.
  • A rule that judges the NAME asks Names: there the name IS the finding, so collapsing two names into one discards the evidence. A symlink named `CHANGELOG.md` pointing at an innocent document left a name-based ban unenforceable, and a symlink survives a clone as mode 120000.

Names is a superset of Files: every file is named, and the aliases the file dedup drops are named too. A rule that judges names and reads nothing should ask Names alone; a rule with both halves asks each half its own list.

Unreadable is not an error. A tree the walk cannot descend into used to abort the run and return an empty report, so one unreadable directory cost every other file its findings — and a gate that reports nothing is indistinguishable from a clean one. It also carries the trees the walk DECLINES to enter — a link to a directory, which is not descended and must not be silent either. These are handed back to be REPORTED, so the run continues and nothing is passed over in silence.

type FileEdit

type FileEdit struct {
	Path  string     `json:"path"`
	Edits []TextEdit `json:"edits"`
}

FileEdit groups the TextEdits that apply to one file.

type FilePath added in v0.12.0

type FilePath string

FilePath is one path a discovery visited or was handed.

type FileReader

type FileReader func(path FilePath) ([]byte, error)

FileReader returns the current bytes of a file.

type FileSystem added in v0.12.0

type FileSystem struct {
	Stat         func(string) (fs.FileInfo, error)
	Lstat        func(string) (fs.FileInfo, error)
	Open         func(string) (io.ReadCloser, error)
	WalkDir      func(string, fs.WalkDirFunc) error
	EvalSymlinks func(string) (string, error)
	Abs          func(string) (string, error)
	CheckIgnore  CheckIgnore
}

FileSystem is the filesystem a Discovery reads through.

Every entry is a reference rather than a call so a command's tests drive the discovery without a real tree, and so the failures that matter here — an unreadable directory, a broken symlink, a walk that cannot stat its own root — are arrangeable at all. They are the failures the defects above lived in.

func OSFileSystem added in v0.12.0

func OSFileSystem() FileSystem

OSFileSystem is the real filesystem.

type FileWriter

type FileWriter func(path FilePath, data []byte) error

FileWriter persists the rewritten bytes of a file. It MUST leave the file as it was when it returns an error: every undo in this package restores only the files whose writes LANDED, so a writer that can fail part-way through a file puts that file beyond the reach of any rollback. ReplaceFile is the implementation that holds to it and is the one to use.

func RecordOriginals added in v0.14.0

func RecordOriginals(read FileReader, write FileWriter, originals map[FilePath][]byte) FileWriter

RecordOriginals wraps a FileWriter so the bytes each path held BEFORE this run first rewrote it are remembered in originals. It is what lets a driver undo a whole multi-round run — ApplyFixes undoes one batch, and a fixpoint loop that applied four rounds before the fifth broke the build needs the bytes from before round one, which no later read can still see.

The content is captured at the first SUCCESSFUL write of a path and never again, so a round that rewrites the same file twice keeps the pristine bytes, and the restore writes that go back through this same writer cannot overwrite the record.

Recording only what landed is what keeps the restore honest, and it rests on the same requirement FileWriter states: a write that FAILS leaves the file as it was. ReplaceFile gives that, and a truncating os.WriteFile does not — under one, a failed path is untouched and belongs in no restore; under the other it is destroyed and belongs in every report, and no rollback can reach it because nothing held its post-failure bytes. Recording the failures too would not rescue that case and would cost a real one: it names files that are perfectly fine and attempts a restore write to each, which fails again for the same reason it failed the first time, so a run that changed nothing reports that it could not put something back.

A read failure fails the write rather than proceeding unrecorded: a rewrite nobody can undo is exactly what this exists to prevent.

type Fix

type Fix struct {
	Description string     `json:"description"`
	Files       []FileEdit `json:"files"`
}

Fix is a named, mechanically-applicable change attached to a Diagnostic. It is present only when the analyzer can offer a safe, deterministic edit.

type FixResult

type FixResult struct {
	FilesChanged int
	EditsApplied int
}

FixResult summarizes what ApplyFixes changed. A run that returns an error changed nothing and the result is zero — the sole exception is ErrRollbackFailed, where the restore itself failed and the error names every file left rewritten.

func ApplyFixes

func ApplyFixes(read FileReader, write FileWriter, format Formatter, fixes []Fix) (FixResult, error)

ApplyFixes applies every fix's edits to disk as ONE ATOMIC BATCH: either every file lands or none does. It merges all edits targeting a file, rewrites the file's bytes via ApplyEdits, reformats the result, and writes it back, in sorted path order for determinism.

Atomicity is what a fix engine owes its caller, because the author trusts the tree afterwards and the gate goes green over it. It is bought in two halves. Every file is PLANNED first — read, edited, reformatted, held in memory — so a read, overlap, or format failure aborts with nothing whatsoever on disk; that is three of the four failure modes gone before the first byte is written. The remaining one is a write, and a write that fails part-way through the batch restores the files already written from the originals the plan is holding.

The batch is atomic exactly as far as the writer is. A FileWriter that leaves a file half-written when it fails puts THAT file outside the undo — the files before it are restored and the one that failed cannot be, because nothing ever held its post-failure bytes. This is why ReplaceFile exists and is the default: it stages the rewrite beside the file and renames it into place, so a failed write is a file that was never opened for writing at all.

The earlier design wrote as it went and reported how far it got, on the argument that the applied edits were correct and the author's next move was to finish them rather than lose them. That argument is about ONE failure — a retyped parameter with a stale _test.go caller — and it does not generalize: the author cannot tell that failure from a disk error apart by reading a tree that is half-rewritten, and a partly-applied batch is not a state any analyzer ever proposed. The list of files is preserved either way; what is no longer preserved is the half-rewritten tree.

type Formatter

type Formatter func(src []byte) ([]byte, error)

Formatter canonicalizes a file's bytes after edits are applied.

type HelpURL added in v0.4.0

type HelpURL string

HelpURL is the documentation URL stamped onto every Diagnostic an analyzer emits.

func CanonicalURL added in v0.14.0

func CanonicalURL(name AnalyzerName) HelpURL

CanonicalURL is the documentation page a rule's diagnostics point at, DERIVED from the rule's name rather than typed beside it.

The URL reaches an author on every finding, and it was the one field nothing checked: two analyzers shipped pointing at the docs ROOT rather than their own page, so every diagnostic they emitted sent the reader to a generic index to go looking. Nothing reported it, because a Registration's field values were asserted nowhere — mutating URL, Categories or Precision left a full suite green at 100%.

Deriving rather than checking a typed value is what makes it unbreakable: a renamed rule cannot keep an old page, and a new analyzer copied from a sibling cannot inherit the sibling's link.

type Pattern added in v0.4.0

type Pattern string

Pattern is a package pattern (e.g. "./...") naming the packages an analyzer run targets.

type Precision added in v0.14.0

type Precision string

Precision is whether an analyzer's verdict is decidable from the code, or bounded by judgment.

The contract requires that an analyzer which cannot be 100% accurate be MARKED, and this is the mark. It is declarative and changes nothing about how a finding gates today: a judgment-bound finding blocks exactly like any other, because there is no disablement of any kind and a finding is removed at its root cause or the analyzer that reported it is fixed.

What it is FOR is the disablement that does not exist yet. When that is built -- configurable repo-wide, file-wide, type-wide and statement-wide, every scope requiring a SIGNED STATEMENT -- this is what will say which rules may be argued with at all. Marking it now, from each analyzer's own documented self-description, means that set is decided by the analyzers rather than assembled later by whoever is holding a finding they dislike.

const (
	// PrecisionUndeclared is the zero value: no author has stated this rule's
	// precision. [Registration.Validate] refuses it, so it never reaches a run.
	PrecisionUndeclared Precision = ""
	// PrecisionExact is a verdict decidable from the code. It must be declared,
	// never inherited from silence.
	PrecisionExact Precision = "exact"
	// PrecisionJudgment is a verdict only a person can settle. The analyzer
	// reports a shape that is legitimate in some readings, so its findings are
	// adjudicated rather than mechanically true.
	PrecisionJudgment Precision = "judgment"
)

The precisions an analyzer may declare.

THE ZERO VALUE IS UNDECLARED, AND UNDECLARED IS REFUSED. It used to be PrecisionExact, which handed every analyzer that said nothing the STRONGEST claim it can make about itself — a verdict decidable from the code, with no author having decided it. That is exactly backwards for a default: silence is the absence of a judgement, never a confident one, and the field exists precisely because some rules cannot be exact.

It was not a theoretical inversion. Measured over the suite when this changed: 3 registrations of 36 declared a precision and 33 were exact by omission, eight of them analyzers whose OWN package doc argues at length that their findings need adjudication. Nobody chose that for any of the 33, and nothing reported it, because the zero value of a string field is the one state no author has to type.

It also gates the session's work order: statement-level disablement reaches only rules marked judgment-bound, so it would have landed inert for 33 of 36.

type Prune added in v0.12.0

type Prune func(name DirName) bool

Prune reports a directory whose contents belong to somebody else. It is asked about one directory's own name, never a path, because the question is about the convention the name carries.

type Registration

type Registration struct {
	Analyzer   *analysis.Analyzer
	Name       AnalyzerName
	URL        HelpURL
	TestScope  TestScope
	Precision  Precision
	Categories []Category
}

Registration declares one analyzer's identity and taxonomy to the framework.

func (Registration) IsJudgmentBound added in v0.14.0

func (r Registration) IsJudgmentBound() bool

IsJudgmentBound reports whether this analyzer's findings are adjudicated rather than mechanically true.

func (Registration) RuleID

func (r Registration) RuleID() RuleID

RuleID returns the stable rule identifier carried by every Diagnostic the analyzer emits.

func (Registration) Validate

func (r Registration) Validate() error

Validate reports the first way a Registration is not well-formed.

The two names must AGREE, because each is the analyzer's identity to a different audience and neither can be reached by the other's spelling. The registration's name is what a rule id is built from, so it is the key a .stickler.yaml `analyzers:` block targets and the id every diagnostic carries. The analyzer's own name is what the go/analysis driver keys its per-analyzer flags on, and what analysistest matches. Let them drift and each audience gets a name the other one does not answer to, with nothing reporting the split.

Nothing in the fleet breaks this today -- all 42 registrations agree -- and it is guarded here rather than left to hold by luck because the two are separate settable fields, and because a new analyzer repository is made by copying a sibling and renaming it. Renaming one field and not the other is precisely what copying produces.

func (Registration) WithTestScope added in v0.9.0

func (r Registration) WithTestScope(scope TestScope) Registration

WithTestScope returns a copy of the registration carrying the given scope, so a catalog can declare the policy centrally without every analyzer repository restating it.

type RepoDir added in v0.11.0

type RepoDir string

RepoDir is a directory inside the repository an ignore question is asked from.

type Report

type Report struct {
	Diagnostics []Diagnostic `json:"diagnostics"`
}

func Run

func Run(driver Driver, regs []Registration, patterns []Pattern) (Report, error)

Run validates the registrations, executes them through the driver, and normalizes every finding into a Report (the native stickler-json model).

func UnmarshalReport

func UnmarshalReport(data []byte) (Report, error)

UnmarshalReport parses a native stickler-json payload, reporting ErrInvalidReport when the bytes are not a well-formed report.

WELL-FORMED means more than parseable JSON, which is all this used to check while its doc claimed otherwise.

THE ENVELOPE HAS TO BE THIS SCHEMA'S. A JSON object of any other shape decoded into a Report with every field left at its zero value, so a checker printing `{"totally":"different","shape":[1,2,3]}` and exiting 0 was a CLEAN PASS -- reported as `{"diagnostics":null}`, exit 0. Every other malformed shape was already caught (a list, a number, a string, truncated JSON, empty output), so the exposure was narrow and precisely the shape a schema change produces: a tool that renamed the key, or a different tool entirely wired by mistake, reports nothing and passes. Requiring the key makes "I parsed it" mean "it is mine". Every diagnostic must carry its flat rule id: that is what a consumer softens, baselines, attributes and gates on, so a diagnostic without one cannot be acted upon at all. Accepting `{}` as a finding handed the runner an anonymous entry it could only ignore or crash on — a report that is worse than no report, because it looks like an answer.

type RuleID added in v0.14.0

type RuleID string

RuleID is the identifier a rule is known by everywhere OUTSIDE the analyzer that implements it: in a report, in a gate's message, in a soft or probe list, and in the per-analyzer settings a config file supplies. It is the analyzer's name under the suite's prefix, and it is a defined type so that the two cannot be passed for each other -- a config keyed by the bare name silently configures nothing, which is the failure this type exists to make impossible.

func RuleIDFor added in v0.14.0

func RuleIDFor(name AnalyzerName) RuleID

RuleIDFor is the one place the suite's prefix is spelled. Both halves of the suite -- the Go registrations here and the source analyzers a driver bundles -- derive their id through it, so the two cannot drift into two id schemes.

type SearchDir added in v0.12.0

type SearchDir string

SearchDir is a directory argument, expanded recursively.

type SettingName added in v0.4.0

type SettingName string

SettingName is the name of an analyzer flag a Settings map targets.

type SettingValue added in v0.4.0

type SettingValue string

SettingValue is the raw value assigned to a setting before the flag parses it.

type Settings added in v0.4.0

type Settings map[RuleID]AnalyzerSettings

Settings is the per-analyzer configuration: each RULE ID maps to that analyzer's settings. It is the public shape ApplyConfig consumes.

The key is the rule id and not the analyzer's bare name because that is the only spelling an author ever sees -- it is what a report prints and what a gate's soft and probe lists name -- and a config addressed by a second spelling is a config an author writes correctly and wrongly at the same time.

type Severity

type Severity string

Severity ranks a Diagnostic. It is the normalized severity shared across every tool stickler runs.

const (
	SeverityError   Severity = "error"
	SeverityWarning Severity = "warning"
	SeverityInfo    Severity = "info"
)

The severity levels a Diagnostic may carry.

type TestScope added in v0.9.0

type TestScope string

TestScope declares which files an analyzer's findings apply to.

Test code is a different kind of code, and most rules about production design are wrong when applied to it: a table-driven test's anonymous struct is the idiom, not a defect; a `want` field is a fine boolean name; a test double may construct an ad-hoc error. Rules of that shape declare TestScopeSourceOnly and their findings in _test.go files are dropped.

The zero value is TestScopeAll, so an analyzer that says nothing keeps reporting everywhere — a scope is opted INTO, never inherited by accident.

The scope is decided from the name the toolchain compiled the file under (see compiled.go), never from the position a directive inside that file can rewrite. It matters because it is the answer to a question analyzers keep answering for themselves: an analyzer filtering test files with its own strings.HasSuffix over fset.Position(...).Filename is forgeable by the file it is judging, and declaring the scope here is the safe way to say the same thing once.

const (
	// TestScopeAll reports findings in every file. The default.
	TestScopeAll TestScope = ""
	// TestScopeSourceOnly drops findings located in _test.go files.
	TestScopeSourceOnly TestScope = "source-only"
)

The available test scopes.

type TextEdit

type TextEdit struct {
	NewText string `json:"new_text"`
	Start   int    `json:"start"`
	End     int    `json:"end"`
}

TextEdit is a byte-range replacement within a single file's content. Start is inclusive and End is exclusive (both byte offsets); an edit with Start == End is a pure insertion of NewText.

type Verifier added in v0.5.0

type Verifier func(patterns []Pattern) (VerifyResult, error)

Verifier reloads the given package patterns — test files included — and returns every residual parse or type error. It is the seam between a fix applier and a concrete loader (the default is CheckerVerifier), so callers can verify a tree still compiles after edits without shelling out to a real build in their tests.

type VerifyIssue added in v0.5.0

type VerifyIssue struct {
	Pos string `json:"pos,omitempty"`
	Msg string `json:"msg"`
}

VerifyIssue is one parse or type error found when reloading the tree after fixes were applied. Pos is the loader's "file:line:col" position and may be empty (or "-") when the error carries no position.

func (VerifyIssue) String added in v0.5.0

func (i VerifyIssue) String() string

String renders the issue as "file:line:col: message", or just the message when the issue carries no position.

type VerifyResult added in v0.5.0

type VerifyResult struct {
	Issues []VerifyIssue
}

VerifyResult is the outcome of reloading the tree after fixes were applied.

func CheckerVerifier added in v0.5.0

func CheckerVerifier(patterns []Pattern) (VerifyResult, error)

CheckerVerifier is the default Verifier: it reloads the patterns through packages.Load with Tests set and collects every package error.

This is a different question from the one the analysis driver answers. defaultLoad also sets Tests (it must — see the comment there), but it loads in order to RUN analyzers; this loads in order to establish that the tree still TYPE-CHECKS after fixes rewrote it. An applied fix that breaks a _test.go caller is invisible to an analyzer pass and fatal to the build.

It judges the SAME package set the driver judges, git-ignore filter included (driveWith applies filterIgnoredPackages before it validates the load). A verifier that judged more than the analyzers did would blame a fix for breakage in a package yze never read: a repository with a git-ignored Go directory that does not compile analyzed clean and then failed verification, naming a file no fix had touched — and, because the verifier's message is about _test.go callers of retyped functions, it accused the fix of a breakage that predated it.

func (VerifyResult) Clean added in v0.5.0

func (r VerifyResult) Clean() bool

Clean reports whether the reloaded tree carried no parse or type errors.

func (VerifyResult) Files added in v0.5.0

func (r VerifyResult) Files() int

Files counts the distinct files the issues point at. Issues without a position share one "unknown" bucket, so the count is never zero while issues remain.

Jump to

Keyboard shortcuts

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