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
|
@@ -28,8 +28,21 @@ module Woods
|
|
|
28
28
|
include SharedDependencyScanner
|
|
29
29
|
include RouteHelperResolver
|
|
30
30
|
|
|
31
|
+
# Matches everything between a `def foo_params` line and its
|
|
32
|
+
# params.require/permit or params.expect call, without crossing into
|
|
33
|
+
# a sibling method's body. Used by {#extract_permitted_params}.
|
|
34
|
+
PARAMS_METHOD_BODY = /(?:(?!\bdef\b)[\s\S])*?/
|
|
35
|
+
|
|
36
|
+
# @return [Array<String>] Warnings collected during extraction
|
|
37
|
+
# (concern-inlining fallbacks). Drained by the orchestrator, which
|
|
38
|
+
# collects warnings from every extractor that exposes them.
|
|
39
|
+
attr_reader :warnings
|
|
40
|
+
|
|
31
41
|
def initialize
|
|
32
42
|
@routes_map = build_routes_map
|
|
43
|
+
@concern_cache = {}
|
|
44
|
+
@concern_module_cache = {}
|
|
45
|
+
@warnings = []
|
|
33
46
|
build_route_helper_map
|
|
34
47
|
end
|
|
35
48
|
|
|
@@ -37,20 +50,56 @@ module Woods
|
|
|
37
50
|
#
|
|
38
51
|
# @return [Array<ExtractedUnit>] List of controller units
|
|
39
52
|
def extract_all
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
controllers = (controllers + ActionController::API.descendants).uniq if defined?(ActionController::API)
|
|
43
|
-
|
|
44
|
-
controllers.map do |controller|
|
|
53
|
+
discoverable_classes.map do |controller|
|
|
45
54
|
extract_controller(controller)
|
|
46
55
|
end.compact
|
|
47
56
|
end
|
|
48
57
|
|
|
58
|
+
# The controller classes this extractor would extract from the running
|
|
59
|
+
# app. Shared with the incremental path's class reconciliation (#164).
|
|
60
|
+
#
|
|
61
|
+
# Discovery walks +ActionController::Base.descendants+ and
|
|
62
|
+
# +ActionController::API.descendants+ — not
|
|
63
|
+
# +ApplicationController.descendants+, which excludes the receiver
|
|
64
|
+
# (Class#descendants never includes the class itself, so
|
|
65
|
+
# ApplicationController — usually the richest controller in the app —
|
|
66
|
+
# was never indexed) and misses controllers inheriting straight from
|
|
67
|
+
# +ActionController::Base+ (#200). Each base is guarded with
|
|
68
|
+
# +defined?+ so a host missing one, or both (no NameError on hosts
|
|
69
|
+
# without an ApplicationController constant), simply contributes
|
|
70
|
+
# nothing.
|
|
71
|
+
#
|
|
72
|
+
# Framework-internal descendants (Rails::InfoController,
|
|
73
|
+
# ActiveStorage controllers, engine controllers) share this ancestry
|
|
74
|
+
# but live in gem source; {#app_defined_controller?} keeps them out.
|
|
75
|
+
# That filter is shared with {#extract_controller} so this set stays
|
|
76
|
+
# exactly the set extract_controller accepts — it is the incremental
|
|
77
|
+
# reconciliation input, and any class listed here that
|
|
78
|
+
# extract_controller rejects would be recomputed as a phantom
|
|
79
|
+
# "addition" on every incremental run (same reasoning as
|
|
80
|
+
# ViewComponentExtractor's preview filtering).
|
|
81
|
+
#
|
|
82
|
+
# @return [Array<Class>]
|
|
83
|
+
def discoverable_classes
|
|
84
|
+
controllers = []
|
|
85
|
+
controllers.concat(ActionController::Base.descendants) if defined?(ActionController::Base)
|
|
86
|
+
controllers.concat(ActionController::API.descendants) if defined?(ActionController::API)
|
|
87
|
+
controllers.uniq.select { |controller| app_defined_controller?(controller) }
|
|
88
|
+
end
|
|
89
|
+
|
|
49
90
|
# Extract a single controller
|
|
50
91
|
#
|
|
92
|
+
# Rejects classes the extractor does not own — anonymous classes and
|
|
93
|
+
# framework-internal controllers — via {#app_defined_controller?},
|
|
94
|
+
# the same gate {#discoverable_classes} applies, so the two stay in
|
|
95
|
+
# agreement for incremental class reconciliation.
|
|
96
|
+
#
|
|
51
97
|
# @param controller [Class] The controller class
|
|
52
|
-
# @return [ExtractedUnit] The extracted unit
|
|
98
|
+
# @return [ExtractedUnit, nil] The extracted unit, or nil for classes
|
|
99
|
+
# the extractor rejects
|
|
53
100
|
def extract_controller(controller)
|
|
101
|
+
return nil unless app_defined_controller?(controller)
|
|
102
|
+
|
|
54
103
|
unit = ExtractedUnit.new(
|
|
55
104
|
type: :controller,
|
|
56
105
|
identifier: controller.name,
|
|
@@ -61,8 +110,9 @@ module Woods
|
|
|
61
110
|
source = source_path && File.exist?(source_path) ? File.read(source_path) : ''
|
|
62
111
|
|
|
63
112
|
unit.namespace = extract_namespace(controller)
|
|
64
|
-
|
|
65
|
-
unit.
|
|
113
|
+
inlined_source, inlined_concerns = build_controller_source_with_concerns(controller, source)
|
|
114
|
+
unit.source_code = build_composite_source(controller, inlined_source)
|
|
115
|
+
unit.metadata = extract_metadata(controller, source, inlined_concerns: inlined_concerns)
|
|
66
116
|
unit.dependencies = extract_dependencies(controller, source)
|
|
67
117
|
|
|
68
118
|
# Controllers benefit from per-action chunks
|
|
@@ -116,6 +166,32 @@ module Woods
|
|
|
116
166
|
# Source Building
|
|
117
167
|
# ──────────────────────────────────────────────────────────────────────
|
|
118
168
|
|
|
169
|
+
# Whether a controller class belongs to the host application.
|
|
170
|
+
#
|
|
171
|
+
# Discovery starts from the ActionController bases (#200), which
|
|
172
|
+
# framework controllers (Rails::InfoController, ActiveStorage's
|
|
173
|
+
# controllers, engine controllers) also descend from. App-defined
|
|
174
|
+
# means: the class is named, and it resolves to an existing source
|
|
175
|
+
# file that {SharedUtilityMethods#app_source?} accepts (under
|
|
176
|
+
# +Rails.root+, outside vendor/ and node_modules/). Framework classes
|
|
177
|
+
# resolve to gem paths, so {#source_file_for} falls back to a
|
|
178
|
+
# convention path that does not exist and they are rejected.
|
|
179
|
+
#
|
|
180
|
+
# Shared by {#discoverable_classes} and {#extract_controller}; the
|
|
181
|
+
# two must agree or the incremental class reconciliation recomputes
|
|
182
|
+
# the same phantom "additions" every run.
|
|
183
|
+
#
|
|
184
|
+
# @param controller [Class] Candidate controller class
|
|
185
|
+
# @return [Boolean]
|
|
186
|
+
def app_defined_controller?(controller)
|
|
187
|
+
return false if controller.name.nil?
|
|
188
|
+
|
|
189
|
+
path = source_file_for(controller)
|
|
190
|
+
return false unless path
|
|
191
|
+
|
|
192
|
+
File.exist?(path) && app_source?(path, Rails.root.to_s)
|
|
193
|
+
end
|
|
194
|
+
|
|
119
195
|
# Find the source file for a controller, validating paths are within Rails.root.
|
|
120
196
|
#
|
|
121
197
|
# Convention path first, then introspection via {#resolve_source_location}
|
|
@@ -130,15 +206,13 @@ module Woods
|
|
|
130
206
|
resolve_source_location(controller, app_root: Rails.root.to_s, fallback: convention_path)
|
|
131
207
|
end
|
|
132
208
|
|
|
133
|
-
# Build composite source with routes and filters as headers
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
end
|
|
141
|
-
|
|
209
|
+
# Build composite source with routes and filters as headers.
|
|
210
|
+
#
|
|
211
|
+
# The sole caller ({#extract_controller}) always passes the
|
|
212
|
+
# already concern-inlined source (see
|
|
213
|
+
# {#build_controller_source_with_concerns}), which returns '' rather
|
|
214
|
+
# than nil for a missing file — so this never sees a nil source.
|
|
215
|
+
def build_composite_source(controller, source)
|
|
142
216
|
# Prepend route information
|
|
143
217
|
routes_comment = build_routes_comment(controller)
|
|
144
218
|
|
|
@@ -207,12 +281,222 @@ module Woods
|
|
|
207
281
|
end
|
|
208
282
|
end
|
|
209
283
|
|
|
284
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
285
|
+
# Concern Detection & Inlining
|
|
286
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
# Modules included in the controller that are application-defined
|
|
289
|
+
# concerns.
|
|
290
|
+
#
|
|
291
|
+
# Detection is by membership, not name (#175): Rails does not
|
|
292
|
+
# namespace controller concerns — app/controllers/concerns/
|
|
293
|
+
# requires_author.rb defines top-level +RequiresAuthor+ — so the old
|
|
294
|
+
# name-substring check ('Concern'/'Concerns') never matched idiomatic
|
|
295
|
+
# controller concerns and +included_concerns+ came back empty while
|
|
296
|
+
# the concern's effects (filters) were captured.
|
|
297
|
+
#
|
|
298
|
+
# @param controller [Class] The controller class
|
|
299
|
+
# @return [Array<Module>] App-defined concern modules
|
|
300
|
+
def detect_included_concerns(controller)
|
|
301
|
+
controller.included_modules.select { |mod| app_concern_module?(mod) }
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Whether a module included in a controller is an application-defined
|
|
305
|
+
# concern.
|
|
306
|
+
#
|
|
307
|
+
# A module counts when it extends +ActiveSupport::Concern+ (the
|
|
308
|
+
# idiomatic case) OR its resolved source file sits under an
|
|
309
|
+
# app/**/concerns directory (a plain module mixed in from concerns/
|
|
310
|
+
# without the extend). Framework modules are excluded first: many gem
|
|
311
|
+
# modules extend +ActiveSupport::Concern+ too
|
|
312
|
+
# (ActionController::MimeResponds et al.), so any module whose source
|
|
313
|
+
# resolves outside the app ({SharedUtilityMethods#app_source?}) is
|
|
314
|
+
# rejected before either positive check runs. Verdicts are memoized
|
|
315
|
+
# per module name — ApplicationController's includes recur in every
|
|
316
|
+
# controller of the app.
|
|
317
|
+
#
|
|
318
|
+
# @param mod [Module] A module from the controller's included_modules
|
|
319
|
+
# @return [Boolean]
|
|
320
|
+
def app_concern_module?(mod)
|
|
321
|
+
return false unless mod.name
|
|
322
|
+
|
|
323
|
+
return @concern_module_cache[mod.name] if @concern_module_cache.key?(mod.name)
|
|
324
|
+
|
|
325
|
+
@concern_module_cache[mod.name] = compute_app_concern_module(mod)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Uncached concern verdict for a named module (see
|
|
329
|
+
# {#app_concern_module?} for the detection rules).
|
|
330
|
+
#
|
|
331
|
+
# @param mod [Module] A named module
|
|
332
|
+
# @return [Boolean]
|
|
333
|
+
def compute_app_concern_module(mod)
|
|
334
|
+
path = module_source_path(mod)
|
|
335
|
+
return false if path && !app_source?(path, Rails.root.to_s)
|
|
336
|
+
|
|
337
|
+
activesupport_concern?(mod) || concerns_directory_path?(path)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Resolve a module's defining source file via const_source_location.
|
|
341
|
+
#
|
|
342
|
+
# @param mod [Module] A named module
|
|
343
|
+
# @return [String, nil] Absolute path, or nil when unresolvable
|
|
344
|
+
def module_source_path(mod)
|
|
345
|
+
return nil unless Object.respond_to?(:const_source_location)
|
|
346
|
+
|
|
347
|
+
Object.const_source_location(mod.name)&.first
|
|
348
|
+
rescue StandardError
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Whether a module extends ActiveSupport::Concern (the idiomatic
|
|
353
|
+
# concern marker). Membership lives on the singleton class.
|
|
354
|
+
#
|
|
355
|
+
# @param mod [Module] A module to test
|
|
356
|
+
# @return [Boolean]
|
|
357
|
+
def activesupport_concern?(mod)
|
|
358
|
+
return false unless defined?(ActiveSupport::Concern)
|
|
359
|
+
|
|
360
|
+
mod.singleton_class.include?(ActiveSupport::Concern)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Whether a resolved source path sits under an app/**/concerns
|
|
364
|
+
# directory (e.g. app/controllers/concerns/, app/models/concerns/).
|
|
365
|
+
# Callers guarantee the path, when present, is app source.
|
|
366
|
+
#
|
|
367
|
+
# @param path [String, nil] Absolute path under Rails.root, or nil
|
|
368
|
+
# @return [Boolean]
|
|
369
|
+
def concerns_directory_path?(path)
|
|
370
|
+
return false unless path
|
|
371
|
+
|
|
372
|
+
relative = path.delete_prefix("#{Rails.root}/")
|
|
373
|
+
relative.match?(%r{\Aapp/(?:[^/]+/)*concerns/})
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Read controller source and inline all detected concerns, mirroring
|
|
377
|
+
# ModelExtractor's approach: concern code is inserted as '#'-prefixed
|
|
378
|
+
# comment lines right after the class declaration (nested or compact
|
|
379
|
+
# style), falling back to appending at end-of-source with a warning
|
|
380
|
+
# rather than silently dropping the block.
|
|
381
|
+
#
|
|
382
|
+
# An empty source (missing file) is returned untouched — inlining
|
|
383
|
+
# into nothing would fabricate source_code out of comments.
|
|
384
|
+
#
|
|
385
|
+
# @param controller [Class] The controller class
|
|
386
|
+
# @param source [String, nil] Pre-read controller source (read from
|
|
387
|
+
# disk when nil)
|
|
388
|
+
# @return [Array(String, Array<String>)] The concern-inlined source
|
|
389
|
+
# and the demodulized names of the concerns actually inlined into
|
|
390
|
+
# it. Returning the pair keeps metadata[:inlined_concerns] truthful
|
|
391
|
+
# — it is derived from the insertion result, never recomputed
|
|
392
|
+
# independently of what the composite source carries.
|
|
393
|
+
def build_controller_source_with_concerns(controller, source = nil)
|
|
394
|
+
if source.nil?
|
|
395
|
+
source_path = source_file_for(controller)
|
|
396
|
+
return ['', []] unless source_path && File.exist?(source_path)
|
|
397
|
+
|
|
398
|
+
source = File.read(source_path)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
return [source, []] if source.empty?
|
|
402
|
+
|
|
403
|
+
concern_sources = resolved_concern_sources(controller)
|
|
404
|
+
return [source, []] if concern_sources.empty?
|
|
405
|
+
|
|
406
|
+
[insert_concern_block(controller, source, build_concern_block(concern_sources)),
|
|
407
|
+
concern_sources.map { |name, _code| name.demodulize }]
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Resolve [name, code] pairs for every detected concern whose source
|
|
411
|
+
# file can be located.
|
|
412
|
+
#
|
|
413
|
+
# @param controller [Class] The controller class
|
|
414
|
+
# @return [Array<Array(String, String)>]
|
|
415
|
+
def resolved_concern_sources(controller)
|
|
416
|
+
detect_included_concerns(controller).filter_map { |mod| concern_source(mod) }
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Get the source code for a concern, with caching. Resolution reuses
|
|
420
|
+
# {#module_source_path} — the same location detection validated — so
|
|
421
|
+
# a concern detected purely by ActiveSupport::Concern membership with
|
|
422
|
+
# no resolvable file keeps its edge and metadata entry but is not
|
|
423
|
+
# inlined.
|
|
424
|
+
#
|
|
425
|
+
# @param mod [Module] A detected concern module
|
|
426
|
+
# @return [Array(String, String), nil] [name, code] or nil
|
|
427
|
+
def concern_source(mod)
|
|
428
|
+
return @concern_cache[mod.name] if @concern_cache.key?(mod.name)
|
|
429
|
+
|
|
430
|
+
path = module_source_path(mod)
|
|
431
|
+
return nil unless path && File.exist?(path)
|
|
432
|
+
|
|
433
|
+
@concern_cache[mod.name] = [mod.name, File.read(path)]
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Render concern sources as a '#'-commented display block showing
|
|
437
|
+
# what's mixed in (same display form as ModelExtractor).
|
|
438
|
+
#
|
|
439
|
+
# @param concern_sources [Array<Array(String, String)>] [name, code] pairs
|
|
440
|
+
# @return [String]
|
|
441
|
+
def build_concern_block(concern_sources)
|
|
442
|
+
concern_sources.map do |name, code|
|
|
443
|
+
indented = code.lines.map { |l| " # #{l.rstrip}" }.join("\n")
|
|
444
|
+
<<~CONCERN
|
|
445
|
+
# ┌─────────────────────────────────────────────────────────────────────┐
|
|
446
|
+
# │ Included from: #{name.ljust(54)}│
|
|
447
|
+
# └─────────────────────────────────────────────────────────────────────┘
|
|
448
|
+
#{indented}
|
|
449
|
+
# ─────────────────────────── End #{name} ───────────────────────────
|
|
450
|
+
CONCERN
|
|
451
|
+
end.join("\n\n")
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Insert the concern block after the controller's class declaration
|
|
455
|
+
# line. Falls back to appending at end-of-source (recording a
|
|
456
|
+
# warning) when no declaration matches — dropping the block silently
|
|
457
|
+
# would leave source_code contradicting metadata[:inlined_concerns].
|
|
458
|
+
#
|
|
459
|
+
# @param controller [Class] The controller class
|
|
460
|
+
# @param source [String] The controller source
|
|
461
|
+
# @param concern_block [String] Commented concern block to insert
|
|
462
|
+
# @return [String]
|
|
463
|
+
def insert_concern_block(controller, source, concern_block)
|
|
464
|
+
pattern = class_declaration_pattern(controller)
|
|
465
|
+
return source.sub(pattern) { "#{::Regexp.last_match(1)}\n\n#{concern_block}" } if source.match?(pattern)
|
|
466
|
+
|
|
467
|
+
@warnings << "[#{controller.name}] No class declaration matched for concern inlining; " \
|
|
468
|
+
'appending inlined concern block at end of source'
|
|
469
|
+
"#{source.chomp}\n\n#{concern_block}"
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Regexp matching the controller's class declaration in either nested
|
|
473
|
+
# (+class PostsController+) or compact
|
|
474
|
+
# (+class Admin::PostsController+) style. The word boundary stops
|
|
475
|
+
# +class PostsControllerError+ from claiming the block; the +(?!::)+
|
|
476
|
+
# lookahead stops a nested +class PostsController::Error+ from
|
|
477
|
+
# claiming it either.
|
|
478
|
+
#
|
|
479
|
+
# @param controller [Class] The controller class
|
|
480
|
+
# @return [Regexp]
|
|
481
|
+
def class_declaration_pattern(controller)
|
|
482
|
+
/(class\s+(?:[\w:]+::)?#{Regexp.escape(controller.name.demodulize)}\b(?!::).*$)/
|
|
483
|
+
end
|
|
484
|
+
|
|
210
485
|
# ──────────────────────────────────────────────────────────────────────
|
|
211
486
|
# Metadata Extraction
|
|
212
487
|
# ──────────────────────────────────────────────────────────────────────
|
|
213
488
|
|
|
214
489
|
# Extract comprehensive metadata
|
|
215
|
-
|
|
490
|
+
#
|
|
491
|
+
# @param controller [Class] The controller class
|
|
492
|
+
# @param source [String, nil] The raw controller source code
|
|
493
|
+
# @param inlined_concerns [Array<String>, nil] Demodulized names of
|
|
494
|
+
# the concerns actually inlined into the unit's source_code (from
|
|
495
|
+
# {#build_controller_source_with_concerns}). When nil, derived by
|
|
496
|
+
# running the same inlining — the metadata must never claim a
|
|
497
|
+
# concern the composite source does not carry.
|
|
498
|
+
# @return [Hash]
|
|
499
|
+
def extract_metadata(controller, source = nil, inlined_concerns: nil)
|
|
216
500
|
own_methods = controller.instance_methods(false).to_set(&:to_s)
|
|
217
501
|
actions = controller.action_methods.select { |m| own_methods.include?(m) }.to_a
|
|
218
502
|
|
|
@@ -231,9 +515,12 @@ module Woods
|
|
|
231
515
|
.map(&:name)
|
|
232
516
|
.compact,
|
|
233
517
|
|
|
234
|
-
# Concerns included
|
|
518
|
+
# Concerns included (detected by membership, not name — #175)
|
|
235
519
|
included_concerns: extract_included_concerns(controller),
|
|
236
520
|
|
|
521
|
+
# Concerns actually inlined into source_code (demodulized)
|
|
522
|
+
inlined_concerns: inlined_concerns || build_controller_source_with_concerns(controller, source).last,
|
|
523
|
+
|
|
237
524
|
# Response formats
|
|
238
525
|
responds_to: extract_respond_formats(controller, source),
|
|
239
526
|
|
|
@@ -246,10 +533,13 @@ module Woods
|
|
|
246
533
|
}
|
|
247
534
|
end
|
|
248
535
|
|
|
536
|
+
# Names of the app-defined concerns included in the controller.
|
|
537
|
+
# Detection lives in {#detect_included_concerns} (#175).
|
|
538
|
+
#
|
|
539
|
+
# @param controller [Class] The controller class
|
|
540
|
+
# @return [Array<String>] Full module names
|
|
249
541
|
def extract_included_concerns(controller)
|
|
250
|
-
controller.
|
|
251
|
-
.select { |m| m.name&.include?('Concern') || m.name&.include?('Concerns') }
|
|
252
|
-
.map(&:name)
|
|
542
|
+
detect_included_concerns(controller).map(&:name)
|
|
253
543
|
end
|
|
254
544
|
|
|
255
545
|
def extract_respond_formats(controller, source = nil)
|
|
@@ -270,6 +560,18 @@ module Woods
|
|
|
270
560
|
formats.uniq
|
|
271
561
|
end
|
|
272
562
|
|
|
563
|
+
# Strong-parameter declarations, keyed by the `*_params` method name.
|
|
564
|
+
#
|
|
565
|
+
# Each entry is `{ model:, permitted: }`. `permitted` is the list of
|
|
566
|
+
# keys named at the TOP level of the `permit`/`expect` call — scalar
|
|
567
|
+
# symbols and hash keys alike — and never the members of a nested list
|
|
568
|
+
# or hash: `permit(:title, tags: [], meta: {seo: [:keyword]})` yields
|
|
569
|
+
# `%w[title tags meta]`. See {#permitted_keys}.
|
|
570
|
+
#
|
|
571
|
+
# @param controller [Class, nil] Controller class (used only to locate
|
|
572
|
+
# the source when +source+ is not supplied)
|
|
573
|
+
# @param source [String, nil] Controller source code
|
|
574
|
+
# @return [Hash{String => Hash}] `{ method_name => { model:, permitted: } }`
|
|
273
575
|
def extract_permitted_params(controller, source = nil)
|
|
274
576
|
if source.nil?
|
|
275
577
|
source_path = source_file_for(controller)
|
|
@@ -280,23 +582,81 @@ module Woods
|
|
|
280
582
|
|
|
281
583
|
params = {}
|
|
282
584
|
|
|
283
|
-
# Match params.require(:x).permit(...) patterns
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
585
|
+
# Match params.require(:x).permit(...) patterns. The body segment
|
|
586
|
+
# between the method name and the require/permit call is bounded by
|
|
587
|
+
# a negative lookahead on `def` so a method with no permit call
|
|
588
|
+
# (e.g. `filter_params; params.fetch(:f); end`) never lets the scan
|
|
589
|
+
# run into the *next* method's body and misattribute its params.
|
|
590
|
+
# The /m flag plus a [\s\S] capture let the permit list cross
|
|
591
|
+
# newlines — the common style in large controllers (M2); without
|
|
592
|
+
# them `permit(` followed by a newline matched nothing and
|
|
593
|
+
# permitted_params came back empty. The `\s*` around each chain joint
|
|
594
|
+
# covers the fluent style the M2 fix missed (EXTA-5): the list could
|
|
595
|
+
# cross newlines but the call chain itself still could not, so
|
|
596
|
+
# `params.require(:post)\n .permit(...)` matched nothing.
|
|
597
|
+
source.scan(
|
|
598
|
+
/def\s+(\w+_params)\b#{PARAMS_METHOD_BODY}
|
|
599
|
+
params\s*\.\s*require\(:(\w+)\)\s*\.\s*permit\(([\s\S]*?)\)/xm
|
|
600
|
+
) do |method, model, permitted|
|
|
601
|
+
params[method] = { model: model, permitted: permitted_keys(permitted) }
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# Rails 8's params.expect(post: [:title, :body]) replacement for
|
|
605
|
+
# require(...).permit(...). Same multi-line capture as above (M2).
|
|
606
|
+
source.scan(
|
|
607
|
+
/def\s+(\w+_params)\b#{PARAMS_METHOD_BODY}
|
|
608
|
+
params\s*\.\s*expect\(\s*(\w+):\s*\[([\s\S]*?)\]\s*\)/xm
|
|
609
|
+
) do |method, model, permitted|
|
|
610
|
+
params[method] ||= { model: model, permitted: permitted_keys(permitted) }
|
|
289
611
|
end
|
|
290
612
|
|
|
291
613
|
params
|
|
292
614
|
end
|
|
293
615
|
|
|
616
|
+
# The top-level keys of a permit/expect argument list.
|
|
617
|
+
#
|
|
618
|
+
# The contract is deliberately flat and shallow: every key the caller
|
|
619
|
+
# names at the top level, whether declared as a scalar symbol
|
|
620
|
+
# (`:title`) or as a hash key (`tags: []`, `meta: {…}`), and nothing
|
|
621
|
+
# from inside a nested list or hash. Scanning for `:(\w+)` alone
|
|
622
|
+
# produced neither contract — hash keys, which is how every array and
|
|
623
|
+
# nested param is declared, were dropped while their nested leaves
|
|
624
|
+
# leaked in flat, so `permit(:title, tags: [], meta: {seo: [:keyword]})`
|
|
625
|
+
# reported `title, keyword` (EXTA-12).
|
|
626
|
+
#
|
|
627
|
+
# @param list [String] Raw text between `permit(`/`expect(…[` and its close
|
|
628
|
+
# @return [Array<String>] Top-level key names, in source order
|
|
629
|
+
def permitted_keys(list)
|
|
630
|
+
top_level_arguments(list).scan(/:(\w+)|(\w+):/).map { |symbol, key| symbol || key }.uniq
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Drop everything inside nested `[...]` / `{...}` groups, leaving the
|
|
634
|
+
# argument list's own tokens.
|
|
635
|
+
#
|
|
636
|
+
# @param list [String]
|
|
637
|
+
# @return [String]
|
|
638
|
+
def top_level_arguments(list)
|
|
639
|
+
depth = 0
|
|
640
|
+
list.each_char.with_object(+'') do |char, kept|
|
|
641
|
+
case char
|
|
642
|
+
when '[', '{' then depth += 1
|
|
643
|
+
when ']', '}' then depth -= 1 if depth.positive?
|
|
644
|
+
else kept << char if depth.zero?
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
294
649
|
# ──────────────────────────────────────────────────────────────────────
|
|
295
650
|
# Dependency Extraction
|
|
296
651
|
# ──────────────────────────────────────────────────────────────────────
|
|
297
652
|
|
|
298
653
|
def extract_dependencies(controller, source = nil)
|
|
299
|
-
|
|
654
|
+
# Included concerns add per-request behavior (filters, helpers).
|
|
655
|
+
# Same edge shape as ModelExtractor's concern edges so graph
|
|
656
|
+
# consumers see one format (#175).
|
|
657
|
+
deps = detect_included_concerns(controller).map do |mod|
|
|
658
|
+
{ type: :concern, target: mod.name, via: :include }
|
|
659
|
+
end
|
|
300
660
|
|
|
301
661
|
if source.nil?
|
|
302
662
|
source_path = source_file_for(controller)
|
|
@@ -97,23 +97,16 @@ module Woods
|
|
|
97
97
|
# @param source [String] Ruby source code
|
|
98
98
|
# @return [String, nil] The class name or nil
|
|
99
99
|
def extract_class_name(file_path, source)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
"#{
|
|
107
|
-
else
|
|
108
|
-
base_class
|
|
109
|
-
end
|
|
110
|
-
else
|
|
111
|
-
relative = file_path.sub("#{Rails.root}/", '')
|
|
112
|
-
relative
|
|
100
|
+
# Zeitwerk-governed naming (G-1) first, then position-aware
|
|
101
|
+
# (SourceNesting, #174): only modules still open at the class
|
|
102
|
+
# declaration qualify it. A sibling module that closed earlier, or a
|
|
103
|
+
# helper module nested inside the class, does not.
|
|
104
|
+
governed_class_name(file_path, source) || qualified_first_class_name(source) ||
|
|
105
|
+
file_path
|
|
106
|
+
.sub("#{Rails.root}/", '')
|
|
113
107
|
.sub(%r{^app/(decorators|presenters|form_objects)/}, '')
|
|
114
108
|
.sub('.rb', '')
|
|
115
109
|
.camelize
|
|
116
|
-
end
|
|
117
110
|
end
|
|
118
111
|
|
|
119
112
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -46,16 +46,40 @@ module Woods
|
|
|
46
46
|
Rails.application.respond_to?(:routes)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
# Retrieve Engine subclasses
|
|
50
|
-
#
|
|
49
|
+
# Retrieve Engine subclasses.
|
|
50
|
+
#
|
|
51
|
+
# `Class#subclasses` is available on every supported Rails version
|
|
52
|
+
# (native since Ruby 3.1, backported by ActiveSupport's
|
|
53
|
+
# `core_ext/class/subclasses` before that, which every booted Rails
|
|
54
|
+
# app loads) — the ObjectSpace fallback below is defensive for the
|
|
55
|
+
# unlikely case that core_ext hasn't loaded, not a Ruby-version gap.
|
|
56
|
+
#
|
|
57
|
+
# Excludes {#host_application_class?} — Rails::Application is itself a
|
|
58
|
+
# Rails::Engine subclass (and the ObjectSpace fallback also reaches the
|
|
59
|
+
# host app's own concrete Application class), neither of which is an
|
|
60
|
+
# installed engine.
|
|
51
61
|
#
|
|
52
62
|
# @return [Array<Class>]
|
|
53
63
|
def engine_subclasses
|
|
54
|
-
if Rails::Engine.respond_to?(:subclasses)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
classes = if Rails::Engine.respond_to?(:subclasses)
|
|
65
|
+
Rails::Engine.subclasses
|
|
66
|
+
else
|
|
67
|
+
ObjectSpace.each_object(Class).select { |klass| klass < Rails::Engine }
|
|
68
|
+
end
|
|
69
|
+
classes.reject { |klass| host_application_class?(klass) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether a class is Rails::Application itself, or the host app's own
|
|
73
|
+
# concrete subclass of it (e.g. MyApp::Application). Both descend from
|
|
74
|
+
# Rails::Engine but represent the host app, not an installed engine —
|
|
75
|
+
# including them produced a phantom "engine" unit for the app itself.
|
|
76
|
+
#
|
|
77
|
+
# @param klass [Class]
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
def host_application_class?(klass)
|
|
80
|
+
return false unless klass.is_a?(Module)
|
|
81
|
+
|
|
82
|
+
defined?(Rails::Application) && klass <= Rails::Application
|
|
59
83
|
end
|
|
60
84
|
|
|
61
85
|
# Build a mapping from engine class to mounted path by scanning app routes.
|
|
@@ -64,7 +88,7 @@ module Woods
|
|
|
64
88
|
def build_mount_map
|
|
65
89
|
map = {}
|
|
66
90
|
Rails.application.routes.routes.each do |route|
|
|
67
|
-
app = route.app
|
|
91
|
+
app = unwrap_mounted_app(route.app)
|
|
68
92
|
next unless engine_class?(app)
|
|
69
93
|
|
|
70
94
|
path = extract_mount_path(route)
|
|
@@ -77,6 +101,27 @@ module Woods
|
|
|
77
101
|
{}
|
|
78
102
|
end
|
|
79
103
|
|
|
104
|
+
# Unwrap a mounted route's app down to the actual engine class.
|
|
105
|
+
#
|
|
106
|
+
# For a mounted engine, `route.app` is an
|
|
107
|
+
# `ActionDispatch::Routing::Mapper::Constraints` wrapper, not the
|
|
108
|
+
# engine itself — the engine is `route.app.app`. {#engine_class?}
|
|
109
|
+
# never matched the wrapper, so `mounted_path` was always nil in a
|
|
110
|
+
# live app. Bounded to a handful of unwraps rather than an open-ended
|
|
111
|
+
# loop, since a route app is never nested more than a level or two
|
|
112
|
+
# deep in practice.
|
|
113
|
+
#
|
|
114
|
+
# @param app [Object] The route's `.app` value
|
|
115
|
+
# @return [Object] The unwrapped app, or the original value unchanged
|
|
116
|
+
def unwrap_mounted_app(app)
|
|
117
|
+
5.times do
|
|
118
|
+
break if engine_class?(app) || !app.respond_to?(:app)
|
|
119
|
+
|
|
120
|
+
app = app.app
|
|
121
|
+
end
|
|
122
|
+
app
|
|
123
|
+
end
|
|
124
|
+
|
|
80
125
|
# Check if an object is a Rails::Engine subclass.
|
|
81
126
|
#
|
|
82
127
|
# Uses duck-typing: checks for engine_name method which is defined on all
|