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,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Woods
6
+ module SessionTracer
7
+ # Value object representing an assembled session flow trace.
8
+ #
9
+ # Contains a two-level structure:
10
+ # - **Timeline** — ordered steps with unit_refs and side_effects (lightweight)
11
+ # - **Context pool** — deduplicated ExtractedUnit data (heavy, included once each)
12
+ #
13
+ # Follows the FlowDocument pattern for serialization and rendering.
14
+ #
15
+ # @example
16
+ # doc = SessionFlowDocument.new(
17
+ # session_id: "abc123",
18
+ # steps: [...],
19
+ # context_pool: { "OrdersController" => { ... } },
20
+ # generated_at: Time.now.utc.iso8601
21
+ # )
22
+ # doc.to_h # => JSON-serializable Hash
23
+ # doc.to_markdown # => human-readable document
24
+ # doc.to_context # => LLM XML format
25
+ #
26
+ # rubocop:disable Metrics/ClassLength
27
+ class SessionFlowDocument
28
+ attr_reader :session_id, :steps, :context_pool, :side_effects,
29
+ :dependency_map, :token_count, :generated_at
30
+
31
+ # @param session_id [String] The session identifier
32
+ # @param steps [Array<Hash>] Ordered timeline steps
33
+ # @param context_pool [Hash<String, Hash>] Deduplicated unit data keyed by identifier
34
+ # @param side_effects [Array<Hash>] Async side effects (jobs, mailers)
35
+ # @param dependency_map [Hash<String, Array<String>>] Unit -> dependency identifiers
36
+ # @param token_count [Integer] Estimated total tokens
37
+ # @param generated_at [String, nil] ISO8601 timestamp (defaults to now)
38
+ # rubocop:disable Metrics/ParameterLists
39
+ def initialize(session_id:, steps: [], context_pool: {}, side_effects: [],
40
+ dependency_map: {}, token_count: 0, generated_at: nil)
41
+ @session_id = session_id
42
+ @steps = steps
43
+ @context_pool = context_pool
44
+ @side_effects = side_effects
45
+ @dependency_map = dependency_map
46
+ @token_count = token_count
47
+ @generated_at = generated_at || Time.now.utc.iso8601
48
+ end
49
+ # rubocop:enable Metrics/ParameterLists
50
+
51
+ # Serialize to a JSON-compatible Hash.
52
+ #
53
+ # @return [Hash]
54
+ def to_h
55
+ {
56
+ session_id: @session_id,
57
+ generated_at: @generated_at,
58
+ token_count: @token_count,
59
+ steps: @steps,
60
+ context_pool: @context_pool,
61
+ side_effects: @side_effects,
62
+ dependency_map: @dependency_map
63
+ }
64
+ end
65
+
66
+ # Reconstruct from a serialized Hash.
67
+ #
68
+ # Handles both symbol and string keys for JSON round-trip compatibility.
69
+ #
70
+ # @param data [Hash] Previously serialized document data
71
+ # @return [SessionFlowDocument]
72
+ def self.from_h(data)
73
+ data = deep_symbolize_keys(data)
74
+
75
+ new(
76
+ session_id: data[:session_id],
77
+ steps: data[:steps] || [],
78
+ context_pool: data[:context_pool] || {},
79
+ side_effects: data[:side_effects] || [],
80
+ dependency_map: data[:dependency_map] || {},
81
+ token_count: data[:token_count] || 0,
82
+ generated_at: data[:generated_at]
83
+ )
84
+ end
85
+
86
+ # Render as human-readable Markdown.
87
+ #
88
+ # @return [String]
89
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
90
+ def to_markdown
91
+ lines = []
92
+ lines << "## Session: #{@session_id}"
93
+ lines << "_Generated at #{@generated_at} | #{@steps.size} requests | ~#{@token_count} tokens_"
94
+ lines << ''
95
+
96
+ # Timeline
97
+ lines << '### Timeline'
98
+ lines << ''
99
+ @steps.each_with_index do |step, idx|
100
+ status = step[:status] || '?'
101
+ duration = step[:duration_ms] ? " (#{step[:duration_ms]}ms)" : ''
102
+ entry = "#{idx + 1}. #{step[:method]} #{step[:path]} → " \
103
+ "#{step[:controller]}##{step[:action]} [#{status}]#{duration}"
104
+ lines << entry
105
+ end
106
+ lines << ''
107
+
108
+ # Side effects
109
+ if @side_effects.any?
110
+ lines << '### Side Effects'
111
+ lines << ''
112
+ @side_effects.each do |effect|
113
+ lines << "- #{effect[:type]}: #{effect[:identifier]} (triggered by #{effect[:trigger_step]})"
114
+ end
115
+ lines << ''
116
+ end
117
+
118
+ # Context pool
119
+ if @context_pool.any?
120
+ lines << '### Code Units'
121
+ lines << ''
122
+ @context_pool.each do |identifier, unit|
123
+ type = unit[:type] || 'unknown'
124
+ file_path = unit[:file_path]
125
+ lines << "#### #{identifier} (#{type})"
126
+ lines << "_#{file_path}_" if file_path
127
+ lines << ''
128
+ next unless unit[:source_code]
129
+
130
+ lines << '```ruby'
131
+ lines << unit[:source_code]
132
+ lines << '```'
133
+ lines << ''
134
+ end
135
+ end
136
+
137
+ # Dependencies
138
+ if @dependency_map.any?
139
+ lines << '### Dependencies'
140
+ lines << ''
141
+ @dependency_map.each do |unit_id, deps|
142
+ lines << "- #{unit_id} → #{deps.join(', ')}"
143
+ end
144
+ lines << ''
145
+ end
146
+
147
+ lines.join("\n")
148
+ end
149
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
150
+
151
+ # Render as LLM-consumable XML context.
152
+ #
153
+ # Follows the format from docs/CONTEXT_AND_CHUNKING.md.
154
+ #
155
+ # @return [String]
156
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
157
+ def to_context
158
+ lines = []
159
+ header = "<session_context session_id=\"#{@session_id}\" requests=\"#{@steps.size}\" " \
160
+ "tokens=\"#{@token_count}\" units=\"#{@context_pool.size}\">"
161
+ lines << header
162
+
163
+ # Timeline
164
+ lines << '<session_timeline>'
165
+ @steps.each_with_index do |step, idx|
166
+ status = step[:status] || '?'
167
+ duration = step[:duration_ms] ? ", #{step[:duration_ms]}ms" : ''
168
+ entry = "#{idx + 1}. #{step[:method]} #{step[:path]} → " \
169
+ "#{step[:controller]}##{step[:action]} (#{status}#{duration})"
170
+ lines << entry
171
+ end
172
+ lines << '</session_timeline>'
173
+
174
+ # Units
175
+ @context_pool.each do |identifier, unit|
176
+ type = unit[:type] || 'unknown'
177
+ file_path = unit[:file_path] || 'unknown'
178
+ lines << %(<unit identifier="#{identifier}" type="#{type}" file="#{file_path}">)
179
+ lines << (unit[:source_code] || '# source not available')
180
+ lines << '</unit>'
181
+ end
182
+
183
+ # Side effects
184
+ if @side_effects.any?
185
+ lines << '<side_effects>'
186
+ @side_effects.each do |effect|
187
+ lines << "#{effect[:identifier]} (triggered by #{effect[:trigger_step]}, #{effect[:type]})"
188
+ end
189
+ lines << '</side_effects>'
190
+ end
191
+
192
+ # Dependencies
193
+ if @dependency_map.any?
194
+ lines << '<dependencies>'
195
+ @dependency_map.each do |unit_id, deps|
196
+ lines << "#{unit_id} → #{deps.join(', ')}"
197
+ end
198
+ lines << '</dependencies>'
199
+ end
200
+
201
+ lines << '</session_context>'
202
+ lines.join("\n")
203
+ end
204
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
205
+
206
+ # @api private
207
+ def self.deep_symbolize_keys(obj)
208
+ case obj
209
+ when Hash
210
+ obj.each_with_object({}) do |(key, value), result|
211
+ result[key.to_sym] = deep_symbolize_keys(value)
212
+ end
213
+ when Array
214
+ obj.map { |item| deep_symbolize_keys(item) }
215
+ else
216
+ obj
217
+ end
218
+ end
219
+ private_class_method :deep_symbolize_keys
220
+ end
221
+ # rubocop:enable Metrics/ClassLength
222
+ end
223
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'store'
5
+
6
+ module Woods
7
+ module SessionTracer
8
+ # SolidCache-backed session store.
9
+ #
10
+ # Uses SolidCache key-value storage with `expires_in`. Single JSON blob
11
+ # per session (read-modify-write pattern). Requires the `solid_cache` gem.
12
+ #
13
+ # @example
14
+ # store = SolidCacheStore.new(cache: SolidCache::Store.new, expires_in: 3600)
15
+ # store.record("abc123", { controller: "OrdersController", action: "create" })
16
+ #
17
+ class SolidCacheStore < Store
18
+ KEY_PREFIX = 'woods:session:'
19
+ INDEX_KEY = 'woods:session_index'
20
+
21
+ # @param cache [ActiveSupport::Cache::Store] A SolidCache (or compatible) cache instance
22
+ # @param expires_in [Integer, nil] Expiry time in seconds (nil = no expiry)
23
+ def initialize(cache:, expires_in: nil)
24
+ super()
25
+ @cache = cache
26
+ @expires_in = expires_in
27
+ end
28
+
29
+ # Append a request record to a session (read-modify-write).
30
+ #
31
+ # NOTE: Not atomic — concurrent writes to the same session may lose data.
32
+ # Acceptable for development tracing. For high-concurrency tracing, use
33
+ # RedisStore (RPUSH is atomic) or FileStore (LOCK_EX).
34
+ #
35
+ # @param session_id [String] The session identifier
36
+ # @param request_data [Hash] Request metadata to store
37
+ # @return [void]
38
+ def record(session_id, request_data)
39
+ key = session_key(session_id)
40
+ existing = @cache.read(key)
41
+ requests = existing ? JSON.parse(existing) : []
42
+ requests << request_data
43
+
44
+ write_opts = @expires_in ? { expires_in: @expires_in } : {}
45
+ @cache.write(key, JSON.generate(requests), **write_opts)
46
+
47
+ update_index(session_id)
48
+ end
49
+
50
+ # Read all request records for a session.
51
+ #
52
+ # @param session_id [String] The session identifier
53
+ # @return [Array<Hash>] Request records, oldest first
54
+ def read(session_id)
55
+ key = session_key(session_id)
56
+ raw = @cache.read(key)
57
+ return [] unless raw
58
+
59
+ JSON.parse(raw)
60
+ rescue JSON::ParserError
61
+ []
62
+ end
63
+
64
+ # List recent session summaries.
65
+ #
66
+ # @param limit [Integer] Maximum number of sessions to return
67
+ # @return [Array<Hash>] Session summaries
68
+ def sessions(limit: 20)
69
+ index = read_index
70
+ active = index.select { |id| @cache.exist?(session_key(id)) }
71
+
72
+ # Clean up expired entries from the index
73
+ write_index(active) if active.size != index.size
74
+
75
+ active.first(limit).map do |session_id|
76
+ session_summary(session_id, read(session_id))
77
+ end
78
+ end
79
+
80
+ # Remove all data for a single session.
81
+ #
82
+ # @param session_id [String] The session identifier
83
+ # @return [void]
84
+ def clear(session_id)
85
+ @cache.delete(session_key(session_id))
86
+ index = read_index
87
+ index.delete(session_id)
88
+ write_index(index)
89
+ end
90
+
91
+ # Remove all session data.
92
+ #
93
+ # @return [void]
94
+ def clear_all
95
+ index = read_index
96
+ index.each { |id| @cache.delete(session_key(id)) }
97
+ @cache.delete(INDEX_KEY)
98
+ end
99
+
100
+ private
101
+
102
+ # @param session_id [String]
103
+ # @return [String] Cache key for this session
104
+ def session_key(session_id)
105
+ "#{KEY_PREFIX}#{sanitize_session_id(session_id)}"
106
+ end
107
+
108
+ # Read the session index (list of known session IDs).
109
+ #
110
+ # @return [Array<String>]
111
+ def read_index
112
+ raw = @cache.read(INDEX_KEY)
113
+ return [] unless raw
114
+
115
+ JSON.parse(raw)
116
+ rescue JSON::ParserError
117
+ []
118
+ end
119
+
120
+ # Write the session index.
121
+ #
122
+ # @param ids [Array<String>]
123
+ def write_index(ids)
124
+ @cache.write(INDEX_KEY, JSON.generate(ids))
125
+ end
126
+
127
+ # Add a session ID to the index if not already present.
128
+ #
129
+ # @param session_id [String]
130
+ def update_index(session_id)
131
+ index = read_index
132
+ return if index.include?(session_id)
133
+
134
+ index << session_id
135
+ write_index(index)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module SessionTracer
5
+ # Abstract store interface for session trace data.
6
+ #
7
+ # Concrete implementations must define:
8
+ # - `record(session_id, request_data)` — append a request record
9
+ # - `read(session_id)` — return all requests for a session, ordered by timestamp
10
+ # - `sessions(limit:)` — return recent session summaries
11
+ # - `clear(session_id)` — remove a single session
12
+ # - `clear_all` — remove all sessions
13
+ #
14
+ # @abstract Subclass and implement the required methods.
15
+ class Store
16
+ # Append a request record to a session.
17
+ #
18
+ # @param session_id [String] The session identifier
19
+ # @param request_data [Hash] Request metadata to store
20
+ # @return [void]
21
+ def record(session_id, request_data)
22
+ raise NotImplementedError, "#{self.class}#record must be implemented"
23
+ end
24
+
25
+ # Read all request records for a session, ordered by timestamp.
26
+ #
27
+ # @param session_id [String] The session identifier
28
+ # @return [Array<Hash>] Request records, oldest first
29
+ def read(session_id)
30
+ raise NotImplementedError, "#{self.class}#read must be implemented"
31
+ end
32
+
33
+ # List recent session summaries.
34
+ #
35
+ # @param limit [Integer] Maximum number of sessions to return
36
+ # @return [Array<Hash>] Session summaries with :session_id, :request_count, :first_request, :last_request
37
+ def sessions(limit: 20)
38
+ raise NotImplementedError, "#{self.class}#sessions must be implemented"
39
+ end
40
+
41
+ # Remove all data for a single session.
42
+ #
43
+ # @param session_id [String] The session identifier
44
+ # @return [void]
45
+ def clear(session_id)
46
+ raise NotImplementedError, "#{self.class}#clear must be implemented"
47
+ end
48
+
49
+ # Remove all session data.
50
+ #
51
+ # @return [void]
52
+ def clear_all
53
+ raise NotImplementedError, "#{self.class}#clear_all must be implemented"
54
+ end
55
+
56
+ private
57
+
58
+ # Sanitize a session ID for use in keys/filenames.
59
+ #
60
+ # @param session_id [String] Raw session identifier
61
+ # @return [String] Sanitized identifier (alphanumeric, hyphens, underscores only)
62
+ def sanitize_session_id(session_id)
63
+ session_id.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
64
+ end
65
+
66
+ # Build a session summary hash from a session ID and its requests.
67
+ #
68
+ # @param session_id [String]
69
+ # @param requests [Array<Hash>]
70
+ # @return [Hash]
71
+ def session_summary(session_id, requests)
72
+ {
73
+ 'session_id' => session_id,
74
+ 'request_count' => requests.size,
75
+ 'first_request' => requests.first&.fetch('timestamp', nil),
76
+ 'last_request' => requests.last&.fetch('timestamp', nil)
77
+ }
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../dependency_graph'
4
+
5
+ module Woods
6
+ module Storage
7
+ # GraphStore provides an interface for querying code unit relationships.
8
+ #
9
+ # All graph store adapters must include the {Interface} module and implement
10
+ # its methods. The {Memory} adapter wraps the existing {DependencyGraph}.
11
+ #
12
+ # @example Using the memory adapter
13
+ # store = Woods::Storage::GraphStore::Memory.new
14
+ # store.register(unit)
15
+ # store.dependencies_of("User")
16
+ #
17
+ module GraphStore
18
+ # Interface that all graph store adapters must implement.
19
+ module Interface
20
+ # Get direct dependencies of a unit.
21
+ #
22
+ # @param identifier [String] Unit identifier
23
+ # @return [Array<String>] List of dependency identifiers
24
+ # @raise [NotImplementedError] if not implemented by adapter
25
+ def dependencies_of(identifier)
26
+ raise NotImplementedError
27
+ end
28
+
29
+ # Get direct dependents of a unit (reverse dependencies).
30
+ #
31
+ # @param identifier [String] Unit identifier
32
+ # @return [Array<String>] List of dependent identifiers
33
+ # @raise [NotImplementedError] if not implemented by adapter
34
+ def dependents_of(identifier)
35
+ raise NotImplementedError
36
+ end
37
+
38
+ # Find all units transitively affected by changes to the given files.
39
+ #
40
+ # @param changed_files [Array<String>] List of changed file paths
41
+ # @param max_depth [Integer, nil] Maximum traversal depth (nil for unlimited)
42
+ # @return [Array<String>] List of affected unit identifiers
43
+ # @raise [NotImplementedError] if not implemented by adapter
44
+ def affected_by(changed_files, max_depth: nil)
45
+ raise NotImplementedError
46
+ end
47
+
48
+ # Get all units of a specific type.
49
+ #
50
+ # @param type [Symbol] Unit type (:model, :controller, etc.)
51
+ # @return [Array<String>] List of unit identifiers
52
+ # @raise [NotImplementedError] if not implemented by adapter
53
+ def by_type(type)
54
+ raise NotImplementedError
55
+ end
56
+
57
+ # Compute PageRank importance scores for all units.
58
+ #
59
+ # @param damping [Float] Damping factor (default: 0.85)
60
+ # @param iterations [Integer] Number of iterations (default: 20)
61
+ # @return [Hash<String, Float>] Identifier => PageRank score
62
+ # @raise [NotImplementedError] if not implemented by adapter
63
+ def pagerank(damping: 0.85, iterations: 20)
64
+ raise NotImplementedError
65
+ end
66
+ end
67
+
68
+ # In-memory graph store wrapping the existing DependencyGraph.
69
+ #
70
+ # Delegates all operations to {Woods::DependencyGraph}, providing
71
+ # a consistent storage interface.
72
+ #
73
+ # @example
74
+ # store = Memory.new
75
+ # store.register(user_unit)
76
+ # store.dependencies_of("User") # => ["Organization"]
77
+ #
78
+ class Memory
79
+ include Interface
80
+
81
+ # @param graph [DependencyGraph, nil] Existing graph to wrap, or nil to create a new one
82
+ def initialize(graph = nil)
83
+ @graph = graph || DependencyGraph.new
84
+ end
85
+
86
+ # Register a unit in the graph.
87
+ #
88
+ # @param unit [ExtractedUnit] The unit to register
89
+ def register(unit)
90
+ @graph.register(unit)
91
+ end
92
+
93
+ # @see Interface#dependencies_of
94
+ def dependencies_of(identifier)
95
+ @graph.dependencies_of(identifier)
96
+ end
97
+
98
+ # @see Interface#dependents_of
99
+ def dependents_of(identifier)
100
+ @graph.dependents_of(identifier)
101
+ end
102
+
103
+ # @see Interface#affected_by
104
+ def affected_by(changed_files, max_depth: nil)
105
+ @graph.affected_by(changed_files, max_depth: max_depth)
106
+ end
107
+
108
+ # @see Interface#by_type
109
+ def by_type(type)
110
+ @graph.units_of_type(type)
111
+ end
112
+
113
+ # @see Interface#pagerank
114
+ def pagerank(damping: 0.85, iterations: 20)
115
+ @graph.pagerank(damping: damping, iterations: iterations)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end