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
@@ -12,6 +12,15 @@ require_relative 'embedding/token_counter'
12
12
  require_relative 'token_utils'
13
13
 
14
14
  module Woods
15
+ # Raised by {Retriever#retrieve} when +query+ fails the validation
16
+ # boundary at the top of the pipeline: nil, blank, non-String, or over
17
+ # {Retriever::MAX_QUERY_BYTES}. One shared boundary for every strategy
18
+ # (keyword/vector/graph/hybrid) — pre-fix, a blank query surfaced as a
19
+ # raw provider ArgumentError, and only on the vector path; the other
20
+ # strategies tolerated it silently, and an oversized query went to the
21
+ # provider verbatim to fail as an opaque 400.
22
+ class InvalidQueryError < Error; end
23
+
15
24
  # Retriever orchestrates the full retrieval pipeline: classify, execute,
16
25
  # rank, and assemble context from a natural language query.
17
26
  #
@@ -50,8 +59,14 @@ module Woods
50
59
  ).freeze
51
60
 
52
61
  # Diagnostic trace for retrieval quality analysis.
62
+ #
63
+ # +skipped_missing_metadata+ carries {Retrieval::ContextAssembler}'s count
64
+ # of candidates dropped because the metadata store had no record for
65
+ # their identifier (a stale vector). See +RetrievalResult+'s docstring
66
+ # for how callers should read it alongside +sources+.
53
67
  RetrievalTrace = Struct.new(:classification, :strategy, :candidate_count,
54
68
  :ranked_count, :tokens_used, :elapsed_ms,
69
+ :skipped_missing_metadata,
55
70
  keyword_init: true)
56
71
 
57
72
  # The result of a retrieval operation.
@@ -85,18 +100,107 @@ module Woods
85
100
  RetrievalResult = Struct.new(:context, :sources, :classification, :strategy, :tokens_used, :budget, :trace,
86
101
  :type_rank_context, keyword_init: true)
87
102
 
103
+ # Raised when a metadata-store access fails during retrieval (M8). One
104
+ # shared typed error for every store call site: the retriever used to
105
+ # swallow these into misleading answers — +types:+ queries reported
106
+ # +:absent+, the rank-within-type fallback short-circuited to empty, and
107
+ # exclusion filtering silently no-op'd, each presenting a broken store as
108
+ # a clean empty result. Raising here lets the MCP server map the failure
109
+ # to tool-visible degraded metadata instead.
110
+ class StoreError < Error
111
+ # @return [String] which store failed (e.g. +"metadata"+)
112
+ attr_reader :store
113
+
114
+ # @param message [String] includes the underlying error class and message
115
+ # @param store [Symbol, String] the failing store component
116
+ def initialize(message, store: :metadata)
117
+ super(message)
118
+ @store = store.to_s
119
+ end
120
+ end
121
+
122
+ # Store adapter wrapper that turns ANY failure of a store call into the
123
+ # shared typed {StoreError}, naming the store that failed.
124
+ #
125
+ # M8 wrapped the three lookups the Retriever performs itself, but the
126
+ # store reads carrying most of the query traffic happen INSIDE the
127
+ # pipeline components — the ranker's and assembler's +find_batch+, and
128
+ # every executor store call. Those escaped raw: the SDK reported
129
+ # "Internal error calling tool codebase_retrieve", or {MCP::ToolContract}
130
+ # saw an IO-flavored cause and relabeled it +corrupt_artifact+ ("An Index
131
+ # artifact is unavailable or malformed") — the wrong diagnosis for, say, a
132
+ # SQLite metadata DB deleted mid-serve (MCP-6).
133
+ #
134
+ # Only the pipeline COMPONENTS get facades. The Pipeline struct's store
135
+ # members and the public +vector_store+ / +metadata_store+ / +graph_store+
136
+ # readers keep the raw adapters, so the reload transaction's capability
137
+ # and store-type checks ({MCP::Bootstrapper.refreshable_stores?},
138
+ # +implements_own?+) still see the real objects.
139
+ class TranslatedStore
140
+ # @param store [Object] the wrapped adapter
141
+ # @param name [Symbol] store component name (+:vector+, +:metadata+, +:graph+)
142
+ def initialize(store, name)
143
+ @store = store
144
+ @name = name
145
+ end
146
+
147
+ # @return [Object] the wrapped adapter, for callers that need identity
148
+ attr_reader :store
149
+
150
+ def respond_to_missing?(name, include_private = false)
151
+ @store.respond_to?(name, include_private) || super
152
+ end
153
+
154
+ def method_missing(name, ...)
155
+ return super unless @store.respond_to?(name)
156
+
157
+ @store.public_send(name, ...)
158
+ rescue StoreError
159
+ raise
160
+ rescue StandardError => e
161
+ raise StoreError.new("#{@name} store call #{name} failed: #{e.class}: #{e.message}", store: @name)
162
+ end
163
+ end
164
+
88
165
  # Unit types queried for the structural context overview.
