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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'shared_utility_methods'
4
6
  require_relative 'shared_dependency_scanner'
5
7
 
@@ -199,7 +201,7 @@ module Woods
199
201
  def log_extraction_error(name, error)
200
202
  return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
201
203
 
202
- Rails.logger.error("Failed to extract channel #{name}: #{error.message}")
204
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract channel #{name}: #{error.message}")
203
205
  end
204
206
  end
205
207
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  module Woods
4
6
  module Extractors
5
7
  # BehavioralProfile introspects resolved Rails.application.config values
@@ -52,7 +54,7 @@ module Woods
52
54
 
53
55
  build_unit(profile)
54
56
  rescue StandardError => e
55
- Rails.logger.error("BehavioralProfile extraction failed: #{e.message}")
57
+ SourceInputs::ConsumerErrors.log(self, "BehavioralProfile extraction failed: #{e.message}")
56
58
  nil
57
59
  end
58
60
 
@@ -86,7 +88,7 @@ module Woods
86
88
 
87
89
  result
88
90
  rescue StandardError => e
89
- Rails.logger.error("BehavioralProfile database section failed: #{e.message}")
91
+ SourceInputs::ConsumerErrors.log(self, "BehavioralProfile database section failed: #{e.message}")
90
92
  {}
91
93
  end
92
94
 
@@ -102,7 +104,7 @@ module Woods
102
104
  Object.const_defined?(constant_name)
103
105
  end
104
106
  rescue StandardError => e
105
- Rails.logger.error("BehavioralProfile frameworks section failed: #{e.message}")
107
+ SourceInputs::ConsumerErrors.log(self, "BehavioralProfile frameworks section failed: #{e.message}")
106
108
  {}
107
109
  end
108
110
 
@@ -132,7 +134,7 @@ module Woods
132
134
 
133
135
  flags
134
136
  rescue StandardError => e
135
- Rails.logger.error("BehavioralProfile behavior_flags section failed: #{e.message}")
137
+ SourceInputs::ConsumerErrors.log(self, "BehavioralProfile behavior_flags section failed: #{e.message}")
136
138
  {}
137
139
  end
138
140
 
@@ -152,7 +154,7 @@ module Woods
152
154
 
153
155
  { adapter: aj.queue_adapter }
154
156
  rescue StandardError => e
155
- Rails.logger.error("BehavioralProfile background section failed: #{e.message}")
157
+ SourceInputs::ConsumerErrors.log(self, "BehavioralProfile background section failed: #{e.message}")
156
158
  {}
157
159
  end
158
160
 
@@ -172,7 +174,7 @@ module Woods
172
174
 
173
175
  { store: store }
174
176
  rescue StandardError => e
175
- Rails.logger.error("BehavioralProfile caching section failed: #{e.message}")
177
+ SourceInputs::ConsumerErrors.log(self, "BehavioralProfile caching section failed: #{e.message}")
176
178
  {}
177
179
  end
178
180
 
@@ -192,7 +194,7 @@ module Woods
192
194
 
193
195
  { delivery_method: am.delivery_method }
194
196
  rescue StandardError => e
195
- Rails.logger.error("BehavioralProfile email section failed: #{e.message}")
197
+ SourceInputs::ConsumerErrors.log(self, "BehavioralProfile email section failed: #{e.message}")
196
198
  {}
197
199
  end
198
200
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'shared_utility_methods'
4
6
  require_relative 'shared_dependency_scanner'
5
7
 
@@ -98,7 +100,7 @@ module Woods
98
100
 
99
101
  unit
100
102
  rescue StandardError => e
101
- Rails.logger.error("Failed to extract caching info from #{file_path}: #{e.message}")
103
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract caching info from #{file_path}: #{e.message}")
102
104
  nil
103
105
  end
104
106
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'shared_utility_methods'
4
6
  require_relative 'shared_dependency_scanner'
5
7
  require_relative 'source_nesting'
@@ -50,18 +52,20 @@ module Woods
50
52
  #
