woods 1.1.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/lib/tasks/woods.rake +54 -0
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 927abae1f4f641405384261569e1d25f94a672ca986d1c50093b3f6a56b7db38
|
|
4
|
+
data.tar.gz: fa35b4320669d195a8e4f377400b6999e735aebf5447071ee3353eaa8856840b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5ae6ef3436f6aa6b936b46103480e797a8a6e0fb4250f5dcc8bc721c2b9b911739e5d5aebd5b8b97c6788d58dcd19e9dbd5c6211a3400283e60084ce80c6d031
|
|
7
|
+
data.tar.gz: 6b38946aca86d407ab6d516d32dda4c5797adfcd27249b1685bdebb249ff34e71d62e3eabb266c991d1b32ad2df815e2a56dc924615c1def9df0c4c6754cd629
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.2.0] - 2026-03-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Unblocked Documents API exporter** — sync extraction data to an Unblocked collection for code review and Q&A context
|
|
13
|
+
- `Woods::Unblocked::Client` — REST client with retry and daily budget rate limiting
|
|
14
|
+
- `Woods::Unblocked::DocumentBuilder` — type-specific Markdown formatters optimized for review context (blast radius, entry points, associations, side effects)
|
|
15
|
+
- `Woods::Unblocked::Exporter` — full/partial sync orchestrator with priority ordering
|
|
16
|
+
- `Woods::Unblocked::RateLimiter` — daily budget tracking (1000 calls/day)
|
|
17
|
+
- New rake tasks: `woods:unblocked_sync` (alias: `woods:relay`)
|
|
18
|
+
- New config: `unblocked_api_token`, `unblocked_collection_id`, `unblocked_repo_url`
|
|
19
|
+
- Integration guide: `docs/UNBLOCKED_INTEGRATION.md`
|
|
20
|
+
- **Domain cluster detection** in `GraphAnalyzer` — groups code units into semantic domains using namespace prefixes and graph connectivity
|
|
21
|
+
- `GraphAnalyzer#domain_clusters` — hybrid namespace + graph clustering with hub identification, entry point detection, and boundary edge mapping
|
|
22
|
+
- New MCP tool: `domain_clusters` with `min_size` and `types` filters
|
|
23
|
+
- New renderer: `render_domain_clusters` in MarkdownRenderer
|
|
24
|
+
|
|
8
25
|
## [0.3.1] - 2026-03-04
|
|
9
26
|
|
|
10
27
|
### Fixed
|
data/lib/tasks/woods.rake
CHANGED
|
@@ -618,4 +618,58 @@ namespace :woods do
|
|
|
618
618
|
|
|
619
619
|
desc 'Send findings from the field — sync to Notion (alias for notion_sync)'
|
|
620
620
|
task send: :notion_sync
|
|
621
|
+
|
|
622
|
+
desc 'Sync extraction data to Unblocked collection (Documents API)'
|
|
623
|
+
task unblocked_sync: :environment do
|
|
624
|
+
require 'woods/unblocked/exporter'
|
|
625
|
+
|
|
626
|
+
config = Woods.configuration
|
|
627
|
+
config.unblocked_api_token = ENV.fetch('UNBLOCKED_API_TOKEN', nil) || config.unblocked_api_token
|
|
628
|
+
config.unblocked_collection_id = ENV.fetch('UNBLOCKED_COLLECTION_ID', nil) || config.unblocked_collection_id
|
|
629
|
+
config.unblocked_repo_url = ENV.fetch('UNBLOCKED_REPO_URL', nil) || config.unblocked_repo_url
|
|
630
|
+
|
|
631
|
+
unless config.unblocked_api_token
|
|
632
|
+
puts 'ERROR: Unblocked API token not configured.'
|
|
633
|
+
puts 'Set UNBLOCKED_API_TOKEN env var or configure unblocked_api_token in Woods.configure.'
|
|
634
|
+
exit 1
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
unless config.unblocked_collection_id
|
|
638
|
+
puts 'ERROR: Unblocked collection ID not configured.'
|
|
639
|
+
puts 'Set UNBLOCKED_COLLECTION_ID env var or configure unblocked_collection_id in Woods.configure.'
|
|
640
|
+
exit 1
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
unless config.unblocked_repo_url
|
|
644
|
+
puts 'ERROR: Repository URL not configured.'
|
|
645
|
+
puts 'Set UNBLOCKED_REPO_URL env var or configure unblocked_repo_url in Woods.configure.'
|
|
646
|
+
puts 'Example: https://github.com/your-org/your-repo'
|
|
647
|
+
exit 1
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
output_dir = ENV.fetch('WOODS_OUTPUT', config.output_dir)
|
|
651
|
+
|
|
652
|
+
puts 'Syncing extraction data to Unblocked...'
|
|
653
|
+
puts " Output dir: #{output_dir}"
|
|
654
|
+
puts " Collection: #{config.unblocked_collection_id}"
|
|
655
|
+
puts " Repo URL: #{config.unblocked_repo_url}"
|
|
656
|
+
puts
|
|
657
|
+
|
|
658
|
+
exporter = Woods::Unblocked::Exporter.new(index_dir: output_dir)
|
|
659
|
+
stats = exporter.sync_all
|
|
660
|
+
|
|
661
|
+
puts
|
|
662
|
+
puts 'Sync complete!'
|
|
663
|
+
puts " Documents synced: #{stats[:synced]}"
|
|
664
|
+
puts " Documents skipped: #{stats[:skipped]}"
|
|
665
|
+
|
|
666
|
+
if stats[:errors].any?
|
|
667
|
+
puts " Errors: #{stats[:errors].size}"
|
|
668
|
+
stats[:errors].first(5).each { |e| puts " - #{e}" }
|
|
669
|
+
puts " ... and #{stats[:errors].size - 5} more" if stats[:errors].size > 5
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
desc 'Relay findings to Unblocked (alias for unblocked_sync)'
|
|
674
|
+
task relay: :unblocked_sync
|
|
621
675
|
end
|
data/lib/woods/graph_analyzer.rb
CHANGED
|
@@ -154,6 +154,52 @@ module Woods
|
|
|
154
154
|
end
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
+
# Group units into semantic domains using namespace prefixes and graph connectivity.
|
|
158
|
+
#
|
|
159
|
+
# Strategy:
|
|
160
|
+
# 1. Seed clusters from top-level namespace prefixes (e.g., ShippingProfile::*, Order::*)
|
|
161
|
+
# 2. Assign unnamespaced units to their most-connected cluster
|
|
162
|
+
# 3. Merge small clusters (< min_size) into their most-connected neighbor
|
|
163
|
+
# 4. For each cluster, identify the hub (highest PageRank) and entry points
|
|
164
|
+
# 5. Compute boundary edges between clusters
|
|
165
|
+
#
|
|
166
|
+
# @param min_size [Integer] Minimum units per cluster before merging (default: 3)
|
|
167
|
+
# @param types [Array<String>, nil] Filter to these unit types (default: all)
|
|
168
|
+
# @return [Array<Hash>] Clusters sorted by member count descending.
|
|
169
|
+
# Each hash: { name:, hub:, members:, member_count:, entry_points:, boundary_edges:, types: }
|
|
170
|
+
def domain_clusters(min_size: 3, types: nil)
|
|
171
|
+
nodes = graph_nodes
|
|
172
|
+
return [] if nodes.empty?
|
|
173
|
+
|
|
174
|
+
# Filter by types if specified
|
|
175
|
+
filtered_ids = if types
|
|
176
|
+
type_set = types.map(&:to_s)
|
|
177
|
+
nodes.select { |_, meta| type_set.include?(meta[:type].to_s) }.keys
|
|
178
|
+
else
|
|
179
|
+
nodes.keys
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
return [] if filtered_ids.empty?
|
|
183
|
+
|
|
184
|
+
# Step 1: Seed clusters from namespace prefixes
|
|
185
|
+
clusters = seed_namespace_clusters(filtered_ids, nodes)
|
|
186
|
+
|
|
187
|
+
# Step 2: Assign unnamespaced/root units to most-connected cluster
|
|
188
|
+
assign_orphaned_units(clusters, filtered_ids, nodes)
|
|
189
|
+
|
|
190
|
+
# Step 3: Merge small clusters
|
|
191
|
+
merge_small_clusters(clusters, min_size)
|
|
192
|
+
|
|
193
|
+
# Step 4: Enrich each cluster with hub, entry points, boundary edges
|
|
194
|
+
pagerank_scores = @graph.pagerank
|
|
195
|
+
enrich_clusters(clusters, nodes, pagerank_scores)
|
|
196
|
+
|
|
197
|
+
# Sort by member count descending
|
|
198
|
+
clusters.values
|
|
199
|
+
.select { |c| c[:members].any? }
|
|
200
|
+
.sort_by { |c| -c[:member_count] }
|
|
201
|
+
end
|
|
202
|
+
|
|
157
203
|
# Full analysis report combining all structural metrics.
|
|
158
204
|
#
|
|
159
205
|
# @return [Hash] Complete analysis with :orphans, :dead_ends, :hubs,
|
|
@@ -182,6 +228,171 @@ module Woods
|
|
|
182
228
|
|
|
183
229
|
private
|
|
184
230
|
|
|
231
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
232
|
+
# Domain Cluster Helpers
|
|
233
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
# Extract the top-level namespace prefix for clustering.
|
|
236
|
+
# "ShippingProfile::Setting" => "ShippingProfile"
|
|
237
|
+
# "Order::Transactions::Refund" => "Order"
|
|
238
|
+
# "Account" => nil (no namespace)
|
|
239
|
+
def cluster_prefix(identifier)
|
|
240
|
+
parts = identifier.to_s.split('::')
|
|
241
|
+
parts.size > 1 ? parts.first : nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Seed initial clusters from namespace prefixes.
|
|
245
|
+
def seed_namespace_clusters(filtered_ids, _nodes)
|
|
246
|
+
clusters = {}
|
|
247
|
+
|
|
248
|
+
filtered_ids.each do |id|
|
|
249
|
+
prefix = cluster_prefix(id)
|
|
250
|
+
next unless prefix
|
|
251
|
+
|
|
252
|
+
clusters[prefix] ||= { name: prefix, members: [], member_set: Set.new }
|
|
253
|
+
clusters[prefix][:members] << id
|
|
254
|
+
clusters[prefix][:member_set].add(id)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
clusters
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Assign units with no namespace prefix to their most-connected cluster.
|
|
261
|
+
def assign_orphaned_units(clusters, filtered_ids, _nodes)
|
|
262
|
+
return if clusters.empty?
|
|
263
|
+
|
|
264
|
+
unassigned = filtered_ids.select { |id| cluster_prefix(id).nil? }
|
|
265
|
+
|
|
266
|
+
unassigned.each do |id|
|
|
267
|
+
best_cluster = find_most_connected_cluster(id, clusters)
|
|
268
|
+
next unless best_cluster
|
|
269
|
+
|
|
270
|
+
clusters[best_cluster][:members] << id
|
|
271
|
+
clusters[best_cluster][:member_set].add(id)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Find which cluster a unit has the most connections to.
|
|
276
|
+
def find_most_connected_cluster(identifier, clusters)
|
|
277
|
+
connections = Hash.new(0)
|
|
278
|
+
|
|
279
|
+
# Check forward edges (dependencies)
|
|
280
|
+
@graph.dependencies_of(identifier).each do |dep|
|
|
281
|
+
clusters.each do |name, cluster|
|
|
282
|
+
connections[name] += 1 if cluster[:member_set].include?(dep)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Check reverse edges (dependents)
|
|
287
|
+
@graph.dependents_of(identifier).each do |dep|
|
|
288
|
+
clusters.each do |name, cluster|
|
|
289
|
+
connections[name] += 1 if cluster[:member_set].include?(dep)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
return nil if connections.empty?
|
|
294
|
+
|
|
295
|
+
connections.max_by { |_, count| count }.first
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Merge clusters smaller than min_size into their most-connected neighbor.
|
|
299
|
+
def merge_small_clusters(clusters, min_size)
|
|
300
|
+
loop do
|
|
301
|
+
small = clusters.select { |_, c| c[:members].size < min_size }
|
|
302
|
+
break if small.empty?
|
|
303
|
+
|
|
304
|
+
# Merge the smallest cluster first
|
|
305
|
+
name, cluster = small.min_by { |_, c| c[:members].size }
|
|
306
|
+
|
|
307
|
+
# Find which other cluster this one connects to most
|
|
308
|
+
target = find_merge_target(cluster, clusters, name)
|
|
309
|
+
|
|
310
|
+
if target
|
|
311
|
+
clusters[target][:members].concat(cluster[:members])
|
|
312
|
+
cluster[:members].each { |id| clusters[target][:member_set].add(id) }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
clusters.delete(name)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Find the best cluster to merge into (most cross-cluster edges).
|
|
320
|
+
def find_merge_target(cluster, all_clusters, exclude_name)
|
|
321
|
+
connections = Hash.new(0)
|
|
322
|
+
|
|
323
|
+
cluster[:members].each do |id|
|
|
324
|
+
(@graph.dependencies_of(id) + @graph.dependents_of(id)).each do |connected|
|
|
325
|
+
all_clusters.each do |name, other|
|
|
326
|
+
next if name == exclude_name
|
|
327
|
+
|
|
328
|
+
connections[name] += 1 if other[:member_set].include?(connected)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
return nil if connections.empty?
|
|
334
|
+
|
|
335
|
+
connections.max_by { |_, count| count }.first
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Enrich clusters with hub, entry points, boundary edges, and type breakdown.
|
|
339
|
+
def enrich_clusters(clusters, nodes, pagerank_scores)
|
|
340
|
+
clusters.each_value do |cluster|
|
|
341
|
+
members = cluster[:members]
|
|
342
|
+
member_set = cluster[:member_set]
|
|
343
|
+
|
|
344
|
+
# Hub: highest PageRank within the cluster
|
|
345
|
+
hub_id = members.max_by { |id| pagerank_scores[id] || 0 }
|
|
346
|
+
cluster[:hub] = hub_id
|
|
347
|
+
|
|
348
|
+
# Entry points: controllers and GraphQL resolvers in the cluster's dependents
|
|
349
|
+
entry_types = %w[controller graphql_resolver graphql_mutation graphql_query]
|
|
350
|
+
entry_points = Set.new
|
|
351
|
+
members.each do |id|
|
|
352
|
+
@graph.dependents_of(id).each do |dep|
|
|
353
|
+
meta = nodes[dep]
|
|
354
|
+
entry_points.add(dep) if meta && entry_types.include?(meta[:type].to_s)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
cluster[:entry_points] = entry_points.to_a
|
|
358
|
+
|
|
359
|
+
# Boundary edges: connections that cross cluster boundaries
|
|
360
|
+
boundary = []
|
|
361
|
+
members.each do |id|
|
|
362
|
+
@graph.dependencies_of(id).each do |dep|
|
|
363
|
+
next if member_set.include?(dep)
|
|
364
|
+
|
|
365
|
+
dep_meta = nodes[dep]
|
|
366
|
+
next unless dep_meta
|
|
367
|
+
|
|
368
|
+
boundary << { from: id, to: dep, via: 'dependency' }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
@graph.dependents_of(id).each do |dep|
|
|
372
|
+
next if member_set.include?(dep)
|
|
373
|
+
|
|
374
|
+
dep_meta = nodes[dep]
|
|
375
|
+
next unless dep_meta
|
|
376
|
+
|
|
377
|
+
boundary << { from: dep, to: id, via: 'dependent' }
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
# Deduplicate and limit boundary edges
|
|
381
|
+
cluster[:boundary_edges] = boundary.uniq { |e| [e[:from], e[:to]] }.first(20)
|
|
382
|
+
|
|
383
|
+
# Type breakdown
|
|
384
|
+
type_counts = members.each_with_object(Hash.new(0)) do |id, counts|
|
|
385
|
+
meta = nodes[id]
|
|
386
|
+
counts[meta[:type].to_s] += 1 if meta
|
|
387
|
+
end
|
|
388
|
+
cluster[:types] = type_counts
|
|
389
|
+
|
|
390
|
+
# Final shape
|
|
391
|
+
cluster[:member_count] = members.size
|
|
392
|
+
cluster.delete(:member_set) # Internal tracking, not part of output
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
185
396
|
# ──────────────────────────────────────────────────────────────────────
|
|
186
397
|
# Graph Accessors
|
|
187
398
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -165,6 +165,67 @@ module Woods
|
|
|
165
165
|
lines.join("\n").rstrip
|
|
166
166
|
end
|
|
167
167
|
|
|
168
|
+
# ── domain_clusters ────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
# @param data [Hash] Domain cluster data with :clusters and :total
|
|
171
|
+
# @return [String] Markdown domain cluster overview
|
|
172
|
+
def render_domain_clusters(data, **)
|
|
173
|
+
clusters = fetch_key(data, :clusters) || []
|
|
174
|
+
total = fetch_key(data, :total) || clusters.size
|
|
175
|
+
lines = []
|
|
176
|
+
lines << '## Domain Clusters'
|
|
177
|
+
lines << ''
|
|
178
|
+
lines << "#{total} domains detected."
|
|
179
|
+
lines << ''
|
|
180
|
+
|
|
181
|
+
clusters.each do |cluster|
|
|
182
|
+
name = cluster[:name] || cluster['name']
|
|
183
|
+
member_count = cluster[:member_count] || cluster['member_count'] || 0
|
|
184
|
+
hub = cluster[:hub] || cluster['hub']
|
|
185
|
+
lines << "### #{name} (#{member_count} units)"
|
|
186
|
+
lines << ''
|
|
187
|
+
lines << "**Hub:** #{hub}" if hub
|
|
188
|
+
lines << ''
|
|
189
|
+
|
|
190
|
+
# Type breakdown
|
|
191
|
+
types = cluster[:types] || cluster['types']
|
|
192
|
+
if types.is_a?(Hash) && types.any?
|
|
193
|
+
type_parts = types.sort_by { |_, count| -count }.map { |type, count| "#{count} #{type}s" }
|
|
194
|
+
lines << "**Types:** #{type_parts.join(', ')}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Entry points
|
|
198
|
+
entry_points = cluster[:entry_points] || cluster['entry_points'] || []
|
|
199
|
+
lines << "**Entry points:** #{entry_points.first(10).join(', ')}" if entry_points.any?
|
|
200
|
+
|
|
201
|
+
# Members (show first 15)
|
|
202
|
+
members = cluster[:members] || cluster['members'] || []
|
|
203
|
+
if members.any?
|
|
204
|
+
lines << ''
|
|
205
|
+
lines << '**Members:**'
|
|
206
|
+
members.first(15).each { |m| lines << "- #{m}" }
|
|
207
|
+
lines << "- _... and #{members.size - 15} more_" if members.size > 15
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Boundary edges (show first 10)
|
|
211
|
+
boundaries = cluster[:boundary_edges] || cluster['boundary_edges'] || []
|
|
212
|
+
if boundaries.any?
|
|
213
|
+
lines << ''
|
|
214
|
+
lines << '**Boundary connections:**'
|
|
215
|
+
boundaries.first(10).each do |edge|
|
|
216
|
+
from = edge[:from] || edge['from']
|
|
217
|
+
to = edge[:to] || edge['to']
|
|
218
|
+
via = edge[:via] || edge['via']
|
|
219
|
+
lines << "- #{from} → #{to} (#{via})"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
lines << ''
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
lines.join("\n").rstrip
|
|
227
|
+
end
|
|
228
|
+
|
|
168
229
|
# ── pagerank ────────────────────────────────────────────────
|
|
169
230
|
|
|
170
231
|
# @param data [Hash] PageRank data with :total_nodes and :results
|
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
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'woods'
|
|
4
|
+
require_relative 'client'
|
|
5
|
+
require_relative 'rate_limiter'
|
|
6
|
+
require_relative 'document_builder'
|
|
7
|
+
|
|
8
|
+
module Woods
|
|
9
|
+
module Unblocked
|
|
10
|
+
# Orchestrates syncing Woods extraction data to an Unblocked collection.
|
|
11
|
+
#
|
|
12
|
+
# Reads extraction output from disk via IndexReader, converts units to
|
|
13
|
+
# condensed Markdown documents, and pushes via the Unblocked Documents API.
|
|
14
|
+
# All syncs are idempotent — documents are upserted by URI.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# exporter = Exporter.new(index_dir: "tmp/woods")
|
|
18
|
+
# stats = exporter.sync_all
|
|
19
|
+
# # => { synced: 940, skipped: 5060, errors: [] }
|
|
20
|
+
#
|
|
21
|
+
class Exporter
|
|
22
|
+
MAX_ERRORS = 100
|
|
23
|
+
|
|
24
|
+
# Unit types to sync, in priority order.
|
|
25
|
+
# All units are synced for these types.
|
|
26
|
+
FULL_SYNC_TYPES = %w[
|
|
27
|
+
model controller service job mailer manager decorator concern serializer
|
|
28
|
+
graphql graphql_type graphql_mutation graphql_resolver graphql_query
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# Unit types where only the most-connected units are synced.
|
|
32
|
+
# Each entry: [type, max_count]
|
|
33
|
+
PARTIAL_SYNC_TYPES = [
|
|
34
|
+
['poro', 100],
|
|
35
|
+
['lib', 50]
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# @param index_dir [String] Path to extraction output directory
|
|
39
|
+
# @param config [Configuration] Woods configuration (default: global config)
|
|
40
|
+
# @param client [Client, nil] Unblocked API client (auto-created from config if nil)
|
|
41
|
+
# @param reader [Object, nil] IndexReader instance (auto-created if nil)
|
|
42
|
+
# @param output [IO] Progress output stream (default: $stdout)
|
|
43
|
+
# @raise [ConfigurationError] if required config is missing
|
|
44
|
+
def initialize(index_dir:, config: Woods.configuration, client: nil, reader: nil, output: $stdout)
|
|
45
|
+
@collection_id = config.unblocked_collection_id
|
|
46
|
+
raise ConfigurationError, 'unblocked_collection_id is required' unless @collection_id
|
|
47
|
+
|
|
48
|
+
repo_url = config.unblocked_repo_url
|
|
49
|
+
raise ConfigurationError, 'unblocked_repo_url is required' unless repo_url
|
|
50
|
+
|
|
51
|
+
api_token = config.unblocked_api_token
|
|
52
|
+
raise ConfigurationError, 'unblocked_api_token is required' unless api_token
|
|
53
|
+
|
|
54
|
+
budget = ENV.fetch('UNBLOCKED_DAILY_BUDGET', RateLimiter::DEFAULT_BUDGET).to_i
|
|
55
|
+
limiter = RateLimiter.new(daily_budget: budget)
|
|
56
|
+
|
|
57
|
+
@client = client || Client.new(api_token: api_token, rate_limiter: limiter)
|
|
58
|
+
@reader = reader || build_reader(index_dir)
|
|
59
|
+
@builder = DocumentBuilder.new(repo_url: repo_url)
|
|
60
|
+
@output = output
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Sync all configured unit types to the Unblocked collection.
|
|
64
|
+
#
|
|
65
|
+
# @return [Hash] { synced: Integer, skipped: Integer, errors: Array<String> }
|
|
66
|
+
def sync_all
|
|
67
|
+
synced = 0
|
|
68
|
+
skipped = 0
|
|
69
|
+
errors = []
|
|
70
|
+
|
|
71
|
+
FULL_SYNC_TYPES.each do |type|
|
|
72
|
+
result = sync_type(type)
|
|
73
|
+
synced += result[:synced]
|
|
74
|
+
skipped += result[:skipped]
|
|
75
|
+
errors.concat(result[:errors])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
PARTIAL_SYNC_TYPES.each do |type, max_count|
|
|
79
|
+
result = sync_type_partial(type, max_count)
|
|
80
|
+
synced += result[:synced]
|
|
81
|
+
skipped += result[:skipped]
|
|
82
|
+
errors.concat(result[:errors])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
{ synced: synced, skipped: skipped, errors: cap_errors(errors) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Sync all units of a given type.
|
|
89
|
+
#
|
|
90
|
+
# @param type [String] Unit type (e.g. "model", "controller")
|
|
91
|
+
# @return [Hash] { synced: Integer, skipped: Integer, errors: Array<String> }
|
|
92
|
+
def sync_type(type)
|
|
93
|
+
units = @reader.list_units(type: type)
|
|
94
|
+
log " #{type}: #{units.size} units"
|
|
95
|
+
|
|
96
|
+
sync_units(units)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Sync the top N most-connected units of a type (by dependent count).
|
|
100
|
+
#
|
|
101
|
+
# @param type [String] Unit type
|
|
102
|
+
# @param max_count [Integer] Maximum units to sync
|
|
103
|
+
# @return [Hash] { synced: Integer, skipped: Integer, errors: Array<String> }
|
|
104
|
+
def sync_type_partial(type, max_count)
|
|
105
|
+
units = @reader.list_units(type: type)
|
|
106
|
+
return empty_stats if units.empty?
|
|
107
|
+
|
|
108
|
+
# Load full data to sort by dependent count
|
|
109
|
+
units_with_data = units.filter_map do |entry|
|
|
110
|
+
data = @reader.find_unit(entry['identifier'])
|
|
111
|
+
next unless data
|
|
112
|
+
|
|
113
|
+
dep_count = (data['dependents'] || []).size
|
|
114
|
+
{ entry: entry, data: data, dep_count: dep_count }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
top_units = units_with_data.sort_by { |u| -u[:dep_count] }.first(max_count)
|
|
118
|
+
skipped_count = [units.size - max_count, 0].max
|
|
119
|
+
|
|
120
|
+
log " #{type}: #{top_units.size}/#{units.size} units (top by dependents)"
|
|
121
|
+
|
|
122
|
+
result = sync_unit_data(top_units.map { |u| [u[:entry], u[:data]] })
|
|
123
|
+
result[:skipped] += skipped_count
|
|
124
|
+
result
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def sync_units(units)
|
|
130
|
+
synced = 0
|
|
131
|
+
skipped = 0
|
|
132
|
+
errors = []
|
|
133
|
+
|
|
134
|
+
units.each do |entry|
|
|
135
|
+
unit_data = @reader.find_unit(entry['identifier'])
|
|
136
|
+
unless unit_data
|
|
137
|
+
skipped += 1
|
|
138
|
+
next
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
push_document(unit_data)
|
|
142
|
+
synced += 1
|
|
143
|
+
rescue Woods::Error => e
|
|
144
|
+
errors << "#{entry['identifier']}: #{e.message}"
|
|
145
|
+
break if e.message.include?('daily budget exhausted')
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
errors << "#{entry['identifier']}: #{e.message}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
{ synced: synced, skipped: skipped, errors: errors }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def sync_unit_data(entries_with_data)
|
|
154
|
+
synced = 0
|
|
155
|
+
skipped = 0
|
|
156
|
+
errors = []
|
|
157
|
+
|
|
158
|
+
entries_with_data.each do |entry, unit_data|
|
|
159
|
+
push_document(unit_data)
|
|
160
|
+
synced += 1
|
|
161
|
+
rescue Woods::Error => e
|
|
162
|
+
errors << "#{entry['identifier']}: #{e.message}"
|
|
163
|
+
break if e.message.include?('daily budget exhausted')
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
errors << "#{entry['identifier']}: #{e.message}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
{ synced: synced, skipped: skipped, errors: errors }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def push_document(unit_data)
|
|
172
|
+
doc = @builder.build(unit_data)
|
|
173
|
+
@client.put_document(
|
|
174
|
+
collection_id: @collection_id,
|
|
175
|
+
title: doc[:title],
|
|
176
|
+
body: doc[:body],
|
|
177
|
+
uri: doc[:uri]
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def build_reader(index_dir)
|
|
182
|
+
require_relative '../mcp/index_reader'
|
|
183
|
+
Woods::MCP::IndexReader.new(index_dir)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def empty_stats
|
|
187
|
+
{ synced: 0, skipped: 0, errors: [] }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def cap_errors(errors)
|
|
191
|
+
return errors if errors.size <= MAX_ERRORS
|
|
192
|
+
|
|
193
|
+
errors.first(MAX_ERRORS) + ["... and #{errors.size - MAX_ERRORS} more errors"]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def log(message)
|
|
197
|
+
@output&.puts(message)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Unblocked
|
|
5
|
+
# Daily budget-based rate limiter for the Unblocked API (1000 calls/day).
|
|
6
|
+
#
|
|
7
|
+
# Unlike Notion's per-second throttling, Unblocked limits by daily call count.
|
|
8
|
+
# Tracks usage against a configurable budget, warns when approaching the limit,
|
|
9
|
+
# and raises when exhausted.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# limiter = RateLimiter.new(daily_budget: 1000)
|
|
13
|
+
# limiter.track { client.put_document(...) } # => result
|
|
14
|
+
# limiter.remaining # => 999
|
|
15
|
+
#
|
|
16
|
+
class RateLimiter
|
|
17
|
+
DEFAULT_BUDGET = 1000
|
|
18
|
+
WARN_THRESHOLD = 0.8 # Warn at 80% usage
|
|
19
|
+
|
|
20
|
+
# @param daily_budget [Integer] Maximum API calls per day
|
|
21
|
+
# @param warn_io [IO] Where to write warnings (default: $stderr)
|
|
22
|
+
def initialize(daily_budget: DEFAULT_BUDGET, warn_io: $stderr)
|
|
23
|
+
unless daily_budget.is_a?(Integer) && daily_budget.positive?
|
|
24
|
+
raise ArgumentError, 'daily_budget must be positive'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@daily_budget = daily_budget
|
|
28
|
+
@calls_today = 0
|
|
29
|
+
@warn_io = warn_io
|
|
30
|
+
@warned = false
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Execute a block, tracking the API call against the daily budget.
|
|
35
|
+
#
|
|
36
|
+
# @yield The API call to execute
|
|
37
|
+
# @return [Object] The block's return value
|
|
38
|
+
# @raise [Woods::Error] if daily budget is exhausted
|
|
39
|
+
def track
|
|
40
|
+
raise ArgumentError, 'block required' unless block_given?
|
|
41
|
+
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
if @calls_today >= @daily_budget
|
|
44
|
+
raise Woods::Error,
|
|
45
|
+
"Unblocked API daily budget exhausted (#{@daily_budget} calls). " \
|
|
46
|
+
'Budget resets at midnight PST. Use UNBLOCKED_DAILY_BUDGET to adjust.'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@calls_today += 1
|
|
50
|
+
warn_if_approaching_limit
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
yield
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Number of API calls remaining in the daily budget.
|
|
57
|
+
#
|
|
58
|
+
# @return [Integer]
|
|
59
|
+
def remaining
|
|
60
|
+
@daily_budget - @calls_today
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Number of API calls used today.
|
|
64
|
+
#
|
|
65
|
+
# @return [Integer]
|
|
66
|
+
def used
|
|
67
|
+
@calls_today
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Reset the daily counter (for testing or manual reset).
|
|
71
|
+
#
|
|
72
|
+
# @return [void]
|
|
73
|
+
def reset!
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
@calls_today = 0
|
|
76
|
+
@warned = false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def warn_if_approaching_limit
|
|
83
|
+
return if @warned
|
|
84
|
+
return unless @calls_today >= (@daily_budget * WARN_THRESHOLD).to_i
|
|
85
|
+
|
|
86
|
+
@warned = true
|
|
87
|
+
@warn_io&.puts(
|
|
88
|
+
"WARNING: Unblocked API usage at #{@calls_today}/#{@daily_budget} " \
|
|
89
|
+
"(#{remaining} calls remaining)"
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/woods/version.rb
CHANGED
data/lib/woods.rb
CHANGED
|
@@ -43,6 +43,7 @@ module Woods
|
|
|
43
43
|
:session_tracer_enabled, :session_store, :session_id_proc, :session_exclude_paths,
|
|
44
44
|
:console_mcp_enabled, :console_mcp_path, :console_redacted_columns,
|
|
45
45
|
:notion_api_token, :notion_database_ids,
|
|
46
|
+
:unblocked_api_token, :unblocked_collection_id, :unblocked_repo_url,
|
|
46
47
|
:cache_store, :cache_options
|
|
47
48
|
attr_reader :max_context_tokens, :similarity_threshold, :extractors, :pretty_json, :context_format,
|
|
48
49
|
:cache_enabled
|
|
@@ -70,6 +71,9 @@ module Woods
|
|
|
70
71
|
@console_redacted_columns = []
|
|
71
72
|
@notion_api_token = nil
|
|
72
73
|
@notion_database_ids = {}
|
|
74
|
+
@unblocked_api_token = nil
|
|
75
|
+
@unblocked_collection_id = nil
|
|
76
|
+
@unblocked_repo_url = nil
|
|
73
77
|
@cache_enabled = false
|
|
74
78
|
@cache_store = nil # :redis, :solid_cache, :memory, or a CacheStore instance
|
|
75
79
|
@cache_options = {} # { redis: client, cache: store, ttl: { embeddings: 86400, ... } }
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: woods
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Leah Armstrong
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
11
|
+
date: 2026-03-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: mcp
|
|
@@ -237,6 +237,10 @@ files:
|
|
|
237
237
|
- lib/woods/temporal/json_snapshot_store.rb
|
|
238
238
|
- lib/woods/temporal/snapshot_store.rb
|
|
239
239
|
- lib/woods/token_utils.rb
|
|
240
|
+
- lib/woods/unblocked/client.rb
|
|
241
|
+
- lib/woods/unblocked/document_builder.rb
|
|
242
|
+
- lib/woods/unblocked/exporter.rb
|
|
243
|
+
- lib/woods/unblocked/rate_limiter.rb
|
|
240
244
|
- lib/woods/version.rb
|
|
241
245
|
homepage: https://github.com/lost-in-the/woods
|
|
242
246
|
licenses:
|