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
@@ -24,7 +24,7 @@ module Woods
24
24
  # SqlNoiseStripper.strip_literals("SELECT 'it\\'s ok' FROM t", dialect: :mysql)
25
25
  # # => "SELECT '' FROM t"
26
26
  #
27
- module SqlNoiseStripper
27
+ module SqlNoiseStripper # rubocop:disable Metrics/ModuleLength
28
28
  # Strips SQL line comments (`-- ...`) and block comments (`/* ... */`).
29
29
  # Line comments are stripped to (but not including) the newline so that
30
30
  # newline-separated statement structure is preserved for callers that
@@ -59,8 +59,7 @@ module Woods
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
- # are also stripped (MySQL does not use them, but stripping them is
63
- # harmless and keeps the two dialects consistent).
62
+ # are not recognized by the combined MySQL security scanner.
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
@@ -97,13 +96,29 @@ module Woods
97
96
  # never under-detect: an unterminated literal is treated as an ordinary
98
97
  # character rather than swallowing the rest of the statement.
99
98
  #
99
+ # `#` opens a MySQL line comment, mirroring `--`, but only under the
100
+ # `:mysql` dialect — PostgreSQL does not treat `#` as a comment, and
101
+ # collapsing it there would hide SQL that a real PostgreSQL server
102
+ # still executes. A MySQL `/*! ... */` executable comment is left
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
106
+ # worst, never under-detection, on a server where it really is inert.
107
+ # An ordinary `/* ... */` block comment is replaced by a single
108
+ # newline rather than vanishing outright, mirroring how a `--`/`#`
109
+ # line comment's own trailing newline survives: SqlValidator's
110
+ # statement-leader scan needs a durable marker showing a comment sat
111
+ # here so a comment-hidden statement (`SELECT 1 /*;*/ DELETE ...`)
112
+ # still reads as following a boundary once comments are gone.
113
+ #
100
114
  # @param sql [String] the SQL string to process
101
115
  # @param dialect [Symbol] `:postgres` (default) or `:mysql` — controls
102
- # single-quote escape rules (see {.strip_literals}).
116
+ # single-quote escape rules (see {.strip_literals}) and whether `#`
117
+ # opens a line comment. MySQL quote flags reflect session sql_mode.
103
118
  # @return [String] a new string with comments removed and every string
104
119
  # literal replaced by `''`
105
120
  # @raise [ArgumentError] if an unsupported dialect is provided
106
- def self.strip_noise(sql, dialect: :postgres) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize
121
+ def self.strip_noise(sql, dialect: :postgres, ansi_quotes: false, no_backslash_escapes: false) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize
107
122
  unless SUPPORTED_DIALECTS.include?(dialect)
108
123
  raise ArgumentError, "Unknown dialect #{dialect.inspect}. Supported: #{SUPPORTED_DIALECTS.inspect}"
109
124
  end
@@ -117,7 +132,10 @@ module Woods
117
132
  ch = sql[i]
118
133
 
119
134
  if ch == "'"
120
- close = single_quote_end(sql, i, mysql: mysql)
135
+ close = single_quote_end(
136
+ sql, i,
137
+ backslash_escapes: (mysql && !no_backslash_escapes) || (!mysql && postgres_escape_string?(sql, i))
138
+ )
121
139
  if close
122
140
  out << "''"
123
141
  i = close
@@ -126,7 +144,33 @@ module Woods
126
144
  out << ch
127
145
  i += 1
128
146
  end
129
- elsif ch == '$' && (tag = dollar_tag_at(sql, i))
147
+ elsif ch == '"'
148
+ close = quoted_span_end(sql, i, quote: '"',
149
+ backslash_escapes: mysql && !ansi_quotes && !no_backslash_escapes)
150
+ if close
151
+ # MySQL parses double quotes as strings unless ANSI_QUOTES is
152
+ # enabled. Treating them as literals prevents a `#` inside the
153
+ # value from hiding live SQL. PostgreSQL uses them for
154
+ # identifiers, which must remain visible to table/column scans.
155
+ out << double_quote_replacement(sql, i, close, mysql: mysql && !ansi_quotes)
156
+ i = close
157
+ else
158
+ out << ch
159
+ i += 1
160
+ end
161
+ elsif mysql && ch == '`'
162
+ close = quoted_span_end(sql, i, quote: '`', backslash_escapes: false)
163
+ if close
164
+ # Backticks delimit identifiers. Preserve the token for table
165
+ # and protected-column scans while shielding comment markers
166
+ # inside it from the noise scanner.
167
+ out << sql[i...close]
168
+ i = close
169
+ else
170
+ out << ch
171
+ i += 1
172
+ end
173
+ elsif !mysql && ch == '$' && !preceded_by_word_char?(sql, i) && (tag = dollar_tag_at(sql, i))
130
174
  close = sql.index(tag, i + tag.length)
