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
@@ -7,6 +7,8 @@ require 'json'
7
7
  require 'pathname'
8
8
  require 'set'
9
9
 
10
+ require_relative '../generation'
11
+
10
12
  module Woods
11
13
  module MCP
12
14
  # Reads extraction output from disk for the MCP server.
@@ -29,7 +31,7 @@ module Woods
29
31
  engines view_templates migrations action_cable_channels
30
32
  scheduled_jobs rake_tasks state_machines events decorators
31
33
  database_views caching factories test_mappings rails_source
32
- poros libs
34
+ poros libs packages ruby_classes ruby_modules ruby_methods ruby_files
33
35
  ].freeze
34
36
 
35
37
  # Singular type name for each directory (used in search filtering).
@@ -42,15 +44,200 @@ module Woods
42
44
  MAX_UNIT_CACHE = 50
43
45
 
44
46
  # @param index_dir [String] Path to extraction output directory
47
+ # @param auto_refresh [Boolean] re-read the index when its published
48
+ # generation moves. On by default; specs that assert caching behaviour
49
+ # turn it off.
45
50
  # @raise [ArgumentError] if directory doesn't exist or has no manifest.json
46
- def initialize(index_dir)
51
+ def initialize(index_dir, auto_refresh: true)
47
52
  @index_dir = Pathname.new(index_dir)
48
53
  raise ArgumentError, "Index directory does not exist: #{index_dir}" unless @index_dir.directory?
49
- raise ArgumentError, "No manifest.json found in: #{index_dir}" unless @index_dir.join('manifest.json').file?
54
+ raise ArgumentError, "No manifest.json found in: #{index_dir}" unless manifest_present?
50
55
 
51
56
  @unit_cache = {}
57
+ @unit_cache_signatures = {}
52
58
  @unit_cache_order = []
53
59
  @identifier_map = nil
60
+ @auto_refresh = auto_refresh
61
+ @pin_depth = 0
62
+ @pin_owners = Hash.new(0)
63
+ @exclusive_waiters = 0
64
+ @exclusive_owner = nil
65
+ # Pins waiting for the held pins to drain so a moved generation can be
66
+ # adopted (MCP-5). Gates NEW pin entries exactly as @exclusive_waiters
67
+ # does, so sustained overlapping traffic cannot pin the reader on a
68
+ # retired generation indefinitely.
69
+ @refresh_waiters = 0
70
+ @freshness_mutex = Mutex.new
71
+ @freshness_condition = ConditionVariable.new
72
+ # Separate from @freshness_mutex: the LRU bookkeeping must not
73
+ # serialize behind a generation check, and a reader that took the
74
+ # freshness lock must be able to touch the caches without deadlocking.
75
+ @cache_mutex = Mutex.new
76
+ @generation = Woods::Generation.new(output_dir: @index_dir)
77
+ @loaded_generation = nil
78
+ @loaded_token = nil
79
+ @generation_signature = nil
80
+ @payload_retention_pin = nil
81
+ # The directory the loaded generation's payload lives in. nil until a
82
+ # generation naming a payload is loaded; {#payload_dir} then falls back
83
+ # to the index root, which is every flat/pre-pointer index.
84
+ @payload_dir = nil
85
+ end
86
+
87
+ # The generation this reader's caches were populated from.
88
+ #
89
+ # @return [Integer, nil] nil until something has been read
90
+ attr_reader :loaded_generation
91
+
92
+ # Drop caches if the index has been rewritten since they were populated.
93
+ #
94
+ # This is what makes the MCP `reload` tool an optimization rather than a
95
+ # correctness requirement. A long-lived server used to hold whatever it
96
+ # read at boot until someone thought to call `reload` — so an agent
97
+ # working alongside a running extraction got answers describing the tree
98
+ # as of the last time the server happened to start.
99
+ #
100
+ # Called at the top of every public read. The cost is one `File.stat` of
101
+ # a ~100-byte file; the file is only parsed when its mtime or size moved,
102
+ # and the caches are only dropped when the generation number actually
103
+ # advanced. An index with no generation file (written before generations
104
+ # existed, or by a third party) never refreshes — same behaviour as
105
+ # before.
106
+ #
107
+ # The check-and-reload is a mutex-guarded critical section. The stdio
108
+ # transport serializes tool calls, but `woods-mcp-http` runs handlers on
109
+ # its Rack server's request threads — and unguarded, two concurrent reads
110
+ # race the signature bookkeeping: duplicate `reload!`s, or one request's
111
+ # freshly populated caches dropped mid-sequence by the other's refresh.
112
+ #
113
+ # @return [Integer, nil] the generation now loaded, or nil when the
114
+ # caches were already current
115
+ def ensure_fresh!
116
+ return nil unless @auto_refresh
117
+
118
+ thread = Thread.current
119
+ @freshness_mutex.synchronize do
120
+ wait_for_generation_access(thread)
121
+ refresh_if_stale
122
+ end
123
+ end
124
+
125
+ # Suppress cache invalidation for the duration of a block.
126
+ #
127
+ # {#ensure_fresh!} runs per read, which bounds staleness but does not
128
+ # make a *sequence* of reads consistent: a caller that reads the manifest
129
+ # and then a unit can straddle two generations if a write lands between
130
+ # them. Both halves describe a real state of the tree, but not the same
131
+ # one.
132
+ #
133
+ # Wrapping a multi-read operation checks freshness once, up front, and
134
+ # then holds it — so nothing already cached is dropped and re-read at a
135
+ # newer generation partway through.
136
+ #
137
+ # **What this does not do on a flat index:** an artifact that has never
138
+ # been read is loaded from whatever is on disk when the block reaches it.
139
+ # Guaranteeing more would mean materializing the whole index on entry,
140
+ # which is what {#warmup!} costs, per request. When the loaded generation
141
+ # names an immutable payload directory (its {Woods::Generation} carries a
142
+ # +payload+ pointer), that gap is closed for free: every read in the
143
+ # block resolves through {#payload_dir}, which is fixed at pin entry and
144
+ # names one immutable snapshot, so a never-read artifact still comes from
145
+ # the pinned generation even as a newer payload is published. A flat
146
+ # index carries no pointer and keeps the old behaviour — see
147
+ # docs/WATCH_DAEMON.md.
148
+ #
149
+ # Pins are *refcounted*, not a boolean. Under a threaded transport two
150
+ # requests can hold pins at once, and with a boolean the first to finish
151
+ # unpinned the reader while the second still relied on it — its remaining
152
+ # reads could then be invalidated mid-sequence, the exact tear this
153
+ # method exists to prevent. Freshness is checked when the depth goes
154
+ # 0 → 1; nested pins and the reads of a pin already held ride the
155
+ # generation it entered on, and invalidation resumes when the last pin
156
+ # releases.
157
+ #
158
+ # **Bounded staleness under sustained overlap (MCP-5).** Refresh used to
159
+ # be attempted ONLY at depth 0 → 1, so on a threaded transport with
160
+ # enough concurrent traffic — several agents against one shared
161
+ # `woods-mcp-http`, the deployment stateless mode exists for — the depth
162
+ # never reached 0 and a retired generation was served indefinitely, with
163
+ # nothing in the response saying so. A pin that arrives after the
164
+ # generation signature moved while pins are held now registers as a
165
+ # refresh waiter: like {#with_exclusive_reload}'s waiters it gates NEW
166
+ # pin entries, waits for the held pins to drain, refreshes, and admits
167
+ # the queue. Nested pins and existing holders are exempt, so the drain
168
+ # can always complete. The cost is one drain per published generation.
169
+ #
170
+ # The Index MCP server applies this around reader-backed handlers. Direct
171
+ # IndexReader callers remain responsible for pinning any multi-read
172
+ # operation that must not refresh between accessors.
173
+ #
174
+ # @example
175
+ # reader.with_pinned_generation { [reader.manifest, reader.find_unit("Post")] }
176
+ #
177
+ # @yield the block to run against a single generation
178
+ # @return [Object] the block's value
179
+ def with_pinned_generation
180
+ thread = Thread.current
181
+ exclusive_owner = false
182
+ @freshness_mutex.synchronize do
183
+ if @exclusive_owner.equal?(thread)
184
+ exclusive_owner = true
185
+ else
186
+ wait_for_generation_access(thread)
187
+ drain_for_pending_refresh(thread)
188
+ refresh_if_stale if @auto_refresh && @pin_depth.zero?
189
+ acquire_payload_retention_pin if @pin_depth.zero?
190
+ @pin_depth += 1
191
+ @pin_owners[thread] += 1
192
+ end
193
+ end
194
+
195
+ begin
196
+ yield
197
+ ensure
198
+ release_generation_pin(thread) unless exclusive_owner
199
+ end
200
+ end
201
+
202
+ # Reload all cached index state while excluding pinned readers.
203
+ #
204
+ # Existing pins may finish and nest normally. Once an exclusive reload is
205
+ # waiting, new pins wait until the reload block has assembled its result.
206
+ #
207
+ # @yieldparam manifest [Hash] the manifest loaded after caches are cleared
208
+ # @return [Object] the block's value
209
+ # @raise [ThreadError] when upgrading a pin or nesting an exclusive reload
210
+ def with_exclusive_reload
211
+ with_exclusive_generation do
212
+ reload!
213
+ yield manifest
214
+ end
215
+ end
216
+
217
+ # Hold the exclusive generation lock WITHOUT refreshing anything.
218
+ #
219
+ # This is the swap lock the reload transaction (M7) acquires after its
220
+ # candidate stores are built off-side. Inside the block the caller
221
+ # rechecks the generation and then swaps atomically; the reader's own
222
+ # caches are reloaded by the caller ONLY after the recheck passes, so a
223
+ # failed recheck leaves the previous fully aligned generation untouched.
224
+ # {#with_exclusive_reload} is this lock plus the unconditional reload.
225
+ #
226
+ # Waiters follow the same rules as {#with_exclusive_reload}: in-flight
227
+ # pins finish before the block runs, and new pins wait while it is held.
228
+ #
229
+ # @yield no arguments
230
+ # @return [Object] the block's value
231
+ # @raise [ThreadError] when upgrading a pin or nesting an exclusive lock
232
+ def with_exclusive_generation
233
+ thread = Thread.current
234
+ acquire_exclusive_generation(thread)
235
+
236
+ begin
237
+ yield
238
+ ensure
239
+ release_exclusive_generation(thread)
240
+ end
54
241
  end
