Skip to content

Fix complexity blind spots, exact churn, a meaningful hot flag, and 2× faster runs - #10

Merged
giacope merged 6 commits into
mainfrom
claude/field-test-fixes
Sep 30, 2026
Merged

giacope merged 6 commits into
mainfrom
claude/field-test-fixes

Conversation

@giacope

@giacope giacope commented Sep 30, 2026

Copy link
Copy Markdown
Owner

These fixes come out of running hashira on 15 MIT-licensed Rails apps and gems: rails, rubocop, chatwoot, feedbin, rubygems.org, huginn, errbit, fat_free_crm, postal, devise, activeadmin, good_job, sinatra, faker and hanami.

Fixes

Complexity only scored methods that sit directly in a class body (dc0b6ce)

  • Before, methods written as private def / protected def, defined inside class << self or class_methods do, behind an if, or in a Const = Data.define do … end block were never scored.
  • That also meant turning def into private def made a complexity regression disappear from --ratchet.
  • A new Complexity::Body walk now covers the whole type body. It stops at nested classes and modules, so no method is counted twice. Singleton contexts name methods Class.method.
  • Across the 15 repos this adds 24 complexity findings, for example faker's Faker::Internet.username (18).
  • Heads-up: users' --ratchet will report these newly visible methods as NEW findings. This is noted in the CHANGELOG.

Churn matched files by bare suffix across the whole repo (08d83cf, 7dc824a)

  • It took the maximum over every path ending in the file's name. Rails' action_view/base.rb was charged activerecord's 1218 commits; its own count is 352. A model picked up its spec's churn, and base.rb even matched database.rb. Only the first target's repo was read.
  • GitLog now does one rev-parse --show-toplevel and one git log --full-history -- <targets> with literal pathspecs. It keys each path relative to its own target, so hits is an exact hash lookup.
  • --full-history is needed because a path-limited log otherwise simplifies away side-branch commits.
  • Checked against an exact per-path count: every hotspot row in all 15 repos now matches (0 mismatches). Before, 42 rails rows were inflated more than 2×.

"Both sites change often" was on 2265 of 2265 clones (c2d79dd)

  • Any file with a single commit counted as changing often.
  • A clone is now hot only when at least two distinct files it spans change more than the typical analyzed file, i.e. above the median commit count.
  • Across the 15 repos this leaves 724 of 2265 clones hot, and same-file pairs no longer qualify.

Performance (byte-identical output)

  • Duplication (16d5ebd): each statement's subtree is walked once per file through a shared Walks cache. Before, it was walked again for every window of up to 12 statements that held it.
  • Smells (cbbd6ad): Lineage#assigned is memoized per class name. A namespace every file reopens, such as rubocop's RuboCop::Cop, used to redo the same ancestry walk hundreds of times.
full run before after
rubocop 48.6s 18.6s
rails (12 targets) 92.7s 42.7s
chatwoot 31.3s 16.1s

Across all 15 repos, every finding other than complexity and duplication is unchanged, and duplication clusters are identical apart from the hot flag.

Checks

  • bin/ci passes: rubocop, rspec at 100% line and branch coverage, the self-gate, and the self-ratchet.
  • kimera ci --since origin/main: 71 mutants on the changed lines, 0 survivors.
  • Each memo has a spec that fails when the memo is removed. Two dead guards that mutation testing flagged were deleted.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CdcWkAxkqwRCup8VkCPcFA


Generated by Claude Code

… and blocks

Only defs sitting directly in a class or module body were scored, so a
method written as `private def`, inside `class << self`, inside
`class_methods do`, behind an `if`, or in a `Data.define do` block was
invisible to complexity — and to the ratchet, where turning `def` into
`private def` made a regression disappear.

The walk now descends through a type's whole body, stopping at nested
classes and modules (which score their own methods). `class << self` and
`class_methods do` name methods with `.`; a constant assigned a
`Data.define`/`Struct.new` block owns the methods inside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdcWkAxkqwRCup8VkCPcFA
…ctory

Churn matched a file by bare suffix against the whole repository's
history, taking the maximum: `base.rb` in actionview was charged the
1218 commits of activerecord's `base.rb` (its own count is 352), a model
took its spec's churn, and `base.rb` even matched `database.rb`. Only
the first analyzed directory's repository was read.

GitLog now asks git for the top level once, logs only the analyzed
directories (literal pathspecs), and keys every path relative to the
directory it sits in — the same display path the analyzers use — so a
lookup is exact and constant-time instead of a scan over every path in
history. A directory outside the repository no longer voids the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdcWkAxkqwRCup8VkCPcFA
`Churn#hot?` counted sites sitting in any file with at least one commit,
so in a git repository every clone was "hot": 2265 of 2265 across fifteen
open-source codebases, same-file pairs included. The warning carried no
information and lengthened every duplication finding.

A clone is now hot when at least two distinct files it spans change
more often than the typical analyzed file — above the median commit
count of the history hashira read. A history where every file changed
once has no hot clone, and a clone inside one file never is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdcWkAxkqwRCup8VkCPcFA
Every sliding window re-walked the subtree of each statement it holds —
up to twelve windows per statement, again at every nesting level, and
once more for a method taken whole. A per-file Walks cache, shared by
the file's sequences and whole-node fragments, walks each root once.

Output is byte-identical; duplication alone on rubocop drops from about
24s to 15s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdcWkAxkqwRCup8VkCPcFA
Lineage#assigned walked the whole ancestry and swept every ancestor's
defs for each type context — and a namespace every file reopens (every
rubocop cop opens `RuboCop::Cop`) is hundreds of contexts sharing one
name, each redoing the same walk. The answer depends only on the name,
so it is now remembered per name. The existing sweep memo shares the
same `remember` helper.

Output is byte-identical; smells alone on rubocop drop from about 14s
to 7s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdcWkAxkqwRCup8VkCPcFA
Restricting `git log` to the analyzed directories switched on git's
default history simplification, which skips a side branch's commits when
the merge leaves the path as the main line had it — fat_free_crm's
contact.rb read 68 commits instead of 72. `--full-history` counts them
as the unrestricted log always did; rails pays under a second for it.

The empty-top-level guard in GitLog#roots changed nothing: outside a
repository `git log` fails and reads as no history either way.

Also records the field-test fixes in the changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdcWkAxkqwRCup8VkCPcFA
@giacope
giacope merged commit bb81961 into main Sep 30, 2026
4 checks passed
@giacope
giacope deleted the claude/field-test-fixes branch September 30, 2026 14:27
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.

2 participants