woods 1.6.1 → 2.0.0.beta2

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../embedding/provider'
4
+ require_relative '../retry_after'
4
5
  require_relative 'circuit_breaker'
5
6
 
6
7
  module Woods
@@ -26,6 +27,17 @@ module Woods
26
27
  class RetryableProvider
27
28
  include Woods::Embedding::Provider::Interface
28
29
 
30
+ # @return [#embed, #embed_batch] the wrapped (raw) embedding provider.
31
+ # Exposed so builders can reach the concrete class for tokenizer
32
+ # calibration (see Builder#chars_per_token_for) without unwinding
33
+ # the resilience stack by hand.
34
+ attr_reader :provider
35
+
36
+ # @return [CircuitBreaker, nil] the breaker guarding this provider's
37
+ # calls, or nil when none was configured. Per-instance by design —
38
+ # never share one breaker across unrelated components.
39
+ attr_reader :circuit_breaker
40
+
29
41
  # @param provider [#embed, #embed_batch, #dimensions, #model_name] The underlying embedding provider
30
42
  # @param max_retries [Integer] Maximum number of retry attempts
31
43
  # @param circuit_breaker [CircuitBreaker, nil] Optional circuit breaker instance
@@ -71,13 +83,20 @@ module Woods
71
83
 
72
84
  # Delegate the per-provider input cap. The retry wrapper does not
73
85
  # change the provider's budget, so just hand through whatever the
74
- # inner provider reports. Without this, `respond_to?` returns true
75
- # via Interface but the call raises NotImplementedError.
86
+ # inner provider reports. `respond_to?` alone is the wrong guard
87
+ # here: {Woods::Embedding::Provider::Interface} *defines*
88
+ # +max_input_tokens+ as a +NotImplementedError+ stub, so a provider
89
+ # that merely includes the interface without overriding it still
90
+ # answers +respond_to?+ with +true+ (B-108) and raises when called.
91
+ # A provider with no such method at all still needs the
92
+ # +respond_to?+ guard to avoid a bare +NoMethodError+.
76
93
  #
77
94
  # @return [Integer, nil]
78
95
  def max_input_tokens
79
- return @provider.max_input_tokens if @provider.respond_to?(:max_input_tokens)
96
+ return nil unless @provider.respond_to?(:max_input_tokens)
80
97
 
98
+ @provider.max_input_tokens
99
+ rescue NotImplementedError
81
100
  nil
82
101
  end
83
102
 
@@ -90,18 +109,27 @@ module Woods
90
109
  # `BACKOFF_BASE * 2**attempt` with full jitter applied on top.
91
110
  BACKOFF_BASE = 0.1
92
111
 
112
+ # Upper bound honored for a server-supplied +Retry-After+. Bounds a
113
+ # hostile or misconfigured header (e.g. an HTTP-date hours away) so a
114
+ # single throttled call cannot stall the embed pipeline indefinitely;
115
+ # real rate-limit windows sit well under this.
116
+ MAX_RETRY_AFTER_SECONDS = 120.0
117
+
93
118
  private
94
119
 
95
120
  # Execute a block with retry logic, exponential backoff, and jitter.
96
121
  #
97
122
  # Argument errors surface immediately (non-retryable — they indicate
98
123
  # a programming mistake or invalid input, not a transient failure).
124
+ # Errors carrying a non-retryable HTTP status (see {#retryable_error?})
125
+ # also surface immediately — retrying a 401 only delays the real error.
99
126
  #
100
127
  # @yield The block to execute
101
128
  # @return [Object] The return value of the block
102
129
  # @raise [CircuitOpenError] immediately without retrying
103
130
  # @raise [ArgumentError] immediately without retrying
104
- # @raise [StandardError] the last error if all retries are exhausted
131
+ # @raise [StandardError] the last error if all retries are exhausted,
132
+ # or the first error when it is classified non-retryable
105
133
  def with_retries
106
134
  attempt = 0
107
135
  begin
