Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/generators/wall_e/install/templates/wall_e_analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ You will receive a JSON object with:
- `candidates`: an array of signals from static analysis tools (dead code detectors, complexity scorers). Each has `file`, `identifier`, `type`, `detail`, and `score`.
- `code_snippets`: a map of `{ "file_path": "source code contents" }` for the flagged files.

Some candidates have `type: "hotspot"`. These are **orientation signals, not findings**: the file simply has high git churn and large size (where debt tends to concentrate), so it was included for you to inspect. Read its snippet and either diagnose a concrete debt type from the existing taxonomy below, or reject it if the file is large and busy but healthy. Never emit `hotspot` as an output `debt_type`.

# Task

1. Analyze all candidates and their corresponding source code.
Expand Down
2 changes: 2 additions & 0 deletions lib/generators/wall_e/install/templates/wall_e_scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so the hotspot collector can read git churn

- name: Setup Ruby
uses: ruby/setup-ruby@v1
Expand Down
8 changes: 8 additions & 0 deletions lib/generators/wall_e/install/templates/wall_e_settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ analysis:
- "node_modules/**"
flog_threshold: 25 # methods with a flog score above this are surfaced as high_complexity candidates
flay_threshold: 25 # structural duplication groups with mass below this are ignored
# Orientation pass: surfaces maintenance hotspots (high git churn + large files) so the LLM
# also reviews files the AST/dead-code collectors never flag. Set enabled: false to skip it.
hotspot:
enabled: true
window_months: 6 # churn window for `git log --since`
min_commits: 5 # ignore files changed fewer times than this in the window
min_loc: 100 # ignore files smaller than this many lines
max_files: 10 # cap the number of hotspot candidates per run

github:
repo: null # defaults to GITHUB_REPOSITORY env var
Expand Down
8 changes: 7 additions & 1 deletion lib/tech_debt/analyzer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require_relative "collectors/complexity_collector"
require_relative "collectors/flay_collector"
require_relative "collectors/layer_collector"
require_relative "collectors/hotspot_collector"
require_relative "github/fingerprint"
require_relative "github/agent_assigner"
require_relative "github/issue_manager"
Expand Down Expand Up @@ -44,7 +45,8 @@ def collect_candidates
Collectors::DebrideCollector.new(@config),
Collectors::ComplexityCollector.new(@config),
Collectors::FlayCollector.new(@config),
Collectors::LayerCollector.new(@config)
Collectors::LayerCollector.new(@config),
Collectors::HotspotCollector.new(@config)
]

collectors.flat_map(&:call)
Expand Down Expand Up @@ -81,6 +83,8 @@ def skip_llm_baseline_metrics(debt_type, score)
{ "pattern_present" => true }
when "structural_duplication"
{ "flay_mass" => score.to_f }
when "hotspot"
{ "hotspot_score" => score.to_f }
else
{}
end
Expand Down Expand Up @@ -114,6 +118,8 @@ def infer_baseline_metrics(item)
{ "pattern_present" => true }
when "structural_duplication"
{ "flay_mass" => item.fetch("score", 0).to_f }
when "hotspot"
{ "hotspot_score" => item.fetch("score", 0).to_f }
else
{}
end
Expand Down
84 changes: 84 additions & 0 deletions lib/tech_debt/collectors/hotspot_collector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

require "open3"
require_relative "base_collector"

module TechDebt
module Collectors
# Orientation pass. The AST/dead-code collectors only surface debt they are
# built to detect, so files that are simply large and churning constantly
# stay invisible. This collector flags maintenance hotspots: in-scope files
# that are both frequently changed (git churn) and large. Their intersection
# is where defect and review cost concentrate. Hotspots seed the LLM triage
# with files it would otherwise never look at; the model then diagnoses the
# concrete debt type from the snippet.
class HotspotCollector < BaseCollector
def call
settings = config.hotspot
return [] unless settings["enabled"]

targets = target_files
return [] if targets.empty?

churn = churn_counts(settings["window_months"])
return [] if churn.empty?

hotspots(targets, churn, settings)
end

private

def hotspots(targets, churn, settings)
in_scope = index(targets)
candidates = churn.filter_map do |file, commits|
next unless in_scope[file]
next if commits < settings["min_commits"]

loc = line_count(file)
next if loc < settings["min_loc"]

build_candidate(file, commits, loc, settings["window_months"])
end
candidates.sort_by { |candidate| -candidate[:score] }.first(settings["max_files"])
end

def build_candidate(file, commits, loc, window_months)
{
file: file,
identifier: file,
type: "hotspot",
detail: "Maintenance hotspot: #{commits} commits in the last #{window_months} months across #{loc} lines. " \
"High churn in a large file concentrates defect and review cost; inspect for responsibilities " \
"that should be extracted.",
score: commits * loc
}
end

# Counts commits touching each Ruby file in the window, repo-wide. Filtering
# to in-scope files happens in #hotspots so we never shell out per file.
def churn_counts(window_months)
stdout, _stderr, status = Open3.capture3(
"git", "log", "--since=#{window_months} months ago", "--name-only", "--format="
)
return {} unless status.success?

