woods 2.0.0.beta2 → 2.0.0.beta4
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 +339 -1
- data/CONTRIBUTING.md +188 -12
- data/README.md +93 -174
- data/SECURITY.md +9 -6
- data/docs/AGENT_GUIDE.md +109 -8
- data/docs/AGENT_SETUP.md +98 -7
- data/docs/BACKEND_MATRIX.md +25 -0
- data/docs/CLIENT_HOOKS.md +111 -0
- data/docs/CONFIGURATION_REFERENCE.md +267 -16
- data/docs/CONSOLE_MCP_SETUP.md +80 -7
- data/docs/DOCKER_SETUP.md +22 -3
- data/docs/EVALUATION.md +464 -1
- data/docs/EXTRACTOR_REFERENCE.md +45 -6
- data/docs/FAQ.md +11 -12
- data/docs/GETTING_STARTED.md +17 -5
- data/docs/INCREMENTAL_EXTRACTION.md +147 -7
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +7 -2
- data/docs/MCP_SERVERS.md +276 -5
- data/docs/MCP_TOOL_COOKBOOK.md +37 -22
- data/docs/MCP_WORKTREE_SETUP.md +43 -83
- data/docs/NOTION_INTEGRATION.md +13 -0
- data/docs/OBSIDIAN_INTEGRATION.md +57 -9
- data/docs/PUBLISHED_INDEX.md +72 -0
- data/docs/README.md +7 -0
- data/docs/RETRIEVAL_GUIDE.md +273 -12
- data/docs/RUNTIME_TRACING.md +71 -0
- data/docs/SOURCE_FRESHNESS.md +143 -0
- data/docs/TROUBLESHOOTING.md +129 -18
- data/docs/UNBLOCKED_INTEGRATION.md +25 -0
- data/docs/UPGRADING_TO_2.md +48 -22
- data/docs/WATCH_DAEMON.md +277 -67
- data/exe/woods-agent-config +6 -0
- data/exe/woods-extract +5 -0
- data/exe/woods-hook-context +6 -0
- data/exe/woods-mcp-start +14 -9
- data/lib/generators/woods/pgvector_generator.rb +8 -2
- data/lib/generators/woods/templates/woods.rb.tt +1 -3
- data/lib/tasks/woods.rake +47 -397
- data/lib/woods/agent_configuration/applier.rb +135 -0
- data/lib/woods/agent_configuration/cli.rb +101 -0
- data/lib/woods/agent_configuration/cli_options.rb +29 -0
- data/lib/woods/agent_configuration/document.rb +105 -0
- data/lib/woods/agent_configuration/error.rb +7 -0
- data/lib/woods/agent_configuration/launcher.rb +75 -0
- data/lib/woods/agent_configuration/layout.rb +72 -0
- data/lib/woods/agent_configuration/managed_section.rb +62 -0
- data/lib/woods/agent_configuration/plan.rb +98 -0
- data/lib/woods/agent_configuration/plan_diff.rb +38 -0
- data/lib/woods/agent_configuration/planned_files.rb +61 -0
- data/lib/woods/agent_configuration/planner.rb +63 -0
- data/lib/woods/agent_configuration/planner_validation.rb +77 -0
- data/lib/woods/agent_configuration/preflight.rb +100 -0
- data/lib/woods/agent_configuration/recovery.rb +49 -0
- data/lib/woods/ast/node.rb +2 -0
- data/lib/woods/ast/parser.rb +38 -5
- data/lib/woods/builder.rb +21 -5
- data/lib/woods/cache/cache_middleware.rb +28 -7
- data/lib/woods/cache/cache_store.rb +4 -5
- data/lib/woods/change_set.rb +5 -4
- data/lib/woods/console/credential_index.rb +20 -2
- data/lib/woods/console/credential_scanner.rb +18 -17
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/dispatch_pipeline.rb +7 -0
- data/lib/woods/console/embedded_executor.rb +32 -10
- data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
- data/lib/woods/console/rack_middleware.rb +22 -13
- data/lib/woods/console/server.rb +18 -16
- data/lib/woods/console/sql_noise_stripper.rb +9 -7
- data/lib/woods/console/sql_table_scanner.rb +47 -7
- data/lib/woods/console/sql_validator.rb +49 -9
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/coordination/pipeline_lock.rb +3 -2
- data/lib/woods/dependency_graph.rb +65 -13
- data/lib/woods/embedding/corpus.rb +94 -0
- data/lib/woods/embedding/indexer.rb +114 -60
- data/lib/woods/embedding/openai.rb +17 -6
- data/lib/woods/evaluation/ablation_executor.rb +6 -1
- data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
- data/lib/woods/export/typed_reader.rb +56 -0
- data/lib/woods/extractor.rb +277 -149
- data/lib/woods/extractors/action_cable_extractor.rb +3 -1
- data/lib/woods/extractors/behavioral_profile.rb +9 -7
- data/lib/woods/extractors/caching_extractor.rb +3 -1
- data/lib/woods/extractors/concern_extractor.rb +64 -6
- data/lib/woods/extractors/configuration_extractor.rb +7 -3
- data/lib/woods/extractors/controller_extractor.rb +13 -4
- data/lib/woods/extractors/database_view_extractor.rb +3 -1
- data/lib/woods/extractors/declared_parent.rb +55 -0
- data/lib/woods/extractors/decorator_extractor.rb +3 -1
- data/lib/woods/extractors/engine_extractor.rb +3 -1
- data/lib/woods/extractors/event_extractor.rb +4 -2
- data/lib/woods/extractors/factory_extractor.rb +3 -1
- data/lib/woods/extractors/graphql_extractor.rb +10 -13
- data/lib/woods/extractors/i18n_extractor.rb +3 -1
- data/lib/woods/extractors/job_extractor.rb +6 -19
- data/lib/woods/extractors/lib_extractor.rb +13 -9
- data/lib/woods/extractors/mailer_extractor.rb +26 -15
- data/lib/woods/extractors/manager_extractor.rb +3 -1
- data/lib/woods/extractors/method_parameters.rb +53 -0
- data/lib/woods/extractors/middleware_argument.rb +65 -0
- data/lib/woods/extractors/middleware_extractor.rb +9 -3
- data/lib/woods/extractors/migration_extractor.rb +3 -1
- data/lib/woods/extractors/model_extractor.rb +26 -34
- data/lib/woods/extractors/package_extractor.rb +24 -4
- data/lib/woods/extractors/phlex_extractor.rb +3 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -1
- data/lib/woods/extractors/poro_extractor.rb +13 -9
- data/lib/woods/extractors/pundit_extractor.rb +3 -1
- data/lib/woods/extractors/rails_source_extractor.rb +4 -2
- data/lib/woods/extractors/rake_task_extractor.rb +4 -2
- data/lib/woods/extractors/route_extractor.rb +3 -1
- data/lib/woods/extractors/route_helper_resolver.rb +10 -33
- data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
- data/lib/woods/extractors/serializer_extractor.rb +4 -2
- data/lib/woods/extractors/service_extractor.rb +3 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
- data/lib/woods/extractors/shared_utility_methods.rb +48 -19
- data/lib/woods/extractors/source_nesting.rb +1 -1
- data/lib/woods/extractors/state_machine_extractor.rb +3 -1
- data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
- data/lib/woods/extractors/validator_extractor.rb +3 -1
- data/lib/woods/extractors/view_component_extractor.rb +3 -1
- data/lib/woods/extractors/view_template_extractor.rb +3 -1
- data/lib/woods/gem_mapper.rb +2 -0
- data/lib/woods/git_history.rb +116 -0
- data/lib/woods/graph_analyzer.rb +35 -6
- data/lib/woods/hooks/context_cli.rb +54 -0
- data/lib/woods/hooks/context_event.rb +88 -0
- data/lib/woods/hooks/context_hint.rb +73 -0
- data/lib/woods/hooks/context_impact.rb +77 -0
- data/lib/woods/hooks/context_output.rb +47 -0
- data/lib/woods/hooks/context_state.rb +102 -0
- data/lib/woods/hooks/refresh.rb +79 -0
- data/lib/woods/hooks/rule_projection.rb +78 -0
- data/lib/woods/input_rules.rb +19 -0
- data/lib/woods/mcp/bearer_auth.rb +22 -13
- data/lib/woods/mcp/bootstrapper.rb +79 -4
- data/lib/woods/mcp/config_resolver.rb +2 -1
- data/lib/woods/mcp/index_reader.rb +334 -162
- data/lib/woods/mcp/initialization_guidance.rb +27 -0
- data/lib/woods/mcp/origin_guard.rb +17 -9
- data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
- data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
- data/lib/woods/mcp/search_results.rb +74 -0
- data/lib/woods/mcp/server.rb +178 -63
- data/lib/woods/mcp/tool_contract.rb +3 -1
- data/lib/woods/mcp/tool_response_renderer.rb +41 -0
- data/lib/woods/mcp/traversal_evidence.rb +113 -0
- data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
- data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
- data/lib/woods/mcp/traversal_response.rb +22 -0
- data/lib/woods/notion/exporter.rb +56 -17
- data/lib/woods/obsidian/destination_plan.rb +98 -0
- data/lib/woods/obsidian/name_mapper.rb +19 -3
- data/lib/woods/obsidian/note_builder.rb +19 -10
- data/lib/woods/obsidian/vault_exporter.rb +88 -32
- data/lib/woods/operator/pipeline_guard.rb +18 -13
- data/lib/woods/path_dispatcher.rb +13 -6
- data/lib/woods/payload_store.rb +27 -26
- data/lib/woods/published_index/typed_unit_reader.rb +40 -3
- data/lib/woods/published_index.rb +2 -2
- data/lib/woods/railtie.rb +3 -3
- data/lib/woods/railtie_support.rb +12 -12
- data/lib/woods/rake_helpers.rb +382 -0
- data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
- data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
- data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
- data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
- data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
- data/lib/woods/resilience/index_validator.rb +112 -23
- data/lib/woods/retrieval/context_assembler.rb +50 -15
- data/lib/woods/retrieval/lexical_assembler.rb +84 -0
- data/lib/woods/retrieval/lexical_index.rb +120 -0
- data/lib/woods/retrieval/ranker.rb +4 -2
- data/lib/woods/retrieval/scope.rb +108 -0
- data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
- data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
- data/lib/woods/retrieval/search_executor.rb +86 -27
- data/lib/woods/retrieval/source_evidence.rb +200 -0
- data/lib/woods/retriever.rb +98 -22
- data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
- data/lib/woods/session_tracer/file_store.rb +6 -1
- data/lib/woods/session_tracer/middleware.rb +10 -12
- data/lib/woods/session_tracer/redis_store.rb +22 -6
- data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
- data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
- data/lib/woods/session_tracer/unit_resolver.rb +63 -0
- data/lib/woods/source_inputs/consumer_errors.rb +31 -0
- data/lib/woods/source_inputs/handoff.rb +102 -0
- data/lib/woods/source_inputs/launcher.rb +157 -0
- data/lib/woods/source_inputs/manifest.rb +124 -0
- data/lib/woods/source_inputs/private_key.rb +55 -0
- data/lib/woods/source_inputs/scanner.rb +171 -0
- data/lib/woods/source_inputs/scopes.rb +71 -0
- data/lib/woods/source_inputs/session.rb +214 -0
- data/lib/woods/source_inputs/status.rb +84 -0
- data/lib/woods/source_inputs/verifier.rb +107 -0
- data/lib/woods/storage/metadata_store.rb +25 -25
- data/lib/woods/storage/pgvector.rb +35 -10
- data/lib/woods/storage/qdrant.rb +17 -7
- data/lib/woods/storage/vector_store.rb +18 -6
- data/lib/woods/tasks.rb +3 -2
- data/lib/woods/temporal/json_snapshot_store.rb +58 -9
- data/lib/woods/unblocked/exporter.rb +59 -70
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/boot_snapshot.rb +52 -0
- data/lib/woods/watch/daemon.rb +154 -32
- data/lib/woods/watch/listen_watcher.rb +4 -0
- data/lib/woods/watch/polling_watcher.rb +5 -1
- data/lib/woods/watch/status.rb +20 -15
- data/lib/woods/watch/tree_scan.rb +21 -13
- data/lib/woods/watch/watcher.rb +4 -1
- data/lib/woods.rb +50 -11
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.jq +15 -0
- data/plugin/hooks/adapters/normalize.rb +63 -0
- data/plugin/hooks/hooks.json +20 -0
- data/plugin/hooks/woods-context.sh +50 -0
- data/plugin/hooks/woods-input-rules.sh +159 -0
- data/plugin/hooks/woods-opencode.mjs +65 -0
- data/plugin/hooks/woods-post-edit.sh +2 -225
- data/plugin/hooks/woods-refresh.sh +260 -0
- data/plugin/hooks/woods-session-start.sh +47 -55
- data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
- data/plugin/skills/woods-diagnose/SKILL.md +319 -1
- data/plugin/skills/woods-investigate/SKILL.md +145 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
- data/plugin/skills/woods-setup/SKILL.md +110 -6
- metadata +87 -5
|
@@ -68,6 +68,7 @@ module Woods
|
|
|
68
68
|
MISSING_KEY_ERRORS = %w[
|
|
69
69
|
ActiveSupport::EncryptedConfiguration::MissingKeyError
|
|
70
70
|
ActiveSupport::EncryptedFile::MissingKeyError
|
|
71
|
+
ActiveSupport::EncryptedFile::MissingContentError
|
|
71
72
|
ActiveSupport::MessageEncryptor::InvalidMessage
|
|
72
73
|
].freeze
|
|
73
74
|
|
|
@@ -90,13 +91,19 @@ module Woods
|
|
|
90
91
|
# @return [CredentialIndex] populated index, or an empty index when
|
|
91
92
|
# the credentials store can't be opened.
|
|
92
93
|
def build(rails_app:)
|
|
93
|
-
|
|
94
|
+
refresh(rails_app: rails_app)
|
|
94
95
|
rescue StandardError => e
|
|
95
96
|
raise unless missing_key_error?(e)
|
|
96
97
|
|
|
97
98
|
new(secrets: [])
|
|
98
99
|
end
|
|
99
100
|
|
|
101
|
+
# Strict refresh: construct the replacement completely before a caller
|
|
102
|
+
# installs it. Unlike boot-time .build, errors must preserve the old index.
|
|
103
|
+
def refresh(rails_app:)
|
|
104
|
+
new(secrets: collect_secrets(rails_app))
|
|
105
|
+
end
|
|
106
|
+
|
|
100
107
|
# Emit a structured log warning when any of the given credentials
|
|
101
108
|
# files has a modification time newer than `process_start`. This
|
|
102
109
|
# catches the common "rotated credentials but forgot to restart"
|
|
@@ -136,12 +143,23 @@ module Woods
|
|
|
136
143
|
private
|
|
137
144
|
|
|
138
145
|
def collect_secrets(rails_app)
|
|
139
|
-
config = rails_app.credentials.config
|
|
146
|
+
config = fresh_credentials(rails_app.credentials).config
|
|
140
147
|
collected = []
|
|
141
148
|
walk(config, collected)
|
|
142
149
|
collected
|
|
143
150
|
end
|
|
144
151
|
|
|
152
|
+
def fresh_credentials(credentials)
|
|
153
|
+
return credentials unless defined?(ActiveSupport::EncryptedConfiguration) &&
|
|
154
|
+
credentials.is_a?(ActiveSupport::EncryptedConfiguration)
|
|
155
|
+
|
|
156
|
+
require_relative 'encrypted_credential_snapshot'
|
|
157
|
+
EncryptedCredentialSnapshot.new(
|
|
158
|
+
config_path: credentials.content_path, key_path: credentials.key_path,
|
|
159
|
+
env_key: credentials.env_key, raise_if_missing_key: true
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
145
163
|
def walk(node, collected)
|
|
146
164
|
case node
|
|
147
165
|
when Hash then node.each_value { |v| walk(v, collected) }
|
|
@@ -145,9 +145,9 @@ module Woods
|
|
|
145
145
|
|
|
146
146
|
# Scan a value (String, Hash, Array, or any other object) for credentials.
|
|
147
147
|
#
|
|
148
|
-
# Strings are
|
|
149
|
-
# elements are walked recursively;
|
|
150
|
-
#
|
|
148
|
+
# Strings and Symbols are scanned against every active pattern. Hash
|
|
149
|
+
# keys/values and Array elements are walked recursively; numeric values,
|
|
150
|
+
# booleans, and nil pass through untouched. Symbols retain their type.
|
|
151
151
|
#
|
|
152
152
|
# @param value [Object]
|
|
153
153
|
# @return [Array(Object, Hash{Symbol=>Integer})] two-tuple of the scanned
|
|
@@ -155,17 +155,18 @@ module Woods
|
|
|
155
155
|
# for patterns that fired — callers should treat a missing key as zero.
|
|
156
156
|
def scan(value)
|
|
157
157
|
counts = {}
|
|
158
|
-
scanned = walk(value, counts)
|
|
158
|
+
scanned = walk(value, counts, @secret_index)
|
|
159
159
|
[scanned, counts]
|
|
160
160
|
end
|
|
161
161
|
|
|
162
162
|
private
|
|
163
163
|
|
|
164
|
-
def walk(value, counts)
|
|
164
|
+
def walk(value, counts, index)
|
|
165
165
|
case value
|
|
166
|
-
when String then scan_string(value, counts)
|
|
167
|
-
when
|
|
168
|
-
when
|
|
166
|
+
when String then scan_string(value, counts, index)
|
|
167
|
+
when Symbol then scan_string(value.to_s, counts, index).to_sym
|
|
168
|
+
when Hash then walk_hash(value, counts, index)
|
|
169
|
+
when Array then value.map { |item| walk(item, counts, index) }
|
|
169
170
|
else value
|
|
170
171
|
end
|
|
171
172
|
end
|
|
@@ -177,19 +178,19 @@ module Woods
|
|
|
177
178
|
# credential-shaped string is emitted as a Symbol after redaction
|
|
178
179
|
# (e.g. `:"[REDACTED]"`). String keys stay Strings. Non-String,
|
|
179
180
|
# non-Symbol keys (Integer, etc.) pass through unchanged.
|
|
180
|
-
def walk_hash(hash, counts)
|
|
181
|
+
def walk_hash(hash, counts, index)
|
|
181
182
|
hash.each_with_object({}) do |(key, val), out|
|
|
182
183
|
scanned_key = case key
|
|
183
|
-
when String then scan_string(key, counts)
|
|
184
|
-
when Symbol then scan_string(key.to_s, counts).to_sym
|
|
184
|
+
when String then scan_string(key, counts, index)
|
|
185
|
+
when Symbol then scan_string(key.to_s, counts, index).to_sym
|
|
185
186
|
else key
|
|
186
187
|
end
|
|
187
|
-
out[scanned_key] = walk(val, counts)
|
|
188
|
+
out[scanned_key] = walk(val, counts, index)
|
|
188
189
|
end
|
|
189
190
|
end
|
|
190
191
|
|
|
191
|
-
def scan_string(str, counts)
|
|
192
|
-
result = redact_indexed_secrets(str, counts)
|
|
192
|
+
def scan_string(str, counts, index)
|
|
193
|
+
result = redact_indexed_secrets(str, counts, index)
|
|
193
194
|
result = scan_encoded_forms(result, counts)
|
|
194
195
|
@active_patterns.inject(result) do |acc, (name, pattern)|
|
|
195
196
|
acc.gsub(pattern) do
|
|
@@ -290,10 +291,10 @@ module Woods
|
|
|
290
291
|
printable.to_f / str.length >= PRINTABLE_RATIO_THRESHOLD
|
|
291
292
|
end
|
|
292
293
|
|
|
293
|
-
def redact_indexed_secrets(str, counts)
|
|
294
|
-
return str unless
|
|
294
|
+
def redact_indexed_secrets(str, counts, index)
|
|
295
|
+
return str unless index&.match?(str)
|
|
295
296
|
|
|
296
|
-
redacted =
|
|
297
|
+
redacted = index.redact(str)
|
|
297
298
|
counts[INDEX_HIT] = (counts[INDEX_HIT] || 0) + redacted.scan(CredentialIndex::REDACTED).size
|
|
298
299
|
redacted
|
|
299
300
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Console
|
|
5
|
+
# Coordinates scanner construction and rotation without retaining servers
|
|
6
|
+
# after their transports release them. Requests keep their own index snapshot.
|
|
7
|
+
class CredentialScannerRegistry
|
|
8
|
+
def initialize
|
|
9
|
+
@scanners = ObjectSpace::WeakMap.new
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def register
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
scanner = yield
|
|
16
|
+
# Older Ruby WeakMap implementations validate the value's liveness
|
|
17
|
+
# when enumerating. Keep the scanner in both weak positions so an
|
|
18
|
+
# immortal sentinel cannot make an abandoned scanner look live.
|
|
19
|
+
@scanners[scanner] = scanner
|
|
20
|
+
scanner
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def rebuild
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
scanners = @scanners.values
|
|
27
|
+
return nil if scanners.empty?
|
|
28
|
+
|
|
29
|
+
index = yield
|
|
30
|
+
scanners.each { |scanner| scanner.replace_index!(index) }
|
|
31
|
+
index
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -103,7 +103,14 @@ module Woods
|
|
|
103
103
|
response = @conn_mgr.send_request(request)
|
|
104
104
|
return error_from_response(response, request) unless response['ok']
|
|
105
105
|
|
|
106
|
+
# Materialize the wire representation before policy checks. Otherwise
|
|
107
|
+
# Symbols and custom serializers can introduce unscanned strings later.
|
|
108
|
+
# Redact first so protected serializers never run; redact again after
|
|
109
|
+
# normalization to cover fields introduced by a custom serializer.
|
|
110
|
+
# Both renderers consume this same JSON-compatible data tree.
|
|
106
111
|
result = @ctx.redact(response['result'])
|
|
112
|
+
result = JSON.parse(JSON.generate(result))
|
|
113
|
+
result = @ctx.redact(result)
|
|
107
114
|
result = scan_for_credentials(result, request)
|
|
108
115
|
text = @renderer ? @renderer.render_default(result) : JSON.pretty_generate(result)
|
|
109
116
|
success_response(text)
|
|
@@ -479,7 +479,7 @@ module Woods
|
|
|
479
479
|
def handle_count(params)
|
|
480
480
|
model = resolve_model(params['model'])
|
|
481
481
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
482
|
-
{ 'count' => scope.count }
|
|
482
|
+
{ 'count' => checked_relation(scope).count }
|
|
483
483
|
end
|
|
484
484
|
|
|
485
485
|
def handle_sample(params)
|
|
@@ -488,7 +488,7 @@ module Woods
|
|
|
488
488
|
limit = params.fetch('limit', 5)
|
|
489
489
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
490
490
|
scope = apply_columns(scope, params['columns'])
|
|
491
|
-
records = scope.order(random_function).limit(limit)
|
|
491
|
+
records = checked_relation(scope.order(random_function).limit(limit))
|
|
492
492
|
{ 'records' => serialize_records(records, params['columns']) }
|
|
493
493
|
end
|
|
494
494
|
|
|
@@ -501,7 +501,8 @@ module Woods
|
|
|
501
501
|
end
|
|
502
502
|
validate_select_columns!(params)
|
|
503
503
|
model = resolve_model(params['model'])
|
|
504
|
-
|
|
504
|
+
scope = checked_relation(model)
|
|
505
|
+
record = params['id'] ? scope.find_by(id: params['id']) : scope.find_by(params['by'])
|
|
505
506
|
{ 'record' => record ? serialize_record(record, params['columns']) : nil }
|
|
506
507
|
end
|
|
507
508
|
|
|
@@ -534,7 +535,7 @@ module Woods
|
|
|
534
535
|
limit = params.fetch('limit', 100)
|
|
535
536
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
536
537
|
scope = scope.distinct if params['distinct']
|
|
537
|
-
values = scope.limit(limit).pluck(*columns.map(&:to_sym))
|
|
538
|
+
values = checked_relation(scope.limit(limit)).pluck(*columns.map(&:to_sym))
|
|
538
539
|
{ 'columns' => Array(columns), 'values' => values }
|
|
539
540
|
end
|
|
540
541
|
|
|
@@ -554,6 +555,7 @@ module Woods
|
|
|
554
555
|
|
|
555
556
|
model = resolve_model(params['model'])
|
|
556
557
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
558
|
+
scope = checked_relation(scope)
|
|
557
559
|
|
|
558
560
|
value = if function == 'count'
|
|
559
561
|
column ? scope.count(column.to_sym) : scope.count
|
|
@@ -566,6 +568,7 @@ module Woods
|
|
|
566
568
|
def handle_association_count(params)
|
|
567
569
|
model = resolve_model(params['model'])
|
|
568
570
|
association_name = params['association']
|
|
571
|
+
requested_scope = params['scope']
|
|
569
572
|
reflection = model.reflect_on_association(association_name.to_sym)
|
|
570
573
|
|
|
571
574
|
raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}" unless reflection
|
|
@@ -581,15 +584,33 @@ module Woods
|
|
|
581
584
|
# association's own model before any database I/O runs (not just
|
|
582
585
|
# before the association is read): `model.find` below is itself a
|
|
583
586
|
# query, and a request with a bad scope should never reach it.
|
|
584
|
-
validate_scope_columns!(
|
|
587
|
+
validate_scope_columns!(requested_scope, reflection.klass.name) if requested_scope
|
|
585
588
|
|
|
586
|
-
record = model.find(params['id'])
|
|
589
|
+
record = checked_relation(model).find(params['id'])
|
|
587
590
|
scope = record.public_send(association_name)
|
|
588
|
-
scope = apply_scope(scope,
|
|
591
|
+
scope = apply_scope(scope, requested_scope, model_name: reflection.klass.name) if requested_scope
|
|
589
592
|
gate_association_sql!(scope)
|
|
590
593
|
{ 'count' => scope.count }
|
|
591
594
|
end
|
|
592
595
|
|
|
596
|
+
# Materialize a model's default scope once, inspect the resolved SQL,
|
|
597
|
+
# and return that same relation for execution. Checking model.table_name
|
|
598
|
+
# alone misses default scopes that change FROM or introduce joins. Do
|
|
599
|
+
# not check model.all then issue a fresh query through the model: a
|
|
600
|
+
# dynamic default scope could differ between the check and execution.
|
|
601
|
+
# With no TableGate collaborator, retain the ungated executor contract.
|
|
602
|
+
#
|
|
603
|
+
# @param scope [Class, ActiveRecord::Relation] Pending model read
|
|
604
|
+
# @return [Class, ActiveRecord::Relation] Relation authorized for reading
|
|
605
|
+
# @raise [ValidationError] if its SQL references a blocked table
|
|
606
|
+
def checked_relation(scope)
|
|
607
|
+
return scope unless @table_gate
|
|
608
|
+
|
|
609
|
+
relation = scope.all
|
|
610
|
+
gate_sql!(relation.to_sql)
|
|
611
|
+
relation
|
|
612
|
+
end
|
|
613
|
+
|
|
593
614
|
# Defense-in-depth: gate_association! (called by
|
|
594
615
|
# {#handle_association_count} before this) only proves the
|
|
595
616
|
# association's OWN target table isn't blocked. A through-association,
|
|
@@ -738,7 +759,7 @@ module Woods
|
|
|
738
759
|
|
|
739
760
|
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
740
761
|
scope = apply_columns(scope, params['columns'])
|
|
741
|
-
records = scope.order(order_by => direction.to_sym).limit(limit)
|
|
762
|
+
records = checked_relation(scope.order(order_by => direction.to_sym).limit(limit))
|
|
742
763
|
{ 'records' => serialize_records(records, params['columns']) }
|
|
743
764
|
end
|
|
744
765
|
|
|
@@ -798,13 +819,14 @@ module Woods
|
|
|
798
819
|
# and PostgreSQL quote/comment grammars differ (`\'` escapes, `#`
|
|
799
820
|
# comments); validating with the matching dialect accepts dialect-valid
|
|
800
821
|
# literals and still rejects every known bypass form. Unknown adapters
|
|
801
|
-
# return nil and get the conservative
|
|
822
|
+
# return nil and get the conservative union of supported dialects.
|
|
802
823
|
#
|
|
803
824
|
# @return [Symbol, nil]
|
|
804
825
|
def sql_dialect
|
|
805
826
|
adapter = active_connection.adapter_name.to_s.downcase
|
|
806
827
|
return :mysql if adapter.include?('mysql')
|
|
807
828
|
return :postgres if adapter.include?('postgre')
|
|
829
|
+
return :sqlite if adapter.include?('sqlite')
|
|
808
830
|
|
|
809
831
|
nil
|
|
810
832
|
end
|
|
@@ -1491,7 +1513,7 @@ module Woods
|
|
|
1491
1513
|
# `SqlNoiseStripper` is the same module SqlValidator uses. The
|
|
1492
1514
|
# combined single-pass strip_noise resolves comments and literals
|
|
1493
1515
|
# together so a comment marker inside a literal can't hide a keyword.
|
|
1494
|
-
stripped = SqlNoiseStripper.strip_noise(template)
|
|
1516
|
+
stripped = SqlNoiseStripper.strip_noise(template, dialect: sql_dialect || :postgres, **mysql_quote_modes)
|
|
1495
1517
|
if SCOPE_TEMPLATE_FORBIDDEN.match?(stripped)
|
|
1496
1518
|
raise ValidationError,
|
|
1497
1519
|
'scope template contains forbidden SQL keywords ' \
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/encrypted_configuration'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Console
|
|
7
|
+
# A private, uncached Rails configuration for one credential-index build.
|
|
8
|
+
# Unlike Rails' permissive configuration reader, a missing encrypted file
|
|
9
|
+
# must raise during an explicit refresh rather than erase the last index.
|
|
10
|
+
class EncryptedCredentialSnapshot < ActiveSupport::EncryptedConfiguration
|
|
11
|
+
def read
|
|
12
|
+
ActiveSupport::EncryptedFile.instance_method(:read).bind(self).call
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -81,17 +81,12 @@ module Woods
|
|
|
81
81
|
# for compatibility. Passing it fails closed because console_eval is unavailable.
|
|
82
82
|
# @param unsafe_eval_audit_log_path [String, Pathname, nil] Legacy option retained
|
|
83
83
|
# for compatibility. Passing it fails closed because console_eval is unavailable.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
@unsafe_eval_confirmation = unsafe_eval_confirmation
|
|
91
|
-
@unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
|
|
92
|
-
@stateless = stateless
|
|
93
|
-
@mutex = Mutex.new
|
|
94
|
-
@transport = nil
|
|
84
|
+
# Rails 6.0 forwards middleware options as a positional hash on Ruby 3.
|
|
85
|
+
# Delegate to explicit keywords to preserve required/unknown option checks.
|
|
86
|
+
def initialize(app, options = {}, **keywords)
|
|
87
|
+
raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
initialize_options(app, **options, **keywords)
|
|
95
90
|
end
|
|
96
91
|
|
|
97
92
|
# Rack interface — intercepts requests at the configured path.
|
|
@@ -99,7 +94,8 @@ module Woods
|
|
|
99
94
|
# The enabled flag is read from Woods.configuration on every request,
|
|
100
95
|
# never captured at construction: the railtie inserts this middleware
|
|
101
96
|
# before config/initializers have run (#183). While
|
|
102
|
-
# `console_mcp_enabled` is false (the default),
|
|
97
|
+
# `console_mcp_enabled` is false (the default), or HTTP is explicitly
|
|
98
|
+
# disabled with `console_mcp_http_enabled = false`, requests — even at the
|
|
103
99
|
# mounted path — pass through to the wrapped app unchanged, so a host
|
|
104
100
|
# that never opted in is completely unaffected. Requests at
|
|
105
101
|
# non-matching paths always pass through.
|
|
@@ -118,11 +114,24 @@ module Woods
|
|
|
118
114
|
|
|
119
115
|
private
|
|
120
116
|
|
|
117
|
+
def initialize_options(app, path: '/mcp/console', embedded_read_tools: false, # rubocop:disable Metrics/ParameterLists
|
|
118
|
+
unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil,
|
|
119
|
+
stateless: true)
|
|
120
|
+
@app = app
|
|
121
|
+
@path = path
|
|
122
|
+
@embedded_read_tools = embedded_read_tools
|
|
123
|
+
@unsafe_eval_confirmation = unsafe_eval_confirmation
|
|
124
|
+
@unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
|
|
125
|
+
@stateless = stateless
|
|
126
|
+
@mutex = Mutex.new
|
|
127
|
+
@transport = nil
|
|
128
|
+
end
|
|
129
|
+
|
|
121
130
|
# Whether the console is enabled, read from the live configuration on
|
|
122
131
|
# every request. Nil-safe: the railtie mounts this middleware in every
|
|
123
132
|
# app (#183), including hosts that never called Woods.configure.
|
|
124
133
|
def enabled?
|
|
125
|
-
Woods.configuration&.console_mcp_enabled
|
|
134
|
+
Woods.configuration&.console_mcp_enabled && Woods.configuration.console_mcp_http_enabled
|
|
126
135
|
end
|
|
127
136
|
|
|
128
137
|
# Thread-safe lazy initialization of the MCP server and transport.
|
data/lib/woods/console/server.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative 'audit_logger'
|
|
|
14
14
|
require_relative 'confirmation'
|
|
15
15
|
require_relative 'console_response_renderer'
|
|
16
16
|
require_relative 'credential_scanner'
|
|
17
|
+
require_relative 'credential_scanner_registry'
|
|
17
18
|
require_relative 'eval_guard'
|
|
18
19
|
require_relative 'table_gate'
|
|
19
20
|
require_relative 'redactor'
|
|
@@ -44,10 +45,14 @@ module Woods
|
|
|
44
45
|
# transport.open
|
|
45
46
|
#
|
|
46
47
|
module Server # rubocop:disable Metrics/ModuleLength
|
|
48
|
+
SCANNERS = CredentialScannerRegistry.new
|
|
49
|
+
private_constant :SCANNERS
|
|
50
|
+
|
|
47
51
|
class << self # rubocop:disable Metrics/ClassLength
|
|
48
52
|
# Rebuild the boot-time credential index from fresh Rails credentials
|
|
49
|
-
# and hot-swap it into
|
|
53
|
+
# and hot-swap it into every live scanner without restarting the process.
|
|
50
54
|
#
|
|
55
|
+
# Refresh failures propagate without replacing any live index.
|
|
51
56
|
# Host rotation jobs should call this immediately after `rails credentials:edit`
|
|
52
57
|
# changes are deployed. The swap is atomic on MRI (GVL) — in-flight scans see
|
|
53
58
|
# either the old or the new index, never a partial one.
|
|
@@ -62,14 +67,11 @@ module Woods
|
|
|
62
67
|
# the rebuild was skipped.
|
|
63
68
|
def rebuild_credential_index(rails_app: nil)
|
|
64
69
|
return nil unless credential_defense_enabled?
|
|
65
|
-
return nil unless @active_scanner
|
|
66
70
|
|
|
67
71
|
target_app = rails_app || default_rails_app
|
|
68
72
|
return nil unless target_app
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
@active_scanner.replace_index!(new_index)
|
|
72
|
-
new_index
|
|
74
|
+
SCANNERS.rebuild { CredentialIndex.refresh(rails_app: target_app) }
|
|
73
75
|
end
|
|
74
76
|
|
|
75
77
|
# True when Woods is configured and credential defense is on.
|
|
@@ -288,21 +290,21 @@ module Woods
|
|
|
288
290
|
model_reflections: model_reflections)
|
|
289
291
|
end
|
|
290
292
|
|
|
291
|
-
|
|
292
|
-
disabled_patterns = Array(config&.console_disabled_scanner_patterns)
|
|
293
|
-
scanner = if disabled_patterns.include?(:all)
|
|
294
|
-
nil
|
|
295
|
-
else
|
|
296
|
-
CredentialScanner.new(
|
|
297
|
-
disabled_patterns: disabled_patterns,
|
|
298
|
-
secret_index: secret_index
|
|
299
|
-
)
|
|
300
|
-
end
|
|
301
|
-
@active_scanner = scanner
|
|
293
|
+
scanner = build_scanner(config)
|
|
302
294
|
|
|
303
295
|
ResponseContext.build(safe_ctx: safe_ctx, table_gate: table_gate, credential_scanner: scanner)
|
|
304
296
|
end
|
|
305
297
|
|
|
298
|
+
def build_scanner(config)
|
|
299
|
+
disabled_patterns = Array(config&.console_disabled_scanner_patterns)
|
|
300
|
+
return nil if disabled_patterns.include?(:all)
|
|
301
|
+
|
|
302
|
+
SCANNERS.register do
|
|
303
|
+
CredentialScanner.new(disabled_patterns: disabled_patterns,
|
|
304
|
+
secret_index: build_credential_index(config))
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
306
308
|
# Build the boot-time credential index from Rails.application's encrypted
|
|
307
309
|
# credentials. Returns nil when credential defense is disabled or when no
|
|
308
310
|
# Rails application is reachable (specs, non-Rails hosts) — the scanner
|
|
@@ -53,20 +53,21 @@ 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) or `:
|
|
56
|
+
# @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite`.
|
|
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.
|
|
63
64
|
# @return [String] a new string with all string literals replaced by `''`
|
|
64
65
|
# @raise [ArgumentError] if an unsupported dialect is provided
|
|
65
66
|
DOLLAR_QUOTED = /\$(\w*)\$.*?\$\1\$/m
|
|
66
67
|
SINGLE_QUOTED_POSTGRES = /'(?:''|[^'])*'/m
|
|
67
68
|
SINGLE_QUOTED_MYSQL = /'(?:\\.|''|[^'])*'/m
|
|
68
69
|
|
|
69
|
-
SUPPORTED_DIALECTS = %i[postgres mysql].freeze
|
|
70
|
+
SUPPORTED_DIALECTS = %i[postgres mysql sqlite].freeze
|
|
70
71
|
private_constant :SUPPORTED_DIALECTS
|
|
71
72
|
|
|
72
73
|
def self.strip_literals(sql, dialect: :postgres)
|
|
@@ -76,7 +77,7 @@ module Woods
|
|
|
76
77
|
|
|
77
78
|
# Strip dollar-quoted strings first so stray apostrophes inside them
|
|
78
79
|
# do not interfere with the single-quote scanner.
|
|
79
|
-
out = sql.gsub(DOLLAR_QUOTED, "''")
|
|
80
|
+
out = dialect == :sqlite ? sql : sql.gsub(DOLLAR_QUOTED, "''")
|
|
80
81
|
|
|
81
82
|
pattern = dialect == :mysql ? SINGLE_QUOTED_MYSQL : SINGLE_QUOTED_POSTGRES
|
|
82
83
|
out.gsub(pattern, "''")
|
|
@@ -112,7 +113,7 @@ module Woods
|
|
|
112
113
|
# still reads as following a boundary once comments are gone.
|
|
113
114
|
#
|
|
114
115
|
# @param sql [String] the SQL string to process
|
|
115
|
-
# @param dialect [Symbol] `:postgres` (default) or `:
|
|
116
|
+
# @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite` — controls
|
|
116
117
|
# single-quote escape rules (see {.strip_literals}) and whether `#`
|
|
117
118
|
# opens a line comment. MySQL quote flags reflect session sql_mode.
|
|
118
119
|
# @return [String] a new string with comments removed and every string
|
|
@@ -134,7 +135,8 @@ module Woods
|
|
|
134
135
|
if ch == "'"
|
|
135
136
|
close = single_quote_end(
|
|
136
137
|
sql, i,
|
|
137
|
-
backslash_escapes: (mysql && !no_backslash_escapes) ||
|
|
138
|
+
backslash_escapes: (mysql && !no_backslash_escapes) ||
|
|
139
|
+
(dialect == :postgres && postgres_escape_string?(sql, i))
|
|
138
140
|
)
|
|
139
141
|
if close
|
|
140
142
|
out << "''"
|
|
@@ -158,7 +160,7 @@ module Woods
|
|
|
158
160
|
out << ch
|
|
159
161
|
i += 1
|
|
160
162
|
end
|
|
161
|
-
elsif mysql && ch == '`'
|
|
163
|
+
elsif (mysql || dialect == :sqlite) && ch == '`'
|
|
162
164
|
close = quoted_span_end(sql, i, quote: '`', backslash_escapes: false)
|
|
163
165
|
if close
|
|
164
166
|
# Backticks delimit identifiers. Preserve the token for table
|
|
@@ -170,7 +172,7 @@ module Woods
|
|
|
170
172
|
out << ch
|
|
171
173
|
i += 1
|
|
172
174
|
end
|
|
173
|
-
elsif
|
|
175
|
+
elsif dialect == :postgres && ch == '$' && !preceded_by_word_char?(sql, i) && (tag = dollar_tag_at(sql, i))
|
|
174
176
|
close = sql.index(tag, i + tag.length)
|
|
175
177
|
if close
|
|
176
178
|
out << "''"
|
|
@@ -167,6 +167,49 @@ 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
|
+
# @param sql [String] noise-stripped SQL
|
|
173
|
+
# @return [Array<String>]
|
|
174
|
+
def self.relation_factors(sql)
|
|
175
|
+
sql.to_enum(:scan, /\b(?:FROM|JOIN)\s+/i).flat_map do
|
|
176
|
+
suffix = sql[Regexp.last_match.end(0)..]
|
|
177
|
+
split_top_level_commas(relation_clause(suffix))
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Quoted tokens shield punctuation and clause words; a closing parenthesis
|
|
182
|
+
# at depth zero ends this query's clause, not a nested table expression.
|
|
183
|
+
def self.relation_clause(suffix)
|
|
184
|
+
depth = 0
|
|
185
|
+
tokens = /
|
|
186
|
+
"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|[()]|
|
|
187
|
+
\b(?:WHERE|GROUP|HAVING|ORDER|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|WINDOW)\b
|
|
188
|
+
/ix
|
|
189
|
+
suffix.to_enum(:scan, tokens).each do
|
|
190
|
+
token = Regexp.last_match[0]
|
|
191
|
+
start = Regexp.last_match.begin(0)
|
|
192
|
+
finish = Regexp.last_match.end(0)
|
|
193
|
+
boundary = token == ')' || relation_keyword_boundary?(suffix[0...start], suffix[finish..], token)
|
|
194
|
+
return suffix[0...start] if depth.zero? && boundary
|
|
195
|
+
|
|
196
|
+
depth += 1 if token == '('
|
|
197
|
+
depth -= 1 if token == ')'
|
|
198
|
+
end
|
|
199
|
+
suffix
|
|
200
|
+
end
|
|
201
|
+
private_class_method :relation_clause
|
|
202
|
+
|
|
203
|
+
def self.relation_keyword_boundary?(prefix, rest, token)
|
|
204
|
+
return false unless token.match?(/\A[A-Za-z]/)
|
|
205
|
+
return false if prefix.strip.empty? || prefix.match?(/(?:,|\bAS)\s*\z/i) || rest.lstrip.start_with?(',')
|
|
206
|
+
return rest.match?(/\A\s+BY\b/i) if %w[GROUP ORDER].include?(token.upcase)
|
|
207
|
+
return rest.match?(/\A\s+\w+\s+AS\b/i) if token.casecmp?('WINDOW')
|
|
208
|
+
|
|
209
|
+
true
|
|
210
|
+
end
|
|
211
|
+
private_class_method :relation_keyword_boundary?
|
|
212
|
+
|
|
170
213
|
# @api private
|
|
171
214
|
# Every view of the stripped SQL the FROM/JOIN scans must consider for
|
|
172
215
|
# MySQL executable comments (`/*! ... */`). {SqlNoiseStripper} leaves
|
|
@@ -203,12 +246,9 @@ module Woods
|
|
|
203
246
|
|
|
204
247
|
# @api private
|
|
205
248
|
def self.collect_from_identifiers(sql, results)
|
|
206
|
-
sql.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
ident = lead_identifier(chunk)
|
|
210
|
-
results << ident if ident
|
|
211
|
-
end
|
|
249
|
+
relation_factors(sql).each do |chunk|
|
|
250
|
+
ident = lead_identifier(chunk)
|
|
251
|
+
results << ident if ident
|
|
212
252
|
end
|
|
213
253
|
end
|
|
214
254
|
private_class_method :collect_from_identifiers
|
|
@@ -229,7 +269,7 @@ module Woods
|
|
|
229
269
|
depth = 0
|
|
230
270
|
buf = +''
|
|
231
271
|
parts = []
|
|
232
|
-
clause.
|
|
272
|
+
clause.scan(/"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|./m).each do |ch|
|
|
233
273
|
case ch
|
|
234
274
|
when '('
|
|
235
275
|
depth += 1
|