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
data/lib/tasks/woods.rake CHANGED
@@ -15,19 +15,380 @@
15
15
  # bundle exec rake woods:self_analyze # Analyze gem's own source
16
16
  # bundle exec rake woods:flow[EntryPoint] # Generate execution flow
17
17
 
18
+ # Reading Woods JSON artifacts with a bare File.read tags the result with the
19
+ # process's default external encoding — US-ASCII under LANG=C, the common
20
+ # case in CI and Docker — so a non-ASCII byte (e.g. a git branch name in the
21
+ # manifest) raises Encoding::InvalidByteSequenceError. AtomicFile.write is
22
+ # binmode, so the read side must go through AtomicFile.read to match.
23
+ require 'woods/atomic_file'
24
+ require 'woods/generation'
25
+ require 'woods/git_command'
26
+
18
27
  namespace :woods do
28
+ # ── Multi-instance helpers (#164 phase 4) ────────────────────────────────
29
+ #
30
+ # Worktrees are disjoint by construction (each has its own Rails.root and
31
+ # its own output dir), so these only ever mediate writers against the *same*
32
+ # index: a manual rake run, a hook-triggered sync, and the watch daemon.
33
+
34
+ # Run a block holding the extraction lock, waiting for another writer to
35
+ # finish.
36
+ #
37
+ # This used to proceed *without* the lock after 30s, on the reasoning that a
38
+ # daemon cycle is milliseconds so a longer wait meant something unusual. That
39
+ # reasoning was wrong in the case that matters: a cycle includes a
40
+ # storm-triggered `extract_all`, which on a large host app runs for minutes.
41
+ # Proceeding then means two writers load `dependency_graph.json`, mutate
42
+ # divergent copies, and the last one silently discards the other's work — then
43
+ # bumps the generation, marking the clobbered graph fresh. Per-file atomic
44
+ # writes do not help, because the file *set* is not atomic.
45
+ #
46
+ # So the wait is now generous and the failure explicit. `WOODS_LOCK_WAIT`
47
+ # overrides it; exceeding it exits non-zero rather than corrupting the index,
48
+ # which is the outcome a CI job or a developer can actually act on.
49
+ # Fail the task when a run reported per-item errors (INF-4 / EXP-4).
50
+ #
51
+ # A printed-but-green run is invisible in the CI/cron pipelines these tasks
52
+ # target: a revoked API key, a full vector store, or a 401 on every page
53
+ # prints `Errors: N` and the job stays green while the index or the export
54
+ # target rots. `woods:unblocked_sync` and `woods:obsidian` already exit 1 on
55
+ # the same shape, and #270 fixed it for the extraction family; the embedding
56
+ # and Notion tasks carry identical CI exposure.
57
+ #
58
+ # Partial progress is deliberately *not* a carve-out here. The unblocked
59
+ # exporter has one (budget exhaustion with progress is the expected
60
+ # cold-start shape and converges on the next run); an embedding or Notion
61
+ # error is a genuine failure per unit and does not self-heal, so any
62
+ # non-zero count fails.
63
+ #
64
+ # @param errors [Integer, Array, nil] the run's error count or error list
65
+ # @return [void]
66
+ def woods_exit_on_reported_errors(errors)
67
+ # The embed indexer reports a count; the exporters report a list.
68
+ # (`Integer#size` exists and answers 8, so this must branch on the type.)
69
+ count = errors.is_a?(Numeric) ? errors.to_i : Array(errors).size
70
+ return if count.zero?
71
+
72
+ puts
73
+ puts 'Run completed with errors — failing so CI surfaces it.'
74
+ exit 1
75
+ end
76
+
77
+ def woods_with_extraction_lock(output_dir, wait: nil, &block)
78
+ # Requires first. The default wait reads a constant from the daemon, so
79
+ # resolving it above these lines NameError'd every write task — the same
80
+ # load-order bug as the missing require in `woods:watch`, reintroduced one
81
+ # method over by the fix for it.
82
+ require 'woods/coordination/pipeline_lock'
83
+ require 'woods/coordination/lock_heartbeat'
84
+ require 'woods/watch/daemon'
85
+
86
+ wait ||= Float(ENV.fetch('WOODS_LOCK_WAIT', Woods::Watch::Daemon::LOCK_STALE_TIMEOUT))
87
+
88
+ lock = Woods::Coordination::PipelineLock.new(
89
+ lock_dir: output_dir.to_s,
90
+ name: Woods::Watch::Daemon::LOCK_NAME,
91
+ stale_timeout: Woods::Watch::Daemon::LOCK_STALE_TIMEOUT
92
+ )
93
+
94
+ woods_abort_on_lock_timeout(wait) unless woods_acquire_within(lock, wait)
95
+
96
+ begin
97
+ Woods::Coordination::LockHeartbeat.run(lock, &block)
98
+ ensure
99
+ lock.release
100
+ end
101
+ end
102
+
103
+ # Poll for the lock until `wait` seconds have elapsed.
104
+ #
105
+ # Monotonic, so a clock adjustment mid-wait cannot cut the window short or
106
+ # extend it indefinitely.
107
+ #
108
+ # @return [Boolean] whether the lock was acquired
109
+ def woods_acquire_within(lock, wait)
110
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
111
+ acquired = lock.acquire
112
+ until acquired || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
113
+ sleep 0.25
114
+ acquired = lock.acquire
115
+ end
116
+ acquired
117
+ end
118
+
119
+ def woods_abort_on_lock_timeout(wait)
120
+ warn "ERROR: another writer has held the extraction lock for #{wait.round}s."
121
+ warn 'Refusing to write: two concurrent writers rewrite the dependency graph from divergent'
122
+ warn 'copies, and the loser\'s work is discarded under a generation that says "fresh".'
123
+ warn 'Set WOODS_LOCK_WAIT to wait longer, or stop the other writer.'
124
+ exit 1
125
+ end
126
+
127
+ # Is a watch daemon already maintaining this index?
128
+ #
129
+ # A session-start or worktree hook that fires `woods:incremental` on a tree
130
+ # a daemon is already watching is pure duplicated work — and it contends for
131
+ # the lock the daemon needs. Set WOODS_IGNORE_WATCH=1 to run anyway.
132
+ #
133
+ # Liveness alone is not coverage, and the difference matters. A `:running`
134
+ # daemon reconciles everything modified since the index's last successful
135
+ # publish when it starts (Daemon#catch_up), so changes that predate it are
136
+ # covered whether or not it witnessed them — that is what makes standing down
137
+ # safe. A `:degraded` daemon is alive but *cannot* currently update, so
138
+ # standing down for it would report success over work nothing is doing.
139
+ #
140
+ # @return [Symbol] `:none`, `:running`, or `:degraded`
141
+ def woods_daemon_coverage(output_dir)
142
+ return :none if ENV['WOODS_IGNORE_WATCH'] == '1'
143
+
144
+ require 'woods/watch/status'
145
+ status = Woods::Watch::Status.new(output_dir: output_dir)
146
+ return :none unless status.alive?
147
+
148
+ status.read['state'] == 'degraded' ? :degraded : :running
149
+ rescue StandardError
150
+ :none
151
+ end
152
+
153
+ # Delete the index without yanking it out from under another writer (#170).
154
+ #
155
+ # The old `woods:clean` body was a bare rm_rf. Run mid-daemon-cycle it
156
+ # deleted the daemon's own extraction lock, so the "writers serialize on
157
+ # PipelineLock" invariant evaporated at the exact moment a writer was
158
+ # mid-graph-rewrite. Now: refuse while a daemon is alive (same stand-down
159
+ # check as `woods:incremental`; WOODS_IGNORE_WATCH=1 overrides), then take
160
+ # the extraction lock like every other writer, delete everything EXCEPT the
161
+ # lock file itself, and let release remove the lock last.
162
+ #
163
+ # The directory the published generation's payload lives in.
164
+ #
165
+ # Read-side tasks resolve through this rather than the output root: an index
166
+ # publishing per-generation payloads keeps only `generation.json`, `dumps/`,
167
+ # `payloads/` and the lock files at the root. A flat index resolves to the
168
+ # root unchanged.
169
+ #
170
+ # @param output_dir [Pathname, String] index directory
171
+ # @return [Pathname]
172
+ def woods_payload_dir(output_dir)
173
+ Woods::Generation.new(output_dir: output_dir).payload_dir
174
+ end
175
+
176
+ # @param output_dir [Pathname, String] index directory
177
+ # @param wait [Numeric, nil] seconds to wait for the lock (default: the
178
+ # shared writer wait; injectable so a spec need not sit out the window)
179
+ # @return [Symbol] `:cleaned`, or `:refused` when a live daemon is
180
+ # maintaining this index
181
+ def woods_clean_index(output_dir, wait: nil)
182
+ require 'woods/watch/daemon'
183
+
184
+ output_dir = Pathname.new(output_dir)
185
+
186
+ unless woods_daemon_coverage(output_dir) == :none
187
+ warn 'ERROR: a watch daemon is maintaining this index — refusing to delete it out from under it.'
188
+ warn 'Stop the daemon first, or set WOODS_IGNORE_WATCH=1 to clean anyway.'
189
+ return :refused
190
+ end
191
+
192
+ lock_name = Woods::Watch::Daemon::LOCK_NAME
193
+ woods_with_extraction_lock(output_dir, wait: wait) do
194
+ woods_sweep_index_dir(output_dir, lock_name)
195
+ end
196
+
197
+ # The lock was released (and its file removed) above; drop the guard so
198
+ # the directory can empty, then remove the now-empty directory. Another
199
+ # writer may legitimately have recreated content between release and here
200
+ # — a non-empty directory is left alone rather than forced.
201
+ woods_remove_if_empty(output_dir, lock_name)
202
+ :cleaned
203
+ end
204
+
205
+ # Delete every index artifact except the lock file and its transaction
206
+ # guard. The guard lives inside the lock directory and a contender may hold
207
+ # its flock right now — deleting it mid-sweep would split the flock across
208
+ # two inodes and defeat the mutual exclusion it provides.
209
+ def woods_sweep_index_dir(output_dir, lock_name)
210
+ preserved = [
211
+ output_dir.join("#{lock_name}.lock"),
212
+ output_dir.join(Woods::Coordination::PipelineLock.guard_filename(lock_name))
213
+ ]
214
+ output_dir.children.each do |entry|
215
+ FileUtils.rm_rf(entry) unless preserved.include?(entry)
216
+ end
217
+ end
218
+
219
+ def woods_remove_if_empty(output_dir, lock_name)
220
+ FileUtils.rm_f(output_dir.join(Woods::Coordination::PipelineLock.guard_filename(lock_name)))
221
+ Dir.rmdir(output_dir)
222
+ rescue SystemCallError
223
+ nil
224
+ end
225
+
226
+ # The root containing the Rakefile that loaded this task file.
227
+ #
228
+ # `woods:watch_status` intentionally avoids Rails boot, so it cannot ask
229
+ # `Rails.root` for the conventional output path. `Dir.pwd` is not a stable
230
+ # substitute: `rake -f /app/Rakefile` and worktree launchers may invoke the
231
+ # task from somewhere else. Rake retains the selected Rakefile even when it
232
+ # does not chdir, which gives the same application root without loading the
233
+ # environment.
234
+ #
235
+ # @return [String] absolute directory containing the active Rakefile
236
+ def woods_task_root
237
+ rakefile = Rake.application.rakefile
238
+ return Rake.application.original_dir if rakefile.nil? || rakefile.empty?
239
+
240
+ # Rake may keep this relative (`Rakefile`) after searching upward from a
241
+ # nested invocation. At task execution its cwd is the directory it loaded
242
+ # that relative file from. An explicit absolute `-f` is already complete.
243
+ File.dirname(File.expand_path(rakefile))
244
+ end
245
+
246
+ # Changed paths across a git range, for `woods:incremental`'s CI branches.
247
+ #
248
+ # `git diff --name-only` split on lines corrupted three things at once: a
249
+ # path containing a newline split into two entries; a non-ASCII path came
250
+ # back octal-escaped inside quotes under git's default `core.quotePath`,
251
+ # which the dispatcher then can't match to any rule; and a rename reported
252
+ # only the new path, so the old path's unit was never pruned. `-z` +
253
+ # `--name-status` + `--no-renames` fixes all three: NUL-delimited records,
254
+ # `core.quotePath=false` unescaped, and a rename decomposed by git itself
255
+ # into a separate `A <new>` and `D <old>` record rather than one `R` record
256
+ # naming both.
257
+ #
258
+ # The diff is rooted at the extracted application (`git -C Rails.root`),
259
+ # consistent with {Woods::GitProvenance} (#262): extraction launched from
260
+ # another checkout must not diff that checkout's history.
261
+ #
262
+ # The child status is carried out, not discarded (M1): `Open3.capture2`
263
+ # turned an unresolvable range — a GitLab zero-SHA, an unfetched GitHub base
264
+ # ref, garbage — into an empty change set the caller could not tell apart
265
+ # from "nothing changed", and `woods:incremental` exited 0 over a sync that
266
+ # never ran.
267
+ #
268
+ # @param range [String] a git diff range/revision expression
269
+ # @param root [Pathname, String] repository root the diff runs against
270
+ # @return [Array(Array<String>, String, nil)] the changed paths (both halves
271
+ # of any rename included), or nil paths plus a human-readable failure when
272
+ # the range could not be resolved
273
+ def woods_changed_paths_for_range(range, root: Rails.root)
274
+ require 'open3'
275
+ output, error, status = Open3.capture3(
276
+ *Woods::GitCommand.argv(
277
+ root, '-c', 'core.quotePath=false',
278
+ 'diff', '--name-status', '-z', '--no-renames', range
279
+ )
280
+ )
281
+ return [woods_parse_git_diff_name_status(output), nil] if status.success?
282
+
283
+ [nil, "#{error.strip} (git exited #{status.exitstatus})"]
284
+ rescue SystemCallError => e
285
+ # No git binary at all (a slim container image, git removed after
286
+ # checkout). Errno::ENOENT out of `Open3.capture3` used to kill the task
287
+ # with a backtrace: non-zero, so safe, but it bypassed the decision below
288
+ # — the `:running`-daemon stand-down branch was unreachable, and the
289
+ # operator got a stack trace instead of the remediation text. An absent
290
+ # binary is an unresolvable range like any other (INF-12).
291
+ [nil, "git unavailable: #{e.message}"]
292
+ end
293
+
294
+ # The changed-path set `woods:incremental` will process, or a stand-down.
295
+ #
296
+ # An explicit `CHANGED_FILES` list bypasses git entirely. Otherwise the
297
+ # range comes from the CI environment (GitLab's before-SHA, GitHub's base
298
+ # ref) or defaults to the last commit.
299
+ #
300
+ # A failed range is a decision, not a skip (M1): the changed-file set is
301
+ # unknown, so silently extracting nothing would exit 0 over work that never
302
+ # happened and leave CI drift unbounded. When a `:running` daemon maintains
303
+ # the index, its start-up catch-up covers whatever changed, so standing down
304
+ # with a printed reason is safe; a degraded daemon covers nothing, so the
305
+ # run fails like any other uncovered case. The decision runs BEFORE the
306
+ # task's empty-range exit — a failed range must never be mistaken for an
307
+ # empty one.
308
+ #
309
+ # @param output_dir [Pathname, String] index directory, for daemon coverage
310
+ # @return [Array<String>] changed paths
311
+ def woods_incremental_changed_paths(output_dir)
312
+ explicit = ENV.fetch('CHANGED_FILES', nil)
313
+ return explicit.split(',').map(&:strip) if explicit
314
+
315
+ range = woods_incremental_range
316
+ changed_files, failure = woods_changed_paths_for_range(range)
317
+ return changed_files unless failure
318
+
319
+ if woods_daemon_coverage(output_dir) == :running
320
+ puts "Could not resolve the git diff range #{range.inspect} (#{failure})."
321
+ puts 'A watch daemon is maintaining this index, so its catch-up covers the changed paths — standing down.'
322
+ puts 'To extract now anyway, repair or provide the range (check the CI env refs),'
323
+ puts 'set CHANGED_FILES explicitly, or run a full woods:extract.'
324
+ exit 0
325
+ end
326
+
327
+ warn "ERROR: could not resolve the git diff range #{range.inspect}: #{failure}"
328
+ warn 'The changed-file set is unknown, so incremental extraction would silently index nothing.'
329
+ warn 'Fix the range (a zero SHA or an unfetched base ref resolve to nothing), or run a full woods:extract.'
330
+ exit 1
331
+ end
332
+
333
+ # The git range `woods:incremental` diffs, from the CI environment or the
334
+ # last-commit default.
335
+ #
336
+ # @return [String]
337
+ def woods_incremental_range
338
+ if ENV['CI_COMMIT_BEFORE_SHA']
339
+ # GitLab CI
340
+ "#{ENV['CI_COMMIT_BEFORE_SHA']}..#{ENV.fetch('CI_COMMIT_SHA', nil)}"
341
+ elsif ENV['GITHUB_BASE_REF']
342
+ # GitHub Actions PR
343
+ "origin/#{ENV['GITHUB_BASE_REF']}...HEAD"
344
+ else
345
+ # Default: changes since last commit
346
+ 'HEAD~1'
347
+ end
348
+ end
349
+
350
+ # Parse NUL-delimited `git diff --name-status -z --no-renames` output.
351
+ #
352
+ # Each record is `<status>\0<path>\0` — `--no-renames` guarantees a single
353
+ # path per record, since it is what stops git emitting a two-path `R`/`C`
354
+ # record in the first place. Status letters are not inspected beyond "did
355
+ # git report anything at all"; a deleted path still needs to reach the
356
+ # change set so its unit can be pruned.
357
+ #
358
+ # A bare `Open3.capture2` read is tagged with the process's default
359
+ # external encoding — US-ASCII under `LANG=C`, this daemon's usual
360
+ # environment (see `AtomicFile.read`'s gotcha) — so a UTF-8 path is a
361
+ # US-ASCII string containing invalid bytes until re-tagged.
362
+ #
363
+ # @param output [String] raw NUL-delimited git output
364
+ # @return [Array<String>] changed paths
365
+ def woods_parse_git_diff_name_status(output)
366
+ fields = output.dup.force_encoding(Encoding::UTF_8).split("\x00")
367
+ paths = []
368
+ fields.each_slice(2) do |status, path|
369
+ break if path.nil?
370
+
371
+ paths << path unless status.nil? || status.empty?
372
+ end
373
+ paths
374
+ end
375
+
19
376
  desc 'Full extraction of codebase for indexing'