51
53
  # @return [Array<ExtractedUnit>] List of concern units
52
54
  def extract_all
53
- find_files_in_directories(@directories).filter_map do |file|
54
- extract_concern_file(file)
55
- end
55
+ conventional = find_files_in_directories(@directories).filter_map { |file| extract_concern_file(file) }
56
+ conventional + runtime_model_mixins.keys.flat_map { |file| extract_model_mixin_file(file) }
56
57
  end
57
58
 
58
59
  # Extract a single concern file
59
60
  #
60
61
  # @param file_path [String] Path to the concern file
62
+ # @param module_name [String, nil] Runtime identity for a discovered model mixin
61
63
  # @return [ExtractedUnit, nil] The extracted unit or nil if not a concern
62
- def extract_concern_file(file_path)
64
+ def extract_concern_file(file_path, module_name: nil)
63
65
  source = File.read(file_path)
64
- module_name = extract_module_name(file_path, source)
66
+ # Runtime discovery supplies each included module's identity. Ordinary
67
+ # concern files retain their canonical identity, including nested helpers.
68
+ module_name ||= extract_module_name(file_path, source)
65
69
 
66
70
  return nil unless module_name
67
71
  return nil unless concern_module?(source)
@@ -79,12 +83,66 @@ module Woods
79
83
 
80
84
  unit
81
85
  rescue StandardError => e
82
- Rails.logger.error("Failed to extract concern #{file_path}: #{e.message}")
86
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract concern #{file_path}: #{e.message}")
83
87
  nil
84
88
  end
85
89
 
90
+ # Extract a module outside concerns directories only when a live model
91
+ # includes it. Filenames alone cannot establish that a module is a mixin.
92
+ #
93
+ # @param file_path [String] Changed application source path
94
+ # @return [Array<ExtractedUnit>, nil]
95
+ def extract_model_mixin_file(file_path)
96
+ modules = model_mixin_paths[file_path.to_s]
97
+ return unless modules
98
+
99
+ modules.filter_map { |mod| extract_concern_file(file_path, module_name: mod.name) }
100
+ end
101
+
102
+ # Runtime mixins that have no conventional concern-directory discovery.
103
+ # @return [Hash<String, Array<Module>>] Source paths and their included modules
104
+ def runtime_model_mixins
105
+ model_mixin_paths.reject { |path, _mod| conventional_concern_path?(path) }
106
+ end
107
+
108
+ # @param path [String] Absolute source path
109
+ # @return [Boolean] Whether directory-based discovery owns this source
110
+ def conventional_concern_path?(path)
111
+ @directories.any? { |directory| path.to_s.start_with?("#{directory}/") }
112
+ end
113
+
86
114
  private
87
115
 
116
+ # Runtime ownership is authoritative; never import gem mixins even when
117
+ # the application adds methods to them.
118
+ # @return [Hash<String, Array<Module>>] App-owned included modules by source path
119
+ def model_mixin_paths
120
+ return {} unless defined?(ActiveRecord::Base)
121
+
122
+ @model_mixin_paths ||= discover_model_mixin_paths
123
+ end
124
+
125
+ # @return [Hash<String, Array<Module>>] Reflected module source locations
126
+ def discover_model_mixin_paths
127
+ models = ActiveRecord::Base.descendants.reject { |model| model.respond_to?(:abstract_class?) && model.abstract_class? }
128
+ models.flat_map(&:included_modules).uniq.each_with_object({}) do |mod, paths|
129
+ next unless mod.name
130
+
131
+ definition = begin
132
+ Object.const_source_location(mod.name)&.first
133
+ rescue NameError
134
+ # Removed/reloaded models can remain in descendants until GC.
135
+ next
136
+ end
137
+ next if definition && !app_source?(definition, "#{Rails.root}/")
138
+
139
+ path = resolve_source_location(mod, app_root: "#{Rails.root}/", fallback: nil)
140
+ next unless app_source?(path, "#{Rails.root}/") && File.file?(path)
141
+
142
+ (paths[path] ||= []) << mod
143
+ end
144
+ end
145
+
88
146
  # ──────────────────────────────────────────────────────────────────────
