woods 1.6.4 → 2.0.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1879 -37
  3. data/CONTRIBUTING.md +195 -137
  4. data/README.md +162 -520
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +620 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +415 -0
  19. data/docs/INTERNALS.md +415 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +197 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +39 -3
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +78 -24
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +40 -1
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +18 -13
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/credential_index.rb +5 -53
  64. data/lib/woods/console/credential_scanner.rb +15 -16
  65. data/lib/woods/console/dispatch_pipeline.rb +46 -34
  66. data/lib/woods/console/embedded_executor.rb +806 -257
  67. data/lib/woods/console/eval_guard.rb +27 -20
  68. data/lib/woods/console/input_contract.rb +78 -0
  69. data/lib/woods/console/model_validator.rb +24 -6
  70. data/lib/woods/console/rack_middleware.rb +62 -63
  71. data/lib/woods/console/redactor.rb +10 -24
  72. data/lib/woods/console/safe_context.rb +45 -45
  73. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  74. data/lib/woods/console/server.rb +136 -267
  75. data/lib/woods/console/sql_noise_stripper.rb +20 -51
  76. data/lib/woods/console/sql_table_scanner.rb +39 -90
  77. data/lib/woods/console/sql_validator.rb +455 -85
  78. data/lib/woods/console/table_gate.rb +2 -2
  79. data/lib/woods/console/tool_specs.rb +462 -88
  80. data/lib/woods/console/tools/tier1.rb +0 -3
  81. data/lib/woods/console/tools/tier4.rb +17 -7
  82. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  83. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  84. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  85. data/lib/woods/db/migrator.rb +3 -9
  86. data/lib/woods/db/schema_version.rb +47 -2
  87. data/lib/woods/dependency_graph.rb +898 -64
  88. data/lib/woods/embedding/fake.rb +138 -0
  89. data/lib/woods/embedding/indexer.rb +832 -40
  90. data/lib/woods/embedding/openai.rb +77 -19
  91. data/lib/woods/embedding/provider.rb +189 -11
  92. data/lib/woods/embedding/text_preparer.rb +1 -1
  93. data/lib/woods/embedding/token_counter.rb +0 -7
  94. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  95. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  96. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  97. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  98. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  99. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  100. data/lib/woods/evaluation/ablation_task.rb +66 -0
  101. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  102. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  103. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  104. data/lib/woods/evaluation/baseline.rb +60 -0
  105. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  106. data/lib/woods/evaluation/evaluator.rb +41 -8
  107. data/lib/woods/evaluation/query_set.rb +79 -13
  108. data/lib/woods/evaluation/report_generator.rb +20 -1
  109. data/lib/woods/export/unit_facts.rb +0 -11
  110. data/lib/woods/extracted_unit.rb +22 -63
  111. data/lib/woods/extractor.rb +2503 -192
  112. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  113. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  114. data/lib/woods/extractors/caching_extractor.rb +46 -12
  115. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  116. data/lib/woods/extractors/component_discovery.rb +123 -0
  117. data/lib/woods/extractors/concern_extractor.rb +17 -3
  118. data/lib/woods/extractors/controller_extractor.rb +389 -29
  119. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  120. data/lib/woods/extractors/engine_extractor.rb +53 -8
  121. data/lib/woods/extractors/event_extractor.rb +55 -4
  122. data/lib/woods/extractors/factory_extractor.rb +49 -11
  123. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  124. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  125. data/lib/woods/extractors/job_extractor.rb +51 -21
  126. data/lib/woods/extractors/lib_extractor.rb +23 -17
  127. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  128. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  129. data/lib/woods/extractors/manager_extractor.rb +19 -2
  130. data/lib/woods/extractors/migration_extractor.rb +22 -11
  131. data/lib/woods/extractors/model_extractor.rb +292 -57
  132. data/lib/woods/extractors/package_extractor.rb +154 -0
  133. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  134. data/lib/woods/extractors/policy_extractor.rb +6 -5
  135. data/lib/woods/extractors/poro_extractor.rb +13 -14
  136. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  137. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  138. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  139. data/lib/woods/extractors/reference_patterns.rb +38 -0
  140. data/lib/woods/extractors/route_extractor.rb +58 -2
  141. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  142. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  143. data/lib/woods/extractors/service_extractor.rb +11 -1
  144. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  145. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  146. data/lib/woods/extractors/source_nesting.rb +560 -0
  147. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  148. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  149. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  150. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  151. data/lib/woods/feedback/gap_detector.rb +9 -3
  152. data/lib/woods/feedback/store.rb +7 -1
  153. data/lib/woods/filename_utils.rb +29 -1
  154. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  155. data/lib/woods/flow_assembler.rb +63 -21
  156. data/lib/woods/flow_document.rb +1 -0
  157. data/lib/woods/flow_precomputer.rb +138 -22
  158. data/lib/woods/gem_mapper.rb +285 -0
  159. data/lib/woods/generation.rb +185 -0
  160. data/lib/woods/git_command.rb +38 -0
  161. data/lib/woods/git_provenance.rb +16 -2
  162. data/lib/woods/graph_analyzer.rb +408 -34
  163. data/lib/woods/index_artifact.rb +93 -23
  164. data/lib/woods/mcp/bearer_auth.rb +92 -22
  165. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  166. data/lib/woods/mcp/bootstrapper.rb +582 -77
  167. data/lib/woods/mcp/config_resolver.rb +66 -6
  168. data/lib/woods/mcp/errors.rb +60 -0
  169. data/lib/woods/mcp/index_reader.rb +836 -117
  170. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  171. data/lib/woods/mcp/origin_guard.rb +108 -23
  172. data/lib/woods/mcp/protocol_policy.rb +98 -0
  173. data/lib/woods/mcp/provider_probe.rb +45 -6
  174. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  175. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  176. data/lib/woods/mcp/server.rb +907 -154
  177. data/lib/woods/mcp/tasks/extension.rb +196 -0
  178. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  179. data/lib/woods/mcp/tasks/store.rb +518 -0
  180. data/lib/woods/mcp/tool_contract.rb +171 -0
  181. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  182. data/lib/woods/mcp/version_aware_tool_dispatch.rb +3 -9
  183. data/lib/woods/model_name_cache.rb +19 -1
  184. data/lib/woods/notion/client.rb +132 -36
  185. data/lib/woods/notion/exporter.rb +456 -61
  186. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  187. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  188. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  189. data/lib/woods/notion/mappers/shared.rb +45 -3
  190. data/lib/woods/notion/sync_manifest.rb +258 -0
  191. data/lib/woods/obsidian/errors.rb +6 -0
  192. data/lib/woods/obsidian/name_mapper.rb +40 -24
  193. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  194. data/lib/woods/operator/pipeline_guard.rb +118 -21
  195. data/lib/woods/operator/status_reporter.rb +20 -3
  196. data/lib/woods/path_dispatcher.rb +276 -0
  197. data/lib/woods/payload_store.rb +223 -0
  198. data/lib/woods/published_index/edge_shaper.rb +61 -0
  199. data/lib/woods/published_index/generation_catalog.rb +72 -0
  200. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  201. data/lib/woods/published_index.rb +287 -0
  202. data/lib/woods/railtie.rb +70 -38
  203. data/lib/woods/railtie_support.rb +167 -0
  204. data/lib/woods/release.rb +12 -0
  205. data/lib/woods/reload_policy.rb +206 -0
  206. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  207. data/lib/woods/resilience/index_validator.rb +296 -10
  208. data/lib/woods/resilience/retryable_provider.rb +71 -6
  209. data/lib/woods/resolved_config.rb +55 -11
  210. data/lib/woods/retrieval/context_assembler.rb +132 -40
  211. data/lib/woods/retrieval/query_classifier.rb +25 -6
  212. data/lib/woods/retrieval/ranker.rb +193 -28
  213. data/lib/woods/retrieval/search_executor.rb +206 -39
  214. data/lib/woods/retriever.rb +317 -71
  215. data/lib/woods/retry_after.rb +22 -2
  216. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  217. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  218. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  219. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  220. data/lib/woods/ruby_analyzer.rb +21 -5
  221. data/lib/woods/session_tracer/file_store.rb +138 -19
  222. data/lib/woods/session_tracer/redis_store.rb +122 -12
  223. data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
  224. data/lib/woods/session_tracer/session_flow_document.rb +52 -6
  225. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  226. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  227. data/lib/woods/session_tracer/store.rb +14 -1
  228. data/lib/woods/storage/metadata_store.rb +230 -26
  229. data/lib/woods/storage/pgvector.rb +180 -22
  230. data/lib/woods/storage/qdrant.rb +367 -41
  231. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  232. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  233. data/lib/woods/storage/snapshotter.rb +23 -5
  234. data/lib/woods/storage/vector_store.rb +49 -8
  235. data/lib/woods/storage_identity.rb +28 -0
  236. data/lib/woods/tasks.rb +53 -2
  237. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  238. data/lib/woods/temporal/snapshot_store.rb +139 -42
  239. data/lib/woods/unblocked/client.rb +119 -17
  240. data/lib/woods/unblocked/document_builder.rb +34 -2
  241. data/lib/woods/unblocked/exporter.rb +63 -27
  242. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  243. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  244. data/lib/woods/update_check.rb +24 -1
  245. data/lib/woods/util/uuid5.rb +124 -0
  246. data/lib/woods/version.rb +1 -1
  247. data/lib/woods/watch/daemon.rb +1345 -0
  248. data/lib/woods/watch/listen_watcher.rb +81 -0
  249. data/lib/woods/watch/polling_watcher.rb +137 -0
  250. data/lib/woods/watch/status.rb +169 -0
  251. data/lib/woods/watch/tree_scan.rb +163 -0
  252. data/lib/woods/watch/watcher.rb +100 -0
  253. data/lib/woods.rb +53 -9
  254. data/plugin/.claude-plugin/plugin.json +18 -0
  255. data/plugin/hooks/hooks.json +29 -0
  256. data/plugin/hooks/woods-post-edit.sh +226 -0
  257. data/plugin/hooks/woods-session-start.sh +77 -0
  258. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  259. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  260. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  261. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  262. data/plugin/skills/woods-setup/SKILL.md +99 -0
  263. metadata +102 -30
  264. data/lib/woods/console/adapter_family.rb +0 -39
  265. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  266. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  268. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  269. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  270. data/lib/woods/console/bridge.rb +0 -210
  271. data/lib/woods/console/credential_scanner_registry.rb +0 -36
  272. data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
  273. data/lib/woods/console/sql_output_policy.rb +0 -535
  274. data/lib/woods/console/sqlite_read_guard.rb +0 -46
  275. data/lib/woods/formatting/claude_adapter.rb +0 -98
  276. data/lib/woods/formatting/generic_adapter.rb +0 -56
  277. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  278. data/lib/woods/mcp/http_transport_options.rb +0 -15
  279. data/lib/woods/mcp/origin_policy.rb +0 -113
  280. data/lib/woods/notion/mapper.rb +0 -40
  281. data/lib/woods/observability/health_check.rb +0 -79
  282. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -1,535 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Woods
4
- module Console
5
- # Preserve protected column identity before legacy Console reads execute.
6
- # This policy supplements the existing output redactor without changing
7
- # the legacy tool surface or its explicitly opted-in mutation controls.
8
- module SqlOutputPolicy # rubocop:disable Metrics/ModuleLength -- one policy keeps output identity and EAV provenance together
9
- SELECT_EXPRESSION = /\A\s*
10
- (?:(SUM|AVG|MIN|MAX|COUNT)\s*\(\s*(\*|\w+(?:\.\w+)?)\s*\)|(\w+(?:\.\w+)?))
11
- (?:\s+AS\s+(\w+))?\s*\z/ix
12
- private_constant :SELECT_EXPRESSION
13
-
14
- private
15
-
16
- def redaction_columns
17
- @redaction_context.respond_to?(:redacted_columns) ? @redaction_context.redacted_columns : []
18
- end
19
-
20
- def redaction_key_values
21
- @redaction_context.respond_to?(:redacted_key_values) ? @redaction_context.redacted_key_values : []
22
- end
23
-
24
- def refuse_redacted_column!(column)
25
- return unless redaction_columns.any? { |name| name.to_s.casecmp?(column.to_s) }
26
-
27
- raise ValidationError,
28
- "Rejected: column '#{column}' is redacted (console_redacted_columns) and cannot be used " \
29
- 'as a scope, filter, aggregate, find, or order key.'
30
- end
31
-
32
- def refuse_protected_predicate_column!(column)
33
- base = base_column_name(column.to_s)
34
- return refuse_redacted_column!(base) if redaction_columns.any? { |name| name.to_s.casecmp?(base) }
35
- return unless redacted_eav_value_columns.any? { |name| name.to_s.casecmp?(base) }
36
-
37
- raise ValidationError,
38
- "Rejected: EAV value column '#{base}' is redacted (console_redacted_key_values) and cannot " \
39
- 'be used as a scope, filter, or having predicate.'
40
- end
41
-
42
- def redacted_eav_value_columns
43
- redaction_key_values.filter_map { |pattern| pattern['value_column'] }
44
- end
45
-
46
- # Keep legacy scope syntax while refusing protected values as inputs.
47
- # The same check runs before every model read, including association
48
- # scopes, so filtering cannot disclose a value hidden by the renderer.
49
- def refuse_protected_scope!(scope)
50
- case scope
51
- when Hash
52
- scope.each do |key, value|
53
- column = key.to_s.sub(ScopePredicateParser::SUFFIX_PATTERN, '')
54
- refuse_protected_predicate_column!(column)
55
- refuse_protected_scope!(value) if value.is_a?(Hash)
56
- end
57
- when Array
58
- refuse_protected_predicate_references!(scope.first) if scope.first.is_a?(String)
59
- end
60
- end
61
-
62
- def refuse_orphan_eav_value_selection!(expressions, model_name = nil)
63
- selected = directly_selected_columns(expressions)
64
-
65
- redaction_key_values.each do |pattern|
66
- values = selected.select { |column| base_column_name(column).casecmp?(pattern['value_column']) }
67
- values.select! { |column| eav_source_has_key?(column, pattern, model_name) }
68
- next if values.empty?
69
-
70
- keys = selected.select { |column| base_column_name(column).casecmp?(pattern['key_column']) }
71
- next if paired_eav_sources?(keys, values)
72
-
73
- raise ValidationError,
74
- "Rejected: selecting EAV value column '#{pattern['value_column']}' without its paired " \
75
- "key column '#{pattern['key_column']}' bypasses redaction; select both columns so the " \
76
- 'value can be masked. Both columns must have the same unambiguous source.'
77
- end
78
- end
79
-
80
- def eav_source_has_key?(column, pattern, model_name)
81
- columns = if column.include?('.')
82
- @model_validator.columns_for_table(column.split('.')[0...-1].join('.'))
83
- elsif model_name
84
- @model_validator.columns_for(model_name)
85
- end
86
- columns.nil? || columns.include?(pattern['key_column'])
87
- end
88
-
89
- def paired_eav_sources?(keys, values)
90
- return false unless keys.one? && values.one?
91
-
92
- keys.first.split('.')[0...-1] == values.first.split('.')[0...-1]
93
- end
94
-
95
- def directly_selected_columns(expressions)
96
- expressions.filter_map do |expr|
97
- match = SELECT_EXPRESSION.match(expr)
98
- next unless match
99
-
100
- fn_arg, bare_col, alias_name = match.captures[1..]
101
- next if fn_arg || alias_name
102
-
103
- bare_col
104
- end
105
- end
106
-
107
- def refuse_redacted_select_shapes!(captures, model_name)
108
- fn, fn_arg, bare_col, alias_name = captures
109
- column = bare_col || fn_arg
110
- validate_column_reference!(column, model_name) unless column == '*'
111
-
112
- refuse_protected_alias_target!(alias_name) if alias_name
113
- refuse_redacted_select_alias!(bare_col, alias_name) if alias_name
114
- refuse_redacted_aggregate_expression!(fn_arg) if fn
115
- end
116
-
117
- def refuse_protected_alias_target!(alias_name)
118
- return unless protected_column_name?(alias_name)
119
-
120
- raise ValidationError,
121
- "Rejected: alias '#{alias_name}' names a protected output header; an alias must not " \
122
- 'collide with a redacted or EAV column name.'
123
- end
124
-
125
- def protected_column_name?(name)
126
- casecmp_member?(redaction_columns, name) ||
127
- casecmp_member?(redacted_kv_columns, name)
128
- end
129
-
130
- def casecmp_member?(list, name)
131
- list.any? { |entry| entry.to_s.casecmp?(name.to_s) }
132
- end
133
-
134
- def refuse_redacted_select_alias!(column, alias_name)
135
- return unless column
136
-
137
- base = base_column_name(column)
138
- if casecmp_member?(redaction_columns, base)
139
- raise ValidationError,
140
- "Rejected: aliasing redacted column '#{base}' as '#{alias_name}' bypasses output " \
141
- 'redaction. Select it unaliased; the value is masked.'
142
- end
143
-
144
- return unless casecmp_member?(redacted_kv_columns, base)
145
-
146
- raise ValidationError,
147
- "Rejected: aliasing redacted key/value column '#{base}' as '#{alias_name}' bypasses " \
148
- 'EAV output redaction. Select it unaliased.'
149
- end
150
-
151
- def refuse_redacted_aggregate_expression!(column)
152
- return if column.nil? || column == '*'
153
-
154
- base = base_column_name(column)
155
- if casecmp_member?(redaction_columns, base)
156
- raise ValidationError,
157
- "Rejected: aggregating redacted column '#{base}' reads its value; it cannot be used " \
158
- 'as an aggregate input.'
159
- end
160
-
161
- return unless casecmp_member?(redacted_kv_columns, base)
162
-
163
- raise ValidationError,
164
- "Rejected: aggregating redacted key/value column '#{base}' reads its value; it cannot " \
165
- 'be used as an aggregate input.'
166
- end
167
-
168
- def redacted_kv_columns
169
- redaction_key_values
170
- .flat_map { |pattern| [pattern['key_column'], pattern['value_column']] }
171
- end
172
-
173
- def base_column_name(column)
174
- column.split('.').last
175
- end
176
-
177
- def validate_protected_sql_usage!(sql)
178
- # This exact wrapper only preserves the inner statement's output
179
- # headers. SqlValidator has already checked balanced delimiters.
180
- limited = /\ASELECT \* FROM \(\n(.*)\n\) AS _limited LIMIT \d+\z/m.match(sql)
181
- if limited
182
- SqlValidator.new(dialect: sql_dialect, mysql_modes: mysql_quote_modes).validate!(limited[1])
183
- return validate_protected_sql_usage!(limited[1])
184
- end
185
-
186
- sql_security_views(sql).each { |stripped| validate_protected_sql_view!(stripped) }
187
- end
188
-
189
- def validate_protected_sql_view!(stripped)
190
- refuse_sql_column_alias_lists!(stripped)
191
- refuse_composite_sql_projection!(stripped)
192
- refuse_ambiguous_eav_sql!(stripped)
193
- refuse_protected_sql_positions!(stripped)
194
- protected = (redaction_columns + redacted_kv_columns).uniq
195
- referenced = protected.select { |column| sql_identifier_referenced?(stripped, column) }
196
- return if referenced.empty?
197
-
198
- expressions, tail = protected_sql_projection(stripped)
199
- selected = expressions.filter_map { |expression| direct_sql_column_name(expression) }
200
- unsafe = unsafe_protected_sql_column(referenced, expressions, selected, tail, stripped)
201
- return unless unsafe
202
-
203
- raise ValidationError,
204
- "Rejected: console_sql uses protected column '#{unsafe}' in an alias, aggregate, predicate, or " \
205
- 'unpaired EAV shape that cannot preserve redaction identity. Select protected columns directly ' \
206
- 'and unaliased, or use a structured Console tool.'
207
- end
208
-
209
- # Relation/CTE column lists rename fields by position, before output
210
- # headers are available. Refuse them whenever either policy is active,
211
- # independently of protected-name references or function validation.
212
- def refuse_sql_column_alias_lists!(stripped)
213
- return if redaction_columns.empty? && redacted_kv_columns.empty?
214
-
215
- identifier = /(?:[A-Za-z_\u0080-\u{10ffff}][A-Za-z0-9_$\u0080-\u{10ffff}]*|"(?:""|[^"])+"|`(?:``|[^`])+`)/u
216
- list = /#{identifier}\s*\(\s*#{identifier}(?:\s*,\s*#{identifier})*\s*\)/
217
- cte = /#{list}\s*AS\s*(?:(?:NOT\s+)?MATERIALIZED\s*)?\(/i
218
- relation = /\A\s*(?:AS\s+)?#{list}/i
219
- renamed = stripped.match?(cte) || SqlTableScanner.relation_factors(stripped).any? do |factor|
220
- sql_relation_alias_tail(factor).match?(relation)
221
- end
222
- return unless renamed
223
-
224
- raise ValidationError,
225
- 'Rejected: SQL relation or CTE column alias lists cannot preserve redaction identity. ' \
226
- 'Select protected columns directly and unaliased, or use a structured Console tool.'
227
- end
228
-
229
- # Consume only the leading source, leaving its optional alias. Balanced
230
- # sources include subqueries, ONLY(table), and table-function arguments;
231
- # scalar functions elsewhere in the clause are not alias declarations.
232
- def sql_relation_alias_tail(factor)
233
- tokens = sql_policy_tokens(factor)
234
- index = %w[ONLY LATERAL].include?(sql_token_text(tokens, 0)) ? 1 : 0
235
- unless sql_token_text(tokens, index) == '('
236
- index += 1
237
- index += 2 while sql_token_text(tokens, index) == '.'
238
- end
239
- index = sql_after_parentheses(tokens, index) if sql_token_text(tokens, index) == '('
240
- index += 1 if sql_token_text(tokens, index) == '*'
241
- tokens[index] ? factor[tokens[index][:start]..] : ''
242
- end
243
-
244
- def sql_after_parentheses(tokens, index)
245
- depth = tokens[index][:depth]
246
- closing = ((index + 1)...tokens.length).find do |offset|
247
- tokens[offset][:text] == ')' && tokens[offset][:depth] == depth
248
- end
249
- closing ? closing + 1 : tokens.length
250
- end
251
-
252
- # Result metadata provides column names, not their table provenance.
253
- # A joined/derived EAV row can supply a key from a different source.
254
- # Keep direct single-source SQL reads and qualified structured joins;
255
- # refuse ambiguous SQL shapes before fetching their values.
256
- def refuse_ambiguous_eav_sql!(stripped)
257
- return if sql_eav_patterns(stripped).empty?
258
- return if SqlTableScanner.relation_factors(stripped).size <= 1
259
- return unless stripped.include?('*') || redacted_eav_value_columns.any? do |column|
260
- sql_identifier_referenced?(stripped, column)
261
- end
262
-
263
- raise ValidationError,
264
- 'Rejected: SQL EAV values require one unambiguous source; use a structured Console query.'
265
- end
266
-
267
- # PostgreSQL can return a table alias as one composite-valued column.
268
- # Its header carries no identities for the protected fields inside it.
269
- # Reject source names used as projection values, including nested forms.
270
- def refuse_composite_sql_projection!(stripped)
271
- return if redaction_columns.empty? && redacted_kv_columns.empty?
272
-
273
- sources = SqlTableScanner.relation_factors(stripped).flat_map { |factor| sql_relation_names(factor) }
274
- tokens = sql_policy_tokens(stripped)
275
- return unless whole_row_value?(tokens, sources)
276
-
277
- raise ValidationError,
278
- 'Rejected: whole-row SQL values cannot preserve protected field identity; select columns.'
279
- end
280
-
281
- # Track each query's clause separately, including correlated expressions
282
- # without a FROM clause. Relation declarations and qualified columns are
283
- # not values; a source token anywhere else may be a composite row.
284
- def whole_row_value?(tokens, sources)
285
- clauses = {}
286
- query_levels = {}
287
- tokens.each_with_index.any? do |token, index|
288
- clause = sql_clause_at_token(token, clauses, query_levels)
289
- next false unless clause && !%w[FROM JOIN STRAIGHT_JOIN].include?(clause)
290
- next false unless sources.any? { |source| sql_unquote(token[:text]).casecmp?(source) }
291
-
292
- !sql_nonvalue_reference?(tokens, index)
293
- end
294
- end
295
-
296
- def sql_clause_at_token(token, clauses, query_levels)
297
- word, depth = token.values_at(:text, :depth)
298
- keyword = word.upcase
299
- if word == '('
300
- clauses[depth + 1] = 'EXPRESSION'
301
- query_levels.delete(depth + 1)
302
- end
303
- if word == ')'
304
- clauses.delete(depth + 1)
305
- query_levels.delete(depth + 1)
306
- end
307
- query_levels[depth] = true if keyword == 'SELECT'
308
- boundaries = %w[SELECT FROM JOIN STRAIGHT_JOIN ON WHERE HAVING ORDER GROUP LIMIT OFFSET]
309
- clauses[depth] = keyword if query_levels[depth] && boundaries.include?(keyword)
310
- clauses[depth]
311
- end
312
-
313
- def sql_nonvalue_reference?(tokens, index)
314
- previous = index.positive? ? tokens[index - 1][:text].upcase : nil
315
- return true if ['.', 'AS'].include?(previous)
316
- return true if sql_token_text(tokens, index + 1) == '.' && sql_token_text(tokens, index + 2) != '*'
317
-
318
- direct_sql_wildcard?(tokens, index)
319
- end
320
-
321
- def sql_token_text(tokens, index)
322
- tokens[index]&.fetch(:text)&.upcase
323
- end
324
-
325
- def direct_sql_wildcard?(tokens, index)
326
- return false unless sql_token_text(tokens, index + 1) == '.' && sql_token_text(tokens, index + 2) == '*'
327
-
328
- previous = index.positive? ? sql_token_text(tokens, index - 1) : nil
329
- following = sql_token_text(tokens, index + 3)
330
- %w[SELECT ,].include?(previous) && [nil, ',', 'FROM'].include?(following)
331
- end
332
-
333
- # Comments and literal bodies have already been removed. Token offsets
334
- # let projection and ordinal checks retain complete nested expressions.
335
- def sql_policy_tokens(stripped)
336
- depth = 0
337
- stripped.to_enum(:scan, /"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|
338
- [A-Za-z_\u0080-\u{10ffff}][A-Za-z0-9_$\u0080-\u{10ffff}]*|[0-9]+|::|[^\s]/ux).map do
339
- match = Regexp.last_match
340
- word = match[0]
341
- depth -= 1 if word == ')'
342
- token = { text: word, depth: depth, start: match.begin(0), finish: match.end(0) }
343
- depth += 1 if word == '('
344
- token
345
- end
346
- end
347
-
348
- def sql_unquote(identifier)
349
- identifier.sub(/\A["`]/, '').sub(/["`]\z/, '').gsub('""', '"').gsub('``', '`')
350
- end
351
-
352
- def sql_select_lists(stripped)
353
- tokens = sql_policy_tokens(stripped)
354
- tokens.each_with_index.filter_map do |token, index|
355
- next unless token[:text].casecmp?('SELECT')
356
-
357
- finish = tokens[(index + 1)..].find { |candidate| sql_projection_end?(candidate, token) }
358
- finish_at = finish ? finish[:start] : stripped.length
359
- { expressions: sql_projection_expressions(stripped[token[:finish]...finish_at]),
360
- depth: token[:depth], start: token[:start], finish: finish_at }
361
- end
362
- end
363
-
364
- def sql_projection_end?(candidate, token)
365
- candidate[:depth] < token[:depth] ||
366
- (candidate[:depth] == token[:depth] &&
367
- %w[FROM WHERE GROUP HAVING ORDER LIMIT UNION INTERSECT EXCEPT].include?(candidate[:text].upcase))
368
- end
369
-
370
- def refuse_protected_sql_positions!(stripped)
371
- lists = sql_select_lists(stripped)
372
- sql_policy_tokens(stripped).each_cons(3) do |clause, by, position|
373
- next unless %w[ORDER GROUP].include?(clause[:text].upcase) && by[:text].casecmp?('BY')
374
-
375
- list = sql_projection_before(lists, clause)
376
- next unless list
377
- next unless sql_projection_expressions(stripped[position[:start]..]).any? do |item|
378
- protected_sql_ordinal?(item, list[:expressions])
379
- end
380
-
381
- raise ValidationError,
382
- 'Rejected: positional SQL ordering or grouping cannot reference protected output columns.'
383
- end
384
- end
385
-
386
- def sql_projection_before(lists, clause)
387
- lists.reverse.find do |candidate|
388
- candidate[:depth] == clause[:depth] && candidate[:start] < clause[:start]
389
- end
390
- end
391
-
392
- def protected_sql_ordinal?(item, expressions)
393
- ordinal = /\A\s*\(*\s*([0-9]+)\s*\)*(?:\s|\z)/.match(item)
394
- return false unless ordinal && ordinal[1].to_i.positive?
395
-
396
- expression = expressions[ordinal[1].to_i - 1]
397
- return false unless expression
398
- return true if expression.include?('*')
399
-
400
- (redaction_columns + redacted_eav_value_columns).any? do |column|
401
- sql_identifier_referenced?(expression, column)
402
- end
403
- end
404
-
405
- def sql_relation_names(factor)
406
- identifier = /(?:[A-Za-z_\u0080-\u{10ffff}][A-Za-z0-9_$\u0080-\u{10ffff}]*|"(?:""|[^"])+"|`(?:``|[^`])+`)/u
407
- source = /\A\s*(?:ONLY\b\s*\(?\s*)?(#{identifier})(?:\s*\.\s*(#{identifier}))?/i
408
- match = source.match(factor)
409
- names = match ? [match[2] || match[1]] : []
410
- rest = match ? factor[match.end(0)..].sub(/\A\s*\*/, '') : factor
411
- aliases = /(?:\A|\))\s*(?:AS\s+)?(#{identifier})/i
412
- names.concat(rest.scan(aliases).flatten)
413
-
414
- names.map { |name| sql_unquote(name) }
415
- end
416
-
417
- def unsafe_protected_sql_column(referenced, expressions, selected, tail, stripped)
418
- referenced.find do |column|
419
- unsafe_protected_sql_reference?(column, expressions, selected, tail)
420
- end || orphan_eav_sql_value(selected, stripped)
421
- end
422
-
423
- def protected_sql_projection(stripped)
424
- list = sql_select_lists(stripped).first
425
- return [[], stripped] unless list && list[:start].zero?
426
-
427
- [list[:expressions], stripped[list[:finish]..]]
428
- end
429
-
430
- def unsafe_protected_sql_reference?(column, expressions, selected, tail)
431
- unsafe_projection = expressions.any? do |expression|
432
- sql_identifier_referenced?(expression, column) && direct_sql_column_name(expression) != column
433
- end
434
- unsafe_tail = protected_sql_predicate_column?(column) && sql_identifier_referenced?(tail, column)
435
- !selected.include?(column) || unsafe_projection || unsafe_tail
436
- end
437
-
438
- def protected_sql_predicate_column?(column)
439
- redaction_columns.include?(column) || redacted_eav_value_columns.include?(column)
440
- end
441
-
442
- def orphan_eav_sql_value(selected, stripped)
443
- pattern = sql_eav_patterns(stripped).find do |candidate|
444
- selected.include?(candidate['value_column']) && !selected.include?(candidate['key_column'])
445
- end
446
- pattern&.fetch('value_column')
447
- end
448
-
449
- def sql_eav_patterns(stripped)
450
- tables = SqlTableScanner.identifiers_in(stripped, dialect: sql_dialect, mysql_modes: mysql_quote_modes)
451
- redaction_key_values.select do |pattern|
452
- tables.any? do |table|
453
- columns = @model_validator.columns_for_table(table)
454
- columns.nil? || [pattern['key_column'], pattern['value_column']].all? { |column| columns.include?(column) }
455
- end
456
- end
457
- end
458
-
459
- def sql_projection_expressions(projection)
460
- commas = sql_policy_tokens(projection).select { |token| token[:text] == ',' && token[:depth].zero? }
461
- start = 0
462
- commas.map do |token|
463
- expression = projection[start...token[:start]].strip
464
- start = token[:finish]
465
- expression
466
- end + [projection[start..].strip]
467
- end
468
-
469
- def direct_sql_column_name(expression)
470
- identifier = /(?:[A-Za-z_]\w*|"(?:""|[^"])+"|`(?:``|[^`])+`)/
471
- match = /\A(?:#{identifier}\.)?(#{identifier})\z/.match(expression)
472
- return unless match
473
-
474
- match[1].sub(/\A["`]/, '').sub(/["`]\z/, '').gsub('""', '"').gsub('``', '`')
475
- end
476
-
477
- def refuse_protected_predicate_references!(template)
478
- protected = (redaction_columns + redacted_eav_value_columns).uniq
479
- referenced = protected.find { |column| sql_identifier_referenced?(template, column) }
480
- refuse_protected_predicate_column!(referenced) if referenced
481
- end
482
-
483
- def sql_identifier_referenced?(sql, column)
484
- sql_security_views(sql).any? do |stripped|
485
- stripped.match?(/(?<![A-Za-z0-9_$])#{Regexp.escape(column)}(?![A-Za-z0-9_$])/i)
486
- end
487
- end
488
-
489
- def sql_security_views(sql)
490
- dialects = sql_dialect ? [sql_dialect] : SqlValidator::KNOWN_DIALECTS
491
- dialects.flat_map do |dialect|
492
- SqlNoiseStripper.security_views(sql, dialect: dialect, mysql_modes: sql_dialect ? mysql_quote_modes : nil)
493
- end.uniq
494
- end
495
-
496
- def validate_sql_result_types!(result)
497
- return unless sql_dialect == :postgres
498
- return if redaction_columns.empty? && redacted_kv_columns.empty?
499
-
500
- return if recognized_sql_result_types?(result)
501
-
502
- raise ValidationError,
503
- 'Rejected: PostgreSQL returned an unrecognized result type that cannot preserve ' \
504
- 'protected field identity. ' \
505
- 'Select ordinary scalar columns or use a structured Console tool.'
506
- end
507
-
508
- def recognized_sql_result_types?(result)
509
- types = result.column_types if result.respond_to?(:column_types)
510
- return false unless types
511
-
512
- result.columns.each_with_index.all? do |column, index|
513
- type = types[index] || types[column]
514
- type&.type
515
- end
516
- end
517
-
518
- def mysql_quote_modes
519
- return {} unless sql_dialect == :mysql
520
-
521
- connection = active_connection
522
- cache = Thread.current[:woods_console_mysql_quote_modes]
523
- return cache[connection] if cache&.key?(connection)
524
-
525
- modes = connection.select_value('SELECT @@SESSION.sql_mode').to_s.upcase.split(',')
526
- result = {
527
- ansi_quotes: modes.include?('ANSI_QUOTES'),
528
- no_backslash_escapes: modes.include?('NO_BACKSLASH_ESCAPES')
529
- }
530
- cache[connection] = result if cache
531
- result
532
- end
533
- end
534
- end
535
- end
@@ -1,46 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'woods/console/sql_noise_stripper'
4
- require 'woods/console/sql_table_scanner'
5
-
6
- module Woods
7
- module Console
8
- # Refuse SQLite-specific syntax outside the scanner's supported grammar.
9
- # This is a conservative read boundary, not a general-purpose SQL parser.
10
- module SqliteReadGuard
11
- SIMPLE_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
12
- UNSUPPORTED_SYNTAX = /\b(?:FROM|JOIN)(?=['"`(])/i
13
- QUOTED_IDENTIFIER = /"(?:[^"]|"")*"|`(?:[^`]|``)*`/
14
-
15
- # @param sql [String] SQL to execute on SQLite
16
- # @raise [SqlValidationError] when an identifier or table factor cannot be checked
17
- # @return [void]
18
- def self.validate!(sql)
19
- view = SqlNoiseStripper.strip_noise(sql, dialect: :sqlite)
20
- refuse! if view.match?(UNSUPPORTED_SYNTAX)
21
- SqlTableScanner.relation_factors(view).each do |factor|
22
- factor.scan(QUOTED_IDENTIFIER) { |quoted| refuse! unless quoted[1...-1].match?(SIMPLE_IDENTIFIER) }
23
- refuse! unless supported_factor?(factor.strip)
24
- end
25
- end
26
-
27
- # A SELECT/WITH subquery has its own independently scanned table factors.
28
- # Parenthesized table groups and string-quoted names are refused rather
29
- # than silently omitted from the blocked-table scan.
30
- def self.supported_factor?(factor)
31
- return true if factor.match?(/\A\(\s*(?:SELECT|WITH)\b/i)
32
-
33
- match = SqlTableScanner::LEAD_IDENT.match(factor)
34
- match && factor[match.end(0)..].match?(/\A(?:\s|,|\)|\z)/)
35
- end
36
- private_class_method :supported_factor?
37
-
38
- def self.refuse!
39
- raise SqlValidationError,
40
- 'Rejected: unsupported SQLite identifier or table-reference syntax. ' \
41
- 'Use simple bare or double-quoted identifiers and SELECT subqueries.'
42
- end
43
- private_class_method :refuse!
44
- end
45
- end
46
- end
@@ -1,98 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'base'
4
-
5
- module Woods
6
- module Formatting
7
- # Formats assembled context as XML for Claude models.
8
- #
9
- # Produces structured XML with:
10
- # - `<codebase-context>` root element
11
- # - `<meta>` tag with token usage and budget
12
- # - `<content>` section with indented context text
13
- # - `<sources>` section with self-closing `<source>` elements
14
- #
15
- # @example
16
- # adapter = ClaudeAdapter.new
17
- # xml = adapter.format(assembled_context)
18
- # # => "<codebase-context>\n <meta tokens=\"42\" budget=\"8000\" />\n..."
19
- #
20
- class ClaudeAdapter < Base
21
- # Format assembled context as XML for Claude.
22
- #
23
- # @param assembled_context [Woods::Retrieval::AssembledContext]
24
- # @return [String] XML-formatted context
25
- def format(assembled_context)
26
- parts = []
27
- parts << '<codebase-context>'
28
- parts << " <meta tokens=\"#{assembled_context.tokens_used}\" budget=\"#{assembled_context.budget}\" />"
29
- parts << format_content(assembled_context.context)
30
- parts << format_sources(assembled_context.sources)
31
- parts << '</codebase-context>'
32
- parts.join("\n")
33
- end
34
-
35
- private
36
-
37
- # Format the content section with indentation.
38
- #
39
- # @param context [String]
40
- # @return [String]
41
- def format_content(context)
42
- lines = []
43
- lines << ' <content>'
44
- lines << indent(escape_xml(context), 4) unless context.empty?
45
- lines << ' </content>'
46
- lines.join("\n")
47
- end
48
-
49
- # Format the sources section.
50
- #
51
- # @param sources [Array<Hash>]
52
- # @return [String]
53
- def format_sources(sources)
54
- lines = []
55
- lines << ' <sources>'
56
- sources.each do |source|
57
- lines << " <source #{source_attributes(source)} />"
58
- end
59
- lines << ' </sources>'
60
- lines.join("\n")
61
- end
62
-
63
- # Build attribute string for a source element.
64
- #
65
- # @param source [Hash]
66
- # @return [String]
67
- def source_attributes(source)
68
- [
69
- "identifier=\"#{escape_xml(source[:identifier].to_s)}\"",
70
- "type=\"#{escape_xml(source[:type].to_s)}\"",
71
- "score=\"#{source[:score]}\"",
72
- "file=\"#{escape_xml(source[:file_path].to_s)}\""
73
- ].join(' ')
74
- end
75
-
76
- # Indent every line of text by the given number of spaces.
77
- #
78
- # @param text [String]
79
- # @param spaces [Integer]
80
- # @return [String]
81
- def indent(text, spaces)
82
- prefix = ' ' * spaces
83
- text.lines.map { |line| "#{prefix}#{line.chomp}" }.join("\n")
84
- end
85
-
86
- # Escape XML special characters.
87
- #
88
- # @param text [String]
89
- # @return [String]
90
- def escape_xml(text)
91
- text.gsub('&', '&amp;')
92
- .gsub('<', '&lt;')
93
- .gsub('>', '&gt;')
94
- .gsub('"', '&quot;')
95
- end
96
- end
97
- end
98
- end