rails-ai-context 5.30.2 → 5.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +868 -0
  3. data/CONTEXT.md +7 -3
  4. data/README.md +1 -1
  5. data/docs/ARCHITECTURE.md +3 -3
  6. data/docs/CLI.md +1 -1
  7. data/docs/COMPATIBILITY.md +50 -14
  8. data/docs/CONFIGURATION.md +4 -1
  9. data/docs/FAQ.md +1 -1
  10. data/docs/GUIDE.md +14 -8
  11. data/docs/INTROSPECTORS.md +50 -23
  12. data/docs/SECURITY.md +56 -1
  13. data/docs/TOOLS.md +58 -12
  14. data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
  15. data/lib/rails_ai_context/action_filters.rb +121 -48
  16. data/lib/rails_ai_context/app_commands.rb +2 -2
  17. data/lib/rails_ai_context/app_kind.rb +65 -8
  18. data/lib/rails_ai_context/ast_cache.rb +53 -18
  19. data/lib/rails_ai_context/base_mixins.rb +46 -16
  20. data/lib/rails_ai_context/boot_manager.rb +5 -1
  21. data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
  22. data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
  23. data/lib/rails_ai_context/concern_macros.rb +791 -111
  24. data/lib/rails_ai_context/concern_membership.rb +25 -5
  25. data/lib/rails_ai_context/concern_paths.rb +63 -17
  26. data/lib/rails_ai_context/config_yaml.rb +40 -0
  27. data/lib/rails_ai_context/configuration.rb +3 -3
  28. data/lib/rails_ai_context/data/docs/index.json +324 -0
  29. data/lib/rails_ai_context/database_yml.rb +136 -0
  30. data/lib/rails_ai_context/doctor.rb +30 -37
  31. data/lib/rails_ai_context/erb_source.rb +40 -0
  32. data/lib/rails_ai_context/fingerprinter.rb +48 -22
  33. data/lib/rails_ai_context/fixture_keys.rb +42 -1
  34. data/lib/rails_ai_context/gem_lock.rb +275 -47
  35. data/lib/rails_ai_context/git_ignore.rb +13 -11
  36. data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
  37. data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
  38. data/lib/rails_ai_context/introspector.rb +10 -1
  39. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
  40. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
  41. data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
  42. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
  43. data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
  44. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
  45. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
  46. data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
  47. data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
  48. data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
  49. data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
  50. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
  51. data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
  52. data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
  53. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
  54. data/lib/rails_ai_context/introspectors/base.rb +38 -0
  55. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
  56. data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
  57. data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
  58. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
  59. data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
  60. data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
  61. data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
  63. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
  65. data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
  66. data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
  67. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
  68. data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
  69. data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
  70. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
  71. data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
  72. data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
  73. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
  74. data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
  75. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
  76. data/lib/rails_ai_context/introspectors/includers.rb +6 -1
  77. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
  78. data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
  79. data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
  80. data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
  81. data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
  82. data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
  83. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
  84. data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
  85. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
  86. data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
  87. data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
  88. data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
  90. data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
  91. data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
  92. data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
  93. data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
  94. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
  95. data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
  96. data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
  97. data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
  98. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
  99. data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
  100. data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
  101. data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
  102. data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
  103. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
  104. data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
  105. data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
  106. data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
  107. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
  108. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
  109. data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
  110. data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
  111. data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
  112. data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
  113. data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
  114. data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
  115. data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
  116. data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
  117. data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
  118. data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
  119. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
  120. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
  121. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
  122. data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
  123. data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
  124. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
  125. data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
  126. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
  127. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
  128. data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
  129. data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
  130. data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
  131. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
  132. data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
  133. data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
  134. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
  135. data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
  136. data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
  137. data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
  138. data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
  139. data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
  140. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
  141. data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
  142. data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
  143. data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
  144. data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
  145. data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
  146. data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
  147. data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
  148. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
  149. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
  150. data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
  151. data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
  152. data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
  153. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
  154. data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
  155. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
  156. data/lib/rails_ai_context/method_name.rb +31 -0
  157. data/lib/rails_ai_context/migration_status.rb +5 -5
  158. data/lib/rails_ai_context/output_guard.rb +6 -4
  159. data/lib/rails_ai_context/package_json.rb +96 -5
  160. data/lib/rails_ai_context/path_resolver.rb +170 -15
  161. data/lib/rails_ai_context/payload.rb +48 -0
  162. data/lib/rails_ai_context/pending_migrations.rb +15 -18
  163. data/lib/rails_ai_context/portable_path.rb +76 -4
  164. data/lib/rails_ai_context/redaction.rb +8 -2
  165. data/lib/rails_ai_context/route_coverage.rb +5 -1
  166. data/lib/rails_ai_context/safe_file.rb +4 -1
  167. data/lib/rails_ai_context/safe_path.rb +61 -10
  168. data/lib/rails_ai_context/schema_hint.rb +8 -2
  169. data/lib/rails_ai_context/schema_version.rb +7 -9
  170. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
  171. data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
  172. data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
  173. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
  174. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
  175. data/lib/rails_ai_context/server.rb +26 -7
  176. data/lib/rails_ai_context/test_framework.rb +32 -5
  177. data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
  178. data/lib/rails_ai_context/tools/base_tool.rb +69 -30
  179. data/lib/rails_ai_context/tools/diagnose.rb +1 -1
  180. data/lib/rails_ai_context/tools/generate_test.rb +8 -9
  181. data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
  182. data/lib/rails_ai_context/tools/get_api.rb +17 -11
  183. data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
  184. data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
  185. data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
  186. data/lib/rails_ai_context/tools/get_concern.rb +31 -18
  187. data/lib/rails_ai_context/tools/get_config.rb +52 -18
  188. data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
  189. data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
  190. data/lib/rails_ai_context/tools/get_engines.rb +2 -0
  191. data/lib/rails_ai_context/tools/get_env.rb +209 -52
  192. data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
  193. data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
  194. data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
  195. data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
  196. data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
  197. data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
  198. data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
  199. data/lib/rails_ai_context/tools/get_routes.rb +35 -9
  200. data/lib/rails_ai_context/tools/get_schema.rb +160 -61
  201. data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
  202. data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
  203. data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
  204. data/lib/rails_ai_context/tools/get_view.rb +37 -20
  205. data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
  206. data/lib/rails_ai_context/tools/onboard.rb +174 -88
  207. data/lib/rails_ai_context/tools/performance_check.rb +5 -13
  208. data/lib/rails_ai_context/tools/query.rb +122 -28
  209. data/lib/rails_ai_context/tools/read_logs.rb +45 -17
  210. data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
  211. data/lib/rails_ai_context/tools/safe_call.rb +20 -15
  212. data/lib/rails_ai_context/tools/search_code.rb +114 -40
  213. data/lib/rails_ai_context/tools/search_docs.rb +22 -11
  214. data/lib/rails_ai_context/tools/security_scan.rb +51 -4
  215. data/lib/rails_ai_context/tools/validate.rb +21 -4
  216. data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
  217. data/lib/rails_ai_context/version.rb +1 -1
  218. data/lib/rails_ai_context/vfs.rb +2 -2
  219. data/lib/rails_ai_context/view_file.rb +53 -3
  220. data/lib/rails_ai_context.rb +10 -2
  221. data/server.json +1 -1
  222. metadata +34 -3
