rails-ai-context 5.30.2 → 5.32.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 +868 -0
- data/CONTEXT.md +7 -3
- data/README.md +1 -1
- data/docs/ARCHITECTURE.md +3 -3
- data/docs/CLI.md +1 -1
- data/docs/COMPATIBILITY.md +50 -14
- data/docs/CONFIGURATION.md +4 -1
- data/docs/FAQ.md +1 -1
- data/docs/GUIDE.md +14 -8
- data/docs/INTROSPECTORS.md +50 -23
- data/docs/SECURITY.md +56 -1
- data/docs/TOOLS.md +58 -12
- data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
- data/lib/rails_ai_context/action_filters.rb +121 -48
- data/lib/rails_ai_context/app_commands.rb +2 -2
- data/lib/rails_ai_context/app_kind.rb +65 -8
- data/lib/rails_ai_context/ast_cache.rb +53 -18
- data/lib/rails_ai_context/base_mixins.rb +46 -16
- data/lib/rails_ai_context/boot_manager.rb +5 -1
- data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
- data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
- data/lib/rails_ai_context/concern_macros.rb +791 -111
- data/lib/rails_ai_context/concern_membership.rb +25 -5
- data/lib/rails_ai_context/concern_paths.rb +63 -17
- data/lib/rails_ai_context/config_yaml.rb +40 -0
- data/lib/rails_ai_context/configuration.rb +3 -3
- data/lib/rails_ai_context/data/docs/index.json +324 -0
- data/lib/rails_ai_context/database_yml.rb +136 -0
- data/lib/rails_ai_context/doctor.rb +30 -37
- data/lib/rails_ai_context/erb_source.rb +40 -0
- data/lib/rails_ai_context/fingerprinter.rb +48 -22
- data/lib/rails_ai_context/fixture_keys.rb +42 -1
- data/lib/rails_ai_context/gem_lock.rb +275 -47
- data/lib/rails_ai_context/git_ignore.rb +13 -11
- data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
- data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
- data/lib/rails_ai_context/introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
- data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
- data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
- data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
- data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
- data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
- data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
- data/lib/rails_ai_context/introspectors/base.rb +38 -0
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
- data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
- data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
- data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
- data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
- data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
- data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
- data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
- data/lib/rails_ai_context/introspectors/includers.rb +6 -1
- data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
- data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
- data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
- data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
- data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
- data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
- data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
- data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
- data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
- data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
- data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
- data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
- data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
- data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
- data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
- data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
- data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
- data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
- data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
- data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
- data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
- data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
- data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
- data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
- data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
- data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
- data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
- data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
- data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
- data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
- data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
- data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
- data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
- data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
- data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
- data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
- data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
- data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
- data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
- data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
- data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
- data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
- data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
- data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
- data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
- data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
- data/lib/rails_ai_context/method_name.rb +31 -0
- data/lib/rails_ai_context/migration_status.rb +5 -5
- data/lib/rails_ai_context/output_guard.rb +6 -4
- data/lib/rails_ai_context/package_json.rb +96 -5
- data/lib/rails_ai_context/path_resolver.rb +170 -15
- data/lib/rails_ai_context/payload.rb +48 -0
- data/lib/rails_ai_context/pending_migrations.rb +15 -18
- data/lib/rails_ai_context/portable_path.rb +76 -4
- data/lib/rails_ai_context/redaction.rb +8 -2
- data/lib/rails_ai_context/route_coverage.rb +5 -1
- data/lib/rails_ai_context/safe_file.rb +4 -1
- data/lib/rails_ai_context/safe_path.rb +61 -10
- data/lib/rails_ai_context/schema_hint.rb +8 -2
- data/lib/rails_ai_context/schema_version.rb +7 -9
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
- data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
- data/lib/rails_ai_context/server.rb +26 -7
- data/lib/rails_ai_context/test_framework.rb +32 -5
- data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
- data/lib/rails_ai_context/tools/base_tool.rb +69 -30
- data/lib/rails_ai_context/tools/diagnose.rb +1 -1
- data/lib/rails_ai_context/tools/generate_test.rb +8 -9
- data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
- data/lib/rails_ai_context/tools/get_api.rb +17 -11
- data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
- data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
- data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
- data/lib/rails_ai_context/tools/get_concern.rb +31 -18
- data/lib/rails_ai_context/tools/get_config.rb +52 -18
- data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
- data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
- data/lib/rails_ai_context/tools/get_engines.rb +2 -0
- data/lib/rails_ai_context/tools/get_env.rb +209 -52
- data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
- data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
- data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
- data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
- data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
- data/lib/rails_ai_context/tools/get_routes.rb +35 -9
- data/lib/rails_ai_context/tools/get_schema.rb +160 -61
- data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
- data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
- data/lib/rails_ai_context/tools/get_view.rb +37 -20
- data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
- data/lib/rails_ai_context/tools/onboard.rb +174 -88
- data/lib/rails_ai_context/tools/performance_check.rb +5 -13
- data/lib/rails_ai_context/tools/query.rb +122 -28
- data/lib/rails_ai_context/tools/read_logs.rb +45 -17
- data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
- data/lib/rails_ai_context/tools/safe_call.rb +20 -15
- data/lib/rails_ai_context/tools/search_code.rb +114 -40
- data/lib/rails_ai_context/tools/search_docs.rb +22 -11
- data/lib/rails_ai_context/tools/security_scan.rb +51 -4
- data/lib/rails_ai_context/tools/validate.rb +21 -4
- data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +2 -2
- data/lib/rails_ai_context/view_file.rb +53 -3
- data/lib/rails_ai_context.rb +10 -2
- data/server.json +1 -1
- metadata +34 -3
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
# Where a method name ends in source text, `ping` not `ping?`; no lookahead,
|
|
5
|
+
# so each fragment is a ripgrep pattern too.
|
|
6
|
+
module MethodName
|
|
7
|
+
# Ruby's `\w` and `\s` are ASCII and ripgrep's Unicode, so every class stays
|
|
8
|
+
# in ASCII; Ruby reads any other character as part of a name.
|
|
9
|
+
SPACE_CHARS = "\\t\\n\\v\\f\\r "
|
|
10
|
+
SPACE = "[#{SPACE_CHARS}]"
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# After `def name`: `def ping = 1` is an endless `ping`.
|
|
15
|
+
def definition_end(name)
|
|
16
|
+
name_end?(name) ? "(?:[\\x00-\\x7F&&[^\\w?!=]]|[!=][=~]|=>|$)" : ""
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# After a call of `name`: `obj.ping = 1` calls `ping=`, while `ping == 1`,
|
|
20
|
+
# `ping => x`, `ping ||= 1` and `ping arg` still read `ping`.
|
|
21
|
+
def call_end(name)
|
|
22
|
+
return "" unless name_end?(name)
|
|
23
|
+
|
|
24
|
+
"(?:[\\x00-\\x7F&&[^\\w?!=\\s]]|[!=][=~]|=>|$|#{SPACE}+(?:[^=#{SPACE_CHARS}]|=[=~>]|$))"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def name_end?(name)
|
|
28
|
+
name.to_s.match?(/(?:\w|[^\x00-\x7F])\z/)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -12,20 +12,20 @@ module RailsAiContext
|
|
|
12
12
|
# ActiveRecord version actually exposes instead of guessing from a version
|
|
13
13
|
# number.
|
|
14
14
|
module MigrationStatus
|
|
15
|
-
# @param migrate_dir [String]
|
|
15
|
+
# @param migrate_dir [String, Array<String>] the migrations directories to check
|
|
16
16
|
# @return [Array<Hash>, nil] [{ version:, name: }, ...] in pending order,
|
|
17
17
|
# or nil when pending status can't be determined (no migrations
|
|
18
18
|
# directory, ActiveRecord not loaded, or the database is unreachable).
|
|
19
19
|
def self.pending(migrate_dir)
|
|
20
|
-
|
|
21
|
-
return nil
|
|
20
|
+
dirs = Array(migrate_dir).select { |dir| Dir.exist?(dir) }
|
|
21
|
+
return nil if dirs.empty? || !defined?(ActiveRecord::Base)
|
|
22
22
|
|
|
23
|
-
context = migration_context(
|
|
23
|
+
context = migration_context(dirs)
|
|
24
24
|
begin
|
|
25
25
|
context.open.pending_migrations.map { |m| { version: m.version.to_s, name: m.name } }
|
|
26
26
|
rescue ActiveRecord::MigrationError
|
|
27
27
|
# A future or duplicate version stops Rails loading the directory; the applied set still answers.
|
|
28
|
-
PendingMigrations.for(migrate_dir:
|
|
28
|
+
PendingMigrations.for(migrate_dir: dirs, applied: context.get_all_versions)
|
|
29
29
|
end
|
|
30
30
|
rescue => e
|
|
31
31
|
RailsAiContext.debug_fail(e, nil, label: "MigrationStatus.pending")
|
|
@@ -25,10 +25,12 @@ module RailsAiContext
|
|
|
25
25
|
# re-execs itself while the quarantine is up.
|
|
26
26
|
STDOUT_FD_ENV = "RAILS_AI_CONTEXT_STDOUT_FD"
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# Yields the real stdout. across_exec: false keeps it from the children of
|
|
29
|
+
# a long-lived caller.
|
|
30
|
+
def self.quarantine_stdout(across_exec: true)
|
|
29
31
|
original = $stdout
|
|
30
32
|
saved_stdout = reopenable_target? ? saved_stdout_io : nil
|
|
31
|
-
if saved_stdout
|
|
33
|
+
if saved_stdout && across_exec
|
|
32
34
|
# `exec` closes a dup'd descriptor unless close-on-exec is cleared,
|
|
33
35
|
# and the new image would then save fd 1 - by then pointing at
|
|
34
36
|
# stderr - as its "stdout" and write every MCP response there.
|
|
@@ -37,10 +39,10 @@ module RailsAiContext
|
|
|
37
39
|
# names a different Bundler than the one running.
|
|
38
40
|
saved_stdout.close_on_exec = false
|
|
39
41
|
ENV[STDOUT_FD_ENV] = saved_stdout.fileno.to_s
|
|
40
|
-
STDOUT.reopen($stderr)
|
|
41
42
|
end
|
|
43
|
+
STDOUT.reopen($stderr) if saved_stdout
|
|
42
44
|
$stdout = $stderr
|
|
43
|
-
yield
|
|
45
|
+
yield(saved_stdout || original)
|
|
44
46
|
ensure
|
|
45
47
|
if saved_stdout
|
|
46
48
|
STDOUT.reopen(saved_stdout)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "pathname"
|
|
4
5
|
|
|
5
6
|
module RailsAiContext
|
|
6
7
|
# Which npm packages an app depends on, read once per package.json, and the
|
|
@@ -13,11 +14,22 @@ module RailsAiContext
|
|
|
13
14
|
# package.json for importmap and a whole Vue app under frontend/, so every
|
|
14
15
|
# manifest is read and merged, the root one winning a version disagreement.
|
|
15
16
|
# Callers ask what the app depends on, never which directory holds the file.
|
|
17
|
+
#
|
|
18
|
+
# Two kinds of directory outside the app root are read, for frontend
|
|
19
|
+
# manifests only: a `frontend_paths` entry the app declared, and the JS
|
|
20
|
+
# workspace root a lockfile sits in. Each is read with the sensitive-file
|
|
21
|
+
# and symlink rules applied relative to that directory.
|
|
16
22
|
module PackageJson
|
|
17
23
|
# Where a frontend app lives when it has not been declared through
|
|
18
24
|
# `frontend_paths`. Same list the frontend introspector falls back to.
|
|
19
25
|
FRONTEND_DIRS = %w[app/frontend app/javascript frontend client].freeze
|
|
20
26
|
|
|
27
|
+
# In the order a package manager is named when several lockfiles exist.
|
|
28
|
+
LOCKFILES = {
|
|
29
|
+
"bun.lock" => "bun", "bun.lockb" => "bun", "pnpm-lock.yaml" => "pnpm",
|
|
30
|
+
"yarn.lock" => "yarn", "package-lock.json" => "npm"
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
21
33
|
MUTEX = Mutex.new
|
|
22
34
|
CACHE = {}
|
|
23
35
|
private_constant :MUTEX, :CACHE
|
|
@@ -25,9 +37,72 @@ module RailsAiContext
|
|
|
25
37
|
module_function
|
|
26
38
|
|
|
27
39
|
def deps(root)
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
outside = configured_outside(root).filter_map { |dir| outside_file(dir[:dir], "package.json") }
|
|
41
|
+
inside = manifest_dirs(root).map { |dir| File.join(dir, "package.json") }
|
|
42
|
+
(outside + inside).reduce({}) { |merged, path| merged.merge(read(path)) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The first lockfile found in the app root, then in each frontend directory,
|
|
46
|
+
# with that directory's label (nil for the app root).
|
|
47
|
+
def package_manager(root)
|
|
48
|
+
root = root.to_s
|
|
49
|
+
name = LOCKFILES.find { |file, _| File.exist?(File.join(root, file)) }&.last
|
|
50
|
+
return [ name, nil ] if name
|
|
51
|
+
|
|
52
|
+
(frontend_roots(root) + [ workspace_root(root) ].compact).each do |dir|
|
|
53
|
+
name = LOCKFILES.find { |file, _| outside_file(dir[:dir], file) }&.last
|
|
54
|
+
return [ name, dir[:label] ] if name
|
|
55
|
+
end
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Every directory besides the app root that holds frontend config: the
|
|
60
|
+
# frontend dirs inside it, then the declared ones outside it.
|
|
61
|
+
def frontend_roots(root)
|
|
62
|
+
root = root.to_s
|
|
63
|
+
inside = manifest_dirs(root)[0...-1].map { |dir| { dir: dir, label: dir.delete_prefix("#{root}/") } }
|
|
64
|
+
inside + configured_outside(root)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Declared frontend_paths entries that resolve to a directory outside the
|
|
68
|
+
# app root, labelled as the app wrote them.
|
|
69
|
+
def configured_outside(root)
|
|
70
|
+
real_root = File.realpath(root.to_s)
|
|
71
|
+
declared_paths.filter_map do |path|
|
|
72
|
+
real = File.realpath(File.join(root.to_s, path.to_s))
|
|
73
|
+
next if SafePath.contained?(real, real_root) || !File.directory?(real)
|
|
74
|
+
|
|
75
|
+
{ dir: real, label: path.to_s, source: "configuration" }
|
|
76
|
+
rescue SystemCallError
|
|
77
|
+
nil
|
|
30
78
|
end
|
|
79
|
+
rescue SystemCallError
|
|
80
|
+
[]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The nearest ancestor that holds a lockfile or declares workspaces,
|
|
84
|
+
# never above the git root, and never outside a git repository: package
|
|
85
|
+
# managers write one lockfile at the workspace root.
|
|
86
|
+
def workspace_root(root)
|
|
87
|
+
real_root = File.realpath(root.to_s)
|
|
88
|
+
repo = SafePath.git_root(real_root) or return nil
|
|
89
|
+
candidates = []
|
|
90
|
+
dir = real_root
|
|
91
|
+
candidates << (dir = File.dirname(dir)) until dir == repo
|
|
92
|
+
found = candidates.find { |candidate| workspace?(candidate) }
|
|
93
|
+
return nil unless found
|
|
94
|
+
|
|
95
|
+
{ dir: found, label: Pathname.new(found).relative_path_from(Pathname.new(real_root)).to_s, source: "workspace" }
|
|
96
|
+
rescue SystemCallError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The realpath of a file in a directory outside the app root, or nil when
|
|
101
|
+
# it is missing, sensitive or a symlink out of that directory. A lockfile
|
|
102
|
+
# past max_file_size still exists.
|
|
103
|
+
def outside_file(dir, name)
|
|
104
|
+
found = SafePath.locate(name, under: dir)
|
|
105
|
+
found.realpath if found.ok? || found.refusal == :too_large
|
|
31
106
|
end
|
|
32
107
|
|
|
33
108
|
def present?(root, name)
|
|
@@ -45,12 +120,28 @@ module RailsAiContext
|
|
|
45
120
|
end
|
|
46
121
|
|
|
47
122
|
def frontend_dirs(root)
|
|
123
|
+
declared = declared_paths
|
|
124
|
+
(declared.any? ? declared : FRONTEND_DIRS).map { |dir| File.join(root, dir.to_s) }
|
|
125
|
+
end
|
|
126
|
+
private_class_method :frontend_dirs
|
|
127
|
+
|
|
128
|
+
def declared_paths
|
|
48
129
|
configured = RailsAiContext.configuration.respond_to?(:frontend_paths) &&
|
|
49
130
|
RailsAiContext.configuration.frontend_paths
|
|
50
|
-
|
|
51
|
-
declared.map { |dir| File.join(root, dir.to_s) }
|
|
131
|
+
configured.is_a?(Array) ? configured : []
|
|
52
132
|
end
|
|
53
|
-
private_class_method :
|
|
133
|
+
private_class_method :declared_paths
|
|
134
|
+
|
|
135
|
+
def workspace?(dir)
|
|
136
|
+
return true if LOCKFILES.keys.any? { |file| outside_file(dir, file) }
|
|
137
|
+
|
|
138
|
+
content, = SafePath.read("package.json", under: dir)
|
|
139
|
+
data = content && JSON.parse(content)
|
|
140
|
+
data.is_a?(Hash) && data.key?("workspaces")
|
|
141
|
+
rescue JSON::ParserError
|
|
142
|
+
false
|
|
143
|
+
end
|
|
144
|
+
private_class_method :workspace?
|
|
54
145
|
|
|
55
146
|
def contained?(dir, root)
|
|
56
147
|
SafePath.contained?(File.realpath(dir), File.realpath(root))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "concurrent"
|
|
4
|
+
require "pathname"
|
|
4
5
|
|
|
5
6
|
module RailsAiContext
|
|
6
7
|
# Resolves where a kind of Rails app code can live for a given app root.
|
|
@@ -26,6 +27,7 @@ module RailsAiContext
|
|
|
26
27
|
DECLARED_ROOTS = Concurrent::Map.new
|
|
27
28
|
APP_ROOTS = Concurrent::Map.new
|
|
28
29
|
PATH_GEM_LIBS = Concurrent::Map.new
|
|
30
|
+
NAMESPACED_ROOTS = Concurrent::Map.new
|
|
29
31
|
|
|
30
32
|
module_function
|
|
31
33
|
|
|
@@ -33,14 +35,36 @@ module RailsAiContext
|
|
|
33
35
|
|
|
34
36
|
def controller_dirs(root) = dirs_for(root, "app/controllers")
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
# Rails searches config.paths["app/views"] in order, so a root the app
|
|
39
|
+
# unshifts wins over app/views even when it is nested inside it.
|
|
40
|
+
def view_dirs(root)
|
|
41
|
+
prepended, appended = declared(root)[:views].partition { |direction, _dir| direction == :prepend }
|
|
42
|
+
spelled = ->(list) { list.map { |_direction, dir| File.join(root.to_s, dir) } }
|
|
43
|
+
engines = RunCache.fetch([ :engine_view_dirs, root.to_s ]) do
|
|
44
|
+
enclosing_engine_roots(root).map { |engine| File.join(engine, "app/views") }.select { |dir| Dir.exist?(dir) }
|
|
45
|
+
end
|
|
46
|
+
(spelled.(prepended) + dirs_for(root, "app/views") + spelled.(appended) + engines).uniq.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Every initializer Rails loads: railties globs config/initializers with **/*.rb, in path order.
|
|
50
|
+
# A symlink that resolves outside the app root, or nowhere, is left out.
|
|
51
|
+
def initializer_paths(root)
|
|
52
|
+
real_root = File.realpath(root.to_s)
|
|
53
|
+
Dir.glob(File.join(root.to_s, "config", "initializers", "**", "*.rb")).sort.select do |path|
|
|
54
|
+
SafePath.contained?(File.realpath(path), real_root)
|
|
55
|
+
rescue SystemCallError
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
rescue SystemCallError
|
|
59
|
+
[]
|
|
60
|
+
end
|
|
37
61
|
|
|
38
62
|
# The initializers an app spells for `name`, however it spells them: a
|
|
39
63
|
# load-order prefix (`009-omniauth.rb`), a hyphen where the gem uses an
|
|
40
64
|
# underscore (`rack-attack.rb`), or a compound name (`custom_devise.rb`).
|
|
41
65
|
def initializer_files(root, name)
|
|
42
66
|
target = normalize_initializer_name(name)
|
|
43
|
-
|
|
67
|
+
initializer_paths(root).select do |path|
|
|
44
68
|
base = normalize_initializer_name(File.basename(path, ".rb"))
|
|
45
69
|
base == target || base.end_with?("_#{target}")
|
|
46
70
|
end
|
|
@@ -72,6 +96,23 @@ module RailsAiContext
|
|
|
72
96
|
end
|
|
73
97
|
end
|
|
74
98
|
|
|
99
|
+
# app/* directories Rails generates for code that is not a model; the model dirs are dropped by path.
|
|
100
|
+
# ponytail: skipped by name, so an app/<dir> of one of these names that does hold models is missed;
|
|
101
|
+
# read config.autoload_paths per dir if that bites.
|
|
102
|
+
NON_MODEL_APP_DIRS = %w[
|
|
103
|
+
assets javascript views controllers helpers mailers mailboxes jobs channels
|
|
104
|
+
components serializers policies decorators presenters workers graphql uploaders validators
|
|
105
|
+
].freeze
|
|
106
|
+
|
|
107
|
+
# The autoload roots besides the model directories that can hold a model:
|
|
108
|
+
# every other app/* root and the roots config/application.rb adds.
|
|
109
|
+
def extra_model_roots(root)
|
|
110
|
+
models = model_dirs(root).map { |dir| root_key(dir) }
|
|
111
|
+
candidates = app_roots(root).reject { |dir| NON_MODEL_APP_DIRS.include?(File.basename(dir)) } + declared_roots(root)
|
|
112
|
+
candidates.reject { |dir| models.any? { |model_dir| SafePath.contained?(model_dir, root_key(dir)) } }
|
|
113
|
+
.uniq { |dir| root_key(dir) }
|
|
114
|
+
end
|
|
115
|
+
|
|
75
116
|
# Only path gems inside the repo (`path "../gems" do` locks as `remote: gems`);
|
|
76
117
|
# one outside is an installed gem as far as the app's source goes.
|
|
77
118
|
def path_gem_libs(root)
|
|
@@ -79,15 +120,17 @@ module RailsAiContext
|
|
|
79
120
|
PATH_GEM_LIBS.compute_if_absent(key) { read_path_gem_libs(key) }
|
|
80
121
|
end
|
|
81
122
|
|
|
123
|
+
# Remotes are relative to the lockfile, which for a test/dummy is the engine's.
|
|
82
124
|
def read_path_gem_libs(root)
|
|
83
|
-
|
|
125
|
+
bundle = GemLock.bundle(root)
|
|
126
|
+
lock = bundle.lockfile && SafeFile.read(bundle.lockfile)
|
|
84
127
|
return [] unless lock
|
|
85
128
|
|
|
86
|
-
|
|
129
|
+
trusted = File.realpath(bundle.trusted)
|
|
87
130
|
remotes = lock.scan(/^PATH\r?\n remote: (.+?)\r?$/).flatten.map(&:strip)
|
|
88
131
|
remotes.flat_map do |remote|
|
|
89
|
-
base = File.expand_path(remote,
|
|
90
|
-
next [] unless Dir.exist?(base) && SafePath.contained?(File.realpath(base),
|
|
132
|
+
base = File.expand_path(remote, bundle.dir)
|
|
133
|
+
next [] unless Dir.exist?(base) && SafePath.contained?(File.realpath(base), trusted)
|
|
91
134
|
|
|
92
135
|
# Bundler's own glob for the gemspecs a path source holds.
|
|
93
136
|
Dir.glob(File.join(base, "{,*,*/*}.gemspec")).map { |spec| File.join(File.dirname(spec), "lib") }
|
|
@@ -100,20 +143,42 @@ module RailsAiContext
|
|
|
100
143
|
# The roots config/application.rb adds by hand, such as lib_static.
|
|
101
144
|
def declared_roots(root)
|
|
102
145
|
key = File.expand_path(root.to_s)
|
|
103
|
-
|
|
146
|
+
declared(key)[:autoload].map { |relative| File.join(key, relative) }
|
|
104
147
|
end
|
|
105
148
|
|
|
106
|
-
|
|
149
|
+
# The lib subdirectories `autoload_lib(ignore:)` keeps out of autoloading.
|
|
150
|
+
def ignored_dirs(root)
|
|
151
|
+
key = File.expand_path(root.to_s)
|
|
152
|
+
declared(key)[:ignored].map { |relative| File.join(key, relative) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# One walk of config/application.rb for the autoload, ignored and view
|
|
156
|
+
# roots it adds, app-relative and kept only when they exist inside the app.
|
|
157
|
+
def declared(root)
|
|
158
|
+
key = File.expand_path(root.to_s)
|
|
159
|
+
DECLARED_ROOTS.compute_if_absent(key) { read_declared(key) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
NONE_DECLARED = { autoload: [].freeze, ignored: [].freeze, views: [].freeze }.freeze
|
|
163
|
+
|
|
164
|
+
def read_declared(root)
|
|
107
165
|
path = File.join(root, "config", "application.rb")
|
|
108
|
-
return
|
|
166
|
+
return NONE_DECLARED unless File.file?(path)
|
|
109
167
|
|
|
110
|
-
|
|
111
|
-
path, { autoload: Introspectors::Listeners::AutoloadPathsListener
|
|
112
|
-
|
|
168
|
+
found = Introspectors::SourceIntrospector.walk(
|
|
169
|
+
path, { autoload: Introspectors::Listeners::AutoloadPathsListener,
|
|
170
|
+
ignored: Introspectors::Listeners::AutoloadIgnoreListener,
|
|
171
|
+
views: Introspectors::Listeners::ViewPathsListener }
|
|
172
|
+
)
|
|
113
173
|
real_root = File.realpath(root)
|
|
114
|
-
|
|
174
|
+
inside = ->(relative) { contained_dir?(File.join(root, relative), real_root) }
|
|
175
|
+
{
|
|
176
|
+
autoload: found[:autoload].uniq.select(&inside).freeze,
|
|
177
|
+
ignored: found[:ignored].uniq.select(&inside).freeze,
|
|
178
|
+
views: found[:views].uniq.select { |_direction, relative| inside.(relative) }.freeze
|
|
179
|
+
}.freeze
|
|
115
180
|
rescue StandardError => e
|
|
116
|
-
RailsAiContext.debug_fail(e,
|
|
181
|
+
RailsAiContext.debug_fail(e, NONE_DECLARED, label: "PathResolver.declared_roots")
|
|
117
182
|
end
|
|
118
183
|
|
|
119
184
|
# A declared root is still a path the file wrote: `#{config.root}/../shared`
|
|
@@ -130,9 +195,43 @@ module RailsAiContext
|
|
|
130
195
|
return nil if relative.empty? || relative.include?("..")
|
|
131
196
|
|
|
132
197
|
(roots || autoload_roots(root)).lazy
|
|
133
|
-
.map { |dir| File.join(dir, "#{relative}.rb") }.find { |path| File.file?(path) }
|
|
198
|
+
.map { |dir| File.join(dir, "#{relative}.rb") }.find { |path| File.file?(path) } ||
|
|
199
|
+
namespaced_file(root, name.to_s)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def namespaced_file(root, name)
|
|
203
|
+
namespaced_roots(root).each do |dir, namespace|
|
|
204
|
+
next unless name.start_with?("#{namespace}::")
|
|
205
|
+
|
|
206
|
+
path = File.join(dir, "#{name.delete_prefix("#{namespace}::").underscore}.rb")
|
|
207
|
+
return path if File.file?(path)
|
|
208
|
+
end
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
private_class_method :namespaced_file
|
|
212
|
+
|
|
213
|
+
# [dir, namespace] for each `push_dir(..., namespace: X)` in config/application.rb
|
|
214
|
+
# or an initializer. Only files that say push_dir are parsed.
|
|
215
|
+
def namespaced_roots(root)
|
|
216
|
+
key = File.expand_path(root.to_s)
|
|
217
|
+
NAMESPACED_ROOTS.compute_if_absent(key) { read_namespaced_roots(key) }
|
|
134
218
|
end
|
|
135
219
|
|
|
220
|
+
def read_namespaced_roots(root)
|
|
221
|
+
real_root = File.realpath(root)
|
|
222
|
+
files = [ "config/application.rb", *initializer_paths(root).map { |path| path.delete_prefix(SafePath.dir_prefix(root)) } ]
|
|
223
|
+
sources = files.filter_map { |relative| SafePath.read(relative, under: root).first }.select { |source| source.include?("push_dir") }
|
|
224
|
+
sources.flat_map do |source|
|
|
225
|
+
Introspectors::SourceIntrospector.walk_source(source, { roots: Introspectors::Listeners::NamespacedRootsListener })[:roots]
|
|
226
|
+
end.uniq.filter_map do |relative, namespace|
|
|
227
|
+
dir = File.join(root, relative)
|
|
228
|
+
[ dir, namespace ] if contained_dir?(dir, real_root)
|
|
229
|
+
end.freeze
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
RailsAiContext.debug_fail(e, [], label: "PathResolver.namespaced_roots")
|
|
232
|
+
end
|
|
233
|
+
private_class_method :read_namespaced_roots
|
|
234
|
+
|
|
136
235
|
# The files a constant can be declared in, its own first, then each enclosing
|
|
137
236
|
# namespace's (`CanonicalURL::Helpers` lives in `canonical_url.rb`).
|
|
138
237
|
def namespace_files(root, name, roots: nil)
|
|
@@ -156,6 +255,61 @@ module RailsAiContext
|
|
|
156
255
|
end
|
|
157
256
|
end
|
|
158
257
|
|
|
258
|
+
# Booted from an engine's test/dummy, the engine whose root holds the app
|
|
259
|
+
# root: its classes, views and tests are the project's own. Never this gem.
|
|
260
|
+
def enclosing_engine_roots(root)
|
|
261
|
+
return [] unless defined?(::Rails::Engine)
|
|
262
|
+
|
|
263
|
+
RunCache.fetch([ :enclosing_engine_roots, root.to_s ]) do
|
|
264
|
+
inside = "#{root_key(root.to_s)}#{File::SEPARATOR}"
|
|
265
|
+
::Rails::Engine.subclasses.filter_map do |engine|
|
|
266
|
+
next if engine.root.nil? || (defined?(RailsAiContext::Engine) && engine.equal?(RailsAiContext::Engine))
|
|
267
|
+
|
|
268
|
+
dir = engine.root.to_s
|
|
269
|
+
dir if inside.start_with?("#{root_key(dir)}#{File::SEPARATOR}")
|
|
270
|
+
rescue StandardError
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The root whose suite tests the app: an engine's test/dummy keeps none of its own.
|
|
277
|
+
def test_root(root)
|
|
278
|
+
root = root.to_s
|
|
279
|
+
return root if %w[test spec].any? { |dir| Dir.exist?(File.join(root, dir)) }
|
|
280
|
+
|
|
281
|
+
RunCache.fetch([ :test_root, root ]) { enclosing_engine_roots(root).first || bundle_engine_root(root) || root }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Unbooted, the engine is the gemspec directory holding the bundle config/boot.rb
|
|
285
|
+
# names, which GemLock only resolves inside the app's git repository.
|
|
286
|
+
def bundle_engine_root(root)
|
|
287
|
+
dir = GemLock.bundle(root).dir
|
|
288
|
+
real_root = root_key(root)
|
|
289
|
+
return nil if dir == root || !real_root.start_with?("#{dir}#{File::SEPARATOR}") || Dir.glob(File.join(dir, "*.gemspec")).empty?
|
|
290
|
+
|
|
291
|
+
# In the root's own spelling, which every caller strips from the paths it prints.
|
|
292
|
+
depth = real_root.delete_prefix("#{dir}#{File::SEPARATOR}").split(File::SEPARATOR).size
|
|
293
|
+
depth.times.reduce(File.expand_path(root)) { |path, _| File.dirname(path) }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# A path under the suite root as the app root reads it: `../models/x_test.rb` from a test/dummy.
|
|
297
|
+
def suite_relative(root, relative)
|
|
298
|
+
suite = test_root(root)
|
|
299
|
+
return relative if suite == root.to_s
|
|
300
|
+
|
|
301
|
+
Pathname.new(File.join(root_key(suite), relative)).relative_path_from(Pathname.new(root_key(root))).to_s
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Inside the app root, or inside the engine its test/dummy runs in.
|
|
305
|
+
def project_file?(path, root)
|
|
306
|
+
real = File.realpath(path)
|
|
307
|
+
dirs = RunCache.fetch([ :project_dirs, root.to_s ]) { [ root.to_s, *enclosing_engine_roots(root) ].map { |dir| root_key(dir) } }
|
|
308
|
+
dirs.any? { |dir| SafePath.contained?(real, dir) }
|
|
309
|
+
rescue SystemCallError
|
|
310
|
+
false
|
|
311
|
+
end
|
|
312
|
+
|
|
159
313
|
def root_key(root)
|
|
160
314
|
File.realpath(root)
|
|
161
315
|
rescue SystemCallError
|
|
@@ -210,6 +364,7 @@ module RailsAiContext
|
|
|
210
364
|
DECLARED_ROOTS.clear
|
|
211
365
|
APP_ROOTS.clear
|
|
212
366
|
PATH_GEM_LIBS.clear
|
|
367
|
+
NAMESPACED_ROOTS.clear
|
|
213
368
|
end
|
|
214
369
|
|
|
215
370
|
def discover_code_roots(root)
|
|
@@ -80,6 +80,54 @@ module RailsAiContext
|
|
|
80
80
|
count.nil? ? " - model count unavailable" : " - #{CountPhrase.call(count, 'model')}"
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
# Every table the schema section holds, as [database, name, data]: the primary's
|
|
84
|
+
# (database nil) first, then each secondary dump's.
|
|
85
|
+
def schema_tables(schema)
|
|
86
|
+
return [] unless schema.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
primary = (schema[:tables].is_a?(Hash) ? schema[:tables] : {}).map { |name, data| [ nil, name, data ] }
|
|
89
|
+
secondary = (schema[:secondary_databases].is_a?(Hash) ? schema[:secondary_databases] : {}).flat_map do |db, info|
|
|
90
|
+
tables = info.is_a?(Hash) && info[:tables].is_a?(Hash) ? info[:tables] : {}
|
|
91
|
+
tables.map { |name, data| [ db.to_s, name, data ] }
|
|
92
|
+
end
|
|
93
|
+
primary + secondary
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A name in two databases answers `database`'s when given, else the primary's.
|
|
97
|
+
def schema_table(schema, name, database: nil)
|
|
98
|
+
found = table_holders(schema, name)
|
|
99
|
+
(found.find { |db, _, _| database && db == database.to_s } || found.first)&.last
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The table a model reads, from the database its `connects_to` writes to.
|
|
103
|
+
def model_table(schema, model)
|
|
104
|
+
model_tables(schema, model).first&.last
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# [database, table] for each database a model may read its table from: the one it writes
|
|
108
|
+
# to, or the primary without connects_to, when that holds the table; every holder otherwise (shards).
|
|
109
|
+
def model_tables(schema, model)
|
|
110
|
+
return [] unless model.is_a?(Hash) && model[:table_name]
|
|
111
|
+
|
|
112
|
+
found = table_holders(schema, model[:table_name]).map { |db, _, data| [ db || "primary", data ] }
|
|
113
|
+
writing = model.dig(:database, :writing)&.to_s || ("primary" unless model[:database])
|
|
114
|
+
written = found.select { |db, _| db == writing }.first(1)
|
|
115
|
+
written.any? ? written : found
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def model_databases(schema, model)
|
|
119
|
+
model_tables(schema, model).map(&:first)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Every database whose schema holds the table, "primary" for the primary's.
|
|
123
|
+
def schema_databases(schema, name)
|
|
124
|
+
table_holders(schema, name).map { |db, _, _| db || "primary" }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def table_holders(schema, name)
|
|
128
|
+
schema_tables(schema).select { |_, table, _| table.to_s == name.to_s }
|
|
129
|
+
end
|
|
130
|
+
|
|
83
131
|
def models(ctx)
|
|
84
132
|
value = ctx.is_a?(Hash) ? ctx[:models] : nil
|
|
85
133
|
value.is_a?(Hash) && !value[:error] ? value : {}
|
|
@@ -11,10 +11,10 @@ module RailsAiContext
|
|
|
11
11
|
# applied: the applied versions, a single newest version, or nil when
|
|
12
12
|
# nothing is known. Unknown answers nil - an empty Array is the different
|
|
13
13
|
# claim that nothing has been applied, and every file is then pending.
|
|
14
|
-
def for(migrate_dir:, applied: nil)
|
|
14
|
+
def for(migrate_dir:, applied: nil, root: nil)
|
|
15
15
|
return nil if applied.nil?
|
|
16
16
|
|
|
17
|
-
files = migration_files(migrate_dir)
|
|
17
|
+
files = migration_files(migrate_dir, root: root)
|
|
18
18
|
unapplied = if applied.is_a?(Array)
|
|
19
19
|
known = applied.map(&:to_i)
|
|
20
20
|
files.reject { |m| known.include?(m[:version].to_i) }
|
|
@@ -31,29 +31,26 @@ module RailsAiContext
|
|
|
31
31
|
RunCache.fetch([ :pending_migrations, migrate_dir.to_s ]) { MigrationStatus.pending(migrate_dir) }
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
""
|
|
41
|
-
end
|
|
42
|
-
File.join(root.to_s, "db", base.empty? ? "migrate" : "#{base}_migrate")
|
|
34
|
+
# The entry's migrations_paths, else db/migrate as Rails runs it; a dump with no entry here takes the generator's db/<name>_migrate.
|
|
35
|
+
# Only the dirs Rails migrates: an engine's renumbered copy in db/migrate would read as pending.
|
|
36
|
+
def migrate_dirs_for(root, dump_path = nil)
|
|
37
|
+
name = Introspectors::SchemaDumpPath.database_name(root, dump_path)
|
|
38
|
+
entry = RailsAiContext::DatabaseYml.entry(root, name)
|
|
39
|
+
Introspectors::MigrationReplay.configured_dirs(root, entry) ||
|
|
40
|
+
[ File.join(root.to_s, "db", name == "primary" || entry ? "migrate" : "#{name}_migrate") ]
|
|
43
41
|
end
|
|
44
42
|
|
|
45
|
-
# Every versioned migration file
|
|
43
|
+
# Every versioned migration file under the directory or directories. One file scan behind
|
|
46
44
|
# both the pending derivation and the migrations listing, so the two
|
|
47
45
|
# cannot disagree on which files count.
|
|
48
|
-
def migration_files(migrate_dir)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
Dir.glob(File.join(migrate_dir, "*.rb")).sort.filter_map do |path|
|
|
46
|
+
def migration_files(migrate_dir, root: nil)
|
|
47
|
+
dirs = Array(migrate_dir).select { |dir| Dir.exist?(dir) }
|
|
48
|
+
Introspectors::MigrationReplay.migration_files(dirs, root: root).filter_map do |path|
|
|
52
49
|
base = File.basename(path, ".rb")
|
|
53
50
|
version = base[/\A\d+/] or next
|
|
54
51
|
# The class name, so a static entry names the migration the way the
|
|
55
|
-
# connection's own pending list does.
|
|
56
|
-
name = base.sub(/\A\d+_/, "").camelize
|
|
52
|
+
# connection's own pending list does; Rails drops an engine's ".scope" suffix.
|
|
53
|
+
name = base.sub(/\A\d+_/, "").split(".", 2).first.to_s.camelize
|
|
57
54
|
{ version: version, name: name, path: path }
|
|
58
55
|
end
|
|
59
56
|
end
|