Skip to content

Implement initial complexity analysis pass and CLI command - #961

Open
rafaelfranca wants to merge 1 commit into
mainfrom
rmf-complexity
Open

Implement initial complexity analysis pass and CLI command#961
rafaelfranca wants to merge 1 commit into
mainfrom
rmf-complexity

Conversation

@rafaelfranca

@rafaelfranca rafaelfranca commented Jul 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds an ABC complexity analysis pass to rubydex: a standalone Prism-based scorer that
computes assignment, branch, and call complexity for Ruby source, exposed through both
the Rust CLI (rubydex_cli --complexity) and the Ruby gem (rdx complexity /
Rubydex::Complexity). It runs as its own analysis pass — no graph or resolution
required — so it is fast enough to run repeatedly over a large codebase.

The scoring rules are modeled on flog; thanks to
Ryan Davis for the original.

A score is reported per method as sqrt(a² + b² + c²), where a, b, and c
accumulate assignment, branch, and call weight respectively (each scaled by the current
nesting multiplier). .rbs files are excluded; everything else the indexer treats as
Ruby (.rb, .rake, .ru, …) is scored.

CLI

# Compute a report for the current directory (top 25 methods by default)
bundle exec rdx complexity

# Scope to specific paths and show more entries
bundle exec rdx complexity app/models lib/services --top 50

# Full report as JSON (--top is ignored for JSON, so every method is included)
bundle exec rdx complexity app/models --format json > report.json

Text output

$ bundle exec rdx complexity app/models

    17.2: total complexity
     5.7: average complexity

    10.3: Rubydex::Complexity.analyze              app/models/rubydex/complexity.rb:8-12
     5.0: Rubydex::Complexity#none                 app/models/rubydex/complexity.rb:5-19
     1.9: Rubydex::Complexity.diff                 app/models/rubydex/complexity.rb:15-17

JSON output

bundle exec rdx complexity app/models --format json > report.json

The JSON report (schema_version: 1) carries total, average, methods_count, and
a methods array of entries with per-bucket breakdowns and start_line/end_line
locations (--top is ignored for JSON, so all methods are emitted):

{
  "schema_version": 1,
  "total": 17.2,
  "average": 5.7,
  "methods_count": 3,
  "methods": [
    {
      "name": "Rubydex::Complexity.analyze",
      "file": "app/models/rubydex/complexity.rb",
      "start_line": 8,
      "end_line": 12,
      "assignments": 1.0,
      "branches": 1.5,
      "calls": 10.1,
      "score": 10.3
    }
  ]
}

Diffing against a baseline

Diff a fresh report against a baseline JSON report to track drift over time. Both
reports must be generated with the same --methods-only setting.

bundle exec rdx complexity app/models --diff baseline.json
Total:   1200.0 -> 1250.3 (+50.3)
Average:    8.0 ->    8.2 (+0.2)
Methods:    150 ->    155 (+5)

Regressions:
   +12.3: Foo#bar (24.0 -> 36.3)                 app/models/foo.rb
Improvements:
    -5.0: Baz#qux (15.0 -> 10.0)                 app/models/baz.rb
Added:
    20.0: New#thing                              app/models/new.rb
Removed:
     8.0: Old#gone                               app/models/old.rb

Per-construct breakdown, methods-only, and grouping

# Show what drives each method's score: per-construct contributions
bundle exec rdx complexity app/models --details --top 10

# Skip code outside methods: drops top-level `#none` noise
bundle exec rdx complexity app/models --methods-only

# Group entries by class with per-class subtotals
bundle exec rdx complexity app/models --group

--details adds a per-method breakdown:

    10.3: Rubydex::Complexity.analyze              app/models/rubydex/complexity.rb:8-12
     1.8:   class
     1.7:   block_pass
     1.7:   map
     1.6:   raise
     1.5:   branch
     0.4:   magic_number

--group is text-only (rejected with --format json); neither --details nor
--group applies to --diff (both are rejected with it).

Ruby API

# Compute a report: returns the text or JSON string ready to print
puts Rubydex::Complexity.analyze(["app/models"], format: :text, top: 25)
json = Rubydex::Complexity.analyze(["app/models"], format: :json)

# Per-construct breakdown, methods-only, and grouping
puts Rubydex::Complexity.analyze(["app/models"], details: true, top: 10)
puts Rubydex::Complexity.analyze(["app/models"], methods_only: true)
puts Rubydex::Complexity.analyze(["app/models"], group: true)

# Diff two JSON reports (baseline against a fresh run).
# Both reports must share the same methods_only setting or diff raises ArgumentError.
puts Rubydex::Complexity.diff(baseline_json, json, format: :text, top: 25)

Configured exclusions

Exclusions are configured in rubydex.toml and are decoupled from indexing: the
top-level exclude key affects indexing only, while a separate [complexity] table
controls what rdx complexity skips. This lets you keep a file in the graph but out of
the complexity report. Both share the default skipped directories (.git,
node_modules, tmp, …).

# rubydex.toml
exclude = ["vendor/**"]              # indexing only; complexity still scores these

[complexity]
exclude = ["app/assets/**", "**/*_spec.rb"]   # complexity only; still indexed

Malformed globs in either set are reported as a config error at load time rather than
silently dropped.