89
147
  # Module Discovery
90
148
  # ──────────────────────────────────────────────────────────────────────
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'shared_utility_methods'
4
6
  require_relative 'shared_dependency_scanner'
5
7
  require_relative 'behavioral_profile'
@@ -40,12 +42,14 @@ module Woods
40
42
  extract_configuration_file(file)
41
43
  end
42
44
 
43
- profile = BehavioralProfile.new.extract
45
+ profiler = BehavioralProfile.new
46
+ profile = profiler.extract
47
+ SourceInputs::ConsumerErrors.record(self) if SourceInputs::ConsumerErrors.failed?(profiler)
44
48
  units << profile if profile
45
49
 
46
50
  units
47
51
  rescue StandardError => e
48
- Rails.logger.error("BehavioralProfile integration failed: #{e.message}")
52
+ SourceInputs::ConsumerErrors.log(self, "BehavioralProfile integration failed: #{e.message}")
49
53
  units || []
50
54
  end
51
55
 
@@ -71,7 +75,7 @@ module Woods
71
75
 
72
76
  unit
73
77
  rescue StandardError => e
74
- Rails.logger.error("Failed to extract configuration #{file_path}: #{e.message}")
78
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract configuration #{file_path}: #{e.message}")
75
79
  nil
76
80
  end
77
81
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require 'digest'
4
6
  require_relative 'ast_source_extraction'
5
7
  require_relative 'shared_utility_methods'
@@ -120,8 +122,8 @@ module Woods
120
122
 
121
123
  unit
122
124
  rescue StandardError => e
123
- Rails.logger.error("[Woods] Failed to extract controller #{controller.name}: #{e.class}: #{e.message}")
124
- Rails.logger.error("[Woods] #{e.backtrace&.first(5)&.join("\n ")}")
125
+ SourceInputs::ConsumerErrors.log(self, "[Woods] Failed to extract controller #{controller.name}: #{e.class}: #{e.message}")
126
+ SourceInputs::ConsumerErrors.log(self, "[Woods] #{e.backtrace&.first(5)&.join("\n ")}")
125
127
  nil
126
128
  end
127
129
 
@@ -253,6 +255,7 @@ module Woods
253
255
  opts << "only: [#{f[:only].map { |a| ":#{a}" }.join(', ')}]" if f[:only]&.any?
254
256
  opts << "except: [#{f[:except].map { |a| ":#{a}" }.join(', ')}]" if f[:except]&.any?
255
257
  opts << "if: #{f[:if]}" if f[:if]
258
+ opts << "unless: #{f[:unless]}" if f[:unless]
256
259
 
257
260
  opts_str = opts.any? ? " (#{opts.join('; ')})" : ''
258
261
  " #{f[:kind].to_s.ljust(8)} :#{f[:filter]}#{opts_str}"
@@ -272,7 +275,7 @@ module Woods
272
275
  controller._process_action_callbacks.map do |callback|
273
276
  only, except, if_conds, unless_conds = extract_callback_conditions(callback)
274
277
 
275
- result = { kind: callback.kind, filter: callback.filter }
278
+ result = { kind: callback.kind, filter: callback_filter(callback) }
276
279
  result[:only] = only if only.any?
277
280
  result[:except] = except if except.any?
278
281
  result[:if] = if_conds.join(', ') if if_conds.any?
@@ -281,6 +284,12 @@ module Woods
281
284
  end
282
285
  end
283
286
 
287
+ # Override only controller Proc conditions; the shared model/mailer
288
+ # condition format is a separate extraction contract.
289
+ def condition_label(condition)
290
+ condition.is_a?(Proc) ? stable_filter(condition) : super
291
+ end
292
+
284
293
  # ──────────────────────────────────────────────────────────────────────
