woods 2.0.0.beta2 → 2.0.0.beta3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +262 -1
  3. data/CONTRIBUTING.md +173 -9
  4. data/README.md +7 -3
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +83 -4
  7. data/docs/AGENT_SETUP.md +82 -1
  8. data/docs/BACKEND_MATRIX.md +20 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +199 -14
  11. data/docs/CONSOLE_MCP_SETUP.md +35 -5
  12. data/docs/DOCKER_SETUP.md +21 -2
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +36 -5
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +117 -1
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +221 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +33 -18
  22. data/docs/NOTION_INTEGRATION.md +13 -0
  23. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  24. data/docs/PUBLISHED_INDEX.md +55 -0
  25. data/docs/README.md +7 -0
  26. data/docs/RETRIEVAL_GUIDE.md +253 -11
  27. data/docs/RUNTIME_TRACING.md +71 -0
  28. data/docs/SOURCE_FRESHNESS.md +143 -0
  29. data/docs/TROUBLESHOOTING.md +117 -5
  30. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  31. data/docs/UPGRADING_TO_2.md +44 -22
  32. data/docs/WATCH_DAEMON.md +259 -59
  33. data/exe/woods-agent-config +6 -0
  34. data/exe/woods-extract +5 -0
  35. data/exe/woods-hook-context +6 -0
  36. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  37. data/lib/tasks/woods.rake +47 -397
  38. data/lib/woods/agent_configuration/applier.rb +133 -0
  39. data/lib/woods/agent_configuration/cli.rb +101 -0
  40. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  41. data/lib/woods/agent_configuration/document.rb +105 -0
  42. data/lib/woods/agent_configuration/error.rb +7 -0
  43. data/lib/woods/agent_configuration/launcher.rb +75 -0
  44. data/lib/woods/agent_configuration/layout.rb +59 -0
  45. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  46. data/lib/woods/agent_configuration/plan.rb +98 -0
  47. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  48. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  49. data/lib/woods/agent_configuration/planner.rb +63 -0
  50. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  51. data/lib/woods/agent_configuration/preflight.rb +100 -0
  52. data/lib/woods/agent_configuration/recovery.rb +49 -0
  53. data/lib/woods/ast/node.rb +2 -0
  54. data/lib/woods/ast/parser.rb +38 -5
  55. data/lib/woods/builder.rb +21 -5
  56. data/lib/woods/cache/cache_middleware.rb +28 -7
  57. data/lib/woods/cache/cache_store.rb +4 -5
  58. data/lib/woods/change_set.rb +5 -4
  59. data/lib/woods/console/credential_index.rb +20 -2
  60. data/lib/woods/console/credential_scanner.rb +14 -14
  61. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  62. data/lib/woods/console/embedded_executor.rb +1 -1
  63. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  64. data/lib/woods/console/rack_middleware.rb +22 -13
  65. data/lib/woods/console/server.rb +18 -16
  66. data/lib/woods/dependency_graph.rb +65 -13
  67. data/lib/woods/embedding/corpus.rb +94 -0
  68. data/lib/woods/embedding/indexer.rb +90 -46
  69. data/lib/woods/embedding/openai.rb +17 -6
  70. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  71. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  72. data/lib/woods/export/typed_reader.rb +56 -0
  73. data/lib/woods/extractor.rb +232 -137
  74. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  75. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  76. data/lib/woods/extractors/caching_extractor.rb +3 -1
  77. data/lib/woods/extractors/concern_extractor.rb +64 -6
  78. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  79. data/lib/woods/extractors/controller_extractor.rb +13 -4
  80. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  81. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  82. data/lib/woods/extractors/engine_extractor.rb +3 -1
  83. data/lib/woods/extractors/event_extractor.rb +4 -2
  84. data/lib/woods/extractors/factory_extractor.rb +3 -1
  85. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  86. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  87. data/lib/woods/extractors/job_extractor.rb +6 -19
  88. data/lib/woods/extractors/lib_extractor.rb +3 -1
  89. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  90. data/lib/woods/extractors/manager_extractor.rb +3 -1
  91. data/lib/woods/extractors/method_parameters.rb +53 -0
  92. data/lib/woods/extractors/middleware_argument.rb +65 -0
  93. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  94. data/lib/woods/extractors/migration_extractor.rb +3 -1
  95. data/lib/woods/extractors/model_extractor.rb +39 -33
  96. data/lib/woods/extractors/package_extractor.rb +24 -4
  97. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  98. data/lib/woods/extractors/policy_extractor.rb +3 -1
  99. data/lib/woods/extractors/poro_extractor.rb +3 -1
  100. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  101. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  102. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  103. data/lib/woods/extractors/route_extractor.rb +3 -1
  104. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  105. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  106. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  107. data/lib/woods/extractors/service_extractor.rb +3 -1
  108. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  109. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  110. data/lib/woods/extractors/source_nesting.rb +1 -1
  111. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  112. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  113. data/lib/woods/extractors/validator_extractor.rb +3 -1
  114. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  116. data/lib/woods/gem_mapper.rb +2 -0
  117. data/lib/woods/git_history.rb +116 -0
  118. data/lib/woods/graph_analyzer.rb +35 -6
  119. data/lib/woods/hooks/context_cli.rb +54 -0
  120. data/lib/woods/hooks/context_event.rb +88 -0
  121. data/lib/woods/hooks/context_hint.rb +73 -0
  122. data/lib/woods/hooks/context_impact.rb +77 -0
  123. data/lib/woods/hooks/context_output.rb +47 -0
  124. data/lib/woods/hooks/context_state.rb +102 -0
  125. data/lib/woods/hooks/refresh.rb +79 -0
  126. data/lib/woods/hooks/rule_projection.rb +78 -0
  127. data/lib/woods/input_rules.rb +19 -0
  128. data/lib/woods/mcp/bearer_auth.rb +20 -12
  129. data/lib/woods/mcp/bootstrapper.rb +62 -0
  130. data/lib/woods/mcp/index_reader.rb +323 -160
  131. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  132. data/lib/woods/mcp/origin_guard.rb +17 -9
  133. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  134. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  135. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  136. data/lib/woods/mcp/search_results.rb +74 -0
  137. data/lib/woods/mcp/server.rb +158 -37
  138. data/lib/woods/mcp/tool_contract.rb +2 -0
  139. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  140. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  141. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  142. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  143. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  144. data/lib/woods/notion/exporter.rb +56 -17
  145. data/lib/woods/obsidian/destination_plan.rb +98 -0
  146. data/lib/woods/obsidian/name_mapper.rb +19 -3
  147. data/lib/woods/obsidian/note_builder.rb +19 -10
  148. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  149. data/lib/woods/operator/pipeline_guard.rb +18 -13
  150. data/lib/woods/path_dispatcher.rb +7 -1
  151. data/lib/woods/payload_store.rb +27 -26
  152. data/lib/woods/railtie.rb +3 -3
  153. data/lib/woods/railtie_support.rb +12 -12
  154. data/lib/woods/rake_helpers.rb +392 -0
  155. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  156. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  157. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  158. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  159. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  160. data/lib/woods/resilience/index_validator.rb +112 -23
  161. data/lib/woods/retrieval/context_assembler.rb +50 -15
  162. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  163. data/lib/woods/retrieval/lexical_index.rb +119 -0
  164. data/lib/woods/retrieval/ranker.rb +4 -2
  165. data/lib/woods/retrieval/scope.rb +108 -0
  166. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  167. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  168. data/lib/woods/retrieval/search_executor.rb +86 -27
  169. data/lib/woods/retrieval/source_evidence.rb +200 -0
  170. data/lib/woods/retriever.rb +98 -22
  171. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  172. data/lib/woods/session_tracer/middleware.rb +10 -12
  173. data/lib/woods/session_tracer/redis_store.rb +22 -6
  174. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  175. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  176. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  177. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  178. data/lib/woods/source_inputs/handoff.rb +102 -0
  179. data/lib/woods/source_inputs/launcher.rb +157 -0
  180. data/lib/woods/source_inputs/manifest.rb +124 -0
  181. data/lib/woods/source_inputs/private_key.rb +55 -0
  182. data/lib/woods/source_inputs/scanner.rb +171 -0
  183. data/lib/woods/source_inputs/scopes.rb +71 -0
  184. data/lib/woods/source_inputs/session.rb +214 -0
  185. data/lib/woods/source_inputs/status.rb +84 -0
  186. data/lib/woods/source_inputs/verifier.rb +107 -0
  187. data/lib/woods/storage/metadata_store.rb +25 -25
  188. data/lib/woods/storage/pgvector.rb +29 -8
  189. data/lib/woods/storage/qdrant.rb +17 -7
  190. data/lib/woods/storage/vector_store.rb +18 -6
  191. data/lib/woods/tasks.rb +3 -2
  192. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  193. data/lib/woods/unblocked/exporter.rb +59 -70
  194. data/lib/woods/version.rb +1 -1
  195. data/lib/woods/watch/boot_snapshot.rb +52 -0
  196. data/lib/woods/watch/daemon.rb +136 -28
  197. data/lib/woods/watch/listen_watcher.rb +4 -0
  198. data/lib/woods/watch/polling_watcher.rb +5 -1
  199. data/lib/woods/watch/status.rb +20 -15
  200. data/lib/woods/watch/tree_scan.rb +21 -13
  201. data/lib/woods/watch/watcher.rb +4 -1
  202. data/lib/woods.rb +50 -11
  203. data/plugin/.claude-plugin/plugin.json +1 -1
  204. data/plugin/hooks/adapters/normalize.jq +15 -0
  205. data/plugin/hooks/adapters/normalize.rb +63 -0
  206. data/plugin/hooks/hooks.json +20 -0
  207. data/plugin/hooks/woods-context.sh +50 -0
  208. data/plugin/hooks/woods-input-rules.sh +159 -0
  209. data/plugin/hooks/woods-opencode.mjs +65 -0
  210. data/plugin/hooks/woods-post-edit.sh +2 -225
  211. data/plugin/hooks/woods-refresh.sh +260 -0
  212. data/plugin/hooks/woods-session-start.sh +47 -55
  213. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  214. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  215. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  216. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  217. data/plugin/skills/woods-setup/SKILL.md +107 -6
  218. metadata +84 -5
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../source_inputs/consumer_errors'
4
+
3
5
  require_relative 'shared_utility_methods'