20
377
  task extract: :environment do
21
378
  require 'woods/extractor'
22
379
 
23
- output_dir = ENV.fetch('WOODS_OUTPUT', Rails.root.join('tmp/woods'))
380
+ output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
24
381
 
25
382
  puts 'Starting full codebase extraction...'
26
383
  puts "Output directory: #{output_dir}"
27
384
  puts
28
385
 
29
386
  extractor = Woods::Extractor.new(output_dir: output_dir)
30
- results = extractor.extract_all
387
+ results = woods_with_extraction_lock(output_dir) do
388
+ extracted = extractor.extract_all
389
+ extractor.raise_on_publication_failure!
390
+ extracted
391
+ end
31
392
 
32
393
  puts
33
394
  puts 'Extraction complete!'
@@ -48,70 +409,48 @@ namespace :woods do
48
409
  task incremental: :environment do
49
410
  require 'woods/extractor'
50
411
 
51
- output_dir = ENV.fetch('WOODS_OUTPUT', Rails.root.join('tmp/woods'))
412
+ output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
52
413
 
53
- # Determine changed files from CI environment or git
54
- require 'open3'
414
+ # Determine changed files from CI environment or git. A failed range is
415
+ # decided here too — stand down under a covering daemon, or fail loudly —
416
+ # BEFORE the empty-range exit below can mistake it for "nothing changed"
417
+ # (M1).
418
+ changed_files = woods_incremental_changed_paths(output_dir)
55
419
 
