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,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'base64'
4
+
3
5
  module Woods
4
6
  module SessionTracer
5
7
  # Abstract store interface for session trace data.
@@ -60,7 +62,18 @@ module Woods
60
62
  # @param session_id [String] Raw session identifier
61
63
  # @return [String] Sanitized identifier (alphanumeric, hyphens, underscores only)
62
64
  def sanitize_session_id(session_id)
63
- session_id.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
65
+ raw = session_id.to_s
66
+ raise ArgumentError, 'session_id exceeds 180 bytes' if raw.bytesize > 180
67
+
68
+ "b64.#{Base64.urlsafe_encode64(raw, padding: false)}"
69
+ end
70
+
71
+ def restore_session_id(encoded)
72
+ return encoded unless encoded.start_with?('b64.')
73
+
74
+ Base64.urlsafe_decode64(encoded.delete_prefix('b64.'))
75
+ rescue ArgumentError
76
+ encoded
64
77
  end
65
78
 
66
79
  # Build a session summary hash from a session ID and its requests.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'fileutils'
4
5
  require 'time'
5
6
 
6
7
  module Woods
@@ -18,11 +19,20 @@ module Woods
18
19
  # its methods. The {SQLite} adapter is provided for local persistence.
19
20
  #
20
21
  # @example Using the SQLite adapter
21
- # store = Woods::Storage::MetadataStore::SQLite.new(":memory:")
22
+ # store = Woods::Storage::MetadataStore::SQLite.new(database: ":memory:")
22
23
  # store.store("User", { type: "model", file_path: "app/models/user.rb" })
23
24
  # store.find("User")
24
25
  #
25
26
  module MetadataStore
27
+ # Conservative whitelist for caller-supplied search field names —
28
+ # mirrors the filter-key standard in {Woods::Storage::Pgvector#build_where}.
29
+ # The SQLite adapter interpolates field names into a
30
+ # `json_extract(data, '$.<field>')` JSON-path literal, so anything
31
+ # outside this charset must be rejected before any SQL is built: a
32
+ # crafted field name (quotes, parens, `--`) can otherwise break out of
33
+ # the string literal and alter the query shape.
34
+ SEARCH_FIELD_NAME = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
35
+
26
36
  # Interface that all metadata store adapters must implement.
27
37
  module Interface
28
38
  # Store or update metadata for a unit.
@@ -68,6 +78,30 @@ module Woods
68
78
 
69
79
  # Search metadata by text query across specified fields.
70
80
  #
81
+ # **Contract every adapter must honour** (pinned by the
82
+ # `'hardened search'` shared examples in
83
+ # spec/storage/metadata_store_spec.rb, which run against every
84
+ # adapter — they were substitutable in name only until the semantics
85
+ # were written down):
86
+ #
87
+ # - **Substring match**, not word or prefix match.
88
+ # - **Case-insensitive.** InMemory used a case-sensitive
89
+ # `String#include?` while SQLite used `LIKE`, so the same query
90
+ # returned different results depending on the configured backend.
91
+ # Case-insensitive is both the search-like expectation and what the
92
+ # durable adapter already did. (SQLite's `LIKE` folds ASCII only, so
93
+ # non-ASCII case folding remains backend-specific — do not rely on
94
+ # it either way.)
95
+ # - **`fields: nil` searches the whole record**, including keys, as
96
+ # serialized JSON. A query can therefore match a field *name*.
97
+ # - **LIKE metacharacters are literal.** `%` and `_` in a query match
98
+ # themselves rather than acting as wildcards.
99
+ # - Field names are validated against {SEARCH_FIELD_NAME} by every
100
+ # adapter, so a hostile name raises the same ArgumentError
101
+ # everywhere.
102
+ # - **An empty `fields` list matches nothing.** Adapters return an
103
+ # empty result before querying their backing store.
104
+ #
71
105
  # @param query [String] Text to search for
72
106
  # @param fields [Array<String>, nil] Specific fields to search (nil = all)
73
107
  # @return [Array<Hash>] Matching metadata records
@@ -76,6 +110,21 @@ module Woods
76
110
  raise NotImplementedError
77
111
  end
78
112
 
113
+ # Every stored identifier.
114
+ #
115
+ # Used by the evaluation harness's {Woods::Evaluation::BaselineRunner}
116
+ # to build naive baselines (grep/random/file-level) to score the real
117
+ # retrieval pipeline against. It called this on the configured store
118
+ # while no adapter implemented it, so every baseline strategy raised
119
+ # NoMethodError — invisible because the harness had no working entry
120
+ # point either (#212).
121
+ #
122
+ # @return [Array<String>] All identifiers, order unspecified
123
+ # @raise [NotImplementedError] if not implemented by adapter
124
+ def all_identifiers
125
+ raise NotImplementedError
126
+ end
127
+
79
128
  # Delete a unit by ID.