55
242
 
56
243
  # Pre-populate cached state so the first MCP tool call doesn't pay
@@ -66,27 +253,18 @@ module Woods
66
253
  #
67
254
  # @return [Hash] Per-step outcome: `{step => true | Exception}`
68
255
  def warmup!
69
- steps = {
70
- manifest: -> { manifest },
71
- summary: -> { summary },
72
- dependency_graph: -> { dependency_graph },
73
- graph_analysis: -> { graph_analysis },
74
- identifier_map: -> { identifier_map }
75
- }
76
- steps.each_with_object({}) do |(step, runner), result|
77
- runner.call
78
- result[step] = true
79
- rescue StandardError => e
80
- result[step] = e
81
- end
256
+ with_pinned_generation { warmup_steps }
82
257
  end
83
258
 
84
259
  # Clear all cached state so the next access re-reads from disk.
85
260
  #
86
261
  # @return [void]
87
262
  def reload!
88
- @unit_cache = {}
89
- @unit_cache_order = []
263
+ @cache_mutex.synchronize do
264
+ @unit_cache = {}
265
+ @unit_cache_signatures = {}
266
+ @unit_cache_order = []
267
+ end
90
268
  @identifier_map = nil
91
269
  @index_cache = {}
92
270
  @manifest = nil
@@ -95,10 +273,31 @@ module Woods
95
273
  @graph_analysis = nil
96
274
  @raw_graph_data = nil
97
275
  @normalized_graph_edges = nil
276
+ @graph_node_types = nil
277
+ remove_instance_variable(:@multi_database_graph) if defined?(@multi_database_graph)
278
+ end
279
+
280
+ # The directory the current read resolves payload artifacts against —
281
+ # manifest, summary, graph, per-type indexes, unit files and precomputed
282
+ # flows. It is the immutable payload directory the loaded generation
283
+ # names, or the index root for a flat/pre-pointer index.
284
+ #
285
+ # Fixed for the life of a pin (only {#refresh_if_stale} moves it, and
286
+ # pins suppress refresh), so every artifact of a pinned read resolves
287
+ # through one generation. Public because callers that read payload files
288
+ # without going through this reader — the flow assembler, the
289
+ # precomputed-flow loader — have to resolve to the same generation this
290
+ # reader is serving, not to whatever is published when they look.
291
+ #
292
+ # @return [Pathname]
293
+ def payload_dir
294
+ ensure_fresh!
295
+ current_payload_dir
98
296
  end
99
297
 
100
298
  # @return [Hash] Parsed manifest.json
101
299
  def manifest
300
+ ensure_fresh!
102
301
  @manifest ||= parse_json('manifest.json')
103
302
  end
104
303
 
@@ -115,22 +314,22 @@ module Woods
115
314
 
116
315
  # @return [String, nil] SUMMARY.md content, or nil if not present
