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.
Files changed (222) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +868 -0
  3. data/CONTEXT.md +7 -3
  4. data/README.md +1 -1
  5. data/docs/ARCHITECTURE.md +3 -3
  6. data/docs/CLI.md +1 -1
  7. data/docs/COMPATIBILITY.md +50 -14
  8. data/docs/CONFIGURATION.md +4 -1
  9. data/docs/FAQ.md +1 -1
  10. data/docs/GUIDE.md +14 -8
  11. data/docs/INTROSPECTORS.md +50 -23
  12. data/docs/SECURITY.md +56 -1
  13. data/docs/TOOLS.md +58 -12
  14. data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
  15. data/lib/rails_ai_context/action_filters.rb +121 -48
  16. data/lib/rails_ai_context/app_commands.rb +2 -2
  17. data/lib/rails_ai_context/app_kind.rb +65 -8
  18. data/lib/rails_ai_context/ast_cache.rb +53 -18
  19. data/lib/rails_ai_context/base_mixins.rb +46 -16
  20. data/lib/rails_ai_context/boot_manager.rb +5 -1
  21. data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
  22. data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
  23. data/lib/rails_ai_context/concern_macros.rb +791 -111
  24. data/lib/rails_ai_context/concern_membership.rb +25 -5
  25. data/lib/rails_ai_context/concern_paths.rb +63 -17
  26. data/lib/rails_ai_context/config_yaml.rb +40 -0
  27. data/lib/rails_ai_context/configuration.rb +3 -3
  28. data/lib/rails_ai_context/data/docs/index.json +324 -0
  29. data/lib/rails_ai_context/database_yml.rb +136 -0
  30. data/lib/rails_ai_context/doctor.rb +30 -37
  31. data/lib/rails_ai_context/erb_source.rb +40 -0
  32. data/lib/rails_ai_context/fingerprinter.rb +48 -22
  33. data/lib/rails_ai_context/fixture_keys.rb +42 -1
  34. data/lib/rails_ai_context/gem_lock.rb +275 -47
  35. data/lib/rails_ai_context/git_ignore.rb +13 -11
  36. data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
  37. data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
  38. data/lib/rails_ai_context/introspector.rb +10 -1
  39. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
  40. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
  41. data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
  42. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
  43. data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
  44. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
  45. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
  46. data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
  47. data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
  48. data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
  49. data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
  50. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
  51. data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
  52. data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
  53. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
  54. data/lib/rails_ai_context/introspectors/base.rb +38 -0
  55. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
  56. data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
  57. data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
  58. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
  59. data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
  60. data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
  61. data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
  63. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
  65. data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
  66. data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
  67. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
  68. data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
  69. data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
  70. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
  71. data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
  72. data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
  73. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
  74. data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
  75. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
  76. data/lib/rails_ai_context/introspectors/includers.rb +6 -1
  77. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
  78. data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
  79. data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
  80. data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
  81. data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
  82. data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
  83. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
  84. data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
  85. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
  86. data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
  87. data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
  88. data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
  90. data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
  91. data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
  92. data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
  93. data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
  94. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
  95. data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
  96. data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
  97. data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
  98. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
  99. data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
  100. data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
  101. data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
  102. data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
  103. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
  104. data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
  105. data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
  106. data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
  107. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
  108. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
  109. data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
  110. data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
  111. data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
  112. data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
  113. data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
  114. data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
  115. data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
  116. data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
  117. data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
  118. data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
  119. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
  120. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
  121. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
  122. data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
  123. data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
  124. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
  125. data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
  126. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
  127. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
  128. data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
  129. data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
  130. data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
  131. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
  132. data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
  133. data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
  134. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
  135. data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
  136. data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
  137. data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
  138. data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
  139. data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
  140. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
  141. data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
  142. data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
  143. data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
  144. data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
  145. data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
  146. data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
  147. data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
  148. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
  149. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
  150. data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
  151. data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
  152. data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
  153. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
  154. data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
  155. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
  156. data/lib/rails_ai_context/method_name.rb +31 -0
  157. data/lib/rails_ai_context/migration_status.rb +5 -5
  158. data/lib/rails_ai_context/output_guard.rb +6 -4
  159. data/lib/rails_ai_context/package_json.rb +96 -5
  160. data/lib/rails_ai_context/path_resolver.rb +170 -15
  161. data/lib/rails_ai_context/payload.rb +48 -0
  162. data/lib/rails_ai_context/pending_migrations.rb +15 -18
  163. data/lib/rails_ai_context/portable_path.rb +76 -4
  164. data/lib/rails_ai_context/redaction.rb +8 -2
  165. data/lib/rails_ai_context/route_coverage.rb +5 -1
  166. data/lib/rails_ai_context/safe_file.rb +4 -1
  167. data/lib/rails_ai_context/safe_path.rb +61 -10
  168. data/lib/rails_ai_context/schema_hint.rb +8 -2
  169. data/lib/rails_ai_context/schema_version.rb +7 -9
  170. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
  171. data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
  172. data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
  173. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
  174. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
  175. data/lib/rails_ai_context/server.rb +26 -7
  176. data/lib/rails_ai_context/test_framework.rb +32 -5
  177. data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
  178. data/lib/rails_ai_context/tools/base_tool.rb +69 -30
  179. data/lib/rails_ai_context/tools/diagnose.rb +1 -1
  180. data/lib/rails_ai_context/tools/generate_test.rb +8 -9
  181. data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
  182. data/lib/rails_ai_context/tools/get_api.rb +17 -11
  183. data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
  184. data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
  185. data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
  186. data/lib/rails_ai_context/tools/get_concern.rb +31 -18
  187. data/lib/rails_ai_context/tools/get_config.rb +52 -18
  188. data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
  189. data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
  190. data/lib/rails_ai_context/tools/get_engines.rb +2 -0
  191. data/lib/rails_ai_context/tools/get_env.rb +209 -52
  192. data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
  193. data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
  194. data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
  195. data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
  196. data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
  197. data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
  198. data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
  199. data/lib/rails_ai_context/tools/get_routes.rb +35 -9
  200. data/lib/rails_ai_context/tools/get_schema.rb +160 -61
  201. data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
  202. data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
  203. data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
  204. data/lib/rails_ai_context/tools/get_view.rb +37 -20
  205. data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
  206. data/lib/rails_ai_context/tools/onboard.rb +174 -88
  207. data/lib/rails_ai_context/tools/performance_check.rb +5 -13
  208. data/lib/rails_ai_context/tools/query.rb +122 -28
  209. data/lib/rails_ai_context/tools/read_logs.rb +45 -17
  210. data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
  211. data/lib/rails_ai_context/tools/safe_call.rb +20 -15
  212. data/lib/rails_ai_context/tools/search_code.rb +114 -40
  213. data/lib/rails_ai_context/tools/search_docs.rb +22 -11
  214. data/lib/rails_ai_context/tools/security_scan.rb +51 -4
  215. data/lib/rails_ai_context/tools/validate.rb +21 -4
  216. data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
  217. data/lib/rails_ai_context/version.rb +1 -1
  218. data/lib/rails_ai_context/vfs.rb +2 -2
  219. data/lib/rails_ai_context/view_file.rb +53 -3
  220. data/lib/rails_ai_context.rb +10 -2
  221. data/server.json +1 -1
  222. 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] path to the migrations directory to check
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
- return nil unless migrate_dir && Dir.exist?(migrate_dir)
21
- return nil unless defined?(ActiveRecord::Base)
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(migrate_dir)
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: migrate_dir, applied: context.get_all_versions)
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
- def self.quarantine_stdout
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
- manifest_dirs(root).reduce({}) do |merged, dir|
29
- merged.merge(read(File.join(dir, "package.json")))
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
- declared = configured.is_a?(Array) && configured.any? ? configured : FRONTEND_DIRS
51
- declared.map { |dir| File.join(root, dir.to_s) }
131
+ configured.is_a?(Array) ? configured : []
52
132
  end