56
- changed_files = if ENV['CHANGED_FILES']
57
- # Explicit list from CI
58
- ENV['CHANGED_FILES'].split(',').map(&:strip)
59
- elsif ENV['CI_COMMIT_BEFORE_SHA']
60
- # GitLab CI
61
- output, = Open3.capture2('git', 'diff', '--name-only',
62
- "#{ENV['CI_COMMIT_BEFORE_SHA']}..#{ENV.fetch('CI_COMMIT_SHA', nil)}")
63
- output.lines.map(&:strip)
64
- elsif ENV['GITHUB_BASE_REF']
65
- # GitHub Actions PR
66
- output, = Open3.capture2('git', 'diff', '--name-only',
67
- "origin/#{ENV['GITHUB_BASE_REF']}...HEAD")
68
- output.lines.map(&:strip)
69
- else
70
- # Default: changes since last commit
71
- output, = Open3.capture2('git', 'diff', '--name-only', 'HEAD~1')
72
- output.lines.map(&:strip)
73
- end
74
-
75
- # Filter to relevant files
76
- relevant_patterns = [
77
- %r{^app/models/},
78
- %r{^app/controllers/},
79
- %r{^app/services/},
80
- %r{^app/components/},
81
- %r{^app/views/components/},
82
- %r{^app/views/.*\.rb$}, # Phlex views
83
- %r{^app/interactors/},
84
- %r{^app/operations/},
85
- %r{^app/commands/},
86
- %r{^app/use_cases/},
87
- %r{^app/jobs/},
88
- %r{^app/workers/}, # Sidekiq workers
89
- %r{^app/mailers/},
90
- %r{^app/graphql/}, # GraphQL types/mutations/resolvers
91
- %r{^app/serializers/},
92
- %r{^app/decorators/},
93
- %r{^app/blueprinters/},
94
- %r{^db/migrate/},
95
- %r{^db/schema\.rb$}, # Schema changes affect model metadata
96
- %r{^config/routes\.rb$},
97
- /^Gemfile\.lock$/ # Dependency changes trigger framework re-index
98
- ]
99
-
100
- changed_files = changed_files.select do |f|
101
- relevant_patterns.any? { |p| f.match?(p) }
102
- end
420
+ # Filter to paths that imply extraction work. The rule set lives in
421
+ # PathDispatcher alongside the dispatch itself — a second hand-maintained
422
+ # pattern list here would drift, and a path this filter drops never
423
+ # reaches the index however good the dispatch behind it is (#164).
424
+ dispatcher = Woods::PathDispatcher.new
425
+ changed_files = changed_files.reject(&:empty?).select { |f| dispatcher.relevant?(f) }
103
426
 
