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,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'time'
4
+ require 'active_support/inflector'
4
5
 
5
6
  module Woods
6
7
  module SessionTracer
7
8
  # Rack middleware that captures request metadata for session tracing.
8
9
  #
9
10
  # Wraps `@app.call(env)`, records after response. Extracts controller/action
10
- # from `env['action_dispatch.request.path_parameters']`. Session ID from
11
+ # from the dispatched controller instance and Rails path parameters. Session ID from
11
12
  # `X-Trace-Session` header first, falls back to `request.session.id`.
12
13
  #
13
14
  # Fire-and-forget writes — `rescue StandardError` on recording, never breaks the request.
@@ -98,8 +99,7 @@ module Woods
98
99
  action = path_params[:action]
99
100
  return unless controller
100
101
 
101
- # Classify controller name (e.g., "orders" -> "OrdersController")
102
- controller_class = classify_controller(controller)
102
+ controller_class = controller_identity(env['action_controller.instance'], controller)
103
103
 
104
104
  request_data = {
105
105
  'session_id' => session_id,
@@ -144,15 +144,13 @@ module Woods
144
144
  @exclude_paths.any? { |prefix| path.start_with?(prefix) }
145
145
  end
146
146
 
147
- # Classify a Rails controller path segment into a controller class name.
148
- #
149
- # @param controller [String] e.g., "orders" or "admin/orders"
150
- # @return [String] e.g., "OrdersController" or "Admin::OrdersController"
151
- def classify_controller(controller)
152
- parts = controller.to_s
153
- .split('/')
154
- .map { |segment| segment.split('_').map(&:capitalize).join }
155
- "#{parts.join('::')}Controller"
147
+ # Runtime identity is authoritative. Rack adapters without a dispatched
148
+ # instance still use Rails' configured acronyms when classifying the route.
149
+ def controller_identity(instance, controller)
150
+ name = instance.class.name if instance
151
+ return name if name && !name.empty?
152
+
153
+ "#{ActiveSupport::Inflector.camelize(controller.to_s)}Controller"
156
154
  end
157
155
 
158
156
  # Extract response format from the Rack env.
@@ -56,6 +56,19 @@ module Woods
56
56
  return 1
57
57
  LUA
58
58
 
59
+ # Read and remove against either index format without converting a
60
+ # legacy SET. Each type check and operation is atomic with respect to
61
+ # a concurrent record's migration. Reader-only upgrades therefore do
62
+ # not prevent older SET writers from continuing to record.
63
+ INDEX_ACCESS_SCRIPT = <<~LUA
64
+ local legacy = redis.call('TYPE', KEYS[1]).ok == 'set'
65
+ if ARGV[1] == 'members' then
66
+ if legacy then return redis.call('SMEMBERS', KEYS[1]) end
67
+ return redis.call('ZRANGE', KEYS[1], 0, -1)
68
+ end
69
+ return redis.call(legacy and 'SREM' or 'ZREM', KEYS[1], ARGV[2])
70
+ LUA
71
+
59
72
  # @param redis [Redis] A Redis client instance
60
73
  # @param ttl [Integer, nil] Time-to-live in seconds for session keys (nil = no expiry)
61
74
  def initialize(redis:, ttl: nil, max_sessions: DEFAULT_MAX_SESSIONS,
@@ -103,14 +116,13 @@ module Woods
103
116
  # @param limit [Integer] Maximum number of sessions to return
104
117
  # @return [Array<Hash>] Session summaries
105
118
  def sessions(limit: 20)
106
- all_ids = @redis.zrange(SESSIONS_KEY, 0, -1)
119
+ all_ids = index_access('members')
107
120
 
108
121
  # Filter to sessions that still have data (TTL may have expired)
109
122
  active = all_ids.select { |id| @redis.exists?(session_key(id)) }
110
123
 
111
124
  # Remove expired session IDs from the index
112
- expired = all_ids - active
113
- expired.each { |id| @redis.zrem(SESSIONS_KEY, id) } if expired.any?
125
+ (all_ids - active).each { |id| index_access('remove', id) }
114
126
 
115
127
  # Redis sorted sets order by score, but a summary's last_request is
116
128
  # the payload timestamp of the session's last record — the same
@@ -133,20 +145,24 @@ module Woods
133
145
  # @return [void]
134
146
  def clear(session_id)
135
147
  @redis.del(session_key(session_id))
136
- @redis.zrem(SESSIONS_KEY, session_id)
148
+ index_access('remove', session_id)
137
149
  end
138
150
 
139
151
  # Remove all session data.
140
152
  #
141
153
  # @return [void]
142
154
  def clear_all
143
- all_ids = @redis.zrange(SESSIONS_KEY, 0, -1)
155
+ all_ids = index_access('members')
144
156
  all_ids.each { |id| @redis.del(session_key(id)) }
145
157
  @redis.del(SESSIONS_KEY)
146
158
  end
147
159
 
148
160
  private
149
161
 
162
+ def index_access(action, session_id = nil)
163
+ @redis.eval(INDEX_ACCESS_SCRIPT, keys: [SESSIONS_KEY], argv: [action, *session_id])
164
+ end
165
+
150
166
  # @param session_id [String]
151
167
  # @return [String] Redis key for this session
152
168
  def session_key(session_id)
@@ -196,7 +212,7 @@ module Woods
196
212
 
197
213
  victims.each do |id|
198
214
  @redis.del(session_key(id))
199
- @redis.zrem(SESSIONS_KEY, id)
215
+ index_access('remove', id)
200
216
  end
201
217
  end
202
218
 
@@ -4,6 +4,7 @@ require 'json'
4
4
  require 'set'
5
5
  require_relative '../token_utils'
6
6
  require_relative 'session_flow_document'
7
+ require_relative 'unit_resolver'
7
8
 
8
9
  module Woods
9
10
  module SessionTracer
@@ -42,8 +43,14 @@ module Woods
42
43
  # @param budget [Integer] Maximum token budget (default: 8000)
43
44
  # @param depth [Integer] Expansion depth (0=metadata only, 1=direct deps, 2+=full flow)
44
45
  # @return [SessionFlowDocument] The assembled document
45
- # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
46
46
  def assemble(session_id, budget: 8000, depth: 1)
47
+ @reader.with_pinned_generation { assemble_pinned(session_id, budget: budget, depth: depth) }
48
+ end
49
+
50
+ private
51
+
52
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
53
+ def assemble_pinned(session_id, budget:, depth:)
47
54
  requests = @store.read(session_id)
48
55
  return empty_document(session_id) if requests.empty?
49
56
 
@@ -52,6 +59,7 @@ module Woods
52
59
  side_effects = []
53
60
  dependency_map = {}
54
61
  seen_units = Set.new
62
+ resolver = UnitResolver.new(@reader)
55
63
 
56
64
  requests.each_with_index do |req, idx|
57
65
  step = build_step(req, idx)
@@ -63,18 +71,17 @@ module Woods
63
71
  next unless controller_id
64
72
 
65
73
  # Resolve controller unit
66
- unit = @reader.find_unit(controller_id)
74
+ unit = @reader.find_unit(controller_id, type: 'controller')
67
75
  if unit && !seen_units.include?(controller_id)
68
76
  seen_units.add(controller_id)
69
77
  context_pool[controller_id] = unit_summary(unit)
70
78
  end
71
- step[:unit_refs] = [controller_id].compact
72
-
73
- # Expand dependencies
79
+ # Expand dependencies only for a resolved controller.
74
80
  next unless unit
75
81
 
82
+ step[:unit_refs] = [controller_id]
76
83
  deps = resolve_dependencies(controller_id, seen_units, context_pool,
77
- side_effects, step, dependency_map, depth)
84
+ side_effects, step, dependency_map, depth, resolver)
78
85
  step[:unit_refs].concat(deps)
79
86
  end
80
87
 
@@ -85,8 +92,6 @@ module Woods
85
92
  budgeted_document(parts, budget)
86
93
  end
87
94
 
88
- private
89
-
90
95
  # Build a timeline step from a request record.
91
96
  #
92
97
  # @param req [Hash] Request data from store
@@ -111,13 +116,13 @@ module Woods
111
116
  # @return [Array<String>] Non-async dependency identifiers added
112
117
  # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
113
118
  def resolve_dependencies(unit_id, seen_units, context_pool,
114
- side_effects, step, dependency_map, depth)
119
+ side_effects, step, dependency_map, depth, resolver)
115
120
  graph = @reader.dependency_graph
