woods 1.6.1 → 2.0.0.beta2

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -28,6 +28,16 @@ module Woods
28
28
  RSPEC_GLOB = 'spec/**/*_spec.rb'
29
29
  MINITEST_GLOB = 'test/**/*_test.rb'
30
30
 
31
+ # Constant-form describe: `describe User do`, `RSpec.describe User, type: :model do`.
32
+ # The constant must start uppercase (quoted strings can't sneak in) and may be
33
+ # followed by whitespace OR a comma — the rspec-rails generator default is
34
+ # `RSpec.describe User, type: :model do` (B-082 / #194).
35
+ RSPEC_CONSTANT_DESCRIBE = /^\s*(?:RSpec\.)?describe\s+([A-Z][\w:]*)(?=[\s,]|$)/
36
+
37
+ # String-form describe: `describe 'User' do`, `RSpec.describe 'GET /users', type: :request do`.
38
+ # The closing quote delimits the subject, so nothing is required after it.
39
+ RSPEC_STRING_DESCRIBE = /^\s*(?:RSpec\.)?describe\s+['"]([^'"]+)['"]/
40
+
31
41
  def initialize
32
42
  @rails_root = Rails.root
33
43
  end
@@ -114,21 +124,28 @@ module Woods
114
124
  framework == :rspec ? extract_rspec_subject(source) : extract_minitest_subject(source)
115
125
  end
116
126
 
117
- # Extract subject class from top-level describe in an RSpec file.
127
+ # Extract subject class from the first describe in an RSpec file.
118
128
  #
119
- # Tries constant reference first (describe User do), then string/symbol
120
- # form (describe 'User' do). Handles both RSpec.describe and bare describe.
129
+ # Scans line by line and takes the file's FIRST describe, whatever its
130
+ # form — constant reference (describe User do, RSpec.describe User,
131
+ # type: :model do) or string (describe 'User' do). An inner
132
+ # `describe 'validations'` nested under a constant-form outer describe
133
+ # must never become the subject: it would mint a phantom graph node and
134
+ # lose the real coverage edge. Handles both RSpec.describe and bare
135
+ # describe.
121
136
  #
122
137
  # @param source [String] RSpec file source code
123
138
  # @return [String, nil]
124
139
  def extract_rspec_subject(source)
125
- # Constant reference: describe User do, RSpec.describe UsersController do
126
- match = source.match(/^\s*(?:RSpec\.)?describe\s+([\w:]+)\s/)
127
- return match[1] if match
140
+ source.each_line do |line|
141
+ constant = line.match(RSPEC_CONSTANT_DESCRIBE)
142
+ return constant[1] if constant
128
143
 
129
- # String/symbol form: describe 'User' do
130
- match = source.match(/^\s*(?:RSpec\.)?describe\s+['"]([^'"]+)['"]\s/)
131
- match ? match[1] : nil
144
+ string = line.match(RSPEC_STRING_DESCRIBE)
145
+ return string[1] if string
146
+ end
147
+
148
+ nil
132
149
  end
133
150
 
134
151
  # Extract subject class from Minitest test class name.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'component_discovery'
3
4
  require_relative 'shared_utility_methods'
4
5
  require_relative 'shared_dependency_scanner'
5
6
  require_relative 'route_helper_resolver'
@@ -25,6 +26,7 @@ module Woods
25
26
  # card = units.find { |u| u.identifier == "CardComponent" }
26
27
  #
27
28
  class ViewComponentExtractor
29
+ include ComponentDiscovery
28
30
  include SharedUtilityMethods
29
31
  include SharedDependencyScanner
30
32
  include RouteHelperResolver
@@ -38,13 +40,36 @@ module Woods
38
40
  #
39
41
  # @return [Array<ExtractedUnit>] List of view component units
40
42
  def extract_all
41
- return [] unless @component_base
42
-
43
- @component_base.descendants.map do |component|
43
+ discoverable_classes.map do |component|
44
44
  extract_component(component)
45
45
  end.compact
46
46
  end
47
47
 