53
- private_class_method :frontend_dirs
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
- def view_dirs(root) = dirs_for(root, "app/views")
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
- Dir.glob(File.join(root.to_s, "config", "initializers", "*.rb")).sort.select do |path|
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
- lock = SafeFile.read(File.join(root, "Gemfile.lock"))
125
+ bundle = GemLock.bundle(root)
126
+ lock = bundle.lockfile && SafeFile.read(bundle.lockfile)
84
127
  return [] unless lock
85
128
 
86
- real_root = File.realpath(root)
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, root)
90
- next [] unless Dir.exist?(base) && SafePath.contained?(File.realpath(base), real_root)
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
- DECLARED_ROOTS.compute_if_absent(key) { read_declared_roots(key) }
146
+ declared(key)[:autoload].map { |relative| File.join(key, relative) }
104
147
  end
105
148
 
106
- def read_declared_roots(root)
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 [] unless File.file?(path)
166
+ return NONE_DECLARED unless File.file?(path)
109
167
 
110
- declared = Introspectors::SourceIntrospector.walk(
111
- path, { autoload: Introspectors::Listeners::AutoloadPathsListener }
112
- )[:autoload]
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
- declared.uniq.map { |relative| File.join(root, relative) }.select { |dir| contained_dir?(dir, real_root) }
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, [], label: "PathResolver.declared_roots")
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
- # Each secondary database keeps its own migrate directory, so a secondary
35
- # dump compared against db/migrate would report the primary's files.
36
- def migrate_dir_for(root, dump_path = nil)
37
- base = if dump_path
38
- File.basename(dump_path.to_s).sub(/\.(rb|sql)\z/, "").sub(/_?(schema|structure)\z/, "")
39
- else
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 in the directory. One file scan behind
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
- return [] unless migrate_dir && Dir.exist?(migrate_dir)
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