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,765 @@
|
|
|
1
|
+
# Woods Extractor Reference
|
|
2
|
+
|
|
3
|
+
Woods ships **35 extractor classes** producing **39 distinct unit types**: one for each meaningful category of Rails code. This doc covers what each extractor captures, how to configure them, and the shape of the data they produce.
|
|
4
|
+
|
|
5
|
+
> **Counts explained.** `lib/woods/extractors/` contains 42 files: 35 extractor classes (each ending in `_extractor.rb`) plus 7 supporting utilities (`shared_utility_methods`, `shared_dependency_scanner`, `callback_analyzer`, `behavioral_profile`, `route_helper_resolver`, `ast_source_extraction`, `source_nesting`). The 39 unit types comes from some extractors emitting multiple categories, `GraphQLExtractor` alone produces four (`graphql_type`, `graphql_mutation`, `graphql_resolver`, `graphql_query`), and `RailsSourceExtractor` produces both `rails_source` and `gem_source`. Supporting utilities enrich existing extractors (callback side-effects, behavioral config, AST-based source slicing, nested-namespace resolution) but are not themselves extractors and do not appear in the unit type enumeration. The authoritative mapping is `Woods::Extractor::TYPE_TO_EXTRACTOR_KEY` in `lib/woods/extractor.rb`.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## How do extractors work?
|
|
10
|
+
|
|
11
|
+
### The five phases
|
|
12
|
+
|
|
13
|
+
A full extraction (`bundle exec rake woods:extract`) runs five phases:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Phase 1: Extract . All 35 extractors run, producing ExtractedUnit objects
|
|
17
|
+
Phase 1.5: Dedupe . Re-derived same-source duplicates are dropped; a same-type identifier still derived from two different files aborts extraction naming both files
|
|
18
|
+
Phase 2: Resolve . Reverse dependency edges are built (A depends on B → B gets a dependent)
|
|
19
|
+
Phase 3: Enrich . Git metadata added (last author, change frequency, recent commits) and copied onto graph nodes
|
|
20
|
+
Phase 4: Graph . PageRank + structural analysis (orphans, hubs, cycles, bridges, cross-database edges)
|
|
21
|
+
Phase 5: Write . One JSON file per unit, _index.json per type, dependency_graph.json, SUMMARY.md
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Two discovery strategies
|
|
25
|
+
|
|
26
|
+
Extractors discover code one of two ways:
|
|
27
|
+
|
|
28
|
+
| Strategy | How it works | Examples |
|
|
29
|
+
|----------|-------------|---------|
|
|
30
|
+
| **Class-based** | `ActiveRecord::Base.descendants`, `ApplicationController.descendants`, etc., requires `eager_load!` | ModelExtractor, ControllerExtractor, MailerExtractor |
|
|
31
|
+
| **File-based** | Scans conventional directories (`app/services`, `db/migrate`, etc.), more robust for non-AR classes | ServiceExtractor, MigrationExtractor, ViewTemplateExtractor |
|
|
32
|
+
|
|
33
|
+
Some extractors combine both (e.g., `JobExtractor` scans directories first, then supplements with `ApplicationJob.descendants`).
|
|
34
|
+
|
|
35
|
+
### Identifier naming (source-derived units)
|
|
36
|
+
|
|
37
|
+
File-based extractors derive an identifier in three steps, first match wins:
|
|
38
|
+
|
|
39
|
+
1. **Zeitwerk-governed naming.** For a file under a managed autoload path, the expected constant path is computed — from `Rails.autoloaders.main` when a Rails autoloader is up, from the same path-to-constant convention offline otherwise — and the source must declare exactly that constant: `app/services/domain/container/parser.rb` is expected to define `Domain::Container::Parser`. This is what lets a file whose namespaces are written as *classes* (`module Domain; class Container; class Parser`) name the file's own constant instead of the wrapper `Domain::Container`, which every sibling under the same wrapper would otherwise collide with.
|
|
40
|
+
2. **Position-aware nesting scan** (`SourceNesting`, the supporting utility above). The first `class` declaration qualified by the namespaces actually open at that position. Compact declarations keep their segments; a helper module nested inside the class and sibling modules that closed earlier do not contribute.
|
|
41
|
+
3. **Path convention.** Camelize the path under `app/<kind>/` (details vary per extractor).
|
|
42
|
+
|
|
43
|
+
Unmanaged paths (`lib/`, configured non-autoload roots) and sources that declare nothing matching the expected constant skip step 1 entirely: the source scan and path convention decide, exactly as before the governed lookup existed.
|
|
44
|
+
|
|
45
|
+
### Eager loading
|
|
46
|
+
|
|
47
|
+
The orchestrator calls `Rails.application.eager_load!` once before extraction begins. If that fails with a `NameError` (common when `app/graphql/` references an uninstalled gem), it falls back to per-directory loading via `EXTRACTION_DIRECTORIES`. This fallback covers the directories that matter for extraction.
|
|
48
|
+
|
|
49
|
+
### What every extractor returns
|
|
50
|
+
|
|
51
|
+
Every extractor returns `Array<ExtractedUnit>`. An `ExtractedUnit` is a self-contained snapshot of one code unit with source, metadata, and relationships. See [ExtractedUnit Field Reference](#extractedunit-field-reference) at the bottom of this doc.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Core application extractors
|
|
56
|
+
|
|
57
|
+
### ModelExtractor
|
|
58
|
+
|
|
59
|
+
**What it captures:** Every non-abstract `ActiveRecord::Base` descendant with concrete table-backed state. The source_code is the model's actual Ruby source *plus* all included concerns inlined below it as formatted comment blocks. Schema information (columns, types, indexes, foreign keys) is prepended as a header comment.
|
|
60
|
+
|
|
61
|
+
**Key details:**
|
|
62
|
+
- Uses `ActiveRecord::Base.descendants` for discovery (runtime introspection, not static parsing)
|
|
63
|
+
- Inlines concerns: all `include FooConcern` references are resolved and the concern source is appended to `source_code`. Inlined concern names are recorded in `metadata[:inlined_concerns]`
|
|
64
|
+
- Extracts all 19 callback types: `before_validation`, `after_validation`, `before_save`, `after_save`, `around_save`, `before_create`, `after_create`, `around_create`, `before_update`, `after_update`, `around_update`, `before_destroy`, `after_destroy`, `around_destroy`, `after_commit`, `after_rollback`, `after_initialize`, `after_find`, `after_touch`
|
|
65
|
+
- Callback side-effects are analyzed via `CallbackAnalyzer`: detects columns written (`self.col =`), jobs enqueued (`perform_later`), and services called
|
|
66
|
+
- Automatically skips HABTM join models and anonymous classes
|
|
67
|
+
- Chunks every model into semantic sections: `:summary`, `:associations`, `:callbacks`, `:validations`, `:scopes`, `:methods`
|
|
68
|
+
- **Runtime-generated method detection:** Because extraction runs inside a booted Rails process, `instance_methods(false)` captures every method Rails generates dynamically, enum predicates (`status_active?`, `status_pending?`), association builders (`build_profile`, `create_line_item!`), attribute accessors, and dynamically registered scopes. Static analysis tools cannot see these methods because they only exist after Rails processes the DSL declarations at boot time
|
|
69
|
+
- **Database partition (multi-DB apps).** `metadata[:database]` is `klass.connection_db_config.name` (Rails 6.1+; `nil` on 6.0). Because reflection climbs to the abstract class that declared `connects_to`, a concrete model that only inherits its connection still reports the right database. Each association entry carries `from_db`, `to_db`, `through_db` (the has_many :through join model's database, guarded the same way and nil for a plain association), and `disable_joins`; `metadata[:foreign_keys]` lists `{ from_table, to_table, column }`, the target table's owning database is resolved separately by the graph-level `cross_database_edges` report (see [Internals](INTERNALS.md#graphanalyzer-structural-metrics)), not stored per model. That report never picks an owner living in the foreign key's own source database, even when another database also claims the table; only when every owner sits elsewhere, across more than one database, does it come back ambiguous rather than guessing. The graph node carries `database`, `table`, and `foreign_key_tables`, and association edges carry `through`, `through_db`, and `disable_joins`. `consolidate_dependencies` keeps the first edge per `[type, target]`, so a model with two associations to the same target keeps only the first edge's `through`/`through_db`/`disable_joins`.
|
|
70
|
+
|
|
71
|
+
**Multi-database configuration Woods reads.** Woods needs nothing beyond the Rails configuration the app already has. Both shapes below produce `metadata[:database]` values of `primary` and `analytics`.
|
|
72
|
+
|
|
73
|
+
MySQL:
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
# config/database.yml
|
|
77
|
+
production:
|
|
78
|
+
primary:
|
|
79
|
+
adapter: mysql2
|
|
80
|
+
database: shop_production
|
|
81
|
+
analytics:
|
|
82
|
+
adapter: mysql2
|
|
83
|
+
database: shop_analytics_production
|
|
84
|
+
migrations_paths: db/analytics_migrate
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
PostgreSQL:
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
# config/database.yml
|
|
91
|
+
production:
|
|
92
|
+
primary:
|
|
93
|
+
adapter: postgresql
|
|
94
|
+
database: shop_production
|
|
95
|
+
analytics:
|
|
96
|
+
adapter: postgresql
|
|
97
|
+
database: shop_analytics_production
|
|
98
|
+
migrations_paths: db/analytics_migrate
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# app/models/analytics_record.rb
|
|
103
|
+
class AnalyticsRecord < ApplicationRecord
|
|
104
|
+
self.abstract_class = true
|
|
105
|
+
connects_to database: { writing: :analytics, reading: :analytics }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
class PageView < AnalyticsRecord; end # metadata[:database] => "analytics"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Edge cases:**
|
|
112
|
+
- STI subclasses are extracted separately from their parent (each has its own identifier)
|
|
113
|
+
- `callback.options` was removed in Rails 4.2, the extractor uses `@if`/`@unless` ivars and ActionFilter duck-typing to extract `:only`/`:except` action lists
|
|
114
|
+
- AR-generated internal methods (like `autosave_associated_records_for_comments`) are filtered by a single combined regex to avoid noise
|
|
115
|
+
|
|
116
|
+
**Example output (abbreviated):**
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"type": "model",
|
|
121
|
+
"identifier": "Order",
|
|
122
|
+
"file_path": "app/models/order.rb",
|
|
123
|
+
"namespace": null,
|
|
124
|
+
"source_code": "# == Schema Information\n# id :bigint\n# user_id :bigint\n# status :string\n# total_cents :integer\n#\nclass Order < ApplicationRecord\n belongs_to :user\n has_many :line_items\n ...\nend\n\n# ┌───────────────────────────────────────────────────────────────────┐\n# │ Included from: Auditable │\n# └───────────────────────────────────────────────────────────────────┘\n# module Auditable\n# ...\n# end\n# ──────────────────────── End Auditable ────────────────────────────",
|
|
125
|
+
"metadata": {
|
|
126
|
+
"database": "primary",
|
|
127
|
+
"associations": [
|
|
128
|
+
{ "type": "belongs_to", "name": "user", "target": "User", "from_db": "primary", "to_db": "primary", "disable_joins": false },
|
|
129
|
+
{ "type": "has_many", "name": "line_items", "target": "LineItem" }
|
|
130
|
+
],
|
|
131
|
+
"callbacks": [
|
|
132
|
+
{ "type": "before_save", "filter": "calculate_total", "kind": "before", "conditions": {},
|
|
133
|
+
"side_effects": { "columns_written": ["total_cents"], "jobs_enqueued": [], "services_called": [], "mailers_triggered": [], "database_reads": [], "operations": [] } },
|
|
134
|
+
{ "type": "after_commit", "filter": "send_confirmation_email", "kind": "after", "conditions": {},
|
|
135
|
+
"side_effects": { "columns_written": [], "jobs_enqueued": ["OrderConfirmationJob"], "services_called": [], "mailers_triggered": ["OrderMailer"], "database_reads": [], "operations": [] } }
|
|
136
|
+
],
|
|
137
|
+
"validations": [
|
|
138
|
+
{ "attribute": "status", "type": "inclusion", "options": { "in": ["pending", "paid", "shipped"] }, "conditions": {} }
|
|
139
|
+
],
|
|
140
|
+
"inlined_concerns": ["Auditable"]
|
|
141
|
+
},
|
|
142
|
+
"dependencies": [
|
|
143
|
+
{ "type": "model", "target": "User", "via": "belongs_to" },
|
|
144
|
+
{ "type": "model", "target": "LineItem", "via": "has_many" }
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
### ControllerExtractor
|
|
152
|
+
|
|
153
|
+
**What it captures:** Every `ApplicationController` and `ActionController::API` descendant. Route context is prepended to the source, each controller gets a header block showing which HTTP verb + path maps to each action. Before/after filter chains are resolved per action.
|
|
154
|
+
|
|
155
|
+
**Key details:**
|
|
156
|
+
- Discovers controllers via `ApplicationController.descendants` (and `ActionController::API.descendants` if present)
|
|
157
|
+
- Builds a routes map from `Rails.application.routes` at initialization time
|
|
158
|
+
- Route context is inlined in `source_code` as a comment header, not just in metadata
|
|
159
|
+
- Chunks per-action: each action becomes a `:action` chunk with its applicable filters and route
|
|
160
|
+
- Metadata includes permitted params (strong parameters), response formats, and applied filters per action
|
|
161
|
+
- Extracts `redirect_to` navigation edges: named route helpers (`posts_path`, `users_url`) are resolved to controller targets via `RouteHelperResolver`, producing `:redirect_to` dependency edges (gated by `extract_navigation_edges` config)
|
|
162
|
+
|
|
163
|
+
**Edge cases:**
|
|
164
|
+
- API-only controllers (`ActionController::API` descendants) are included when the gem is present
|
|
165
|
+
- Controllers with no corresponding routes still get extracted (they may be base classes)
|
|
166
|
+
|
|
167
|
+
**Example output (abbreviated):**
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"type": "controller",
|
|
172
|
+
"identifier": "OrdersController",
|
|
173
|
+
"metadata": {
|
|
174
|
+
"actions": ["index", "show", "create", "update"],
|
|
175
|
+
"routes": [
|
|
176
|
+
{ "verb": "GET", "path": "/orders", "action": "index" },
|
|
177
|
+
{ "verb": "POST", "path": "/orders", "action": "create" }
|
|
178
|
+
],
|
|
179
|
+
"filters": {
|
|
180
|
+
"before": ["authenticate_user!", "set_order"],
|
|
181
|
+
"after": ["track_event"]
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
### ServiceExtractor
|
|
190
|
+
|
|
191
|
+
**What it captures:** Service objects, interactors, operations, commands, and use cases, the "business logic layer." Discovers them by scanning conventional directories for Ruby files.
|
|
192
|
+
|
|
193
|
+
**Key details:**
|
|
194
|
+
- Scans: `app/services`, `app/interactors`, `app/operations`, `app/commands`, `app/use_cases`
|
|
195
|
+
- Extracts public entry points (`call`, `perform`, `execute`, `run`), custom error classes, and dependency references
|
|
196
|
+
- File-based discovery (not class introspection), so it catches services with non-standard superclasses
|
|
197
|
+
|
|
198
|
+
**Example output (abbreviated):**
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{
|
|
202
|
+
"type": "service",
|
|
203
|
+
"identifier": "CheckoutService",
|
|
204
|
+
"metadata": {
|
|
205
|
+
"entry_points": ["call"],
|
|
206
|
+
"custom_errors": ["CheckoutService::PaymentFailedError"],
|
|
207
|
+
"dependencies": ["Order", "PaymentProcessor"]
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
### JobExtractor
|
|
215
|
+
|
|
216
|
+
**What it captures:** ActiveJob workers and Sidekiq workers. Scans job directories, then supplements with `ApplicationJob.descendants` for anything discovered at runtime but not found via files.
|
|
217
|
+
|
|
218
|
+
**Key details:**
|
|
219
|
+
- Scans: `app/jobs`, `app/workers`, `app/sidekiq`
|
|
220
|
+
- Extracts queue name, retry configuration, concurrency options, perform method arguments, and callbacks
|
|
221
|
+
- Records what triggers this job (reverse lookup via dependency graph after extraction)
|
|
222
|
+
- Supports both ActiveJob and Sidekiq native workers
|
|
223
|
+
|
|
224
|
+
**Example output (abbreviated):**
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{
|
|
228
|
+
"type": "job",
|
|
229
|
+
"identifier": "ProcessOrderJob",
|
|
230
|
+
"metadata": {
|
|
231
|
+
"queue": "default",
|
|
232
|
+
"retry_on": ["Stripe::APIError"],
|
|
233
|
+
"perform_args": ["order_id"],
|
|
234
|
+
"adapter": "ActiveJob"
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
### MailerExtractor
|
|
242
|
+
|
|
243
|
+
**What it captures:** ActionMailer classes with their mailer actions, defaults, template paths, callbacks, and helper usage.
|
|
244
|
+
|
|
245
|
+
**Key details:**
|
|
246
|
+
- Discovers via class introspection (`ActionMailer::Base.descendants`)
|
|
247
|
+
- Each mailer action corresponds to an email template, template paths are recorded in metadata
|
|
248
|
+
- Extracts `default from:`, `layout`, and per-action subject patterns
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
### ConfigurationExtractor
|
|
253
|
+
|
|
254
|
+
**What it captures:** Rails initializers (`config/initializers/**/*.rb`) and environment files (`config/environments/*.rb`). Also extracts a behavioral profile from the resolved `Rails.application.config` values at runtime.
|
|
255
|
+
|
|
256
|
+
**Key details:**
|
|
257
|
+
- `BehavioralProfile` introspects live config using `respond_to?`/`defined?` guards, a missing config section produces `nil`, not an error
|
|
258
|
+
- Captures: asset pipeline config, middleware additions, cache store, logger config, and custom initializer logic
|
|
259
|
+
- One unit per config file, plus one special `:behavioral_profile` unit per environment
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
### RouteExtractor
|
|
264
|
+
|
|
265
|
+
**What it captures:** Every route in the Rails routing table via `Rails.application.routes.routes`. Each route becomes its own `ExtractedUnit`.
|
|
266
|
+
|
|
267
|
+
**Key details:**
|
|
268
|
+
- Pure runtime introspection, reads the live routing table, not `config/routes.rb` AST
|
|
269
|
+
- Each unit's identifier is `"VERB /path"` (e.g., `"POST /orders"`)
|
|
270
|
+
- A route with request constraints is qualified: `"GET /users [subdomain=api]"`, `"GET /users [format=json]"`, `"GET /users [constraint=proc]"` for a callable. Path-segment requirements (`id: /\d+/`) do not qualify
|
|
271
|
+
- Routes that still share an identifier are numbered in route order (`"GET /users #2"`) so none are dropped
|
|
272
|
+
- Records controller, action, route name, and constraints
|
|
273
|
+
- Since routes don't map to individual files, incremental re-extraction re-runs `RouteExtractor` wholesale whenever `config/routes.rb` changes, it isn't skipped, just not diffed per file
|
|
274
|
+
|
|
275
|
+
**Example output (abbreviated):**
|
|
276
|
+
|
|
277
|
+
```json
|
|
278
|
+
{
|
|
279
|
+
"type": "route",
|
|
280
|
+
"identifier": "POST /orders",
|
|
281
|
+
"metadata": {
|
|
282
|
+
"controller": "orders",
|
|
283
|
+
"action": "create",
|
|
284
|
+
"route_name": "orders"
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
### MiddlewareExtractor
|
|
292
|
+
|
|
293
|
+
**What it captures:** The full Rack middleware stack as a single ordered unit. Useful for understanding request preprocessing and which middleware is active.
|
|
294
|
+
|
|
295
|
+
**Key details:**
|
|
296
|
+
- Extracts the entire stack as one unit (not one per middleware)
|
|
297
|
+
- Records middleware class names, insertion order, and any initialization arguments
|
|
298
|
+
- No per-file mapping, so incremental re-extraction re-runs `MiddlewareExtractor` wholesale when `config/application.rb`, `Gemfile.lock`, or a file under `config/initializers`/`config/environments` changes
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## UI component extractors
|
|
303
|
+
|
|
304
|
+
### PhlexExtractor
|
|
305
|
+
|
|
306
|
+
**What it captures:** Phlex component classes (`Phlex::HTML`, `Phlex::SVG` subclasses) from `app/components`. Extracts slots, initialize parameters, sub-component references, Stimulus controller names, and route helper usage.
|
|
307
|
+
|
|
308
|
+
**Key details:**
|
|
309
|
+
- Phlex components render pure Ruby, no template files to parse separately
|
|
310
|
+
- Slots and sub-component composition are extracted from the `view_template` method
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
### ViewComponentExtractor
|
|
315
|
+
|
|
316
|
+
**What it captures:** ViewComponent classes from `app/components`. Extracts slots, template paths, preview class references, and collection rendering support.
|
|
317
|
+
|
|
318
|
+
**Key details:**
|
|
319
|
+
- Template path is inferred from the component file name (e.g., `ButtonComponent` → `button_component.html.erb`)
|
|
320
|
+
- Preview class associations are extracted when `<ComponentName>Preview` is found in `spec/components/previews/` or `test/components/previews/`
|
|
321
|
+
|
|
322
|
+
**Edge cases:**
|
|
323
|
+
- Phlex and ViewComponent both scan `app/components`, the orchestrator uses separate extractors for each. A Phlex component won't be extracted by ViewComponentExtractor and vice versa (the filtering is by superclass, not file name)
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
### ViewTemplateExtractor
|
|
328
|
+
|
|
329
|
+
**What it captures:** ERB view templates from `app/views`. Extracts render calls (partials and components), instance variable references, and helper method usage.
|
|
330
|
+
|
|
331
|
+
**Key details:**
|
|
332
|
+
- File-based scanning, no Rails boot needed for the actual file reading
|
|
333
|
+
- Records which partials a template renders and which instance variables it expects
|
|
334
|
+
- Extracts navigation dependencies: `link_to` and `form_with`/`form_for` calls using `_path`/`_url` route helpers are resolved to controller targets via `RouteHelperResolver`
|
|
335
|
+
- Navigation edges use `:link_to` and `:form_action` via types in the dependency array
|
|
336
|
+
- Gated by `extract_navigation_edges` config (default: true)
|
|
337
|
+
|
|
338
|
+
**Template engine coverage.** ERB only as a parsed template engine, HAML, Slim, and Turbo Streams are not parsed at all; an app using HAML or Slim as its primary view engine gets zero view-layer coverage from this extractor. Stimulus controller *references* are a partial exception: `PhlexExtractor` and `ViewComponentExtractor` scan `data-controller` attributes in their component source and emit `:stimulus_controller` dependency edges, the target Stimulus controller files under `app/javascript/controllers/` are not themselves parsed or extracted. The MCP `structure` tool surfaces the supported engine list via the `template_engines` field. The pluggable `Woods::Extractors::ViewEngines::Base` protocol and the `ViewTemplateExtractor::ENGINES` registry shipped with issue #110, HAML / Slim / Turbo implementations become plug-in additions: subclass `Base`, implement `name` / `extensions` / the three `scan_*` methods / `resolve_partial_identifier`, and append the class to `ENGINES`.
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
### DecoratorExtractor
|
|
343
|
+
|
|
344
|
+
**What it captures:** Decorator, presenter, and form object classes from `app/decorators`, `app/presenters`, and `app/form_objects`.
|
|
345
|
+
|
|
346
|
+
**Key details:**
|
|
347
|
+
- These directories are also added to `EXTRACTION_DIRECTORIES` for eager loading
|
|
348
|
+
- Extracts delegated methods, wrapped model class, and custom presentation methods
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## Data layer extractors
|
|
353
|
+
|
|
354
|
+
### ConcernExtractor
|
|
355
|
+
|
|
356
|
+
**What it captures:** `ActiveSupport::Concern` modules from `app/models/concerns` and `app/controllers/concerns`.
|
|
357
|
+
|
|
358
|
+
**Key details:**
|
|
359
|
+
- Scans: `app/models/concerns`, `app/controllers/concerns`
|
|
360
|
+
- Extracts included hooks, `ClassMethods` block, instance methods, and class methods added by the concern
|
|
361
|
+
- Dependencies on models and other concerns are tracked
|
|
362
|
+
- Note: concerns are *also* inlined into model/controller source by ModelExtractor and ControllerExtractor. ConcernExtractor produces standalone units for direct lookup
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
### PoroExtractor
|
|
367
|
+
|
|
368
|
+
**What it captures:** Plain Ruby objects in `app/models` that are not ActiveRecord (non-AR classes, excluding concerns).
|
|
369
|
+
|
|
370
|
+
**Key details:**
|
|
371
|
+
- Scans `app/models` for files that don't define an `ActiveRecord::Base` descendant
|
|
372
|
+
- Common examples: value objects, form objects placed in `app/models`, domain structs
|
|
373
|
+
- Excludes concerns (those go to ConcernExtractor)
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
### SerializerExtractor
|
|
378
|
+
|
|
379
|
+
**What it captures:** Serializer classes for ActiveModelSerializers, Blueprinter, Alba, and Draper. Auto-detects which serialization gems are loaded.
|
|
380
|
+
|
|
381
|
+
**Key details:**
|
|
382
|
+
- Each supported library is probed with `defined?` before attempting extraction
|
|
383
|
+
- Extracts serialized attributes, associations, and any custom method overrides
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
### ValidatorExtractor
|
|
388
|
+
|
|
389
|
+
**What it captures:** Custom `ActiveModel::Validator` subclasses with their validation rules.
|
|
390
|
+
|
|
391
|
+
**Key details:**
|
|
392
|
+
- File-based scanning; extracts `validate` method logic and the attribute being validated
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
### ManagerExtractor
|
|
397
|
+
|
|
398
|
+
**What it captures:** `SimpleDelegator` subclasses that wrap a model. Records the wrapped model class, all public methods, and the delegation chain.
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
## API & authorization extractors
|
|
403
|
+
|
|
404
|
+
### GraphQLExtractor
|
|
405
|
+
|
|
406
|
+
**What it captures:** graphql-ruby types, mutations, queries, and resolvers. Produces four distinct unit types from one extractor.
|
|
407
|
+
|
|
408
|
+
**Key details:**
|
|
409
|
+
- Scans `app/graphql` with runtime introspection via `GraphQL::Schema.types` when available, falls back to file discovery
|
|
410
|
+
- Produces unit types: `graphql_type`, `graphql_mutation`, `graphql_resolver`, `graphql_query`
|
|
411
|
+
- Extracts field metadata (types, descriptions, complexity, arguments), authorization patterns (Pundit, CanCan, `authorized?`), and dependencies on models/services
|
|
412
|
+
- Since all GraphQL units come from one extractor, incremental re-extraction handles them via `extract_graphql_file`
|
|
413
|
+
|
|
414
|
+
**Example output (abbreviated):**
|
|
415
|
+
|
|
416
|
+
```json
|
|
417
|
+
{
|
|
418
|
+
"type": "graphql_type",
|
|
419
|
+
"identifier": "Types::UserType",
|
|
420
|
+
"metadata": {
|
|
421
|
+
"fields": [
|
|
422
|
+
{ "name": "id", "type": "ID!", "description": null },
|
|
423
|
+
{ "name": "email", "type": "String!" }
|
|
424
|
+
],
|
|
425
|
+
"authorized_by": "pundit"
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
### PunditExtractor
|
|
433
|
+
|
|
434
|
+
**What it captures:** Pundit policy classes with their action methods (`index?`, `show?`, `create?`, `update?`, `destroy?`, and custom predicates).
|
|
435
|
+
|
|
436
|
+
**Key details:**
|
|
437
|
+
- Pairs policy units with their corresponding model (e.g., `UserPolicy` → `User`)
|
|
438
|
+
- Extracts scope class and `resolve` method when present
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
### PolicyExtractor
|
|
443
|
+
|
|
444
|
+
**What it captures:** Domain policy classes (non-Pundit) with decision methods and eligibility rules. Covers plain Ruby objects used for authorization decisions.
|
|
445
|
+
|
|
446
|
+
**Key details:**
|
|
447
|
+
- Scans `app/policies` for files not identified as Pundit policies
|
|
448
|
+
- Extracts public predicate methods and their dependencies
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## Infrastructure extractors
|
|
453
|
+
|
|
454
|
+
### EngineExtractor
|
|
455
|
+
|
|
456
|
+
**What it captures:** Mounted Rails engines via runtime introspection. Records mount points and route counts for each engine.
|
|
457
|
+
|
|
458
|
+
**Key details:**
|
|
459
|
+
- Uses `Rails::Engine.subclasses` at runtime, finds both gem-mounted and in-repo engines
|
|
460
|
+
- Engine units don't map to individual files, so incremental re-extraction re-runs `EngineExtractor` wholesale when `config/routes.rb` or `Gemfile.lock` changes
|
|
461
|
+
- A mounted engine may duplicate some routes; the deduplication phase handles this
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
### PackageExtractor
|
|
466
|
+
|
|
467
|
+
**What it captures:** Packwerk / pks package boundaries from every `package.yml`, one unit per package. No Rails boot is needed for the read itself.
|
|
468
|
+
|
|
469
|
+
**Key details:**
|
|
470
|
+
- Identifier is the package directory relative to `Rails.root` (`.` for the root package), the same name Packwerk uses
|
|
471
|
+
- Honors `packwerk.yml` `package_paths` and `exclude`; without one, `**/` with the Packwerk default excludes (`bin`, `node_modules`, `script`, `tmp`, `vendor`)
|
|
472
|
+
- `metadata`: `name`, `dependencies` (sorted), `enforce_dependencies` (`true`, `false`, or `"strict"`), `enforce_privacy`, `layer` (pks), `public_path`, `owner`
|
|
473
|
+
- Each declared dependency becomes a `{ type: :package, target: <name>, via: :package_dependency }` edge
|
|
474
|
+
- Package membership on other units (`metadata[:package]`, below) does not depend on how a unit was discovered: any registered unit with a file path under a package root is annotated. The undeclared cross-package edge report remains a follow-up, not this extractor. Discovery is the separate open gap: a pack-resident file-based unit is not yet found by `PathDispatcher` when only its `package.yml` changes (follow-up B-175), so it carries no membership only because it has no unit at all yet, not because membership skips it
|
|
475
|
+
- Woods does not enforce anything. `pks check` and `packwerk check` own enforcement; Woods shows the boundary before an agent writes the cross-package call
|
|
476
|
+
- Whole-app: any `package.yml` or `packwerk.yml` change re-runs the extractor wholesale
|
|
477
|
+
|
|
478
|
+
**Example output (abbreviated):**
|
|
479
|
+
|
|
480
|
+
```json
|
|
481
|
+
{
|
|
482
|
+
"type": "package",
|
|
483
|
+
"identifier": "packs/billing",
|
|
484
|
+
"file_path": "packs/billing/package.yml",
|
|
485
|
+
"metadata": {
|
|
486
|
+
"name": "packs/billing",
|
|
487
|
+
"dependencies": [".", "packs/accounts"],
|
|
488
|
+
"enforce_dependencies": "strict",
|
|
489
|
+
"layer": "product",
|
|
490
|
+
"owner": "billing-team"
|
|
491
|
+
},
|
|
492
|
+
"dependencies": [
|
|
493
|
+
{ "type": "package", "target": ".", "via": "package_dependency" },
|
|
494
|
+
{ "type": "package", "target": "packs/accounts", "via": "package_dependency" }
|
|
495
|
+
]
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
#### Package membership
|
|
500
|
+
|
|
501
|
+
Every app-owned unit under a package root carries `metadata[:package]` with the package name (longest root wins; `.` when only a root package exists). Framework sources and units with no path never carry it. The graph node carries the same value as `package`. When a `package.yml` changes, an incremental run re-annotates every unit whose package changed in the same run, so `metadata[:package]` never lags behind the file that defines it.
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
### I18nExtractor
|
|
506
|
+
|
|
507
|
+
**What it captures:** Locale files from `config/locales` with the full translation key hierarchy.
|
|
508
|
+
|
|
509
|
+
**Key details:**
|
|
510
|
+
- Scans `config/locales/**/*.{yml,yaml}`
|
|
511
|
+
- Produces one unit per locale file with the nested key structure flattened in metadata
|
|
512
|
+
- Useful for answering "what locales do we support?" and "what keys exist under X?"
|
|
513
|
+
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
### ActionCableExtractor
|
|
517
|
+
|
|
518
|
+
**What it captures:** ActionCable channel classes with stream subscriptions, subscribed/unsubscribed hooks, broadcast patterns, and action methods.
|
|
519
|
+
|
|
520
|
+
**Key details:**
|
|
521
|
+
- Discovers via `ActionCable::Channel::Base.descendants`
|
|
522
|
+
- Records stream names, authentication checks in `subscribed`, and any `broadcast_to` calls
|
|
523
|
+
|
|
524
|
+
---
|
|
525
|
+
|
|
526
|
+
### ScheduledJobExtractor
|
|
527
|
+
|
|
528
|
+
**What it captures:** Scheduled job definitions from cron-style config files. Supports multiple scheduling backends.
|
|
529
|
+
|
|
530
|
+
**Key details:**
|
|
531
|
+
- Reads: `config/recurring.yml` (Solid Queue), `config/sidekiq_cron.yml` (Sidekiq Cron), `config/schedule.rb` (Whenever)
|
|
532
|
+
- Extracts job class name, cron expression, queue, and any arguments
|
|
533
|
+
- File-based (static read, no Rails introspection needed)
|
|
534
|
+
- No per-file mapping, so incremental re-extraction re-runs `ScheduledJobExtractor` wholesale whenever one of the schedule files above changes
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
### RakeTaskExtractor
|
|
539
|
+
|
|
540
|
+
**What it captures:** Rake tasks from `lib/tasks/*.rake`. Extracts namespaces, task names, descriptions, prerequisites (`:depends_on`), and the task body.
|
|
541
|
+
|
|
542
|
+
**Key details:**
|
|
543
|
+
- Reads `.rake` files statically, no Rails boot required for parsing
|
|
544
|
+
- Uses `block_opener?` for depth tracking; `if`/`unless` only match at line start to avoid counting trailing modifiers as blocks
|
|
545
|
+
- Supports nested namespaces (`namespace :data do namespace :import do task :users`)
|
|
546
|
+
- A task reopened in more than one `.rake` file is one unit, as Rake sees it: the source carries every definition, `metadata.defined_in` lists the files, and a per-file incremental run yields the same merged unit as a full run
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
550
|
+
### MigrationExtractor
|
|
551
|
+
|
|
552
|
+
**What it captures:** ActiveRecord migration files from `db/migrate`. Extracts DDL metadata, affected tables, risk indicators, and reversibility.
|
|
553
|
+
|
|
554
|
+
**Key details:**
|
|
555
|
+
- Scans `db/migrate/*.rb`
|
|
556
|
+
- Extracts: tables created/dropped/modified, columns added/removed, indexes, references
|
|
557
|
+
- Risk indicators: data migrations (manual SQL or bulk updates), irreversible operations (`remove_column` without type), `execute` calls with raw SQL
|
|
558
|
+
- Rails internal tables (`schema_migrations`, `active_storage_blobs`, etc.) are excluded from model dependency links
|
|
559
|
+
|
|
560
|
+
**Example output (abbreviated):**
|
|
561
|
+
|
|
562
|
+
```json
|
|
563
|
+
{
|
|
564
|
+
"type": "migration",
|
|
565
|
+
"identifier": "AddStatusToOrders",
|
|
566
|
+
"metadata": {
|
|
567
|
+
"version": "20240115120000",
|
|
568
|
+
"tables_affected": ["orders"],
|
|
569
|
+
"operations": [
|
|
570
|
+
{ "type": "add_column", "table": "orders", "column": "status", "column_type": "string" }
|
|
571
|
+
],
|
|
572
|
+
"reversible": true,
|
|
573
|
+
"risk_level": "low"
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
---
|
|
579
|
+
|
|
580
|
+
### DatabaseViewExtractor
|
|
581
|
+
|
|
582
|
+
**What it captures:** SQL views from `db/views` following the Scenic gem convention.
|
|
583
|
+
|
|
584
|
+
**Key details:**
|
|
585
|
+
- Only extracts the **latest version** of each view (highest `_vNN` suffix)
|
|
586
|
+
- Older versions are skipped
|
|
587
|
+
- Records whether the view is materialized and which tables it references
|
|
588
|
+
- Incremental re-extraction re-runs `DatabaseViewExtractor` wholesale on any `.sql` change under `db/views`, a per-file dispatch could index a version a full extraction drops
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
### StateMachineExtractor
|
|
593
|
+
|
|
594
|
+
**What it captures:** State machine DSL definitions using AASM, Statesman, or the `state_machines` gem.
|
|
595
|
+
|
|
596
|
+
**Key details:**
|
|
597
|
+
- Detects which library is active by checking `defined?` for each DSL constant
|
|
598
|
+
- Extracts states, events, transitions, guard conditions, and callbacks
|
|
599
|
+
- Returns an array from the file method (like `ScheduledJobExtractor`), cannot be used in the incremental file-based dispatch map; incremental re-extraction re-runs it wholesale on any `.rb` change under the model directories it scans
|
|
600
|
+
|
|
601
|
+
---
|
|
602
|
+
|
|
603
|
+
### EventExtractor
|
|
604
|
+
|
|
605
|
+
**What it captures:** Event publish/subscribe patterns using `ActiveSupport::Notifications` or Wisper.
|
|
606
|
+
|
|
607
|
+
**Key details:**
|
|
608
|
+
- Two-pass approach: first collects all `publish`/`instrument` calls, then `subscribe`/`on` calls, then merges them
|
|
609
|
+
- No single-file extraction method, incremental re-extraction re-runs `EventExtractor` wholesale on any `.rb` change under `app/` (a publish or subscribe site can appear anywhere)
|
|
610
|
+
- Useful for tracing event-driven flows: "what subscribes to order.created?"
|
|
611
|
+
|
|
612
|
+
---
|
|
613
|
+
|
|
614
|
+
### CachingExtractor
|
|
615
|
+
|
|
616
|
+
**What it captures:** Cache usage patterns across controllers, models, and ERB view templates.
|
|
617
|
+
|
|
618
|
+
**Key details:**
|
|
619
|
+
- Scans controllers, models, and `.erb` view files
|
|
620
|
+
- Extracts: `cache` blocks, `Rails.cache.fetch`, `expire_fragment`, TTLs, and cache keys
|
|
621
|
+
- The `file_type` parameter on `extract_caching_file` defaults to `nil` (auto-detected from path)
|
|
622
|
+
|
|
623
|
+
---
|
|
624
|
+
|
|
625
|
+
## Testing & source extractors
|
|
626
|
+
|
|
627
|
+
### FactoryExtractor
|
|
628
|
+
|
|
629
|
+
**What it captures:** FactoryBot factory definitions including traits, associations, and lazy attribute blocks.
|
|
630
|
+
|
|
631
|
+
**Key details:**
|
|
632
|
+
- Scans `spec/factories` and `test/factories`
|
|
633
|
+
- Produces one unit per factory definition (including trait sub-factories)
|
|
634
|
+
- Useful for understanding test data structure and available factory combinations
|
|
635
|
+
- No per-file mapping, so incremental re-extraction re-runs `FactoryExtractor` wholesale on any `.rb` change under the factory directories
|
|
636
|
+
|
|
637
|
+
---
|
|
638
|
+
|
|
639
|
+
### TestMappingExtractor
|
|
640
|
+
|
|
641
|
+
**What it captures:** Test file-to-subject mappings with test counts, describe/context hierarchy, and test framework detection.
|
|
642
|
+
|
|
643
|
+
**Key details:**
|
|
644
|
+
- Scans `spec/` and `test/` directories
|
|
645
|
+
- Maps each spec file to its subject class by convention (e.g., `spec/models/user_spec.rb` → `User`)
|
|
646
|
+
- Records test count and whether RSpec or Minitest is detected
|
|
647
|
+
- These directories are outside `app/` so no eager loading is needed
|
|
648
|
+
|
|
649
|
+
---
|
|
650
|
+
|
|
651
|
+
### LibExtractor
|
|
652
|
+
|
|
653
|
+
**What it captures:** Ruby files from `lib/`, utility modules, standalone libraries, and infrastructure code.
|
|
654
|
+
|
|
655
|
+
**Key details:**
|
|
656
|
+
- Excludes `lib/tasks/` (covered by RakeTaskExtractor) and `lib/generators/`
|
|
657
|
+
- File-based scanning; no assumption about class hierarchy
|
|
658
|
+
|
|
659
|
+
---
|
|
660
|
+
|
|
661
|
+
### RailsSourceExtractor
|
|
662
|
+
|
|
663
|
+
**What it captures:** High-value Rails framework source and gem source files, pinned to the exact versions in `Gemfile.lock`.
|
|
664
|
+
|
|
665
|
+
**Key details:**
|
|
666
|
+
- Reads from `Gem.loaded_specs`, paths depend on the installed gem location
|
|
667
|
+
- Indexes selected paths from: `activerecord` (associations, callbacks, validations, relation, enum, transactions), `actionpack` (controller metal, callbacks, rendering, redirecting), `activesupport` (callbacks, concern, configurable, delegation)
|
|
668
|
+
- `config.add_gem` is accepted but not implemented; only the fixed framework path list is indexed
|
|
669
|
+
- This is what makes framework-specific queries accurate: "what options does `has_many` support?" returns the actual source for the installed Rails version
|
|
670
|
+
|
|
671
|
+
---
|
|
672
|
+
|
|
673
|
+
## How do I enable or disable extractors?
|
|
674
|
+
|
|
675
|
+
You can't, today. All 35 extractors always run during a full extraction, there is no opt-in/opt-out mechanism and nothing in the extraction path reads
|
|
676
|
+
`config.extractors`. The array is accepted for forward compatibility: setting
|
|
677
|
+
it to anything other than its default value emits a warning and has no
|
|
678
|
+
effect on which extractors run or what the retrieval pipeline sees.
|
|
679
|
+
Extractor selection is a documented future knob, not a shipped feature.
|
|
680
|
+
|
|
681
|
+
`config.add_gem` is in the same state: accepted with a warning, not read by
|
|
682
|
+
`RailsSourceExtractor`. There is no shipped way to widen the framework path list.
|
|
683
|
+
|
|
684
|
+
---
|
|
685
|
+
|
|
686
|
+
## ExtractedUnit field reference
|
|
687
|
+
|
|
688
|
+
Every extractor produces `ExtractedUnit` objects with this schema:
|
|
689
|
+
|
|
690
|
+
| Field | Type | Description |
|
|
691
|
+
|-------|------|-------------|
|
|
692
|
+
| `type` | Symbol | Unit category, one of the 39 types in `Woods::Extractor::TYPE_TO_EXTRACTOR_KEY`: `:model`, `:controller`, `:service`, `:job`, `:mailer`, `:component`, `:view_component`, `:graphql_type`, `:graphql_mutation`, `:graphql_resolver`, `:graphql_query`, `:serializer`, `:manager`, `:policy`, `:validator`, `:concern`, `:route`, `:middleware`, `:i18n`, `:pundit_policy`, `:configuration`, `:engine`, `:view_template`, `:migration`, `:action_cable_channel`, `:scheduled_job`, `:rake_task`, `:state_machine`, `:event`, `:decorator`, `:database_view`, `:caching`, `:factory`, `:test_mapping`, `:rails_source`, `:gem_source`, `:poro`, `:lib`, `:package` |
|
|
693
|
+
| `identifier` | String | Unique key for this unit. Usually the class name (e.g., `"User"`, `"OrdersController"`) or a descriptive string for non-class units (e.g., `"POST /orders"`) |
|
|
694
|
+
| `file_path` | String | Relative path to the source file (e.g., `"app/models/user.rb"`). Relative to `Rails.root` after normalization. A gem-owned unit (an engine model such as `ActiveStorage::Blob`, a framework source) keeps its absolute gem path, since nothing under `Rails.root` defines it. |
|
|
695
|
+
| `namespace` | String\|nil | Module namespace if the class is nested (e.g., `"Admin"` for `Admin::DashboardController`) |
|
|
696
|
+
| `source_code` | String | The full source code, potentially enriched: models have concerns inlined and schema prepended; controllers have a route context header prepended |
|
|
697
|
+
| `metadata` | Hash | Type-specific structured data, associations, callbacks, actions, fields, etc. Keys and structure vary by extractor. Model units add `database`, `foreign_keys`, and per-association `from_db`/`to_db`/`disable_joins` (#280). Any app-owned unit under a Packwerk package adds `package` (#280) |
|
|
698
|
+
| `dependencies` | Array\<Hash\> | Forward edges: `[{ type: :model, target: "User", via: "belongs_to" }, ...]` |
|
|
699
|
+
| `dependents` | Array\<Hash\> | Reverse edges: **populated in Phase 2 (Resolve)**, not Phase 1 (Extract). After Phase 2 every field on a unit is effectively immutable. Shape: `[{ type: :controller, identifier: "OrdersController" }, ...]` |
|
|
700
|
+
| `chunks` | Array\<Hash\> | Semantic sub-sections for large units. Each chunk: `{ chunk_index:, identifier:, content:, content_hash:, estimated_tokens: }` |
|
|
701
|
+
| `estimated_tokens` | Integer | Approximate token count for `source_code + metadata.to_json` using 4.0 chars/token. Computed, not stored. |
|
|
702
|
+
|
|
703
|
+
### Serialized JSON fields
|
|
704
|
+
|
|
705
|
+
When written to disk, units also include:
|
|
706
|
+
|
|
707
|
+
| Field | Description |
|
|
708
|
+
|-------|-------------|
|
|
709
|
+
| `extracted_at` | ISO 8601 timestamp of extraction |
|
|
710
|
+
| `source_hash` | SHA-256 of `source_code` for change detection |
|
|
711
|
+
|
|
712
|
+
### Git enrichment fields (`metadata[:git]`)
|
|
713
|
+
|
|
714
|
+
If the host app is a git repo, the following are added to `metadata[:git]` after extraction:
|
|
715
|
+
|
|
716
|
+
| Field | Description |
|
|
717
|
+
|-------|-------------|
|
|
718
|
+
| `last_modified` | ISO 8601 date of last commit touching this file |
|
|
719
|
+
| `last_author` | Name of the author who last modified the file |
|
|
720
|
+
| `commit_count` | Total commit count for this file (past 365 days) |
|
|
721
|
+
| `contributors` | Top 5 contributors by commit count: `[{ name:, commits: }]` |
|
|
722
|
+
| `recent_commits` | Last 5 commits: `[{ sha:, message:, date:, author: }]` |
|
|
723
|
+
| `change_frequency` | `:new`, `:hot`, `:active`, `:stable`, or `:dormant` |
|
|
724
|
+
|
|
725
|
+
### Full example JSON
|
|
726
|
+
|
|
727
|
+
```json
|
|
728
|
+
{
|
|
729
|
+
"type": "model",
|
|
730
|
+
"identifier": "User",
|
|
731
|
+
"file_path": "app/models/user.rb",
|
|
732
|
+
"namespace": null,
|
|
733
|
+
"source_code": "# == Schema Information\n# id :bigint not null, pk\n# email :string not null\n# created_at :datetime\n#\nclass User < ApplicationRecord\n has_many :orders\n validates :email, presence: true, uniqueness: true\nend\n\n# ┌───────────────────────────────────────────────────────────────────┐\n# │ Included from: Searchable │\n# └───────────────────────────────────────────────────────────────────┘\n# module Searchable\n# extend ActiveSupport::Concern\n# ...\n# end\n# ──────────────────────── End Searchable ───────────────────────────",
|
|
734
|
+
"metadata": {
|
|
735
|
+
"associations": [{ "type": "has_many", "name": "orders", "target": "Order" }],
|
|
736
|
+
"validations": [{ "attribute": "email", "type": "presence", "options": {}, "conditions": {} }, { "attribute": "email", "type": "uniqueness", "options": {}, "conditions": {} }],
|
|
737
|
+
"callbacks": [],
|
|
738
|
+
"scopes": [],
|
|
739
|
+
"inlined_concerns": ["Searchable"],
|
|
740
|
+
"git": {
|
|
741
|
+
"last_modified": "2024-11-20T14:32:00Z",
|
|
742
|
+
"last_author": "Alice",
|
|
743
|
+
"commit_count": 23,
|
|
744
|
+
"change_frequency": "active"
|
|
745
|
+
}
|
|
746
|
+
},
|
|
747
|
+
"dependencies": [
|
|
748
|
+
{ "type": "model", "target": "Order", "via": "has_many" }
|
|
749
|
+
],
|
|
750
|
+
"dependents": [
|
|
751
|
+
{ "type": "controller", "identifier": "UsersController" }
|
|
752
|
+
],
|
|
753
|
+
"chunks": [
|
|
754
|
+
{
|
|
755
|
+
"chunk_index": 0,
|
|
756
|
+
"identifier": "User#chunk_0",
|
|
757
|
+
"content": "# Unit: User (model)\n# File: app/models/user.rb\n# ---\nclass User < ApplicationRecord\n has_many :orders\n ...",
|
|
758
|
+
"content_hash": "abc123...",
|
|
759
|
+
"estimated_tokens": 312
|
|
760
|
+
}
|
|
761
|
+
],
|
|
762
|
+
"extracted_at": "2024-11-21T09:15:00Z",
|
|
763
|
+
"source_hash": "def456..."
|
|
764
|
+
}
|
|
765
|
+
```
|