117
316
  def summary
317
+ ensure_fresh!
118
318
  @summary ||= begin
119
- path = @index_dir.join('SUMMARY.md')
120
- path.file? ? path.read : nil
319
+ path = current_payload_dir.join('SUMMARY.md')
320
+ path.file? ? read_utf8(path) : nil
121
321
  end
122
322
  end
123
323
 
124
324
  # @return [Woods::DependencyGraph] Graph loaded from disk
125
325
  def dependency_graph
126
- @dependency_graph ||= begin
127
- data = parse_json('dependency_graph.json')
128
- Woods::DependencyGraph.from_h(data)
129
- end
326
+ ensure_fresh!
327
+ @dependency_graph ||= Woods::DependencyGraph.from_h(raw_graph_data)
130
328
  end
131
329
 
132
330
  # @return [Hash] Parsed graph_analysis.json
133
331
  def graph_analysis
332
+ ensure_fresh!
134
333
  @graph_analysis ||= parse_json('graph_analysis.json')
135
334
  end
136
335
 
@@ -139,6 +338,7 @@ module Woods
139
338
  # @param identifier [String] Unit identifier (e.g. "Post", "Api::V1::HealthController")
140
339
  # @return [Hash, nil] Full unit data or nil if not found
141
340
  def find_unit(identifier)
341
+ ensure_fresh!
142
342
  location = identifier_map[identifier]
143
343
  return nil unless location
144
344
 
@@ -150,6 +350,7 @@ module Woods
150
350
  # @param type [String, nil] Singular type name (e.g. "model", "controller")
151
351
  # @return [Array<Hash>] Index entries for matching units
152
352
  def list_units(type: nil)
353
+ ensure_fresh!
153
354
  dirs = if type
154
355
  dir = TYPE_TO_DIR[type]
155
356
  dir ? [dir] : []
@@ -164,6 +365,14 @@ module Woods
164
365
  # Override with WOODS_SEARCH_MAX_SCAN env var.
165
366
  DEFAULT_SEARCH_MAX_SCAN = 500
166
367
 
368
+ # Wall-clock budget for a single match against the user-supplied search
369
+ # pattern (audit P5). The query is compiled as a raw Ruby regex; a
370
+ # pattern with catastrophic backtracking would otherwise stall the
371
+ # dispatch thread indefinitely. Ruby 3.2+ can enforce a per-match limit
372
+ # at the engine level (Regexp.new(timeout:)); on older Rubies the
373
+ # pattern compiles without one and this constant is unused.
374
+ SEARCH_PATTERN_TIMEOUT = 1.0
375
+
167
376
  # Search units by case-insensitive pattern.
168
377
  #
169
378
  # Phase 1: match identifiers from index files (cheap).
@@ -194,6 +403,20 @@ module Woods
194
403
  # @return [Hash] { results: Array<Hash>, note: String|nil, partial: Boolean }
195
404
  # @raise [ArgumentError] when all of query, exact_prefix, and exact_suffix are blank
196
405
  def search(query = nil, types: nil, fields: %w[identifier], limit: 20, exact_prefix: nil, exact_suffix: nil)
406
+ # Pinned, not merely checked-once. This walks the identifier map and
407
+ # then loads units for the hits; each nested `find_unit` re-checks
408
+ # freshness, so a publish landing mid-walk rebuilt the caches while the
409
+ # result list still held identifiers from the previous generation — one
410
+ # response describing two indexes.
411
+ with_pinned_generation do
412
+ search_within_pin(query, types: types, fields: fields, limit: limit,
413
+ exact_prefix: exact_prefix, exact_suffix: exact_suffix)
414
+ end
415
+ end
416
+
417
+ # @api private
418
+ def search_within_pin(query = nil, types: nil, fields: %w[identifier], limit: 20,
419
+ exact_prefix: nil, exact_suffix: nil)
197
420
  prefix = exact_prefix.blank? ? nil : exact_prefix.downcase
198
421
  suffix = exact_suffix.blank? ? nil : exact_suffix.downcase
199
422
  if query.blank? && !prefix && !suffix
@@ -212,85 +435,92 @@ module Woods
212
435
  phase2_scanned = 0
213
436
  partial = false
214
437
 
215
- dirs = if types
216
- types.filter_map { |t| TYPE_TO_DIR[t] }
217
- else
218
- TYPE_DIRS
219
- end
220
-
221
- # Phase 2 candidates are collected per-dir and then scanned in
222
- # round-robin across dirs. Exhausting the per-run scan cap linearly
223
- # down TYPE_DIRS order would starve later types (`concerns` at pos
224
- # 13, `test_mappings` at pos 31) on any codebase where the earlier
225
- # dirs together exceed max_scan entries. Interleaving guarantees
226
- # every type contributes to the scanned set.
227
- phase2_queues = {}
228
-
229
- dirs.each do |dir|
230
- type_name = DIR_TO_TYPE[dir]
231
- entries = read_index(dir)
232
-
233
- # Broad-match detection: warn when pattern matches >50% of dir entries
234
- if entries.size > 1
235
- matching_count = entries.count do |e|
236
- identifier_passes_filters?(e['identifier'], pattern, prefix, suffix)
237
- end
238
- if matching_count > entries.size / 2.0
239
- notes << "broad pattern matched #{matching_count}/#{entries.size} entries in #{dir}"
438
+ begin
439
+ dirs = if types
440
+ types.filter_map { |t| TYPE_TO_DIR[t] }
441
+ else
442
+ TYPE_DIRS
443
+ end
444
+
445
+ # Phase 2 candidates are collected per-dir and then scanned in
446
+ # round-robin across dirs. Exhausting the per-run scan cap linearly
447
+ # down TYPE_DIRS order would starve later types (`concerns` at pos
448
+ # 13, `test_mappings` at pos 31) on any codebase where the earlier
449
+ # dirs together exceed max_scan entries. Interleaving guarantees
450
+ # every type contributes to the scanned set.
451
+ phase2_queues = {}
452
+
453
+ dirs.each do |dir|
454
+ type_name = DIR_TO_TYPE[dir]
455
+ entries = read_index(dir)
456
+
457
+ # Broad-match detection: warn when pattern matches >50% of dir entries
458
+ if entries.size > 1
459
+ matching_count = entries.count do |e|
460
+ identifier_passes_filters?(e['identifier'], pattern, prefix, suffix)
461
+ end
462
+ if matching_count > entries.size / 2.0
463
+ notes << "broad pattern matched #{matching_count}/#{entries.size} entries in #{dir}"
464
+ end
240
465
  end
241
- end
242
466
 
