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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions checkie.gemspec
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
Gem::Specification.new do |s|
s.name = %q{checkie}
s.version = "0.0.1"
s.version = "0.0.3"
s.authors = %q{Alignable}
s.date = %q{2018-05-23}
s.date = %q{2026-02-04}
s.summary = %q{Hi, It's Checkie! Looks like you're trying to write some code}
s.files = [
"lib/checkie.rb"
Expand All @@ -11,9 +11,10 @@ Gem::Specification.new do |s|
s.license = 'MIT'

s.add_runtime_dependency 'dotenv'
s.add_runtime_dependency "octokit", "~>4.22.0"
s.add_runtime_dependency "octokit"
s.add_runtime_dependency "git_diff_parser"
s.add_runtime_dependency "activesupport"
s.add_runtime_dependency "anthropic"

# to write specs for rules.rb
s.add_runtime_dependency "rspec"
Expand Down
8 changes: 8 additions & 0 deletions lib/checkie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,19 @@ def matching(file_pattern, &block)
Checkie::Parser.instance.add_matching_file(file_pattern,&block)
end

def matching_ai(file_pattern, rules:, exclude: nil, &block)
Checkie::Parser.instance.add_matching_file(file_pattern, ai: true, rules: rules, exclude: exclude, &block)
end


def file_rule(name,description,references:[])
Checkie::Parser.instance.add_file_rule(name, description, references: references)
end

def file_rule_ai(name, description, **opts)
Checkie::Parser.instance.add_file_rule(name, description, **opts.merge({ai: true}))
end

def run(url, action)
Checkie::Runner.new.run(url, action)
end
43 changes: 43 additions & 0 deletions lib/checkie/matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,49 @@ def check(rule,references = nil )
@rules[rule.to_s] += references if references
end

def match_ai
ruleset_to_files = {exploration: {}, standard: {}}
@pr.each do |file|
std_rules, exp_rules = gather_rules_ai(file[:filename])
next if std_rules.empty? && exp_rules.empty?

changeset = {name: file[:filename], patch: file[:patch]}
unless exp_rules.empty?
subset_key = :exploration
ruleset_to_files[subset_key][exp_rules] ||= []
ruleset_to_files[subset_key][exp_rules] << changeset
end
unless std_rules.empty?
subset_key = :standard
ruleset_to_files[subset_key][std_rules] ||= []
ruleset_to_files[subset_key][std_rules] << changeset
end
end

{
exploration: ruleset_to_files[:exploration].map { |k, v| [k.join("\n"), v] },
standard: ruleset_to_files[:standard].map { |k, v| [k.join("\n"), v] }
}
end

def gather_rules_ai(path)
applicable = Set[]
exploration = Set[]
parser.matches_ai.each do |rule|
next unless File.fnmatch(rule[:pattern], path, File::FNM_EXTGLOB)
next if rule[:exclude] && rule[:exclude].any? { |p| File.fnmatch(p, path, File::FNM_EXTGLOB) }

rule[:rules].compact.each do |r|
if r[:exploration]
exploration.add(r[:description])
else
applicable.add(r[:description])
end
end
end
[applicable, exploration]
end

def match
all_files = Checkie::FileMatchSet.new(self)

Expand Down
45 changes: 33 additions & 12 deletions lib/checkie/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,52 @@
class Checkie::Parser
include Singleton

attr_reader :matches, :rules
attr_reader :matches, :rules, :matches_ai, :rules_ai

def initialize
reinitialize
end

def reinitialize
@rules = {}
@rules_ai = {}
@matches = []
@matches_ai = []
end

def add_matching_file(glob_pattern, matching_proc=nil, &block)
def add_matching_file(glob_pattern, matching_proc=nil, **opts, &block)
matching_proc ||= block
@matches << { pattern: glob_pattern,
matching_proc: matching_proc
}
obj = { pattern: glob_pattern,
matching_proc: matching_proc
}
if opts[:ai]
obj[:rules] = opts[:rules].map do |r|
@rules_ai[r.to_s]
end
if opts[:exclude]
obj[:exclude] = Array(opts[:exclude])
end
@matches_ai << obj
else
@matches << obj
end
end

def add_file_rule(name, description, references: [])
raise "Duplicate rule #{name}" if @rules[name.to_s].present?
@rules[name.to_s] = {
name: name.to_s,
description: description,
references: references
}
def add_file_rule(name, description, **opts)
raise "Duplicate rule #{name}" if @rules_ai[name.to_s].present? || @rules_ai[name.to_s].present?
if opts[:ai]
@rules_ai[name.to_s] = {
name: name.to_s,
description: description,
exploration: opts[:exploration],
}
else
@rules[name.to_s] = {
name: name.to_s,
description: description,
references: opts[:references]
}
end
end

end
45 changes: 45 additions & 0 deletions lib/checkie/poster.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ def initialize(details, dry_run: true)
@dry_run = dry_run
end

def post_ai_annotations!(rules)
if rules.length > 0
annotations = annotations_ai(rules)
repo_name = @details[:base][:repo][:full_name]
sha = @details[:head][:sha]

annotations.each do |a|
if @dry_run
pp a
else
client.create_pull_request_comment(
repo_name,
@details[:number],
a[:message],
sha,
a[:path],
a[:start_line]
)
end
rescue Octokit::UnprocessableEntity
pp "Bad request sent! Probably a line number issue: #{a[:path]}:#{a[:start_line]}"
end
end
end

# Post file rules as annotations
def post_annotations!(rules)
if rules.length > 0
Expand All @@ -32,6 +57,26 @@ def post_annotations!(rules)

private

def annotations_ai(rules)
arr = []
rules.each do |r|
r = r[:violations]
next if r.empty?
r.each do |annotation|
# Because Claude can be stupid.
next if annotation[:suggestion].downcase.include?("no violation")
arr << {
path: annotation[:file],
start_line: annotation[:line_number],
end_line: annotation[:line_number],
title: annotation[:rule],
message: "#{annotation[:issue]}\n#{annotation[:suggestion]}"
}
end
end
return arr
end

def annotations(rules)
arr = []

Expand Down
158 changes: 155 additions & 3 deletions lib/checkie/runner.rb
Original file line number Diff line number Diff line change
@@ -1,16 +1,168 @@
require "open3"
require "anthropic"
class Checkie::Runner
# Action is the pull request action type
def run(url, action)
fetcher = Checkie::Fetcher.new(url)
poster = Checkie::Poster.new(fetcher.details, dry_run: false)
poster = Checkie::Poster.new(fetcher.details, dry_run: true)

if action == "synchronize" || action == "opened"
data = fetcher.fetch_files

matcher = Checkie::Matcher.new(data)
rules = matcher.match
rules = matcher.match_ai
ai_rules = call_claude(rules[:exploration]) + call_claude_api(rules[:standard])
reg_rules = matcher.match
poster.post_ai_annotations!(ai_rules)
poster.post_annotations!(reg_rules)
end
end

def call_claude(rule_mapping)
total_calls = 0
pp "Beginning checks with Claude Code..."
# assumes that non-exploratory rules have been filtered out
res = rule_mapping.map do |mapping|
# mappping == [rule strings joined by \n, arr of patch diffs]
prompt = create_prompt(mapping[0], mapping[1])

repo_dir = File.expand_path("../../", Dir.pwd)
total_calls += 1
res = Open3.capture3("claude", "--model", "haiku", "--dangerously-skip-permissions", "-p", prompt, chdir: repo_dir)
puts res[0]
prefix = res[0].index("```json")
postfix = res[0].index("```", prefix + 7)
unless prefix && postfix
puts "Bad response from Claude!!", res[0]
next
end
parsed = res[0][prefix+7...postfix]
JSON.parse(parsed)
end
pp "Total CC calls: #{total_calls}"
res
end

def call_claude_api(rule_mapping)
pp "Beginning checks with API..."
# Assumes that exploratory rules have been filtered out
client = Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"])

total_calls = 0
res = rule_mapping.map do |mapping|
# mapping == [rule strings joined by \n, arr of patch diffs]
prompt = create_prompt(mapping[0], mapping[1])

begin
response = client.messages.create(
model: "claude-haiku-4-5-20251001",
max_tokens: 4096,
messages: [
{
role: "user",
content: prompt
}
],
tools: [
{
name: "report_violations",
description: "Report code style violations found in the PR diff",
input_schema: structured_schema
}
],
tool_choice: {
type: "tool",
name: "report_violations"
}
)
total_calls += 1

# Extract the tool use result
tool_use = response.content&.find { |block| block.type == :tool_use }
pp tool_use
if tool_use && tool_use.input
tool_use.input
else
puts "No tool use found in response"
{ "violations" => [] }
end
rescue => e
puts "Error calling Claude API: #{e.message}"
{ "violations" => [] }
end
end
pp "Total API calls: #{total_calls}"
res
end

poster.post_annotations!(rules)
def structured_schema
{
type: "object",
properties: {
violations: {
type: "array",
items: {
type: "object",
properties: {
file: { type: "string", description: "Path to the file" },
rule: { type: "string", description: "Name of the rule violated" },
line_number: { type: "integer", description: "Line number in the diff (must be a + or - line)" },
issue: { type: "string", description: "Description of what's wrong" },
suggestion: { type: "string", description: "How to fix the issue" }
},
required: ["file", "rule", "line_number", "issue", "suggestion"]
}
}
},
required: ["violations"]
}
end

def create_prompt(rules, diffs)
formatted = diffs.map do |d|
<<-PATCH
File: #{d[:name]}
#{d[:patch]}
PATCH
end
<<-PROMPT
You are an AI code reviewer, going through PRs and identifying any changes that
don't follow the teams established best practices.

Code style rules:
#{rules}

Git PR diff to evaluate:
#{formatted}

IMPORTANT: The diff above only shows files matching the rules being checked. Before flagging
any missing files (like spec files), you MUST use the Read tool or Glob tool to verify the file
does not exist in the repository. Do not assume a file is missing just because it's not in the
diff above - it may have been added elsewhere in the PR.
One block / line of code may violate multiple rules. Include any and all violations.

Instructions:
1. For each changed file, check if modified lines ONLY WITHIN THE DIFF violate any rules.
1.1 DO NOT make your own assumptions about how to interpret the rules
1.2 DO NOT make up your own rules, such as grammar violations
1.3 DO NOT RESPOND WITH VIOLATIONS ON UNCHANGED CODE
2. You may read all related files for context

Ensure all parts of your response are succinct and to the point. DO NOT ramble.
Return valid JSON only:
{
"violations": [
{
"file": "path/to/file.rb",
"rule": "rule name",
"line_number": 10,
"issue": "what's wrong",
"suggestion": "how to fix"
}
]
}

Return empty violations array if no issues found: {"violations": []}
PROMPT
end
end
Loading