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
@@ -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).
@@ -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
  }
@@ -647,6 +650,20 @@ module Woods
647
650
  end.compact
648
651
  end
649
652
 
653
+ # Preserve application labels; strip addresses only from Ruby's actual
654
+ # default representation. Nested identities occur for anonymous classes.
655
+ # These are descriptive labels, not serialization of callback object state.
656
+ def callback_filter_label(callback)
657
+ filter = callback_filter(callback)
658
+ label = filter.to_s
659
+ return label unless label.start_with?('#<')
660
+
661
+ owner = filter.is_a?(Module) ? Module : Kernel
662
+ return label unless label == owner.instance_method(:to_s).bind(filter).call
663
+
664
+ label.gsub(/:0x[0-9a-f]+(?=>)/i, '')
665
+ end
666
+
650
667
  # Extract scopes with their source if available.
651
668
  # Parses the full source with the AST layer to get accurate scope
652
669
  # boundaries, falling back to regex line-scanning on parse failure.
@@ -1221,17 +1238,6 @@ module Woods
1221
1238
  # Helper methods
1222
1239
  # ──────────────────────────────────────────────────────────────────────
1223
1240
 
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
1241
  def count_loc(model, source = nil)
1236
1242
  if source
1237
1243
  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'
@@ -87,7 +89,7 @@ module Woods
87
89
 
88
90
  unit
89
91
  rescue StandardError => e
90
- Rails.logger.error("Failed to extract PORO #{file_path}: #{e.message}")
92
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract PORO #{file_path}: #{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
 
@@ -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
 
@@ -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
 
@@ -76,7 +78,7 @@ module Woods
76
78
 
77
79
  unit
78
80
  rescue StandardError => e
79
- Rails.logger.error("Failed to extract route: #{e.message}")
81
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract route: #{e.message}")
80
82
  nil
81
83
  end
82
84
 
@@ -23,41 +23,13 @@ module Woods
23
23
  # end
24
24
  #
25
25
  module RouteHelperResolver
26
- # Route helper prefixes that produce non-navigation dependencies.
27
- # These generate asset URLs or are common false positives from
28
- # non-route uses of _path/_url suffixes in Ruby code.
29
- #
30
- # NOTE: `root` is intentionally excluded — root_path is the most common
31
- # Rails route helper, but it appears so frequently in non-navigation contexts
32
- # (path construction, config, tests) that it generates excessive noise.
33
- # The tradeoff: "what links to the home page?" won't appear in graph queries.
34
- # Add new prefixes here when false positives are discovered in host apps.
35
- IGNORED_HELPER_PREFIXES = %w[
36
- asset
37
- image
38
- stylesheet
39
- javascript
40
- font
41
- audio
42
- video
43
- turbo_stream
44
- file
45
- tmp
46
- base
47
- root
48
- log
49
- socket
50
- download
51
- ].freeze
52
-
53
26
  # Build the route helper lookup map from Rails named routes.
54
27
  # Call this once in your extractor's initialize method.
55
28
  #
56
29
  # Resilient to partial test doubles: any exception raised while
57
30
  # traversing Rails routes (unstubbed `application` on a double,
58
31
  # missing `named_routes`, etc.) is swallowed and leaves the map
59
- # empty — extractors fall back to returning the helper name
60
- # literal as the dependency target.
32
+ # empty — extractors omit unresolved navigation dependencies.
61
33
  def build_route_helper_map
62
34
  @route_helper_map = {}
63
35
  return unless defined?(Rails)
@@ -65,6 +37,11 @@ module Woods
65
37
  routes = safe_rails_application_routes
66
38
  return unless routes
67
39
 
40
+ # Rails lazy route sets do not load when named_routes is read. Use the
41
+ # runtime route collection reader before caching helpers, just as the
42
+ # route extractor does. Older route sets and partial doubles still work.
43
+ routes.routes if routes.respond_to?(:routes)
44
+
68
45
  routes.named_routes.each do |name, route|
69
46
  controller = route.defaults[:controller]
70
47
  action = route.defaults[:action]
@@ -78,8 +55,8 @@ module Woods
78
55
  }
79
56
  end
80
57
  rescue StandardError
81
- # Leave @route_helper_map empty — navigation-edge extractors will
82
- # fall back to the helper-name literal.
58
+ # Leave @route_helper_map empty — navigation-edge extractors omit
59
+ # helpers whose controller/action cannot be resolved.
83
60
  @route_helper_map = {}
84
61
  end
85
62
 
@@ -105,8 +82,8 @@ module Woods
105
82
  # @return [Hash, nil] { controller:, action:, path:, verb: } or nil if unresolvable
106
83
  def resolve_route_helper(helper_name)
107
84
  base = helper_name.sub(/_(path|url)\z/, '')
108
- return nil if IGNORED_HELPER_PREFIXES.any? { |prefix| base.start_with?("#{prefix}_") || base == prefix }
109
-
85
+ # A real named route is authoritative even when its name resembles
86
+ # a filesystem or asset helper. Unresolved names still produce no edge.
110
87
  @route_helper_map&.[](base)
111
88
  end
112
89
 