@@ -110,13 +138,50 @@ module Woods
110
138
  rescue CircuitOpenError, ArgumentError
111
139
  raise
112
140
  rescue StandardError => e
113
- raise e if attempt > @max_retries
141
+ raise e if attempt > @max_retries || !retryable_error?(e)
114
142
 
115
- sleep(backoff_seconds(attempt))
143
+ sleep(retry_delay(e, attempt))
116
144
  retry
117
145
  end
118
146
  end
119
147
 
148
+ # Decide whether an error is worth retrying.
149
+ #
150
+ # Errors that expose an HTTP status (duck-typed on +#http_status+ —
151
+ # {Woods::Embedding::Provider::RequestError} is the canonical shape)
152
+ # are classified precisely: 429 (rate limit), 408 (request timeout),
153
+ # and 5xx are transient; every other 4xx is a request or credential
154
+ # problem no retry can fix, so burning the retry budget (and sleeping
155
+ # between attempts) only postpones the real error. Status-less errors
156
+ # — connection resets, socket timeouts, legacy Woods::Error strings —
157
+ # keep the historical retry-everything behavior.
158
+ #
159
+ # @param error [StandardError]
160
+ # @return [Boolean] true when the error should be retried
161
+ def retryable_error?(error)
162
+ status = error.respond_to?(:http_status) ? error.http_status : nil
163
+ return true unless status.is_a?(Integer)
164
+
165
+ status == 429 || status == 408 || status >= 500
166
+ end
167
+
168
+ # Seconds to sleep before the next retry. A server-supplied
169
+ # +Retry-After+ (parsed via {Woods::RetryAfter}, which handles both
170
+ # the delta-seconds and HTTP-date forms) takes precedence over the
171
+ # local backoff schedule, capped at {MAX_RETRY_AFTER_SECONDS};
172
+ # without one, full-jitter exponential backoff applies.
173
+ #
174
+ # @param error [StandardError] the error that triggered the retry
175
+ # @param attempt [Integer] 1-based attempt counter
176
+ # @return [Float] seconds to sleep before the next retry
177
+ def retry_delay(error, attempt)
178
+ fallback = backoff_seconds(attempt)
179
+ header = error.respond_to?(:retry_after) ? error.retry_after : nil
180
+ return fallback if header.nil?
181
+
182
+ RetryAfter.seconds(header, fallback: fallback, max: MAX_RETRY_AFTER_SECONDS)
183
+ end
184
+
120
185
  # Full-jitter exponential backoff with a hard cap. See "Exponential
121
186
  # Backoff and Jitter", AWS Architecture Blog (Marc Brooker, 2015):
122
187
  # a uniformly random delay in [0, base*2**attempt] de-correlates
@@ -44,6 +44,9 @@ module Woods
44
44
  # @return [Hash] Store types — :vector_store, :metadata_store, :graph_store (Symbols)
45
45
  attr_reader :stores
46
46
 
47
+ # @return [Hash] Non-secret options required to reopen durable stores
48
+ attr_reader :store_options
49
+
47
50
  # Parse a +woods.json+ hash into a {ResolvedConfig}.
48
51
  #
49
52
  # @param raw [Hash] Parsed JSON hash (string or symbol keys)
@@ -58,7 +61,8 @@ module Woods
58
61
  gem_version: data[:gem_version].to_s,
59
62
  created_at: parse_time(data[:created_at]),
60
63
  embedding_provider: parse_provider(data[:embedding_provider] || {}),
61
- stores: parse_stores(data[:stores] || {})
64
+ stores: parse_stores(data[:stores] || {}),
65
+ store_options: parse_store_options(data[:store_options] || {})
62
66
  )
63
67
  end
64
68
 
