README
¶
slopradar
slopradar shows reviewers how a pull request changes concentrated complexity and exact duplication. It reads Git, writes a Markdown comment, and keeps the two signals separate so a movement in one cannot hide a movement in the other.
What it measures
Cyclomatic complexity (CC) counts decision paths through a function. SLOC is its non-blank, non-comment source lines. Mass is CC × √SLOC; erosion is the share of repository function mass in functions with CC over 10. A clone pair is two ranges with the same tokens after comments are removed, while clone share is the share of source lines in such ranges. Lower erosion and clone share are generally easier to maintain, but duplication is not always wrong. Absolute erosion varies by language, so compare this repository against its own history. This report is information for the reviewer, not a merge gate.
Functions nested inside another function are folded into the outer function's CC, SLOC, and mass. slopradar also keeps rows for nested functions so the report can point inside a monolith, but only top-level rows enter erosion totals. Clone detection compares exact lexical tokens after removing comments, with the 50-token and 5-source-line minimums used for the project's pinned prototype receipts.
Python tokens also carry parsed suite boundaries. Every tree-sitter block
with retained tokens contributes an unambiguous NUL-prefixed open/close pair,
anchored to the first and last retained physical token lines. A block containing
only removed comments or docstrings contributes neither marker. Tabs and spaces
that parse to the same nesting therefore compare equally, inline suites retain
the same block shape, and indentation inside a continued expression contributes
no suite boundary. String-content whitespace remains exact. This analysis does
not invoke a Python runtime.
Install
slopradar supports Linux and macOS on arm64 and amd64. Installation needs Go 1.27 or newer, cgo, and a C compiler:
go install github.com/SolenesInc/slopradar/cmd/slopradar@v0.1.0
The module includes Oxc 0.149.0 native archives for every supported platform,
so installation does not need Rust, Cargo, or Node. Maintainers only need the
pinned Rust 1.96.0 toolchain when rebuilding those archives; the measured
platform compatibility and rebuild procedure live in
internal/oxc/README.md.
Add it to a pull request
Add .github/workflows/slopradar.yml:
name: slopradar
on:
pull_request:
permissions:
contents: read
pull-requests: write
concurrency:
group: slopradar-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
slopradar:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: SolenesInc/slopradar@v0.1.0
with:
trend-months: "0"
comment: "true"
The action builds its binary from the same checkout that supplied its scripts,
so a moved ref or fork cannot mix code from two sources. The standalone
versioned go install command above remains available for CLI installation.
The action always writes the report to the job summary. It creates one PR comment and updates that same comment on later runs. The concurrency group serializes report runs per PR; keep it when copying the workflow. A run for an older PR head skips comment publication. On a fork pull request, or a Dependabot pull request whose token is read-only, it keeps the summary and says in the job log that it skipped the comment. Other API failures stay visible. The numbers never fail the job.
Reports within GitHub's 65,536 UTF-16-code-unit comment capacity appear in full. Above it, the comment keeps the complete headline-and-bucket prefix when it fits, then names the capacity and actual size and links the workflow run. If that prefix is itself too large, the comment falls back to a short linked notice. If a report exceeds GitHub's 1 MiB job-summary capacity, the action uploads the untouched Markdown as an artifact and posts a compact linked summary. It never cuts through a table or code fence; the exact edges are covered by the boundary tests.
The action accepts base (the pull request's base ref by default),
trend-months (0 disables the trend), and comment (true by default).
Read the comment

The headline reports absolute mass added to and removed from functions over CC 10, plus clone pairs introduced and removed. Those are the useful pull-request signals: repository-wide ratios can barely move even when a change adds a large complex function. Expand the details for per-function CC, SLOC, mass changes, clone locations, and the metric explainer. When the trend is enabled, compare erosion and clone share with this repository's own earlier points, not another language or repository.
Commands
slopradar scan [<rev> | <dir>] [--format text|md|json] [--no-cache]
slopradar diff --base <rev> --head <rev> [--trend <months>] [--format text|md|json] [--no-cache]
slopradar trend (--merges <count> | --months <count>) [--format text|md|json] [--no-cache]
scan defaults to HEAD. A directory target reads the working tree; a revision
target reads Git objects without checking them out. diff resolves its base to
the merge base of --base and --head. trend walks first-parent history.
Revision scans report the Git tree ID in rev, so commits with identical trees
produce identical output. Directory scans use directory. Diff base and head
and each trend point's rev retain commit IDs to identify the compared history.
The cache stores analysis by blob SHA, language, and analyzer version under the
operating system's user cache directory. --no-cache bypasses it. Analyses
containing non-UTF-8 token bytes are recomputed so JSON caching cannot alter their clone identities. Deleting the
cache is always safe, and slopradar never writes it into the repository being
analyzed.
Configuration
An optional .slopradar.json at the repository root adds exclusions and test
paths to the built-in classification:
{
"excludes": ["third_party/", "app/src/types/generated.ts"],
"test_globs": ["integration/"]
}
Patterns use Go’s path.Match syntax. A trailing slash matches a directory
and every descendant, including wildcard directories such as
packages/*/generated/; the same rule applies to excludes and test_globs.
Built-in exclusions are vendor/, node_modules/, dist/, target/, Go files
under testdata/, and dot directories. Built-in test paths are Go _test.go files;
JavaScript and TypeScript .test.*, .spec.*, and __tests__/; Python
test_*.py, *_test.py, and tests/; and Rust tests/ plus #[cfg(test)]
items and #[test] functions. Rust also recognizes compound cfg predicates
that prove an item requires a test build, such as all(test, unix) and
not(not(test)). Predicates that can permit a production build, such as
any(test, unix), stay in the source bucket. Other configuration options remain
unknown; their target or feature values are never guessed. These combinations
follow the Rust conditional compilation rules.
A physical line containing both production and test-only code counts once in each bucket. File-wide test classification counts that shared line only once; clone pair lengths count distinct physical lines across both buckets.
Rust test scope follows external mod declarations through name.rs,
name/mod.rs, nested inline modules, and literal #[path] attributes. Each
import retains the directory used to resolve its child modules, including when
the same file is loaded through ordinary and #[path] declarations. A file
also reachable from production keeps its source bucket. Scope is recomputed
for each snapshot, including when an unchanged child comes from the cache;
parent-only scope changes appear in the diff. File-level #![cfg(test)] also
marks the file and its child modules as tests. Module resolution uses only
analyzed files, without running Cargo or expanding macros. Crate roots follow
standard Cargo entry-point layouts relative to the nearest Cargo.toml directory
(or the snapshot root for standalone sources): src/lib.rs, src/main.rs,
build.rs, binaries,
examples, benches, and integration tests. Custom Cargo target paths are not
interpreted. Build-script roots use Cargo.toml path metadata even when source
files are generated or excluded. Nested files named main.rs or lib.rs remain
ordinary modules.
Conditional or unresolvable module paths produce warnings; scans retain conservative source
classification and diff/trend reject incomplete analysis. Explicit test_globs
can classify standalone test files.
Configuration globs use / as the path separator on the supported Unix
platforms. A backslash escapes the next glob character; it is not a separator.
Output keeps ordinary UTF-8 paths unchanged. To represent every Unix filename
without collisions, path backslashes are written as \\, line breaks as \n
or \r, tabs as \t, other control characters as \uHHHH, and invalid UTF-8
bytes as \xHH with uppercase hex digits. Decode those escapes after decoding
JSON itself; a literal sequence such as \xFF is written as \\xFF.
Generated files are skipped when a leading comment contains Go's
Code generated … DO NOT EDIT., @generated, or linguist-generated marker.
Marker text inside a string literal does not exclude a handwritten file.
Python accepts LF, CRLF, and CR physical line endings, including mixed endings.
Parser coordinates and SLOC follow those physical lines; clone tokens retain
the original literal bytes. Git line matching is disabled for files whose
physical lines differ from Git’s LF-based coordinates. Rust attributes attached
to a test-only item inherit its test bucket, including companion attributes.
JavaScript line comments end at LF, CRLF, CR, U+2028, or U+2029, so marker
strings on the following source line stay outside the comment.
Generator output without a marker must be listed in excludes.
Languages and decision points
Every function starts at CC 1. The following syntax adds one decision point.
Go declarations without bodies contribute no function mass: their implementations are outside the analyzed Go source. Empty implemented functions and function literals still count. This follows the project's Go baseline prototype; gocyclo's inclusion of bodyless declarations is deliberately different.
| Language | Decision points |
|---|---|
| Go | if; for and range; each non-default expression-case, type-case, and communication-case clause; each && and || |
| TypeScript, TSX, JavaScript | if; for, for…in, and for…of; while and do…while; catch; each non-default switch case; ternary expressions; each &&, ||, and ?? |
| Python | if and each elif; for; while; except; each boolean operator; ternary expressions; each comprehension for and if clause; each match case |
| Rust | if and if let; while and while let; for; loop; each match arm; each &&, ||, and ? |
Python docstrings count as comments. Rust's ? counts as an explicit
error-flow decision, analogous to the explicit error checks visible in Go.
Rust method names include their owner: Worker::run for an inherent method,
Service::run for a trait default, and <Worker as Service>::run for a trait
implementation. Nested test functions retain their test bucket in drill-down
rows; totals still include only top-level functions.
TypeScript namespaces retain their complete owner paths, such as A.Inner.run,
including class and object members. Nested function names remain relative to
their enclosing function.
Inline Rust modules and nested Python classes retain their complete owner paths,
such as alpha::inner::run and Outer.Inner.run. Function boundaries stop owner
inheritance for nested local functions; their enclosing function row already
provides that context.
JavaScript and TypeScript class and object-literal members also retain their
owner, such as Worker.run and worker.handlers.run. Object ownership follows
variable, assignment, nested-property, and named-expression paths, such as
holder.child.Inner.run. Function boundaries stop owner inheritance. Callbacks
retain their binding and callee, such as task.cb:map.
Collection functions retain their key or position in the literal, such as
handlers["a"] and items[0]. Calls with multiple arguments also retain argument
positions, such as task.cb:combine[1], so exchanging two callbacks does not hide
their individual changes.
Baselines
These are source-bucket erosion measurements made with the CLI producer recorded
in the baseline receipt, using Go 1.27.1 and
--no-cache. Each row is restricted to
the stated language and path because erosion is language-bound. The adjacent
prototype value is the expectation recorded before the production parsers were
integrated.
Each $BASELINES subdirectory is a clean copy of only the paths named in its
row at the pinned revision. As in the prototypes, the TypeScript copies omit
declaration files, and the attn copy explicitly excludes its unmarked generated
file. For the commands below, erosion '\.go$' and
erosion '\.(ts|tsx)$' mean:
erosion() {
jq --arg files "$1" '[.functions[] | select(.bucket == "source" and (.file | test($files)))] as $f | (($f | map(select(.cc > 10) | .mass) | add) / ($f | map(.mass) | add))'
}
| Corpus | Pinned revision | Shipped | Prototype | Command |
|---|---|---|---|---|
| Go standard library | go1.27.1, $GOROOT/src |
0.742 | 0.743 | slopradar scan --no-cache --format json "$GOROOT/src" | erosion '\.go$' |
golang.org/x/tools |
v0.49.0 |
0.721 | 0.722 | slopradar scan --no-cache --format json "$BASELINES/tools" | erosion '\.go$' |
honnef.co/go/tools (staticcheck) |
v0.8.1 |
0.748 | 0.749 | slopradar scan --no-cache --format json "$BASELINES/staticcheck" | erosion '\.go$' |
net/http |
go1.27.1, $GOROOT/src/net/http |
0.588 | 0.589 | slopradar scan --no-cache --format json "$GOROOT/src/net/http" | erosion '\.go$' |
os |
go1.27.1, $GOROOT/src/os |
0.476 | 0.477 | slopradar scan --no-cache --format json "$GOROOT/src/os" | erosion '\.go$' |
attn Go, cmd and internal |
e05560650af06855452a9793a4f987ded9ca1d99 |
0.593 | 0.593 | slopradar scan --no-cache --format json "$BASELINES/attn-go" | erosion '\.go$' |
zod, packages/zod/src |
46da95720b7293f156ad9c683c14bd8ab9664c2f |
0.804 | 0.805 | slopradar scan --no-cache --format json "$BASELINES/zod" | erosion '\.(ts|tsx)$' |
vitest, packages/* |
2ce29d5fa758046e5453bd92b8ed6c9da9709bb5 |
0.652 | 0.652 | slopradar scan --no-cache --format json "$BASELINES/vitest" | erosion '\.(ts|tsx)$' |
excalidraw, packages and excalidraw-app |
afa3a653fc5d2b742adcbd5a6063187b056d2419 |
0.795 | 0.796 | slopradar scan --no-cache --format json "$BASELINES/excalidraw" | erosion '\.(ts|tsx)$' |
attn TypeScript, app/src, app/lint, sdk, and plugins |
e05560650af06855452a9793a4f987ded9ca1d99 |
0.912 | 0.912 | slopradar scan --no-cache --format json "$BASELINES/attn-ts" | erosion '\.(ts|tsx)$' |
The production and prototype figures differ slightly because their parsers do
not define identical rows. The shipped Go analyzer includes module-level
closures and strips parsed comment spans; the prototype only walked declared
functions and approximated comment removal line by line. The shipped
TypeScript analyzer uses Oxc, while the prototype used the TypeScript compiler.
The Vitest and other TypeScript receipt directories omit declaration files, as
the prototype did. The attn TypeScript receipt also excludes its unmarked
app/src/types/generated.ts; a default scan includes unmarked generated output.
Test-directory conventions are language-specific. The production Go analyzer
in x/tools/go/analysis/passes/tests/tests.go is included as source. TypeScript
helpers in ordinary tests/ directories are also source unless their filenames
or configured globs classify them as tests. This affects one zod helper and
nine Excalidraw helper or fixture files in these scopes. The
baseline guide explains the corrected historical receipts.
The Go analyzer uses the Go 1.27 standard parser and accepts generalized
new(expression) and generic methods. The full standard-library scan completes
without warnings or incomplete-file skips. Generated files, including oversized
cmd/compile/internal/ssa/opGen.go and cmd/compile/internal/ssa/rewriteAMD64.go,
are excluded by their leading markers.
The full baseline receipt preserves the inputs, commands, warnings, and unrounded outputs. The reproduction guide records how each scoped input was assembled.
What it is not
- No merge gate or quality ratchet.
- No composite slop score.
- No author, model, or agent attribution.
- No hosted service.
- No per-repository baseline file or analysis output stored in your repository.
Limitations
- Renamed or moved functions read as removed plus new in this release.
- Erosion ratios are language-bound. A mixed-language bucket is useful for a repository's own trend, not for comparing languages or unrelated projects.
- Clone detection is exact-token matching. A near-duplicate with renamed identifiers is not a clone in this release.
- Files above
max_file_bytes=2097152are checked for a leading generated marker within that bounded prefix. Recognized generated files are ignored; other oversized files are skipped and named with both the limit and requested size. Canonical Go generation directives must end on a complete line within the prefix; leading block markers such as/* @generated */also work in single-line files. This tripwire was set above attn revisione05560650's measured largest source file (925234bytes) and largest handwritten source file (256764bytes).
Development
Use Go 1.27 or newer and the same C compiler prerequisites as installation. Install golangci-lint v2.13.2 to match CI, then run:
golangci-lint run ./...
golangci-lint fmt --diff
go test -race ./...
CI checks every push and pull request with the standard golangci-lint checks
(Staticcheck, govet, errcheck, ineffassign, and unused), plus gofmt formatting.
Run golangci-lint fmt to apply formatting locally. Tests and installation
checks run on Linux and macOS, on both arm64 and amd64.
Credits
slopradar's mass and erosion model comes from SlopCodeBench (arXiv:2603.24755). The project also builds on scb-check and Sebastian's essay on measuring code sloppiness.