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
|
@@ -76,10 +76,18 @@ module RailsAiContext
|
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
# A tsconfig's compilerOptions as its `extends` chain settles them, and
|
|
79
|
-
# the directory each option was declared in; nil when unreadable.
|
|
80
|
-
|
|
79
|
+
# the directory each option was declared in; nil when unreadable. With
|
|
80
|
+
# `within`, a config is only read through PackageJson.outside_file in that directory.
|
|
81
|
+
def compiler_options(path, seen = Set.new, within: nil)
|
|
81
82
|
return nil if path.nil? || seen.include?(path) || seen.size > MAX_EXTENDS
|
|
82
83
|
|
|
84
|
+
if within
|
|
85
|
+
full = File.expand_path(path)
|
|
86
|
+
return nil unless full.start_with?("#{within}/")
|
|
87
|
+
|
|
88
|
+
path = RailsAiContext::PackageJson.outside_file(within, full.delete_prefix("#{within}/"))&.to_s or return nil
|
|
89
|
+
end
|
|
90
|
+
|
|
83
91
|
seen << path
|
|
84
92
|
text = RailsAiContext::SafeFile.read(path) or return nil
|
|
85
93
|
config = JSON.parse(jsonc(text))
|
|
@@ -89,7 +97,7 @@ module RailsAiContext
|
|
|
89
97
|
dirs = {}
|
|
90
98
|
# A config's own value replaces what it extends; a later `extends` entry replaces an earlier one.
|
|
91
99
|
Array(config["extends"]).each do |spec|
|
|
92
|
-
parent, parent_dirs = compiler_options(extended_config(spec.to_s, path), seen)
|
|
100
|
+
parent, parent_dirs = compiler_options(extended_config(spec.to_s, path), seen, within: within)
|
|
93
101
|
options.merge!(parent.to_h)
|
|
94
102
|
dirs.merge!(parent_dirs.to_h)
|
|
95
103
|
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "yaml"
|
|
4
|
-
|
|
5
3
|
module RailsAiContext
|
|
6
4
|
module Introspectors
|
|
7
5
|
# Discovers multi-database configuration: multiple databases, replicas,
|
|
@@ -10,10 +8,6 @@ module RailsAiContext
|
|
|
10
8
|
extend StaticTier
|
|
11
9
|
static_tier :files_only
|
|
12
10
|
|
|
13
|
-
ERB_SENTINEL = "__rails_ai_context_erb__"
|
|
14
|
-
# `ENV["X"].presence || "mysql2"`: the literal is what runs with the variable unset.
|
|
15
|
-
ERB_DEFAULT = /\|\|\s*(["'])([\w.-]+)\1\s*\z/
|
|
16
|
-
|
|
17
11
|
# @return [Hash] multi-database configuration
|
|
18
12
|
def call
|
|
19
13
|
dbs = discover_databases
|
|
@@ -63,7 +57,7 @@ module RailsAiContext
|
|
|
63
57
|
result[:shard_names] = shard_names if shard_names.any?
|
|
64
58
|
|
|
65
59
|
# Extract shard config from model source via AST
|
|
66
|
-
|
|
60
|
+
model_sources.each do |record|
|
|
67
61
|
ast = SourceIntrospector.walk_source(record.source, {
|
|
68
62
|
connects: -> { Listeners::GenericMacroListener.new(:connects_to) }
|
|
69
63
|
})
|
|
@@ -83,16 +77,16 @@ module RailsAiContext
|
|
|
83
77
|
|
|
84
78
|
def detect_model_connections
|
|
85
79
|
connections = []
|
|
86
|
-
|
|
80
|
+
model_classes.each do |model_name, record|
|
|
87
81
|
ast = SourceIntrospector.walk_source(record.source, {
|
|
88
|
-
connects_to: -> { Listeners::
|
|
82
|
+
connects_to: -> { Listeners::ConditionalMacroListener.new(:connects_to) },
|
|
89
83
|
connected_to: -> { Listeners::GenericMacroListener.new(:connected_to) }
|
|
90
84
|
})
|
|
91
85
|
|
|
92
86
|
if ast[:connects_to].any?
|
|
93
87
|
hit = ast[:connects_to].first
|
|
94
88
|
connects_to_text = hit[:options].map { |k, v| "#{k}: #{format_connects_value(v)}" }.join(", ")
|
|
95
|
-
connections << { model: model_name, connects_to: connects_to_text }
|
|
89
|
+
connections << { model: model_name, connects_to: connects_to_text, condition: hit[:condition] }.compact
|
|
96
90
|
end
|
|
97
91
|
|
|
98
92
|
if ast[:connected_to].any?
|
|
@@ -130,64 +124,22 @@ module RailsAiContext
|
|
|
130
124
|
end
|
|
131
125
|
end
|
|
132
126
|
|
|
133
|
-
# Read as YAML so anchors, merge keys and comments follow the file format itself.
|
|
134
127
|
def file_databases
|
|
135
|
-
|
|
136
|
-
|
|
128
|
+
databases = RailsAiContext::DatabaseYml.databases(root)
|
|
129
|
+
# Rails builds the primary from DATABASE_URL when the file has no entry for the env.
|
|
130
|
+
return ENV["DATABASE_URL"].to_s.empty? ? [] : [ database_entry("primary", {}) ] if databases.empty?
|
|
137
131
|
|
|
138
|
-
|
|
139
|
-
# database per key, anything else is a single primary config.
|
|
140
|
-
if config.values.all? { |value| value.is_a?(Hash) }
|
|
141
|
-
config.map { |name, entry| database_entry(name, entry) }
|
|
142
|
-
else
|
|
143
|
-
[ database_entry("primary", config) ]
|
|
144
|
-
end
|
|
132
|
+
databases.map { |name, entry| database_entry(name, entry) }
|
|
145
133
|
end
|
|
146
134
|
|
|
147
135
|
def database_entry(name, entry)
|
|
148
|
-
adapter, from_default =
|
|
136
|
+
adapter, from_default = RailsAiContext::DatabaseYml.adapter(name, entry)
|
|
149
137
|
info = { name: name.to_s, adapter: adapter }
|
|
150
138
|
info[:adapter_default] = true if from_default
|
|
151
139
|
info[:replica] = true if entry["replica"] == true
|
|
152
140
|
info
|
|
153
141
|
end
|
|
154
142
|
|
|
155
|
-
# An ERB-computed value is unknown, unless the whole value is one tag carrying its own
|
|
156
|
-
# literal default, which the sentinel keeps; two tags compose into something neither said.
|
|
157
|
-
def adapter_value(value)
|
|
158
|
-
return [ nil, false ] if value.nil?
|
|
159
|
-
|
|
160
|
-
text = value.to_s
|
|
161
|
-
return [ text, false ] unless text.include?(ERB_SENTINEL)
|
|
162
|
-
return [ nil, false ] unless text.start_with?(ERB_SENTINEL) && text.scan(ERB_SENTINEL).size == 1
|
|
163
|
-
|
|
164
|
-
literal = text.delete_prefix(ERB_SENTINEL)
|
|
165
|
-
literal.empty? ? [ nil, false ] : [ literal, true ]
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def database_yml_env
|
|
169
|
-
path = File.join(root, "config/database.yml")
|
|
170
|
-
return nil unless File.exist?(path)
|
|
171
|
-
|
|
172
|
-
content = RailsAiContext::SafeFile.read(path)
|
|
173
|
-
return nil unless content
|
|
174
|
-
|
|
175
|
-
data = YAML.safe_load(neutralize_erb(content), aliases: true, permitted_classes: [ Symbol ])
|
|
176
|
-
return nil unless data.is_a?(Hash)
|
|
177
|
-
|
|
178
|
-
data[RailsAiContext.environment_name]
|
|
179
|
-
rescue => e
|
|
180
|
-
RailsAiContext.debug_fail(e, nil, label: "database_yml")
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# ERB never runs: an output tag becomes an unknown marker, other tags go, and both keep
|
|
184
|
-
# their newlines so the rest of the file parses at its written indentation.
|
|
185
|
-
def neutralize_erb(content)
|
|
186
|
-
content.gsub(RailsAiContext::ErbSource::TAG) do |tag|
|
|
187
|
-
(tag.start_with?("<%=") ? ERB_SENTINEL + Regexp.last_match(1).to_s[ERB_DEFAULT, 2].to_s : "") + ("\n" * tag.count("\n"))
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
|
|
191
143
|
def anonymize_db_name(name)
|
|
192
144
|
return name unless name
|
|
193
145
|
|
|
@@ -124,16 +124,13 @@ module RailsAiContext
|
|
|
124
124
|
RailsAiContext.debug_fail(e, {}, label: "detect_server_timing")
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
-
# Rails 8.1
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
# that delegates to `TagStack#with_tags` and `yield`s unconditionally, so
|
|
131
|
-
# a blockless call raises `LocalJumpError`. There is no keyspace to
|
|
132
|
-
# introspect.
|
|
127
|
+
# Rails 8.1's reporter is `Rails.event`; the application object has none.
|
|
128
|
+
# Never call `reporter.tagged`: it yields unconditionally, so a blockless
|
|
129
|
+
# call raises LocalJumpError.
|
|
133
130
|
def detect_event_reporter
|
|
134
|
-
|
|
131
|
+
reporter = Rails.event if Rails.respond_to?(:event)
|
|
132
|
+
return { available: false } unless reporter
|
|
135
133
|
|
|
136
|
-
reporter = app.event_reporter
|
|
137
134
|
entry = { available: true }
|
|
138
135
|
entry[:subscriber_count] = reporter.subscribers.size if reporter.respond_to?(:subscribers) && reporter.subscribers.respond_to?(:size)
|
|
139
136
|
entry
|
|
@@ -75,13 +75,14 @@ module RailsAiContext
|
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
def load_model_data
|
|
78
|
-
|
|
78
|
+
model_sources.filter_map do |record|
|
|
79
79
|
ast = SourceIntrospector.walk_source(record.source, {
|
|
80
80
|
classes: Listeners::ClassDefinitionListener,
|
|
81
81
|
associations: Listeners::AssociationsListener,
|
|
82
82
|
includes: -> { Listeners::ChainedCallListener.new(:includes) },
|
|
83
83
|
tree: TREE_LISTENER,
|
|
84
|
-
mixins: Listeners::MixinsListener
|
|
84
|
+
mixins: Listeners::MixinsListener,
|
|
85
|
+
counter_culture: -> { Listeners::GenericMacroListener.new(:counter_culture) }
|
|
85
86
|
})
|
|
86
87
|
|
|
87
88
|
# The listener names the class node alone, so the qualified name is
|
|
@@ -111,6 +112,7 @@ module RailsAiContext
|
|
|
111
112
|
has_many: has_many,
|
|
112
113
|
belongs_to: belongs_to,
|
|
113
114
|
tree_parent_keys: tree_parent_keys(ast, class_name),
|
|
115
|
+
counter_cultures: ast[:counter_culture].map { |c| counter_culture_record(c) },
|
|
114
116
|
includes_calls: includes_calls
|
|
115
117
|
}
|
|
116
118
|
rescue => e
|
|
@@ -128,9 +130,9 @@ module RailsAiContext
|
|
|
128
130
|
# The parent key a tree macro declares, in the model or its concerns.
|
|
129
131
|
# The models section cannot carry it statically: the macro is a gem's.
|
|
130
132
|
def tree_parent_keys(ast, class_name)
|
|
131
|
-
collected
|
|
132
|
-
|
|
133
|
-
|
|
133
|
+
collected = ConcernMacros.collect(root, ast[:mixins], keys: %i[tree], prefer: "model",
|
|
134
|
+
within: class_name, cache: (@concern_cache ||= {}),
|
|
135
|
+
listeners: TREE_CONCERN_LISTENERS).collected
|
|
134
136
|
(ast[:tree] + Array(collected[:tree])).map do |macro|
|
|
135
137
|
((macro[:options] || {})[TREE_MACROS[macro[:macro]]] || "parent_id").to_s
|
|
136
138
|
end
|
|
@@ -322,7 +324,6 @@ module RailsAiContext
|
|
|
322
324
|
|
|
323
325
|
def detect_missing_counter_cache(model_data, schema_data)
|
|
324
326
|
missing = []
|
|
325
|
-
written = app_written_counter_columns
|
|
326
327
|
|
|
327
328
|
model_data.each do |model|
|
|
328
329
|
model[:has_many].each do |assoc|
|
|
@@ -338,13 +339,13 @@ module RailsAiContext
|
|
|
338
339
|
next unless table
|
|
339
340
|
next unless table[:columns].any? { |c| c[:name] == count_col }
|
|
340
341
|
next if options.key?(:counter_cache)
|
|
341
|
-
next if
|
|
342
|
+
next if app_written_counter_columns.include?(count_col)
|
|
342
343
|
|
|
343
344
|
belongs_to_model = association_model(model_data, assoc, model[:name])
|
|
344
345
|
next unless belongs_to_model
|
|
345
346
|
next if belongs_to_model[:belongs_to].any? { |b| b[:options].key?(:counter_cache) }
|
|
346
|
-
|
|
347
347
|
inverse_name = options[:as] || model[:name].demodulize.underscore
|
|
348
|
+
next if counter_culture_keeps?(belongs_to_model, inverse_name, count_col)
|
|
348
349
|
|
|
349
350
|
missing << {
|
|
350
351
|
model: model[:name],
|
|
@@ -359,6 +360,25 @@ module RailsAiContext
|
|
|
359
360
|
missing
|
|
360
361
|
end
|
|
361
362
|
|
|
363
|
+
# counter_culture names its column `<child table>_count` unless column_name says
|
|
364
|
+
# otherwise; a column_name it computes may be this column, so it is not flagged.
|
|
365
|
+
def counter_culture_keeps?(child, relation, column)
|
|
366
|
+
Array(child[:counter_cultures]).any? do |culture|
|
|
367
|
+
next false unless culture[:relation] == relation.to_s
|
|
368
|
+
|
|
369
|
+
named = culture.fetch(:column) { "#{child[:name].demodulize.tableize}_count" }
|
|
370
|
+
named == :computed || named == column
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# A multi-level `counter_culture [:a, :b]` counts on a further model and has no relation here.
|
|
375
|
+
def counter_culture_record(call)
|
|
376
|
+
node = call[:option_nodes][:column_name]
|
|
377
|
+
record = { relation: call[:args].first&.to_s }
|
|
378
|
+
record[:column] = node.is_a?(Prism::StringNode) || node.is_a?(Prism::SymbolNode) ? node.unescaped : :computed if node
|
|
379
|
+
record
|
|
380
|
+
end
|
|
381
|
+
|
|
362
382
|
# The class an association declares is the one the app has;
|
|
363
383
|
# `has_many :remarks, class_name: "Comment"` is answered by Comment, and
|
|
364
384
|
# never by the Remark the name implies. When no model in the app answers
|
|
@@ -2,79 +2,146 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsAiContext
|
|
4
4
|
module Introspectors
|
|
5
|
-
# Discovers
|
|
5
|
+
# Discovers the app's own commands: rake tasks from the Rakefile, lib/tasks/
|
|
6
|
+
# and rakelib/, and the generators, generator template overrides and
|
|
7
|
+
# Railties it keeps under lib/.
|
|
6
8
|
class RakeTaskIntrospector < Base
|
|
7
9
|
extend StaticTier
|
|
8
10
|
static_tier :files_only
|
|
9
11
|
|
|
12
|
+
# The names Rake looks for, Rakefile first so a case-insensitive disk prints it as written.
|
|
13
|
+
RAKEFILES = %w[Rakefile rakefile Rakefile.rb rakefile.rb].freeze
|
|
14
|
+
|
|
10
15
|
def call
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
rakefile = RAKEFILES.map { |name| File.join(root, name) }.find { |path| File.file?(path) }
|
|
17
|
+
sources = [ rakefile ].compact
|
|
18
|
+
sources += Dir.glob(File.join(root, "lib/tasks", "**/*.rake")).sort
|
|
19
|
+
# Rake imports rakelib/*.rake, one level only.
|
|
20
|
+
sources += Dir.glob(File.join(root, "rakelib", "*.rake")).sort
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
tasks: merge_by_name(sources.flat_map { |path| parse_rake_file(path) }),
|
|
24
|
+
generators: lib_classes[:generators].sort_by { |g| g[:command] }.presence,
|
|
25
|
+
generator_templates: generator_templates.presence,
|
|
26
|
+
railties: lib_classes[:railties].presence
|
|
27
|
+
}.compact
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
RAILTIE_BASES = %w[Rails::Railtie].freeze
|
|
13
33
|
|
|
14
|
-
|
|
15
|
-
|
|
34
|
+
# One pass over lib/ (the cached SourceScan list) for both kinds of class.
|
|
35
|
+
def lib_classes
|
|
36
|
+
@lib_classes ||= { generators: [], railties: [] }.tap do |found|
|
|
37
|
+
SourceScan.each(root, kind: "lib") do |record|
|
|
38
|
+
if record.file.start_with?("lib/generators/") && record.file.end_with?("_generator.rb")
|
|
39
|
+
generator = generator_entry(record)
|
|
40
|
+
found[:generators] << generator if generator
|
|
41
|
+
elsif record.source.include?("Railtie")
|
|
42
|
+
found[:railties].concat(railties_in(record))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
16
45
|
end
|
|
46
|
+
end
|
|
17
47
|
|
|
18
|
-
|
|
48
|
+
# The command Rails::Generators::Base.namespace gives the class:
|
|
49
|
+
# Admin::PageGenerator answers to `admin:page`.
|
|
50
|
+
def generator_entry(record)
|
|
51
|
+
declared = DeclaredConstant.declarations(record.source, path_name: record.path_name).map(&:name).find { |name| name.end_with?("Generator") } or return nil
|
|
52
|
+
entry = { command: "bin/rails generate #{declared.delete_suffix('Generator').underscore.tr('/', ':')}", file: record.file }
|
|
53
|
+
usage = SafeFile.read(File.join(File.dirname(record.path), "USAGE"))
|
|
54
|
+
line = usage&.lines&.map(&:strip)&.find { |text| !text.empty? && text != "Description:" }
|
|
55
|
+
line ? entry.merge(usage: line) : entry
|
|
19
56
|
end
|
|
20
57
|
|
|
21
|
-
|
|
58
|
+
def railties_in(record)
|
|
59
|
+
railties = DeclaredConstant.declarations(record.source, path_name: record.path_name).select { |d| RAILTIE_BASES.include?(d.superclass.to_s.delete_prefix("::")) }
|
|
60
|
+
return [] if railties.empty?
|
|
61
|
+
|
|
62
|
+
calls = SourceIntrospector.walk_source(record.source, { calls: -> { Listeners::GenericMacroListener.new(:initializer, :rake_tasks) } })[:calls]
|
|
63
|
+
initializers = calls.select { |c| c[:macro] == :initializer }.filter_map { |c| c[:values].first if c[:values].first.is_a?(String) }
|
|
64
|
+
railties.map do |railtie|
|
|
65
|
+
{ name: railtie.name, file: record.file, initializers: initializers, rake_tasks: calls.any? { |c| c[:macro] == :rake_tasks } }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
22
68
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
69
|
+
# Rails adds lib/templates to every generator's source paths ahead of its
|
|
70
|
+
# own, so a file at lib/templates/<namespace>/ replaces that generator's template.
|
|
71
|
+
def generator_templates
|
|
72
|
+
dir = File.join(root, "lib", "templates")
|
|
73
|
+
return [] unless File.directory?(dir) && SafePath.contained?(File.realpath(dir), File.realpath(root))
|
|
27
74
|
|
|
28
|
-
|
|
29
|
-
|
|
75
|
+
Dir.glob(File.join(dir, "**", "*")).sort.filter_map do |path|
|
|
76
|
+
next unless File.file?(path) && SafePath.contained?(File.realpath(path), File.realpath(root))
|
|
30
77
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
namespace_indents = []
|
|
35
|
-
tasks = []
|
|
78
|
+
relative = path.delete_prefix("#{dir}/")
|
|
79
|
+
namespace = File.dirname(relative)
|
|
80
|
+
next if namespace == "."
|
|
36
81
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
line_events = {}
|
|
41
|
-
results.each { |r| (line_events[r[:location]] ||= []) << r }
|
|
82
|
+
{ file: "lib/templates/#{relative}", generator: namespace.tr("/", ":") }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
42
85
|
|
|
43
|
-
|
|
44
|
-
|
|
86
|
+
# Rake keeps one task per name: a later definition adds its prerequisites and
|
|
87
|
+
# description, and replaces the arguments only when it names some.
|
|
88
|
+
def merge_by_name(entries)
|
|
89
|
+
merged = {}
|
|
90
|
+
entries.each do |entry|
|
|
91
|
+
next merged[entry.object_id] = entry if entry[:error]
|
|
45
92
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
93
|
+
task = merged[entry[:name]] ||= { name: entry[:name], comments: [], file: entry[:file] }
|
|
94
|
+
comment = entry[:description]&.strip
|
|
95
|
+
task[:comments] << comment if comment.present? && !task[:comments].include?(comment)
|
|
96
|
+
task[:dependencies] = Array(task[:dependencies]) | entry[:dependencies] if entry[:dependencies]
|
|
97
|
+
task[:args] = entry[:args] if entry[:args]
|
|
98
|
+
end
|
|
99
|
+
merged.values.map do |task|
|
|
100
|
+
next task if task[:error]
|
|
50
101
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
102
|
+
comments = task.delete(:comments)
|
|
103
|
+
task.merge(description: comments.filter_map { |c| first_sentence(c) }.join(" / ").presence).compact
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Rake::Task#first_sentence, which rake -T prints.
|
|
108
|
+
def first_sentence(text)
|
|
109
|
+
text.split(/(?<=\w)(\.|!)[ \t]|(\.$|!)|\n/).first
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def parse_rake_file(path)
|
|
113
|
+
relative = path.delete_prefix("#{root}/")
|
|
114
|
+
content, located = RailsAiContext::SafePath.read(relative, under: root)
|
|
115
|
+
return [] if %i[outside sensitive traversal].include?(located.refusal)
|
|
116
|
+
return [ { file: relative, error: "unreadable" } ] unless content
|
|
117
|
+
|
|
118
|
+
ast_data = SourceIntrospector.walk_source(content, { rake: -> { Listeners::RakeTaskDslListener.new } })
|
|
119
|
+
results = ast_data[:rake]
|
|
120
|
+
|
|
121
|
+
# Rake's desc applies to the next task defined, whatever namespace it is in.
|
|
122
|
+
last_desc = nil
|
|
123
|
+
scopes = []
|
|
124
|
+
tasks = []
|
|
125
|
+
results.each do |entry|
|
|
126
|
+
scopes.pop while scopes.any? && entry[:offset] >= scopes.last[:end_offset]
|
|
127
|
+
|
|
128
|
+
case entry[:type]
|
|
129
|
+
when :namespace
|
|
130
|
+
scopes.push(entry)
|
|
131
|
+
when :desc
|
|
132
|
+
last_desc = entry[:description]
|
|
133
|
+
when :task
|
|
134
|
+
task = { name: (scopes.map { |ns| ns[:name] } + [ entry[:name] ]).join(":"), description: last_desc, file: relative }
|
|
135
|
+
task[:dependencies] = entry[:deps] if entry[:deps]&.any?
|
|
136
|
+
task[:args] = entry[:args] if entry[:args]&.any?
|
|
137
|
+
tasks << task.compact
|
|
138
|
+
last_desc = nil
|
|
72
139
|
end
|
|
73
140
|
end
|
|
74
141
|
|
|
75
142
|
tasks
|
|
76
143
|
rescue => e
|
|
77
|
-
[ { file:
|
|
144
|
+
[ { file: relative, error: e.message } ]
|
|
78
145
|
end
|
|
79
146
|
end
|
|
80
147
|
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Introspectors
|
|
5
|
+
# Recurring tasks from each scheduler's own file, read the way that gem reads it.
|
|
6
|
+
module RecurringSchedules
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# `assignments` returns a config file's ConfigAssignmentListener hits from the caller's own walk, [] when it never names good_job.
|
|
10
|
+
def read(root, assignments)
|
|
11
|
+
solid_queue(root) + sidekiq_cron(root) + sidekiq_scheduler(root) + good_job(root, assignments) + whenever(root)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Solid Queue takes the section named for the environment, else the whole file,
|
|
15
|
+
# and keeps only the entries that carry a `schedule`.
|
|
16
|
+
def solid_queue(root)
|
|
17
|
+
file = "config/recurring.yml"
|
|
18
|
+
data = yaml(root, file)
|
|
19
|
+
return [] unless data.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
data.flat_map do |key, value|
|
|
22
|
+
next [] unless value.is_a?(Hash)
|
|
23
|
+
next [ task(file, key, value, :schedule) ].compact if value.key?("schedule")
|
|
24
|
+
|
|
25
|
+
value.filter_map { |name, options| task(file, name, options, :schedule, env: key) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def sidekiq_cron(root)
|
|
30
|
+
%w[config/schedule.yml config/schedule.yaml config/sidekiq_cron.yml].flat_map do |file|
|
|
31
|
+
named_tasks(yaml(root, file)).filter_map { |name, options| task(file, name, options, :cron) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# sidekiq-scheduler runs a job by its entry's name when no class is given.
|
|
36
|
+
def sidekiq_scheduler(root)
|
|
37
|
+
file = "config/sidekiq.yml"
|
|
38
|
+
data = yaml(root, file)
|
|
39
|
+
return [] unless data.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
section = data["scheduler"].is_a?(Hash) ? data["scheduler"]["schedule"] : data["schedule"]
|
|
42
|
+
named_tasks(section).filter_map do |name, options|
|
|
43
|
+
next unless options.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
kind = SCHEDULER_KEYS.find { |key| options.key?(key.to_s) } or next
|
|
46
|
+
task(file, name, { "class" => name }.merge(options), kind)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
SCHEDULER_KEYS = %i[cron every at in interval].freeze
|
|
51
|
+
|
|
52
|
+
# In the order Rails loads them; initializers at any depth, sorted by path.
|
|
53
|
+
GOOD_JOB_FILES = %w[config/application.rb config/environments/*.rb config/initializers/**/*.rb].freeze
|
|
54
|
+
|
|
55
|
+
# `config.good_job.cron = {...}`, or entries merged into it (apps also merge! in after_initialize).
|
|
56
|
+
GOOD_JOB_CRON = [ %i[good_job cron], %i[good_job cron merge!], %i[good_job cron update] ].freeze
|
|
57
|
+
|
|
58
|
+
# `cron =` replaces the hash, dropping what earlier files set; an environment
|
|
59
|
+
# file's assignment drops only that file's earlier entries.
|
|
60
|
+
def good_job(root, assignments)
|
|
61
|
+
GOOD_JOB_FILES.flat_map { |pattern| Dir.glob(pattern, base: root.to_s).sort }.each_with_object([]) do |file, tasks|
|
|
62
|
+
env = File.basename(file, ".rb") if file.start_with?("config/environments/")
|
|
63
|
+
assignments.call(file).each do |hit|
|
|
64
|
+
next unless GOOD_JOB_CRON.include?(hit[:path])
|
|
65
|
+
|
|
66
|
+
tasks.reject! { |entry| env.nil? || entry[:file] == file } if hit[:assignment] && !hit[:condition]
|
|
67
|
+
next unless hit[:value].is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
tasks.concat(hit[:value].filter_map { |name, options| task(file, name, stringify(options), :cron, env: env) })
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
WHENEVER_JOBS = %i[runner rake command].freeze
|
|
75
|
+
|
|
76
|
+
# `every 1.day, at: "4:30 am" do runner "CleanupJob.perform_later" end`: the job is
|
|
77
|
+
# the constant the runner's code starts with.
|
|
78
|
+
def whenever(root)
|
|
79
|
+
file = "config/schedule.rb"
|
|
80
|
+
source = read_file(root, file) or return []
|
|
81
|
+
hits = SourceIntrospector.walk_source(source, {
|
|
82
|
+
every: -> { Listeners::GenericMacroListener.new(:every, *WHENEVER_JOBS) }
|
|
83
|
+
})[:every]
|
|
84
|
+
everies = hits.select { |hit| hit[:macro] == :every }.to_h { |hit| [ hit[:offset], hit ] }
|
|
85
|
+
hits.filter_map do |hit|
|
|
86
|
+
every = everies[hit[:parent_offset]]
|
|
87
|
+
code = hit[:values].first
|
|
88
|
+
next unless every && WHENEVER_JOBS.include?(hit[:macro]) && code.is_a?(String)
|
|
89
|
+
|
|
90
|
+
klass = code[/\A\s*(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)\./, 1] if hit[:macro] == :runner
|
|
91
|
+
{ class: klass, command: hit[:macro] == :runner ? code : "#{hit[:macro]} #{code}",
|
|
92
|
+
schedule: whenever_schedule(every), file: file }.compact
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def whenever_schedule(every)
|
|
97
|
+
text = "every #{every[:values].first}"
|
|
98
|
+
at = every[:option_values][:at]
|
|
99
|
+
at ? "#{text} at #{at}" : text
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def task(file, name, options, kind, env: nil)
|
|
103
|
+
return nil unless options.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
options = options.transform_values { |value| computed?(value) ? :computed : value }
|
|
106
|
+
schedule = options[kind.to_s]
|
|
107
|
+
return nil if schedule.nil?
|
|
108
|
+
|
|
109
|
+
# sidekiq-scheduler's `every: ['5m', first_in: '4m']`: the interval, then its options.
|
|
110
|
+
schedule = Array(schedule).flat_map { |part| part.is_a?(Hash) ? part.map { |key, value| "#{key}: #{value}" } : [ part ] }.join(", ")
|
|
111
|
+
|
|
112
|
+
klass = options["class"] || options["klass"]
|
|
113
|
+
computed = ("class" if klass == :computed) || ("command" if options["command"] == :computed)
|
|
114
|
+
klass = nil if klass == :computed
|
|
115
|
+
command = options["command"] unless options["command"] == :computed
|
|
116
|
+
{ name: computed?(name.to_s) ? "computed" : name.to_s, class: klass&.to_s&.delete_prefix("::"), command: command&.to_s,
|
|
117
|
+
computed: computed, schedule: schedule.to_s, env: computed?(env.to_s) ? "computed" : env&.to_s, file: file }.compact
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
ERB_OUTPUT = ConfigYaml::ERB_OUTPUT
|
|
121
|
+
|
|
122
|
+
def computed?(value)
|
|
123
|
+
value == RailsAiContext::Confidence::INFERRED || ConfigYaml.marked?(value) ||
|
|
124
|
+
(value.is_a?(Array) && value.any? { |part| computed?(part) }) ||
|
|
125
|
+
(value.is_a?(Hash) && value.values.any? { |part| computed?(part) })
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# sidekiq-cron accepts a hash keyed by name or a list of hashes that carry it.
|
|
129
|
+
def named_tasks(data)
|
|
130
|
+
case data
|
|
131
|
+
when Hash then data.to_a
|
|
132
|
+
when Array then data.filter_map { |entry| [ entry["name"], entry ] if entry.is_a?(Hash) }
|
|
133
|
+
else []
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def stringify(value)
|
|
138
|
+
value.is_a?(Hash) ? value.to_h { |key, inner| [ key.to_s, inner ] } : value
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def yaml(root, file)
|
|
142
|
+
ConfigYaml.read(root, file, label: "schedule", marker: ERB_OUTPUT)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def read_file(root, file)
|
|
146
|
+
SafePath.read(file, under: root.to_s).first
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|