@@ -82,17 +86,18 @@ module Woods
82
86
  def self.from_configuration(config, gem_version: nil, provider: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
83
87
  require_relative 'version'
84
88
 
85
- opts = config.embedding_options || {}
86
- declared_dim = opts[:dimension] || opts['dimension']
87
- dim = declared_dim || (provider.respond_to?(:dimensions) ? provider.dimensions : nil)
89
+ opts = (config.embedding_options || {}).transform_keys(&:to_sym)
90
+ declared_dim = opts[:dimensions] || opts[:dimension] || opts[:dims]
91
+ dim = provider.respond_to?(:dimensions) ? provider.dimensions : declared_dim
92
+ model = provider.respond_to?(:model_name) ? provider.model_name : (opts[:model] || config.embedding_model)
88
93
 
89
94
  provider_hash = {
90
95
  class: resolve_provider_class(config.embedding_provider),
91
- model: (opts[:model] || opts['model'] || config.embedding_model).to_s,
96
+ model: model.to_s,
92
97
  dimension: dim.to_i,
93
- host: opts[:host] || opts['host'],
94
- num_ctx: opts[:num_ctx] || opts['num_ctx'],
95
- read_timeout: opts[:read_timeout] || opts['read_timeout']
98
+ host: opts[:host],
99
+ num_ctx: opts[:num_ctx],
100
+ read_timeout: opts[:read_timeout]
96
101
  }.compact
97
102
 
98
103
  new(
@@ -104,7 +109,8 @@ module Woods
104
109
  vector_store: config.vector_store,
105
110
  metadata_store: config.metadata_store,
106
111
  graph_store: config.graph_store
107
- }
112
+ },
113
+ store_options: durable_store_options(config)
108
114
  )
109
115
  end
110
116
 
@@ -113,12 +119,13 @@ module Woods
113
119
  # @param created_at [Time]
114
120
  # @param embedding_provider [Hash]
115
121
  # @param stores [Hash]
116
- def initialize(schema_version:, gem_version:, created_at:, embedding_provider:, stores:)
122
+ def initialize(schema_version:, gem_version:, created_at:, embedding_provider:, stores:, store_options: {}) # rubocop:disable Metrics/ParameterLists
117
123
  @schema_version = schema_version
118
124
  @gem_version = gem_version.to_s.freeze
119
125
  @created_at = created_at
120
126
  @embedding_provider = deep_freeze(embedding_provider)
121
127
  @stores = deep_freeze(stores)
128
+ @store_options = deep_freeze(store_options)
122
129
  freeze
123
130
  end
124
131
 
@@ -127,6 +134,20 @@ module Woods
127
134
  embedding_provider[:dimension].to_i
128
135
  end
129
136
 
137
+ # Model recorded in +embedding_provider[:model]+.
138
+ #
139
+ # {Storage::Snapshotter::Vector.dump} guards its call with
140
+ # +resolved_config.respond_to?(:model_name)+ before writing the WVF1
141
+ # header's model field — that guard passed for every {ResolvedConfig}
142
+ # even with no +#model_name+ defined, so the header's model was always
143
+ # +''+ and no dump could say which model produced it.
144
+ #
145
+ # @return [String, nil] the model name, or nil when absent
146
+ def model_name
147
+ model = embedding_provider[:model].to_s
148
+ model.empty? ? nil : model
149
+ end
150
+
130
151
  # Short string identifying this provider configuration, useful for log
131
152
  # messages and {ConfigMismatch} error text.
132
153
  #
@@ -175,7 +196,8 @@ module Woods
175
196
  'gem_version' => gem_version,
176
197
  'created_at' => created_at.iso8601,
177
198
  'embedding_provider' => embedding_provider.transform_keys(&:to_s),
178
- 'stores' => stores.transform_keys(&:to_s).transform_values(&:to_s)
199
+ 'stores' => stores.transform_keys(&:to_s).transform_values(&:to_s),
200
+ 'store_options' => stringify_nested_keys(store_options)
179
201
  }
180
202
  end
181
203
 
@@ -186,6 +208,13 @@ module Woods
186
208
 
187
209
  private
188
210
 
