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,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
|