@@ -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
- schema_path = File.join(app.root, "db/schema.rb")
101
- structure_path = File.join(app.root, "db/structure.sql")
102
- if File.exist?(schema_path)
103
- lines = File.readlines(schema_path).size
104
- Check.new(name: "Schema", status: :pass, message: "db/schema.rb found (#{count_phrase(lines, "line")})", fix: nil)
105
- elsif File.exist?(structure_path)
106
- size = (File.size(structure_path) / 1024.0).round(1)
107
- Check.new(name: "Schema", status: :pass, message: "db/structure.sql found (#{size}KB)", fix: nil)
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.migrate_dir_for(app.root))
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
- lock_path = File.join(app.root, "Gemfile.lock")
148
- if File.exist?(lock_path)
149
- Check.new(name: "Gems", status: :pass, message: "Gemfile.lock found", fix: nil)
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: "Gemfile.lock not found", fix: "Run `bundle install`")
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
- migrate_dir = File.join(app.root, "db/migrate")
186
- if Dir.exist?(migrate_dir) && Dir.glob(File.join(migrate_dir, "*.rb")).any?
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
- config.introspectors.each do |name|
442
- result = introspector.send(:resolve_introspector, name).call
443
- failures << [ name.to_s, result[:error].to_s ] if result.is_a?(Hash) && result[:error]
444
- rescue StandardError, ScriptError => e
445
- # ScriptError included: a syntax-broken app file must cost one
446
- # introspector, not the diagnosis the user ran doctor for.
447
- failures << [ name.to_s, "#{e.class}: #{e.message}" ]
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
- unless File.exist?(gitignore_path)
581
- return Check.new(name: "Secrets in .gitignore", status: :fail,
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
- schema_path = File.join(app.root, "db/schema.rb")
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, plus the one file under a watched directory whose
10
- # extension WATCHED_EXTENSIONS does not name.
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
- ].freeze
17
+ config.ru
18
+ ] + Introspectors::RakeTaskIntrospector::RAKEFILES).freeze
18
19
 
19
- # The one scope: everything the fingerprint walks is also everything the
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/tasks
35
- ] + Introspectors::JobIntrospector::JOB_DIRS).freeze
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 app/services
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
- WATCHED_EXTENSIONS = "**/*.{rb,rake,js,ts,erb,haml,slim,yml}"
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
- Dir.glob(File.join(full_dir, WATCHED_EXTENSIONS)).sort.each do |path|
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 watched directories hold a file newer than the given time,
117
- # named the way an app author would write them.
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
- watched_dirs(base).select { |dir|
121
- Dir.glob(File.join(dir, WATCHED_EXTENSIONS)).any? { |path| newer?(path, time) }
122
- }.map { |dir| dir.delete_prefix(base + File::SEPARATOR) }
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