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,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
3
5
|
module RailsAiContext
|
|
4
6
|
module Introspectors
|
|
5
7
|
# Discovers test infrastructure: framework, factories/fixtures,
|
|
@@ -10,20 +12,34 @@ module RailsAiContext
|
|
|
10
12
|
|
|
11
13
|
TEST_FILE_GLOB = "*_{spec,test}.rb"
|
|
12
14
|
|
|
15
|
+
# An engine's test/dummy is tested by the engine's suite.
|
|
16
|
+
def suite_root
|
|
17
|
+
@suite_root ||= PathResolver.test_root(root)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
RAILS_CI = "config/ci.rb"
|
|
21
|
+
|
|
13
22
|
def call
|
|
23
|
+
ci_steps, ci_steps_dir = detect_ci_steps
|
|
14
24
|
{
|
|
15
25
|
framework: detect_framework,
|
|
16
26
|
factories: detect_factories,
|
|
17
27
|
factory_names: detect_factory_names,
|
|
18
28
|
computed_factories: factory_definitions[:computed].nonzero?,
|
|
29
|
+
fabricators: detect_fabricators,
|
|
30
|
+
fabricator_names: fabricator_definitions.presence,
|
|
31
|
+
cucumber: detect_cucumber,
|
|
19
32
|
fixtures: detect_fixtures,
|
|
20
|
-
fixture_names:
|
|
33
|
+
fixture_names: fixture_labels[:names],
|
|
34
|
+
fixture_erb_labels: fixture_labels[:erb],
|
|
21
35
|
system_tests: detect_system_tests,
|
|
22
36
|
test_helpers: detect_test_helpers,
|
|
23
37
|
test_helper_setup: detect_test_helper_setup,
|
|
24
38
|
test_files: test_categories,
|
|
25
39
|
vcr_cassettes: detect_vcr,
|
|
26
40
|
ci_config: detect_ci,
|
|
41
|
+
ci_steps: ci_steps,
|
|
42
|
+
ci_steps_dir: ci_steps_dir,
|
|
27
43
|
coverage: detect_coverage,
|
|
28
44
|
factory_traits: detect_factory_traits,
|
|
29
45
|
test_count_by_category: detect_test_count_by_category,
|
|
@@ -35,7 +51,7 @@ module RailsAiContext
|
|
|
35
51
|
private
|
|
36
52
|
|
|
37
53
|
def detect_framework
|
|
38
|
-
RailsAiContext::TestFramework.for(
|
|
54
|
+
RailsAiContext::TestFramework.for(suite_root)
|
|
39
55
|
rescue => e
|
|
40
56
|
RailsAiContext.debug_fail(e, "unknown", label: "detect_framework")
|
|
41
57
|
end
|
|
@@ -45,7 +61,7 @@ module RailsAiContext
|
|
|
45
61
|
# answers the other way.
|
|
46
62
|
def first_dir_with(glob, *rels)
|
|
47
63
|
rels.each do |rel|
|
|
48
|
-
dir = File.join(
|
|
64
|
+
dir = File.join(suite_root, rel)
|
|
49
65
|
next unless Dir.exist?(dir)
|
|
50
66
|
|
|
51
67
|
count = Dir.glob(File.join(dir, "**", glob)).size
|
|
@@ -55,25 +71,180 @@ module RailsAiContext
|
|
|
55
71
|
end
|
|
56
72
|
|
|
57
73
|
def detect_factories
|
|
58
|
-
|
|
74
|
+
locations_with_count(factory_sources)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# factory_bot's definition_file_paths: each is loaded as `path.rb` and
|
|
78
|
+
# as a directory.
|
|
79
|
+
FACTORY_PATHS = %w[factories test/factories spec/factories].freeze
|
|
80
|
+
FABRICATOR_PATHS = %w[test/fabricators spec/fabricators].freeze
|
|
81
|
+
UNREAD_FACTORY_PATH = "a path a test helper builds at run time (not read)"
|
|
82
|
+
|
|
83
|
+
# [location, files] for every place factory_bot loads definitions
|
|
84
|
+
# from, each pack's spec/factories and test/factories included as
|
|
85
|
+
# packs-rails adds them.
|
|
86
|
+
def factory_sources
|
|
87
|
+
@factory_sources ||= factory_definition_paths.flat_map do |rel|
|
|
88
|
+
next [ [ UNREAD_FACTORY_PATH, [] ] ] if rel.equal?(UNREAD_FACTORY_PATH)
|
|
89
|
+
|
|
90
|
+
single = app_files("#{rel}.rb")
|
|
91
|
+
files = app_files(File.join(rel, "**", "*.rb"))
|
|
92
|
+
[ ([ "#{rel}.rb", single ] if single.any?), ([ rel, files ] if files.any?) ].compact
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The paths factory_bot loads: factory_bot_rails finds the defaults before any
|
|
97
|
+
# helper runs, and a helper's writes count only once find_definitions (adds)
|
|
98
|
+
# or reload (replaces) runs after them.
|
|
99
|
+
def factory_definition_paths
|
|
100
|
+
paths = defaults = FACTORY_PATHS + pack_factory_paths
|
|
101
|
+
loaded = defaults if RailsAiContext::GemLock.for(root).present?("factory_bot_rails")
|
|
102
|
+
helper_paths.flat_map { |path| Array(helper_walk(path)&.dig(:definition_paths)) }.each do |event|
|
|
103
|
+
case event[:load]
|
|
104
|
+
when :find_definitions then loaded = Array(loaded) + paths
|
|
105
|
+
when :reload then loaded = paths
|
|
106
|
+
else
|
|
107
|
+
found = event[:paths].map { |rel| Pathname.new(rel).cleanpath.to_s }.reject { |rel| RailsAiContext::SafePath.traversal?(rel) }
|
|
108
|
+
found << UNREAD_FACTORY_PATH if event[:unread]
|
|
109
|
+
paths = event[:replace] ? found : paths + found
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
(loaded || defaults).uniq
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ponytail: packs-specification's default pack_paths; a packs.yml that
|
|
116
|
+
# sets its own pack_paths is not read.
|
|
117
|
+
def pack_factory_paths
|
|
118
|
+
Dir.glob(File.join(suite_root, "packs", "{*,*/*}", "package.yml")).sort.flat_map do |manifest|
|
|
119
|
+
pack = File.dirname(manifest)
|
|
120
|
+
next [] if Dir.glob(File.join(pack, "*.gemspec")).any?
|
|
121
|
+
|
|
122
|
+
rel = pack.delete_prefix("#{suite_root}/")
|
|
123
|
+
[ File.join(rel, "spec/factories"), File.join(rel, "test/factories") ]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def fabricator_sources
|
|
128
|
+
@fabricator_sources ||= FABRICATOR_PATHS.filter_map do |rel|
|
|
129
|
+
files = app_files(File.join(rel, "**", "*.rb"))
|
|
130
|
+
[ rel, files ] if files.any?
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def detect_fabricators
|
|
135
|
+
locations_with_count(fabricator_sources)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def fabricator_definitions
|
|
139
|
+
fabricator_sources.flat_map(&:last).each_with_object({}) do |path, names|
|
|
140
|
+
found = SourceIntrospector.walk(path, { fabricators: -> { Listeners::GenericMacroListener.new(:Fabricator) } })[:fabricators]
|
|
141
|
+
named = found.map { |hit| hit[:args].first.to_s }.reject(&:empty?)
|
|
142
|
+
names[path.delete_prefix("#{suite_root}/")] = named if named.any?
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def detect_cucumber
|
|
147
|
+
features = app_files("features/**/*.feature")
|
|
148
|
+
return nil if features.empty?
|
|
149
|
+
|
|
150
|
+
{ location: "features", count: features.size, step_definitions: app_files("features/step_definitions/**/*.rb").size }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A path a helper builds at run time has no files to count, so the count covers only the paths read.
|
|
154
|
+
def locations_with_count(sources)
|
|
155
|
+
return nil if sources.empty?
|
|
156
|
+
|
|
157
|
+
read = sources.reject { |location, _| location.equal?(UNREAD_FACTORY_PATH) }
|
|
158
|
+
found = { location: sources.map(&:first).join(", "), count: (read.sum { |_, files| files.size } if read.any?) }
|
|
159
|
+
read.size < sources.size ? found.merge(unread: true) : found
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The files a glob under the suite root matches, sorted, leaving out any whose
|
|
163
|
+
# real path leaves the app or names a sensitive file. A count (`read: false`)
|
|
164
|
+
# keeps a sensitive-named file that sits where it is listed, since it opens nothing.
|
|
165
|
+
# A directory is resolved once for all its files; only a linked file needs its own realpath.
|
|
166
|
+
def app_files(pattern, read: true)
|
|
167
|
+
real_root = (@real_root ||= File.realpath(suite_root))
|
|
168
|
+
real_dirs = {}
|
|
169
|
+
Dir.glob(File.join(suite_root, pattern)).sort.select do |path|
|
|
170
|
+
real = if File.lstat(path).symlink?
|
|
171
|
+
File.realpath(path)
|
|
172
|
+
else
|
|
173
|
+
dir = File.dirname(path)
|
|
174
|
+
File.join(real_dirs[dir] ||= File.realpath(dir), File.basename(path))
|
|
175
|
+
end
|
|
176
|
+
relative = real.delete_prefix("#{real_root}/")
|
|
177
|
+
linked = relative != path.delete_prefix("#{suite_root}/")
|
|
178
|
+
File.file?(real) && RailsAiContext::SafePath.contained?(real, real_root) &&
|
|
179
|
+
!((read || linked) && RailsAiContext::SafePath.sensitive?(relative))
|
|
180
|
+
rescue SystemCallError
|
|
181
|
+
false
|
|
182
|
+
end
|
|
59
183
|
end
|
|
60
184
|
|
|
61
185
|
# The YAML fixture sets Rails loads, and apart from them the other files
|
|
62
186
|
# a fixtures directory holds for file_fixture: an app can keep one YAML file
|
|
63
187
|
# beside hundreds of JSON, XML and binary ones.
|
|
64
188
|
def detect_fixtures
|
|
65
|
-
|
|
189
|
+
rows = fixture_dirs.map { |rel| [ rel, app_files(File.join(rel, "**", "*"), read: false) ] }
|
|
190
|
+
return nil if rows.empty?
|
|
66
191
|
|
|
67
|
-
|
|
68
|
-
others =
|
|
192
|
+
sets = rows.sum { |_, files| files.count { |path| File.extname(path) == ".yml" } }
|
|
193
|
+
others = rows.sum { |_, files| files.count { |path| File.extname(path) != ".yml" } }
|
|
194
|
+
locations = rows.map(&:first)
|
|
195
|
+
found = { location: locations.join(", "), locations: locations, count: sets }
|
|
69
196
|
others.positive? ? found.merge(other_files: others) : found
|
|
70
197
|
end
|
|
71
198
|
|
|
199
|
+
DEFAULT_FIXTURE_DIRS = %w[spec/fixtures test/fixtures].freeze
|
|
200
|
+
|
|
201
|
+
# The first default directory that holds a set (an app with both reports
|
|
202
|
+
# one, as for factories), then each directory a test helper adds to
|
|
203
|
+
# fixture_paths that holds one and stays inside the app.
|
|
204
|
+
def fixture_dirs
|
|
205
|
+
@fixture_dirs ||= begin
|
|
206
|
+
real_root = File.realpath(suite_root)
|
|
207
|
+
writes = helper_paths
|
|
208
|
+
.to_h { |path| [ path, Array(helper_walk(path)&.dig(:fixture_paths)) ] }
|
|
209
|
+
configured = writes.values.flatten.grep(String)
|
|
210
|
+
defaults = DEFAULT_FIXTURE_DIRS.reject { |rel| rel == "spec/fixtures" && rspec_fixture_paths_known?(writes) }
|
|
211
|
+
candidates = [ defaults.find { |rel| fixture_sets?(rel, real_root) } ] +
|
|
212
|
+
configured.map { |rel| Pathname.new(rel).cleanpath.to_s }
|
|
213
|
+
candidates.compact.uniq.select { |rel| fixture_sets?(rel, real_root) }
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# rspec-rails gives fixture_paths no default, so an RSpec suite loads only the
|
|
218
|
+
# directories its helpers name; spec/fixtures stays only when a write under spec/ is unreadable.
|
|
219
|
+
def rspec_fixture_paths_known?(writes)
|
|
220
|
+
spec_dir = File.join(suite_root, "spec/")
|
|
221
|
+
%w[spec/rails_helper.rb spec/spec_helper.rb].any? { |rel| File.file?(File.join(suite_root, rel)) } &&
|
|
222
|
+
writes.none? { |path, found| path.start_with?(spec_dir) && found.include?(:unread) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def helper_paths
|
|
226
|
+
HELPER_FILES.map { |rel| File.join(suite_root, rel) } + support_files
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def support_files
|
|
230
|
+
@support_files ||= HELPER_DIRS.flat_map { |rel| Dir.glob(File.join(suite_root, rel, "**", "*.rb")).sort }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def fixture_sets?(rel, real_root)
|
|
234
|
+
return false if RailsAiContext::SafePath.traversal?(rel)
|
|
235
|
+
|
|
236
|
+
real = File.realpath(File.join(suite_root, rel))
|
|
237
|
+
RailsAiContext::SafePath.contained?(real, real_root) && File.directory?(real) &&
|
|
238
|
+
Dir.glob(File.join(real, "**", "*.yml")).any?
|
|
239
|
+
rescue SystemCallError
|
|
240
|
+
false
|
|
241
|
+
end
|
|
242
|
+
|
|
72
243
|
# Both bases are summed: an app that keeps system tests under spec/ and
|
|
73
244
|
# test/ has both, and reporting one hid the other.
|
|
74
245
|
def detect_system_tests
|
|
75
246
|
rows = %w[spec/system test/system].filter_map do |rel|
|
|
76
|
-
dir = File.join(
|
|
247
|
+
dir = File.join(suite_root, rel)
|
|
77
248
|
next unless Dir.exist?(dir)
|
|
78
249
|
|
|
79
250
|
count = Dir.glob(File.join(dir, "**/#{TEST_FILE_GLOB}")).size
|
|
@@ -84,16 +255,12 @@ module RailsAiContext
|
|
|
84
255
|
{ location: rows.map(&:first).join(", "), count: rows.sum(&:last) }
|
|
85
256
|
end
|
|
86
257
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
File.join(root, "test/helpers")
|
|
91
|
-
]
|
|
258
|
+
# Where suites keep helper modules. test/helpers is not one: Rails runs
|
|
259
|
+
# it as a test folder for helper tests.
|
|
260
|
+
HELPER_DIRS = %w[spec/support test/support test/test_helpers].freeze
|
|
92
261
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
Dir.glob(File.join(dir, "**/*.rb")).map { |f| f.sub("#{root}/", "") }
|
|
96
|
-
end.flatten.sort
|
|
262
|
+
def detect_test_helpers
|
|
263
|
+
HELPER_DIRS.flat_map { |rel| Dir.glob(File.join(suite_root, rel, "**/*.rb")) }.map { |f| f.sub("#{suite_root}/", "") }.sort
|
|
97
264
|
end
|
|
98
265
|
|
|
99
266
|
def detect_factory_names
|
|
@@ -103,87 +270,135 @@ module RailsAiContext
|
|
|
103
270
|
# One walk per factory file for both the factories and their traits.
|
|
104
271
|
# A factory whose name is computed (`factory :"#{model}_comment"` in a
|
|
105
272
|
# loop, as Consul writes) has no name to list, so it is counted apart.
|
|
106
|
-
# The first factory directory that has any factory wins.
|
|
107
273
|
def factory_definitions
|
|
108
274
|
@factory_definitions ||= begin
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
computed += named.count(&:empty?)
|
|
124
|
-
named = named.reject(&:empty?)
|
|
125
|
-
trait_names = ast_data[:traits].map { |hit| hit[:args].first.to_s }.reject(&:empty?)
|
|
126
|
-
names[path.sub("#{root}/", "")] = named if named.any?
|
|
127
|
-
traits[File.basename(path)] = trait_names if trait_names.any?
|
|
128
|
-
end
|
|
129
|
-
next if names.empty? && traits.empty? && computed.zero?
|
|
130
|
-
|
|
131
|
-
found = { names: names.presence, traits: traits.presence, computed: computed }
|
|
132
|
-
break
|
|
275
|
+
names = {}
|
|
276
|
+
traits = {}
|
|
277
|
+
computed = 0
|
|
278
|
+
factory_sources.flat_map(&:last).each do |path|
|
|
279
|
+
ast_data = SourceIntrospector.walk(path, {
|
|
280
|
+
factories: -> { Listeners::GenericMacroListener.new(:factory) },
|
|
281
|
+
traits: -> { Listeners::GenericMacroListener.new(:trait) }
|
|
282
|
+
})
|
|
283
|
+
named = ast_data[:factories].map { |hit| hit[:args].first.to_s }
|
|
284
|
+
computed += named.count(&:empty?)
|
|
285
|
+
named = named.reject(&:empty?)
|
|
286
|
+
trait_names = ast_data[:traits].map { |hit| hit[:args].first.to_s }.reject(&:empty?)
|
|
287
|
+
names[path.sub("#{suite_root}/", "")] = named if named.any?
|
|
288
|
+
traits[File.basename(path)] = trait_names if trait_names.any?
|
|
133
289
|
end
|
|
134
|
-
|
|
290
|
+
{ names: names.presence, traits: traits.presence, computed: computed }
|
|
135
291
|
end
|
|
136
292
|
end
|
|
137
293
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
294
|
+
# Each set named as Rails names it, by its path under its directory,
|
|
295
|
+
# with the labels ActiveRecord loads. A file that does not read as YAML
|
|
296
|
+
# still gives the labels its top-level keys spell. A label ERB computes
|
|
297
|
+
# names no row we can write, so it is kept apart, shown as ERB.
|
|
298
|
+
def fixture_labels
|
|
299
|
+
@fixture_labels ||= begin
|
|
143
300
|
names = {}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
301
|
+
erb = {}
|
|
302
|
+
read_fixture_labels(names, erb)
|
|
303
|
+
{ names: names.presence, erb: erb.presence }
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def read_fixture_labels(names, erb)
|
|
308
|
+
fixture_dirs.each do |rel|
|
|
309
|
+
dir = File.join(suite_root, rel)
|
|
310
|
+
app_files(File.join(rel, "**", "*.yml")).each do |path|
|
|
311
|
+
set = path.delete_prefix("#{dir}/").delete_suffix(".yml")
|
|
312
|
+
next if names.key?(set)
|
|
313
|
+
|
|
147
314
|
content = RailsAiContext::SafeFile.read(path) or next
|
|
148
|
-
|
|
149
|
-
|
|
315
|
+
labels = RailsAiContext::FixtureKeys.parse(content)&.keys ||
|
|
316
|
+
content.scan(/^(\w+):/).flatten.select { |key| RailsAiContext::FixtureKeys.name?(key) }
|
|
317
|
+
computed, named = labels.partition { |label| RailsAiContext::ConfigYaml.marked?(label) }
|
|
318
|
+
names[set] = named if labels.any?
|
|
319
|
+
erb[set] = computed.map { |label| RailsAiContext::ConfigYaml.shown(label) } if computed.any?
|
|
150
320
|
end
|
|
151
|
-
return names if names.any?
|
|
152
321
|
end
|
|
153
|
-
nil
|
|
154
322
|
end
|
|
155
323
|
|
|
324
|
+
# Calls that configure every test or every system test, shown as written.
|
|
325
|
+
SETUP_MACROS = %i[parallelize fixtures driven_by].freeze
|
|
326
|
+
|
|
327
|
+
HELPER_FILES = %w[spec/rails_helper.rb spec/spec_helper.rb test/test_helper.rb].freeze
|
|
328
|
+
|
|
156
329
|
def detect_test_helper_setup
|
|
157
|
-
helpers = %w[
|
|
330
|
+
helpers = (HELPER_FILES + %w[test/application_system_test_case.rb]).map { |rel| File.join(suite_root, rel) }
|
|
158
331
|
# Apps also configure helpers in support files (Errbit's spec/support/devise.rb). A bare
|
|
159
332
|
# include there is usually a support module's own mixin, so only config.include counts.
|
|
160
|
-
support =
|
|
333
|
+
support = support_files
|
|
161
334
|
|
|
162
335
|
setup = []
|
|
336
|
+
calls = []
|
|
163
337
|
(helpers + support).each do |path|
|
|
164
|
-
|
|
165
|
-
ast = SourceIntrospector.walk(path, {
|
|
166
|
-
bare: -> { Listeners::GenericMacroListener.new(:include) },
|
|
167
|
-
chained: -> { Listeners::ChainedCallListener.new(:include, receiver: :config) }
|
|
168
|
-
})
|
|
338
|
+
ast = helper_walk(path) or next
|
|
169
339
|
hits = helpers.include?(path) ? ast[:bare] + ast[:chained] : ast[:chained]
|
|
170
340
|
# `config.include Helpers, :js` scopes Helpers to tagged examples; the tag is no helper.
|
|
171
341
|
hits.each { |hit| setup.concat(hit[:values].map(&:to_s).grep(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)) }
|
|
342
|
+
source = AstCache.parse(path).source
|
|
343
|
+
ast[:setup].each { |hit| calls << source.slice(hit[:offset], hit[:end_offset] - hit[:offset]).squish }
|
|
172
344
|
end
|
|
173
|
-
setup.uniq
|
|
345
|
+
(setup + calls).uniq
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# One walk per helper file serves every reader of it.
|
|
349
|
+
def helper_walk(path)
|
|
350
|
+
@helper_walks ||= {}
|
|
351
|
+
return @helper_walks[path] if @helper_walks.key?(path)
|
|
352
|
+
|
|
353
|
+
@helper_walks[path] = File.file?(path) ? SourceIntrospector.walk(path, {
|
|
354
|
+
bare: -> { Listeners::GenericMacroListener.new(:include) },
|
|
355
|
+
chained: -> { Listeners::ChainedCallListener.new(:include, receiver: :config) },
|
|
356
|
+
setup: -> { Listeners::GenericMacroListener.new(*SETUP_MACROS) },
|
|
357
|
+
fixture_paths: -> { Listeners::FixturePathsListener.new(file: path.delete_prefix("#{suite_root}/")) },
|
|
358
|
+
definition_paths: -> { Listeners::DefinitionFilePathsListener.new(file: path.delete_prefix("#{suite_root}/")) },
|
|
359
|
+
cleaner: -> { Listeners::ConfigAssignmentListener.new(:DatabaseCleaner) }
|
|
360
|
+
}) : nil
|
|
174
361
|
end
|
|
175
362
|
|
|
176
363
|
def detect_vcr
|
|
177
364
|
first_dir_with("*.yml", "spec/cassettes", "spec/vcr_cassettes", "test/cassettes", "test/vcr_cassettes")
|
|
178
365
|
end
|
|
179
366
|
|
|
367
|
+
# A dummy's own config/ci.rb and its engine's .github are both the project's CI.
|
|
180
368
|
def detect_ci
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
369
|
+
ci_roots.flat_map do |dir|
|
|
370
|
+
configs = []
|
|
371
|
+
configs << "rails_ci" if File.file?(File.join(dir, RAILS_CI))
|
|
372
|
+
configs << "github_actions" if Dir.exist?(File.join(dir, ".github/workflows"))
|
|
373
|
+
configs << "circleci" if File.exist?(File.join(dir, ".circleci/config.yml"))
|
|
374
|
+
configs << "gitlab_ci" if File.exist?(File.join(dir, ".gitlab-ci.yml"))
|
|
375
|
+
configs << "travis" if File.exist?(File.join(dir, ".travis.yml"))
|
|
376
|
+
configs << "buildkite" if Dir.exist?(File.join(dir, ".buildkite")) || Dir.glob(File.join(dir, "buildkite.{yml,yaml,json}")).any?
|
|
377
|
+
configs << "jenkins" if File.file?(File.join(dir, "Jenkinsfile"))
|
|
378
|
+
configs << "bitbucket_pipelines" if File.file?(File.join(dir, "bitbucket-pipelines.yml"))
|
|
379
|
+
configs
|
|
380
|
+
end.uniq
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def ci_roots
|
|
384
|
+
[ root, suite_root ].uniq
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# The steps bin/ci runs, from the `step title, *command` calls of the
|
|
388
|
+
# CI DSL Rails 8.1 generates, and the directory of config/ci.rb when it is not the suite root.
|
|
389
|
+
def detect_ci_steps
|
|
390
|
+
dir, content = ci_roots.lazy.filter_map { |d| (text = RailsAiContext::SafePath.read(RAILS_CI, under: d).first) && [ d, text ] }.first
|
|
391
|
+
return nil unless content
|
|
392
|
+
|
|
393
|
+
# Paths in the answer are under the suite root, which for a test/dummy is the engine's.
|
|
394
|
+
ci_dir = "#{Pathname.new(PathResolver.root_key(dir)).relative_path_from(Pathname.new(PathResolver.root_key(suite_root)))}/" unless dir == suite_root
|
|
395
|
+
|
|
396
|
+
hits = SourceIntrospector.walk_source(content, steps: -> { Listeners::GenericMacroListener.new(:step) })[:steps]
|
|
397
|
+
steps = hits.filter_map do |hit|
|
|
398
|
+
title, *command = hit[:values]
|
|
399
|
+
{ name: title, command: command.join(" ") } if title.is_a?(String)
|
|
400
|
+
end
|
|
401
|
+
[ steps.presence, ci_dir ]
|
|
187
402
|
end
|
|
188
403
|
|
|
189
404
|
def detect_coverage
|
|
@@ -199,7 +414,7 @@ module RailsAiContext
|
|
|
199
414
|
def detect_shared_examples
|
|
200
415
|
shared = []
|
|
201
416
|
%w[spec test].each do |base|
|
|
202
|
-
support_dir = File.join(
|
|
417
|
+
support_dir = File.join(suite_root, base, "support")
|
|
203
418
|
next unless Dir.exist?(support_dir)
|
|
204
419
|
Dir.glob(File.join(support_dir, "**/*.rb")).each do |path|
|
|
205
420
|
ast_data = SourceIntrospector.walk(path, {
|
|
@@ -208,7 +423,7 @@ module RailsAiContext
|
|
|
208
423
|
ast_data[:shared].each do |entry|
|
|
209
424
|
name = entry[:args].first&.to_s
|
|
210
425
|
next unless name && !name.empty?
|
|
211
|
-
shared << { name: name, file: path.sub("#{
|
|
426
|
+
shared << { name: name, file: path.sub("#{suite_root}/", "") }
|
|
212
427
|
end
|
|
213
428
|
end
|
|
214
429
|
end
|
|
@@ -222,12 +437,8 @@ module RailsAiContext
|
|
|
222
437
|
# complete signal; the unsuffixed name is the pre-2.0 single gem.
|
|
223
438
|
if RailsAiContext::GemLock.for(root).any?("database_cleaner", "database_cleaner-core")
|
|
224
439
|
strategy = nil
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
next unless File.exist?(path)
|
|
228
|
-
ast = SourceIntrospector.walk(path, {
|
|
229
|
-
cleaner: -> { Listeners::ConfigAssignmentListener.new(:DatabaseCleaner) }
|
|
230
|
-
})
|
|
440
|
+
HELPER_FILES.each do |helper|
|
|
441
|
+
ast = helper_walk(File.join(suite_root, helper)) or next
|
|
231
442
|
hit = ast[:cleaner].find { |h| h[:assignment] && h[:path] == [ :strategy ] }
|
|
232
443
|
strategy = hit[:value].to_s if hit
|
|
233
444
|
end
|
|
@@ -261,7 +472,7 @@ module RailsAiContext
|
|
|
261
472
|
rows = Hash.new { |h, k| h[k] = [] }
|
|
262
473
|
|
|
263
474
|
%w[spec test].each do |base|
|
|
264
|
-
base_dir = File.join(
|
|
475
|
+
base_dir = File.join(suite_root, base)
|
|
265
476
|
next unless Dir.exist?(base_dir)
|
|
266
477
|
|
|
267
478
|
Dir.children(base_dir).sort.each do |entry|
|
|
@@ -9,7 +9,7 @@ module RailsAiContext
|
|
|
9
9
|
|
|
10
10
|
MODEL_BROADCAST_MACROS = %w[broadcasts broadcasts_to broadcasts_refreshes broadcasts_refreshes_to].freeze
|
|
11
11
|
BROADCAST_CALL = /\Abroadcast_\w+_to\z/
|
|
12
|
-
BROADCAST_KINDS = %w[app/controllers app/
|
|
12
|
+
BROADCAST_KINDS = %w[app/controllers app/services app/jobs app/workers app/channels].freeze
|
|
13
13
|
|
|
14
14
|
# A mention of these helpers anywhere in a controller is the signal,
|
|
15
15
|
# whether called, referenced or guarded. Vocabulary, not structure, so
|
|
@@ -90,7 +90,7 @@ module RailsAiContext
|
|
|
90
90
|
# The views root a path was found under, for the containment check that
|
|
91
91
|
# keeps a symlink escaping the tree out of the answer.
|
|
92
92
|
def views_root_of(path)
|
|
93
|
-
PathResolver.view_dirs(root)
|
|
93
|
+
RailsAiContext::ViewFile.root_for(path, PathResolver.view_dirs(root)) || views_dir
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
# Views are not Ruby, so they are read line by line. Files are yielded
|
|
@@ -187,10 +187,15 @@ module RailsAiContext
|
|
|
187
187
|
def scan_broadcasts
|
|
188
188
|
models = []
|
|
189
189
|
explicit = []
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
seen = Set.new
|
|
191
|
+
# A model outside app/models also sits under another kind's directory.
|
|
192
|
+
[ :models, *BROADCAST_KINDS ].each do |kind|
|
|
193
|
+
records = kind == :models ? model_sources(skip_concerns: false) : SourceScan.each(root, kind: kind, skip_concerns: false)
|
|
194
|
+
records.each do |record|
|
|
195
|
+
next unless seen.add?(record.path)
|
|
196
|
+
|
|
192
197
|
walked = walk_broadcasts(record.source)
|
|
193
|
-
models.concat(model_entries(record, walked[:macros])) if kind ==
|
|
198
|
+
models.concat(model_entries(record, walked[:macros])) if kind == :models
|
|
194
199
|
explicit.concat(explicit_entries(record, walked[:calls]))
|
|
195
200
|
end
|
|
196
201
|
end
|
|
@@ -17,8 +17,7 @@ module RailsAiContext
|
|
|
17
17
|
view_components: extract_view_components,
|
|
18
18
|
template_engines: detect_template_engines,
|
|
19
19
|
form_builders_detected: detect_form_builders,
|
|
20
|
-
component_usage: detect_component_usage
|
|
21
|
-
conditional_layouts: detect_conditional_layouts
|
|
20
|
+
component_usage: detect_component_usage
|
|
22
21
|
}
|
|
23
22
|
end
|
|
24
23
|
|
|
@@ -127,9 +126,12 @@ module RailsAiContext
|
|
|
127
126
|
return [] unless Dir.exist?(dir)
|
|
128
127
|
|
|
129
128
|
Dir.glob(File.join(dir, "**/*.rb")).filter_map do |path|
|
|
129
|
+
source = RailsAiContext::SafeFile.read(path)
|
|
130
|
+
next if source && !HelperNames.view_helper?(source, path, dir)
|
|
131
|
+
|
|
130
132
|
relative = path.sub("#{dir}/", "")
|
|
131
133
|
module_name = relative.sub(/\.rb\z/, "").camelize
|
|
132
|
-
ast_data = SourceIntrospector.walk(path, { methods: Listeners::MethodsListener })
|
|
134
|
+
ast_data = SourceIntrospector.walk(path, { methods: Listeners::MethodsListener }, source: source)
|
|
133
135
|
methods = ActionResolver.own_methods(ast_data[:methods], module_name).map { |m| m[:name] }
|
|
134
136
|
{
|
|
135
137
|
file: relative,
|
|
@@ -202,43 +204,6 @@ module RailsAiContext
|
|
|
202
204
|
rescue => e
|
|
203
205
|
RailsAiContext.debug_fail(e, [], label: "detect_component_usage")
|
|
204
206
|
end
|
|
205
|
-
|
|
206
|
-
def detect_conditional_layouts
|
|
207
|
-
layouts = []
|
|
208
|
-
controllers_dir = File.join(app.root, "app", "controllers")
|
|
209
|
-
return layouts unless Dir.exist?(controllers_dir)
|
|
210
|
-
|
|
211
|
-
Dir.glob(File.join(controllers_dir, "**", "*.rb")).each do |path|
|
|
212
|
-
ast_data = SourceIntrospector.walk(path, {
|
|
213
|
-
layout_calls: -> { Listeners::GenericMacroListener.new(:layout) }
|
|
214
|
-
})
|
|
215
|
-
|
|
216
|
-
ast_data[:layout_calls].each do |macro|
|
|
217
|
-
layout_name = macro[:args]&.first&.to_s
|
|
218
|
-
# Also check options for string-based layout names
|
|
219
|
-
layout_name ||= macro[:options].values.first&.to_s if macro[:options]&.any?
|
|
220
|
-
next unless layout_name
|
|
221
|
-
|
|
222
|
-
entry = { layout: layout_name, controller: File.basename(path, ".rb").camelize }
|
|
223
|
-
opts = macro[:options] || {}
|
|
224
|
-
entry[:only] = Array(opts[:only]).map(&:to_s) if opts[:only]
|
|
225
|
-
entry[:except] = Array(opts[:except]).map(&:to_s) if opts[:except]
|
|
226
|
-
|
|
227
|
-
# Build condition string from options for backward compat
|
|
228
|
-
condition_parts = []
|
|
229
|
-
condition_parts << "only: #{opts[:only].inspect}" if opts[:only]
|
|
230
|
-
condition_parts << "except: #{opts[:except].inspect}" if opts[:except]
|
|
231
|
-
entry[:condition] = condition_parts.join(", ") if condition_parts.any?
|
|
232
|
-
|
|
233
|
-
layouts << entry
|
|
234
|
-
end
|
|
235
|
-
rescue => e
|
|
236
|
-
RailsAiContext.debug_fail(e, label: "detect_conditional_layouts")
|
|
237
|
-
end
|
|
238
|
-
layouts
|
|
239
|
-
rescue => e
|
|
240
|
-
RailsAiContext.debug_fail(e, [], label: "detect_conditional_layouts")
|
|
241
|
-
end
|
|
242
207
|
end
|
|
243
208
|
end
|
|
244
209
|
end
|
|
@@ -363,6 +363,9 @@ module RailsAiContext
|
|
|
363
363
|
# A partial or template name: a path, never text with spaces or quotes in it.
|
|
364
364
|
PARTIAL_NAME = %r{\A[\w./-]+\z}
|
|
365
365
|
RENDER_NAMED = /(?:\b(?:partial|template)\s*:|:(?:partial|template)\s*=>)\s*["']([^"']+)["']/
|
|
366
|
+
# `render @posts`, `render(post)`, `render @posts, cached: true`: a bare record or collection, or a
|
|
367
|
+
# chain on one (`@post.comments.recent`), matched against "render <args>"; RenderedRecord reads the chain.
|
|
368
|
+
IMPLICIT_RENDER = /\Arender\s*\(?\s*@?((?:[a-z_]\w*\.)*[a-z_]\w*)\s*(?:[,)]|\s(?:if|unless)\b|-?\s*\z)/
|
|
366
369
|
|
|
367
370
|
def extract_partial_refs(content)
|
|
368
371
|
refs = []
|
|
@@ -372,22 +375,21 @@ module RailsAiContext
|
|
|
372
375
|
named = [ args[RENDER_POSITIONAL, 2], *top_level(args).scan(RENDER_NAMED).flatten ].compact
|
|
373
376
|
# An interpolated name is decided at runtime, not a partial on disk.
|
|
374
377
|
refs.concat(named.select { |name| name.match?(PARTIAL_NAME) })
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
# render (the scaffold default: `render @articles.each { |article| render article }`).
|
|
380
|
-
# The negative lookahead excludes keyword-argument hashes like
|
|
381
|
-
# `render json: ...` / `render partial: ...`, where a colon immediately
|
|
382
|
-
# follows the identifier instead of a statement boundary.
|
|
383
|
-
content.scan(/\brender[\s(]+([a-z_]\w*)\b(?!\s*:)/).each do |m|
|
|
384
|
-
refs << m[0] unless RENDER_KEYWORD_ARGS.include?(m[0])
|
|
378
|
+
chain = self.class.render_line(args)[IMPLICIT_RENDER, 1] if named.empty?
|
|
379
|
+
model, collection = chain && !RENDER_KEYWORD_ARGS.include?(chain) && RenderedRecord.resolve(chain, root, @rendered_models ||= {})
|
|
380
|
+
# The records' model names the partial; `replies` with class_name "Comment" renders comments/_comment.
|
|
381
|
+
refs << (collection ? model.pluralize : model) if model
|
|
385
382
|
end
|
|
386
383
|
# Phlex: render ComponentName.new(...) or render(ComponentName.new(...))
|
|
387
384
|
content.scan(/render[\s(]+([A-Z]\w+(?:::\w+)*)\.new/).each { |m| refs << m[0] }
|
|
388
385
|
refs.uniq
|
|
389
386
|
end
|
|
390
387
|
|
|
388
|
+
# A render call's ERB-free arguments on one line, as IMPLICIT_RENDER reads them.
|
|
389
|
+
def self.render_line(args)
|
|
390
|
+
"render #{args.split("%>", 2).first.gsub(/\s+/, " ").strip}"
|
|
391
|
+
end
|
|
392
|
+
|
|
391
393
|
# Each render call as [offset, argument text]: its balanced parentheses,
|
|
392
394
|
# or the rest of the line and every line a trailing comma continues onto.
|
|
393
395
|
def self.render_calls(content)
|