116
- dep_ids = graph.dependencies_of(unit_id)
121
+ dep_ids = graph.dependencies_of(unit_id, type: :controller)
117
122
  added = []
118
123
 
119
124
  dep_ids.each do |dep_id|
120
- dep_unit = @reader.find_unit(dep_id)
125
+ dep_unit = resolver.find(dep_id)
121
126
  next unless dep_unit
122
127
 
123
128
  dep_type = dep_unit['type']&.to_s
@@ -137,13 +142,13 @@ module Woods
137
142
  added << dep_id
138
143
 
139
144
  # Depth 2+: expand transitive dependencies
140
- expand_transitive(dep_id, seen_units, context_pool, dependency_map, depth - 1) if depth >= 2
145
+ expand_transitive(dep_id, seen_units, context_pool, dependency_map, depth - 1, resolver) if depth >= 2
141
146
  end
142
147
  end
143
148
  end
144
149
 
145
150
  # Record dependency map for this unit
146
- all_deps = dep_ids.select { |id| @reader.find_unit(id) }
151
+ all_deps = dep_ids.select { |id| resolver.find(id) }
147
152
  dependency_map[unit_id] = all_deps if all_deps.any?
148
153
 
149
154
  added
@@ -156,15 +161,16 @@ module Woods
156
161
  # @param context_pool [Hash] Accumulator for unit data
