woods 1.6.4 → 2.0.0.beta1

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 (282) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1879 -37
  3. data/CONTRIBUTING.md +195 -137
  4. data/README.md +162 -520
  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 +620 -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 +415 -0
  19. data/docs/INTERNALS.md +415 -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 +197 -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 +39 -3
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +78 -24
  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 +40 -1
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +18 -13
  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/credential_index.rb +5 -53
  64. data/lib/woods/console/credential_scanner.rb +15 -16
  65. data/lib/woods/console/dispatch_pipeline.rb +46 -34
  66. data/lib/woods/console/embedded_executor.rb +806 -257
  67. data/lib/woods/console/eval_guard.rb +27 -20
  68. data/lib/woods/console/input_contract.rb +78 -0
  69. data/lib/woods/console/model_validator.rb +24 -6
  70. data/lib/woods/console/rack_middleware.rb +62 -63
  71. data/lib/woods/console/redactor.rb +10 -24
  72. data/lib/woods/console/safe_context.rb +45 -45
  73. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  74. data/lib/woods/console/server.rb +136 -267
  75. data/lib/woods/console/sql_noise_stripper.rb +20 -51
  76. data/lib/woods/console/sql_table_scanner.rb +39 -90
  77. data/lib/woods/console/sql_validator.rb +455 -85
  78. data/lib/woods/console/table_gate.rb +2 -2
  79. data/lib/woods/console/tool_specs.rb +462 -88
  80. data/lib/woods/console/tools/tier1.rb +0 -3
  81. data/lib/woods/console/tools/tier4.rb +17 -7
  82. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  83. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  84. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  85. data/lib/woods/db/migrator.rb +3 -9
  86. data/lib/woods/db/schema_version.rb +47 -2
  87. data/lib/woods/dependency_graph.rb +898 -64
  88. data/lib/woods/embedding/fake.rb +138 -0
  89. data/lib/woods/embedding/indexer.rb +832 -40
  90. data/lib/woods/embedding/openai.rb +77 -19
  91. data/lib/woods/embedding/provider.rb +189 -11
  92. data/lib/woods/embedding/text_preparer.rb +1 -1
  93. data/lib/woods/embedding/token_counter.rb +0 -7
  94. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  95. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  96. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  97. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  98. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  99. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  100. data/lib/woods/evaluation/ablation_task.rb +66 -0
  101. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  102. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  103. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  104. data/lib/woods/evaluation/baseline.rb +60 -0
  105. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  106. data/lib/woods/evaluation/evaluator.rb +41 -8
  107. data/lib/woods/evaluation/query_set.rb +79 -13
  108. data/lib/woods/evaluation/report_generator.rb +20 -1
  109. data/lib/woods/export/unit_facts.rb +0 -11
  110. data/lib/woods/extracted_unit.rb +22 -63
  111. data/lib/woods/extractor.rb +2503 -192
  112. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  113. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  114. data/lib/woods/extractors/caching_extractor.rb +46 -12
  115. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  116. data/lib/woods/extractors/component_discovery.rb +123 -0
  117. data/lib/woods/extractors/concern_extractor.rb +17 -3
  118. data/lib/woods/extractors/controller_extractor.rb +389 -29
  119. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  120. data/lib/woods/extractors/engine_extractor.rb +53 -8
  121. data/lib/woods/extractors/event_extractor.rb +55 -4
  122. data/lib/woods/extractors/factory_extractor.rb +49 -11
  123. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  124. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  125. data/lib/woods/extractors/job_extractor.rb +51 -21
  126. data/lib/woods/extractors/lib_extractor.rb +23 -17
  127. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  128. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  129. data/lib/woods/extractors/manager_extractor.rb +19 -2
  130. data/lib/woods/extractors/migration_extractor.rb +22 -11
  131. data/lib/woods/extractors/model_extractor.rb +292 -57
  132. data/lib/woods/extractors/package_extractor.rb +154 -0
  133. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  134. data/lib/woods/extractors/policy_extractor.rb +6 -5
  135. data/lib/woods/extractors/poro_extractor.rb +13 -14
  136. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  137. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  138. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  139. data/lib/woods/extractors/reference_patterns.rb +38 -0
  140. data/lib/woods/extractors/route_extractor.rb +58 -2
  141. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  142. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  143. data/lib/woods/extractors/service_extractor.rb +11 -1
  144. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  145. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  146. data/lib/woods/extractors/source_nesting.rb +560 -0
  147. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  148. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  149. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  150. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  151. data/lib/woods/feedback/gap_detector.rb +9 -3
  152. data/lib/woods/feedback/store.rb +7 -1
  153. data/lib/woods/filename_utils.rb +29 -1
  154. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  155. data/lib/woods/flow_assembler.rb +63 -21
  156. data/lib/woods/flow_document.rb +1 -0
  157. data/lib/woods/flow_precomputer.rb +138 -22
  158. data/lib/woods/gem_mapper.rb +285 -0
  159. data/lib/woods/generation.rb +185 -0
  160. data/lib/woods/git_command.rb +38 -0
  161. data/lib/woods/git_provenance.rb +16 -2
  162. data/lib/woods/graph_analyzer.rb +408 -34
  163. data/lib/woods/index_artifact.rb +93 -23
  164. data/lib/woods/mcp/bearer_auth.rb +92 -22
  165. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  166. data/lib/woods/mcp/bootstrapper.rb +582 -77
  167. data/lib/woods/mcp/config_resolver.rb +66 -6
  168. data/lib/woods/mcp/errors.rb +60 -0
  169. data/lib/woods/mcp/index_reader.rb +836 -117
  170. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  171. data/lib/woods/mcp/origin_guard.rb +108 -23
  172. data/lib/woods/mcp/protocol_policy.rb +98 -0
  173. data/lib/woods/mcp/provider_probe.rb +45 -6
  174. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  175. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  176. data/lib/woods/mcp/server.rb +907 -154
  177. data/lib/woods/mcp/tasks/extension.rb +196 -0
  178. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  179. data/lib/woods/mcp/tasks/store.rb +518 -0
  180. data/lib/woods/mcp/tool_contract.rb +171 -0
  181. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  182. data/lib/woods/mcp/version_aware_tool_dispatch.rb +3 -9
  183. data/lib/woods/model_name_cache.rb +19 -1
  184. data/lib/woods/notion/client.rb +132 -36
  185. data/lib/woods/notion/exporter.rb +456 -61
  186. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  187. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  188. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  189. data/lib/woods/notion/mappers/shared.rb +45 -3
  190. data/lib/woods/notion/sync_manifest.rb +258 -0
  191. data/lib/woods/obsidian/errors.rb +6 -0
  192. data/lib/woods/obsidian/name_mapper.rb +40 -24
  193. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  194. data/lib/woods/operator/pipeline_guard.rb +118 -21
  195. data/lib/woods/operator/status_reporter.rb +20 -3
  196. data/lib/woods/path_dispatcher.rb +276 -0
  197. data/lib/woods/payload_store.rb +223 -0
  198. data/lib/woods/published_index/edge_shaper.rb +61 -0
  199. data/lib/woods/published_index/generation_catalog.rb +72 -0
  200. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  201. data/lib/woods/published_index.rb +287 -0
  202. data/lib/woods/railtie.rb +70 -38
  203. data/lib/woods/railtie_support.rb +167 -0
  204. data/lib/woods/release.rb +12 -0
  205. data/lib/woods/reload_policy.rb +206 -0
  206. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  207. data/lib/woods/resilience/index_validator.rb +296 -10
  208. data/lib/woods/resilience/retryable_provider.rb +71 -6
  209. data/lib/woods/resolved_config.rb +55 -11
  210. data/lib/woods/retrieval/context_assembler.rb +132 -40
  211. data/lib/woods/retrieval/query_classifier.rb +25 -6
  212. data/lib/woods/retrieval/ranker.rb +193 -28
  213. data/lib/woods/retrieval/search_executor.rb +206 -39
  214. data/lib/woods/retriever.rb +317 -71
  215. data/lib/woods/retry_after.rb +22 -2
  216. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  217. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  218. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  219. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  220. data/lib/woods/ruby_analyzer.rb +21 -5
  221. data/lib/woods/session_tracer/file_store.rb +138 -19
  222. data/lib/woods/session_tracer/redis_store.rb +122 -12
  223. data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
  224. data/lib/woods/session_tracer/session_flow_document.rb +52 -6
  225. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  226. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  227. data/lib/woods/session_tracer/store.rb +14 -1
  228. data/lib/woods/storage/metadata_store.rb +230 -26
  229. data/lib/woods/storage/pgvector.rb +180 -22
  230. data/lib/woods/storage/qdrant.rb +367 -41
  231. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  232. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  233. data/lib/woods/storage/snapshotter.rb +23 -5
  234. data/lib/woods/storage/vector_store.rb +49 -8
  235. data/lib/woods/storage_identity.rb +28 -0
  236. data/lib/woods/tasks.rb +53 -2
  237. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  238. data/lib/woods/temporal/snapshot_store.rb +139 -42
  239. data/lib/woods/unblocked/client.rb +119 -17
  240. data/lib/woods/unblocked/document_builder.rb +34 -2
  241. data/lib/woods/unblocked/exporter.rb +63 -27
  242. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  243. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  244. data/lib/woods/update_check.rb +24 -1
  245. data/lib/woods/util/uuid5.rb +124 -0
  246. data/lib/woods/version.rb +1 -1
  247. data/lib/woods/watch/daemon.rb +1345 -0
  248. data/lib/woods/watch/listen_watcher.rb +81 -0
  249. data/lib/woods/watch/polling_watcher.rb +137 -0
  250. data/lib/woods/watch/status.rb +169 -0
  251. data/lib/woods/watch/tree_scan.rb +163 -0
  252. data/lib/woods/watch/watcher.rb +100 -0
  253. data/lib/woods.rb +53 -9
  254. data/plugin/.claude-plugin/plugin.json +18 -0
  255. data/plugin/hooks/hooks.json +29 -0
  256. data/plugin/hooks/woods-post-edit.sh +226 -0
  257. data/plugin/hooks/woods-session-start.sh +77 -0
  258. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  259. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  260. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  261. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  262. data/plugin/skills/woods-setup/SKILL.md +99 -0
  263. metadata +102 -30
  264. data/lib/woods/console/adapter_family.rb +0 -39
  265. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  266. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  268. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  269. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  270. data/lib/woods/console/bridge.rb +0 -210
  271. data/lib/woods/console/credential_scanner_registry.rb +0 -36
  272. data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
  273. data/lib/woods/console/sql_output_policy.rb +0 -535
  274. data/lib/woods/console/sqlite_read_guard.rb +0 -46
  275. data/lib/woods/formatting/claude_adapter.rb +0 -98
  276. data/lib/woods/formatting/generic_adapter.rb +0 -56
  277. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  278. data/lib/woods/mcp/http_transport_options.rb +0 -15
  279. data/lib/woods/mcp/origin_policy.rb +0 -113
  280. data/lib/woods/notion/mapper.rb +0 -40
  281. data/lib/woods/observability/health_check.rb +0 -79
  282. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -30,8 +30,8 @@ module Woods
