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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'cache_store'
5
+
6
+ module Woods
7
+ module Cache
8
+ # Redis-backed cache store using GET/SET with TTL.
9
+ #
10
+ # Uses simple key-value operations (not Lists like SessionTracer::RedisStore).
11
+ # Values are JSON-serialized on write and deserialized on read. TTL is
12
+ # enforced natively by Redis via the EX option on SET.
13
+ #
14
+ # Requires the `redis` gem at runtime.
15
+ #
16
+ # @example
17
+ # store = RedisCacheStore.new(redis: Redis.new, default_ttl: 3600)
18
+ # store.write("ci:emb:abc", [0.1, 0.2], ttl: 86_400)
19
+ # store.read("ci:emb:abc") # => [0.1, 0.2]
20
+ #
21
+ class RedisCacheStore < CacheStore
22
+ # @param redis [Redis] A Redis client instance
23
+ # @param default_ttl [Integer, nil] Default TTL in seconds when none specified (nil = no expiry)
24
+ # @raise [ConfigurationError] if the redis gem is not loaded
25
+ def initialize(redis:, default_ttl: nil)
26
+ super()
27
+ unless defined?(::Redis)
28
+ raise ConfigurationError,
29
+ 'The redis gem is required for RedisCacheStore. Add `gem "redis"` to your Gemfile.'
30
+ end
31
+
32
+ @redis = redis
33
+ @default_ttl = default_ttl
34
+ end
35
+
36
+ # Read a value from Redis.
37
+ #
38
+ # @param key [String] Cache key
39
+ # @return [Object, nil] Deserialized value or nil
40
+ def read(key)
41
+ raw = @redis.get(key)
42
+ return nil unless raw
43
+
44
+ JSON.parse(raw)
45
+ rescue JSON::ParserError
46
+ delete_silently(key)
47
+ nil
48
+ rescue ::Redis::BaseError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
49
+ logger.warn("[Woods] RedisCacheStore#read failed for #{key}: #{e.message}")
50
+ nil
51
+ end
52
+
53
+ # Write a value to Redis with optional TTL.
54
+ #
55
+ # @param key [String] Cache key
56
+ # @param value [Object] Value to cache (must be JSON-serializable)
57
+ # @param ttl [Integer, nil] TTL in seconds (falls back to default_ttl)
58
+ # @return [void]
59
+ def write(key, value, ttl: nil)
60
+ serialized = JSON.generate(value)
61
+ effective_ttl = ttl || @default_ttl
62
+
63
+ if effective_ttl
64
+ @redis.set(key, serialized, ex: effective_ttl)
65
+ else
66
+ @redis.set(key, serialized)
67
+ end
68
+ rescue ::Redis::BaseError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
69
+ logger.warn("[Woods] RedisCacheStore#write failed for #{key}: #{e.message}")
70
+ nil
71
+ end
72
+
73
+ # Delete a key from Redis.
74
+ #
75
+ # @param key [String] Cache key
76
+ # @return [void]
77
+ def delete(key)
78
+ @redis.del(key)
79
+ rescue ::Redis::BaseError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
80
+ logger.warn("[Woods] RedisCacheStore#delete failed for #{key}: #{e.message}")
81
+ nil
82
+ end
83
+
84
+ # Check if a key exists in Redis.
85
+ #
86
+ # @param key [String] Cache key
87
+ # @return [Boolean]
88
+ def exist?(key)
89
+ @redis.exists?(key)
90
+ rescue ::Redis::BaseError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
91
+ logger.warn("[Woods] RedisCacheStore#exist? failed for #{key}: #{e.message}")
92
+ false
93
+ end
94
+
95
+ # Clear cached entries by namespace or all woods cache keys.
96
+ #
97
+ # Uses SCAN (not KEYS) to avoid blocking Redis on large keyspaces.
98
+ #
99
+ # @param namespace [Symbol, nil] Domain to clear, or nil for all cache keys
100
+ # @return [void]
101
+ def clear(namespace: nil)
102
+ pattern = clear_pattern(namespace)
103
+
104
+ cursor = '0'
105
+ loop do
106
+ cursor, keys = @redis.scan(cursor, match: pattern, count: 100)
107
+ @redis.del(*keys) if keys.any?
108
+ break if cursor == '0'
109
+ end
110
+ rescue ::Redis::BaseError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
111
+ logger.warn("[Woods] RedisCacheStore#clear failed: #{e.message}")
112
+ nil
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'cache_store'
5
+
6
+ module Woods
7
+ module Cache
8
+ # SolidCache-backed (or any ActiveSupport::Cache::Store) cache store.
9
+ #
10
+ # Delegates to a Rails-compatible cache backend. Values are JSON-serialized
11
+ # to avoid Marshal dependency issues across Ruby versions. TTL is passed
12
+ # as `expires_in:` to the underlying cache.
13
+ #
14
+ # @example With SolidCache
15
+ # store = SolidCacheStore.new(cache: SolidCache::Store.new, default_ttl: 3600)
16
+ # store.write("ci:emb:abc", [0.1, 0.2], ttl: 86_400)
17
+ # store.read("ci:emb:abc") # => [0.1, 0.2]
18
+ #
19
+ # @example With Rails.cache (any backend)
20
+ # store = SolidCacheStore.new(cache: Rails.cache)
21
+ #
22
+ class SolidCacheStore < CacheStore
23
+ # @param cache [ActiveSupport::Cache::Store] A SolidCache or compatible cache instance
24
+ # @param default_ttl [Integer, nil] Default TTL in seconds (nil = no expiry)
25
+ def initialize(cache:, default_ttl: nil)
26
+ super()
27
+ @cache = cache
28
+ @default_ttl = default_ttl
29
+ end
30
+
31
+ # Read a value from the cache.
32
+ #
33
+ # @param key [String] Cache key
34
+ # @return [Object, nil] Deserialized value or nil
35
+ def read(key)
36
+ raw = @cache.read(key)
37
+ return nil unless raw
38
+
39
+ JSON.parse(raw)
40
+ rescue JSON::ParserError
41
+ delete_silently(key)
42
+ nil
43
+ rescue StandardError => e
44
+ logger.warn("[Woods] SolidCacheStore#read failed for #{key}: #{e.message}")
45
+ nil
46
+ end
47
+
48
+ # Write a value with optional TTL.
49
+ #
50
+ # @param key [String] Cache key
51
+ # @param value [Object] Value to cache (must be JSON-serializable)
52
+ # @param ttl [Integer, nil] TTL in seconds (falls back to default_ttl)
53
+ # @return [void]
54
+ def write(key, value, ttl: nil)
55
+ serialized = JSON.generate(value)
56
+ effective_ttl = ttl || @default_ttl
57
+
58
+ opts = effective_ttl ? { expires_in: effective_ttl } : {}
59
+ @cache.write(key, serialized, **opts)
60
+ rescue StandardError => e
61
+ logger.warn("[Woods] SolidCacheStore#write failed for #{key}: #{e.message}")
62
+ nil
63
+ end
64
+
65
+ # Delete a key from the cache.
66
+ #
67
+ # @param key [String] Cache key
68
+ # @return [void]
69
+ def delete(key)
70
+ @cache.delete(key)
71
+ rescue StandardError => e
72
+ logger.warn("[Woods] SolidCacheStore#delete failed for #{key}: #{e.message}")
73
+ nil
74
+ end
75
+
76
+ # Check if a key exists in the cache.
77
+ #
78
+ # @param key [String] Cache key
79
+ # @return [Boolean]
80
+ def exist?(key)
81
+ @cache.exist?(key)
82
+ rescue StandardError => e
83
+ logger.warn("[Woods] SolidCacheStore#exist? failed for #{key}: #{e.message}")
84
+ false
85
+ end
86
+
87
+ # Clear cached entries by namespace or all woods cache keys.
88
+ #
89
+ # Uses `delete_matched` if the underlying cache supports it (Redis, Memcached).
90
+ # Falls back to a no-op if pattern deletion is not available (some backends
91
+ # like SolidCache don't support wildcard deletion).
92
+ #
93
+ # @param namespace [Symbol, nil] Domain to clear, or nil for all cache keys
94
+ # @return [void]
95
+ def clear(namespace: nil)
96
+ pattern = clear_pattern(namespace)
97
+
98
+ unless @cache.respond_to?(:delete_matched)
99
+ logger.warn("[Woods] Cache#clear(namespace: #{namespace.inspect}) is a no-op: " \
100
+ "backend #{@cache.class} does not support delete_matched")
101
+ return
102
+ end
103
+
104
+ @cache.delete_matched(pattern)
105
+ rescue StandardError => e
106
+ logger.warn("[Woods] SolidCacheStore#clear failed: #{e.message}")
107
+ nil
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Woods
6
+ module Chunking
7
+ # A single semantic chunk extracted from an ExtractedUnit.
8
+ #
9
+ # Chunks represent meaningful subsections of a code unit — associations,
10
+ # callbacks, validations, individual actions, etc. Each chunk is independently
11
+ # embeddable and retrievable, with a back-reference to its parent unit.
12
+ #
13
+ # @example
14
+ # chunk = Chunk.new(
15
+ # content: "has_many :posts\nhas_many :comments",
16
+ # chunk_type: :associations,
17
+ # parent_identifier: "User",
18
+ # parent_type: :model
19
+ # )
20
+ # chunk.token_count # => 20
21
+ # chunk.identifier # => "User#associations"
22
+ #
23
+ class Chunk
24
+ attr_reader :content, :chunk_type, :parent_identifier, :parent_type, :metadata
25
+
26
+ # @param content [String] The chunk's source code or text
27
+ # @param chunk_type [Symbol] Semantic type (:summary, :associations, :callbacks, etc.)
28
+ # @param parent_identifier [String] Identifier of the parent ExtractedUnit
29
+ # @param parent_type [Symbol] Type of the parent unit (:model, :controller, etc.)
30
+ # @param metadata [Hash] Optional chunk-specific metadata
31
+ def initialize(content:, chunk_type:, parent_identifier:, parent_type:, metadata: {})
32
+ @content = content
33
+ @chunk_type = chunk_type
34
+ @parent_identifier = parent_identifier
35
+ @parent_type = parent_type
36
+ @metadata = metadata
37
+ end
38
+
39
+ # Estimated token count using project convention.
40
+ #
41
+ # @return [Integer]
42
+ def token_count
43
+ @token_count ||= (content.length / 4.0).ceil
44
+ end
45
+
46
+ # SHA256 hash of content for change detection.
47
+ #
48
+ # @return [String]
49
+ def content_hash
50
+ @content_hash ||= Digest::SHA256.hexdigest(content)
51
+ end
52
+
53
+ # Unique identifier combining parent and chunk type.
54
+ #
55
+ # @return [String]
56
+ def identifier
57
+ "#{parent_identifier}##{chunk_type}"
58
+ end
59
+
60
+ # Whether the chunk has no meaningful content.
61
+ #
62
+ # @return [Boolean]
63
+ def empty?
64
+ content.nil? || content.strip.empty?
65
+ end
66
+
67
+ # Serialize to hash for JSON output.
68
+ #
69
+ # @return [Hash]
70
+ def to_h
71
+ {
72
+ content: content,
73
+ chunk_type: chunk_type,
74
+ parent_identifier: parent_identifier,
75
+ parent_type: parent_type,
76
+ identifier: identifier,
77
+ token_count: token_count,
78
+ content_hash: content_hash,
79
+ metadata: metadata
80
+ }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'chunk'
4
+
5
+ module Woods
6
+ module Chunking
7
+ # Shared method-detection patterns used by ModelChunker and ControllerChunker.
8
+ METHOD_PATTERN = /^\s*def\s+/
9
+ PRIVATE_PATTERN = /^\s*(private|protected)\s*$/
10
+
11
+ # Mixin that provides the shared build_chunk helper for chunker classes.
12
+ #
13
+ # Requires the including class to have an @unit ivar (ExtractedUnit).
14
+ module ChunkBuilder
15
+ private
16
+
17
+ # Build a Chunk for a given section.
18
+ #
19
+ # @param chunk_type [Symbol]
20
+ # @param content [String]
21
+ # @return [Chunk]
22
+ def build_chunk(chunk_type, content)
23
+ Chunk.new(
24
+ content: content,
25
+ chunk_type: chunk_type,
26
+ parent_identifier: @unit.identifier,
27
+ parent_type: @unit.type
28
+ )
29
+ end
30
+ end
31
+
32
+ # Splits ExtractedUnits into semantic chunks based on unit type.
33
+ #
34
+ # Models are split by: summary, associations, validations, callbacks,
35
+ # scopes, methods. Controllers are split by: summary (filters), per-action.
36
+ # Other types use whole-unit or method-level splitting based on size.
37
+ #
38
+ # Units below the token threshold are returned as a single :whole chunk.
39
+ #
40
+ # @example
41
+ # chunker = SemanticChunker.new(threshold: 200)
42
+ # chunks = chunker.chunk(extracted_unit)
43
+ # chunks.map(&:chunk_type) # => [:summary, :associations, :validations, :methods]
44
+ #
45
+ class SemanticChunker
46
+ # Default token threshold below which units stay whole.
47
+ DEFAULT_THRESHOLD = 200
48
+
49
+ # @param threshold [Integer] Token count threshold for chunking
50
+ def initialize(threshold: DEFAULT_THRESHOLD)
51
+ @threshold = threshold
52
+ end
53
+
54
+ # Split an ExtractedUnit into semantic chunks.
55
+ #
56
+ # @param unit [ExtractedUnit] The unit to chunk
57
+ # @return [Array<Chunk>] Ordered list of chunks
58
+ def chunk(unit)
59
+ return [] if unit.source_code.nil? || unit.source_code.strip.empty?
60
+ return [build_whole_chunk(unit)] if unit.estimated_tokens <= @threshold
61
+
62
+ case unit.type
63
+ when :model then ModelChunker.new(unit).chunk
64
+ when :controller then ControllerChunker.new(unit).chunk
65
+ else [build_whole_chunk(unit)]
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ # Build a single :whole chunk for small units.
72
+ #
73
+ # @param unit [ExtractedUnit]
74
+ # @return [Chunk]
75
+ def build_whole_chunk(unit)
76
+ Chunk.new(
77
+ content: unit.source_code,
78
+ chunk_type: :whole,
79
+ parent_identifier: unit.identifier,
80
+ parent_type: unit.type
81
+ )
82
+ end
83
+ end
84
+
85
+ # Chunks a model unit by semantic sections: summary, associations,
86
+ # validations, callbacks, scopes, methods.
87
+ #
88
+ # @api private
89
+ class ModelChunker
90
+ include ChunkBuilder
91
+
92
+ ASSOCIATION_PATTERN = /^\s*(has_many|has_one|belongs_to|has_and_belongs_to_many)\b/
93
+ VALIDATION_PATTERN = /^\s*validates?\b/
94
+ CALLBACK_ACTIONS = '(save|create|update|destroy|validation|action|commit|rollback|find|initialize|touch)'
95
+ CALLBACK_PATTERN = /^\s*(before_|after_|around_)#{CALLBACK_ACTIONS}\b/
96
+ SCOPE_PATTERN = /^\s*scope\s+:/
97
+
98
+ SECTION_PATTERNS = {
99
+ associations: ASSOCIATION_PATTERN,
100
+ validations: VALIDATION_PATTERN,
101
+ callbacks: CALLBACK_PATTERN,
102
+ scopes: SCOPE_PATTERN
103
+ }.freeze
104
+
105
+ SEMANTIC_SECTIONS = %i[associations validations callbacks scopes].freeze
106
+
107
+ # @param unit [ExtractedUnit]
108
+ def initialize(unit)
109
+ @unit = unit
110
+ end
111
+
112
+ # @return [Array<Chunk>]
113
+ def chunk
114
+ sections = classify_lines(@unit.source_code.lines)
115
+ build_chunks(sections).reject(&:empty?)
116
+ end
117
+
118
+ private
119
+
120
+ # @param sections [Hash<Symbol, Array<String>>]
121
+ # @return [Array<Chunk>]
122
+ def build_chunks(sections)
123
+ chunks = []
124
+ chunks << build_chunk(:summary, sections[:summary].join) if sections[:summary].any?
125
+
126
+ SEMANTIC_SECTIONS.each do |type|
127
+ next if sections[type].empty?
128
+
129
+ chunks << build_chunk(type, sections[type].join)
130
+ end
131
+
132
+ chunks << build_chunk(:methods, sections[:methods].join) if sections[:methods].any?
133
+ chunks
134
+ end
135
+
136
+ # Classify each line into a semantic section.
137
+ #
138
+ # @param lines [Array<String>]
139
+ # @return [Hash<Symbol, Array<String>>]
140
+ def classify_lines(lines)
141
+ state = { sections: empty_sections, current: :summary, in_method: false,
142
+ depth: 0 }
143
+ lines.each do |line|
144
+ if state[:in_method]
145
+ track_method_line(state, line)
146
+ else
147
+ classify_line(state, line)
148
+ end
149
+ end
150
+
151
+ state[:sections]
152
+ end
153
+
154
+ # @return [Hash<Symbol, Array<String>>]
155
+ def empty_sections
156
+ { summary: [], associations: [], validations: [], callbacks: [], scopes: [], methods: [] }
157
+ end
158
+
159
+ # Track lines inside a method body.
160
+ def track_method_line(state, line)
161
+ state[:sections][:methods] << line
162
+ update_method_depth(state, line)
163
+ state[:in_method] = false if state[:depth] <= 0 && line.strip.match?(/^end\s*$/)
164
+ end
165
+
166
+ def update_method_depth(state, line)
167
+ state[:depth] += 1 if line.match?(/\bdo\b|\bdef\b/) && !line.match?(/\bend\b/)
168
+ state[:depth] -= 1 if line.strip == 'end' || (line.match?(/\bend\s*$/) && state[:depth].positive?)
169
+ end
170
+
171
+ # Classify a single non-method line.
172
+ def classify_line(state, line)
173
+ section = detect_semantic_section(line)
174
+ if section
175
+ state[:current] = section
176
+ state[:sections][section] << line
177
+ elsif line.match?(PRIVATE_PATTERN)
178
+ state[:sections][:methods] << line
179
+ elsif line.match?(METHOD_PATTERN)
180
+ start_method(state, line)
181
+ else
182
+ assign_fallback(state, line)
183
+ end
184
+ end
185
+
186
+ # Detect which semantic section a line belongs to, if any.
187
+ #
188
+ # @return [Symbol, nil] the section name, or nil if no pattern matched
189
+ def detect_semantic_section(line)
190
+ SECTION_PATTERNS.each do |section, pattern|
191
+ return section if line.match?(pattern)
192
+ end
193
+ nil
194
+ end
195
+
196
+ def start_method(state, line)
197
+ state[:in_method] = true
198
+ state[:depth] = 1
199
+ state[:sections][:methods] << line
200
+ end
201
+
202
+ def assign_fallback(state, line)
203
+ if state[:current] == :summary || line.strip.empty? || line.match?(/^\s*#/)
204
+ state[:sections][:summary] << line
205
+ else
206
+ state[:sections][state[:current]] << line
207
+ end
208
+ end
209
+ end
210
+
211
+ # Chunks a controller unit by actions: summary (class + filters),
212
+ # then one chunk per public action method.
213
+ #
214
+ # @api private
215
+ class ControllerChunker
216
+ include ChunkBuilder
217
+
218
+ FILTER_PATTERN = /^\s*(before_action|after_action|around_action|skip_before_action)\b/
219
+
220
+ # @param unit [ExtractedUnit]
221
+ def initialize(unit)
222
+ @unit = unit
223
+ end
224
+
225
+ # @return [Array<Chunk>]
226
+ def chunk
227
+ state = parse_lines(@unit.source_code.lines)
228
+ build_chunks(state).reject(&:empty?)
229
+ end
230
+
231
+ private
232
+
233
+ # Parse controller lines into summary + action buffers.
234
+ #
235
+ # @param lines [Array<String>]
236
+ # @return [Hash]
237
+ def parse_lines(lines)
238
+ state = { summary: [], actions: {}, current_action: nil, depth: 0,
239
+ in_private: false }
240
+ lines.each do |line|
241
+ if state[:current_action]
242
+ track_action_line(state, line)
243
+ else
244
+ classify_controller_line(state, line)
245
+ end
246
+ end
247
+
248
+ state
249
+ end
250
+
251
+ def track_action_line(state, line)
252
+ state[:actions][state[:current_action]] << line
253
+ state[:depth] += 1 if line.match?(/\bdo\b/) && !line.match?(/\bend\b/)
254
+ return unless line.strip.match?(/^end\s*$/)
255
+
256
+ state[:depth] -= 1
257
+ return unless state[:depth] <= 0
258
+
259
+ state[:current_action] = nil
260
+ state[:depth] = 0
261
+ end
262
+
263
+ def classify_controller_line(state, line)
264
+ if line.match?(PRIVATE_PATTERN)
265
+ state[:in_private] = true
266
+ state[:summary] << line
267
+ elsif !state[:in_private] && line.match?(METHOD_PATTERN)
268
+ start_action(state, line)
269
+ else
270
+ state[:summary] << line
271
+ end
272
+ end
273
+
274
+ def start_action(state, line)
275
+ action_name = line[/def\s+(\w+)/, 1]
276
+ state[:current_action] = action_name
277
+ state[:depth] = 1
278
+ state[:actions][action_name] = [line]
279
+ end
280
+
281
+ # @param state [Hash]
282
+ # @return [Array<Chunk>]
283
+ def build_chunks(state)
284
+ chunks = []
285
+ chunks << build_chunk(:summary, state[:summary].join) if state[:summary].any?
286
+
287
+ state[:actions].each do |action_name, action_lines|
288
+ chunks << build_chunk(:"action_#{action_name}", action_lines.join)
289
+ end
290
+
291
+ chunks
292
+ end
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Console
5
+ module Adapters
6
+ # Cache adapter that auto-detects the active cache store.
7
+ #
8
+ # Supports Redis, Solid Cache, memory, and file cache stores.
9
+ # Detection checks Rails.cache class name first, then falls back
10
+ # to checking for SolidCache constant.
11
+ #
12
+ # @example
13
+ # CacheAdapter.detect # => :redis
14
+ # CacheAdapter.stats # => { tool: 'cache_stats', params: {} }
15
+ #
16
+ module CacheAdapter
17
+ STORE_PATTERNS = {
18
+ 'RedisCacheStore' => :redis,
19
+ 'MemoryStore' => :memory,
20
+ 'FileStore' => :file
21
+ }.freeze
22
+
23
+ module_function
24
+
25
+ # Detect the active cache store backend.
26
+ #
27
+ # @return [Symbol] One of :redis, :solid_cache, :memory, :file, :unknown
28
+ def detect
29
+ if defined?(::Rails) && ::Rails.respond_to?(:cache) && ::Rails.cache
30
+ class_name = ::Rails.cache.class.name.to_s
31
+ STORE_PATTERNS.each do |pattern, backend|
32
+ return backend if class_name.include?(pattern)
33
+ end
34
+ end
35
+
36
+ return :solid_cache if defined?(::SolidCache)
37
+
38
+ :unknown
39
+ end
40
+
41
+ # Get cache store statistics.
42
+ #
43
+ # @param namespace [String, nil] Cache namespace filter
44
+ # @return [Hash] Bridge request
45
+ def stats(namespace: nil)
46
+ { tool: 'cache_stats', params: { namespace: namespace }.compact }
47
+ end
48
+
49
+ # Get cache store info (backend type, configuration).
50
+ #
51
+ # @return [Hash] Bridge request
52
+ def info
53
+ { tool: 'cache_info', params: {} }
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'job_adapter'
4
+
5
+ module Woods
6
+ module Console
7
+ module Adapters
8
+ # Job backend adapter for GoodJob.
9
+ #
10
+ # Builds bridge requests for GoodJob queue stats, failure listing,
11
+ # job lookup, scheduled jobs, and retry operations.
12
+ #
13
+ # @example
14
+ # adapter = GoodJobAdapter.new
15
+ # adapter.queue_stats # => { tool: 'good_job_queue_stats', params: {} }
16
+ #
17
+ class GoodJobAdapter < JobAdapter
18
+ # Check if GoodJob is available in the current environment.
19
+ #
20
+ # @return [Boolean]
21
+ def self.available?
22
+ !!defined?(::GoodJob)
23
+ end
24
+
25
+ private
26
+
27
+ def prefix
28
+ 'good_job'
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end