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
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Woods
7
+ class PublishedIndex
8
+ # Reads one type's unit file directly from a pinned payload, bypassing
9
+ # {Woods::MCP::IndexReader#find_unit}'s identifier-only map, which lets a
10
+ # later `TYPE_DIRS` entry silently overwrite an earlier one when two
11
+ # types share an identifier.
12
+ #
13
+ # Split out of {PublishedIndex} for the same reason as {EdgeShaper}: a
14
+ # pure function of the payload directory and the reader's own per-type
15
+ # index, with no need to touch the retention lock.
16
+ module TypedUnitReader
17
+ # @param payload_dir [Pathname] the pinned generation's payload directory
18
+ # @param reader [Woods::MCP::IndexReader]
19
+ # @param identifier [String]
20
+ # @param type [String] singular type name
21
+ # @return [Hash, nil] string-keyed unit, or nil when this type has no
22
+ # such identifier
23
+ def self.call(payload_dir, reader, identifier, type)
24
+ dir = Woods::MCP::IndexReader::TYPE_TO_DIR[type]
25
+ return nil unless dir
26
+ return nil unless reader.list_units(type: type).any? { |entry| entry['identifier'] == identifier }
27
+
28
+ path = payload_dir.join(dir, filename_for(identifier))
29
+ return nil unless path.file?
30
+
31
+ JSON.parse(path.binread.force_encoding(Encoding::UTF_8))
32
+ end
33
+
34
+ # The on-disk filename for a unit, matching
35
+ # `Woods::MCP::IndexReader#build_identifier_map`'s naming exactly.
36
+ #
37
+ # @param identifier [String]
38
+ # @return [String]
39
+ def self.filename_for(identifier)
40
+ base = identifier.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
41
+ digest = Digest::SHA256.hexdigest(identifier)[0, 8]
42
+ "#{base}_#{digest}.json"
43
+ end
44
+
45
+ private_class_method :filename_for
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'pathname'
6
+ require_relative 'generation'
7
+ require_relative 'payload_store'
8
+ require_relative 'mcp/index_reader'
9
+ require_relative 'published_index/edge_shaper'
10
+ require_relative 'published_index/generation_catalog'
11
+ require_relative 'published_index/typed_unit_reader'
12
+
13
+ module Woods
14
+ class Error < StandardError; end unless defined?(Woods::Error)
15
+
16
+ # A small, stable Ruby API over a published Woods index for tools that are
17
+ # not MCP clients: RuboCop cops, CI gate scripts, `woods:check:*` tasks (#280).
18
+ #
19
+ # Three things it does that the MCP reader does not expose directly:
20
+ #
21
+ # * pin one published generation (the current one, or a retained older
22
+ # `payloads/gen-N`) for the reader's whole lifetime, so a check reads one
23
+ # consistent snapshot and can compare two generations against each other;
24
+ # * hold that generation's {Woods::PayloadStore} retention lock for as long
25
+ # as the reader is open, so a concurrent publish's pruning cannot remove
26
+ # the payload out from under it;
27
+ # * iterate edges with their attributes as plain hashes, and hand out a
28
+ # checksum keyed to the pinned payload for RuboCop's
29
+ # `external_dependency_checksum`.
30
+ #
31
+ # Everything is read-only and needs no Rails. Unit hashes are string-keyed,
32
+ # exactly as written on disk. Unlike {Woods::MCP::IndexReader}, a
33
+ # {PublishedIndex} never auto-refreshes: every fact it returns comes from
34
+ # the one generation it opened with, for its entire lifetime.
35
+ #
36
+ # Holds an open file handle (a shared lock on the pinned generation's
37
+ # `manifest.json`) for as long as the reader is open. Use the block form or
38
+ # call {#close} explicitly; do not let an instance leak past the scope that
39
+ # needs it.
40
+ #
41
+ # **Not thread-safe.** A single instance is meant for one script or cop
42
+ # process reading one generation; it keeps no mutex around its lock file or
43
+ # its underlying {Woods::MCP::IndexReader}. Give each thread its own
44
+ # {PublishedIndex} rather than sharing one.
45
+ #
46
+ # @example A cop keyed on the index
47
+ # Woods::PublishedIndex.open(Rails.root.join('tmp/woods')) do |index|
48
+ # index.table_database_map # => { "orders" => "primary", "events" => "analytics" }
49
+ # index.external_dependency_checksum # => "9f2c..." (changes on every publish)
50
+ # end
51
+ #
52
+ # @example Comparing two generations
53
+ # before = Woods::PublishedIndex.new(dir, generation: 41)
54
+ # after = Woods::PublishedIndex.new(dir, generation: 42)
55
+ # begin
56
+ # # ...
57
+ # ensure
58
+ # before.close
59
+ # after.close
60
+ # end
61
+ #
62
+ class PublishedIndex
63
+ # Raised when `generation.json` exists but cannot be parsed. Distinct from
64
+ # a *missing* pointer file, which means a flat (pre-2.0) index and is not
65
+ # an error.
66
+ class CorruptPointerError < Woods::Error; end
67
+
68
+ # @return [Pathname] the index root passed to {#initialize}
69
+ attr_reader :index_dir
70
+
71
+ # Published generation numbers, ascending.
72
+ #
73
+ # A generation counts as published only when its number is at or below
74
+ # the pointer `generation.json` currently names AND its payload directory
75
+ # holds a `manifest.json`. A directory numbered above the pointer, or one
76
+ # without a manifest (an interrupted or failed publish), is never listed.
77
+ #
78
+ # @param index_dir [String, Pathname]
79
+ # @return [Array<Integer>]
80
+ # @raise [CorruptPointerError] when `generation.json` exists but will not parse
81
+ def self.available_generations(index_dir)
82
+ GenerationCatalog.available(Pathname.new(index_dir.to_s))
83
+ end
84
+
85
+ # Open a reader, yield it, and guarantee the generation lock is released.
86
+ #
87
+ # Without a block this behaves exactly like {.new}: the caller owns the
88
+ # lock and must call {#close}.
89
+ #
90
+ # @param (see #initialize)
91
+ # @yieldparam index [PublishedIndex]
92
+ # @return [PublishedIndex] when no block is given
93
+ # @return [Object] the block's return value, when a block is given
94
+ def self.open(index_dir, generation: nil)
95
+ index = new(index_dir, generation: generation)
96
+ return index unless block_given?
97
+
98
+ begin
99
+ yield index
100
+ ensure
101
+ index.close
102
+ end
103
+ end
104
+
105
+ # @param index_dir [String, Pathname] the index root (holds generation.json)
106
+ # @param generation [Integer, nil] pin a published generation; defaults to
107
+ # the currently published one
108
+ # @raise [ArgumentError] when the index, or the requested generation, is
109
+ # not published
110
+ # @raise [CorruptPointerError] when `generation.json` exists but will not parse
111
+ def initialize(index_dir, generation: nil)
112
+ @index_dir = Pathname.new(index_dir.to_s)
113
+ @lock_file = nil
114
+ pointer = GenerationCatalog.pointer(@index_dir)
115
+
116
+ if pointer.number.zero? && generation.nil?
117
+ initialize_flat_index
118
+ else
119
+ initialize_generation(generation || pointer.number)
120
+ end
121
+
122
+ open_reader!
123
+ end
124
+
125
+ # @return [Integer] the generation being read; 0 for a flat index
126
+ # @return [Pathname] the directory the units are read from
127
+ attr_reader :generation_number, :payload_dir
128
+
129
+ # Release the retention lock held on the pinned generation, if any.
130
+ #
131
+ # Safe to call more than once. A flat index (generation 0) holds no lock,
132
+ # so this is a no-op for it.
133
+ #
134
+ # @return [void]
135
+ def close
136
+ @lock_file&.flock(File::LOCK_UN)
137
+ @lock_file&.close
138
+ @lock_file = nil
139
+ end
140
+
141
+ # @return [Hash] parsed manifest.json
142
+ def manifest
143
+ @reader.manifest
144
+ end
145
+
146
+ # Look up one unit by identifier.
147
+ #
148
+ # `Woods::MCP::IndexReader#find_unit` (used when +type+ is nil) keys its
149
+ # identifier map on identifier alone: if two type directories both list
150
+ # the same identifier, whichever type sorts last in
151
+ # `Woods::MCP::IndexReader::TYPE_DIRS` wins, silently. Pass +type+ to read
152
+ # that type's unit file directly and skip the collision.
153
+ #
154
+ # @param identifier [String]
155
+ # @param type [String, Symbol, nil] singular type name; disambiguates an
156
+ # identifier shared by more than one type
157
+ # @return [Hash, nil] string-keyed unit, or nil
158
+ def unit(identifier, type: nil)
159
+ return @reader.find_unit(identifier) if type.nil?
160
+
161
+ TypedUnitReader.call(@payload_dir, @reader, identifier, type.to_s)
162
+ end
163
+
164
+ # Index entries, each with a `'type'` key added.
165
+ #
166
+ # @param type [String, Symbol, nil] singular type name to restrict to
167
+ # @return [Array<Hash>]
168
+ def units(type: nil)
169
+ dirs = if type
170
+ dir = Woods::MCP::IndexReader::TYPE_TO_DIR[type.to_s]
171
+ dir ? [dir] : []
172
+ else
173
+ Woods::MCP::IndexReader::TYPE_DIRS
174
+ end
175
+ dirs.flat_map do |dir|
176
+ @reader.list_units(type: Woods::MCP::IndexReader::DIR_TO_TYPE[dir])
177
+ .map { |entry| entry.merge('type' => Woods::MCP::IndexReader::DIR_TO_TYPE[dir]) }
178
+ end
179
+ end
180
+
181
+ # Every forward edge in the graph, primary nodes and variants alike.
182
+ #
183
+ # An identifier shared by more than one type contributes one edge per
184
+ # owning type; two edges are never folded into one just because they look
185
+ # alike once reduced to `{from, to, via, through, through_db, disable_joins}`.
186
+ #
187
+ # @param via [String, Symbol, nil] restrict to one relationship label
188
+ # @return [Array<Hash>] `{ from:, to:, via:, through:, through_db:, disable_joins: }`
189
+ def edges(via: nil)
190
+ wanted = via&.to_s
191
+ EdgeShaper.call(@reader.raw_graph_data).select { |edge| wanted.nil? || edge[:via] == wanted }
192
+ end
193
+
194
+ # @yieldparam edge [Hash] see {#edges}
195
+ # @return [void]
196
+ def each_edge(via: nil, &block)
197
+ edges(via: via).each(&block)
198
+ end
199
+
200
+ # @param identifier [String]
201
+ # @param via [String, Symbol, nil]
202
+ # @return [Array<String>] identifiers that depend on `identifier`
203
+ def dependents_of(identifier, via: nil)
204
+ return Array((@reader.raw_graph_data['reverse'] || {})[identifier]).dup if via.nil?
205
+
206
+ edges(via: via).select { |edge| edge[:to] == identifier }.map { |edge| edge[:from] }.uniq
207
+ end
208
+
209
+ # table name => database name, from model units that carry
210
+ # `metadata.database` (Rails 6.1+ extractions).
211
+ #
212
+ # Reads every model unit once; cache it in a cop.
213
+ #
214
+ # @return [Hash{String => String}]
215
+ def table_database_map
216
+ units(type: 'model').each_with_object({}) do |entry, map|
217
+ data = unit(entry['identifier'], type: 'model')
218
+ next unless data
219
+
220
+ table = data.dig('metadata', 'table_name')
221
+ database = data.dig('metadata', 'database')
222
+ map[table] = database if table && database
223
+ end
224
+ end
225
+
226
+ # A digest of the pinned payload's `manifest.json`. RuboCop re-runs a cop
227
+ # on every file when this value changes, so keying on the manifest that
228
+ # was rewritten by the publish this reader is pinned to catches every
229
+ # publish, whether the reader ended up on a flat index or a numbered
230
+ # generation.
231
+ #
232
+ # @return [String] SHA-256 hex
233
+ def external_dependency_checksum
234
+ Digest::SHA256.file(@payload_dir.join('manifest.json').to_s).hexdigest
235
+ end
236
+
237
+ private
238
+
239
+ # @return [void]
240
+ def initialize_flat_index
241
+ raise ArgumentError, "No manifest.json found in: #{@index_dir}" unless @index_dir.join('manifest.json').file?
242
+
243
+ @generation_number = 0
244
+ @payload_dir = @index_dir
245
+ end
246
+
247
+ # @param number [Integer]
248
+ # @return [void]
249
+ # @raise [ArgumentError] when +number+ is not a published generation
250
+ def initialize_generation(number)
251
+ dir = Woods::PayloadStore.new(@index_dir).path_for(number)
252
+ unless self.class.available_generations(@index_dir).include?(number)
253
+ raise ArgumentError, "Generation #{number} is not published under #{@index_dir} (expected #{dir.basename})"
254
+ end
255
+
256
+ @generation_number = number
257
+ @payload_dir = dir
258
+ acquire_retention_lock!
259
+ end
260
+
261
+ # Hold the same shared advisory lock on the generation's `manifest.json`
262
+ # that {Woods::MCP::IndexReader} takes to survive concurrent retention
263
+ # (see `PayloadStore#prune`'s doc comment on the lock protocol), but for
264
+ # the whole lifetime of this reader rather than one pinned read. Kept
265
+ # open until {#close}.
266
+ #
267
+ # @return [void]
268
+ def acquire_retention_lock!
269
+ @lock_file = File.open(@payload_dir.join('manifest.json').to_s, File::RDONLY)
270
+ @lock_file.flock(File::LOCK_SH)
271
+ end
272
+
273
+ # Build the underlying reader, releasing any retention lock already
274
+ # acquired ({#initialize_generation}) before letting the failure
275
+ # propagate. Without this a `PublishedIndex` that fails to finish
276
+ # constructing would leak an open advisory lock for the life of the
277
+ # process.
278
+ #
279
+ # @return [void]
280
+ def open_reader!
281
+ @reader = Woods::MCP::IndexReader.new(@payload_dir.to_s, auto_refresh: false)
282
+ rescue Exception # rubocop:disable Lint/RescueException -- release the lock for every failure mode, then re-raise unchanged
283
+ close
284
+ raise
285
+ end
286
+ end
287
+ end
data/lib/woods/railtie.rb CHANGED
@@ -1,16 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'woods/railtie_support'
4
+
3
5
  module Woods
