woods 2.0.0.beta1 → 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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +400 -1
  3. data/CONTRIBUTING.md +224 -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 +233 -13
  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 +158 -2
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +15 -7
  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 +71 -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/atomic_file.rb +133 -3
  56. data/lib/woods/builder.rb +21 -5
  57. data/lib/woods/cache/cache_middleware.rb +28 -7
  58. data/lib/woods/cache/cache_store.rb +4 -5
  59. data/lib/woods/change_set.rb +5 -4
  60. data/lib/woods/console/credential_index.rb +20 -2
  61. data/lib/woods/console/credential_scanner.rb +14 -14
  62. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  63. data/lib/woods/console/embedded_executor.rb +1 -1
  64. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  65. data/lib/woods/console/rack_middleware.rb +22 -13
  66. data/lib/woods/console/server.rb +18 -16
  67. data/lib/woods/dependency_graph.rb +65 -13
  68. data/lib/woods/embedding/corpus.rb +94 -0
  69. data/lib/woods/embedding/indexer.rb +90 -46
  70. data/lib/woods/embedding/openai.rb +17 -6
  71. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  72. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  73. data/lib/woods/export/typed_reader.rb +56 -0
  74. data/lib/woods/extractor.rb +557 -228
  75. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  76. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  77. data/lib/woods/extractors/caching_extractor.rb +3 -1
  78. data/lib/woods/extractors/concern_extractor.rb +64 -6
  79. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  80. data/lib/woods/extractors/controller_extractor.rb +13 -4
  81. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  82. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  83. data/lib/woods/extractors/engine_extractor.rb +3 -1
  84. data/lib/woods/extractors/event_extractor.rb +4 -2
  85. data/lib/woods/extractors/factory_extractor.rb +3 -1
  86. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  87. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  88. data/lib/woods/extractors/job_extractor.rb +6 -19
  89. data/lib/woods/extractors/lib_extractor.rb +3 -1
  90. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  91. data/lib/woods/extractors/manager_extractor.rb +3 -1
  92. data/lib/woods/extractors/method_parameters.rb +53 -0
  93. data/lib/woods/extractors/middleware_argument.rb +65 -0
  94. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  95. data/lib/woods/extractors/migration_extractor.rb +3 -1
  96. data/lib/woods/extractors/model_extractor.rb +39 -33
  97. data/lib/woods/extractors/package_extractor.rb +24 -4
  98. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  99. data/lib/woods/extractors/policy_extractor.rb +3 -1
  100. data/lib/woods/extractors/poro_extractor.rb +3 -1
  101. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  102. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  103. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  104. data/lib/woods/extractors/route_extractor.rb +3 -1
  105. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  106. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  107. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  108. data/lib/woods/extractors/service_extractor.rb +3 -1
  109. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  110. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  111. data/lib/woods/extractors/source_nesting.rb +1 -1
  112. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  113. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  114. data/lib/woods/extractors/validator_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  116. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  117. data/lib/woods/flow_assembler.rb +87 -8
  118. data/lib/woods/flow_precomputer.rb +44 -7
  119. data/lib/woods/gem_mapper.rb +2 -0
  120. data/lib/woods/git_history.rb +116 -0
  121. data/lib/woods/graph_analyzer.rb +195 -63
  122. data/lib/woods/hooks/context_cli.rb +54 -0
  123. data/lib/woods/hooks/context_event.rb +88 -0
  124. data/lib/woods/hooks/context_hint.rb +73 -0
  125. data/lib/woods/hooks/context_impact.rb +77 -0
  126. data/lib/woods/hooks/context_output.rb +47 -0
  127. data/lib/woods/hooks/context_state.rb +102 -0
  128. data/lib/woods/hooks/refresh.rb +79 -0
  129. data/lib/woods/hooks/rule_projection.rb +78 -0
  130. data/lib/woods/input_rules.rb +19 -0
  131. data/lib/woods/mcp/bearer_auth.rb +20 -12
  132. data/lib/woods/mcp/bootstrapper.rb +62 -0
  133. data/lib/woods/mcp/index_reader.rb +323 -160
  134. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  135. data/lib/woods/mcp/origin_guard.rb +17 -9
  136. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  137. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  138. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  139. data/lib/woods/mcp/search_results.rb +74 -0
  140. data/lib/woods/mcp/server.rb +158 -37
  141. data/lib/woods/mcp/tool_contract.rb +2 -0
  142. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  143. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  144. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  145. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  146. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  147. data/lib/woods/notion/exporter.rb +56 -17
  148. data/lib/woods/obsidian/destination_plan.rb +98 -0
  149. data/lib/woods/obsidian/name_mapper.rb +19 -3
  150. data/lib/woods/obsidian/note_builder.rb +19 -10
  151. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  152. data/lib/woods/operator/pipeline_guard.rb +18 -13
  153. data/lib/woods/path_dispatcher.rb +7 -1
  154. data/lib/woods/payload_store.rb +29 -15
  155. data/lib/woods/railtie.rb +3 -3
  156. data/lib/woods/railtie_support.rb +12 -12
  157. data/lib/woods/rake_helpers.rb +392 -0
  158. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  159. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  160. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  161. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  162. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  163. data/lib/woods/resilience/index_validator.rb +112 -23
  164. data/lib/woods/retrieval/context_assembler.rb +50 -15
  165. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  166. data/lib/woods/retrieval/lexical_index.rb +119 -0
  167. data/lib/woods/retrieval/ranker.rb +4 -2
  168. data/lib/woods/retrieval/scope.rb +108 -0
  169. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  170. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  171. data/lib/woods/retrieval/search_executor.rb +86 -27
  172. data/lib/woods/retrieval/source_evidence.rb +200 -0
  173. data/lib/woods/retriever.rb +98 -22
  174. data/lib/woods/ruby_analyzer/trace_enricher.rb +80 -38
  175. data/lib/woods/session_tracer/middleware.rb +10 -12
  176. data/lib/woods/session_tracer/redis_store.rb +22 -6
  177. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  178. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  179. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  180. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  181. data/lib/woods/source_inputs/handoff.rb +102 -0
  182. data/lib/woods/source_inputs/launcher.rb +157 -0
  183. data/lib/woods/source_inputs/manifest.rb +124 -0
  184. data/lib/woods/source_inputs/private_key.rb +55 -0
  185. data/lib/woods/source_inputs/scanner.rb +171 -0
  186. data/lib/woods/source_inputs/scopes.rb +71 -0
  187. data/lib/woods/source_inputs/session.rb +214 -0
  188. data/lib/woods/source_inputs/status.rb +84 -0
  189. data/lib/woods/source_inputs/verifier.rb +107 -0
  190. data/lib/woods/storage/metadata_store.rb +25 -25
  191. data/lib/woods/storage/pgvector.rb +29 -8
  192. data/lib/woods/storage/qdrant.rb +17 -7
  193. data/lib/woods/storage/vector_store.rb +18 -6
  194. data/lib/woods/tasks.rb +3 -2
  195. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  196. data/lib/woods/unblocked/exporter.rb +59 -70
  197. data/lib/woods/version.rb +1 -1
  198. data/lib/woods/watch/boot_snapshot.rb +52 -0
  199. data/lib/woods/watch/daemon.rb +136 -28
  200. data/lib/woods/watch/listen_watcher.rb +4 -0
  201. data/lib/woods/watch/polling_watcher.rb +5 -1
  202. data/lib/woods/watch/status.rb +20 -15
  203. data/lib/woods/watch/tree_scan.rb +21 -13
  204. data/lib/woods/watch/watcher.rb +4 -1
  205. data/lib/woods.rb +135 -11
  206. data/plugin/.claude-plugin/plugin.json +1 -1
  207. data/plugin/hooks/adapters/normalize.jq +15 -0
  208. data/plugin/hooks/adapters/normalize.rb +63 -0
  209. data/plugin/hooks/hooks.json +20 -0
  210. data/plugin/hooks/woods-context.sh +50 -0
  211. data/plugin/hooks/woods-input-rules.sh +159 -0
  212. data/plugin/hooks/woods-opencode.mjs +65 -0
  213. data/plugin/hooks/woods-post-edit.sh +2 -225
  214. data/plugin/hooks/woods-refresh.sh +260 -0
  215. data/plugin/hooks/woods-session-start.sh +47 -55
  216. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  217. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  218. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  219. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  220. data/plugin/skills/woods-setup/SKILL.md +107 -6
  221. 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
 