243
- entries.each do |entry|
244
- id = entry['identifier']
245
- next unless identifier_passes_prefix_suffix?(id, prefix, suffix)
467
+ entries.each do |entry|
468
+ id = entry['identifier']
469
+ next unless identifier_passes_prefix_suffix?(id, prefix, suffix)
246
470
 
247
- # Phase 1: identifier matching (still in-order per dir)
248
- if fields.include?('identifier') && pattern.match?(id)
249
- next if results.size >= limit
471
+ # Phase 1: identifier matching (still in-order per dir)
472
+ if fields.include?('identifier') && pattern.match?(id)
473
+ next if results.size >= limit
250
474
 
251
- results << { identifier: id, type: type_name, match_field: 'identifier' }
252
- next
253
- end
475
+ results << { identifier: id, type: type_name, match_field: 'identifier' }
476
+ next
477
+ end
254
478
 
255
- # Phase 2 is only reached when the caller opted into deeper fields.
256
- next unless fields.include?('metadata') || fields.include?('source_code')
479
+ # Phase 2 is only reached when the caller opted into deeper fields.
480
+ next unless fields.include?('metadata') || fields.include?('source_code')
257
481
 
258
- (phase2_queues[dir] ||= []) << [type_name, id]
482
+ (phase2_queues[dir] ||= []) << [type_name, id]
483
+ end
259
484
  end
260
- end
261
485
 
262
- if results.size < limit && phase2_queues.any?
263
- queues = phase2_queues.values.map(&:dup)
264
- catch(:phase2_done) do
265
- loop do
266
- progressed = false
267
- queues.each do |queue|
268
- next if queue.empty?
486
+ if results.size < limit && phase2_queues.any?
487
+ queues = phase2_queues.values.map(&:dup)
488
+ catch(:phase2_done) do
489
+ loop do
490
+ progressed = false
491
+ queues.each do |queue|
492
+ next if queue.empty?
269
493
 
270
- throw :phase2_done if results.size >= limit
494
+ throw :phase2_done if results.size >= limit
271
495
 
272
- if phase2_scanned >= max_scan
273
- partial = true
274
- throw :phase2_done
275
- end
496
+ if phase2_scanned >= max_scan
497
+ partial = true
498
+ throw :phase2_done
499
+ end
276
500
 
277
- type_name, id = queue.shift
278
- progressed = true
501
+ type_name, id = queue.shift
502
+ progressed = true
279
503
 
280
- unit = find_unit(id)
281
- next unless unit
504
+ unit = find_unit(id)
505
+ next unless unit
282
506
 
283
- phase2_scanned += 1
507
+ phase2_scanned += 1
284
508
 
285
- if fields.include?('source_code') && unit['source_code'] && pattern.match?(unit['source_code'])
286
- results << { identifier: id, type: type_name, match_field: 'source_code' }
287
- elsif fields.include?('metadata') && unit['metadata'] && pattern.match?(unit['metadata'].to_json)
288
- results << { identifier: id, type: type_name, match_field: 'metadata' }
509
+ if fields.include?('source_code') && unit['source_code'] && pattern.match?(unit['source_code'])
510
+ results << { identifier: id, type: type_name, match_field: 'source_code' }
511
+ elsif fields.include?('metadata') && unit['metadata'] && pattern.match?(unit['metadata'].to_json)
512
+ results << { identifier: id, type: type_name, match_field: 'metadata' }
513
+ end
289
514
  end
515
+ break unless progressed
290
516
  end
291
- break unless progressed
292
517
  end
293
518
  end
519
+ rescue StandardError => e
520
+ raise unless regexp_timeout_error?(e)
521
+
522
+ notes << "search aborted: the pattern exceeded the #{SEARCH_PATTERN_TIMEOUT}s per-match limit"
523
+ partial = true
294
524
  end
295
525
 
296
526
  response = { results: results.first(limit) }
@@ -330,6 +560,11 @@ module Woods
330
560
  # @param limit [Integer] Maximum results to return
331
561
  # @return [Array<Hash>] Matching rails_source unit summaries
332
562
  def framework_sources(keyword, limit: 20)
563
+ with_pinned_generation { framework_sources_within_pin(keyword, limit: limit) }
564
+ end
565
+
566
+ # @api private
567
+ def framework_sources_within_pin(keyword, limit: 20)
333
568
  # Multi-word keywords ("ActiveRecord callbacks") are split on
334
569
  # whitespace and ANDed. Single-word queries behave as before.
335
570
  tokens = keyword.to_s.strip.split(/\s+/)
@@ -375,6 +610,11 @@ module Woods
375
610
  # @param types [Array<String>, nil] Filter to these singular type names
376
611
  # @return [Array<Hash>] Units sorted by last_modified descending
377
612
  def recent_changes(limit: 10, types: nil)
613
+ with_pinned_generation { recent_changes_within_pin(limit: limit, types: types) }
614
+ end
615
+
616
+ # @api private
617
+ def recent_changes_within_pin(limit: 10, types: nil)
378
618
  dirs = if types
379
619
  types.filter_map { |t| TYPE_TO_DIR[t] }
380
620
  else
@@ -410,24 +650,362 @@ module Woods
410
650
  end
411
651
 
412
652
  # @return [Hash] Raw dependency graph data from JSON
653
+ #
654
+ # The single parse point for dependency_graph.json per generation
655
+ # (audit P6): {#dependency_graph} builds the typed graph from this
656
+ # same hash (DependencyGraph.from_h does not mutate its input), so a
657
+ # generation that touches both accessors holds one parsed copy.
658
+ #
659
+ # The parsed structure is deep-frozen before the memo publishes it.
660
+ # It is shared state for the whole generation — the typed graph, the
661
+ # memoized edge normalization, traversal, and the server graph tools
662
+ # all read from it — so a caller mutation of a nested edge or node
663
+ # would reach the internal copy and corrupt every later read. Frozen
664
+ # at parse time, not lazily, so even the first accessor's returned
665
+ # value is immutable.
413
666
  def raw_graph_data
414
- @raw_graph_data ||= parse_json('dependency_graph.json')
667
+ ensure_fresh!
668
+ @raw_graph_data ||= deep_freeze_json(parse_json('dependency_graph.json'))
415
669
  end
416
670
 
417
671
  private
418
672
 
