From 3ea6da1efb621ca4028848b20d2ddc27641ae237 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:31:34 +0000 Subject: [PATCH 1/7] refactor: memoize with ||= like the rest of the codebase Five analyzers memoized with a guard (`return @_x if @_x`, or a `@_done = true` flag) because their builders filled instance variables as a side effect. kimera mutated each guard away, and all 7 mutants survived, including in isolation: recomputing from the same trees yields an equal result, so only the repeated work differed. The builders now return what they build (Branches#chart, Refs#holder, Ownership#chart, Roster#registered and #counted), and each view is an `@_x ||=` memo like the other 93 in lib. kimera treats `||=` as memoization and judges what it computes instead. `hashira --json --top 100000` output is byte-identical before and after on kimera, hashira, rubocop-kata and a 2,600-file Rails app. kimera on the five files: 115 mutants, 115 killed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PcDfzLuRgjoSnfPLrTaA4h --- lib/hashira/boundaries.rb | 14 +++-------- lib/hashira/coupling/roster.rb | 43 ++++++++------------------------- lib/hashira/smells/branches.rb | 14 ++++------- lib/hashira/smells/ownership.rb | 41 ++++++++----------------------- lib/hashira/smells/refs.rb | 19 ++++++--------- 5 files changed, 36 insertions(+), 95 deletions(-) diff --git a/lib/hashira/boundaries.rb b/lib/hashira/boundaries.rb index 546adb5..a6c3255 100644 --- a/lib/hashira/boundaries.rb +++ b/lib/hashira/boundaries.rb @@ -22,11 +22,7 @@ def initialize(records, trees) @trees = trees end - def interpreted - return @_interpreted if @_interpreted - verify - @_interpreted = entries.map(&:root) - end + def interpreted = @_interpreted ||= entries.each { check(it) }.map(&:root) private @@ -37,11 +33,9 @@ def declaration(record) Declaration.new(*%w[root role entrypoint reason].map { record[it] }) end - def verify - entries.each do - it.verify - route(it) - end + def check(entry) + entry.verify + route(entry) end def route(entry) diff --git a/lib/hashira/coupling/roster.rb b/lib/hashira/coupling/roster.rb index 630008f..fc08ec8 100644 --- a/lib/hashira/coupling/roster.rb +++ b/lib/hashira/coupling/roster.rb @@ -5,50 +5,27 @@ def initialize(placed) @placed = placed end - def registry - admitted - @_registry - end + def registry = @_registry ||= registered - def types - admitted - @_types - end + def types = @_types ||= counted.each_with_object(Hash.new(0)) { |(_, package), counts| counts[package] += 1 } def origins = registry.origins - def type?(path) - admitted - @_typed.include?(path) - end + def type?(path) = typed.include?(path) def packages = types.keys | registry.packages private - def admitted - return if @_admitted - @_admitted = true - blank - fill - end + def packaged = @_packaged ||= @placed.select { |_, package| package } - def blank - @_registry = Hashira::Coupling::ConstantRegistry.new - @_types = Hash.new(0) - @_typed = Set.new + def registered + Hashira::Coupling::ConstantRegistry.new.tap do |registry| + packaged.each { |definition, package| registry.register(definition.path, package) } + end end - def fill - counted = Set.new - @placed.each { |definition, package| admit(definition, package, counted) } - end + def counted = packaged.select { |definition, _| definition.counted? }.uniq { |definition, _| definition.path } - def admit(definition, package, counted) - return unless package - path = definition.path - @_registry.register(path, package) - @_typed << path if definition.type? - @_types[package] += 1 if definition.counted? && counted.add?(path) - end + def typed = @_typed ||= packaged.filter_map { |definition, _| definition.path if definition.type? }.to_set end diff --git a/lib/hashira/smells/branches.rb b/lib/hashira/smells/branches.rb index bea095d..961d6fa 100644 --- a/lib/hashira/smells/branches.rb +++ b/lib/hashira/smells/branches.rb @@ -19,16 +19,12 @@ def parted?(here, there) = here.first.equal?(there.first) && here.last != there. def trail(node) = trails.fetch(node) - def trails - return @_trails if @_trails - @_trails = {}.compare_by_identity - chart(@root, []) - @_trails - end + def trails = @_trails ||= chart(@root, [], {}.compare_by_identity) - def chart(node, trail) - @_trails[node] = trail - node.compact_child_nodes.each { chart(it, trail + taken(node, it)) } + def chart(node, trail, charted) + charted[node] = trail + node.compact_child_nodes.each { chart(it, trail + taken(node, it), charted) } + charted end def taken(node, child) diff --git a/lib/hashira/smells/ownership.rb b/lib/hashira/smells/ownership.rb index b7077c3..0a03c66 100644 --- a/lib/hashira/smells/ownership.rb +++ b/lib/hashira/smells/ownership.rb @@ -5,48 +5,27 @@ def initialize(trees) @trees = trees end - def owned?(segments) - surveyed - @_suffixes.include?(segments.join("::")) - end + def owned?(segments) = suffixed.include?(segments.join("::")) - def keys(segments) - surveyed - @_tables.fetch(segments.join("::"), []) - end + def keys(segments) = tables.fetch(segments.join("::"), []) private - def surveyed - return if @_surveyed - @_surveyed = true - @_suffixes = Set.new - @_tables = {} - @trees.each { survey(it) } - end + def suffixed = @_suffixed ||= (walked.map(&:last) + constants.map(&:first)).flat_map { suffixes(it) }.to_set - def survey(tree) - Hashira::Analysis::TypeWalk.each(tree) do |node, full| - absorb(full) - Hashira::Analysis::Syntax.constants(node).each { record(full, it) } - end - end + def tables = @_tables ||= constants.each_with_object({}) { |(path, value), found| chart(found, path, thaw(value)) } - def record(full, constant) - path = full + [constant.name.to_s] - absorb(path) - chart(path, thaw(constant.value)) - end + def constants = @_constants ||= walked.flat_map { |node, full| declared(node, full) } - def absorb(path) - @_suffixes.merge(suffixes(path)) - end + def declared(node, full) = Hashira::Analysis::Syntax.constants(node).map { [full + [it.name.to_s], it.value] } + + def walked = @_walked ||= @trees.flat_map { Hashira::Analysis::TypeWalk.enum_for(:each, it).to_a } - def chart(path, value) + def chart(found, path, value) return unless value.is_a?(Prism::HashNode) keys = value.elements.map { spine(it) } return if keys.empty? || keys.any?(&:nil?) - suffixes(path).each { @_tables[it] = keys } + suffixes(path).each { found[it] = keys } end def thaw(value) diff --git a/lib/hashira/smells/refs.rb b/lib/hashira/smells/refs.rb index 03f469d..3f24463 100644 --- a/lib/hashira/smells/refs.rb +++ b/lib/hashira/smells/refs.rb @@ -28,17 +28,14 @@ def envious private - def tallies - return @_tallies if @_tallies - @_tallies = {} - Hashira::Smells::Scope.inside(@node).each { record(it) } - @_tallies - end + def tallies = @_tallies ||= sightings.group_by(&:first).transform_values { |pairs| pairs.map(&:last) } + + def sightings = Hashira::Smells::Scope.inside(@node).map { [holder(it), it.location.start_line] }.select(&:first) - def record(node) - return note(:self, node) if selfish?(node) - return note(node.receiver.name, node) if local?(node) - note(node.name, node) if node.is_a?(Prism::LocalVariableOperatorWriteNode) + def holder(node) + return :self if selfish?(node) + return node.receiver.name if local?(node) + node.name if node.is_a?(Prism::LocalVariableOperatorWriteNode) end def selfish?(node) @@ -48,6 +45,4 @@ def selfish?(node) def implicit?(node) = node.is_a?(Prism::CallNode) && !node.receiver def local?(node) = node.is_a?(Prism::CallNode) && LOCALS.include?(node.receiver.class) && node.name != :new - - def note(name, node) = (@_tallies[name] ||= []) << node.location.start_line end From 21bf246145769de80cf979ee0f520761bba97036 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:33:13 +0000 Subject: [PATCH 2/7] refactor(complexity): memoize the cognitive walk with ||= Deleting `walked` from CognitiveScore#nesting survived, including in isolation: nesting is read only by IfChain and RescueScan mid-walk, when the walk has already begun, so the call never did anything. With it gone, the `return if @_walked` guard showed the same memo survivors as the analyzers in the previous commit. Its mutants had been killed only because re-entering the walk from nesting recursed until the stack overflowed. The walk now returns its increments and calls, memoized with ||=. Nothing reads them mid-walk, so nothing re-enters. kimera on the file: 20 mutants, 20 killed. `hashira --json` output is unchanged on the four codebases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PcDfzLuRgjoSnfPLrTaA4h --- lib/hashira/complexity/cognitive_score.rb | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/lib/hashira/complexity/cognitive_score.rb b/lib/hashira/complexity/cognitive_score.rb index e136e0e..31ec5a5 100644 --- a/lib/hashira/complexity/cognitive_score.rb +++ b/lib/hashira/complexity/cognitive_score.rb @@ -16,20 +16,11 @@ def initialize(node) @node = node end - def increments - walked - @_increments - end + def increments = walked.fetch(:increments) - def calls - walked - @_calls - end + def calls = walked.fetch(:calls) - def nesting - walked - @_nesting - end + def nesting = @_nesting def total = increments.sum(&:cost) @@ -50,11 +41,12 @@ def deeper private - def walked - return if @_walked - @_walked = true + def walked = @_walked ||= walk + + def walk blank visit(@node.body) + { increments: @_increments, calls: @_calls } end def blank From 4abf47ff303057a521c775f486d8453f2052ebe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:34:31 +0000 Subject: [PATCH 3/7] perf(duplication): prefilter fragments at the exact-clone floor `fragment.mass >= PREFILTER` -> `>` survived, including in isolation: nothing observable separates a 12-token fragment from a 13-token one. A fragment under BASE_MASS (16) never fits a uniform cluster, and a thin one needs a canonical of about 42+ tokens, which a near-miss (at most 1.5x apart) reaches from a 12-token fragment only through a chain of several near-misses. Clusters now prefilters at BASE_MASS, and the existing "exact clone whose mass sits exactly on the floor" spec pins the boundary. Clusters are identical with the prefilter at 12 or 16 on kimera, hashira, rubocop-kata and a 2,600-file Rails app (221 clusters), and the duplication pass takes 6.9s instead of 8.9s on the latter. kimera on the file: 27 mutants, 27 killed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PcDfzLuRgjoSnfPLrTaA4h --- CHANGELOG.md | 10 ++++++++++ lib/hashira/duplication/clusters.rb | 3 +-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec16527..76b304e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Duplication pairs only fragments of at least 16 tokens, the exact-clone + floor, instead of 12. A shorter fragment could reach a report only through + a chain of near-misses. Findings on four codebases, including a 2,600-file + Rails app, are unchanged, and the duplication pass runs about a fifth + faster there. + ## [0.10.1] - 2026-09-26 ### Fixed diff --git a/lib/hashira/duplication/clusters.rb b/lib/hashira/duplication/clusters.rb index 15fc562..e248707 100644 --- a/lib/hashira/duplication/clusters.rb +++ b/lib/hashira/duplication/clusters.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class Hashira::Duplication::Clusters - PREFILTER = 12 BASE_MASS = 16 NEAR_MASS = 40 PAIR = 2 @@ -19,7 +18,7 @@ def sorted private - def fragments = @_fragments ||= @all.select { |fragment| fragment.mass >= PREFILTER }.reject(&:schema?) + def fragments = @_fragments ||= @all.select { |fragment| fragment.mass >= BASE_MASS }.reject(&:schema?) def sets = @_sets ||= Hashira::Duplication::UnionFind.new From dcd11b0ab49ec6a955d591e87617b512189ab758 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:35:04 +0000 Subject: [PATCH 4/7] chore(kimera): ignore the equivalent empty-words filter in Echo `[...] unless words.empty?` -> `unless false` survives in isolation and is equivalent: RollCall keeps only word sets that at least 3 files share, so a file with no words never joins a roll. The filter is a real optimization, not dead code. Without it the coupling pass takes 2.7-3.3s instead of 2.1s on a 2,600-file app, with identical output. Any form of the filter keeps a "keep every file" mutant, so it gets an ignore entry naming the mechanism, and max_ignored rises to 1 with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PcDfzLuRgjoSnfPLrTaA4h --- .kimera.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.kimera.yml b/.kimera.yml index 52f39c9..c31b062 100644 --- a/.kimera.yml +++ b/.kimera.yml @@ -8,3 +8,10 @@ jobs: 4 max_survivors: 0 max_errors: 0 fail_on_no_coverage: true +max_ignored: 1 +ignore: +- file: lib/hashira/coupling/echo.rb + line: 18 + label: condition => false + reason: RollCall keeps only word sets at least 3 files share (MIN_WORDS = MIN_FILES = 3), so a file with no words never + joins a roll; the filter only keeps such files out of the pairwise scan (about 30% of coupling time on a 2,600-file app) From 4c0ec7d68aa69cbe6bb5e7726d83c6af49a40dab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:36:03 +0000 Subject: [PATCH 5/7] refactor(roster): split counted so it doesn't envy its definitions hashira's own ratchet flagged the new Roster#counted as feature_envy (two references to `definition`, one to self). Filtering and de-duplicating are now one step each. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PcDfzLuRgjoSnfPLrTaA4h --- lib/hashira/coupling/roster.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/hashira/coupling/roster.rb b/lib/hashira/coupling/roster.rb index fc08ec8..7a42753 100644 --- a/lib/hashira/coupling/roster.rb +++ b/lib/hashira/coupling/roster.rb @@ -25,7 +25,9 @@ def registered end end - def counted = packaged.select { |definition, _| definition.counted? }.uniq { |definition, _| definition.path } + def counted = countable.uniq { |definition, _| definition.path } + + def countable = packaged.select { |definition, _| definition.counted? } def typed = @_typed ||= packaged.filter_map { |definition, _| definition.path if definition.type? }.to_set end From af166e212fe69d59e22cd5dced15cc9d13bcf69f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:08:57 +0000 Subject: [PATCH 6/7] Back out the memo refactors and the prefilter change These changed production code only so kimera would stop reporting survivors, and none of them fixed a bug: - The ||= rewrites of seven guard-style memos (and CognitiveScore's walk) are behavior-identical; kimera just doesn't mutate `@x ||=`. Their mutants are equivalent (recomputing yields an equal result), and the gap is kimera not recognizing guard-style memoization, to be fixed there instead. - Prefiltering at BASE_MASS instead of 12 is a design decision about duplication, not a test gap, so it's left for its own change. This reverts 3ea6da1, 21bf246, 4abf47f and 4c0ec7d. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PcDfzLuRgjoSnfPLrTaA4h --- CHANGELOG.md | 10 ----- lib/hashira/boundaries.rb | 14 +++++-- lib/hashira/complexity/cognitive_score.rb | 22 +++++++---- lib/hashira/coupling/roster.rb | 45 +++++++++++++++++------ lib/hashira/duplication/clusters.rb | 3 +- lib/hashira/smells/branches.rb | 14 ++++--- lib/hashira/smells/ownership.rb | 41 ++++++++++++++++----- lib/hashira/smells/refs.rb | 19 ++++++---- 8 files changed, 112 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b304e..ec16527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - -### Changed - -- Duplication pairs only fragments of at least 16 tokens, the exact-clone - floor, instead of 12. A shorter fragment could reach a report only through - a chain of near-misses. Findings on four codebases, including a 2,600-file - Rails app, are unchanged, and the duplication pass runs about a fifth - faster there. - ## [0.10.1] - 2026-09-26 ### Fixed diff --git a/lib/hashira/boundaries.rb b/lib/hashira/boundaries.rb index a6c3255..546adb5 100644 --- a/lib/hashira/boundaries.rb +++ b/lib/hashira/boundaries.rb @@ -22,7 +22,11 @@ def initialize(records, trees) @trees = trees end - def interpreted = @_interpreted ||= entries.each { check(it) }.map(&:root) + def interpreted + return @_interpreted if @_interpreted + verify + @_interpreted = entries.map(&:root) + end private @@ -33,9 +37,11 @@ def declaration(record) Declaration.new(*%w[root role entrypoint reason].map { record[it] }) end - def check(entry) - entry.verify - route(entry) + def verify + entries.each do + it.verify + route(it) + end end def route(entry) diff --git a/lib/hashira/complexity/cognitive_score.rb b/lib/hashira/complexity/cognitive_score.rb index 31ec5a5..e136e0e 100644 --- a/lib/hashira/complexity/cognitive_score.rb +++ b/lib/hashira/complexity/cognitive_score.rb @@ -16,11 +16,20 @@ def initialize(node) @node = node end - def increments = walked.fetch(:increments) + def increments + walked + @_increments + end - def calls = walked.fetch(:calls) + def calls + walked + @_calls + end - def nesting = @_nesting + def nesting + walked + @_nesting + end def total = increments.sum(&:cost) @@ -41,12 +50,11 @@ def deeper private - def walked = @_walked ||= walk - - def walk + def walked + return if @_walked + @_walked = true blank visit(@node.body) - { increments: @_increments, calls: @_calls } end def blank diff --git a/lib/hashira/coupling/roster.rb b/lib/hashira/coupling/roster.rb index 7a42753..630008f 100644 --- a/lib/hashira/coupling/roster.rb +++ b/lib/hashira/coupling/roster.rb @@ -5,29 +5,50 @@ def initialize(placed) @placed = placed end - def registry = @_registry ||= registered + def registry + admitted + @_registry + end - def types = @_types ||= counted.each_with_object(Hash.new(0)) { |(_, package), counts| counts[package] += 1 } + def types + admitted + @_types + end def origins = registry.origins - def type?(path) = typed.include?(path) + def type?(path) + admitted + @_typed.include?(path) + end def packages = types.keys | registry.packages private - def packaged = @_packaged ||= @placed.select { |_, package| package } - - def registered - Hashira::Coupling::ConstantRegistry.new.tap do |registry| - packaged.each { |definition, package| registry.register(definition.path, package) } - end + def admitted + return if @_admitted + @_admitted = true + blank + fill end - def counted = countable.uniq { |definition, _| definition.path } + def blank + @_registry = Hashira::Coupling::ConstantRegistry.new + @_types = Hash.new(0) + @_typed = Set.new + end - def countable = packaged.select { |definition, _| definition.counted? } + def fill + counted = Set.new + @placed.each { |definition, package| admit(definition, package, counted) } + end - def typed = @_typed ||= packaged.filter_map { |definition, _| definition.path if definition.type? }.to_set + def admit(definition, package, counted) + return unless package + path = definition.path + @_registry.register(path, package) + @_typed << path if definition.type? + @_types[package] += 1 if definition.counted? && counted.add?(path) + end end diff --git a/lib/hashira/duplication/clusters.rb b/lib/hashira/duplication/clusters.rb index e248707..15fc562 100644 --- a/lib/hashira/duplication/clusters.rb +++ b/lib/hashira/duplication/clusters.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Hashira::Duplication::Clusters + PREFILTER = 12 BASE_MASS = 16 NEAR_MASS = 40 PAIR = 2 @@ -18,7 +19,7 @@ def sorted private - def fragments = @_fragments ||= @all.select { |fragment| fragment.mass >= BASE_MASS }.reject(&:schema?) + def fragments = @_fragments ||= @all.select { |fragment| fragment.mass >= PREFILTER }.reject(&:schema?) def sets = @_sets ||= Hashira::Duplication::UnionFind.new diff --git a/lib/hashira/smells/branches.rb b/lib/hashira/smells/branches.rb index 961d6fa..bea095d 100644 --- a/lib/hashira/smells/branches.rb +++ b/lib/hashira/smells/branches.rb @@ -19,12 +19,16 @@ def parted?(here, there) = here.first.equal?(there.first) && here.last != there. def trail(node) = trails.fetch(node) - def trails = @_trails ||= chart(@root, [], {}.compare_by_identity) + def trails + return @_trails if @_trails + @_trails = {}.compare_by_identity + chart(@root, []) + @_trails + end - def chart(node, trail, charted) - charted[node] = trail - node.compact_child_nodes.each { chart(it, trail + taken(node, it), charted) } - charted + def chart(node, trail) + @_trails[node] = trail + node.compact_child_nodes.each { chart(it, trail + taken(node, it)) } end def taken(node, child) diff --git a/lib/hashira/smells/ownership.rb b/lib/hashira/smells/ownership.rb index 0a03c66..b7077c3 100644 --- a/lib/hashira/smells/ownership.rb +++ b/lib/hashira/smells/ownership.rb @@ -5,27 +5,48 @@ def initialize(trees) @trees = trees end - def owned?(segments) = suffixed.include?(segments.join("::")) + def owned?(segments) + surveyed + @_suffixes.include?(segments.join("::")) + end - def keys(segments) = tables.fetch(segments.join("::"), []) + def keys(segments) + surveyed + @_tables.fetch(segments.join("::"), []) + end private - def suffixed = @_suffixed ||= (walked.map(&:last) + constants.map(&:first)).flat_map { suffixes(it) }.to_set - - def tables = @_tables ||= constants.each_with_object({}) { |(path, value), found| chart(found, path, thaw(value)) } + def surveyed + return if @_surveyed + @_surveyed = true + @_suffixes = Set.new + @_tables = {} + @trees.each { survey(it) } + end - def constants = @_constants ||= walked.flat_map { |node, full| declared(node, full) } + def survey(tree) + Hashira::Analysis::TypeWalk.each(tree) do |node, full| + absorb(full) + Hashira::Analysis::Syntax.constants(node).each { record(full, it) } + end + end - def declared(node, full) = Hashira::Analysis::Syntax.constants(node).map { [full + [it.name.to_s], it.value] } + def record(full, constant) + path = full + [constant.name.to_s] + absorb(path) + chart(path, thaw(constant.value)) + end - def walked = @_walked ||= @trees.flat_map { Hashira::Analysis::TypeWalk.enum_for(:each, it).to_a } + def absorb(path) + @_suffixes.merge(suffixes(path)) + end - def chart(found, path, value) + def chart(path, value) return unless value.is_a?(Prism::HashNode) keys = value.elements.map { spine(it) } return if keys.empty? || keys.any?(&:nil?) - suffixes(path).each { found[it] = keys } + suffixes(path).each { @_tables[it] = keys } end def thaw(value) diff --git a/lib/hashira/smells/refs.rb b/lib/hashira/smells/refs.rb index 3f24463..03f469d 100644 --- a/lib/hashira/smells/refs.rb +++ b/lib/hashira/smells/refs.rb @@ -28,14 +28,17 @@ def envious private - def tallies = @_tallies ||= sightings.group_by(&:first).transform_values { |pairs| pairs.map(&:last) } - - def sightings = Hashira::Smells::Scope.inside(@node).map { [holder(it), it.location.start_line] }.select(&:first) + def tallies + return @_tallies if @_tallies + @_tallies = {} + Hashira::Smells::Scope.inside(@node).each { record(it) } + @_tallies + end - def holder(node) - return :self if selfish?(node) - return node.receiver.name if local?(node) - node.name if node.is_a?(Prism::LocalVariableOperatorWriteNode) + def record(node) + return note(:self, node) if selfish?(node) + return note(node.receiver.name, node) if local?(node) + note(node.name, node) if node.is_a?(Prism::LocalVariableOperatorWriteNode) end def selfish?(node) @@ -45,4 +48,6 @@ def selfish?(node) def implicit?(node) = node.is_a?(Prism::CallNode) && !node.receiver def local?(node) = node.is_a?(Prism::CallNode) && LOCALS.include?(node.receiver.class) && node.name != :new + + def note(name, node) = (@_tallies[name] ||= []) << node.location.start_line end From 348b58ceda45580d6ad500286e1ca3ae0f587578 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:09:06 +0000 Subject: [PATCH 7/7] refactor(complexity): drop the dead walked call from CognitiveScore#nesting Deleting it survived, including in isolation: nesting is read only by IfChain and RescueScan mid-walk, after the walk has begun, so the call never did anything. It was also the only reason the `return if @_walked` guard's mutants were killed: without the guard, nesting re-entered the walk and recursed until the stack overflowed. Those two mutants now survive like the other guard-style memos, which is kimera's gap to close, not hashira's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PcDfzLuRgjoSnfPLrTaA4h --- lib/hashira/complexity/cognitive_score.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/hashira/complexity/cognitive_score.rb b/lib/hashira/complexity/cognitive_score.rb index e136e0e..4ef05eb 100644 --- a/lib/hashira/complexity/cognitive_score.rb +++ b/lib/hashira/complexity/cognitive_score.rb @@ -26,10 +26,7 @@ def calls @_calls end - def nesting - walked - @_nesting - end + def nesting = @_nesting def total = increments.sum(&:cost)