30
30
  # newline-separated statement structure is preserved for callers that
31
31
  # check for multiple statements.
32
32
  #
33
- # This legacy helper is not a security scanner. Use strip_noise for
34
- # quote-aware and PostgreSQL nested-comment handling.
33
+ # Block comments are non-nested — real SQL engines do not support nested
34
+ # block comments, and neither does this stripper.
35
35
  #
36
36
  # @param sql [String] the SQL string to process
37
37
  # @return [String] a new string with all SQL comments removed
@@ -40,7 +40,7 @@ module Woods
40
40
 
41
41
  def self.strip_comments(sql)
42
42
  out = sql.gsub(LINE_COMMENT, '')
43
- out.gsub(BLOCK_COMMENT, ' ')
43
+ out.gsub(BLOCK_COMMENT, '')
44
44
  end
45
45
 
46
46
  # Strips single-quoted string literals and (for the `:postgres` dialect)
@@ -53,21 +53,20 @@ module Woods
53
53
  # single-quote scanner.
54
54
  #
55
55
  # @param sql [String] the SQL string to process
56
- # @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite`.
56
+ # @param dialect [Symbol] `:postgres` (default) or `:mysql`.
57
57
  # - `:postgres` — single-quoted strings support `''` as an apostrophe
58
58
  # escape. Backslash is treated literally and does not escape quotes.