4
6
  require_relative 'shared_dependency_scanner'
5
7
  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,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'source_nesting'
4
+ require_relative 'method_parameters'
4
5
 
5
6
  module Woods
6
7
  module Extractors
@@ -221,6 +222,29 @@ module Woods
221
222
  actions.to_a
222
223
  end
223
224
 
225
+ # Rails 6 exposes a numeric Proc identity through #filter, and the
226
+ # callable through #raw_filter. Newer Rails exposes the callable directly.
227
+ def callback_filter(callback)
228
+ stable_filter(callback.respond_to?(:raw_filter) ? callback.raw_filter : callback.filter)
229
+ end
230
+
231
+ # Proc#to_s embeds a process-local address. Model and controller filters
232
+ # share source-site labels, so metadata and derived chunks agree across
233
+ # boots. Preserve symbols and other application-defined callback values.
234
+ def stable_filter(filter)
235
+ return filter unless filter.is_a?(Proc)
236
+
237
+ location = filter.source_location
238
+ site = if location
239
+ path, line = location
240
+ "#{path.delete_prefix("#{Rails.root}/")}:#{line}"
241
+ else
242
+ 'native'
243
+ end
244
+ "#<#{filter.lambda? ? 'lambda' : 'Proc'} #{site}>"
245
+ end
246
+ private :callback_filter, :stable_filter
247
+
224
248
  # Human-readable label for a non-ActionFilter condition.
