humanizelint

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 2, 2026 License: MIT Imports: 16 Imported by: 0

README

go-humanize-linter

An AST-based linter that detects hand-rolled reimplementations of go-humanize and suggests the library function instead.

Built on go-linter-sdk and go-finding.

Why?

go-humanize provides 30+ functions for formatting numbers, bytes, times, and English text. Across a typical Go codebase, developers reinvent the same features — byte-size formatting, comma separators, relative time, pluralization — dozens of times, often with subtle bugs (wrong rounding, missing edge cases, inconsistent unit labels).

This linter finds those reimplementations automatically.

Rules

Rule Name Detects Suggests
H001 manual-bytes-format Byte-size formatting (1024 division + unit strings) humanize.Bytes / humanize.IBytes
H002 manual-comma-format Comma/thousands separator insertion (digit grouping loops) humanize.Comma
H003 manual-reltime-format Relative time formatting ("3 hours ago") humanize.RelTime / humanize.Time
H004 manual-plural English pluralization (if n == 1) humanize.Plural / humanize.PluralWord
H005 manual-si-format SI-prefix formatting ("1.2K", "3.4M") humanize.SI / humanize.SIWithDigits
H006 manual-ftoa Trailing-zero stripping (strings.TrimRight nesting) humanize.Ftoa
H007 manual-parse-bytes Byte-size string parsing (HasSuffix chains, mult maps) humanize.ParseBytes
H008 manual-ordinal Ordinal formatting (switch n%10 with st/nd/rd/th) humanize.Ordinal
H009 manual-commaf Float-with-comma formatting (%.Nf + separator loop) humanize.Commaf

Detection Strategy

Examples of detected patterns
// H001: "KMGTPE"[exp] trick
func formatBytes(bytes int64) string {
    const unit = 1024
    return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}

// H002: comma grouping loop
func formatNumber(n int) string {
    for i, r := range str {
        if (len(str)-i)%3 == 0 { result.WriteString(",") }
    }
}

// H003: relative time
func relativeTime(t time.Time) string {
    elapsed := time.Since(t)
    if elapsed < time.Hour { return strconv.Itoa(int(elapsed.Minutes())) + "m ago" }
}

// H004: pluralization
func pluralize(n int, singular, plural string) string {
    if n == 1 { return fmt.Sprintf("%d %s", n, singular) }
    return fmt.Sprintf("%d %s", n, plural)
}

Usage