89
166
  STRUCTURAL_TYPES = %w[model controller service job mailer component graphql].freeze
90
167
 
91
- # Direct handles to the injected stores. The sub-components
92
- # ({Retrieval::SearchExecutor}, {Retrieval::Ranker},
93
- # {Retrieval::ContextAssembler}) hold their own references too, but those
94
- # are implementation details callers that want to mutate store contents
95
- # (e.g. the MCP +reload+ tool) read through these accessors. All three
96
- # refer to the same Ruby objects the sub-components were initialised with,
97
- # so in-place +#clear!+ + +#bulk_load+ propagates through the entire
98
- # pipeline without re-instantiating sub-components.
99
- attr_reader :vector_store, :metadata_store, :graph_store
168
+ # Immutable retrieval pipeline captured per store bundle. Every component
169
+ # that references a store lives here, so swapping the bundle is ONE
170
+ # assignment: {#swap_stores!} replaces +@pipeline+ and every query
171
+ # resolves the pipeline once at the top of {#retrieve}. An in-flight query
172
+ # keeps the struct it resolved and finishes entirely against the old
173
+ # stores; a new query sees only the complete new bundle (M7 — build-then-
174
+ # swap, never clear!+bulk_load on live stores).
175
+ Pipeline = Struct.new(:executor, :ranker, :assembler,
176
+ :vector_store, :metadata_store, :graph_store,
177
+ keyword_init: true)
178
+ private_constant :Pipeline
179
+
180
+ # The live store handles. These delegate to the current pipeline —
181
+ # callers that want store contents read through these accessors, exactly
182
+ # as before; the difference is that a swap retires the old store objects
183
+ # instead of mutating them in place.
184
+ def vector_store = @pipeline.vector_store
185
+ def metadata_store = @pipeline.metadata_store
186
+ def graph_store = @pipeline.graph_store
187
+
188
+ # Read-only view of the current store bundle and the components wired to
189
+ # it (executor, ranker, assembler). Diagnostics/specs used to poke
190
+ # +@executor+/-style ivars directly; this is the supported equivalent.
191
+ # The reload transaction swaps the whole struct via {#swap_stores!}.
192
+ #
193
+ # @return [Pipeline]
194
+ attr_reader :pipeline
195
+
196
+ # Optional callback invoked with the pipeline struct the moment
197
+ # {#retrieve} resolves it, before any pipeline work runs. Nil in
198
+ # production. Observability seam for the reload transaction's old-or-new
199
+ # guarantee (M7): a test can block an in-flight query AFTER it captured
200
+ # the old bundle but BEFORE the swap lands, deterministically.
201
+ #
202
+ # @return [Proc, nil]
203
+ attr_accessor :pipeline_observer
100
204
 
101
205
  # @param vector_store [Storage::VectorStore::Interface] Vector store adapter
102
206
  # @param metadata_store [Storage::MetadataStore::Interface] Metadata store adapter
@@ -104,30 +208,75 @@ module Woods
104
208
  # @param embedding_provider [Embedding::Provider::Interface] Embedding provider
105
209
  # @param formatter [#call, nil] Optional callable to post-process the context string
106
210
  def initialize(vector_store:, metadata_store:, graph_store:, embedding_provider:, formatter: nil)
107
- @vector_store = vector_store
108
- @metadata_store = metadata_store
109
- @graph_store = graph_store
211
+ @embedding_provider = embedding_provider
110
212
  @formatter = formatter
111
-
112
213
  @classifier = Retrieval::QueryClassifier.new