48
+ # The component classes this extractor would extract from the running
49
+ # app. Shared with the incremental path's class reconciliation (#164).
50
+ #
51
+ # @return [Array<Class>]
52
+ def discoverable_classes
53
+ return [] unless @component_base
54
+
55
+ # Same reason PhlexExtractor walks first: `descendants` cannot see a
56
+ # component the eager load never reached (B-184). Skipped entirely
57
+ # when ViewComponent::Base is undefined, since @component_base is then
58
+ # nil and this app has no view components to find.
59
+ load_component_files
60
+
61
+ # Previews are filtered here rather than only in {#extract_component},
62
+ # because this set is also the incremental path's reconciliation input.
63
+ # A preview class is never in the graph — `extract_component` rejects it
64
+ # — so leaving it in made every incremental run recompute the same
65
+ # "addition", extract it, get nil, and dirty the dependents pass for
66
+ # nothing. Anonymous classes go too: `extract_component` rejects a nil
67
+ # name for the same reason.
68
+ @component_base.descendants.reject do |component|
69
+ component.name.nil? || preview_class?(component)
70
+ end
71
+ end
72
+
48
73
  # Extract a single ViewComponent component
49
74
  #
50
75
  # @param component [Class] The component class
@@ -19,8 +19,9 @@ module Woods
19
19
  ENGINE_NAME = :erb
20
20
 
21
21
  # Matches named route helpers (e.g. `posts_path`, `user_url`) in
22
- # template source. Identical shape across ERB / plain Ruby, so
23
- # shared with {SharedDependencyScanner}.
22
+ # template source. Same shape as
23
+ # {SharedDependencyScanner::ROUTE_HELPER_PATTERN}, but this engine
24
+ # has no dependency on that module, so it keeps its own copy.
24
25
  ROUTE_HELPER_PATTERN = /\b(\w+)_(path|url)\b/
25
26
 
26
27
  # Matches form_with / form_for calls whose action is a named
@@ -72,8 +73,17 @@ module Woods
72
73
  EXTENSIONS
73
74
  end
74
75
 
76
+ # Render option keys that are never themselves a partial name. The
77
+ # bare `render :foo` shorthand pattern below cannot tell `render
78
+ # :partial => 'shared/header'` (the pre-Ruby-3.0 hash-rocket form of
79
+ # the `partial:` option) from an actual `render :partial_name` call,
80
+ # so it must exclude these explicitly rather than record the option
81
+ # key as a partial.
82
+ RESERVED_RENDER_OPTIONS = %w[partial template layout].freeze
83
+
75
84
  # Matches:
76
85
  # - `render partial: 'foo/bar'`
86
+ # - `render :partial => 'foo/bar'` (hash-rocket form)
77
87
  # - `render 'foo/bar'`
78
88
  # - `render :foo`
79
89
  #
@@ -85,12 +95,16 @@ module Woods
85
95
  partials << match[0]
86
96
  end
87
97
 
98
+ source.scan(/render\s+:partial\s*=>\s*['"]([^'"]+)['"]/).each do |match|
99
+ partials << match[0]
100
+ end
101
+
88
102
  source.scan(/render\s+['"]([^'"]+)['"]/).each do |match|
89
103
  partials << match[0]
90
104
  end
91
105
 
92
106
  source.scan(/render\s+:(\w+)/).each do |match|
93
- partials << match[0]
107
+ partials << match[0] unless RESERVED_RENDER_OPTIONS.include?(match[0])
94
108
  end
95
109
 
96
110
  partials.to_a
@@ -53,15 +53,21 @@ module Woods
53
53
  end
54
54
  end
55
55
 
56
- # Count keyword occurrences across low-scoring query texts.
56
+ # Count how many low-scoring *queries* mention each keyword.
57
+ #
58
+ # Deliberately one vote per query, not per occurrence: counting
59
+ # occurrences let a single query reading "user user user user" clear a
60
+ # threshold of four on its own, and made every reported count larger
61
+ # than the number of queries it summarized — which is what the
62
+ # description string claims to be reporting.
57
63
  #
58
64
  # @param ratings [Array<Hash>] Low-score rating entries
59
- # @return [Hash<String, Integer>] Keyword => occurrence count
65
+ # @return [Hash<String, Integer>] Keyword => number of queries mentioning it
60
66
  def count_keywords(ratings)
61
67
  counts = Hash.new(0)
62
68
  ratings.each do |rating|
63
69
  words = rating['query'].to_s.downcase.split(/\W+/).reject { |w| w.length < 3 }
64
- words.each { |w| counts[w] += 1 }
70
+ words.uniq.each { |w| counts[w] += 1 }
65
71
  end
66
72
  counts
67
73
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'fileutils'
5
+ require 'time'
5
6
 
6
7
  module Woods
7
8
  module Feedback
@@ -68,7 +69,12 @@ module Woods
68
69
  return [] unless File.exist?(@path)
69
70
 
70
71
  entries = []