157
162
  # @param dependency_map [Hash] Accumulator for dependency edges
158
163
  # @param remaining_depth [Integer] Remaining expansion depth
159
- def expand_transitive(unit_id, seen_units, context_pool, dependency_map, remaining_depth)
164
+ # rubocop:disable-next Metrics/ParameterLists
165
+ def expand_transitive(unit_id, seen_units, context_pool, dependency_map, remaining_depth, resolver)
160
166
  return if remaining_depth <= 0
161
167
 
162
168
  graph = @reader.dependency_graph
163
- dep_ids = graph.dependencies_of(unit_id)
169
+ dep_ids = graph.dependencies_of(unit_id, type: resolver.find(unit_id).fetch('type').to_sym)
164
170
  resolved_deps = []
165
171
 
166
172
  dep_ids.each do |dep_id|
167
- dep_unit = @reader.find_unit(dep_id)
173
+ dep_unit = resolver.find(dep_id)
168
174
  next unless dep_unit
169
175
 
170
176
  resolved_deps << dep_id
@@ -173,7 +179,7 @@ module Woods
173
179
  seen_units.add(dep_id)
174
180
  context_pool[dep_id] = unit_summary(dep_unit)
175
181
 
176
- expand_transitive(dep_id, seen_units, context_pool, dependency_map, remaining_depth - 1)
182
+ expand_transitive(dep_id, seen_units, context_pool, dependency_map, remaining_depth - 1, resolver)
177
183
  end
178
184
 
179
185
  dependency_map[unit_id] = resolved_deps if resolved_deps.any?
@@ -157,11 +157,13 @@ module Woods
157
157
  end
158
158
 
159
159
  def inserted?(entry_class, result, key, payload)
160
- affected_rows = result.affected_rows if result.respond_to?(:affected_rows)
161
- return affected_rows.positive? unless affected_rows.nil?
162
- return result.rows.any? if entry_class.connection.supports_insert_returning?
160
+ # MySQL's FOUND_ROWS can count a duplicate no-op as an affected row.
161
+ # The per-attempt CAS version makes equal values distinct payloads, so
162
+ # only our exact stored bytes establish ownership without RETURNING.
163
+ return entry_class.read(key) == payload unless entry_class.connection.supports_insert_returning?
163
164
 
164
- entry_class.read(key) == payload
165
+ affected_rows = result.affected_rows if result.respond_to?(:affected_rows)
166
+ affected_rows.nil? ? result.rows.any? : affected_rows.positive?
165
167
  end
166
168
 