211
+ def stringify_nested_keys(value)
212
+ return value.transform_keys(&:to_s).transform_values { |item| stringify_nested_keys(item) } if value.is_a?(Hash)
213
+ return value.map { |item| stringify_nested_keys(item) } if value.is_a?(Array)
214
+
215
+ value
216
+ end
217
+
189
218
  # Recursively freeze a Hash and every Hash/Array/String it transitively
190
219
  # holds. The previous shallow `.freeze` left nested Hash values mutable
191
220
  # — a caller reaching `config.embedding_provider[:options][:foo] = …`
@@ -279,6 +308,20 @@ module Woods
279
308
  }
280
309
  end
281
310
 
311
+ DURABLE_VECTOR_OPTION_KEYS = %i[collection dimensions distance allow_private_hosts table schema].freeze
312
+ private_constant :DURABLE_VECTOR_OPTION_KEYS
313
+
314
+ def durable_store_options(config)
315
+ vector = normalize_keys(config.vector_store_options || {}).slice(*DURABLE_VECTOR_OPTION_KEYS)
316
+ vector.empty? ? {} : { vector_store: vector }
317
+ end
318
+
319
+ def parse_store_options(raw)
320
+ data = normalize_keys(raw)
321
+ vector = normalize_keys(data[:vector_store] || {}).slice(*DURABLE_VECTOR_OPTION_KEYS)
322
+ vector.empty? ? {} : { vector_store: vector }
323
+ end
324
+
282
325
  def parse_time(value)
283
326
  value ? Time.parse(value.to_s) : Time.now.utc
284
327
  end
@@ -291,6 +334,7 @@ module Woods
291
334
  case provider
292
335
  when :openai then 'Woods::Embedding::Provider::OpenAI'
293
336
  when :ollama then 'Woods::Embedding::Provider::Ollama'
337
+ when :fake then 'Woods::Embedding::Provider::Fake'
294
338
  when String then provider
295
339
  when Class then provider.name
296
340
  when nil then ''
@@ -8,14 +8,12 @@ module Woods
8
8
  # Transforms ranked search candidates into a token-budgeted context string
9
9
  # for LLM consumption.
10
10
  #
11
- # Allocates a fixed token budget across four sections:
12
- # - Structural (10%): Always-included codebase overview
13
- # - Primary (50%): Direct query results
14
- # - Supporting (25%): Dependencies and related context
15
- # - Framework (15%): Rails/gem source when query has framework context
16
- #
17
- # When framework context is not needed, primary and supporting sections
18
- # receive the framework allocation proportionally.
11
+ # Allocates a fixed token budget in two passes:
12
+ # - Structural (10% of the total budget): Always-included codebase overview
13
+ # - Everything else splits what's LEFT after structural is carved out
14
+ # ({#compute_section_budgets}), not the total budget directly:
15
+ # - Framework context active: Primary 55%, Supporting 25%, Framework 20%
16
+ # - Framework context inactive: Primary 65%, Supporting 35%, Framework 0%
19
17
  #
20
18
  # @example
21
19
  # assembler = ContextAssembler.new(metadata_store: store)
@@ -27,11 +25,30 @@ module Woods
27
25
  class ContextAssembler
28
26
  DEFAULT_BUDGET = 8000 # tokens
29
27
 
28
+ # Only +:structural+ is read directly ({#add_structural_section}) — it
29
+ # is a fraction of the TOTAL budget. The other sections split what
30
+ # remains after structural is carved out and have two different
31
+ # fraction sets depending on +classification.framework_context+, which
32
+ # a single flat hash can't express; see {FRAMEWORK_ACTIVE_ALLOCATION}
33
+ # and {FRAMEWORK_INACTIVE_ALLOCATION}.
30
34
  BUDGET_ALLOCATION = {
31
- structural: 0.10,
32
- primary: 0.50,
35
+ structural: 0.10
36
+ }.freeze
37
+
38
+ # Fractions of the REMAINING (post-structural) budget when the query
39
+ # has framework context. Read by {#compute_section_budgets}.
40
+ FRAMEWORK_ACTIVE_ALLOCATION = {
41
+ primary: 0.55,
33
42
  supporting: 0.25,
34
- framework: 0.15
43
+ framework: 0.20
44
+ }.freeze
45
+
46
+ # Fractions of the REMAINING (post-structural) budget when the query
47
+ # has no framework context — the framework section gets nothing, and
48
+ # its share isn't proportionally added to primary/supporting.
49
+ FRAMEWORK_INACTIVE_ALLOCATION = {
50
+ primary: 0.65,
51
+ supporting: 0.35
35
52
  }.freeze