71
- File.foreach(@path) do |line|
72
+ # Explicit UTF-8: `record_*` appends raw UTF-8 bytes, so a bare read
73
+ # would tag every line with the process default external encoding and
74
+ # the first non-ASCII entry would raise under LANG=C — permanently,
75
+ # since the poison line stays in the file (INF-3). Same contract as
76
+ # AtomicFile.read.
77
+ File.foreach(@path, encoding: Encoding::UTF_8) do |line|
72
78
  entry = JSON.parse(line.strip)
73
79
  entries << entry
74
80
  break if limit && entries.size >= limit
@@ -30,12 +30,40 @@ module Woods
30
30
  # (the writer previously left the action raw while the reader allow-listed
31
31
  # it, so such flows were written under one name and never found).
32
32
  #
33
+ # A SHA256 digest (the same disambiguation {#collision_safe_filename}
34
+ # already uses) is appended whenever either segment's transform was
35
+ # lossy — e.g. `bar.baz` and `bar_baz` both {.safe_segment} to `bar_baz`,
36
+ # so writing them under one name means the second write clobbers the
37
+ # first and `flow_index.json` points both entries at that one file. The
38
+ # namespace transform keeps its legacy spelling. Literal underscores in
39
+ # either input are digested, preventing both namespace and separator
40
+ # collisions, including an action that resembles another file's digest.
41
+ #
33
42
  # @param controller_id [String] e.g. "Admin::UsersController"
34
43
  # @param action [String] e.g. "index"
35
44
  # @return [String] e.g. "Admin__UsersController_index.json"
36
45
  def self.flow_filename(controller_id, action)
37
- "#{safe_segment(controller_id)}_#{safe_segment(action)}.json"
46
+ base = "#{safe_segment(controller_id)}_#{safe_segment(action)}"
47
+ ambiguous = controller_id.to_s.include?('_') || action.to_s.include?('_')
48
+ return "#{base}.json" unless ambiguous || lossy_segment?(controller_id) || lossy_segment?(action)
49
+
50
+ digest = Digest::SHA256.hexdigest("#{controller_id}##{action}")[0, 8]
51
+ "#{base}_#{digest}.json"
52
+ end
53
+
54
+ # Whether {.safe_segment} discarded information for +value+ beyond the
55
+ # `::` → `__` namespace transform — i.e. whether some OTHER character
56
+ # got folded to `_`. That residual loss is what makes two different
57
+ # inputs land on the same segment (`bar.baz` and `bar_baz` both become
58
+ # `bar_baz`). Literal underscores are handled separately by flow_filename.
59
+ #
60
+ # @param value [String]
61
+ # @return [Boolean]
62
+ def self.lossy_segment?(value)
63
+ namespaced = value.to_s.gsub('::', '__')
64
+ namespaced.gsub(/[^a-zA-Z0-9_-]/, '_') != namespaced
38
65
  end
66
+ private_class_method :lossy_segment?
39
67
 
40
68
  # Convert an identifier to a safe filename (legacy format).
41
69
  #
@@ -139,16 +139,10 @@ module Woods
139
139
 
140
140
  return if then_ops.empty? && else_ops.empty?
141
141
 