@@ -4,7 +4,6 @@ require 'digest'
4
4
  require 'json'
5
5
  require 'set'
6
6
  require_relative 'ast/parser'
7
- require_relative 'ast/method_extractor'
8
7
  require_relative 'flow_analysis/operation_extractor'
9
8
  require_relative 'flow_document'
10
9
 
@@ -25,15 +24,25 @@ module Woods
25
24
  # puts flow.to_markdown
26
25
  #
27
26
  class FlowAssembler
27
+ # How many entries each per-instance memo holds before the oldest is
28
+ # dropped. One assembler serves a whole precompute run (every controller,
29
+ # every action), so an unbounded memo would hold the source and the parsed
30
+ # AST of every unit the run ever reached. A thousand covers the shared
31
+ # services a large app's flows keep landing on without pinning the whole
32
+ # index in memory.
33
+ MEMO_LIMIT = 1_000
34
+
28
35
  # @param graph [DependencyGraph] The dependency graph for resolving targets
29
36
  # @param extracted_dir [String] Directory containing extracted unit JSON files
30
37
  def initialize(graph:, extracted_dir:)
31
38
  @graph = graph
32
39
  @extracted_dir = extracted_dir
33
40
  @parser = Ast::Parser.new
34
- @method_extractor = Ast::MethodExtractor.new(parser: @parser)
35
41
  @operation_extractor = FlowAnalysis::OperationExtractor.new