167
169
  def routed_read(key, failsafe)
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module SessionTracer
5
+ # A bare dependency name cannot choose between published extraction types.
6
+ class AmbiguousUnitError < StandardError
7
+ attr_reader :identifier, :types
8
+
9
+ def initialize(identifier, types)
10
+ @identifier = identifier
11
+ @types = types.sort.freeze
12
+ super("Ambiguous session unit #{identifier.inspect}: published types #{@types.join(', ')}. " \
13
+ 'No session context was returned. Use depth: 0 for the timeline, or inspect each type with lookup.')
14
+ end
15
+ end
16
+
17
+ # Per-assembly resolver. The caller pins its complete lifetime to one generation.
18
+ # Summary entries may omit type in legacy indexes; the containing bucket and
19
+ # validated unit body establish the concrete type without reading unrelated bodies.
20
+ class UnitResolver
21
+ def initialize(reader)
22
+ @reader = reader
23
+ @resolved = {}
24
+ end
25
+
26
+ def find(identifier)
27
+ return @resolved[identifier] if @resolved.key?(identifier)
28
+
29
+ units = candidate_types.fetch(identifier, []).filter_map do |type|
30
+ @reader.find_unit(identifier, type: type)
31
+ end
32
+ raise AmbiguousUnitError.new(identifier, units.map { |unit| unit.fetch('type') }) if units.size > 1
33
+
34
+ @resolved[identifier] = units.first
35
+ end
36
+
37
+ private
38
+
39
+ def entries_for(dir)
40
+ path = @reader.payload_dir.join(dir, '_index.json')
41
+ raise IOError, "symlink unit index: #{dir}" if path.symlink?
42
+
43
+ entries = @reader.list_units(type: MCP::IndexReader::DIR_TO_TYPE.fetch(dir))
44
+ expected = @reader.manifest.dig('counts', dir)
45
+ if expected.is_a?(Integer) && expected != entries.size
46
+ raise IOError, "unit count mismatch in #{dir}: expected #{expected}, found #{entries.size}"
47
+ end
48
+
49
+ entries
50
+ end
51
+
52
+ def candidate_types
53
+ @candidate_types ||= MCP::IndexReader::UNIT_TYPES_BY_DIR.each_with_object({}) do |(dir, types), candidates|
54
+ entries_for(dir).each do |entry|
55
+ identifier = entry.fetch('identifier')
56
+ candidates[identifier] ||= []
57
+ candidates[identifier] |= types
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module SourceInputs
5
+ # Error handlers can return the same nil/empty result as a successful
6
+ # negative match. Keep explicit evidence on the consumer instance instead
7
+ # of guessing from results or intercepting the application's logger.
8
+ module ConsumerErrors
9
+ FLAG = :@woods_source_consumer_failed
10
+
11
+ module_function
12
+
13
+ def record(consumer)
14
+ consumer.instance_variable_set(FLAG, true)
15
+ end
16
+
17
+ def reset(consumer)
18
+ consumer.instance_variable_set(FLAG, false)
19
+ end
20
+
21
+ def failed?(consumer)
22
+ consumer && consumer.instance_variable_get(FLAG) == true
23
+ end
24
+
25
+ def log(consumer, ...)
26
+ record(consumer)
27
+ Rails.logger.error(...)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'woods/source_inputs/manifest'
5
+
6
+ module Woods
7
+ module SourceInputs
8
+ # Private one-process launch handoff. Its environment token names neither an
9
+ # existing index manifest nor a user-supplied proof of runtime freshness.
10
+ module Handoff
11
+ ENV_KEY = 'WOODS_SOURCE_CAPTURE'
12
+
13
+ module_function
14
+
15
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
16
+ # Every independent handoff binding must match before a token is consumed.
17
+ def read(root:, output_dir:, operation:, rules:, key_id:)
18
+ token = ENV.fetch(ENV_KEY, nil)
19
+ return nil if token.nil? || token.empty?
20
+
21
+ descriptor = JSON.parse(token)
22
+ return nil unless descriptor.is_a?(Hash)
23
+
24
+ data = private_data(descriptor.fetch('path'))
25
+ return nil unless data.is_a?(Hash)
26
+
27
+ expected = { 'version' => 1, 'nonce' => descriptor.fetch('nonce'),
28
+ 'root' => File.expand_path(root.to_s), 'output' => File.expand_path(output_dir.to_s),
29
+ 'operation' => operation.to_s, 'rules' => rules, 'launcher_pid' => Process.ppid }
30
+ return nil unless expected.all? { |key, value| data[key] == value }
31
+ return nil unless valid_nonce?(data['nonce'])
32
+
33
+ snapshot = data.fetch('snapshot')
34
+ return nil unless valid_snapshot?(snapshot, expected, key_id)
35
+
36
+ ENV.delete(ENV_KEY)
37
+ snapshot
38
+ rescue JSON::ParserError, KeyError, TypeError, SystemCallError, IOError
39
+ nil
40
+ end
41
+
42
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
43
+
44
+ def private_data(path)
45
+ flags = File::RDONLY | File::NONBLOCK
46
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
47
+ File.open(path, flags) do |file|
48
+ stat = file.stat
49
+ return nil unless stat.file? && stat.uid == Process.uid && stat.mode.nobits?(0o077)
50
+ return nil if stat.size > Manifest::MAX_BYTES
51
+
52
+ JSON.parse(file.read(Manifest::MAX_BYTES + 1))
53
+ end
54
+ end
55
+
56
+ def valid_nonce?(value)
57
+ value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
58
+ end
59
+
60
+ def valid_snapshot?(snapshot, expected, key_id)
61
+ return false unless matching_snapshot?(snapshot, expected, key_id)
62
+
63
+ files = snapshot['files']
64
+ paths = snapshot['scope_paths']
65
+ return false unless valid_tables?(files, paths)
66
+
67
+ expanded = paths.transform_values { |list| list.to_h { |path| [path, files.fetch(path)] } }
68
+ Manifest.build(snapshot: snapshot, scopes: expanded, boot_verified: true, generation: 1)
69
+ true
70
+ rescue Manifest::Invalid, KeyError, TypeError
71
+ false
72
+ end
73
+
74
+ def matching_snapshot?(snapshot, expected, key_id)
75
+ snapshot.is_a?(Hash) && snapshot['root'] == expected['root'] &&
76
+ snapshot['rules'] == expected['rules'] && snapshot['key_id'] == key_id
77
+ end
78
+
79
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
80
+ def valid_tables?(files, paths)
81
+ files.is_a?(Hash) && paths.is_a?(Hash) &&
82
+ files.all? { |path, digest| path.is_a?(String) && valid_nonce?(digest) } &&
83
+ paths.values.all? { |list| list.is_a?(Array) && list.all? { |path| files.key?(path) } }
84
+ end
85
+
86
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
87
+
88
+ def extra_roots
89
+ token = ENV.fetch(ENV_KEY, nil)
90
+ return [] if token.nil? || token.empty?
91
+
92
+ descriptor = JSON.parse(token)
93
+ return [] unless descriptor.is_a?(Hash)
94
+
95
+ roots = descriptor.fetch('extra_roots', [])
96
+ roots.is_a?(Array) ? roots : []
97
+ rescue JSON::ParserError, TypeError
98
+ []
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'tempfile'
5
+ require 'base64'
6
+ require 'securerandom'
7
+ require 'woods/source_inputs/scanner'
8
+ require 'woods/source_inputs/handoff'
9
+ require 'woods/hooks/refresh'
10
+
11
+ module Woods
12
+ module SourceInputs
13
+ # Captures before a fresh child evaluates Bundler, the Rakefile and Rails.
14
+ # The parent waits so all exit paths can remove the private handoff.
15
+ class Launcher # rubocop:disable Metrics/ClassLength -- parser and child lifetime form one CLI contract
16
+ OPERATIONS = %w[full incremental refresh].freeze
17
+
18
+ def self.run(argv, **options)
19
+ new(argv, **options).run
20
+ rescue OptionParser::ParseError, ArgumentError, PrivateKey::Unavailable, SystemCallError => e
21
+ warn "woods-extract: #{e.message}"
22
+ 1
23
+ end
24
+
25
+ def initialize(argv, command: %w[bundle exec rake])
26
+ @arguments = argv.dup
27
+ @command = command
28
+ @original_directory = Dir.pwd
29
+ @root = @original_directory
30
+ @output = ENV.fetch('WOODS_OUTPUT', 'tmp/woods')
31
+ @extra_roots = []
32
+ parse!
33
+ end
34
+
35
+ def run # rubocop:disable Metrics/MethodLength -- handoff lifetime encloses the fresh child
36
+ return 0 if @help
37
+
38
+ operation, task = task_for_arguments
39
+ key = PrivateKey.new(output_dir: @output, create: true)
40
+ rules = Scopes.new(extra_roots: @extra_roots)
41
+ snapshot = Scanner.new(root: @root, output_dir: @output, key: key, scopes: rules).call
42
+ nonce = SecureRandom.hex(32)
43
+ Tempfile.create(['woods-source-capture-', '.json']) do |file|
44
+ file.binmode
45
+ file.chmod(0o600)
46
+ file.write(JSON.generate(version: 1, nonce: nonce, root: @root, output: @output,
47
+ operation: operation, rules: rules.fingerprint,
48
+ launcher_pid: Process.pid, snapshot: snapshot))
49
+ file.flush
50
+ file.fsync
51
+ descriptor = JSON.generate(path: file.path, nonce: nonce, extra_roots: @extra_roots)
52
+ environment = { Handoff::ENV_KEY => descriptor, 'WOODS_OUTPUT' => @output }
53
+ if ENV['BUNDLE_GEMFILE']
54
+ environment['BUNDLE_GEMFILE'] = File.expand_path(ENV.fetch('BUNDLE_GEMFILE'), @original_directory)
55
+ end
56
+ run_child(environment, task)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def parse!
63
+ parser.permute!(@arguments)
64
+ validate_arguments! unless @help
65
+ end
66
+
67
+ def parser
68
+ OptionParser.new do |options|
69
+ options.banner = 'Usage: woods-extract [options] full | incremental PATH... | refresh TYPE...'
70
+ options.on('--root PATH', 'Application root (default: current directory)') { |path| @root = path }
71
+ options.on('--output PATH', 'Index output (default: WOODS_OUTPUT or tmp/woods)') { |path| @output = path }
72
+ options.on('--source-root PATH', 'Additional application-relative runtime source root') do |path|
73
+ @extra_roots << path
74
+ end
75
+ options.on('-h', '--help', 'Show usage') do
76
+ puts options
77
+ @help = true
78
+ end
79
+ end
80
+ end
81
+
82
+ def validate_arguments!
83
+ @root = File.expand_path(@root)
84
+ raise ArgumentError, 'application root does not exist' unless File.directory?(@root)
85
+
86
+ @output = File.expand_path(@output, @root)
87
+ @operation = @arguments.shift
88
+ raise ArgumentError, 'choose full, incremental, or refresh' unless OPERATIONS.include?(@operation)
89
+ if @operation == 'full' && !@arguments.empty?
90
+ raise ArgumentError,
91
+ 'full does not accept paths or extractor names'
92
+ end
93
+ if @operation != 'full' && @arguments.empty?
94
+ raise ArgumentError,
95
+ "#{@operation} requires paths or extractor names"
96
+ end
97
+
98
+ @extra_roots = Scopes.new(extra_roots: @extra_roots).extra_roots
99
+ end
100
+
101
+ def task_for_arguments
102
+ return ['full', 'woods:extract'] if @operation == 'full'
103
+ return refresh_task if @operation == 'refresh'
104
+
105
+ incremental_task
106
+ end
107
+
108
+ def incremental_task
109
+ paths = @arguments.map { |path| relative_path(path) }.uniq
110
+ action = InputRules.new
111
+ operation = paths.any? { |path| action.action(path) == :full } ? 'full' : 'incremental'
112
+ if paths.size > Hooks::Refresh::MAX_EVENTS
113
+ raise ArgumentError, 'too many incremental paths; split the input or run full'
114
+ end
115
+
116
+ batch = Base64.strict_encode64(JSON.generate(version: 1, output: @output,
117
+ events: paths.map { |path| { path: path, operation: 'update' } }))
118
+ raise ArgumentError, 'incremental batch too large; split the input or run full' if batch.bytesize > 120_000
119
+
120
+ [operation, "woods:hook_refresh[#{batch}]"]
121
+ end
122
+
123
+ def refresh_task
124
+ names = @arguments.uniq
125
+ unless names.all? { |name| name.match?(/\A[a-z_]+\z/) && Extractor::EXTRACTORS.key?(name.to_sym) }
126
+ raise ArgumentError, 'refresh requires known extractor names'
127
+ end
128
+
129
+ ['refresh', "woods:refresh[#{names.join(',')}]"]
130
+ end
131
+
132
+ def relative_path(path)
133
+ absolute = File.expand_path(path, @root)
134
+ unless absolute.start_with?("#{@root}/") && !path.include?("\0")
135
+ raise ArgumentError, 'incremental paths must be inside the application root'
136
+ end
137
+
138
+ absolute.delete_prefix("#{@root}/")
139
+ end
140
+
141
+ def run_child(environment, task)
142
+ pid = Process.spawn(environment, *@command, task, chdir: @root, pgroup: true)
143
+ previous = %w[INT TERM].to_h do |signal|
144
+ [signal, Signal.trap(signal) do
145
+ Process.kill(signal, -pid)
146
+ rescue StandardError
147
+ nil
148
+ end]
149
+ end
150
+ _finished, status = Process.wait2(pid)
151
+ status.exitstatus || (128 + status.termsig)
152
+ ensure
153
+ previous&.each { |signal, handler| Signal.trap(signal, handler) }
154
+ end
155
+ end
156
+ end
157
+ end