4
6
  # Railtie integrates Woods into Rails applications.
5
7
  # Loads rake tasks automatically when the gem is bundled.
6
- # Conditionally inserts session tracer middleware when enabled.
8
+ #
9
+ # == Console MCP (#183)
10
+ #
11
+ # The console stack (OriginGuard -> BearerAuth -> Console::RackMiddleware)
12
+ # is mounted unconditionally, but every decision is deferred to request
13
+ # time: railtie initializers run before `:load_config_initializers`, so
14
+ # flags and tokens set in `config/initializers/woods.rb` (the
15
+ # generator-created location) do not exist yet when middleware arguments
16
+ # are captured. Both guards are scoped to `console_mcp_path` — requests
17
+ # outside it pass through untouched — and while `console_mcp_enabled` is
18
+ # false the whole stack passes through, so an unconfigured host app is
19
+ # completely unaffected. Boot-time validation of the settled configuration
20
+ # happens in `after_initialize` via {Woods::RailtieSupport}.
21
+ #
22
+ # == Session tracer
23
+ #
24
+ # SessionTracer::Middleware requires a store at construction, so it cannot
25
+ # be mounted lazily. It stays conditionally mounted, and the
26
+ # `after_initialize` hook warns when the flag was set too late to mount
27
+ # (i.e. in config/initializers instead of config/application.rb).
7
28
  class Railtie < Rails::Railtie
