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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +89 -0
- data/CODE_OF_CONDUCT.md +83 -0
- data/CONTRIBUTING.md +65 -0
- data/LICENSE.txt +21 -0
- data/README.md +406 -0
- data/exe/woods-console +59 -0
- data/exe/woods-console-mcp +22 -0
- data/exe/woods-mcp +34 -0
- data/exe/woods-mcp-http +37 -0
- data/exe/woods-mcp-start +58 -0
- data/lib/generators/woods/install_generator.rb +32 -0
- data/lib/generators/woods/pgvector_generator.rb +37 -0
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +15 -0
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +43 -0
- data/lib/tasks/woods.rake +621 -0
- data/lib/tasks/woods_evaluation.rake +115 -0
- data/lib/woods/ast/call_site_extractor.rb +106 -0
- data/lib/woods/ast/method_extractor.rb +71 -0
- data/lib/woods/ast/node.rb +116 -0
- data/lib/woods/ast/parser.rb +614 -0
- data/lib/woods/ast.rb +6 -0
- data/lib/woods/builder.rb +200 -0
- data/lib/woods/cache/cache_middleware.rb +199 -0
- data/lib/woods/cache/cache_store.rb +264 -0
- data/lib/woods/cache/redis_cache_store.rb +116 -0
- data/lib/woods/cache/solid_cache_store.rb +111 -0
- data/lib/woods/chunking/chunk.rb +84 -0
- data/lib/woods/chunking/semantic_chunker.rb +295 -0
- data/lib/woods/console/adapters/cache_adapter.rb +58 -0
- data/lib/woods/console/adapters/good_job_adapter.rb +33 -0
- data/lib/woods/console/adapters/job_adapter.rb +68 -0
- data/lib/woods/console/adapters/sidekiq_adapter.rb +33 -0
- data/lib/woods/console/adapters/solid_queue_adapter.rb +33 -0
- data/lib/woods/console/audit_logger.rb +75 -0
- data/lib/woods/console/bridge.rb +177 -0
- data/lib/woods/console/confirmation.rb +90 -0
- data/lib/woods/console/connection_manager.rb +173 -0
- data/lib/woods/console/console_response_renderer.rb +74 -0
- data/lib/woods/console/embedded_executor.rb +373 -0
- data/lib/woods/console/model_validator.rb +81 -0
- data/lib/woods/console/rack_middleware.rb +87 -0
- data/lib/woods/console/safe_context.rb +82 -0
- data/lib/woods/console/server.rb +612 -0
- data/lib/woods/console/sql_validator.rb +172 -0
- data/lib/woods/console/tools/tier1.rb +118 -0
- data/lib/woods/console/tools/tier2.rb +117 -0
- data/lib/woods/console/tools/tier3.rb +110 -0
- data/lib/woods/console/tools/tier4.rb +79 -0
- data/lib/woods/coordination/pipeline_lock.rb +109 -0
- data/lib/woods/cost_model/embedding_cost.rb +88 -0
- data/lib/woods/cost_model/estimator.rb +128 -0
- data/lib/woods/cost_model/provider_pricing.rb +67 -0
- data/lib/woods/cost_model/storage_cost.rb +52 -0
- data/lib/woods/cost_model.rb +22 -0
- data/lib/woods/db/migrations/001_create_units.rb +38 -0
- data/lib/woods/db/migrations/002_create_edges.rb +35 -0
- data/lib/woods/db/migrations/003_create_embeddings.rb +37 -0
- data/lib/woods/db/migrations/004_create_snapshots.rb +45 -0
- data/lib/woods/db/migrations/005_create_snapshot_units.rb +40 -0
- data/lib/woods/db/migrations/006_rename_tables.rb +34 -0
- data/lib/woods/db/migrator.rb +73 -0
- data/lib/woods/db/schema_version.rb +73 -0
- data/lib/woods/dependency_graph.rb +236 -0
- data/lib/woods/embedding/indexer.rb +140 -0
- data/lib/woods/embedding/openai.rb +126 -0
- data/lib/woods/embedding/provider.rb +162 -0
- data/lib/woods/embedding/text_preparer.rb +112 -0
- data/lib/woods/evaluation/baseline_runner.rb +115 -0
- data/lib/woods/evaluation/evaluator.rb +139 -0
- data/lib/woods/evaluation/metrics.rb +79 -0
- data/lib/woods/evaluation/query_set.rb +148 -0
- data/lib/woods/evaluation/report_generator.rb +90 -0
- data/lib/woods/extracted_unit.rb +145 -0
- data/lib/woods/extractor.rb +1028 -0
- data/lib/woods/extractors/action_cable_extractor.rb +201 -0
- data/lib/woods/extractors/ast_source_extraction.rb +46 -0
- data/lib/woods/extractors/behavioral_profile.rb +309 -0
- data/lib/woods/extractors/caching_extractor.rb +261 -0
- data/lib/woods/extractors/callback_analyzer.rb +246 -0
- data/lib/woods/extractors/concern_extractor.rb +292 -0
- data/lib/woods/extractors/configuration_extractor.rb +219 -0
- data/lib/woods/extractors/controller_extractor.rb +404 -0
- data/lib/woods/extractors/database_view_extractor.rb +278 -0
- data/lib/woods/extractors/decorator_extractor.rb +253 -0
- data/lib/woods/extractors/engine_extractor.rb +223 -0
- data/lib/woods/extractors/event_extractor.rb +211 -0
- data/lib/woods/extractors/factory_extractor.rb +289 -0
- data/lib/woods/extractors/graphql_extractor.rb +892 -0
- data/lib/woods/extractors/i18n_extractor.rb +117 -0
- data/lib/woods/extractors/job_extractor.rb +374 -0
- data/lib/woods/extractors/lib_extractor.rb +218 -0
- data/lib/woods/extractors/mailer_extractor.rb +269 -0
- data/lib/woods/extractors/manager_extractor.rb +188 -0
- data/lib/woods/extractors/middleware_extractor.rb +133 -0
- data/lib/woods/extractors/migration_extractor.rb +469 -0
- data/lib/woods/extractors/model_extractor.rb +988 -0
- data/lib/woods/extractors/phlex_extractor.rb +252 -0
- data/lib/woods/extractors/policy_extractor.rb +191 -0
- data/lib/woods/extractors/poro_extractor.rb +229 -0
- data/lib/woods/extractors/pundit_extractor.rb +223 -0
- data/lib/woods/extractors/rails_source_extractor.rb +473 -0
- data/lib/woods/extractors/rake_task_extractor.rb +343 -0
- data/lib/woods/extractors/route_extractor.rb +181 -0
- data/lib/woods/extractors/scheduled_job_extractor.rb +331 -0
- data/lib/woods/extractors/serializer_extractor.rb +339 -0
- data/lib/woods/extractors/service_extractor.rb +217 -0
- data/lib/woods/extractors/shared_dependency_scanner.rb +91 -0
- data/lib/woods/extractors/shared_utility_methods.rb +281 -0
- data/lib/woods/extractors/state_machine_extractor.rb +398 -0
- data/lib/woods/extractors/test_mapping_extractor.rb +225 -0
- data/lib/woods/extractors/validator_extractor.rb +211 -0
- data/lib/woods/extractors/view_component_extractor.rb +311 -0
- data/lib/woods/extractors/view_template_extractor.rb +261 -0
- data/lib/woods/feedback/gap_detector.rb +89 -0
- data/lib/woods/feedback/store.rb +119 -0
- data/lib/woods/filename_utils.rb +32 -0
- data/lib/woods/flow_analysis/operation_extractor.rb +206 -0
- data/lib/woods/flow_analysis/response_code_mapper.rb +154 -0
- data/lib/woods/flow_assembler.rb +290 -0
- data/lib/woods/flow_document.rb +191 -0
- data/lib/woods/flow_precomputer.rb +102 -0
- data/lib/woods/formatting/base.rb +30 -0
- data/lib/woods/formatting/claude_adapter.rb +98 -0
- data/lib/woods/formatting/generic_adapter.rb +56 -0
- data/lib/woods/formatting/gpt_adapter.rb +64 -0
- data/lib/woods/formatting/human_adapter.rb +78 -0
- data/lib/woods/graph_analyzer.rb +374 -0
- data/lib/woods/mcp/bootstrapper.rb +96 -0
- data/lib/woods/mcp/index_reader.rb +394 -0
- data/lib/woods/mcp/renderers/claude_renderer.rb +81 -0
- data/lib/woods/mcp/renderers/json_renderer.rb +17 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +353 -0
- data/lib/woods/mcp/renderers/plain_renderer.rb +240 -0
- data/lib/woods/mcp/server.rb +962 -0
- data/lib/woods/mcp/tool_response_renderer.rb +85 -0
- data/lib/woods/model_name_cache.rb +51 -0
- data/lib/woods/notion/client.rb +217 -0
- data/lib/woods/notion/exporter.rb +219 -0
- data/lib/woods/notion/mapper.rb +40 -0
- data/lib/woods/notion/mappers/column_mapper.rb +57 -0
- data/lib/woods/notion/mappers/migration_mapper.rb +39 -0
- data/lib/woods/notion/mappers/model_mapper.rb +161 -0
- data/lib/woods/notion/mappers/shared.rb +22 -0
- data/lib/woods/notion/rate_limiter.rb +68 -0
- data/lib/woods/observability/health_check.rb +79 -0
- data/lib/woods/observability/instrumentation.rb +34 -0
- data/lib/woods/observability/structured_logger.rb +57 -0
- data/lib/woods/operator/error_escalator.rb +81 -0
- data/lib/woods/operator/pipeline_guard.rb +92 -0
- data/lib/woods/operator/status_reporter.rb +80 -0
- data/lib/woods/railtie.rb +38 -0
- data/lib/woods/resilience/circuit_breaker.rb +99 -0
- data/lib/woods/resilience/index_validator.rb +167 -0
- data/lib/woods/resilience/retryable_provider.rb +108 -0
- data/lib/woods/retrieval/context_assembler.rb +261 -0
- data/lib/woods/retrieval/query_classifier.rb +133 -0
- data/lib/woods/retrieval/ranker.rb +277 -0
- data/lib/woods/retrieval/search_executor.rb +316 -0
- data/lib/woods/retriever.rb +152 -0
- data/lib/woods/ruby_analyzer/class_analyzer.rb +170 -0
- data/lib/woods/ruby_analyzer/dataflow_analyzer.rb +77 -0
- data/lib/woods/ruby_analyzer/fqn_builder.rb +18 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +280 -0
- data/lib/woods/ruby_analyzer/method_analyzer.rb +143 -0
- data/lib/woods/ruby_analyzer/trace_enricher.rb +143 -0
- data/lib/woods/ruby_analyzer.rb +87 -0
- data/lib/woods/session_tracer/file_store.rb +104 -0
- data/lib/woods/session_tracer/middleware.rb +143 -0
- data/lib/woods/session_tracer/redis_store.rb +106 -0
- data/lib/woods/session_tracer/session_flow_assembler.rb +254 -0
- data/lib/woods/session_tracer/session_flow_document.rb +223 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +139 -0
- data/lib/woods/session_tracer/store.rb +81 -0
- data/lib/woods/storage/graph_store.rb +120 -0
- data/lib/woods/storage/metadata_store.rb +196 -0
- data/lib/woods/storage/pgvector.rb +195 -0
- data/lib/woods/storage/qdrant.rb +205 -0
- data/lib/woods/storage/vector_store.rb +167 -0
- data/lib/woods/temporal/json_snapshot_store.rb +245 -0
- data/lib/woods/temporal/snapshot_store.rb +345 -0
- data/lib/woods/token_utils.rb +19 -0
- data/lib/woods/version.rb +5 -0
- data/lib/woods.rb +246 -0
- 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
|