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_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
 
@@ -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'
@@ -83,7 +85,7 @@ module Woods
83
85
 
84
86
  unit
85
87
  rescue StandardError => e
86
- Rails.logger.error("Failed to extract service #{file_path}: #{e.message}")
88
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract service #{file_path}: #{e.message}")
87
89
  nil
88
90
  end
89
91
 
@@ -204,8 +204,8 @@ module Woods
204
204
  # Match _path/_url route helpers anywhere in source.
205
205
  # This intentionally matches all usages (assignments, string interpolation, etc.)
206
206
  # not just link_to/redirect_to calls — any reference to a route helper indicates
207
- # a dependency on that controller. False positives from non-route _path/_url
208
- # suffixes (file_path, base_url, etc.) are filtered by RouteHelperResolver::IGNORED_HELPER_PREFIXES.
207
+ # a dependency on that controller. RouteHelperResolver accepts only names
208
+ # present in the live controller/action route map, excluding unknown helpers.
209
209
  # Requires the including class to also include RouteHelperResolver
210
210
  # and call build_route_helper_map in its initializer.
211
211
  ROUTE_HELPER_PATTERN = /\b(\w+)_(path|url)\b/
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'declared_parent'
4
+
3
5
  require_relative 'source_nesting'
6
+ require_relative 'method_parameters'
4
7
 
5
8
  module Woods
6
9
  module Extractors
@@ -124,10 +127,10 @@ module Woods
124
127
  # Extract the parent class name from a class definition.
125
128
  #
126
129
  # @param source [String] Ruby source code
127
- # @return [String, nil] Parent class name or nil
128
- def extract_parent_class(source)
129
- match = source.match(/^\s*class\s+[\w:]+\s*<\s*([\w:]+)/)
130
- match ? match[1] : nil
130
+ # @param class_name [String] Already selected extraction identity
131
+ # @return [String, nil] Explicit parent of that declaration, or nil
132
+ def extract_parent_class(source, class_name)
133
+ DeclaredParent.call(source, class_name)
131
134
  end
132
135
 
133
136
  # Count non-blank, non-comment lines of code.
@@ -221,6 +224,44 @@ module Woods
221
224
  actions.to_a
222
225
  end
223
226
 
227
+ # Rails 6 exposes a numeric Proc identity through #filter, and the
228
+ # callable through #raw_filter. Newer Rails exposes the callable directly.
229
+ def callback_filter(callback)
230
+ stable_filter(callback.respond_to?(:raw_filter) ? callback.raw_filter : callback.filter)
231
+ end
232
+
233
+ # Proc#to_s embeds a process-local address. Model and controller filters
234
+ # share source-site labels, so metadata and derived chunks agree across
235
+ # boots. Preserve symbols and other application-defined callback values.
236
+ def stable_filter(filter)
237
+ return filter unless filter.is_a?(Proc)
238
+
239
+ location = filter.source_location
240
+ site = if location
241
+ path, line = location
242
+ "#{path.delete_prefix("#{Rails.root}/")}:#{line}"
243
+ else
244
+ 'native'
245
+ end
246
+ "#<#{filter.lambda? ? 'lambda' : 'Proc'} #{site}>"
247
+ end
248
+
249
+ # Preserve application labels; strip addresses only from Ruby's actual
250
+ # default representation. Nested identities occur for anonymous classes.
251
+ # These are descriptive labels, not serialization of callback object state.
252
+ def callback_filter_label(callback)
253
+ filter = callback_filter(callback)
254
+ label = filter.to_s
255
+ return label unless label.start_with?('#<')
256
+
257
+ owner = filter.is_a?(Module) ? Module : Kernel
258
+ return label unless label == owner.instance_method(:to_s).bind(filter).call
259
+
260
+ label.gsub(/:0x[0-9a-f]+(?=>)/i, '')
261
+ end
262
+
263
+ private :callback_filter, :stable_filter, :callback_filter_label
264
+
224
265
  # Human-readable label for a non-ActionFilter condition.
225
266
  #
226
267
  # @param condition [Object] A proc, symbol, or other condition
@@ -292,7 +333,7 @@ module Woods
292
333
  source.scan(/def\s+self\.(\w+[?!=]?)/).flatten
293
334
  end
294
335
 
295
- # Extract initialize parameters from source code via regex.
336
+ # Extract initialize parameters from source syntax without evaluating defaults.
296
337
  #
297
338
  # Parses the parameter list of the initialize method to determine
298
339
  # parameter names, defaults, and whether they are keyword arguments.
@@ -304,21 +345,9 @@ module Woods
304
345
  # @param source [String] Ruby source code
305
346
  # @return [Array<Hash>] Parameter info hashes with :name, :has_default, :keyword
