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
27 changes: 27 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ PATH
wall-e (0.1.0)
debride (~> 1.12)
faraday-retry
flay (~> 2.13)
flog (~> 4.8)
octokit (~> 9.0)
railties (>= 7.0)
Expand Down Expand Up @@ -55,6 +56,7 @@ GEM
path_expander (~> 2.0)
prism (~> 1.7)
sexp_processor (~> 4.17)
diff-lcs (1.6.2)
drb (2.2.3)
erb (6.0.2)
erubi (1.13.1)
Expand All @@ -69,6 +71,11 @@ GEM
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
flay (2.14.4)
erubi (~> 1.10)
path_expander (~> 2.0)
prism (~> 1.7)
sexp_processor (~> 4.0)
flog (4.9.4)
path_expander (~> 2.0)
prism (~> 1.7)
Expand All @@ -94,8 +101,12 @@ GEM
multipart-post (2.4.1)
net-http (0.9.1)
uri (>= 0.11.1)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
octokit (9.2.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
Expand Down Expand Up @@ -146,6 +157,19 @@ GEM
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
rspec (3.13.2)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.7)
rubocop (1.84.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
Expand Down Expand Up @@ -199,10 +223,13 @@ GEM
zeitwerk (2.7.5)

PLATFORMS
arm64-darwin
x86_64-darwin
x86_64-linux

DEPENDENCIES
rake
rspec
standard
wall-e!

Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Running the install generator adds these project files:
| -------------------------------------- | -------------------------------------------- |
| `.github/workflows/wall_e_scan.yml` | Scheduled/manual scan |
| `.github/workflows/wall_e_verify.yml` | Optional PR verification on `pull_request` |
| `.github/workflows/wall_e_pr_delta.yml` | Per-PR debt delta comment on `pull_request` |
| `config/wall_e_settings.yml` | Scanner, LLM, GitHub, auto-assign, verification |
| `.github/prompts/wall_e_analysis.md` | System prompt for semantic triage |
| `.github/prompts/wall_e_issue_writer.md` | Second-pass prompt: criteria + baselines |
Expand Down Expand Up @@ -191,6 +192,35 @@ verification:

Install the **`wall_e_verify`** workflow (via `rails g wall_e:install`) to run verification on `pull_request` events. If no linked issues contain wall-e verification metadata, the run exits early with minimal work.

### Per-PR debt delta (`--pr-delta`)

Comment on a pull request with the debt it **adds on its changed lines**. This is a fast, static-only feedback loop that runs on every PR, complementary to the scheduled scan (which files tracked issues) and `--verify-pr` (which checks fixes for existing issues).

```sh
bundle exec wall-e --pr-delta 42
bundle exec wall-e --pr-delta 42 --dry-run
```

How it works:

1. Fetches the PR's changed Ruby files and diffs.
2. Runs the static collectors (`debride`, `flog`, `flay`, layer checks) scoped to those files.
3. Keeps only findings whose line range overlaps the lines the PR actually added.
4. Posts (or **updates**) a single summary comment marked with `<!-- wall_e_pr_delta -->`, so repeated pushes edit one comment instead of stacking duplicates.

No LLM is called and no issues are created. The comment is informational unless you opt into gating.

Configure in `config/wall_e_settings.yml`:

```yaml
pr_delta:
enabled: true
fail_on: "none" # "none" (comment only) | "high" (fail check on high-severity) | "any"
debt_types: [] # empty = all debt types
```

With `fail_on: "high"` or `"any"`, the command exits non-zero when matching findings exist, turning the PR check red. Install the **`wall_e_pr_delta`** workflow (via `rails g wall_e:install`) to run it on `pull_request` events; it only needs `GITHUB_TOKEN`.

## GitHub Actions usage

The **scan** workflow supports:
Expand All @@ -200,6 +230,8 @@ The **scan** workflow supports:

The **verify** workflow (`wall_e_verify.yml`) runs on `pull_request` (`opened`, `synchronize`) and executes `bundle exec wall-e --verify-pr <number>`.

The **pr_delta** workflow (`wall_e_pr_delta.yml`) runs on the same `pull_request` events and executes `bundle exec wall-e --pr-delta <number>`. It is static-only (no `OPENAI_API_KEY` needed) and uses `concurrency` to cancel superseded runs.

Manual scan example:

1. Open **Actions** in your repo
Expand Down Expand Up @@ -313,6 +345,7 @@ bundle exec wall-e [options]
| `--skip-llm` | Skip triage and issue writer; static collectors only |
| `--max-issues N` | Override max issues to create (for testing) |
| `--verify-pr NUMBER` | Run PR verification instead of a repo scan |
| `--pr-delta NUMBER` | Comment on a PR with debt added on its changed lines (static-only) |

## Troubleshooting

Expand Down
25 changes: 24 additions & 1 deletion exe/wall-e
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ options = {
dry_run: false,
skip_llm: false,
max_issues: nil,
verify_pr: nil
verify_pr: nil,
pr_delta: nil
}

OptionParser.new do |opts|
Expand Down Expand Up @@ -54,6 +55,10 @@ OptionParser.new do |opts|
opts.on("--verify-pr NUMBER", Integer, "Verify a PR against linked wall-e issues (skips normal scan)") do |value|
options[:verify_pr] = value
end

opts.on("--pr-delta NUMBER", Integer, "Comment on a PR with debt added on its changed lines (skips normal scan)") do |value|
options[:pr_delta] = value
end
end.parse!

config = TechDebt::Config.load(options[:config_path])
Expand All @@ -77,6 +82,24 @@ if options[:verify_pr]

puts JSON.pretty_generate(summary)
exit 1 if summary["status"] == "error"
elsif options[:pr_delta]
require "tech_debt/delta/pr_delta"
begin
summary = TechDebt::Delta::PrDelta.new(
config,
pr_number: options[:pr_delta],
dry_run: options[:dry_run]
).run
rescue Octokit::NotFound => e
warn "[wall-e] #{e.message}"
exit 1
rescue ArgumentError => e
warn "[wall-e] #{e.message}"
exit 1
end

puts JSON.pretty_generate(summary)
exit 1 if summary["status"] == "fail"
else
summary = TechDebt::Analyzer.new(
config,
Expand Down
7 changes: 6 additions & 1 deletion lib/generators/wall_e/install/install_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ def copy_verify_workflow
copy_file "wall_e_verify.yml", ".github/workflows/wall_e_verify.yml"
end

def copy_pr_delta_workflow
say "Adding PR debt-delta workflow...", :green
copy_file "wall_e_pr_delta.yml", ".github/workflows/wall_e_pr_delta.yml"
end

def copy_config
say "Adding wall-e settings...", :green
copy_file "wall_e_settings.yml", "config/wall_e_settings.yml"
Expand Down Expand Up @@ -50,7 +55,7 @@ def print_next_steps
say ""
say " 1. Add OPENAI_API_KEY as a GitHub Actions secret"
say " 2. (Optional) Add AGENT_ASSIGN_TOKEN for auto-assign (falls back to GITHUB_TOKEN)"
say " 3. Review .github/workflows/wall_e_scan.yml and wall_e_verify.yml triggers"
say " 3. Review .github/workflows/ triggers (scan, verify, pr_delta)"
say " 4. Adjust analysis.paths and flog_threshold in config/wall_e_settings.yml if needed"
say " 5. Optional: set verification.close_on_pass in config/wall_e_settings.yml"
say " 6. Test locally:"
Expand Down
29 changes: 29 additions & 0 deletions lib/generators/wall_e/install/templates/wall_e_pr_delta.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: wall-e debt delta

on:
pull_request:
types: [opened, synchronize]

# Cancel superseded runs when a PR is updated quickly.
concurrency:
group: wall-e-pr-delta-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
pr_delta:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true

- name: Comment debt delta on PR
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bundle exec wall-e --pr-delta ${{ github.event.pull_request.number }}
7 changes: 7 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 @@ -51,3 +51,10 @@ auto_assign:
# Optional: PR verification (--verify-pr) can auto-close issues when all checks pass (use with care).
verification:
close_on_pass: false

# Optional: per-PR debt delta (--pr-delta). Static-only; runs collectors on changed Ruby
# files and comments with debt found on the lines the PR added. No issues are created.
pr_delta:
enabled: true
fail_on: "none" # "none" (comment only) | "high" (fail check on high-severity) | "any"
debt_types: [] # empty = all debt types
11 changes: 7 additions & 4 deletions lib/tech_debt/collectors/complexity_collector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,21 @@ def parse_output(output, threshold)
score = match[:score].to_f
next if score < threshold

file = rest[%r{(?<path>[\w\/\.\-]+\.rb):\d+(?:-\d+)?}, :path]
next unless file
location = rest.match(%r{(?<path>[\w/.\-]+\.rb):(?<start>\d+)(?:-(?<end>\d+))?})
next unless location

identifier = rest.sub(%r{\s+[\w\/\.\-]+\.rb:\d+(?:-\d+)?\s*$}, "")
next if identifier =~ /\Amain#none\z/i

start_line = location[:start].to_i
{
file: file,
file: location[:path],
identifier: identifier,
type: "high_complexity",
detail: "Method complexity score #{score} exceeds threshold #{threshold}",
score: score
score: score,
line: start_line,
end_line: location[:end] ? location[:end].to_i : start_line
}
end
end
Expand Down
5 changes: 4 additions & 1 deletion lib/tech_debt/collectors/debride_collector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@ def parse_output(output)
match = line.match(%r{^(?<file>[^:]+):(?<line>\d+)\s+(?<identifier>\S+)\s+is not called from anywhere})
next unless match

line = match[:line].to_i
{
file: match[:file],
identifier: match[:identifier],
type: "dead_code",
detail: "Method appears to be uncalled (debride)",
score: 1
score: 1,
line: line,
end_line: line
}
end
end
Expand Down
4 changes: 3 additions & 1 deletion lib/tech_debt/collectors/flay_collector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ def candidates_for_group(lines, target_set)
identifier: "#{loc[:file]}:#{loc[:line]}",
type: "structural_duplication",
detail: build_detail(match_type, node_type, mass, other_refs),
score: mass
score: mass,
line: loc[:line],
end_line: loc[:line]
}
end
end
Expand Down
19 changes: 15 additions & 4 deletions lib/tech_debt/collectors/layer_collector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,27 @@ def analyze_file(file)
end

def model_file?(file)
file.match?(%r{/app/models/})
file.match?(%r{(?:\A|/)app/models/})
end

def job_file?(file)
file.match?(%r{/app/jobs/})
file.match?(%r{(?:\A|/)app/jobs/})
end

def current_attribute_violations(file, content)
return [] unless content.match?(/Current\.\w+/)

line = line_of(content, /Current\.\w+/)
[{
file: file,
identifier: extract_class_name(content) || File.basename(file, ".rb"),
type: "leaked_business_logic",
detail: "References Current.* inside a model — layer violation. " \
"Current context is unavailable in background jobs and rake tasks, " \
"causing silent nil failures. Pass the value as an explicit parameter instead.",
score: 8
score: 8,
line: line,
end_line: line
}]
end

Expand All @@ -54,17 +57,25 @@ def anemic_job_signals(file, content)
return [] if body.nil? || body.size != 1
return [] unless body[0].match?(/\A\w+\.\w+[\w!?]*(\(.*\))?\z/)

line = line_of(content, /def perform/)
[{
file: file,
identifier: "#{extract_class_name(content)}#perform",
type: "leaked_business_logic",
detail: "Job perform delegates entirely to a single model method with no added logic — " \
"anemic job. Consider using the active_job-performs gem to eliminate the " \
"separate job class and declare background execution directly on the model.",
score: 5
score: 5,
line: line,
end_line: line
}]
end

def line_of(content, regex)
index = content.each_line.find_index { |l| l.match?(regex) }
index ? index + 1 : 1
end

def extract_perform_body(content)
match = content.match(/def perform\([^)]*\)\n(.*?)\n\s*end/m)
return nil unless match
Expand Down
7 changes: 7 additions & 0 deletions lib/tech_debt/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ def close_issues_on_verification_pass?
verification.fetch("close_on_pass", false)
end

def pr_delta
value = raw["pr_delta"]
return { "enabled" => false } unless value.is_a?(Hash)

{ "enabled" => false }.merge(value)
end

private

def validate!
Expand Down
Loading