225
249
  #
226
250
  # @param condition [Object] A proc, symbol, or other condition
@@ -292,7 +316,7 @@ module Woods
292
316
  source.scan(/def\s+self\.(\w+[?!=]?)/).flatten
293
317
  end
294
318
 
295
- # Extract initialize parameters from source code via regex.
319
+ # Extract initialize parameters from source syntax without evaluating defaults.
296
320
  #
297
321
  # Parses the parameter list of the initialize method to determine
298
322
  # parameter names, defaults, and whether they are keyword arguments.
@@ -304,21 +328,9 @@ module Woods
304
328
  # @param source [String] Ruby source code
305
329
  # @return [Array<Hash>] Parameter info hashes with :name, :has_default, :keyword
306
330
  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
- }
331
+ MethodParameters.extract(source, :initialize).map do |parameter|
332
+ parameter.slice(:name, :has_default, :keyword)
319
333
  end
320
-
321
- params
322
334
  end
323
335
  end
324
336
  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.
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'timeout'
5
+
6
+ module Woods
7
+ module Hooks
8
+ # The plugin's independent process-group deadline also includes bundle/Ruby
9
+ # startup. This inner deadline keeps ordinary direct calls short as well.
10
+ class ContextCLI
11
+ MAX_INPUT_BYTES = 1_048_576
12
+ HELP = 'Usage: woods-hook-context SessionStart|PostToolUse < claude-event.json; ' \
13
+ 'opt in with WOODS_HOOK_CONTEXT_ENABLED=1. Prefer the bounded plugin hook entry point.'
14
+
15
+ def self.run(kind, input: $stdin, output: $stdout)
16
+ return output.puts(HELP) || 0 if kind == '--help'
17
+
18
+ return 0 if ENV['WOODS_HOOKS_DISABLED'] == '1' || ENV['WOODS_HOOK_CONTEXT_ENABLED'] != '1'
19
+ return 0 unless %w[SessionStart PostToolUse].include?(kind)
20
+
21
+ Timeout.timeout(0.65) do
22
+ require 'woods'
23
+ require 'woods/hooks/context_hint'
24
+ require 'woods/hooks/context_state'
25
+ emit(kind, input, output)
26
+ end
27
+ 0
28
+ rescue Timeout::Error, JSON::ParserError, ArgumentError, KeyError, TypeError, SystemCallError, IOError
29
+ # Missing/degraded/slow inputs are optional context, not permission to
30
+ # run extraction, read application data, or acknowledge queued edits.
31
+ 0
32
+ end
33
+
34
+ def self.emit(kind, input, output)
35
+ bytes = input.read(MAX_INPUT_BYTES + 1)
36
+ return if bytes.bytesize > MAX_INPUT_BYTES
37
+
38
+ event = JSON.parse(bytes)
39
+ return unless event.is_a?(Hash) && event['hook_event_name'] == kind
40
+
41
+ root = ENV['WOODS_HOOK_CONTEXT_ROOT'] || event.fetch('cwd')
42
+ directory = File.expand_path(ENV.fetch('WOODS_OUTPUT', 'tmp/woods'), root)
43
+ result = ContextHint.new(event: event, output_dir: directory, root: root).call
44
+ return unless result
45
+
46
+ ContextState.new(directory).emit(result) do
47
+ output.write(ContextOutput.new(kind).encode(result.fetch(:context)))
48
+ output.flush
49
+ end
50
+ end
51
+ private_class_method :emit
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'woods/input_rules'
5
+
6
+ module Woods
7
+ module Hooks
8
+ # Claude context events are deliberately separate from refresh queue records.
9
+ class ContextEvent
10
+ MAX_FILE_BYTES = 1_048_576
11
+ attr_reader :root, :path, :kind, :session
12
+
13
+ def initialize(input, root: nil)
14
+ validate_root!(input)
15
+
16
+ @logical_root = File.expand_path(input['cwd'])
17
+ @root = File.realpath(root || @logical_root)
18
+ @kind = input['hook_event_name']
19
+ @session = input['session_id'] if valid_string?(input['session_id'], 256)
20
+ return if kind == 'SessionStart'
21
+
22
+ raise ArgumentError unless kind == 'PostToolUse' && %w[Edit Write MultiEdit].include?(input['tool_name'])
23
+
24
+ @path = relative_path(input.fetch('tool_input').fetch('file_path'))
25
+ end
26
+
27
+ def eligible?
28
+ return true if kind == 'SessionStart'
29
+
30
+ require 'woods/extractor' unless defined?(Woods::Extractor::EXTRACTORS)
31
+ InputRules.new.action(path) != :ignore
32
+ end
33
+
34
+ def fingerprint
35
+ return 'orientation' if kind == 'SessionStart'
36
+
37
+ flags = File::RDONLY | File::NONBLOCK
38
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
39
+ File.open(File.join(root, path), flags) do |file|
40
+ return nil unless file.stat.file? && file.stat.size <= MAX_FILE_BYTES
41
+
42
+ bytes = file.read(MAX_FILE_BYTES + 1)
43
+ return nil if bytes.bytesize > MAX_FILE_BYTES
44
+
45
+ Digest::SHA256.hexdigest(bytes)
46
+ end
47
+ rescue Errno::ENOENT
48
+ 'missing'
49
+ rescue SystemCallError, IOError
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ def validate_root!(input)
56
+ raise ArgumentError unless input.is_a?(Hash) && valid_string?(input['cwd']) && input['cwd'].start_with?('/')
57
+ end
58
+
59
+ def valid_string?(value, limit = 4096)
60
+ value.is_a?(String) && !value.empty? && value.bytesize <= limit &&
61
+ value.valid_encoding? && !value.include?("\0")
62
+ end
63
+
64
+ def relative_path(value)
65
+ raise ArgumentError unless valid_string?(value)
66
+
67
+ prefix = [@logical_root, root].find { |base| value.start_with?("#{base}/") }
68
+ relative = prefix ? value.delete_prefix("#{prefix}/") : value
69
+ validate_parts!(relative)
70
+ validate_containment!(relative)
71
+ relative
72
+ end
73
+
74
+ def validate_parts!(relative)
75
+ parts = relative.split('/', -1)
76
+ raise ArgumentError if relative.start_with?('/') || parts.size > 64
77
+ raise ArgumentError if parts.any? { |part| ['', '.', '..'].include?(part) }
78
+ end
79
+
80
+ def validate_containment!(relative)
81
+ candidate = File.join(root, relative)
82
+ candidate = File.dirname(candidate) until File.exist?(candidate) || File.symlink?(candidate)
83
+ resolved = File.realpath(candidate)
84
+ raise ArgumentError unless resolved == root || resolved.start_with?("#{root}/")
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'woods/mcp/index_reader'
4
+ require_relative 'context_event'
5
+ require_relative 'context_output'
6
+ require_relative 'context_impact'
7
+
8
+ module Woods
9
+ module Hooks
10
+ # A bounded snapshot hint. The outer hook process owns the end-to-end deadline.
11
+ class ContextHint
12
+ MAX_ARTIFACT_BYTES = 16 * 1024 * 1024
13
+
14
+ def initialize(event:, output_dir:, root: nil)
15
+ @event = ContextEvent.new(event, root: root)
16
+ @output = File.expand_path(output_dir, @event.root)
17
+ @renderer = ContextOutput.new(@event.kind)
18
+ rescue ArgumentError, KeyError, TypeError, SystemCallError
19
+ @event = nil
20
+ end
21
+
22
+ def call
23
+ return nil unless @event&.eligible?
24
+
25
+ @reader = MCP::IndexReader.new(@output)
26
+ @reader.with_pinned_generation do
27
+ @generation = @reader.generation_identity
28
+ raise ArgumentError if @reader.payload_dir.to_s == @output
29
+
30
+ check_artifacts!
31
+ freshness = source_freshness
32
+ text = if @event.kind == 'SessionStart'
33
+ orientation(freshness)
34
+ else
35
+ ContextImpact.new(@reader, @renderer).call(@event.path, @generation.first, freshness)
36
+ end
37
+ result(text)
38
+ end
39
+ rescue ArgumentError, KeyError, TypeError, NoMethodError, JSON::ParserError, SystemCallError, IOError
40
+ { context: ContextOutput::UNAVAILABLE, identity: nil, session: nil }
41
+ end
42
+
43
+ private
44
+
45
+ def check_artifacts!
46
+ %w[manifest.json dependency_graph.json source_inputs.json].each do |name|
47
+ file = @reader.payload_dir.join(name)
48
+ next if name == 'source_inputs.json' && !file.exist?
49
+
50
+ raise ArgumentError unless file.file? && file.size <= MAX_ARTIFACT_BYTES
51
+ end
52
+ end
53
+
54
+ def source_freshness
55
+ SourceInputs::Status.new(output_dir: @output, root: @event.root, payload_dir: @reader.payload_dir,
56
+ generation: @reader.loaded_generation).call.fetch('state')
57
+ end
58
+
59
+ def orientation(freshness)
60
+ @renderer.context("Woods index generation #{@generation.first}; source freshness: #{freshness}. " \
61
+ 'Use woods_status, search and typed lookup; dependents explain:true suggests checks.')
62
+ end
63
+
64
+ def result(text)
65
+ fingerprint = @event.fingerprint
66
+ identity = if fingerprint && @event.session
67
+ Digest::SHA256.hexdigest(JSON.generate([@event.root, @event.path, @generation, fingerprint, text]))
68
+ end
69
+ { context: text, identity: identity, session: @event.session, root: @event.root }
70
+ end
71
+ end
72
+ end
73
+ end