673
+ def wait_for_generation_access(thread)
674
+ @freshness_condition.wait(@freshness_mutex) while generation_access_blocked?(thread)
675
+ end
676
+
677
+ def generation_access_blocked?(thread)
678
+ return false if @exclusive_owner.equal?(thread) || @pin_owners.key?(thread)
679
+
680
+ !@exclusive_owner.nil? || @exclusive_waiters.positive? || @refresh_waiters.positive?
681
+ end
682
+
683
+ # Wait for held pins to drain when the generation moved while they were
684
+ # held, so the pin about to be taken adopts the new generation instead
685
+ # of riding the retired one (MCP-5).
686
+ #
687
+ # Exemptions keep the drain from deadlocking against itself:
688
+ # - a thread that already holds a pin (a NESTED pin) never waits — the
689
+ # drain it would wait for can only happen after that thread returns,
690
+ # and its nested read must stay on its owner's generation anyway;
691
+ # - the reads of an existing pin go through {#ensure_fresh!}, not
692
+ # here, and stay pinned as before.
693
+ #
694
+ # Registering as a waiter blocks other new pins ({#generation_access_blocked?})
695
+ # for the same reason an exclusive reload does: without it, a steady
696
+ # arrival rate keeps the depth off zero forever. The waiter itself holds
697
+ # no pin, so it never blocks the drain it is waiting on.
698
+ #
699
+ # Callers hold `@freshness_mutex`.
700
+ #
701
+ # @param thread [Thread] the thread taking the pin
702
+ # @return [void]
703
+ def drain_for_pending_refresh(thread)
704
+ return unless @auto_refresh
705
+ return if @pin_depth.zero?
706
+ return if @pin_owners.key?(thread)
707
+ return unless generation_moved?
708
+
709
+ @refresh_waiters += 1
710
+ begin
711
+ @freshness_condition.wait(@freshness_mutex) until @pin_depth.zero? && @exclusive_owner.nil?
712
+ ensure
713
+ @refresh_waiters -= 1
714
+ @freshness_condition.broadcast
715
+ end
716
+ end
717
+
718
+ # Has the published generation moved since the caches were populated?
719
+ # Signature-only (one `File.stat`) — the same cheap probe
720
+ # {#refresh_if_stale} guards its parse with, without consuming it.
721
+ #
722
+ # @return [Boolean]
723
+ def generation_moved?
724
+ signature = generation_signature
725
+ !signature.nil? && signature != @generation_signature
726
+ end
727
+
728
+ def release_generation_pin(thread)
729
+ @freshness_mutex.synchronize do
730
+ @pin_depth -= 1
731
+ @pin_owners[thread] -= 1
732
+ @pin_owners.delete(thread) if @pin_owners[thread].zero?
733
+ if @pin_depth.zero?
734
+ release_payload_retention_pin
735
+ @freshness_condition.broadcast
736
+ end
737
+ end
738
+ end
739
+
740
+ # Keep the immutable payload backing a pinned read on disk across
741
+ # processes. The in-memory pin counters above coordinate threads that
742
+ # share this reader, but a writer in another process cannot see them and
743
+ # retention could otherwise remove the loaded generation before a later
744
+ # artifact in the same request was opened.
745
+ #
746
+ # The reader opens the generation's existing manifest read-only and
747
+ # takes a shared advisory lock. PayloadStore takes an exclusive,
748
+ # non-blocking lock on the same inode before pruning. Re-reading the
749
+ # generation after acquiring closes both races around the lock: if a
750
+ # publish landed first we adopt it and retry, and if pruning landed first
751
+ # the manifest open (or the directory check) fails and we retry from the
752
+ # new published pointer.
753
+ #
754
+ # The retry is bounded (CORE-4). A payload directory that exists without
755
+ # its `manifest.json` — only reachable by tampering, since every
756
+ # published payload carries one — leaves the pointer unchanged, so every
757
+ # iteration would take the identical path with no sleep and no cap: a
758
+ # 100%-CPU hang of the request thread instead of a degraded read. A
759
+ # second consecutive ENOENT against the SAME expected directory means
760
+ # retrying cannot help, so the read proceeds unpinned, exactly as it
761
+ # does for a flat index.
762
+ #
763
+ # @return [void]
764
+ def acquire_payload_retention_pin
765
+ missing_manifest_dir = nil
766
+ saw_missing_manifest = false
767
+ loop do
768
+ expected_dir = current_payload_dir
769
+ return if expected_dir == @index_dir
770
+
771
+ # Intentionally outlives this method; the last overlapping pin
772
+ # closes it in #release_payload_retention_pin.
773
+ file = File.open( # rubocop:disable Style/FileOpen
774
+ expected_dir.join('manifest.json').to_s, File::RDONLY
775
+ )
776
+ file.flock(File::LOCK_SH)
777
+ marker = @generation.current
778
+ resolved = resolve_payload_dir(marker)
779
+ marker_still_matches = marker.number.zero? || (same_generation?(marker) && resolved == expected_dir)
780
+ if marker_still_matches && expected_dir.directory?
781
+ @payload_retention_pin = file
782
+ return
783
+ end
784
+
785
+ file.flock(File::LOCK_UN)
786
+ file.close
787
+ load_generation(marker) unless marker.number.zero?
788
+ rescue Errno::ENOENT
789
+ file&.close unless file&.closed?
790
+ return if saw_missing_manifest && missing_manifest_dir == expected_dir
791
+
792
+ saw_missing_manifest = true
793
+ missing_manifest_dir = expected_dir
794
+ marker = @generation.current
795
+ load_generation(marker) unless marker.number.zero?
796
+ end
797
+ end
798
+
799
+ # @return [void]
800
+ def release_payload_retention_pin
801
+ @payload_retention_pin&.flock(File::LOCK_UN)
802
+ @payload_retention_pin&.close
803
+ @payload_retention_pin = nil
804
+ rescue IOError, SystemCallError
805
+ @payload_retention_pin = nil
806
+ end
807
+
808
+ def acquire_exclusive_generation(thread)
809
+ @freshness_mutex.synchronize do
810
+ raise ThreadError, 'Cannot start an exclusive reload from a pinned generation' if @pin_owners.key?(thread)
811
+ raise ThreadError, 'This thread already owns an exclusive reload' if @exclusive_owner.equal?(thread)
812
+
813
+ @exclusive_waiters += 1
814
+ begin
815
+ @freshness_condition.wait(@freshness_mutex) until @pin_depth.zero? && @exclusive_owner.nil?
816
+ @exclusive_owner = thread
817
+ ensure
818
+ @exclusive_waiters -= 1
819
+ @freshness_condition.broadcast
820
+ end
821
+ end
822
+ end
823
+
824
+ def release_exclusive_generation(thread)
825
+ @freshness_mutex.synchronize do
826
+ @exclusive_owner = nil if @exclusive_owner.equal?(thread)
827
+ @freshness_condition.broadcast
828
+ end
829
+ end
830
+
831
+ # The body of {#ensure_fresh!}. Callers must hold `@freshness_mutex`; it
832
+ # is split out so {#with_pinned_generation} can run it inside the same
833
+ # critical section that increments the pin depth.
834
+ #
835
+ # @return [Integer, nil] the generation now loaded, or nil when the
836
+ # caches were already current
837
+ def refresh_if_stale
838
+ return nil if @pin_depth.positive?
839
+
840
+ signature = generation_signature
841
+ return nil if signature.nil? || signature == @generation_signature
842
+
843
+ @generation_signature = signature
844
+ marker = @generation.current
845
+ return nil if marker.number.zero? || same_generation?(marker)
846
+
847
+ load_generation(marker)
848
+ end
849
+
850
+ # Adopt one already-read marker as the reader's loaded generation.
851
+ # Callers hold @freshness_mutex.
852
+ #
853
+ # @param marker [Woods::Generation::Marker]
854
+ # @return [void]
855
+ def load_generation(marker)
856
+ reload!
857
+ @payload_dir = resolve_payload_dir(marker)
858
+ @loaded_token = marker.token
859
+ @loaded_generation = marker.number
860
+ end
861
+
862
+ # Is there an index here at all?
863
+ #
864
+ # A flat index answers with the manifest at the root. An index that
865
+ # publishes per-generation payloads has no manifest there — every payload
866
+ # artifact lives in the directory the published generation names — so the
867
+ # pointer is followed before concluding the directory is not an index.
868
+ #
869
+ # @return [Boolean]
870
+ def manifest_present?
871
+ return true if @index_dir.join('manifest.json').file?
872
+
873
+ marker = Woods::Generation.new(output_dir: @index_dir).current
874
+ resolve_payload_dir(marker).join('manifest.json').file?
875
+ end
876
+
877
+ # The loaded generation's payload directory, without a freshness check.
878
+ #
879
+ # Internal reads call this rather than {#payload_dir}: they are already
880
+ # inside a public read that checked freshness once, and re-checking per
881
+ # artifact would take the freshness mutex and stat generation.json for
882
+ # every unit file a request touches.
883
+ #
884
+ # @return [Pathname]
885
+ def current_payload_dir
886
+ @payload_dir || @index_dir
887
+ end
888
+
889
+ # Resolve a generation's payload pointer to an on-disk directory.
890
+ #
891
+ # Delegates to {Woods::Generation#payload_dir} so every reader of a
892
+ # payload artifact — the exporters, the validator, the daemon and this
893
+ # reader — follows the pointer by exactly the same rules.
894
+ #
895
+ # @param marker [Woods::Generation::Marker]
896
+ # @return [Pathname]
897
+ def resolve_payload_dir(marker)
898
+ Woods::Generation.new(output_dir: @index_dir).payload_dir(marker)
899
+ end
900
+
901
+ # Compare the *token*, not the number.
902
+ #
903
+ # `bump!` is a read-modify-write, so two writers that overlap can both
904
+ # publish the same number — which the design permits, since a manual rake
905
+ # run proceeds after waiting for the lock. A reader already loaded at N+1
906
+ # would then see `published == loaded`, conclude it was current, and hold
907
+ # caches describing the *other* writer's N+1 indefinitely: a stale index
908
+ # that believes it is fresh, which is the one failure generations exist to
909
+ # prevent.
910
+ #
911
+ # The token is a fresh random value per publish, so it distinguishes two
912
+ # collapsed bumps that the counter cannot. Falls back to the number for
913
+ # generation files written before tokens existed.
914
+ def same_generation?(marker)
915
+ return @loaded_generation == marker.number if marker.token.nil?
916
+
917
+ @loaded_token == marker.token
918
+ end
919
+
920
+ # Touch every lazy accessor, recording rather than raising per-step
921
+ # failures so a missing optional artefact (graph_analysis.json on an index
922
+ # written before it existed) never blocks the rest.
923
+ #
924
+ # @return [Hash] `{step => true | Exception}`
925
+ def warmup_steps
926
+ steps = {
927
+ manifest: -> { manifest },
928
+ summary: -> { summary },
929
+ dependency_graph: -> { dependency_graph },
930
+ graph_analysis: -> { graph_analysis },
931
+ identifier_map: -> { identifier_map }
932
+ }
933
+ steps.each_with_object({}) do |(step, runner), result|
934
+ runner.call
935
+ result[step] = true
936
+ rescue StandardError => e
937
+ result[step] = e
938
+ end
939
+ end
940
+
419
941
  # Compile a case-insensitive regex from a query string.