As a library
import (
    "context"
    "fmt"
    "os"

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

func main() {
    registry := humanizelint.DefaultRegistry()
    report, err := registry.Run(context.Background(), ".")
    if err != nil { panic(err) }

    for f := range report.All() {
        fmt.Printf("%s:%d [%s] %s\n", f.Position.File, f.Position.Line, f.Rule, f.Message)
    }

    os.Exit(linter.ExitCodeFromReport(report))
}
CLI
# Scan a path (text output, exit 1 if findings, 0 if clean)
go-humanize-linter ./...

# JSON or SARIF output
go-humanize-linter --format json ./...
go-humanize-linter --format sarif ./... > results.sarif

# Write report to a file instead of stdout
go-humanize-linter --output report.txt ./...
go-humanize-linter --output results.json --format json ./...
go-humanize-linter --output results.sarif --format sarif ./...

# Enable / disable specific rules
go-humanize-linter --enable H001 --enable H003 ./...
go-humanize-linter --disable H004 ./...

# Load enable/disable rules from a YAML config file
go-humanize-linter --config .gohumanize.yaml ./...

# YAML config file format (.gohumanize.yaml):
#   enable:
#     - H001
#     - H003
#   disable:
#     - H004
# CLI flags are merged on top of config file values (union semantics).

# List rules or print version
go-humanize-linter --rules
go-humanize-linter --version

# Explain a rule or list which files would be scanned
go-humanize-linter --explain H001
go-humanize-linter --list-files ./...

# Filter by confidence (low, medium, high, full)
# Useful for triage: high/full findings are strong signals, medium/low need review.
go-humanize-linter --min-confidence high ./...

# Verify suppression directives are still needed
go-humanize-linter --verify-suppressions ./...

# Save a baseline and compare future runs against it
# First, save the current findings as a baseline:
go-humanize-linter --save-baseline baseline.json ./...
# Then in CI, fail only when findings are added or removed:
go-humanize-linter --behavior-delta baseline.json ./...
As a GitHub Action
- uses: LarsArtmann/go-humanize-linter@v0.2.0
  with:
    path: ./...
    # enable: H001,H003     # only run these rules
    # disable: H004          # skip these rules
    # format: sarif          # text (default), json, or sarif
    # min-confidence: high   # low (default), medium, high, full
    # verify-suppressions: true  # report stale //nolint directives
    # behavior-delta: baseline.json  # fail if findings changed vs baseline
    # save-baseline: baseline.json   # save current findings as baseline
Suppressing findings

Add a //nolint:gohumanize directive to suppress findings. The directive can be placed on the function declaration, in the doc comment, or anywhere inside the function body (on the specific statement that triggers the finding).

// On the function declaration:
//nolint:gohumanize // intentional hand-rolled format
func prettySize(b int64) string {
    return fmt.Sprintf("%.1f %cB", float64(b)/1048576, "M")
}

// Inside the function body (on the triggering line):
func formatBytes(b int64) string {
    return fmt.Sprintf("%.1f %cB", float64(b)/1048576, "M") //nolint:gohumanize
}

Recognised forms:

Directive Effect
//nolint Suppresses all linters
//nolint:all Suppresses all linters
//nolint:gohumanize Suppresses all gohumanize rules on this function
//nolint:gohumanize:H001 Suppresses only H001 (scoped)
//nolint:gohumanize:H001,H002 Suppresses H001 and H002 only
//nolint:gohumanize,other Suppresses gohumanize and another linter

A trailing // reason comment is allowed on any form.

Build & Test

nix run .#test       # run tests
nix run .#lint       # run golangci-lint
nix run .#build      # build all packages

Direct Go commands require GOEXPERIMENT=jsonv2 and GOPRIVATE=github.com/larsartmann/*.

Requirements

Documentation

Overview

Package humanizelint detects hand-rolled reimplementations of github.com/dustin/go-humanize and suggests the library function instead.

The linter scans Go source files using AST pattern matching. Each rule looks for a cluster of signals that, together, strongly indicate a manual reimplementation of a specific go-humanize feature. Single weak signals are never enough — the rules require multiple corroborating signals within the same function to keep false positives near zero.

Rules

H001  manual-bytes-format     → humanize.Bytes / humanize.IBytes
H002  manual-comma-format     → humanize.Comma / humanize.Commaf
H003  manual-reltime-format   → humanize.RelTime / humanize.Time
H004  manual-plural           → humanize.Plural / humanize.PluralWord
H005  manual-si-format        → humanize.SI / humanize.SIWithDigits
H006  manual-ftoa             → humanize.Ftoa / humanize.FtoaWithDigits
H007  manual-parse-bytes      → humanize.ParseBytes

Usage as a library:

import (
    humanizelint "github.com/larsartmann/go-humanize-linter"
    "github.com/larsartmann/go-linter-sdk"
)

reg := humanizelint.DefaultRegistry()
report, err := reg.Run(ctx, ".")
fmt.Println(linter.ExitCodeFromReport(report))

Index

Examples

Constants

View Source
const (
	RuleIDH001 = "H001"
	RuleIDH002 = "H002"
	RuleIDH003 = "H003"
	RuleIDH004 = "H004"
	RuleIDH005 = "H005"
	RuleIDH006 = "H006"
	RuleIDH007 = "H007"
	RuleIDH008 = "H008"
	RuleIDH009 = "H009"
)

Rule ID constants — the single source of truth for rule identifiers. All packages (CLI, plugin, detectors) must reference these instead of string literals to prevent drift when rules are added or renamed.

View Source
const RuleIDH0SUP = "H0SUP"

RuleIDH0SUP is the pseudo-rule ID for suppression-verification diagnostics. It is NOT in AllRules() and cannot be enabled/disabled via --enable/--disable. It is produced only by VerifySuppressions and must bypass confidence filtering so that stale or misspelled //nolint directives are always surfaced.

Variables

This section is empty.

Functions

func AllRules

func AllRules() []linter.RuleFunc

AllRules returns every rule in this linter as a slice. Useful for consumers that want to cherry-pick rules into their own registry.

Example

ExampleAllRules lists the stable rule IDs in registration order.

package main

import (
	"fmt"

	humanizelint "github.com/larsartmann/go-humanize-linter"
)

func main() {
	for _, rule := range humanizelint.AllRules() {
		fmt.Println(rule.Meta.ID)
	}

}
Output:
H001
H002
H003
H004
H005
H006
H007
H008
H009

func DefaultRegistry

func DefaultRegistry() *linter.Registry

DefaultRegistry returns a Registry pre-loaded with all humanize-lint rules, all enabled by default.

Example

ExampleDefaultRegistry shows building a registry with every rule enabled by default — the typical starting point for running the full linter.

package main

import (
	"fmt"

	humanizelint "github.com/larsartmann/go-humanize-linter"
)

func main() {
	registry := humanizelint.DefaultRegistry()

	fmt.Println("registered rules:", len(registry.All()))

}
Output:
registered rules: 9

func DetectFuncDecl

func DetectFuncDecl(fset *token.FileSet, file *ast.File, fn *ast.FuncDecl, filePath string) []finding.Finding

DetectFuncDecl runs all enabled rules against a single *ast.FuncDecl and returns any findings. This is the per-function entry point used by the golangci-lint plugin wrapper (plugin/plugin.go) which iterates over pass.Files instead of walking a directory.

Suppression via //nolint:gohumanize (or //nolint:all, or scoped //nolint:gohumanize:H001) is honoured per-rule. A bare //nolint suppresses every rule; //nolint:gohumanize suppresses only this linter.

Example

ExampleDetectFuncDecl runs the detectors against a hand-rolled byte formatter parsed from source and reports how many findings the KMGTPE trick produces.

package main

import (
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"

	humanizelint "github.com/larsartmann/go-humanize-linter"
)

func main() {
	const src = `package main

import "fmt"

func formatBytes(b uint64) string {
	const unit = 1024
	if b < unit {
		return fmt.Sprintf("%d B", b)
	}
	div, exp := uint64(unit), 0
	for n := b / unit; n >= unit; n /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
`

	fset := token.NewFileSet()

	file, err := parser.ParseFile(fset, "demo.go", src, parser.ParseComments)
	if err != nil {
		panic(err)
	}

	findings := 0

	for _, decl := range file.Decls {
		fn, ok := decl.(*ast.FuncDecl)
		if !ok {
			continue
		}

		findings += len(humanizelint.DetectFuncDecl(fset, file, fn, "demo.go"))
	}

	fmt.Println("findings:", findings)

}
Output:
findings: 1

func IsGeneratedFile added in v0.2.0

func IsGeneratedFile(path string, content []byte) bool

IsGeneratedFile reports whether a file looks like auto-generated Go code. The check covers every generator supported by github.com/LarsArtmann/gogenfilter/v3 (sqlc, templ, protobuf, deepcopy-gen, wire, moq, mockgen, mockery, easyjson, counterfeiter, etc.) plus the generic "// Code generated by" fallback per https://go.dev/s/generatedcode.

Two-phase detection is delegated to gogenfilter:

  1. Filename-only phase (zero I/O) — runs whenever path is non-empty.
  2. Content phase — runs only when content is non-empty. Pass nil to skip the content phase (e.g. from the golangci-lint plugin runtime, where the file content is already held by the analyzer and reading again would be wasteful).

Callers that already read the file (the walker) should pass the content; callers that have only the path (the plugin) should pass nil.

Compatibility note: gogenfilter does not enumerate every tooling convention. In particular, the generic `_gen.go` and `.gen.go` suffixes (used by go-enum-output, deep-copy generators, hand-rolled `go generate` outputs, etc.) are NOT detected by gogenfilter's table (it only knows specific patterns like `wire_gen.go`). To preserve the original behaviour of the inline checks this helper replaced, we run a small fallback that catches those three conventions when gogenfilter reports nothing.

func RuleBytes

func RuleBytes() linter.RuleFunc

RuleBytes (H001) detects manual byte-size formatting that should use humanize.Bytes or humanize.IBytes.

Triggers when a function contains any of these signal clusters:

  • "KMGTPE"[exp] index expression (the classic prefix trick)
  • A []string literal with 2+ byte-unit strings (KB, MB, KiB, MiB, etc.)
  • 2+ distinct byte-unit strings in format strings + division by a power of 1024
  • 3+ distinct byte-unit strings (very strong signal even without explicit division)
Example

ExampleRuleBytes inspects a single rule factory's metadata.

package main

import (
	"fmt"

	humanizelint "github.com/larsartmann/go-humanize-linter"
)

func main() {
	rule := humanizelint.RuleBytes()

	fmt.Println(rule.Meta.ID, rule.Meta.Name)

}
Output:
H001 manual-bytes-format

func RuleComma

func RuleComma() linter.RuleFunc

RuleComma (H002) detects manual comma (thousands separator) insertion that should use humanize.Comma or humanize.Commaf.

Triggers via two detection paths:

  1. Strong: modulo-3 or step-by-3 grouping + comma writing (high/full confidence)
  2. Fallback: for-loop + comma writing + digit conversion (medium confidence) — catches cases where the step size is a named constant like digitsPerGroup
Example

ExampleRuleComma shows the H002 manual-comma-format rule.

package main

import (
	"fmt"

	humanizelint "github.com/larsartmann/go-humanize-linter"
)

func main() {
	rule := humanizelint.RuleComma()

	fmt.Println(rule.Meta.ID, rule.Meta.Name)

}
Output:
H002 manual-comma-format

func RuleCommaf added in v0.2.0

func RuleCommaf() linter.RuleFunc

RuleCommaf (H009) detects manual float-with-thousands-separator formatting that should use humanize.Commaf.

Triggers when a function contains BOTH a "%.Nf" fmt.Sprintf call AND manual comma/separator writing. The combination is a near-certain reimplementation of humanize.Commaf.

Example

ExampleRuleCommaf shows the H009 manual-commaf rule.

package main

import (
	"fmt"

	humanizelint "github.com/larsartmann/go-humanize-linter"
)

func main() {
	rule := humanizelint.RuleCommaf()

	fmt.Println(rule.Meta.ID, rule.Meta.Name)

}
Output:
H009 manual-commaf

func RuleFtoa

func RuleFtoa() linter.RuleFunc

RuleFtoa (H006) detects manual float-to-string conversion with trailing-zero stripping that should use humanize.Ftoa.

Triggers when a function contains:

strings.TrimRight(strings.TrimRight(x, "0"), ".")

This nested-TrimRight pattern is the textbook reimplementation of humanize.Ftoa's trailing-zero removal.

Example

ExampleRuleFtoa shows the H006 manual-ftoa rule.

package main

import (
	"fmt"

	humanizelint "github.com/larsartmann/go-humanize-linter"
)

func main() {
	rule := humanizelint.RuleFtoa()

	fmt.Println(rule.Meta.ID, rule.Meta.Name)

}
Output:
H006 manual-ftoa

func RuleOrdinal added in v0.2.0

func RuleOrdinal() linter.RuleFunc

RuleOrdinal (H008) detects hand-rolled English ordinal-suffix formatting (1st, 2nd, 3rd, 4th) that should use humanize.Ordinal.

Triggers when a function contains a switch on n%10 or n%100 whose case branches return at least 3 of the 4 ordinal suffixes ("st", "nd", "rd", "th"). Three distinct suffixes is enough to flag — the fourth is almost always the "default" case.

Example

ExampleRuleOrdinal shows the H008 manual-ordinal rule.

package main

import (
	"fmt"

	humanizelint "github.com/larsartmann/go-humanize-linter"
)

func main() {
	rule := humanizelint.RuleOrdinal()

	fmt.Println(rule.Meta.ID, rule.Meta.Name)

}
Output:
H008 manual-ordinal

func RuleParseBytes

func RuleParseBytes() linter.RuleFunc

RuleParseBytes (H007) detects manual byte-size string parsing ("10MB" → bytes) that should use humanize.ParseBytes.

Triggers when a function contains:

  • 2+ strings.HasSuffix/CutSuffix/TrimSuffix calls checking byte-unit suffixes
  • OR a map[string]int64 literal with 2+ byte-unit keys used as multipliers

Also detects package-level var declarations with byte-unit multiplier maps.

Example

ExampleRuleParseBytes shows the H007 manual-parse-bytes rule.

package main

import (
	"fmt"

	humanizelint "github.com/larsartmann/go-humanize-linter"
)

func main() {
	rule := humanizelint.RuleParseBytes()

	fmt.Println(rule.Meta.ID, rule.Meta.Name)

}
Output:
H007 manual-parse-bytes

func RulePlural

func RulePlural() linter.RuleFunc

RulePlural (H004) detects manual English pluralization that should use github.com/dustin/go-humanize/english.Plural or english.PluralWord.

Triggers when a function:

  • Has parameters named "singular" and "plural" (explicit reimplementation)
  • OR contains an `if x == 1` / `if x != 1` conditional where the branches return different string values (the classic plural switch)
Example

ExampleRulePlural shows the H004 manual-plural rule.

package main

import (
	"fmt"

	humanizelint "github.com/larsartmann/go-humanize-linter"
)

func main() {
	rule := humanizelint.RulePlural()

	fmt.Println(rule.Meta.ID, rule.Meta.Name)

}
Output:
H004 manual-plural

func RuleRelTime

func RuleRelTime() linter.RuleFunc

RuleRelTime (H003) detects manual relative-time formatting ("3 hours ago", "in 2 days") that should use humanize.RelTime or humanize.Time.

Triggers when a function contains BOTH:

  • A time-difference computation (time.Since or .Sub call)
  • A string literal containing "ago", "from now", or "just now"

AND at least one time-threshold comparison (time.Minute, time.Hour, etc.) to suppress false positives where "ago" appears in unrelated contexts.

Example

ExampleRuleRelTime shows the H003 manual-reltime-format rule.

package main

import (
	"fmt"

	humanizelint "github.com/larsartmann/go-humanize-linter"
)

func main() {
	rule := humanizelint.RuleRelTime()

	fmt.Println(rule.Meta.ID, rule.Meta.Name)

}
Output:
H003 manual-reltime-format

func RuleSI

func RuleSI() linter.RuleFunc

RuleSI (H005) detects manual SI-prefix formatting ("1.2K", "3.4M") that should use humanize.SI or humanize.SIWithDigits.

Triggers when a function contains BOTH:

  • Division by 1000 or 1000000 (or 1000000000)
  • A standalone "K" or "M" string literal used as a suffix

This is distinct from H001 (byte formatting) because SI uses 1000-base and does not append "B".

Example

ExampleRuleSI shows the H005 manual-si-format rule.

package main

import (
	"fmt"

	humanizelint "github.com/larsartmann/go-humanize-linter"
)

func main() {
	rule := humanizelint.RuleSI()

	fmt.Println(rule.Meta.ID, rule.Meta.Name)

}
Output:
H005 manual-si-format

func VerifySuppressionComment added in v0.2.0

func VerifySuppressionComment(comment string) (bool, bool)

VerifySuppressionComment is a convenience helper for tests: it parses a single comment string and returns whether it suppresses our linter and whether it contains a misspelled linter name.

func VerifySuppressions added in v0.2.0

func VerifySuppressions(dir string, report *finding.Report) ([]finding.Finding, error)

VerifySuppressions reports two classes of suppression problems:

  1. Unknown linter names that look like "gohumanize" (e.g. "go-humanize-linter").
  2. //nolint:gohumanize[:Hxxx] directives that did not suppress any finding.

The returned findings use the pseudo-rule ID "H0SUP" so they are clearly verification diagnostics, not humanize reimplementation findings.

func VerifySuppressionsInFiles added in v0.2.0

func VerifySuppressionsInFiles(
	fset *token.FileSet,
	files []*ast.File,
	report *finding.Report,
) []finding.Finding

VerifySuppressionsInFiles is the plugin-path variant of VerifySuppressions. It collects directives from pre-parsed files (pass.Files) instead of walking a directory, making it suitable for use in analysis.Analyzer.Run where the files are already parsed by the driver.

Types

type HumanizeDetector added in v0.2.0

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

HumanizeDetector is the facade that owns the per-function detection pipeline: it iterates over every detector, applies per-rule suppression, and returns the aggregated findings. Use it instead of DetectFuncDecl when you need to configure the detector set, plug a custom suppression resolver, or share state (e.g. metrics) across calls.

Construction:

detector := humanizelint.NewHumanizeDetector()    // all 9 rules enabled
detector := humanizelint.NewHumanizeDetector(     // opt-in subset
    humanizelint.RuleBytes(),
    humanizelint.RuleComma(),
)

Then run it on a single function:

findings := detector.Run(fset, file, fn, "demo.go")

func NewHumanizeDetector added in v0.2.0

func NewHumanizeDetector(rules ...linter.RuleFunc) *HumanizeDetector

NewHumanizeDetector constructs a HumanizeDetector running the given rules in the given order. If no rules are passed, all 9 default rules are registered.

func (*HumanizeDetector) Run added in v0.2.0

func (d *HumanizeDetector) Run(
	fset *token.FileSet,
	file *ast.File,
	fn *ast.FuncDecl,
	filePath string,
) []finding.Finding

Run applies every registered detector to a single function, honouring per-rule //nolint:gohumanize[:Hxxx] suppression directives. Returns the aggregated findings (nil if none).

type ParsedFile

type ParsedFile struct {
	Path string
	Fset *token.FileSet
	File *ast.File
}

ParsedFile holds a parsed Go source file together with its token file set.

func WalkGoDir

func WalkGoDir(dir string) ([]ParsedFile, error)

WalkGoDir walks dir recursively, parses every non-test .go file, and returns them. Files that fail to parse are silently skipped — syntax errors are the compiler's job, not the linter's. On walk failure it returns a *WalkError wrapping the underlying fs error. Callers should errors.AsType[*WalkError](err) to read .Dir.

Each file is read once and the content is reused for both the generated-file check (via gogenfilter) and the AST parser. This keeps the walker I/O-free beyond a single read per file and gives full two-phase generated detection.

Always returns *WalkError (an error type) — signature stays `error` for v0.1.x API compatibility.

type SuppressionDirective added in v0.2.0

type SuppressionDirective struct {
	FilePath     string
	FunctionLine int
	Line         int
	Column       int
	RawText      string
	Suppressed   []string
}

SuppressionDirective records a //nolint-style directive that is attached to a function declaration. It is used by --verify-suppressions to detect stale or misspelled suppressions.

type WalkError added in v0.2.0

type WalkError struct {
	Dir string
	Err error
}

WalkError is returned by WalkGoDir and checkFuncDecls when the directory walk itself fails. It carries the directory the walker was asked to scan so callers can produce actionable error messages without parsing the error string.

func (*WalkError) Error added in v0.2.0

func (e *WalkError) Error() string

Error implements the error interface.

func (*WalkError) Unwrap added in v0.2.0

func (e *WalkError) Unwrap() error

Unwrap returns the underlying error so errors.Is / errors.AsType work through the chain to the original fs.PathError, syscall.Errno, etc.

Directories

Path Synopsis
cmd
go-humanize-linter command
Command go-humanize-linter scans Go source files for hand-rolled reimplementations of github.com/dustin/go-humanize and reports them as findings.
Command go-humanize-linter scans Go source files for hand-rolled reimplementations of github.com/dustin/go-humanize and reports them as findings.
gohumanize command
Command gohumanize runs the go-humanize-linter as a standalone analysis.Analyzer via singlechecker.Main.
Command gohumanize runs the go-humanize-linter as a standalone analysis.Analyzer via singlechecker.Main.
Package plugin exposes go-humanize-linter as a golangci-lint v2 custom linter module plugin.
Package plugin exposes go-humanize-linter as a golangci-lint v2 custom linter module plugin.

Jump to

Keyboard shortcuts

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