285
294
  # Concern Detection & Inlining
286
295
  # ──────────────────────────────────────────────────────────────────────
@@ -734,7 +743,7 @@ module Woods
734
743
  applicable = controller._process_action_callbacks.select do |cb|
735
744
  callback_applies_to_action?(cb, action_name)
736
745
  end
737
- applicable.map { |cb| { kind: cb.kind, filter: cb.filter } }
746
+ applicable.map { |cb| { kind: cb.kind, filter: callback_filter(cb) } }
738
747
  end
739
748
 
740
749
  # Determine if a callback applies to a given action name.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'shared_utility_methods'
4
6
  require_relative 'shared_dependency_scanner'
5
7
 
@@ -85,7 +87,7 @@ module Woods
85
87
 
86
88
  unit
87
89
  rescue StandardError => e
88
- Rails.logger.error("Failed to extract database view #{file_path}: #{e.message}")
90
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract database view #{file_path}: #{e.message}")
89
91
  nil
90
92
  end
91
93
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'shared_utility_methods'
4
6
  require_relative 'shared_dependency_scanner'
5
7
 
@@ -78,7 +80,7 @@ module Woods
78
80
 
79
81
  unit
80
82
  rescue StandardError => e
81
- Rails.logger.error("Failed to extract decorator #{file_path}: #{e.message}")
83
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract decorator #{file_path}: #{e.message}")
82
84
  nil
83
85
  end
84
86
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'shared_utility_methods'
4
6
 
5
7
  module Woods
@@ -184,7 +186,7 @@ module Woods
184
186
 
185
187
  unit
186
188
  rescue StandardError => e
187
- Rails.logger.error("Failed to extract engine #{engine.name}: #{e.message}")
189
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract engine #{engine.name}: #{e.message}")
188
190
  nil
189
191
  end
190
192
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'shared_utility_methods'
4
6
  require_relative 'shared_dependency_scanner'
5
7
 
@@ -59,14 +61,14 @@ module Woods
59
61
  def scan_file(file_path, event_map)
60
62
  source = cached_source(file_path)
61
63
  unless source
62
- Rails.logger.error("Failed to scan #{file_path} for events: file unreadable")
64
+ SourceInputs::ConsumerErrors.log(self, "Failed to scan #{file_path} for events: file unreadable")
63
65
  return
64
66
  end
65
67
 
66
68
  scan_active_support_notifications(source, file_path, event_map)
67
69
  scan_wisper_patterns(source, file_path, event_map)
68
70
  rescue StandardError => e
69
- Rails.logger.error("Failed to scan #{file_path} for events: #{e.message}")
71
+ SourceInputs::ConsumerErrors.log(self, "Failed to scan #{file_path} for events: #{e.message}")
70
72
  end
71
73
 
72
74
  private
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'line_neutralizer'
4
6
  require_relative 'shared_utility_methods'
5
7
  require_relative 'shared_dependency_scanner'
@@ -53,7 +55,7 @@ module Woods
53
55
 
54
56
  factories.map { |factory_data| build_unit(factory_data, file_path, source) }
55
57
  rescue StandardError => e
56
- Rails.logger.error("Failed to extract factories from #{file_path}: #{e.message}")
58
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract factories from #{file_path}: #{e.message}")
57
59
  []
58
60
  end
59
61
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'shared_utility_methods'
4
6
  require_relative 'shared_dependency_scanner'
5
7
  require_relative 'source_nesting'
@@ -151,7 +153,9 @@ module Woods
151
153
 
152
154
  unit
153
155
  rescue StandardError => e
154
- Rails.logger.error("Failed to extract GraphQL file #{file_path}: #{e.message}") if defined?(Rails)
156
+ if defined?(Rails)
157
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract GraphQL file #{file_path}: #{e.message}")
158
+ end
155
159
  nil
156
160
  end
157
161
 
@@ -191,7 +195,9 @@ module Woods
191
195
 
192
196
  unit
193
197
  rescue StandardError => e
