Project

metz-scan

0.0
The project is in a healthy, maintained state
metz-scan is the user-facing CLI for the Sandi-Metz-inspired code-quality toolchain. It runs RuboCop with the rubocop-metz plugin and renders developer-friendly reports.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 0.6.0
 Project Readme

metz-scan

CI Gem Version License: MIT

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 the Metz/* 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-analyzers adds the rest.
  • CI-native. Text, JSON, SARIF (GitHub code scanning), and GitHub annotations, with an exit code of 1 reserved for "findings reported."
  • RuboCop-native. Built as a RuboCop plugin that honors your project's Include/Exclude scope, so it slots into an existing pipeline.
  • Self-explaining. metz-scan explain <cop> and metz-scan rules describe 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-scan

Or add it to your Gemfile:

gem "metz-scan", "~> 0.6.0"
bundle install
bundle exec metz-scan --version

metz-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 lib

scan 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 --json

List the available project analyzers and their rollout status:

bundle exec metz-scan project-analyzers
bundle exec metz-scan project-analyzers --json

Explain a cop:

bundle exec metz-scan explain Metz/ViewsDeepNavigation

Scan 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 sarif

In 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-annotations

Re-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 text

Use the RuboCop plugin directly:

bundle exec rubocop --plugin rubocop-metz

Experimental 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.rb

The 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 inline

A 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_TOKEN
source "https://rubygems.org"

source "https://rubygems.pkg.github.com/fuentesjr" do
  gem "metz-scan", "~> 0.6.0"
end
bundle install
bundle exec metz-scan --version

Requirements

  • 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 install

Run the test suite and linter:

bundle exec rake
bundle exec rubocop

bundle 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_parity

bin/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 27

Add 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.