131
175
  if close
132
176
  out << "''"
@@ -135,12 +179,18 @@ module Woods
135
179
  out << ch
136
180
  i += 1
137
181
  end
138
- elsif ch == '-' && sql[i + 1] == '-'
182
+ elsif dash_comment?(sql, i, mysql: mysql) || (mysql && ch == '#')
139
183
  nl = sql.index("\n", i)
140
184
  i = nl || len
141
- elsif ch == '/' && sql[i + 1] == '*'
185
+ elsif ch == '/' && sql[i + 1] == '*' && sql[i + 2] != '!'
142
186
  close = sql.index('*/', i + 2)
143
187
  if close
188
+ # Preserve a newline in place of the removed comment, mirroring
189
+ # line comments (see class docs): callers that check for
190
+ # statement structure (SqlValidator's statement-leader scan)
191
+ # need a survivable marker showing a comment sat here, the
192
+ # same way a `--`/`#` comment's own trailing newline does.
193
+ out << "\n"
144
194
  i = close + 2
145
195
  else
146
196
  # Unterminated block comment: never under-detect. Leave it in
@@ -158,6 +208,25 @@ module Woods
158
208
  out
159
209
  end
160
210
 
211
+ # Session modes change MySQL quoting without changing the adapter name.
212
+ # Security consumers must reject SQL unsafe under any supported combination.
213
+ MYSQL_QUOTE_MODES = [false, true].product([false, true]).map do |ansi, no_backslash|
214
+ { ansi_quotes: ansi, no_backslash_escapes: no_backslash }.freeze
215
+ end.freeze
216
+
217
+ def self.security_views(sql, dialect:, mysql_modes: nil)
218
+ modes = dialect == :mysql && mysql_modes.nil? ? MYSQL_QUOTE_MODES : [mysql_modes || {}]
219
+ modes.map { |mode| strip_noise(sql, dialect: dialect, **mode) }.uniq
220
+ end
221
+
222
+ # MySQL requires whitespace/control after --; otherwise it is subtraction.
223
+ def self.dash_comment?(sql, index, mysql:)
224
+ return false unless sql[index, 2] == '--'
225
+
226
+ !mysql || sql[index + 2].nil? || sql[index + 2].match?(/[[:space:][:cntrl:]]/)
227
+ end
228
+ private_class_method :dash_comment?
229
+
161
230
  # Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
162
231
  # `$tag$`) at the start of the given slice.
163
232
  DOLLAR_TAG = /\A\$\w*\$/
@@ -172,29 +241,69 @@ module Woods
172
241
  end
173
242
  private_class_method :dollar_tag_at
174
243
 
244
+ # Whether the character immediately before +index+ is a word character
245
+ # (`\w`). PostgreSQL allows `$` inside identifiers (`x$a$`), so a `$`
246
+ # is only a candidate dollar-quote opener when it does NOT follow an
247
+ # identifier character — otherwise `x$a$ FROM blocked, (SELECT 1 AS
248
+ # z$a$)` gets misread as one dollar-quoted literal spanning the real
249
+ # FROM clause.
250
+ #
251
+ # @api private
252
+ def self.preceded_by_word_char?(sql, index)
253
+ index.positive? && sql[index - 1].match?(/\w/)
254
+ end
255
+ private_class_method :preceded_by_word_char?
256
+
175
257
  # Return the index just past the closing quote of the single-quoted
176
258
  # literal that opens at +start+, honoring `''` (both dialects) and `\'`
177
259
  # (MySQL only) escapes. Returns nil when the literal is unterminated.
178
260
  #
179
261
  # @api private
180
- def self.single_quote_end(sql, start, mysql:)
262
+ def self.single_quote_end(sql, start, backslash_escapes:)
263
+ quoted_span_end(sql, start, quote: "'", backslash_escapes: backslash_escapes)
264
+ end
265
+ private_class_method :single_quote_end
266
+
267
+ def self.double_quote_replacement(sql, start, close, mysql:)
268
+ mysql ? "''" : sql[start...close]
269
+ end
270
+ private_class_method :double_quote_replacement
271
+
272
+ # Return the index just past a quoted span. Doubled delimiters escape
273
+ # themselves in every supported dialect; MySQL strings/identifiers and
274
+ # PostgreSQL E-strings additionally honor backslash escapes.
275
+ #
276
+ # @api private
277
+ def self.quoted_span_end(sql, start, quote:, backslash_escapes:)
181
278
  i = start + 1
182
279
  len = sql.length
183
280
  while i < len
