Goulinette is a rule-based static analyzer for Go projects, built to enforce a strict, chaptered requirements spec. It scans a target repository, runs selected rules, emits diagnostics in text or JSON, and returns CI-friendly exit codes.
- Enforces 73 rules across formatting, naming, structure, context, concurrency, testing, and more.
- Supports per-chapter and per-rule selection/disable.
- Produces deterministic diagnostics sorted by file/line/column/rule.
- Integrates external tools where required (for example
go vet, optionallystaticcheck). - Uses process-local caching to avoid repeated AST/type-load work across rules in one run.
go build -o goulinette ./cmd/goulinette./goulinette --root ../goulinette --root . --format json./goulinette --root . --chapter 14./goulinette --root . --level 0 # bugs-only baseline
./goulinette --root . --level 1 # idiomatic default
./goulinette --root . --level 2 # opinionated
./goulinette --root . --level 3 # maximum strictness./goulinette --root . --rule CTX-01,CTX-04./goulinette --root . --disable TST-03./goulinette --version
# goulinette dev./goulinette --explain CTX-01
# CTX-01
#
# context.Context must be the first parameter of any function that accepts
# one. This is a hard Go convention. Fix: move ctx to position 0.Print rationale for every rule at once:
./goulinette --explain allAdd a //goulinette:ignore directive on the same line as, or on the
line immediately preceding, the offending code:
// Suppress one rule for this line
x := doThing() //goulinette:ignore ERR-01
// Suppress multiple rules
//goulinette:ignore MAG-02 NAM-07
const appName = "myapp"
// Suppress all rules for the next line
//goulinette:ignore
blockingLegacyCall()Rule IDs in the directive are case-insensitive.
./goulinetteThis is equivalent to:
./goulinette --root . --format text --level 1Behavior:
- Scans the current directory recursively for Go files.
- Runs the default rule set for level
1. - Prints human-readable colored text diagnostics.
- Returns exit code
0,1, or2(see Exit codes).
--root .: scan current directory.--root /path/to/repo: scan that repository/path instead.- Discovery skips common non-source folders such as
.git,vendor, andnode_modules.
Example:
./goulinette --root /home/user/myservice-
--format text|jsontext: colored human-readable output.json: machine-readable output for CI/reporting.
-
--level 0..3- Selects the default strictness tier.
- Default is
1.
-
--chapter 1,2,14- Filters active rules to the listed chapters.
-
--rule CTX-01,CTX-04- Explicit rule include list.
- Takes precedence over level-based selection.
-
--disable RULE1,RULE2- Removes specific rules from the active selection after includes/filters.
-
--warnings-as-errors- Treats warnings as failing diagnostics (affects exit behavior).
-
--strict-tools- Fails hard when required external tools are missing instead of soft-degrading.
-
--version- Prints the goulinette version string and exits.
- The default value is
dev; release builds set this via-ldflags.
-
--explain RULE-ID|all- Prints the rationale for a single rule (e.g.
--explain CTX-01) and exits. - Use
--explain allto print rationale for every rule in order.
- Prints the rationale for a single rule (e.g.
-
--max-workers N- Number of goroutines used to execute rules concurrently (default 4).
- Rules are dispatched over a buffered work channel; the pool is bounded by
N.
-
--timeout 2m- Timeout budget for external tool invocations.
Selection is applied in this order:
- Start from
--leveldefaults. - If
--ruleis provided, use that explicit include set instead. - Apply
--chapterfilter. - Apply
--disableexclusions.
--root string root directory to scan (default ".")
--format string output format: text|json (default "text")
--level int strictness level: 0..3 (default 1)
--chapter string comma-separated chapter numbers
--rule string comma-separated rule IDs
--disable string comma-separated rule IDs to disable
--warnings-as-errors treat warnings as errors
--strict-tools fail when required external tools are missing
--max-workers int max concurrent rule workers (default 4)
--timeout duration command timeout (default 2m)
--version print version and exit
--explain string print rule rationale and exit (rule ID or "all")
Notes:
--leveldefines the default enabled rule set (cumulative from level 0 to chosen level).--ruleoverrides level-based selection (explicit include list).--chapterand--disableare applied on top of the active include set.--max-workerscontrols the size of the concurrent worker pool used during rule execution.--timeoutapplies to tool invocations run through the internal tools wrapper.--versionand--explainare query flags; they print and exit before any analysis runs.
0(non-negotiable): compiler-invisible/runtime-risk checks (for example context misuse, copy-sensitive sync patterns, channel close ownership, comma-ok pitfalls).1(strict idiomatic): strong Go conventions and API hygiene checks; this is the default.2(opinionated): team-style preferences and stricter readability conventions.3(maximum strictness): pedagogical/library-grade structural constraints.
0: no errors (and no warnings if--warnings-as-errorsis used)1: at least one error diagnostic, or warning promoted to error2: runtime/tooling failure (for example parse/type-load/tool execution failure)
path/to/file.go:12:5: error [CTX-03] nil must not be passed as context.Context
runtime: FMT-02: tool "go" not found in PATH
Text output is ANSI-colorized for readability:
- Severity:
errorin bold red,warningin yellow. - Broken rule ID: highlighted with an actual color (not just bold).
- File location: rendered in dim gray to reduce visual noise.
Fixed chapter color mapping (not runtime-random):
- Rule IDs are color-grouped by chapter prefix and always use the same preselected color.
- Colors are enforced in code and do not change between runs.
Chapter palette (ANSI 256 colors):
- Chapter 1 (
FMT-*) →39 - Chapter 2 (
NAM-*) →208 - Chapter 3 (
VAR-*) →45 - Chapter 4 (
CTL-*) →171 - Chapter 5 (
FUN-*) →220 - Chapter 6 (
ERR-*) →196 - Chapter 7 (
TYP-*) →51 - Chapter 8 (
STR-*) →99 - Chapter 9 (
DOC-*) →34 - Chapter 10 (
SLC-*) →214 - Chapter 11 (
CON-*) →129 - Chapter 12 (
CER-*) →44 - Chapter 13 (
LIM-*) →177 - Chapter 14 (
CTX-*) →81 - Chapter 15 (
IMP-*) →203 - Chapter 16 (
RES-*) →141 - Chapter 17 (
SAF-*) →40 - Chapter 18 (
MAG-*) →201 - Chapter 19 (
TST-*) →93
{
"diagnostics": [
{
"ruleId": "CTX-03",
"severity": "error",
"message": "nil must not be passed as context.Context",
"position": {"file": "x.go", "line": 12, "col": 5},
"hint": "use context.Background() or context.TODO()"
}
],
"runtimeErrors": []
}Any diagnostic can be silenced without changing the analysis by placing a
//goulinette:ignore comment directive in the source file.
//goulinette:ignore [RULE-ID ...]
- No rule IDs — suppresses all findings on the associated line.
- One or more rule IDs (space-separated) — suppresses only those rules.
- Rule IDs are matched case-insensitively.
The directive may appear either:
- On the same line as the flagged code (trailing comment), or
- On the immediately preceding line (standalone comment).
// Same-line suppression
result := riskyOp() //goulinette:ignore ERR-01
// Preceding-line suppression
//goulinette:ignore MAG-02
const retries = 3
// Suppress multiple rules at once
//goulinette:ignore CON-02 CON-03
go func() { /* ... */ }()
// Suppress everything on the next line
//goulinette:ignore
legacyHack()Use inline suppression sparingly for narrow cases where the rule does not apply in context (e.g. a deliberate constant that is intentionally not named, a generated file pattern, or a vendored snippet). Prefer fixing the root cause where possible.
All requirement rules in goulinette_requirements.md are implemented and registered.
FMT-01, FMT-02, FMT-03
NAM-01, NAM-02, NAM-03, NAM-04, NAM-05, NAM-06, NAM-07
VAR-01, VAR-02, VAR-03, VAR-04
CTL-01, CTL-02, CTL-03, CTL-04
FUN-01, FUN-02, FUN-03, FUN-04
ERR-01, ERR-02, ERR-03, ERR-04, ERR-05, ERR-06, ERR-07, ERR-08
TYP-01, TYP-02, TYP-03, TYP-04, TYP-05, TYP-06, TYP-07
STR-01, STR-02, STR-03, STR-04
DOC-01, DOC-02, DOC-03, DOC-04, DOC-05
SLC-01
CON-01, CON-02, CON-03
CER-01, CER-02, CER-03
LIM-01, LIM-02, LIM-03, LIM-04
CTX-01, CTX-02, CTX-03, CTX-04
IMP-01, IMP-02, IMP-03
RES-01, RES-02
SAF-01, SAF-02
MAG-01, MAG-02
TST-01, TST-02, TST-03
- Parse CLI flags into
internal/config.Config - Handle query flags:
--versionprints and exits;--explainprints rule rationale and exits - Discover Go files under
--root(excluding.git,vendor,node_modules) - Build rule context (
root, files, tool strictness) - Select rules via chapter/include/disable filters
- Dispatch selected rules to a bounded
--max-workersgoroutine pool via a buffered work channel - Aggregate diagnostics + runtime errors from the results channel
- Apply inline suppression: remove any finding covered by a
//goulinette:ignoredirective - Sort diagnostics deterministically (file → line → column → rule ID)
- Print report (
textorjson) and return exit code
cmd/goulinette: CLI entry point; handles--version/--explainbefore delegating toapp.Runnerinternal/app: orchestration and run loop; owns the worker-pool dispatch and suppress-filter stepinternal/config: flag parsing; definesSettings(includesExplainRule,PrintVersion,MaxWorkers)internal/discovery: repository file discoveryinternal/rules: rule implementations, helpers, registry, andexplain.go(rationale map for all 62 rules)internal/suppress:Filter()— removes diagnostics covered by//goulinette:ignoredirectivesinternal/version: build-time version string (Current); injectable via-ldflagsat release timeinternal/tools: external command execution + diagnostics parsinginternal/diag: shared diagnostic/result modelinternal/report: text/json rendering
Goulinette runs rules concurrently through a bounded worker pool and avoids repeated expensive work via per-run caches:
- Worker pool:
--max-workersgoroutines (default 4) pull from a buffered work channel; results are collected over a pre-sized results channel. The pool respectscontext.Contextcancellation. - AST cache: repeated
parseFiles(...)calls over the same file set reuse parsed structures. - Typed package cache: repeated
loadTypedPackages(...)calls per root reuse type-loaded package graphs. - Per-run reset: caches are reset at app run start, preventing stale data across independent runs.
This means parse/type-load work is shared across all concurrent rules, while --max-workers lets you trade off CPU saturation against wall-clock time.
Benchmarks for both cache paths live in internal/rules/cache_bench_test.go:
go test ./internal/rules -bench BenchmarkParseFiles -benchmem
go test ./internal/rules -bench BenchmarkCacheKey -benchmemTypical results on a modern laptop:
| Benchmark | ns/op |
|---|---|
BenchmarkParseFilesCacheMiss |
~420 000 |
BenchmarkParseFilesCacheHit |
~5 000 |
BenchmarkCacheKeyGeneration |
< 1 000 |
Some rules call external tools through internal/tools.
- If a tool is missing or command fails, diagnostics/runtime behavior depends on rule implementation and
--strict-tools. - Commands are timeout-bound by config (
--timeout).
Common tools involved:
go(forgo vet,gofmtchecks)staticcheck(warning-level enrichment when available)
go test ./...go test -race -count=1 ./...go test ./internal/rulesgo test ./internal/rules -bench BenchmarkParseFiles -benchmem
go test ./internal/rules -bench BenchmarkCacheKey -benchmemgo build -o goulinette ./cmd/goulinettego build -ldflags "-X github.com/YeiyoNathnael/goulinette/internal/version.Current=1.2.3" \
-o goulinette ./cmd/goulinette
./goulinette --version
# goulinette 1.2.3./goulinette --level=3 --format json | python3 -c \
"import sys,json; d=json.load(sys.stdin); assert len(d['diagnostics'])==0, d['diagnostics']"GitHub Actions runs on every push to main / fix/** / feat/** and on all pull requests:
| Job | Command |
|---|---|
| Build | go build ./... |
| Vet | go vet ./... |
| Test (race) | go test -race -count=1 ./... |
| Self-lint | build binary → ./goulinette --level=3 --format json → assert 0 findings |
See .github/workflows/ci.yml for the full workflow definition.
git checkout -b feat/my-change
# edit
go test -race ./...
./goulinette --level=3 --format json # must report 0 findings
git add .
git commit -m "feat: my change"
git push -u origin feat/my-change- Rules are intentionally independent files for maintainability.
- Some checks use conservative heuristics to avoid over-claiming certainty in static analysis.
- Typed analysis requires successful package loading; unresolved build contexts can degrade specific typed rules.
TST-03intentionally flags directtime.Sleepcalls in_test.gofiles only.
Install required tool (go, optionally staticcheck) and retry.
Ensure target project module/build context is valid (go mod tidy, buildable package graph).
- Use
--format jsonto inspect diagnostics and runtime errors clearly. - Check whether
--warnings-as-errorsis enabled.
Rule intent and chapter definitions are specified in:
goulinette_requirements.md
If a behavior question arises, this document takes precedence over inferred style.