80
129
  #
81
130
  # @param id [String] The identifier to delete
@@ -91,6 +140,30 @@ module Woods
91
140
  def count
92
141
  raise NotImplementedError
93
142
  end
143
+
144
+ private
145
+
146
+ # Validate caller-supplied search field names against
147
+ # {SEARCH_FIELD_NAME}, stringifying as it goes (symbol fields were
148
+ # always accepted). Every adapter's {#search} must run its `fields:`
149
+ # through this BEFORE touching its backing store, so a hostile field
150
+ # name raises the same {ArgumentError} everywhere — including
151
+ # adapters (InMemory) where the name is not an injection vector,
152
+ # keeping the adapters substitutable.
153
+ #
154
+ # @param fields [Array<String, Symbol>, nil] Field names, or nil for "all fields"
155
+ # @return [Array<String>, nil] Stringified field names, or nil if fields was nil
156
+ # @raise [ArgumentError] if any field name fails the whitelist
157
+ def validate_search_fields!(fields)
158
+ return nil if fields.nil?
159
+
160
+ fields.map do |field|
161
+ field_s = field.to_s
162
+ raise ArgumentError, "Invalid search field: #{field_s.inspect}" unless field_s.match?(SEARCH_FIELD_NAME)
163
+
164
+ field_s
165
+ end
166
+ end
94
167
  end
95
168
 
96
169
  # Pure-Ruby metadata store backed by a hash. No external dependencies,
@@ -113,7 +186,7 @@ module Woods
113
186
 
114
187
  # @see Interface#store
115
188
  def store(id, metadata)
116
- @data[id] = stringify_keys(metadata).merge('updated_at' => Time.now.iso8601)
189
+ @data[id] = normalize(metadata).merge('updated_at' => Time.now.iso8601)
117
190
  end
118
191
 
119
192
  # @see Interface#find
@@ -143,16 +216,43 @@ module Woods
143
216
  end
144
217
 
145
218
  # @see Interface#search
219
+ #
220
+ # Matching is literal substring inclusion — `%` and `_` in the query
221
+ # have no special meaning here, and the SQLite adapter escapes them
222
+ # so the two adapters agree.
223
+ #
224
+ # @raise [ArgumentError] if a field name fails {SEARCH_FIELD_NAME}
146
225
  def search(query, fields: nil)
147
- needle = query.to_s
226
+ fields = validate_search_fields!(fields)
227
+ return [] if fields == []
228
+
229
+ # Case-insensitive, matching the SQLite adapter's `LIKE` (see the
230
+ # contract note on {Interface#search}). This used to be a
231
+ # case-sensitive `String#include?`, so the same query returned
232
+ # different results depending on which backend a host had configured.
233
+ needle = query.to_s.downcase
148
234
  @data.each_with_object([]) do |(id, record), out|
149
- haystacks = fields ? fields.map { |f| record[f.to_s] } : [JSON.generate(record)]
150
- next unless haystacks.compact.any? { |h| h.to_s.include?(needle) }
235
+ # `updated_at` is excluded from the whole-record haystack: SQLite's
236
+ # `data` column never carries it (it's a store-level column there,
237
+ # not part of the JSON blob), so leaving it in here made a query
238
+ # that only matched a timestamp (e.g. "2026-08") return every
239
+ # record on InMemory and none on SQLite.
240
+ haystacks = if fields
241
+ fields.map { |f| field_haystack(record[f]) }
242
+ else
243
+ [JSON.generate(record.except('updated_at'))]
244
+ end
245
+ next unless haystacks.compact.any? { |h| h.downcase.include?(needle) }
151
246
 
152
247
  out << record.except('updated_at').merge('id' => id)
153
248
  end
154
249
  end
155
250
 
251
+ # @see Interface#all_identifiers
252
+ def all_identifiers
253
+ @data.keys
254
+ end
255
+
156
256
  # @see Interface#delete
157
257
  def delete(id)
158
258
  @data.delete(id)
@@ -196,13 +296,37 @@ module Woods
196
296
 
197
297
  private
198
298
 