104
427
  if changed_files.empty?
105
428
  puts 'No relevant files changed. Skipping extraction.'
106
429
  exit 0
107
430
  end
108
431
 
432
+ case woods_daemon_coverage(output_dir)
433
+ when :running
434
+ puts 'A watch daemon is maintaining this index — skipping.'
435
+ puts 'It reconciles anything changed since the last publish when it starts, so these are covered.'
436
+ puts 'Set WOODS_IGNORE_WATCH=1 to extract anyway.'
437
+ exit 0
438
+ when :degraded
439
+ # Alive but unable to update. Standing down here would exit 0 over work
440
+ # nothing is actually doing.
441
+ puts 'Warning: a watch daemon is alive but degraded — extracting anyway rather than assuming coverage.'
442
+ end
443
+
109
444
  puts "Incremental extraction for #{changed_files.size} changed files..."
110
445
  changed_files.each { |f| puts " - #{f}" }
111
446
  puts
112
447
 
113
448
  extractor = Woods::Extractor.new(output_dir: output_dir)
114
- affected = extractor.extract_changed(changed_files)
449
+ affected = woods_with_extraction_lock(output_dir) do
450
+ extracted = extractor.extract_changed(changed_files)
451
+ extractor.raise_on_publication_failure!
452
+ extracted
453
+ end
115
454
 
116
455
  puts
117
456
  puts "Re-extracted #{affected.size} affected units."
@@ -120,99 +459,190 @@ namespace :woods do
120
459
  desc 'Tend the garden — incremental extraction (alias for incremental)'
121
460
  task tend: :incremental
122
461
 