306
347
  def extract_initialize_params(source)
307
- init_match = source.match(/def\s+initialize\s*\((.*?)\)/m)
308
- return [] unless init_match
309
-
310
- params_str = init_match[1]
311
- params = []
312
-
313
- params_str.scan(/(\w+)(?::\s*([^,\n]+))?/) do |name, default|
314
- params << {
315
- name: name,
316
- has_default: !default.nil?,
317
- keyword: params_str.include?("#{name}:")
318
- }
348
+ MethodParameters.extract(source, :initialize).map do |parameter|
349
+ parameter.slice(:name, :has_default, :keyword)
319
350
  end
320
-
321
- params
322
351
  end
323
352
  end
324
353
  end
@@ -245,7 +245,7 @@ module Woods
245
245
  # The constant path the active main loader expects for +file_path+,
246
246
  # asked through the loader's own API so its inflector, namespace
247
247
  # collapse, and ignore rules decide (a custom `api => API` inflection
248
- # must win over the local camelizer). Available on Zeitwerk 2.6.4+;
248
+ # must win over the local camelizer). Available on Zeitwerk 2.6.9+;
249
249
  # nil — unmanaged — when the loader's API is older, declines the
250
250
  # file, or cannot derive a name. A decline is authoritative: the
251
251
  # local camelizer must not rescue it.
@@ -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'
@@ -58,7 +60,7 @@ module Woods
58
60
  units.concat(extract_state_machines_units(source, class_name, file_path))
59
61
  units
60
62
  rescue StandardError => e
61
- Rails.logger.error("Failed to extract state machines from #{file_path}: #{e.message}")
63
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract state machines from #{file_path}: #{e.message}")
62
64
  []
63
65
  end
64
66
 
@@ -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
 
@@ -70,7 +72,7 @@ module Woods
70
72
 
71
73
  unit
72
74
  rescue StandardError => e
73
- Rails.logger.error("Failed to extract test mapping from #{file_path}: #{e.message}")
75
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract test mapping from #{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 'shared_utility_methods'
4
6
  require_relative 'shared_dependency_scanner'
5
7
 
@@ -70,7 +72,7 @@ module Woods
70
72
 
71
73
  unit
72
74
  rescue StandardError => e
73
- Rails.logger.error("Failed to extract validator #{file_path}: #{e.message}")
75
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract validator #{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 'component_discovery'
4
6
  require_relative 'shared_utility_methods'
5
7
  require_relative 'shared_dependency_scanner'
@@ -95,7 +97,7 @@ module Woods
95
97
 
96
98
  unit
97
99
  rescue StandardError => e
98
- Rails.logger.error("Failed to extract view component #{component.name}: #{e.message}")
100
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract view component #{component.name}: #{e.message}")
99
101
  nil
100
102
  end
101
103
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require 'set'
4
6
  require_relative 'route_helper_resolver'
5
7
  require_relative 'view_engines/base'
@@ -95,7 +97,7 @@ module Woods
95
97
 
96
98
  unit
97
99
  rescue StandardError => e
98
- Rails.logger.error("Failed to extract view template #{file_path}: #{e.message}")
100
+ SourceInputs::ConsumerErrors.log(self, "Failed to extract view template #{file_path}: #{e.message}")
99
101
  nil
100
102
  end
101
103
 
@@ -7,6 +7,7 @@ require 'pathname'
7
7
  require 'time'
8
8
 
9
9
  require_relative 'atomic_file'
10
+ require_relative 'version'
10
11
  require_relative 'coordination/pipeline_lock'
11
12
  require_relative 'dependency_graph'
12
13
  require_relative 'extracted_unit'
@@ -267,6 +268,7 @@ module Woods
267
268
  end