184
281
  c = sql[i]
185
- if mysql && c == '\\'
282
+ if backslash_escapes && c == '\\'
186
283
  i += 2
187
- elsif c == "'"
188
- return i + 1 unless sql[i + 1] == "'" # closing quote
284
+ elsif c == quote
285
+ return i + 1 unless sql[i + 1] == quote # closing delimiter
189
286
 
190
- i += 2 # doubled-quote escape — literal continues
287
+ i += 2 # doubled-delimiter escape — span continues
191
288
  else
192
289
  i += 1
193
290
  end
194
291
  end
195
292
  nil
196
293
  end
197
- private_class_method :single_quote_end
198
- end
294
+ private_class_method :quoted_span_end
295
+
296
+ # PostgreSQL E'...' strings opt into C-style backslash escapes. The E
297
+ # must begin a token; an identifier ending in e immediately before a
298
+ # quote is not an escape-string prefix.
299
+ #
300
+ # @api private
301
+ def self.postgres_escape_string?(sql, quote_index)
302
+ return false unless quote_index.positive? && sql[quote_index - 1].match?(/[eE]/)
303
+
304
+ quote_index < 2 || !sql[quote_index - 2].match?(/[A-Za-z0-9_$]/)
305
+ end
306
+ private_class_method :postgres_escape_string?
307
+ end # rubocop:enable Metrics/ModuleLength
199
308
  end
200
309
  end
@@ -46,7 +46,7 @@ module Woods
46
46
  "(?<jschema_dq>[^"]+)" |
47
47
  (?<jschema_bare>\w+)
48
48
  )
49
- \.
49
+ \s* \. \s*
50
50
  )?