36
53
 
37
54
  # Minimum token count for a section to be worth including.
@@ -91,6 +108,7 @@ module Woods
91
108
  sections = []
92
109
  sources = []
93
110
  tokens_used = 0
111
+ @skipped_missing_metadata = 0
94
112
 
95
113
  # Collapse +User#chunk_0+, +User#chunk_1+, … back to their base unit
96
114
  # BEFORE metadata lookup and section assembly. Chunk IDs are an
@@ -109,16 +127,9 @@ module Woods
109
127
  budgets = compute_section_budgets(effective_budget - tokens_used, classification)
110
128
 
111
129
  # 3. Primary, supporting, and framework sections
112
- add_candidate_section(sections, sources, :primary,
113
- candidates.reject { |c| c.source == :graph_expansion }, budgets[:primary])
114
- add_candidate_section(sections, sources, :supporting,
115
- candidates.select { |c| c.source == :graph_expansion }, budgets[:supporting])
116
- if budgets[:framework].positive?
117
- add_candidate_section(sections, sources, :framework,
118
- candidates.select { |c| framework_candidate?(c) }, budgets[:framework])
119
- end
130
+ add_result_sections(sections, sources, candidates, budgets)
120
131
 
121
- build_result(sections, sources, effective_budget)
132
+ build_result(sections, sources, effective_budget, @skipped_missing_metadata)
122
133
  end
123
134
 
124
135
  private
@@ -159,7 +170,8 @@ module Woods
159
170
  identifier: new_identifier,
160
171
  score: candidate.score,
161
172
  source: candidate.source,
162
- metadata: candidate.metadata
173
+ metadata: candidate.metadata,
174
+ matched_fields: candidate.matched_fields
163
175
  )
164
176
  end
165
177
 
@@ -175,6 +187,54 @@ module Woods
175
187
  tokens_used + estimate_tokens(text)
176
188
  end
177
189
 
190
+ # Partition candidates into the primary/supporting/framework sections
191
+ # and append each section's content.
192
+ #
193
+ # The three sections are a strict PARTITION, not overlapping selects.
194
+ # When the framework budget is active, framework-source candidates
195
+ # (rails_source / gem_source) are formatted ONLY in the framework
196
+ # section — previously a vector-sourced rails_source hit landed in
197
+ # primary AND framework, spending both budgets on the same source
198
+ # text (#186 / B-074). When the framework budget is zero (no
199
+ # framework context), framework candidates stay in their natural
200
+ # primary/supporting bucket, preserving the pre-fix behaviour.
201
+ #
202
+ # @param sections [Array<Hash>] Section accumulator (mutated)
203
+ # @param sources [Array<Hash>] Source-attribution accumulator (mutated)
204
+ # @param candidates [Array<Candidate>] Ranked, chunk-collapsed candidates
205
+ # @param budgets [Hash<Symbol, Integer>] Per-section token budgets
206
+ # @return [void]
207
+ def add_result_sections(sections, sources, candidates, budgets)
208
+ framework_active = budgets[:framework].positive?
209
+ framework, local = if framework_active
210
+ candidates.partition { |c| framework_candidate?(c) }
211
+ else
212
+ [[], candidates]
213
+ end
214
+
215
+ supporting_candidates, primary_candidates = local.partition { |c| c.source == :graph_expansion }
216
+ budgets = reclaim_empty_supporting_budget(budgets, supporting_candidates)
217
+
218
+ add_candidate_section(sections, sources, :primary, primary_candidates, budgets[:primary])
219
+ add_candidate_section(sections, sources, :supporting, supporting_candidates, budgets[:supporting])
220
+ add_candidate_section(sections, sources, :framework, framework, budgets[:framework]) if framework_active
221
+ end
222
+
223
+ # The supporting section only ever holds :graph_expansion candidates
224
+ # ({#add_result_sections}), so a query where no graph expansion ran
225
+ # reserves a supporting budget it can never spend. Rolling that
226
+ # reservation into primary before assembly recovers it instead of
227
+ # stranding it.
228
+ #
229
+ # @param budgets [Hash<Symbol, Integer>]
230
+ # @param supporting_candidates [Array<Candidate>]
231
+ # @return [Hash<Symbol, Integer>]
232
+ def reclaim_empty_supporting_budget(budgets, supporting_candidates)
233
+ return budgets unless supporting_candidates.empty?
234
+
235
+ budgets.merge(primary: budgets[:primary] + budgets[:supporting], supporting: 0)
236
+ end
237
+
178
238
  # Add a candidate-based section if candidates produce content.
