woods 1.0.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.
Files changed (185) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +89 -0
  3. data/CODE_OF_CONDUCT.md +83 -0
  4. data/CONTRIBUTING.md +65 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +406 -0
  7. data/exe/woods-console +59 -0
  8. data/exe/woods-console-mcp +22 -0
  9. data/exe/woods-mcp +34 -0
  10. data/exe/woods-mcp-http +37 -0
  11. data/exe/woods-mcp-start +58 -0
  12. data/lib/generators/woods/install_generator.rb +32 -0
  13. data/lib/generators/woods/pgvector_generator.rb +37 -0
  14. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +15 -0
  15. data/lib/generators/woods/templates/create_woods_tables.rb.erb +43 -0
  16. data/lib/tasks/woods.rake +621 -0
  17. data/lib/tasks/woods_evaluation.rake +115 -0
  18. data/lib/woods/ast/call_site_extractor.rb +106 -0
  19. data/lib/woods/ast/method_extractor.rb +71 -0
  20. data/lib/woods/ast/node.rb +116 -0
  21. data/lib/woods/ast/parser.rb +614 -0
  22. data/lib/woods/ast.rb +6 -0
  23. data/lib/woods/builder.rb +200 -0
  24. data/lib/woods/cache/cache_middleware.rb +199 -0
  25. data/lib/woods/cache/cache_store.rb +264 -0
  26. data/lib/woods/cache/redis_cache_store.rb +116 -0
  27. data/lib/woods/cache/solid_cache_store.rb +111 -0
  28. data/lib/woods/chunking/chunk.rb +84 -0
  29. data/lib/woods/chunking/semantic_chunker.rb +295 -0
  30. data/lib/woods/console/adapters/cache_adapter.rb +58 -0
  31. data/lib/woods/console/adapters/good_job_adapter.rb +33 -0
  32. data/lib/woods/console/adapters/job_adapter.rb +68 -0
  33. data/lib/woods/console/adapters/sidekiq_adapter.rb +33 -0
  34. data/lib/woods/console/adapters/solid_queue_adapter.rb +33 -0
  35. data/lib/woods/console/audit_logger.rb +75 -0
  36. data/lib/woods/console/bridge.rb +177 -0
  37. data/lib/woods/console/confirmation.rb +90 -0
  38. data/lib/woods/console/connection_manager.rb +173 -0
  39. data/lib/woods/console/console_response_renderer.rb +74 -0
  40. data/lib/woods/console/embedded_executor.rb +373 -0
  41. data/lib/woods/console/model_validator.rb +81 -0
  42. data/lib/woods/console/rack_middleware.rb +87 -0
  43. data/lib/woods/console/safe_context.rb +82 -0
  44. data/lib/woods/console/server.rb +612 -0
  45. data/lib/woods/console/sql_validator.rb +172 -0
  46. data/lib/woods/console/tools/tier1.rb +118 -0
  47. data/lib/woods/console/tools/tier2.rb +117 -0
  48. data/lib/woods/console/tools/tier3.rb +110 -0
  49. data/lib/woods/console/tools/tier4.rb +79 -0
  50. data/lib/woods/coordination/pipeline_lock.rb +109 -0
  51. data/lib/woods/cost_model/embedding_cost.rb +88 -0
  52. data/lib/woods/cost_model/estimator.rb +128 -0
  53. data/lib/woods/cost_model/provider_pricing.rb +67 -0
  54. data/lib/woods/cost_model/storage_cost.rb +52 -0
  55. data/lib/woods/cost_model.rb +22 -0
  56. data/lib/woods/db/migrations/001_create_units.rb +38 -0
  57. data/lib/woods/db/migrations/002_create_edges.rb +35 -0
  58. data/lib/woods/db/migrations/003_create_embeddings.rb +37 -0
  59. data/lib/woods/db/migrations/004_create_snapshots.rb +45 -0
  60. data/lib/woods/db/migrations/005_create_snapshot_units.rb +40 -0
  61. data/lib/woods/db/migrations/006_rename_tables.rb +34 -0
  62. data/lib/woods/db/migrator.rb +73 -0
  63. data/lib/woods/db/schema_version.rb +73 -0
  64. data/lib/woods/dependency_graph.rb +236 -0
  65. data/lib/woods/embedding/indexer.rb +140 -0
  66. data/lib/woods/embedding/openai.rb +126 -0
  67. data/lib/woods/embedding/provider.rb +162 -0
  68. data/lib/woods/embedding/text_preparer.rb +112 -0
  69. data/lib/woods/evaluation/baseline_runner.rb +115 -0
  70. data/lib/woods/evaluation/evaluator.rb +139 -0
  71. data/lib/woods/evaluation/metrics.rb +79 -0
  72. data/lib/woods/evaluation/query_set.rb +148 -0
  73. data/lib/woods/evaluation/report_generator.rb +90 -0
  74. data/lib/woods/extracted_unit.rb +145 -0
  75. data/lib/woods/extractor.rb +1028 -0
  76. data/lib/woods/extractors/action_cable_extractor.rb +201 -0
  77. data/lib/woods/extractors/ast_source_extraction.rb +46 -0
  78. data/lib/woods/extractors/behavioral_profile.rb +309 -0
  79. data/lib/woods/extractors/caching_extractor.rb +261 -0
  80. data/lib/woods/extractors/callback_analyzer.rb +246 -0
  81. data/lib/woods/extractors/concern_extractor.rb +292 -0
  82. data/lib/woods/extractors/configuration_extractor.rb +219 -0
  83. data/lib/woods/extractors/controller_extractor.rb +404 -0
  84. data/lib/woods/extractors/database_view_extractor.rb +278 -0
  85. data/lib/woods/extractors/decorator_extractor.rb +253 -0
  86. data/lib/woods/extractors/engine_extractor.rb +223 -0
  87. data/lib/woods/extractors/event_extractor.rb +211 -0
  88. data/lib/woods/extractors/factory_extractor.rb +289 -0
  89. data/lib/woods/extractors/graphql_extractor.rb +892 -0
  90. data/lib/woods/extractors/i18n_extractor.rb +117 -0
  91. data/lib/woods/extractors/job_extractor.rb +374 -0
  92. data/lib/woods/extractors/lib_extractor.rb +218 -0
  93. data/lib/woods/extractors/mailer_extractor.rb +269 -0
  94. data/lib/woods/extractors/manager_extractor.rb +188 -0
  95. data/lib/woods/extractors/middleware_extractor.rb +133 -0
  96. data/lib/woods/extractors/migration_extractor.rb +469 -0
  97. data/lib/woods/extractors/model_extractor.rb +988 -0
  98. data/lib/woods/extractors/phlex_extractor.rb +252 -0
  99. data/lib/woods/extractors/policy_extractor.rb +191 -0
  100. data/lib/woods/extractors/poro_extractor.rb +229 -0
  101. data/lib/woods/extractors/pundit_extractor.rb +223 -0
  102. data/lib/woods/extractors/rails_source_extractor.rb +473 -0
  103. data/lib/woods/extractors/rake_task_extractor.rb +343 -0
  104. data/lib/woods/extractors/route_extractor.rb +181 -0
  105. data/lib/woods/extractors/scheduled_job_extractor.rb +331 -0
  106. data/lib/woods/extractors/serializer_extractor.rb +339 -0
  107. data/lib/woods/extractors/service_extractor.rb +217 -0
  108. data/lib/woods/extractors/shared_dependency_scanner.rb +91 -0
  109. data/lib/woods/extractors/shared_utility_methods.rb +281 -0
  110. data/lib/woods/extractors/state_machine_extractor.rb +398 -0
  111. data/lib/woods/extractors/test_mapping_extractor.rb +225 -0
  112. data/lib/woods/extractors/validator_extractor.rb +211 -0
  113. data/lib/woods/extractors/view_component_extractor.rb +311 -0
  114. data/lib/woods/extractors/view_template_extractor.rb +261 -0
  115. data/lib/woods/feedback/gap_detector.rb +89 -0
  116. data/lib/woods/feedback/store.rb +119 -0
  117. data/lib/woods/filename_utils.rb +32 -0
  118. data/lib/woods/flow_analysis/operation_extractor.rb +206 -0
  119. data/lib/woods/flow_analysis/response_code_mapper.rb +154 -0
  120. data/lib/woods/flow_assembler.rb +290 -0
  121. data/lib/woods/flow_document.rb +191 -0
  122. data/lib/woods/flow_precomputer.rb +102 -0
  123. data/lib/woods/formatting/base.rb +30 -0
  124. data/lib/woods/formatting/claude_adapter.rb +98 -0
  125. data/lib/woods/formatting/generic_adapter.rb +56 -0
  126. data/lib/woods/formatting/gpt_adapter.rb +64 -0
  127. data/lib/woods/formatting/human_adapter.rb +78 -0
  128. data/lib/woods/graph_analyzer.rb +374 -0
  129. data/lib/woods/mcp/bootstrapper.rb +96 -0
  130. data/lib/woods/mcp/index_reader.rb +394 -0
  131. data/lib/woods/mcp/renderers/claude_renderer.rb +81 -0
  132. data/lib/woods/mcp/renderers/json_renderer.rb +17 -0
  133. data/lib/woods/mcp/renderers/markdown_renderer.rb +353 -0
  134. data/lib/woods/mcp/renderers/plain_renderer.rb +240 -0
  135. data/lib/woods/mcp/server.rb +962 -0
  136. data/lib/woods/mcp/tool_response_renderer.rb +85 -0
  137. data/lib/woods/model_name_cache.rb +51 -0
  138. data/lib/woods/notion/client.rb +217 -0
  139. data/lib/woods/notion/exporter.rb +219 -0
  140. data/lib/woods/notion/mapper.rb +40 -0
  141. data/lib/woods/notion/mappers/column_mapper.rb +57 -0
  142. data/lib/woods/notion/mappers/migration_mapper.rb +39 -0
  143. data/lib/woods/notion/mappers/model_mapper.rb +161 -0
  144. data/lib/woods/notion/mappers/shared.rb +22 -0
  145. data/lib/woods/notion/rate_limiter.rb +68 -0
  146. data/lib/woods/observability/health_check.rb +79 -0
  147. data/lib/woods/observability/instrumentation.rb +34 -0
  148. data/lib/woods/observability/structured_logger.rb +57 -0
  149. data/lib/woods/operator/error_escalator.rb +81 -0
  150. data/lib/woods/operator/pipeline_guard.rb +92 -0
  151. data/lib/woods/operator/status_reporter.rb +80 -0
  152. data/lib/woods/railtie.rb +38 -0
  153. data/lib/woods/resilience/circuit_breaker.rb +99 -0
  154. data/lib/woods/resilience/index_validator.rb +167 -0
  155. data/lib/woods/resilience/retryable_provider.rb +108 -0
  156. data/lib/woods/retrieval/context_assembler.rb +261 -0
  157. data/lib/woods/retrieval/query_classifier.rb +133 -0
  158. data/lib/woods/retrieval/ranker.rb +277 -0
  159. data/lib/woods/retrieval/search_executor.rb +316 -0
  160. data/lib/woods/retriever.rb +152 -0
  161. data/lib/woods/ruby_analyzer/class_analyzer.rb +170 -0
  162. data/lib/woods/ruby_analyzer/dataflow_analyzer.rb +77 -0
  163. data/lib/woods/ruby_analyzer/fqn_builder.rb +18 -0
  164. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +280 -0
  165. data/lib/woods/ruby_analyzer/method_analyzer.rb +143 -0
  166. data/lib/woods/ruby_analyzer/trace_enricher.rb +143 -0
  167. data/lib/woods/ruby_analyzer.rb +87 -0
  168. data/lib/woods/session_tracer/file_store.rb +104 -0
  169. data/lib/woods/session_tracer/middleware.rb +143 -0
  170. data/lib/woods/session_tracer/redis_store.rb +106 -0
  171. data/lib/woods/session_tracer/session_flow_assembler.rb +254 -0
  172. data/lib/woods/session_tracer/session_flow_document.rb +223 -0
  173. data/lib/woods/session_tracer/solid_cache_store.rb +139 -0
  174. data/lib/woods/session_tracer/store.rb +81 -0
  175. data/lib/woods/storage/graph_store.rb +120 -0
  176. data/lib/woods/storage/metadata_store.rb +196 -0
  177. data/lib/woods/storage/pgvector.rb +195 -0
  178. data/lib/woods/storage/qdrant.rb +205 -0
  179. data/lib/woods/storage/vector_store.rb +167 -0
  180. data/lib/woods/temporal/json_snapshot_store.rb +245 -0
  181. data/lib/woods/temporal/snapshot_store.rb +345 -0
  182. data/lib/woods/token_utils.rb +19 -0
  183. data/lib/woods/version.rb +5 -0
  184. data/lib/woods.rb +246 -0
  185. metadata +270 -0
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module MCP
5
+ module Renderers
6
+ # Renders MCP tool responses as pure Markdown.
7
+ # Headers, tables, code blocks, and bullet lists — no JSON structural markers.
8
+ class MarkdownRenderer < ToolResponseRenderer
9
+ # ── lookup ──────────────────────────────────────────────────
10
+
11
+ # @param data [Hash] Unit data from IndexReader#find_unit
12
+ # @return [String] Markdown-formatted unit
13
+ def render_lookup(data, **)
14
+ return 'Unit not found' unless data.is_a?(Hash) && data['identifier']
15
+
16
+ lines = []
17
+ lines << "## #{data['identifier']} (#{data['type']})"
18
+ lines << ''
19
+ lines << "**File:** `#{data['file_path']}`" if data['file_path']
20
+ lines << "**Namespace:** #{data['namespace']}" if data['namespace']
21
+ lines << ''
22
+
23
+ lines << render_metadata_section(data['metadata']) if data['metadata'].is_a?(Hash) && data['metadata'].any?
24
+
25
+ if data['source_code']
26
+ lines << '### Source'
27
+ lines << ''
28
+ lines << '```ruby'
29
+ lines << data['source_code'].chomp
30
+ lines << '```'
31
+ lines << ''
32
+ end
33
+
34
+ if data['dependencies'].is_a?(Array) && data['dependencies'].any?
35
+ lines << '### Dependencies'
36
+ lines << ''
37
+ data['dependencies'].each { |dep| lines << "- #{dep}" }
38
+ lines << ''
39
+ end
40
+
41
+ if data['dependents'].is_a?(Array) && data['dependents'].any?
42
+ lines << '### Dependents'
43
+ lines << ''
44
+ data['dependents'].each { |dep| lines << "- #{dep}" }
45
+ lines << ''
46
+ end
47
+
48
+ lines.join("\n").rstrip
49
+ end
50
+
51
+ # ── search ──────────────────────────────────────────────────
52
+
53
+ # @param data [Hash] Search results with :query, :result_count, :results
54
+ # @return [String] Markdown search results
55
+ def render_search(data, **)
56
+ query = fetch_key(data, :query)
57
+ count = fetch_key(data, :result_count, 0)
58
+ results = fetch_key(data, :results, [])
59
+
60
+ lines = []
61
+ lines << "## Search: \"#{query}\""
62
+ lines << ''
63
+ lines << "#{count} result#{'s' unless count == 1} found."
64
+ lines << ''
65
+
66
+ results.each do |r|
67
+ ident = fetch_key(r, :identifier)
68
+ type = fetch_key(r, :type)
69
+ match = fetch_key(r, :match_field)
70
+ line = "- **#{ident}** (#{type})"
71
+ line += " — matched in #{match}" if match
72
+ lines << line
73
+ end
74
+
75
+ lines.join("\n").rstrip
76
+ end
77
+
78
+ # ── dependencies / dependents ───────────────────────────────
79
+
80
+ # @param data [Hash] Traversal result with :root, :nodes, :found
81
+ # @return [String] Markdown dependency tree
82
+ def render_dependencies(data, **)
83
+ render_traversal('Dependencies', data)
84
+ end
85
+
86
+ # @param data [Hash] Traversal result with :root, :nodes, :found
87
+ # @return [String] Markdown dependents tree
88
+ def render_dependents(data, **)
89
+ render_traversal('Dependents', data)
90
+ end
91
+
92
+ # ── structure ───────────────────────────────────────────────
93
+
94
+ # @param data [Hash] Structure data with :manifest and optional :summary
95
+ # @return [String] Markdown structure overview
96
+ def render_structure(data, **)
97
+ manifest = fetch_key(data, :manifest, {})
98
+ lines = []
99
+ lines << '## Codebase Structure'
100
+ lines << ''
101
+
102
+ %w[rails_version ruby_version git_branch git_sha extracted_at].each do |key|
103
+ lines << "- **#{key.tr('_', ' ').capitalize}:** #{manifest[key]}" if manifest[key]
104
+ end
105
+ lines << "- **Total units:** #{manifest['total_units']}" if manifest['total_units']
106
+ lines << ''
107
+
108
+ counts = manifest['counts']
109
+ if counts.is_a?(Hash) && counts.any?
110
+ lines << '| Type | Count |'
111
+ lines << '|------|-------|'
112
+ counts.sort_by { |_k, v| -v }.each do |type, count|
113
+ lines << "| #{type} | #{count} |"
114
+ end
115
+ lines << ''
116
+ end
117
+
118
+ summary = fetch_key(data, :summary)
119
+ if summary
120
+ lines << '### Summary'
121
+ lines << ''
122
+ lines << summary
123
+ end
124
+
125
+ lines.join("\n").rstrip
126
+ end
127
+
128
+ # ── graph_analysis ──────────────────────────────────────────
129
+
130
+ # @param data [Hash] Graph analysis with section arrays and stats
131
+ # @return [String] Markdown graph analysis
132
+ def render_graph_analysis(data, **)
133
+ lines = []
134
+ lines << '## Graph Analysis'
135
+ lines << ''
136
+
137
+ stats = fetch_key(data, :stats)
138
+ if stats.is_a?(Hash)
139
+ stats.each { |k, v| lines << "- **#{k}:** #{v}" }
140
+ lines << ''
141
+ end
142
+
143
+ %w[orphans dead_ends hubs cycles bridges].each do |section|
144
+ items = fetch_key(data, section)
145
+ next unless items.is_a?(Array) && items.any?
146
+
147
+ lines << "### #{section.tr('_', ' ').capitalize}"
148
+ lines << ''
149
+ items.each do |item|
150
+ lines << if item.is_a?(Hash)
151
+ "- **#{item['identifier']}** (#{item['type']}) — #{item['dependent_count']} dependents"
152
+ else
153
+ "- #{item}"
154
+ end
155
+ end
156
+
157
+ total_key = "#{section}_total"
158
+ if data[total_key]
159
+ lines << ''
160
+ lines << "_Showing #{items.size} of #{data[total_key]} (truncated)_"
161
+ end
162
+ lines << ''
163
+ end
164
+
165
+ lines.join("\n").rstrip
166
+ end
167
+
168
+ # ── pagerank ────────────────────────────────────────────────
169
+
170
+ # @param data [Hash] PageRank data with :total_nodes and :results
171
+ # @return [String] Markdown table of ranked nodes
172
+ def render_pagerank(data, **)
173
+ lines = []
174
+ lines << '## PageRank Scores'
175
+ lines << ''
176
+ lines << "#{fetch_key(data, :total_nodes)} nodes in graph."
177
+ lines << ''
178
+ lines << '| Rank | Identifier | Type | Score |'
179
+ lines << '|------|-----------|------|-------|'
180
+
181
+ results = fetch_key(data, :results, [])
182
+ results.each_with_index do |r, i|
183
+ lines << "| #{i + 1} | #{fetch_key(r, :identifier)} | #{fetch_key(r, :type)} | #{fetch_key(r, :score)} |"
184
+ end
185
+
186
+ lines.join("\n").rstrip
187
+ end
188
+
189
+ # ── framework ───────────────────────────────────────────────
190
+
191
+ # @param data [Hash] Framework search results
192
+ # @return [String] Markdown framework results
193
+ def render_framework(data, **)
194
+ keyword = fetch_key(data, :keyword)
195
+ count = fetch_key(data, :result_count, 0)
196
+ results = fetch_key(data, :results, [])
197
+
198
+ lines = []
199
+ lines << "## Framework: \"#{keyword}\""
200
+ lines << ''
201
+ lines << "#{count} result#{'s' unless count == 1} found."
202
+ lines << ''
203
+
204
+ results.each do |r|
205
+ ident = fetch_key(r, :identifier)
206
+ type = fetch_key(r, :type)
207
+ file = fetch_key(r, :file_path)
208
+ line = "- **#{ident}** (#{type})"
209
+ line += " — `#{file}`" if file
210
+ lines << line
211
+ end
212
+
213
+ lines.join("\n").rstrip
214
+ end
215
+
216
+ # ── recent_changes ──────────────────────────────────────────
217
+
218
+ # @param data [Hash] Recent changes with :result_count and :results
219
+ # @return [String] Markdown table of recent changes
220
+ def render_recent_changes(data, **)
221
+ count = fetch_key(data, :result_count, 0)
222
+ results = fetch_key(data, :results, [])
223
+
224
+ lines = []
225
+ lines << '## Recent Changes'
226
+ lines << ''
227
+ lines << "#{count} recently modified unit#{'s' unless count == 1}."
228
+ lines << ''
229
+ lines << '| Identifier | Type | Last Modified | Author |'
230
+ lines << '|-----------|------|---------------|--------|'
231
+
232
+ results.each do |r|
233
+ ident = fetch_key(r, :identifier)
234
+ type = fetch_key(r, :type)
235
+ modified = fetch_key(r, :last_modified) || '-'
236
+ author = fetch_key(r, :author) || '-'
237
+ lines << "| #{ident} | #{type} | #{modified} | #{author} |"
238
+ end
239
+
240
+ lines.join("\n").rstrip
241
+ end
242
+
243
+ # ── Default fallback ────────────────────────────────────────
244
+
245
+ # @param data [Object] Any data
246
+ # @return [String] Markdown-formatted default output
247
+ def render_default(data)
248
+ case data
249
+ when Hash
250
+ render_hash_as_markdown(data)
251
+ when Array
252
+ render_array_as_markdown(data)
253
+ else
254
+ data.to_s
255
+ end
256
+ end
257
+
258
+ private
259
+
260
+ def render_traversal(label, data)
261
+ root = fetch_key(data, :root)
262
+ found = data[:found] || data['found']
263
+ nodes = fetch_key(data, :nodes, {})
264
+ message = fetch_key(data, :message)
265
+
266
+ lines = []
267
+ lines << "## #{label} of #{root}"
268
+ lines << ''
269
+
270
+ if found == false
271
+ lines << (message || "Identifier '#{root}' not found in the index.")
272
+ return lines.join("\n").rstrip
273
+ end
274
+
275
+ nodes.each do |id, info|
276
+ depth = fetch_key(info, :depth) || 0
277
+ deps = fetch_key(info, :deps, [])
278
+ indent = ' ' * depth
279
+ lines << "#{indent}- **#{id}**"
280
+ deps.each { |d| lines << "#{indent} - #{d}" }
281
+ end
282
+
283
+ lines.join("\n").rstrip
284
+ end
285
+
286
+ def render_metadata_section(metadata)
287
+ lines = []
288
+ lines << '### Metadata'
289
+ lines << ''
290
+
291
+ metadata.each do |key, value|
292
+ case value
293
+ when Array
294
+ next if value.empty?
295
+
296
+ lines << "**#{key}:**"
297
+ value.each do |item|
298
+ if item.is_a?(Hash)
299
+ summary = item.map { |k, v| "#{k}: #{v}" }.join(', ')
300
+ lines << " - #{summary}"
301
+ else
302
+ lines << " - #{item}"
303
+ end
304
+ end
305
+ when Hash
306
+ lines << "**#{key}:** #{value.map { |k, v| "#{k}=#{v}" }.join(', ')}"
307
+ else
308
+ lines << "**#{key}:** #{value}"
309
+ end
310
+ end
311
+ lines << ''
312
+ lines.join("\n")
313
+ end
314
+
315
+ def render_hash_as_markdown(hash)
316
+ lines = []
317
+ hash.each do |key, value|
318
+ case value
319
+ when Hash
320
+ lines << "**#{key}:**"
321
+ value.each { |k, v| lines << " - #{k}: #{v}" }
322
+ when Array
323
+ lines << "**#{key}:** #{value.size} items"
324
+ value.first(10).each do |item|
325
+ lines << " - #{item.is_a?(Hash) ? item.values.first(3).join(', ') : item}"
326
+ end
327
+ else
328
+ lines << "**#{key}:** #{value}"
329
+ end
330
+ end
331
+ lines.join("\n")
332
+ end
333
+
334
+ def render_array_as_markdown(array)
335
+ return '_(empty)_' if array.empty?
336
+
337
+ if array.first.is_a?(Hash)
338
+ keys = array.first.keys.first(5)
339
+ lines = []
340
+ lines << "| #{keys.join(' | ')} |"
341
+ lines << "| #{keys.map { '---' }.join(' | ')} |"
342
+ array.each do |row|
343
+ lines << "| #{keys.map { |k| row[k] }.join(' | ')} |"
344
+ end
345
+ lines.join("\n")
346
+ else
347
+ array.map { |item| "- #{item}" }.join("\n")
348
+ end
349
+ end
350
+ end
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module MCP
5
+ module Renderers
6
+ # Renders MCP tool responses as plain text with === dividers.
7
+ # Lightweight fallback format with no markup.
8
+ class PlainRenderer < ToolResponseRenderer
9
+ DIVIDER = ('=' * 60).freeze
10
+
11
+ def render_lookup(data, **)
12
+ return 'Unit not found' unless data.is_a?(Hash) && data['identifier']
13
+
14
+ lines = []
15
+ lines << "#{data['identifier']} (#{data['type']})"
16
+ lines << DIVIDER
17
+ lines << "File: #{data['file_path']}" if data['file_path']
18
+ lines << "Namespace: #{data['namespace']}" if data['namespace']
19
+ lines << ''
20
+
21
+ if data['metadata'].is_a?(Hash) && data['metadata'].any?
22
+ lines << 'Metadata:'
23
+ data['metadata'].each do |key, value|
24
+ case value
25
+ when Array
26
+ next if value.empty?
27
+
28
+ lines << " #{key}:"
29
+ value.each do |item|
30
+ lines << " - #{item.is_a?(Hash) ? item.map { |k, v| "#{k}: #{v}" }.join(', ') : item}"
31
+ end
32
+ when Hash
33
+ lines << " #{key}: #{value.map { |k, v| "#{k}=#{v}" }.join(', ')}"
34
+ else
35
+ lines << " #{key}: #{value}"
36
+ end
37
+ end
38
+ lines << ''
39
+ end
40
+
41
+ if data['source_code']
42
+ lines << 'Source:'
43
+ lines << DIVIDER
44
+ lines << data['source_code'].chomp
45
+ lines << DIVIDER
46
+ lines << ''
47
+ end
48
+
49
+ if data['dependencies'].is_a?(Array) && data['dependencies'].any?
50
+ lines << "Dependencies: #{data['dependencies'].join(', ')}"
51
+ end
52
+
53
+ if data['dependents'].is_a?(Array) && data['dependents'].any?
54
+ lines << "Dependents: #{data['dependents'].join(', ')}"
55
+ end
56
+
57
+ lines.join("\n").rstrip
58
+ end
59
+
60
+ def render_search(data, **)
61
+ query = fetch_key(data, :query)
62
+ count = fetch_key(data, :result_count, 0)
63
+ results = fetch_key(data, :results, [])
64
+
65
+ lines = []
66
+ lines << "Search: \"#{query}\" (#{count} results)"
67
+ lines << DIVIDER
68
+
69
+ results.each do |r|
70
+ ident = fetch_key(r, :identifier)
71
+ type = fetch_key(r, :type)
72
+ lines << " #{ident} (#{type})"
73
+ end
74
+
75
+ lines.join("\n").rstrip
76
+ end
77
+
78
+ def render_dependencies(data, **)
79
+ render_plain_traversal('Dependencies', data)
80
+ end
81
+
82
+ def render_dependents(data, **)
83
+ render_plain_traversal('Dependents', data)
84
+ end
85
+
86
+ def render_structure(data, **)
87
+ manifest = fetch_key(data, :manifest, {})
88
+ lines = []
89
+ lines << 'Codebase Structure'
90
+ lines << DIVIDER
91
+
92
+ %w[rails_version ruby_version git_branch git_sha extracted_at total_units].each do |key|
93
+ lines << " #{key}: #{manifest[key]}" if manifest[key]
94
+ end
95
+
96
+ counts = manifest['counts']
97
+ if counts.is_a?(Hash) && counts.any?
98
+ lines << ''
99
+ lines << 'Unit counts:'
100
+ counts.sort_by { |_k, v| -v }.each { |type, count| lines << " #{type}: #{count}" }
101
+ end
102
+
103
+ summary = fetch_key(data, :summary)
104
+ if summary
105
+ lines << ''
106
+ lines << DIVIDER
107
+ lines << summary
108
+ end
109
+
110
+ lines.join("\n").rstrip
111
+ end
112
+
113
+ def render_graph_analysis(data, **)
114
+ lines = []
115
+ lines << 'Graph Analysis'
116
+ lines << DIVIDER
117
+
118
+ stats = fetch_key(data, :stats)
119
+ if stats.is_a?(Hash)
120
+ stats.each { |k, v| lines << " #{k}: #{v}" }
121
+ lines << ''
122
+ end
123
+
124
+ %w[orphans dead_ends hubs cycles bridges].each do |section|
125
+ items = fetch_key(data, section)
126
+ next unless items.is_a?(Array) && items.any?
127
+
128
+ lines << "#{section.tr('_', ' ').upcase}:"
129
+ items.each do |item|
130
+ lines << if item.is_a?(Hash)
131
+ " #{item['identifier']} (#{item['type']}) - #{item['dependent_count']} dependents"
132
+ else
133
+ " #{item}"
134
+ end
135
+ end
136
+
137
+ total_key = "#{section}_total"
138
+ lines << " (showing #{items.size} of #{data[total_key]})" if data[total_key]
139
+ lines << ''
140
+ end
141
+
142
+ lines.join("\n").rstrip
143
+ end
144
+
145
+ def render_pagerank(data, **)
146
+ lines = []
147
+ lines << "PageRank Scores (#{fetch_key(data, :total_nodes)} nodes)"
148
+ lines << DIVIDER
149
+
150
+ results = fetch_key(data, :results, [])
151
+ results.each_with_index do |r, i|
152
+ ident = fetch_key(r, :identifier)
153
+ type = fetch_key(r, :type)
154
+ score = fetch_key(r, :score)
155
+ lines << " #{i + 1}. #{ident} (#{type}) - #{score}"
156
+ end
157
+
158
+ lines.join("\n").rstrip
159
+ end
160
+
161
+ def render_framework(data, **)
162
+ keyword = fetch_key(data, :keyword)
163
+ count = fetch_key(data, :result_count, 0)
164
+ results = fetch_key(data, :results, [])
165
+
166
+ lines = []
167
+ lines << "Framework: \"#{keyword}\" (#{count} results)"
168
+ lines << DIVIDER
169
+
170
+ results.each do |r|
171
+ ident = fetch_key(r, :identifier)
172
+ type = fetch_key(r, :type)
173
+ lines << " #{ident} (#{type})"
174
+ end
175
+
176
+ lines.join("\n").rstrip
177
+ end
178
+
179
+ def render_recent_changes(data, **)
180
+ count = fetch_key(data, :result_count, 0)
181
+ results = fetch_key(data, :results, [])
182
+
183
+ lines = []
184
+ lines << "Recent Changes (#{count} units)"
185
+ lines << DIVIDER
186
+
187
+ results.each do |r|
188
+ ident = fetch_key(r, :identifier)
189
+ type = fetch_key(r, :type)
190
+ modified = fetch_key(r, :last_modified) || '-'
191
+ lines << " #{ident} (#{type}) - #{modified}"
192
+ end
193
+
194
+ lines.join("\n").rstrip
195
+ end
196
+
197
+ # @param data [Object] Any data
198
+ # @return [String] Plain text output
199
+ def render_default(data)
200
+ case data
201
+ when Hash
202
+ data.map { |k, v| "#{k}: #{v}" }.join("\n")
203
+ when Array
204
+ data.map { |item| " #{item}" }.join("\n")
205
+ else
206
+ data.to_s
207
+ end
208
+ end
209
+
210
+ private
211
+
212
+ def render_plain_traversal(label, data)
213
+ root = fetch_key(data, :root)
214
+ found = data[:found] || data['found']
215
+ nodes = fetch_key(data, :nodes, {})
216
+ message = fetch_key(data, :message)
217
+
218
+ lines = []
219
+ lines << "#{label} of #{root}"
220
+ lines << DIVIDER
221
+
222
+ if found == false
223
+ lines << (message || "Identifier '#{root}' not found in the index.")
224
+ return lines.join("\n").rstrip
225
+ end
226
+
227
+ nodes.each do |id, info|
228
+ depth = fetch_key(info, :depth) || 0
229
+ deps = fetch_key(info, :deps, [])
230
+ indent = ' ' * (depth + 1)
231
+ lines << "#{indent}#{id}"
232
+ deps.each { |d| lines << "#{indent} -> #{d}" }
233
+ end
234
+
235
+ lines.join("\n").rstrip
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end