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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- 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
|
|
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(
|
|
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 == '
|
|
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
|
|
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,
|
|
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
|
|
282
|
+
if backslash_escapes && c == '\\'
|
|
186
283
|
i += 2
|
|
187
|
-
elsif c ==
|
|
188
|
-
return i + 1 unless sql[i + 1] ==
|
|
284
|
+
elsif c == quote
|
|
285
|
+
return i + 1 unless sql[i + 1] == quote # closing delimiter
|
|
189
286
|
|
|
190
|
-
i += 2 # doubled-
|
|
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 :
|
|
198
|
-
|
|
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
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
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 :
|
|
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
|