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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ast'
4
+ require_relative 'extracted_unit'
5
+ require_relative 'ruby_analyzer/class_analyzer'
6
+ require_relative 'ruby_analyzer/method_analyzer'
7
+ require_relative 'ruby_analyzer/dataflow_analyzer'
8
+ require_relative 'ruby_analyzer/trace_enricher'
9
+
10
+ module Woods
11
+ # Analyzes plain Ruby source code and produces ExtractedUnit objects.
12
+ #
13
+ # Orchestrates ClassAnalyzer, MethodAnalyzer, DataFlowAnalyzer, and
14
+ # optional TraceEnricher to extract structured data from Ruby files.
15
+ #
16
+ # @example Analyze gem source
17
+ # units = Woods::RubyAnalyzer.analyze(paths: ["lib/"])
18
+ # units.select { |u| u.type == :ruby_class }.map(&:identifier)
19
+ #
20
+ module RubyAnalyzer
21
+ class << self
22
+ # Analyze Ruby source files and produce ExtractedUnit objects.
23
+ #
24
+ # @param paths [Array<String>] File paths or directories to analyze
25
+ # @param trace_data [Array<Hash>, nil] Optional runtime trace data for enrichment
26
+ # @return [Array<ExtractedUnit>] All extracted units
27
+ def analyze(paths:, trace_data: nil)
28
+ files = discover_files(paths)
29
+ return [] if files.empty?
30
+
31
+ parser = Ast::Parser.new
32
+ class_analyzer = ClassAnalyzer.new(parser: parser)
33
+ method_analyzer = MethodAnalyzer.new(parser: parser)
34
+ dataflow_analyzer = DataFlowAnalyzer.new(parser: parser)
35
+
36
+ units = []
37
+
38
+ files.each do |file_path|
39
+ source = read_file(file_path)
40
+ next unless source
41
+
42
+ units.concat(class_analyzer.analyze(source: source, file_path: file_path))
43
+ units.concat(method_analyzer.analyze(source: source, file_path: file_path))
44
+ rescue Woods::ExtractionError
45
+ # Skip files that fail to parse
46
+ next
47
+ end
48
+
49
+ dataflow_analyzer.annotate(units)
50
+ TraceEnricher.merge(units: units, trace_data: trace_data) if trace_data
51
+
52
+ units
53
+ end
54
+
55
+ private
56
+
57
+ # Discover .rb files from a list of paths (files and/or directories).
58
+ #
59
+ # @param paths [Array<String>] File paths or directory paths
60
+ # @return [Array<String>] Absolute paths to .rb files
61
+ def discover_files(paths)
62
+ files = []
63
+ paths.each do |path|
64
+ expanded = File.expand_path(path)
65
+ if File.directory?(expanded)
66
+ Dir.glob(File.join(expanded, '**', '*.rb')).each do |f|
67
+ files << f
68
+ end
69
+ elsif File.file?(expanded) && expanded.end_with?('.rb')
70
+ files << expanded
71
+ end
72
+ end
73
+ files.uniq
74
+ end
75
+
76
+ # Read a file safely, returning nil on failure.
77
+ #
78
+ # @param path [String] File path
79
+ # @return [String, nil] File contents or nil
80
+ def read_file(path)
81
+ File.read(path)
82
+ rescue StandardError
83
+ nil
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require_relative 'store'
6
+
7
+ module Woods
8
+ module SessionTracer
9
+ # File-backed session store using JSONL (one JSON object per line).
10
+ #
11
+ # Sessions are stored as individual files in a configurable directory:
12
+ # {base_dir}/{session_id}.jsonl
13
+ #
14
+ # Append-only with file locking for concurrency safety. Zero external dependencies.
15
+ #
16
+ # @example
17
+ # store = FileStore.new(base_dir: "tmp/woods/sessions")
18
+ # store.record("abc123", { controller: "PostsController", action: "create" })
19
+ # store.read("abc123") # => [{ "controller" => "PostsController", ... }]
20
+ #
21
+ class FileStore < Store
22
+ # @param base_dir [String] Directory for session JSONL files
23
+ def initialize(base_dir:)
24
+ super()
25
+ @base_dir = base_dir
26
+ FileUtils.mkdir_p(@base_dir)
27
+ end
28
+
29
+ # Append a request record to a session's JSONL file.
30
+ #
31
+ # Uses file locking (LOCK_EX) for concurrency safety.
32
+ #
33
+ # @param session_id [String] The session identifier
34
+ # @param request_data [Hash] Request metadata to store
35
+ # @return [void]
36
+ def record(session_id, request_data)
37
+ path = session_path(session_id)
38
+ line = "#{JSON.generate(request_data)}\n"
39
+
40
+ File.open(path, 'a') do |f|
41
+ f.flock(File::LOCK_EX)
42
+ f.write(line)
43
+ end
44
+ end
45
+
46
+ # Read all request records for a session, ordered by file line order (timestamp).
47
+ #
48
+ # @param session_id [String] The session identifier
49
+ # @return [Array<Hash>] Request records, oldest first
50
+ def read(session_id)
51
+ path = session_path(session_id)
52
+ return [] unless File.exist?(path)
53
+
54
+ File.readlines(path).filter_map do |line|
55
+ stripped = line.strip
56
+ next if stripped.empty?
57
+
58
+ JSON.parse(stripped)
59
+ rescue JSON::ParserError
60
+ nil
61
+ end
62
+ end
63
+
64
+ # List recent session summaries, sorted by last modification time (newest first).
65
+ #
66
+ # @param limit [Integer] Maximum number of sessions to return
67
+ # @return [Array<Hash>] Session summaries
68
+ def sessions(limit: 20)
69
+ pattern = File.join(@base_dir, '*.jsonl')
70
+ files = Dir.glob(pattern).sort_by { |f| -File.mtime(f).to_f }
71
+
72
+ files.first(limit).map do |file|
73
+ session_id = File.basename(file, '.jsonl')
74
+ session_summary(session_id, read(session_id))
75
+ end
76
+ end
77
+
78
+ # Remove all data for a single session.
79
+ #
80
+ # @param session_id [String] The session identifier
81
+ # @return [void]
82
+ def clear(session_id)
83
+ path = session_path(session_id)
84
+ FileUtils.rm_f(path)
85
+ end
86
+
87
+ # Remove all session data.
88
+ #
89
+ # @return [void]
90
+ def clear_all
91
+ pattern = File.join(@base_dir, '*.jsonl')
92
+ Dir.glob(pattern).each { |f| File.delete(f) }
93
+ end
94
+
95
+ private
96
+
97
+ # @param session_id [String]
98
+ # @return [String] Full path to the session's JSONL file
99
+ def session_path(session_id)
100
+ File.join(@base_dir, "#{sanitize_session_id(session_id)}.jsonl")
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Woods
6
+ module SessionTracer
7
+ # Rack middleware that captures request metadata for session tracing.
8
+ #
9
+ # Wraps `@app.call(env)`, records after response. Extracts controller/action
10
+ # from `env['action_dispatch.request.path_parameters']`. Session ID from
11
+ # `X-Trace-Session` header first, falls back to `request.session.id`.
12
+ #
13
+ # Fire-and-forget writes — `rescue StandardError` on recording, never breaks the request.
14
+ #
15
+ # @example Inserting into a Rails middleware stack
16
+ # app.middleware.insert_after ActionDispatch::Session::CookieStore,
17
+ # Woods::SessionTracer::Middleware
18
+ #
19
+ class Middleware
20
+ # @param app [#call] The downstream Rack application
21
+ # @param store [Store] Session trace store backend
22
+ # @param session_id_proc [Proc, nil] Custom session ID extraction (receives env)
23
+ # @param exclude_paths [Array<String>] Path prefixes to skip
24
+ def initialize(app, store:, session_id_proc: nil, exclude_paths: [])
25
+ @app = app
26
+ @store = store
27
+ @session_id_proc = session_id_proc
28
+ @exclude_paths = exclude_paths
29
+ end
30
+
31
+ # @param env [Hash] Rack environment
32
+ # @return [Array] Rack response triple [status, headers, body]
33
+ def call(env)
34
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
35
+ status, headers, body = @app.call(env)
36
+ duration_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - start_time
37
+
38
+ begin
39
+ record_request(env, status, duration_ms)
40
+ rescue StandardError
41
+ # Fire-and-forget — recording failures never break the request
42
+ end
43
+
44
+ [status, headers, body]
45
+ end
46
+
47
+ private
48
+
49
+ # Record the request metadata to the store.
50
+ #
51
+ # @param env [Hash] Rack environment
52
+ # @param status [Integer] HTTP response status
53
+ # @param duration_ms [Integer] Request duration in milliseconds
54
+ # rubocop:disable Metrics/MethodLength
55
+ def record_request(env, status, duration_ms)
56
+ path = env['PATH_INFO'] || ''
57
+ return if excluded?(path)
58
+
59
+ session_id = extract_session_id(env)
60
+ return unless session_id
61
+
62
+ path_params = env['action_dispatch.request.path_parameters'] || {}
63
+ controller = path_params[:controller]
64
+ action = path_params[:action]
65
+ return unless controller
66
+
67
+ # Classify controller name (e.g., "orders" -> "OrdersController")
68
+ controller_class = classify_controller(controller)
69
+
70
+ request_data = {
71
+ 'session_id' => session_id,
72
+ 'trace_tag' => env['HTTP_X_TRACE_SESSION'],
73
+ 'timestamp' => Time.now.utc.iso8601,
74
+ 'method' => env['REQUEST_METHOD'],
75
+ 'path' => path,
76
+ 'controller' => controller_class,
77
+ 'action' => action.to_s,
78
+ 'status' => status.to_i,
79
+ 'duration_ms' => duration_ms.to_i,
80
+ 'format' => extract_format(env)
81
+ }
82
+
83
+ @store.record(session_id, request_data)
84
+ end
85
+ # rubocop:enable Metrics/MethodLength
86
+
87
+ # Extract session ID: X-Trace-Session header first, then session cookie, then fallback.
88
+ #
89
+ # @param env [Hash] Rack environment
90
+ # @return [String, nil] Session identifier
91
+ def extract_session_id(env)
92
+ return @session_id_proc.call(env) if @session_id_proc
93
+
94
+ # 1. X-Trace-Session header (explicit trace tag doubles as session ID)
95
+ trace_header = env['HTTP_X_TRACE_SESSION']
96
+ return trace_header if trace_header && !trace_header.empty?
97
+
98
+ # 2. Rack session ID
99
+ session = env['rack.session']
100
+ session_id = session&.id || session&.dig('session_id')
101
+ return session_id.to_s if session_id
102
+
103
+ nil
104
+ end
105
+
106
+ # Check if the path should be excluded from tracing.
107
+ #
108
+ # @param path [String] Request path
109
+ # @return [Boolean]
110
+ def excluded?(path)
111
+ @exclude_paths.any? { |prefix| path.start_with?(prefix) }
112
+ end
113
+
114
+ # Classify a Rails controller path segment into a controller class name.
115
+ #
116
+ # @param controller [String] e.g., "orders" or "admin/orders"
117
+ # @return [String] e.g., "OrdersController" or "Admin::OrdersController"
118
+ def classify_controller(controller)
119
+ parts = controller.to_s
120
+ .split('/')
121
+ .map { |segment| segment.split('_').map(&:capitalize).join }
122
+ "#{parts.join('::')}Controller"
123
+ end
124
+
125
+ # Extract response format from the Rack env.
126
+ #
127
+ # @param env [Hash] Rack environment
128
+ # @return [String] Format string (e.g., "html", "json")
129
+ def extract_format(env)
130
+ # Check action_dispatch format first
131
+ path_params = env['action_dispatch.request.path_parameters'] || {}
132
+ return path_params[:format].to_s if path_params[:format]
133
+
134
+ # Infer from content type or Accept header
135
+ accept = env['HTTP_ACCEPT'] || ''
136
+ return 'json' if accept.include?('application/json')
137
+ return 'html' if accept.include?('text/html')
138
+
139
+ 'html'
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'store'
5
+
6
+ module Woods
7
+ module SessionTracer
8
+ # Redis-backed session store using Lists.
9
+ #
10
+ # Each session is stored as a Redis List keyed `woods:session:{id}`.
11
+ # RPUSH per request for append-only ordering. Native TTL for automatic cleanup.
12
+ #
13
+ # Requires the `redis` gem at runtime.
14
+ #
15
+ # @example
16
+ # store = RedisStore.new(redis: Redis.new, ttl: 3600)
17
+ # store.record("abc123", { controller: "OrdersController", action: "create" })
18
+ #
19
+ class RedisStore < Store
20
+ KEY_PREFIX = 'woods:session:'
21
+ SESSIONS_KEY = 'woods:sessions'
22
+
23
+ # @param redis [Redis] A Redis client instance
24
+ # @param ttl [Integer, nil] Time-to-live in seconds for session keys (nil = no expiry)
25
+ def initialize(redis:, ttl: nil)
26
+ super()
27
+ unless defined?(::Redis)
28
+ raise SessionTracerError, 'The redis gem is required for RedisStore. Add `gem "redis"` to your Gemfile.'
29
+ end
30
+
31
+ @redis = redis
32
+ @ttl = ttl
33
+ end
34
+
35
+ # Append a request record to a session's Redis List.
36
+ #
37
+ # @param session_id [String] The session identifier
38
+ # @param request_data [Hash] Request metadata to store
39
+ # @return [void]
40
+ def record(session_id, request_data)
41
+ key = session_key(session_id)
42
+ @redis.rpush(key, JSON.generate(request_data))
43
+ @redis.expire(key, @ttl) if @ttl
44
+ @redis.sadd(SESSIONS_KEY, session_id)
45
+ end
46
+
47
+ # Read all request records for a session.
48
+ #
49
+ # @param session_id [String] The session identifier
50
+ # @return [Array<Hash>] Request records, oldest first
51
+ def read(session_id)
52
+ key = session_key(session_id)
53
+ @redis.lrange(key, 0, -1).filter_map do |json|
54
+ JSON.parse(json)
55
+ rescue JSON::ParserError
56
+ nil
57
+ end
58
+ end
59
+
60
+ # List recent session summaries.
61
+ #
62
+ # @param limit [Integer] Maximum number of sessions to return
63
+ # @return [Array<Hash>] Session summaries
64
+ def sessions(limit: 20)
65
+ all_ids = @redis.smembers(SESSIONS_KEY)
66
+
67
+ # Filter to sessions that still have data (TTL may have expired)
68
+ active = all_ids.select { |id| @redis.exists?(session_key(id)) }
69
+
70
+ # Remove expired session IDs from the set
71
+ expired = all_ids - active
72
+ expired.each { |id| @redis.srem(SESSIONS_KEY, id) } if expired.any?
73
+
74
+ active.first(limit).map do |session_id|
75
+ session_summary(session_id, read(session_id))
76
+ end
77
+ end
78
+
79
+ # Remove all data for a single session.
80
+ #
81
+ # @param session_id [String] The session identifier
82
+ # @return [void]
83
+ def clear(session_id)
84
+ @redis.del(session_key(session_id))
85
+ @redis.srem(SESSIONS_KEY, session_id)
86
+ end
87
+
88
+ # Remove all session data.
89
+ #
90
+ # @return [void]
91
+ def clear_all
92
+ all_ids = @redis.smembers(SESSIONS_KEY)
93
+ all_ids.each { |id| @redis.del(session_key(id)) }
94
+ @redis.del(SESSIONS_KEY)
95
+ end
96
+
97
+ private
98
+
99
+ # @param session_id [String]
100
+ # @return [String] Redis key for this session
101
+ def session_key(session_id)
102
+ "#{KEY_PREFIX}#{sanitize_session_id(session_id)}"
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'set'
5
+ require_relative '../token_utils'
6
+ require_relative 'session_flow_document'
7
+
8
+ module Woods
9
+ module SessionTracer
10
+ # Assembles a context tree from captured session requests against the extracted index.
11
+ #
12
+ # Does NOT require Rails — reads from a store + on-disk extracted index.
13
+ #
14
+ # Algorithm:
15
+ # 1. Load requests from store for session_id
16
+ # 2. For each request, resolve "Controller#action" via IndexReader
17
+ # 3. Expand dependencies via DependencyGraph — filter :job/:mailer as async side effects
18
+ # 4. Deduplicate units across steps (include source once, reference by identifier)
19
+ # 5. Token budget allocation with priority-based truncation
20
+ # 6. Build SessionFlowDocument
21
+ #
22
+ # @example
23
+ # assembler = SessionFlowAssembler.new(store: store, reader: reader)
24
+ # doc = assembler.assemble("abc123", budget: 8000, depth: 1)
25
+ # puts doc.to_context
26
+ #
27
+ # rubocop:disable Metrics/ClassLength
28
+ class SessionFlowAssembler
29
+ ASYNC_TYPES = %w[job mailer].to_set.freeze
30
+
31
+ # @param store [Store] Session trace store
32
+ # @param reader [MCP::IndexReader] Index reader for unit lookups
33
+ def initialize(store:, reader:)
34
+ @store = store
35
+ @reader = reader
36
+ end
37
+
38
+ # Assemble a context tree for a session.
39
+ #
40
+ # @param session_id [String] The session to assemble
41
+ # @param budget [Integer] Maximum token budget (default: 8000)
42
+ # @param depth [Integer] Expansion depth (0=metadata only, 1=direct deps, 2+=full flow)
43
+ # @return [SessionFlowDocument] The assembled document
44
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
45
+ def assemble(session_id, budget: 8000, depth: 1)
46
+ requests = @store.read(session_id)
47
+ return empty_document(session_id) if requests.empty?
48
+
49
+ steps = []
50
+ context_pool = {}
51
+ side_effects = []
52
+ dependency_map = {}
53
+ seen_units = Set.new
54
+
55
+ requests.each_with_index do |req, idx|
56
+ step = build_step(req, idx)
57
+ steps << step
58
+
59
+ next if depth.zero?
60
+
61
+ controller_id = req['controller']
62
+ next unless controller_id
63
+
64
+ # Resolve controller unit
65
+ unit = @reader.find_unit(controller_id)
66
+ if unit && !seen_units.include?(controller_id)
67
+ seen_units.add(controller_id)
68
+ context_pool[controller_id] = unit_summary(unit)
69
+ end
70
+ step[:unit_refs] = [controller_id].compact
71
+
72
+ # Expand dependencies
73
+ next unless unit
74
+
75
+ deps = resolve_dependencies(controller_id, seen_units, context_pool,
76
+ side_effects, step, dependency_map, depth)
77
+ step[:unit_refs].concat(deps)
78
+ end
79
+
80
+ # Apply token budget
81
+ token_count = apply_budget(context_pool, budget)
82
+
83
+ SessionFlowDocument.new(
84
+ session_id: session_id,
85
+ steps: steps,
86
+ context_pool: context_pool,
87
+ side_effects: side_effects,
88
+ dependency_map: dependency_map,
89
+ token_count: token_count
90
+ )
91
+ end
92
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
93
+
94
+ private
95
+
96
+ # Build a timeline step from a request record.
97
+ #
98
+ # @param req [Hash] Request data from store
99
+ # @param index [Integer] Step index
100
+ # @return [Hash] Step hash
101
+ def build_step(req, index)
102
+ {
103
+ index: index,
104
+ method: req['method'],
105
+ path: req['path'],
106
+ controller: req['controller'],
107
+ action: req['action'],
108
+ status: req['status'],
109
+ duration_ms: req['duration_ms'],
110
+ unit_refs: [],
111
+ side_effects: []
112
+ }
113
+ end
114
+
115
+ # Resolve dependencies for a unit, separating sync deps from async side effects.
116
+ #
117
+ # @return [Array<String>] Non-async dependency identifiers added
118
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
119
+ def resolve_dependencies(unit_id, seen_units, context_pool,
120
+ side_effects, step, dependency_map, depth)
121
+ graph = @reader.dependency_graph
122
+ dep_ids = graph.dependencies_of(unit_id)
123
+ added = []
124
+
125
+ dep_ids.each do |dep_id|
126
+ dep_unit = @reader.find_unit(dep_id)
127
+ next unless dep_unit
128
+
129
+ dep_type = dep_unit['type']&.to_s
130
+
131
+ if ASYNC_TYPES.include?(dep_type)
132
+ effect = {
133
+ type: dep_type.to_sym,
134
+ identifier: dep_id,
135
+ trigger_step: "#{step[:controller]}##{step[:action]}"
136
+ }
137
+ side_effects << effect
138
+ step[:side_effects] << effect
139
+ else
140
+ unless seen_units.include?(dep_id)
141
+ seen_units.add(dep_id)
142
+ context_pool[dep_id] = unit_summary(dep_unit)
143
+ added << dep_id
144
+
145
+ # Depth 2+: expand transitive dependencies
146
+ expand_transitive(dep_id, seen_units, context_pool, dependency_map, depth - 1) if depth >= 2
147
+ end
148
+ end
149
+ end
150
+
151
+ # Record dependency map for this unit
152
+ all_deps = dep_ids.select { |id| @reader.find_unit(id) }
153
+ dependency_map[unit_id] = all_deps if all_deps.any?
154
+
155
+ added
156
+ end
157
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
158
+
159
+ # Expand transitive dependencies (depth 2+).
160
+ #
161
+ # @param unit_id [String] Unit to expand from
162
+ # @param seen_units [Set<String>] Already-seen unit identifiers
163
+ # @param context_pool [Hash] Accumulator for unit data
164
+ # @param dependency_map [Hash] Accumulator for dependency edges
165
+ # @param remaining_depth [Integer] Remaining expansion depth
166
+ def expand_transitive(unit_id, seen_units, context_pool, dependency_map, remaining_depth)
167
+ return if remaining_depth <= 0
168
+
169
+ graph = @reader.dependency_graph
170
+ dep_ids = graph.dependencies_of(unit_id)
171
+ resolved_deps = []
172
+
173
+ dep_ids.each do |dep_id|
174
+ dep_unit = @reader.find_unit(dep_id)
175
+ next unless dep_unit
176
+
177
+ resolved_deps << dep_id
178
+ next if seen_units.include?(dep_id)
179
+
180
+ seen_units.add(dep_id)
181
+ context_pool[dep_id] = unit_summary(dep_unit)
182
+
183
+ expand_transitive(dep_id, seen_units, context_pool, dependency_map, remaining_depth - 1)
184
+ end
185
+
186
+ dependency_map[unit_id] = resolved_deps if resolved_deps.any?
187
+ end
188
+
189
+ # Extract a summary hash from a full unit data hash.
190
+ #
191
+ # @param unit [Hash] Full unit data from IndexReader
192
+ # @return [Hash] Summary with :type, :file_path, :source_code
193
+ def unit_summary(unit)
194
+ {
195
+ type: unit['type'],
196
+ file_path: unit['file_path'],
197
+ source_code: unit['source_code']
198
+ }
199
+ end
200
+
201
+ # Apply token budget by truncating source code from lowest-priority units.
202
+ #
203
+ # Priority order (highest first):
204
+ # 1. Controller action chunks (directly hit by requests)
205
+ # 2. Direct dependencies (models, services)
206
+ # 3. Transitive dependencies
207
+ #
208
+ # @param context_pool [Hash] Unit data to budget
209
+ # @param budget [Integer] Maximum tokens
210
+ # @return [Integer] Actual token count
211
+ def apply_budget(context_pool, budget)
212
+ total = estimate_tokens(context_pool)
213
+ return total if total <= budget
214
+
215
+ # Truncate from the end (lowest priority = last added)
216
+ identifiers = context_pool.keys.reverse
217
+ identifiers.each do |id|
218
+ break if total <= budget
219
+
220
+ unit = context_pool[id]
221
+ source = unit[:source_code]
222
+ next unless source
223
+
224
+ source_tokens = TokenUtils.estimate_tokens(source)
225
+ unit[:source_code] = "# source truncated (#{source_tokens} tokens)"
226
+ total -= source_tokens
227
+ total += TokenUtils.estimate_tokens(unit[:source_code])
228
+ end
229
+
230
+ [total, 0].max
231
+ end
232
+
233
+ # Estimate total tokens for the context pool.
234
+ #
235
+ # @param context_pool [Hash] Unit data
236
+ # @return [Integer] Estimated token count
237
+ def estimate_tokens(context_pool)
238
+ context_pool.values.sum do |unit|
239
+ source = unit[:source_code] || ''
240
+ TokenUtils.estimate_tokens(source) + 20 # overhead for tags/metadata
241
+ end
242
+ end
243
+
244
+ # Build an empty document for sessions with no requests.
245
+ #
246
+ # @param session_id [String]
247
+ # @return [SessionFlowDocument]
248
+ def empty_document(session_id)
249
+ SessionFlowDocument.new(session_id: session_id)
250
+ end
251
+ end
252
+ # rubocop:enable Metrics/ClassLength
253
+ end
254
+ end