462
+ desc 'Watch the app and keep the index current (resident daemon)'
463
+ task watch: :environment do
464
+ # Both, and the extractor is not optional. The daemon's default
465
+ # extractor_factory names Woods::Extractor lazily, so omitting this require
466
+ # loaded and started cleanly and then NameError'd on the first real cycle —
467
+ # which the failure posture turns into a permanently degraded daemon rather
468
+ # than a crash. Every spec pre-requires the extractor in its own setup, so
469
+ # the suite stayed green over a broken entry point.
470
+ require 'woods/extractor'
471
+ require 'woods/watch/daemon'
472
+
473
+ output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
474
+
475
+ daemon = Woods::Watch::Daemon.new(
476
+ output_dir: output_dir,
477
+ root: Rails.root,
478
+ debounce: Float(ENV.fetch('WOODS_WATCH_DEBOUNCE', Woods::Watch::Daemon::DEFAULT_DEBOUNCE)),
479
+ full_extraction_threshold: Integer(
480
+ ENV.fetch('WOODS_WATCH_FULL_THRESHOLD', Woods::Watch::Daemon::DEFAULT_FULL_EXTRACTION_THRESHOLD)
481
+ ),
482
+ # The documented fix for a container watching a bind mount, where native
483
+ # FS events do not propagate and the daemon would sit silent. Nothing
484
+ # exposed it before, which made the advice unfollowable.
485
+ force_polling: ENV['WOODS_WATCH_POLL'] == '1', # container autodetect also applies; see Watcher.containerized?
486
+ idle_timeout: ENV.fetch('WOODS_WATCH_IDLE_TIMEOUT', nil) && Float(ENV.fetch('WOODS_WATCH_IDLE_TIMEOUT')),
487
+ catch_up: ENV['WOODS_WATCH_CATCH_UP'] != '0',
488
+ logger: Rails.logger
489
+ )
490
+
491
+ # The trap sets a flag and nothing else. `daemon.stop` reaches
492
+ # `Listen::Listener#stop`, which drives a state machine behind mutexes —
493
+ # and taking a mutex in trap context raises ThreadError on some Ruby
494
+ # versions, turning Ctrl-C into a crash instead of a clean shutdown. A tiny
495
+ # supervisor thread does the real work outside trap context.
496
+ stop_requested = Queue.new
497
+ %w[INT TERM].each { |sig| Signal.trap(sig) { stop_requested.push(sig) } }
498
+ Thread.new do
499
+ stop_requested.pop
500
+ daemon.stop
501
+ end
502
+
503
+ puts "Watching #{Rails.root} — index at #{output_dir}"
504
+ puts 'Ctrl-C to stop.'
505
+ puts
506
+
507
+ reason = daemon.run
508
+
509
+ if reason == :restart_required
510
+ # Boot-captured state changed; Rails cannot reload it. Exit non-zero so
511
+ # a supervisor (foreman, systemd, `docker compose` restart policy)
512
+ # brings the process back with the new configuration.
513
+ warn 'Restart required — boot-captured configuration changed. Exiting for a supervisor to restart.'
514
+ exit 75 # EX_TEMPFAIL
515
+ end
516
+
517
+ puts 'Watcher stopped.'
518
+ end
519
+
520
+ desc 'Keep watch over the woods — resident index daemon (alias for watch)'
521
+ task guard: :watch
522
+
523
+ desc 'Report whether a watch daemon is maintaining this index (exit 0 if alive)'
524
+ # Deliberately not `=> :environment`. This reads one small JSON file, and the
525
+ # whole point is that a worktree hook can call it before deciding whether to
526
+ # do real work — paying a full Rails boot to find out would cost more than the
527
+ # sync it is trying to avoid. WOODS_OUTPUT covers the non-default layout;
528
+ # otherwise the conventional path is derived without booting.
529
+ task :watch_status do
530
+ require 'woods/watch/status'
531
+ require 'json'
532
+
533
+ output_dir = ENV.fetch('WOODS_OUTPUT') { File.join(woods_task_root, 'tmp/woods') }
534
+ status = Woods::Watch::Status.new(output_dir: output_dir)
535
+
536
+ puts JSON.pretty_generate(status.read)
537
+ # Exit status is the point: a worktree hook can `rake woods:watch_status ||
538
+ # start_daemon` without parsing anything.
539
+ exit(status.alive? ? 0 : 1)
540
+ end
541
+
542
+ desc 'Re-run named extractors wholesale, e.g. woods:refresh[routes,middleware]'
543
+ task :refresh, [:extractor] => :environment do |_task, args|
544
+ require 'woods/extractor'
545
+
546
+ keys = [args[:extractor], *args.extras].compact.map(&:strip).reject(&:empty?)
547
+
548
+ if keys.empty?
549
+ puts 'Usage: rake "woods:refresh[routes]" (comma-separate for several)'
550
+ puts
551
+ puts 'Whole-app extractors — no per-file entry point, so these are the'
552
+ puts 'ones a targeted refresh is normally for:'
553
+ puts " #{Woods::Extractor::WHOLE_APP_EXTRACTORS.keys.sort.join(', ')}"
554
+ puts
555
+ puts 'Any extractor key is accepted:'
556
+ puts " #{Woods::Extractor::EXTRACTORS.keys.sort.join(', ')}"
557
+ exit 1
558
+ end
559
+
560
+ output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
561
+ extractor = Woods::Extractor.new(output_dir: output_dir)
562
+
563
+ # A refresh is a fourth writer against this index, and it rewrites the whole
564
+ # dependency graph. Two writers loading the persisted graph, mutating
565
+ # divergent copies and writing back means the last one silently discards the
566
+ # other's work — and then bumps the generation, telling readers the
567
+ # clobbered state is fresh. Atomic writes do not help: each write is
568
+ # individually intact, the *set* is not. So it serializes like the others.
569
+ begin
570
+ result = woods_with_extraction_lock(output_dir) do
571
+ refreshed = extractor.refresh(*keys)
572
+ extractor.raise_on_publication_failure!
573
+ refreshed
574
+ end
575
+ rescue ArgumentError => e
576
+ puts "ERROR: #{e.message}"
577
+ puts "Known extractors: #{Woods::Extractor::EXTRACTORS.keys.sort.join(', ')}"
578
+ exit 1
579
+ end
580
+
581
+ puts "Refreshed: #{result[:types].join(', ')}"
582
+ puts "Warning: ignored unknown extractor(s): #{result[:unknown].join(', ')}" if result[:unknown].any?
583
+ puts "#{result[:touched].size} unit(s) written or removed."
584
+ end
585
+
123
586
  desc 'Extract only Rails/gem framework sources (run when dependencies change)'
124
587
  task extract_framework: :environment do
