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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- 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.
|