@@ -1,6 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require 'yaml'
6
+ begin
7
+ require 'active_support/configuration_file'
8
+ rescue LoadError
9
+ # Rails 6.0 predates ConfigurationFile.
10
+ require 'erb'
11
+ end
4
12
 
5
13
  module Woods
6
14
  module Extractors
@@ -38,9 +46,6 @@ module Woods
38
46
  '0 0 1 1 *' => 'yearly on January 1st'
39
47
  }.freeze
40
48
 
41
- # Environment keys to unwrap when nested in YAML
42
- ENVIRONMENT_KEYS = %w[production development test staging].freeze
43
-
44
49
  # A quoted Whenever command argument in either style. The body runs to
45
50
  # the *matching* delimiter, so the inner quotes of `command "echo 'hi'"`
46
51
  # stay part of the command.
@@ -79,8 +84,8 @@ module Woods
79
84
  else
80
85
  []
81
86
  end
82
- rescue StandardError => e
83
- Rails.logger.error("Failed to extract scheduled jobs from #{file_path}: #{e.message}")
87
+ rescue StandardError, SyntaxError, LoadError => e
88
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract scheduled jobs from #{file_path}: #{e.message}")
84
89
  []
85
90
  end
86
91
 
@@ -97,12 +102,15 @@ module Woods
97
102
  # @return [Array<ExtractedUnit>]
98
103
  def extract_yaml_schedule(file_path, format)
99
104
  source = File.read(file_path)
100
- # aliases: true — schedule files commonly share defaults via anchors
101
- # (`<<: *defaults`); without it Psych 4+ raises AliasesNotEnabled and
102
- # the whole file is silently dropped (#203). Alias expansion only
103
- # re-references the scalar/hash types already permitted, so it adds
104
- # no deserialization risk.
105
- data = YAML.safe_load(source, permitted_classes: [Symbol], aliases: true)
105
+ data = if format == :solid_queue
106
+ # Match Solid Queue's trusted Rails configuration loader: ERB
107
+ # executes with its real filename (including require_relative).
108
+ load_recurring_configuration(file_path, source)
109
+ else
110
+ # Sidekiq remains safe-loaded; aliases allow shared defaults
111
+ # without permitting additional deserialized classes (#203).
112
+ YAML.safe_load(source, permitted_classes: [Symbol], aliases: true)
113
+ end
106
114
 
107
115
  return [] unless data.is_a?(Hash) && data.any?
108
116
 
@@ -116,10 +124,23 @@ module Woods
116
124
  end
117
125
  end
118
126
 
127
+ def load_recurring_configuration(file_path, source)
128
+ if defined?(ActiveSupport::ConfigurationFile)
129
+ ActiveSupport::ConfigurationFile.parse(file_path)
130
+ else
131
+ # Rails 6.0: preserve the same filename-aware ERB evaluation while
132
+ # retaining the existing scalar/hash YAML policy.
133
+ require 'erb'
134
+ erb = ERB.new(source)
135
+ erb.filename = file_path
136
+ YAML.safe_load(erb.result, permitted_classes: [Symbol], aliases: true)
137
+ end
138
+ end
139
+
119
140
  # Detect and unwrap environment-nested YAML.
120
141
  #
121
- # If the top-level keys are environment names (production, development,
122
- # etc.), unwrap to the section for the environment being extracted,
142
+ # If the top level contains maps of tasks, unwrap to the section for
143
+ # the environment being extracted (including custom environment names),
123
144
  # falling back to the first section when the current environment has no
124
145
  # entry. Taking `values.first` unconditionally meant a file listing
125
146
  # `development:` before `production:` indexed the development schedule
@@ -128,9 +149,14 @@ module Woods
128
149
  # @param data [Hash] Parsed YAML data
129
150
  # @return [Hash] Unwrapped entries
130
151
  def unwrap_environment_nesting(data)
131
- return data unless data.keys.all? { |k| ENVIRONMENT_KEYS.include?(k.to_s) }
152
+ # Environment sections contain task maps; task entries contain scalar
153
+ # configuration values. Shape matters even for a task called production.
154
+ nested = data.values.all? do |section|
155
+ section.nil? || (section.is_a?(Hash) && section.values.all?(Hash))
156
+ end
157
+ return data unless nested
132
158
 
133
- data[current_environment] || data.values.first || {}
159
+ data.key?(current_environment) ? (data[current_environment] || {}) : (data.values.first || {})
134
160
  end
135
161
 
136
162
  # The environment name the extraction is running under, as a String.
@@ -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
 
@@ -106,7 +108,7 @@ module Woods
106
108
 
107
109
  unit
108
110
  rescue StandardError => e
109
- Rails.logger.error("Failed to extract serializer #{file_path}: #{e.message}")
111
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract serializer #{file_path}: #{e.message}")
110
112
  nil
111
113
  end
112
114
 
@@ -136,7 +138,7 @@ module Woods
136
138
 
137
139
  unit
138
140
  rescue StandardError => e
139
- Rails.logger.error("Failed to extract serializer #{klass.name}: #{e.message}")
141
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract serializer #{klass.name}: #{e.message}")
140
142
  nil
141
143
  end
142
144