59
59
  # Dollar-quoted strings (`$$...$$`, `$tag$...$tag$`) are also stripped.
60
60
  # - `:mysql` — single-quoted strings support both `\'` (backslash-escape)
61
61
  # and `''` (doubled-quote) as apostrophe escapes. Dollar-quoted strings
62
62
  # are not recognized by the combined MySQL security scanner.
63
- # - `:sqlite` — doubled apostrophes escape strings; backslashes and dollar signs are literal.
64
63
  # @return [String] a new string with all string literals replaced by `''`
65
64
  # @raise [ArgumentError] if an unsupported dialect is provided
66
65
  DOLLAR_QUOTED = /\$(\w*)\$.*?\$\1\$/m
67
66
  SINGLE_QUOTED_POSTGRES = /'(?:''|[^'])*'/m
68
67
  SINGLE_QUOTED_MYSQL = /'(?:\\.|''|[^'])*'/m
69
68
 
70
- SUPPORTED_DIALECTS = %i[postgres mysql sqlite].freeze
69
+ SUPPORTED_DIALECTS = %i[postgres mysql].freeze
71
70
  private_constant :SUPPORTED_DIALECTS
72
71
 
73
72
  def self.strip_literals(sql, dialect: :postgres)
@@ -77,7 +76,7 @@ module Woods
77
76
 