199
- # Match the SQLite adapter's string-key contract regardless of how
200
- # the caller serialises the input hash. Without this, find/search
201
- # consumers that expect string keys (the SQLite path round-trips
202
- # through JSON, which always returns strings) would break under
203
- # symbol-keyed test fixtures.
204
- def stringify_keys(hash)
205
- hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
299
+ # Match the SQLite adapter's contract regardless of how the caller
300
+ # serialises the input hash.
301
+ #
302
+ # The SQLite path stores +JSON.generate(metadata)+ and reads it back
303
+ # with +JSON.parse+, so every key *and value* comes back as JSON
304
+ # types: symbols become strings, nested hashes are stringified all the
305
+ # way down. Stringifying only the top-level keys left the two adapters
306
+ # returning different values for the same input (STO-8), so the round
307
+ # trip is performed here too rather than approximated.
308
+ #
309
+ # @param metadata [Hash]
310
+ # @return [Hash] string-keyed, JSON-typed values
311
+ def normalize(metadata)
312
+ JSON.parse(JSON.generate(metadata))
313
+ end
314
+
315
+ # The text SQLite's +json_extract(data, '$.field')+ would compare
316
+ # against for one field value: strings come back raw, structured
317
+ # values as their JSON text, scalars as their decimal form. A Ruby
318
+ # +Hash#to_s+ haystack used to leak `=>` and `:sym` syntax that no
319
+ # JSON document contains (STO-8).
320
+ #
321
+ # @param value [Object] the stored field value
322
+ # @return [String, nil] nil for a missing field, which never matches
323
+ def field_haystack(value)
324
+ case value
325
+ when nil then nil
326
+ when String then value
327
+ when Hash, Array then JSON.generate(value)
328
+ else value.to_s
329
+ end
206
330
  end
207
331
  end
208
332
 
@@ -212,15 +336,19 @@ module Woods
212
336
  # for efficient filtering. Uses upsert semantics for store operations.
213
337
  #
214
338
  # @example
215
- # store = SQLite.new(":memory:")
339
+ # store = SQLite.new(database: ":memory:")
216
340
  # store.store("User", { type: "model", namespace: "Admin" })
217
341
  # store.find("User") # => { "type" => "model", "namespace" => "Admin" }
218
342
  #
219
- class SQLite
343
+ class SQLite # rubocop:disable Metrics/ClassLength
220
344
  include Interface
221
345
 
222
- # @param db_path [String] Path to the SQLite database file, or ":memory:" for in-memory
223
- def initialize(db_path = ':memory:')
346
+ # Attempts allowed for a contended write, including the first.
347
+ # Mirrors {Woods::Temporal::SnapshotStore::LOCK_ATTEMPTS}.
348
+ LOCK_ATTEMPTS = 3
349
+
350
+ # @param database [String, Pathname] Database file, or ":memory:" for an explicit in-memory store
351
+ def initialize(database:)
224
352
  begin
225
353
  require 'sqlite3'
226
354
  rescue LoadError
@@ -229,21 +357,42 @@ module Woods
229
357
  "Add `gem 'sqlite3'` and re-bundle, or set " \
230
358
  "`config.metadata_store = :in_memory` if you don't need cross-process persistence."
231
359
  end
232
- @db = ::SQLite3::Database.new(db_path)
360
+ database = database.to_s
361
+ FileUtils.mkdir_p(File.dirname(File.expand_path(database))) unless database == ':memory:'
362
+ @db = ::SQLite3::Database.new(database)
233
363
  @db.results_as_hash = true
364
+ # SQLite's busy handler only helps when it is set. Two processes
365
+ # sharing one metadata database (a long extraction while an MCP
366
+ # server stores a checkpoint) used to raise SQLite3::BusyException
367
+ # immediately on the second writer (O2). Same value as the twin
368
+ # {Woods::Temporal::SnapshotStore}.
369
+ @db.busy_timeout = 5_000
234
370
  create_table
235
371
  end
236
372
 
237
373
  # @see Interface#store
374
+ # @raise [ArgumentError] if +metadata+ carries no type key
375
+ #
376
+ # The type column backs {#find_by_type}, so an absent key used to
377
+ # fall through +type.to_s+ and be stored as +""+ — fabricating a
378
+ # type where none existed rather than surfacing the missing field
379
+ # (L22). A present-but-blank type fabricates exactly the same empty
380
+ # column, so it is refused on the same grounds (STO-9).
238
381
  def store(id, metadata)
239
382
  type = metadata[:type] || metadata['type']
383
+ if type.nil? || type.to_s.strip.empty?
384
+ raise ArgumentError, "metadata for #{id.inspect} has no usable type key (got #{type.inspect})"
385
+ end
386
+
240
387
  data = JSON.generate(metadata)
241
388
 
