woods 2.0.0.beta2 → 2.0.0.beta4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +339 -1
  3. data/CONTRIBUTING.md +188 -12
  4. data/README.md +93 -174
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +109 -8
  7. data/docs/AGENT_SETUP.md +98 -7
  8. data/docs/BACKEND_MATRIX.md +25 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +267 -16
  11. data/docs/CONSOLE_MCP_SETUP.md +80 -7
  12. data/docs/DOCKER_SETUP.md +22 -3
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +45 -6
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +147 -7
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +276 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +37 -22
  22. data/docs/MCP_WORKTREE_SETUP.md +43 -83
  23. data/docs/NOTION_INTEGRATION.md +13 -0
  24. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  25. data/docs/PUBLISHED_INDEX.md +72 -0
  26. data/docs/README.md +7 -0
  27. data/docs/RETRIEVAL_GUIDE.md +273 -12
  28. data/docs/RUNTIME_TRACING.md +71 -0
  29. data/docs/SOURCE_FRESHNESS.md +143 -0
  30. data/docs/TROUBLESHOOTING.md +129 -18
  31. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  32. data/docs/UPGRADING_TO_2.md +48 -22
  33. data/docs/WATCH_DAEMON.md +277 -67
  34. data/exe/woods-agent-config +6 -0
  35. data/exe/woods-extract +5 -0
  36. data/exe/woods-hook-context +6 -0
  37. data/exe/woods-mcp-start +14 -9
  38. data/lib/generators/woods/pgvector_generator.rb +8 -2
  39. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  40. data/lib/tasks/woods.rake +47 -397
  41. data/lib/woods/agent_configuration/applier.rb +135 -0
  42. data/lib/woods/agent_configuration/cli.rb +101 -0
  43. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  44. data/lib/woods/agent_configuration/document.rb +105 -0
  45. data/lib/woods/agent_configuration/error.rb +7 -0
  46. data/lib/woods/agent_configuration/launcher.rb +75 -0
  47. data/lib/woods/agent_configuration/layout.rb +72 -0
  48. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  49. data/lib/woods/agent_configuration/plan.rb +98 -0
  50. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  51. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  52. data/lib/woods/agent_configuration/planner.rb +63 -0
  53. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  54. data/lib/woods/agent_configuration/preflight.rb +100 -0
  55. data/lib/woods/agent_configuration/recovery.rb +49 -0
  56. data/lib/woods/ast/node.rb +2 -0
  57. data/lib/woods/ast/parser.rb +38 -5
  58. data/lib/woods/builder.rb +21 -5
  59. data/lib/woods/cache/cache_middleware.rb +28 -7
  60. data/lib/woods/cache/cache_store.rb +4 -5
  61. data/lib/woods/change_set.rb +5 -4
  62. data/lib/woods/console/credential_index.rb +20 -2
  63. data/lib/woods/console/credential_scanner.rb +18 -17
  64. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  65. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  66. data/lib/woods/console/embedded_executor.rb +32 -10
  67. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  68. data/lib/woods/console/rack_middleware.rb +22 -13
  69. data/lib/woods/console/server.rb +18 -16
  70. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  71. data/lib/woods/console/sql_table_scanner.rb +47 -7
  72. data/lib/woods/console/sql_validator.rb +49 -9
  73. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  74. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  75. data/lib/woods/dependency_graph.rb +65 -13
  76. data/lib/woods/embedding/corpus.rb +94 -0
  77. data/lib/woods/embedding/indexer.rb +114 -60
  78. data/lib/woods/embedding/openai.rb +17 -6
  79. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  80. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  81. data/lib/woods/export/typed_reader.rb +56 -0
  82. data/lib/woods/extractor.rb +277 -149
  83. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  84. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  85. data/lib/woods/extractors/caching_extractor.rb +3 -1
  86. data/lib/woods/extractors/concern_extractor.rb +64 -6
  87. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  88. data/lib/woods/extractors/controller_extractor.rb +13 -4
  89. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  90. data/lib/woods/extractors/declared_parent.rb +55 -0
  91. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  92. data/lib/woods/extractors/engine_extractor.rb +3 -1
  93. data/lib/woods/extractors/event_extractor.rb +4 -2
  94. data/lib/woods/extractors/factory_extractor.rb +3 -1
  95. data/lib/woods/extractors/graphql_extractor.rb +10 -13
  96. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  97. data/lib/woods/extractors/job_extractor.rb +6 -19
  98. data/lib/woods/extractors/lib_extractor.rb +13 -9
  99. data/lib/woods/extractors/mailer_extractor.rb +26 -15
  100. data/lib/woods/extractors/manager_extractor.rb +3 -1
  101. data/lib/woods/extractors/method_parameters.rb +53 -0
  102. data/lib/woods/extractors/middleware_argument.rb +65 -0
  103. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  104. data/lib/woods/extractors/migration_extractor.rb +3 -1
  105. data/lib/woods/extractors/model_extractor.rb +26 -34
  106. data/lib/woods/extractors/package_extractor.rb +24 -4
  107. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  108. data/lib/woods/extractors/policy_extractor.rb +3 -1
  109. data/lib/woods/extractors/poro_extractor.rb +13 -9
  110. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  111. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  112. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  113. data/lib/woods/extractors/route_extractor.rb +3 -1
  114. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  115. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  116. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  117. data/lib/woods/extractors/service_extractor.rb +3 -1
  118. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  119. data/lib/woods/extractors/shared_utility_methods.rb +48 -19
  120. data/lib/woods/extractors/source_nesting.rb +1 -1
  121. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  122. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  123. data/lib/woods/extractors/validator_extractor.rb +3 -1
  124. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  125. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  126. data/lib/woods/gem_mapper.rb +2 -0
  127. data/lib/woods/git_history.rb +116 -0
  128. data/lib/woods/graph_analyzer.rb +35 -6
  129. data/lib/woods/hooks/context_cli.rb +54 -0
  130. data/lib/woods/hooks/context_event.rb +88 -0
  131. data/lib/woods/hooks/context_hint.rb +73 -0
  132. data/lib/woods/hooks/context_impact.rb +77 -0
  133. data/lib/woods/hooks/context_output.rb +47 -0
  134. data/lib/woods/hooks/context_state.rb +102 -0
  135. data/lib/woods/hooks/refresh.rb +79 -0
  136. data/lib/woods/hooks/rule_projection.rb +78 -0
  137. data/lib/woods/input_rules.rb +19 -0
  138. data/lib/woods/mcp/bearer_auth.rb +22 -13
  139. data/lib/woods/mcp/bootstrapper.rb +79 -4
  140. data/lib/woods/mcp/config_resolver.rb +2 -1
  141. data/lib/woods/mcp/index_reader.rb +334 -162
  142. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  143. data/lib/woods/mcp/origin_guard.rb +17 -9
  144. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  145. data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
  146. data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
  147. data/lib/woods/mcp/search_results.rb +74 -0
  148. data/lib/woods/mcp/server.rb +178 -63
  149. data/lib/woods/mcp/tool_contract.rb +3 -1
  150. data/lib/woods/mcp/tool_response_renderer.rb +41 -0
  151. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  152. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  153. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  154. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  155. data/lib/woods/mcp/traversal_response.rb +22 -0
  156. data/lib/woods/notion/exporter.rb +56 -17
  157. data/lib/woods/obsidian/destination_plan.rb +98 -0
  158. data/lib/woods/obsidian/name_mapper.rb +19 -3
  159. data/lib/woods/obsidian/note_builder.rb +19 -10
  160. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  161. data/lib/woods/operator/pipeline_guard.rb +18 -13
  162. data/lib/woods/path_dispatcher.rb +13 -6
  163. data/lib/woods/payload_store.rb +27 -26
  164. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  165. data/lib/woods/published_index.rb +2 -2
  166. data/lib/woods/railtie.rb +3 -3
  167. data/lib/woods/railtie_support.rb +12 -12
  168. data/lib/woods/rake_helpers.rb +382 -0
  169. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  170. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  171. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  172. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  173. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  174. data/lib/woods/resilience/index_validator.rb +112 -23
  175. data/lib/woods/retrieval/context_assembler.rb +50 -15
  176. data/lib/woods/retrieval/lexical_assembler.rb +84 -0
  177. data/lib/woods/retrieval/lexical_index.rb +120 -0
  178. data/lib/woods/retrieval/ranker.rb +4 -2
  179. data/lib/woods/retrieval/scope.rb +108 -0
  180. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  181. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  182. data/lib/woods/retrieval/search_executor.rb +86 -27
  183. data/lib/woods/retrieval/source_evidence.rb +200 -0
  184. data/lib/woods/retriever.rb +98 -22
  185. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  186. data/lib/woods/session_tracer/file_store.rb +6 -1
  187. data/lib/woods/session_tracer/middleware.rb +10 -12
  188. data/lib/woods/session_tracer/redis_store.rb +22 -6
  189. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  190. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  191. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  192. data/lib/woods/source_inputs/consumer_errors.rb +31 -0
  193. data/lib/woods/source_inputs/handoff.rb +102 -0
  194. data/lib/woods/source_inputs/launcher.rb +157 -0
  195. data/lib/woods/source_inputs/manifest.rb +124 -0
  196. data/lib/woods/source_inputs/private_key.rb +55 -0
  197. data/lib/woods/source_inputs/scanner.rb +171 -0
  198. data/lib/woods/source_inputs/scopes.rb +71 -0
  199. data/lib/woods/source_inputs/session.rb +214 -0
  200. data/lib/woods/source_inputs/status.rb +84 -0
  201. data/lib/woods/source_inputs/verifier.rb +107 -0
  202. data/lib/woods/storage/metadata_store.rb +25 -25
  203. data/lib/woods/storage/pgvector.rb +35 -10
  204. data/lib/woods/storage/qdrant.rb +17 -7
  205. data/lib/woods/storage/vector_store.rb +18 -6
  206. data/lib/woods/tasks.rb +3 -2
  207. data/lib/woods/temporal/json_snapshot_store.rb +58 -9
  208. data/lib/woods/unblocked/exporter.rb +59 -70
  209. data/lib/woods/version.rb +1 -1
  210. data/lib/woods/watch/boot_snapshot.rb +52 -0
  211. data/lib/woods/watch/daemon.rb +154 -32
  212. data/lib/woods/watch/listen_watcher.rb +4 -0
  213. data/lib/woods/watch/polling_watcher.rb +5 -1
  214. data/lib/woods/watch/status.rb +20 -15
  215. data/lib/woods/watch/tree_scan.rb +21 -13
  216. data/lib/woods/watch/watcher.rb +4 -1
  217. data/lib/woods.rb +50 -11
  218. data/plugin/.claude-plugin/plugin.json +1 -1
  219. data/plugin/hooks/adapters/normalize.jq +15 -0
  220. data/plugin/hooks/adapters/normalize.rb +63 -0
  221. data/plugin/hooks/hooks.json +20 -0
  222. data/plugin/hooks/woods-context.sh +50 -0
  223. data/plugin/hooks/woods-input-rules.sh +159 -0
  224. data/plugin/hooks/woods-opencode.mjs +65 -0
  225. data/plugin/hooks/woods-post-edit.sh +2 -225
  226. data/plugin/hooks/woods-refresh.sh +260 -0
  227. data/plugin/hooks/woods-session-start.sh +47 -55
  228. data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
  229. data/plugin/skills/woods-diagnose/SKILL.md +319 -1
  230. data/plugin/skills/woods-investigate/SKILL.md +145 -0
  231. data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
  232. data/plugin/skills/woods-setup/SKILL.md +110 -6
  233. metadata +87 -5
