codebase_index 0.1.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 (171) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -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 +481 -0
  7. data/exe/codebase-console-mcp +22 -0
  8. data/exe/codebase-index-mcp +61 -0
  9. data/exe/codebase-index-mcp-http +64 -0
  10. data/exe/codebase-index-mcp-start +58 -0
  11. data/lib/codebase_index/ast/call_site_extractor.rb +106 -0
  12. data/lib/codebase_index/ast/method_extractor.rb +76 -0
  13. data/lib/codebase_index/ast/node.rb +88 -0
  14. data/lib/codebase_index/ast/parser.rb +653 -0
  15. data/lib/codebase_index/ast.rb +6 -0
  16. data/lib/codebase_index/builder.rb +137 -0
  17. data/lib/codebase_index/chunking/chunk.rb +84 -0
  18. data/lib/codebase_index/chunking/semantic_chunker.rb +290 -0
  19. data/lib/codebase_index/console/adapters/cache_adapter.rb +58 -0
  20. data/lib/codebase_index/console/adapters/good_job_adapter.rb +66 -0
  21. data/lib/codebase_index/console/adapters/sidekiq_adapter.rb +66 -0
  22. data/lib/codebase_index/console/adapters/solid_queue_adapter.rb +66 -0
  23. data/lib/codebase_index/console/audit_logger.rb +75 -0
  24. data/lib/codebase_index/console/bridge.rb +170 -0
  25. data/lib/codebase_index/console/confirmation.rb +90 -0
  26. data/lib/codebase_index/console/connection_manager.rb +173 -0
  27. data/lib/codebase_index/console/console_response_renderer.rb +78 -0
  28. data/lib/codebase_index/console/model_validator.rb +81 -0
  29. data/lib/codebase_index/console/safe_context.rb +82 -0
  30. data/lib/codebase_index/console/server.rb +557 -0
  31. data/lib/codebase_index/console/sql_validator.rb +172 -0
  32. data/lib/codebase_index/console/tools/tier1.rb +118 -0
  33. data/lib/codebase_index/console/tools/tier2.rb +117 -0
  34. data/lib/codebase_index/console/tools/tier3.rb +110 -0
  35. data/lib/codebase_index/console/tools/tier4.rb +79 -0
  36. data/lib/codebase_index/coordination/pipeline_lock.rb +109 -0
  37. data/lib/codebase_index/cost_model/embedding_cost.rb +88 -0
  38. data/lib/codebase_index/cost_model/estimator.rb +128 -0
  39. data/lib/codebase_index/cost_model/provider_pricing.rb +67 -0
  40. data/lib/codebase_index/cost_model/storage_cost.rb +52 -0
  41. data/lib/codebase_index/cost_model.rb +22 -0
  42. data/lib/codebase_index/db/migrations/001_create_units.rb +38 -0
  43. data/lib/codebase_index/db/migrations/002_create_edges.rb +35 -0
  44. data/lib/codebase_index/db/migrations/003_create_embeddings.rb +37 -0
  45. data/lib/codebase_index/db/migrations/004_create_snapshots.rb +45 -0
  46. data/lib/codebase_index/db/migrations/005_create_snapshot_units.rb +40 -0
  47. data/lib/codebase_index/db/migrator.rb +71 -0
  48. data/lib/codebase_index/db/schema_version.rb +73 -0
  49. data/lib/codebase_index/dependency_graph.rb +227 -0
  50. data/lib/codebase_index/embedding/indexer.rb +130 -0
  51. data/lib/codebase_index/embedding/openai.rb +105 -0
  52. data/lib/codebase_index/embedding/provider.rb +135 -0
  53. data/lib/codebase_index/embedding/text_preparer.rb +112 -0
  54. data/lib/codebase_index/evaluation/baseline_runner.rb +115 -0
  55. data/lib/codebase_index/evaluation/evaluator.rb +146 -0
  56. data/lib/codebase_index/evaluation/metrics.rb +79 -0
  57. data/lib/codebase_index/evaluation/query_set.rb +148 -0
  58. data/lib/codebase_index/evaluation/report_generator.rb +90 -0
  59. data/lib/codebase_index/extracted_unit.rb +145 -0
  60. data/lib/codebase_index/extractor.rb +956 -0
  61. data/lib/codebase_index/extractors/action_cable_extractor.rb +228 -0
  62. data/lib/codebase_index/extractors/ast_source_extraction.rb +46 -0
  63. data/lib/codebase_index/extractors/behavioral_profile.rb +309 -0
  64. data/lib/codebase_index/extractors/caching_extractor.rb +261 -0
  65. data/lib/codebase_index/extractors/callback_analyzer.rb +232 -0
  66. data/lib/codebase_index/extractors/concern_extractor.rb +253 -0
  67. data/lib/codebase_index/extractors/configuration_extractor.rb +219 -0
  68. data/lib/codebase_index/extractors/controller_extractor.rb +494 -0
  69. data/lib/codebase_index/extractors/database_view_extractor.rb +278 -0
  70. data/lib/codebase_index/extractors/decorator_extractor.rb +260 -0
  71. data/lib/codebase_index/extractors/engine_extractor.rb +204 -0
  72. data/lib/codebase_index/extractors/event_extractor.rb +211 -0
  73. data/lib/codebase_index/extractors/factory_extractor.rb +289 -0
  74. data/lib/codebase_index/extractors/graphql_extractor.rb +917 -0
  75. data/lib/codebase_index/extractors/i18n_extractor.rb +117 -0
  76. data/lib/codebase_index/extractors/job_extractor.rb +369 -0
  77. data/lib/codebase_index/extractors/lib_extractor.rb +249 -0
  78. data/lib/codebase_index/extractors/mailer_extractor.rb +339 -0
  79. data/lib/codebase_index/extractors/manager_extractor.rb +202 -0
  80. data/lib/codebase_index/extractors/middleware_extractor.rb +133 -0
  81. data/lib/codebase_index/extractors/migration_extractor.rb +469 -0
  82. data/lib/codebase_index/extractors/model_extractor.rb +960 -0
  83. data/lib/codebase_index/extractors/phlex_extractor.rb +252 -0
  84. data/lib/codebase_index/extractors/policy_extractor.rb +214 -0
  85. data/lib/codebase_index/extractors/poro_extractor.rb +246 -0
  86. data/lib/codebase_index/extractors/pundit_extractor.rb +223 -0
  87. data/lib/codebase_index/extractors/rails_source_extractor.rb +473 -0
  88. data/lib/codebase_index/extractors/rake_task_extractor.rb +343 -0
  89. data/lib/codebase_index/extractors/route_extractor.rb +181 -0
  90. data/lib/codebase_index/extractors/scheduled_job_extractor.rb +331 -0
  91. data/lib/codebase_index/extractors/serializer_extractor.rb +334 -0
  92. data/lib/codebase_index/extractors/service_extractor.rb +254 -0
  93. data/lib/codebase_index/extractors/shared_dependency_scanner.rb +91 -0
  94. data/lib/codebase_index/extractors/shared_utility_methods.rb +99 -0
  95. data/lib/codebase_index/extractors/state_machine_extractor.rb +398 -0
  96. data/lib/codebase_index/extractors/test_mapping_extractor.rb +225 -0
  97. data/lib/codebase_index/extractors/validator_extractor.rb +225 -0
  98. data/lib/codebase_index/extractors/view_component_extractor.rb +310 -0
  99. data/lib/codebase_index/extractors/view_template_extractor.rb +261 -0
  100. data/lib/codebase_index/feedback/gap_detector.rb +89 -0
  101. data/lib/codebase_index/feedback/store.rb +119 -0
  102. data/lib/codebase_index/flow_analysis/operation_extractor.rb +209 -0
  103. data/lib/codebase_index/flow_analysis/response_code_mapper.rb +154 -0
  104. data/lib/codebase_index/flow_assembler.rb +290 -0
  105. data/lib/codebase_index/flow_document.rb +191 -0
  106. data/lib/codebase_index/flow_precomputer.rb +102 -0
  107. data/lib/codebase_index/formatting/base.rb +40 -0
  108. data/lib/codebase_index/formatting/claude_adapter.rb +98 -0
  109. data/lib/codebase_index/formatting/generic_adapter.rb +56 -0
  110. data/lib/codebase_index/formatting/gpt_adapter.rb +64 -0
  111. data/lib/codebase_index/formatting/human_adapter.rb +78 -0
  112. data/lib/codebase_index/graph_analyzer.rb +374 -0
  113. data/lib/codebase_index/mcp/index_reader.rb +394 -0
  114. data/lib/codebase_index/mcp/renderers/claude_renderer.rb +81 -0
  115. data/lib/codebase_index/mcp/renderers/json_renderer.rb +17 -0
  116. data/lib/codebase_index/mcp/renderers/markdown_renderer.rb +352 -0
  117. data/lib/codebase_index/mcp/renderers/plain_renderer.rb +240 -0
  118. data/lib/codebase_index/mcp/server.rb +935 -0
  119. data/lib/codebase_index/mcp/tool_response_renderer.rb +62 -0
  120. data/lib/codebase_index/model_name_cache.rb +51 -0
  121. data/lib/codebase_index/notion/client.rb +217 -0
  122. data/lib/codebase_index/notion/exporter.rb +219 -0
  123. data/lib/codebase_index/notion/mapper.rb +39 -0
  124. data/lib/codebase_index/notion/mappers/column_mapper.rb +65 -0
  125. data/lib/codebase_index/notion/mappers/migration_mapper.rb +39 -0
  126. data/lib/codebase_index/notion/mappers/model_mapper.rb +164 -0
  127. data/lib/codebase_index/notion/rate_limiter.rb +68 -0
  128. data/lib/codebase_index/observability/health_check.rb +81 -0
  129. data/lib/codebase_index/observability/instrumentation.rb +34 -0
  130. data/lib/codebase_index/observability/structured_logger.rb +75 -0
  131. data/lib/codebase_index/operator/error_escalator.rb +81 -0
  132. data/lib/codebase_index/operator/pipeline_guard.rb +99 -0
  133. data/lib/codebase_index/operator/status_reporter.rb +80 -0
  134. data/lib/codebase_index/railtie.rb +26 -0
  135. data/lib/codebase_index/resilience/circuit_breaker.rb +99 -0
  136. data/lib/codebase_index/resilience/index_validator.rb +185 -0
  137. data/lib/codebase_index/resilience/retryable_provider.rb +108 -0
  138. data/lib/codebase_index/retrieval/context_assembler.rb +249 -0
  139. data/lib/codebase_index/retrieval/query_classifier.rb +131 -0
  140. data/lib/codebase_index/retrieval/ranker.rb +273 -0
  141. data/lib/codebase_index/retrieval/search_executor.rb +327 -0
  142. data/lib/codebase_index/retriever.rb +160 -0
  143. data/lib/codebase_index/ruby_analyzer/class_analyzer.rb +190 -0
  144. data/lib/codebase_index/ruby_analyzer/dataflow_analyzer.rb +78 -0
  145. data/lib/codebase_index/ruby_analyzer/fqn_builder.rb +18 -0
  146. data/lib/codebase_index/ruby_analyzer/mermaid_renderer.rb +275 -0
  147. data/lib/codebase_index/ruby_analyzer/method_analyzer.rb +143 -0
  148. data/lib/codebase_index/ruby_analyzer/trace_enricher.rb +139 -0
  149. data/lib/codebase_index/ruby_analyzer.rb +87 -0
  150. data/lib/codebase_index/session_tracer/file_store.rb +111 -0
  151. data/lib/codebase_index/session_tracer/middleware.rb +143 -0
  152. data/lib/codebase_index/session_tracer/redis_store.rb +112 -0
  153. data/lib/codebase_index/session_tracer/session_flow_assembler.rb +263 -0
  154. data/lib/codebase_index/session_tracer/session_flow_document.rb +223 -0
  155. data/lib/codebase_index/session_tracer/solid_cache_store.rb +145 -0
  156. data/lib/codebase_index/session_tracer/store.rb +67 -0
  157. data/lib/codebase_index/storage/graph_store.rb +120 -0
  158. data/lib/codebase_index/storage/metadata_store.rb +169 -0
  159. data/lib/codebase_index/storage/pgvector.rb +163 -0
  160. data/lib/codebase_index/storage/qdrant.rb +172 -0
  161. data/lib/codebase_index/storage/vector_store.rb +156 -0
  162. data/lib/codebase_index/temporal/snapshot_store.rb +341 -0
  163. data/lib/codebase_index/version.rb +5 -0
  164. data/lib/codebase_index.rb +223 -0
  165. data/lib/generators/codebase_index/install_generator.rb +32 -0
  166. data/lib/generators/codebase_index/pgvector_generator.rb +37 -0
  167. data/lib/generators/codebase_index/templates/add_pgvector_to_codebase_index.rb.erb +15 -0
  168. data/lib/generators/codebase_index/templates/create_codebase_index_tables.rb.erb +43 -0
  169. data/lib/tasks/codebase_index.rake +583 -0
  170. data/lib/tasks/codebase_index_evaluation.rake +115 -0
  171. metadata +252 -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 CodebaseIndex
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 = CodebaseIndex::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 CodebaseIndex::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,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require_relative 'store'
6
+
7
+ module CodebaseIndex
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/codebase_index/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
+ requests = read(session_id)
75
+
76
+ {
77
+ 'session_id' => session_id,
78
+ 'request_count' => requests.size,
79
+ 'first_request' => requests.first&.fetch('timestamp', nil),
80
+ 'last_request' => requests.last&.fetch('timestamp', nil)
81
+ }
82
+ end
83
+ end
84
+
85
+ # Remove all data for a single session.
86
+ #
87
+ # @param session_id [String] The session identifier
88
+ # @return [void]
89
+ def clear(session_id)
90
+ path = session_path(session_id)
91
+ FileUtils.rm_f(path)
92
+ end
93
+
94
+ # Remove all session data.
95
+ #
96
+ # @return [void]
97
+ def clear_all
98
+ pattern = File.join(@base_dir, '*.jsonl')
99
+ Dir.glob(pattern).each { |f| File.delete(f) }
100
+ end
101
+
102
+ private
103
+
104
+ # @param session_id [String]
105
+ # @return [String] Full path to the session's JSONL file
106
+ def session_path(session_id)
107
+ File.join(@base_dir, "#{sanitize_session_id(session_id)}.jsonl")
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module CodebaseIndex
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
+ # CodebaseIndex::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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'store'
5
+
6
+ module CodebaseIndex
7
+ module SessionTracer
8
+ # Redis-backed session store using Lists.
9
+ #
10
+ # Each session is stored as a Redis List keyed `codebase_index: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 = 'codebase_index:session:'
21
+ SESSIONS_KEY = 'codebase_index: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
+ requests = read(session_id)
76
+ {
77
+ 'session_id' => session_id,
78
+ 'request_count' => requests.size,
79
+ 'first_request' => requests.first&.fetch('timestamp', nil),
80
+ 'last_request' => requests.last&.fetch('timestamp', nil)
81
+ }
82
+ end
83
+ end
84
+
85
+ # Remove all data for a single session.
86
+ #
87
+ # @param session_id [String] The session identifier
88
+ # @return [void]
89
+ def clear(session_id)
90
+ @redis.del(session_key(session_id))
91
+ @redis.srem(SESSIONS_KEY, session_id)
92
+ end
93
+
94
+ # Remove all session data.
95
+ #
96
+ # @return [void]
97
+ def clear_all
98
+ all_ids = @redis.smembers(SESSIONS_KEY)
99
+ all_ids.each { |id| @redis.del(session_key(id)) }
100
+ @redis.del(SESSIONS_KEY)
101
+ end
102
+
103
+ private
104
+
105
+ # @param session_id [String]
106
+ # @return [String] Redis key for this session
107
+ def session_key(session_id)
108
+ "#{KEY_PREFIX}#{sanitize_session_id(session_id)}"
109
+ end
110
+ end
111
+ end
112
+ end