78
77
  # Strip dollar-quoted strings first so stray apostrophes inside them
79
78
  # do not interfere with the single-quote scanner.
80
- out = dialect == :sqlite ? sql : sql.gsub(DOLLAR_QUOTED, "''")
79
+ out = sql.gsub(DOLLAR_QUOTED, "''")
81
80
 
82
81
  pattern = dialect == :mysql ? SINGLE_QUOTED_MYSQL : SINGLE_QUOTED_POSTGRES
83
82
  out.gsub(pattern, "''")
@@ -101,10 +100,9 @@ module Woods
101
100
  # `:mysql` dialect — PostgreSQL does not treat `#` as a comment, and
102
101
  # collapsing it there would hide SQL that a real PostgreSQL server
103
102
  # still executes. A MySQL `/*! ... */` executable comment is left
104
- # visible as one span, including MariaDB's `/*M! ... */` spelling:
105
- # its body can execute, so it must stay visible to every downstream
106
- # scan without changing literal state outside the span. Leaving it
107
- # visible under `:postgres` too is over-detection at
103
+ # untouched (not treated as a comment at all, under either dialect):
104
+ # MySQL runs its body, so it must stay visible to every downstream
105
+ # scan; leaving it visible under `:postgres` too is over-detection at
108
106
  # worst, never under-detection, on a server where it really is inert.
109
107
  # An ordinary `/* ... */` block comment is replaced by a single
110
108
  # newline rather than vanishing outright, mirroring how a `--`/`#`
@@ -114,7 +112,7 @@ module Woods
114
112
  # still reads as following a boundary once comments are gone.
115
113
  #
116
114
  # @param sql [String] the SQL string to process
117
- # @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite` — controls
115
+ # @param dialect [Symbol] `:postgres` (default) or `:mysql` — controls
118
116
  # single-quote escape rules (see {.strip_literals}) and whether `#`
119
117
  # opens a line comment. MySQL quote flags reflect session sql_mode.
120
118
  # @return [String] a new string with comments removed and every string
@@ -136,8 +134,7 @@ module Woods
136
134
  if ch == "'"
137
135
  close = single_quote_end(
138
136
  sql, i,
139
- backslash_escapes: (mysql && !no_backslash_escapes) ||
140
- (dialect == :postgres && postgres_escape_string?(sql, i))
137
+ backslash_escapes: (mysql && !no_backslash_escapes) || (!mysql && postgres_escape_string?(sql, i))
141
138
  )
142
139
  if close
143
140
  out << "''"
@@ -161,7 +158,7 @@ module Woods
161
158
  out << ch
162
159
  i += 1
163
160
  end
164
- elsif (mysql || dialect == :sqlite) && ch == '`'
161
+ elsif mysql && ch == '`'
165
162
  close = quoted_span_end(sql, i, quote: '`', backslash_escapes: false)
