rails-ai-context 5.30.2 → 5.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +868 -0
- data/CONTEXT.md +7 -3
- data/README.md +1 -1
- data/docs/ARCHITECTURE.md +3 -3
- data/docs/CLI.md +1 -1
- data/docs/COMPATIBILITY.md +50 -14
- data/docs/CONFIGURATION.md +4 -1
- data/docs/FAQ.md +1 -1
- data/docs/GUIDE.md +14 -8
- data/docs/INTROSPECTORS.md +50 -23
- data/docs/SECURITY.md +56 -1
- data/docs/TOOLS.md +58 -12
- data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
- data/lib/rails_ai_context/action_filters.rb +121 -48
- data/lib/rails_ai_context/app_commands.rb +2 -2
- data/lib/rails_ai_context/app_kind.rb +65 -8
- data/lib/rails_ai_context/ast_cache.rb +53 -18
- data/lib/rails_ai_context/base_mixins.rb +46 -16
- data/lib/rails_ai_context/boot_manager.rb +5 -1
- data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
- data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
- data/lib/rails_ai_context/concern_macros.rb +791 -111
- data/lib/rails_ai_context/concern_membership.rb +25 -5
- data/lib/rails_ai_context/concern_paths.rb +63 -17
- data/lib/rails_ai_context/config_yaml.rb +40 -0
- data/lib/rails_ai_context/configuration.rb +3 -3
- data/lib/rails_ai_context/data/docs/index.json +324 -0
- data/lib/rails_ai_context/database_yml.rb +136 -0
- data/lib/rails_ai_context/doctor.rb +30 -37
- data/lib/rails_ai_context/erb_source.rb +40 -0
- data/lib/rails_ai_context/fingerprinter.rb +48 -22
- data/lib/rails_ai_context/fixture_keys.rb +42 -1
- data/lib/rails_ai_context/gem_lock.rb +275 -47
- data/lib/rails_ai_context/git_ignore.rb +13 -11
- data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
- data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
- data/lib/rails_ai_context/introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
- data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
- data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
- data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
- data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
- data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
- data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
- data/lib/rails_ai_context/introspectors/base.rb +38 -0
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
- data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
- data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
- data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
- data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
- data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
- data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
- data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
- data/lib/rails_ai_context/introspectors/includers.rb +6 -1
- data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
- data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
- data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
- data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
- data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
- data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
- data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
- data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
- data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
- data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
- data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
- data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
- data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
- data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
- data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
- data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
- data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
- data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
- data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
- data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
- data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
- data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
- data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
- data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
- data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
- data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
- data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
- data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
- data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
- data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
- data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
- data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
- data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
- data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
- data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
- data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
- data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
- data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
- data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
- data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
- data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
- data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
- data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
- data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
- data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
- data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
- data/lib/rails_ai_context/method_name.rb +31 -0
- data/lib/rails_ai_context/migration_status.rb +5 -5
- data/lib/rails_ai_context/output_guard.rb +6 -4
- data/lib/rails_ai_context/package_json.rb +96 -5
- data/lib/rails_ai_context/path_resolver.rb +170 -15
- data/lib/rails_ai_context/payload.rb +48 -0
- data/lib/rails_ai_context/pending_migrations.rb +15 -18
- data/lib/rails_ai_context/portable_path.rb +76 -4
- data/lib/rails_ai_context/redaction.rb +8 -2
- data/lib/rails_ai_context/route_coverage.rb +5 -1
- data/lib/rails_ai_context/safe_file.rb +4 -1
- data/lib/rails_ai_context/safe_path.rb +61 -10
- data/lib/rails_ai_context/schema_hint.rb +8 -2
- data/lib/rails_ai_context/schema_version.rb +7 -9
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
- data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
- data/lib/rails_ai_context/server.rb +26 -7
- data/lib/rails_ai_context/test_framework.rb +32 -5
- data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
- data/lib/rails_ai_context/tools/base_tool.rb +69 -30
- data/lib/rails_ai_context/tools/diagnose.rb +1 -1
- data/lib/rails_ai_context/tools/generate_test.rb +8 -9
- data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
- data/lib/rails_ai_context/tools/get_api.rb +17 -11
- data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
- data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
- data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
- data/lib/rails_ai_context/tools/get_concern.rb +31 -18
- data/lib/rails_ai_context/tools/get_config.rb +52 -18
- data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
- data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
- data/lib/rails_ai_context/tools/get_engines.rb +2 -0
- data/lib/rails_ai_context/tools/get_env.rb +209 -52
- data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
- data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
- data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
- data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
- data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
- data/lib/rails_ai_context/tools/get_routes.rb +35 -9
- data/lib/rails_ai_context/tools/get_schema.rb +160 -61
- data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
- data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
- data/lib/rails_ai_context/tools/get_view.rb +37 -20
- data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
- data/lib/rails_ai_context/tools/onboard.rb +174 -88
- data/lib/rails_ai_context/tools/performance_check.rb +5 -13
- data/lib/rails_ai_context/tools/query.rb +122 -28
- data/lib/rails_ai_context/tools/read_logs.rb +45 -17
- data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
- data/lib/rails_ai_context/tools/safe_call.rb +20 -15
- data/lib/rails_ai_context/tools/search_code.rb +114 -40
- data/lib/rails_ai_context/tools/search_docs.rb +22 -11
- data/lib/rails_ai_context/tools/security_scan.rb +51 -4
- data/lib/rails_ai_context/tools/validate.rb +21 -4
- data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +2 -2
- data/lib/rails_ai_context/view_file.rb +53 -3
- data/lib/rails_ai_context.rb +10 -2
- data/server.json +1 -1
- metadata +34 -3
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module RailsAiContext
|
|
6
|
+
# config/database.yml read without booting: YAML, so anchors, merge keys and
|
|
7
|
+
# comments follow the file format itself, and ERB never runs.
|
|
8
|
+
module DatabaseYml
|
|
9
|
+
ERB_SENTINEL = "__rails_ai_context_erb__"
|
|
10
|
+
# `ENV["X"].presence || "mysql2"`: the literal is what runs with the variable unset.
|
|
11
|
+
ERB_DEFAULT = /\|\|\s*(["'])([\w.-]+)\1\s*\z/
|
|
12
|
+
# ActiveRecord.protocol_adapters' defaults.
|
|
13
|
+
URL_SCHEME_ADAPTERS = { "postgres" => "postgresql", "mysql" => "mysql2", "sqlite" => "sqlite3" }.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# The running environment's entry, or nil when the file is missing or unreadable.
|
|
18
|
+
def env(root)
|
|
19
|
+
data = file(root)
|
|
20
|
+
data[RailsAiContext.environment_name] if data
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Every environment by name. Every database question routes here, so a run reads the file once.
|
|
24
|
+
def file(root)
|
|
25
|
+
RailsAiContext::RunCache.fetch([ :database_yml, root.to_s ]) { read_file(root) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def read_file(root)
|
|
29
|
+
path = File.join(root.to_s, "config/database.yml")
|
|
30
|
+
return nil unless File.exist?(path)
|
|
31
|
+
|
|
32
|
+
content = RailsAiContext::SafeFile.read(path)
|
|
33
|
+
return nil unless content
|
|
34
|
+
|
|
35
|
+
data = YAML.safe_load(neutralize_erb(content), aliases: true, permitted_classes: [ Symbol ])
|
|
36
|
+
data if data.is_a?(Hash)
|
|
37
|
+
rescue => e
|
|
38
|
+
RailsAiContext.debug_fail(e, nil, label: "database_yml")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Each database by name, in the file's order. An env whose values are all Hashes names one database per key.
|
|
42
|
+
def databases(root)
|
|
43
|
+
databases_in(env(root))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def databases_in(config)
|
|
47
|
+
return {} unless config.is_a?(Hash) && config.any?
|
|
48
|
+
|
|
49
|
+
config.values.all?(Hash) ? config : { "primary" => config }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Rails' rule: "primary", else the first database.
|
|
53
|
+
def primary_name(root)
|
|
54
|
+
primary_name_in(databases(root))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def primary_name_in(found)
|
|
58
|
+
found.key?("primary") ? "primary" : found.keys.first
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The databases other than the primary that Rails dumps and migrates:
|
|
62
|
+
# HashConfig#database_tasks? skips a replica and database_tasks: false.
|
|
63
|
+
def task_secondaries(root)
|
|
64
|
+
databases(root).except(primary_name(root)).select { |_, entry| !entry["replica"] && entry.fetch("database_tasks", true) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The primary database's settings.
|
|
68
|
+
def primary(root)
|
|
69
|
+
databases(root)[primary_name(root)]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The named database's settings in the running environment, or nil.
|
|
73
|
+
def entry(root, name)
|
|
74
|
+
entry_in(databases(root), name)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# [environment, settings] from the first other environment that configures the database,
|
|
78
|
+
# the one whose tasks wrote its dump; nil when none does.
|
|
79
|
+
def elsewhere(root, name)
|
|
80
|
+
data = file(root)
|
|
81
|
+
return nil unless data
|
|
82
|
+
|
|
83
|
+
# A key with no config/environments file holds an anchor (`default: &default`), not an environment.
|
|
84
|
+
declared = Dir.glob(File.join(root.to_s, "config/environments/*.rb")).map { |path| File.basename(path, ".rb") }
|
|
85
|
+
data.each do |env_name, config|
|
|
86
|
+
next if env_name == RailsAiContext.environment_name
|
|
87
|
+
next if declared.any? && !declared.include?(env_name)
|
|
88
|
+
|
|
89
|
+
found = entry_in(databases_in(config), name)
|
|
90
|
+
return [ env_name, found ] if found.is_a?(Hash)
|
|
91
|
+
end
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def entry_in(found, name)
|
|
96
|
+
found[name] || (found[primary_name_in(found)] if name == "primary")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Rails' DatabaseConfigurations: an entry's own url wins over its keys, and an entry
|
|
100
|
+
# without one takes <NAME>_DATABASE_URL, or DATABASE_URL for the primary.
|
|
101
|
+
def url_adapter(name, own_url)
|
|
102
|
+
url = own_url.nil? ? ENV["#{name.upcase}_DATABASE_URL"] || (ENV["DATABASE_URL"] if name == "primary") : own_url.to_s
|
|
103
|
+
return nil if url.to_s.empty? || computed?(url)
|
|
104
|
+
|
|
105
|
+
scheme = url[/\A([a-z][a-z0-9+.-]*):/i, 1]&.tr("-", "_")
|
|
106
|
+
scheme && URL_SCHEME_ADAPTERS.fetch(scheme, scheme)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# [adapter, from_default]: the URL's scheme wins, then the entry's adapter. An ERB-computed
|
|
110
|
+
# value is unknown unless it is one tag carrying its own literal default.
|
|
111
|
+
def adapter(name, entry)
|
|
112
|
+
entry = {} unless entry.is_a?(Hash)
|
|
113
|
+
from_url = url_adapter(name.to_s, entry["url"])
|
|
114
|
+
return [ from_url, false ] if from_url
|
|
115
|
+
|
|
116
|
+
text = entry["adapter"]&.to_s
|
|
117
|
+
return [ text, false ] unless computed?(text)
|
|
118
|
+
return [ nil, false ] unless text.start_with?(ERB_SENTINEL) && text.scan(ERB_SENTINEL).size == 1
|
|
119
|
+
|
|
120
|
+
literal = text.delete_prefix(ERB_SENTINEL)
|
|
121
|
+
literal.empty? ? [ nil, false ] : [ literal, true ]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# An output tag becomes an unknown marker, other tags go, and both keep their
|
|
125
|
+
# newlines so the rest of the file parses at its written indentation.
|
|
126
|
+
def neutralize_erb(content)
|
|
127
|
+
content.gsub(RailsAiContext::ErbSource::TAG) do |tag|
|
|
128
|
+
(tag.start_with?("<%=") ? ERB_SENTINEL + Regexp.last_match(1).to_s[ERB_DEFAULT, 2].to_s : "") + ("\n" * tag.count("\n"))
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def computed?(value)
|
|
133
|
+
value.to_s.include?(ERB_SENTINEL)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -97,14 +97,14 @@ module RailsAiContext
|
|
|
97
97
|
# ── Existence checks ──────────────────────────────────────────────
|
|
98
98
|
|
|
99
99
|
def check_schema
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if
|
|
103
|
-
lines = File.readlines(
|
|
104
|
-
Check.new(name: "Schema", status: :pass, message: "
|
|
105
|
-
elsif
|
|
106
|
-
size = (File.size(
|
|
107
|
-
Check.new(name: "Schema", status: :pass, message: "
|
|
100
|
+
format, path = RailsAiContext::Introspectors::SchemaDumpPath.present(app.root)
|
|
101
|
+
shown = path.to_s.delete_prefix("#{app.root.to_s.chomp('/')}/")
|
|
102
|
+
if format == :ruby
|
|
103
|
+
lines = File.readlines(path).size
|
|
104
|
+
Check.new(name: "Schema", status: :pass, message: "#{shown} found (#{count_phrase(lines, "line")})", fix: nil)
|
|
105
|
+
elsif format == :sql
|
|
106
|
+
size = (File.size(path) / 1024.0).round(1)
|
|
107
|
+
Check.new(name: "Schema", status: :pass, message: "#{shown} found (#{size}KB)", fix: nil)
|
|
108
108
|
else
|
|
109
109
|
Check.new(name: "Schema", status: :warn, message: "No schema file found", fix: "Run `rails db:schema:dump`")
|
|
110
110
|
end
|
|
@@ -113,7 +113,7 @@ module RailsAiContext
|
|
|
113
113
|
def check_pending_migrations
|
|
114
114
|
return nil unless defined?(ActiveRecord::Base)
|
|
115
115
|
|
|
116
|
-
pending = RailsAiContext::PendingMigrations.live(RailsAiContext::PendingMigrations.
|
|
116
|
+
pending = RailsAiContext::PendingMigrations.live(RailsAiContext::PendingMigrations.migrate_dirs_for(app.root))
|
|
117
117
|
return nil unless pending
|
|
118
118
|
|
|
119
119
|
if pending.empty?
|
|
@@ -144,11 +144,11 @@ module RailsAiContext
|
|
|
144
144
|
end
|
|
145
145
|
|
|
146
146
|
def check_gems
|
|
147
|
-
|
|
148
|
-
if File.exist?(
|
|
149
|
-
Check.new(name: "Gems", status: :pass, message: "
|
|
147
|
+
lockfile = GemLock.lockfile_name(app.root)
|
|
148
|
+
if File.exist?(File.join(app.root, lockfile))
|
|
149
|
+
Check.new(name: "Gems", status: :pass, message: "#{lockfile} found", fix: nil)
|
|
150
150
|
else
|
|
151
|
-
Check.new(name: "Gems", status: :warn, message: "
|
|
151
|
+
Check.new(name: "Gems", status: :warn, message: "#{lockfile} not found", fix: "Run `bundle install`")
|
|
152
152
|
end
|
|
153
153
|
end
|
|
154
154
|
|
|
@@ -182,9 +182,8 @@ module RailsAiContext
|
|
|
182
182
|
end
|
|
183
183
|
|
|
184
184
|
def check_migrations
|
|
185
|
-
|
|
186
|
-
if
|
|
187
|
-
count = Dir.glob(File.join(migrate_dir, "*.rb")).size
|
|
185
|
+
count = RailsAiContext::PendingMigrations.migration_files(RailsAiContext::PendingMigrations.migrate_dirs_for(app.root), root: app.root).size
|
|
186
|
+
if count.positive?
|
|
188
187
|
Check.new(name: "Migrations", status: :pass, message: count_phrase(count, "migration file"), fix: nil)
|
|
189
188
|
else
|
|
190
189
|
Check.new(name: "Migrations", status: :warn, message: "No migrations", fix: nil)
|
|
@@ -438,13 +437,16 @@ module RailsAiContext
|
|
|
438
437
|
introspector = RailsAiContext::Introspector.new(app)
|
|
439
438
|
failures = []
|
|
440
439
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
440
|
+
# One run, as introspection has: the file lists and stats each introspector asks for are shared.
|
|
441
|
+
RunCache.around do
|
|
442
|
+
config.introspectors.each do |name|
|
|
443
|
+
result = introspector.send(:resolve_introspector, name).call
|
|
444
|
+
failures << [ name.to_s, result[:error].to_s ] if result.is_a?(Hash) && result[:error]
|
|
445
|
+
rescue StandardError, ScriptError => e
|
|
446
|
+
# ScriptError included: a syntax-broken app file must cost one
|
|
447
|
+
# introspector, not the diagnosis the user ran doctor for.
|
|
448
|
+
failures << [ name.to_s, "#{e.class}: #{e.message}" ]
|
|
449
|
+
end
|
|
448
450
|
end
|
|
449
451
|
|
|
450
452
|
if failures.empty?
|
|
@@ -577,21 +579,15 @@ module RailsAiContext
|
|
|
577
579
|
|
|
578
580
|
return Check.new(name: "Secrets in .gitignore", status: :pass, message: "No sensitive files found", fix: nil) if sensitive_files.empty?
|
|
579
581
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
message: "No .gitignore found - #{sensitive_files.join(', ')} would be committed to version control",
|
|
583
|
-
fix: "Create .gitignore with: #{sensitive_files.map { |f| "`#{f}`" }.join(', ')}")
|
|
584
|
-
end
|
|
585
|
-
|
|
586
|
-
gitignore = File.read(gitignore_path)
|
|
587
|
-
exposed = sensitive_files.reject { |file| gitignore_covers?(gitignore, file) }
|
|
582
|
+
gitignore = File.read(gitignore_path) if File.exist?(gitignore_path)
|
|
583
|
+
exposed = gitignore ? sensitive_files.reject { |file| gitignore_covers?(gitignore, file) } : sensitive_files
|
|
588
584
|
never, others = exposed.partition { |file| NEVER_COMMIT.any? { |pattern| File.fnmatch(pattern, file, File::FNM_PATHNAME | File::FNM_DOTMATCH) } }
|
|
589
585
|
|
|
590
586
|
if never.any?
|
|
591
|
-
message = never.map { |file| "#{file} not in .gitignore" }.join("; ")
|
|
587
|
+
message = gitignore ? never.map { |file| "#{file} not in .gitignore" }.join("; ") : "No .gitignore found - #{never.join(', ')} would be committed"
|
|
592
588
|
message += "; also committed: #{others.join(', ')}" if others.any?
|
|
593
589
|
Check.new(name: "Secrets in .gitignore", status: :fail, message: message,
|
|
594
|
-
fix: "Add to .gitignore: #{never.map { |file| "`#{file}`" }.join(', ')}")
|
|
590
|
+
fix: "#{gitignore ? 'Add to .gitignore' : 'Create .gitignore with'}: #{never.map { |file| "`#{file}`" }.join(', ')}")
|
|
595
591
|
elsif others.any?
|
|
596
592
|
Check.new(name: "Secrets in .gitignore", status: :warn,
|
|
597
593
|
message: "Committed, and never read by the tools: #{others.join(', ')}",
|
|
@@ -658,10 +654,7 @@ module RailsAiContext
|
|
|
658
654
|
|
|
659
655
|
def check_performance_schema_size
|
|
660
656
|
config = RailsAiContext.configuration
|
|
661
|
-
|
|
662
|
-
structure_path = File.join(app.root, "db/structure.sql")
|
|
663
|
-
|
|
664
|
-
path = File.exist?(schema_path) ? schema_path : (File.exist?(structure_path) ? structure_path : nil)
|
|
657
|
+
_, path = RailsAiContext::Introspectors::SchemaDumpPath.present(app.root)
|
|
665
658
|
return nil unless path
|
|
666
659
|
|
|
667
660
|
size = File.size(path)
|
|
@@ -48,6 +48,46 @@ module RailsAiContext
|
|
|
48
48
|
source.to_s.gsub(TAG) { "\n" * Regexp.last_match(0).count("\n") }
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Like without_tags, but an output tag (`<%= %>`) becomes `marker`, so a value
|
|
52
|
+
# or key it builds reads as computed. A tag alone on its line writes lines we
|
|
53
|
+
# cannot see, and a marker there would break the YAML around it, unless the
|
|
54
|
+
# line above opens a value the tag fills. Each marker is numbered, so two keys tags name stay two keys.
|
|
55
|
+
def with_output_marked(source, marker)
|
|
56
|
+
source = source.to_s
|
|
57
|
+
count = 0
|
|
58
|
+
source.gsub(TAG) do
|
|
59
|
+
match = Regexp.last_match
|
|
60
|
+
tag = match[0]
|
|
61
|
+
next "\n" * tag.count("\n") unless tag.start_with?("<%=") && !lines_written?(source, match.begin(0), match.end(0))
|
|
62
|
+
|
|
63
|
+
"#{marker}_#{count += 1}_"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
BLOCK_SCALAR = /(?:\A|\s)[|>][-+0-9]*\z/
|
|
68
|
+
VALUE_OPENER = /:\z/
|
|
69
|
+
private_constant :BLOCK_SCALAR, :VALUE_OPENER
|
|
70
|
+
|
|
71
|
+
def lines_written?(source, from, to)
|
|
72
|
+
line_start = from.zero? ? 0 : (source.rindex("\n", from - 1) || -1) + 1
|
|
73
|
+
line_end = source.index("\n", to) || source.size
|
|
74
|
+
return false unless source[line_start...from].strip.empty? && source[to...line_end].strip.empty?
|
|
75
|
+
|
|
76
|
+
above = source[0...line_start].rstrip.lines.last.to_s.rstrip
|
|
77
|
+
return false if above.match?(BLOCK_SCALAR)
|
|
78
|
+
return true unless above.match?(VALUE_OPENER)
|
|
79
|
+
|
|
80
|
+
# A sibling at the tag's indent below means the tag wrote keys, not the value.
|
|
81
|
+
below = source[line_end..].to_s.lines.find { |line| !line.strip.empty? }
|
|
82
|
+
!below.nil? && indent(below) >= from - line_start
|
|
83
|
+
end
|
|
84
|
+
private_class_method :lines_written?
|
|
85
|
+
|
|
86
|
+
def indent(line)
|
|
87
|
+
line[/\A */].size
|
|
88
|
+
end
|
|
89
|
+
private_class_method :indent
|
|
90
|
+
|
|
51
91
|
def blank(text)
|
|
52
92
|
text.gsub(/[^\n]/, " ")
|
|
53
93
|
end
|
|
@@ -6,18 +6,18 @@ module RailsAiContext
|
|
|
6
6
|
# Computes a SHA256 fingerprint of key application files to detect changes.
|
|
7
7
|
# Used by BaseTool to invalidate cached introspection when files change.
|
|
8
8
|
class Fingerprinter
|
|
9
|
-
# The root manifests
|
|
10
|
-
|
|
11
|
-
WATCHED_FILES = %w[
|
|
12
|
-
db/structure.sql
|
|
9
|
+
# The root manifests.
|
|
10
|
+
WATCHED_FILES = (%w[
|
|
13
11
|
Gemfile
|
|
14
12
|
Gemfile.lock
|
|
13
|
+
gems.rb
|
|
14
|
+
gems.locked
|
|
15
15
|
package.json
|
|
16
16
|
tsconfig.json
|
|
17
|
-
|
|
17
|
+
config.ru
|
|
18
|
+
] + Introspectors::RakeTaskIntrospector::RAKEFILES).freeze
|
|
18
19
|
|
|
19
|
-
#
|
|
20
|
-
# watcher watches.
|
|
20
|
+
# One scope for fingerprint and watcher; app/, test/ and spec/ whole since readers scan any file there.
|
|
21
21
|
WATCHED_DIRS = (%w[
|
|
22
22
|
app/models
|
|
23
23
|
app/controllers
|
|
@@ -26,13 +26,14 @@ module RailsAiContext
|
|
|
26
26
|
app/channels
|
|
27
27
|
app/components
|
|
28
28
|
app/helpers
|
|
29
|
-
app/services
|
|
30
29
|
app/javascript/controllers
|
|
31
30
|
app/middleware
|
|
32
31
|
config
|
|
33
32
|
db
|
|
34
|
-
lib
|
|
35
|
-
|
|
33
|
+
lib
|
|
34
|
+
rakelib
|
|
35
|
+
] + Introspectors::ServiceClasses::ROOTS + Introspectors::JobIntrospector::JOB_DIRS + Introspectors::GrapeEndpoints::DIRS +
|
|
36
|
+
%w[app test spec]).freeze
|
|
36
37
|
|
|
37
38
|
# The kinds whose homes PathResolver resolves beyond the conventional
|
|
38
39
|
# tree - packs/*, engines/* and configured extras. Derived at compute
|
|
@@ -41,12 +42,20 @@ module RailsAiContext
|
|
|
41
42
|
# prevent.
|
|
42
43
|
RESOLVED_KINDS = (%w[
|
|
43
44
|
app/models app/controllers app/views app/mailers
|
|
44
|
-
app/channels app/components app/helpers
|
|
45
|
-
] + Introspectors::JobIntrospector::JOB_DIRS).freeze
|
|
45
|
+
app/channels app/components app/helpers
|
|
46
|
+
] + Introspectors::ServiceClasses::ROOTS + Introspectors::JobIntrospector::JOB_DIRS).freeze
|
|
46
47
|
|
|
47
48
|
# The file kinds a change can hide in. One list, so a walk that reports
|
|
48
49
|
# a change and a walk that names it read the same tree.
|
|
49
|
-
|
|
50
|
+
# sql: a structure dump, under whatever name database.yml's schema_dump gives it.
|
|
51
|
+
# tt: a generator template override under lib/templates.
|
|
52
|
+
WATCHED_EXTNAMES = %w[.rb .rake .js .ts .erb .haml .slim .yml .sql .tt].freeze
|
|
53
|
+
WATCHED_EXTENSIONS = "**/*{#{WATCHED_EXTNAMES.join(",")}}"
|
|
54
|
+
|
|
55
|
+
# jsbundling and cssbundling rewrite this on every frontend save, and no reader reads it.
|
|
56
|
+
BUILD_OUTPUT = "/app/assets/builds/"
|
|
57
|
+
# A test run re-records these; the VCR reader counts them and never opens one.
|
|
58
|
+
CASSETTES = %r{\A(?:spec|test)/(?:[^/]+/)*(?:vcr_)?cassettes/}
|
|
50
59
|
|
|
51
60
|
# What a reader holds so it can ask later whether the app moved. Taken
|
|
52
61
|
# before the read it protects: a mark taken after introspection records
|
|
@@ -84,9 +93,10 @@ module RailsAiContext
|
|
|
84
93
|
# File deleted between exist? check and mtime read - skip
|
|
85
94
|
end
|
|
86
95
|
|
|
96
|
+
prefix = SafePath.dir_prefix(root)
|
|
87
97
|
watched_dirs(root).each do |full_dir|
|
|
88
|
-
|
|
89
|
-
digest.update(File.mtime(path).to_f.to_s)
|
|
98
|
+
watched_files(full_dir).sort.each do |path|
|
|
99
|
+
digest.update(cassette?(path, prefix) ? path : File.mtime(path).to_f.to_s)
|
|
90
100
|
rescue Errno::ENOENT
|
|
91
101
|
# File deleted between glob and mtime read - skip
|
|
92
102
|
end
|
|
@@ -99,11 +109,16 @@ module RailsAiContext
|
|
|
99
109
|
# the resolvers add for this app (packs, engines, extra_app_paths,
|
|
100
110
|
# concern homes such as app/serializers/concerns).
|
|
101
111
|
def watched_dirs(root)
|
|
112
|
+
dirs = scope_dirs(root)
|
|
113
|
+
# A dir under another one is already globbed and watched through it.
|
|
114
|
+
dirs.reject { |dir| dirs.any? { |other| dir.start_with?("#{other}/") } }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def scope_dirs(root)
|
|
102
118
|
conventional = WATCHED_DIRS.map { |dir| File.join(root, dir) }
|
|
103
119
|
resolved = RESOLVED_KINDS.flat_map { |kind| PathResolver.dirs_for(root, kind) }
|
|
104
120
|
|
|
105
|
-
(conventional + resolved + ConcernPaths.resolve(root) + stimulus_dirs(root))
|
|
106
|
-
.uniq.select { |dir| Dir.exist?(dir) }
|
|
121
|
+
(conventional + resolved + ConcernPaths.resolve(root) + stimulus_dirs(root)).uniq.select { |dir| Dir.exist?(dir) }
|
|
107
122
|
end
|
|
108
123
|
|
|
109
124
|
# The controller homes the Stimulus introspector reads, so an edit under
|
|
@@ -113,17 +128,28 @@ module RailsAiContext
|
|
|
113
128
|
.map { |path, _js_root| File.dirname(path) }.uniq
|
|
114
129
|
end
|
|
115
130
|
|
|
116
|
-
# Which
|
|
117
|
-
#
|
|
131
|
+
# Which directories hold a file newer than the given time, each named by
|
|
132
|
+
# the narrowest scope dir that holds it, the way an app author would write it.
|
|
118
133
|
def changed_since(root, time)
|
|
119
134
|
base = File.expand_path(root.to_s)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
named = scope_dirs(base)
|
|
136
|
+
prefix = SafePath.dir_prefix(base)
|
|
137
|
+
watched_dirs(base).flat_map { |dir| watched_files(dir).select { |path| !cassette?(path, prefix) && newer?(path, time) } }
|
|
138
|
+
.map { |path| named.select { |dir| path.start_with?("#{dir}/") }.max_by(&:size) }
|
|
139
|
+
.uniq.map { |dir| dir.delete_prefix(prefix) }
|
|
123
140
|
end
|
|
124
141
|
|
|
125
142
|
private
|
|
126
143
|
|
|
144
|
+
# One walk with an extension filter: a brace glob walks the tree once per extension.
|
|
145
|
+
def watched_files(dir)
|
|
146
|
+
Dir.glob(File.join(dir, "**/*")).select { |path| WATCHED_EXTNAMES.include?(File.extname(path)) && !path.include?(BUILD_OUTPUT) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def cassette?(path, prefix)
|
|
150
|
+
path.delete_prefix(prefix).match?(CASSETTES)
|
|
151
|
+
end
|
|
152
|
+
|
|
127
153
|
def newer?(path, time)
|
|
128
154
|
File.mtime(path) > time
|
|
129
155
|
rescue Errno::ENOENT
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "date"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
3
7
|
module RailsAiContext
|
|
4
8
|
# A fixture file's top-level keys are fixture names, with two exceptions.
|
|
5
9
|
# ActiveRecord drops the shared-attribute anchor the fixtures guide writes as
|
|
@@ -7,10 +11,47 @@ module RailsAiContext
|
|
|
7
11
|
# "No fixture named". Every surface that reads those keys asks here.
|
|
8
12
|
module FixtureKeys
|
|
9
13
|
ANCHOR = "DEFAULTS"
|
|
14
|
+
CONFIG = "_fixture"
|
|
10
15
|
|
|
16
|
+
# A label ERB printed into was computed, so its real names are unknown.
|
|
11
17
|
def self.name?(key)
|
|
12
18
|
key = key.to_s
|
|
13
|
-
key != ANCHOR && !key.start_with?("_")
|
|
19
|
+
key != ANCHOR && !key.start_with?("_") && !ConfigYaml.marked?(key)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# label => attributes, or nil when not fixtures; ERB unrun, cached by digest so readers share a parse.
|
|
23
|
+
def self.parse(content)
|
|
24
|
+
key = Digest::SHA256.hexdigest(content.to_s)
|
|
25
|
+
PARSED_MUTEX.synchronize { return PARSED[key] if PARSED.key?(key) }
|
|
26
|
+
|
|
27
|
+
parsed = read(content)
|
|
28
|
+
PARSED_MUTEX.synchronize do
|
|
29
|
+
PARSED.clear if PARSED.size >= MAX_PARSED
|
|
30
|
+
PARSED[key] = parsed
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
PARSED = {}
|
|
35
|
+
PARSED_MUTEX = Mutex.new
|
|
36
|
+
MAX_PARSED = 512
|
|
37
|
+
private_constant :PARSED, :PARSED_MUTEX, :MAX_PARSED
|
|
38
|
+
|
|
39
|
+
def self.read(content)
|
|
40
|
+
parsed = YAML.safe_load(ErbSource.with_output_marked(content, ConfigYaml::ERB_OUTPUT), permitted_classes: [ Date, Time, Symbol ], aliases: true)
|
|
41
|
+
return {} unless parsed
|
|
42
|
+
return nil unless parsed.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
config = parsed[CONFIG]
|
|
45
|
+
ignored = config.is_a?(Hash) ? Array(config["ignore"]).map(&:to_s) : []
|
|
46
|
+
parsed.each_with_object({}) do |(label, attributes), entries|
|
|
47
|
+
label = label.to_s
|
|
48
|
+
next if label == CONFIG || label == ANCHOR || ignored.include?(label) || !attributes.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
entries[label] = attributes
|
|
51
|
+
end.freeze
|
|
52
|
+
rescue Psych::Exception, ArgumentError
|
|
53
|
+
nil
|
|
14
54
|
end
|
|
55
|
+
private_class_method :read
|
|
15
56
|
end
|
|
16
57
|
end
|