194
- Rails.logger.error("Failed to extract GraphQL type #{type_class.name}: #{e.message}") if defined?(Rails)
198
+ if defined?(Rails)
199
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract GraphQL type #{type_class.name}: #{e.message}")
200
+ end
195
201
  nil
196
202
  end
197
203
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require 'yaml'
4
6
 
5
7
  require_relative 'shared_utility_methods'
@@ -70,7 +72,7 @@ module Woods
70
72
 
71
73
  unit
72
74
  rescue StandardError => e
73
- Rails.logger.error("Failed to extract i18n #{file_path}: #{e.message}")
75
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract i18n #{file_path}: #{e.message}")
74
76
  nil
75
77
  end
76
78
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'reference_patterns'
4
6
  require_relative 'shared_utility_methods'
5
7
  require_relative 'shared_dependency_scanner'
@@ -107,7 +109,7 @@ module Woods
107
109
 
108
110
  unit
109
111
  rescue StandardError => e
110
- Rails.logger.error("Failed to extract job #{file_path}: #{e.message}")
112
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract job #{file_path}: #{e.message}")
111
113
  nil
112
114
  end
113
115
 
@@ -134,7 +136,7 @@ module Woods
134
136
 
135
137
  unit
136
138
  rescue StandardError => e
137
- Rails.logger.error("Failed to extract job #{job_class.name}: #{e.message}")
139
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract job #{job_class.name}: #{e.message}")
138
140
  nil
139
141
  end
140
142
 
@@ -318,24 +320,9 @@ module Woods
318
320
  end
319
321
 
320
322
  def extract_perform_params(source)
321
- return [] unless source =~ /def\s+perform\s*\(([^)]*)\)/
322
-
323
- params_str = ::Regexp.last_match(1)
324
- params = []
325
-
326
- params_str.scan(/(\*?\*?\w+)(?:\s*=\s*([^,]+))?/) do |name, default|
327
- params << {
328
- name: name.gsub(/^\*+/, ''),
329
- splat: if name.start_with?('**')
330
- :double
331
- else
332
- (name.start_with?('*') ? :single : nil)
333
- end,
334
- has_default: !default.nil?
335
- }
323
+ MethodParameters.extract(source, :perform).map do |parameter|
324
+ parameter.slice(:name, :splat, :has_default)
336
325
  end
337
-
338
- params
339
326
  end
340
327
 
341
328
  def extract_discard_on(source)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'shared_utility_methods'
4
6
  require_relative 'shared_dependency_scanner'
5
7
  require_relative 'source_nesting'
@@ -85,7 +87,7 @@ module Woods
85
87
 
86
88
  unit
87
89
  rescue StandardError => e
88
- Rails.logger.error("Failed to extract lib file #{file_path}: #{e.message}")
90
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract lib file #{file_path}: #{e.message}")
89
91
  nil
90
92
  end
91
93
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require 'digest'
4
6
  require_relative 'ast_source_extraction'
5
7
  require_relative 'shared_utility_methods'
@@ -27,7 +29,9 @@ module Woods
27
29
  include RouteHelperResolver
28
30
 
29
31
  def initialize
30
- @mailer_base = defined?(ApplicationMailer) ? ApplicationMailer : ActionMailer::Base
32
+ if defined?(ActionMailer::Base)
33
+ @mailer_base = defined?(ApplicationMailer) ? ApplicationMailer : ActionMailer::Base
34
+ end
31
35
  build_route_helper_map
32
36
  end
33
37
 
@@ -45,7 +49,9 @@ module Woods
45
49
  #
46
50
  # @return [Array<Class>]
47
51
  def discoverable_classes
48
- @mailer_base.descendants
52
+ return [] unless @mailer_base
53
+
54
+ @mailer_base.descendants.select { |mailer| app_defined_mailer?(mailer) }
49
55
  end
50
56
 
51
57
  # Extract a single mailer