420
942
  #
421
943
  # Treats the query as a raw Ruby regex pattern. Falls back to an escaped
422
944
  # literal match (with a :note field added by callers) when the pattern is
423
945
  # invalid.
424
946
  #
947
+ # The compiled pattern carries the {SEARCH_PATTERN_TIMEOUT} per-match
948
+ # wall-clock limit on Ruby 3.2+: a pattern with catastrophic
949
+ # backtracking raises Regexp::TimeoutError on its first overrun instead
950
+ # of stalling the dispatch thread indefinitely, which {#search_within_pin}
951
+ # converts into a partial response.
952
+ #
425
953
  # @param query [String] Raw regex pattern
426
954
  # @return [Regexp] Compiled case-insensitive pattern
427
955
  def compile_search_pattern(query)
428
- Regexp.new(query, Regexp::IGNORECASE)
956
+ compile_case_insensitive_pattern(query)
429
957
  rescue RegexpError
430
- Regexp.new(Regexp.escape(query), Regexp::IGNORECASE)
958
+ compile_case_insensitive_pattern(Regexp.escape(query))
959
+ end
960
+
961
+ # @param pattern [String] Regex source (already escaped when falling back)
962
+ # @return [Regexp]
963
+ def compile_case_insensitive_pattern(pattern)
964
+ if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.2')
965
+ Regexp.new(pattern, Regexp::IGNORECASE, timeout: SEARCH_PATTERN_TIMEOUT)
966
+ else
967
+ Regexp.new(pattern, Regexp::IGNORECASE)
968
+ end
969
+ end
970
+
971
+ # Is this the engine-level per-match timeout, Regexp::TimeoutError?
972
+ #
973
+ # The constant does not exist before Ruby 3.2, and Ruby resolves
974
+ # rescue-class expressions lazily, when an exception occurs — naming
975
+ # the constant in a rescue clause turned any *other* in-block failure
976
+ # into `NameError: uninitialized constant Regexp::TimeoutError` on
977
+ # Ruby 3.0/3.1, masking the original error. Rescue StandardError and
978
+ # recognize the timeout through a defined? check instead; everything
979
+ # else is re-raised unchanged.
980
+ #
981
+ # @param error [StandardError]
982
+ # @return [Boolean]
983
+ def regexp_timeout_error?(error)
984
+ defined?(Regexp::TimeoutError) && error.is_a?(Regexp::TimeoutError)
985
+ end
986
+
987
+ # A cheap stand-in for "has the generation file been rewritten?", so the
988
+ # common case costs one `File.stat` of a ~100-byte file instead of a parse.
989
+ #
990
+ # The inode is load-bearing, not belt-and-braces. `[mtime, size]` alone
991
+ # misses a bump whenever two writes land in the same mtime tick with the
992
+ # same payload length — and equal length is the daemon's *steady state*:
993
+ # reason `"incremental"` every cycle, fixed-width number and timestamp.
994
+ # Coarse mtime granularity is not exotic either; it includes the
995
+ # volume-mounted Docker deployment the Index Server is documented for. The
996
+ # reader would then serve a stale index indefinitely, which is the exact
997
+ # silent staleness generations exist to end.
998
+ #
999
+ # {AtomicFile} renames a fresh Tempfile over the target on every write, so
1000
+ # a new inode is guaranteed per publish and this costs nothing extra.
1001
+ #
1002
+ # @return [Array, nil] opaque signature, or nil when there is no
1003
+ # generation file
1004
+ def generation_signature
1005
+ stat = File.stat(@generation.path)
1006
+ [stat.mtime.to_f, stat.size, stat.ino]
1007
+ rescue SystemCallError
1008
+ nil
431
1009
  end