113
- @executor = Retrieval::SearchExecutor.new(
214
+ @pipeline = build_pipeline(vector_store: vector_store,
215
+ metadata_store: metadata_store,
216
+ graph_store: graph_store)
217
+ end
218
+
219
+ # Replace the store bundle atomically (M7).
220
+ #
221
+ # Builds a complete new pipeline from +stores+ (fresh executor, ranker and
222
+ # assembler — a fresh ranker also carries no memoized PageRank from the
223
+ # retired graph) and swaps it in with a single assignment. Queries already
224
+ # running resolved the old pipeline and finish against the old stores;
225
+ # queries started afterwards resolve the new bundle. No query can observe
226
+ # an empty or half-swapped store set, because no store is ever mutated in
227
+ # place.
228
+ #
229
+ # Called by the MCP reload transaction AFTER its candidate stores passed
230
+ # the generation recheck, under the exclusive swap lock.
231
+ #
232
+ # @param vector_store [Storage::VectorStore::Interface]
233
+ # @param metadata_store [Storage::MetadataStore::Interface]
234
+ # @param graph_store [Storage::GraphStore::Interface]
235
+ # @return [self]
236
+ def swap_stores!(vector_store:, metadata_store:, graph_store:)
237
+ @pipeline = build_pipeline(vector_store: vector_store,
238
+ metadata_store: metadata_store,
239
+ graph_store: graph_store)
240
+ self
241
+ end
242
+
243
+ # Build one immutable pipeline around a store bundle.
244
+ #
245
+ # Components read through {TranslatedStore} facades so every store call
246
+ # they make raises the typed {StoreError} instead of a raw adapter error
247
+ # (MCP-6); the struct keeps the raw adapters for identity and capability
248
+ # checks.
249
+ def build_pipeline(vector_store:, metadata_store:, graph_store:)
250
+ translated_vector = translate_store(vector_store, :vector)
251
+ translated_metadata = translate_store(metadata_store, :metadata)
252
+ translated_graph = translate_store(graph_store, :graph)
253
+
254
+ Pipeline.new(
255
+ executor: Retrieval::SearchExecutor.new(
256
+ vector_store: translated_vector,
257
+ metadata_store: translated_metadata,
258
+ graph_store: translated_graph,
259
+ embedding_provider: @embedding_provider
260
+ ),
261
+ ranker: Retrieval::Ranker.new(metadata_store: translated_metadata, graph_store: translated_graph),
262
+ assembler: Retrieval::ContextAssembler.new(
263
+ metadata_store: translated_metadata,
264
+ chars_per_token: infer_chars_per_token(@embedding_provider),
265
+ token_counter: infer_token_counter(@embedding_provider)
266
+ ),
114
267
  vector_store: vector_store,
115
268
  metadata_store: metadata_store,
116
- graph_store: graph_store,
117
- embedding_provider: embedding_provider
118
- )
119
- @ranker = Retrieval::Ranker.new(metadata_store: metadata_store, graph_store: graph_store)
120
- # Match truncation sizing to the embedding provider's tokenizer so
121
- # Ollama-indexed corpora (ratio ~1.5) don't get over-truncated by
122
- # an OpenAI-sized default (4.0). Unknown/missing providers fall
123
- # back to the OpenAI-friendly default.
124
- chars_per_token = infer_chars_per_token(embedding_provider)
125
- @assembler = Retrieval::ContextAssembler.new(
126
- metadata_store: metadata_store,
127
- chars_per_token: chars_per_token,
128
- token_counter: infer_token_counter(embedding_provider)
269
+ graph_store: graph_store
129
270
  )
130
271
  end
272
+ private :build_pipeline
273
+
274
+ # Wrap one store adapter for the pipeline components. Nil stays nil — a
275
+ # nil graph store is a supported bundle shape.
276
+ def translate_store(store, name)
277
+ store && TranslatedStore.new(store, name)
278
+ end
279
+ private :translate_store
131
280
 
132
281
  # Infer the chars-per-token ratio from an embedding provider's model.
133
282
  # Ollama WordPiece-style tokenizers (nomic-embed-text, bge-*,
@@ -183,6 +332,16 @@ module Woods
183
332
  CHUNK_SUFFIX_PATTERN = /#chunk_\d+\z/
184
333
  private_constant :CHUNK_SUFFIX_PATTERN
185
334
 
335
+ # Maximum query size accepted by {#retrieve}, in bytes.
336
+ #
337
+ # Configuration/ResolvedConfig have no existing query-length knob
338
+ # (checked both) — +max_context_tokens+ bounds assembled OUTPUT
339
+ # context, not the input query. ~8KB comfortably covers any
340
+ # legitimate natural-language query and fails fast, in-process,
341
+ # instead of round-tripping an oversized string to an embedding or
342
+ # metadata provider just to get a 400 back.
343
+ MAX_QUERY_BYTES = 8 * 1024
344
+
186
345
  # Execute the full retrieval pipeline for a natural language query.
187
346
  #
188
347
  # Pipeline: classify -> execute -> rank -> filter -> (fallback within-type
@@ -200,18 +359,25 @@ module Woods
200
359
  # exclude. Applied on top of DEFAULT_EXCLUDE_TYPES unless +types:+ is set.
201
360
  # @return [RetrievalResult] Complete retrieval result
202
361
  def retrieve(query, budget: 8000, types: nil, exclude_types: nil)
362
+ validate_query!(query)
203
363
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
364
+ # One atomic read of the bundle reference: everything this query does
365
+ # from here on — execution, ranking, filtering, assembly — stays on the
366
+ # SAME store set even if a reload swaps the pipeline mid-flight (M7).
367
+ pipeline = @pipeline
368
+ @pipeline_observer&.call(pipeline)
204
369
  classification = @classifier.classify(query)
205
- execution_result = @executor.execute(query: query, classification: classification)
206
- ranked = @ranker.rank(execution_result.candidates, classification: classification)
370
+ execution_result = pipeline.executor.execute(query: query, classification: classification)
371
+ ranked = pipeline.ranker.rank(execution_result.candidates, classification: classification)
207
372
 
208
373
  type_list = normalize_type_list(types)
209
- filtered, fallback_ran = apply_type_filter(
210
- ranked, query, classification, types: types, type_list: type_list, exclude_types: exclude_types
211
- )
212
- type_rank_context = type_list ? build_type_rank_context(ranked, type_list, fallback_ran: fallback_ran) : nil
374
+ filtered, fallback_ran = apply_type_filter(pipeline, ranked, query, classification,
375
+ types: types, type_list: type_list,
376
+ exclude_types: exclude_types)
377
+ type_rank_context = build_type_rank_context_for(ranked, pipeline, type_list, filtered,
378
+ fallback_ran: fallback_ran)
213
379
 
214
- assembled = assemble_context(filtered, classification, budget)
380
+ assembled = assemble_context(pipeline, filtered, classification, budget)
215
381
  trace = build_trace(classification, execution_result, filtered, assembled, start_time)
216
382
 
217
383
  build_result(
@@ -222,6 +388,19 @@ module Woods
222
388
 
223
389
  private
224
390
 
391
+ # Validate +query+ before any classify/execute/rank work happens.
392
+ #
393
+ # @param query [Object] The caller-supplied query
394
+ # @raise [InvalidQueryError] nil, non-String, blank, or over {MAX_QUERY_BYTES}
395
+ # @return [void]
396
+ def validate_query!(query)
397
+ raise InvalidQueryError, "query must be a String, got #{query.class}" unless query.is_a?(String)
398
+ raise InvalidQueryError, 'query cannot be blank' if query.strip.empty?
399
+ return unless query.bytesize > MAX_QUERY_BYTES
400
+
401
+ raise InvalidQueryError, "query exceeds max size of #{MAX_QUERY_BYTES} bytes (got #{query.bytesize})"
402
+ end
403
+
225
404
  # Filter ranked candidates by type, using an include-list when +types+
226
405
  # is set and an exclude-list otherwise (default: +DEFAULT_EXCLUDE_TYPES+,
227
406
  # extended by any +exclude_types+ the caller adds).
@@ -231,18 +410,20 @@ module Woods
231
410
  # filter still works on graph-expansion candidates that carry no
232
411
  # vector-store metadata.
233
412
  #
413
+ # @param pipeline [Pipeline] the store bundle this query resolved (M7:
414
+ # every store access in the query flows from this snapshot)
234
415
  # @param candidates [Array<Candidate>]
235
416
  # @param types [Array<String, Symbol>, nil]
236
417
  # @param exclude_types [Array<String, Symbol>, nil]
237
418
  # @return [Array<Candidate>]
238
- def filter_by_type(candidates, types:, exclude_types:)
419
+ def filter_by_type(pipeline, candidates, types:, exclude_types:)
239
420
  allowed = normalize_type_list(types)
240
- return candidates.select { |c| allowed.include?(candidate_type(c)) } if allowed
421
+ return candidates.select { |c| allowed.include?(candidate_type(pipeline, c)) } if allowed
241
422
 
423
+ # DEFAULT_EXCLUDE_TYPES is always non-empty, so `excluded` here can
424
+ # never be empty — no early-return-candidates-unchanged branch exists.
242
425
  excluded = (normalize_type_list(exclude_types) || Set.new) | DEFAULT_EXCLUDE_TYPES.to_set
243
- return candidates if excluded.empty?
244
-
245
- candidates.reject { |c| excluded.include?(candidate_type(c)) }
426
+ candidates.reject { |c| excluded.include?(candidate_type(pipeline, c)) }
246
427
  end
247
428
 
248
429
  def normalize_type_list(list)
@@ -251,7 +432,7 @@ module Woods
251
432
  list.to_set(&:to_s)
252
433
  end
253
434
 
254
- def candidate_type(candidate)
435
+ def candidate_type(pipeline, candidate)
255
436
  inline = type_from_hash(candidate.metadata)
256
437
  return inline if inline
257
438
 
@@ -262,9 +443,12 @@ module Woods
262
443
  # missed lookup would let the candidate past the default-exclude
263
444
  # (type resolves to '', which +excluded+ never contains).
264
445
  lookup_id = candidate.identifier.to_s.sub(CHUNK_SUFFIX_PATTERN, '')
265
- type_from_hash(@metadata_store.find(lookup_id)) || ''
266
- rescue StandardError
267
- ''
446
+ type_from_hash(pipeline.metadata_store.find(lookup_id)) || ''
447
+ rescue StandardError => e
448
+ # M8: a failed lookup must not read as "type is ''" — that silently
449
+ # disables exclusion filtering. Raise the shared typed store error.
450
+ raise StoreError,
451
+ "metadata store lookup failed while resolving a candidate type: #{e.class}: #{e.message}"
268
452
  end
269
453
 
270
454
  def type_from_hash(hash)
@@ -276,14 +460,15 @@ module Woods
276
460
 
277
461
  # Assemble token-budgeted context from ranked candidates.
278
462
  #
463
+ # @param pipeline [Pipeline] the resolved store bundle
279
464
  # @param ranked [Array<Candidate>] Ranked search candidates
280
465
  # @param classification [QueryClassifier::Classification] Query classification
281
466
  # @return [AssembledContext]
282
- def assemble_context(ranked, classification, budget)
283
- @assembler.assemble(
467
+ def assemble_context(pipeline, ranked, classification, budget)
468
+ pipeline.assembler.assemble(
284
469
  candidates: ranked,
285
470
  classification: classification,
286
- structural_context: build_structural_context,
471
+ structural_context: build_structural_context(pipeline.metadata_store),
287
472
  budget: budget
288
473
  )
289
474
  end
@@ -315,11 +500,11 @@ module Woods
315
500
  # passed a type filter and the global top-K had no candidate of the
316
501
  # requested type(s). Returns +[filtered, fallback_ran]+ — the second
317
502
  # element drives the :source field on type_rank_context.
318
- def apply_type_filter(ranked, query, classification, types:, type_list:, exclude_types:)
319
- filtered = filter_by_type(ranked, types: types, exclude_types: exclude_types)
503
+ def apply_type_filter(pipeline, ranked, query, classification, types:, type_list:, exclude_types:)
504
+ filtered = filter_by_type(pipeline, ranked, types: types, exclude_types: exclude_types)
320
505
  return [filtered, false] unless type_list && filtered.empty?
321
506
 
322
- [within_type_fallback(query, classification, type_list, exclude_types), true]
507
+ [within_type_fallback(pipeline, query, classification, type_list, exclude_types), true]
323
508
  end
324
509
 
325
510
  # Rank-within-type fallback query. Pushes the explicit type filter
@@ -337,16 +522,16 @@ module Woods
337
522
  # Short-circuits to an empty Array when every requested type has
338
523
  # zero units in the index — there is nothing for the fallback to
339
524
  # find, so we skip the extra vector search.
340
- def within_type_fallback(query, classification, type_list, exclude_types)
525
+ def within_type_fallback(pipeline, query, classification, type_list, exclude_types)
341
526
  type_array = type_list.to_a
342
- return [] if type_array.all? { |t| total_of_type(t).to_i.zero? }
527
+ return [] if type_array.all? { |t| total_of_type(pipeline.metadata_store, t).to_i.zero? }
343
528
 
344
- fallback = @executor.execute(
529
+ fallback = pipeline.executor.execute(
345
530
  query: query, classification: classification,
346
531
  type_filter: type_array, strategy: :vector
347
532
  )
348
- ranked = @ranker.rank(fallback.candidates, classification: classification)
349
- filter_by_type(ranked, types: type_array, exclude_types: exclude_types)
533
+ ranked = pipeline.ranker.rank(fallback.candidates, classification: classification)
534
+ filter_by_type(pipeline, ranked, types: type_array, exclude_types: exclude_types)
350
535
  end
351
536
 
352
537
  def build_trace(classification, execution_result, filtered, assembled, start_time)
@@ -357,10 +542,26 @@ module Woods
357
542
  candidate_count: execution_result.candidates.size,
358
543
  ranked_count: filtered.size,
359
544
  tokens_used: assembled.tokens_used,
360
- elapsed_ms: elapsed_ms
545
+ elapsed_ms: elapsed_ms,
546
+ skipped_missing_metadata: assembled.skipped_missing_metadata.to_i
361
547
  )
362
548
  end
363
549
 
550
+ # Per-type rank metadata for +types:+ queries; nil on unfiltered queries.
551
+ #
552
+ # @param ranked [Array<Candidate>]
553
+ # @param pipeline [Pipeline] the resolved store bundle (M7)
554
+ # @param type_list [Set<String>, nil]
555
+ # @param filtered [Array<Candidate>] The post-fallback candidate list
556
+ # @param fallback_ran [Boolean] Whether rank-within-type fallback ran
557
+ # @return [Hash{String => Hash}, nil]
558
+ def build_type_rank_context_for(ranked, pipeline, type_list, filtered, fallback_ran:)
559
+ return nil unless type_list
560
+
561
+ build_type_rank_context(ranked, pipeline.metadata_store, type_list, filtered,
562
+ fallback_ran: fallback_ran)
563
+ end
564
+
364
565
  # Build per-type rank metadata from the unfiltered global ranked list.
365
566
  #
366
567
  # +top_of_type_global_rank+ is the 1-based position of the first
@@ -372,19 +573,25 @@ module Woods
372
573
  # see the RetrievalResult docstring for the four-value enum.
373
574
  #
374
575
  # @param ranked [Array<Candidate>]
576
+ # @param metadata_store [Storage::MetadataStore::Interface] the resolved
577
+ # bundle's metadata store (M7)
375
578
  # @param type_list [Set<String>]
579
+ # @param filtered [Array<Candidate>] The post-fallback candidate list
580
+ # {#retrieve} is about to assemble — used to confirm the fallback
581
+ # actually surfaced a candidate OF this specific type, not just that
582
+ # fallback ran.
376
583
  # @param fallback_ran [Boolean] Whether rank-within-type fallback ran
377
584
  # @return [Hash{String => Hash}]
378
- def build_type_rank_context(ranked, type_list, fallback_ran:)
585
+ def build_type_rank_context(ranked, metadata_store, type_list, filtered, fallback_ran:)
379
586
  global_k = ranked.size
380
587
  type_list.to_h do |type|
381
- match_index = ranked.index { |c| candidate_type(c) == type }
588
+ match_index = ranked.index { |c| candidate_type_from(metadata_store, c) == type }
382
589
  top_rank = match_index ? match_index + 1 : nil
383
- total = total_of_type(type)
590
+ total = total_of_type(metadata_store, type)
384
591
  [
385
592
  type,
386
593
  {
387
- source: type_source(top_rank, total, fallback_ran: fallback_ran),
594
+ source: type_source(top_rank, total, metadata_store, filtered, type, fallback_ran: fallback_ran),
388
595
  top_of_type_global_rank: top_rank,
389
596
  global_k: global_k,
390
597
  total_of_type: total
@@ -395,18 +602,44 @@ module Woods
395
602
 
396
603
  # Pick the :source enum value for a single type based on where its
397
604
  # candidate ended up. See RetrievalResult's docstring for the enum.
398
- def type_source(top_rank, total, fallback_ran:)
605
+ #
606
+ # +:within_type_fallback+ requires the fallback to have actually
607
+ # returned a candidate of THIS type — a multi-type fallback (e.g.
608
+ # +types: %w[service mailer]+) can run and surface candidates for only
609
+ # some of the requested types, and the type(s) it missed are
610
+ # +:outside_top_k+, not falsely reported as a weak fallback match.
611
+ def type_source(top_rank, total, metadata_store, filtered, type, fallback_ran:)
399
612
  return :in_top_k if top_rank
400
613
  return :absent if total.to_i.zero?
401
- return :within_type_fallback if fallback_ran
614
+ return :within_type_fallback if fallback_ran && filtered.any? do |c|
615
+ candidate_type_from(metadata_store, c) == type
616
+ end
402
617
 
403
618
  :outside_top_k
404
619
  end
405
620
 
406
- def total_of_type(type)
407
- @metadata_store.find_by_type(type).size
408
- rescue StandardError
409
- nil
621
+ def total_of_type(metadata_store, type)
622
+ metadata_store.find_by_type(type).size
623
+ rescue StandardError => e
624
+ # M8: a failed count must not read as zero — that reports :absent for
625
+ # a type that may exist and short-circuits the within-type fallback.
626
+ raise StoreError,
627
+ "metadata store count failed for type #{type.inspect}: #{e.class}: #{e.message}"
628
+ end
629
+
630
+ # Chunk-suffix-stripping candidate-type probe against an explicit
631
+ # metadata store. Same lookup rules as {#candidate_type}; takes the
632
+ # store as an argument so a caller holding a resolved bundle snapshot
633
+ # (M7) probes the generation it started on.
634
+ def candidate_type_from(metadata_store, candidate)
635
+ inline = type_from_hash(candidate.metadata)
636
+ return inline if inline
637
+
638
+ lookup_id = candidate.identifier.to_s.sub(CHUNK_SUFFIX_PATTERN, '')
639
+ type_from_hash(metadata_store.find(lookup_id)) || ''
640
+ rescue StandardError => e
641
+ raise StoreError,
642
+ "metadata store lookup failed while resolving a candidate type: #{e.class}: #{e.message}"
410
643
  end
411
644
 
412
645
  # Append a compact markdown summary of +type_rank_context+ to the
@@ -439,20 +672,33 @@ module Woods
439
672
  # spot the searchable-entries vs unit-count discrepancy know which
440
673
  # tool carries the canonical unit totals (issue #105).
441
674
  #
442
- # @return [String, nil] Overview string, or nil if the store is empty or on error
443
- def build_structural_context
444
- total = @metadata_store.count
675
+ # @param metadata_store [Storage::MetadataStore::Interface] the resolved
676
+ # bundle's metadata store (M7)
677
+ # @return [String, nil] Overview string, or nil if the store is empty
678
+ # @raise [StoreError] the metadata store failed
679
+ def build_structural_context(metadata_store)
680
+ total = metadata_store.count
445
681
  return nil if total.zero?
446
682
 
447
683
  type_counts = STRUCTURAL_TYPES.filter_map do |type|
448
- count = @metadata_store.find_by_type(type).size
684
+ count = metadata_store.find_by_type(type).size
449
685
  "#{count} #{type} entries" if count.positive?
450
686
  end
451
687
 
452
688
  "Codebase: #{total} searchable entries (#{type_counts.join(', ')}). " \
453
689
  'Entries include per-chunk rows for chunked units; see `structure` for canonical unit counts.'
454
- rescue StandardError
455
- nil
690
+ rescue StoreError
691
+ raise
692
+ rescue StandardError => e
693
+ # MCP-6: this used to rescue to nil — the last swallow-to-empty on the
694
+ # query path. A broken metadata store dropped the banner and every
695
+ # answer looked like a healthy codebase with nothing to say about
696
+ # itself. It is a metadata read like any other; raise the shared typed
697
+ # store error so the MCP boundary can report degraded_index.
698
+ raise StoreError.new(
699
+ "metadata store failed while building the structural overview: #{e.class}: #{e.message}",
700
+ store: :metadata
701
+ )
456
702
  end
457
703
  end
458
704
  end
@@ -12,13 +12,33 @@ module Woods
12
12
  # back off. This helper handles both forms and falls back to a caller-supplied
13
13
  # value when the header is absent or unparseable.
14
14
  module RetryAfter
15
+ # Ceiling on an honoured +Retry-After+, in seconds.
16
+ #
17
+ # The header is server-controlled, and nothing stops it saying `86400` or
18
+ # naming a date next week. A client that sleeps for exactly as long as it
19
+ # is told hands a buggy or hostile server the ability to park a sync
20
+ # indefinitely — an export run would sit in `sleep` for days with no
21
+ # output. Capping keeps the back-off honest without ignoring the server's
22
+ # intent: past this point, retrying later is the operator's call, not the
23
+ # process's.
24
+ #
25
+ # 120s matches the embedding providers' cap (#188).
26
+ MAX_SECONDS = 120.0
27
+
15
28
  module_function
16
29
 
17
30
  # @param header [String, nil] the raw +Retry-After+ header value
18
31
  # @param fallback [Numeric] seconds to use when the header is missing or unparseable
19
32
  # @param now [Time] reference time for the HTTP-date form (injectable for tests)
20
- # @return [Float] non-negative seconds to wait
21
- def seconds(header, fallback:, now: Time.now)
33
+ # @param max [Numeric] ceiling on the returned wait. Pass +Float::INFINITY+
34
+ # to honour the header no matter how large.
35
+ # @return [Float] non-negative seconds to wait, never more than +max+
36
+ def seconds(header, fallback:, now: Time.now, max: MAX_SECONDS)
37
+ [raw_seconds(header, fallback: fallback, now: now), max.to_f].min
38
+ end
39
+
40
+ # The header's own value, uncapped.
41
+ def raw_seconds(header, fallback:, now: Time.now)
22
42
  value = header.to_s.strip
23
43
  return fallback.to_f if value.empty?
24
44
 
@@ -33,28 +33,28 @@ module Woods
33
33
  def analyze(source:, file_path:)
34
34
  root = @parser.parse(source)
35
35
  units = []
36
- extract_definitions(root, source, file_path, [], units)
36
+ extract_definitions(root, source, file_path, [], units, [])
37
37
  units
38
38
  end
39
39
 
40
40
  private
41
41
 
42
- def extract_definitions(node, source, file_path, namespace_stack, units)
42
+ def extract_definitions(node, source, file_path, namespace_stack, units, scopes)
43
43
  return unless node.is_a?(Ast::Node)
44
44
 
45
45
  case node.type
46
46
  when :class
47
- process_definition(node, :ruby_class, source, file_path, namespace_stack, units)
47
+ process_definition(node, :ruby_class, source, file_path, namespace_stack, units, scopes)
48
48
  when :module
49
- process_definition(node, :ruby_module, source, file_path, namespace_stack, units)
49
+ process_definition(node, :ruby_module, source, file_path, namespace_stack, units, scopes)
50
50
  else
51
51
  (node.children || []).each do |child|
52
- extract_definitions(child, source, file_path, namespace_stack, units)
52
+ extract_definitions(child, source, file_path, namespace_stack, units, scopes)
53
53
  end
54
54
  end
55
55
  end
56
56
 
57
- def process_definition(node, type, source, file_path, namespace_stack, units)
57
+ def process_definition(node, type, source, file_path, namespace_stack, units, scopes)
58
58
  name = node.method_name
59
59
  fqn = build_fqn(name, namespace_stack)
60
60
  namespace = build_namespace(name, namespace_stack)
@@ -70,6 +70,7 @@ module Woods
70
70
  unit.namespace = namespace
71
71
  unit.source_code = extract_source(node, source)
72
72
  unit.metadata = {
73
+ lexical_scopes: [fqn] + scopes,
73
74
  superclass: superclass,
74
75
  includes: includes,
75
76
  extends: extends,
@@ -80,24 +81,19 @@ module Woods
80
81
  units << unit
81
82
 
82
83
  # Recurse into body for nested definitions
83
- inner_ns = namespace_stack + fqn_parts(name)
84
+ inner_ns = fqn.split('::')
84
85
  children.each do |child|
85
- extract_definitions(child, source, file_path, inner_ns, units)
86
+ extract_definitions(child, source, file_path, inner_ns, units, [fqn] + scopes)
86
87
  end
87
88
  end
88
89
 
89
90
  # Build namespace string (everything except the leaf name).
90
91
  def build_namespace(name, namespace_stack)
91
- parts = namespace_stack + fqn_parts(name)
92
+ parts = build_fqn(name, namespace_stack).split('::')
92
93
  parts.pop # Remove leaf
93
94
  parts.empty? ? nil : parts.join('::')
94
95
  end
95
96
 
96
- # Split a name that may contain :: into parts.
97
- def fqn_parts(name)
98
- name.to_s.split('::')
99
- end
100
-
101
97
  # Extract superclass name from a class node.
102
98
  # Children[0] is name, children[1] is superclass (or nil).
103
99
  def extract_superclass(class_node)