179
239
  #
180
240
  # @return [void]
@@ -188,24 +248,18 @@ module Woods
188
248
  sources.concat(section_sources)
189
249
  end
190
250
 
191
- # Compute token budgets for primary/supporting/framework sections.
251
+ # Compute token budgets for primary/supporting/framework sections from
252
+ # {FRAMEWORK_ACTIVE_ALLOCATION} / {FRAMEWORK_INACTIVE_ALLOCATION}.
192
253
  #
193
254
  # @param remaining [Integer] Tokens available after structural
194
255
  # @param classification [QueryClassifier::Classification]
195
256
  # @return [Hash<Symbol, Integer>]
196
257
  def compute_section_budgets(remaining, classification)
197
258
  if classification.framework_context
198
- {
199
- primary: (remaining * 0.55).to_i,
200
- supporting: (remaining * 0.25).to_i,
201
- framework: (remaining * 0.20).to_i
202
- }
259
+ FRAMEWORK_ACTIVE_ALLOCATION.transform_values { |fraction| (remaining * fraction).to_i }
203
260
  else
204
- {
205
- primary: (remaining * 0.65).to_i,
206
- supporting: (remaining * 0.35).to_i,
207
- framework: 0
208
- }
261
+ FRAMEWORK_INACTIVE_ALLOCATION.transform_values { |fraction| (remaining * fraction).to_i }
262
+ .merge(framework: 0)
209
263
  end
210
264
  end
211
265
 
@@ -230,7 +284,14 @@ module Woods
230
284
  # Append a single candidate to the section. Returns updated tokens_used, or nil to stop.
231
285
  def append_candidate(parts, sources, candidate, budget, tokens_used)
232
286
  unit = @unit_cache[candidate.identifier]
233
- return tokens_used unless unit
287
+ unless unit
288
+ # Vector search matched an identifier the metadata store no longer
289
+ # has (a stale vector for a deleted/renamed unit). Counted so
290
+ # callers can distinguish "nothing matched" from "matches existed
291
+ # but the index is stale" instead of a silent empty section.
292
+ @skipped_missing_metadata += 1
293
+ return tokens_used
294
+ end
234
295
 
235
296
  text = format_unit(unit, candidate)
236
297
  tokens = estimate_tokens(text)
@@ -271,7 +332,7 @@ module Woods
271
332
  # @return [Hash]
272
333
  def build_source_attribution(candidate, unit, truncated: false)
