Skip to content

Commit efcb4db

Browse files
committed
Extract behavior to an api, add tests for api. extend cli to mimic api
1 parent ce8fdf5 commit efcb4db

19 files changed

+525
-378
lines changed

.rubocop.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,5 @@ Style/SuperArguments:
8686
Style/ArgumentsForwarding:
8787
Enabled: false
8888

89-
Style/BlockForwarding:
89+
Naming/BlockForwarding:
9090
Enabled: false

Gemfile.lock

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ GEM
6666
constant_resolver (0.2.0)
6767
crass (1.0.6)
6868
date (3.4.1)
69-
diff-lcs (1.6.0)
69+
diff-lcs (1.6.1)
7070
drb (2.2.1)
7171
erubi (1.13.1)
7272
i18n (1.14.7)
@@ -79,12 +79,14 @@ GEM
7979
json (2.10.2)
8080
language_server-protocol (3.17.0.4)
8181
lint_roller (1.1.0)
82-
logger (1.6.6)
82+
logger (1.7.0)
8383
loofah (2.24.0)
8484
crass (~> 1.0.2)
8585
nokogiri (>= 1.12.0)
8686
minitest (5.25.5)
87-
nokogiri (1.18.5-arm64-darwin)
87+
nokogiri (1.18.7-arm64-darwin)
88+
racc (~> 1.4)
89+
nokogiri (1.18.7-x86_64-darwin)
8890
racc (~> 1.4)
8991
packwerk (3.2.2)
9092
activesupport (>= 6.0)
@@ -101,7 +103,7 @@ GEM
101103
parse_packwerk (0.26.0)
102104
bigdecimal
103105
sorbet-runtime
104-
parser (3.3.7.2)
106+
parser (3.3.7.4)
105107
ast (~> 2.4.1)
106108
racc
107109
pp (0.6.2)
@@ -122,7 +124,7 @@ GEM
122124
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
123125
rainbow (3.1.1)
124126
rake (13.2.1)
125-
rdoc (6.12.0)
127+
rdoc (6.13.1)
126128
psych (>= 4.0.0)
127129
regexp_parser (2.10.0)
128130
reline (0.6.0)
@@ -140,34 +142,34 @@ GEM
140142
diff-lcs (>= 1.2.0, < 2.0)
141143
rspec-support (~> 3.13.0)
142144
rspec-support (3.13.2)
143-
rubocop (1.74.0)
145+
rubocop (1.75.1)
144146
json (~> 2.3)
145147
language_server-protocol (~> 3.17.0.2)
146148
lint_roller (~> 1.1.0)
147149
parallel (~> 1.10)
148150
parser (>= 3.3.0.2)
149151
rainbow (>= 2.2.2, < 4.0)
150152
regexp_parser (>= 2.9.3, < 3.0)
151-
rubocop-ast (>= 1.38.0, < 2.0)
153+
rubocop-ast (>= 1.43.0, < 2.0)
152154
ruby-progressbar (~> 1.7)
153155
unicode-display_width (>= 2.4.0, < 4.0)
154-
rubocop-ast (1.41.0)
156+
rubocop-ast (1.43.0)
155157
parser (>= 3.3.7.2)
156-
rubocop-performance (1.24.0)
158+
prism (~> 1.4)
159+
rubocop-performance (1.25.0)
157160
lint_roller (~> 1.1)
158-
rubocop (>= 1.72.1, < 2.0)
161+
rubocop (>= 1.75.0, < 2.0)
159162
rubocop-ast (>= 1.38.0, < 2.0)
160-
rubocop-sorbet (0.9.0)
161-
lint_roller (~> 1.1)
163+
rubocop-sorbet (0.10.0)
162164
rubocop (>= 1)
163165
ruby-progressbar (1.13.0)
164166
securerandom (0.4.1)
165167
smart_properties (1.17.0)
166-
sorbet (0.5.11953)
167-
sorbet-static (= 0.5.11953)
168-
sorbet-runtime (0.5.11953)
169-
sorbet-static (0.5.11953-universal-darwin)
170-
stringio (3.1.5)
168+
sorbet (0.5.11971)
169+
sorbet-static (= 0.5.11971)
170+
sorbet-runtime (0.5.11971)
171+
sorbet-static (0.5.11971-universal-darwin)
172+
stringio (3.1.6)
171173
thor (1.3.2)
172174
tzinfo (2.0.6)
173175
concurrent-ruby (~> 1.0)

lib/chatwerk.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ module Chatwerk
1111

1212
autoload :API, 'chatwerk/api'
1313
autoload :CLI, 'chatwerk/cli'
14+
autoload :Error, 'chatwerk/error'
1415
autoload :Helpers, 'chatwerk/helpers'
1516
autoload :Mcp, 'chatwerk/mcp'
1617
autoload :Views, 'chatwerk/views'
1718
autoload :VERSION, 'chatwerk/version'
1819

19-
class Error < StandardError; end
2020
class NotFoundError < Error; end
2121
end

lib/chatwerk/api.rb