51
51
  (?:
52
52
  `(?<backtick>[^`]+)` |
@@ -89,7 +89,7 @@ module Woods
89
89
  "(?<schema_dq>[^"]+)" |
90
90
  (?<schema_bare>\w+)
91
91
  )
92
- \.
92
+ \s* \. \s*
93
93
  )?
94
94
  (?:
95
95
  `(?<backtick>[^`]+)` |
@@ -104,43 +104,93 @@ module Woods
104
104
  # first via #strip.
105
105
  ONLY_PREFIX = /\AONLY\s+/i
106
106
 
107
+ # Matches a MySQL executable comment (`/*!...*/` or the version-guarded
108
+ # `/*!NNNNN...*/`), capturing the body. Mirrors
109
+ # SqlValidator::EXECUTABLE_COMMENT_PATTERN — {SqlNoiseStripper}
110
+ # deliberately leaves these markers in place because their meaning is
111
+ # version-dependent; .executable_comment_views scans both of their
112
+ # possible semantics.
113
+ EXECUTABLE_COMMENT_PATTERN = %r{/\*!(?:\d{5})?(.*?)\*/}m
114
+
115
+ # Matches the standalone SQL `TABLE name` statement (PostgreSQL, and
116
+ # MySQL 8.0.19+) — shorthand for `SELECT * FROM name`. It appears as a
117
+ # full statement, inside a CTE body (`WITH x AS (TABLE blocked) ...`),
118
+ # or as a FROM-clause subquery (`FROM (TABLE blocked) AS t`), so it is
119
+ # scanned independently of FROM_CLAUSE/JOIN_REFERENCE rather than as
120
+ # part of either. The identifier grammar mirrors LEAD_IDENT.
121
+ TABLE_STATEMENT = /
122
+ \bTABLE\s+
123
+ (?:ONLY\s+)?
124
+ (?:
125
+ (?:
126
+ `(?<schema_bt>[^`]+)` |
127
+ "(?<schema_dq>[^"]+)" |
128
+ (?<schema_bare>\w+)
129
+ )
130
+ \s* \. \s*
131
+ )?
132
+ (?:
133
+ `(?<backtick>[^`]+)` |
134
+ "(?<double>[^"]+)" |
135
+ (?<bare>\w+(?:\.\w+)?)
136
+ )
137
+ /xi
138
+
107
139
  # Returns every table/schema-qualified identifier referenced in the SQL
108
140
  # string. Noise (comments, string literals, dollar-quoted bodies) is
109
141
  # stripped before scanning. Both JOIN-style and ANSI-89 comma-join syntax
110
142
  # are handled.
111
143
  #
112
- # Literals are stripped under BOTH supported dialects and the scans
113
- # unioned. This scanner backs TableGate, so it may over-detect but must
114
- # never under-detect: stripping with the wrong dialect's escape rules
115
- # can swallow a real FROM clause — e.g. MySQL's `\'` escape applied on
116
- # a PostgreSQL host (where backslash is literal under
117
- # standard_conforming_strings) folds `'x\' FROM blocked WHERE y = '`
118
- # into one literal, hiding `blocked` from the gate while PostgreSQL
119
- # genuinely reads that table.
144
+ # A known adapter restricts the dialect, but not its session quote modes.
145
+ # Without known session settings, MySQL scans every quote-mode combination. An unknown
146
+ # adapter also scans PostgreSQL. This may over-detect, but must not hide a
147
+ # table merely because the server uses a different quote interpretation.
148
+ #
149
+ # MySQL executable comments (`/*! ... */`) are scanned under both of
150
+ # their possible semantics (see .executable_comment_views) so a table
151
+ # hidden at FROM/JOIN/subquery lead position is still surfaced.
120
152
  #
121
153
  # @param sql [String, nil] the SQL string to scan
122
154
  # @return [Array<String>] identifiers in first-encounter order, deduplicated
123
- def self.identifiers_in(sql)
155
+ def self.identifiers_in(sql, dialect: nil, mysql_modes: nil)
124
156
  return [] if sql.nil? || sql.empty?
125
157
 
126
158
  results = []
127
- %i[postgres mysql].each do |dialect|
128
- stripped = strip_noise(sql, dialect: dialect)
129
- collect_join_identifiers(stripped, results)
130
- collect_from_identifiers(stripped, results)
159
+ (dialect ? [dialect] : %i[postgres mysql]).each do |dialect|
160
+ views = SqlNoiseStripper.security_views(sql, dialect: dialect, mysql_modes: mysql_modes)
161
+ views.flat_map { |stripped| executable_comment_views(stripped) }.each do |view|
162
+ collect_join_identifiers(view, results)
163
+ collect_from_identifiers(view, results)
164
+ collect_table_statement_identifiers(view, results)
165
+ end
131
166
  end
132
167
  results.uniq
133
168
  end
134
169
 
135
170
  # @api private
136
- # Comments and literals must be stripped in a single combined pass —
137
- # stripping them separately lets a comment marker inside a literal
138
- # (`'-- '`) hide a real FROM clause from the gate. See
139
- # {SqlNoiseStripper.strip_noise}.
140
- def self.strip_noise(sql, dialect:)
141
- SqlNoiseStripper.strip_noise(sql, dialect: dialect)
171
+ # Every view of the stripped SQL the FROM/JOIN scans must consider for
172
+ # MySQL executable comments (`/*! ... */`). {SqlNoiseStripper} leaves
173
+ # these forms in place because MySQL executes their body, but the lead
174
+ # grammars (FROM_CLAUSE/LEAD_IDENT/JOIN_REFERENCE) cannot start on a
175
+ # comment marker, so `SELECT * FROM /*!authorizations*/` surfaced no
176
+ # identifier and a blocked table slipped past TableGate. Each view
177
+ # interprets the form under one of its two possible semantics, mirroring
178
+ # SqlValidator#lock_clause_views: the whole construct replaced by its
179
+ # body (version guard satisfied — the body executes in place) and the
180
+ # whole construct dropped (guard unsatisfied — the form is inert
181
+ # whitespace). The stripped text itself is always scanned too: it keeps
182
+ # the preserved-form behavior the post-comma executable-comment shape
183
+ # relies on, and no comment body is ever hidden from the union.
184
+ #
185
+ # Over-detection on PostgreSQL (where the `/*!` form is a syntax error)
186
+ # is acceptable: the gate may reject more than a server would execute,
187
+ # never less.
188
+ def self.executable_comment_views(stripped)
189
+ [stripped,
190
+ stripped.gsub(EXECUTABLE_COMMENT_PATTERN) { Regexp.last_match[1] },
191
+ stripped.gsub(EXECUTABLE_COMMENT_PATTERN, ' ')]
142
192
  end
143
- private_class_method :strip_noise
193
+ private_class_method :executable_comment_views
144
194
 
145
195
  # @api private
146
196
  def self.collect_join_identifiers(sql, results)
@@ -163,6 +213,16 @@ module Woods
163
213
  end
164
214
  private_class_method :collect_from_identifiers
165
215
 
216
+ # @api private
217
+ # Collect identifiers referenced by the standalone `TABLE name`
218
+ # statement (see {TABLE_STATEMENT}).
219
+ def self.collect_table_statement_identifiers(sql, results)
220
+ sql.scan(TABLE_STATEMENT) do
221
+ results << qualified_identifier(Regexp.last_match)
222
+ end
223
+ end
224
+ private_class_method :collect_table_statement_identifiers
225
+
166
226
  # @api private
167
227
  # Split a comma-separated list at depth 0, skipping commas inside parens.
168
228
  def self.split_top_level_commas(clause) # rubocop:disable Metrics/MethodLength