268
269
  manifest = {
269
270
  extracted_at: Time.now.utc.iso8601, rails_version: nil, ruby_version: RUBY_VERSION,
271
+ woods_version: Woods::VERSION,
270
272
  counts: counts, total_units: @units.size, total_chunks: 0, git_sha: nil, git_branch: nil,
271
273
  provenance: GemMapper::PROVENANCE.merge(source_checksum: @checksum),
272
274
  graph_nodes: @graph.to_h.dig(:stats, :node_count), graph_edges: @graph.to_h.dig(:stats, :edge_count)
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'time'
5
+ require_relative 'git_command'
6
+
7
+ module Woods
8
+ # Stream HEAD-reachable touched-path events without pathspec simplification.
9
+ # Raw NUL records keep filenames separate from commit fields; only requested
10
+ # paths are retained. Merge diffs compare against the first parent, while
11
+ # traversal still visits every parent. Requires Git 2.31 or newer.
12
+ class GitHistory
13
+ class ReadError < StandardError; end
14
+
15
+ FORMAT = '%x00%H%x00%an%x00%cI%x00%s%x00'
16
+ private_constant :FORMAT
17
+
18
+ def initialize(root:, logger:)
19
+ @root = root
20
+ @logger = logger
21
+ end
22
+
23
+ # @return [Hash, nil] raw per-path aggregates, or nil on any incomplete read
24
+ def read(paths, recent_after:)
25
+ requested = paths.to_h { |path| [repository_prefix + path.b, path] }
26
+ result = paths.to_h { |path| [path, {}] }
27
+ stream { |stdout| parse(stdout, requested, result, recent_after) }
28
+ result
29
+ rescue StandardError => e
30
+ @logger.warn('[Woods] Git enrichment omitted: history could not be read completely. ' \
31
+ 'Git 2.31 or newer is required; check the application repository and git installation. ' \
32
+ "(#{e.class})")
33
+ nil
34
+ end
35
+
36
+ private
37
+
38
+ def repository_prefix
39
+ return @repository_prefix if defined?(@repository_prefix)
40
+
41
+ prefix, _error, status = Open3.capture3(*GitCommand.argv(@root, 'rev-parse', '--show-prefix'))
42
+ raise ReadError, 'cannot resolve application path in repository' unless status.success?
43
+
44
+ @repository_prefix = prefix.delete_suffix("\n").b
45
+ end
46
+
47
+ def stream
48
+ argv = GitCommand.argv(@root, '-c', 'log.showSignature=false', 'log', 'HEAD',
49
+ '--since=365 days ago', '--root', '--raw', '-z', '--no-renames',
50
+ '--no-relative', '--no-ext-diff', '--encoding=UTF-8',
51
+ '--diff-merges=first-parent', "--format=#{FORMAT}")
52
+ Open3.popen3(*argv) do |stdin, stdout, stderr, wait|
53
+ stdin.close
54
+ stdout.binmode
55
+ # Drain stderr without retaining a potentially unbounded diagnostic.
56
+ drain = Thread.new { stderr.read(16_384) until stderr.eof? }
57
+ begin
58
+ yield stdout
59
+ raise ReadError, "history command exited #{wait.value.exitstatus}" unless wait.value.success?
60
+ ensure
61
+ stdout.close
62
+ drain.join
63
+ end
64
+ end
65
+ end
66
+
67
+ def token(io)
68
+ value = io.gets("\0")
69
+ return nil unless value
70
+ raise ReadError, 'unterminated history record' unless value.end_with?("\0")
71
+
72
+ value.delete_suffix("\0")
73
+ end
74
+
75
+ def parse(io, requested, result, recent_after)
76
+ commit = nil
77
+ while (field = token(io))
78
+ next if field.empty?
79
+
80
+ if field.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/)
81
+ commit = read_commit(io, field, recent_after)
82
+ elsif field.match?(/\A\n?:[0-7]{6} [0-7]{6} [0-9a-f]+ [0-9a-f]+ [A-Z]\z/)
83
+ record_path(io, requested, result, commit)
84
+ else
85
+ raise ReadError, 'unexpected history record'
86
+ end
87
+ end
88
+ end
89
+
90
+ def read_commit(io, sha, recent_after)
91
+ author, date, message = Array.new(3) { token(io) }
92
+ raise ReadError, 'incomplete commit header' unless author && date && message
93
+
94
+ { sha: sha, author: author.force_encoding(Encoding::UTF_8),
95
+ date: date, message: message.force_encoding(Encoding::UTF_8), recent: Time.iso8601(date) > recent_after }
96
+ end
97
+
98
+ def record_path(io, requested, result, commit)
99
+ path = token(io)
100
+ raise ReadError, 'incomplete path record' unless commit && path
101
+
102
+ relative = requested[path]
103
+ record(result[relative], commit) if relative
104
+ end
105
+
106
+ def record(data, commit)
107
+ data[:last_modified] ||= commit[:date]
108
+ data[:last_author] ||= commit[:author]
109
+ data[:commit_count] = data.fetch(:commit_count, 0) + 1
110
+ data[:recent_count] = data.fetch(:recent_count, 0) + (commit[:recent] ? 1 : 0)
111
+ commits = (data[:commits] ||= [])
112
+ commits << commit if commits.size < 5
113
+ (data[:contributors] ||= Hash.new(0))[commit[:author]] += 1
114
+ end
115
+ end
116
+ end
@@ -71,12 +71,14 @@ module Woods
71
71
 
72
72
  # @param dependency_graph [DependencyGraph] The graph to analyze