125
- require 'woods/extractors/rails_source_extractor'
588
+ require 'woods/extractor'
126
589
 
127
- output_dir = ENV.fetch('WOODS_OUTPUT', Rails.root.join('tmp/woods'))
590
+ output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
128
591
 
129
592
  puts 'Extracting Rails and gem framework sources...'
130
593
  puts "Rails version: #{Rails.version}"
131
594
  puts
132
595
 
133
- extractor = Woods::Extractors::RailsSourceExtractor.new
134
- units = extractor.extract_all
135
-
136
- # Write output
137
- framework_dir = Pathname.new(output_dir).join('rails_source')
138
- FileUtils.mkdir_p(framework_dir)
139
-
140
- units.each do |unit|
141
- file_name = "#{unit.identifier.gsub('/', '__').gsub('::', '__')}.json"
142
- File.write(
143
- framework_dir.join(file_name),
144
- JSON.pretty_generate(unit.to_h)
145
- )
596
+ # Back-compat alias for `woods:refresh[rails_source]` (#169). The old body
597
+ # hand-wrote unit JSON with a bare File.write — no AtomicFile, no path
598
+ # normalization, no _index.json, no manifest counts, no PipelineLock, no
599
+ # generation bump — so the units it produced were invisible to
600
+ # generation-keyed readers and went stale forever. Routing through
601
+ # Extractor#refresh sends framework sources through the same write
602
+ # pipeline (and the same writer lock) as every other unit type. This runs
603
+ # regardless of `include_framework_sources` — an explicit invocation is
604
+ # the escape hatch the knob deliberately leaves open.
605
+ extractor = Woods::Extractor.new(output_dir: output_dir)
606
+ result = woods_with_extraction_lock(output_dir) do
607
+ refreshed = extractor.refresh(:rails_source)
608
+ extractor.raise_on_publication_failure!
609
+ refreshed
146
610
  end
147
611
 
148
- puts "Extracted #{units.size} framework source units."
149
- puts "Output: #{framework_dir}"
612
+ puts "Extracted #{result[:touched].size} framework source unit(s)."
613
+ puts "Output: #{Pathname.new(output_dir).join('rails_source')}"
150
614
  end
151
615
 
152
616
  desc 'Validate extracted index integrity'
153
617
  task validate: :environment do
154
- output_dir = Pathname.new(ENV.fetch('WOODS_OUTPUT', Rails.root.join('tmp/woods')))
618
+ require 'woods/resilience/index_validator'
619
+
620
+ output_dir = Pathname.new(ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir))
155
621
 
156
622
  unless output_dir.exist?
157
623
  puts "ERROR: Index directory does not exist: #{output_dir}"
158
624
  exit 1
159
625
  end
160
626
 
161
- manifest_path = output_dir.join('manifest.json')
627
+ manifest_path = woods_payload_dir(output_dir).join('manifest.json')
162
628
  unless manifest_path.exist?
163
629
  puts 'ERROR: Manifest not found. Run extraction first.'
164
630
  exit 1
165
631
  end
166
632
 
167
- manifest = JSON.parse(File.read(manifest_path))
633
+ manifest = JSON.parse(Woods::AtomicFile.read(manifest_path))
168
634
 
169
635
  puts 'Validating index...'
170
636
  puts " Extracted at: #{manifest['extracted_at']}"
171
637
  puts " Git SHA: #{manifest['git_sha']}"
172
638
  puts
173
639
 
174
- errors = []
175
- warnings = []
176
-
177
- # Check each type directory
178
- manifest['counts'].each do |type, expected_count|
179
- type_dir = output_dir.join(type)
180
- unless type_dir.exist?
181
- errors << "Missing directory: #{type}"
182
- next
183
- end
184
-
185
- actual_count = Dir[type_dir.join('*.json')].reject { |f| f.end_with?('_index.json') }.size
186
-
187
- warnings << "#{type}: expected #{expected_count}, found #{actual_count}" if actual_count != expected_count
640
+ # One implementation of the check (B-128): the class the worktree
641
+ # integration spec asserts through is the one this task runs.
642
+ report = Woods::Resilience::IndexValidator.new(index_dir: output_dir.to_s, app_root: Rails.root.to_s).validate
643
+ errors = report.errors
644
+ warnings = report.warnings
188
645
 
189
- # Validate each unit file is valid JSON
190
- Dir[type_dir.join('*.json')].each do |file|
191
- next if file.end_with?('_index.json')
192
-
193
- begin
194
- data = JSON.parse(File.read(file))
195
- errors << "#{file}: missing identifier" unless data['identifier']
196
- errors << "#{file}: missing source_code" unless data['source_code']
197
- rescue JSON::ParserError => e
198
- errors << "#{file}: invalid JSON - #{e.message}"
199
- end
200
- end
201
- end
202
-
203
- # Check dependency graph
204
- graph_path = output_dir.join('dependency_graph.json')
205
- if graph_path.exist?
206
- begin
207
- JSON.parse(File.read(graph_path))
208
- rescue JSON::ParserError
209
- errors << 'dependency_graph.json: invalid JSON'
210
- end
211
- else
212
- errors << 'Missing dependency_graph.json'
213
- end
214
-
215
- # Report
216
646
  if errors.any?
217
647
  puts 'ERRORS:'
218
648
  errors.each { |e| puts " ✗ #{e}" }
@@ -238,15 +668,16 @@ namespace :woods do
238
668
 
239
669
  desc 'Show index statistics'
240
670
  task stats: :environment do
241
- output_dir = Pathname.new(ENV.fetch('WOODS_OUTPUT', Rails.root.join('tmp/woods')))
671
+ output_dir = Pathname.new(ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir))
242
672
 
243
673
  unless output_dir.exist?
244
674
  puts 'Index directory does not exist. Run extraction first.'
245
675
  exit 1
246
676
  end
247
677
 
248
- manifest_path = output_dir.join('manifest.json')
249
- manifest = manifest_path.exist? ? JSON.parse(File.read(manifest_path)) : {}
678
+ payload_dir = woods_payload_dir(output_dir)
679
+ manifest_path = payload_dir.join('manifest.json')
680
+ manifest = manifest_path.exist? ? JSON.parse(Woods::AtomicFile.read(manifest_path)) : {}
250
681
 
