heimdal_ai_analyze 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +84 -0
- data/exe/heimdal-ai-analyze-install +118 -0
- data/heimdal_ai_analyze.gemspec +27 -0
- data/lib/heimdal_ai_analyze/git_analyze_staged_coverage.rb +149 -0
- data/lib/heimdal_ai_analyze/version.rb +5 -0
- data/lib/heimdal_ai_analyze.rb +6 -0
- data/templates/env.hook.example +32 -0
- data/templates/pre-commit +788 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e1de7a84ebfd8fd06367312a54270c64bf6c43923238d3b85cdff16dff2e2e25
|
|
4
|
+
data.tar.gz: 6cac8c75b7870aa5a56add62d203053ba505d4f397a8c74e67f9f60ce727e603
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 74670f8e9b9bc96c3a6a7f8d1a58260e0b6d8a3f74c6b4df1aa4eaec5f19733268a6491f33ddf5c3389147230d5e4237e09b610fe498f8982beb57745e0b12e4
|
|
7
|
+
data.tar.gz: 9c3b237a19785f0c44ef66e6f84afa15465d056af00b4b780d9b18c67895ef94c9923daeb2d4c0506d3a8dc22ceb89f2c985eb2fdd9a0747e764e230cf4e8bf3
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 heimdal_ai_analyze contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Heimdal AI Analyze
|
|
2
|
+
|
|
3
|
+
Ruby gem that installs a **git pre-commit** hook for **AI-assisted review** of your **staged diff** via the [Cursor Agent CLI](https://cursor.com/docs/cli/overview). The hook runs only when you commit with analysis enabled (e.g. `git analyze -m "message"`), not on normal commits.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
1. **Cursor Agent CLI** — install from [cursor.com/install](https://cursor.com/install) and ensure `agent` or `cursor-agent` is on your `PATH` (or set `CURSOR_AGENT_BIN`).
|
|
8
|
+
2. **`CURSOR_API_KEY`** — export in your environment, or use a repo-local `scripts/.env.hook`, or `~/.config/heimdal_ai_analyze/env` (see below).
|
|
9
|
+
|
|
10
|
+
## Install the gem
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
gem install heimdal_ai_analyze
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or with Bundler:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# Gemfile
|
|
20
|
+
gem "heimdal_ai_analyze", group: :development
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bundle install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## One-time setup per repository
|
|
28
|
+
|
|
29
|
+
From the git repository root:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bundle exec heimdal-ai-analyze-install
|
|
33
|
+
# or, if the gem executable is on PATH:
|
|
34
|
+
heimdal-ai-analyze-install
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This symlinks `.git/hooks/pre-commit` to the gem’s hook, sets `git analyze` alias (`ANALYZE=true git commit …`), records `heimdalAiAnalyze.gemPath`, and tries to save `cursorHook.agentPath` for the Cursor Agent binary.
|
|
38
|
+
|
|
39
|
+
## Credentials
|
|
40
|
+
|
|
41
|
+
**Minimum:** set an API key for the Cursor Agent:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export CURSOR_API_KEY="your-key"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Optional locations (loaded in order; later sources override earlier ones):
|
|
48
|
+
|
|
49
|
+
1. `~/.config/heimdal_ai_analyze/env` or `$XDG_CONFIG_HOME/heimdal_ai_analyze/env` — `export CURSOR_API_KEY=...`
|
|
50
|
+
2. `scripts/.env.hook` in the project (gitignored) — copy from `templates/env.hook.example` in this gem if you open the installed path under `$(gem env gemdir)`.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git analyze -m "Your commit message" # runs AI review on staged changes, then commits if allowed
|
|
56
|
+
git commit -m "message" # normal commit (hook skips AI unless ANALYZE=true)
|
|
57
|
+
git commit --no-verify # bypass hooks when needed
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
Clone [github.com/ffarhhan/heimdal_ai_analyze](https://github.com/ffarhhan/heimdal_ai_analyze):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
git clone -o personal git@github.com:ffarhhan/heimdal_ai_analyze.git
|
|
66
|
+
cd heimdal_ai_analyze
|
|
67
|
+
gem build heimdal_ai_analyze.gemspec
|
|
68
|
+
gem install ./heimdal_ai_analyze-*.gem --local
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Publish to RubyGems.org (maintainers)
|
|
72
|
+
|
|
73
|
+
1. [Create an account](https://rubygems.org/sign_up) and enable MFA as required.
|
|
74
|
+
2. `gem signin` with a [RubyGems API key](https://rubygems.org/profile/edit) (push scope).
|
|
75
|
+
3. Bump `lib/heimdal_ai_analyze/version.rb`, then:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
gem build heimdal_ai_analyze.gemspec
|
|
79
|
+
gem push heimdal_ai_analyze-VERSION.gem
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# One-time per-repo: symlink pre-commit hook, set git analyze alias,
|
|
5
|
+
# record gem path + Cursor Agent CLI path (local git config).
|
|
6
|
+
|
|
7
|
+
require "fileutils"
|
|
8
|
+
|
|
9
|
+
def gem_root
|
|
10
|
+
Gem::Specification.find_by_name("heimdal_ai_analyze").full_gem_path
|
|
11
|
+
rescue Gem::MissingSpecError
|
|
12
|
+
File.expand_path("..", __dir__)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
root = gem_root
|
|
16
|
+
hook_src = File.join(root, "templates", "pre-commit")
|
|
17
|
+
unless File.file?(hook_src)
|
|
18
|
+
warn "heimdal-ai-analyze-install: missing #{hook_src}"
|
|
19
|
+
exit 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
repo_root = `git rev-parse --show-toplevel 2>/dev/null`.strip
|
|
23
|
+
if repo_root.empty?
|
|
24
|
+
warn "heimdal-ai-analyze-install: not inside a git repository"
|
|
25
|
+
exit 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Dir.chdir(repo_root)
|
|
29
|
+
|
|
30
|
+
git_common = `git rev-parse --git-common-dir 2>/dev/null`.strip
|
|
31
|
+
if git_common.empty?
|
|
32
|
+
warn "heimdal-ai-analyze-install: could not resolve git dir"
|
|
33
|
+
exit 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
git_common_abs = File.expand_path(git_common, repo_root)
|
|
37
|
+
hooks_dir = File.join(git_common_abs, "hooks")
|
|
38
|
+
target = File.join(hooks_dir, "pre-commit")
|
|
39
|
+
|
|
40
|
+
FileUtils.mkdir_p(hooks_dir)
|
|
41
|
+
File.chmod(0o755, hook_src)
|
|
42
|
+
FileUtils.ln_sf(hook_src, target)
|
|
43
|
+
puts "Linked: #{target} -> #{hook_src}"
|
|
44
|
+
|
|
45
|
+
unless system("git", "config", "alias.analyze", '!f() { ANALYZE=true git commit "$@"; }; f')
|
|
46
|
+
warn "heimdal-ai-analyze-install: failed to set git alias analyze"
|
|
47
|
+
exit 1
|
|
48
|
+
end
|
|
49
|
+
puts "Set git alias: analyze -> ANALYZE=true git commit (with \"$@\")"
|
|
50
|
+
|
|
51
|
+
unless system("git", "config", "--local", "heimdalAiAnalyze.gemPath", root)
|
|
52
|
+
warn "heimdal-ai-analyze-install: failed to set heimdalAiAnalyze.gemPath"
|
|
53
|
+
exit 1
|
|
54
|
+
end
|
|
55
|
+
puts "Saved: git config --local heimdalAiAnalyze.gemPath"
|
|
56
|
+
puts " #{root}"
|
|
57
|
+
|
|
58
|
+
def find_cursor_agent_bin
|
|
59
|
+
bin = ENV["CURSOR_AGENT_BIN"]
|
|
60
|
+
return bin if bin && !bin.empty? && File.executable?(bin)
|
|
61
|
+
|
|
62
|
+
%w[agent cursor-agent].each do |name|
|
|
63
|
+
path = `command -v #{name} 2>/dev/null`.strip
|
|
64
|
+
return path if !path.empty? && File.executable?(path)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
base = File.join(Dir.home, ".local/share/cursor-agent/versions")
|
|
68
|
+
if File.directory?(base)
|
|
69
|
+
found = Dir.glob(File.join(base, "**", "cursor-agent")).select { |f| File.file?(f) }.max
|
|
70
|
+
return found if found && File.executable?(found)
|
|
71
|
+
end
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
agent_bin = find_cursor_agent_bin
|
|
76
|
+
if agent_bin
|
|
77
|
+
system("git", "config", "--local", "cursorHook.agentPath", agent_bin)
|
|
78
|
+
puts "Saved Cursor Agent path (local): git config cursorHook.agentPath"
|
|
79
|
+
puts " #{agent_bin}"
|
|
80
|
+
else
|
|
81
|
+
puts "Could not find Cursor Agent CLI (agent / cursor-agent)."
|
|
82
|
+
puts " Install: curl https://cursor.com/install -fsSL | bash"
|
|
83
|
+
puts " Add ~/.local/bin to PATH, then re-run this script."
|
|
84
|
+
if $stdin.tty? && $stdout.tty?
|
|
85
|
+
print "Enter full path to cursor-agent binary (empty to skip): "
|
|
86
|
+
manual = $stdin.gets
|
|
87
|
+
manual = manual&.strip
|
|
88
|
+
if manual && !manual.empty? && File.executable?(manual)
|
|
89
|
+
system("git", "config", "--local", "cursorHook.agentPath", manual)
|
|
90
|
+
puts "Saved: git config --local cursorHook.agentPath '#{manual}'"
|
|
91
|
+
agent_bin = manual
|
|
92
|
+
else
|
|
93
|
+
puts "Skipped. Set CURSOR_AGENT_BIN or git config --local cursorHook.agentPath, or use scripts/.env.hook"
|
|
94
|
+
end
|
|
95
|
+
else
|
|
96
|
+
puts "Non-interactive: set CURSOR_AGENT_BIN or git config cursorHook.agentPath after installing the CLI."
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
if agent_bin
|
|
101
|
+
puts ""
|
|
102
|
+
puts "Cursor Agent CLI probe:"
|
|
103
|
+
ok = system(agent_bin, "--help", out: $stdout, err: $stdout)
|
|
104
|
+
unless ok
|
|
105
|
+
system(agent_bin, "-h", out: $stdout, err: $stdout) || puts(" (could not read --help; binary: #{agent_bin})")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
puts ""
|
|
110
|
+
puts "Usage:"
|
|
111
|
+
puts " git analyze -m \"message\" # AI pre-commit analysis, then commit if allowed"
|
|
112
|
+
puts " git commit -m \"message\" # normal commit (hook skips analysis)"
|
|
113
|
+
puts " git commit --no-verify # bypass hooks when needed"
|
|
114
|
+
puts ""
|
|
115
|
+
puts "Credentials: export CURSOR_API_KEY (see README; optional scripts/.env.hook or ~/.config/heimdal_ai_analyze/env)."
|
|
116
|
+
puts " CLI overview: https://cursor.com/docs/cli/overview"
|
|
117
|
+
puts ""
|
|
118
|
+
system("git config --local --unset claudeHook.cliPath 2>/dev/null")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/heimdal_ai_analyze/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "heimdal_ai_analyze"
|
|
7
|
+
spec.version = HeimdalAiAnalyze::VERSION
|
|
8
|
+
spec.authors = ["ffarhhan"]
|
|
9
|
+
spec.email = ["ffarhhan@users.noreply.github.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Heimdal AI Analyze — Cursor Agent pre-commit gate for staged diffs"
|
|
12
|
+
spec.description = "Installs a git pre-commit hook that runs Cursor Agent on ANALYZE=true commits (e.g. git analyze). Requires CURSOR_API_KEY and the Cursor Agent CLI."
|
|
13
|
+
spec.homepage = "https://github.com/ffarhhan/heimdal_ai_analyze"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
16
|
+
|
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
18
|
+
spec.metadata["source_code_uri"] = "https://github.com/ffarhhan/heimdal_ai_analyze"
|
|
19
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
20
|
+
|
|
21
|
+
spec.files = %w[heimdal_ai_analyze.gemspec LICENSE.txt README.md] +
|
|
22
|
+
Dir["lib/**/*.rb"] + Dir["exe/*"] + Dir["templates/*"]
|
|
23
|
+
spec.files = spec.files.select { |f| File.file?(File.join(__dir__, f)) }
|
|
24
|
+
spec.bindir = "exe"
|
|
25
|
+
spec.executables = ["heimdal-ai-analyze-install"]
|
|
26
|
+
spec.require_paths = ["lib"]
|
|
27
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Summarize SimpleCov line hits for lines touched in `git diff --cached` (app/, lib/).
|
|
5
|
+
# Reads coverage/.resultset.json from the last COVERAGE=true test run (not invoked by this script).
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
require "shellwords"
|
|
9
|
+
|
|
10
|
+
root = File.expand_path(ARGV[0] || Dir.pwd)
|
|
11
|
+
resultset_path = File.join(root, "coverage", ".resultset.json")
|
|
12
|
+
|
|
13
|
+
unless File.file?(resultset_path)
|
|
14
|
+
warn " No coverage/.resultset.json — run: COVERAGE=true bundle exec rspec"
|
|
15
|
+
warn " Then re-run git analyze to see per-file changed-line coverage."
|
|
16
|
+
exit 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
raw = JSON.parse(File.read(resultset_path))
|
|
20
|
+
|
|
21
|
+
def merge_line_arrays(a, b)
|
|
22
|
+
return b if a.nil? || a.empty?
|
|
23
|
+
return a if b.nil? || b.empty?
|
|
24
|
+
len = [a.size, b.size].max
|
|
25
|
+
(0...len).map do |i|
|
|
26
|
+
va = a[i]
|
|
27
|
+
vb = b[i]
|
|
28
|
+
if va.nil? && vb.nil?
|
|
29
|
+
nil
|
|
30
|
+
elsif va.nil?
|
|
31
|
+
vb
|
|
32
|
+
elsif vb.nil?
|
|
33
|
+
va
|
|
34
|
+
else
|
|
35
|
+
[va.to_i, vb.to_i].max
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
merged = {}
|
|
41
|
+
raw.each_value do |payload|
|
|
42
|
+
cov = payload["coverage"] || {}
|
|
43
|
+
cov.each do |abs_path, info|
|
|
44
|
+
lines = info["lines"] || info
|
|
45
|
+
next unless lines.is_a?(Array)
|
|
46
|
+
|
|
47
|
+
merged[abs_path] = merge_line_arrays(merged[abs_path], lines)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
diff = `git -C #{Shellwords.escape(root)} diff --cached --unified=3 -- app lib 2>/dev/null`
|
|
52
|
+
if diff.strip.empty?
|
|
53
|
+
warn " (no staged app/lib diff for coverage mapping)"
|
|
54
|
+
exit 0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def each_staged_file_diff(diff_text)
|
|
58
|
+
diff_text.split(/(?=^diff --git )/m).each do |chunk|
|
|
59
|
+
chunk = chunk.strip
|
|
60
|
+
next if chunk.empty?
|
|
61
|
+
|
|
62
|
+
next unless chunk =~ %r{\Adiff --git a/(.+?) b/}
|
|
63
|
+
|
|
64
|
+
path = Regexp.last_match(1)
|
|
65
|
+
yield path, chunk
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# New-file line numbers for lines introduced or modified on the right-hand side of the diff.
|
|
70
|
+
def changed_executable_line_numbers(chunk)
|
|
71
|
+
lines = []
|
|
72
|
+
new_ln = nil
|
|
73
|
+
chunk.each_line do |line|
|
|
74
|
+
if line =~ /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
|
75
|
+
new_ln = Regexp.last_match(3).to_i
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
next if line.start_with?("---", "+++", "diff ", "\\")
|
|
79
|
+
|
|
80
|
+
if line.start_with?("+") && !line.start_with?("+++")
|
|
81
|
+
lines << new_ln
|
|
82
|
+
new_ln += 1
|
|
83
|
+
elsif line.start_with?("-") && !line.start_with?("---")
|
|
84
|
+
# old side only
|
|
85
|
+
next
|
|
86
|
+
elsif line.start_with?(" ")
|
|
87
|
+
new_ln += 1
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
lines.uniq.sort
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
dim = "\033[2m"
|
|
94
|
+
reset = "\033[0m"
|
|
95
|
+
green = "\033[1;32m"
|
|
96
|
+
yellow = "\033[33m"
|
|
97
|
+
use_color = $stderr.tty?
|
|
98
|
+
unless use_color
|
|
99
|
+
dim = reset = green = yellow = ""
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
printed = false
|
|
103
|
+
each_staged_file_diff(diff) do |rel, chunk|
|
|
104
|
+
next unless rel.end_with?(".rb")
|
|
105
|
+
next unless rel.start_with?("app/", "lib/")
|
|
106
|
+
next if rel.match?(%r{\Aapp/(assets|javascript|views|helpers|mailers|jobs)/})
|
|
107
|
+
|
|
108
|
+
abs = File.expand_path(File.join(root, rel))
|
|
109
|
+
changed = changed_executable_line_numbers(chunk)
|
|
110
|
+
next if changed.empty?
|
|
111
|
+
|
|
112
|
+
cov = merged[abs]
|
|
113
|
+
unless cov
|
|
114
|
+
key = merged.keys.find { |p| File.expand_path(p) == abs || p.end_with?("/#{rel}") }
|
|
115
|
+
cov = key ? merged[key] : nil
|
|
116
|
+
end
|
|
117
|
+
unless cov
|
|
118
|
+
warn " #{rel} → #{yellow}no SimpleCov data for this file#{reset} (not loaded in last COVERAGE run)"
|
|
119
|
+
printed = true
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
executable_changed = changed.select do |ln|
|
|
124
|
+
idx = ln - 1
|
|
125
|
+
idx >= 0 && !cov[idx].nil?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
if executable_changed.empty?
|
|
129
|
+
warn " #{rel} → #{dim}n/a (no executable changed lines in diff)#{reset}"
|
|
130
|
+
printed = true
|
|
131
|
+
next
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
covered = executable_changed.count { |ln| cov[ln - 1].to_i.positive? }
|
|
135
|
+
total = executable_changed.size
|
|
136
|
+
pct = ((100.0 * covered) / total).round
|
|
137
|
+
|
|
138
|
+
color = pct >= 80 ? green : yellow
|
|
139
|
+
warn " #{rel} → #{color}#{pct}%#{reset} changed-line coverage (#{covered}/#{total} executable changed lines hit)"
|
|
140
|
+
printed = true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
unless printed
|
|
144
|
+
warn " #{dim}(no staged Ruby files under app/ or lib/ to map)#{reset}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
warn "#{dim} Source: coverage/.resultset.json vs git diff --cached#{reset}"
|
|
148
|
+
|
|
149
|
+
exit 0
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Copy to scripts/.env.hook (gitignored). Never commit this file.
|
|
2
|
+
# Or use ~/.config/heimdal_ai_analyze/env for all repos on your machine (loaded first; this file overrides).
|
|
3
|
+
#
|
|
4
|
+
# Copy this file to scripts/.env.hook in your app repo (see gem README).
|
|
5
|
+
#
|
|
6
|
+
# The hook uses the Cursor Agent CLI (not Claude Code / Anthropic console keys).
|
|
7
|
+
# Install CLI: curl https://cursor.com/install -fsSL | bash
|
|
8
|
+
# Docs: https://cursor.com/docs/cli/overview
|
|
9
|
+
# One-time per repo: bundle exec heimdal-ai-analyze-install
|
|
10
|
+
|
|
11
|
+
# Required: API key for Cursor Agent (per Cursor docs)
|
|
12
|
+
# export CURSOR_API_KEY="..."
|
|
13
|
+
|
|
14
|
+
# Optional: full path if `agent` is not on PATH (heimdal-ai-analyze-install sets git config cursorHook.agentPath)
|
|
15
|
+
# export CURSOR_AGENT_BIN="$HOME/.local/share/cursor-agent/versions/VERSION/cursor-agent"
|
|
16
|
+
|
|
17
|
+
# Optional: if the default invoke fails, try: chat-p or print-only
|
|
18
|
+
# export GIT_ANALYZE_AGENT_STYLE=chat-p
|
|
19
|
+
|
|
20
|
+
# Default adds --trust so non-interactive git analyze works. Override only if needed:
|
|
21
|
+
# export GIT_ANALYZE_AGENT_EXTRA_ARGS="--trust"
|
|
22
|
+
# export GIT_ANALYZE_AGENT_EXTRA_ARGS="--trust --yolo" # stronger; use only if you accept agent file/shell access
|
|
23
|
+
|
|
24
|
+
# Optional: progress bar (simulated %; min visible wait if Cursor returns fast):
|
|
25
|
+
# export GIT_ANALYZE_MIN_PROGRESS_SEC=2
|
|
26
|
+
# export GIT_ANALYZE_PROGRESS_FILL_SEC=10
|
|
27
|
+
# export GIT_ANALYZE_FRAME_SLEEP=0.07
|
|
28
|
+
|
|
29
|
+
# Staged new-code coverage (git analyze): percentages use coverage/.resultset.json from SimpleCov.
|
|
30
|
+
# Generate or refresh before analyze, e.g. COVERAGE=true bundle exec rspec
|
|
31
|
+
|
|
32
|
+
# Same variables can be set in Cursor → Settings → Environment for integrated terminals.
|
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ╔══════════════════════════════════════════════════════════════════════╗
|
|
3
|
+
# ║ Git Analyze — AI pre-commit code quality gate ║
|
|
4
|
+
# ║ Runs via Cursor Agent CLI when ANALYZE=true ║
|
|
5
|
+
# ║ ║
|
|
6
|
+
# ║ Categories: DUPLICATION · COMPLEXITY · SECURITY · STYLE · TESTS ║
|
|
7
|
+
# ╚══════════════════════════════════════════════════════════════════════╝
|
|
8
|
+
#
|
|
9
|
+
# Usage: ANALYZE=true git commit -m "message"
|
|
10
|
+
# Alias: git analyze -m "message" (if alias configured)
|
|
11
|
+
#
|
|
12
|
+
# Requirements:
|
|
13
|
+
# • Cursor Agent CLI — https://cursor.com/install
|
|
14
|
+
# • CURSOR_API_KEY — export in env, or ~/.config/heimdal_ai_analyze/env, or scripts/.env.hook (gitignored)
|
|
15
|
+
#
|
|
16
|
+
# Env file load order (later overrides earlier): user config → repo scripts/.env.hook
|
|
17
|
+
#
|
|
18
|
+
# Agent binary lookup (first match):
|
|
19
|
+
# 1. $CURSOR_AGENT_BIN 2. scripts/.env.hook exports 3. git config cursorHook.agentPath
|
|
20
|
+
# 4. PATH: agent | cursor-agent 5. ~/.local/share/cursor-agent/versions/*/cursor-agent
|
|
21
|
+
#
|
|
22
|
+
# Env overrides:
|
|
23
|
+
# GIT_ANALYZE_AGENT_STYLE chat-print (default) | chat-p | print-only
|
|
24
|
+
# GIT_ANALYZE_AGENT_EXTRA_ARGS default: --trust
|
|
25
|
+
# GIT_ANALYZE_PROGRESS_FILL_SEC progress bar fill target (default: 10)
|
|
26
|
+
# GIT_ANALYZE_FRAME_SLEEP animation frame delay (default: 0.07)
|
|
27
|
+
|
|
28
|
+
set -euo pipefail
|
|
29
|
+
[[ "${ANALYZE:-}" == "true" ]] || exit 0
|
|
30
|
+
|
|
31
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
32
|
+
# │ Bootstrap │
|
|
33
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
34
|
+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || REPO_ROOT=""
|
|
35
|
+
# User-level config first (Heimdal gem); repo scripts/.env.hook overrides.
|
|
36
|
+
for f in "${XDG_CONFIG_HOME:+$XDG_CONFIG_HOME/heimdal_ai_analyze/env}" \
|
|
37
|
+
"${HOME}/.config/heimdal_ai_analyze/env"; do
|
|
38
|
+
[[ -n "$f" && -f "$f" ]] && { set -a; . "$f"; set +a; }
|
|
39
|
+
done
|
|
40
|
+
ENV_HOOK="${REPO_ROOT:+$REPO_ROOT/scripts/.env.hook}"
|
|
41
|
+
[[ -n "$ENV_HOOK" && -f "$ENV_HOOK" ]] && { set -a; . "$ENV_HOOK"; set +a; }
|
|
42
|
+
|
|
43
|
+
AGENT_PID="" TMP_PROMPT="" TMP_OUT=""
|
|
44
|
+
|
|
45
|
+
cleanup() {
|
|
46
|
+
local st=$?
|
|
47
|
+
if [[ -n "${AGENT_PID:-}" ]] && kill -0 "$AGENT_PID" 2>/dev/null; then
|
|
48
|
+
kill "$AGENT_PID" 2>/dev/null || true
|
|
49
|
+
wait "$AGENT_PID" 2>/dev/null || true
|
|
50
|
+
fi
|
|
51
|
+
rm -f "${TMP_PROMPT:-}" "${TMP_OUT:-}"
|
|
52
|
+
return "$st"
|
|
53
|
+
}
|
|
54
|
+
trap cleanup EXIT INT TERM
|
|
55
|
+
|
|
56
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
57
|
+
# │ Find Cursor Agent binary │
|
|
58
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
59
|
+
find_agent() {
|
|
60
|
+
local p
|
|
61
|
+
# Explicit paths
|
|
62
|
+
for p in "${CURSOR_AGENT_BIN:-}" "$(git config --get cursorHook.agentPath 2>/dev/null || true)"; do
|
|
63
|
+
[[ -n "$p" && -x "$p" ]] && { printf '%s' "$p"; return 0; }
|
|
64
|
+
done
|
|
65
|
+
# PATH lookup
|
|
66
|
+
for name in agent cursor-agent; do
|
|
67
|
+
p=$(command -v "$name" 2>/dev/null || true)
|
|
68
|
+
[[ -n "$p" && -x "$p" ]] && { printf '%s' "$p"; return 0; }
|
|
69
|
+
done
|
|
70
|
+
# Versioned install
|
|
71
|
+
local base="$HOME/.local/share/cursor-agent/versions"
|
|
72
|
+
if [[ -d "$base" ]]; then
|
|
73
|
+
p=$(find "$base" -name cursor-agent -type f 2>/dev/null | LC_ALL=C sort | tail -1)
|
|
74
|
+
[[ -n "$p" && -x "$p" ]] && { printf '%s' "$p"; return 0; }
|
|
75
|
+
fi
|
|
76
|
+
return 1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
80
|
+
# │ Spec-coverage hints (injected into TESTS section of prompt) │
|
|
81
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
82
|
+
spec_guess() {
|
|
83
|
+
local f=$1
|
|
84
|
+
case "$f" in
|
|
85
|
+
app/models/*.rb) echo "spec/models/${f#app/models/}" | sed 's/\.rb$/_spec.rb/' ;;
|
|
86
|
+
app/services/*.rb) echo "spec/services/${f#app/services/}" | sed 's/\.rb$/_spec.rb/' ;;
|
|
87
|
+
app/workers/*.rb) echo "spec/workers/${f#app/workers/}" | sed 's/\.rb$/_spec.rb/' ;;
|
|
88
|
+
app/controllers/*.rb) echo "spec/requests/${f#app/controllers/}" | sed 's/\.rb$/_spec.rb/' ;;
|
|
89
|
+
lib/*.rb) echo "spec/lib/${f#lib/}" | sed 's/\.rb$/_spec.rb/' ;;
|
|
90
|
+
*) echo "spec/**/*" ;;
|
|
91
|
+
esac
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
build_coverage_hint() {
|
|
95
|
+
local staged_specs f guess
|
|
96
|
+
staged_specs=$(git -C "${REPO_ROOT:-.}" diff --cached --name-only --diff-filter=ACM 2>/dev/null \
|
|
97
|
+
| grep -E '^spec/.+_spec\.rb$' | sort -u || true)
|
|
98
|
+
|
|
99
|
+
if [[ -z "$staged_specs" ]]; then
|
|
100
|
+
printf 'Staged spec files: <none>\n'
|
|
101
|
+
else
|
|
102
|
+
printf 'Staged spec files:\n'
|
|
103
|
+
printf '%s\n' "$staged_specs" | sed 's/^/ • /'
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
printf '\nProduction files → expected spec:\n'
|
|
107
|
+
|
|
108
|
+
while IFS= read -r f; do
|
|
109
|
+
[[ -z "$f" ]] && continue
|
|
110
|
+
[[ "$f" =~ \.rb$ && "$f" =~ ^(app|lib)/ ]] || continue
|
|
111
|
+
[[ "$f" =~ ^app/(assets|javascript|views|helpers|mailers|jobs)/ ]] && continue
|
|
112
|
+
guess=$(spec_guess "$f")
|
|
113
|
+
if [[ "$guess" == *'*'* ]]; then
|
|
114
|
+
printf '• %s → %s\n' "$f" "$guess"
|
|
115
|
+
elif printf '%s\n' "$staged_specs" | grep -qFx "$guess" 2>/dev/null; then
|
|
116
|
+
printf '• %s → %s [STAGED ✓]\n' "$f" "$guess"
|
|
117
|
+
else
|
|
118
|
+
printf '• %s → %s [NOT STAGED]\n' "$f" "$guess"
|
|
119
|
+
fi
|
|
120
|
+
done < <(git -C "${REPO_ROOT:-.}" diff --cached --name-only --diff-filter=ACM 2>/dev/null | sort -u)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
124
|
+
# │ Prerequisites │
|
|
125
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
126
|
+
DIFF=$(git diff --cached || true)
|
|
127
|
+
if [[ -z "$DIFF" ]]; then
|
|
128
|
+
echo "pre-commit (analyze): no staged changes; skipping." >&2
|
|
129
|
+
exit 0
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
MAX_CHARS=60000
|
|
133
|
+
if (( ${#DIFF} > MAX_CHARS )); then
|
|
134
|
+
DIFF="${DIFF:0:$MAX_CHARS}
|
|
135
|
+
[TRUNCATED at $MAX_CHARS chars — diff was ${#DIFF} characters]"
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
if [[ -z "${CURSOR_API_KEY:-}" ]]; then
|
|
139
|
+
cat >&2 <<'MSG'
|
|
140
|
+
pre-commit (analyze): CURSOR_API_KEY is required.
|
|
141
|
+
→ export CURSOR_API_KEY="…"
|
|
142
|
+
→ Or ~/.config/heimdal_ai_analyze/env or scripts/.env.hook (see gem README / env.hook.example)
|
|
143
|
+
MSG
|
|
144
|
+
exit 1
|
|
145
|
+
fi
|
|
146
|
+
export CURSOR_API_KEY
|
|
147
|
+
|
|
148
|
+
AGENT_BIN=$(find_agent) || {
|
|
149
|
+
cat >&2 <<'MSG'
|
|
150
|
+
pre-commit (analyze): Cursor Agent CLI not found.
|
|
151
|
+
→ Install: curl https://cursor.com/install -fsSL | bash
|
|
152
|
+
→ Ensure ~/.local/bin is on PATH (or set CURSOR_AGENT_BIN).
|
|
153
|
+
MSG
|
|
154
|
+
exit 1
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
158
|
+
# │ PROMPT — 4 categories + TESTS, single source of truth │
|
|
159
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
160
|
+
read -r -d '' PROMPT <<'PROMPT_EOF' || true
|
|
161
|
+
Role: Senior Ruby on Rails code reviewer. Review ONLY the staged diff below.
|
|
162
|
+
|
|
163
|
+
## HARD RULES
|
|
164
|
+
- Analyze ONLY added/changed lines (`+` prefix). Lines with `-` or ` ` are context only.
|
|
165
|
+
- Every finding MUST cite a real file path and line number from the diff `@@ … @@` headers. Never invent paths or lines not present in the diff.
|
|
166
|
+
- Do NOT flag: deleted code, test files (unless security risk), schema.rb, or auto-generated files.
|
|
167
|
+
- Migrations: flag ONLY for security or data-safety risks (table locks, missing rollback, column removal without ignored_columns).
|
|
168
|
+
- Prefer ZERO false positives over completeness — when uncertain, skip the finding.
|
|
169
|
+
- Do NOT report the same issue under multiple categories.
|
|
170
|
+
- Maximum 15 findings. Prioritize by severity.
|
|
171
|
+
|
|
172
|
+
───────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
## CATEGORY 1 — SECURITY
|
|
175
|
+
The only category that can produce CRITICAL findings.
|
|
176
|
+
|
|
177
|
+
CRITICAL (blocks commit):
|
|
178
|
+
- SQL injection: string interpolation in raw SQL — `where("col = '#{v}'")`, `execute("…#{v}…")`. Safe: `where(col: v)`, `where("col = ?", v)`, Arel, `sanitize_sql`.
|
|
179
|
+
- Mass assignment: `params.permit!`; `update(params)`/`create(params)` without strong params; `.permit` exposing sensitive columns (role, admin, is_superadmin, password_digest, email_verified).
|
|
180
|
+
- Hardcoded secrets: API keys, passwords, tokens, private keys as string literals in source. Safe: `Rails.application.credentials`, `ENV.fetch`, `ENV[]`, encrypted credentials.
|
|
181
|
+
- Missing auth/authz: new controller action without `before_action :authenticate_*` or Pundit/CanCan policy when sibling actions enforce them.
|
|
182
|
+
- Unsafe redirect: `redirect_to params[:url]` / `params[:return_to]` without allow-list.
|
|
183
|
+
- Unsafe deserialization: `Marshal.load`, `YAML.unsafe_load`, `constantize`/`safe_constantize` on user input.
|
|
184
|
+
- Unsafe migration: `add_index` on large table without `algorithm: :concurrently` (PG write-lock); removing/renaming column without `ignored_columns` (rolling-deploy crash); `change_column` type change on populated table; irreversible `execute` DDL without `down`/`reversible`.
|
|
185
|
+
- Race condition with data-loss risk: read-then-write without DB lock/unique constraint on financial data — balance increment without `with_lock`/`update_counters`; check-then-create without unique index; double-submit without idempotency key.
|
|
186
|
+
|
|
187
|
+
WARNING:
|
|
188
|
+
- `skip_forgery_protection` without API-only justification.
|
|
189
|
+
- `send`/`public_send`/`constantize` on user-controlled input.
|
|
190
|
+
- `File.read(params[…])` / `send_file(params[…])` — path traversal.
|
|
191
|
+
- Logging unfiltered `params` or explicitly logging tokens/passwords.
|
|
192
|
+
- `rescue` silently swallowing exceptions in payment/billing/financial path.
|
|
193
|
+
- Missing `null: false` on column validated `presence: true` (DB/app disagree).
|
|
194
|
+
- Missing foreign key or index on `belongs_to` reference column.
|
|
195
|
+
|
|
196
|
+
───────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
## CATEGORY 2 — DUPLICATION
|
|
199
|
+
DRY violations, repeated logic, N+1 queries, thin controllers.
|
|
200
|
+
|
|
201
|
+
WARNING:
|
|
202
|
+
- Repeated code blocks: ≥5 structurally identical lines differing only by variable/column names → extract method, concern, or service.
|
|
203
|
+
- N+1 queries: iterating a collection and calling `.association`, `Model.find`, or `Model.where` inside the loop without prior `includes`/`preload`/`eager_load` → add eager loading.
|
|
204
|
+
- Copy-pasted queries: same where-chains/scopes/joins duplicated across controllers or services → extract to named scope or query object.
|
|
205
|
+
- Controller responsibility: identical before_action, param-parsing, or response-building repeated across actions → extract concern or shared method.
|
|
206
|
+
|
|
207
|
+
INFO:
|
|
208
|
+
- Near-duplicate logic (same shape, different domain) that could share an abstraction.
|
|
209
|
+
- Same guard condition repeated in 3+ methods → extract predicate method.
|
|
210
|
+
|
|
211
|
+
───────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
## CATEGORY 3 — COMPLEXITY
|
|
214
|
+
Long methods, deep nesting, SRP violations, error handling, robustness.
|
|
215
|
+
|
|
216
|
+
WARNING:
|
|
217
|
+
- Long methods: >20 added lines in a single `def…end` → extract private method or service.
|
|
218
|
+
- Deep nesting: >3 levels (if inside each inside if inside begin) → guard clause, early return, extract method.
|
|
219
|
+
- Fat controllers: >10 lines of business logic beyond CRUD → service object or form object.
|
|
220
|
+
- God model: model receiving >5 unrelated public methods in this diff → concern or service.
|
|
221
|
+
- Callback chains: >3 `before_*`/`after_*` callbacks forming orchestration → explicit service.
|
|
222
|
+
- Bare rescue / `rescue Exception`: catches `SignalException`/`SystemExit` → use `rescue StandardError`.
|
|
223
|
+
- Swallowed exceptions: empty rescue or `rescue => e` with no logging/re-raise → add logging + re-raise or meaningful fallback.
|
|
224
|
+
- External call without error handling: HTTP client (`Net::HTTP`, `Faraday`, `HTTParty`, `RestClient`) without rescue for timeout/connection errors → wrap with rescue + fallback.
|
|
225
|
+
- Retry without limit: `retry` with no counter → add `attempts < MAX` guard.
|
|
226
|
+
- Unbounded query: `Model.all` or `.where(…)` without `.limit()` in controller/API → add pagination.
|
|
227
|
+
- Missing `find_each`: `.each` on large scope loads everything into memory → use `.find_each`.
|
|
228
|
+
- Non-atomic writes: multiple `save!`/`update!` without `transaction {}` → wrap in transaction.
|
|
229
|
+
- `update_column`/`update_all` bypassing validations on critical fields → use `update!` or add guards.
|
|
230
|
+
|
|
231
|
+
INFO:
|
|
232
|
+
- Long parameter lists (>4 params) → keyword args or value object.
|
|
233
|
+
- Method with >3 responsibilities → split pipeline.
|
|
234
|
+
- Single-line `rescue nil` modifier — hides failures.
|
|
235
|
+
- Synchronous HTTP in request cycle without timeout → background job or `timeout:`.
|
|
236
|
+
- `.count`/`.size` inside loop on loaded association → `.length` or `counter_cache`.
|
|
237
|
+
- Missing `ensure` for resource cleanup.
|
|
238
|
+
|
|
239
|
+
───────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
## CATEGORY 4 — STYLE
|
|
242
|
+
Idiomatic Ruby/Rails, naming, dead code. Always INFO — never CRITICAL or WARNING.
|
|
243
|
+
|
|
244
|
+
INFO only:
|
|
245
|
+
- `if !x` → `unless x`; `x == nil` → `x.nil?`; manual loop → `map`/`select`/`each_with_object`; unnecessary `self.`; `lambda { }` → `-> { }`.
|
|
246
|
+
- `Model.all.map { |r| r.col }` → `.pluck(:col)`.
|
|
247
|
+
- Opaque names (`x`, `tmp`, `data`, `obj`, `res`) → descriptive. Don't nitpick `id`, `url`, `params`.
|
|
248
|
+
- Dead code: defined method never called in diff and not public API.
|
|
249
|
+
- `default_scope` — almost always wrong.
|
|
250
|
+
- `after_save` with side effects → `after_commit` (avoids job running before commit).
|
|
251
|
+
- Mixing `Hash#[]` / `Hash#fetch` inconsistently.
|
|
252
|
+
- `present?`/`blank?` where `nil?` suffices.
|
|
253
|
+
- Missing `dependent: :destroy`/`:nullify` on `has_many`/`has_one` → orphan records.
|
|
254
|
+
- Missing `counter_cache: true` when parent frequently calls `.count`.
|
|
255
|
+
|
|
256
|
+
───────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
## CATEGORY 5 — TESTS
|
|
259
|
+
Staged spec coverage. Uses the hook hint injected below.
|
|
260
|
+
|
|
261
|
+
WARNING:
|
|
262
|
+
- Production behavior changed (new branch, validation, auth, external call, error path, state transition) but matching spec NOT staged and no other staged spec covers it.
|
|
263
|
+
- New public endpoint or service method with no plausible spec staged.
|
|
264
|
+
|
|
265
|
+
INFO:
|
|
266
|
+
- Spec staged but diff adds branch/edge case the spec likely doesn't hit.
|
|
267
|
+
|
|
268
|
+
SKIP (do NOT flag): comment-only edits, renames, config churn, `[STAGED ✓]` in hint.
|
|
269
|
+
|
|
270
|
+
───────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
## SEVERITY LADDER
|
|
273
|
+
|
|
274
|
+
| Level | Blocks commit? | Where used |
|
|
275
|
+
|----------|:-:|-----------------------------------------------------------------------------|
|
|
276
|
+
| CRITICAL | ✅ | SECURITY only: SQLi, mass assignment, secrets, missing auth, unsafe migration, race condition, unsafe deserialization, unsafe redirect |
|
|
277
|
+
| WARNING | ❌ | SECURITY (CSRF, logging, missing constraints) · DUPLICATION (N+1, DRY) · COMPLEXITY (long methods, bare rescue, unbounded query, non-atomic) · TESTS (missing spec) |
|
|
278
|
+
| INFO | ❌ | STYLE (always) · minor DUPLICATION / COMPLEXITY / TESTS findings |
|
|
279
|
+
|
|
280
|
+
───────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
## OUTPUT FORMAT (follow exactly)
|
|
283
|
+
|
|
284
|
+
### Zero issues → single line:
|
|
285
|
+
NO_ISSUES_FOUND
|
|
286
|
+
|
|
287
|
+
### One or more issues:
|
|
288
|
+
|
|
289
|
+
Line 1 (mandatory):
|
|
290
|
+
SUMMARY: X critical, Y warnings, Z info
|
|
291
|
+
|
|
292
|
+
Then findings in severity order — all CRITICAL first, then WARNING, then INFO:
|
|
293
|
+
|
|
294
|
+
#### CRITICAL (full block per finding, blank line between):
|
|
295
|
+
|
|
296
|
+
[CRITICAL] path/to/file.rb:LINE - CATEGORY: one-line description
|
|
297
|
+
LOCATION: path/to/file.rb:LINE
|
|
298
|
+
SUGGESTED_FIX: Imperative sentence.
|
|
299
|
+
|
|
300
|
+
```ruby before
|
|
301
|
+
# minimal excerpt of problematic code from the diff
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
```ruby recommended
|
|
305
|
+
# paste-ready fixed version
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
(Omit `before` fence if code is entirely new.)
|
|
309
|
+
|
|
310
|
+
#### WARNING (one line each):
|
|
311
|
+
[WARNING] path/to/file.rb:LINE - CATEGORY: short description
|
|
312
|
+
|
|
313
|
+
#### INFO (one line each):
|
|
314
|
+
[INFO] path/to/file.rb:LINE - CATEGORY: short description
|
|
315
|
+
|
|
316
|
+
CATEGORY must be exactly: DUPLICATION | COMPLEXITY | SECURITY | STYLE | TESTS
|
|
317
|
+
|
|
318
|
+
No prose before SUMMARY. No closing section after the last finding. Stop immediately.
|
|
319
|
+
|
|
320
|
+
───────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
## EXAMPLE
|
|
323
|
+
|
|
324
|
+
SUMMARY: 1 critical, 3 warnings, 2 info
|
|
325
|
+
|
|
326
|
+
[CRITICAL] app/controllers/users_controller.rb:42 - SECURITY: SQL injection via string interpolation in where clause
|
|
327
|
+
LOCATION: app/controllers/users_controller.rb:42
|
|
328
|
+
SUGGESTED_FIX: Use parameterized hash condition instead of string interpolation.
|
|
329
|
+
|
|
330
|
+
```ruby before
|
|
331
|
+
User.where("email = '#{params[:email]}'")
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
```ruby recommended
|
|
335
|
+
User.where(email: params[:email])
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
[WARNING] app/services/billing_service.rb:18 - DUPLICATION: charge calculation duplicated at lines 18 and 45; extract private method
|
|
339
|
+
[WARNING] app/services/sync_service.rb:33 - COMPLEXITY: bare rescue catches SignalException and SystemExit; use rescue StandardError
|
|
340
|
+
[WARNING] app/models/order.rb:72 - TESTS: new fulfill! state transition but spec/models/order_spec.rb not staged
|
|
341
|
+
[INFO] app/controllers/api/v1/products_controller.rb:12 - STYLE: if !product.active? is more idiomatic as unless product.active?
|
|
342
|
+
[INFO] app/models/user.rb:55 - COMPLEXITY: .each on User.where(active: true) without find_each; may load entire table
|
|
343
|
+
|
|
344
|
+
___GIT_ANALYZE_HOOK_HINT___
|
|
345
|
+
|
|
346
|
+
--- BEGIN DIFF ---
|
|
347
|
+
PROMPT_EOF
|
|
348
|
+
|
|
349
|
+
# ── Inject hints + diff ──────────────────────────────────────────────
|
|
350
|
+
COVERAGE_HINT=$(build_coverage_hint)
|
|
351
|
+
PROMPT="${PROMPT/___GIT_ANALYZE_HOOK_HINT___/$COVERAGE_HINT}"
|
|
352
|
+
PROMPT+=$'\n'"$DIFF"$'\n'"--- END DIFF ---"
|
|
353
|
+
|
|
354
|
+
TMP_PROMPT=$(mktemp "${TMPDIR:-/tmp}/analyze-prompt.XXXXXX")
|
|
355
|
+
TMP_OUT=$(mktemp "${TMPDIR:-/tmp}/analyze-out.XXXXXX")
|
|
356
|
+
printf '%s' "$PROMPT" > "$TMP_PROMPT"
|
|
357
|
+
|
|
358
|
+
ESCAPED_PROMPT=$(ruby -e 'require "json"; print JSON.generate(File.read(ARGV[0]))' "$TMP_PROMPT")
|
|
359
|
+
PROMPT_SHELL_Q=$(printf '%q' "$ESCAPED_PROMPT")
|
|
360
|
+
|
|
361
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
362
|
+
# │ Terminal Colors & Layout │
|
|
363
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
364
|
+
HAS_COLOR=0
|
|
365
|
+
[[ -t 1 || -t 2 ]] && HAS_COLOR=1
|
|
366
|
+
|
|
367
|
+
if (( HAS_COLOR )); then
|
|
368
|
+
# ── Core ──
|
|
369
|
+
RST=$'\e[0m' BOLD=$'\e[1m' DIM=$'\e[2m' ITALIC=$'\e[3m' ULINE=$'\e[4m'
|
|
370
|
+
# ── Foreground ──
|
|
371
|
+
RED=$'\e[31m' REDB=$'\e[1;31m' # findings: CRITICAL
|
|
372
|
+
GRN=$'\e[32m' GRNB=$'\e[1;32m' # recommended code, pass verdict
|
|
373
|
+
YEL=$'\e[33m' YELB=$'\e[1;33m' # findings: WARNING
|
|
374
|
+
BLU=$'\e[34m' BLUB=$'\e[1;34m' # findings: INFO, LOCATION
|
|
375
|
+
MAG=$'\e[35m' MAGB=$'\e[1;35m' # SUMMARY line
|
|
376
|
+
CYN=$'\e[36m' CYNB=$'\e[1;36m' # accents, SUGGESTED_FIX
|
|
377
|
+
WHT=$'\e[37m' WHTB=$'\e[1;37m' # headings
|
|
378
|
+
# ── Background accents (subtle) ──
|
|
379
|
+
BG_RED=$'\e[41m' BG_GRN=$'\e[42m' BG_BLU=$'\e[44m' BG_YEL=$'\e[43m'
|
|
380
|
+
else
|
|
381
|
+
RST='' BOLD='' DIM='' ITALIC='' ULINE=''
|
|
382
|
+
RED='' REDB='' GRN='' GRNB='' YEL='' YELB=''
|
|
383
|
+
BLU='' BLUB='' MAG='' MAGB='' CYN='' CYNB=''
|
|
384
|
+
WHT='' WHTB='' BG_RED='' BG_GRN='' BG_BLU='' BG_YEL=''
|
|
385
|
+
fi
|
|
386
|
+
|
|
387
|
+
# Terminal width (capped 50–100)
|
|
388
|
+
COLS=$(tput cols 2>/dev/null || echo 80)
|
|
389
|
+
[[ "$COLS" =~ ^[0-9]+$ ]] || COLS=80
|
|
390
|
+
(( COLS < 50 )) && COLS=80
|
|
391
|
+
(( COLS > 100 )) && COLS=100
|
|
392
|
+
|
|
393
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
394
|
+
# │ Display Helpers │
|
|
395
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
396
|
+
|
|
397
|
+
# Repeat a character N times
|
|
398
|
+
repeat_char() { printf '%*s' "$2" '' | tr ' ' "$1"; }
|
|
399
|
+
|
|
400
|
+
# Full-width rule with optional character
|
|
401
|
+
rule() { printf '%s%s%s\n' "$DIM" "$(repeat_char "${1:-─}" "$COLS")" "$RST"; }
|
|
402
|
+
|
|
403
|
+
# Double-line decorative banner
|
|
404
|
+
banner() {
|
|
405
|
+
local title=$1 subtitle=${2:-}
|
|
406
|
+
local pad_t=$(( (COLS - ${#title}) / 2 ))
|
|
407
|
+
local pad_s=$(( (COLS - ${#subtitle}) / 2 ))
|
|
408
|
+
(( pad_t < 0 )) && pad_t=0
|
|
409
|
+
(( pad_s < 0 )) && pad_s=0
|
|
410
|
+
|
|
411
|
+
printf '\n'
|
|
412
|
+
printf '%s%s%s\n' "$DIM" "$(repeat_char '═' "$COLS")" "$RST"
|
|
413
|
+
printf '%*s%s%s%s\n' "$pad_t" '' "$WHTB" "$title" "$RST"
|
|
414
|
+
[[ -n "$subtitle" ]] && \
|
|
415
|
+
printf '%*s%s%s%s\n' "$pad_s" '' "${DIM}${CYN}" "$subtitle" "$RST"
|
|
416
|
+
printf '%s%s%s\n' "$DIM" "$(repeat_char '═' "$COLS")" "$RST"
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
# Centered title between dashes: ──── Title ────
|
|
420
|
+
hr_title() {
|
|
421
|
+
local title=$1
|
|
422
|
+
local tlen=${#title}
|
|
423
|
+
local avail=$(( COLS - tlen - 4 ))
|
|
424
|
+
(( avail < 8 )) && avail=8
|
|
425
|
+
local left=$(( avail / 2 ))
|
|
426
|
+
local right=$(( avail - left ))
|
|
427
|
+
printf '%s%s%s %s%s%s %s%s%s\n' \
|
|
428
|
+
"$DIM" "$(repeat_char '─' "$left")" "$RST" \
|
|
429
|
+
"$BOLD" "$title" "$RST" \
|
|
430
|
+
"$DIM" "$(repeat_char '─' "$right")" "$RST"
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
# Right-aligned label after dashes: ────────── Label
|
|
434
|
+
hr_right() {
|
|
435
|
+
local label=$1
|
|
436
|
+
local dashes=$(( COLS - ${#label} - 2 ))
|
|
437
|
+
(( dashes < 12 )) && dashes=12
|
|
438
|
+
printf '%s%s%s %s%s\n' "$DIM" "$(repeat_char '─' "$dashes")" "$RST" "$label" "$RST"
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
# Word-wrap text with color, 2-space indent (first line), 6-space (continuation)
|
|
442
|
+
wrap() {
|
|
443
|
+
local text=$1 color=${2:-$RST} width=$(( COLS - 6 )) ln n=0
|
|
444
|
+
(( width < 52 )) && width=52
|
|
445
|
+
while IFS= read -r ln || [[ -n "$ln" ]]; do
|
|
446
|
+
[[ -z "${ln//[[:space:]]/}" ]] && continue
|
|
447
|
+
if (( n == 0 )); then
|
|
448
|
+
printf ' %s%s%s\n' "$color" "$ln" "$RST"
|
|
449
|
+
else
|
|
450
|
+
printf ' %s%s%s\n' "$color" "$ln" "$RST"
|
|
451
|
+
fi
|
|
452
|
+
n=$((n + 1))
|
|
453
|
+
done < <(fold -s -w "$width" <<< "$text" 2>/dev/null || printf '%s\n' "$text")
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
# Trim leading/trailing whitespace
|
|
457
|
+
trim() {
|
|
458
|
+
local s=$1
|
|
459
|
+
s="${s#"${s%%[![:space:]]*}"}"
|
|
460
|
+
s="${s%"${s##*[![:space:]]}"}"
|
|
461
|
+
printf '%s' "$s"
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
# Labeled badge: ▸ LABEL
|
|
465
|
+
badge() { printf ' %s▸%s %s%s%s' "$CYNB" "$RST" "$BOLD" "$1" "$RST"; }
|
|
466
|
+
|
|
467
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
468
|
+
# │ Pre-flight display │
|
|
469
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
470
|
+
FILE_COUNT=$(git diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
|
|
471
|
+
FILE_STAT=$(git diff --cached --shortstat 2>/dev/null | sed 's/^[[:space:]]*//' || true)
|
|
472
|
+
FILE_NAMES=$(git diff --cached --name-only 2>/dev/null | head -6)
|
|
473
|
+
FILE_REMAIN=$(( FILE_COUNT - 6 ))
|
|
474
|
+
|
|
475
|
+
{
|
|
476
|
+
banner "Git Analyze · AI Code Quality Gate" \
|
|
477
|
+
"Cursor Agent · single-pass staged diff review"
|
|
478
|
+
printf '\n'
|
|
479
|
+
|
|
480
|
+
# ── Staged scope ──
|
|
481
|
+
badge "Staged Scope"
|
|
482
|
+
printf '\n'
|
|
483
|
+
printf ' %s%s%s file(s)' "$BOLD" "$FILE_COUNT" "$RST"
|
|
484
|
+
[[ -n "$FILE_STAT" ]] && printf ' %s(%s)%s' "$DIM" "$FILE_STAT" "$RST"
|
|
485
|
+
printf '\n'
|
|
486
|
+
while IFS= read -r fn; do
|
|
487
|
+
[[ -z "$fn" ]] && continue
|
|
488
|
+
# Color by file type
|
|
489
|
+
if [[ "$fn" == app/controllers/* ]]; then printf ' %s●%s %s\n' "$CYN" "$RST" "$fn"
|
|
490
|
+
elif [[ "$fn" == app/models/* ]]; then printf ' %s●%s %s\n' "$MAG" "$RST" "$fn"
|
|
491
|
+
elif [[ "$fn" == app/services/* ]]; then printf ' %s●%s %s\n' "$GRN" "$RST" "$fn"
|
|
492
|
+
elif [[ "$fn" == spec/* ]]; then printf ' %s●%s %s\n' "$YEL" "$RST" "$fn"
|
|
493
|
+
elif [[ "$fn" == db/migrate/* ]]; then printf ' %s●%s %s\n' "$RED" "$RST" "$fn"
|
|
494
|
+
else printf ' %s●%s %s\n' "$DIM" "$RST" "$fn"
|
|
495
|
+
fi
|
|
496
|
+
done <<< "$FILE_NAMES"
|
|
497
|
+
(( FILE_REMAIN > 0 )) && printf ' %s… and %d more file(s)%s\n' "$DIM" "$FILE_REMAIN" "$RST"
|
|
498
|
+
printf '\n'
|
|
499
|
+
|
|
500
|
+
# ── Review dimensions ──
|
|
501
|
+
badge "Review Dimensions"
|
|
502
|
+
printf ' %s%s\n' "$DIM" "$RST"
|
|
503
|
+
printf '\n'
|
|
504
|
+
printf ' %s┌──────────────────────────────────────────────────┐%s\n' "$DIM" "$RST"
|
|
505
|
+
printf ' %s│%s %s 1 %s %-11s %s│%s %s\n' "$DIM" "$RST" "${BG_BLU}${WHTB}" "$RST" "DUPLICATION" "$DIM" "$RST" "DRY · repeated logic · N+1 · thin controllers"
|
|
506
|
+
printf ' %s│%s %s 2 %s %-11s %s│%s %s\n' "$DIM" "$RST" "${BG_BLU}${WHTB}" "$RST" "COMPLEXITY" "$DIM" "$RST" "long methods · nesting · god objects · SRP"
|
|
507
|
+
printf ' %s│%s %s 3 %s %-11s %s│%s %s\n' "$DIM" "$RST" "${BG_RED}${WHTB}" "$RST" "SECURITY" "$DIM" "$RST" "SQLi · mass assignment · secrets · auth/authz"
|
|
508
|
+
printf ' %s│%s %s 4 %s %-11s %s│%s %s\n' "$DIM" "$RST" "${BG_BLU}${WHTB}" "$RST" "STYLE" "$DIM" "$RST" "idiomatic Rails/Ruby · naming · dead code"
|
|
509
|
+
printf ' %s│%s %s + %s %-11s %s│%s %s\n' "$DIM" "$RST" "${BG_GRN}${WHTB}" "$RST" "TESTS" "$DIM" "$RST" "staged spec coverage for changed behavior"
|
|
510
|
+
printf ' %s└──────────────────────────────────────────────────┘%s\n' "$DIM" "$RST"
|
|
511
|
+
printf '\n'
|
|
512
|
+
|
|
513
|
+
hr_right "Running analysis…"
|
|
514
|
+
printf '\n'
|
|
515
|
+
} >&2
|
|
516
|
+
|
|
517
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
518
|
+
# │ Run Agent │
|
|
519
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
520
|
+
run_agent() {
|
|
521
|
+
local style="${GIT_ANALYZE_AGENT_STYLE:-chat-print}"
|
|
522
|
+
local extra="${GIT_ANALYZE_AGENT_EXTRA_ARGS:---trust}"
|
|
523
|
+
# shellcheck disable=SC2086
|
|
524
|
+
case "$style" in
|
|
525
|
+
chat-p) eval "\"$AGENT_BIN\" $extra chat -p $PROMPT_SHELL_Q" </dev/null ;;
|
|
526
|
+
print-only) eval "\"$AGENT_BIN\" $extra --print $PROMPT_SHELL_Q" </dev/null ;;
|
|
527
|
+
*) eval "\"$AGENT_BIN\" $extra chat --print $PROMPT_SHELL_Q" </dev/null ;;
|
|
528
|
+
esac
|
|
529
|
+
}
|
|
530
|
+
( run_agent >"$TMP_OUT" 2>&1 ) &
|
|
531
|
+
AGENT_PID=$!
|
|
532
|
+
|
|
533
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
534
|
+
# │ Progress Bar │
|
|
535
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
536
|
+
BAR_W=44
|
|
537
|
+
SPIN_CHARS=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
|
538
|
+
# Fallback to ASCII if terminal can't render braille
|
|
539
|
+
printf '%s' "${SPIN_CHARS[0]}" >/dev/null 2>&1 || SPIN_CHARS=('|' '/' '-' '\')
|
|
540
|
+
SPIN_LEN=${#SPIN_CHARS[@]}
|
|
541
|
+
|
|
542
|
+
FILL_SEC=${GIT_ANALYZE_PROGRESS_FILL_SEC:-10}
|
|
543
|
+
FSLEEP=${GIT_ANALYZE_FRAME_SLEEP:-0.07}
|
|
544
|
+
FSLEEP_MS=$(awk -v s="$FSLEEP" 'BEGIN{x=int(s*1000+.5); print (x<1?1:x)}' 2>/dev/null || echo 70)
|
|
545
|
+
MIN_FR=$(( (2000 + FSLEEP_MS - 1) / FSLEEP_MS ))
|
|
546
|
+
(( MIN_FR < 12 )) && MIN_FR=12
|
|
547
|
+
MAX_FR=$(( (FILL_SEC * 1000 + FSLEEP_MS - 1) / FSLEEP_MS ))
|
|
548
|
+
(( MAX_FR < 30 )) && MAX_FR=30
|
|
549
|
+
|
|
550
|
+
draw_bar() {
|
|
551
|
+
local pct=$1 spin_ch=${2:-}
|
|
552
|
+
local filled=$(( pct * BAR_W / 100 )) empty
|
|
553
|
+
(( filled > BAR_W )) && filled=$BAR_W
|
|
554
|
+
(( pct > 0 && filled == 0 )) && filled=1
|
|
555
|
+
empty=$(( BAR_W - filled ))
|
|
556
|
+
|
|
557
|
+
# Build bar segments
|
|
558
|
+
local bar_fill bar_empty
|
|
559
|
+
bar_fill=$(repeat_char '█' "$filled")
|
|
560
|
+
bar_empty=$(repeat_char '░' "$empty")
|
|
561
|
+
|
|
562
|
+
# Color the percentage based on progress
|
|
563
|
+
local pct_color=$DIM
|
|
564
|
+
(( pct >= 30 )) && pct_color=$CYN
|
|
565
|
+
(( pct >= 60 )) && pct_color=$CYNB
|
|
566
|
+
(( pct >= 90 )) && pct_color=$GRNB
|
|
567
|
+
|
|
568
|
+
printf '\e[2K\r %s%s%s%s%s %s%3d%%%s' \
|
|
569
|
+
"$CYNB" "$bar_fill" "$DIM" "$bar_empty" "$RST" \
|
|
570
|
+
"$pct_color" "$pct" "$RST" >&2
|
|
571
|
+
if [[ -n "$spin_ch" ]]; then
|
|
572
|
+
printf ' %s%s%s' "$CYNB" "$spin_ch" "$RST" >&2
|
|
573
|
+
fi
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
draw_complete() {
|
|
577
|
+
local bar_full
|
|
578
|
+
bar_full=$(repeat_char '█' "$BAR_W")
|
|
579
|
+
printf '\e[2K\r %s%s%s %s100%% %s ✓ Analysis complete%s\n' \
|
|
580
|
+
"$GRNB" "$bar_full" "$RST" "$GRNB" "$GRNB" "$RST" >&2
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if [[ -t 2 ]]; then
|
|
584
|
+
frame=0 reaped=0 pct=0
|
|
585
|
+
while true; do
|
|
586
|
+
frame=$((frame + 1))
|
|
587
|
+
si=$(( frame % SPIN_LEN ))
|
|
588
|
+
|
|
589
|
+
# Check if agent finished
|
|
590
|
+
if (( !reaped )) && ! kill -0 "$AGENT_PID" 2>/dev/null; then
|
|
591
|
+
wait "$AGENT_PID" || true
|
|
592
|
+
reaped=1; AGENT_PID=""
|
|
593
|
+
fi
|
|
594
|
+
|
|
595
|
+
# Calculate percentage
|
|
596
|
+
if (( !reaped )); then
|
|
597
|
+
if (( frame < MAX_FR )); then pct=$(( frame * 90 / MAX_FR ))
|
|
598
|
+
else pct=$(( 90 + (frame - MAX_FR) / 28 )); fi
|
|
599
|
+
else
|
|
600
|
+
pct=$(( frame * 92 / MIN_FR ))
|
|
601
|
+
(( pct > 92 )) && pct=92
|
|
602
|
+
fi
|
|
603
|
+
(( pct > 94 )) && pct=94
|
|
604
|
+
|
|
605
|
+
draw_bar "$pct" "${SPIN_CHARS[$si]}"
|
|
606
|
+
sleep "$FSLEEP"
|
|
607
|
+
(( reaped && frame >= MIN_FR )) && break
|
|
608
|
+
done
|
|
609
|
+
|
|
610
|
+
for endpct in 96 98 100; do draw_bar "$endpct" ""; sleep 0.04; done
|
|
611
|
+
draw_complete
|
|
612
|
+
else
|
|
613
|
+
printf '%s ⏳ No TTY — waiting for Cursor Agent…%s\n' "$DIM" "$RST" >&2
|
|
614
|
+
wait "$AGENT_PID" || true
|
|
615
|
+
AGENT_PID=""
|
|
616
|
+
fi
|
|
617
|
+
|
|
618
|
+
# Ensure no zombie
|
|
619
|
+
[[ -n "${AGENT_PID:-}" ]] && { wait "$AGENT_PID" || true; AGENT_PID=""; }
|
|
620
|
+
|
|
621
|
+
RAW=$(cat "$TMP_OUT" || true)
|
|
622
|
+
if [[ -z "$RAW" ]]; then
|
|
623
|
+
printf '\n %s✗ Cursor Agent produced no output (check CLI, API key, network). Failing closed.%s\n' "$REDB" "$RST" >&2
|
|
624
|
+
exit 1
|
|
625
|
+
fi
|
|
626
|
+
|
|
627
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
628
|
+
# │ Render findings │
|
|
629
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
630
|
+
colorize_line() {
|
|
631
|
+
local orig line
|
|
632
|
+
orig=$(trim "$1")
|
|
633
|
+
[[ -z "$orig" ]] && { printf '\n'; return 0; }
|
|
634
|
+
line="${orig//\*\*/}"
|
|
635
|
+
line=$(trim "$line")
|
|
636
|
+
|
|
637
|
+
if [[ "$line" == SUMMARY:* ]]; then
|
|
638
|
+
printf '\n'
|
|
639
|
+
printf ' %s%s ▸ %s%s\n' "$MAGB" "$BOLD" "$line" "$RST"
|
|
640
|
+
printf '\n'
|
|
641
|
+
elif [[ "$line" == *"[CRITICAL]"* ]]; then
|
|
642
|
+
printf ' %s%s⬤ %s%s\n' "$REDB" "$BOLD" "$orig" "$RST"
|
|
643
|
+
elif [[ "$line" == *"[WARNING]"* ]]; then
|
|
644
|
+
printf ' %s◉ %s%s\n' "$YELB" "$orig" "$RST"
|
|
645
|
+
elif [[ "$line" == *"[INFO]"* ]]; then
|
|
646
|
+
printf ' %s○ %s%s\n' "$BLUB" "$orig" "$RST"
|
|
647
|
+
elif [[ "$line" == LOCATION:* ]]; then
|
|
648
|
+
printf ' %s↳ %s%s\n' "$BLU" "${line#LOCATION: }" "$RST"
|
|
649
|
+
elif [[ "$line" == SUGGESTED_FIX:* ]]; then
|
|
650
|
+
printf ' %s💡 %s%s\n' "$CYNB" "${line#SUGGESTED_FIX: }" "$RST"
|
|
651
|
+
else
|
|
652
|
+
wrap "$orig" "$DIM"
|
|
653
|
+
fi
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
render_findings() {
|
|
657
|
+
local line t tl fence="" skip_rec=0 nocolor low
|
|
658
|
+
|
|
659
|
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
660
|
+
(( skip_rec )) && continue
|
|
661
|
+
|
|
662
|
+
t=$(trim "$line")
|
|
663
|
+
t="${t//$'\r'/}"
|
|
664
|
+
nocolor="${t//\*\*/}"
|
|
665
|
+
nocolor=$(trim "$nocolor")
|
|
666
|
+
low=$(printf '%s' "$nocolor" | tr '[:upper:]' '[:lower:]')
|
|
667
|
+
|
|
668
|
+
# Stop at any "Recommendation" / recap section the model might append
|
|
669
|
+
if [[ "$nocolor" == RECOMMENDATION:* ]] || \
|
|
670
|
+
[[ "$low" == *recommendation* && "$nocolor" == *▸* ]] || \
|
|
671
|
+
[[ "$low" == \#*recommendation* ]] || \
|
|
672
|
+
[[ "$low" == \#*summary* && "$nocolor" != SUMMARY:* ]]; then
|
|
673
|
+
skip_rec=1; continue
|
|
674
|
+
fi
|
|
675
|
+
|
|
676
|
+
# ── Code fences ──
|
|
677
|
+
if [[ "$t" == \`\`\`* ]]; then
|
|
678
|
+
tl=$(printf '%s' "$t" | tr '[:upper:]' '[:lower:]')
|
|
679
|
+
if [[ "$t" == '```' ]]; then
|
|
680
|
+
printf ' %s%s%s\n' "$DIM" '```' "$RST"
|
|
681
|
+
fence=""; continue
|
|
682
|
+
elif [[ "$tl" =~ ^\`\`\`ruby[[:space:]]+before$ || "$tl" == '```before' ]]; then
|
|
683
|
+
fence="before"
|
|
684
|
+
printf '\n %s┄┄ before ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄%s\n' "$DIM" "$RST"
|
|
685
|
+
continue
|
|
686
|
+
elif [[ "$tl" =~ ^\`\`\`ruby[[:space:]]+(recommended|after)$ || "$tl" =~ ^\`\`\`(recommended|after)$ ]]; then
|
|
687
|
+
fence="recommended"
|
|
688
|
+
printf ' %s┄┄ recommended ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄%s\n' "$DIM" "$RST"
|
|
689
|
+
continue
|
|
690
|
+
else
|
|
691
|
+
fence="neutral"
|
|
692
|
+
printf ' %s%s%s\n' "$DIM" "$line" "$RST"
|
|
693
|
+
continue
|
|
694
|
+
fi
|
|
695
|
+
fi
|
|
696
|
+
|
|
697
|
+
# ── Inside fenced code ──
|
|
698
|
+
case "$fence" in
|
|
699
|
+
before)
|
|
700
|
+
printf ' %s - %s%s\n' "$REDB" "$line" "$RST" ;;
|
|
701
|
+
recommended)
|
|
702
|
+
printf ' %s + %s%s\n' "$GRNB" "$line" "$RST" ;;
|
|
703
|
+
neutral)
|
|
704
|
+
printf ' %s %s%s\n' "$DIM" "$line" "$RST" ;;
|
|
705
|
+
*)
|
|
706
|
+
colorize_line "$line" ;;
|
|
707
|
+
esac
|
|
708
|
+
done <<< "$RAW"
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
# ── Coverage helper (optional external script) ──
|
|
712
|
+
print_coverage() {
|
|
713
|
+
local rb gem_path
|
|
714
|
+
rb="$REPO_ROOT/scripts/git_analyze_staged_coverage.rb"
|
|
715
|
+
if [[ ! -f "$rb" ]]; then
|
|
716
|
+
gem_path=$(git config --get heimdalAiAnalyze.gemPath 2>/dev/null || true)
|
|
717
|
+
[[ -n "$gem_path" ]] && rb="$gem_path/lib/heimdal_ai_analyze/git_analyze_staged_coverage.rb"
|
|
718
|
+
fi
|
|
719
|
+
[[ ! -f "$rb" ]] && return 0
|
|
720
|
+
printf '\n' >&2
|
|
721
|
+
hr_title "STAGED NEW-CODE COVERAGE" >&2
|
|
722
|
+
rule >&2
|
|
723
|
+
printf '\n' >&2
|
|
724
|
+
ruby "$rb" "$REPO_ROOT" >&2 || true
|
|
725
|
+
printf '\n' >&2
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
# ── Main output ──
|
|
729
|
+
{
|
|
730
|
+
printf '\n'
|
|
731
|
+
hr_title "FINDINGS"
|
|
732
|
+
rule
|
|
733
|
+
printf '\n'
|
|
734
|
+
render_findings
|
|
735
|
+
printf '\n'
|
|
736
|
+
} >&2
|
|
737
|
+
|
|
738
|
+
print_coverage
|
|
739
|
+
|
|
740
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
741
|
+
# │ Verdict │
|
|
742
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
743
|
+
verdict_block() {
|
|
744
|
+
local icon=$1 label=$2 color=$3 msg=$4
|
|
745
|
+
printf '\n' >&2
|
|
746
|
+
hr_title "$label" >&2
|
|
747
|
+
printf '\n' >&2
|
|
748
|
+
printf ' %s%s %s%s\n' "$color" "$icon" "$label" "$RST" >&2
|
|
749
|
+
[[ -n "$msg" ]] && printf ' %s%s%s\n' "$DIM" "$msg" "$RST" >&2
|
|
750
|
+
printf '\n' >&2
|
|
751
|
+
rule >&2
|
|
752
|
+
printf '\n' >&2
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if printf '%s\n' "$RAW" | grep -Fq '[CRITICAL]'; then
|
|
756
|
+
verdict_block "✗" "COMMIT BLOCKED" "$REDB" \
|
|
757
|
+
"Resolve [CRITICAL] items above, or bypass: git commit --no-verify"
|
|
758
|
+
exit 1
|
|
759
|
+
fi
|
|
760
|
+
|
|
761
|
+
NONBLANK=$(printf '%s\n' "$RAW" | grep -c '[^[:space:]]' 2>/dev/null || true)
|
|
762
|
+
if [[ "${NONBLANK:-0}" -eq 1 ]] && printf '%s\n' "$RAW" | grep -qFx 'NO_ISSUES_FOUND'; then
|
|
763
|
+
verdict_block "✓" "CLEAN — NO ISSUES" "$GRNB" ""
|
|
764
|
+
exit 0
|
|
765
|
+
fi
|
|
766
|
+
|
|
767
|
+
if printf '%s\n' "$RAW" | grep -qE '^(\*\*)?SUMMARY:'; then
|
|
768
|
+
verdict_block "✓" "COMMIT APPROVED" "$GRNB" \
|
|
769
|
+
"No CRITICAL findings. Bypass anytime: git commit --no-verify"
|
|
770
|
+
exit 0
|
|
771
|
+
fi
|
|
772
|
+
|
|
773
|
+
# Malformed output — fail closed
|
|
774
|
+
{
|
|
775
|
+
printf '\n' >&2
|
|
776
|
+
hr_title "ERROR" >&2
|
|
777
|
+
printf '\n' >&2
|
|
778
|
+
printf ' %s✗ Malformed AI output. Failing closed.%s\n' "$REDB" "$RST" >&2
|
|
779
|
+
if printf '%s\n' "$RAW" | grep -Fq 'Workspace Trust Required'; then
|
|
780
|
+
printf ' %sCursor Agent blocked on workspace trust.%s\n' "$YEL" "$RST" >&2
|
|
781
|
+
printf ' %sRun once interactively: agent chat "hi"%s\n' "$DIM" "$RST" >&2
|
|
782
|
+
fi
|
|
783
|
+
printf '\n %sRaw output (last 20 lines):%s\n' "$DIM" "$RST" >&2
|
|
784
|
+
printf '%s\n' "$RAW" | tail -20 | sed "s/^/ $DIM/" >&2
|
|
785
|
+
printf '%s\n' "$RST" >&2
|
|
786
|
+
rule >&2
|
|
787
|
+
} >&2
|
|
788
|
+
exit 1
|
metadata
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: heimdal_ai_analyze
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ffarhhan
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Installs a git pre-commit hook that runs Cursor Agent on ANALYZE=true
|
|
14
|
+
commits (e.g. git analyze). Requires CURSOR_API_KEY and the Cursor Agent CLI.
|
|
15
|
+
email:
|
|
16
|
+
- ffarhhan@users.noreply.github.com
|
|
17
|
+
executables:
|
|
18
|
+
- heimdal-ai-analyze-install
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- LICENSE.txt
|
|
23
|
+
- README.md
|
|
24
|
+
- exe/heimdal-ai-analyze-install
|
|
25
|
+
- heimdal_ai_analyze.gemspec
|
|
26
|
+
- lib/heimdal_ai_analyze.rb
|
|
27
|
+
- lib/heimdal_ai_analyze/git_analyze_staged_coverage.rb
|
|
28
|
+
- lib/heimdal_ai_analyze/version.rb
|
|
29
|
+
- templates/env.hook.example
|
|
30
|
+
- templates/pre-commit
|
|
31
|
+
homepage: https://github.com/ffarhhan/heimdal_ai_analyze
|
|
32
|
+
licenses:
|
|
33
|
+
- MIT
|
|
34
|
+
metadata:
|
|
35
|
+
homepage_uri: https://github.com/ffarhhan/heimdal_ai_analyze
|
|
36
|
+
source_code_uri: https://github.com/ffarhhan/heimdal_ai_analyze
|
|
37
|
+
rubygems_mfa_required: 'true'
|
|
38
|
+
post_install_message:
|
|
39
|
+
rdoc_options: []
|
|
40
|
+
require_paths:
|
|
41
|
+
- lib
|
|
42
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 3.1.0
|
|
47
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '0'
|
|
52
|
+
requirements: []
|
|
53
|
+
rubygems_version: 3.4.19
|
|
54
|
+
signing_key:
|
|
55
|
+
specification_version: 4
|
|
56
|
+
summary: Heimdal AI Analyze — Cursor Agent pre-commit gate for staged diffs
|
|
57
|
+
test_files: []
|