woods 1.6.0 → 2.0.0.beta1

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 (273) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1908 -0
  3. data/CONTRIBUTING.md +202 -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 +620 -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 +415 -0
  19. data/docs/INTERNALS.md +415 -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 +197 -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 +40 -1
  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 +2509 -189
  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 +63 -21
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +138 -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 +408 -34
  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 +223 -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.rb +21 -5
  218. data/lib/woods/session_tracer/file_store.rb +138 -19
  219. data/lib/woods/session_tracer/middleware.rb +1 -2
  220. data/lib/woods/session_tracer/redis_store.rb +122 -12
  221. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  222. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  223. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  224. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  225. data/lib/woods/session_tracer/store.rb +14 -1
  226. data/lib/woods/storage/metadata_store.rb +230 -26
  227. data/lib/woods/storage/pgvector.rb +180 -22
  228. data/lib/woods/storage/qdrant.rb +367 -41
  229. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  230. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  231. data/lib/woods/storage/snapshotter.rb +23 -5
  232. data/lib/woods/storage/vector_store.rb +49 -8
  233. data/lib/woods/storage_identity.rb +28 -0
  234. data/lib/woods/tasks.rb +53 -2
  235. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  236. data/lib/woods/temporal/snapshot_store.rb +139 -42
  237. data/lib/woods/unblocked/client.rb +119 -17
  238. data/lib/woods/unblocked/document_builder.rb +34 -2
  239. data/lib/woods/unblocked/exporter.rb +63 -27
  240. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  241. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  242. data/lib/woods/update_check.rb +24 -1
  243. data/lib/woods/util/uuid5.rb +124 -0
  244. data/lib/woods/version.rb +1 -1
  245. data/lib/woods/watch/daemon.rb +1345 -0
  246. data/lib/woods/watch/listen_watcher.rb +81 -0
  247. data/lib/woods/watch/polling_watcher.rb +137 -0
  248. data/lib/woods/watch/status.rb +169 -0
  249. data/lib/woods/watch/tree_scan.rb +163 -0
  250. data/lib/woods/watch/watcher.rb +100 -0
  251. data/lib/woods.rb +53 -9
  252. data/plugin/.claude-plugin/plugin.json +18 -0
  253. data/plugin/hooks/hooks.json +29 -0
  254. data/plugin/hooks/woods-post-edit.sh +226 -0
  255. data/plugin/hooks/woods-session-start.sh +77 -0
  256. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  257. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  258. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  259. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  260. data/plugin/skills/woods-setup/SKILL.md +99 -0
  261. metadata +134 -23
  262. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  263. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  264. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  265. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  266. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  267. data/lib/woods/console/bridge.rb +0 -210
  268. data/lib/woods/formatting/claude_adapter.rb +0 -98
  269. data/lib/woods/formatting/generic_adapter.rb +0 -56
  270. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  271. data/lib/woods/notion/mapper.rb +0 -40
  272. data/lib/woods/observability/health_check.rb +0 -79
  273. 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)
@@ -33,6 +33,7 @@ module Woods
33
33
  @parser = Ast::Parser.new
34
34
  @method_extractor = Ast::MethodExtractor.new(parser: @parser)
35
35
  @operation_extractor = FlowAnalysis::OperationExtractor.new
36
+ @resolved_targets = {}
36
37
  end
37
38
 
38
39
  # Assemble an execution flow from the given entry point.
@@ -72,11 +73,17 @@ module Woods
72
73
  # @param depth [Integer] Current recursion depth
73
74
  # @param max_depth [Integer] Maximum recursion depth
74
75
  # @param path [Set<String>] Identifiers on the current recursion path
75
- def expand(identifier, steps, visited, depth:, max_depth:, path: Set.new)
76
+ # @param method_name [String, nil] The method the caller actually invoked
77
+ # (from the triggering operation's `op[:method]`), for callers that
78
+ # resolve to a bare unit id and so cannot encode it in +identifier+
79
+ # itself. `identifier`'s own `#method` suffix (entry points) wins when
80
+ # both are present.
81
+ def expand(identifier, steps, visited, depth:, max_depth:, path: Set.new, method_name: nil)
76
82
  return if depth > max_depth
77
83
 
78
84
  # Parse identifier into unit name and optional method
79
- unit_id, method_name = parse_identifier(identifier)
85
+ unit_id, parsed_method = parse_identifier(identifier)
86
+ method_name = parsed_method || method_name
80
87
 
81
88
  if path.include?(unit_id)
82
89
  # Genuine cycle — the unit is an ancestor of itself on this path.
@@ -129,26 +136,25 @@ module Woods
129
136
  end
130
137
  end
131
138
 