166
163
  if close
167
164
  # Backticks delimit identifiers. Preserve the token for table
@@ -173,7 +170,7 @@ module Woods
173
170
  out << ch
174
171
  i += 1
175
172
  end
176
- elsif dialect == :postgres && ch == '$' && !preceded_by_word_char?(sql, i) && (tag = dollar_tag_at(sql, i))
173
+ elsif !mysql && ch == '$' && !preceded_by_word_char?(sql, i) && (tag = dollar_tag_at(sql, i))
177
174
  close = sql.index(tag, i + tag.length)
178
175
  if close
179
176
  out << "''"
@@ -185,22 +182,8 @@ module Woods
185
182
  elsif dash_comment?(sql, i, mysql: mysql) || (mysql && ch == '#')
186
183
  nl = sql.index("\n", i)
187
184
  i = nl || len
188
- elsif sql[i, 3] == '/*!' || sql[i, 4] == '/*M!'
189
- close = block_comment_end(sql, i, nested: dialect == :postgres)
190
- if close
191
- comment = sql[i...close]
192
- yield comment if block_given?
193
- # An executable comment is a lexical boundary even when its
194
- # version guard makes its body inert. A quote in that body must
195
- # never change the scanner's state outside the comment.
196
- out << comment
197
- i = close
198
- else
199
- out << ch
200
- i += 1
201
- end
202
- elsif ch == '/' && sql[i + 1] == '*'
203
- close = block_comment_end(sql, i, nested: dialect == :postgres)
185
+ elsif ch == '/' && sql[i + 1] == '*' && sql[i + 2] != '!'
186
+ close = sql.index('*/', i + 2)
204
187
  if close
205
188
  # Preserve a newline in place of the removed comment, mirroring
206
189
  # line comments (see class docs): callers that check for
@@ -208,7 +191,7 @@ module Woods
208
191
  # need a survivable marker showing a comment sat here, the
209
192
  # same way a `--`/`#` comment's own trailing newline does.
210
193
  out << "\n"
211
- i = close
194
+ i = close + 2
212
195
  else
213
196
  # Unterminated block comment: never under-detect. Leave it in
214
197
  # place (over-detection is safe; the old regex also required a
@@ -225,20 +208,6 @@ module Woods
225
208
  out
226
209
  end
227
210
 
228
- def self.block_comment_end(sql, start, nested:)
229
- depth = 1
230
- position = start + 2
231
- while (match = %r{/\*|\*/}.match(sql, position))
232
- depth += 1 if nested && match[0] == '/*'
233
- depth -= 1 if match[0] == '*/'
234
- return match.end(0) if depth.zero?
235
-
236
- position = match.end(0)
237
- end
238
- nil
239
- end
240
- private_class_method :block_comment_end
241
-
242
211
  # Session modes change MySQL quoting without changing the adapter name.
243
212
  # Security consumers must reject SQL unsafe under any supported combination.
244
213
  MYSQL_QUOTE_MODES = [false, true].product([false, true]).map do |ansi, no_backslash|
@@ -260,7 +229,7 @@ module Woods
260
229
 
261
230
  # Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
262
231
  # `$tag$`) at the start of the given slice.
263
- DOLLAR_TAG = /\A\$(?:[A-Za-z_\u0080-\u{10ffff}][A-Za-z0-9_\u0080-\u{10ffff}]*)?\$/u
232
+ DOLLAR_TAG = /\A\$\w*\$/
264
233
  private_constant :DOLLAR_TAG
265
234
 
266
235
  # Return the dollar-quote tag opening at +index+, or nil.
@@ -273,7 +242,7 @@ module Woods
273
242
  private_class_method :dollar_tag_at
274
243
 
275
244
  # Whether the character immediately before +index+ is a word character
276
- # (including high-bit characters). PostgreSQL allows `$` inside identifiers (`x$a$`), so a `$`
245
+ # (`\w`). PostgreSQL allows `$` inside identifiers (`x$a$`), so a `$`
277
246
  # is only a candidate dollar-quote opener when it does NOT follow an
278
247
  # identifier character — otherwise `x$a$ FROM blocked, (SELECT 1 AS