242
- @db.execute(<<~SQL, [id, type.to_s, data, Time.now.iso8601])
243
- INSERT INTO units (id, type, data, updated_at) VALUES (?, ?, ?, ?)
244
- ON CONFLICT(id) DO UPDATE SET
245
- type = excluded.type, data = excluded.data, updated_at = excluded.updated_at
246
- SQL
389
+ with_lock_retry do
390
+ @db.execute(<<~SQL, [id, type.to_s, data, Time.now.iso8601])
391
+ INSERT INTO units (id, type, data, updated_at) VALUES (?, ?, ?, ?)
392
+ ON CONFLICT(id) DO UPDATE SET
393
+ type = excluded.type, data = excluded.data, updated_at = excluded.updated_at
394
+ SQL
395
+ end
247
396
  end
248
397
 
249
398
  # @see Interface#find
@@ -272,21 +421,42 @@ module Woods
272
421
  end
273
422
 
274
423
  # @see Interface#search
424
+ #
425
+ # Field names are interpolated into a `json_extract` JSON-path
426
+ # literal, so they are validated against {SEARCH_FIELD_NAME} first —
427
+ # a crafted name could otherwise break out of the literal and alter
428
+ # the SQL shape. LIKE metacharacters (`%`, `_`, `\`) in the query are
429
+ # escaped (with an explicit ESCAPE clause) so they match literally,
430
+ # aligning with the InMemory adapter's substring semantics instead of
431
+ # silently broadening matches.
432
+ #
433
+ # @raise [ArgumentError] if a field name fails the whitelist
275
434
  def search(query, fields: nil)
435
+ fields = validate_search_fields!(fields)
436
+ return [] if fields == []
437
+
438
+ pattern = "%#{escape_like(query)}%"
276
439
  if fields
277
- conditions = fields.map { "json_extract(data, '$.#{_1}') LIKE ?" }.join(' OR ')
278
- params = fields.map { "%#{query}%" }
440
+ conditions = fields.map { "json_extract(data, '$.#{_1}') LIKE ? ESCAPE '\\'" }.join(' OR ')
441
+ params = Array.new(fields.size, pattern)
279
442
  rows = @db.execute("SELECT id, data FROM units WHERE #{conditions}", params)
280
443
  else
281
- rows = @db.execute('SELECT id, data FROM units WHERE data LIKE ?', ["%#{query}%"])
444
+ rows = @db.execute("SELECT id, data FROM units WHERE data LIKE ? ESCAPE '\\'", [pattern])
282
445
  end
283
446
 
284
447
  rows.map { |row| parse_row(row) }
285
448
  end
286
449
 
450
+ # @see Interface#all_identifiers
451
+ def all_identifiers
452
+ @db.execute('SELECT id FROM units').map { |row| row['id'] }
453
+ end
454
+
287
455
  # @see Interface#delete
288
456
  def delete(id)
289
- @db.execute('DELETE FROM units WHERE id = ?', [id])
457
+ with_lock_retry do
458
+ @db.execute('DELETE FROM units WHERE id = ?', [id])
459
+ end
290
460
  end
291
461
 
292
462
  # @see Interface#count
@@ -296,6 +466,40 @@ module Woods
296
466
 
297
467
  private
298
468
 
469
+ # Bounded retry around a contended write, mirroring
470
+ # {Woods::Temporal::SnapshotStore#with_lock_retry}: SQLite skips the
471
+ # busy handler when it detects a lock-order deadlock, so a write can
472
+ # raise "database is locked" at once despite the busy timeout. A
473
+ # short bounded retry lets the other writer finish; anything else,
474
+ # or a non-SQLite connection, raises straight through.
475
+ def with_lock_retry
476
+ attempt = 0
477
+ begin
478
+ attempt += 1
479
+ yield
480
+ rescue StandardError => e
481
+ raise unless sqlite_busy?(e) && attempt < LOCK_ATTEMPTS
482
+
483
+ sleep(0.05 * attempt)
484
+ retry
485
+ end
486
+ end
487
+
488
+ def sqlite_busy?(error)
489
+ defined?(SQLite3::BusyException) && error.is_a?(SQLite3::BusyException)
490
+ end
491
+
492
+ # Escape SQL LIKE metacharacters in a user query so they match
493
+ # literally under the `ESCAPE '\'` clause {#search} emits. Without
494
+ # this, `%` and `_` in a query act as wildcards and silently broaden
495
+ # matches (`"user_name"` would match `"userXname"`).
496
+ #
497
+ # @param query [String] Raw search query
498
+ # @return [String] Query safe for embedding in a LIKE pattern
499
+ def escape_like(query)
500
+ query.to_s.gsub(/[\\%_]/) { |ch| "\\#{ch}" }
501
+ end
502
+
299
503
  # Parse a database row into a metadata hash with the id field injected.
300
504
  #
301
505
  # @param row [Hash] Database row with 'id' and 'data' keys