8
29
  rake_tasks do
9
30
  load File.expand_path('../tasks/woods.rake', __dir__)
31
+ # Loaded here too, or `woods:evaluate` exists on no host — the file
32
+ # shipped in the gem for its whole life without ever being loaded, so
33
+ # the retrieval-quality harness had no entry point at all (#212).
34
+ load File.expand_path('../tasks/woods_evaluation.rake', __dir__)
35
+ # Read-only checks over retained generations (#280); no Rails boot.
36
+ load File.expand_path('../tasks/woods_checks.rake', __dir__)
10
37
  end
11
38
 
12
39
  initializer 'woods.session_tracer' do |app|
13
- config = Woods.configuration
40
+ # RailtieSupport.config initializes defaults when the host app never
41
+ # called Woods.configure — this initializer runs in every app.
42
+ config = Woods::RailtieSupport.config
43
+ Woods::RailtieSupport.session_tracer_state = nil
14
44
  next unless config.session_tracer_enabled
15
45
 
16
46
  if defined?(Rails) && Rails.env.production? && !config.session_tracer_allow_production
@@ -21,6 +51,7 @@ module Woods
21
51
  else
22
52
  warn msg
23
53
  end
54
+ Woods::RailtieSupport.session_tracer_state = :skipped_production
24
55
  next
