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
|
@@ -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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|