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
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ipaddr"
4
+ require "uri"
5
+
3
6
  module RailsAiContext
4
7
  module Tools
5
8
  class GetEnv < BaseTool
6
9
  tool_name "rails_get_env"
7
10
  description "Discover environment variables, external service dependencies, and credentials keys used by the app. " \
8
11
  "Use when: setting up a development environment, debugging missing config, or auditing external dependencies. " \
9
- "Scans .rb, .rake, ERB views and config YAML for ENV[], plus .env.example, Dockerfile, external HTTP calls, and credentials keys (never values)."
12
+ "Scans .rb, .rake, ERB views and config YAML for ENV[], plus .env.example, Dockerfile, the env Kamal's config/deploy.yml sets, config gem settings and Anyway::Config keys, external HTTP calls, and credentials keys (never values)."
10
13
 
11
14
  input_schema(
12
15
  properties: {
@@ -35,6 +38,9 @@ module RailsAiContext
35
38
  env_vars = scan_env_vars(root)
36
39
  env_example = scan_env_example(root)
37
40
  dockerfile_vars = scan_dockerfile(root)
41
+ kamal_env = scan_kamal_env(root)
42
+ settings = scan_settings(root)
43
+ anyway_configs = Introspectors::AnywayConfigs.scan(root)
38
44
  external_services = detect_external_services(root, env_vars.values.flatten.map { |v| v[:name] }.uniq)
39
45
  credentials_keys = detect_credentials_keys
40
46
  encrypted_columns = detect_encrypted_columns
@@ -44,19 +50,31 @@ module RailsAiContext
44
50
  env_vars.each { |_file, vars| vars.each { |v| all_var_names << v[:name] } }
45
51
  env_example.each { |v| all_var_names << v[:name] }
46
52
  dockerfile_vars.each { |v| all_var_names << v[:name] if v[:type] == "ENV" }
47
-
48
- if all_var_names.empty? && external_services.empty? && credentials_keys.empty?
49
- return text_response("No environment variables, external services, or credentials keys detected.")
53
+ kamal_env.each { |v| all_var_names << v[:name] if v[:name] }
54
+
55
+ anyway_configs.each { |c| c[:attributes].each { |a| all_var_names << a[:env] if a[:env] } }
56
+ deploy_and_settings = kamal_lines(kamal_env, root) + settings_lines(settings) + anyway_lines(anyway_configs)
57
+
58
+ text = if all_var_names.empty? && external_services.empty? && credentials_keys.empty? && deploy_and_settings.empty?
59
+ "No environment variables, external services, or credentials keys detected."
60
+ else
61
+ case detail
62
+ when "summary"
63
+ format_summary(all_var_names, external_services, credentials_keys)
64
+ when "standard"
65
+ format_standard(env_vars, env_example, deploy_and_settings, external_services, credentials_keys, encrypted_columns)
66
+ when "full"
67
+ format_full(env_vars, env_example, deploy_and_settings, dockerfile_vars, external_services, credentials_keys, encrypted_columns, root)
68
+ end
50
69
  end
70
+ text_response([ text, unread_bundle_note(root) ].compact.join("\n\n"))
71
+ end
51
72
 
52
- case detail
53
- when "summary"
54
- format_summary(all_var_names, external_services, credentials_keys)
55
- when "standard"
56
- format_standard(env_vars, env_example, external_services, credentials_keys, encrypted_columns)
57
- when "full"
58
- format_full(env_vars, env_example, dockerfile_vars, external_services, credentials_keys, encrypted_columns, root)
59
- end
73
+ # Without it, an unread bundle reads as an app that declares no service gems.
74
+ private_class_method def self.unread_bundle_note(root)
75
+ unread = RailsAiContext::GemLock.for(root).unread_bundle or return nil
76
+
77
+ "_Gem-based services and config gem settings not read: #{unread}._"
60
78
  end
61
79
 
62
80
  private_class_method def self.format_summary(all_var_names, external_services, credentials_keys)
@@ -75,16 +93,16 @@ module RailsAiContext
75
93
  end
76
94
 
77
95
  lines << "" << "_Use `detail:\"standard\"` for sources and external services, or `detail:\"full\"` for per-file locations._"
78
- text_response(lines.join("\n"))
96
+ lines.join("\n")
79
97
  end
80
98
 
81
99
  # Named in the answer, because a name missing from it is otherwise
82
100
  # indistinguishable from a name the app does not read.
83
- SCAN_NOTE = "_Scanned `app`, `config` and `lib` for `.rb`, `.rake`, `.erb` and config `.yml`. " \
101
+ SCAN_NOTE = "_Scanned `app`, `config` and `lib` for `.rb`, `.rake`, `.erb` and config `.yml`, plus `config.ru`, `db/seeds` and the Ruby scripts in `bin`. " \
84
102
  "Config YAML on `sensitive_patterns` (config/database.yml) is read for the ENV names in its ERB tags only; " \
85
103
  "credentials, keys and the rest are never read._"
86
104
 
87
- private_class_method def self.format_standard(env_vars, env_example, external_services, credentials_keys, encrypted_columns)
105
+ private_class_method def self.format_standard(env_vars, env_example, deploy_and_settings, external_services, credentials_keys, encrypted_columns)
88
106
  lines = [ "# Environment Configuration", "" ]
89
107
 
90
108
  # ENV vars from code, grouped by purpose
@@ -132,6 +150,8 @@ module RailsAiContext
132
150
  end
133
151
  end
134
152
 
153
+ lines.concat(deploy_and_settings)
154
+
135
155
  # External services
136
156
  if external_services.any?
137
157
  lines << "## External Services"
@@ -147,7 +167,7 @@ module RailsAiContext
147
167
  lines.concat(credentials_and_encrypted_lines(credentials_keys, encrypted_columns))
148
168
 
149
169
  lines << SCAN_NOTE
150
- text_response(lines.join("\n"))
170
+ lines.join("\n")
151
171
  end
152
172
 
153
173
  # Both `standard` and `full` end with these two sections, so a wording
@@ -159,6 +179,10 @@ module RailsAiContext
159
179
  lines << "## Credentials Keys (values hidden)"
160
180
  credentials_keys.each { |k| lines << "- `#{k}`" }
161
181
  lines << ""
182
+ elsif (empty = empty_credentials_file)
183
+ lines << "## Credentials Keys (values hidden)"
184
+ lines << "_`#{empty}` decrypts and holds no keys._"
185
+ lines << ""
162
186
  elsif credentials_file_present?
163
187
  lines << "## Credentials Keys (values hidden)"
164
188
  lines << RailsAiContext::Confidence.unavailable("credentials are encrypted; reading the key names needs a booted app with its master key")
@@ -176,7 +200,7 @@ module RailsAiContext
176
200
  lines
177
201
  end
178
202
 
179
- private_class_method def self.format_full(env_vars, env_example, dockerfile_vars, external_services, credentials_keys, encrypted_columns, root)
203
+ private_class_method def self.format_full(env_vars, env_example, deploy_and_settings, dockerfile_vars, external_services, credentials_keys, encrypted_columns, root)
180
204
  lines = [ "# Environment Configuration (Full Detail)", "" ]
181
205
 
182
206
  # ENV vars grouped by category with file annotations
@@ -248,6 +272,8 @@ module RailsAiContext
248
272
  lines << ""
249
273
  end
250
274
 
275
+ lines.concat(deploy_and_settings)
276
+
251
277
  # Dockerfile ENV/ARG
252
278
  if dockerfile_vars.any?
253
279
  lines << "## Dockerfile Variables"
@@ -275,13 +301,130 @@ module RailsAiContext
275
301
  lines.concat(credentials_and_encrypted_lines(credentials_keys, encrypted_columns))
276
302
 
277
303
  lines << SCAN_NOTE
278
- text_response(lines.join("\n"))
304
+ lines.join("\n")
279
305
  end
280
306
 
281
307
  private_class_method def self.scan_env_vars(root)
282
308
  Introspectors::EnvReferences.scan(root)
283
309
  end
284
310
 
311
+ KAMAL_DEPLOY = "config/deploy.yml"
312
+
313
+ private_class_method def self.kamal_lines(kamal_env, root)
314
+ return [] unless File.file?(File.join(root, KAMAL_DEPLOY))
315
+
316
+ destinations = Dir.glob("config/deploy.*.yml", base: root).sort
317
+ return [] if kamal_env.empty? && destinations.empty?
318
+
319
+ lines = [ "## Set by Kamal (`#{KAMAL_DEPLOY}`)" ]
320
+ kamal_env.each do |v|
321
+ scope = v[:scope] ? " (#{v[:scope]})" : ""
322
+ lines << if v[:name].nil?
323
+ "- a #{v[:secret] ? 'secret' : 'variable'} whose name an ERB tag sets at deploy time#{scope}"
324
+ elsif v[:secret]
325
+ alias_note = if v[:secret] == v[:name] then ""
326
+ elsif RailsAiContext::ConfigYaml.marked?(v[:secret]) then " (a name an ERB tag sets)"
327
+ else " (`#{v[:secret]}`)"
328
+ end
329
+ "- `#{v[:name]}` - secret, from `.kamal/secrets`#{alias_note}#{scope}"
330
+ elsif v[:value] == :computed
331
+ "- `#{v[:name]}` - set by ERB at deploy time#{scope}"
332
+ elsif v[:value] == :hidden
333
+ "- `#{v[:name]}` - value hidden#{scope}"
334
+ else
335
+ "- `#{v[:name]}` = `#{v[:value]}`#{scope}"
336
+ end
337
+ end
338
+ destinations.each { |file| lines << "- `#{file}` merges over this per destination and is not read" }
339
+ lines << ""
340
+ end
341
+
342
+ # As Kamal's Role#env merges it: top-level env, `servers.<role>.env`, then each `env.tags.<tag>`.
343
+ private_class_method def self.scan_kamal_env(root)
344
+ config = RailsAiContext::ConfigYaml.read(root, KAMAL_DEPLOY, label: "Kamal", marker: RailsAiContext::ConfigYaml::ERB_OUTPUT)
345
+ return [] unless config.is_a?(Hash)
346
+
347
+ env = config["env"].is_a?(Hash) ? config["env"] : {}
348
+ servers = config["servers"].is_a?(Hash) ? config["servers"] : {}
349
+ roles = servers.filter_map { |role, options| [ "role `#{role}`", options["env"] ] if options.is_a?(Hash) && options["env"].is_a?(Hash) }
350
+ tags = env["tags"].is_a?(Hash) ? env["tags"].filter_map { |tag, tag_env| [ "tag `#{tag}`", tag_env ] if tag_env.is_a?(Hash) } : []
351
+ [ [ nil, env ], *roles, *tags ].flat_map { |scope, scoped| kamal_env_entries(scoped, scope) }
352
+ end
353
+
354
+ # Kamal::Configuration::Env's shape: `clear` and `secret` keys, or a bare hash that is all clear values.
355
+ private_class_method def self.kamal_env_entries(env, scope)
356
+ clear = env.fetch("clear", env.key?("secret") || env.key?("tags") ? {} : env)
357
+ clear = {} unless clear.is_a?(Hash)
358
+ secrets = Array(env["secret"]).filter_map do |key|
359
+ name, aliased = key.to_s.split(":", 2)
360
+ { name: kamal_name(name), secret: aliased || name, scope: scope }.compact unless name.to_s.empty?
361
+ end
362
+ secrets + clear.map { |name, value| { name: kamal_name(name.to_s), value: kamal_clear_value(name.to_s, value), scope: scope }.compact }
363
+ end
364
+
365
+ # nil for a name an ERB tag writes, so the marker is never printed as a variable.
366
+ private_class_method def self.kamal_name(name)
367
+ name unless RailsAiContext::ConfigYaml.marked?(name)
368
+ end
369
+
370
+ SAFE_ENV_NAMES = Introspectors::EnvIntrospector::KNOWN_ENV_VARS.select { |spec| spec[:safe] }.to_set { |spec| spec[:name] }.freeze
371
+ # A run of letters and digits this long is a key or token, whatever the variable is called;
372
+ # a hyphen or underscore breaks the run, so a host name such as `myapp-production-db-1` shows.
373
+ OPAQUE_TOKEN = /(?=[A-Za-z0-9+\/=]*\d)(?=[A-Za-z0-9+\/=]*[A-Za-z])[A-Za-z0-9+\/=]{16,}/
374
+ # A UUID or a hex key split into groups is still a key once the separators go.
375
+ GROUPED_HEX = /\A\h{16,}\z/
376
+
377
+ # Webhook URLs and DSNs hide their secret in the path or user part, where Redaction does not look.
378
+ private_class_method def self.kamal_clear_value(name, value)
379
+ return :computed if RailsAiContext::ConfigYaml.marked?(value)
380
+
381
+ text = value.to_s
382
+ return RailsAiContext::Redaction.value(name, text) if SAFE_ENV_NAMES.include?(name)
383
+ return :hidden if text.include?("://") || text.match?(OPAQUE_TOKEN) || text.delete("-_").match?(GROUPED_HEX) || RailsAiContext::Redaction.value(name, text) != text
384
+
385
+ text
386
+ end
387
+
388
+ # Without the config gem, config/settings.yml is the app's own file and no Settings constant exists.
389
+ private_class_method def self.scan_settings(root)
390
+ return [] unless RailsAiContext::GemLock.for(root).present?("config")
391
+
392
+ files = [ "config/settings.yml" ] +
393
+ %w[settings environments].flat_map { |dir| Dir.glob(File.join(root, "config", dir, "*.yml")).sort.map { |path| path.delete_prefix("#{root}/") } }
394
+ files.filter_map do |file|
395
+ data = RailsAiContext::ConfigYaml.read(root, file, label: "config gem settings")
396
+ next unless data.is_a?(Hash) && data.any?
397
+
398
+ { file: file, keys: setting_keys(data) }
399
+ end
400
+ end
401
+
402
+ private_class_method def self.setting_keys(hash, prefix = nil)
403
+ hash.flat_map do |key, value|
404
+ path = [ prefix, key ].compact.join(".")
405
+ value.is_a?(Hash) && value.any? ? setting_keys(value, path) : [ path ]
406
+ end
407
+ end
408
+
409
+ private_class_method def self.settings_lines(settings)
410
+ return [] if settings.empty?
411
+
412
+ [ "## Settings (config gem, read as `Settings.<key>`; values hidden)" ] +
413
+ settings.map { |s| "- `#{s[:file]}`: #{s[:keys].map { |k| "`#{k}`" }.join(', ')}" } + [ "" ]
414
+ end
415
+
416
+ private_class_method def self.anyway_lines(configs)
417
+ return [] if configs.empty?
418
+
419
+ [ "## Anyway::Config classes (values hidden)" ] + configs.map do |config|
420
+ attributes = config[:attributes].map do |a|
421
+ notes = [ ("`#{a[:env]}`" if a[:env]), ("required" if a[:required]) ].compact
422
+ notes.any? ? "`#{a[:name]}` (#{notes.join(', ')})" : "`#{a[:name]}`"
423
+ end
424
+ "- `#{config[:name]}` (`#{config[:file]}`): #{attributes.join(', ')}"
425
+ end + [ "" ]
426
+ end
427
+
285
428
  private_class_method def self.scan_env_example(root)
286
429
  # Only read .env.example or .env.sample - NEVER .env or .env.local
287
430
  candidates = %w[.env.example .env.sample .env.template]
@@ -444,37 +587,32 @@ module RailsAiContext
444
587
  services.uniq { |s| s[:name] }
445
588
  end
446
589
 
590
+ # Prefilter: the AST decides, on the files that name a client at all.
591
+ HTTP_CLIENT_NAME = /(?:#{Regexp.union(Introspectors::Listeners::HttpClientCallListener::CLIENTS).source})\.|URI\.open/
592
+ BARE_HOST = /\A[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}\z/i
593
+
447
594
  private_class_method def self.detect_http_clients(root)
448
595
  services = []
449
- app_dir = File.join(root, "app")
450
- return services unless Dir.exist?(app_dir)
451
-
452
596
  real_root = File.realpath(root).to_s
453
- safe_glob(app_dir, "**/*.rb", real_root).each do |file|
454
- source = safe_read(file)
455
- next unless source
456
597
 
457
- relative = file.sub("#{real_root}/", "")
598
+ %w[app config lib].each do |dir|
599
+ scan_dir = File.join(root, dir)
600
+ next unless Dir.exist?(scan_dir)
458
601
 
459
- # Faraday connections
460
- source.scan(/Faraday\.new\s*\(?\s*(?:url:\s*)?["']([^"']+)["']/).each do |match|
461
- url = match[0]
462
- name = extract_service_name_from_url(url)
463
- services << { name: name, detection: "Faraday.new", file: relative } if name
464
- end
465
-
466
- # Net::HTTP
467
- source.scan(/Net::HTTP\.\w+\s*\(?\s*(?:URI\.parse\s*\(?\s*)?["']([^"']+)["']/).each do |match|
468
- url = match[0]
469
- name = extract_service_name_from_url(url)
470
- services << { name: name, detection: "Net::HTTP", file: relative } if name
471
- end
602
+ safe_glob(scan_dir, "**/*.rb", real_root).each do |file|
603
+ source = safe_read(file)
604
+ next unless source&.match?(HTTP_CLIENT_NAME)
472
605
 
473
- # HTTParty
474
- source.scan(/HTTParty\.\w+\s*\(?\s*["']([^"']+)["']/).each do |match|
475
- url = match[0]
476
- name = extract_service_name_from_url(url)
477
- services << { name: name, detection: "HTTParty", file: relative } if name
606
+ relative = file.sub("#{real_root}/", "")
607
+ calls = Introspectors::SourceIntrospector.walk_source(source, { http: Introspectors::Listeners::HttpClientCallListener })[:http]
608
+ calls.each do |call|
609
+ name = if call[:url]
610
+ extract_service_name_from_url(call[:url])
611
+ else
612
+ call[:host].match?(BARE_HOST) && service_name_from_host(call[:host])
613
+ end
614
+ services << { name: name, detection: call[:client], file: relative } if name
615
+ end
478
616
  end
479
617
  end
480
618
 
@@ -487,20 +625,25 @@ module RailsAiContext
487
625
  return nil if url.start_with?("ENV") || url.include?("#" + "{")
488
626
 
489
627
  begin
490
- uri = URI.parse(url)
491
- return nil unless uri&.host
492
- # Extract meaningful service name from hostname
493
- host = uri.host
494
- # Remove common TLDs and subdomains
495
- parts = host.split(".")
496
- return nil if parts.size < 2
497
- # Use the main domain part
498
- parts[-2]&.capitalize
628
+ host = URI.parse(url).host
629
+ host && service_name_from_host(host)
499
630
  rescue => e
500
631
  RailsAiContext.debug_fail(e, nil, label: "extract_service_name_from_url")
501
632
  end
502
633
  end
503
634
 
635
+ private_class_method def self.service_name_from_host(host)
636
+ # An address names no service; a loopback, private or unspecified one is not external at all.
637
+ if (ip = (IPAddr.new(host.delete("[]")) rescue nil))
638
+ return ip.loopback? || ip.private? || ip.link_local? || ip.to_i.zero? ? nil : host
639
+ end
640
+
641
+ parts = host.split(".")
642
+ return nil if parts.size < 2
643
+
644
+ parts[-2]&.capitalize
645
+ end
646
+
504
647
  # An encrypted credentials file the tool could not open is a different
505
648
  # fact from an app with no credentials, and only one of them is true here.
506
649
  private_class_method def self.credentials_file_present?
@@ -513,6 +656,20 @@ module RailsAiContext
513
656
  false
514
657
  end
515
658
 
659
+ # Rails answers {} both for a file it decrypted and found empty and for
660
+ # one it had no key to open, so only a key plus the file tells them apart.
661
+ private_class_method def self.empty_credentials_file
662
+ return nil if RailsAiContext.static_tier?
663
+
664
+ creds = Rails.application&.credentials
665
+ return nil unless creds.respond_to?(:key) && creds.respond_to?(:content_path) && creds.respond_to?(:config)
666
+ return nil unless creds.key && creds.content_path.exist? && creds.config.empty?
667
+
668
+ creds.content_path.to_s.delete_prefix("#{rails_app.root}/")
669
+ rescue StandardError
670
+ nil
671
+ end
672
+
516
673
  private_class_method def self.detect_credentials_keys
517
674
  keys = []
518
675
 
@@ -4,7 +4,8 @@ module RailsAiContext
4
4
  module Tools
5
5
  class GetEnvConfig < BaseTool
6
6
  tool_name "rails_get_env_config"
7
- description "Get per-environment configuration from config/environments/*.rb: notable toggles (force_ssl, eager_load, caching, log level, queue adapter, mailer delivery) and every config key each environment sets. " \
7
+ description "Get per-environment configuration from config/environments/*.rb: notable toggles (force_ssl, eager_load, caching, log level, queue adapter, mailer delivery) and every config key each environment sets, " \
8
+ "plus the keys config/application.rb sets for every environment (config.x included) and the keys each config_for YAML file gives. " \
8
9
  "Use when: comparing development vs production behavior, debugging env-specific bugs, or checking what a custom environment changes. " \
9
10
  "Filter with environment:\"production\". Omit for all environments. " \
10
11
  "For environment variables and ENV[] usage, use rails_get_env instead."
@@ -51,6 +52,7 @@ module RailsAiContext
51
52
  lines << ""
52
53
  lines << "_Current: **#{envs[:current]}** - #{count_phrase(envs[:count], "environment file")} under config/environments/_"
53
54
 
55
+ render_application(lines, envs[:application], current: envs[:current], offset: offset, limit: limit) if envs[:application]
54
56
  if list.any?
55
57
  list.each { |entry| render_environment(lines, entry, offset: offset, limit: limit) }
56
58
  else
@@ -67,6 +69,40 @@ module RailsAiContext
67
69
  class << self
68
70
  private
69
71
 
72
+ def render_application(lines, app, current:, offset:, limit:)
73
+ lines << "" << "## Every environment" << "_#{app[:file]}_"
74
+ render_keys(lines, app[:config_keys] || [], offset: offset, limit: limit)
75
+ config_for = Array(app[:config_for])
76
+ return if config_for.empty?
77
+
78
+ lines << "" << "**config_for:**"
79
+ config_for.each do |entry|
80
+ found = if entry[:path_unread] then "keys not read: its path is not a literal"
81
+ elsif entry[:missing] then "does not exist"
82
+ elsif entry[:outside] then "outside the app, not read"
83
+ elsif entry[:withheld] then "on sensitive_patterns, keys not read"
84
+ elsif entry[:too_large] then "over max_file_size, keys not read"
85
+ elsif entry[:unreadable] then "could not be read as YAML"
86
+ elsif entry[:environment_unread] then "keys not read: its env: is not a literal"
87
+ end
88
+ call = "- `#{entry[:key]}` = config_for(#{entry[:call]})#{entry[:read]},"
89
+ named = [ Array(entry[:keys]).join(", ").presence, ("#{entry[:erb_keys]} named by an ERB tag" if entry[:erb_keys]) ].compact
90
+ keys = "keys for #{entry[:environment] || current}: #{named.join(', and ').presence || 'none'}"
91
+ lines << if found then "#{call}#{" `#{entry[:file]}`" if entry[:file]} #{found}"
92
+ elsif entry[:read] then "#{call} one value from `#{entry[:file]}` (#{keys})"
93
+ else "#{call} `#{entry[:file]}` #{keys}"
94
+ end
95
+ end
96
+ end
97
+
98
+ def render_keys(lines, keys, offset:, limit:)
99
+ return if keys.empty?
100
+
101
+ page = paginate(keys, offset: offset, limit: limit, default_limit: 50)
102
+ lines << "" << "**Config keys set (#{page[:total]}):** #{page[:items].map { |k| "`#{k}`" }.join(', ')}"
103
+ lines << page[:hint] unless page[:hint].empty?
104
+ end
105
+
70
106
  # Paging is per environment, so one offset/limit reads the same slice
71
107
  # of every environment listed - which is what makes comparing the same
72
108
  # page across development and production useful.
@@ -81,12 +117,7 @@ module RailsAiContext
81
117
  lines << "- _No notable toggles set_"
82
118
  end
83
119
 
84
- keys = entry[:config_keys] || []
85
- return if keys.empty?
86
-
87
- page = paginate(keys, offset: offset, limit: limit, default_limit: 50)
88
- lines << "" << "**Config keys set (#{page[:total]}):** #{page[:items].map { |k| "`#{k}`" }.join(', ')}"
89
- lines << page[:hint] unless page[:hint].empty?
120
+ render_keys(lines, entry[:config_keys] || [], offset: offset, limit: limit)
90
121
  end
91
122
  end
92
123
  end
@@ -63,12 +63,15 @@ module RailsAiContext
63
63
  parts << "(#{count_phrase(total, "component")})" if total > 0
64
64
 
65
65
  # If no JS framework data, try building a Hotwire summary from cached context
66
+ pipeline = asset_pipeline
67
+ css = css_framework
66
68
  if parts.empty?
67
69
  hotwire = build_hotwire_summary
68
- return hotwire if hotwire
70
+ return [ hotwire, css, pipeline ].compact.join(", ") if hotwire
71
+ return [ "#{pipeline} (no JavaScript build)", css ].compact.join(", ") if pipeline
69
72
  end
70
73
 
71
- return parts.join(" + ") if parts.any?
74
+ return [ *parts, css ].compact.join(" + ") if parts.any?
72
75
 
73
76
  api_only_note("a frontend") || "No frontend framework detected."
74
77
  end
@@ -79,7 +82,6 @@ module RailsAiContext
79
82
  has_turbo = notable.any? { |g| g[:name] == "turbo-rails" }
80
83
  has_stimulus = notable.any? { |g| g[:name] == "stimulus-rails" }
81
84
  has_importmap = notable.any? { |g| g[:name] == "importmap-rails" }
82
- has_tailwind = notable.any? { |g| g[:name] == "tailwindcss-rails" }
83
85
 
84
86
  return nil unless has_turbo || has_stimulus
85
87
 
@@ -95,8 +97,6 @@ module RailsAiContext
95
97
  parts << count_phrase(count, "Stimulus controller") if count > 0
96
98
  end
97
99
 
98
- parts << "Tailwind CSS" if has_tailwind
99
-
100
100
  parts.join(", ")
101
101
  end
102
102
 
@@ -113,7 +113,16 @@ module RailsAiContext
113
113
  state_management = data[:state_management]
114
114
  state_management = state_management.join(", ") if state_management.is_a?(Array)
115
115
  lines << "- **State management:** #{state_management}" if state_management.present?
116
- lines << "- **Package manager:** #{data[:package_manager]}" if data[:package_manager]
116
+ if data[:package_manager]
117
+ where = " (lockfile in `#{data[:package_manager_dir]}`)" if data[:package_manager_dir]
118
+ lines << "- **Package manager:** #{data[:package_manager]}#{where}"
119
+ end
120
+ pipeline_lines = asset_pipeline_lines
121
+ pipeline_lines << "- **CSS framework:** #{css_framework}" if css_framework
122
+ outside = Array(data[:outside_frontend_roots])
123
+ outside_line = "- **Outside the app root (manifests only):** #{outside.map { |p| "`#{p}`" }.join(', ')}" if outside.any?
124
+ lines << outside_line if outside_line
125
+ lines.concat(pipeline_lines)
117
126
 
118
127
  # TypeScript
119
128
  ts_enabled = data[:typescript].is_a?(Hash) && data[:typescript][:enabled]
@@ -151,7 +160,12 @@ module RailsAiContext
151
160
  !has_testing && !has_hotwire && !has_frontend_roots
152
161
 
153
162
  if no_frontend_evidence
154
- return "# Frontend Stack\n\nNo frontend stack detected (API-only app / no app/javascript, no package.json)."
163
+ if pipeline_lines.any?
164
+ return [ "# Frontend Stack", "", *outside_line, *pipeline_lines, "- **JavaScript build:** none (no app/javascript, no package.json)" ].join("\n")
165
+ end
166
+
167
+ note = api_only_note("a frontend") || no_frontend_note
168
+ return [ "# Frontend Stack", "", note, *([ "", outside_line ] if outside_line) ].join("\n")
155
169
  end
156
170
 
157
171
  lines.concat(hotwire_lines)
@@ -218,6 +232,41 @@ module RailsAiContext
218
232
  lines.join("\n")
219
233
  end
220
234
 
235
+ # Gems that could not be read deny no asset pipeline.
236
+ def no_frontend_note
237
+ gems = cached_context[:gems]
238
+ unread = gems[:unavailable] || gems[:error] if gems.is_a?(Hash)
239
+ return "No frontend stack detected (no app/javascript, no package.json, no asset pipeline)." unless unread
240
+
241
+ "No frontend stack detected (no app/javascript, no package.json). Asset pipeline not known: #{unread}."
242
+ end
243
+
244
+ # Read from the gems, as rails_get_config does: the :assets section is
245
+ # in the full preset only.
246
+ def asset_pipeline
247
+ return "Propshaft" if Payload.gem?(cached_context, "propshaft")
248
+
249
+ "Sprockets" if Payload.gem?(cached_context, "sprockets-rails") || Payload.gem?(cached_context, "sprockets")
250
+ end
251
+
252
+ def css_framework
253
+ Introspectors::AssetPipelineIntrospector.css_framework_label(rails_app.root) ||
254
+ ("Tailwind CSS" if Payload.gem?(cached_context, "tailwindcss-rails"))
255
+ end
256
+
257
+ def asset_pipeline_lines
258
+ pipeline = asset_pipeline
259
+ return [] unless pipeline
260
+
261
+ lines = [ "- **Asset pipeline:** #{pipeline}, serving app/assets" ]
262
+ return lines unless pipeline == "Sprockets"
263
+
264
+ content, = RailsAiContext::SafePath.read("app/assets/config/manifest.js", under: rails_app.root.to_s)
265
+ links = content.to_s.scrub.scan(%r{^\s*//=\s*(link\w*\s+\S.*?)\s*$}).flatten
266
+ lines << "- **Sprockets manifest:** #{links.map { |l| "`#{l}`" }.join(', ')}" if links.any?
267
+ lines
268
+ end
269
+
221
270
  # The introspector emits frameworks as a hash of framework symbol =>
222
271
  # package.json version requirement (e.g. { react: "^19.0.0" }), not a
223
272
  # singular framework/version pair. Render the primary (first) entry,