diff --git a/lib/generators/wall_e/install/templates/wall_e_analysis.md b/lib/generators/wall_e/install/templates/wall_e_analysis.md index 0955d50..6cdf0be 100644 --- a/lib/generators/wall_e/install/templates/wall_e_analysis.md +++ b/lib/generators/wall_e/install/templates/wall_e_analysis.md @@ -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. diff --git a/lib/generators/wall_e/install/templates/wall_e_scan.yml b/lib/generators/wall_e/install/templates/wall_e_scan.yml index e1f4698..eb31247 100644 --- a/lib/generators/wall_e/install/templates/wall_e_scan.yml +++ b/lib/generators/wall_e/install/templates/wall_e_scan.yml @@ -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 diff --git a/lib/generators/wall_e/install/templates/wall_e_settings.yml b/lib/generators/wall_e/install/templates/wall_e_settings.yml index c397261..e5843b5 100644 --- a/lib/generators/wall_e/install/templates/wall_e_settings.yml +++ b/lib/generators/wall_e/install/templates/wall_e_settings.yml @@ -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 diff --git a/lib/tech_debt/analyzer.rb b/lib/tech_debt/analyzer.rb index 2798ca4..05b9078 100644 --- a/lib/tech_debt/analyzer.rb +++ b/lib/tech_debt/analyzer.rb @@ -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" @@ -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) @@ -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 @@ -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 diff --git a/lib/tech_debt/collectors/hotspot_collector.rb b/lib/tech_debt/collectors/hotspot_collector.rb new file mode 100644 index 0000000..bc8af9d --- /dev/null +++ b/lib/tech_debt/collectors/hotspot_collector.rb @@ -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 diff --git a/lib/tech_debt/config.rb b/lib/tech_debt/config.rb index 7597669..9b3f2c8 100644 --- a/lib/tech_debt/config.rb +++ b/lib/tech_debt/config.rb @@ -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 @@ -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) diff --git a/lib/tech_debt/github/issue_manager.rb b/lib/tech_debt/github/issue_manager.rb index 168c4a7..4169206 100644 --- a/lib/tech_debt/github/issue_manager.rb +++ b/lib/tech_debt/github/issue_manager.rb @@ -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 diff --git a/spec/tech_debt/collectors/hotspot_collector_spec.rb b/spec/tech_debt/collectors/hotspot_collector_spec.rb new file mode 100644 index 0000000..1ba24db --- /dev/null +++ b/spec/tech_debt/collectors/hotspot_collector_spec.rb @@ -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