279
248
  # z$a$)` gets misread as one dollar-quoted literal spanning the real
@@ -281,7 +250,7 @@ module Woods
281
250
  #
282
251
  # @api private
283
252
  def self.preceded_by_word_char?(sql, index)
284
- index.positive? && sql[index - 1].match?(/[A-Za-z0-9_$\u0080-\u{10ffff}]/u)
253
+ index.positive? && sql[index - 1].match?(/\w/)
285
254
  end
286
255
  private_class_method :preceded_by_word_char?
287
256
 
@@ -332,7 +301,7 @@ module Woods
332
301
  def self.postgres_escape_string?(sql, quote_index)
333
302
  return false unless quote_index.positive? && sql[quote_index - 1].match?(/[eE]/)
334
303
 
335
- quote_index < 2 || !sql[quote_index - 2].match?(/[A-Za-z0-9_$\u0080-\u{10ffff}]/u)
304
+ quote_index < 2 || !sql[quote_index - 2].match?(/[A-Za-z0-9_$]/)
336
305
  end
337
306
  private_class_method :postgres_escape_string?
338
307
  end # rubocop:enable Metrics/ModuleLength
@@ -38,20 +38,20 @@ module Woods
38
38
  # identifier so it does not hide the table name. ANSI-89 comma joins
39
39
  # are handled separately — see FROM_CLAUSE.
40
40
  JOIN_REFERENCE = /
41
- \b(?:STRAIGHT_)?JOIN(?=[\s("`])\s*
42
- (?:ONLY(?=[\s("`])\s*\(?\s*)?
41
+ \b(?:STRAIGHT_)?JOIN\s+
42
+ (?:ONLY\s+)?
43
43
  (?:
44
44
  (?:
45
- `(?<jschema_bt>(?:``|[^`])+)` |
46
- "(?<jschema_dq>(?:""|[^"])+)" |
47
- (?<jschema_bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+)
45
+ `(?<jschema_bt>[^`]+)` |
46
+ "(?<jschema_dq>[^"]+)" |
47
+ (?<jschema_bare>\w+)
48
48
  )
49
49
  \s* \. \s*
50
50
  )?
51
51
  (?:
52
- `(?<backtick>(?:``|[^`])+)` |
53
- "(?<double>(?:""|[^"])+)" |
54
- (?<bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+(?:\.[A-Za-z0-9_$\u0080-\u{10ffff}]+)?)
52
+ `(?<backtick>[^`]+)` |
53
+ "(?<double>[^"]+)" |
54
+ (?<bare>\w+(?:\.\w+)?)
55
55
  )
56
56
  /xi
57
57
 
@@ -66,7 +66,7 @@ module Woods
66
66
  # every `FROM` as its own independent scan match is what keeps CTEs,
67
67
  # UNIONs, and nested subqueries in coverage.
68
68
  FROM_CLAUSE = /
69
- \bFROM(?=[\s("`])\s*
69
+ \bFROM\s+
70
70
  (?<clause>.+?)
71
71
  (?=
72
72
  \b(?:WHERE|GROUP|HAVING|ORDER|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|
@@ -85,16 +85,16 @@ module Woods
85
85
  \A
86
86
  (?:
87
87
  (?:
88
- `(?<schema_bt>(?:``|[^`])+)` |
89
- "(?<schema_dq>(?:""|[^"])+)" |
90
- (?<schema_bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+)
88
+ `(?<schema_bt>[^`]+)` |
89
+ "(?<schema_dq>[^"]+)" |
90
+ (?<schema_bare>\w+)
91
91
  )
92
92
  \s* \. \s*
93
93
  )?
94
94
  (?:
95
- `(?<backtick>(?:``|[^`])+)` |
96
- "(?<double>(?:""|[^"])+)" |
97
- (?<bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+(?:\.[A-Za-z0-9_$\u0080-\u{10ffff}]+)?)
95
+ `(?<backtick>[^`]+)` |
96
+ "(?<double>[^"]+)" |
97
+ (?<bare>\w+(?:\.\w+)?)
98
98
  )
99
99
  /xi
100
100
 
@@ -102,7 +102,7 @@ module Woods
102
102
  # identifier. Strip it so the lead-identifier regex sees the table
