rails-ai-context 5.32.2 → 5.34.0
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 +1491 -0
- data/CONTEXT.md +27 -3
- data/README.md +16 -12
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +7 -5
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +75 -1
- data/docs/CONFIGURATION.md +30 -11
- data/docs/CUSTOM_TOOLS.md +2 -1
- data/docs/FAQ.md +16 -6
- data/docs/GUIDE.md +117 -71
- data/docs/INTROSPECTORS.md +10 -8
- data/docs/QUICKSTART.md +3 -3
- data/docs/RECIPES.md +1 -1
- data/docs/SECURITY.md +109 -25
- data/docs/SETUP.md +30 -6
- data/docs/STANDALONE.md +10 -3
- data/docs/TOOLS.md +152 -34
- data/docs/TROUBLESHOOTING.md +90 -12
- data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
- data/docs/adr/0005-workspace-mcp-entries.md +39 -0
- data/exe/rails-ai-context +733 -104
- data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
- data/lib/rails_ai_context/app_kind.rb +18 -5
- data/lib/rails_ai_context/ast_cache.rb +7 -0
- data/lib/rails_ai_context/base_mixins.rb +8 -8
- data/lib/rails_ai_context/boot_manager.rb +22 -3
- data/lib/rails_ai_context/change_watch.rb +14 -11
- data/lib/rails_ai_context/cli/app_root.rb +401 -0
- data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
- data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
- data/lib/rails_ai_context/code_reloader.rb +115 -1
- data/lib/rails_ai_context/concern_membership.rb +3 -1
- data/lib/rails_ai_context/concern_paths.rb +8 -3
- data/lib/rails_ai_context/configuration.rb +41 -9
- data/lib/rails_ai_context/context_file_report.rb +19 -2
- data/lib/rails_ai_context/database_file.rb +26 -0
- data/lib/rails_ai_context/doctor.rb +1122 -184
- data/lib/rails_ai_context/engine.rb +18 -1
- data/lib/rails_ai_context/erb_source.rb +19 -0
- data/lib/rails_ai_context/executable.rb +21 -0
- data/lib/rails_ai_context/facts_formatter.rb +5 -2
- data/lib/rails_ai_context/file_walk.rb +61 -10
- data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
- data/lib/rails_ai_context/fingerprinter.rb +40 -12
- data/lib/rails_ai_context/gem_lock.rb +186 -27
- data/lib/rails_ai_context/git_ignore.rb +22 -0
- data/lib/rails_ai_context/install/ai_tool.rb +31 -5
- data/lib/rails_ai_context/install/cleanup.rb +21 -0
- data/lib/rails_ai_context/install/program.rb +209 -35
- data/lib/rails_ai_context/install/selection_record.rb +258 -97
- data/lib/rails_ai_context/install/surface.rb +6 -1
- data/lib/rails_ai_context/install/validation_hook.rb +282 -0
- data/lib/rails_ai_context/install/workspace.rb +166 -0
- data/lib/rails_ai_context/install_mode.rb +71 -13
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
- data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
- data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
- data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
- data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
- data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
- data/lib/rails_ai_context/live_reload.rb +20 -20
- data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
- data/lib/rails_ai_context/mcp_edge.rb +182 -32
- data/lib/rails_ai_context/middleware.rb +5 -1
- data/lib/rails_ai_context/migration_status.rb +88 -25
- data/lib/rails_ai_context/output_guard.rb +48 -2
- data/lib/rails_ai_context/package_json.rb +1 -1
- data/lib/rails_ai_context/path_resolver.rb +46 -13
- data/lib/rails_ai_context/payload.rb +92 -8
- data/lib/rails_ai_context/pending_migrations.rb +5 -1
- data/lib/rails_ai_context/portable_path.rb +3 -2
- data/lib/rails_ai_context/presets.rb +15 -11
- data/lib/rails_ai_context/redaction.rb +262 -51
- data/lib/rails_ai_context/resources.rb +55 -20
- data/lib/rails_ai_context/route_coverage.rb +8 -0
- data/lib/rails_ai_context/safe_file.rb +14 -1
- data/lib/rails_ai_context/safe_path.rb +101 -4
- data/lib/rails_ai_context/schema_adapter.rb +8 -0
- data/lib/rails_ai_context/schema_version.rb +9 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
- data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
- data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
- data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
- data/lib/rails_ai_context/server.rb +304 -71
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
- data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
- data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
- data/lib/rails_ai_context/tools/base_tool.rb +367 -46
- data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
- data/lib/rails_ai_context/tools/diagnose.rb +218 -11
- data/lib/rails_ai_context/tools/generate_test.rb +339 -39
- data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
- data/lib/rails_ai_context/tools/get_concern.rb +91 -27
- data/lib/rails_ai_context/tools/get_config.rb +13 -4
- data/lib/rails_ai_context/tools/get_context.rb +71 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
- data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
- data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
- data/lib/rails_ai_context/tools/get_env.rb +18 -9
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
- data/lib/rails_ai_context/tools/get_gems.rb +1 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +29 -8
- data/lib/rails_ai_context/tools/get_schema.rb +52 -19
- data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
- data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
- data/lib/rails_ai_context/tools/get_view.rb +14 -14
- data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
- data/lib/rails_ai_context/tools/onboard.rb +52 -8
- data/lib/rails_ai_context/tools/performance_check.rb +5 -3
- data/lib/rails_ai_context/tools/query.rb +914 -86
- data/lib/rails_ai_context/tools/read_logs.rb +69 -15
- data/lib/rails_ai_context/tools/review_changes.rb +215 -53
- data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
- data/lib/rails_ai_context/tools/safe_call.rb +35 -14
- data/lib/rails_ai_context/tools/search_code.rb +243 -67
- data/lib/rails_ai_context/tools/search_docs.rb +4 -4
- data/lib/rails_ai_context/tools/security_scan.rb +143 -33
- data/lib/rails_ai_context/tools/session_context.rb +39 -5
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +67 -40
- data/lib/rails_ai_context/view_file.rb +41 -3
- data/lib/rails_ai_context/watcher.rb +51 -13
- data/lib/rails_ai_context.rb +21 -0
- data/server.json +1 -1
- metadata +14 -1
|
@@ -6,10 +6,11 @@ require "json"
|
|
|
6
6
|
module RailsAiContext
|
|
7
7
|
# What the three HTTP entry points - the standalone rack app, the Rack
|
|
8
8
|
# middleware and the engine controller - have in common: how a transport is
|
|
9
|
-
# built,
|
|
10
|
-
#
|
|
11
|
-
# them.
|
|
9
|
+
# built, which conversation a request belongs to, and what a transport
|
|
10
|
+
# failure looks like on the wire. Their genuine differences (streaming,
|
|
11
|
+
# header handling, memoization lifetime) stay with them.
|
|
12
12
|
module McpEdge
|
|
13
|
+
INVALID_REQUEST = -32600
|
|
13
14
|
INTERNAL_ERROR = -32603
|
|
14
15
|
|
|
15
16
|
class << self
|
|
@@ -17,11 +18,15 @@ module RailsAiContext
|
|
|
17
18
|
# into a JSON-RPC reply, so each has to answer in JSON-RPC shape itself
|
|
18
19
|
# or leave the client's request loop hanging on a non-JSON body.
|
|
19
20
|
def internal_error_frame(error)
|
|
20
|
-
{
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
error_frame(INTERNAL_ERROR, "Internal error: #{error.message}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# JSON.generate rather than to_json: ActiveSupport replaces to_json,
|
|
25
|
+
# and in an app whose encoder raises (Rails 8.0 under json 3: "unknown
|
|
26
|
+
# keyword: quirks_mode") the frame that should name the failure failed
|
|
27
|
+
# with it, so the client got the web server's plain-text 500.
|
|
28
|
+
def error_frame(code, message)
|
|
29
|
+
JSON.generate({ jsonrpc: "2.0", error: { code: code, message: message }, id: nil })
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
# The Rack-shaped answer, for the two entry points that return triples.
|
|
@@ -31,35 +36,180 @@ module RailsAiContext
|
|
|
31
36
|
[ 500, { "Content-Type" => "application/json" }, [ internal_error_frame(error) ] ]
|
|
32
37
|
end
|
|
33
38
|
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
# middleware and the standalone server used to state these ten lines
|
|
37
|
-
# separately, and only the non-match branch differs, so the caller
|
|
38
|
-
# supplies it as the block. The block runs outside the rescue: an
|
|
39
|
-
# exception from the app behind the middleware is the app's to handle,
|
|
40
|
-
# not an MCP error frame.
|
|
41
|
-
def rack_call(env, transport:)
|
|
39
|
+
# Whether a request is for the MCP endpoint, trailing slash included.
|
|
40
|
+
def mcp_request?(env)
|
|
42
41
|
path = RailsAiContext.configuration.http_path
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
42
|
+
env["PATH_INFO"] == path || env["PATH_INFO"] == "#{path}/"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# One MCP request, Rack-shaped: session scoping, dispatch and error
|
|
46
|
+
# containment. Only a transport failure becomes an MCP error frame; the
|
|
47
|
+
# middleware calls the app behind it outside this, so the app's own
|
|
48
|
+
# exceptions stay the app's to handle.
|
|
49
|
+
def serve(env, transport)
|
|
50
|
+
request = Rack::Request.new(env)
|
|
51
|
+
session = Tools::BaseTool.session_from(env)
|
|
52
|
+
status, headers, body = Tools::BaseTool.with_session(session) { transport.handle_request(request) }
|
|
53
|
+
[ status, headers, session_scoped(body, session) ]
|
|
54
|
+
rescue => e
|
|
55
|
+
internal_error_response(e)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The SDK answers a request inside a session with a streaming body, and
|
|
59
|
+
# the tool runs when the web server calls that body - after `serve` has
|
|
60
|
+
# returned and the scope around handle_request is gone. Every client's
|
|
61
|
+
# calls then landed in the one default record, so the scope travels
|
|
62
|
+
# with the body.
|
|
63
|
+
def session_scoped(body, session)
|
|
64
|
+
return body unless body.respond_to?(:call) && !body.respond_to?(:each)
|
|
65
|
+
|
|
66
|
+
->(stream) { Tools::BaseTool.with_session(session) { body.call(stream) } }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The middleware and the engine ride the app's own web server, which in
|
|
70
|
+
# a deployed environment is the one the network reaches, and every tool
|
|
71
|
+
# answers whoever asks. Serving there is the app's decision, so without
|
|
72
|
+
# it the answer is a refusal that says how to make it. Deployed is any
|
|
73
|
+
# environment but development and test: a staging app is reached the
|
|
74
|
+
# way production is.
|
|
75
|
+
def production_refusal
|
|
76
|
+
config = RailsAiContext.configuration
|
|
77
|
+
env = RailsAiContext.environment_name
|
|
78
|
+
return nil if config.allow_http_in_production || LOCAL_ENVIRONMENTS.include?(env)
|
|
79
|
+
|
|
80
|
+
log_production_refusal(env)
|
|
81
|
+
[ 403, { "Content-Type" => "application/json" }, [ error_frame(INVALID_REQUEST, production_refusal_message(env)) ] ]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def production_refusal_message(env)
|
|
85
|
+
"rails-ai-context does not serve MCP over HTTP in the #{env} environment, where every tool " \
|
|
86
|
+
"would answer whoever can reach this app. To serve it here, put the endpoint behind your app's " \
|
|
87
|
+
"authentication and set config.allow_http_in_production = true."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The engine's transport, one per process, so every session it opens is
|
|
91
|
+
# found again on the next request, whatever the app reloads in between.
|
|
92
|
+
def engine_transport
|
|
93
|
+
ENGINE_TRANSPORT_LOCK.synchronize { @engine_transport ||= build_transport(answered_whole: true) }
|
|
53
94
|
end
|
|
54
95
|
|
|
55
|
-
#
|
|
56
|
-
# and
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
96
|
+
# Drops the engine's transport and closes it, which ends its sessions
|
|
97
|
+
# and stops its reaper; the next request builds a fresh one.
|
|
98
|
+
def reset_engine_transport!
|
|
99
|
+
transport = ENGINE_TRANSPORT_LOCK.synchronize { @engine_transport.tap { @engine_transport = nil } }
|
|
100
|
+
transport.close if transport.respond_to?(:close)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Memoization is the caller's: the middleware holds one per instance,
|
|
104
|
+
# and the engine one per process (engine_transport). The standalone
|
|
105
|
+
# server builds its own transport because start_http also needs the
|
|
106
|
+
# underlying server for the banner and live reload.
|
|
107
|
+
#
|
|
108
|
+
# The middleware and the engine never start live reload; their tool
|
|
109
|
+
# calls check the app's files themselves, as every server's do.
|
|
110
|
+
def build_transport(app = nil, answered_whole: false)
|
|
60
111
|
app ||= Rails.application
|
|
61
|
-
|
|
112
|
+
options = host_check_options
|
|
113
|
+
options = options.merge(answered_whole_options) if answered_whole
|
|
114
|
+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(Server.new(app, transport: :http).build, **options)
|
|
115
|
+
transport.extend(AnsweredWhole) if answered_whole
|
|
116
|
+
CodeReloader.track_loaded_code!
|
|
117
|
+
Tools::BaseTool.check_files_per_call!(app)
|
|
118
|
+
transport
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The SDK answers only the Host 127.0.0.1, ::1 or localhost, and its
|
|
122
|
+
# allowed_hosts takes exact names only: none of config.hosts' domain,
|
|
123
|
+
# pattern and address forms. These transports ride the app's own server,
|
|
124
|
+
# so its config.hosts decides instead (host_refusal), and the SDK's
|
|
125
|
+
# check is turned off where the SDK lets it be.
|
|
126
|
+
def host_check_options
|
|
127
|
+
return {} unless defined?(ActionDispatch::HostAuthorization)
|
|
128
|
+
|
|
129
|
+
transport_option?(:dns_rebinding_protection) ? { dns_rebinding_protection: false } : {}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The engine answers each request whole (McpController), so it cannot
|
|
133
|
+
# keep open the stream `subscriptions/listen` delivers notifications
|
|
134
|
+
# on: the answer would be the acknowledgement alone, and the client,
|
|
135
|
+
# told it was subscribed, would hear nothing more. Told not to serve
|
|
136
|
+
# it, the SDK answers the method as one it does not have; an SDK older
|
|
137
|
+
# than the method has nothing to turn off.
|
|
138
|
+
def answered_whole_options
|
|
139
|
+
transport_option?(:serve_subscriptions_listen) ? { serve_subscriptions_listen: false } : {}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The Host and Origin checks the SDK made, made by the app's rules: the
|
|
143
|
+
# Host as Rails' HostAuthorization judges it against config.hosts, its
|
|
144
|
+
# exclusions included, so the endpoint answers every name the app does
|
|
145
|
+
# (`myapp.localhost` in development, the app's own domain in
|
|
146
|
+
# production) and refuses the rest, a rebinding attacker's domain
|
|
147
|
+
# among them. A browser's request from another origin is refused, as
|
|
148
|
+
# the SDK refuses it.
|
|
149
|
+
def host_refusal(env, app = Rails.application)
|
|
150
|
+
message = if !host_allowed?(env, app) then "Forbidden: Invalid Host header"
|
|
151
|
+
elsif env["HTTP_ORIGIN"] && !same_origin?(env["HTTP_ORIGIN"], env["HTTP_HOST"]) then "Forbidden: Invalid Origin header"
|
|
152
|
+
end
|
|
153
|
+
message && [ 403, { "Content-Type" => "application/json" }, [ error_frame(INVALID_REQUEST, message) ] ]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def host_allowed?(env, app)
|
|
159
|
+
config = app.config
|
|
160
|
+
exclude = config.respond_to?(:host_authorization) && config.host_authorization.is_a?(Hash) ? config.host_authorization[:exclude] : nil
|
|
161
|
+
judge = ActionDispatch::HostAuthorization.new(->(_env) { :allowed }, config.hosts, exclude: exclude, response_app: ->(_env) { :blocked })
|
|
162
|
+
judge.call(env.dup) == :allowed
|
|
163
|
+
rescue StandardError
|
|
164
|
+
# Rails' own rules unreadable: the SDK's, a loopback name only.
|
|
165
|
+
LOOPBACK_HOSTS.include?(env["HTTP_HOST"].to_s.downcase.sub(/:\d+\z/, "").delete_prefix("[").delete_suffix("]"))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The SDK's test: the Origin's host and port are the request's own, a
|
|
169
|
+
# scheme's default port dropped from both.
|
|
170
|
+
def same_origin?(origin, host)
|
|
171
|
+
return false if host.nil?
|
|
172
|
+
|
|
173
|
+
origin = origin.downcase
|
|
174
|
+
default_port = origin.start_with?("https://") ? ":443" : ":80"
|
|
175
|
+
origin.sub(%r{\Ahttps?://}, "").delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Once per process: a client that keeps retrying would otherwise fill
|
|
179
|
+
# the production log with the same line.
|
|
180
|
+
def log_production_refusal(env)
|
|
181
|
+
return if @production_refusal_logged
|
|
182
|
+
|
|
183
|
+
@production_refusal_logged = true
|
|
184
|
+
RailsAiContext.log_warn "[rails-ai-context] Refused an MCP request. #{production_refusal_message(env)}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Whether the installed SDK's transport takes this keyword: the gemspec
|
|
188
|
+
# allows SDKs from before it took several of them.
|
|
189
|
+
def transport_option?(name)
|
|
190
|
+
MCP::Server::Transports::StreamableHTTPTransport.instance_method(:initialize).parameters.any? { |_, param| param == name }
|
|
62
191
|
end
|
|
63
192
|
end
|
|
193
|
+
|
|
194
|
+
# The engine's transport, which answers each request whole: what a tool
|
|
195
|
+
# sends during a call reaches the client with the answer. A request a
|
|
196
|
+
# tool makes of the client mid-call (sampling, elicitation, roots, ping)
|
|
197
|
+
# would get there only once nothing waited for its reply, so the tool
|
|
198
|
+
# would wait out the SDK's timeout - 600 seconds on mcp 1.7, without end
|
|
199
|
+
# on 0.13 - holding a server thread, and in development every code reload
|
|
200
|
+
# and every request after an edit behind it. It fails at once instead,
|
|
201
|
+
# saying why.
|
|
202
|
+
module AnsweredWhole
|
|
203
|
+
def send_request(method, *_params, **_routing)
|
|
204
|
+
raise "#{method} needs the client to answer during the call, and the mounted engine answers each request " \
|
|
205
|
+
"whole: nothing it sends reaches the client before the call is over. Serve a tool that asks the " \
|
|
206
|
+
"client over stdio or the standalone HTTP server (rails-ai-context serve --transport http)."
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The environments the endpoint answers in without allow_http_in_production.
|
|
211
|
+
LOCAL_ENVIRONMENTS = %w[development test].freeze
|
|
212
|
+
LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
|
|
213
|
+
ENGINE_TRANSPORT_LOCK = Mutex.new
|
|
64
214
|
end
|
|
65
215
|
end
|
|
@@ -14,8 +14,12 @@ module RailsAiContext
|
|
|
14
14
|
@mutex = Mutex.new
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# The transport is built on the first MCP request, not the first request
|
|
18
|
+
# of any kind: building it builds the whole MCP server.
|
|
17
19
|
def call(env)
|
|
18
|
-
|
|
20
|
+
return @app.call(env) unless McpEdge.mcp_request?(env)
|
|
21
|
+
|
|
22
|
+
McpEdge.production_refusal || McpEdge.host_refusal(env) || McpEdge.serve(env, transport)
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
private
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RailsAiContext
|
|
4
|
-
# Resolves pending-migration status the
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
4
|
+
# Resolves pending-migration status the way Rails' own
|
|
5
|
+
# MigrationContext#needs_migration? reads it, and without writing: the
|
|
6
|
+
# migration files Rails loads, minus the versions schema_migrations holds
|
|
7
|
+
# (none when the table is not there). MigrationContext#open, which the
|
|
8
|
+
# "Migrations are pending" page walks, builds a Migrator, and building one
|
|
9
|
+
# creates schema_migrations and ar_internal_metadata in a database that
|
|
10
|
+
# lacks them.
|
|
11
|
+
#
|
|
12
|
+
# The MigrationContext itself moved from the connection object to the
|
|
13
|
+
# connection pool between Rails 7.0 and 7.1, and from 7.1 on it takes a
|
|
14
|
+
# schema_migration + internal_metadata pair - so a single hardcoded
|
|
15
|
+
# construction path breaks on part of the range this gem supports. The
|
|
16
|
+
# respond_to? cascade below picks whichever construction the loaded
|
|
12
17
|
# ActiveRecord version actually exposes instead of guessing from a version
|
|
13
18
|
# number.
|
|
14
19
|
module MigrationStatus
|
|
@@ -19,23 +24,76 @@ module RailsAiContext
|
|
|
19
24
|
def self.pending(migrate_dir)
|
|
20
25
|
dirs = Array(migrate_dir).select { |dir| Dir.exist?(dir) }
|
|
21
26
|
return nil if dirs.empty? || !defined?(ActiveRecord::Base)
|
|
27
|
+
# A database that is not there yet, said without creating its file.
|
|
28
|
+
return nil if DatabaseFile.missing(ActiveRecord::Base.connection_db_config)
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
begin
|
|
25
|
-
context.open.pending_migrations.map { |m| { version: m.version.to_s, name: m.name } }
|
|
26
|
-
rescue ActiveRecord::MigrationError
|
|
27
|
-
# A future or duplicate version stops Rails loading the directory; the applied set still answers.
|
|
28
|
-
PendingMigrations.for(migrate_dir: dirs, applied: context.get_all_versions)
|
|
29
|
-
end
|
|
30
|
+
pending_in(migration_context(dirs), dirs)
|
|
30
31
|
rescue => e
|
|
31
32
|
RailsAiContext.debug_fail(e, nil, label: "MigrationStatus.pending")
|
|
32
33
|
end
|
|
33
34
|
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
# One database's pending migrations, as `db:migrate:status:<name>` reads
|
|
36
|
+
# them: from that database's own schema_migrations, through a pool of its
|
|
37
|
+
# own that is removed after, so the app's connections are left as they
|
|
38
|
+
# are. The primary is asked through the app's own pool. The connection is
|
|
39
|
+
# made first, so a database that does not exist, or a server that does
|
|
40
|
+
# not answer, says so rather than reading as nothing pending; a SQLite
|
|
41
|
+
# file that is not there is said to be missing without connecting, which
|
|
42
|
+
# would create it.
|
|
43
|
+
#
|
|
44
|
+
# @param db_config [ActiveRecord::DatabaseConfigurations::DatabaseConfig]
|
|
45
|
+
# @return [Hash] { pending: [{ version:, name: }, ...] }, or { error: exception }
|
|
46
|
+
def self.of_database(db_config, migrate_dir, primary: false)
|
|
47
|
+
missing = DatabaseFile.missing(db_config)
|
|
48
|
+
return { error: missing } if missing
|
|
49
|
+
|
|
50
|
+
dirs = Array(migrate_dir).select { |dir| Dir.exist?(dir) }
|
|
51
|
+
with_pool(db_config, primary: primary) do |pool|
|
|
52
|
+
pool.with_connection(&:verify!)
|
|
53
|
+
{ pending: dirs.empty? ? [] : pending_in(migration_context(dirs, pool), dirs) }
|
|
54
|
+
end
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
{ error: e }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.pending_in(context, dirs)
|
|
60
|
+
applied = context.get_all_versions
|
|
61
|
+
context.migrations.reject { |m| applied.include?(m.version) }.map { |m| { version: m.version.to_s, name: m.name } }
|
|
62
|
+
rescue ActiveRecord::MigrationError
|
|
63
|
+
# A version Rails will not load (one past tomorrow, a name it cannot
|
|
64
|
+
# read) stops it reading the directory; the applied set still answers.
|
|
65
|
+
PendingMigrations.for(migrate_dir: dirs, applied: context.get_all_versions)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# What a pool established for another database belongs to: Rails names a
|
|
69
|
+
# pool by its owner, and an owner that is no primary class leaves the
|
|
70
|
+
# app's own pools alone (ActiveRecord::PendingMigrationConnection, which
|
|
71
|
+
# Rails 7.0 lacks and 7.1 shapes differently).
|
|
72
|
+
class TemporaryOwner
|
|
73
|
+
def self.primary_class?
|
|
74
|
+
false
|
|
75
|
+
end
|
|
37
76
|
|
|
38
|
-
|
|
77
|
+
def self.current_preventing_writes
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.with_pool(db_config, primary:)
|
|
83
|
+
return yield(ActiveRecord::Base.connection_pool) if primary
|
|
84
|
+
|
|
85
|
+
handler = ActiveRecord::Base.connection_handler
|
|
86
|
+
begin
|
|
87
|
+
yield handler.establish_connection(db_config, owner_name: TemporaryOwner)
|
|
88
|
+
ensure
|
|
89
|
+
handler.remove_connection_pool(TemporaryOwner.name)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
private_class_method :with_pool
|
|
93
|
+
|
|
94
|
+
# @return [ActiveRecord::MigrationContext]
|
|
95
|
+
def self.migration_context(migrate_dir, pool = ActiveRecord::Base.connection_pool)
|
|
96
|
+
# Rails 7.2+: MigrationContext takes a schema_migration + internal_metadata
|
|
39
97
|
# pair (per-connection bookkeeping objects) that only the pool knows how
|
|
40
98
|
# to build - construct explicitly with OUR migrate_dir rather than
|
|
41
99
|
# calling pool.migration_context directly, since that resolves its own
|
|
@@ -46,12 +104,17 @@ module RailsAiContext
|
|
|
46
104
|
return ActiveRecord::MigrationContext.new(migrate_dir, pool.schema_migration, pool.internal_metadata)
|
|
47
105
|
end
|
|
48
106
|
|
|
49
|
-
# Rails 7.
|
|
50
|
-
#
|
|
51
|
-
# as above. The connection's own migration_context resolves the
|
|
52
|
-
# configured migrations_paths and ignores the directory being
|
|
53
|
-
# about, silently reporting zero pending migrations.
|
|
54
|
-
|
|
107
|
+
# Rails 7.1 keeps the pair on the connection, and Rails 7.0 the
|
|
108
|
+
# schema_migration alone - construct with OUR migrate_dir for the same
|
|
109
|
+
# reason as above. The connection's own migration_context resolves the
|
|
110
|
+
# app's configured migrations_paths and ignores the directory being
|
|
111
|
+
# asked about, silently reporting zero pending migrations.
|
|
112
|
+
connection = pool.connection
|
|
113
|
+
if connection.respond_to?(:internal_metadata)
|
|
114
|
+
return ActiveRecord::MigrationContext.new(migrate_dir, connection.schema_migration, connection.internal_metadata)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
ActiveRecord::MigrationContext.new(migrate_dir, connection.schema_migration)
|
|
55
118
|
end
|
|
56
119
|
end
|
|
57
120
|
end
|
|
@@ -37,14 +37,19 @@ module RailsAiContext
|
|
|
37
37
|
# Bundler re-execs exactly here: `require "bundler/setup"` runs
|
|
38
38
|
# inside this block, and auto_switch re-execs when the lockfile
|
|
39
39
|
# names a different Bundler than the one running.
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
if Gem.win_platform?
|
|
41
|
+
arm_exec(saved_stdout)
|
|
42
|
+
else
|
|
43
|
+
saved_stdout.close_on_exec = false
|
|
44
|
+
ENV[STDOUT_FD_ENV] = saved_stdout.fileno.to_s
|
|
45
|
+
end
|
|
42
46
|
end
|
|
43
47
|
STDOUT.reopen($stderr) if saved_stdout
|
|
44
48
|
$stdout = $stderr
|
|
45
49
|
yield(saved_stdout || original)
|
|
46
50
|
ensure
|
|
47
51
|
if saved_stdout
|
|
52
|
+
@exec_stdout = nil
|
|
48
53
|
STDOUT.reopen(saved_stdout)
|
|
49
54
|
# Closed for real, and the pointer goes with it: the descriptor
|
|
50
55
|
# exists to survive one exec, and leaving it open with close-on-exec
|
|
@@ -56,6 +61,47 @@ module RailsAiContext
|
|
|
56
61
|
$stdout = original
|
|
57
62
|
end
|
|
58
63
|
|
|
64
|
+
# Windows has no exec: Ruby starts the new image as a child that takes
|
|
65
|
+
# the standard handles as they stand and no other descriptor, so a
|
|
66
|
+
# descriptor named in the environment means nothing there. An exec
|
|
67
|
+
# inside the quarantine puts fd 1 back on the real stdout first, for the
|
|
68
|
+
# new image to start with.
|
|
69
|
+
module ExecWithRealStdout
|
|
70
|
+
def exec(...)
|
|
71
|
+
OutputGuard.restore_for_exec
|
|
72
|
+
super(...)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The same for a bare `exec`, which is Kernel's private instance method;
|
|
77
|
+
# Kernel.exec and Process.exec are public.
|
|
78
|
+
module PrivateExecWithRealStdout
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def exec(...)
|
|
82
|
+
OutputGuard.restore_for_exec
|
|
83
|
+
super(...)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.arm_exec(saved_stdout)
|
|
88
|
+
@exec_stdout = saved_stdout
|
|
89
|
+
return if @exec_patched
|
|
90
|
+
|
|
91
|
+
Kernel.prepend(PrivateExecWithRealStdout)
|
|
92
|
+
Kernel.singleton_class.prepend(ExecWithRealStdout)
|
|
93
|
+
Process.singleton_class.prepend(ExecWithRealStdout)
|
|
94
|
+
@exec_patched = true
|
|
95
|
+
end
|
|
96
|
+
private_class_method :arm_exec
|
|
97
|
+
|
|
98
|
+
def self.restore_for_exec
|
|
99
|
+
io = @exec_stdout
|
|
100
|
+
STDOUT.reopen(io) if io && !io.closed?
|
|
101
|
+
rescue IOError, SystemCallError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
59
105
|
# The descriptor an earlier image of this process saved, when there is
|
|
60
106
|
# one and it is still open; a fresh dup of fd 1 otherwise.
|
|
61
107
|
def self.saved_stdout_io
|
|
@@ -101,7 +101,7 @@ module RailsAiContext
|
|
|
101
101
|
# it is missing, sensitive or a symlink out of that directory. A lockfile
|
|
102
102
|
# past max_file_size still exists.
|
|
103
103
|
def outside_file(dir, name)
|
|
104
|
-
found = SafePath.locate(name, under: dir)
|
|
104
|
+
found = SafePath.locate(name, under: dir, links: false)
|
|
105
105
|
found.realpath if found.ok? || found.refusal == :too_large
|
|
106
106
|
end
|
|
107
107
|
|
|
@@ -39,24 +39,20 @@ module RailsAiContext
|
|
|
39
39
|
# unshifts wins over app/views even when it is nested inside it.
|
|
40
40
|
def view_dirs(root)
|
|
41
41
|
prepended, appended = declared(root)[:views].partition { |direction, _dir| direction == :prepend }
|
|
42
|
-
spelled = ->(list) { list.map { |_direction, dir| File.join(root.to_s, dir) } }
|
|
42
|
+
spelled = ->(list) { list.map { |_direction, dir| File.join(root.to_s, dir) }.reject { |dir| linked_out?(dir, root) } }
|
|
43
43
|
engines = RunCache.fetch([ :engine_view_dirs, root.to_s ]) do
|
|
44
44
|
enclosing_engine_roots(root).map { |engine| File.join(engine, "app/views") }.select { |dir| Dir.exist?(dir) }
|
|
45
45
|
end
|
|
46
46
|
(spelled.(prepended) + dirs_for(root, "app/views") + spelled.(appended) + engines).uniq.freeze
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
# Every initializer Rails loads: railties globs config/initializers with **/*.rb, in path order
|
|
50
|
-
# A symlink
|
|
49
|
+
# Every initializer Rails loads: railties globs config/initializers with **/*.rb, in path order,
|
|
50
|
+
# a glob that enters no linked directory below it. A symlink out of the app (linked_out?), or
|
|
51
|
+
# to nothing, is left out.
|
|
51
52
|
def initializer_paths(root)
|
|
52
|
-
real_root = File.realpath(root.to_s)
|
|
53
53
|
Dir.glob(File.join(root.to_s, "config", "initializers", "**", "*.rb")).sort.select do |path|
|
|
54
|
-
|
|
55
|
-
rescue SystemCallError
|
|
56
|
-
false
|
|
54
|
+
File.file?(path) && !linked_out?(path, root)
|
|
57
55
|
end
|
|
58
|
-
rescue SystemCallError
|
|
59
|
-
[]
|
|
60
56
|
end
|
|
61
57
|
|
|
62
58
|
# The initializers an app spells for `name`, however it spells them: a
|
|
@@ -251,7 +247,7 @@ module RailsAiContext
|
|
|
251
247
|
relative = RunCache.fetch([ :dirs_for, root_key(root), kind, extra ]) do
|
|
252
248
|
resolve_dirs(root, kind).map { |dir| dir.start_with?("#{root}/") ? dir.delete_prefix("#{root}/") : dir }
|
|
253
249
|
end
|
|
254
|
-
relative.map { |dir|
|
|
250
|
+
relative.map { |dir| SafePath.absolute?(dir) ? dir : File.join(root, dir) }.freeze
|
|
255
251
|
end
|
|
256
252
|
end
|
|
257
253
|
|
|
@@ -304,12 +300,49 @@ module RailsAiContext
|
|
|
304
300
|
# Inside the app root, or inside the engine its test/dummy runs in.
|
|
305
301
|
def project_file?(path, root)
|
|
306
302
|
real = File.realpath(path)
|
|
307
|
-
|
|
308
|
-
|
|
303
|
+
project_dirs(root).any? { |dir| SafePath.contained?(real, dir) }
|
|
304
|
+
rescue SystemCallError
|
|
305
|
+
false
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# A path the app spells inside the project that a symlink carries out of
|
|
309
|
+
# it: to a place neither the app root, the engine its test/dummy runs in,
|
|
310
|
+
# nor a directory linked in from the app's repository holds
|
|
311
|
+
# (SafePath.linked_in). A pack linked into packs/ from elsewhere in the
|
|
312
|
+
# repository keeps its files, as Zeitwerk follows it; a link to anywhere
|
|
313
|
+
# outside the repository, or a file linked out of the app and of the
|
|
314
|
+
# linked-in directory it sits in, does not. A path spelled outside the
|
|
315
|
+
# project, such as a gem's, is not one, nor is one that resolves nowhere:
|
|
316
|
+
# nothing can be read through it.
|
|
317
|
+
def linked_out?(path, root)
|
|
318
|
+
# Resolved as given: a spelled `..` after a link climbs from where the link leads.
|
|
319
|
+
real = SafePath.real_file(path.to_s)
|
|
320
|
+
return false if project_dirs(root).any? { |dir| SafePath.contained?(real, dir) }
|
|
321
|
+
|
|
322
|
+
spelled = File.expand_path(path.to_s)
|
|
323
|
+
base = [ root.to_s, *enclosing_engine_roots(root) ].map { |dir| File.expand_path(dir) }
|
|
324
|
+
.find { |dir| spelled.start_with?(SafePath.dir_prefix(dir)) }
|
|
325
|
+
return false unless base
|
|
326
|
+
|
|
327
|
+
SafePath.linked_in(path, real, base, root).nil?
|
|
309
328
|
rescue SystemCallError
|
|
310
329
|
false
|
|
311
330
|
end
|
|
312
331
|
|
|
332
|
+
# Whether a directory link that leads to `real` is one a walk enters: one
|
|
333
|
+
# inside the app's repository or the project, never one back up over
|
|
334
|
+
# `start`, the directory being walked, which would only walk it again.
|
|
335
|
+
def enter_link?(real, start, root)
|
|
336
|
+
return false if SafePath.contained?(start, real)
|
|
337
|
+
|
|
338
|
+
SafePath.contained?(real, SafePath.link_bound(root)) || project_dirs(root).any? { |dir| SafePath.contained?(real, dir) }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def project_dirs(root)
|
|
342
|
+
RunCache.fetch([ :project_dirs, root.to_s ]) { [ root.to_s, *enclosing_engine_roots(root) ].map { |dir| root_key(dir) } }
|
|
343
|
+
end
|
|
344
|
+
private_class_method :project_dirs
|
|
345
|
+
|
|
313
346
|
def root_key(root)
|
|
314
347
|
File.realpath(root)
|
|
315
348
|
rescue SystemCallError
|
|
@@ -323,7 +356,7 @@ module RailsAiContext
|
|
|
323
356
|
|
|
324
357
|
def resolve_dirs(root, kind)
|
|
325
358
|
places(root, kind).flat_map { |_shown, path, glob| glob ? Dir.glob(path).sort : [ path ] }
|
|
326
|
-
.uniq.select { |dir| Dir.exist?(dir) }.freeze
|
|
359
|
+
.uniq.select { |dir| Dir.exist?(dir) && !linked_out?(dir, root) }.freeze
|
|
327
360
|
end
|
|
328
361
|
private_class_method :resolve_dirs
|
|
329
362
|
|