25
56
  end
26
57
 
@@ -32,47 +63,55 @@ module Woods
32
63
  session_id_proc: config.session_id_proc,
33
64
  exclude_paths: config.session_exclude_paths
34
65
  )
66
+ Woods::RailtieSupport.session_tracer_state = :mounted
35
67
  end
36
68
 
37
69
  initializer 'woods.console_mcp' do |app|
38
- config = Woods.configuration
39
- next unless config.console_mcp_enabled
40
-
41
70
  require 'woods/console/rack_middleware'
42
71
  require 'woods/mcp/bearer_auth'
43
72
  require 'woods/mcp/origin_guard'
44
73
 
45
- token = config.console_mcp_token
46
- production = defined?(Rails) && Rails.env.production?
47
- token_missing = token.nil? || token.to_s.empty?
48
-
49
- if token_missing
50
- msg = '[Woods Console] console_mcp_token is not set — Console MCP is a high-privilege ' \
51
- 'endpoint that runs SQL and model introspection against the live database. ' \
52
- 'Set Woods.configuration.console_mcp_token (or WOODS_CONSOLE_MCP_TOKEN env var) ' \
53
- 'to a 32+ character random string.'
54
- raise Woods::ConfigurationError, msg if production
55
-
56
- # Non-prod without a token: refuse to wire the middleware at all.
57
- # Earlier iterations fell through and installed the RackMiddleware
58
- # with ZERO auth/origin guard in front of it — a binding on 0.0.0.0
59
- # (common in devcontainers/docker-compose) would expose an
60
- # unauthenticated SQL-bearing endpoint to every local process.
61
- # Fail-closed: warn and skip.
62
- warn "#{msg} Refusing to mount the Console MCP middleware until a token is configured."
63
- next
64
- end
74
+ # Mounted unconditionally; enablement, token, and allow-list are all
75
+ # resolved at request time (see RailtieSupport) so values set in
76
+ # config/initializers take effect. The path is the one thing captured
77
+ # now — changing console_mcp_path must happen in config/application.rb
78
+ # (after_initialize warns otherwise).
79
+ path = Woods::RailtieSupport.config.console_mcp_path
80
+ Woods::RailtieSupport.console_mounted_path = path
81
+ enabled = Woods::RailtieSupport.console_enabled_proc
65
82
 
