Website: richardwooding.github.io/codemetrics
Per-function cyclomatic and cognitive complexity across 17 languages — a CLI and GitHub Action that gate pull requests on complexity, plus a Go library.
It computes both metrics in one pass, where most tools give you just one
(for Go, fzipp/gocyclo does cyclomatic and
uudashr/gocognit does cognitive). Go is analyzed with the standard
library's go/ast and 16 more languages via
tree-sitter — both backends return the same
FunctionMetrics.
Works with:
- Cyclomatic complexity (McCabe) counts decision points flatly:
1 + one per branch(if / for / range / case / comm-clause /&&/||). It measures the number of independent paths through a function. - Cognitive complexity (SonarSource) weights nested control flow more
heavily, so deeply-nested logic scores higher than a flat sequence with the
same number of branches. It tracks how hard code is to understand rather
than how many paths it has. The implementation follows the SonarSource
specification; results match
uudashr/gocognit.
CLI — Homebrew (macOS):
brew install --cask richardwooding/tap/codemetricsThe cask installs a pre-built binary. Since it isn't notarized, the cask strips the macOS quarantine attribute on install so it runs without a Gatekeeper prompt.
CLI — go install (any platform), or download a binary from the
releases page:
go install github.com/richardwooding/codemetrics/cmd/codemetrics@latestLibrary:
go get github.com/richardwooding/codemetricsThis repo ships a composite Action that runs the --diff gate on pull requests:
it installs the released codemetrics binary and fails the check when a function
the PR touches exceeds your threshold. No Docker image — it just downloads
the binary for the runner.
name: complexity
on: pull_request
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required: full history for the diff's merge-base
- uses: richardwooding/codemetrics@v0.8.0
with:
max-cognitive: "15"Optionally upload SARIF so findings show up in the PR's Files-changed view:
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: richardwooding/codemetrics@v0.8.0
with:
max-cognitive: "15"
sarif-file: codemetrics.sarif
- if: always() # upload even when the gate fails
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: codemetrics.sarif
permissions:
contents: read
security-events: write # needed for the SARIF uploadInputs: paths (default .), max-cognitive (default 15),
max-cyclomatic (default 0), base-ref (default the PR base branch),
baseline, sarif-file, fail-on-findings (default true),
codemetrics-version (default latest). Runs on Linux and macOS runners.
The CLI is built on Kong and is polyglot: files are routed to a
language backend by extension via projectdetect — Go through
go/ast, the other 16 languages through the tree-sitter backend.
# Top 10 functions by cognitive complexity across a tree
codemetrics --top 10 ./... # (pass directories or files)
codemetrics --top 10 internal/
# Sort by cyclomatic instead, only show the gnarly ones
codemetrics --sort cyclomatic --min 15 .
# JSON for tooling / CI
codemetrics --format json ./mypkg | jq '.[] | select(.cognitive > 20)'
# Read from stdin (Go by default; --lang for anything else)
cat foo.go | codemetrics
cat foo.py | codemetrics --lang pythonCOGNITIVE CYCLOMATIC LINES FUNCTION LOCATION
83 35 148 BuildCodeGraph codegraph.go:172
74 39 175 UnusedExports unused_exports.go:179
...
Directories are walked recursively, skipping dot-directories, testdata, and
each detected project's build-artefact dirs (vendor, node_modules,
target, __pycache__, …, resolved by projectdetect). Files whose extension
maps to no supported language are ignored. Vendored and minified files
(bundled libraries, *.min.js, and content that looks minified — via
projectdetect) are skipped too, so a stray jquery.min.js
under docs/ doesn't pollute the report; pass --include-vendored to analyze
them anyway. (Explicitly-named files and stdin are always analyzed.)
Flags: --sort cognitive|cyclomatic, --top N, --min N,
--format table|json|sarif, --lang <id>, plus the quality-gate flags below.
The table is colorized when writing to a terminal and plain when redirected to a
file or pipe. Color honors NO_COLOR; force it either
way with --color always|never (default auto).
Flag style changed. The CLI now uses GNU-style double-dash flags (
--sort,--top,--min) and--jsonis replaced by--format json.
Set a threshold and any function above it becomes a finding. Findings render
as [SARIF 2.1.0][sarif] (via go-sarif) for GitHub Code Scanning,
and the process exits non-zero when a finding remains — so it gates CI.
# Fail the build if any function is too complex; emit SARIF for Code Scanning
codemetrics --format sarif --max-cognitive 15 --max-cyclomatic 20 . > results.sarifTo adopt the gate on an existing codebase without fixing all debt first, record a baseline. Baselined findings (matched by rule + file + function, ignoring line numbers) are suppressed; the gate then fails only on new findings — the same pattern as detekt / semgrep.
# 1. Record today's findings as the accepted baseline
codemetrics --max-cognitive 15 --write-baseline .codemetrics-baseline.json .
# 2. In CI: pass unless a NEW violation is introduced
codemetrics --max-cognitive 15 --baseline .codemetrics-baseline.json .A GitHub Actions step that uploads the SARIF:
- run: codemetrics --format sarif --max-cognitive 15 . > results.sarif
continue-on-error: true # let the upload run even when the gate fails
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarifQuality-gate flags: --max-cognitive N, --max-cyclomatic N (0 = disabled),
--baseline FILE, --write-baseline FILE.
Opt an intentionally-complex function out of the gate with a comment directive —
codemetrics honours the ones you may already use with gocyclo / gocognit /
golangci-lint, plus its own cross-language codemetrics:ignore. Put the
directive on the line directly above the function (or trailing its declaration):
//gocyclo:ignore
func bigStateMachine() { … } // won't fail the gate
//nolint:gocognit
func alsoFine() { … }# codemetrics:ignore
def parser(): ... # any language, native comment syntax| Directive | Suppresses | Languages |
|---|---|---|
codemetrics:ignore |
both metrics | all |
codemetrics:ignore cognitive / cyclomatic |
the named metric(s) | all |
gocyclo:ignore |
both metrics | Go |
nolint / nolint:all |
both metrics | Go |
nolint:gocyclo / nolint:gocognit |
cyclomatic / cognitive | Go |
An ignored function still appears in the table/JSON with its metrics (marked
(ignored: …)), but produces no finding — so it never fails the gate, lands in
SARIF, or enters a baseline. The metric libraries are unchanged and still
match gocyclo / gocognit; the directive is applied by
the CLI, exactly as those tools do.
--diff <ref> restricts the whole run to functions touched by a git diff, so
a pull request is judged only on the code it actually changes — not the
pre-existing tree. It parses git diff <ref> and keeps a function only when its
line span overlaps a changed (new-side) line, then applies your thresholds to
that subset.
# Fail the PR only if a function it touches is too complex
codemetrics --diff origin/main...HEAD --max-cognitive 15 .<ref> is passed straight to git diff, so use whatever expresses "this PR":
origin/main...HEAD (changes since the merge base — the usual PR diff),
origin/main, or HEAD~1. Added files count in full; modified files count only
their changed regions. Combine with --format sarif to upload PR-scoped
findings, or with --baseline to also suppress known offenders among them.
On GitHub, the Action above wires this up for you; to run the CLI directly in a workflow instead:
- run: git fetch origin ${{ github.base_ref }}
- run: codemetrics --diff origin/${{ github.base_ref }}...HEAD --max-cognitive 15 .The CLI embeds the tree-sitter grammars (~22 MB binary). The root library package is standard-library-only, so importing it compiles no third-party code;
gotreesitter+ grammars come in only with thetreesittersubpackage, and the CLI's deps (Kong, go-sarif, projectdetect) only with thecodemetricscommand.
package main
import (
"fmt"
codemetrics "github.com/richardwooding/codemetrics"
)
func main() {
src := []byte(`package p
func classify(n int) string {
if n < 0 {
return "neg"
} else if n == 0 {
return "zero"
}
return "pos"
}`)
fns, err := codemetrics.ParseGo(src)
if err != nil {
panic(err)
}
for _, f := range fns {
fmt.Printf("%-12s cyclomatic=%d cognitive=%d lines=%d\n",
f.QualifiedName(), f.Cyclomatic, *f.Cognitive, f.Lines())
}
// classify cyclomatic=3 cognitive=2 lines=8
}Parse(language, src) dispatches by language identifier ("go" / "golang"
today) and returns a wrapped ErrUnsupportedLanguage for anything else.
SupportedLanguages() lists what's available.
type FunctionMetrics struct {
Name string // bare name, e.g. "Write"
Receiver string // receiver type for methods, e.g. "*Buffer"; "" otherwise
Cyclomatic int
Cognitive *int // nil if a language reports no cognitive score; set for all supported today
StartLine int // 1-based, inclusive
EndLine int
}Cognitive is a pointer so a language that reports no cognitive score is
distinguishable (nil) from a genuine zero — though every language supported
today populates it.
If you've already parsed Go source with go/parser, compute either metric for
a single declaration without re-parsing:
func Cyclomatic(body *ast.BlockStmt) int // 1 + branch points
func Cognitive(fn *ast.FuncDecl) int // SonarSource cognitive complexityBoth are nil-safe (return 0). This mirrors the AST-level entry points of
gocyclo and gocognit.
Parsing is best-effort: input that still yields a partial syntax tree is tolerated and metrics are computed for every recovered function; only a total parse failure returns an error.
The subpackage github.com/richardwooding/codemetrics/treesitter
computes the same metrics for 16 more languages using the pure-Go
tree-sitter runtime gotreesitter:
Python · JavaScript · TypeScript · Java · Rust · C · C++ · C# · Kotlin · PHP · Ruby · Scala · R · MATLAB · Perl · Swift
import "github.com/richardwooding/codemetrics/treesitter"
fns, err := treesitter.Parse("python", src) // -> []codemetrics.FunctionMetricsIt returns the same FunctionMetrics type, so the two backends are
interchangeable. Both cyclomatic and cognitive complexity are computed for every
one of these languages.
Already parsed the tree? If you've parsed the source with gotreesitter yourself (e.g. while extracting symbols), compute metrics over that existing tree — no second parse:
func MetricsFromTree(language string, tree *ts.Tree, lang *ts.Language, spans []Span) []codemetrics.FunctionMetricsThis is how treesitter-symbols returns symbols and complexity from a
single parse.
Dependencies stay opt-in. The module root is go/ast-only and pulls in
nothing; gotreesitter and its embedded grammars are compiled in only when
you import the treesitter subpackage. A plain build of that subpackage embeds
every bundled grammar (~22 MB); to embed only the languages you use, build with
the gotreesitter subset tags:
go build -tags 'grammar_subset grammar_subset_python grammar_subset_rust' ./...MIT — see LICENSE.