metz-scan
metz-scan is a Ruby CLI for finding Sandi-Metz-style design pressure in Ruby and Rails code.
Why this exists
RuboCop is excellent at enforcing local style and correctness, but design smells often need more explanation than a terse lint message. metz-scan wraps a custom RuboCop plugin with reports that explain why a finding matters and what a developer can do next.
Example output:
$ metz-scan scan app lib
Metz/MethodsTooLong
Why it matters: Long methods hide multiple responsibilities and resist understanding at a glance.
Run `metz-scan explain Metz/MethodsTooLong` for details.
app/models/order.rb:12:3 Metz/MethodsTooLong: Method has too many lines. [7/5]
Summary
-------
Metz compliance: 62% (312/500 files clean)
435 offenses across 6 cops
By cop:
Metz/MethodsTooLong 333
Metz/ControllersTooManyDirectCollaborators 58
Metz/ClassesTooLong 21
Metz/DemeterTrainWreck 13
Metz/MethodsTooManyParameters 6
Metz/ViewsDeepNavigation 4
Most offenses:
app/models/order.rb 31
app/models/account.rb 24
app/controllers/orders_controller.rb 19
app/services/checkout.rb 15
app/models/user.rb 12
The compliance percentage is the share of inspected files with no Metz/*
rule offenses; advisory MetzProject/* project-analyzer findings never count
against it.
The repo contains two gems:
-
metz-scan: the user-facing CLI. -
rubocop-metz: the RuboCop plugin that provides theMetz/*cops.
How metz-scan compares
metz-scan is a modern take on the question sandi_meter
popularized: how well does this code follow Sandi Metz's rules? sandi_meter was
the prior art here, and metz-scan carries the same headline idea — a compliance
scorecard — forward with a wider, more current toolset:
-
All four classic rules, plus more. Class and method size, parameter count,
and controller collaborators, plus Law-of-Demeter chains (
DemeterTrainWreck) and deep view navigation (ViewsDeepNavigation). -
Correct on modern Ruby. Default scans analyze at your project's
TargetRubyVersion, so Ruby 3.x syntax (endless methods, anonymous argument forwarding, pattern matching) is parsed, not flagged as a syntax error. -
Project-level design pressure, not just per-file rules. Nine project
analyzers surface service soup, repeated branching, deep inheritance, and
other cross-file smells; two report in the default scan, and
--project-analyzersadds the rest. -
CI-native. Text, JSON, SARIF (GitHub code scanning), and GitHub
annotations, with an exit code of
1reserved for "findings reported." -
RuboCop-native. Built as a RuboCop plugin that honors your project's
Include/Excludescope, so it slots into an existing pipeline. -
Self-explaining.
metz-scan explain <cop>andmetz-scan rulesdescribe each rule and why it matters.
metz-scan measures design pressure only. It is not a security or correctness
auditor — pair it with brakeman or a full audit pipeline for those concerns.
When an application operation (“service object”) is legitimate vs procedural abuse,
and how that is enforced (including Metz/OperationsTooManyPublicMethods and
Metz/GodServiceClass), see
docs/application-operations.md. That document is
maintained in full in this repo so metz-scan has no hard dependency on rails-audit.
Install
gem install metz-scanOr add it to your Gemfile:
gem "metz-scan", "~> 0.6.0"bundle install
bundle exec metz-scan --versionmetz-scan depends on rubocop-metz (the RuboCop plugin providing the
Metz/* cops); Bundler resolves a compatible version automatically.
Also available on GitHub Packages if you need package-registry provenance alongside rubygems.org — see GitHub Packages install below for the auth setup.
Quick Start
From the project you added metz-scan to, list the rules, explain a cop, and
scan your code:
bundle exec metz-scan rules
bundle exec metz-scan explain Metz/DemeterTrainWreck
bundle exec metz-scan scan app libscan exits 0 when it checked at least one file and found nothing, and 1
when it reports findings — that is the tool working, not a crash.
Exit 2 means scan could not check the code: a RuboCop error (a cop raised,
an autocorrect never converged, a file could not be read, or an invalid
configuration file), an internal error in metz-scan, or no files scanned. In
default mode, an invalid configuration file is a project .rubocop.yml or a
nested .rubocop.yml in a subdirectory that cannot be read, has ERB that fails
to evaluate, is not valid YAML, or names a missing inherit_from file; default
mode reads only file scope from it, so it does not reject an unknown cop name.
--all-cops also rejects whatever RuboCop finds invalid, such as an unknown cop
name. Errors take precedence over findings. The error goes to stderr and stdout
gets no report in any format, so a saved report never looks clean when code
went unchecked. With in-place --auto-fix, corrections RuboCop already wrote
stay on disk when the scan exits 2; --dry-run leaves files unchanged.
64 means a usage error (unknown option, missing or nonexistent path, invalid
format, or a report file that is not a JSON report from scan --format json or
rubocop --format json) in any subcommand.
Text scan output ends with a Summary scorecard. The compliance percentage
is the share of inspected files with no Metz/* rule offenses; advisory
MetzProject/* project-analyzer findings do not make a file unclean. The same
summary also reports total offenses across cops, per-cop counts, and the five
files with the most reported offenses. Clean scans print No offenses found.
View templates a directory scan checks count as inspected files, so they
appear in the N/M files clean count.
To see a guaranteed finding on a bundled fixture, run this from a checkout of
this repository (the test/fixtures path exists only in the repo):
fixture_dir="$(mktemp -d)"
cp -R test/fixtures/service_soup_app "$fixture_dir/service_soup_app"
bundle exec metz-scan scan "$fixture_dir/service_soup_app" --project-analyzers --format text || true
rm -rf "$fixture_dir"The service-soup fixture intentionally contains a project-analyzer finding. The copy keeps the scan target outside this repository's fixture exclusions, so the scan command prints findings and exits nonzero.
Usage
List the available Metz cops:
bundle exec metz-scan rules
bundle exec metz-scan rules --jsonList the available project analyzers and their rollout status:
bundle exec metz-scan project-analyzers
bundle exec metz-scan project-analyzers --jsonExplain a cop:
bundle exec metz-scan explain Metz/ViewsDeepNavigationScan paths:
bundle exec metz-scan scan app/models app/controllers
bundle exec metz-scan scan . --format json
bundle exec metz-scan scan . --format sarif
bundle exec metz-scan scan . --format gh-annotations--format json adds an additive summary object to the report —
clean_file_count, files_with_offenses, and offenses_by_cop (a cop → count
map) alongside the existing fields — so tools can consume the compliance
scorecard without parsing the text output.
Guidance lives once per cop in a top-level guidance object keyed by
cop_name: one entry for each cop with an offense in files, holding
why_it_matters, suggested_next_moves, and fix_safety. Under
--all-cops, a stock RuboCop cop's entry comes from the resolved config of
the first file that reports the cop: why_it_matters is its Description,
fix_safety is safe, unsafe, or manual from its autocorrect support and
AutoCorrect/Safe/SafeAutoCorrect, and
suggested_next_moves is empty; text output prints that description once per
cop block. An offense carries one of those fields only
when its finding-specific guidance differs from the cop's entry; read the
offense's field first, then guidance[cop_name]. metz-scan report resolves
both shapes, including saved reports from before this map existed.
A default scan also lists the findings a suppression hid. Each finding that an
inline # rubocop:disable (or todo, disable-next) directive, a
project per-cop Exclude entry, or a project Enabled: false kept out of the
report appears once in a top-level suppressions array, sorted by path, line,
column, and cop name. Each record carries cop_name, path, line, column,
message, suppressed_by (inline_disable, config_exclude, or
config_disabled), reason, reason_status (present, missing, or
unchecked), directive ({line}), and config ({path, line, pattern};
config_disabled records carry {path, line}, the line of the Enabled key).
Both directive and config are always present; exactly one is non-null. Suppressed
findings never count toward offense_count, compliance, or the exit code.
Text output lists them in a Suppressed findings: N, M without a reason
section before the Summary, omitted when nothing was suppressed. Under
--all-cops the key lists only suppressed project-analyzer findings and is
absent when there are none; SARIF and gh-annotations output are unchanged.
By default, scan runs the RuboCop-backed Metz/* cops only, plus
project-analyzer findings that satisfy the default-output policy: the analyzer
is explicitly default-output eligible, the analyzer is validated, and the
individual finding is medium-confidence design pressure. Use --all-cops to
run the full stock RuboCop suite as well. Use --project-analyzers to include
the full opt-in project-analyzer set, including validated opt-in-only analyzers,
candidates, and lower-confidence findings:
bundle exec metz-scan scan . --all-cops
bundle exec metz-scan scan . --project-analyzers
bundle exec metz-scan scan . --project-analyzers --format json
bundle exec metz-scan scan . --project-analyzers --format sarifIn default mode, a directory scan checks Ruby files plus ERB, HAML, and Slim
view templates under app/views; other templates, such as generator or config
templates, are not Ruby and are skipped. Template files passed explicitly are
checked in every mode.
--all-cops uses RuboCop's own file discovery.
In default mode, scan reports stock Metz opinion on your project: it honors
your project's file scope — AllCops: Exclude, per-cop Exclude lists for
Metz/* cops, and a per-cop Include that narrows a Metz/* cop (it cannot
widen one; Metz/ControllersTooManyDirectCollaborators and
Metz/ViewsDeepNavigation ignore it and keep their stock scope plus engines
and nested apps) — and Enabled: false for a Metz/* cop
or the whole Metz department, but uses stock Metz cop configuration for thresholds, severity, and opt-in
status, so a project cannot weaken a Metz cop and get a rosier report. Every
finding that project scope or Enabled: false hides is listed in the
suppression ledger. This is why the length cops (Metz/MethodsTooLong,
Metz/ClassesTooLong) can be scoped off test trees with a per-cop Exclude
while still applying to production code. --all-cops runs the full stock
RuboCop suite under your complete project configuration instead.
Metz/TestReachesPrivate, Metz/TestAssertsOnInternals, and
Metz/TestStubsSubject are opt-in testing-discipline cops. They are listed by
rules and explain, but they are not included in default scan output until
separate dogfooding earns that promotion.
Default mode reads only file-scope settings, Enabled for Metz/* cops and
the Metz department, and AllCops: TargetRubyVersion from the target
.rubocop.yml, so it does not require external RuboCop
extensions declared with plugins:, require:, or inherit_gem:.
--all-cops uses RuboCop's complete project configuration; if a target
extension gem is missing, install that gem in the bundle you use to run
metz-scan or run the default Metz-only scan.
If an inherit_gem: entry names a gem that isn't installed in the bundle
running metz-scan, that gem's file-scope Exclude cannot be read, so it is
not applied — default mode prints a one-line metz-scan: note: warning to
stderr naming the gem instead of silently dropping the exclude. Install the
gem (or run --all-cops, which uses your complete project configuration) to
have that scope honored.
Default mode matches cops scoped to app/ paths (for example
Metz/OperationsTooManyPublicMethods) relative to the current directory, so
app/ files under any other root (a project scanned from its parent, a Rails
engine, a spec/dummy app) skip those cops. When that happens, default mode
prints one metz-scan: note: line to stderr naming those roots. Rescan a root
from inside it only when it is the project you meant to scan; nested engines
and dummy apps are expected to be skipped.
Default mode skips remote (http:// or https://) inherit_from: entries
without a warning, so file-scope settings in a remote config are not applied.
Use --all-cops when a remote config carries scope you need.
Current project analyzer status:
| Analyzer | Status | Default scan | Expected findings |
|---|---|---|---|
MetzProject/ServiceSoup |
Validated | Yes | Methods that coordinate at least three distinct service constants, such as ValidateOrder.call(...), CapturePayment.new(...).call, or FetchMessages.new(...).perform. |
MetzProject/RepeatedBranching |
Validated | Yes | Repeated case expressions with the same lexical decision and branch-value set, or repeated if/elsif predicate chains with the same receiver and predicate set, across distinct Ruby files. |
MetzProject/DeepInheritanceTree |
Validated | No | Indexed base classes or modules with at least three known descendants, when the optional Rubydex-backed project index is available. |
MetzProject/PackageDependencyPressure |
Candidate | No | Indexed namespaced classes or modules referenced from several files across multiple coarse packages outside their declaration package. |
MetzProject/NamespaceLeakPressure |
Candidate | No | Indexed deeply nested classes or modules referenced from multiple files across packages outside their home namespace. |
MetzProject/ImplicitContextPressure |
Candidate | No | Repeated Current.*, namespaced Current, or literal Thread.current[...] ambient context access across files and coarse packages. |
MetzProject/RepeatedQueryCriteria |
Candidate | No | Repeated constant-receiver or constant-root scope-chain hash criteria in where, where.not, or finder calls across files and coarse packages. Current fixture evidence is where plus find_by; where.not is supported and test-covered but not yet active-fixture evidenced. |
MetzProject/SubclassOverridePressure |
Candidate | No | Indexed base classes whose descendants repeatedly override the same method. |
MetzProject/TestCallsPrivateMethod |
Candidate | No | Test calls to private or protected methods confirmed by the optional Rubydex-backed project index; this is the index-confirmed form of Metz/TestReachesPrivate. |
Project analyzers parse Ruby files only and avoid semantic claims that require
resolving runtime types (they do not inspect ERB/HAML/SLIM templates). Default
scan output includes only findings from analyzers that are default-output
eligible, validated, and medium-confidence; pass --project-analyzers for the
complete opt-in set, including candidate analyzers and lower-confidence
findings. MetzProject/TestCallsPrivateMethod needs a scan path set that
includes tests, such as metz-scan scan . --project-analyzers; scanning only
app lib yields zero findings for that analyzer by design. See
docs/project-analyzer-calibration.md
for per-analyzer scope, thresholds, triage rules, and calibration evidence.
No Metz/* cop autocorrects, so --auto-fix changes nothing unless you add
--all-cops; it then applies stock RuboCop's safe corrections. Preview them
first:
bundle exec metz-scan scan . --all-cops --auto-fix --dry-run
bundle exec metz-scan scan . --all-cops --auto-fix--dry-run prints the diff and restores the original files afterward; files
are rewritten during the run.
Use --format gh-annotations in GitHub Actions to emit workflow command
annotations that appear inline on pull requests:
- name: Run metz-scan annotations
run: bundle exec metz-scan scan . --format gh-annotationsRe-render a saved JSON report:
bundle exec metz-scan scan . --format json > tmp/metz-scan.json
bundle exec metz-scan report tmp/metz-scan.json --format textUse the RuboCop plugin directly:
bundle exec rubocop --plugin rubocop-metzExperimental project index
metz-scan has an optional Rubydex-backed project index for evaluating
project-level design analysis. Normal scans do not require it; scan --project-analyzers uses it opportunistically for index-backed analyzers when
the optional bundle group is available.
Enable the optional bundle group and run the spike:
bundle config set --local with rubydex
bundle install
bundle exec ruby script/rubydex_spike.rbThe script indexes Ruby files, prints declaration/document counts, reports known
Minitest::Test descendants, lists RuboCop::Cop::Metz declarations, and
counts references to RuboCop::Cop::Metz::OnSendCsendBridge.
See docs/rubydex-spike.md for current results and feasibility notes.
For common questions and adoption guidance, see docs/faq.md. For agent-oriented consumer guidance, see skills/metz-scan/SKILL.md. For release checks, see RELEASE_CHECKLIST.md.
Configuration
The default scan reads three kinds of settings from .rubocop.yml: file scope
(AllCops: Exclude, per-cop Exclude, and a per-cop Include that narrows
a cop), Enabled: false
for a Metz/* cop or the Metz department, and AllCops: TargetRubyVersion.
Every other cop setting stays at its stock value, so a project cannot weaken a
Metz cop and get a rosier report.
MetzProject/* project analyzers are not RuboCop cops, but the plugin declares
a config entry for each one, so plain rubocop and --all-cops accept
MetzProject/<Rule> keys. Every scan mode honors Enabled: false and a
per-analyzer Exclude on them, and an inline
# metz-scan:disable MetzProject/<Rule> -- reason on the reported line, or a
block closed by # metz-scan:enable MetzProject/<Rule> (name the rule in
enable; enable all does not close the block). Other directive forms, such as
todo, disable-next, and all, do not reach analyzers. Each hidden finding is
credited in the suppression ledger like a cop's. # rubocop:disable does not
reach analyzers either, and RuboCop flags it as an unknown cop
(Lint/CopDirectiveSyntax). The MetzProject department cannot be disabled as
a whole.
To take code out of a cop's scope in every scan mode, add a per-cop Exclude:
Metz/MethodsTooLong:
Exclude:
- "test/**/*" # tests build fixtures inlineA default scan credits every finding a per-cop Exclude hides to the entry
that hid it, and reads the entry's reason from the first of: a trailing
comment on the entry, a comment block directly above the entry, a trailing
comment on Exclude:, or a comment block directly above Exclude:. An inline
directive's reason is its -- reason text. Suppressions without a reason are
counted in the ledger so they stay visible. Entries in an ERB config are read
from the rendered YAML and reported without a line number; entries reached
through a YAML alias are reported as unchecked. Regexp entries
(!ruby/regexp) are reported with their /regexp/ text and a reason that is
not checked. When the same Exclude pattern appears in a base config and an
inheriting config (inherit_mode: merge), the base file's entry is credited.
Stock-default and AllCops: Exclude scope produce no record, and neither does
a per-cop Include that narrows a cop: findings it hides leave no record.
Enabled: false on a Metz/* cop, or on the Metz department, hides every
finding of the cops it disables from the counts, compliance, and exit code; as
in RuboCop, a cop's own Enabled: true overrides a disabled department. A
default scan still lists each hidden finding in the ledger as
config_disabled, credited to the config file and line that wrote
Enabled: false, with the reason read from a trailing comment on that line or
a comment block directly above it. AllCops: DisabledByDefault is not read.
Other cop settings, such as Max, Severity, and
AllowedReceivers, take effect only under metz-scan scan --all-cops or plain
bundle exec rubocop with the plugin loaded. The default scan ignores them
without a warning. To set them for --all-cops and plain rubocop:
plugins:
- rubocop-metz
Metz/MethodsTooLong:
Max: 5
Metz/DemeterTrainWreck:
Max: 4| Setting | Where | Notes |
|---|---|---|
| File scope |
.rubocop.yml (AllCops: Exclude, per-cop Include and Exclude) |
AllCops: Exclude and per-cop Exclude are honored by every scan mode. A per-cop Include is honored in full by --all-cops and plain rubocop; the default scan lets it narrow a Metz/* cop but not widen it, and Metz/ControllersTooManyDirectCollaborators and Metz/ViewsDeepNavigation ignore it there. |
Enabled: false |
.rubocop.yml (Metz/* cop, Metz department, or MetzProject/* analyzer) |
Honored by every scan mode. The suppression ledger lists hidden Metz/* findings in the default scan and hidden MetzProject/* findings in every mode. |
Other cop settings (Max, Severity, allow-lists) |
.rubocop.yml |
Honored only by --all-cops and plain rubocop; the default scan uses stock values. |
| Output format | metz-scan scan --format text|json|sarif|gh-annotations |
text is for humans; json/sarif are for tools; gh-annotations emits GitHub Actions workflow annotations. |
| Auto-fix safety |
--auto-fix, --unsafe, --dry-run
|
Safe fixes use RuboCop -a; unsafe fixes use RuboCop -A. No Metz/* cop autocorrects, so these only change code with --all-cops. |
| Environment variables | N/A |
metz-scan does not require environment variables. |
GitHub Packages install
metz-scan is also published to GitHub Packages, for consumers who want a
package-registry install alongside rubygems.org. Configure Bundler with a
GitHub token that can read packages, then add the GitHub Packages source to your
Gemfile:
gh auth refresh -h github.com -s read:packages
GITHUB_PACKAGES_TOKEN="$(gh auth token)"
bundle config set --global https://rubygems.pkg.github.com/fuentesjr \
"fuentesjr:${GITHUB_PACKAGES_TOKEN}"
unset GITHUB_PACKAGES_TOKENsource "https://rubygems.org"
source "https://rubygems.pkg.github.com/fuentesjr" do
gem "metz-scan", "~> 0.6.0"
endbundle install
bundle exec metz-scan --versionRequirements
- Ruby
>= 4.0 - A working compiler toolchain may be needed by transitive native gems on some platforms.
If your shell resolves to macOS system Ruby, switch to a Ruby >= 4.0 before running Bundler.
Contributing / Development
See CONTRIBUTING.md for bug reports, feature proposals, and security reporting. Issues are welcome: https://github.com/fuentesjr/metz-scan/issues
Clone the repo (Ruby >= 4.0, Bundler 4.0.8):
git clone https://github.com/fuentesjr/metz-scan.git
cd metz-scan
gem install bundler -v 4.0.8
bundle installRun the test suite and linter:
bundle exec rake
bundle exec rubocopbundle exec rake runs each slow test file as a separate process and the fast
group as one process, in parallel (default 4 at once, never more than the CPU
count).
Set TEST_JOBS=1 to run them serially, or another number to change the limit.
Additional maintainer checks before a push:
bin/check_dogfood
bin/check_dependency_direction
bin/check_sample_app_frozen
bin/check_ci_paritybin/check_dogfood runs metz-scan against this repository with
--project-analyzers enabled and requires the optional rubydex bundle group.
It accepts zero project-analyzer findings. It fails if any
non-project-analyzer offense appears or if a project-analyzer finding appears.
Run bin/check_ci_parity before every push. It clones the committed HEAD
into a temp dir and runs the single-command CI phases without local Bundler
config or untracked files. Its test phase is a subset: docs-freshness tests
for docs-only commits and rake test:fast for code commits. Set
CI_PARITY_FULL=1 to force the full suite. If a phase fails, use the
printed clean clone preserved at path plus the next action: command to
reproduce that failed phase inside the preserved clone.
From this repository, bin/check_published_gem VERSION creates a clean
temporary consumer project and verifies a GitHub Packages install. Use it when
debugging package credentials, source configuration, or packaged runtime
behavior; failures include redacted Bundler output and credential hints.
Use bin/check_read_only_commands for maintenance commands that must leave
tracked files unchanged. The guard runs each command with BUNDLE_FROZEN=1,
fails if tracked files are dirty before or after a command, and currently covers
these read-only checks:
bundle exec ruby bin/check_project_analyzer_calibration --text --no-write test/fixtures/sample_app
bundle exec ruby bin/check_project_analyzer_calibration --print-baseline --baseline-label sample-app-preview --analyzer MetzProject/RepeatedBranching test/fixtures/sample_app
bin/check_rubydex_drift --allow-missing-rubydex --text test/fixtures/sample_app
bin/render_issue_comment_summary 27Add future read-only maintenance commands to that guard instead of creating a one-off wrapper.
Build both gems:
gem build metz-scan.gemspec
cd rubocop-metz && gem build rubocop-metz.gemspec && cd ..Maintainer / agent notes
This subsection is for maintainers and coding agents, not required for a one-off external contribution.
Before autonomous repo work, run trk status (see AGENTS.md
and CLAUDE.md) for the current local direction, next queue, parked
work, and why this repo tracks agent coordination in .trk/ rather than only
in GitHub Projects or issues.
For durable code-level design exceptions, write a lightweight Design Decision Record before finalizing the exception. See docs/design-decision-records.md.
Working papers, including the debt list, live under docs/maintainers/.
License
MIT. See LICENSE.