132
- # Extract operations from source code for a specific method.
139
+ # Extract operations from source code for a specific method — or, when no
140
+ # method is named or the unit does not define one by that name, from the
141
+ # entire source.
142
+ #
143
+ # A named method that isn't found falls back to the whole unit rather
144
+ # than yielding no operations: {#expand_operation} now threads the
145
+ # calling operation's `op[:method]` through, and that method need not be
146
+ # one the callee actually defines locally (inherited, dynamically
147
+ # defined, or metaprogrammed) — losing the trace entirely would be worse
148
+ # than over-including it.
133
149
  def extract_operations(source_code, method_name, metadata, unit_type)
134
150
  operations = []
135
151
 
136
152
  # For controllers, prepend before_action callbacks
137
153
  prepend_callbacks(operations, metadata, method_name) if unit_type == 'controller'
138
154
 
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)
145
- 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
- end
155
+ scope_node = method_name && @method_extractor.extract_method(source_code, method_name)
156
+ scope_node ||= @parser.parse(source_code)
157
+ operations.concat(@operation_extractor.extract(scope_node))
152
158
 
153
159
  operations
154
160
  end
@@ -189,6 +195,15 @@ module Woods
189
195
 
190
196
  # Recursively expand an operation's target if it resolves to a known unit.
191
197
  #
198
+ # `op[:method]` is threaded into the recursive {#expand} call so the
199
+ # callee is scoped to the method actually invoked, not its whole source.
200
+ # When {#resolve_target} answers with an ambiguity marker (several
201
+ # namespaces share the target's short name) this does not recurse at
202
+ # all — it mutates +op+ in place with `:ambiguous_candidates` so the
203
+ # unresolved call is still visible in the flow output, naming every
204
+ # candidate instead of silently expanding whichever one the graph's
205
+ # suffix index happened to pick.
206
+ #
192
207
  # @param op [Hash] The operation to potentially expand
193
208
  # @param current_unit [String] The identifier of the unit containing this operation
194
209
  # @param steps [Array<Hash>] Accumulator for step hashes
@@ -201,10 +216,21 @@ module Woods
201
216
  target = op[:target]
202
217
  return unless target
203
218
 
204
- candidate = resolve_target(target)
205
- return unless candidate
219
+ resolution = resolve_target(target)
220
+ return unless resolution
206
221
 
207
- expand(candidate, steps, visited, depth: depth + 1, max_depth: max_depth, path: path)
222
+ if resolution.is_a?(Hash)
223
+ # Several namespaces share this short name (Tier 1 suffix match) —
224
+ # record which ones rather than silently expanding whichever the
225
+ # graph's deterministic-but-arbitrary suffix index picked.
226
+ op[:ambiguous_candidates] = resolution[:candidates]
227
+ return
228
+ end
229
+
230
+ expand(
231
+ resolution, steps, visited,
232
+ depth: depth + 1, max_depth: max_depth, path: path, method_name: op[:method]
233
+ )
208
234
  when :transaction
209
235
  (op[:nested] || []).each do |nested_op|
210
236
  expand_operation(nested_op, current_unit, steps, visited, depth: depth, max_depth: max_depth, path: path)
@@ -229,12 +255,28 @@ module Woods
229
255
  #
230
256
  # @param target [String] The call target name to resolve
231
257
  # @return [String, nil] The resolved unit identifier, or nil if not found
258
+ # Memoized per assembler instance, and negative results are cached too
259
+ # (`nil` is a real answer — most call targets are not units). Resolution
260
+ # used to run a linear suffix scan over every graph node *and*, on a miss,
261
+ # a set of disk globs, once per operation encountered. A flow touching a
262
+ # few hundred operations on a host with tens of thousands of nodes paid
263
+ # that every time, including for the same target repeatedly.
232
264
  def resolve_target(target)
265
+ return @resolved_targets[target] if @resolved_targets.key?(target)
266
+
267
+ @resolved_targets[target] = compute_resolved_target(target)
268
+ end
269
+
270
+ # @return [String, Hash, nil] a resolved unit identifier; a
271
+ # `{ ambiguous: true, candidates: }` marker when several namespaces
272
+ # share +target+'s short name; or nil when nothing resolves
273
+ def compute_resolved_target(target)
233
274
  # Tier 1: Graph-wide lookup
234
275
  return target if @graph.node_exists?(target)
235
276
 
236
- graph_match = @graph.find_node_by_suffix(target)
237
- return graph_match if graph_match
277
+ suffix_matches = @graph.find_all_by_suffix(target)
278
+ return suffix_matches.first if suffix_matches.size == 1
279
+ return { ambiguous: true, candidates: suffix_matches } if suffix_matches.size > 1
238
280
 
239
281
  # Tier 2: Disk fallback (unit JSON exists but isn't in the graph)
240
282
  unit_data = load_unit(target)
@@ -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.