Moult
Codebase intelligence for Ruby and Rails. Moult sheds dead code.
moult-action (GitHub Action)
Add the gate to your CI and upload results to Moult Cloud:
- uses: moult-rb/moult-rb@v1
with:
base-sha: ${{ github.event.pull_request.base.sha }}
moult-cloud-url: https://app.moult.dev
Your workflow needs permissions: id-token: write for OIDC authentication.
Three commands today:
moult hotspotsranks files by a complexity × churn score — the code that is both hard to understand and changed often.moult deadcodelists confidence-graded dead-code candidates — unused methods and constants, over a real definition/reference graph, with Rails entrypoint awareness so framework-invoked code isn't a false positive. Feed it a coverage file with--coverageand runtime evidence is merged into every finding's confidence (see Runtime coverage).moult coverageresolves a coverage file to a per-symbol hot / cold / untracked map.
Parsing is Prism; the definition/reference
index behind deadcode is rubydex.
Every finding Moult produces is a confidence-graded signal, never a claim of fact. In Ruby, dead code can almost never be proven statically (metaprogramming,
send,method_missing, Zeitwerk, dynamic dispatch), so Moult never asserts that code is certainly dead — it attaches a confidence and the reasons behind it. Runtime coverage is the missing signal: it raises confidence on code that never ran and rescues candidates that did. Later phases add duplication analysis behind the same typed JSON contract.
Install
Add to your Gemfile:
gem "moult"
Or install directly:
gem install moult
Requires Ruby 3.3+.
Usage
moult hotspots [PATH] [options]
PATH defaults to the current directory. Inside a git repository Moult
analyses the files git tracks (respecting .gitignore); elsewhere it globs for
*.rb, skipping vendor/, tmp/, and node_modules/.
Example
$ moult hotspots
Hotspots (complexity x churn): 3 files — churn over last 12 months
# SCORE COMPLEXITY CHURN FILE WORST METHOD
1 95.2 15.9 6 app/services/charge.rb Charge#call (15.9)
2 59.6 19.9 3 app/models/user.rb User#eligible? (15.9)
3 3.0 3.0 1 lib/util.rb Util.blank? (3.0)
app/models/user.rb is the most complex file, but app/services/charge.rb
tops the list because it changes twice as often — complexity alone would have
missed it.
JSON
--format json emits the typed contract (see
schema/hotspots.schema.json), suitable for CI
and tooling:
moult hotspots --format json
{
"schema_version": 1,
"tool": { "name": "moult", "version": "0.1.0" },
"analysis": {
"root": "/path/to/project",
"git_ref": "c6e23f6f5d1003ea3cbc874aea5f1c55bf80a740",
"generated_at": "2026-06-29T06:03:22Z",
"churn": { "window": "last 12 months", "since": null }
},
"hotspots": [
{
"path": "app/services/charge.rb",
"score": 95.22,
"complexity": 15.87,
"churn": 6,
"confidence": null,
"category": null,
"methods": [
{
"symbol_id": "app/services/charge.rb:2:Charge#call",
"name": "Charge#call",
"span": { "start_line": 2, "start_column": 2, "end_line": 15, "end_column": 5 },
"abc": 15.87,
"confidence": null,
"category": null
}
]
}
]
}
confidence and category are reserved for later phases and are always null
today — Moult never asserts that code is dead.
Options
| Flag | Default | Description | |
|---|---|---|---|
| `--format table\ | json` | table |
Output format. |
--limit N |
20 |
Show the top N hotspots. 0 shows all. |
|
--since DATE |
12 months ago |
Churn window start; any value git log --since accepts (e.g. 2025-01-01). |
|
--quiet |
off | Suppress informational notes on stderr. |
Moult is report-only: it exits 0 on success and non-zero only on error. There
are no failing thresholds.
Dead code
moult deadcode [PATH] [options]
Lists methods and constants with no resolvable reference, each as a confidence-graded candidate — never an assertion that the code is dead. The definition/reference graph comes from rubydex (the engine behind ruby-lsp); the confidence and its reasons come from Moult.
$ moult deadcode
Dead-code candidates (confidence-graded — not certainties): 3 findings
CONF KIND SYMBOL LOCATION TOP REASON
0.85 method Report#legacy_to_h lib/report.rb:42 private method with no caller in the codebase
0.30 method Api#export lib/api.rb:8 public method may be an external API entrypoint
0.00 method UsersController#index app/controllers/users_controller.rb:5 Rails framework entrypoint: public action in app/controllers/users_controller.rb
A private method with no caller scores high; a public method (a likely API surface) scores lower; a routed controller action sinks to the bottom — but still appears. Moult lowers confidence for framework conventions and metaprogramming, it never silently hides a candidate.
How confidence is computed
Each finding starts from a base score (by kind and visibility) and is adjusted
by a set of named rules, every one recorded as a reason:
- Raises confidence: a private method with no caller; a
@deprecatedmark. - Lowers confidence: a public method (API surface); references only from
tests; a constructor (
initialize, invoked implicitly by.new); a method that overrides or implements an ancestor's method (reachable via that interface — polymorphic dispatch); a file that usessend/define_method/method_missing/const_get/eval. - Lowers strongly: a Rails entrypoint — controller/mailer actions, helpers,
job
#perform,before_action :symbol-style callbacks, serializers, initializers. Rails awareness is on automatically when a Rails app is detected (--no-railsto disable).
Dead-code JSON
--format json emits the typed contract (see
schema/deadcode.schema.json):
{
"schema_version": 2,
"tool": { "name": "moult", "version": "0.1.0" },
"analysis": {
"root": "/path/to/project",
"git_ref": "c6e23f6f…",
"generated_at": "2026-06-29T06:03:22Z",
"coverage": null,
"index": { "backend": "rubydex", "backend_version": "0.2.6", "resolved": true, "rails": true, "diagnostics": [] }
},
"findings": [
{
"symbol_id": "lib/report.rb:42:Report#legacy_to_h",
"kind": "method",
"name": "Report#legacy_to_h",
"span": { "start_line": 42, "start_column": 2, "end_line": 50, "end_column": 5 },
"confidence": 0.85,
"category": "dead_code",
"runtime": null,
"reasons": [
{ "rule": "base_score", "delta": 0.75, "detail": "base for method/private" },
{ "rule": "private_unused", "delta": 0.1, "detail": "private method with no caller in the codebase" }
]
}
]
}
analysis.coverage and each finding's runtime are null until you pass
--coverage (see below). The symbol_id is the same "<path>:<line>:<name>"
join key the hotspots contract uses, so the analyses — and the coverage merge —
line up.
Options
| Flag | Default | Description | |
|---|---|---|---|
| `--format table\ | json` | table |
Output format. |
--min-confidence N |
0.0 |
Hide findings below this confidence (0–1). |
|
--[no-]rails |
on | Apply Rails entrypoint awareness. | |
--coverage PATH |
off | Merge a local coverage file as runtime evidence. | |
--coverage-format FMT |
auto |
auto, simplecov, or coverage. |
|
--quiet |
off | Suppress informational notes on stderr. |
Currently in scope: unused methods and non-class constants. Classes and modules are not flagged (this sidesteps Zeitwerk/STI false positives). Route-file and view-template resolution are deferred to a later slice.
Runtime coverage
Static analysis can never prove Ruby dead code — send, method_missing,
metaprogramming and Zeitwerk all defeat it. Production coverage is the missing
signal, and Moult merges it both ways:
- a candidate whose body never ran (runtime-cold) gets its confidence raised — corroboration that it really is dead;
- a candidate that did run (runtime-hot) is rescued — its confidence is capped low, because it's the false positive static analysis missed.
Point --coverage at a local coverage file — either SimpleCov's
coverage/.resultset.json or a JSON dump of stdlib
Coverage.result. The
format is auto-detected.
moult deadcode --coverage coverage/.resultset.json
Dead-code candidates (confidence-graded — not certainties): 3 findings
CONF KIND RUNTIME SYMBOL LOCATION TOP REASON
0.95 method cold Report#legacy_to_h lib/report.rb:42 never executed in the supplied coverage run (runtime-cold corroborates)
0.10 method hot Api#dispatch lib/api.rb:8 executed at runtime (coverage) despite no static reference; rescued
To produce a stdlib dump without SimpleCov, capture coverage around your test run and write the result:
require "coverage"
Coverage.start(lines: true)
# ...load and exercise your app / run your tests...
File.write("coverage.json", JSON.generate(Coverage.result))
Coverage is keyed by line; Moult resolves it to each symbol's definition span,
counting only the method body (the def line is counted at load time, not
per call, so it's excluded). A symbol is hot if any executable body line ran,
cold if the file is tracked but none did, and untracked when there's no
signal (the file isn't in the dataset, or it's a constant). Coverage is evidence,
never proof — runtime-cold raises confidence, it never asserts certain death.
Coverage map
moult coverage is the standalone view of the same classification — a typed
hot/cold/untracked map over every definition (see
schema/coverage.schema.json):
moult coverage --coverage coverage/.resultset.json # table
moult coverage --coverage coverage/.resultset.json --format json
Runtime coverage map: 128 hot, 14 cold, 9 untracked
RUNTIME KIND SYMBOL LOCATION
hot method Charge#call app/services/charge.rb:2
cold method Report#legacy_to_h lib/report.rb:42
How the score works
- Complexity — a flog-style weighted ABC score per method: assignments, branches (every method call, including operators), and conditions, with metaprogramming calls penalised and a compounding penalty for nesting depth. A file's complexity is the sum of its methods'.
- Churn — the number of commits that touched the file within the window (default: the last 12 months). Renames are not followed.
- Score —
complexity × churn, ranked descending.
Outside a git repository churn is 0, so files rank by complexity alone.
Open source & Moult Cloud
The moult gem — the CLI and every analysis in it — is free and open source
under Apache-2.0. Moult Cloud is a separate commercial
product: a hosted GitHub App that turns moult gate into an enforced,
team-visible PR check with history, trends, and dashboards. The gem stands on
its own; the cloud is optional.
Contributing
Contributions welcome — see CONTRIBUTING, our Code of Conduct, and SECURITY for reporting vulnerabilities. Changes are tracked in CHANGELOG.md.
License
Apache-2.0. © 2026 The Moult authors. See NOTICE.