Implementation

  • New complexity module in the Rust crate: a Prism Visit implementation that scores
    each method into a/b/c buckets with compounding nesting penalties, plus
    Report/DiffReport with render_text / to_json / from_json / diff.
  • FFI plumbing (rdx_complexity_analyze / rdx_complexity_diff) and a C extension
    (Rubydex::Complexity.native_analyze / native_diff).
  • Parallel analyze mirrors the indexer's job queue; per-file non-fatal errors are
    surfaced as warnings.
  • Scoring rules and deliberate deviations are documented in the module doc comment;
    behavior is pinned by inline regression tests.

Test plan

  • Rust: 1163 lib + 19 CLI tests, cargo clippy --all-targets --all-features -- -D warnings clean.
  • Ruby: 282 runs / 1219 assertions, bundle exec rake lint clean.

@rafaelfranca
rafaelfranca requested a review from a team as a code owner July 29, 2026 19:59
@rafaelfranca
rafaelfranca force-pushed the rmf-complexity branch 3 times, most recently from e0a4058 to 11bedd2 Compare July 29, 2026 20:25
The complexity pass is a flog-style ABC report that scores assignments,
branches, and calls in Ruby source files. The CLI command `rdx complexity`
computes the complexity report for the given paths and can output in
text or JSON format.

It also supports diffing against a baseline JSON report to show changes
in complexity.

@st0012 st0012 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a lot of complexity in complexity.rs especially on receiver/namespace handling, that we already went through in https://github.com/Shopify/rubydex/blob/main/rust/rubydex/src/indexing/ruby_indexer.rs. Is it possible to build part of the complexity detection using Definition instead?

It may look like:

  1. For class & modules....etc. that we create definition for, we already handled a lot of the context building, and we can use definition attributes to calculate the penalty
  2. For things that we don't index, we keep the existing visiting logic. Yes we double index, but it's also fast

And then we merge the results from 1 and 2. As time goes on and Rubydex index more and more stuff, we'll gradually expand 1 and shrink 2, until hopefully 1 day the indexing phase's result & CFG would already be enough for complexity analysis?

Comment thread rust/rubydex/src/listing.rs Outdated
#[must_use]
pub fn workspace_path_for(paths: &[String]) -> Option<PathBuf> {
let first_path = paths.first()?;
fs::canonicalize(first_path).ok().filter(|path| path.is_dir())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if we can do paths.first and treat it as workspace path, especially if we want to support complexity check on specific paths only.
Let's say I use rdx complexity lib/, this will use lib/ as the workspace, but then miss the ./rubydex.toml file.

} else {
'#'
};
let key = format!("{namespace}{separator}{method_name}");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're losing the receiver information here. Consider this:

class Foo
  def Bar.bar; end
end

the key becomes Foo.bar.

let baseline_map: HashMap<(String, String), &MethodEntry> = baseline
.methods
.iter()
.map(|entry| ((entry.name.clone(), entry.file.clone()), entry))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think entry.file is an absolute path? Which means we can't compare reports from different machines as the absolute path may differ. Should we use the relative path to the workspace instead?

Comment thread lib/rubydex/complexity.rb
def analyze(paths, format: :text, top: 25, methods_only: false, details: false, group: false)
raise TypeError, "no implicit conversion of #{paths.class} into Array" unless paths.is_a?(Array)

native_analyze(paths.map(&:to_s), format, top, methods_only, details, group)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the to_s, isn't paths an Array[String] from the signature?

Comment thread exe/rdx
puts parser
exit
end
end.parse!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we rescue OptionParser::ParseError?

Comment thread exe/rdx
if diff_file
baseline = begin
File.read(diff_file)
rescue Errno::ENOENT

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could broaden to SystemCallError and say can't read baseline report so you also cover EACCESS, EISDIR and friends.

Comment thread ext/rubydex/complexity.c
rdxi_check_array_of_strings(paths);

long length = RARRAY_LEN(paths);
char **paths_array = rdxi_str_array_to_char(paths, (size_t)length);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to leak if rdxi_symbol_or_string_cstr or NUM2SIZET raise

Comment on lines +99 to +101
return CComplexityResult::error(&format!(
"unknown complexity format `{other}` (expected `text` or `json`)"
));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's validate the output format before doing the expensive computation in analyze

}

fn in_singleton(&self) -> bool {
self.scope_stack.iter().any(|frame| frame.is_singleton)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we limit this to the next scope?

Consider this:

class Foo
  class << self
    class Bar
      def run
        work
      end
    end
  end
end

total / f64::from(u32::try_from(methods_count).unwrap_or(u32::MAX))
};
Report {
schema_version: 1,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we ever enforce this?

let (key, start_line, end_line) = if let Some((k, s, e)) = self.method_stack.last() {
(k.clone(), *s, *e)
} else {
let k = format!("{}#none", self.namespace_string());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this collide with an actual #none method if --methods-only=false? 🤔

@rafaelfranca
rafaelfranca force-pushed the rmf-complexity branch 3 times, most recently from f46711c to 8de6c75 Compare July 30, 2026 17:15
@rafaelfranca

Copy link
Copy Markdown
Member Author

@st0012 I tried that approach right now and it instead of deleting code required me to add way more code, not to speak on the duplicated indexing for each file. I'm not sure it is worth it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants