woods 2.0.0.beta2 → 2.0.0.beta3

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 (218) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +262 -1
  3. data/CONTRIBUTING.md +173 -9
  4. data/README.md +7 -3
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +83 -4
  7. data/docs/AGENT_SETUP.md +82 -1
  8. data/docs/BACKEND_MATRIX.md +20 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +199 -14
  11. data/docs/CONSOLE_MCP_SETUP.md +35 -5
  12. data/docs/DOCKER_SETUP.md +21 -2
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +36 -5
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +117 -1
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +221 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +33 -18
  22. data/docs/NOTION_INTEGRATION.md +13 -0
  23. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  24. data/docs/PUBLISHED_INDEX.md +55 -0
  25. data/docs/README.md +7 -0
  26. data/docs/RETRIEVAL_GUIDE.md +253 -11
  27. data/docs/RUNTIME_TRACING.md +71 -0
  28. data/docs/SOURCE_FRESHNESS.md +143 -0
  29. data/docs/TROUBLESHOOTING.md +117 -5
  30. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  31. data/docs/UPGRADING_TO_2.md +44 -22
  32. data/docs/WATCH_DAEMON.md +259 -59
  33. data/exe/woods-agent-config +6 -0
  34. data/exe/woods-extract +5 -0
  35. data/exe/woods-hook-context +6 -0
  36. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  37. data/lib/tasks/woods.rake +47 -397
  38. data/lib/woods/agent_configuration/applier.rb +133 -0
  39. data/lib/woods/agent_configuration/cli.rb +101 -0
  40. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  41. data/lib/woods/agent_configuration/document.rb +105 -0
  42. data/lib/woods/agent_configuration/error.rb +7 -0
  43. data/lib/woods/agent_configuration/launcher.rb +75 -0
  44. data/lib/woods/agent_configuration/layout.rb +59 -0
  45. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  46. data/lib/woods/agent_configuration/plan.rb +98 -0
  47. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  48. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  49. data/lib/woods/agent_configuration/planner.rb +63 -0
  50. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  51. data/lib/woods/agent_configuration/preflight.rb +100 -0
  52. data/lib/woods/agent_configuration/recovery.rb +49 -0
  53. data/lib/woods/ast/node.rb +2 -0
  54. data/lib/woods/ast/parser.rb +38 -5
  55. data/lib/woods/builder.rb +21 -5
  56. data/lib/woods/cache/cache_middleware.rb +28 -7
  57. data/lib/woods/cache/cache_store.rb +4 -5
  58. data/lib/woods/change_set.rb +5 -4
  59. data/lib/woods/console/credential_index.rb +20 -2
  60. data/lib/woods/console/credential_scanner.rb +14 -14
  61. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  62. data/lib/woods/console/embedded_executor.rb +1 -1
  63. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  64. data/lib/woods/console/rack_middleware.rb +22 -13
  65. data/lib/woods/console/server.rb +18 -16
  66. data/lib/woods/dependency_graph.rb +65 -13
  67. data/lib/woods/embedding/corpus.rb +94 -0
  68. data/lib/woods/embedding/indexer.rb +90 -46
  69. data/lib/woods/embedding/openai.rb +17 -6
  70. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  71. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  72. data/lib/woods/export/typed_reader.rb +56 -0
  73. data/lib/woods/extractor.rb +232 -137
  74. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  75. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  76. data/lib/woods/extractors/caching_extractor.rb +3 -1
  77. data/lib/woods/extractors/concern_extractor.rb +64 -6
  78. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  79. data/lib/woods/extractors/controller_extractor.rb +13 -4
  80. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  81. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  82. data/lib/woods/extractors/engine_extractor.rb +3 -1
  83. data/lib/woods/extractors/event_extractor.rb +4 -2
  84. data/lib/woods/extractors/factory_extractor.rb +3 -1
  85. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  86. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  87. data/lib/woods/extractors/job_extractor.rb +6 -19
  88. data/lib/woods/extractors/lib_extractor.rb +3 -1
  89. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  90. data/lib/woods/extractors/manager_extractor.rb +3 -1
  91. data/lib/woods/extractors/method_parameters.rb +53 -0
  92. data/lib/woods/extractors/middleware_argument.rb +65 -0
  93. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  94. data/lib/woods/extractors/migration_extractor.rb +3 -1
  95. data/lib/woods/extractors/model_extractor.rb +39 -33
  96. data/lib/woods/extractors/package_extractor.rb +24 -4
  97. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  98. data/lib/woods/extractors/policy_extractor.rb +3 -1
  99. data/lib/woods/extractors/poro_extractor.rb +3 -1
  100. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  101. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  102. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  103. data/lib/woods/extractors/route_extractor.rb +3 -1
  104. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  105. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  106. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  107. data/lib/woods/extractors/service_extractor.rb +3 -1
  108. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  109. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  110. data/lib/woods/extractors/source_nesting.rb +1 -1
  111. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  112. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  113. data/lib/woods/extractors/validator_extractor.rb +3 -1
  114. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  116. data/lib/woods/gem_mapper.rb +2 -0
  117. data/lib/woods/git_history.rb +116 -0
  118. data/lib/woods/graph_analyzer.rb +35 -6
  119. data/lib/woods/hooks/context_cli.rb +54 -0
  120. data/lib/woods/hooks/context_event.rb +88 -0
  121. data/lib/woods/hooks/context_hint.rb +73 -0
  122. data/lib/woods/hooks/context_impact.rb +77 -0
  123. data/lib/woods/hooks/context_output.rb +47 -0
  124. data/lib/woods/hooks/context_state.rb +102 -0
  125. data/lib/woods/hooks/refresh.rb +79 -0
  126. data/lib/woods/hooks/rule_projection.rb +78 -0
  127. data/lib/woods/input_rules.rb +19 -0
  128. data/lib/woods/mcp/bearer_auth.rb +20 -12
  129. data/lib/woods/mcp/bootstrapper.rb +62 -0
  130. data/lib/woods/mcp/index_reader.rb +323 -160
  131. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  132. data/lib/woods/mcp/origin_guard.rb +17 -9
  133. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  134. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  135. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  136. data/lib/woods/mcp/search_results.rb +74 -0
  137. data/lib/woods/mcp/server.rb +158 -37
  138. data/lib/woods/mcp/tool_contract.rb +2 -0
  139. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  140. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  141. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  142. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  143. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  144. data/lib/woods/notion/exporter.rb +56 -17
  145. data/lib/woods/obsidian/destination_plan.rb +98 -0
  146. data/lib/woods/obsidian/name_mapper.rb +19 -3
  147. data/lib/woods/obsidian/note_builder.rb +19 -10
  148. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  149. data/lib/woods/operator/pipeline_guard.rb +18 -13
  150. data/lib/woods/path_dispatcher.rb +7 -1
  151. data/lib/woods/payload_store.rb +27 -26
  152. data/lib/woods/railtie.rb +3 -3
  153. data/lib/woods/railtie_support.rb +12 -12
  154. data/lib/woods/rake_helpers.rb +392 -0
  155. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  156. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  157. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  158. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  159. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  160. data/lib/woods/resilience/index_validator.rb +112 -23
  161. data/lib/woods/retrieval/context_assembler.rb +50 -15
  162. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  163. data/lib/woods/retrieval/lexical_index.rb +119 -0
  164. data/lib/woods/retrieval/ranker.rb +4 -2
  165. data/lib/woods/retrieval/scope.rb +108 -0
  166. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  167. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  168. data/lib/woods/retrieval/search_executor.rb +86 -27
  169. data/lib/woods/retrieval/source_evidence.rb +200 -0
  170. data/lib/woods/retriever.rb +98 -22
  171. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  172. data/lib/woods/session_tracer/middleware.rb +10 -12
  173. data/lib/woods/session_tracer/redis_store.rb +22 -6
  174. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  175. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  176. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  177. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  178. data/lib/woods/source_inputs/handoff.rb +102 -0
  179. data/lib/woods/source_inputs/launcher.rb +157 -0
  180. data/lib/woods/source_inputs/manifest.rb +124 -0
  181. data/lib/woods/source_inputs/private_key.rb +55 -0
  182. data/lib/woods/source_inputs/scanner.rb +171 -0
  183. data/lib/woods/source_inputs/scopes.rb +71 -0
  184. data/lib/woods/source_inputs/session.rb +214 -0
  185. data/lib/woods/source_inputs/status.rb +84 -0
  186. data/lib/woods/source_inputs/verifier.rb +107 -0
  187. data/lib/woods/storage/metadata_store.rb +25 -25
  188. data/lib/woods/storage/pgvector.rb +29 -8
  189. data/lib/woods/storage/qdrant.rb +17 -7
  190. data/lib/woods/storage/vector_store.rb +18 -6
  191. data/lib/woods/tasks.rb +3 -2
  192. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  193. data/lib/woods/unblocked/exporter.rb +59 -70
  194. data/lib/woods/version.rb +1 -1
  195. data/lib/woods/watch/boot_snapshot.rb +52 -0
  196. data/lib/woods/watch/daemon.rb +136 -28
  197. data/lib/woods/watch/listen_watcher.rb +4 -0
  198. data/lib/woods/watch/polling_watcher.rb +5 -1
  199. data/lib/woods/watch/status.rb +20 -15
  200. data/lib/woods/watch/tree_scan.rb +21 -13
  201. data/lib/woods/watch/watcher.rb +4 -1
  202. data/lib/woods.rb +50 -11
  203. data/plugin/.claude-plugin/plugin.json +1 -1
  204. data/plugin/hooks/adapters/normalize.jq +15 -0
  205. data/plugin/hooks/adapters/normalize.rb +63 -0
  206. data/plugin/hooks/hooks.json +20 -0
  207. data/plugin/hooks/woods-context.sh +50 -0
  208. data/plugin/hooks/woods-input-rules.sh +159 -0
  209. data/plugin/hooks/woods-opencode.mjs +65 -0
  210. data/plugin/hooks/woods-post-edit.sh +2 -225
  211. data/plugin/hooks/woods-refresh.sh +260 -0
  212. data/plugin/hooks/woods-session-start.sh +47 -55
  213. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  214. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  215. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  216. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  217. data/plugin/skills/woods-setup/SKILL.md +107 -6
  218. metadata +84 -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
- new(secrets: collect_secrets(rails_app))
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) }
@@ -155,17 +155,17 @@ 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 Hash then walk_hash(value, counts)
168
- when Array then value.map { |item| walk(item, counts) }
166
+ when String then scan_string(value, counts, index)
167
+ when Hash then walk_hash(value, counts, index)
168
+ when Array then value.map { |item| walk(item, counts, index) }
169
169
  else value
170
170
  end
171
171
  end
@@ -177,19 +177,19 @@ module Woods
177
177
  # credential-shaped string is emitted as a Symbol after redaction
178
178
  # (e.g. `:"[REDACTED]"`). String keys stay Strings. Non-String,
179
179
  # non-Symbol keys (Integer, etc.) pass through unchanged.
180
- def walk_hash(hash, counts)
180
+ def walk_hash(hash, counts, index)
181
181
  hash.each_with_object({}) do |(key, val), out|
182
182
  scanned_key = case key
183
- when String then scan_string(key, counts)
184
- when Symbol then scan_string(key.to_s, counts).to_sym
183
+ when String then scan_string(key, counts, index)
184
+ when Symbol then scan_string(key.to_s, counts, index).to_sym
185
185
  else key
186
186
  end
187
- out[scanned_key] = walk(val, counts)
187
+ out[scanned_key] = walk(val, counts, index)
188
188
  end
189
189
  end
190
190
 
191
- def scan_string(str, counts)
192
- result = redact_indexed_secrets(str, counts)
191
+ def scan_string(str, counts, index)
192
+ result = redact_indexed_secrets(str, counts, index)
193
193
  result = scan_encoded_forms(result, counts)
194
194
  @active_patterns.inject(result) do |acc, (name, pattern)|
195
195
  acc.gsub(pattern) do
@@ -290,10 +290,10 @@ module Woods
290
290
  printable.to_f / str.length >= PRINTABLE_RATIO_THRESHOLD
291
291
  end
292
292
 
293
- def redact_indexed_secrets(str, counts)
294
- return str unless @secret_index&.match?(str)
293
+ def redact_indexed_secrets(str, counts, index)
294
+ return str unless index&.match?(str)
295
295
 
296
- redacted = @secret_index.redact(str)
296
+ redacted = index.redact(str)
297
297
  counts[INDEX_HIT] = (counts[INDEX_HIT] || 0) + redacted.scan(CredentialIndex::REDACTED).size
298
298
  redacted
299
299
  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
@@ -1491,7 +1491,7 @@ module Woods
1491
1491
  # `SqlNoiseStripper` is the same module SqlValidator uses. The
1492
1492
  # combined single-pass strip_noise resolves comments and literals
1493
1493
  # together so a comment marker inside a literal can't hide a keyword.
1494
- stripped = SqlNoiseStripper.strip_noise(template)
1494
+ stripped = SqlNoiseStripper.strip_noise(template, dialect: sql_dialect || :postgres, **mysql_quote_modes)
1495
1495
  if SCOPE_TEMPLATE_FORBIDDEN.match?(stripped)
1496
1496
  raise ValidationError,
1497
1497
  '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
- def initialize(app, path: '/mcp/console', embedded_read_tools: false, # rubocop:disable Metrics/ParameterLists
85
- unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil,
86
- stateless: true)
87
- @app = app
88
- @path = path
89
- @embedded_read_tools = embedded_read_tools
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), requests — even at the
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.
@@ -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 the active scanner without restarting the process.
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
- new_index = CredentialIndex.build(rails_app: target_app)
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
- secret_index = build_credential_index(config)
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
@@ -32,9 +32,14 @@ module Woods
32
32
  # enforce_dependencies: package units (Task 7)
33
33
  # commit_count, change_frequency: git enrichment (Task 5)
34
34
  NODE_ATTRIBUTE_KEYS = %i[
35
- database table foreign_key_tables package enforce_dependencies commit_count change_frequency
35
+ database table foreign_key_tables package enforce_dependencies commit_count change_frequency kind
36
36
  ].freeze
37
37
 
38
+ # These extractors describe a whole source file rather than one constant.
39
+ # Other types remain unclassified: a path-like identifier is not evidence
40
+ # that a unit is a profile, and an unmarked unit need not name a constant.
41
+ FILE_PROFILE_TYPES = %i[caching configuration test_mapping rails_source gem_source].freeze
42
+
38
43
  # Optional edge keys beyond target and via. `through` is the through
39
44
  # association name; `through_db` is the through model's resolved
40
45
  # database, emitted only when present; `disable_joins` is emitted only
@@ -80,14 +85,14 @@ module Woods
80
85
  }.merge(node_attributes_from(unit))
81
86
 
82
87
  (@edges[unit.identifier] ||= {})[unit.type] =
83
- unit.dependencies.map { |d| { target: d[:target], via: d[:via] }.merge(self.class.edge_attributes(d)) }
88
+ self.class.normalize_edges(unit.dependencies, strict: true)
84
89
  (@file_map[unit.file_path] ||= Set.new).add(unit.identifier) if unit.file_path
85
90
 
86
91
  # Type index for filtering (Set-based for O(1) insert)
87
92
  (@type_index[unit.type] ||= Set.new).add(unit.identifier)
88
93
 
89
94
  # Build reverse edges (Set-based for O(1) insert)
90
- unit.dependencies.each do |dep|
95
+ @edges[unit.identifier][unit.type].each do |dep|
91
96
  (@reverse[dep[:target]] ||= Set.new).add(unit.identifier)
92
97
  (@reverse_via[[dep[:target], dep[:via]]] ||= Set.new).add(unit.identifier)
93
98
  end
@@ -645,7 +650,7 @@ module Woods
645
650
  # @return [Hash{Symbol => Object}] normalized, nil-free
646
651
  def node_attributes_from(unit)
647
652
  metadata = unit.respond_to?(:metadata) && unit.metadata.is_a?(Hash) ? unit.metadata : {}
648
- attrs = {}
653
+ attrs = self.class.file_profile_attributes(unit.type)
649
654
  attrs[:database] = metadata[:database] unless metadata[:database].nil?
650
655
  attrs[:table] = metadata[:table_name] if unit.type == :model && !metadata[:table_name].nil?
651
656
  tables = Array(metadata[:foreign_keys]).filter_map { |fk| fk[:to_table] || fk['to_table'] if fk.is_a?(Hash) }
@@ -729,14 +734,15 @@ module Woods
729
734
  # `nodes` and `edges` stay keyed on the bare identifier, holding the
730
735
  # **primary** node — the type that sorts first. Identifiers registered
731
736
  # under more than one type put their remaining nodes in `variants`, a flat
732
- # array that is **omitted entirely when empty**. So the serialized form of
733
- # a graph with no collisions is byte-for-byte what it has always been, and
734
- # a `dependency_graph.json` written before this change loads through
737
+ # array that is **omitted entirely when empty**. A
738
+ # `dependency_graph.json` written before this change loads through
735
739
  # {.from_h} unmodified: it simply carries no `variants`.
736
740
  #
737
741
  # `reverse`, `file_map` and `type_index` need no new shape. They are
738
742
  # already identifier-valued sets, so a Scenic view `reports` and a factory
739
743
  # `reports` each contribute to their own type bucket and their own path.
744
+ # The additive `reverse_via` index preserves typed source identities and
745
+ # complete relationship records without changing the legacy `reverse` map.
740
746
  #
741
747
  # @return [Hash] Complete graph data
742
748
  def to_h
@@ -747,6 +753,7 @@ module Woods
747
753
  nodes: key_sorted(self.class.relativize_nodes(primary_nodes, root)),
748
754
  edges: key_sorted(primary_edges),
749
755
  reverse: key_sorted(@reverse.transform_values { |ids| ids.to_a.sort }),
756
+ reverse_via: published_reverse_via,
750
757
  file_map: key_sorted(self.class.relativize_file_map(@file_map, root)),
751
758
  type_index: key_sorted(@type_index.transform_values { |ids| ids.to_a.sort }),
752
759
  stats: {
@@ -770,6 +777,23 @@ module Woods
770
777
  end
771
778
  private :key_sorted
772
779
 
780
+ # Reverse buckets are derived from every typed forward registration, rather
781
+ # than the bare-name in-memory filter index, which cannot preserve variants
782
+ # or distinguish association attributes. Legacy nil relationships stay nil.
783
+ def published_reverse_via
784
+ buckets = {}
785
+ @edges.each do |source, by_type|
786
+ by_type.each do |type, edges|
787
+ edges.each do |edge|
788
+ record = { source: source, source_type: type }.merge(edge.except(:target))
789
+ (buckets[edge[:target]] ||= []) << record
790
+ end
791
+ end
792
+ end
793
+ key_sorted(buckets.transform_values { |records| records.sort_by { |record| JSON.generate(record) } })
794
+ end
795
+ private :published_reverse_via
796
+
773
797
  # A copy whose edge containers are the caller's alone.
774
798
  #
775
799
  # `dup` copies only the top level, so `to_h[:edges][id]` used to be the
@@ -783,6 +807,9 @@ module Woods
783
807
  def detached_snapshot(memo)
784
808
  snapshot = memo.dup
785
809
  snapshot[:edges] = memo[:edges].transform_values { |list| list.map(&:dup) }
810
+ snapshot[:reverse_via] = memo[:reverse_via].transform_values do |records|
811
+ records.map { |record| record.transform_values { |value| value.is_a?(String) ? value.dup : value } }
812
+ end
786
813
  if memo.key?(:variants)
787
814
  snapshot[:variants] = memo[:variants].map do |record|
788
815
  record.merge(edges: record[:edges].map(&:dup))
@@ -794,7 +821,12 @@ module Woods
794
821
 
795
822
  # @return [Hash{String => Hash}] identifier => primary node
796
823
  def primary_nodes
797
- @nodes.transform_values { |nodes| primary_of(nodes) }
824
+ # Late enrichment and JSON loading can insert optional fields in a
825
+ # different order. Canonicalize like variant_records so a graph's JSON
826
+ # fingerprint is stable across full and incremental publication.
827
+ @nodes.transform_values do |nodes|
828
+ primary_of(nodes).slice(:type, :file_path, :namespace, *NODE_ATTRIBUTE_KEYS)
829
+ end
798
830
  end
799
831
  private :primary_nodes
800
832
 
@@ -969,10 +1001,17 @@ module Woods
969
1001
  # @param node [Hash]
970
1002
  # @return [Hash{Symbol => Object}]
971
1003
  def self.persisted_node_attributes(node)
972
- NODE_ATTRIBUTE_KEYS.each_with_object({}) do |key, attrs|
1004
+ attributes = NODE_ATTRIBUTE_KEYS.each_with_object({}) do |key, attrs|
973
1005
  value = node.key?(key) ? node[key] : node[key.to_s]
974
1006
  attrs[key] = normalize_node_attribute(key, value) unless value.nil?
975
1007
  end
1008
+ attributes.merge(file_profile_attributes(node[:type] || node['type']))
1009
+ end
1010
+
1011
+ # Backfill the additive marker when an older graph is republished, so
1012
+ # unchanged nodes in an incremental run agree with a fresh full graph.
1013
+ def self.file_profile_attributes(type)
1014
+ FILE_PROFILE_TYPES.include?(type&.to_sym) ? { kind: 'file_profile' } : {}
976
1015
  end
977
1016
 
978
1017
  # Edge attributes present in a dependency or persisted edge hash.
@@ -1032,7 +1071,10 @@ module Woods
1032
1071
  graph.instance_variable_set(:@edges, edges)
1033
1072
 
1034
1073
  raw_reverse = data[:reverse] || data['reverse'] || {}
1035
- graph.instance_variable_set(:@reverse, raw_reverse.transform_values { |v| v.is_a?(Set) ? v : Set.new(v) })
1074
+ reverse = raw_reverse.each_with_object({}) do |(target, sources), index|
1075
+ (index[target.to_s] ||= Set.new).merge(sources)
1076
+ end
1077
+ graph.instance_variable_set(:@reverse, reverse)
1036
1078
 
1037
1079
  raw_file_map = data[:file_map] || data['file_map'] || {}
1038
1080
  graph.instance_variable_set(:@file_map, absolutize_file_map(normalize_file_map(raw_file_map), root))
@@ -1126,7 +1168,12 @@ module Woods
1126
1168
  }.merge(persisted_node_attributes(node))
1127
1169
  end
1128
1170
 
1129
- # Normalize edge data from either old format (bare strings) or new format (hashes).
1171
+ # Normalize fresh dependencies and persisted edges to the same identity:
1172
+ # string targets and symbol relationship labels. Extractors may emit
1173
+ # symbolic external targets (such as :http_api); retaining those symbols
1174
+ # after a JSON-loaded baseline splits reverse indexes on re-registration.
1175
+ # Unit dependency metadata stays unchanged; only graph records normalize.
1176
+ # Also accepts the old bare-string edge format.
1130
1177
  #
1131
1178
  # ROUND-TRIP INVARIANT (do not break when refactoring):
1132
1179
  # DependencyGraph#to_h -> JSON.generate -> JSON.parse -> DependencyGraph.from_h
@@ -1147,15 +1194,20 @@ module Woods
1147
1194
  # explicit data conversion.
1148
1195
  #
1149
1196
  # @param edges [Array] Edge entries — either strings or hashes
1197
+ # @param strict [Boolean] require hashes for fresh units; legacy loads stay tolerant
1150
1198
  # @return [Array<Hash>] Normalized edges with :target and :via keys
1151
- def self.normalize_edges(edges)
1199
+ def self.normalize_edges(edges, strict: false)
1200
+ raise ArgumentError, 'Fresh graph dependencies must be an array' if strict && !edges.is_a?(Array)
1201
+
1152
1202
  return [] unless edges.is_a?(Array)
1153
1203
 
1154
1204
  edges.map do |edge|
1205
+ raise ArgumentError, 'Fresh graph dependencies must be hashes' if strict && !edge.is_a?(Hash)
1206
+
1155
1207
  if edge.is_a?(String)
1156
1208
  { target: edge, via: nil }
1157
1209
  elsif edge.is_a?(Hash)
1158
- { target: edge[:target] || edge['target'], via: (edge[:via] || edge['via'])&.to_sym }
1210
+ { target: (edge[:target] || edge['target'])&.to_s, via: (edge[:via] || edge['via'])&.to_sym }
1159
1211
  .merge(edge_attributes(edge))
1160
1212
  else
1161
1213
  { target: edge.to_s, via: nil }
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../atomic_file'
5
+ require_relative '../generation'
6
+
7
+ module Woods
8
+ class Error < StandardError; end unless defined?(Woods::Error)
9
+
10
+ module Embedding
11
+ # Collect the complete extraction input before an embedding run can mutate
12
+ # stores. Native publications use their authoritative listings, never a glob
13
+ # that could mistake a missing/corrupt payload for intentional deletion.
14
+ class Corpus
15
+ class Incomplete < Woods::Error; end
16
+
17
+ def initialize(output_dir)
18
+ @output_dir = output_dir.to_s
19
+ end
20
+
21
+ def load
22
+ native? ? published_units : legacy_units
23
+ rescue IOError, SystemCallError, JSON::ParserError, EncodingError, ArgumentError => e
24
+ raise Incomplete, "Embedding input incomplete: #{e.message}. Repair or rebuild extraction before embedding."
25
+ end
26
+
27
+ private
28
+
29
+ def native?
30
+ path = File.join(@output_dir, Generation::FILENAME)
31
+ if File.exist?(path)
32
+ marker = JSON.parse(AtomicFile.read(path))
33
+ raise IOError, 'invalid generation marker' unless marker.is_a?(Hash)
34
+ end
35
+
36
+ if !marker || marker['payload'].nil?
37
+ if File.directory?(File.join(@output_dir, 'payloads'))
38
+ raise IOError, 'missing publication pointer beside payloads'
39
+ end
40
+
41
+ return false
42
+ end
43
+
44
+ validate_marker(marker)
45
+ true
46
+ end
47
+
48
+ def validate_marker(marker)
49
+ strings_valid = %w[payload token].all? { |key| marker[key].is_a?(String) && !marker[key].empty? }
50
+ return if strings_valid && marker['number'].is_a?(Integer) && marker['number'].positive?
51
+
52
+ raise IOError, 'invalid published generation marker'
53
+ end
54
+
55
+ def published_units
56
+ require_relative '../mcp/index_reader'
57
+
58
+ reader = MCP::IndexReader.new(@output_dir)
59
+ reader.with_pinned_generation do
60
+ # Generation's compatibility fallback to the root is useful for
61
+ # readers, but cannot prove completeness for destructive reconciliation.
62
+ if reader.payload_dir.expand_path == Pathname.new(@output_dir).expand_path
63
+ raise IOError,
64
+ 'published payload missing or invalid'
65
+ end
66
+
67
+ validate_counts(reader.manifest)
68
+ reader.each_unit.to_a
69
+ end
70
+ end
71
+
72
+ def validate_counts(manifest)
73
+ counts = manifest.is_a?(Hash) && manifest['counts']
74
+ valid = counts.is_a?(Hash) && counts.all? do |dir, count|
75
+ MCP::IndexReader::TYPE_DIRS.include?(dir) && count.is_a?(Integer) && count >= 0
76
+ end
77
+ raise IOError, 'invalid published manifest counts' unless valid
78
+ end
79
+
80
+ # Pre-pointer indexes may use arbitrary filenames and have no manifest
81
+ # or authoritative listing. Keep that input contract, but never silently
82
+ # omit unreadable JSON before reconciling persisted identities.
83
+ def legacy_units
84
+ Dir.glob(File.join(@output_dir, '**', '*.json')).filter_map do |path|
85
+ relative = path.delete_prefix("#{@output_dir}/")
86
+ next if relative.start_with?('dumps/', 'payloads/') || File.basename(path) == 'checkpoint.json'
87
+
88
+ data = JSON.parse(AtomicFile.read(path))
89
+ data if data.is_a?(Hash) && data.key?('type') && data.key?('identifier')
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end