103
103
  # directly. Anchored with `\A` because callers strip leading whitespace
104
104
  # first via #strip.
105
- ONLY_PREFIX = /\AONLY(?=[\s("`])\s*/i
105
+ ONLY_PREFIX = /\AONLY\s+/i
106
106
 
107
107
  # Matches a MySQL executable comment (`/*!...*/` or the version-guarded
108
108
  # `/*!NNNNN...*/`), capturing the body. Mirrors
@@ -110,7 +110,7 @@ module Woods
110
110
  # deliberately leaves these markers in place because their meaning is
111
111
  # version-dependent; .executable_comment_views scans both of their
112
112
  # possible semantics.
113
- EXECUTABLE_COMMENT_PATTERN = %r{/\*(?:M)?!(?:\d{5,6})?(.*?)\*/}m
113
+ EXECUTABLE_COMMENT_PATTERN = %r{/\*!(?:\d{5})?(.*?)\*/}m
114
114
 
115
115
  # Matches the standalone SQL `TABLE name` statement (PostgreSQL, and
116
116
  # MySQL 8.0.19+) — shorthand for `SELECT * FROM name`. It appears as a
@@ -119,20 +119,20 @@ module Woods
119
119
  # scanned independently of FROM_CLAUSE/JOIN_REFERENCE rather than as
120
120
  # part of either. The identifier grammar mirrors LEAD_IDENT.
121
121
  TABLE_STATEMENT = /
122
- \bTABLE(?=[\s("`])\s*
123
- (?:ONLY(?=[\s("`])\s*\(?\s*)?
122
+ \bTABLE\s+
123
+ (?:ONLY\s+)?
124
124
  (?:
125
125
  (?:
126
- `(?<schema_bt>(?:``|[^`])+)` |
127
- "(?<schema_dq>(?:""|[^"])+)" |
128
- (?<schema_bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+)
126
+ `(?<schema_bt>[^`]+)` |
127
+ "(?<schema_dq>[^"]+)" |
128
+ (?<schema_bare>\w+)
129
129
  )
130
130
  \s* \. \s*
131
131
  )?
132
132
  (?:
133
- `(?<backtick>(?:``|[^`])+)` |
134
- "(?<double>(?:""|[^"])+)" |
135
- (?<bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+(?:\.[A-Za-z0-9_$\u0080-\u{10ffff}]+)?)
133
+ `(?<backtick>[^`]+)` |
134
+ "(?<double>[^"]+)" |
135
+ (?<bare>\w+(?:\.\w+)?)
136
136
  )
137
137
  /xi
138
138
 
@@ -167,51 +167,6 @@ module Woods
167
167
  results.uniq
168
168
  end
169
169
 
170
- # Table-factor prefixes, retaining commas after balanced subqueries and
171
- # JOIN predicates. Each nested FROM/JOIN is also scanned independently.
172
- # SQL punctuation separates tokens without mandatory whitespace. Keep
173
- # compact quoted targets and parenthesized relations in the same policy.
174
- # @param sql [String] noise-stripped SQL
175
- # @return [Array<String>]
176
- def self.relation_factors(sql)
177
- sql.to_enum(:scan, /\b(?:FROM|(?:STRAIGHT_)?JOIN)(?=[\s("`])\s*/i).flat_map do
178
- suffix = sql[Regexp.last_match.end(0)..]
179
- split_top_level_commas(relation_clause(suffix))
180
- end
181
- end
182
-
183
- # Quoted tokens shield punctuation and clause words; a closing parenthesis
184
- # at depth zero ends this query's clause, not a nested table expression.
185
- def self.relation_clause(suffix)
186
- depth = 0
187
- tokens = /
188
- "(?:[^"]|"")*"|`(?:[^`]|``)*`|''|[()]|
189
- \b(?:WHERE|GROUP|HAVING|ORDER|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|WINDOW)\b
190
- /ix
191
- suffix.to_enum(:scan, tokens).each do
192
- token = Regexp.last_match[0]
193
- start = Regexp.last_match.begin(0)
194
- finish = Regexp.last_match.end(0)
195
- boundary = token == ')' || relation_keyword_boundary?(suffix[0...start], suffix[finish..], token)
196
- return suffix[0...start] if depth.zero? && boundary
197
-
198
- depth += 1 if token == '('
199
- depth -= 1 if token == ')'
200
- end
201
- suffix
202
- end
203
- private_class_method :relation_clause
204
-
205
- def self.relation_keyword_boundary?(prefix, rest, token)
206
- return false unless token.match?(/\A[A-Za-z]/)
207
- return false if prefix.strip.empty? || prefix.match?(/(?:,|\bAS)\s*\z/i) || rest.lstrip.start_with?(',')
208
- return rest.match?(/\A\s+BY\b/i) if %w[GROUP ORDER].include?(token.upcase)
209
- return rest.match?(/\A\s+[A-Za-z0-9_$\u0080-\u{10ffff}]+\s+AS\b/i) if token.casecmp?('WINDOW')
210
-
211
- true
212
- end
213
- private_class_method :relation_keyword_boundary?
214
-
215
170
  # @api private
216
171
  # Every view of the stripped SQL the FROM/JOIN scans must consider for
217
172
  # MySQL executable comments (`/*! ... */`). {SqlNoiseStripper} leaves
@@ -248,9 +203,12 @@ module Woods
248
203
 
249
204
  # @api private
250
205
  def self.collect_from_identifiers(sql, results)
251
- relation_factors(sql).each do |chunk|
252
- ident = lead_identifier(chunk)
253
- results << ident if ident
206
+ sql.scan(FROM_CLAUSE) do
207
+ clause = Regexp.last_match[:clause]
208
+ split_top_level_commas(clause).each do |chunk|
209
+ ident = lead_identifier(chunk)
210
+ results << ident if ident
211
+ end
254
212
  end
255
213
  end
256
214
  private_class_method :collect_from_identifiers
@@ -271,7 +229,7 @@ module Woods
271
229
  depth = 0
272
230
  buf = +''
273
231
  parts = []
274
- clause.scan(/"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|./m).each do |ch|
232
+ clause.each_char do |ch|
275
233
  case ch
276
234
  when '('
277
235
  depth += 1
@@ -301,9 +259,8 @@ module Woods
301
259
  # PostgreSQL `ONLY` inheritance keyword is stripped first so it does
302
260
  # not hide the table.
303
261
  def self.lead_identifier(chunk)
304
- stripped = chunk.to_s.strip.sub(/\A(?:\(\s*)+/, '').sub(ONLY_PREFIX, '')
305
- stripped = stripped.sub(/\A(?:\(\s*)+/, '')
306
- return nil if stripped.empty? || stripped.match?(/\A(?:SELECT|WITH|TABLE)\b/i)
262
+ stripped = chunk.to_s.strip.sub(ONLY_PREFIX, '')
263
+ return nil if stripped.empty?
307
264
 
308
265
  match = LEAD_IDENT.match(stripped)
309
266
  return nil unless match
@@ -316,22 +273,14 @@ module Woods
316
273
  # Combine a schema prefix with the table identifier captured by
317
274
  # JOIN_REFERENCE / LEAD_IDENT into a single `schema.table` string.
318
275
  def self.qualified_identifier(match)
319
- captures = match.named_captures
320
- table = decoded_identifier(*captures.values_at('backtick', 'double', 'bare'))
321
- schema = decoded_identifier(captures['schema_bt'] || captures['jschema_bt'],
322
- captures['schema_dq'] || captures['jschema_dq'],
323
- captures['schema_bare'] || captures['jschema_bare'])
276
+ table = match[:backtick] || match[:double] || match[:bare]
277
+ schema = match.named_captures.values_at(
278
+ 'schema_bt', 'schema_dq', 'schema_bare',
279
+ 'jschema_bt', 'jschema_dq', 'jschema_bare'
280
+ ).compact.first
324
281
  schema ? "#{schema}.#{table}" : table
325
282
  end
326
283
  private_class_method :qualified_identifier
327
-
328
- def self.decoded_identifier(backtick, double, bare)
329
- return backtick.gsub('``', '`') if backtick
330
- return double.gsub('""', '"') if double
331
-
332
- bare
333
- end
334
- private_class_method :decoded_identifier
335
284
  end
336
285
  end
337
286
  end