woods 1.0.0 → 1.2.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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +175 -2
- data/exe/woods-console-mcp +4 -0
- data/exe/woods-mcp +4 -0
- data/lib/tasks/woods.rake +54 -0
- data/lib/woods/extractors/model_extractor.rb +4 -1
- data/lib/woods/graph_analyzer.rb +211 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +61 -0
- data/lib/woods/mcp/server.rb +34 -0
- data/lib/woods/unblocked/client.rb +163 -0
- data/lib/woods/unblocked/document_builder.rb +301 -0
- data/lib/woods/unblocked/exporter.rb +201 -0
- data/lib/woods/unblocked/rate_limiter.rb +94 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +4 -0
- metadata +6 -2
data/lib/woods/mcp/server.rb
CHANGED
|
@@ -61,6 +61,7 @@ module Woods
|
|
|
61
61
|
render_key: :dependents)
|
|
62
62
|
define_structure_tool(server, reader, respond, renderer)
|
|
63
63
|
define_graph_analysis_tool(server, reader, respond, renderer)
|
|
64
|
+
define_domain_clusters_tool(server, reader, respond, renderer)
|
|
64
65
|
define_pagerank_tool(server, reader, respond, renderer)
|
|
65
66
|
define_framework_tool(server, reader, respond, renderer)
|
|
66
67
|
define_recent_changes_tool(server, reader, respond, renderer)
|
|
@@ -306,6 +307,39 @@ module Woods
|
|
|
306
307
|
end
|
|
307
308
|
end
|
|
308
309
|
|
|
310
|
+
def define_domain_clusters_tool(server, reader, respond, renderer)
|
|
311
|
+
coerce = method(:coerce_array)
|
|
312
|
+
coerce_int = method(:coerce_integer)
|
|
313
|
+
server.define_tool(
|
|
314
|
+
name: 'domain_clusters',
|
|
315
|
+
description: 'Group code units into semantic domains by namespace and graph connectivity. ' \
|
|
316
|
+
'Returns clusters with hub nodes, entry points, boundary edges, and type breakdowns. ' \
|
|
317
|
+
'Useful for understanding architectural domains and blast radius.',
|
|
318
|
+
input_schema: {
|
|
319
|
+
properties: {
|
|
320
|
+
min_size: {
|
|
321
|
+
type: 'integer',
|
|
322
|
+
description: 'Minimum units per cluster before merging into neighbors (default: 3)'
|
|
323
|
+
},
|
|
324
|
+
types: {
|
|
325
|
+
type: 'array', items: { type: 'string' },
|
|
326
|
+
description: 'Filter to these unit types (default: all). Example: ["model", "service", "job"]'
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
) do |server_context:, min_size: nil, types: nil|
|
|
331
|
+
min_size = coerce_int.call(min_size) || 3
|
|
332
|
+
types = coerce.call(types)
|
|
333
|
+
|
|
334
|
+
graph = reader.dependency_graph
|
|
335
|
+
analyzer = Woods::GraphAnalyzer.new(graph)
|
|
336
|
+
|
|
337
|
+
clusters = analyzer.domain_clusters(min_size: min_size, types: types)
|
|
338
|
+
|
|
339
|
+
respond.call(renderer.render(:domain_clusters, { clusters: clusters, total: clusters.size }))
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
309
343
|
def define_pagerank_tool(server, reader, respond, renderer)
|
|
310
344
|
coerce = method(:coerce_array)
|
|
311
345
|
coerce_int = method(:coerce_integer)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require_relative 'rate_limiter'
|
|
7
|
+
|
|
8
|
+
module Woods
|
|
9
|
+
module Unblocked
|
|
10
|
+
# REST client for the Unblocked API v1.
|
|
11
|
+
#
|
|
12
|
+
# Handles document and collection CRUD with rate limiting, retries,
|
|
13
|
+
# and error handling. Uses Net::HTTP for zero external dependencies.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# client = Client.new(api_token: "ubk_...")
|
|
17
|
+
# client.put_document(
|
|
18
|
+
# collection_id: "uuid",
|
|
19
|
+
# title: "Order (model)",
|
|
20
|
+
# body: "# Order\n...",
|
|
21
|
+
# uri: "https://github.com/org/repo/blob/main/app/models/order.rb"
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
class Client
|
|
25
|
+
BASE_URL = 'https://getunblocked.com/api/v1'
|
|
26
|
+
MAX_RETRIES = 3
|
|
27
|
+
DEFAULT_TIMEOUT = 30
|
|
28
|
+
|
|
29
|
+
# @param api_token [String] Unblocked API token (Personal or Team)
|
|
30
|
+
# @param rate_limiter [RateLimiter] Rate limiter instance
|
|
31
|
+
# @raise [ArgumentError] if api_token is nil or empty
|
|
32
|
+
def initialize(api_token:, rate_limiter: RateLimiter.new)
|
|
33
|
+
raise ArgumentError, 'api_token is required' if api_token.nil? || api_token.to_s.strip.empty?
|
|
34
|
+
|
|
35
|
+
@api_token = api_token
|
|
36
|
+
@rate_limiter = rate_limiter
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Create or update a document (upsert by URI).
|
|
40
|
+
#
|
|
41
|
+
# Documents are unique by `uri` across the organization. If a document
|
|
42
|
+
# with the given URI exists, it is updated; otherwise it is created.
|
|
43
|
+
# Documents become available for queries within ~1 minute.
|
|
44
|
+
#
|
|
45
|
+
# @param collection_id [String] Target collection UUID
|
|
46
|
+
# @param title [String] Document title (plain text)
|
|
47
|
+
# @param body [String] Document body (Markdown preferred)
|
|
48
|
+
# @param uri [String] Source URL (used as unique identifier and citation link)
|
|
49
|
+
# @return [Hash] { "id" => "document-uuid" }
|
|
50
|
+
def put_document(collection_id:, title:, body:, uri:)
|
|
51
|
+
request(:put, 'documents', {
|
|
52
|
+
collectionId: collection_id,
|
|
53
|
+
title: title,
|
|
54
|
+
body: body,
|
|
55
|
+
uri: uri
|
|
56
|
+
})
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Create a new collection.
|
|
60
|
+
#
|
|
61
|
+
# @param name [String] Collection name (1-32 chars)
|
|
62
|
+
# @param description [String] Collection description (1-4096 chars)
|
|
63
|
+
# @param icon_url [String, nil] Optional icon URL
|
|
64
|
+
# @return [Hash] { "id" => "collection-uuid", "name" => "...", ... }
|
|
65
|
+
def create_collection(name:, description:, icon_url: nil)
|
|
66
|
+
body = { name: name, description: description }
|
|
67
|
+
body[:iconUrl] = icon_url if icon_url
|
|
68
|
+
request(:post, 'collections', body)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# List all collections.
|
|
72
|
+
#
|
|
73
|
+
# @return [Array<Hash>] Collection objects
|
|
74
|
+
def list_collections
|
|
75
|
+
result = request(:get, 'collections')
|
|
76
|
+
result['items'] || result['data'] || [result].flatten.compact
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Delete a document by ID.
|
|
80
|
+
#
|
|
81
|
+
# @param document_id [String] Document UUID
|
|
82
|
+
# @return [Hash] API response
|
|
83
|
+
def delete_document(document_id:)
|
|
84
|
+
request(:delete, "documents/#{document_id}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def request(method, path, body = nil)
|
|
90
|
+
retries = 0
|
|
91
|
+
|
|
92
|
+
loop do
|
|
93
|
+
response = @rate_limiter.track { execute_http(method, path, body) }
|
|
94
|
+
|
|
95
|
+
return parse_response(response) if response.is_a?(Net::HTTPSuccess)
|
|
96
|
+
|
|
97
|
+
if response.code == '429' && retries < MAX_RETRIES
|
|
98
|
+
retries += 1
|
|
99
|
+
wait_time = (response['Retry-After'] || (retries * 2)).to_f
|
|
100
|
+
sleep(wait_time)
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
raise_api_error(response)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def execute_http(method, path, body)
|
|
109
|
+
attempts = 0
|
|
110
|
+
begin
|
|
111
|
+
uri = URI("#{BASE_URL}/#{path}")
|
|
112
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
113
|
+
http.use_ssl = true
|
|
114
|
+
http.open_timeout = DEFAULT_TIMEOUT
|
|
115
|
+
http.read_timeout = DEFAULT_TIMEOUT
|
|
116
|
+
|
|
117
|
+
req = build_request(method, uri, body)
|
|
118
|
+
http.request(req)
|
|
119
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED => e
|
|
120
|
+
attempts += 1
|
|
121
|
+
raise Woods::Error, "Network error after #{attempts} retries: #{e.message}" if attempts >= MAX_RETRIES
|
|
122
|
+
|
|
123
|
+
sleep(2**attempts)
|
|
124
|
+
retry
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_request(method, uri, body)
|
|
129
|
+
req = case method
|
|
130
|
+
when :put then Net::HTTP::Put.new(uri)
|
|
131
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
132
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
133
|
+
when :delete then Net::HTTP::Delete.new(uri)
|
|
134
|
+
else raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
req['Authorization'] = "Bearer #{@api_token}"
|
|
138
|
+
req['Content-Type'] = 'application/json'
|
|
139
|
+
req.body = JSON.generate(body) if body
|
|
140
|
+
|
|
141
|
+
req
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def parse_response(response)
|
|
145
|
+
return {} if response.body.nil? || response.body.strip.empty?
|
|
146
|
+
|
|
147
|
+
JSON.parse(response.body)
|
|
148
|
+
rescue JSON::ParserError
|
|
149
|
+
{}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def raise_api_error(response)
|
|
153
|
+
parsed = begin
|
|
154
|
+
JSON.parse(response.body)
|
|
155
|
+
rescue JSON::ParserError, TypeError
|
|
156
|
+
{ 'message' => response.body&.slice(0, 200) || 'Unknown error' }
|
|
157
|
+
end
|
|
158
|
+
message = parsed['message'] || parsed['error'] || 'Unknown error'
|
|
159
|
+
raise Woods::Error, "Unblocked API error #{response.code}: #{message}"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Unblocked
|
|
5
|
+
# Converts extracted unit JSON into condensed Markdown documents
|
|
6
|
+
# optimized for Unblocked's code review and Q&A context.
|
|
7
|
+
#
|
|
8
|
+
# Each unit type has a specialized formatting strategy that emphasizes
|
|
9
|
+
# what matters for code review: associations, blast radius, entry points,
|
|
10
|
+
# side effects, and structural complexity.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# builder = DocumentBuilder.new(repo_url: "https://github.com/bigcartel/admin")
|
|
14
|
+
# doc = builder.build(unit_data)
|
|
15
|
+
# # => { title: "Order (model)", body: "# Order (model)\n...", uri: "https://..." }
|
|
16
|
+
#
|
|
17
|
+
class DocumentBuilder
|
|
18
|
+
# @param repo_url [String] GitHub repo base URL for citation URIs
|
|
19
|
+
def initialize(repo_url:)
|
|
20
|
+
@repo_url = repo_url.chomp('/')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Build a document hash from a unit's extracted data.
|
|
24
|
+
#
|
|
25
|
+
# @param unit_data [Hash] Parsed unit JSON (from IndexReader)
|
|
26
|
+
# @return [Hash] { title:, body:, uri: }
|
|
27
|
+
def build(unit_data)
|
|
28
|
+
type = unit_data['type']
|
|
29
|
+
identifier = unit_data['identifier']
|
|
30
|
+
file_path = unit_data['file_path']
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
title: "#{identifier} (#{type})",
|
|
34
|
+
body: build_body(unit_data),
|
|
35
|
+
uri: build_uri(file_path)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def build_uri(file_path)
|
|
42
|
+
return @repo_url unless file_path
|
|
43
|
+
|
|
44
|
+
"#{@repo_url}/blob/main/#{file_path}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_body(unit_data)
|
|
48
|
+
type = unit_data['type']
|
|
49
|
+
case type
|
|
50
|
+
when 'model' then build_model_body(unit_data)
|
|
51
|
+
when 'controller' then build_controller_body(unit_data)
|
|
52
|
+
when 'service', 'job', 'mailer', 'manager', 'decorator', 'concern'
|
|
53
|
+
build_generic_body(unit_data)
|
|
54
|
+
when 'graphql', 'graphql_type', 'graphql_mutation', 'graphql_resolver', 'graphql_query'
|
|
55
|
+
build_graphql_body(unit_data)
|
|
56
|
+
else build_generic_body(unit_data)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# ── Model formatting ─────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def build_model_body(unit)
|
|
63
|
+
meta = unit['metadata'] || {}
|
|
64
|
+
sections = []
|
|
65
|
+
|
|
66
|
+
sections << model_header(unit, meta)
|
|
67
|
+
sections << model_associations(meta)
|
|
68
|
+
sections << model_dependents(unit)
|
|
69
|
+
sections << model_entry_points(unit)
|
|
70
|
+
sections << model_schema_highlights(meta)
|
|
71
|
+
sections << model_side_effects(unit)
|
|
72
|
+
|
|
73
|
+
sections.compact.join("\n\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def model_header(unit, meta)
|
|
77
|
+
parts = ["# #{unit['identifier']} (model)"]
|
|
78
|
+
file_info = ["**File:** `#{unit['file_path']}`"]
|
|
79
|
+
file_info << "**LOC:** #{meta['loc']}" if meta['loc']
|
|
80
|
+
file_info << "**Table:** #{meta['table_name']}" if meta['table_name']
|
|
81
|
+
column_count = meta['column_count'] || (meta['columns'] || []).size
|
|
82
|
+
file_info << "(#{column_count} columns)" if column_count&.positive?
|
|
83
|
+
parts << file_info.join(' | ')
|
|
84
|
+
parts.join("\n")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def model_associations(meta)
|
|
88
|
+
assocs = meta['associations'] || []
|
|
89
|
+
return nil if assocs.empty?
|
|
90
|
+
|
|
91
|
+
grouped = assocs.group_by { |a| a['type'] }
|
|
92
|
+
lines = ["## Associations (#{assocs.size})"]
|
|
93
|
+
|
|
94
|
+
%w[belongs_to has_many has_one has_and_belongs_to_many].each do |type|
|
|
95
|
+
items = grouped[type]
|
|
96
|
+
next unless items&.any?
|
|
97
|
+
|
|
98
|
+
targets = items.map do |a|
|
|
99
|
+
name = a['target'] || a['name']
|
|
100
|
+
dep = a.dig('options', 'dependent')
|
|
101
|
+
dep ? "#{name} (#{dep})" : name
|
|
102
|
+
end
|
|
103
|
+
lines << "**#{type}:** #{targets.join(', ')}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
lines.join("\n")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def model_dependents(unit)
|
|
110
|
+
deps = unit['dependents'] || []
|
|
111
|
+
return nil if deps.empty?
|
|
112
|
+
|
|
113
|
+
grouped = deps.group_by { |d| d['type'] }
|
|
114
|
+
summary_parts = grouped.map { |type, items| "#{items.size} #{type}s" }
|
|
115
|
+
|
|
116
|
+
lines = ["## Dependents (#{deps.size} units)"]
|
|
117
|
+
lines << summary_parts.join(', ')
|
|
118
|
+
|
|
119
|
+
# Blast radius assessment
|
|
120
|
+
if deps.size > 50
|
|
121
|
+
lines << '**High blast radius** — changes here affect many parts of the codebase'
|
|
122
|
+
elsif deps.size > 20
|
|
123
|
+
lines << '**Moderate blast radius** — changes may ripple to dependent code'
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
lines.join("\n")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def model_entry_points(unit)
|
|
130
|
+
deps = unit['dependents'] || []
|
|
131
|
+
controllers = deps.select { |d| d['type'] == 'controller' }
|
|
132
|
+
graphql = deps.select { |d| d['type']&.start_with?('graphql') }
|
|
133
|
+
jobs = deps.select { |d| d['type'] == 'job' }
|
|
134
|
+
|
|
135
|
+
return nil if controllers.empty? && graphql.empty?
|
|
136
|
+
|
|
137
|
+
lines = ['## Entry Points']
|
|
138
|
+
lines << "**Controllers:** #{controllers.map { |c| c['identifier'] }.join(', ')}" if controllers.any?
|
|
139
|
+
lines << "**GraphQL:** #{graphql.map { |g| g['identifier'] }.join(', ')}" if graphql.any?
|
|
140
|
+
lines << "**Jobs:** #{jobs.map { |j| j['identifier'] }.join(', ')}" if jobs.any?
|
|
141
|
+
|
|
142
|
+
lines.join("\n")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def model_schema_highlights(meta)
|
|
146
|
+
parts = []
|
|
147
|
+
|
|
148
|
+
enums = meta['enums']
|
|
149
|
+
if enums.is_a?(Hash) && enums.any?
|
|
150
|
+
enum_strs = enums.map { |name, values| "#{name} (#{format_enum_values(values)})" }
|
|
151
|
+
parts << "**Enums:** #{enum_strs.join('; ')}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
scopes = meta['scopes']
|
|
155
|
+
parts << "**Scopes:** #{scopes.map { |s| s['name'] }.join(', ')}" if scopes.is_a?(Array) && scopes.any?
|
|
156
|
+
|
|
157
|
+
concerns = meta['inlined_concerns']
|
|
158
|
+
parts << "**Concerns:** #{concerns.join(', ')}" if concerns.is_a?(Array) && concerns.any?
|
|
159
|
+
|
|
160
|
+
callbacks = meta['callbacks']
|
|
161
|
+
if callbacks.is_a?(Array) && callbacks.any?
|
|
162
|
+
parts << "**Callbacks (#{callbacks.size}):** #{format_callbacks(callbacks)}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
return nil if parts.empty?
|
|
166
|
+
|
|
167
|
+
(['## Schema Highlights'] + parts).join("\n")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def model_side_effects(unit)
|
|
171
|
+
deps = unit['dependents'] || []
|
|
172
|
+
jobs = deps.select { |d| d['type'] == 'job' }
|
|
173
|
+
mailers = deps.select { |d| d['type'] == 'mailer' }
|
|
174
|
+
|
|
175
|
+
return nil if jobs.empty? && mailers.empty?
|
|
176
|
+
|
|
177
|
+
lines = ['## Side Effects']
|
|
178
|
+
lines << "**Jobs:** #{jobs.map { |j| j['identifier'] }.join(', ')}" if jobs.any?
|
|
179
|
+
lines << "**Mailers:** #{mailers.map { |m| m['identifier'] }.join(', ')}" if mailers.any?
|
|
180
|
+
|
|
181
|
+
lines.join("\n")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ── Controller formatting ────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
def build_controller_body(unit)
|
|
187
|
+
meta = unit['metadata'] || {}
|
|
188
|
+
sections = []
|
|
189
|
+
|
|
190
|
+
sections << "# #{unit['identifier']} (controller)"
|
|
191
|
+
sections << "**File:** `#{unit['file_path']}`"
|
|
192
|
+
|
|
193
|
+
ancestors = meta['ancestors']
|
|
194
|
+
sections << "**Inherits:** #{ancestors[1..3]&.join(' → ')}" if ancestors.is_a?(Array) && ancestors.size > 1
|
|
195
|
+
|
|
196
|
+
sections << controller_routes(meta)
|
|
197
|
+
sections << controller_dependencies(unit)
|
|
198
|
+
sections << controller_dependents(unit)
|
|
199
|
+
|
|
200
|
+
sections.compact.join("\n\n")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def controller_routes(meta)
|
|
204
|
+
routes = meta['routes']
|
|
205
|
+
return nil unless routes.is_a?(Hash) && routes.any?
|
|
206
|
+
|
|
207
|
+
lines = ['## Routes']
|
|
208
|
+
routes.each do |action, route_list|
|
|
209
|
+
next unless route_list.is_a?(Array)
|
|
210
|
+
|
|
211
|
+
route_list.each do |route|
|
|
212
|
+
next unless route.is_a?(Hash)
|
|
213
|
+
|
|
214
|
+
lines << "- `#{route['verb']} #{route['path']}` (#{action})"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
lines.size > 1 ? lines.first(20).join("\n") : nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def controller_dependencies(unit)
|
|
222
|
+
deps = unit['dependencies'] || []
|
|
223
|
+
return nil if deps.empty?
|
|
224
|
+
|
|
225
|
+
models = deps.select { |d| d['type'] == 'model' }.map { |d| d['target'] }
|
|
226
|
+
return nil if models.empty?
|
|
227
|
+
|
|
228
|
+
"## Dependencies\n**Models:** #{models.join(', ')}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def controller_dependents(unit)
|
|
232
|
+
deps = unit['dependents'] || []
|
|
233
|
+
views = deps.select { |d| d['type'] == 'view_template' }
|
|
234
|
+
return nil if views.empty?
|
|
235
|
+
|
|
236
|
+
"## Views\n#{views.map { |v| "- `#{v['identifier']}`" }.first(10).join("\n")}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# ── GraphQL formatting ───────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
def build_graphql_body(unit)
|
|
242
|
+
sections = []
|
|
243
|
+
|
|
244
|
+
sections << "# #{unit['identifier']} (#{unit['type']})"
|
|
245
|
+
sections << "**File:** `#{unit['file_path']}`"
|
|
246
|
+
|
|
247
|
+
deps = unit['dependencies'] || []
|
|
248
|
+
models = deps.select { |d| d['type'] == 'model' }.map { |d| d['target'] }
|
|
249
|
+
sections << "**Models:** #{models.join(', ')}" if models.any?
|
|
250
|
+
|
|
251
|
+
dependents = unit['dependents'] || []
|
|
252
|
+
sections << "**Referenced by:** #{dependents.size} units" if dependents.any?
|
|
253
|
+
|
|
254
|
+
sections.compact.join("\n\n")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# ── Generic formatting (services, jobs, mailers, etc.) ──────────
|
|
258
|
+
|
|
259
|
+
def build_generic_body(unit)
|
|
260
|
+
meta = unit['metadata'] || {}
|
|
261
|
+
sections = []
|
|
262
|
+
|
|
263
|
+
sections << "# #{unit['identifier']} (#{unit['type']})"
|
|
264
|
+
sections << "**File:** `#{unit['file_path']}`"
|
|
265
|
+
sections << "**LOC:** #{meta['loc']}" if meta['loc']
|
|
266
|
+
|
|
267
|
+
deps = unit['dependencies'] || []
|
|
268
|
+
if deps.any?
|
|
269
|
+
by_type = deps.group_by { |d| d['type'] }
|
|
270
|
+
dep_parts = by_type.map { |type, items| "#{type}: #{items.map { |d| d['target'] }.join(', ')}" }
|
|
271
|
+
sections << "## Dependencies\n#{dep_parts.join("\n")}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
dependents = unit['dependents'] || []
|
|
275
|
+
if dependents.any?
|
|
276
|
+
grouped = dependents.group_by { |d| d['type'] }
|
|
277
|
+
summary = grouped.map { |type, items| "#{items.size} #{type}s" }
|
|
278
|
+
sections << "## Dependents (#{dependents.size})\n#{summary.join(', ')}"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
sections.compact.join("\n\n")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# ── Helpers ──────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
def format_enum_values(values)
|
|
287
|
+
case values
|
|
288
|
+
when Hash then values.keys.first(5).join(', ')
|
|
289
|
+
when Array then values.first(5).join(', ')
|
|
290
|
+
else values.to_s
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def format_callbacks(callbacks)
|
|
295
|
+
callbacks.first(5).map do |cb|
|
|
296
|
+
"#{cb['type']}: #{cb['filter']}"
|
|
297
|
+
end.join(', ')
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|