273
334
  attribution = {
274
- identifier: candidate.identifier,
335
+ identifier: unit_field(unit, :identifier) || candidate.identifier,
275
336
  type: unit_field(unit, :type),
276
337
  score: candidate.score,
277
338
  file_path: unit_field(unit, :file_path)
@@ -291,14 +352,36 @@ module Woods
291
352
 
292
353
  # Check if a candidate is framework source.
293
354
  #
355
+ # Reads +candidate.metadata+ first, then falls back to the assembled
356
+ # +@unit_cache+ entry. A real graph-expansion candidate always carries
357
+ # +metadata: {}+ (see {SearchExecutor#execute_graph}), so relying on
358
+ # +candidate.metadata+ alone silently misses every graph-expanded
359
+ # framework unit — they'd never partition out of primary/supporting
360
+ # into the framework section.
361
+ #
294
362
  # @param candidate [Candidate]
295
363
  # @return [Boolean]
296
364
  def framework_candidate?(candidate)
365
+ type = type_from_candidate_metadata(candidate) || type_from_unit_cache(candidate)
366
+ %w[rails_source gem_source].include?(type.to_s)
367
+ end
368
+
369
+ # @param candidate [Candidate]
370
+ # @return [String, Symbol, nil]
371
+ def type_from_candidate_metadata(candidate)
297
372
  metadata = candidate.metadata
298
- return false unless metadata
373
+ return nil unless metadata
299
374
 
300
- type = metadata[:type] || metadata['type']
301
- %w[rails_source gem_source].include?(type.to_s)
375
+ metadata[:type] || metadata['type']
376
+ end
377
+
378
+ # @param candidate [Candidate]
379
+ # @return [String, Symbol, nil]
380
+ def type_from_unit_cache(candidate)
381
+ unit = @unit_cache[candidate.identifier]
382
+ return nil unless unit
383
+
384
+ unit_field(unit, :type)
302
385
  end
303
386
 
304
387
  # Truncate text to fit within a token budget.
@@ -349,20 +432,29 @@ module Woods
349
432
  # @param sections [Array<Hash>] Assembled sections
350
433
  # @param sources [Array<Hash>] Source attributions
351
434
  # @param effective_budget [Integer] The budget actually used for assembly
435
+ # @param skipped_missing_metadata [Integer] Candidates dropped because the
436
+ # metadata store had no record for their identifier (stale vector)
352
437
  # @return [AssembledContext]
353
- def build_result(sections, sources, effective_budget)
438
+ def build_result(sections, sources, effective_budget, skipped_missing_metadata)
354
439
  context = sections.map { |s| s[:content] }.join("\n\n---\n\n")
355
440
  AssembledContext.new(
356
441
  context: context,
357
442
  tokens_used: estimate_tokens(context),
358
443
  budget: effective_budget,
359
444
  sources: sources.uniq,
360
- sections: sections.map { |s| s[:section] }
445
+ sections: sections.map { |s| s[:section] },
446
+ skipped_missing_metadata: skipped_missing_metadata
361
447
  )
362
448
  end
363
449
  end
364
450
 
365
451
  # Result of context assembly.
366
- AssembledContext = Struct.new(:context, :tokens_used, :budget, :sources, :sections, keyword_init: true)
452
+ #
453
+ # +skipped_missing_metadata+ counts candidates whose identifier had no
454
+ # entry in the metadata store — a stale vector left behind by a deleted
455
+ # or renamed unit. A nonzero count alongside empty +sources+ means the
456
+ # index needs re-embedding, not that the query had no matches.
457
+ AssembledContext = Struct.new(:context, :tokens_used, :budget, :sources, :sections,
458
+ :skipped_missing_metadata, keyword_init: true)
367
459
  end
368
460
  end
@@ -25,14 +25,17 @@ module Woods
25
25
 
26
26
  # Intent patterns — order matters (first match wins)
27
27
  INTENT_PATTERNS = {
28
- locate: /\b(where|find|which file|locate|look for|search for)\b/i,
28
+ # :trace is checked BEFORE :locate: a query like "find who calls X"
29
+ # matches both patterns ("find" vs "who calls"), and the tracing
30
+ # intent is the one the agent means. :locate first misrouted these
31
+ # to keyword/direct location handling, skipping graph-based tracing.
29
32
  trace: /\b(trace|follow|track|call(s|ed by)|depends on|used by|who calls|what calls)\b/i,
33
+ locate: /\b(where|find|which file|locate|look for|search for)\b/i,
30
34
  debug: /\b(bug|error|fix|broken|failing|wrong|issue|problem|crash|exception)\b/i,
31
35
  implement: /\b(implement|add|create|build|write|make|generate)\b/i,
32
36
  compare: /\b(compare|difference|vs|versus|between|contrast)\b/i,
33
- # rubocop:disable Layout/LineLength
37
+ # rubocop:disable-next Layout/LineLength
34
38
  framework: /\b(how does rails|what does rails|rails .+ work|work.+\brails\b|in rails\b|activerecord|actioncontroller|activejob|actionmailer|actioncable|actiontext|activestorage|solid_queue|solid_cache|solid_cable|kamal|propshaft|importmap|hotwire|turbo|stimulus|zeitwerk)\b/i,
35
- # rubocop:enable Layout/LineLength
36
39
  reference: /\b(show me|what is|what are|list|options for|api|interface|signature)\b/i,
37
40
  understand: /\b(how|why|explain|understand|what happens|describe|overview)\b/i
38
41
  }.freeze
@@ -44,16 +47,31 @@ module Woods
44
47
  exploratory: /\b(related|around|near|similar|like|associated)\b/i
45
48
  }.freeze
