woods 1.6.1 → 2.0.0.beta2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -0,0 +1,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
+ ```