@@ -53,8 +59,7 @@ module Woods
53
59
  # @param mailer [Class] The mailer class
54
60
  # @return [ExtractedUnit] The extracted unit
55
61
  def extract_mailer(mailer)
56
- return nil if mailer.name.nil?
57
- return nil if mailer == ActionMailer::Base
62
+ return nil unless app_defined_mailer?(mailer)
58
63
 
59
64
  file_path = source_file_for(mailer)
60
65
 
@@ -76,12 +81,22 @@ module Woods
76
81
 
77
82
  unit
78
83
  rescue StandardError => e
79
- Rails.logger.error("Failed to extract mailer #{mailer.name}: #{e.message}")
84
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract mailer #{mailer.name}: #{e.message}")
80
85
  nil
81
86
  end
82
87
 
83
88
  private
84
89
 
90
+ # Discovery and direct incremental extraction must agree on ownership;
91
+ # otherwise a gem class with a fabricated path becomes a phantom unit.
92
+ def app_defined_mailer?(mailer)
93
+ return false unless @mailer_base && mailer.name
94
+ return false if mailer == ActionMailer::Base
95
+
96
+ path = source_file_for(mailer)
97
+ path && File.file?(path) && app_source?(path, Rails.root.to_s)
98
+ end
99
+
85
100
  # Locate the source file for a mailer class.
86
101
  #
87
102
  # Convention path first, then introspection via {#resolve_source_location}
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'shared_utility_methods'
4
6
  require_relative 'shared_dependency_scanner'
5
7
 
@@ -69,7 +71,7 @@ module Woods
69
71
 
70
72
  unit
71
73
  rescue StandardError => e
72
- Rails.logger.error("Failed to extract manager #{file_path}: #{e.message}")
74
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract manager #{file_path}: #{e.message}")
73
75
  nil
74
76
  end
75
77
 
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Woods
6
+ module Extractors
7
+ # Syntactic method signatures only: never evaluate a default expression or
8
+ # mistake names inside one for additional parameters. Consumers retain their
9
+ # existing metadata shapes by selecting the fields they expose.
10
+ module MethodParameters
11
+ module_function
12
+
13
+ def extract(source, method_name)
14
+ parsed = Prism.parse(source)
15
+ return [] unless parsed.success?
16
+
17
+ method = find_method(parsed.value, method_name.to_sym)
18
+ return [] unless method&.parameters
19
+
20
+ method.parameters.compact_child_nodes.flat_map { |node| describe(node) }
21
+ end
22
+
23
+ def find_method(node, name)
24
+ return nil if node.is_a?(Prism::SingletonClassNode)
25
+ return node if node.is_a?(Prism::DefNode) && node.receiver.nil? && node.name == name
26
+
27
+ node.compact_child_nodes.each do |child|
28
+ found = find_method(child, name)
29
+ return found if found
30
+ end
31
+ nil
32
+ end
33
+
34
+ def describe(node)
35
+ # Destructured positional arguments contain declarations, while a
36
+ # default's subtree contains values and must never be walked.
37
+ return node.compact_child_nodes.flat_map { |child| describe(child) } if node.is_a?(Prism::MultiTargetNode)
38
+ return [] unless node.respond_to?(:name) && node.name
39
+
40
+ optional = node.is_a?(Prism::OptionalParameterNode) || node.is_a?(Prism::OptionalKeywordParameterNode)
41
+ keyword = node.is_a?(Prism::RequiredKeywordParameterNode) ||
42
+ node.is_a?(Prism::OptionalKeywordParameterNode) || node.is_a?(Prism::KeywordRestParameterNode)
43
+ splat = if node.is_a?(Prism::KeywordRestParameterNode)
44
+ :double
45
+ elsif node.is_a?(Prism::RestParameterNode)
46
+ :single
47
+ end
48
+ [{ name: node.name.to_s, has_default: optional, keyword: keyword, splat: splat }]
49
+ end
50
+ private_class_method :find_method, :describe
51
+ end
52
+ end
53
+ end