woods 1.6.1 → 2.0.0.beta2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -0,0 +1,183 @@
1
+ # Getting started with Woods 2.0
2
+
3
+ This guide takes a Rails application from no Woods installation to a validated index that an MCP client can query. The default path is structural only: it does not require OpenAI, Ollama, a vector database, or access to application records.
4
+
5
+ ## Before you begin
6
+
7
+ Woods 2.0 requires Ruby 3.0 or later, Rails 6.0 through 8.x, a Rails environment that can boot and connect to its database, and Bundler. An MCP-capable client is only needed when an agent will query the result.
8
+
9
+ The install generator adds an initializer plus a legacy compatibility migration for older/custom integrations. Shipped v2 paths do not use that migration's application tables; a new default install removes it without running it. Extraction boots Rails and writes a generated index under `tmp/woods/` by default; it does not read rows from your application's business tables.
10
+
11
+ If an agent will perform the installation, use the safety and handoff checklist in [Agent setup](AGENT_SETUP.md).
12
+
13
+ ## 1. Install the gem
14
+
15
+ Add Woods to the development group:
16
+
17
+ ```ruby
18
+ # Gemfile
19
+ group :development do
20
+ gem "woods", "~> 2.0"
21
+ end
22
+ ```
23
+
24
+ Then install and confirm the resolved version:
25
+
26
+ ```bash
27
+ bundle install
28
+ bundle exec ruby -rwoods/version -e 'puts Woods::VERSION'
29
+ ```
30
+
31
+ If your application runs through Docker Compose, run Rails and Bundler commands inside its application service. See [Docker setup](DOCKER_SETUP.md) before configuring MCP paths.
32
+
33
+ ## 2. Generate and review configuration
34
+
35
+ ```bash
36
+ bin/rails generate woods:install
37
+ ```
38
+
39
+ Review the generated files:
40
+
41
+ - `config/initializers/woods.rb` documents supported configuration;
42
+ - `db/migrate/*_create_woods_tables.rb` is a legacy application migration for `woods_units`, `woods_edges`, and `woods_embeddings`.
43
+
44
+ **For a new default installation, do not run that migration.** Woods 2's shipped structural index and storage backends do not read or write those application tables. Remove it before the next Rails boot. Keep and run it only when deliberately preserving an older/custom integration that uses the tables; first check for name conflicts and obtain normal migration approval.
45
+
46
+ The generated initializer defaults are enough for structural extraction. Do not choose a storage preset or configure an embedding provider unless you want semantic search.
47
+
48
+ ## 3. Extract the application
49
+
50
+ ```bash
51
+ bin/rails woods:extract
52
+ ```
53
+
54
+ Woods boots and eager-loads the Rails application, runs its extractors, builds dependency edges, and publishes one complete generation under `tmp/woods/`. A reader stays on the previous complete generation until the new one is published.
55
+
56
+ If Rails only boots with environment variables, provide the same variables here. Do not work around a boot failure inside Woods configuration. First confirm that the application can boot and eager-load with the same environment:
57
+
58
+ ```bash
59
+ bin/rails runner 'puts Rails.application.class.name'
60
+ bin/rails runner 'Rails.application.eager_load!; puts "eager load ok"'
61
+ ```
62
+
63
+ ## 4. Validate and inspect the index
64
+
65
+ Use the Woods tasks rather than depending on internal filenames:
66
+
67
+ ```bash
68
+ bin/rails woods:validate
69
+ bin/rails woods:stats
70
+ ```
71
+
72
+ Validation should finish successfully. Statistics should report non-zero units for the types your application contains. The exact distribution varies by application.
73
+
74
+ The generated directory is disposable build output. Add `tmp/woods/` to `.gitignore` unless your team deliberately publishes it as an artifact.
75
+
76
+ ## 5. Connect the Index Server
77
+
78
+ The Index Server reads the generated index. It does not boot Rails and does not query application records.
79
+
80
+ For a project-scoped stdio MCP client, add the equivalent server entry at the application root (for clients that support `.mcp.json`, use this shape):
81
+
82
+ ```json
83
+ {
84
+ "mcpServers": {
85
+ "woods": {
86
+ "command": "bundle",
87
+ "args": ["exec", "woods-mcp-start", "./tmp/woods"],
88
+ "cwd": "/absolute/path/to/your-rails-app"
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ Use an absolute `cwd`. It ensures Bundler resolves the application's Woods version and makes the relative index path unambiguous. Reconnect or restart the MCP client after changing its configuration.
95
+
96
+ For client-specific configuration locations, Docker, direct `woods-mcp`, and HTTP transport, use [MCP servers](MCP_SERVERS.md).
97
+
98
+ ## 6. Verify from the client
99
+
100
+ Ask the client to call these tools in order:
101
+
102
+ 1. `woods_status` — confirm the index path, generation, unit counts, and retrieval status.
103
+ 2. `search` with a known class name — discover its exact Woods identifier.
104
+ 3. `lookup` with that identifier — inspect its runtime source, metadata, and relationships.
105
+ 4. `dependents` with that identifier — see what may be affected by a change.
106
+
107
+ For example:
108
+
109
+ > Use Woods to find `Order`, inspect its resolved callbacks and associations, and list the first two levels of code that depend on it. Cite the Woods identifiers you used.
110
+
111
+ The Index schema inventory totals 29 schemas. Fourteen register as tools in a normal packaged launch; `codebase_retrieve` is among them but returns a configuration error until embeddings are enabled. The other structural tools work immediately. See [Agent guide](AGENT_GUIDE.md) for a reliable query workflow.
112
+
113
+ ## Optional next steps
114
+
115
+ ### Add semantic search
116
+
117
+ Structural search, exact lookup, dependency traversal, graph analysis, and flow tracing do not need embeddings. Add embeddings only when agents need natural-language retrieval.
118
+
119
+ The local preset uses SQLite metadata, persisted in-memory vectors, and a local Ollama service. Add `gem "sqlite3"` to the application bundle if it is not already present. MySQL/PostgreSQL applications that do not want that dependency can use the `:shared_filesystem` preset instead; it still uses Ollama but persists all stores beneath the Woods output directory.
120
+
121
+ ```ruby
122
+ # config/initializers/woods.rb
123
+ Woods.configure_with_preset(:local)
124
+ ```
125
+
126
+ Install and start Ollama, then pull the default model and build embeddings:
127
+
128
+ ```bash
129
+ ollama pull nomic-embed-text
130
+ bin/rails woods:embed
131
+ ```
132
+
133
+ For dense Ruby source, add `gem "tokenizers", "~> 0.5"` for exact WordPiece token counting. Without it, Woods falls back to character-based estimation, which can over-pack some Ollama chunks.
134
+
135
+ Reconnect the MCP server and check `woods_status`. For OpenAI, pgvector, Qdrant, model dimensions, and provider changes, read the [Retrieval guide](RETRIEVAL_GUIDE.md) and [Backend matrix](BACKEND_MATRIX.md).
136
+
137
+ ### Keep the index current
138
+
139
+ For automatic maintenance, keep a watcher running beside the Rails development process:
140
+
141
+ ```bash
142
+ bin/rails woods:watch
143
+ ```
144
+
145
+ ```text
146
+ # Procfile.dev
147
+ web: bin/rails server
148
+ woods: bundle exec rake woods:watch
149
+ ```
150
+
151
+ On startup it reconciles changes made since the last successful generation. While running it batches file events, reloads Rails code when safe, extracts affected units, and publishes atomically. The Index Server detects the new generation on its next call and reloads automatically. After the initial extraction, ordinary code edits need no manual extraction or MCP restart.
152
+
153
+ When dependencies, initializers, database configuration, credentials, or schema change, Rails cannot safely reload all captured state. The watcher records a degraded reason and exits with status 75 so the process manager can restart it. Docker bind mounts may require polling; follow [Watch daemon](WATCH_DAEMON.md).
154
+
155
+ The watcher maintains the structural index. If semantic retrieval is enabled, also run `bin/rails woods:embed_incremental` to update vectors. Without a resident watcher, run `bin/rails woods:incremental` after changes. Use a full `woods:extract` after major upgrades or when validation reports drift. CI and shared-artifact patterns are covered in [Incremental extraction](INCREMENTAL_EXTRACTION.md).
156
+
157
+ On Rails 8.1, `config/ci.rb` can refresh the index before any gate that reads it: `step "Woods: refresh", "bin/rails woods:incremental"`. With the Claude Code plugin installed, an opt-in `PostToolUse` hook refreshes the index after graph-changing edits and an opt-in `SessionStart` hook warns when it predates the last commit; set `WOODS_HOOKS_ENABLED=1` to turn them on. See [Watch daemon](WATCH_DAEMON.md#hooks-for-agent-sessions).
158
+
159
+ ### Enable the Console Server
160
+
161
+ The Console Server is separate from the Index Server. It boots Rails and can read live model data. Do not enable it merely to inspect code structure.
162
+
163
+ If live-data queries are necessary, review its allowlists, blocked tables, credential scanning, redaction, SQL validation, and environment boundary in [Console MCP setup](CONSOLE_MCP_SETUP.md). The default surface is nine tools; `console_sql` and `console_query` require an explicit read-tools opt-in.
164
+
165
+ ## First-run problems
166
+
167
+ | Symptom | Check first | Continue with |
168
+ |---|---|---|
169
+ | Bundler cannot resolve Woods | Ruby/Rails requirements and the lockfile's selected gem version | [Upgrade guide](UPGRADING_TO_2.md) |
170
+ | Generator reports existing files | Diff the existing initializer and migration; do not overwrite blindly | [Configuration reference](CONFIGURATION_REFERENCE.md) |
171
+ | Rails fails during extraction | Boot and eager-load Rails with the same environment variables | [Troubleshooting](TROUBLESHOOTING.md) |
172
+ | Validation reports missing or stale units | Run a full extraction, then validate again | [Incremental extraction](INCREMENTAL_EXTRACTION.md) |
173
+ | MCP reports no index or zero units | Confirm `cwd`, the host-visible `tmp/woods` path, and `woods:stats` output | [MCP servers](MCP_SERVERS.md) |
174
+ | `codebase_retrieve` says it is disabled | Configure an embedding provider and run `woods:embed`, or use `search` | [Retrieval guide](RETRIEVAL_GUIDE.md) |
175
+ | Docker extraction succeeds but MCP cannot see it | Translate the container output path to its host-mounted path | [Docker setup](DOCKER_SETUP.md) |
176
+
177
+ ## Where to go next
178
+
179
+ - [Agent guide](AGENT_GUIDE.md): teach an agent to use the index effectively.
180
+ - [MCP servers](MCP_SERVERS.md): client configuration and exact callable surfaces.
181
+ - [Configuration reference](CONFIGURATION_REFERENCE.md): supported settings and defaults.
182
+ - [Upgrade to Woods 2.0](UPGRADING_TO_2.md): migrate an existing 1.x installation.
183
+ - [Troubleshooting](TROUBLESHOOTING.md): diagnose extraction, storage, MCP, and Docker failures.
@@ -0,0 +1,455 @@
1
+ # Incremental Extraction
2
+
3
+ `woods:incremental` re-indexes only what changed. This page states the
4
+ correctness contract it is held to, the inventory of which path class triggers
5
+ which work, how to run the differential harness that enforces the contract, and
6
+ what is still out of scope.
7
+
8
+ Background: [#164](https://github.com/lost-in-the/woods/issues/164).
9
+
10
+ ## The contract
11
+
12
+ > After any sequence of file create / modify / delete / rename operations, an
13
+ > index maintained purely by `extract_changed` is **indistinguishable from a
14
+ > cold `extract_all` of the same tree**.
15
+
16
+ Indistinguishable means: the same unit identifiers, the same per-unit JSON
17
+ content, the same `_index.json` per type, the same graph nodes / edges /
18
+ reverse edges / file map / type index / stats, PageRank recomputed, the
19
+ same manifest counts and `graph_analysis.json` — and, when flow
20
+ precomputation is enabled, the same `flows/flow_index.json`, the same
21
+ flow documents, and the same flow annotations on controller units.
22
+
23
+ Three differences are tolerated, and nothing else:
24
+
25
+ | Tolerated | Why |
26
+ |---|---|
27
+ | Wall-clock stamps (`extracted_at`, `generated_at`, and the digest over it) | A unit an incremental run correctly left alone keeps an older stamp. |
28
+ | Ordering inside a unit's `dependents` | Full extraction appends in extractor order, incremental in graph order. Same multiset. |
29
+ | PageRank beyond six decimal places | Iterative floating point accumulated in each run's registration order. Scores are compared as values; only the last bits are forgiven. |
30
+
31
+ `graph_analysis.json` used to be a fourth row, tolerating list ordering. It no
32
+ longer is: the analyzer is order-independent and the oracle compares the file
33
+ exactly. Tolerating the ordering there meant the harness, the only test that
34
+ compares a full run against an incremental one, could not see the very
35
+ dependence the analyzer's determinism work existed to remove.
36
+
37
+ This matters most for **incremental CI chains**: restore the previous graph,
38
+ run `woods:incremental` per merge. There, a unit that goes missing propagates
39
+ forward run over run instead of being erased by the next full rebuild.
40
+
41
+ ## Exit behavior in CI chains
42
+
43
+ The task decides what to do before it extracts, and the exit code is part of
44
+ that decision — a green job that silently skipped the sync is exactly the
45
+ failure a CI chain cannot afford:
46
+
47
+ | Situation | Behavior |
48
+ |---|---|
49
+ | `CHANGED_FILES` is set | Used verbatim (comma-separated paths); git is not consulted. |
50
+ | The git range resolves | Current behavior: extract the changed paths, or exit 0 with `No relevant files changed` when nothing relevant changed. |
51
+ | The range fails **and** a `:running` watch daemon maintains the index | Stand down with a printed reason, exit 0 — the daemon's start-up catch-up covers whatever changed. |
52
+ | The range fails otherwise | Actionable error naming the range, **exit 1**. |
53
+ | There is no `git` binary at all | Same two rows as above: the failure reads `git unavailable: …` and takes the daemon-coverage decision, rather than dying with an `Errno::ENOENT` backtrace. |
54
+
55
+ The range comes from `CI_COMMIT_BEFORE_SHA..CI_COMMIT_SHA` (GitLab),
56
+ `origin/$GITHUB_BASE_REF...HEAD` (GitHub Actions), or `HEAD~1` (default). An
57
+ unresolvable range — a GitLab zero-SHA on a new branch, an unfetched base ref,
58
+ a shallow clone with no `HEAD~1` — reads as "nothing changed" to git, which is
59
+ why a failed range must not be mistaken for an empty one: the sync never ran,
60
+ and CI drift would stay unbounded. A degraded daemon covers nothing, so it
61
+ does not stand the run down. `WOODS_IGNORE_WATCH=1` removes daemon coverage
62
+ too — with it set, a failed range exits 1. A slim image with no `git` binary
63
+ resolves to the same decision rather than a raw `Errno::ENOENT`: the failure is
64
+ reported as `git unavailable: …`, so a daemon-covered tree still stands down
65
+ and an uncovered one still gets the remediation text.
66
+
67
+ Recovery choices, in the order they are worth trying:
68
+
69
+ 1. Repair or provide the range: fetch the base ref (`fetch-depth: 2` or more),
70
+ or correct the CI environment variables that build it.
71
+ 2. Set `CHANGED_FILES` explicitly from your CI platform, bypassing git range
72
+ resolution entirely.
73
+ 3. Run a full `woods:extract` when the range cannot be repaired this run.
74
+
75
+ The diff itself is rooted at the extracted application (`git -C Rails.root`),
76
+ so it cannot read whatever checkout the process happened to start in — the
77
+ same rooting rule the manifest's git provenance follows.
78
+
79
+ ## What a run does, in order
80
+
81
+ `Extractor#extract_changed` is order-sensitive; each step exists because of the
82
+ step before it.
83
+
84
+ 1. **Blast radius** from the *pre-change* graph, so dependents of a file that
85
+ just disappeared still get re-extracted. Unbounded by default; see
86
+ [Bounding the blast radius](#bounding-the-blast-radius).
87
+ 2. **Reconcile changed paths.** Every changed path that still exists is handed
88
+ to the file-based extractors that claim it (`PathDispatcher`), and units the
89
+ path no longer produces are dropped. This is what indexes a file the index
90
+ has never seen, and what lets a task removed from a multi-task `.rake` file
91
+ actually go away.
92
+ 3. **Re-extract the rest of the blast radius**: units whose own file did not
93
+ change but which depend on something that did.
94
+ 4. **Reconcile class-based types** against each extractor's
95
+ `#discoverable_classes`, classes added since the last extraction, and
96
+ classes the graph still holds that the set no longer contains. Exact by
97
+ construction: it is the same discovery code a full extraction uses, so
98
+ there is no path-to-constant guessing.
99
+ 5. **Re-run whole-app extractors** whose trigger paths changed, replacing that
100
+ unit type wholesale.
101
+ 6. **Prune vanished units**, so anything steps 2–5 resurrected against a
102
+ deleted file is swept in the same run rather than surviving as a ghost.
103
+ 7. **Reconcile class-based types once more**, because pruning can un-know a
104
+ class the first pass skipped: a class-based file moved between autoload
105
+ directories with its constant unchanged still looks known when step 4 runs,
106
+ so it is not re-extracted, and step 6 then removes it for its vanished old
107
+ path. This pass re-adds it in the same run instead of waiting for some later
108
+ run to notice. It skips everything else step 6 pruned (`except:`), because
109
+ without a reload a constant outlives the file that defined it, otherwise
110
+ deleting `app/models/user.rb` would prune `User` only for this pass to find
111
+ it still in `ActiveRecord::Base.descendants` and re-register it against a
112
+ path nothing can ever remove again. What separates the two shapes is
113
+ loader-derived constant identity, not a textual class-name match: a pruned
114
+ identifier is re-added only when the active Zeitwerk loader governs a
115
+ changed file for exactly that constant (`cpath_expected_at` — the loader's
116
+ inflector, ignores, and root namespaces decide — and the file declares it).
117
+ A loader non-claim is authoritative: an unmanaged or declined path re-adds
118
+ nothing. So a moved file whose governed constant matches qualifies, while
119
+ another namespace's same-demodulized file, a mention in a comment or
120
+ string literal, and an unrelated addition in the same batch do not.
121
+ Idempotent when nothing was pruned.
122
+
123
+ Then the second pass: `dependents` and `metadata.git` are refreshed on every
124
+ touched unit (the incremental equivalents of full extraction's phases 2 and 4),
125
+ type indexes are regenerated, the graph, `graph_analysis.json` and the
126
+ manifest are written, and — with flow precomputation enabled — the run's
127
+ controller delta gets the same flow treatment a full run gives (see
128
+ [Flow artifacts](#flow-artifacts)).
129
+
130
+ A run that changed nothing **does not rewrite the manifest**. The manifest
131
+ timestamp drives `woods_status.staleness_seconds`, and touching it after a no-op
132
+ would report the index as freshly synced when nothing was re-read.
133
+
134
+ ## Bounding the blast radius
135
+
136
+ `incremental_blast_radius_depth` caps how many reverse hops step 1 walks.
137
+ `nil`, the default, keeps the unbounded transitive closure: every unit that
138
+ reaches the changed file, at any depth, is re-extracted.
139
+
140
+ The reason to cap it is cost. A unit's extracted content is mostly a function
141
+ of its own source and its own reflection, so on most graphs the deep half of
142
+ the closure re-derives bytes that do not change. On a 200-service chain in the
143
+ dummy app, one leaf edit re-extracts 200 units unbounded and 2 at a depth
144
+ of 1.
145
+
146
+ The reason the default is not capped is that "mostly" is not "always". An STI
147
+ grandchild reads its grandparent's reflection: `SportsCar < Car < Vehicle`
148
+ inherits `Vehicle`'s associations, validations and callback chain, and a
149
+ nested `has_many :through` resolves through the same kind of chain. The graph
150
+ records only the one-hop superclass reference each source file mentions, so
151
+ that grandchild sits two hops out while its content depends on hop zero. Set
152
+ the key on a tree you know has neither shape.
153
+
154
+ What the cap never affects is `dependents`. Edges change only when a unit is
155
+ re-extracted, and the run marks every target of a re-extracted unit's edges,
156
+ before and after registration, so a unit that gains or loses an inbound edge
157
+ is rewritten by the second pass whether or not the walk reached it.
158
+ `spec/integration/incremental_equivalence_spec.rb` holds a depth of 1 to
159
+ full-extraction equivalence, including that case.
160
+
161
+ ## Dispatch inventory
162
+
163
+ ### Per-file
164
+
165
+ Routed by `PathDispatcher.file_rules`. Rules reference each extractor's own
166
+ `*_DIRECTORIES` constant, so adding a directory there flows through
167
+ automatically.
168
+
169
+ | Path class | Extractor |
170
+ |---|---|
171
+ | `app/services`, `app/interactors`, `app/operations`, `app/commands`, `app/use_cases` | services |
172
+ | `app/jobs`, `app/workers`, `app/sidekiq` | jobs |
173
+ | `app/serializers`, `app/blueprinters`, `app/decorators` | serializers |
174
+ | `app/decorators`, `app/presenters`, `app/form_objects` | decorators |
175
+ | `app/managers` / `app/policies` / `app/validators` | managers / policies + pundit_policies / validators |
176
+ | `app/**/concerns/**/*.rb` | concerns |
177
+ | `app/models/**/*.rb` (outside `concerns/`) | poros, caching |
178
+ | `app/controllers/**/*.rb` | caching |
179
+ | `app/views/**/*.erb` | view_templates, caching |
180
+ | `config/locales/**/*.yml` | i18n |
181
+ | `config/initializers`, `config/environments` | configurations |
182
+ | `db/migrate/*.rb` (top level only) | migrations |
183
+ | `lib/tasks/**/*.rake` | rake_tasks |
184
+ | `lib/**/*.rb` (outside `tasks/`, `generators/`) | libs |
185
+ | `spec/**/*_spec.rb`, `test/**/*_test.rb` | test_mappings |
186
+
187
+ A path can match several rules, `app/policies` is claimed by both
188
+ `PolicyExtractor` and `PunditExtractor`, `app/decorators` by both the
189
+ serializer and decorator extractors, and all matching rules run.
190
+
191
+ ### Wholesale re-runs
192
+
193
+ `PathDispatcher.whole_app_rules` → `Extractor::WHOLE_APP_EXTRACTORS`. These
194
+ extractors have no per-file entry point: they introspect the runtime or scan a
195
+ whole directory in one pass. In an already-booted process re-running them is
196
+ cheap, which is what makes wholesale replacement the right shape.
197
+
198
+ | Trigger | Re-runs |
199
+ |---|---|
200
+ | `config/routes.rb`, `config/routes/**` | routes, engines, **and** controllers, mailers, components, view components, view templates |
201
+ | `Gemfile.lock` | engines, middleware, rails_source (gated by `include_framework_sources`) |
202
+ | `config/application.rb`, `config/initializers/**`, `config/environments/**` | middleware |
203
+ | `config/recurring.yml`, `config/sidekiq_cron.yml`, `config/schedule.rb` | scheduled_jobs |
204
+ | `app/models/**/*.rb` | state_machines |
205
+ | `app/**/*.rb` | events |
206
+ | `spec/factories/**`, `test/factories/**` | factories |
207
+ | `db/views/**/*.sql` | database_views |
208
+ | any `package.yml`, `packwerk.yml` | packages |
209
+
210
+ Three of these deserve a note:
211
+
212
+ - **Routes cascade.** `ROUTE_CONSUMER_EXTRACTORS` embed the route table, controllers write each action's routes into unit metadata and into the action
213
+ chunks, and everything using `RouteHelperResolver` resolves navigation edges
214
+ against it. The graph cannot express this, because a route unit depends *on*
215
+ its controller, not the other way round, so walking dependents from
216
+ `config/routes.rb` never reaches them.
217
+ - **Database views are wholesale, not per file.** Scenic keeps only the highest
218
+ `_vNN` of each view, so pointing the per-file method at
219
+ `db/views/foo_v01.sql` would index a version a full extraction drops.
220
+ - **Packages don't yet claim their members.** `PackageExtractor` (#280) only
221
+ produces `package` units from `package.yml`; it does not annotate which
222
+ package every other unit belongs to. A pack-resident file-based unit is not
223
+ discovered by `PathDispatcher` through its package boundary today
224
+ (follow-up B-175).
225
+
226
+ ### Class-based types
227
+
228
+ Models, controllers, mailers, components, view components and channels are
229
+ **not** dispatched by path. They are reconciled against
230
+ `#discoverable_classes` on their own extractor, in **both** directions:
231
+ additions are that set minus the graph, removals are the graph minus that set.
232
+
233
+ For all six, `extract_all` is literally `discoverable_classes.map { … }.compact`,
234
+ so absence from the set is exactly "a full extraction would not produce this", the equivalence the incremental path is held to.
235
+
236
+ Removal is gated on the eager load having **completed**, and that gate carries
237
+ the whole safety argument:
238
+
239
+ - **A partial eager load.** The documented `NameError` fallback loads only
240
+ `EXTRACTION_DIRECTORIES`, so descendants are known-incomplete and the
241
+ difference would be most of the app. Deleting by the type is far worse than a
242
+ stale unit, so a partial load removes nothing.
243
+ - **A constant outliving its file.** A resident daemon that has not reloaded
244
+ still holds a deleted class as a descendant, so it is *in* the set and not
245
+ stale, correct for that process. The subsequent reload is what makes it
246
+ removable.
247
+
248
+ Without this, a class deleted from a file that still exists was never removed
249
+ at all: path-keyed deletion sees no missing path, and a class-based unit
250
+ records a *convention* path from its constant name, so a second model in one
251
+ `.rb` was never attributed to the file it actually lived in. The unit outlived
252
+ every subsequent incremental run.
253
+
254
+ The booted harness cannot cover that case. Zeitwerk unloads only the constant a
255
+ file is *expected* to define, so a class defined there as a side effect survives
256
+ the reload, stays in `descendants`, and the in-process full extraction the
257
+ oracle compares against emits it too, both sides agree, wrongly. The coverage
258
+ is in `spec/extractor_spec.rb`, driving the reconciler with a shrinking
259
+ discovery set.
260
+
261
+ ### Deletion
262
+
263
+ - Paths named in the change set that no longer exist are **authoritative** for
264
+ any unit type. This covers deleted models and the old side of a rename.
265
+ - A **sweep** over registered paths catches callers whose change set is
266
+ incomplete (a git diff that omits deletions, a missed unlink, a branch
267
+ switch). Being a heuristic, it is bounded twice:
268
+ - **To paths a file rule claims.** Some units name a *nominal* path rather
269
+ than a source file, `BehavioralProfile` names `config/application.rb`,
270
+ which no rule claims.
271
+ - **Away from class-based units entirely.** A class-based unit records a
272
+ convention path when its source location can't be resolved, and that path
273
+ need not exist. On Rails < 7.1, `ActiveRecord::SchemaMigration` and
274
+ `ActiveRecord::InternalMetadata` are real `ActiveRecord::Base` descendants
275
+ whose convention path (`app/models/active_record/schema_migration.rb`) no
276
+ application has, and *is* claimed by the PORO rule, so the first bound
277
+ doesn't cover it.
278
+
279
+ Sweeping either would delete units a full extraction still produces.
280
+ - Only paths under `Rails.root` are considered either way: framework units point
281
+ at gem paths, and an index restored from a CI artifact can carry paths
282
+ produced under a different root.
283
+
284
+ ## Flow artifacts
285
+
286
+ Everything in this section is gated on `precompute_flows` (default false).
287
+ The family has three parts: `flows/flow_index.json` (entry point → relative
288
+ document path), one document per controller action, and
289
+ `metadata[:flow_paths]` on the controller units.
290
+
291
+ A full extraction computes all three in one pass. An incremental run computes
292
+ them for its **delta**:
293
+
294
+ - **Re-extracted controllers** get their `metadata[:flow_paths]` back, their
295
+ flow documents are re-assembled from the units on disk, and their entries
296
+ replace whatever the previous index held for them — so an action removed
297
+ from a re-extracted controller leaves the index even though the file still
298
+ exists.
299
+ - **Controllers the run pruned** (deleted or renamed) leave the index
300
+ entirely.
301
+ - **Untouched controllers' entries carry forward** from the previous
302
+ generation, which payload seeding hardlinks into the run's payload
303
+ directory.
304
+
305
+ Re-assembly is scoped further, because a flow document only reaches
306
+ `FlowPrecomputer::DEFAULT_MAX_DEPTH` units. The run walks the pre-change graph
307
+ to that same depth from its changed files; a re-extracted controller inside
308
+ that radius is re-assembled, and one outside it takes its
309
+ `metadata[:flow_paths]` back from the previous index without paying for the
310
+ assembly. Three cases opt out and re-assemble every re-extracted controller: a
311
+ targeted `Extractor#refresh`, which has no change set; a routes re-run, which
312
+ replaces every controller and moves the route a flow document carries without
313
+ touching any dependency edge; and a controller whose action set no longer
314
+ matches the previous index, which is how an action inherited from further up a
315
+ controller chain than the radius reaches still lands.
316
+
317
+ After the index is rewritten, a **dedicated flow-artifact sweep** removes
318
+ every `flows/` document no index entry references. It validates against
319
+ `flow_index.json` and is deliberately separate from the unit sweep: flows/
320
+ holds neither units nor an `_index.json`, so the unit sweep's in-memory
321
+ contract does not describe it. The whole refresh is **fail closed**: a
322
+ genuinely absent family (no `flows/` directory, or an empty one — typically
323
+ an index built while the gate was off) skips the refresh, but a family that
324
+ holds any artifact is authoritative. A missing `flow_index.json` among
325
+ documents, a corrupt one, a failed rehydration, write, patch, or sweep
326
+ raises, and the raise aborts the run **before** the generation publish —
327
+ no generation bump, the preceding generation stays resolved and readable.
328
+ `woods:validate` applies the same corruption rule (a populated family
329
+ without its index is an error) and never demands `_index.json` from
330
+ `flows/`. The full extraction path is fail closed too: `precompute_flows`
331
+ raises before the manifest and generation publish.
332
+
333
+ ## Refreshing one extractor on demand
334
+
335
+ `Extractor#refresh` re-runs named extractors wholesale against an
336
+ already-booted app. Incremental runs reach the whole-app extractors by trigger
337
+ path; this reaches them by name, for a caller that already knows what went
338
+ stale.
339
+
340
+ ```ruby
341
+ # After editing config/routes.rb, or from a resident process that just reloaded
342
+ Woods::Extractor.new(output_dir: "tmp/woods").refresh(:routes)
343
+ # => { types: [:routes, :controllers, :mailers, ...], touched: [...], unknown: [] }
344
+ ```
345
+
346
+ ```bash
347
+ bundle exec rake "woods:refresh[routes]"
348
+ bundle exec rake "woods:refresh[state_machines,factories]"
349
+ bundle exec rake woods:refresh # lists the valid keys
350
+ ```
351
+
352
+ Any extractor key works, not only the whole-app ones, `refresh(:models)` is a
353
+ legitimate way to re-derive every model after a schema change. A routes refresh
354
+ cascades to `ROUTE_CONSUMER_EXTRACTORS` for the reason given above. Like an
355
+ incremental run, `refresh` rewrites the graph, `graph_analysis.json`, the
356
+ affected type indexes and the manifest, so the result is durable.
357
+
358
+ ## What a change actually requires: reload, restart, or neither
359
+
360
+ `Woods::ReloadPolicy` answers the question a resident process has to ask before
361
+ re-extracting: extraction reads the *runtime*, so "the file changed" is not the
362
+ same question as "what has to happen before re-reading it is worth anything".
363
+
364
+ | Action | Path classes | Why |
365
+ |---|---|---|
366
+ | `:reextract` | `config/locales/**`, `db/migrate/**`, `db/views/**`, `lib/tasks/**`, `spec/**`, `test/**`, `app/views/**` (non-Ruby), schedule files, `package.yml`, `packwerk.yml` | Woods reads bytes. No constant involved. |
367
+ | `:reload` | `app/**/*.rb`, `lib/**/*.rb` (outside `tasks/`, `generators/`), `config/routes.rb`, `config/routes/**` | An autoloaded constant changed; introspecting the old class would be a lie. |
368
+ | `:restart` | `Gemfile`, `Gemfile.lock`, `.ruby-version`, `.env*`, application/boot/environment files, initializers/environments/credentials, database/schema files, `config/settings*.yml`, and boot-captured service YAML | Captured at boot. Rails' reloader re-runs none of it. See the exact list below. |
369
+ | `:ignore` | everything else | Not extraction input. |
370
+
371
+ The `:restart` set is drawn generously on purpose. Rails' reloader replaces
372
+ autoloaded constants and nothing else, it does not re-run initializers,
373
+ re-resolve `Rails.application.config`, or rebuild the schema cache, all of
374
+ which Woods captures (`BehavioralProfile`, `MiddlewareExtractor`, model column
375
+ data). `rails/spring`'s staleness bugs came from under-scoping exactly this
376
+ set.
377
+
378
+ The exact additional boot-captured YAML set is `config/settings.yml`,
379
+ `config/settings/*.yml`, and `config/{cable,storage,sidekiq,puma,cache,queue}.yml`
380
+ (including `.yaml` spellings). Scheduled-job sources such as
381
+ `config/recurring.yml` and `config/sidekiq_cron.yml` remain `:reextract` inputs,
382
+ not restart triggers. `lib/woods/reload_policy.rb` is authoritative.
383
+
384
+ Two version-sensitive behaviours sit *behind* the classification rather than in
385
+ it, and belong to whoever implements the reload step:
386
+
387
+ - `ActiveSupport::DescendantsTracker` internals changed across Rails 6.0–8.x, so
388
+ a reload can leave stale entries in a descendants set. Discovery-based
389
+ extraction must re-read descendants *after* the reload completes, never
390
+ across it.
391
+ - A schema change needs `reset_column_information` plus schema-cache
392
+ invalidation to become visible. It is classified `:restart` rather than
393
+ `:reload` because getting that right in-process is subtle and schema changes
394
+ are rare.
395
+
396
+ `Watch::Daemon` consumes the policy on every cycle: `classify_all` decides what
397
+ the batch demands, and `paths_requiring(:restart)` names the offending paths in
398
+ the restart message a supervisor sees. See `docs/WATCH_DAEMON.md`.
399
+
400
+ ## Running the differential harness
401
+
402
+ `spec/integration/incremental_equivalence_spec.rb` is the oracle. It boots the
403
+ `spec/dummy` app against a tmpdir copy, applies randomized
404
+ create/modify/delete/rename sequences, and compares the maintained index to a
405
+ cold full extraction at every step. It runs in CI on every Rails-matrix row.
406
+
407
+ ```bash
408
+ # CI defaults: 60 operations x 3 seeds
409
+ WOODS_RUN_BOOTED_APP=1 BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile \
410
+ bundle exec rspec spec/integration/incremental_equivalence_spec.rb
411
+
412
+ # Soak run
413
+ WOODS_RUN_BOOTED_APP=1 BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile \
414
+ WOODS_DIFF_OPS=1000 WOODS_DIFF_SEEDS=1,2,3,4,5 \
415
+ bundle exec rspec spec/integration/incremental_equivalence_spec.rb -e randomized
416
+
417
+ # When a truncated delta isn't enough to see what moved
418
+ WOODS_DIFF_BRIEF=4000 ...
419
+ ```
420
+
421
+ Seeds are fixed, so a failure reproduces. `spec/support/index_comparison.rb`
422
+ owns the definition of "the two indexes agree" and documents every exclusion.
423
+
424
+ **Run it before and after any change to the incremental path.**
425
+
426
+ ## Boundaries and open work
427
+
428
+ - **Reloaded deletion is supported.** The resident watcher reloads changed
429
+ constants before discovery-set reconciliation, so deleting a class-backed
430
+ file removes its unit. A caller using the lower-level incremental API without
431
+ the watcher must ensure the Rails runtime has been reloaded first.
432
+ - **Identifier identity is typed.** Units of different types that share an
433
+ identifier remain separate graph nodes. Two source files that produce the
434
+ same type+identifier are not representable; full extraction fails closed
435
+ with both source paths instead of publishing a glob-order tie-break (resolved
436
+ B-063). Same-file re-derivation remains a legitimate deduplication case.
437
+ - **Class-based units are never swept**: see [Deletion](#deletion) above for
438
+ why (the `SchemaMigration`/`InternalMetadata` convention-path case).
439
+ Deleting a class-based unit therefore requires either the caller naming the
440
+ path or the discovery-set reconciliation above; the sweep never infers it.
441
+ - **Git metadata for untouched units.** An incremental run refreshes
442
+ `metadata.git` on the units it wrote. A unit nothing touched keeps the git
443
+ metadata from the last run that did, which goes stale as commits land on
444
+ other files. The same holds for the node attributes `commit_count` and
445
+ `change_frequency` that feed the `volatile_dependencies` report.
446
+ - **Snapshots stay full-extraction-only.** They hash the full unit set, and an
447
+ incremental run only holds changed units in memory.
448
+ - **A divergence floor is still worth keeping.** Incremental correctness is a
449
+ ratchet, not a proof: schedule a periodic full extraction and gate on
450
+ `woods:validate` so any undiscovered drift has a bounded lifetime.
451
+ - **Phases 1–4** of #164, a public single-extractor re-run API
452
+ (`Extractor#refresh`), the resident `woods:watch` daemon, an MCP freshness
453
+ contract, and multi-worktree operation, all landed alongside this work
454
+ (B-064, resolved). `docs/WATCH_DAEMON.md` covers them, including the parts
455
+ that remain unmeasured.