73
73
  # @param volatile_ratio [Numeric] see {#volatile_dependencies}
74
+ # @param volatile_limit_per_target [Integer, nil] maximum edges per typed dependency, before the global limit
74
75
  # @param cycle_limit [Integer, nil] see {DEFAULT_CYCLE_LIMIT}
75
76
  # @param cycle_max_length [Integer, nil] see {DEFAULT_CYCLE_MAX_LENGTH}
76
- def initialize(dependency_graph, volatile_ratio: DEFAULT_VOLATILE_RATIO,
77
+ def initialize(dependency_graph, volatile_ratio: DEFAULT_VOLATILE_RATIO, volatile_limit_per_target: nil,
77
78
  cycle_limit: DEFAULT_CYCLE_LIMIT, cycle_max_length: DEFAULT_CYCLE_MAX_LENGTH)
78
79
  @graph = dependency_graph
79
80
  @volatile_ratio = volatile_ratio.to_f
81
+ @volatile_limit_per_target = volatile_limit_per_target
80
82
  @cycle_limit = cycle_limit
81
83
  @cycle_max_length = cycle_max_length
82
84
  @cycle_limit_reached = false
@@ -296,15 +298,16 @@ module Woods
296
298
  # dependencies with fewer than {VOLATILE_MIN_COMMITS} commits or a
297
299
  # `new` change frequency are skipped. Ranked by the dependency's
298
300
  # PageRank so the most-depended-on volatile unit comes first. `limit`
299
- # caps what this method hands back; {#analyze}'s
301
+ # caps what this method hands back after the optional per-target cap; {#analyze}'s
300
302
  # `stats[:volatile_dependency_count]` reports the full qualifying count
301
303
  # regardless of `limit`, and `stats[:volatile_dependencies_limit]` reports
302
- # the cap, so a reader of the array alone can tell it was truncated.
304
+ # the global cap. An enabled per-target cap also publishes its setting
305
+ # and the final reported count; the full qualifying count is unchanged.
303
306
  #
304
307
  # @param limit [Integer] maximum entries
305
308
  # @return [Array<Hash>] `{ from:, from_type:, to:, to_type:, via:, from_commits:, to_commits:, ratio:, pagerank: }`
306
309
  def volatile_dependencies(limit: DEFAULT_VOLATILE_LIMIT)
307
- all_volatile_dependencies.first(limit)
310
+ capped_volatile_dependencies.first(limit)
308
311
  end
309
312
 
310
313
  # Edges that cross a Packwerk package boundary the source package never
@@ -397,8 +400,7 @@ module Woods
397
400
  cycle_count: computed_cycles.size,
398
401
  cycle_limit_reached: cycle_limit_reached?,
399
402
  cross_database_edge_count: computed_cross_database.size,
400
- volatile_dependency_count: all_volatile_dependencies.size,
401
- volatile_dependencies_limit: DEFAULT_VOLATILE_LIMIT,
403
+ **volatile_dependency_stats(computed_volatile),
402
404
  undeclared_package_edge_count: computed_undeclared.size
403
405
  }
404
406
  }
@@ -722,6 +724,33 @@ module Woods
722
724
  @all_volatile_dependencies ||= compute_volatile_dependencies
723
725
  end
724
726
 
727
+ # Apply the optional hub cap after ranking, before the overall report
728
+ # limit. Keep individual relationship edges; two `via` values consume two
729
+ # slots. Type is part of dependency identity, never just the display name.
730
+ def capped_volatile_dependencies
731
+ return all_volatile_dependencies unless @volatile_limit_per_target
732
+
733
+ counts = Hash.new(0)
734
+ all_volatile_dependencies.select do |entry|
735
+ target = [entry[:to_type], entry[:to]]
736
+ counts[target] += 1
737
+ counts[target] <= @volatile_limit_per_target
738
+ end
739
+ end
740
+
741
+ # Preserve the default artifact exactly; expose extra selection metadata
742
+ # only when the per-target cap was requested. The full count always means
743
+ # all ratio-qualified edges, before either cap.
744
+ def volatile_dependency_stats(reported)
745
+ stats = { volatile_dependency_count: all_volatile_dependencies.size,
746
+ volatile_dependencies_limit: DEFAULT_VOLATILE_LIMIT }
747
+ if @volatile_limit_per_target
748
+ stats[:volatile_dependencies_limit_per_target] = @volatile_limit_per_target
749
+ stats[:volatile_dependency_reported_count] = reported.size
750
+ end
751
+ stats
752
+ end
753
+
725
754
  # Short-circuits to `[]`, skipping the PageRank computation entirely,
726
755
  # when no node carries an Integer `commit_count` (git enrichment never
727
756
  # ran): there is nothing to rank.