@@ -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}
@@ -101,12 +116,8 @@ module Woods
101
116
  # ──────────────────────────────────────────────────────────────────────
102
117
 
103
118
  def annotate_source(source, mailer)
104
- actions = mailer.action_methods.to_a
105
- default_from = begin
106
- mailer.default[:from]
107
- rescue StandardError
108
- nil
109
- end
119
+ actions = mailer.action_methods.sort
120
+ default_from = extract_defaults(mailer)[:from]
110
121
 
111
122
  <<~ANNOTATION
112
123
  # ╔═══════════════════════════════════════════════════════════════════════╗
@@ -124,7 +135,7 @@ module Woods
124
135
  # ──────────────────────────────────────────────────────────────────────
125
136
 
126
137
  def extract_metadata(mailer, source)
127
- actions = mailer.action_methods.to_a
138
+ actions = mailer.action_methods.sort
128
139
 
129
140
  {
130
141
  # Actions (mail methods)
@@ -156,7 +167,7 @@ module Woods
156
167
 
157
168
  def extract_defaults(mailer)
158
169
  mailer_defaults = mailer.default
159
- mailer_defaults.slice(:from, :reply_to, :cc, :bcc).compact
170
+ mailer_defaults.slice(:from, :reply_to, :cc, :bcc).compact.transform_values { |value| stable_filter(value) }
160
171
  rescue StandardError
161
172
  {}
162
173
  end
@@ -167,7 +178,7 @@ module Woods
167
178
 
168
179
  result = {
169
180
  type: :"#{cb.kind}_action",
170
- filter: cb.filter.to_s
181
+ filter: callback_filter_label(cb)
171
182
  }
172
183
  result[:only] = only if only.any?
173
184
  result[:except] = except if except.any?
@@ -259,7 +270,7 @@ module Woods
259
270
  # ──────────────────────────────────────────────────────────────────────
260
271
 
261
272
  def build_action_chunks(mailer, _source)
262
- mailer.action_methods.filter_map do |action|
273
+ mailer.action_methods.sort.filter_map do |action|
263
274
  action_source = extract_action_source(mailer, action)
264
275
  next if action_source.nil? || action_source.strip.empty?
265
276
 
@@ -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
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Extractors
5
+ # Stable, readable middleware arguments without inspecting opaque runtime state.
6
+ # Literal strings and custom #to_s implementations remain application data.
7
+ module MiddlewareArgument
8
+ module_function
9
+
10
+ # @param value [Object] Runtime middleware argument
11
+ # @return [String] Deterministic description for ordinary Ruby runtime values
12
+ def render(value, ancestors = [], nested: false)
13
+ return "<recursive #{value.class}>" if ancestors.any? { |ancestor| ancestor.equal?(value) }
14
+
15
+ ancestors += [value]
16
+ case value
17
+ when String then nested ? value.inspect : value
18
+ when Symbol then nested ? value.inspect : value.to_s
19
+ when NilClass then nested ? 'nil' : ''
20
+ when Array then "[#{value.map { |item| render(item, ancestors, nested: true) }.join(', ')}]"
21
+ when Hash
22
+ pairs = value.map { |key, item| "#{render(key, ancestors, nested: true)}=>#{render(item, ancestors, nested: true)}" }
23
+ "{#{pairs.join(', ')}}"
24
+ when Module then module_name(value)
25
+ when Proc then "#<#{value.lambda? ? 'lambda' : 'Proc'} #{value.source_location&.join(':') || 'native'}>"
26
+ else
27
+ # Only Object's identity-based implementation is replaced. Never scrub
28
+ # arbitrary hex strings or override application-defined textual values.
29
+ if [Kernel, Object].include?(value.method(:to_s).owner)
30
+ "#<#{module_name(value.class)}>"
31
+ else
32
+ value.to_s
33
+ end
34
+ end
35
+ end
36
+
37
+ def module_name(value)
38
+ return value.name if constant_path?(value.name)
39
+
40
+ parent = value.is_a?(Class) ? " < #{module_name(value.superclass)}" : ''
41
+ methods = (value.instance_methods(false) + value.private_instance_methods(false)).uniq.sort.map do |name|
42
+ "#{name}@#{value.instance_method(name).source_location&.join(':') || 'native'}"
43
+ end
44
+ "#<anonymous #{value.class}#{parent}#{" (#{methods.join(', ')})" unless methods.empty?}>"
45
+ end
46
+
47
+ # Ruby temporary names are explicitly not constant paths and can contain
48
+ # runtime identities (Rails 8.2 uses these for executor/reloader classes).
49
+ # Validate each segment with Ruby itself, including Unicode constants,
50
+ # against an empty namespace: never resolve or autoload application names.
51
+ def constant_path?(name)
52
+ return false if name.nil? || name.empty?
53
+
54
+ namespace = Module.new
55
+ name.split('::', -1).all? do |segment|
56
+ namespace.const_defined?(segment, false)
57
+ true
58
+ end
59
+ rescue NameError
60
+ false
61
+ end
62
+ private_class_method :module_name, :constant_path?
63
+ end
64
+ end
65
+ end
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
5
+ require_relative 'middleware_argument'
6
+
3
7
  module Woods
4
8
  module Extractors
5
9
  # MiddlewareExtractor handles Rack middleware stack extraction via runtime introspection.
@@ -42,7 +46,7 @@ module Woods
42
46
 
43
47
  [unit]
44
48
  rescue StandardError => e
45
- Rails.logger.error("Failed to extract middleware stack: #{e.message}")
49
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract middleware stack: #{e.message}")
46
50
  []
47
51
  end
48
52
 
@@ -80,7 +84,9 @@ module Woods
80
84
  # @param position [Integer] Position in the stack
81
85
  # @return [Hash, nil]
82
86
  def extract_single_middleware(middleware, position)
83
- name = if middleware.respond_to?(:name)
87
+ name = if middleware.respond_to?(:klass) && middleware.klass.is_a?(Module)
88
+ MiddlewareArgument.render(middleware.klass)
89
+ elsif middleware.respond_to?(:name)
84
90
  middleware.name
85
91
  elsif middleware.respond_to?(:klass)
86
92
  middleware.klass.to_s
@@ -89,7 +95,7 @@ module Woods
89
95
  end
90
96
 
91
97
  args = if middleware.respond_to?(:args)
92
- middleware.args.map(&:to_s)
98
+ middleware.args.map { |argument| MiddlewareArgument.render(argument) }
93
99
  else
94
100
  []
95
101
  end
@@ -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
 
@@ -122,7 +124,7 @@ module Woods
122
124
 
123
125
  unit
124
126
  rescue StandardError => e
125
- Rails.logger.error("Failed to extract migration #{file_path}: #{e.message}")
127
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract migration #{file_path}: #{e.message}")
126
128
  nil
127
129
  end
128
130
 
@@ -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/parser'
5
7
  require_relative 'shared_utility_methods'
@@ -36,6 +38,12 @@ module Woods
36
38
  (?:after|before)_(?:add|remove)_for_ # collection callbacks
37
39
  )/x
38
40
 
41
+ # Rails exposes one chain per event; each entry supplies its own kind.
42
+ # before_commit is itself an event, unlike before_save and after_commit.
43
+ CALLBACK_EVENTS = %i[
44
+ validation save create update destroy commit rollback before_commit initialize find touch
45
+ ].freeze
46
+
39
47
  # Warnings collected during extraction (skipped associations, failed models)
40
48
  attr_reader :warnings
41
49
 
@@ -97,7 +105,7 @@ module Woods
97
105
  unit
98
106
  rescue StandardError => e
99
107
  @warnings << "Failed to extract model #{model.name}: #{e.message}"
100
- Rails.logger.error("Failed to extract model #{model.name}: #{e.message}")
108
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract model #{model.name}: #{e.message}")
101
109
  nil
102
110
  end
103
111
 
@@ -384,6 +392,9 @@ module Woods
384
392
 
385
393
  # Find the file path for a concern
386
394
  def concern_path_for(mod)
395
+ reflected = resolve_source_location(mod, app_root: Rails.root.to_s, fallback: nil)
396
+ return reflected if app_source?(reflected, "#{Rails.root}/") && File.file?(reflected)
397
+
387
398
  possible_paths = [
388
399
  Rails.root.join("app/models/concerns/#{mod.name.underscore}.rb"),
389
400
  Rails.root.join("app/controllers/concerns/#{mod.name.underscore}.rb"),
@@ -407,6 +418,7 @@ module Woods
407
418
  # composite source does not carry.
408
419
  # @return [Hash]
409
420
  def extract_metadata(model, source = nil, inlined_concerns: nil)
421
+ callbacks = extract_callbacks(model)
410
422
  {
411
423
  # Core identifiers
412
424
  table_name: model.table_name,
@@ -416,15 +428,11 @@ module Woods
416
428
  # Relationships and behaviors
417
429
  associations: extract_associations(model),
418
430
  validations: extract_validations(model),
419
- callbacks: extract_callbacks(model),
431
+ callbacks: callbacks,
420
432
  scopes: extract_scopes(model, source),
421
433
  enums: extract_enums(model),
422
434
  inlined_concerns: inlined_concerns || build_model_source_with_concerns(model, source).last,
423
435
 
424
- # API surface
425
- class_methods: model.methods(false).sort,
426
- instance_methods: filter_instance_methods(model.instance_methods(false)).sort,
427
-
428
436
  # Inheritance
429
437
  sti_column: model.inheritance_column,
430
438
  is_sti_base: sti_base?(model),
@@ -433,7 +441,7 @@ module Woods
433
441
 
434
442
  # Metrics for retrieval ranking
435
443
  loc: count_loc(model, source),
436
- callback_count: callback_count(model),
444
+ callback_count: callbacks.length,
437
445
  association_count: model.reflect_on_all_associations.size,
438
446
  validation_count: model._validators.values.flatten.size,
439
447
 
@@ -449,6 +457,12 @@ module Woods
449
457
  []
450
458
  end,
451
459
 
460
+ # Schema loading can define methods (including Rails' optimized
461
+ # constructor). Reflect only after the schema reads above so a cold
462
+ # full extraction and a warmed incremental run see the same API.
463
+ class_methods: model.methods(false).sort,
464
+ instance_methods: filter_instance_methods(model.instance_methods(false)).sort,
465
+
452
466
  # Foreign keys as the connection reports them. The target table's
453
467
  # database is a graph-level concern, resolved by the
454
468
  # cross_database_edges report, not stored per model here (#280).
@@ -605,7 +619,7 @@ module Woods
605
619
  entry = {
606
620
  attribute: attribute,
607
621
  type: v.class.name.demodulize.underscore.sub(/_validator$/, ''),
608
- options: v.options.except(:if, :unless, :on),
622
+ options: v.options.except(:if, :unless, :on).transform_values { |value| stable_filter(value) },
609
623
  conditions: format_validation_conditions(v)
610
624
  }
611
625
  entry[:implicit_belongs_to] = true if implicit_belongs_to_validator?(model, v)
@@ -616,23 +630,12 @@ module Woods
616
630
 
617
631
  # Extract all callbacks with their full chain
618
632
  def extract_callbacks(model)
619
- callback_types = %i[
620
- before_validation after_validation
621
- before_save after_save around_save
622
- before_create after_create around_create
623
- before_update after_update around_update
624
- before_destroy after_destroy around_destroy
625
- after_commit after_rollback
626
- after_initialize after_find
627
- after_touch
628
- ]
629
-
630
- callback_types.flat_map do |type|
631
- callbacks = model.send("_#{type}_callbacks")
633
+ CALLBACK_EVENTS.flat_map do |event|
634
+ callbacks = model.send("_#{event}_callbacks")
632
635
  callbacks.map do |cb|
633
636
  {
634
- type: type,
635
- filter: cb.filter.to_s,
637
+ type: event == :before_commit ? :before_commit : :"#{cb.kind}_#{event}",
638
+ filter: callback_filter_label(cb),
636
639
  kind: cb.kind, # :before, :after, :around
637
640
  conditions: format_callback_conditions(cb)
638
641
  }
@@ -1221,17 +1224,6 @@ module Woods
1221
1224
  # Helper methods
1222
1225
  # ──────────────────────────────────────────────────────────────────────
1223
1226
 
1224
- def callback_count(model)
1225
- %i[validation save create update destroy commit rollback].sum do |type|
1226
- # CallbackChain includes Enumerable but defines no #size on any
1227
- # supported Rails version — #size raises and the rescue would
1228
- # zero the count. #count is the only correct API here.
1229
- model.send("_#{type}_callbacks").count
1230
- rescue StandardError
1231
- 0
1232
- end
1233
- end
1234
-
1235
1227
  def count_loc(model, source = nil)
1236
1228
  if source
1237
1229
  source.lines.count { |l| l.strip.present? && !l.strip.start_with?('#') }
@@ -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
  require 'pathname'
5
7
 
@@ -69,7 +71,7 @@ module Woods
69
71
  unit.dependencies = dependencies.map { |dep| { type: :package, target: dep, via: :package_dependency } }
70
72
  unit
71
73
  rescue StandardError => e
72
- Rails.logger.error("Failed to extract package from #{file_path}: #{e.message}")
74
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract package from #{file_path}: #{e.message}")
73
75
  nil
74
76
  end
75
77
 
@@ -103,13 +105,30 @@ module Woods
103
105
  @package_files ||= begin
104
106
  patterns = Array(@packwerk['package_paths'] || DEFAULT_PACKAGE_PATHS)
105
107
  excludes = Array(@packwerk['exclude'] || DEFAULT_EXCLUDE)
106
- found = patterns.flat_map do |pattern|
107
- Dir.glob(@rails_root.join(pattern.to_s, PACKAGE_FILE).to_s)
108
- end
108
+ found = if patterns == DEFAULT_PACKAGE_PATHS && excludes == DEFAULT_EXCLUDE
109
+ default_package_files
110
+ else
111
+ patterns.flat_map { |pattern| Dir.glob(@rails_root.join(pattern.to_s, PACKAGE_FILE).to_s) }
112
+ end
109
113
  found.uniq.reject { |path| excluded?(relativize(path), excludes) }.sort
110
114
  end
111
115
  end
112
116
 
117
+ # Prune only the known default top-level exclusions before recursion.
118
+ # Recursive glob does not descend through symlink directories; keep that
119
+ # behavior when turning top-level entries into separate glob roots.
120
+ def default_package_files
121
+ found = Dir.glob(PACKAGE_FILE, base: @rails_root).map { |name| @rails_root.join(name).to_s }
122
+ Dir.glob('*', base: @rails_root).each do |name|
123
+ directory = @rails_root.join(name)
124
+ next unless directory.directory? && !directory.symlink?
125
+ next if excluded?("#{name}/#{PACKAGE_FILE}", DEFAULT_EXCLUDE)
126
+
127
+ found.concat(Dir.glob("**/#{PACKAGE_FILE}", base: directory).map { |path| directory.join(path).to_s })
128
+ end
129
+ found
130
+ end
131
+
113
132
  def excluded?(relative, excludes)
114
133
  excludes.any? { |glob| File.fnmatch?(glob.to_s, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
115
134
  end
@@ -122,6 +141,7 @@ module Woods
122
141
  data = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true)
123
142
  data.is_a?(Hash) ? data : {}
124
143
  rescue StandardError => e
144
+ SourceInputs::ConsumerErrors.record(self)
125
145
  Rails.logger.warn("[Woods] Could not read #{PACKWERK_CONFIG}: #{e.message}")
126
146
  {}
127
147
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'component_discovery'
4
6
  require_relative 'shared_utility_methods'
5
7
  require_relative 'shared_dependency_scanner'
@@ -87,7 +89,7 @@ module Woods
87
89
 
88
90
  unit
89
91
  rescue StandardError => e
90
- Rails.logger.error("Failed to extract component #{component.name}: #{e.message}")
92
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract component #{component.name}: #{e.message}")
91
93
  nil
92
94
  end
93
95
 
@@ -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 policy #{file_path}: #{e.message}")
74
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract policy #{file_path}: #{e.message}")
73
75
  nil
74
76
  end
75
77
 
@@ -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'
@@ -80,14 +82,16 @@ module Woods
80
82
  file_path: file_path
81
83
  )
82
84
 
85
+ parent_class = extract_parent_class(source, class_name)
86
+
83
87
  unit.namespace = extract_namespace(class_name)
84
- unit.source_code = annotate_source(source, class_name)
85
- unit.metadata = extract_metadata(source, class_name)
88
+ unit.source_code = annotate_source(source, class_name, parent_class)
89
+ unit.metadata = extract_metadata(source, parent_class)
86
90
  unit.dependencies = extract_dependencies(source)
87
91
 
88
92
  unit
89
93
  rescue StandardError => e
90
- Rails.logger.error("Failed to extract PORO #{file_path}: #{e.message}")
94
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract PORO #{file_path}: #{e.message}")
91
95
  nil
92
96
  end
93
97
 
@@ -175,10 +179,10 @@ module Woods
175
179
  #
176
180
  # @param source [String] Ruby source code
177
181
  # @param class_name [String] The class name
182
+ # @param parent_class [String, nil] Selected explicit parent
178
183
  # @return [String] Annotated source
179
- def annotate_source(source, class_name)
180
- parent = extract_parent_class(source)
181
- parent_label = parent || 'none'
184
+ def annotate_source(source, class_name, parent_class)
185
+ parent_label = parent_class || 'none'
182
186
 
183
187
  annotation = <<~ANNOTATION
184
188
  # ╔═══════════════════════════════════════════════════════════════════════╗
@@ -198,14 +202,14 @@ module Woods
198
202
  # Build the metadata hash for a PORO unit.
199
203
  #
200
204
  # @param source [String] Ruby source code
201
- # @param class_name [String] The class name
205
+ # @param parent_class [String, nil] Selected explicit parent
202
206
  # @return [Hash] PORO metadata
203
- def extract_metadata(source, _class_name)
207
+ def extract_metadata(source, parent_class)
204
208
  {
205
209
  public_methods: extract_public_methods(source),
206
210
  class_methods: extract_class_methods(source),
207
211
  initialize_params: extract_initialize_params(source),
208
- parent_class: extract_parent_class(source),
212
+ parent_class: parent_class,
209
213
  loc: count_loc(source),
210
214
  method_count: source.scan(/def\s+(?:self\.)?\w+/).size
211
215
  }
@@ -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
 
@@ -67,7 +69,7 @@ module Woods
67
69
 
68
70
  unit
69
71
  rescue StandardError => e
70
- Rails.logger.error("Failed to extract Pundit policy #{file_path}: #{e.message}")
72
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract Pundit policy #{file_path}: #{e.message}")
71
73
  nil
72
74
  end
73
75
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative '../extracted_unit'
4
6
 
5
7
  module Woods
@@ -268,7 +270,7 @@ module Woods
268
270
 
269
271
  unit
270
272
  rescue StandardError => e
271
- Rails.logger.error("Failed to extract Rails source #{file_path}: #{e.message}")
273
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract Rails source #{file_path}: #{e.message}")
272
274
  nil
273
275
  end
274
276
 
@@ -302,7 +304,7 @@ module Woods
302
304
 
303
305
  unit
304
306
  rescue StandardError => e
305
- Rails.logger.error("Failed to extract gem source #{file_path}: #{e.message}")
307
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract gem source #{file_path}: #{e.message}")
306
308
  nil
307
309
  end
308
310
 
@@ -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'
@@ -73,7 +75,7 @@ module Woods
73
75
  build_unit(task_data, file_path, data[:source], sibling_definitions(task_data[:full_name], file_path))
74
76
  end
75
77
  rescue StandardError => e
76
- Rails.logger.error("Failed to extract rake tasks from #{file_path}: #{e.message}")
78
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract rake tasks from #{file_path}: #{e.message}")
77
79
  []
78
80
  end
79
81
 
@@ -100,7 +102,7 @@ module Woods
100
102
  { source: source, tasks: parse_tasks(source) }
101
103
  end
102
104
  rescue StandardError => e
103
- Rails.logger.error("Failed to scan rake tasks in #{file}: #{e.message}")
105
+ SourceInputs::ConsumerErrors.log(self, "Failed to scan rake tasks in #{file}: #{e.message}")
104
106
  nil
105
107
  end
106
108