251
682
  puts 'Woods Index Statistics'
252
683
  puts '=' * 50
@@ -265,7 +696,7 @@ namespace :woods do
265
696
  total_chunks = 0
266
697
 
267
698
  (manifest['counts'] || {}).each do |type, count|
268
- type_dir = output_dir.join(type)
699
+ type_dir = payload_dir.join(type)
269
700
  next unless type_dir.exist?
270
701
 
271
702
  type_size = Dir[type_dir.join('*.json')].sum { |f| File.size(f) }
@@ -276,7 +707,7 @@ namespace :woods do
276
707
  index_path = type_dir.join('_index.json')
277
708
  type_chunks = 0
278
709
  if index_path.exist?
279
- index = JSON.parse(File.read(index_path))
710
+ index = JSON.parse(Woods::AtomicFile.read(index_path))
280
711
  type_chunks = index.sum { |u| u['chunk_count'] || 0 }
281
712
  total_chunks += type_chunks
282
713
  end
@@ -289,9 +720,9 @@ namespace :woods do
289
720
  puts
290
721
 
291
722
  # Dependency graph stats
292
- graph_path = output_dir.join('dependency_graph.json')
723
+ graph_path = payload_dir.join('dependency_graph.json')
293
724
  if graph_path.exist?
294
- graph = JSON.parse(File.read(graph_path))
725
+ graph = JSON.parse(Woods::AtomicFile.read(graph_path))
295
726
  stats = graph['stats'] || {}
296
727
  puts 'Dependency Graph'
297
728
  puts '-' * 50
@@ -305,11 +736,11 @@ namespace :woods do
305
736
 
306
737
  desc 'Clean extracted index'
307
738
  task clean: :environment do
308
- output_dir = Pathname.new(ENV.fetch('WOODS_OUTPUT', Rails.root.join('tmp/woods')))
739
+ output_dir = Pathname.new(ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir))
309
740
 
310
741
  if output_dir.exist?
311
742
  puts "Removing #{output_dir}..."
312
- FileUtils.rm_rf(output_dir)
743
+ exit 1 unless woods_clean_index(output_dir) == :cleaned
313
744
  puts 'Done.'
314
745
  else
315
746
  puts 'Index directory does not exist.'
@@ -319,36 +750,38 @@ namespace :woods do
319
750
  desc 'Clear the brush — remove index (alias for clean)'
320
751
  task clear: :clean
321
752
 
322
- # Internal debugging tool — hidden from `rails -T`
323
- task :retrieve, [:query] => :environment do |_t, args|
324
- query = args[:query] || raise('Usage: rake woods:retrieve[query]')
325
-
753
+ # Resolve the configured retrieval stack and run one ad-hoc query (#178).
754
+ #
755
+ # Every backend — embedding provider, vector store, metadata store, graph
756
+ # store — is resolved through Woods::Builder from Woods.configuration,
757
+ # the same wiring `woods:embed` writes through
758
+ # (Woods::Tasks.build_embed_indexer). The old task body hardcoded
759
+ # Ollama + InMemory + SQLite + Memory, so on any other configured stack it
760
+ # queried backends the embed run never wrote to and silently returned
761
+ # nothing.
762
+ #
763
+ # In-memory stores start empty in a fresh process; hosts on the :local /
764
+ # :shared_filesystem presets should query through woods-mcp, which
765
+ # hydrates them from the dumps on disk.
766
+ #
767
+ # @param query [String] natural-language retrieval query
768
+ # @return [String] human-formatted retrieval output
769
+ def woods_run_retrieval(query)
326
770
  require 'woods'
327
- require 'woods/retriever'
328
- require 'woods/embedding/provider'
329
- require 'woods/storage/vector_store'
330
- require 'woods/storage/metadata_store'
331
- require 'woods/storage/graph_store'
332
771
  require 'woods/formatting/human_adapter'
333
772
 
334
773
  config = Woods.configuration
774
+ retriever = Woods::Builder.new(config).build_retriever
775
+ result = retriever.retrieve(query, budget: config.max_context_tokens)
335
776
 
336
- provider = Woods::Embedding::Provider::Ollama.new
337
- vector_store = Woods::Storage::VectorStore::InMemory.new
338
- metadata_store = Woods::Storage::MetadataStore::SQLite.new
339
- graph_store = Woods::Storage::GraphStore::Memory.new
340
-
341
- retriever = Woods::Retriever.new(
342
- vector_store: vector_store,
343
- metadata_store: metadata_store,
344
- graph_store: graph_store,
345
- embedding_provider: provider
346
- )
777
+ Woods::Formatting::HumanAdapter.new.format(result)
778
+ end
347
779
 
348
- result = retriever.retrieve(query, budget: config.max_context_tokens)
780
+ # Internal debugging tool — hidden from `rails -T`
781
+ task :retrieve, [:query] => :environment do |_t, args|
782
+ query = args[:query] || raise('Usage: rake woods:retrieve[query]')
349
783
 
350
- formatter = Woods::Formatting::HumanAdapter.new
351
- puts formatter.format(result)
784
+ puts woods_run_retrieval(query)
352
785
  end
353
786
 
354
787
  desc 'Embed all extracted units'
@@ -356,9 +789,20 @@ namespace :woods do
356
789
  require 'woods'
357
790
  require 'woods/tasks'
358
791
 
792
+ # Embedding writes dumps/, checkpoint.json and woods.json under the same
793
+ # output dir the extraction writers own — and `woods:clean` deletes them —
794
+ # so it serializes on the same PipelineLock (#170). One lock domain per
795
+ # index, deliberately coarse: an embed does block an extract it doesn't
796
+ # byte-conflict with, but the failure the lock prevents is a concurrent
797
+ # clean/extract silently clobbering a dump mid-promotion, and a second
798
+ # lock name would reintroduce exactly the unlocked-writer gap.
799
+ output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
800
+
359
801
  indexer = Woods::Tasks.build_embed_indexer
360
802
  puts 'Embedding all extracted units...'
361
- Woods::Tasks.print_embed_stats(indexer.index_all, mode: :full)
803
+ stats = woods_with_extraction_lock(output_dir) { indexer.index_all }
804
+ Woods::Tasks.print_embed_stats(stats, mode: :full)
805
+ woods_exit_on_reported_errors(stats[:errors])
362
806
  end