Lines changed: 39 additions & 203 deletions
Original file line numberDiff line numberDiff line change
@@ -1,224 +1,60 @@
11
# typed: strict
22
# frozen_string_literal: true
33

4+
require 'yaml'
5+
require_relative 'views'
6+
47
module Chatwerk
5-
# The API module provides a structured interface for accessing QueryPackwerk functionality.
6-
# It's designed to be used by the MCP (Machine Control Protocol) server.
7-
# All methods are designed to return structured data that can be easily serialized to JSON.
88
module API
9-
extend T::Sig
10-
extend self # rubocop:disable Style/ModuleFunction
11-
12-
# Get detailed information about a single package
13-
sig { params(pack_name: String).returns(T::Hash[String, T.untyped]) }
14-
def package_info(pack_name)
15-
pack = QueryPackwerk.package(pack_name)
16-
{ error: "Package '#{pack_name}' not found" } unless pack
17-
end
18-
19-
# Get all dependencies of a package, including information about ignored dependencies and violations
20-
sig { params(pack_name: String).returns(T::Hash[String, T.untyped]) }
21-
def package_dependencies(pack_name)
22-
pack = QueryPackwerk.package(pack_name)
23-
return { error: "Package '#{pack_name}' not found" } unless pack
24-
25-
violations = QueryPackwerk::Violations.where(consuming_pack: pack.name)
26-
# Group violations by the package they reference
27-
violation_by_package = {}
28-
violations.each do |v|
29-
package_name = v.to_package_name
30-
violation_by_package[package_name] ||= []
31-
violation_by_package[package_name] << v
32-
end
33-
34-
# Get declared dependencies
35-
declared_dependencies = pack.dependency_names.map do |dep_name|
36-
dep_pack = QueryPackwerk.package(dep_name)
37-
next { name: dep_name, status: 'unknown' } unless dep_pack
38-
39-
{
40-
name: dep_name,
41-
status: 'declared',
42-
owner: dep_pack.owner,
43-
enforce_dependencies: dep_pack.enforce_dependencies,
44-
enforce_privacy: dep_pack.enforce_privacy
45-
}
46-
end
47-
48-
# Get ignored dependencies from package.yml if available
49-
ignored_dependencies = []
50-
if pack.metadata['ignored_dependencies'].is_a?(Array)
51-
ignored_dependencies = pack.metadata['ignored_dependencies'].map do |dep_name|
52-
dep_pack = QueryPackwerk.package(dep_name)
53-
next { name: dep_name, status: 'ignored' } unless dep_pack
54-
55-
{
56-
name: dep_name,
57-
status: 'ignored',
58-
owner: dep_pack.owner
59-
}
9+
class << self
10+
def packages(package_path: nil)
11+
packages = Helpers.all_packages(package_path)
12+
13+
if packages.empty?
14+
has_packwerk_yml = File.exist?('packwerk.yml')
15+
Views::NoPackagesView.render(has_packwerk_yml:)
16+
else
17+
Views::PackagesView.render(packages:, has_packwerk_yml:)
6018
end
19+
rescue StandardError => e
20+
raise Chatwerk::Error.new(e, package_path:)
6121
end
6222

63-
# Get violation dependencies (undeclared dependencies)
64-
violation_dependencies = violation_by_package.keys.reject do |dep|
65-
pack.dependency_names.include?(dep)
66-
end.map do |dep_name|
67-
dep_pack = QueryPackwerk.package(dep_name)
68-
vcount = violation_by_package[dep_name]&.size || 0
69-
70-
next { name: dep_name, status: 'violation', violation_count: vcount } unless dep_pack
71-
72-
{
73-
name: dep_name,
74-
status: 'violation',
75-
owner: dep_pack.owner,
76-
enforce_dependencies: dep_pack.enforce_dependencies,
77-
enforce_privacy: dep_pack.enforce_privacy,
78-
violation_count: vcount
79-
}
80-
end
81-
82-
{
83-
package: pack.name,
84-
dependencies: {
85-
declared: declared_dependencies,
86-
ignored: ignored_dependencies,
87-
violations: violation_dependencies
88-
},
89-
total_declared: declared_dependencies.size,
90-
total_ignored: ignored_dependencies.size,
91-
total_violations: violation_dependencies.size
92-
}
93-
end
94-
95-
# Get all consumers of a package
96-
sig { params(pack_name: String, threshold: Integer).returns(T::Hash[String, T.untyped]) }
97-
def package_consumers(pack_name, threshold: 0)
98-
pack = QueryPackwerk.package(pack_name)
99-
return { error: "Package '#{pack_name}' not found" } unless pack
100-
101-
consumer_counts = QueryPackwerk.consumers(pack_name, threshold: threshold)
102-
103-
consumers = consumer_counts.map do |consumer_name, count|
104-
consumer_pack = QueryPackwerk.package(consumer_name)
105-
next { name: consumer_name, count: count } unless consumer_pack
106-
107-
{
108-
name: consumer_name,
109-
owner: consumer_pack.owner,
110-
count: count,
111-
is_declared_dependency: consumer_pack.dependency_names.include?(pack_name)
112-
}
113-
end
114-
115-
{
116-
package: pack_name,
117-
consumers: consumers,
118-
total_consumers: consumers.size
119-
}
120-
end
121-
122-
# Get all usage patterns for a package
123-
sig { params(pack_name: String, threshold: Integer).returns(T::Hash[String, T.untyped]) }
124-
def package_usage_patterns(pack_name, threshold: 0)
125-
pack = QueryPackwerk.package(pack_name)
126-
return { error: "Package '#{pack_name}' not found" } unless pack
23+
def package(package_path:)
24+
package = Helpers.find_package(package_path)
12725