66
83
  # Origin guard first — rejects cross-origin POSTs before any auth cost.
67
- # BearerAuth next — requires `Authorization: Bearer <token>` on every request.
68
- app.middleware.use(Woods::MCP::OriginGuard, allowed_origins: Array(config.console_mcp_allowed_origins))
69
- app.middleware.use(Woods::MCP::BearerAuth, token: token)
84
+ # BearerAuth next — requires `Authorization: Bearer <token>` at the
85
+ # console path, failing closed (401) while no usable token is
86
+ # configured. Both are scoped to the console path and inert while
87
+ # console_mcp_enabled is false.
88
+ app.middleware.use(
89
+ Woods::MCP::OriginGuard,
90
+ allowed_origins: Woods::RailtieSupport.console_allowed_origins_proc,
91
+ path: path,
92
+ enabled: enabled
93
+ )
94
+ app.middleware.use(
95
+ Woods::MCP::BearerAuth,
96
+ token: Woods::RailtieSupport.console_token_proc,
97
+ path: path,
98
+ enabled: enabled
99
+ )
70
100
 
71
101
  app.middleware.use(
72
102
  Woods::Console::RackMiddleware,
73
- path: config.console_mcp_path,
74
- embedded_read_tools: config.console_embedded_read_tools
103
+ path: path,
104
+ embedded_read_tools: Woods::RailtieSupport.console_read_tools_proc
105
+ )
106
+ end
107
+
108
+ # Runs after :load_config_initializers, when the configuration is final —
109
+ # this is where boot-time validation belongs (#183).
110
+ config.after_initialize do
111
+ Woods::RailtieSupport.verify_console_configuration!(
112
+ production: defined?(Rails) && Rails.env.production?
75
113
  )
114
+ Woods::RailtieSupport.verify_session_tracer_configuration!
76
115
  end
77
116
  end
78
117
  end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ # Rails-independent logic behind {Woods::Railtie}.
