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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HeimdalAiAnalyze
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "heimdal_ai_analyze/version"
4
+
5
+ module HeimdalAiAnalyze
6
+ end
@@ -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: []