432
1010
 
433
1011
  # Case-insensitive literal prefix/suffix check on an identifier.
@@ -452,8 +1030,65 @@ module Woods
452
1030
 
453
1031
  # Memoized normalized edges — converts bare strings (old format) to hashes once.
454
1032
  # Cleared by reload! alongside raw_graph_data.
1033
+ #
1034
+ # `edges` holds one type's edges per identifier. Where an identifier
1035
+ # names units of several types the rest live in `variants`, and a
1036
+ # traversal that read only the primary would report a unit as having no
1037
+ # dependencies at all. They are unioned here so traversal follows the
1038
+ # identifier's whole out-edge set.
455
1039
  def normalized_graph_edges
456
- @normalized_graph_edges ||= normalize_all_edges(raw_graph_data['edges'] || {})
1040
+ @normalized_graph_edges ||= begin
1041
+ edges = normalize_all_edges(raw_graph_data['edges'] || {})
1042
+ variant_records.each do |record|
1043
+ identifier = record['identifier']
1044
+ next unless identifier
1045
+
1046
+ extra = normalize_all_edges(identifier => Array(record['edges']))
1047
+ edges[identifier] = ((edges[identifier] || []) + extra[identifier]).uniq
1048
+ end
1049
+ edges
1050
+ end
1051
+ end
1052
+
1053
+ # Every unit type registered under each identifier, sorted.
1054
+ #
1055
+ # One entry for all but the handful of identifiers a codebase reuses
1056
+ # across types; those get one per coexisting unit.
1057
+ #
1058
+ # @return [Hash{String => Array<String>}]
1059
+ def graph_node_types
1060
+ @graph_node_types ||= begin
1061
+ types = (raw_graph_data['nodes'] || {}).transform_values { |node| [node['type']].compact }
1062
+ variant_records.each do |record|
1063
+ identifier = record['identifier']
1064
+ next unless identifier && record['type']
1065
+
1066
+ (types[identifier] ||= []) << record['type']
1067
+ end
1068
+ types.transform_values { |list| list.uniq.sort }
1069
+ end
1070
+ end
1071
+
1072
+ # Does the graph span more than one database?
1073
+ #
1074
+ # Nodes carry `database` only from Rails 6.1 on, and only for models, so
1075
+ # an app with one database (or none recorded) answers false and the
1076
+ # traversal shape is unchanged for it.
1077
+ #
1078
+ # @return [Boolean]
1079
+ def multi_database_graph?
1080
+ return @multi_database_graph if defined?(@multi_database_graph)
1081
+
1082
+ databases = (raw_graph_data['nodes'] || {}).each_value.filter_map { |node| node['database'] }
1083
+ @multi_database_graph = databases.uniq.size > 1
1084
+ end
1085
+
1086
+ # @return [Array<Hash>] the graph's `variants` section, empty when the
1087
+ # graph has no identifier shared across types (and for every graph
1088
+ # written before the section existed)
1089
+ def variant_records
1090
+ records = raw_graph_data['variants']
1091
+ records.is_a?(Array) ? records.grep(Hash) : []
457
1092
  end
458
1093
 
459
1094
  # Build identifier → { type_dir, filename } map from all _index.json files.
@@ -480,42 +1115,118 @@ module Woods
480
1115
  def read_index(dir)
481
1116
  @index_cache ||= {}
482
1117
  @index_cache[dir] ||= begin
483
- path = @index_dir.join(dir, '_index.json')
484
- path.file? ? JSON.parse(path.read) : []
1118
+ path = current_payload_dir.join(dir, '_index.json')
1119
+ path.file? ? JSON.parse(read_utf8(path)) : []
485
1120
  end
486
1121
  end
487
1122
 
488
1123
  # Load a unit JSON file with LRU cache eviction.
1124
+ #
1125
+ # The bookkeeping runs under {@cache_mutex}; the file read deliberately
1126
+ # does not, so concurrent readers still parse different units in
1127
+ # parallel. `woods-mcp-http` executes tool handlers on its Rack server's
1128
+ # request threads, and unsynchronized bookkeeping drifts: two threads
1129
+ # can both observe `size >= MAX_UNIT_CACHE` and both evict, or both push
1130
+ # the same key so the order array accumulates a duplicate. A duplicate
1131
+ # for a key never read again is never cleaned up (only a cache *hit*
1132
+ # dedupes it), so `shift` starts evicting keys that are no longer in the
1133
+ # cache and the cache creeps past its cap — a slow leak rather than a
1134
+ # wrong answer, but a leak.
1135
+ #
1136
+ # Values are unaffected either way: both threads parse the same file.
489
1137
  def load_unit(type_dir, filename)
490
1138
  cache_key = "#{type_dir}/#{filename}"
1139
+ path = current_payload_dir.join(type_dir, filename)
1140
+ open_unit(path.to_s) do |file|
1141
+ signature = unit_file_signature(file.stat)
1142
+ cached = @cache_mutex.synchronize do
1143
+ if @unit_cache.key?(cache_key) && @unit_cache_signatures[cache_key] == signature
1144
+ # Move to end (most recently used)
1145
+ @unit_cache_order.delete(cache_key)
1146
+ @unit_cache_order.push(cache_key)
1147
+ @unit_cache[cache_key]
1148
+ elsif @unit_cache.key?(cache_key)
1149
+ @unit_cache.delete(cache_key)
1150
+ @unit_cache_signatures.delete(cache_key)
1151
+ @unit_cache_order.delete(cache_key)
1152
+ nil
1153
+ end
1154
+ end
1155
+ return cached if cached
491
1156
 