5
+ #
6
+ # Rails railtie initializers run before `:load_config_initializers`, so
7
+ # anything the railtie reads from Woods.configuration at initializer time
8
+ # sees the defaults — not the values set in `config/initializers/woods.rb`
9
+ # (the generator-created location every doc names). The railtie therefore
10
+ # mounts the console stack unconditionally and defers every decision to
11
+ # request time via the procs below, and validates the settled
12
+ # configuration from an `after_initialize` hook (#183).
13
+ #
14
+ # This module carries no Rails dependency so the gem's unit specs can
15
+ # exercise the request-time procs and boot-time verification without
16
+ # loading Rails::Railtie.
17
+ module RailtieSupport
18
+ # Boot-time message for a console enabled without a token. Raised in
19
+ # production; warned everywhere else. Only the HTTP transport enforces
20
+ # the token ({Woods::MCP::BearerAuth} fails closed with 401 at the console
21
+ # path); the stdio server never sees a bearer header, so for it a missing
22
+ # token is a production-boot failure, not a request failure.
23
+ MISSING_TOKEN_MESSAGE =
24
+ '[Woods Console] console_mcp_token is not set — Console MCP is a high-privilege ' \
25
+ 'endpoint that runs SQL and model introspection against the live database. ' \
26
+ 'Set Woods.configuration.console_mcp_token (or WOODS_CONSOLE_MCP_TOKEN env var) ' \
27
+ 'to a 32+ character random string.'
28
+
29
+ # Transport qualifier appended to {MISSING_TOKEN_MESSAGE} outside
30
+ # production (G-3). The token authenticates the *HTTP* Console transport
31
+ # only: {Woods::Console::RackMiddleware} and {Woods::MCP::BearerAuth} are
32
+ # what return the 401. The stdio transport (`rake woods:console`,
33
+ # `exe/woods-console`) speaks over a pipe and neither sends nor consumes a
34
+ # bearer token, so an unqualified "requests will be refused (401)" sent
35
+ # stdio-only operators configuring a token their setup never uses.
36
+ MISSING_TOKEN_TRANSPORT_NOTE =
37
+ 'HTTP Console MCP requests will be refused (401) until one is set. The stdio ' \
38
+ 'transport (rake woods:console) does not transmit or use a bearer token, so a ' \
39
+ 'stdio-only setup still works — set the token before exposing the HTTP endpoint. ' \
40
+ 'Production boot will raise while Console MCP is enabled without a token.'
41
+
42
+ class << self
43
+ # @return [String, nil] path the console stack was mounted at (captured
44
+ # at railtie-initializer time). Used by
45
+ # {.verify_console_configuration!} to detect a `console_mcp_path`
46
+ # set too late to take effect.
47
+ attr_accessor :console_mounted_path
48
+
49
+ # @return [Symbol, nil] `:mounted` when the session tracer middleware
50
+ # was inserted, `:skipped_production` when the production guard
51
+ # refused it, nil when the flag was off at railtie-initializer time.
52
+ attr_accessor :session_tracer_state
53
+
54
+ # Request-time predicate handed to the console guards and consulted by
55
+ # {Woods::Console::RackMiddleware}. Reads the live configuration on
56
+ # every call, so a flag set after the middleware was inserted (e.g. in
57
+ # config/initializers) still takes effect.
58
+ #
59
+ # @return [Proc] arity-0 proc returning the current enabled flag
60
+ def console_enabled_proc
61
+ -> { config.console_mcp_enabled }
62
+ end
63
+
64
+ # Deferred bearer token for {Woods::MCP::BearerAuth}, resolved on each
65
+ # guarded request.
66
+ #
67
+ # @return [Proc] arity-0 proc returning the configured token (or nil)
68
+ def console_token_proc
69
+ -> { config.console_mcp_token }
70
+ end
71
+
72
+ # Deferred origin allow-list for {Woods::MCP::OriginGuard}, resolved on
73
+ # the first guarded request.
74
+ #
75
+ # @return [Proc] arity-0 proc returning the configured allow-list
76
+ def console_allowed_origins_proc
77
+ -> { Array(config.console_mcp_allowed_origins) }
78
+ end
79
+
80
+ # Deferred read-tools flag for {Woods::Console::RackMiddleware},
81
+ # resolved when the embedded server is first built.
82
+ #
83
+ # @return [Proc] arity-0 proc returning the configured flag
84
+ def console_read_tools_proc
85
+ -> { config.console_embedded_read_tools }
86
+ end
87
+
88
+ # The live configuration, initialized to defaults when the host never
89
+ # called Woods.configure — the console stack is mounted in every app
90
+ # now, so nothing here may assume a configure block ran.
91
+ #
92
+ # @return [Woods::Configuration]
93
+ def config
94
+ Woods.configuration || Woods.configure
95
+ end
96
+
97
+ # Boot-time console validation, run from `after_initialize` once the
98
+ # final configuration is known. With the console enabled and no usable
99
+ # token, every HTTP request at the console path will be refused with
100
+ # 401 — production refuses to boot instead (matching the pre-#183
101
+ # fail-closed posture), other environments warn loudly. The stdio
102
+ # server carries no bearer check, so the warning says which transport
103
+ # it is about rather than claiming every request fails. A token shorter than
104
+ # {Woods::MCP::BearerAuth::MIN_TOKEN_LENGTH} is a misconfiguration and
105
+ # raises in every environment (as the eager BearerAuth constructor
106
+ # used to). Also warns when `console_mcp_path` changed after the stack
107
+ # was mounted.
108
+ #
109
+ # @param production [Boolean] whether the app runs in production
110
+ # @return [void]
111
+ # @raise [Woods::ConfigurationError] on a missing token in production,
112
+ # or a too-short token anywhere
113
+ def verify_console_configuration!(production:)
114
+ warn_late_console_path(config)
115
+ return unless config.console_mcp_enabled
116
+
117
+ require 'woods/mcp/bearer_auth'
118
+ token = config.console_mcp_token.to_s
119
+ if token.empty?
120
+ raise Woods::ConfigurationError, MISSING_TOKEN_MESSAGE if production
121
+
122
+ warn "#{MISSING_TOKEN_MESSAGE} #{MISSING_TOKEN_TRANSPORT_NOTE}"
123
+ elsif token.length < Woods::MCP::BearerAuth::MIN_TOKEN_LENGTH
124
+ raise Woods::ConfigurationError,
125
+ '[Woods Console] console_mcp_token is shorter than ' \
126
+ "#{Woods::MCP::BearerAuth::MIN_TOKEN_LENGTH} characters; generate one with " \
127
+ '`SecureRandom.hex(32)` (or `rake woods:generate_token`).'
128
+ end
129
+ end
130
+
131
+ # Boot-time session tracer validation, run from `after_initialize`.
132
+ # Unlike the console stack, SessionTracer::Middleware requires a store
133
+ # at construction, so it cannot be mounted lazily — when the flag was
134
+ # set too late for the railtie initializer to see it, warn with the
135
+ # remediation instead of silently doing nothing (#183).
136
+ #
137
+ # @return [void]
138
+ def verify_session_tracer_configuration!
139
+ return unless config.session_tracer_enabled
140
+ return unless session_tracer_state.nil?
141
+
142
+ warn '[Woods] session_tracer_enabled is set but the middleware was not mounted: ' \
143
+ 'the flag was enabled after Rails railtie initializers ran (config/initializers/ ' \
144
+ 'runs later). Set session_tracer_enabled and session_store in ' \
145
+ 'config/application.rb, then restart.'
146
+ end
147
+
148
+ private
149
+
150
+ # Warn when the configured console path no longer matches the path the
151
+ # stack was mounted at — the path was set after railtie initializers
152
+ # captured the middleware arguments.
153
+ #
154
+ # @param config [Woods::Configuration]
155
+ # @return [void]
156
+ def warn_late_console_path(config)
157
+ mounted = console_mounted_path
158
+ return if mounted.nil? || mounted == config.console_mcp_path
159
+
160
+ warn "[Woods Console] console_mcp_path is #{config.console_mcp_path.inspect} but the " \
161
+ "console stack was mounted at #{mounted.inspect} — the path was changed after " \
162
+ 'Rails railtie initializers ran. Set console_mcp_path in config/application.rb, ' \
163
+ 'then restart.'
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ # Maintainer-only release machinery, deliberately not packaged with the gem.
5
+ # These files back `release:prepare` and `release:reopen`, which only ever run
6
+ # from a source checkout of this repository.
7
+ module Release
8
+ # Every refusal the release flow raises. `lib/tasks/release.rake` rescues
9
+ # this one class and turns it into a clean abort.
10
+ class Error < StandardError; end
11
+ end
12
+ end