counts = Hash.new(0)
stdout.each_line do |line|
path = line.strip
counts[path] += 1 if path.end_with?(".rb")
end
counts
end

def line_count(file)
File.readlines(file).size
rescue StandardError
0
end

def index(targets)
targets.each_with_object({}) { |file, memo| memo[file] = true }
end
end
end
end
12 changes: 12 additions & 0 deletions lib/tech_debt/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ class Config
SUMMARY_PATH = "tmp/wall_e_report.json"
DEFAULT_FLOG_THRESHOLD = 25
DEFAULT_FLAY_THRESHOLD = 25
HOTSPOT_DEFAULTS = {
"enabled" => true,
"window_months" => 6,
"min_commits" => 5,
"min_loc" => 100,
"max_files" => 10
}.freeze

attr_reader :raw

Expand Down Expand Up @@ -46,6 +53,11 @@ def flay_threshold
analysis.fetch("flay_threshold", DEFAULT_FLAY_THRESHOLD).to_i
end

def hotspot
custom = analysis["hotspot"]
HOTSPOT_DEFAULTS.merge(custom.is_a?(Hash) ? custom : {})
end

def auto_assign
value = raw["auto_assign"]
return { "enabled" => false } unless value.is_a?(Hash)
Expand Down
2 changes: 2 additions & 0 deletions lib/tech_debt/github/issue_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ def score_details(item)
"Complexity score: #{score}/#{@config.flog_threshold} threshold (higher is worse)"
when 'dead_code'
"Dead-code signal score: #{score} (binary/static detector signal, not a complexity scale)"
when 'hotspot'
"Hotspot score: #{score} (commits in window multiplied by file size; higher means more churn-weighted debt risk)"
when 'semantic_duplication'
"Impact score: #{score} — duplicated lines across all locations"
else
Expand Down
98 changes: 98 additions & 0 deletions spec/tech_debt/collectors/hotspot_collector_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

require "spec_helper"
require "open3"
require "tech_debt/config"
require "tech_debt/collectors/hotspot_collector"

RSpec.describe TechDebt::Collectors::HotspotCollector do
let(:settings) do
{"enabled" => true, "window_months" => 6, "min_commits" => 5, "min_loc" => 100, "max_files" => 10}
end
let(:config) { instance_double(TechDebt::Config, hotspot: settings) }

subject(:collector) { described_class.new(config, files: files) }

let(:files) { %w[app/models/order.rb app/models/user.rb app/models/small.rb app/models/rare.rb] }

# commits-in-window per file (other.rb is churned but out of the configured scope)
let(:commits) do
{
"app/models/order.rb" => 8, "app/models/user.rb" => 6,
"app/models/small.rb" => 7, "app/models/rare.rb" => 2,
"app/models/other.rb" => 10
}
end
# line counts per file
let(:loc) do
{"app/models/order.rb" => 300, "app/models/user.rb" => 200, "app/models/small.rb" => 50, "app/models/rare.rb" => 400}
end

before do
allow(File).to receive(:file?).and_return(true)
allow(File).to receive(:readlines) { |path| Array.new(loc.fetch(path, 0), "x\n") }
git_log = commits.flat_map { |file, count| Array.new(count, file) }.join("\n") + "\n"
allow(Open3).to receive(:capture3).and_return([git_log, "", double(success?: true)])
end

describe "#call" do
it "flags files that are both high-churn and large" do
expect(collector.call.map { |c| c[:file] }).to contain_exactly("app/models/order.rb", "app/models/user.rb")
end

it "labels them as hotspot candidates scored by churn times size" do
order = collector.call.find { |c| c[:file] == "app/models/order.rb" }
expect(order).to include(type: "hotspot", identifier: "app/models/order.rb", score: 2400)
end

it "excludes files below the commit threshold" do
expect(collector.call.map { |c| c[:file] }).not_to include("app/models/rare.rb")
end

it "excludes files below the loc threshold" do
expect(collector.call.map { |c| c[:file] }).not_to include("app/models/small.rb")
end

it "excludes churned files outside the analysis scope" do
expect(collector.call.map { |c| c[:file] }).not_to include("app/models/other.rb")
end

it "sorts hotspots by score descending" do
expect(collector.call.map { |c| c[:file] }).to eq(["app/models/order.rb", "app/models/user.rb"])
end

context "with a max_files cap" do
let(:settings) { super().merge("max_files" => 1) }

it "returns only the top-scoring hotspots" do
expect(collector.call.map { |c| c[:file] }).to eq(["app/models/order.rb"])
end
end

context "when disabled" do
let(:settings) { super().merge("enabled" => false) }

it "returns nothing without shelling out to git" do
expect(Open3).not_to receive(:capture3)
expect(collector.call).to eq([])
end
end

context "when git log fails" do
before { allow(Open3).to receive(:capture3).and_return(["", "fatal: not a git repository", double(success?: false)]) }

it "returns nothing" do
expect(collector.call).to eq([])
end
end

context "when no files are in scope" do
let(:files) { [] }

it "returns nothing without shelling out to git" do
expect(Open3).not_to receive(:capture3)
expect(collector.call).to eq([])
end
end
end
end