46
49
 
47
- # Target type patterns
50
+ # Target type patterns.
51
+ #
52
+ # Deliberately conservative (#184): +target_type+ is a heuristic that
53
+ # feeds the Ranker's soft +type_match+ signal, so every word here
54
+ # nudges the ranking of any query that contains it. Ordinary
55
+ # conversational words — bare HTTP verbs (get/post/put/patch/delete),
56
+ # "query", "type", "field", "email", "perform" — are not type signals
57
+ # on their own; they only count with disambiguating context (an
58
+ # uppercase HTTP verb, "notification email", "perform_later"). Add
59
+ # new words only when they are genuinely discriminative.
48
60
  TARGET_PATTERNS = {
49
61
  model: /\b(model|activerecord|association|schema|table|column|scope|validation)\b/i,
50
62
  controller: /\b(controller|action|route|endpoint|api|request|response|filter|callback)\b/i,
51
63
  service: /\b(service|interactor|operation|command|use.?case|business.?logic)\b/i,
52
- job: /\b(job|worker|background|async|sidekiq|queue|perform)\b/i,
53
- mailer: /\b(mailer|email|notification|send.?mail)\b/i,
54
- graphql: /\b(graphql|mutation|query|type|resolver|field|argument|schema)\b/i,
64
+ job: /\b(job|worker|background|async|sidekiq|queue|perform.?(later|now|async))\b/i,
65
+ # rubocop:disable-next Layout/LineLength
66
+ mailer: /\b(mailer|mail|send.?mail|deliver.?(later|now)|notification\s+email|email\s+(template|notification|deliver\w*)|send\w*\s+(an\s+)?email)\b/i,
67
+ graphql: /\b(graphql|mutation|resolver)\b/i,
55
68
  concern: /\b(concern|mixin|module|included|extend)\b/i,
56
- route: /\b(route|path|url|endpoint|uri|http|get|post|put|patch|delete)\b/i,
69
+ # Lowercase get/post/put/patch/delete are ordinary verbs ("how do we
70
+ # get the current user?") — only the unambiguous uppercase HTTP form
71
+ # counts. Verb-plus-context phrases ("GET request", "delete
72
+ # endpoint") already classify :controller via that pattern's
73
+ # request/endpoint/route words, which run first.
74
+ route: Regexp.union(/\b(route|path|url|uri|http)\b/i, /\b(GET|POST|PUT|PATCH|DELETE)\b/),
57
75
  middleware: /\b(middleware|rack|request.?pipeline|before.?action)\b/i,
58
76
  i18n: /\b(i18n|translation|locale|internationalization|t\(|translate)\b/i,
59
77
  pundit_policy: /\b(pundit|authorize|policy|allowed|permitted)\b/i,