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
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Woods
6
+ module Checks
7
+ # Public methods that look like they moved from one unit to another
8
+ # between two generations while their test coverage did not follow
9
+ # (#280, 2026-09-09 ruling).
10
+ #
11
+ # A refactor that pulls `total` out of `Checkout` into `Pricing` keeps
12
+ # `spec/checkout_spec.rb` green only until the old call site goes; if
13
+ # nothing covers `Pricing`, the moved message is now untested. The check
14
+ # is a set difference over method lists in two {Woods::PublishedIndex}
15
+ # generations, joined with `:test_coverage` edges from the same
16
+ # generations.
17
+ #
18
+ # Each row is a **candidate** move into a unit without mapped tests, not
19
+ # a proven coverage loss: matching is by name and kind alone, so two
20
+ # unrelated methods that happen to share both are indistinguishable from
21
+ # a real move. This is a heuristic by design (see `docs/PUBLISHED_INDEX.md`
22
+ # and the rake task's `desc`), and is reported only when the source unit
23
+ # was covered before and the destination unit is not covered after; a
24
+ # method that was never covered is not a regression this check owns.
25
+ #
26
+ # Consumes anything shaped like {Woods::PublishedIndex}: `#units`,
27
+ # `#unit(identifier)`, and `#edges(via:)`. It never opens an index
28
+ # itself, so it needs no Rails and takes no retention lock of its own.
29
+ #
30
+ # @example
31
+ # before = Woods::PublishedIndex.new(dir, generation: 41)
32
+ # after = Woods::PublishedIndex.new(dir, generation: 42)
33
+ # Woods::Checks::MovedMessages.new(before: before, after: after).run
34
+ # # => [#<Finding method="total" kind=:instance from_unit="Checkout" ...>]
35
+ #
36
+ class MovedMessages
37
+ # Metadata keys extractors use for method lists (see
38
+ # `docs/PUBLISHED_INDEX.md`'s "Moved-message check" section for which
39
+ # extractor writes which key).
40
+ METHOD_KEYS = %w[public_methods instance_methods class_methods].freeze
41
+
42
+ COVERAGE_VIA = 'test_coverage'
43
+
44
+ # @!attribute method
45
+ # @return [String] the method name, `self.`-prefix stripped
46
+ # @!attribute kind
47
+ # @return [Symbol] `:instance` or `:class`
48
+ # @!attribute from_unit
49
+ # @return [String] identifier that lost the method
50
+ # @!attribute to_unit
51
+ # @return [String] identifier that gained the method
52
+ # @!attribute covered_before
53
+ # @return [Boolean] whether `from_unit` had a `:test_coverage` edge before
54
+ # @!attribute covered_after
55
+ # @return [Boolean] whether `to_unit` has a `:test_coverage` edge after
56
+ # rubocop:disable Lint/StructNewOverride -- :method is the field name docs and callers already use
57
+ Finding = Struct.new(:method, :kind, :from_unit, :to_unit, :covered_before, :covered_after, keyword_init: true) do
58
+ # rubocop:enable Lint/StructNewOverride
59
+ # @return [Hash] symbol-keyed, ready for JSON
60
+ def to_h
61
+ { method: method, kind: kind, from_unit: from_unit, to_unit: to_unit,
62
+ covered_before: covered_before, covered_after: covered_after }
63
+ end
64
+ end
65
+
66
+ # @param before [Woods::PublishedIndex] the older generation
67
+ # @param after [Woods::PublishedIndex] the newer generation
68
+ def initialize(before:, after:)
69
+ @before = before
70
+ @after = after
71
+ end
72
+
73
+ # @return [Array<Finding>] sorted by method, then kind, then source, then destination
74
+ def run
75
+ removed, added = method_moves
76
+ covered_before = covered_units(@before)
77
+ covered_after = covered_units(@after)
78
+
79
+ removed.keys.sort_by { |name, kind| [name, kind.to_s] }.flat_map do |signature|
80
+ destinations = added[signature]
81
+ next [] unless destinations
82
+
83
+ build_findings(signature, removed[signature], destinations, covered_before, covered_after)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ # @param signature [Array(String, Symbol)] `[method_name, kind]`
90
+ # @param sources [Array<Array(String, String)>] `[identifier, type]` units that lost it
91
+ # @param destinations [Array<Array(String, String)>] `[identifier, type]` units that gained it
92
+ # @param covered_before [Set<String>]
93
+ # @param covered_after [Set<String>]
94
+ # @return [Array<Finding>]
95
+ def build_findings(signature, sources, destinations, covered_before, covered_after)
96
+ name, kind = signature
97
+
98
+ sources.sort.product(destinations.sort).filter_map do |(from_unit, _from_type), (to_unit, _to_type)|
99
+ next if from_unit == to_unit
100
+
101
+ finding = Finding.new(method: name, kind: kind, from_unit: from_unit, to_unit: to_unit,
102
+ covered_before: covered_before.include?(from_unit),
103
+ covered_after: covered_after.include?(to_unit))
104
+ finding if finding.covered_before && !finding.covered_after
105
+ end
106
+ end
107
+
108
+ # @return [Array(Hash, Hash)] `[method_kind, unit] => units that lost/gained it`
109
+ def method_moves
110
+ before_methods = method_index(@before)
111
+ after_methods = method_index(@after)
112
+ [signature_losses(before_methods, after_methods), signature_losses(after_methods, before_methods)]
113
+ end
114
+
115
+ # Signatures a `[identifier, type]` unit has in +from+ but not in +to+,
116
+ # grouped by signature. "Removed" and "added" are the same computation
117
+ # with the two method-index hashes swapped, so both come from this one
118
+ # method.
119
+ #
120
+ # @param from [Hash{Array(String, String) => Set}]
121
+ # @param to [Hash{Array(String, String) => Set}]
122
+ # @return [Hash{Array(String, Symbol) => Array<Array(String, String)>}]
123
+ def signature_losses(from, to)
124
+ losses = Hash.new { |hash, key| hash[key] = [] }
125
+ (from.keys | to.keys).each do |unit_key|
126
+ lost = (from[unit_key] || Set.new) - (to[unit_key] || Set.new)
127
+ lost.each { |signature| losses[signature] << unit_key }
128
+ end
129
+ losses
130
+ end
131
+
132
+ # `[identifier, type]` => Set of `[name, kind]` signatures, for units
133
+ # that list any.
134
+ #
135
+ # Keyed on `[identifier, type]`, not identifier alone: an identifier a
136
+ # model and a service both use would otherwise resolve through
137
+ # `reader.unit(identifier)`'s untyped, collision-prone lookup, and the
138
+ # second entry processed would overwrite the first's slot in this hash.
139
+ # Typed lookup and a composite key keep each unit's own method list
140
+ # under its own type.
141
+ #
142
+ # @param reader [Woods::PublishedIndex]
143
+ # @return [Hash{Array(String, String) => Set<Array(String, Symbol)>}]
144
+ def method_index(reader)
145
+ reader.units.each_with_object({}) do |entry, index|
146
+ data = reader.unit(entry['identifier'], type: entry['type'])
147
+ next unless data
148
+
149
+ signatures = METHOD_KEYS.flat_map { |key| normalized_signatures(key, data.dig('metadata', key)) }
150
+ index[[entry['identifier'], entry['type']]] = signatures.to_set if signatures.any?
151
+ end
152
+ end
153
+
154
+ # Normalizes a raw metadata method name into a `[name, kind]` signature.
155
+ #
156
+ # `public_methods` (regex-extracted) mixes instance and class methods in
157
+ # one list, marking a class method with a literal `self.` prefix.
158
+ # `class_methods` and `instance_methods` are already split by key, with
159
+ # no prefix on the name. Normalizing both shapes to the same
160
+ # `[name, kind]` pair is what lets a `self.build` in one unit's
161
+ # `public_methods` match a bare `build` in another unit's
162
+ # `class_methods`, and, just as importantly, keeps an instance method
163
+ # from matching a class method of the same bare name.
164
+ #
165
+ # @param key [String] the metadata key the names came from
166
+ # @param names [Array<String>, nil]
167
+ # @return [Array<Array(String, Symbol)>]
168
+ def normalized_signatures(key, names)
169
+ Array(names).map do |raw|
170
+ name = raw.to_s
171
+ if name.start_with?('self.')
172
+ [name.delete_prefix('self.'), :class]
173
+ else
174
+ [name, key == 'class_methods' ? :class : :instance]
175
+ end
176
+ end
177
+ end
178
+
179
+ # @param reader [Woods::PublishedIndex]
180
+ # @return [Set<String>] identifiers some test_mapping unit covers
181
+ def covered_units(reader)
182
+ reader.edges(via: COVERAGE_VIA).to_set { |edge| edge[:to] }
183
+ end
184
+ end
185
+ end
186
+ end
@@ -29,6 +29,123 @@ module Woods
29
29
  end
