Every repository grows its own lint script: a loop that checks tools are installed, linters invoked one by one, advisory steps that never fail, and configs that drift apart from every other repository's.
I want one command that runs the same strict policy on every Go and TypeScript repository, fails on every finding, and needs no configuration to start.
Linting should:
- Be one command
- Need no configuration
- Gate on every finding
- Never modify files unless asked
- Never install anything
- Pin every tool it runs
Libcheck is a thin wrapper around proven engines. The policy lives in libcheck, so a repository keeps no engine configuration of its own.
- Go: gofmt, golangci-lint, go mod tidy, deadcode, and govulncheck.
- TypeScript: oxlint with type-aware rules, the project's own tsc or tsgo with stricter options, Prettier, and pnpm audit.
go install github.com/nathants/libcheck@latest
export PATH=$PATH:$(go env GOPATH)/binLibcheck needs git and Go 1.27 or later. The TypeScript engines need Node,
and libcheck security audits pnpm lockfiles with pnpm.
Libcheck runs globally installed tools at pinned versions and never installs
them. libcheck tools verifies each one and prints the command that installs
any tool that is missing or at the wrong version. TypeScript projects provide
their own tsc or tsgo through their lockfile, since the compiler version
must match the project's code. go and pnpm run as installed: every go command
runs with GOTOOLCHAIN=local and GOWORK=off, and pnpm with
pnpm_config_pm_on_fail=ignore, so neither downloads the version a project
pins. Go steps fetch missing module dependencies into the module cache, as any
go build does.
libcheck check # verify tools, then lint without modifying files (the default)
libcheck fix # apply gofmt, go mod tidy, and prettier fixes
libcheck security # govulncheck and pnpm audit (needs network)
libcheck tools # verify every installed tool version
libcheck -C DIR # check the repository at DIR
libcheck -only errcheck,gofmt # run only these steps or golangci-lint lintersA repository's check fails fast when libcheck is missing:
command -v libcheck >/dev/null || { echo 'libcheck not found; install it with: go install github.com/nathants/libcheck@latest' >&2; exit 1; }
libcheck checkLibcheck discovers every Go module (go.mod) and TypeScript project
(tsconfig.json) among the files git lists, so ignored build output is never
checked. A nested module or project owns its own files.
Each Go module runs gofmt -l, golangci-lint, go mod tidy -diff, and
deadcode when it has main packages. golangci-lint also checks each standalone
program, a file constrained by //go:build ignore and run with go run FILE,
by name, since package patterns skip it.
golangci-lint and deadcode analyze the host's build. A module with
"goos": ["linux", "windows"] is analyzed once per listed GOOS, and each
build must pass on its own, so a helper in a shared file that only Windows code
calls is unused in the Linux build: move it into a Windows file. A GOOS other
than the host's builds without cgo.
golangci-lint runs asasalint, bidichk, bodyclose, durationcheck, errcheck,
errorlint, exhaustive, gocheckcompilerdirectives, govet (defaults plus nilness
and unusedwrite), ineffassign, modernize, nilnesserr, nolintlint, reassign,
recvcheck, revive unused-parameter, staticcheck, unconvert, unparam, unused,
usetesting, and wastedassign. usetesting reports os.MkdirTemp only when its
parent directory is "", as it does os.CreateTemp; an explicit parent counts
only on the call's first line. errorlint accepts an fmt.Errorf whose format
string wraps at least one error with %w, so other errors can be detail
formatted with %v. unparam skips parameters that always receive the same
argument.
errorlint accepts err == io.EOF only when every assignment to err comes
from a call documented to return io.EOF unwrapped, such as
io.Reader.Read. When it flags one because the variable also holds other
errors, give the Read error its own variable rather than switching to
errors.Is, which also accepts a wrapped EOF.
exhaustive requires every switch over an enum to have a default case and to
list every member, including for enums from other modules, so the switch shows
every value. List the members the default handles as a case that falls
through to it. With "exhaustive": "default-signifies-exhaustive", a switch
with a default case passes, and one without must list every member.
go mod tidy -diff fails on a requirement kept only to raise a module that no
package in the build imports, such as a floor above a vulnerable version that
appears only in the module graph. Instead, exclude the older dependency
version whose requirements bring the vulnerable one in; tidy keeps the
exclude.
modernize's omitzero finding flags an omitempty that never omits anything,
such as on a time.Time field. Switching to omitzero changes the encoded
JSON, so remove the no-op omitempty instead unless omitting zero values is
intended.
errcheck has no autofix. Rewriting defer f.Close() as
defer func() { _ = f.Close() }() moves the evaluation of f from the defer
statement to function exit, which changes behavior if f is reassigned
afterward or the defer sits in a loop. Pass the value in instead:
defer func(f *os.File) { _ = f.Close() }(f).
Each TypeScript project runs:
- oxlint with type-aware rules: the correctness category, whose no-unused-vars accepts properties destructured only to leave them out of a rest element and whose no-floating-promises accepts node:test's test and suite calls, plus rules-of-hooks, ban-ts-comment, no-deprecated, no-misused-promises, only-throw-error, and switch-exhaustiveness-check.
- The project's own
tscortsgowith stricter options added:noFallthroughCasesInSwitch,noImplicitOverride,noImplicitReturns,noUncheckedIndexedAccess, andnoUnusedParameters. prettier --checkover the project's files with libcheck's Prettier settings. Project Prettier configs and.editorconfigare ignored, and package manager lockfiles are skipped.
Every JavaScript or TypeScript file must belong to a project, a
tsconfig.json in its directory or above it, and be compiled by that
tsconfig.json, since type-aware rules find a file's types through the nearest
tsconfig.json. A file outside every project, or outside its project's
program, fails the check until a tsconfig includes it or libcheck.json
excludes it.
oxlint reads tsconfig.json as written, while libcheck adds --noEmit only
to tsc. A project whose tsconfig sets allowJs without noEmit or outDir
fails oxlint with a tsconfig-error, because each JavaScript input would also be
its own output. Set noEmit when a bundler emits the code.
libcheck security queries vulnerability databases over the network, so
libcheck check leaves it out. It runs:
- govulncheck on each Go module, once per listed GOOS, which reports the vulnerabilities the code can reach.
pnpm auditin the directory of eachpnpm-lock.yaml, which reports every advisory for a locked package, devDependencies included. The projects of a workspace share its lockfile, so each lockfile is audited once.
Optional. It narrows discovery and records exceptions:
{
"exclude": ["scratch", "third_party"],
"go": {
".": {
"tags": ["integration"],
"goos": ["linux", "windows"],
"disable": ["bodyclose"],
"exhaustive": "default-signifies-exhaustive",
"modernizeDisable": ["any", "stringsseq"],
"deadcodeIgnore": ["example.com/app/internal/term.clearScreen"]
}
}
}includeorexclude(not both): repository-relative directories, each covering everything below it. The compiler still type-checks a left-out source file that a checked file imports, such as vendored code, but its diagnostics are dropped.gois keyed by module directory; a key that names no checked module is an error.gooslists the operating systems whose builds golangci-lint, deadcode, and govulncheck analyze.disabletakes golangci-lint linter names ordeadcode.deadcodeIgnorenames functions whose callers deadcode cannot see, such as cgo exports. An entry that matches nothing in any GOOS fails the check.
Suppress a single golangci-lint finding with //nolint:LINTER // reason.
nolintlint fails a directive that names no linter, gives no reason, or
suppresses nothing. staticcheck's //lint:ignore is not honored.
Suppress an oxlint finding with
// oxlint-disable-next-line PLUGIN/RULE -- reason. oxlint fails an
oxlint-disable or eslint-disable directive that suppresses nothing, and
the suppressions step fails one that names no rule or gives no reason after
--. That step scans text, not syntax, so a directive inside a string literal
counts too. ban-ts-comment bans @ts-ignore and @ts-nocheck and requires a
description after @ts-expect-error.
The suppressions step also fails any directive that names exhaustive-deps or
rules-of-hooks. Such a directive turns off oxlint's React Compiler rules, such
as immutability, refs, and set-state-in-effect, for the whole component, as
the React Compiler skips such components. Fix the finding instead;
useEffectEvent lets an effect read current values without listing them as
dependencies.