492
- if @unit_cache.key?(cache_key)
493
- # Move to end (most recently used)
494
- @unit_cache_order.delete(cache_key)
495
- @unit_cache_order.push(cache_key)
496
- return @unit_cache[cache_key]
497
- end
498
-
499
- path = @index_dir.join(type_dir, filename)
500
- return nil unless path.file?
1157
+ data = JSON.parse(file.read)
501
1158
 
502
- data = JSON.parse(path.read)
1159
+ @cache_mutex.synchronize do
1160
+ # Evict oldest if at capacity
1161
+ if @unit_cache.size >= MAX_UNIT_CACHE && !@unit_cache.key?(cache_key)
1162
+ oldest = @unit_cache_order.shift
1163
+ @unit_cache.delete(oldest)
1164
+ @unit_cache_signatures.delete(oldest)
1165
+ end
503
1166
 
504
- # Evict oldest if at capacity
505
- if @unit_cache.size >= MAX_UNIT_CACHE
506
- oldest = @unit_cache_order.shift
507
- @unit_cache.delete(oldest)
1167
+ @unit_cache[cache_key] = data
1168
+ @unit_cache_signatures[cache_key] = signature
1169
+ @unit_cache_order.delete(cache_key)
1170
+ @unit_cache_order.push(cache_key)
1171
+ end
1172
+ data
508
1173
  end
1174
+ end
1175
+
1176
+ def unit_file_signature(stat)
1177
+ [stat.dev, stat.ino, stat.size, stat.mtime.to_r, stat.ctime.to_r]
1178
+ end
509
1179
 
510
- @unit_cache[cache_key] = data
511
- @unit_cache_order.push(cache_key)
512
- data
1180
+ def open_unit(path, &block)
1181
+ File.open(path, 'rb', &block)
513
1182
  end
514
1183
 
515
- # Parse a JSON file relative to the index directory.
1184
+ # Read an index artifact as UTF-8, whatever the host locale.
1185
+ #
1186
+ # Bare Pathname#read tags the bytes with Encoding.default_external. On
1187
+ # a C/US-ASCII host that tag is US-ASCII, so a single non-ASCII byte in
1188
+ # an artifact (a branch like "feature/café" in manifest.json, a unit
1189
+ # identifier, prose in SUMMARY.md) makes JSON.parse raise
1190
+ # Encoding::InvalidByteSequenceError and leaves bare string reads with
1191
+ # an invalid encoding — search, lookup, dependencies, dependents,
1192
+ # framework, and recent_changes then surface as misleading
1193
+ # corrupt_artifact results. Reading binary (the mode {#load_unit} has
1194
+ # always used) and forcing UTF-8 keeps the bytes byte-identical while
1195
+ # giving them the encoding extraction writes artifacts in.
1196
+ #
1197
+ # @param path [Pathname] Artifact path inside the loaded generation
1198
+ # @return [String] Contents, tagged UTF-8
1199
+ def read_utf8(path)
1200
+ path.binread.force_encoding(Encoding::UTF_8)
1201
+ end
1202
+
1203
+ # Parse a JSON payload file (manifest, graph, analysis) resolved through
1204
+ # the loaded generation's {#payload_dir}.
516
1205
  def parse_json(filename)
517
- path = @index_dir.join(filename)
518
- JSON.parse(path.read)
1206
+ path = current_payload_dir.join(filename)
1207
+ JSON.parse(read_utf8(path))
1208
+ end
1209
+
1210
+ # Deep-freeze a parsed JSON structure (hashes, arrays, and the strings
1211
+ # and primitives inside them) so a shared parse result cannot be
1212
+ # mutated through any reference.
1213
+ #
1214
+ # JSON.parse(..., freeze: true) would do this natively, but that
1215
+ # keyword needs json >= 2.7.0 while the supported floor (Ruby 3.0)
1216
+ # bundles json 2.5 — a host app on the floor would get ArgumentError
1217
+ # from this path. A small recursive walk is version-independent.
1218
+ #
1219
+ # @param value [Object]
1220
+ # @return [Object] the same structure, frozen
1221
+ def deep_freeze_json(value)
1222
+ case value
1223
+ when Hash then value.each do |key, item|
1224
+ deep_freeze_json(key)
1225
+ deep_freeze_json(item)
1226
+ end.freeze
1227
+ when Array then value.each { |item| deep_freeze_json(item) }.freeze
1228
+ else value.freeze
1229
+ end
519
1230
  end
520
1231
 
521
1232
  # BFS traversal in either direction.
@@ -554,12 +1265,12 @@ module Woods
554
1265
  resolve_reverse_neighbors(graph_data, normalized_edges, current, via_set)
555
1266
  end
556
1267
 
557
- # Filter by node type if requested
1268
+ # Filter by node type if requested. An identifier naming units of
1269
+ # several types matches when any of them does — excluding it because
1270
+ # the type that happens to sort first is not the requested one would
1271
+ # hide a unit the filter asked for.
558
1272
  filtered = if type_set
559
- neighbors.select do |n|
560
- node_meta = nodes_data[n]
561
- node_meta && type_set.include?(node_meta['type'])
562
- end
1273
+ neighbors.select { |n| graph_node_types[n]&.any? { |t| type_set.include?(t) } }
563
1274
  else
564
1275
  neighbors
565
1276
  end
@@ -569,11 +1280,19 @@ module Woods
569
1280
  # node's deps list already shows this node as a child.
570
1281
  will_expand = current_depth < depth
571
1282
  node_meta = nodes_data[current]
572
- result_nodes[current] = {
1283
+ entry = {
573
1284
  type: node_meta&.dig('type'),
574
1285
  depth: current_depth,
575
1286
  deps: will_expand ? filtered : []
576
1287
  }
1288
+ # Only when the identifier is genuinely ambiguous, so the shape is
1289
+ # unchanged for every node in an index with no shared identifiers.
1290
+ node_types = graph_node_types[current] || []
1291
+ entry[:types] = node_types if node_types.size > 1
1292
+ # Likewise: a single-database app learns nothing from a column that
1293
+ # answers the same thing on every row (B-183).
1294
+ entry[:database] = node_meta&.dig('database') if multi_database_graph?
1295
+ result_nodes[current] = entry
577
1296
 
578
1297
  next unless will_expand
579
1298