363
807
 
364
808
  desc 'Nest the data — embed all units (alias for embed)'
@@ -369,9 +813,14 @@ namespace :woods do
369
813
  require 'woods'
370
814
  require 'woods/tasks'
371
815
 
816
+ # Same lock domain as woods:embed — see the comment there (#170).
817
+ output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
818
+
372
819
  indexer = Woods::Tasks.build_embed_indexer
373
820
  puts 'Embedding changed units (incremental)...'
374
- Woods::Tasks.print_embed_stats(indexer.index_incremental, mode: :incremental)
821
+ stats = woods_with_extraction_lock(output_dir) { indexer.index_incremental }
822
+ Woods::Tasks.print_embed_stats(stats, mode: :incremental)
823
+ woods_exit_on_reported_errors(stats[:errors])
375
824
  end
376
825
 
377
826
  desc 'Hone the blade — incremental embedding (alias for embed_incremental)'
@@ -452,31 +901,31 @@ namespace :woods do
452
901
  FileUtils.mkdir_p(docs_dir)
453
902
  renderer = Woods::RubyAnalyzer::MermaidRenderer.new
454
903
 
904
+ # architecture.md embeds the call-graph, dependency-map, and dataflow
905
+ # renders as sections; emitting them as standalone files too duplicated
906
+ # ~13k generated lines in the docs tree for no reader benefit.
455
907
  File.write(
456
908
  File.join(docs_dir, 'architecture.md'),
457
909
  renderer.render_architecture(units, graph_data, analysis)
458
910
  )
459
911
 
460
- File.write(
461
- File.join(docs_dir, 'call-graph.md'),
462
- "# Call Graph\n\n```mermaid\n#{renderer.render_call_graph(units)}\n```\n"
463
- )
464
-
465
- File.write(
466
- File.join(docs_dir, 'dependency-map.md'),
467
- "# Dependency Map\n\n```mermaid\n#{renderer.render_dependency_map(graph_data)}\n```\n"
468
- )
469
-
470
- File.write(
471
- File.join(docs_dir, 'dataflow.md'),
472
- "# Data Flow\n\n```mermaid\n#{renderer.render_dataflow(units)}\n```\n"
473
- )
474
-
475
912
  puts " JSON output: #{json_dir}"
476
913
  puts " Mermaid docs: #{docs_dir}"
477
914
  puts 'Self-analysis complete.'
478
915
  end
479
916
 
917
+ # Internal developer tool. Unlike self_analyze, this publishes an ordinary
918
+ # index generation that the packaged Index MCP server can read. It is
919
+ # intentionally Woods-only and static: it does not boot Rails.
920
+ task :self_map, [:output_dir] do |_task, args|
921
+ require 'woods/gem_mapper'
922
+
923
+ gem_root = File.expand_path('../..', __dir__)
924
+ output_dir = args[:output_dir] || ENV.fetch('WOODS_SELF_MAP_OUTPUT', File.join(gem_root, 'tmp', 'woods_self_map'))
925
+ result = Woods::GemMapper.new(root: gem_root, output_dir: output_dir).map!
926
+ puts "Woods self-map #{result[:status]} (generation #{result[:generation]}) at #{output_dir}"
927
+ end
928
+
480
929
  desc 'Generate execution flow document for a Rails entry point'
481
930
  task :flow, [:entry_point] => :environment do |_t, args|
482
931
  require 'json'
@@ -489,8 +938,8 @@ namespace :woods do
489
938
  exit 1
490
939
  end
491
940
 
492
- output_dir = ENV.fetch('WOODS_OUTPUT', Rails.root.join('tmp/woods'))
493
- graph_path = File.join(output_dir, 'dependency_graph.json')
941
+ output_dir = ENV.fetch('WOODS_OUTPUT', Woods.configuration.output_dir)
942
+ graph_path = File.join(woods_payload_dir(output_dir).to_s, 'dependency_graph.json')
494
943
 
495
944
  unless File.exist?(graph_path)
496
945
  puts "ERROR: Dependency graph not found at #{graph_path}"
@@ -498,11 +947,11 @@ namespace :woods do
498
947
  exit 1
499
948
  end
500
949
 
501
- graph_data = JSON.parse(File.read(graph_path))
950
+ graph_data = JSON.parse(Woods::AtomicFile.read(graph_path))
502
951
  graph = Woods::DependencyGraph.from_h(graph_data)
503
952
 
504
953
  max_depth = ENV.fetch('MAX_DEPTH', 5).to_i
505
- assembler = Woods::FlowAssembler.new(graph: graph, extracted_dir: output_dir)
954
+ assembler = Woods::FlowAssembler.new(graph: graph, extracted_dir: woods_payload_dir(output_dir).to_s)
506
955
  flow = assembler.assemble(entry_point, max_depth: max_depth)
507
956
 
508
957
  format = ENV.fetch('FORMAT', 'markdown').downcase
@@ -571,6 +1020,7 @@ namespace :woods do
571
1020
  puts " Errors: #{stats[:errors].size}"
572
1021
  stats[:errors].first(5).each { |e| puts " - #{e}" }
573
1022
  puts " ... and #{stats[:errors].size - 5} more" if stats[:errors].size > 5
1023
+ woods_exit_on_reported_errors(stats[:errors])
574
1024
  end
575
1025
  end
576
1026
 
@@ -640,7 +1090,11 @@ namespace :woods do
640
1090
  # post-merge pipelines (a dead token would otherwise stay green forever).
641
1091
  # Exception: budget exhaustion *with* partial progress is the expected
642
1092
  # cold-start shape; it converges on the next run.
643
- budget_only = stats[:errors].all? { |e| e.include?('daily budget exhausted') }
1093
+ # Matched on the message because errors reach here as strings, not
1094
+ # exceptions. `budget exhausted` is the stable part of
1095
+ # BudgetExhaustedError's message — spec/unblocked/rate_limiter_spec.rb
1096
+ # pins it so this branch cannot be silently broken by a rewording.
1097
+ budget_only = stats[:errors].all? { |e| e.include?('budget exhausted') }
644
1098
  unless budget_only && stats[:synced].positive?
645
1099
  puts
646
1100
  puts 'Sync completed with errors — failing so CI surfaces it.'