30
30
  end
31
31
 
32
+ # Shared line-level Ruby block accounting for the chunkers in this
33
+ # file (B-083 / #195). Mirrors the proven heuristic in
34
+ # Woods::Extractors::RakeTaskExtractor#block_opener? — credit to that
35
+ # implementation for the "modifier keywords only count in statement
36
+ # position" rule (`return if x` must not open a block). Copied rather
37
+ # than shared so the extractor and the chunkers stay independent.
38
+ #
39
+ # Known limitations (deliberate — this is a line classifier, not a
40
+ # parser): heredoc and multi-line string bodies whose lines look like
41
+ # keywords (`<<~SQL` containing a bare `end` or a leading `case`)
42
+ # will skew the depth count, multi-line brace blocks (`{ ... }`) are
43
+ # not tracked, and a trailing comment containing `do`/`end` on the
44
+ # same line as real code can miscount. The AST layer
45
+ # (Woods::Ast) is the right tool when exactness matters.
46
+ module LineDepthTracking
47
+ # Multi-line `do` block opener — `items.each do |item|`. Anchored
48
+ # to end-of-line (optionally through block args and a trailing
49
+ # comment) so prose containing "do" mid-line doesn't count.
50
+ DO_BLOCK_PATTERN = /\bdo\s*(?:\|[^|]*\|)?\s*(?:#.*)?\z/
51
+
52
+ # Keywords that open an `end`-terminated body only in statement
53
+ # position: at line start, or directly after an assignment
54
+ # (`status = if urgent?`, `@memo ||= begin`). Trailing modifier
55
+ # forms (`return if x`, `retry while busy?`) must not count, and
56
+ # ternaries / `case ... in` clauses never involve these keywords
57
+ # in statement position, so they add no depth.
58
+ STATEMENT_KEYWORD_PATTERN =
59
+ /(?:\A|=\s*)(?:if|unless|case|begin|while|until|for|def|class|module)\b/
60
+
61
+ # A line that begins with `end` closes one body — including the
62
+ # chained (`end.compact`) and modifier (`end while retry?`) forms.
63
+ # `(?!:)` excludes `end:` (a symbol hash key, legal despite the
64
+ # reserved word).
65
+ END_LINE_PATTERN = /\Aend\b(?!:)/
66
+
67
+ # `=begin` / `=end` block-comment markers. `=begin` matched the
68
+ # assignment-position keyword branch (its `=` satisfying `=\s*`) and
69
+ # counted as an opener, while `=end` matched neither opener nor
70
+ # {END_LINE_PATTERN} — a net +1 depth per block comment, after which
71
+ # the enclosing method never closed and every later method was
72
+ # appended to its chunk (STO-3).
73
+ BLOCK_COMMENT_MARKER_PATTERN = /\A=(?:begin|end)\b/
74
+
75
+ # Operator method names, longest-first so `<=>` wins over `<=`.
76
+ OPERATOR_METHOD_NAMES =
77
+ '<=>|===|==|=~|!~|!=|\[\]=|\[\]|\*\*|<<|>>|<=|>=|[+-]@|[+\-*\/%<>&|^~!]'
78
+
79
+ # Name of an operator method definition (`def ==(other)`).
80
+ OPERATOR_DEF_NAME_PATTERN = /\A\s*def\s+(?:self\s*\.\s*)?(#{OPERATOR_METHOD_NAMES})/
81
+
82
+ # Endless (Ruby 3.0+) method definition: `def show = head :ok`.
83
+ # The endless marker is an `=` AFTER the method name and optional
84
+ # parameter list — `def ==(other)` and `def x=(v)` are ordinary
85
+ # defs whose *name* contains `=`, and both open a body. The
86
+ # marker's `=` must follow whitespace or a closing paren, which
87
+ # also keeps the rare space-form setter (`def x= v`) classified
88
+ # as a normal def. Limitation: a parameter default containing
89
+ # `)` (`def f(a = (1)) = a`) is misread as a normal def.
90
+ ENDLESS_DEF_PATTERN = /
91
+ \A \s* def \s+
92
+ (?: self \s* \. \s* )?
93
+ (?: #{OPERATOR_METHOD_NAMES}
94
+ | \w+ = (?= \s* \( ) # setter name (`def x=(v)`) — `=` is part of the name
95
+ | \w+ [?!]? # plain name, may end with ? or !
96
+ )
97
+ (?: \s* \( [^)]* \) \s* | \s+ )
98
+ = \s* \S
99
+ /x
100
+
101
+ private
102
+
103
+ # Does this line open a block that a later `end` will close?
104
+ #
105
+ # @param line [String] raw source line
106
+ # @return [Boolean]
107
+ def block_opener?(line)
108
+ stripped = line.strip
109
+ return false if stripped.empty? || stripped.start_with?('#')
110
+ return false if stripped.match?(BLOCK_COMMENT_MARKER_PATTERN)
111
+ return false if endless_def?(stripped)
112
+ # An opener whose `end` sits on the same line is self-balancing
113
+ # (`if x then y end`) — count neither side.
114
+ return false if stripped.match?(/\bend\s*\z/)
115
+
116
+ stripped.match?(STATEMENT_KEYWORD_PATTERN) || stripped.match?(DO_BLOCK_PATTERN)
117
+ end
118
+
119
+ # Does this line close a block (`end`, `end.compact`, `end while x`)?
120
+ #
121
+ # @param line [String] raw source line
122
+ # @return [Boolean]
123
+ def block_terminator?(line)
124
+ line.strip.match?(END_LINE_PATTERN)
125
+ end
126
+
127
+ # Is this line an endless method definition (`def show = head :ok`)?
128
+ # Endless defs have no body and no closing `end`, so they must
129
+ # never enter method-tracking state or contribute depth.
130
+ #
131
+ # @param line [String] raw source line
132
+ # @return [Boolean]
133
+ def endless_def?(line)
134
+ line.match?(ENDLESS_DEF_PATTERN)
135
+ end
136
+
137
+ # Extract an operator method name (`==`, `<=>`, `[]`) from a def
138
+ # line. Fallback for the chunkers' `\w+`-based name extraction —
139
+ # without it `current_method` is set to nil (falsy), tracking never
140
+ # engages, and the operator method's body leaks out of its chunk.
141
+ #
142
+ # @param line [String] raw source line
143
+ # @return [String, nil] the operator name, or nil for non-operator defs
144
+ def operator_def_name(line)
145
+ line[OPERATOR_DEF_NAME_PATTERN, 1]
146
+ end
147
+ end
148
+
32
149
  # Class-like unit types that MethodChunker handles — anything
33
150
  # shaped as "class or module with public methods, maybe privates,
34
151
  # maybe callbacks/filters." Extend this list when new extractors
@@ -298,6 +415,7 @@ module Woods
298
415
  # @api private
299
416
  class ModelChunker
300
417
  include ChunkBuilder
418
+ include LineDepthTracking
301
419
 
302
420
  ASSOCIATION_PATTERN = /^\s*(has_many|has_one|belongs_to|has_and_belongs_to_many)\b/
303
421
  VALIDATION_PATTERN = /^\s*validates?\b/
@@ -370,12 +488,15 @@ module Woods
370
488
  def track_method_line(state, line)
371
489
  state[:sections][:methods] << line
372
490
  update_method_depth(state, line)
373
- state[:in_method] = false if state[:depth] <= 0 && line.strip.match?(/^end\s*$/)
491
+ state[:in_method] = false if state[:depth] <= 0 && block_terminator?(line)
374
492
  end
375
493
 
494
+ # Balance nested blocks (`do`, multi-line `if`/`unless`/`case`/
495
+ # `begin`/`while`/`until`, nested `def`) so a nested `end` doesn't
496
+ # close the method early (B-083 / #195).
376
497
  def update_method_depth(state, line)
377
- state[:depth] += 1 if line.match?(/\bdo\b|\bdef\b/) && !line.match?(/\bend\b/)
378
- state[:depth] -= 1 if line.strip == 'end' || (line.match?(/\bend\s*$/) && state[:depth].positive?)
498
+ state[:depth] += 1 if block_opener?(line)
499
+ state[:depth] -= 1 if block_terminator?(line)
379
500
  end
380
501
 
381
502
  # Classify a single non-method line.
@@ -404,9 +525,14 @@ module Woods
404
525
  end
405
526
 
406
527
  def start_method(state, line)
528
+ state[:sections][:methods] << line
529
+ # An endless def (`def title = super&.strip`) has no body and no
530
+ # closing `end` — entering tracking state for it would swallow
531
+ # every subsequent line into :methods.
532
+ return if endless_def?(line)
533
+
407
534
  state[:in_method] = true
408
535
  state[:depth] = 1
409
- state[:sections][:methods] << line
410
536
  end
411
537
 
412
538
  def assign_fallback(state, line)
@@ -424,8 +550,7 @@ module Woods
424
550
  # @api private
425
551
  class ControllerChunker
426
552
  include ChunkBuilder
427
-
428
- FILTER_PATTERN = /^\s*(before_action|after_action|around_action|skip_before_action)\b/
553
+ include LineDepthTracking
429
554
 
430
555
  # @param unit [ExtractedUnit]
431
556
  def initialize(unit)
@@ -458,10 +583,13 @@ module Woods
458
583
  state
459
584
  end
460
585
 
586
+ # While inside an action body, collect every line and balance
587
+ # nested blocks (`do`, multi-line `if`/`case`/…) so a nested `end`
588
+ # doesn't close the action early (B-083 / #195).
461
589
  def track_action_line(state, line)
462
590
  state[:actions][state[:current_action]] << line
463
- state[:depth] += 1 if line.match?(/\bdo\b/) && !line.match?(/\bend\b/)
464
- return unless line.strip.match?(/^end\s*$/)
591
+ state[:depth] += 1 if block_opener?(line)
592
+ return unless block_terminator?(line)
465
593
 
466
594
  state[:depth] -= 1
467
595
  return unless state[:depth] <= 0
@@ -482,10 +610,18 @@ module Woods
482
610
  end
483
611
 
484
612
  def start_action(state, line)
485
- action_name = line[/def\s+(\w+)/, 1]
613
+ # Capture an optional `self.` so a class method chunks under
614
+ # `self.<name>` instead of every one landing on the key "self"
615
+ # and clobbering the previous class method's collected lines.
616
+ action_name = line[/def\s+((?:self\.)?\w+)/, 1]
617
+ state[:actions][action_name] = [line]
618
+ # An endless def (`def show = head :ok`) is a complete action on
619
+ # its own line — no body follows, so entering tracking state
620
+ # would swallow every subsequent action into this chunk.
621
+ return if endless_def?(line)
622
+
486
623
  state[:current_action] = action_name
487
624
  state[:depth] = 1
488
- state[:actions][action_name] = [line]
489
625
  end
490
626
 
491
627
  # @param state [Hash]
@@ -520,6 +656,7 @@ module Woods
520
656
  # @api private
521
657
  class MethodChunker
522
658
  include ChunkBuilder
659
+ include LineDepthTracking
523
660
 
524
661
  # @param unit [ExtractedUnit]
525
662
  def initialize(unit)
@@ -555,15 +692,15 @@ module Woods
555
692
  end
556
693
 
557
694
  # While inside a method body, collect every line and track depth so
558
- # we know when the method closes. Blocks (`do...end`, `if...end`)
559
- # nest inside methods and must be balanced before the method's own
560
- # `end` line counts.
695
+ # we know when the method closes. Blocks (`do...end`, multi-line
696
+ # `if...end`, `case...end`, …) nest inside methods and must be
697
+ # balanced before the method's own `end` line counts (B-083 / #195).
561
698
  def track_method_line(state, line)
562
699
  target = state[:in_private] ? state[:private_methods] : state[:methods][state[:current_method]]
563
700
  target << line
564
701
 
565
- state[:depth] += 1 if line.match?(/\bdo\b/) && !line.match?(/\bend\b/)
566
- return unless line.strip.match?(/^end\s*$/)
702
+ state[:depth] += 1 if block_opener?(line)
703
+ return unless block_terminator?(line)
567
704
 
568
705
  state[:depth] -= 1
569
706
  return unless state[:depth] <= 0
@@ -588,9 +725,7 @@ module Woods
588
725
  end
589
726
 
590
727
  def start_method(state, line)
591
- method_name = line[/def\s+(?:self\.)?(\w+)/, 1]
592
- state[:current_method] = method_name
593
- state[:depth] = 1
728
+ method_name = line[/def\s+(?:self\.)?(\w+)/, 1] || operator_def_name(line)
594
729
 
595
730
  if state[:in_private]
596
731
  state[:private_methods] << line
@@ -600,6 +735,13 @@ module Woods
600
735
  # methods in source order.
601
736
  state[:methods][method_name] = [line]
602
737
  end
738
+
739
+ # An endless def (`def ping = :pong`) is complete on its own line
740
+ # — entering tracking state would swallow every later method.
741
+ return if endless_def?(line)
742
+
743
+ state[:current_method] = method_name
744
+ state[:depth] = 1
603
745
  end
604
746
 
605
747
  # Build the final chunk array from the parse state.
@@ -44,11 +44,17 @@ module Woods
44
44
  def log(tool:, params:, confirmed:, result_summary:)
45
45
  ensure_directory!
46
46
 
47
+ # Redact BEFORE truncating (CON-5). Truncation cuts at
48
+ # MAX_FIELD_CHARS, and a credential straddling that boundary is split
49
+ # in two: the scanner's word-boundary/length-anchored patterns no
50
+ # longer match the surviving prefix, so cleartext `sk_live_…` reached
51
+ # the JSONL. Scanning the whole value first means the boundary can
52
+ # only ever fall inside `[REDACTED]`.
47
53
  entry = {
48
54
  tool: tool,
49
- params: redact(truncate_deep(params)),
55
+ params: truncate_deep(redact(params)),
50
56
  confirmed: confirmed,
51
- result_summary: redact(truncate_value(result_summary)),
57
+ result_summary: truncate_value(redact(result_summary)),
52
58
  timestamp: Time.now.utc.iso8601
53
59
  }
54
60
 
@@ -121,7 +127,10 @@ module Woods
121
127
  def entries
122
128
  return [] unless File.exist?(@path)
123
129
 
124
- File.readlines(@path).filter_map do |line|
130
+ # Explicit UTF-8: entries are written as UTF-8 (the truncation notice
131
+ # alone carries a multibyte ellipsis), so a bare read would tag them
132
+ # with the process default external encoding and raise under LANG=C.
133
+ File.readlines(@path, encoding: Encoding::UTF_8).filter_map do |line|
125
134
  JSON.parse(line.strip) unless line.strip.empty?
126
135
  end
127
136
  end
@@ -2,27 +2,16 @@
2
2
 
3
3
  module Woods
4
4
  module Console
5
- # Canonical console-tool protocol contract shared by {StubBridge}
6
- # (the JSON-lines scaffold) and {EmbeddedExecutor} (the in-process
7
- # executor that ships today). The eventual real bridge-process
8
- # implementation (Option D — see `docs/design/CONSOLE_SERVER.md`)
9
- # will also reference this module so every executor that speaks the
10
- # protocol agrees on the tool vocabulary.
5
+ # Canonical Tier 1 vocabulary used by {EmbeddedExecutor}. The historical
6
+ # name remains for compatibility after removal of the JSON-lines bridge.
11
7
  #
12
- # Three constants live here:
8
+ # Two constants live here:
13
9
  #
14
10
  # - {SUPPORTED_TOOLS} — the canonical Tier 1 tool list.
15
11
  # - {TIER1_TOOLS} — alias, kept as a distinct name for call
16
12
  # sites that reason about tier semantics rather than the whole
17
13
  # supported set.
18
- # - {TOOL_HANDLERS} — tool → `handle_<tool>` method-symbol map.
19
14
  #
20
- # Previously these lived on {StubBridge} and {EmbeddedExecutor}
21
- # borrowed them with `TIER1_TOOLS = StubBridge::TIER1_TOOLS`, which
22
- # reads as "the real executor borrows constants from the stub" —
23
- # backwards. Extracting the protocol here lets the real executor
24
- # (and a future non-stub `Bridge` class) claim the contract without
25
- # importing the scaffold.
26
15
  module BridgeProtocol
27
16
  SUPPORTED_TOOLS = %w[
28
17
  count
@@ -37,8 +26,6 @@ module Woods
37
26
  ].freeze
38
27
 
39
28
  TIER1_TOOLS = SUPPORTED_TOOLS
40
-
41
- TOOL_HANDLERS = SUPPORTED_TOOLS.to_h { |t| [t, :"handle_#{t}"] }.freeze
42
29
  end
43
30
  end
44
31
  end