142
- condition_text = if children[0].is_a?(Ast::Node)
143
- children[0].to_source
144
- elsif children[0].is_a?(String)
145
- children[0]
146
- end
147
-
148
142
  operations << {
149
143
  type: :conditional,
150
144
  kind: 'if',
151
- condition: condition_text || node.source,
145
+ condition: condition_source(children[0]) || node.source,
152
146
  line: node.line,
153
147
  then_ops: then_ops,
154
148
  else_ops: else_ops
@@ -156,23 +150,41 @@ module Woods
156
150
  end
157
151
 
158
152
  # Handle :case nodes as a conditional variant.
153
+ #
154
+ # A case node is `(case predicate when… else)`, so children[0] is the
155
+ # predicate and the branches follow it. Walking *all* children counted
156
+ # any call in the predicate — `case user.role` — as an operation
157
+ # performed inside a branch, and reporting `node.source` as the condition
158
+ # meant the whole multi-line statement stood in for the one expression
159
+ # being switched on. Mirrors {#handle_conditional}.
159
160
  def handle_case(node, operations)
160
- # Treat case as a conditional - extract ops from all branches
161
+ children = node.children || []
161
162
  branch_ops = []
162
- walk_children(node, branch_ops)
163
+ children.drop(1).each { |child| walk(child, branch_ops) if child.is_a?(Ast::Node) }
163
164
 
164
165
  return if branch_ops.empty?
165
166
 
166
167
  operations << {
167
168
  type: :conditional,
168
169
  kind: 'case',
169
- condition: node.source,
170
+ condition: condition_source(children[0]) || node.source,
170
171
  line: node.line,
171
172
  then_ops: branch_ops,
172
173
  else_ops: []
173
174
  }
174
175
  end
175
176
 
177
+ # Source text for a conditional's predicate child, which the parser may
178
+ # hand back as a node or as an already-rendered string.
179
+ #
180
+ # @param child [Ast::Node, String, nil]
181
+ # @return [String, nil]
182
+ def condition_source(child)
183
+ return child.to_source if child.is_a?(Ast::Node)
184
+
185
+ child if child.is_a?(String)
186
+ end
187
+
176
188
  # Detect transaction/with_lock calls.
177
189
  def transaction_call?(node)
178
190
  TRANSACTION_METHODS.include?(node.method_name)
@@ -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,14 +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
42
+ @resolved_targets = {}
43
+ @unit_cache = {}
44
+ @ast_cache = {}
45
+ @method_node_cache = {}
36
46
  end
37
47
 
38
48
  # Assemble an execution flow from the given entry point.
@@ -72,11 +82,17 @@ module Woods
72
82
  # @param depth [Integer] Current recursion depth
73
83
  # @param max_depth [Integer] Maximum recursion depth
74
84
  # @param path [Set<String>] Identifiers on the current recursion path
75
- def expand(identifier, steps, visited, depth:, max_depth:, path: Set.new)
85
+ # @param method_name [String, nil] The method the caller actually invoked
86
+ # (from the triggering operation's `op[:method]`), for callers that
87
+ # resolve to a bare unit id and so cannot encode it in +identifier+
88
+ # itself. `identifier`'s own `#method` suffix (entry points) wins when
89
+ # both are present.
90
+ def expand(identifier, steps, visited, depth:, max_depth:, path: Set.new, method_name: nil)
76
91
  return if depth > max_depth
77
92
 
78
93
  # Parse identifier into unit name and optional method
79
- unit_id, method_name = parse_identifier(identifier)
94
+ unit_id, parsed_method = parse_identifier(identifier)
95
+ method_name = parsed_method || method_name
80
96
 
81
97
  if path.include?(unit_id)
82
98
  # Genuine cycle — the unit is an ancestor of itself on this path.
@@ -107,7 +123,7 @@ module Woods
107
123
  file_path = unit_data[:file_path]
108
124
 
109
125
  # Extract operations from the relevant method
110
- operations = extract_operations(source_code, method_name, metadata, unit_type)
126
+ operations = extract_operations(unit_id, source_code, method_name, metadata, unit_type)
111
127
 
112
128
  step = {
113
129
  unit: identifier,
@@ -129,28 +145,83 @@ module Woods
129
145
  end
130
146
  end
131
147
 
132
- # Extract operations from source code for a specific method.
133
- def extract_operations(source_code, method_name, metadata, unit_type)
148
+ # Extract operations from source code for a specific method — or, when no
149
+ # method is named or the unit does not define one by that name, from the
150
+ # entire source.
151
+ #
152
+ # A named method that isn't found falls back to the whole unit rather
153
+ # than yielding no operations: {#expand_operation} now threads the
154
+ # calling operation's `op[:method]` through, and that method need not be
155
+ # one the callee actually defines locally (inherited, dynamically
156
+ # defined, or metaprogrammed) — losing the trace entirely would be worse
157
+ # than over-including it.
158
+ def extract_operations(unit_id, source_code, method_name, metadata, unit_type)
134
159
  operations = []
135
160
 
136
161
  # For controllers, prepend before_action callbacks
137
162
  prepend_callbacks(operations, metadata, method_name) if unit_type == 'controller'
138
163
 
139
- if method_name
140
- # Extract specific method
141
- method_node = @method_extractor.extract_method(source_code, method_name)
142
- if method_node
143
- ops = @operation_extractor.extract(method_node)
144
- operations.concat(ops)
164
+ scope_node = method_node(unit_id, source_code, method_name)
165
+ scope_node ||= parsed_source(unit_id, source_code)
166
+ operations.concat(@operation_extractor.extract(scope_node))
167
+
168
+ operations
169
+ end
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
145
201
  end
146
- else
147
- # No specific method - parse entire source
148
- root = @parser.parse(source_code)
149
- ops = @operation_extractor.extract(root)
150
- operations.concat(ops)
151
202
  end
152
203
 
153
- operations
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
154
225
  end
155
226
 
156
227
  # Prepend before_action callbacks from controller metadata.
@@ -189,6 +260,15 @@ module Woods
189
260
 
190
261
  # Recursively expand an operation's target if it resolves to a known unit.
191
262
  #
263
+ # `op[:method]` is threaded into the recursive {#expand} call so the
264
+ # callee is scoped to the method actually invoked, not its whole source.
265
+ # When {#resolve_target} answers with an ambiguity marker (several
266
+ # namespaces share the target's short name) this does not recurse at
267
+ # all — it mutates +op+ in place with `:ambiguous_candidates` so the
268
+ # unresolved call is still visible in the flow output, naming every
269
+ # candidate instead of silently expanding whichever one the graph's
270
+ # suffix index happened to pick.
271
+ #
192
272
  # @param op [Hash] The operation to potentially expand
193
273
  # @param current_unit [String] The identifier of the unit containing this operation
194
274
  # @param steps [Array<Hash>] Accumulator for step hashes
@@ -201,10 +281,21 @@ module Woods
201
281
  target = op[:target]
202
282
  return unless target
203
283
 
204
- candidate = resolve_target(target)
205
- return unless candidate
284
+ resolution = resolve_target(target)
285
+ return unless resolution
206
286
 
207
- expand(candidate, steps, visited, depth: depth + 1, max_depth: max_depth, path: path)
287
+ if resolution.is_a?(Hash)
288
+ # Several namespaces share this short name (Tier 1 suffix match) —
289
+ # record which ones rather than silently expanding whichever the
290
+ # graph's deterministic-but-arbitrary suffix index picked.
291
+ op[:ambiguous_candidates] = resolution[:candidates]
292
+ return
293
+ end
294
+
295
+ expand(
296
+ resolution, steps, visited,
297
+ depth: depth + 1, max_depth: max_depth, path: path, method_name: op[:method]
298
+ )
208
299
  when :transaction
209
300
  (op[:nested] || []).each do |nested_op|
210
301
  expand_operation(nested_op, current_unit, steps, visited, depth: depth, max_depth: max_depth, path: path)
@@ -229,12 +320,28 @@ module Woods
229
320
  #
230
321
  # @param target [String] The call target name to resolve
231
322
  # @return [String, nil] The resolved unit identifier, or nil if not found
323
+ # Memoized per assembler instance, and negative results are cached too
324
+ # (`nil` is a real answer — most call targets are not units). Resolution
325
+ # used to run a linear suffix scan over every graph node *and*, on a miss,
326
+ # a set of disk globs, once per operation encountered. A flow touching a
327
+ # few hundred operations on a host with tens of thousands of nodes paid
328
+ # that every time, including for the same target repeatedly.
232
329
  def resolve_target(target)
330
+ return @resolved_targets[target] if @resolved_targets.key?(target)
331
+
332
+ @resolved_targets[target] = compute_resolved_target(target)
333
+ end
334
+
335
+ # @return [String, Hash, nil] a resolved unit identifier; a
336
+ # `{ ambiguous: true, candidates: }` marker when several namespaces
337
+ # share +target+'s short name; or nil when nothing resolves
338
+ def compute_resolved_target(target)
233
339
  # Tier 1: Graph-wide lookup
234
340
  return target if @graph.node_exists?(target)
235
341
 
236
- graph_match = @graph.find_node_by_suffix(target)
237
- return graph_match if graph_match
342
+ suffix_matches = @graph.find_all_by_suffix(target)
343
+ return suffix_matches.first if suffix_matches.size == 1
344
+ return { ambiguous: true, candidates: suffix_matches } if suffix_matches.size > 1
238
345
 
239
346
  # Tier 2: Disk fallback (unit JSON exists but isn't in the graph)
240
347
  unit_data = load_unit(target)
@@ -254,13 +361,27 @@ module Woods
254
361
  end
255
362
  end
256
363
 
257
- # 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}.
258
379
  #
259
380
  # Uses {Extractor#collision_safe_filename} convention (with SHA256 digest suffix).
260
381
  # Falls back to legacy {Extractor#safe_filename} for older indexes.
261
382
  # Searches across type subdirectories since the extractor writes to
262
383
  # `<output_dir>/<type>/<filename>.json`.
263
- def load_unit(unit_id)
384
+ def read_unit(unit_id)
264
385
  base = unit_id.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
265
386
  digest = Digest::SHA256.hexdigest(unit_id)[0, 8]
266
387
  filenames = [
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'time' # Time#iso8601 — the file is loadable standalone (EXTB-13)
4
5
 
5
6
  module Woods
6
7
  # Value object representing an assembled execution flow trace.