Measures how sloppy a codebase is, and shows its working.
Rust, TypeScript, and C are covered today. Which rules apply to which language is stated in the rules reference, so that list has one home rather than several.
slopometer .
slopometer .
16 files, 3181 lines, configuration from built-in defaults
score 80/100, graded good (75 to 89)
categories
rust 72 ##############......
doc 68 #############.......
filler 100 ####################
pass --explain for the densities behind these scores
findings
src/lib.rs 9 findings
LOW src/lib.rs:1:1 this file is 1193 lines of non-test code, over the limit of 600
rust/oversized-file
HIGH src/lib.rs:904:13 the failure path is unhandled here, so a broken value ends the process
rust/unwrap-in-library
Findings are grouped by file and labelled with their severity. Every finding
line keeps its full path:line:column, so editors and terminals can still jump
straight to it. On a terminal the report is coloured by severity and by score
band; piped anywhere else it is plain text.
The name invites that assumption, so it is worth answering first. slopometer does not detect, estimate, or claim who or what wrote any line, and it never will. No message, category, or field in its output expresses an opinion about authorship, and a test enforces that.
One of its five rule families does look for patterns common in code that was
generated and never edited afterwards: comments narrating every statement,
documentation templated across a dozen items, checks that cannot fail,
placeholder bodies. Those patterns are worth finding because of what they are,
not because of where they came from. Plenty of hand-written code has them, and
plenty of generated code does not. The family is named filler for that reason,
and it carries the lowest severity of anything the tool reports.
Five families of rules, each addressable on its own:
rustpicks up error handling that panics instead of propagating, abstractions with no second user, items exposed more widely than their use requires, and functions and files past the size you configured.typescriptpicks up interfaces with a single unexported implementor, aliases that only rename another type, types whose properties are almost all optional, modules that exist only to forward, shapes declared twice under different names, and the constructs that discard type checking: writtenany, non-null assertions, casts laundered throughunknown, suppressions with no reason given, console calls in library code, and default exports.cpicks up functions given external linkage nothing outside their own file uses, function bodies placed in headers, headers nothing includes, declarations nothing defines, and mutable state at file scope.docpicks up public items with no documentation, documentation that only restates the signature, documentation that has drifted away from the code beneath it, comments that paraphrase the line below, commented-out code, and unresolved work markers.fillerpicks up the patterns described above.
Deliberately absent: anything needing type inference or borrow analysis. Deciding that a clone is unnecessary requires the borrow checker, so that job belongs to clippy, which has one. Rules the analyzer cannot establish are not shipped half-working.
Six of them measure something Biome or typescript-eslint also checks. They ship anyway, because the two ecosystems differ in what a project has to do to be unchecked. Clippy comes with the Rust toolchain, so a Rust project not running it opted out of something already there, and duplicating it would be noise. TypeScript linting is opted into twice, by installing a tool and by turning the individual rule on. A project with no configuration is not one that decided these were acceptable; it is one where nothing is checking them.
So each of the six first asks whether the project already enforces it, by
reading biome.json or .eslintrc.json as data. Configuration is never
executed, for the same reason the analyzer never builds the project it is
pointed at. Where the rule is already enforced, it stays silent. Where there is
no configuration at all, it reports. Where configuration exists only as
JavaScript that would have to be run to understand, such as a flat
eslint.config.ts, it reports unconfirmed, and unconfirmed findings never reach
the score.
C source means nothing until it is preprocessed, and this tool will not preprocess it, for the same reason it never compiles the project it is pointed at. Expanding a macro means resolving include paths and evaluating conditionals, which is running the build in all but name.
Measured against twenty widely deployed C projects, that leaves very little
standing: 97.6 percent of C findings came back unconfirmed, and nineteen of the
twenty carried no c score at all. Every one of those projects invokes macros at
file scope, and a macro can expand to a declaration, a call, or an include the
analyzer never reads.
So point it at C for the findings, not for a score. Each unconfirmed finding names a real construct at a real location, and someone who knows the build can judge it at a glance. What the tool cannot do is aggregate them into a number, because it cannot tell which of them the compiler actually sees. It says so rather than reporting a figure that would look clean for the wrong reason.
Analysis is deterministic and offline. It makes no network requests, and its results do not vary with the time, the machine, or the user. Two runs over unchanged input produce byte-identical reports.
It never compiles the code it analyzes. Compiling runs build scripts and procedural macros, which is not an acceptable thing to do to a repository you did not write. Everything comes from parsing the source and reading the package manifests.
That has a cost worth stating. Parsing gives syntax, not meaning, so the tool
builds its own project-local name resolution. In Rust that is the module tree
from mod declarations and file layout, use statements including aliases,
globs and re-exports, and a table of which types implement which traits. In
TypeScript it is what each module exports and which declarations implement which
interfaces, walked out from the entry point the package manifest names so that
public means what an importer can write rather than what any module exports. In
C it is which names a header declares, which is the author stating the public
surface rather than the analyzer inferring it. Types, borrows, macro-generated items, and anything inside a
dependency stay out of reach.
Where a TypeScript package has no readable manifest there is no entry point to walk from, so any module-level export counts as public and the answer is reported as approximate rather than trusted.
Where a rule cannot establish a fact it depends on, the finding is reported as unconfirmed. Unconfirmed findings never affect the score, and are hidden unless you ask for them:
slopometer . --show-unconfirmed
Every score is reconstructible from the report. There is no hidden judgment.
For each category, findings are weighted by severity (low 1, medium 3, high 9), divided by the lines that category's rules could apply to, and put through a curve:
score = 100 / (1 + weighted_density / half_density)
half_density is the weighted density at which the category scores exactly 50.
The compact report shows the score and a bar; --explain prints the density,
the half density, and what each severity tier contributed, so one division
reproduces the number:
slopometer . --explain
The JSON report always carries those figures, whether or not you asked for them.
The curve is hyperbolic rather than exponential on purpose. Exponential decay collapses to near zero a short way past the halfway point, so a team cleaning up a genuinely bad codebase would see no movement at all. This keeps resolution across the whole range.
The overall score is the weighted mean of the categories that applied. A category with nothing to inspect is reported as not applicable rather than as a perfect score, and a category with too little code to support a rate is reported as low confidence rather than given a number the sample cannot justify.
The constants come from measuring twenty widely used crates, twenty widely used
TypeScript packages, and twenty widely deployed C projects, setting each so the
median lands at 70. A language family is calibrated against its own twenty and
normalized against its own language, so a project with no Rust in it does not
score a perfect rust for having written none.
A category carries no score at all when fewer than a tenth of its findings are
confirmed. A score is computed from confirmed findings alone, so a category the
analyzer could barely read would otherwise score near perfect while describing
nothing. That is why c reports no score on almost every real C project.
The constants differ by more than an order of magnitude between categories, because the constructs each family names differ that much in how often they appear. Comparing two categories' scores to each other says nothing. Comparing one category against itself across two revisions is what the number is for.
See calibration/ for the corpus, the measurements, and every rule that was
narrowed or switched off because it fired too often on code that is not sloppy.
Optional. Without a slopometer.toml the built-in defaults apply, and the
report says so.
exclude = ["generated/**"]
[thresholds]
max_function_lines = 60
max_file_lines = 600
max_nesting_depth = 5
min_relevant_lines = 200
[rules."rust/unwrap-in-library"]
enabled = false
severity = "medium"
[categories.doc]
weight = 2.0
half_density = 6.8Rules are namespaced by category, so doc addresses the whole family and
doc/work-marker addresses one rule. Unknown keys and unknown rule names are
rejected before analysis starts: a typo that silently disabled nothing would
corrupt a score with no sign of it.
Three rules ship switched off because they fired too often on well-regarded
code. Turn any of them on with --enable, and read why they are off in
calibration/results.md.
To silence one rule at one place, name it:
// slopometer:allow(rust/unwrap-in-library)
let value = certain.unwrap();A marker that names no rule is refused and reported, because a suppression nobody can read is indistinguishable from a rule that quietly stopped working.
slopometer [PATH]
--json Write the full report as JSON
--config <FILE> Use this configuration instead of discovering one
--min-severity <LEVEL> List only findings at or above low, medium, or high
--category <NAME> List only findings in one category
--show-unconfirmed List findings the engine could not confirm
--explain Show the figures behind each category score
--color <WHEN> Colour the report: auto, always, or never
--disable <RULE> Turn a rule or category off for this run
--enable <RULE> Turn a rule or category on for this run
--exclude <PATTERN> Leave matching paths out of the analysis
Filters change what is listed, never what is scored. Findings never fail the run; only being unable to analyze does. In JSON mode nothing but the report reaches standard output, so piping into a parser always works.
Colour follows the destination. It is on for a terminal, off for a pipe or a
file, and off whenever NO_COLOR is set. --color overrides that in either
direction. Colour never carries anything the text does not also say, so a
report read without it loses nothing. Output is ASCII throughout.
Rust and TypeScript ship today. Language dispatch is an enum rather than a trait, so adding a variant makes every match over it fail to compile until it is handled, and the compiler lists exactly what a new language needs.
What a language supplies is two interfaces. Extraction turns a parsed file into facts; resolution answers what the project makes reachable and how sure the analyzer is about it. Both are traits, and both were designed against two real implementations rather than guessed at from one. The fact model is split to match: a language-neutral core of items, comments, references, imports, and regions the analyzer could not read, plus an extension holding whatever belongs to one language alone.
The doc and filler families read only the neutral core, so they apply to
every language the engine parses without being edited for it.
Dual licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.