128-
patterns = QueryPackwerk.anonymous_violation_counts_for(pack_name, threshold: threshold)
129-
130-
result = {
131-
package: pack_name,
132-
patterns: {},
133-
total_patterns: 0
134-
}
135-
136-
patterns.each do |pattern_type, pattern_counts|
137-
result[:patterns][pattern_type] = pattern_counts.map do |pattern, count|
138-
{
139-
pattern: pattern,
140-
count: count
141-
}
142-
end.sort_by { |p| -p[:count] }
143-
144-
result[:total_patterns] += pattern_counts.size
26+
Views::PackageView.render(package:)
27+
rescue StandardError => e
28+
raise Chatwerk::Error.new(e, package_path:)
14529
end
14630

147-
result
148-
end
149-
150-
# Get all code patterns that access a specific package
151-
sig { params(pack_name: String).returns(T::Hash[String, T.untyped]) }
152-
def package_access_patterns(pack_name)
153-
violations = QueryPackwerk::Violations.where(producing_pack: QueryPackwerk.full_name(pack_name))
154-
return { error: "No access patterns found for package '#{pack_name}'" } if violations.count.zero?
31+
def package_todos(package_path:, constant_name: nil)
32+
package = Helpers.find_package(package_path)
33+
constant_name = Helpers.normalize_constant_name(constant_name)
34+
violations = package.todos
15535

156-
# Group violations by type and class name
157-
patterns = {}
158-
violations.each do |violation|
159-
type = violation.type
160-
class_name = violation.class_name
161-
patterns[type] ||= {}
162-
patterns[type][class_name] ||= []
163-
164-
# Get first sample file for example
165-
next unless violation.sources_with_locations.any?
166-
167-
patterns[type][class_name] << {
168-
from_package: violation.consuming_pack.name,
169-
location: violation.sources_with_locations.first,
170-
file: violation.sources.first
171-
}
172-
end
173-
174-
# Format the response
175-
formatted_patterns = {}
176-
patterns.each do |type, class_patterns|
177-
formatted_patterns[type] = class_patterns.map do |class_name, examples|
178-
{
179-
constant: class_name,
180-
count: examples.size,
181-
examples: examples.uniq { |e| e[:from_package] }.first(3) # Limit to 3 examples
182-
}
36+
if constant_name.empty?
37+
Views::ViolationsListView.render(package:, violations:)
38+
else
39+
Views::ViolationsDetailsView.render(package:, violations:, constant_name:)
18340
end
41+
rescue StandardError => e
42+
raise Chatwerk::Error.new(e, package_path:, constant_name:)
18443
end
18544

186-
{
187-
package: pack_name,
188-
access_patterns: formatted_patterns,
189-
total_constants: patterns.values.flat_map(&:keys).uniq.size
190-
}
191-
end
45+
def package_violations(package_path:, constant_name: nil)
46+
package = Helpers.find_package(package_path)
47+
constant_name = Helpers.normalize_constant_name(constant_name)
48+
violations = package.violations
19249

193-
# Find all the places in the code where a package is used from other packages
194-
sig { params(pack_name: String).returns(T::Hash[String, T.untyped]) }
195-
def find_usage_locations(pack_name)
196-
violations = QueryPackwerk::Violations.where(producing_pack: QueryPackwerk.full_name(pack_name))
197-
return { error: "No usage found for package '#{pack_name}'" } if violations.count.zero?
198-
199-
locations = violations.sources_with_locations
200-
201-
# Group by consuming package for better organization
202-
usage_by_consumer = {}
203-
violations.each do |violation|
204-
consumer = violation.consuming_pack.name
205-
usage_by_consumer[consumer] ||= []
206-
207-
violation.sources_with_locations.each do |loc|
208-
usage_by_consumer[consumer] << {
209-
constant: violation.class_name,
210-
location: loc,
211-
type: violation.type
212-
}
50+
if constant_name.empty?
51+
Views::ViolationsListView.render(package:, violations:)
52+
else
53+
Views::ViolationsDetailsView.render(package:, violations:, constant_name:)
21354
end
55+
rescue StandardError => e
56+
raise Chatwerk::Error.new(e, package_path:, constant_name:)
21457
end
215-
216-
{
217-
package: pack_name,
218-
usage_locations: usage_by_consumer,
219-
total_consumers: usage_by_consumer.keys.size,
220-
total_locations: locations.values.flatten.size
221-
}
22258
end
22359
end
22460
end

0 commit comments

Comments
 (0)