36
42
  @resolved_targets = {}
43
+ @unit_cache = {}
44
+ @ast_cache = {}
45
+ @method_node_cache = {}
37
46
  end
38
47
 
39
48
  # Assemble an execution flow from the given entry point.
@@ -114,7 +123,7 @@ module Woods
114
123
  file_path = unit_data[:file_path]
115
124
 
116
125
  # Extract operations from the relevant method
117
- operations = extract_operations(source_code, method_name, metadata, unit_type)
126
+ operations = extract_operations(unit_id, source_code, method_name, metadata, unit_type)
118
127
 
119
128
  step = {
120
129
  unit: identifier,
@@ -146,19 +155,75 @@ module Woods
146
155
  # one the callee actually defines locally (inherited, dynamically
147
156
  # defined, or metaprogrammed) — losing the trace entirely would be worse
148
157
  # than over-including it.
149
- def extract_operations(source_code, method_name, metadata, unit_type)
158
+ def extract_operations(unit_id, source_code, method_name, metadata, unit_type)
150
159
  operations = []
151
160
 
152
161
  # For controllers, prepend before_action callbacks
153
162
  prepend_callbacks(operations, metadata, method_name) if unit_type == 'controller'
154
163
 
155
- scope_node = method_name && @method_extractor.extract_method(source_code, method_name)
156
- scope_node ||= @parser.parse(source_code)
164
+ scope_node = method_node(unit_id, source_code, method_name)
165
+ scope_node ||= parsed_source(unit_id, source_code)
157
166
  operations.concat(@operation_extractor.extract(scope_node))
158
167
 
159
168
  operations
160
169
  end
161
170
 
171
+ # The unit's whole parsed source, parsed once per assembler instance.
172
+ #
173
+ # A unit reached from several controllers used to be re-parsed once per
174
+ # action of every controller that reached it, and Prism dominates the
175
+ # flow run's cost on a large app.
176
+ #
177
+ # @param unit_id [String] cache key; a unit's source is fixed for a run
178
+ # @param source_code [String] the unit's source
179
+ # @return [Ast::Node] root node
180
+ def parsed_source(unit_id, source_code)
181
+ memoize(@ast_cache, unit_id) { @parser.parse(source_code) }
182
+ end
183
+
184
+ # The `def` node for +method_name+, from a per-unit index built off the
185
+ # memoized parse.
186
+ #
187
+ # First definition wins for a name defined more than once, matching
188
+ # {Ast::MethodExtractor#extract_method}'s first-match lookup.
189
+ #
190
+ # @param unit_id [String] cache key
191
+ # @param source_code [String] the unit's source
192
+ # @param method_name [String, nil] the method the caller invoked
193
+ # @return [Ast::Node, nil] nil when no method was named, or the unit does
194
+ # not define one by that name
195
+ def method_node(unit_id, source_code, method_name)
196
+ return nil unless method_name
197
+
198
+ nodes = memoize(@method_node_cache, unit_id) do
199
+ parsed_source(unit_id, source_code).find_all(:def).each_with_object({}) do |node, index|
200
+ index[node.method_name] ||= node
201
+ end
202
+ end
203
+
204
+ nodes[method_name.to_s]
205
+ end
206
+
207
+ # Read +key+ from +cache+, computing and storing it on a miss.
208
+ #
209
+ # Least-recently-used, using the fact that a Ruby Hash iterates in
210
+ # insertion order: a hit re-inserts the key at the young end, and
211
+ # +Hash#shift+ drops the old end once the cache is over {MEMO_LIMIT}.
212
+ # A nil value is a real answer (most call targets are not units) and is
213
+ # cached like any other.
214
+ #
215
+ # @param cache [Hash]
216
+ # @param key [Object]
217
+ # @return [Object] the cached or freshly computed value
218
+ def memoize(cache, key)
219
+ return cache[key] = cache.delete(key) if cache.key?(key)
220
+
221
+ value = yield
222
+ cache[key] = value
223
+ cache.shift while cache.size > MEMO_LIMIT
224
+ value
225
+ end
226
+
162
227
  # Prepend before_action callbacks from controller metadata.
163
228
  #
164
229
  # Handles two metadata formats:
@@ -296,13 +361,27 @@ module Woods
296
361
  end
297
362
  end
298
363
 
299
- # Load an ExtractedUnit's data from its JSON file on disk.
364
+ # Load an ExtractedUnit's data from its JSON file on disk, memoized per
365
+ # assembler instance.
366
+ #
367
+ # The glob and the JSON parse used to run once per expansion, so a unit
368
+ # reached from many controllers paid for both once per action of every one
369
+ # of them. The memo also makes {#extract_route} free: the entry unit it
370
+ # asks for is the one {#expand} just loaded.
371
+ #
372
+ # @param unit_id [String] Unit identifier
373
+ # @return [Hash, nil] symbolized unit data, or nil when no file matched
374
+ def load_unit(unit_id)
375
+ memoize(@unit_cache, unit_id) { read_unit(unit_id) }
376
+ end
377
+
378
+ # The disk half of {#load_unit}.
300
379
  #
301
380
  # Uses {Extractor#collision_safe_filename} convention (with SHA256 digest suffix).
302
381
  # Falls back to legacy {Extractor#safe_filename} for older indexes.
303
382
  # Searches across type subdirectories since the extractor writes to
304
383
  # `<output_dir>/<type>/<filename>.json`.
305
- def load_unit(unit_id)
384
+ def read_unit(unit_id)
306
385
  base = unit_id.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
307
386
  digest = Digest::SHA256.hexdigest(unit_id)[0, 8]
308
387
  filenames = [
@@ -89,11 +89,17 @@ module Woods
89
89
  # controller units, rehydrated from the payload this run seeded
90
90
  # @param removed_identifiers [Array<String>] controller identifiers
91
91
  # whose unit the run pruned
92
+ # @param carried_identifiers [Array<String>] controllers the run
93
+ # re-extracted whose flows it established cannot have changed. Their
94
+ # index entries stay as they are and their annotation is read back out
95
+ # of those entries, because a re-extracted unit lost the annotation
96
+ # whether or not its flows moved. Skipping the assembly is the whole
97
+ # saving; skipping the annotation would diverge from a full run.
92
98
  # @return [Hash{String => Hash{String => String}}] per-controller
93
99
  # annotation (action => relative flow path) to write into the units'
94
100
  # metadata; an empty hash means "no flows", which clears any
95
101
  # annotation a previous run had written
96
- def recompute_delta(touched_units:, removed_identifiers: [])
102
+ def recompute_delta(touched_units:, removed_identifiers: [], carried_identifiers: [])
97
103
  FileUtils.mkdir_p(@flows_dir)
98
104
 
99
105
  assembler = FlowAssembler.new(graph: @graph, extracted_dir: @output_dir)
@@ -110,8 +116,13 @@ module Woods
110
116
  delta.merge!(entries)
111
117
  end
112
118
 
119
+ previous = previous_flow_index
120
+ Array(carried_identifiers).each do |identifier|
121
+ annotations[identifier] = carried_annotation(previous, identifier)
122
+ end
123
+
113
124
  replaced = touched_units.map(&:identifier) + Array(removed_identifiers)
114
- carried = previous_flow_index.reject { |entry_point, _path| replaced.include?(controller_of(entry_point)) }
125
+ carried = previous.reject { |entry_point, _path| replaced.include?(controller_of(entry_point)) }
115
126
 
116
127
  write_flow_index(carried.merge(delta))
117
128
 
@@ -156,6 +167,18 @@ module Woods
156
167
  [entries, unit_flow_paths]
157
168
  end
158
169
 
170
+ # One controller's annotation, rebuilt from the index entries it keeps.
171
+ #
172
+ # @param previous [Hash{String => String}] the previous flow index
173
+ # @param identifier [String] controller identifier
174
+ # @return [Hash{String => String}] action to relative flow path
175
+ def carried_annotation(previous, identifier)
176
+ previous.each_with_object({}) do |(entry_point, path), annotation|
177
+ controller, action = entry_point.to_s.split('#', 2)
178
+ annotation[action] = path if action && controller == identifier
179
+ end
180
+ end
181
+
159
182
  # The controller part of a flow index entry point.
160
183
  #
161
184
  # @param entry_point [String]
@@ -183,9 +206,10 @@ module Woods
183
206
 
184
207
  # Assemble a flow for one entry point and write the JSON file.
185
208
  #
186
- # Written via {Woods::AtomicFile} (temp + fsync + rename) like every
187
- # other index artifact — a plain +File.write+ interrupted mid-write left
188
- # a torn partial for the MCP read side to trip over.
209
+ # Written via {Woods::AtomicFile} (temp + rename) like every other index
210
+ # artifact. A plain +File.write+ interrupted mid-write left a torn
211
+ # partial for the MCP read side to trip over. Not durable by default: see
212
+ # {#durable_writes?}.
189
213
  #
190
214
  # @param assembler [FlowAssembler]
191
215
  # @param entry_point [String]
@@ -199,7 +223,7 @@ module Woods
199
223
 
200
224
  filename = Woods::FilenameUtils.flow_filename(controller_id, action)
201
225
 
202
- Woods::AtomicFile.write(File.join(@flows_dir, filename), canonical_json(flow.to_h))
226
+ Woods::AtomicFile.write(File.join(@flows_dir, filename), canonical_json(flow.to_h), durable: durable_writes?)
203
227
 
204
228
  # Relative — this value is persisted (flow_index.json and the unit's
205
229
  # metadata[:flow_paths]), so it must not carry this machine's root.
@@ -213,7 +237,20 @@ module Woods
213
237
  # @param flow_map [Hash{String => String}]
214
238
  def write_flow_index(flow_map)
215
239
  index_path = File.join(@flows_dir, 'flow_index.json')
216
- Woods::AtomicFile.write(index_path, canonical_json(flow_map))
240
+ Woods::AtomicFile.write(index_path, canonical_json(flow_map), durable: durable_writes?)
241
+ end
242
+
243
+ # Whether a flow document pays for its own fsync as it is written.
244
+ #
245
+ # False by default. A flow document is a payload file: nothing reads it
246
+ # until +generation.json+ names the payload, and
247
+ # {Woods::AtomicFile.sync_directory_tree} flushes the whole payload before
248
+ # that pointer is written. `durable_payload_writes = true` buys the
249
+ # per-file fsync back for a host that wants it.
250
+ #
251
+ # @return [Boolean]
252
+ def durable_writes?
253
+ Woods.configuration&.durable_payload_writes ? true : false
217
254
  end
218
255
 
219
256
  # Emit deterministic pretty JSON — keys recursively sorted so two runs
@@ -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